Tranquillity on C1 · MVP Plan

Meditation Membership, From Join Button to Renewal

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.

12new c1_ tables
4providers, one contract
5member screens
0card numbers stored
Meditation artwork
Meditation MembershipThe first product on the new billing system. Built so any tenant can sell its own membership.
01

The Idea in One Picture

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.

Member PortalJoin · Account · Gated content Admin DashboardPlans · Members · Refunds Email + AttentionReceipts · "Update your card" C1 API · EXPRESS · c1_billing MODULE RoutesMember · Admin · Public plans Billing CoreOrders · Subscriptions · Access Postgres12 c1_ tables Adapter RegistryOne contract, many providers Webhook InboxVerify · store once · 200 OK Worker (BullMQ)Apply · renew · reconcile PAYMENT PROVIDERS StripeProvider clock RazorpayProvider clock · India RevolutProvider clock · EU/UK PayUC1 clock · India mandates + Next provider: one folder API calls signedwebhooks ask apply notices
02

Four Rules That Hold It Together

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.

1

Money only moves against an order

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.

2

Access reads entitlements, never payments

The portal asks: does this member hold meditation right now? Subscriptions, purchases and free grants all answer through the same table.

3

Provider messages are verified, stored once, then applied

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.

4

A new provider never touches the core

Adding PayU or the next one is one adapter folder and one enum value. The core only knows canonical events like payment.failed.

03

Decisions We Weighed

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.

Who runs the renewal clock?

Something has to decide "charge this member today".
Each provider's own subscription engineCommon

Least code. But PayU India makes the merchant trigger each standing-instruction debit, so this alone fails for PayU.

C1 runs every renewal itself

One behaviour everywhere, but we would rebuild retries, 3DS re-checks and India mandate rules that Stripe, Razorpay and Revolut already handle.

C1 owns the record; each adapter declares the clockPick

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.

How do we unify four payment APIs?

The system must stay pluggable for the next provider.
One integration per provider, each with its own tablesCommon

Fast for the first one. Every new provider then changes the schema and the core.

A payments orchestration service, such as Hyperswitch (open source)Less common

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.

One internal adapter contract plus canonical eventsPick

Small TypeScript interface, a registry, and a capability flag per provider. We can still put an orchestration service behind one adapter later.

Where do the new tables live?

C1 already has transaction for real-estate deals.
Reuse the existing transaction system

Ruled out. Those tables model property deals, deposits and agreements, not memberships.

A separate Postgres schema, billing.*Less common

Cleanest isolation, but the query engine and Objection models assume the public schema. Extra friction for every read.

c1_ prefix in the public schemaPick

What you asked for, and the same pattern Better Auth uses with user_. It also avoids the reserved word order.

How does the portal decide who gets in?

Meditation today; courses, retreats and gifts later.
Check subscription status directlyCommon

Works for one membership. Breaks for one-time course buys, free comps, grace periods and anything sold twice.

Entitlement rows with a key and a time windowPick

One cheap query, any source. The tenant template stays generic: each product just names the key it unlocks.

04

The New Database

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.

Idsuuidv7, like the Better Auth tables. Time-ordered, safe to show in URLs.
Moneybigint minor units (cents, paise) plus a char(3) currency. Never floats.
Timetimestamptz everywhere, plus created_at and updated_at on every table.
HistoryStatus changes go through the existing audit_log. No extra history tables.
05

Lifecycles

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.

SUBSCRIPTION incomplete trialing active past_due expired canceled card saved, trial first payment succeeds trial ends, paid charge fails recovered grace over member or admin cancels cancel while past due

Order

What is owed. One per purchase, one per renewal period.

open→requires_action→paid→partially_refunded→refunded
open→failedretries used upvoidabandoned checkout

Transaction

One money movement. Status changes; amount and kind never do.

pending→requires_action→succeeded
pending→failedwith decline_codecanceled
kind:chargerefundchargebackchargeback_reversal
06

How Each Flow Works

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.

07

Card Declines, in Plain Words

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 codeStripe examplesMember seesSystem does
insufficient_fundsinsufficient_funds"Your bank declined this payment. We'll try again in 2 days, or you can use another card."Retry on schedule
processing_errorprocessing_error"Something went wrong at the bank. We'll try again automatically."Retry soon
authentication_requiredauthentication_required"Your bank needs you to confirm this payment." Button: Confirm PaymentSend confirm link; no blind retry
card_expiredexpired_card"Your card has expired. Add a new one to keep your membership."Stop retries until card changes
incorrect_detailsincorrect_cvc, incorrect_number"The card details don't match. Please enter them again."Stop retries until card changes
limit_exceededcard_velocity_exceeded"This card has reached a limit. Try another card or contact your bank."One retry, then wait
card_declinedgeneric_decline, do_not_honor"Your bank declined this card. Try another card or contact your bank."One retry, then wait
fraud_suspectedfraudulent, stolen_card, lost_cardSame message as card_declinedStop 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.

08

Backend Routes

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.

RouteWhat it doesGuard

Background Jobs

  • billing.apply_event: apply one inbox row
  • billing.renew: hourly, C1-clock renewals and pre-debit notices
  • billing.retry: C1-clock retries on days 1, 3 and 5
  • billing.expire_grace: hourly, ends access after grace
  • billing.reconcile: nightly, compare with each provider

Idempotency

Checkout, 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.

Webhook Mount

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.

09

Mockup Screens

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.

9:41●●●
‹ Home
Meditation Membership
174 guided meditations
MonthlyYearly
$19 per month
Every guided meditation
Your own playlists
Cancel any time
Join for $19 a Month
1 · JoinPlans from c1_price. Tap sends POST /billing/checkout, then the provider's page.
9:41●●●
✓
You're In

Your membership is active. Meditation is now open.

PlanMonthly
Next payment9 Dec 2026
ReceiptTQ-000123
Start Listening
2 · ConfirmedShows "Confirming…" while it polls the order, then this once the webhook lands.
9:41●●●
‹ Account
Membership
MeditationActive
Next payment9 Dec · $19
CardVisa •••• 4242
Change Card›
Receipts›
Switch to YearlyLater
Cancel Membership›
3 · Account · MembershipOne call: GET /me/billing. Card on file and receipts live here.
9:41●●●
‹ Account
Needs Your Attention
Payment didn't go throughYour bank declined the card ending 4242. Update it by 17 Nov to keep Meditation open.
Amount$19.00
Next try12 Nov
Access until17 Nov

Stays here until paid.

Update Card
4 · Card DeclinedAn attention item that can't be cleared. Message comes from the decline code.
9:41●●●
‹ Membership
Cancel Membership?

You keep full access until 9 Dec 2026. You won't be charged again.

Too expensive
Not using it enough
Something else
Cancel at End of Period
Keep My Membership
5 · CancelEnds at period end by default. A Resume button shows until then.

Member Routes (App)

  • /join: plans, public
  • /join/return: confirming, then welcome
  • /meditation: gated by entitlement
  • /account/membership
  • /account/payment: card on file
  • /account/receipts

The prefix for the login-only member area needs Evgeny's agreement, because it touches the router and login.

Admin Routes (Dashboard)

  • billing/plans: products and prices
  • billing/members: subscriptions list
  • billing/orders: order detail with Refund
  • billing/providers: connect, test, copy webhook URL
  • billing/events: inbox with Replay

Locked State

Without 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.

10

The Provider Adapter

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
};
ProviderClockRecurring via
StripeProviderBilling subscriptions; India cards through Stripe's e-mandate support
RazorpayProviderPlans and Subscriptions; retries, then halted
RevolutProviderMerchant API plans with cycle phases; setup order on the hosted page
PayUC1Standing instructions and e-mandates; C1 sends each debit and the pre-debit notice

Canonical events: checkout.completed · payment.succeeded · payment.failed · payment.requires_action · subscription.updated · subscription.canceled · refund.succeeded · refund.failed · dispute.opened · dispute.closed · payment_method.updated

PayU and Revolut field-level details get confirmed in the Phase 0 spike.

Module Layout

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
11

Build Plan

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.

MVP
Phase 0

Decide and Spike

  • Answer the open questions below
  • Stripe test account for the selling entity
  • Confirm PayU and Revolut recurring details
  • Agree the member-area URL with Evgeny
Phase 1

Foundation

  • Migration for the 12 tables
  • Schemas, models, entities
  • Adapter interface, registry, Stripe adapter
  • Webhook inbox, worker, replay
  • Tests: duplicate webhook applies once; late event can't roll back; refund can't exceed charge
Phase 2

Member Flow and Admin

  • Join, Confirmed, Membership, Card Declined, Cancel
  • Entitlement gate on Meditation
  • Admin plans, members, orders, refund, comps
  • E2E: Stripe test cards 4242 (works), 0002 (declined), 3184 (3DS)
Phase 3

More Providers

  • Razorpay, then Revolut (provider clock)
  • PayU with C1 clock, pre-debit notices, renewal job
  • Nightly reconcile job
  • Second tenant seeded to prove the template

Left Out on Purpose

Coupons and discountsThe discount_minor column is ready. Add a coupon table when there is a first campaign.
Carts and order itemsOrders hold one price. Add c1_order_item when the Retreat Shop needs several items.
Tax enginePrices are tax-inclusive for the MVP. Stripe Tax or similar comes later.
Plan switching and prorationMonthly to Yearly shows "Later". It needs per-provider proration rules.
PauseNot in the status list until a tenant asks for it.
C1-made invoicesReceipts link to the provider's receipt. Our own PDFs come later.
12

Open Questions

The design handles each answer. These only change seed data and settings.

Which legal entity sells the membership?

Retreats run through a separate entity and Stripe account. Each becomes a merchant account; we need to know which one sells Meditation.

Nikki, Evgeny
Prices and currencies

USD only at launch, or INR through Razorpay for members in India too?

Nikki
Grace period

How long a member keeps access after a failed payment. Proposed: 7 days.

Nikki
Where provider secrets live

Envelope-encrypted columns in Postgres, or a secret manager on the C1 servers?

Evgeny
Free trial

Supported through trial_days. Do we offer one at launch?

Nikki
Chargeback policy

End access as soon as a dispute opens, or wait for the outcome?

Nikki
Tax and VAT

Tax-inclusive prices for the MVP, or do we need VAT handling for EU members from day one?

Nikki, accountant
Settings placement

Grace and retry days become project settings. Their place in the settings UI needs a quick confirm.

Evgeny