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:
- Top up the wallet via Razorpay (real money in → wallet credit).
- 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
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 toget_balance. amountis signed — positive = credit, negative = debit (wallet_transaction.jsonfield description: "Positive for credit, negative for debit").- Append-only —
on_cancelhard-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 inbefore_submitis not wrapped in an explicit row lock (SELECT ... FOR UPDATE). It relies on the per-request DB transaction plus the unique index onrazorpay_payment_id. The guard against concurrent debits is thebefore_submitre-read of the latest committedending_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.
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) therazorpay_payment_idunique index + pre-check, (c) theref_dt/ref_dn == this customerownership assertion (wallet.py:251-252). Gap: there is no Razorpay webhook handler inbenchspace/.razorpay_frappedoes ship a webhook endpoint (rzp_renderer.py:handle_webhook), but it only writes aRazorpay Webhook Log— it does not backfill a wallet credit. So if the browser never callsconfirm_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):
- Validate the plan exists and is enabled; validate
payment_method ∈ {razorpay, wallet}. _get_or_create_customer(...)— currency comes from the customer;amount = _get_plan_price(plan, currency).dev_boxset ⇒ this is a renewal; ownership is checked (_validate_dev_box_ownership,billing.py:293).- 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_submitis the real guard). - Compute
start_date/end_date(renewals stack — see below), setstatus=Paid, insert the sub. _debit_for_plan(customer, amount, sub)submits aPlan Purchaserow 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(...), storerazorpay_order, return{key_id, order_id, subscription_name, amount, currency, is_renewal}. The browser opens checkout, then callshandle_devbox_payment(new box) orhandle_devbox_renewal(renewal). Both verify the signature viahandle_success, re-checksub.user == session.userandsub.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:
- 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). - Pool claim — grab a pre-warmed
is_poolbox (_claim_pool_box,billing.py:375, usesfor_updateto avoid double-claim). - 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 Purchasedebit, 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):
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
allscheduler event fires on every scheduler tick (Frappe's default cadence, ~every few minutes — set by the framework'sscheduler_tick_interval, not by this app). So expiry granularity is "within a tick ofend_date + 1hgrace", not exact-to-the-second.
4. The billing page UI
Route /billing → dashboard/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_topup → loadRazorpayScript (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_success → verify_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_subearly-returns unlesspayment_method == "Wallet".RazorpayOrder.refundexists (razorpay_order.py:141) but has no caller anywhere inbenchspace/, 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 |