Skip to content

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:

  1. Single subscription via Stripe. Monthly or yearly. No tier ladder.
  2. 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