nastaranmehri
فارسی
Let's work together

VPN Sales Platform

A shop for VPN subscriptions. A customer picks a plan on the website or inside a Telegram bot; the website sends them to Zarinpal to pay, and once the payment is verified a background worker creates their account on a Marzban panel. An operator manages plans, and can disable a user or cancel a subscription, from an admin panel. One FastAPI backend and its database hold every fact — who paid, what was activated, when it expires: the website and the bot talk to it over its API, the admin panel is served by it, and the two workers run against the same database. The VPN itself is not part of the project: creating the account is delegated to Marzban over its API.

Role
Sole developer
Period
2026
Status
Source available
  • 353 automated tests, all passing how this was measured
    method
    python -m pytest over the full suite: 25 test modules, 353 collected, 0 skipped
    measured
    sample
    repository at commit 185b9ab
    environment
    local machine, Python 3.13.7, pytest 8.4.1, PostgreSQL 18
  • 0 lint and type errors how this was measured
    method
    ruff check app/ bot/ tests/ alembic/; tsc --noEmit and eslint . in website/ (one ESLint warning, no errors)
    measured
    sample
    repository at commit 185b9ab
    environment
    ruff 0.11.13, TypeScript 5.7.2, ESLint 9.17.0

What a purchase goes through

A purchase is four backend objects created in order, and each step is idempotent on a key of its own. A purchase intent records that a user wants a plan, under a key the client supplies. An order is created from the intent, and an intent can produce at most one. A payment attempt is opened against the order under a second client key; from the website it is handed to Zarinpal, which answers with an authority that the attempt keeps, and when the gateway calls back the backend verifies the payment against the amount it stored before it marks the order paid. That is the end of the request path. A provisioning worker claims the paid order, creates the account on the Marzban panel, stores the username and the subscription link on a subscription row that is unique to the order, and marks the order active. The website shows the customer each of these states by polling a status endpoint through its own route handler. The bot creates the same intent, order and attempt through the same services and polls the same status; in this commit it does not yet hand a Telegram customer to the gateway.

The checkout page before payment starts: a status card reading Ready to start purchase, a payment-details panel holding only a placeholder line, a Start checkout button, and an order summary for a 90-day, 200 GB plan.
Checkout on the website: the repository’s own screenshot, captured from the running application with demo plans.

Where the guarantees are

Two of the five invariants the project sets itself, that a payment is never processed twice and an order is never activated twice, are enforced by the database and a state machine rather than by callers behaving.

  • Orders move through nine states and payments through four, and state changes go through one function per model that raises on anything not in an explicit allowed-transitions table. The only exceptions are two recovery branches in the same module that set an order straight to active when its subscription already exists and is active.
  • Uniqueness is declared at each place a duplicate could be created: an intent per user and idempotency key, an order per intent, a payment per idempotency key, a payment per gateway authority, a payment per gateway reference, an intent per token, and a subscription per order.
  • A subscription is bound to its order by two composite foreign keys, on order and user and on order and plan, so a row cannot point at an order that belongs to a different customer or a different plan.
  • Money is an integer number of toman, with the currency pinned by a check constraint. A payment whose amount does not equal its order’s total is refused before initiation and again before verification.
  • A user row must have either an email with a password hash or a Telegram id, and never a password without an email. That is a check constraint, not a comment.

The two customer surfaces, and the third

The Next.js website never holds a token in the browser. Both tokens live in HttpOnly cookies, and every browser-initiated call to a protected endpoint goes through a route handler that adds the bearer header, spends the single-use refresh token exactly once on a 401 and retries, and writes the rotated pair back as cookies. Server-rendered pages fetch the public plan list and the current user directly, and recover an expired session by redirecting through a dedicated refresh route. A guard cookie stops a broken session from bouncing between that route and the login page, and the post-login destination is checked against protocol-relative forms before it is followed.

The Telegram bot, built on aiogram, runs in polling mode as a single replica. It authenticates to the backend with a service token and passes the Telegram user id as a header. Its only state is a fifteen-minute flow record holding a token, a plan id, a generated idempotency key and a timestamp; a stale or mismatched flow token is rejected by the bot before any purchase API is called, and a repeated tap resolves to the same intent, order and payment attempt on the backend.

The admin panel is server-rendered with Jinja over the same admin API and the same JWT authentication, with the token carried in an HttpOnly cookie as transport only. Its scope is deliberately narrow: operational reads, plan management, user status and subscription cancellation. The first admin is created by a command-line bootstrap that needs a one-time token and refuses to run if any admin already exists.

Running it

Production is seven processes, the API, the bot, the provisioning worker, the expiry worker, the website, PostgreSQL and Redis, each its own container from a production compose example, with migrations as a separate and explicit step. The Python image runs as a non-root user. The settings module refuses to start in production with debug on or with a placeholder still in place for any secret it owns (the one-time admin bootstrap token is read separately and is not checked), and the health endpoint reports degraded with a 503 when the database is unreachable rather than pretending otherwise.

What is not built

The scope document keeps its own list, and it is honest: support tickets, a customer-facing renewal screen (the backend already accepts a renewal target), payment history, the website side of Telegram account linking (the backend primitives exist), and manual payment verification by an admin are all deferred past this version. One more thing the code shows rather than the document: the bot creates the order and the payment attempt, but the hand-off to Zarinpal exists only on the website.

Activation runs in a worker that claims the order, never inside the payment request

When Zarinpal confirms a payment, the order is marked paid. Creating the account on the Marzban panel is a network call to a second system that can be slow or down, and it has to happen exactly once per order.

Also considered
Activating in the payment callback — The deployment notes rule it out in as many words: do not rely on web requests to activate paid orders. A callback has the gateway and the customer waiting on it, and a panel outage would turn a successful payment into a failed request.

Chosen: A separate provisioning process claims one paid order at a time with SELECT … FOR UPDATE SKIP LOCKED, writes a claim token, commits, and only then calls the panel. Completion and failure both check that the token is still current before they touch the order.

  • A worker that dies after claiming is recovered: a claim older than PROVISIONING_STALE_CLAIM_SECONDS (300 by default) can be taken again, and the provider timeout is required to be shorter than that window so a hung call cannot outlive its claim.
  • Two provisioning workers can run at once. SKIP LOCKED keeps them off the same row, and the claim token keeps a stale one from completing an order the other has taken over.
  • Failures retry with exponential backoff from PROVISIONING_RETRY_BASE_SECONDS, capped at PROVISIONING_RETRY_MAX_SECONDS, and the last error is stored on the order beside the retry time; nothing in the admin panel reads it back yet.

Replay protection lives in the database, not in the bot

A Telegram user can tap Pay twice, the bot can restart mid-flow, and a callback can be delivered more than once. Something has to remember that this purchase already exists.

Also considered
Holding purchase state in the bot — The README forbids it by name: the bot's state is disposable and must not store order, payment, subscription or lifecycle state. Anything the bot remembered would be gone on restart, and a second tap would be a second order.

Chosen: Three unique constraints, one per stage: purchase intents on (user, idempotency key), one order per purchase intent, one payment per idempotency key. The bot keeps only a flow token, the plan id, a generated key and a timestamp, for fifteen minutes.

  • Repeating a pay callback returns the same intent, the same order and the same payment attempt. A replay that reuses a key with a different plan is rejected as a conflict rather than silently reused.
  • The website goes through the same purchase service and the same constraints, so neither client has its own notion of a duplicate.
  • A payment is tied to one gateway authority, which is itself unique, and its amount is checked against the order total both before initiation and before verification.

Expiry disables the panel account first and marks the row second, under one lock

An expiry worker finds active subscriptions whose date has passed and has to change two systems, the Marzban account and the database row, without ever leaving them disagreeing in the customer's favour.

Also considered
Marking the subscription inactive first — If the disable call then failed, the database would say the subscription had ended while the account still worked. The service's own docstring names that state as the one it exists to prevent: a failed provider call leaving the database inactive while the Marzban account is still enabled.

Chosen: Claim one expired, still-active row with SKIP LOCKED, call disable while the lock is held, and only on success set it inactive and move the order to expired. A failed disable returns without writing, so the row stays active and is claimed again on the next pass.

  • The failure mode is bounded to one direction: the panel may be disabled before the row says so, but the row is never marked expired while the account still works.
  • Retrying is safe by construction, because only active rows are ever claimed.
  • Expiry is its own process with its own poll interval, separate from provisioning and from the API.

Ports only where a system has to be swappable

The backend makes outbound calls to two external systems it does not control: the Marzban panel and the Zarinpal gateway.

Also considered
An interface in front of every layer — The README's architecture note draws the line: port interfaces only for the external integrations that need to be swappable. Routes, services and models are one codebase and gain nothing from a seam between them.

Chosen: Two abstract ports, VPNProvider with provision, renew, disable and get, and PaymentProvider with initiate_payment and verify_payment, each with one adapter. The provisioning and expiry services take a provider as an argument and never import Marzban.

  • The provisioning, expiry and payment tests inject fakes for both ports, so the whole suite runs with no panel and no gateway reachable.
  • Replacing the gateway is one adapter; the state machines above it do not change.

Migrations are a deploy step, not a startup side effect

The API and both workers share one PostgreSQL database, and each restarts on its own schedule.

Also considered
Running the upgrade when the API starts — Startup deliberately does not run migrations. The README makes the upgrade a required, explicit step before new application containers start, on every deployment that includes schema changes.

Chosen: A migrations service in the production compose file, behind a profile, run between starting the database and starting the application services. The API starts without a database and reports degraded on its health endpoint rather than crashing.

  • A deploy with schema changes is three commands in a fixed order, and a container restart can never alter the schema.
  • The health endpoint answers 503 with a component-level body when the database is unreachable, so an orchestrator can tell a dead process from a missing dependency.

Nastaran's assistant

Ask me about Nastaran's work, skills and services, or how to reach her — in English or Persian. I answer only from what is published on this site, and I'll say so when it doesn't cover something.

Try one of these: