OmidOxygen
A Persian, RTL e-learning and events platform for professional barbering education: online courses sold through a payment gateway, in-person workshops run across several cities, free courses opened by two independent access doors, issued certificates, and bulk SMS — with the public site and the admin panel served by a single Next.js application.
Overview
A Persian, RTL e-learning and events platform for professional barbering education: online courses sold through a payment gateway, in-person workshops run across several cities, free courses opened by two independent access doors, issued certificates, and bulk SMS — with the public site and the admin panel served by a single Next.js application.
Problem
An academy was selling courses and running workshops in different cities with no system behind either. Enrolments arrived by direct message, payments came card to card, attendee lists lived in spreadsheets, and reminding a room full of people of the venue and time meant messaging them by hand. The two halves of the business also do not work alike — a course is bought online and delivered by an external video platform, while a workshop is paid for off-site and entered by an organizer — so a single generic product-with-a-price model would have been wrong for half of it.
Solution
Modelled the two halves separately rather than forcing them into one abstraction. Courses are products with orders, discount codes and a Zarinpal checkout, delivered as licences on an external player. Workshops carry no price and no gateway at all: an admin enters registrations, and can broadcast a venue-and-time SMS to everyone signed up — or, by default, only to those who have not been messaged yet, so re-broadcasting after new sign-ups never reaches the earlier attendees twice. Free courses are the bridge between the two: a course opens either through a one-time access code or through attendance at any linked workshop, with the workshop door evaluated live on every request so that linking another workshop widens the circle the moment it is linked.
Architecture
Django 5.2 + DRF over PostgreSQL, with Redis for cache and Huey for background work, behind cookie-based JWT authentication. Eleven vertical-slice apps — users, product, order, payment, discount, workshop, free_course, certificate, sms_campaign, assets and a shared toolkit — hold 23 models behind two API surfaces per feature: a landing scope for the public and signed-in users, and a superuser-only dashboard scope. Views stay thin, every business rule lives in a service, and errors are typed exceptions carrying their own code. Money is handled in Toman and converted at the gateway boundary only, and an order exists as PENDING from the moment a buyer registers — long before any money moves — so the storefront can tell "registered but unpaid" apart from "paid" and resume the same order later. Payments run through Zarinpal, SMS through FarazSMS pattern messages with Jalali dates rendered into the template. The frontend is one Next.js 16 App Router application serving both the public site and the /admin panel: Server Components read through a controller layer and Client Components mutate through server actions that delegate to those same controllers, so every call to the backend has exactly one home.
Challenges
Two offerings that only look alike: a course is bought online and delivered by an external player, a workshop is paid for off-site and entered by an admin. Keeping them as separate models with separate flows — no price, no gateway and no discounts on workshops — avoided an abstraction that would have been wrong for half the business.
Making a repeatable SMS broadcast safe to run again: each registration carries its own sent flag, so a re-broadcast after new sign-ups reaches only the new people, and the two empty cases — nobody registered at all, and everybody already notified — are distinct errors rather than one silent no-op.
An access rule that must not be cached: workshop-based access to a free course is recomputed on every request rather than written into rows, because the admin keeps linking new workshops and each link has to open the course for its attendees immediately — and unlinking has to close it again just as fast.
Discount codes applied to orders that already exist: since an order is PENDING from the moment someone registers, a code can be attached or removed afterwards, which means recomputing from the live product price, clearing any stale gateway authority, and accepting that the usage cap is advisory because the counter only moves at payment verification.
Outcomes
A production platform running the academy course sales and its multi-city workshops end to end — 23 models and roughly 100 endpoints across eleven domains, with the entire backend built solo.
Public site and admin panel delivered as one Next.js application, with a single controller layer as the only place that talks to the backend.
Manual operations replaced by product features: attendee lists, targeted workshop SMS, CSV-driven bulk campaigns, discount codes, and certificates issued for both course completion and workshop attendance.
Zero-amount checkout handled as a first-class path — a discount that brings the total to nothing skips the gateway entirely instead of failing against its minimum-amount rule.
Lessons Learned
Two things that both have a title and a date are not the same entity. Refusing a shared abstraction for courses and workshops is what kept both flows honest.
Derived access should stay derived. Materializing the workshop door into stored rows would have been faster and would have broken the exact behaviour the feature exists for.
An order created before payment is a different object from a purchase, and saying so in the API is what lets a storefront show "you registered, you still owe money" rather than guess.
Idempotence in messaging is a data-model question, not a sending question — a per-recipient flag is what makes a broadcast safe to run again.

