Company subscription
Purpose
The platform’s billing relationship with a tenant. One row per Company, representing their current plan, billing state, period boundaries, and auto-renewal configuration. The SaaS revenue model is keyed off this row — the existence and status here determine whether the company can use the platform’s paid features.
Identity & key fields
- Primary key:
id(uuid, defaultgen_random_uuid()). companyId(uuid, unique, FK →companies.company.id) — one subscription per company.plan(enumsubscription_plan:free,starter,business,pro,enterprise).status(enumsubscription_status:trialing,active,past_due,cancelled).scheduledPlan(nullable enumsubscription_plan) — downgrade scheduled for end of current period.monoWalletId,renewalGateway,autoRenew,renewalFailedAt— auto-renewal bookkeeping (saved card token via Monobank).trialEndsAt,currentPeriodEndsAt(timestamps) — null means indefinite.
(plan, status) together drive access to paid features — plan alone is insufficient (e.g. plan=pro, status=cancelled must NOT grant pro features). scheduledPlan is a queued downgrade applied later by cron, not the active plan. Auto-renewal state lives in autoRenew + monoWalletId + renewalGateway + renewalFailedAt.
Invariants
companyIdis NOT NULL and UNIQUE → at most onecompanySubscriptionrow percompany(enforced in tktspace-backend/libs/shared/data-access-db/src/lib/schema/companies.schema.ts).companyIdON DELETE CASCADE — subscription disappears with the company (enforced in tktspace-backend/libs/shared/data-access-db/src/lib/schema/companies.schema.ts).plandefaults tofree;statusdefaults totrialing;autoRenewdefaults totrue(enforced in tktspace-backend/libs/shared/data-access-db/src/lib/schema/companies.schema.ts).
Business invariants:
- Exactly one row per Company — UNIQUE on
companyId. There is no history table; lifecycle past states are not preserved here. - Access to paid features is granted by the pair
(plan, status)— never byplanalone.plan=pro, status=cancelledis a non-paying customer. scheduledPlanrepresents a queued downgrade — it is never the active plan and never directly affects authorisation. The cron at period rollover swapsplan ← scheduledPlan, then clearsscheduledPlan.autoRenew=true⇒ the platform attempts a charge against the saved card token (monoWalletId,renewalGateway) whencurrentPeriodEndsAtarrives. A failed charge setsrenewalFailedAtand transitions the row topast_due.trialEndsAt = NULLorcurrentPeriodEndsAt = NULLmeans indefinite — no time limit (currently only meaningful for special accounts).
Lifecycle
Status enum values: trialing, active, past_due, cancelled.
created → trialing (default on company signup; plan=free, status=trialing)trialing → active (first successful payment via /checkout webhook)active → past_due (cron: currentPeriodEndsAt passed AND auto-renew charge failed → renewalFailedAt set)past_due → active (successful retry)active|past_due → cancelled (explicit user/admin cancel, or exhausted retry attempts)cancelled → active (new subscription via /checkout; not via UPDATE on the same row)
scheduledPlan rollover (daily cron 2AM): plan ← scheduledPlan, scheduledPlan ← NULL (when currentPeriodEndsAt passes)Mutation sources (verified against code)
This row is mutated by three distinct sources, each with a specific concern. They are not interchangeable — knowing which source moved a field is required for debugging the state machine.
| Source | What it does | When |
|---|---|---|
BillingAdminController.checkout | Start / upgrade a subscription. Initiates a gateway payment; the row reaches active only after the gateway webhook lands. | User-initiated |
BillingAdminController.cancelRenewal | Set autoRenew=false. Does NOT immediately cancel — the paid period continues. | User-initiated |
BillingAdminController.webhook | Gateway callback after a payment attempt. Activates on success; on failure, moves to past_due and sets renewalFailedAt. | Gateway-initiated |
BillingScheduler.handleRenewalsDue (cron EVERY_HOUR) | Picks up rows whose currentPeriodEndsAt is due and autoRenew=true, attempts charge via Mono wallet token. | Time-driven |
BillingScheduler.handleFailedRenewalRetries (cron EVERY_HOUR) | Picks up rows with renewalFailedAt set and retries the charge. | Time-driven |
BillingScheduler.handleScheduledDowngrades (cron EVERY_DAY_AT_2AM) | Picks up rows whose currentPeriodEndsAt has passed AND scheduledPlan is set; swaps plan ← scheduledPlan and clears scheduledPlan. | Time-driven |
Files: libs/features/billing/src/lib/controllers/billing-admin.controller.ts, libs/features/billing/src/lib/services/billing.scheduler.ts.
Relationships
- Company (ENT-016) —
companyId→companies.company.id, on-delete cascade. Unique (1:1). - Subscription payment (ENT-002) — referenced indirectly via
companyId; payments record each renewal attempt.
API surfaces
| Surface | Exposed | Notes |
|---|---|---|
| client | no | — |
| business | yes — billing-admin module routes under /api/business/* | Swagger UI |
| super-admin | no | — |
Known gotchas / open questions
- The
companiesDrizzle schema file holds both core company-management tables and billing tables (companySubscriptions,subscriptionPayments). The split into a separatebillingcontext is a docs/domain-level decision, not a file-level one. renewalGatewayis plaintext, not an enum — accepted values are implied by service code, not by the type system. Adding a new gateway requires updates in both the renewal cron and the webhook handler.- The webhook endpoint (
POST /api/business/billing/webhook) is the only path that transitionstrialing → activeandpast_due → active— direct UPDATEs from the admin controller never set status toactive. If a webhook is missed, the subscription stays unactivated even after payment succeeds at the gateway. - A cancelled subscription cannot be reactivated by UPDATE — the cancellation creates a terminal record. A fresh
/checkoutcall starts a new lifecycle. - OPEN: the business OpenAPI contract (as of audit date) does not yet expose explicit
companySubscriptionschema names — the surface listing is inferred from the existence oflibs/features/billing/src/lib/billing-admin.module.ts. Confirm contract surface coverage.
Recommendations
Forward-looking improvements suggested while filling this doc — not currently in place.
- Model the state machine explicitly. Today the
statustransitions are scattered across the controller, webhook handler, and three cron methods. A typed state-machine helper (or DB CHECK constraints on allowed transitions) would make the rules enforceable and prevent illegal hops (e.g.cancelled → past_due). - Promote
renewalGatewayto an enum to align withpaymentSettings.platform. Loose typing here masks integration mistakes. - Add an
endedAttimestamp that records when the company actually loses access (typicallycurrentPeriodEndsAtof the last paid period). Distinguishes “cancelled but still inside paid window” from “fully ended” — currently the consumer must compute this. - Document trial duration in one place. Today it is determined by the
checkoutservice / the gateway config; a constant inbilling.constants.ts(or a column onplan) would centralise it. - Document the retry policy for
past_due. The two cron jobs (handleRenewalsDueandhandleFailedRenewalRetries) embed timing and attempt counts in code — promote them to a constants block or a per-plan config. - Centralise the
(plan, status) → granted featuresdecision behind a single guard module so feature gates across the codebase don’t drift. - Idempotency on the webhook. See Payment — same recommendation about
externalIdUNIQUE applies.