A member subscribes in the portal, pays through whichever provider the tenant uses, and keeps access while the payments keep going through. This plan covers the screens, the routes, a new c1_ billing system kept separate from the real-estate transaction tables, and the provider adapters that plug in behind them.
The portal and admin talk to one billing core. The core never calls Stripe or Razorpay directly: it asks an adapter. Providers report back through signed webhooks that land in an inbox, and a worker applies them. Access comes from entitlements, not from payment state.
Every table, route and adapter below follows from these. If a future change breaks one of them, it is a bug, even if the tests pass.
A first payment, a renewal and a one-time purchase are all orders. Every charge, refund and chargeback is a transaction on an order. Receipts and refunds work one way.
The portal asks: does this member hold meditation right now? Subscriptions, purchases and free grants all answer through the same table.
Signature first. A unique key stops duplicates. The worker re-reads the object from the provider, so late or out-of-order webhooks can't roll state back.
Adding PayU or the next one is one adapter folder and one enum value. The core only knows canonical events like payment.failed.
These are the choices that are expensive to reverse. Each one lists the common answer, the pick, and why. Tell me if you'd go a different way before we build.
Least code. But PayU India makes the merchant trigger each standing-instruction debit, so this alone fails for PayU.
One behaviour everywhere, but we would rebuild retries, 3DS re-checks and India mandate rules that Stripe, Razorpay and Revolut already handle.
Stripe, Razorpay and Revolut run their own clocks. C1 runs PayU's. Both paths end in the same canonical events, so the core can't tell them apart.
Fast for the first one. Every new provider then changes the schema and the core.
Many providers behind one API. But it is another system to run, its subscription support varies by provider, and billing data would sit partly outside C1.
Small TypeScript interface, a registry, and a capability flag per provider. We can still put an orchestration service behind one adapter later.
transaction for real-estate deals.Ruled out. Those tables model property deals, deposits and agreements, not memberships.
billing.*Less commonCleanest isolation, but the query engine and Objection models assume the public schema. Extra friction for every read.
c1_ prefix in the public schemaPickWhat you asked for, and the same pattern Better Auth uses with user_. It also avoids the reserved word order.
Works for one membership. Breaks for one-time course buys, free comps, grace periods and anything sold twice.
One cheap query, any source. The tenant template stays generic: each product just names the key it unlocks.
Twelve tables in six groups. Hover a table to see what it connects to; click it to jump to its columns. Every table carries project_id, because in C1 a project is the tenant, and its custom domain already decides which project a request belongs to.
bigint minor units (cents, paise) plus a char(3) currency. Never floats.timestamptz everywhere, plus created_at and updated_at on every table.audit_log. No extra history tables.Each status has one meaning and one way in. The worker moves a subscription only from a verified provider event or from C1's own clock, never from a browser redirect.
What is owed. One per purchase, one per renewal period.
One money movement. Status changes; amount and kind never do.
Six flows cover the MVP. Each step names who acts. The browser never decides that a payment worked: it only shows what the server has confirmed.
Each provider has its own decline codes. The adapter maps them to eight C1 codes, and each C1 code has one message and one action. The member never sees the word "fraud".
| C1 code | Stripe examples | Member sees | System does |
|---|---|---|---|
| insufficient_funds | insufficient_funds | "Your bank declined this payment. We'll try again in 2 days, or you can use another card." | Retry on schedule |
| processing_error | processing_error | "Something went wrong at the bank. We'll try again automatically." | Retry soon |
| authentication_required | authentication_required | "Your bank needs you to confirm this payment." Button: Confirm Payment | Send confirm link; no blind retry |
| card_expired | expired_card | "Your card has expired. Add a new one to keep your membership." | Stop retries until card changes |
| incorrect_details | incorrect_cvc, incorrect_number | "The card details don't match. Please enter them again." | Stop retries until card changes |
| limit_exceeded | card_velocity_exceeded | "This card has reached a limit. Try another card or contact your bank." | One retry, then wait |
| card_declined | generic_decline, do_not_honor | "Your bank declined this card. Try another card or contact your bank." | One retry, then wait |
| fraud_suspected | fraudulent, stolen_card, lost_card | Same message as card_declined | Stop retries; flag in admin |
While a subscription is past_due, access continues until grace_until (proposed: 7 days, a tenant setting). The member sees an attention item on their avatar that can't be dismissed until the payment goes through, matching the Account design from the portal mockups.
Paths follow the C1 pattern /project/:project_id/…. Admin lists don't get new routes: they are query-engine declarations (c1_orders, c1_subscriptions, c1_transactions), run through the existing POST /project/:project_id/queries/:name.
| Route | What it does | Guard |
|---|
billing.apply_event: apply one inbox rowbilling.renew: hourly, C1-clock renewals and pre-debit noticesbilling.retry: C1-clock retries on days 1, 3 and 5billing.expire_grace: hourly, ends access after gracebilling.reconcile: nightly, compare with each providerCheckout, refund and cancel calls take an Idempotency-Key header, stored in a unique column. A double tap returns the same order instead of charging twice. The same key is passed on to the provider.
The webhook route reads the raw body, so it mounts before the Express body parsers, the same way the Better Auth handler does. It answers 200 as soon as the event is stored, then queues the work.
Five member screens make the MVP, in the Sanctuary style you picked. Prices are placeholders. Admin screens reuse the existing dashboard and query views, listed below the phones.
c1_price. Tap sends POST /billing/checkout, then the provider's page.Your membership is active. Meditation is now open.
GET /me/billing. Card on file and receipts live here.Stays here until paid.
You keep full access until 9 Dec 2026. You won't be charged again.
/join: plans, public/join/return: confirming, then welcome/meditation: gated by entitlement/account/membership/account/payment: card on file/account/receiptsThe prefix for the login-only member area needs Evgeny's agreement, because it touches the router and login.
billing/plans: products and pricesbilling/members: subscriptions listbilling/orders: order detail with Refundbilling/providers: connect, test, copy webhook URLbilling/events: inbox with ReplayWithout the meditation entitlement, Meditation shows its cards with a Join banner on top, the same Live Sessions banner pattern from the portal mockups. Nothing plays until the member joins.
Every provider implements one interface. The registry maps provider to its adapter. The core only uses this interface and the canonical event names.
// api/src/modules/c1_billing/providers/provider.types.ts export interface IPaymentAdapter { provider: PAYMENT_PROVIDER; capabilities: { clock: 'provider' | 'c1'; // who triggers renewals partialRefunds: boolean; saveCardWithoutCharge: boolean; }; ensureCustomer(ctx, customer): Promise<string>; syncPrice(ctx, price): Promise<string>; startCheckout(ctx, input): Promise<ICheckoutAction>; startCardSetup(ctx, input): Promise<ICheckoutAction>; cancelSubscription(ctx, input): Promise<void>; refund(ctx, input): Promise<IProviderRefund>; // clock 'c1' only chargeMandate?(ctx, input): Promise<IProviderPayment>; notifyPreDebit?(ctx, input): Promise<void>; // inbound verifyWebhook(ctx, raw: Buffer, headers): IProviderEvent; // throws toCanonical(event): ICanonicalEvent[]; fetchSubscription(ctx, externalId): Promise<IProviderSubscription>; declineCode(providerCode: string): DECLINE_CODE; } // providers/index.ts: the only place a provider is registered export const PAYMENT_ADAPTERS: Record<PAYMENT_PROVIDER, IPaymentAdapter> = { stripe, razorpay, revolut, payu };
shared/modules/c1_billing/ c1_billing.schemas.ts Zod triads and enums for all 12 tables c1_billing.utils.ts pure: status transitions, decline mapping, refund caps c1_*.entity.ts query-engine entities (orders, subscriptions, transactions…) c1_billing.queries.ts admin lists c1_billing.settings.ts grace_days, retry_days api/src/modules/c1_billing/ c1_billing.routes.ts · c1_billing.controllers.ts · c1_billing.service.ts c1_*.model.ts Objection models, @Auditable c1_billing_webhook.routes.ts · c1_billing.jobs.ts providers/{stripe,razorpay,revolut,payu}/adapter.ts · providers/index.ts api/db/migrations/ <ts>_create_c1_billing_tables.js
The MVP is Phases 0 to 2 on Stripe alone. The adapter contract is there from day one, so Phase 3 adds providers without touching the core.
discount_minor column is ready. Add a coupon table when there is a first campaign.c1_order_item when the Retreat Shop needs several items.The design handles each answer. These only change seed data and settings.
Retreats run through a separate entity and Stripe account. Each becomes a merchant account; we need to know which one sells Meditation.
USD only at launch, or INR through Razorpay for members in India too?
How long a member keeps access after a failed payment. Proposed: 7 days.
Envelope-encrypted columns in Postgres, or a secret manager on the C1 servers?
Supported through trial_days. Do we offer one at launch?
End access as soon as a dispute opens, or wait for the outcome?
Tax-inclusive prices for the MVP, or do we need VAT handling for EU members from day one?
Grace and retry days become project settings. Their place in the settings UI needs a quick confirm.