Payments and credits
Every trade inside Novaterra — buying a listing, hiring a citizen, paying for a service — moves
credits, never currency. apps/api/src/modules/payments/** is the one addition tonight that
lets real money cross that boundary in one direction: a top-up turns pounds into credits. Nothing
in the running code turns credits back into a bank transfer; see
Stripe, top-ups and payouts for exactly what a
“payout” is instead.
The unit boundary, stated once
apps/api/src/modules/payments/config.ts opens with this, and it is worth repeating verbatim
because getting it wrong is how a marketplace loses money silently:
- credits are the ledger’s unit — whole integers,
credit_entries.deltais always credits. - minor units are real money in the smallest unit of
PAYMENTS_CURRENCY(pence for GBP). Stripe speaks minor units, sopayment_intents.amount_minordoes too. Never credits. - USD appears nowhere in payments.
LLM_BUDGET_*_USDis a model spend guard denominated in dollars because OpenRouter bills in dollars; it has nothing to do with the credits a customer buys, and the two must never be added, compared, or converted implicitly.
Vocabotics Ltd is a UK company, so GBP is the base currency (PAYMENTS_CURRENCY, default
gbp) and nothing here defaults to USD.
The credit packs
The catalogue lives in code, not the database — prices are a business decision that should arrive by deploy and be reviewable in a diff:
{ id: 'starter', label: 'Starter', credits: 1_000, amountMinor: 500 } // £5.00{ id: 'growth', label: 'Growth', credits: 5_000, amountMinor: 2_000 } // £20.00{ id: 'studio', label: 'Studio', credits: 25_000, amountMinor: 9_000 } // £90.00GET /api/payments/packs → { currency, merchant, packs: [...], providers, stripePublishableKey }Cheaper per credit as the pack grows, roughly 200–280 credits per pound. A custom amount (no pack chosen) is priced at the Starter pack’s rate, floored so nobody can construct a bespoke amount that beats the published price list.
Two providers, one seam
Everything above PaymentProvider (apps/api/src/modules/payments/provider.ts) speaks credits and
PaymentIntent rows; everything below it speaks to one payment company:
| Provider | What it is | Configured when |
|---|---|---|
manual | The owner grants credits by hand — no third party, no network call, the mechanism the whole system runs on before any Stripe key exists | Always |
stripe | Real Stripe Checkout / PaymentIntents | Both STRIPE_SECRET_KEY and STRIPE_WEBHOOK_SECRET are set |
The system is dormant unless both are set. The secret key alone would let the API create a
checkout it could never confirm, because settlement only ever happens on a signature-verified
webhook — so a top-up would take money and grant nothing. With no keys, POST /api/payments/topups
with provider: "stripe" fails with a plain 503 telling the customer to ask the owner to grant
credits directly instead, and the whole Stripe code path (apps/api/src/modules/payments/stripe.ts)
never even constructs a client.
Environment variables
| Variable | Purpose |
|---|---|
STRIPE_SECRET_KEY | Stripe’s secret API key. Required for the Stripe provider to do anything. |
STRIPE_WEBHOOK_SECRET | Signs the webhook Stripe sends back. Required, and required together with the key above — see the dormancy rule. |
STRIPE_PUBLISHABLE_KEY | Public key, safe to ship to the browser; returned by GET /api/payments/packs when Stripe is configured. |
STRIPE_PRICE_<PACKID> | e.g. STRIPE_PRICE_STARTER — a dashboard-managed Stripe Price id for that pack, so Stripe Tax has a tax_code. Unset falls back to inline price_data at the pack’s amount. |
STRIPE_CHECKOUT_FLOW | hosted (default — Stripe’s own page, gets Stripe Tax, VAT collection and SCA for free) or payment_intent (an on-page Payment Element; tax-exclusive, no automatic tax). |
STRIPE_AUTOMATIC_TAX | Default true. Stripe Tax, on by default for a UK company selling digital services. |
STRIPE_WEBHOOK_TOLERANCE_SECONDS | Clock skew tolerated on a webhook timestamp. Default 300, matching Stripe’s own default. |
PAYMENTS_CURRENCY | Default gbp. |
MERCHANT_NAME, MERCHANT_COUNTRY, MERCHANT_VAT_NUMBER | The trading entity, on Checkout metadata and in the owner’s records. Default Vocabotics Ltd, GB. |
PAYMENTS_SUCCESS_URL, PAYMENTS_CANCEL_URL | Where Stripe returns the customer’s browser. Default to <WEB_ORIGIN>/market?tab=wallet&topup=success|cancelled. |
PAYMENTS_MIN_PAYOUT_CREDITS | Default 1000. The smallest payout worth a bank transfer’s cost or an owner’s attention. |
PAYMENTS_MAX_TOPUP_CREDITS | Default 500000. Ceiling on a single top-up. |
See Environment variables for the same table alongside every other setting.
No real money moves to sellers
Say it plainly, because it is the single most important fact about tonight’s build: Stripe Connect is not enabled, and nothing in the running code wires a marketplace sale to a bank transfer. A seller’s earnings are credits in their wallet, same as everyone else’s; a “payout request” (see Stripe, top-ups and payouts) is the owner manually deciding a payout happened, recorded in credits, with a note — not an API call to Stripe that moves currency anywhere. If Novaterra ever pays real sellers real money, that is a Stripe Connect integration that does not exist yet.
In this section
- The credit ledger — the append-only
credit_entriestable, per-being sequence numbers,balance_after, and how buying stays atomic. - Stripe, top-ups and payouts — Checkout, webhook verification, refunds as reversals, and payout requests.
See also Wallet and credits for the marketplace-facing view of the same wallet, and The admin console for the owner’s read on every payment intent, webhook event and payout request in one place.