Skip to content

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, default gen_random_uuid()).
  • companyId (uuid, unique, FK → companies.company.id) — one subscription per company.
  • plan (enum subscription_plan: free, starter, business, pro, enterprise).
  • status (enum subscription_status: trialing, active, past_due, cancelled).
  • scheduledPlan (nullable enum subscription_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

  • companyId is NOT NULL and UNIQUE → at most one companySubscription row per company (enforced in tktspace-backend/libs/shared/data-access-db/src/lib/schema/companies.schema.ts).
  • companyId ON DELETE CASCADE — subscription disappears with the company (enforced in tktspace-backend/libs/shared/data-access-db/src/lib/schema/companies.schema.ts).
  • plan defaults to free; status defaults to trialing; autoRenew defaults to true (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 by plan alone. plan=pro, status=cancelled is a non-paying customer.
  • scheduledPlan represents a queued downgrade — it is never the active plan and never directly affects authorisation. The cron at period rollover swaps plan ← scheduledPlan, then clears scheduledPlan.
  • autoRenew=true ⇒ the platform attempts a charge against the saved card token (monoWalletId, renewalGateway) when currentPeriodEndsAt arrives. A failed charge sets renewalFailedAt and transitions the row to past_due.
  • trialEndsAt = NULL or currentPeriodEndsAt = NULL means 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.

SourceWhat it doesWhen
BillingAdminController.checkoutStart / upgrade a subscription. Initiates a gateway payment; the row reaches active only after the gateway webhook lands.User-initiated
BillingAdminController.cancelRenewalSet autoRenew=false. Does NOT immediately cancel — the paid period continues.User-initiated
BillingAdminController.webhookGateway 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) — companyIdcompanies.company.id, on-delete cascade. Unique (1:1).
  • Subscription payment (ENT-002) — referenced indirectly via companyId; payments record each renewal attempt.

API surfaces

SurfaceExposedNotes
clientno
businessyes — billing-admin module routes under /api/business/*Swagger UI
super-adminno

Known gotchas / open questions

  • The companies Drizzle schema file holds both core company-management tables and billing tables (companySubscriptions, subscriptionPayments). The split into a separate billing context is a docs/domain-level decision, not a file-level one.
  • renewalGateway is plain text, 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 transitions trialing → active and past_due → active — direct UPDATEs from the admin controller never set status to active. 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 /checkout call starts a new lifecycle.
  • OPEN: the business OpenAPI contract (as of audit date) does not yet expose explicit companySubscription schema names — the surface listing is inferred from the existence of libs/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 status transitions 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 renewalGateway to an enum to align with paymentSettings.platform. Loose typing here masks integration mistakes.
  • Add an endedAt timestamp that records when the company actually loses access (typically currentPeriodEndsAt of 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 checkout service / the gateway config; a constant in billing.constants.ts (or a column on plan) would centralise it.
  • Document the retry policy for past_due. The two cron jobs (handleRenewalsDue and handleFailedRenewalRetries) embed timing and attempt counts in code — promote them to a constants block or a per-plan config.
  • Centralise the (plan, status) → granted features decision behind a single guard module so feature gates across the codebase don’t drift.
  • Idempotency on the webhook. See Payment — same recommendation about externalId UNIQUE applies.