Payments — Domain Spec
The shape of paid functionality in Tomoda: how subscription state is
modelled, where it comes from, and the rules that hold today.
Mental model
Two shapes:
- Single subscription via Stripe. Monthly or yearly. No tier
ladder.
- Stripe is the source of truth. The
users row mirrors the
minimum we need (subscription_status, subscription_plan,
subscription_end, stripe_customer_id). We don't run our own
billing engine.
Pricing
| Plan |
Price |
| Monthly |
$29.00 / month |
| Yearly |
$290.00 / year |
Subscription states (mirror of Stripe)
| State |
Meaning |
App behaviour |
active |
Currently subscribed |
Paid features unlocked |
trialing |
In trial (if trials are ever enabled) |
Paid features unlocked |
past_due |
Payment failed but subscription not yet cancelled |
Paid features unlocked (grace) |
canceled |
Cancelled (active until subscription_end) |
Paid features unlocked through subscription_end |
| (no Stripe customer) |
Never subscribed |
Free tier |
Invariants
| Invariant |
Why |
| Stripe is the source of truth |
We mirror, we never write status without a webhook |
| Webhook signature verification is mandatory |
HandleWebhook returns an error before touching DB on bad signature |
| Lazy customer creation |
A stripe.Customer is created only on first CreateCheckoutSession |
| Portal session requires existing customer |
CreatePortalSession errors for non-subscribers |
subscription_end is the user-facing horizon |
The app gates paid features by comparing now to subscription_end, not by string-matching status |
| Zero-timestamp safeguard |
Test fixtures with CurrentPeriodEnd = 0 are normalised to now + 1 month |
Lifecycle (prose)
Subscription lifecycle is driven entirely by Stripe webhooks. A user
starts in the "no Stripe customer" state. On CreateCheckoutSession
a Stripe customer is provisioned and the user is sent to Stripe's
hosted checkout. On payment success, Stripe sends
customer.subscription.created, the backend writes
subscription_status = active, subscription_plan = monthly|yearly,
subscription_end = current_period_end. Subsequent updates
(customer.subscription.updated, customer.subscription.deleted)
keep the mirror in sync.
On cancellation, Stripe keeps the subscription active (or sets it
to canceled immediately, depending on the cancellation mode) until
current_period_end. Paid features remain unlocked through that
horizon and lock at next-period boundary.
Cross-domain interactions
| Other domain |
Interaction |
| Identity |
Subscription fields live on the users row; Stripe customer ID is set lazily |
Privacy / visibility
| Field |
Visibility |
stripe_customer_id |
Self only (never exposed in any API response) |
subscription_status, subscription_plan, subscription_end |
Self only |
See also