to select ↑↓ to navigate
Doppiobox

Doppiobox

Open in ChatGPT
Ask ChatGPT about this page
Open in Claude
Ask Claude about this page

Billing, Wallet & Subscriptions

How money moves through Doppio Box: a prepaid wallet (a ledger of signed transactions), plans the user buys for a fixed period, and subscriptions that tie a paid plan to a running Dev Box. There are exactly two ways money enters or is spent:

  1. Top up the wallet via Razorpay (real money in → wallet credit).
  2. Buy / renew a plan — paid either from the wallet (a wallet debit) or directly via Razorpay (no wallet touched).

There is no per-hour metering and no usage-based billing. A user pays the full plan price up front for a fixed window (1 Day … 6 Months); when that window ends a scheduler expires the subscription and stops the box. See Charging cadence — this is the single most misread part of the system.

Currency rule (locked): 1 credit = 1 currency unit. ₹1 INR or $1 USD, 1:1, no FX conversion. A customer's currency is decided once (India → INR, everything else → USD) and is immutable. specs/wallet-and-billing-page.md:23, doppio_customer.py:6.


1. The data model

diagram

Doppio Customer — the billing entity

benchspace/bench_space/doctype/doppio_customer/doppio_customer.py

The billing identity, 1:1 with a Frappe User (the user field is the document name, autoname: field:user). It is created lazily — only on the first paid action (top-up or a Razorpay-direct purchase). Trial users never get one (wallet-and-billing-page.md:26).

It holds the billing snapshot (billing_name, billing_address, country, tax_id) and the locked currency:

def before_insert(self):
    self.currency = "INR" if self.country == "India" else "USD"

doppio_customer.py:6-7. currency and country are set_only_once / read_only — immutable after creation. razorpay_customer_id exists for a future "saved customer" feature but is unused in v1.

The balance is not stored — it is computed by reading the most recent submitted ledger row:

def get_balance(self) -> float:
    ending_balance = frappe.db.get_value(
        "Wallet Transaction",
        filters={"customer": self.name, "docstatus": 1},
        fieldname="ending_balance",
        order_by="creation desc",
    )
    return float(ending_balance or 0)

doppio_customer.py:9-17. No customer → no ledger → balance 0.

Wallet Transaction — the append-only ledger

benchspace/bench_space/doctype/wallet_transaction/wallet_transaction.py + .json

This is the only money store. There is no separate Wallet doctype — the balance is simply the latest row's ending_balance (wallet-and-billing-page.md:24). The design mirrors Frappe Press's Balance Transaction.

Key properties:

  • Submittable (is_submittable: 1). Only submitted rows (docstatus == 1) count toward the balance. Drafts and cancelled rows are invisible to get_balance.
  • amount is signed — positive = credit, negative = debit (wallet_transaction.json field description: "Positive for credit, negative for debit").
  • Append-onlyon_cancel hard-throws: "Wallet Transaction entries cannot be cancelled" (wallet_transaction.py:39-40). A correction is a new row, never an edit.
Type amount sign source Created by
Top-up + credit Razorpay _record_topup (wallet.py:54)
Plan Purchase - debit (none) _debit_for_plan (wallet.py:81)
Refund + credit Refund _refund_for_failed_sub (wallet.py:99)
Adjustment either Manual manual desk entry (no API)

The running balance is computed atomically at submit time:

def before_submit(self):
    prev = frappe.db.get_value(
        "Wallet Transaction",
        filters={"customer": self.customer, "docstatus": 1},
        fieldname="ending_balance",
        order_by="creation desc",
    )
    self.ending_balance = float(prev or 0) + float(self.amount)
    if self.ending_balance < 0:
        frappe.throw("Insufficient wallet balance", exc=frappe.ValidationError)

wallet_transaction.py:14-23.

This is the hard ceiling on overspend: every debit recomputes prev + amount and refuses to submit if the result is negative. Two simultaneous wallet payments that each individually fit cannot both succeed — the second's before_submit reads the first's already-committed ending_balance and throws. validate also rejects a zero amount (wallet_transaction.py:11-12).

On submit it publishes the realtime event and (for top-ups) emails a receipt:

def on_submit(self):
    frappe.publish_realtime("wallet_updated",
        {"balance": self.ending_balance, "currency": self.currency}, user=self.user)
    if self.type == "Top-up":
        self._send_topup_email()

wallet_transaction.py:25-37. The dashboard listens for wallet_updated to refresh the balance live.

Concurrency note: the +/- balance recompute in before_submit is not wrapped in an explicit row lock (SELECT ... FOR UPDATE). It relies on the per-request DB transaction plus the unique index on razorpay_payment_id. The guard against concurrent debits is the before_submit re-read of the latest committed ending_balance — correct under MariaDB's default REPEATABLE READ for serialized requests, but there is no explicit pessimistic lock here.

Dev Box Plan — the price list

benchspace/bench_space/doctype/dev_box_plan/dev_box_plan.json

A pricing tier. Note: the plan stores price and billing period only — there are NO cpu/mem/disk fields on the plan. Resource limits live on the Dev Box / template layer (see backbone/notes/pricing.md for the physical allocation: 2 vCPU, 4 GB RAM, 8 GB swap, ~15 GB storage per box). Plan fields:

Field Type Notes
plan_name Data unique, is the doc name
frequency Select 1 Day / 3 Days / 7 Days / 1 Month / 3 Months / 6 Months — the billing window
enabled Check hidden from pickers when off
is_trial Check the free-trial plan; price/frequency not required
price_usd Currency charged when customer currency is USD
price_inr Currency charged when customer currency is INR

Plan selection by currency: _get_plan_price(plan, currency) returns price_inr for INR else price_usd (billing.py:34-35). Pricing rationale ($14.99/mo target, ~61% margin at 10 boxes/VM) is in backbone/notes/pricing.md.

Dev Box Subscription — a box's active plan

benchspace/bench_space/doctype/dev_box_subscription/dev_box_subscription.json

One subscription = one purchase of a plan, linked to one Dev Box. It snapshots the price, currency, and billing details at purchase time (legal/audit — they stay even if the Customer later edits billing). The status machine:

Status Meaning
Pending created, awaiting Razorpay payment confirmation
Paid payment confirmed (wallet path sets this synchronously)
Provisioning box claim/create in flight (agent task queued)
Active agent reported the box up; the clock is running
Extended superseded by a renewal that stacked time on top of it
Expired end_date passed + grace; box stopped
Failed provisioning failed; wallet purchases auto-refunded

payment_method (Razorpay / Wallet, set_only_once) records how it was paid. start_date / end_date define the prepaid window. Row-level permissions: a Dev Box User reads only their own subs (dev_box_subscription.py:11-21).


2. Razorpay top-up flow (real money → wallet credit)

This is the only path that brings external money in. Razorpay integration is provided by the razorpay_frappe app; Doppio Box never talks to Razorpay's HTTP API directly — it calls RazorpayOrder.initiate / RazorpayOrder.handle_success.

diagram

Money units — read this

The app passes major units (rupees / dollars) everywhere. razorpay_frappe converts to subunits (paise/cents) at the gateway boundary:

def get_in_razorpay_money(amount): return amount * 100   # utils.py:44
def convert_from_razorpay_money(amount): return amount / 100  # utils.py:48

So initiate_topup(amount=500) for an INR customer means ₹500, and RazorpayOrder.initiate sends 50000 paise to Razorpay (razorpay_order.py:64). Wallet Transaction amount, plan prices, and get_balance are all in major units. Never multiply by 100 in app code.

Signature verification

RazorpayOrder.handle_success is the trust boundary. It calls Razorpay's SDK HMAC check before marking the order paid:

client.utility.verify_payment_signature({
    "razorpay_order_id": order_id,
    "razorpay_payment_id": payment_id,
    "razorpay_signature": signature,
})

razorpay_order.py:101-108. If the signature is forged the SDK raises and the whole confirm_topup aborts before any wallet credit. Note: verification is skipped under frappe.flags.in_test (razorpay_order.py:101) — tests don't need a real signature.

This is a client-confirm model (browser posts the success back to confirm_topup), not a server-to-server webhook. The defenses against a malicious/replayed confirm are: (a) HMAC signature check, (b) the razorpay_payment_id unique index + pre-check, (c) the ref_dt/ref_dn == this customer ownership assertion (wallet.py:251-252). Gap: there is no Razorpay webhook handler in benchspace/. razorpay_frappe does ship a webhook endpoint (rzp_renderer.py:handle_webhook), but it only writes a Razorpay Webhook Log — it does not backfill a wallet credit. So if the browser never calls confirm_topup (e.g. the tab is closed after paying), the payment succeeds at Razorpay but no wallet credit is recorded until a manual reconcile.

Whitelisted API — benchspace/api/wallet.py

Method HTTP Params Returns Auth / guards
get_customer GET {exists} or {exists, name, user, currency, country, billing_name, billing_address, tax_id, balance} session user; reads own customer only
get_wallet_transactions GET limit=50, offset=0 list of submitted rows {name, type, source, amount, currency, ending_balance, description, reference_doctype, reference_name, posted_at} ordered creation desc scoped to session user's customer; [] if no customer
initiate_topup POST amount, country?, billing_name?, billing_address?, tax_id? {key_id, order_id, amount, currency} creates customer if missing (billing args then required); validates amount > 0 and TOPUP_BOUNDS
confirm_topup POST order_id, payment_id, signature {balance, transaction} customer must exist; idempotent on payment_id; verifies signature + order ownership
update_customer_billing POST billing_name?, billing_address?, tax_id? updated customer dict cannot change country/currency (immutable); customer must exist

Top-up bounds (wallet.py:7-10): INR 100 … 50000, USD 5 … 1000. Out-of-range throws a clear message (wallet.py:205-211).

Idempotency is belt-and-suspenders: confirm_topup first checks for an existing submitted txn with that payment_id and returns early (wallet.py:237-246); _record_topup re-checks (wallet.py:56-62); and the razorpay_payment_id column is unique in the JSON — a race that slips past both checks still fails at the DB index.


3. Plans & subscriptions — buying and renewing

benchspace/api/billing.py. The single entry point for purchases is initiate_devbox_order; it branches on payment_method.

Whitelisted API — benchspace/api/billing.py

Method HTTP Purpose Returns
get_templates GET enabled DevBox Templates for the picker (cached 1h) [{name, title, description, is_default}]
get_plan_info GET enabled non-trial plans, sorted by frequency (cached 1h) [{name, plan_name, frequency, price_usd, price_inr}]
get_trial_info GET trial eligibility for current user {eligible, trial_duration_seconds, trial_duration_formatted}
start_free_trial POST provision a free-trial box (no Customer, no charge) {dev_box, slug}
get_countries GET country list for the billing form [str]
initiate_devbox_order POST buy or renew a plan (wallet or razorpay) wallet: {dev_box, slug} · razorpay: {key_id, order_id, subscription_name, amount, currency, is_renewal}
handle_devbox_payment POST confirm a Razorpay new-box payment {dev_box, slug}
handle_devbox_renewal POST confirm a Razorpay renewal payment {dev_box, slug}

initiate_devbox_order(plan, payment_method="razorpay", dev_box=None, country=…, billing_name=…, billing_address=…, tax_id="", devbox_template=None) (billing.py:178):

  1. Validate the plan exists and is enabled; validate payment_method ∈ {razorpay, wallet}.
  2. _get_or_create_customer(...) — currency comes from the customer; amount = _get_plan_price(plan, currency).
  3. dev_box set ⇒ this is a renewal; ownership is checked (_validate_dev_box_ownership, billing.py:293).
  4. Build the subscription snapshot (status=Pending), then branch:

Wallet branch (billing.py:258-290):

  • Upfront check customer.get_balance() < amount → throw "Insufficient wallet balance…" (clean UX; before_submit is the real guard).
  • Compute start_date/end_date (renewals stack — see below), set status=Paid, insert the sub.
  • _debit_for_plan(customer, amount, sub) submits a Plan Purchase row with -amount (wallet.py:81-96).
  • _provision_box_for_sub (new) or _renew_box_for_sub (renewal). Returns {dev_box, slug} synchronously — no Razorpay round-trip.

Razorpay branch (billing.py:235-256):

  • Insert the Pending sub, RazorpayOrder.initiate(...), store razorpay_order, return {key_id, order_id, subscription_name, amount, currency, is_renewal}. The browser opens checkout, then calls handle_devbox_payment (new box) or handle_devbox_renewal (renewal). Both verify the signature via handle_success, re-check sub.user == session.user and sub.status == "Pending", set the dates, then provision/renew (billing.py:399-429, 540-571).

Provisioning a box for a sub

_provision_box_for_sub (billing.py:313-349) resolves the box in priority order:

  1. Trial upgrade — if the user has an expired trial within a 2-day grace window whose box is still stopped/active (_find_trial_for_upgrade, billing.py:152-175), reuse that box (unlink it from the trial sub so the trial-cleanup job won't delete it).
  2. Pool claim — grab a pre-warmed is_pool box (_claim_pool_box, billing.py:375, uses for_update to avoid double-claim).
  3. Fresh create — insert a new Dev Box and let the agent build it.

It sets dev_box.subscription_end_date = sub.end_date, links sub.dev_box, and marks the sub Provisioning. The agent later reports completion: devbox.update_task flips the sub Provisioning → Active on success or → Failed on failure (api/devbox.py:124-150).

Renewals stack time

_get_active_sub_end_date(dev_box) (billing.py:299-310): if there's an Active sub whose end_date is still in the future, it marks that sub Extended and returns its end_date. The new sub's window then starts from that end_date (not now), so renewing early doesn't waste the remaining time (billing.py:266-270, 560-564).

Charging cadence

Important — there is NO metering. A user is charged once, the full plan price, at purchase time (one Plan Purchase debit, or one Razorpay charge). They are not billed per hour and the wallet is not drained incrementally while the box runs. "Billing period" = the prepaid window (frequency), nothing more.

The scheduler does the expiry half, not charging. Registered under the all event (hooks.py:151-159):

diagram

check_expired_subscriptions (billing.py:520-537): an Active sub past end_date plus a 1-hour grace is flipped to Expired, its box's subscription_end_date cleared, and if the box is active/starting the agent is told to stop it (not delete). send_expiry_warnings emails users with < 6h left (billing.py:432-459).

Timing note: the all scheduler event fires on every scheduler tick (Frappe's default cadence, ~every few minutes — set by the framework's scheduler_tick_interval, not by this app). So expiry granularity is "within a tick of end_date + 1h grace", not exact-to-the-second.


4. The billing page UI

Route /billingdashboard/src/pages/billing-page.tsx. Three regions:

Balance card (billing-page.tsx:56-73) — calls wallet.get_customer (cache key wallet-customer), shows formatCurrency(balance, currency) large, plus a Top up button opening <TopUpDialog>. Subscribes to the wallet_updated realtime event and re-fetches on fire (billing-page.tsx:38-43) — top-ups and debits update the number without a reload.

Wallet tab<WalletTransactionsTable> (wallet-transactions-table.tsx) — calls wallet.get_wallet_transactions({limit:100}). Columns: Date · Type (color-coded Badge) · Description · Amount (credits green with +, debits plain) · Balance. Empty state: "No wallet transactions yet."

Subscriptions tab<SubscriptionsTable> (subscriptions-table.tsx) — uses useFrappeGetDocList("Dev Box Subscription", …) directly (relies on the row-level permission filter to scope to the user). Columns: Plan · Box (title + slug) · Status (Badge) · Start · End · Method · Amount · Renew button. Renew shows only when dev_box is set and status is Active/Expired (subscriptions-table.tsx:140); it opens <CreateDevBoxDialog renewForDevBox=…> which routes back through initiate_devbox_order with dev_box set.

<TopUpDialog> (top-up-dialog.tsx) — first-time users see country + billing fields (currency shown read-only, derived from country); returning users see only the amount step. Preset chips (₹500/1000/2500/5000 or $5/10/25/50) + custom input bounded to the currency's min/max. On Pay: initiate_topuploadRazorpayScript (lib/razorpay.ts) → new window.Razorpay(...).open() → success handler calls confirm_topup → toast + close. formatCurrency (lib/format.ts) renders en-IN/en-US with the currency symbol, dropping decimals for whole amounts.


5. Edge cases — what the code actually does

Situation Behaviour Source
Insufficient balance (wallet purchase) Upfront throw in initiate_devbox_order; Wallet Transaction.before_submit is the hard backstop (refuses to submit a debit that goes negative). billing.py:259, wallet_transaction.py:14-23
Concurrent wallet payments Second debit's before_submit re-reads the now-lower ending_balance and throws. Exactly one wins. wallet_transaction.py:14-23
Provisioning fails (agent error) Sub → Failed; if payment_method == "Wallet", _refund_for_failed_sub submits a positive Refund row (idempotent — skips if a Refund already references the sub). Razorpay-paid fails are not auto-refunded here. api/devbox.py:134-150, wallet.py:99-134
Duplicate top-up confirm / replay No-op: pre-check returns existing balance; _record_topup re-checks; razorpay_payment_id unique index is final guard. wallet.py:237-246, 56-62
Subscription expires Scheduler flips Active → Expired after end_date + 1h grace and stops (not deletes) the box. No money moves at expiry. billing.py:520-537
Forged Razorpay signature handle_successverify_payment_signature raises; no wallet credit / no sub activation. (Skipped under in_test.) razorpay_order.py:101-108
Trial users No Doppio Customer, no wallet, amount=0 sub. Trial box auto-deleted >2 days after expiry. billing.py:81-144, 493-517
Order belongs to another customer confirm_topup asserts ref_dt/ref_dn match the session customer → PermissionError. wallet.py:251-252

Refund gap (confirmed): a Razorpay-direct plan purchase that fails provisioning (sub → Failed) has real money captured at Razorpay but no automatic refund path in code_refund_for_failed_sub early-returns unless payment_method == "Wallet". RazorpayOrder.refund exists (razorpay_order.py:141) but has no caller anywhere in benchspace/, so Razorpay-paid refunds must be issued manually / out-of-band.


Reference map

Concern File
Wallet ledger + balance recompute benchspace/bench_space/doctype/wallet_transaction/wallet_transaction.py
Billing entity + get_balance benchspace/bench_space/doctype/doppio_customer/doppio_customer.py
Plan price list benchspace/bench_space/doctype/dev_box_plan/dev_box_plan.json
Subscription state machine benchspace/bench_space/doctype/dev_box_subscription/dev_box_subscription.{py,json}
Wallet/top-up API benchspace/api/wallet.py
Plan/subscription API + expiry jobs benchspace/api/billing.py
Sub status transitions (agent) benchspace/api/devbox.py:124-150
Razorpay money + signature razorpay_frappe/.../razorpay_order.py, razorpay_frappe/.../utils.py
Billing page dashboard/src/pages/billing-page.tsx
Top-up dialog dashboard/src/components/wallet/top-up-dialog.tsx
Wallet / subs tables dashboard/src/components/wallet/{wallet-transactions-table,subscriptions-table}.tsx
Razorpay loader / currency fmt dashboard/src/lib/razorpay.ts, dashboard/src/lib/format.ts
Design decisions specs/wallet-and-billing-page.md
Pricing rationale + VM specs backbone/notes/pricing.md
Last updated 1 month ago
Was this helpful?
Thanks!