@softure-ai/billing
v0.10.0
Published
SOFTURE AI billing module: trial, paid and read-only entitlements from a pure state machine, billing.entitlements apart from auth.users, a write guard for server actions, access badge and notice components with date and day-count formatters, a privacy con
Readme
@softure-ai/billing
Depends on: core, db, ui, security, auth (mailing optional, for @softure-ai/billing/mailing; jobs optional, for
@softure-ai/billing/jobs).
Decides whether an account may still write: a trial every account starts with, paid access (dated
or lifetime) and a read-only state once both end. It replaces FIRE_TRACKER's access logic
(src/lib/access.ts, src/db/access.ts, src/components/{access-badge,access-notice*}.tsx), with
the paid_until and trial_ends_at columns moved off the users table into billing.entitlements
and the hand-written guard replaced by a pure state machine. FIRE's hard-coded prices and its access
script become plans in the config, a payment page and an admin page that grants a plan. Accounts
that exist when billing is turned on keep their access through a trial floor (trial.startsAt), an
import of what the old system knew (import-entitlements) and a pin step for derived trials
(pin-trials); see "Existing accounts" in §4.
1. What it provides
- A pure state machine (
resolveEntitlement,applyEntitlementEventfrom the root entry): a record (trial end, paid until, lifetime) and an instant givetrial | paid | read_only, with the days left and whether the reminder window is open; an event (grant,grant_lifetime,revoke,shorten,end_lifetime,extend_trial,import) gives the next record. No database and no clock. billing.entitlements, at most one row per account, apart fromauth.users. An account without a row is on the trial that starts at itsauth.users.created_at(see §5).- The write guard:
requireWriteAccess()(/next) for server actions,checkWriteAccess()(/server) for other hosts. getEntitlement()andgetCurrentEntitlement()to read where an account stands, andchangeEntitlement(), the one write path (grants, revokes, trial extensions) the payment adapters build on.AccessBadgeandAccessNotice(/ui), standalone with slots,unstyledand messages, andCurrentAccessBadge/CurrentAccessNotice(/next) wired to the signed-in account, with an "Unlimited access" reading for very long access, a compact label and the app's tones, plus the date and day-count formatters they use, for the app's own sentences (see §9).- Plans in the config (
billing({ plans })): name, description, price in the currency's minor unit, period (days, weeks, months, years or lifetime), feature lines;formatPriceand the period copy follow the app's locale. PricingTiles(/ui) andPricing(/next, wired to the config), aPaymentPageto mount atroutes.payment, and aBillingAdminPageatroutes.adminwhere an admin works the open invoice requests (grant or dismiss each), grants a plan by email, extends a trial for free (never written as paid access), and looks up an account's history of grants, trial extensions and payments with a revoke button on each manual grant. Both pages render an<h1>first in their<main>(see "Page headings" in §4).- A
PaymentProviderinterface and its first adapter,manual({ onRequest }): the buyer requests an invoice, the app hands the request to its owner (a mail, a ticket), and the owner grants the plan once it is paid. The request is stored inbilling.payment_requestsbefore it is handed over, reaches the owner once however often the buyer asks again, and stays until the admin grants or dismisses it or it expires (expireStaleRequests).grantPlanManually()grants and records a plan inbilling.manual_grants;revokeManualGrant()takes back only what one grant added. stripe(), a card, BLIK and transfer adapter on Stripe Checkout (one-time payments), andstripeWebhookRoute(/next): a verified Stripe webhook that grants a paid checkout's plan and, on a refund, takes back what that one payment granted (a partial refund its share, by thepartialRefundspolicy), exactly once per payment, recorded inbilling.payments, and gives it back when the refund fails (see "Refunds" and "Failed refunds" below).- Reminder mail (
@softure-ai/billing/mailing):sendAccessReminders(ctx)mails every account whose trial or dated paid access is in its reminder window, or ended in the last few days, once per account and window through@softure-ai/mailing's delivery ledger; the app runs it on a schedule (see "Reminder mail" in §4), with the package's mail or the app's template (buildMail) and delivery scopes (getScope), as onerunDeliveriesrun (dryRun,limit,retakeUncertain,onDelivery).findAccessReminders(/server) is the same list without mail, for an app that sends its own. - Jobs (
@softure-ai/billing/jobs):billingJobHandlersandbillingJobSchedulesrun the reminder mail and the invoice request expiry as daily@softure-ai/jobsschedules instead of an app cron, with retries after a mail outage (see "Scheduled runs" in §4). - Plan and trial scripts (
@softure-ai/billing/scripts):grant-plan,grant-lifetime,revoke-grantandextend-trial, ops scripts (dry run by default,--commitwrites) for a host without the admin page; their grants and extensions are in the account's history like the admin page's; and the read-onlyentitlement-status(see "Scripts" in §4). - Existing accounts:
trial.startsAtgives accounts created before a chosen day a trial from that day;import-entitlements(importEntitlement()on the server) records the trial ends, paid periods and lifetime access another system knew, never shortening access, or with--exact(mode: "replace") exactly as it knew them for accounts billing has no row of yet;pin-trials(pinDerivedTrials()) writes every derived trial into a row before a config change would move it (see "Existing accounts" in §4). - Usage limits for a metered resource (AI calls a month, exports a month): limits per meter in
trial.limits,paid.limits,readOnly.limitsand each plan'slimits;getLimits()answers an account's state, the plan paying for today and its limits;consumeUsage()counts usage per calendar month inbilling.usageand refuses atomically once the limit is used up (see "Usage limits" in §4). - Entitlement state in SQL (migration
0012):billing.entitlement_states(...)andbilling.entitlement_state(user_id, ...)answer whatgetEntitlement()answers, for an app's own SQL (recipient selection, reports);getEntitlementStatesSql(ctx)(/server) fills in the app's trial policy (see "Entitlement state in SQL" in §5). - Test fixtures (
@softure-ai/billing/testing):createTestEntitlement()writes an account's row andsetTestEntitlementState()puts it in a named state at the clock's instant, so tests never insert intobilling.entitlementsby hand (see "Test fixtures" in §5). - Export and deletion of the entitlement row, the payments, the invoice requests, the manual grants, the trial extensions and the usage ledger (
@softure-ai/privacy), and a health check forGET /api/health.
2. Installation
npm install @softure-ai/billing @softure-ai/auth @softure-ai/security @softure-ai/core @softure-ai/db @softure-ai/ui drizzle-orm zodPeer dependencies: next 16, react 19, drizzle-orm; @softure-ai/mailing (optional) for
@softure-ai/billing/mailing; @softure-ai/jobs 0.1.1 or later (optional) for @softure-ai/billing/jobs. The
module depends on security and auth; a configuration without them fails at startup.
3. Configuration
import { billing, BILLING_RATE_LIMIT_BUCKETS, manual } from "@softure-ai/billing";
// in defineSoftureConfig({ timezone: "Europe/Warsaw", modules: [...] }):
security({ buckets: { ...AUTH_RATE_LIMIT_BUCKETS, ...BILLING_RATE_LIMIT_BUCKETS } }),
// ... auth(...), then:
billing({
trial: { days: 14, reminderDays: 3 },
paid: { reminderDays: 7 },
plans: [
{ id: "monthly", name: { en: "Monthly", de: "Monatlich" }, price: { amount: 2900, currency: "PLN" }, period: "month", features: [{ en: "Unlimited notes" }] },
{ id: "yearly", name: { en: "Yearly" }, price: { amount: 29000, currency: "PLN" }, period: "year", isFeatured: true },
{ id: "lifetime", name: { en: "Lifetime" }, price: { amount: 79000, currency: "PLN" }, period: "lifetime" },
],
payment: manual({ onRequest: async (request, ctx) => sendInvoiceRequestMail(request, ctx) }),
}),| Option | Type | Default | Meaning |
| --- | --- | --- | --- |
| trial.days | integer 0 to 365 | 14 | Length of the trial every account starts with, the registration day included. 0: no trial, an account is read-only until it pays. |
| trial.startsAt | YYYY-MM-DD | — | The first day a trial can start, a local day in config.timezone: an account without a row created before it gets its trial.days from this day (see "Existing accounts" in §4). A day in the future keeps those accounts writing until it, plus trial.days. |
| trial.reminderDays | integer 0 to 365 | 3 | From how many days left the trial counts as ending (badge tone, notice). 0: never. |
| paid.reminderDays | integer 0 to 365 | 7 | The same for dated paid access. Lifetime access never ends. |
| trial.limits, paid.limits, readOnly.limits | { [meter]: integer 0 to 1e9 or null }, at most 20 meters | {} | Usage limits per calendar month during the trial, of paid access (a meter its plan leaves out, or a paid account no recorded grant explains), and of a read-only account. null: no limit. See "Usage limits" in §4. |
| plans | array, at most 12 | [] | The plans, in the order the tiles show them (see below). |
| payment | PaymentProvider | — | The adapter the payment page uses: stripe() or manual({ onRequest }). The payment page throws without one. |
| partialRefunds | "pro_rata" or "keep_access" | "pro_rata" | What a partial provider refund does to access (see "Refunds" in §4): pro_rata takes back the refunded share of the payment's unused days, keep_access nothing until the whole payment is refunded. |
| requests.expireAfterDays | 1-365 | 30 | Days an open invoice request waits, counted from the buyer's last ask; expireStaleRequests (run daily, see "Scheduled runs" in §4) then closes it as expired and clears its invoice details. |
| onAccessChanged | function | — | Called after a grant, a refund or a trial extension committed (see "Hooks" in §10); a throw is logged and never fails the change. |
| adminRole | role | admin | The auth role that may grant plans in BillingAdminPage; declare any other in auth({ roles }). A role auth does not declare fails the first billing request and the readiness probe. |
| routes.payment | path | /payment | Where PaymentPage is mounted; the notice and the tiles link there, and Stripe Checkout returns there. |
| routes.admin | path | /admin/billing | Where BillingAdminPage is mounted; its actions revalidate it, and the account lookup sends the admin there with ?account=<id>. |
| routes.webhook | path | /api/billing/webhook | Where stripeWebhookRoute is mounted (the Stripe endpoint's URL). |
| messages | partial dictionaries per locale (en built in; any other is the app's own translation) | — | Copy overrides. |
A plan is { id, name, description?, price: { amount, currency }, period, features?, isFeatured?, limits? }:
id kebab-case and unique; name, description and each feature line a text per locale with at
least en; amount an integer in the currency's minor unit as billing pins it (2900 is 29.00 PLN,
1500 is ¥1,500, 1500 is ISK 1,500, 1250 is KWD 1.250, 2950 is HUF 29.50);
currency an upper-case ISO 4217 code billing knows; period "day", "week", "month", "year",
"lifetime" or { unit, count } such as { unit: "month", count: 3 }; limits the plan's usage
limits, as paid.limits (a meter it leaves out falls back to paid.limits). A plan has one currency; a
second currency is a second plan. Plans live in the config, not in a table: a price change is a
deploy, and a payment record (with the price paid) belongs to the provider.
Minor units. The digits of each currency's minor unit come from a pinned table
(CURRENCY_MINOR_UNIT_DIGITS, kept in @softure-ai/core and re-exported here; formatPrice is core's formatMoney): ISO 4217 List One of 2024-06-25 without funds and units that are not
prices, MGA counted without a minor unit (its subunit is a fifth, as Stripe counts it) and XCG added.
The runtime's Intl only supplies the notation (symbol, separators, where the sign goes), so a price
means the same amount on every Node build; Intl's own digits follow its CLDR data and have changed
between builds (HUF had 0 on some and 2 on others). A code outside the table (HRK, SLL, a typo) is
refused when the config loads. Where Intl on current runtimes counts other digits, an amount
written against Intl's unit must be converted: AFN, ALL, IRR, KPW, LAK, LBP, MMK, RSD, SOS, SYP and
YER have two decimals here (ALL 1,500 is 150000), IQD three (IQD 25,000 is 25000000), and HUF and
TWD two on every runtime.
A paid period runs in local calendar days like a trial, the start day included: a month granted on 3 October covers every day to 2 November and ends when 3 November begins. It starts when the access the account already has ends (a running trial or paid access), so paying early loses no day, and each grant adds one period. A month keeps the day of the month where it can: from 31 January it ends with 27 February (the 28th starts the next period), and renewals then continue from the 28th. A lifetime plan grants lifetime access; a dated grant to a lifetime account changes nothing.
Stripe. stripe({ secretKey?, apiBase?, fetch? }) reads STRIPE_SECRET_KEY on every payment
(so the config loads at build time without it) and creates one Checkout session per payment
(mode: "payment"): the plan's price as a one-off line item in its currency, the plan's name in the
app's locale, the buyer's email, and the account id and plan id in the session's and the
PaymentIntent's metadata (softure_user_id, softure_plan_id). The buyer returns to
routes.payment with ?checkout=success (the page thanks them; access follows the webhook, usually
within seconds) or ?plan=<id>&checkout=cancelled. Payment methods are the ones enabled in the
Stripe dashboard (cards, BLIK, Przelewy24, transfers). A refusal, a timeout or a missing key is
billing.payment_failed for the buyer and one log line (HTTP status and Stripe's error type and
code, never its message or the key). Subscriptions are not used: each payment buys one period, and
renewing is paying again, as with manual().
Stripe's currency units. Stripe takes amounts in its own unit per currency
(currency guide), which is not always billing's: ISK and UGX
have no decimals in ISO 4217 but two (always 00) at Stripe. Plans stay in billing's unit;
stripe() converts what it sends (ISK 1,500 goes as 150000) and the webhook converts
what Stripe reports back, so payments and refunds are stored and shown in the plan's unit. HUF and
TWD need nothing: Stripe's divisible-by-100 rule for them is for payouts, not charges. A price
stripe() cannot charge exactly is refused when the config loads, naming the plan: a three-decimal
amount (BHD, JOD, KWD, OMR, TND) whose last digit is not 0, or an amount finer than Stripe's unit
(IQD and LYD have three decimals in ISO 4217 and two at Stripe, so their amounts must end in 0). Stripe's minimum and maximum amounts depend on the account and the payment method, so
Stripe checks them at Checkout (billing.payment_failed and a log line).
Days and time zones. Trials end at the start of a local day in config.timezone: a 14-day
trial begun at any hour of 3 October ends when 17 October begins there, so 16 October is its last
day. Days left count local calendar days, today included (1 on the last day). Access covers every
instant before its end; at the end itself the account is read-only. Paid access wins over a trial;
a trial that outlasts paid access takes over again when the payment ends.
The root's getDayNumber, getStartOfDay and parseDay serve billing's periods; day arithmetic
elsewhere uses addCalendarDays, addCalendarMonths and calendarDaysBetween from @softure-ai/core,
whose month arithmetic (end of month clamped) billing's periods use too.
4. Mounting
Mount the payment page at routes.payment and the admin page at routes.admin, one line each:
// app/payment/page.tsx
export { PaymentPage as default } from "@softure-ai/billing/next";
// app/admin/billing/page.tsx
export { BillingAdminPage as default } from "@softure-ai/billing/next";
// app/api/billing/webhook/route.ts (with stripe(); public, outside any auth guard)
export { stripeWebhookRoute as POST } from "@softure-ai/billing/next";Page headings. Each page renders one <h1> as the first child of its <main>: the
payment.heading and admin.heading messages ("Payment", "Billing"). Pass heading to replace
it, or heading={null} when the app's own frame renders the page heading. lead adds one
paragraph right after the heading (first in <main> without one) that says what the page is for;
there is none by default:
// app/admin/billing/page.tsx, inside the app's frame that has its own <h1>
import { BillingAdminPage, type BillingAdminPageProps } from "@softure-ai/billing/next";
export default function Page(props: BillingAdminPageProps) {
return <BillingAdminPage {...props} heading={null} />;
}The Stripe webhook. In the Stripe dashboard, add an endpoint at <appOrigin>/api/billing/webhook
for checkout.session.completed, checkout.session.async_payment_succeeded, charge.refunded and
refund.failed, and put its signing secret in STRIPE_WEBHOOK_SECRET (locally: stripe listen --forward-to
localhost:3000/api/billing/webhook prints one). The route checks Stripe-Signature (HMAC-SHA256,
at most five minutes old, any v1 entry during a secret rotation) before it parses the body (at
most 256 KiB) or touches the database, then:
| Delivery | Effect | Answer |
| --- | --- | --- |
| a paid checkout (completed with payment_status paid or no_payment_required, or async_payment_succeeded) | the payment is stored and its plan granted, in one transaction | 200 |
| the same checkout again (a retry, or both events of a delayed payment) | nothing | 200 |
| a checkout still waiting for a transfer, a charge with nothing refunded, any other event, a session billing did not create | nothing | 200 |
| charge.refunded with refunded: true for a stored payment | the payment is marked refunded and what it granted taken back, once | 200 |
| charge.refunded with refunded: false for a stored payment | amount_refunded (the total so far) is recorded and access follows partialRefunds; a total not above the stored one (a retry, a late delivery) changes nothing; when that state is newer than every one recorded, it is kept for a late failure (see "Failed refunds") | 200 |
| refund.failed (or refund.updated / charge.refund.updated with status failed or canceled) for a stored payment | what the refund took is given back, once per refund (see "Failed refunds") | 200 |
| charge.refunded or a Refund event without the event's created | nothing | 400 |
| a paid checkout whose account was deleted or whose plan left the config | nothing stored; one log line with the checkout id to refund in Stripe | 200 |
| no or a wrong signature, a replay, a body that is not a Stripe event | nothing | 400 |
| no STRIPE_WEBHOOK_SECRET, a database failure | nothing; Stripe retries | 500 |
Refunds. Each payment row records what its grant added: a period (from where access ended, the
trial's end or the payment's instant, to the period's end) or lifetime access. A full refund takes
back only that, with the pure getRefundEvent (root entry):
- A period loses its unused days,
[max(from, now), until), counted in local days of the app's time zone: the dated end moves back by that many days. Access ahead of now is one unbroken run (every grant starts where running access ends), so the other stacked periods, manual grants and the trial keep their length. A period already used up takes nothing back, and an old payment refunded after a lapse never touches a newer period. The stored periods of the payments stacked after it move back by the same days, so a later refund of one of them takes back the right days. A dated end moved to the trial's end or before it drops paid access: the account is back on its trial. - A lifetime ends lifetime access unless another lifetime payment of the account is still
paidor an active manual lifetime grant (billing.manual_grants) still gives it. Lifetime keeps the dated end beside it (a grant on lifetime still extends it), so the months bought next to a refunded lifetime stay. - A payment stored before grants were recorded (no grant columns) revokes paid access, as before.
Partial refunds. A payment keeps the total refunded so far (refunded_amount, from Stripe's
cumulative amount_refunded) and stays paid until that total reaches its amount. Under
partialRefunds: "pro_rata" (the default) each partial refund takes back the share of the period's
unused days that the newly refunded money is of the money not refunded before, rounded down to
whole days (the account keeps a part of a day), and the payment's stored period ends that many days
earlier; the periods stacked after it move back too. Refunding 14.50 of a 29.00 month bought for 31
unused days takes back 15. The refund that completes the amount is a full refund, so partial refunds
that add up to the payment end exactly where one full refund at the time of the last one would.
Under "keep_access" (refunds as goodwill or compensation) a partial refund takes nothing back,
and the completing one takes back every unused day left. Either way a partial refund never ends a
lifetime nor revokes a payment stored before grants were recorded: only the completing refund does.
Failed refunds. A refund the bank or card refuses after Stripe reported it (refund.failed)
gives back what it took, once per refund id (billing.refund_failures):
- the payment's
refunded_amountdrops by the refund's amount, and a payment refunded in full ispaidagain; - a lifetime payment refunded in full gives lifetime access back;
- a period gets back days: each payment keeps the local days refunds took from it
(
taken_back_days), and a failure gives back the failed money's share of them underpro_rata(rounded down; the failure that leaves nothing refunded gives back the rest), all of them underkeep_access(only the completing refund took any). While the payment is still paid and its period still ahead, the days go back right after that period and the periods stacked after it move forward again, the refund in reverse. Otherwise (refunded in full, or the period used up) they are a grant at the end of the account's access, from the latest of the trial's end, dated access and now, and the payment's stored period becomes that grant; - a payment stored before grants were recorded gets its total and status back, not its access.
Stripe does not order events, so billing keeps the time of the newest charge state it recorded
(refunds_seen_at, the event's created). A failure of a refund created after that state, or failed
before it, was never counted: it is recorded and gives back nothing. A charge state taken before a
failure (a late retry of charge.refunded) still counts the failed refund; billing subtracts it, so
the delivery changes nothing. A lower amount_refunded on its own is never read as a failure.
A new refund can be reported before the failure of an earlier one: the bank refuses refund A, then
refund B's charge.refunded arrives with an amount_refunded that no longer counts A, so it is not
above what billing recorded. When such a state is newer than every one billing recorded, the
payment keeps it (pending_refunded_amount, pending_refunds_seen_at; only the newest) and the
delivery answers duplicate. Each failure billing records then gives back what the failed refund
took and applies the kept state if, less the failures it still counts, it reports more than billing
now counts: B is taken back once, by the partialRefunds policy (in full when it completes the
amount). A kept state waits through as many failures as it needs, and a state applied at or after
its time clears it.
The decision is made under the entitlement row's lock, so a lifetime bought at the same moment is either seen or granted after the refund.
There is no rate limit on the route: Stripe sends from a few addresses, and an unsigned request costs one HMAC.
PaymentPage needs a session (a visitor goes to the login page and back) and shows the account's
badge and the plans; with ?plan=<id> it shows the order and the provider's form: the invoice
details for manual(), one checkout button for a hosted provider. An account with lifetime access
sees that it has nothing left to pay for instead of the order (startPayment refuses it with
billing.lifetime_active). Show the plans anywhere else, e.g. a public pricing page, with
<Pricing LinkComponent={Link} />.
The admin page. BillingAdminPage answers "not found" to anyone without adminRole; every
action checks the role from the session again before reading its form. It has four cards:
- Invoice requests: the open requests, oldest first, with the account, the plan, when it was
asked for, the price it quoted and the invoice details (the latest ones: a buyer who asks again
refreshes them without a new hand-over, so the owner's mail may hold older ones). Grant applies the plan and closes the request in one
transaction (
grantPaymentRequest); Dismiss closes it without a grant; History opens the account's history. A request is granted or dismissed once: a second click finds it closed. - Grant access: a plan for the account with a given email, recorded like a request's grant.
The list ends with Lifetime access, free of charge, offered even when no declared plan is a
lifetime one (or none is declared): it grants lifetime access without a price through
grantLifetimeManually, so it is in the account's history and revoked there like any manual grant. An account with lifetime access is refused (billing.lifetime_active): a dated period under lifetime would be invisible. - Extend a trial: the account's email and the trial's new last day (a date field; the trial
ends when the next day begins in the app's time zone).
extendTrialActioncallsextendTrialManually(ctx, { userId, until, adminId })(/server), which moves the trial end later and records it inbilling.trial_extensionswith the end before and after. It never writespaid_until, so an app that counts paying accounts by it does not count a free extension (an invited user, a longer trial for an account). It works under paid access too (paid access still comes first; the longer trial shows once it ends) and on a trial that already ended. An end at or before now isbilling.end_not_in_future, one at or before the current trial endbilling.trial_not_extended, a malformed daybilling.day_invalid; a refusal writes nothing. - Account history: the email lookup sends the admin to
?account=<id>(no address in a URL), which shows the account's badge and its manual grants, trial extensions and provider payments, newest first, each with its price, the access it added and its state. Revoke on an active manual grant takes back only what it added, like a refund: a period loses its unused days and the periods stored after it (manual or paid) move back; a lifetime ends unless another active manual lifetime or a paid lifetime payment still gives it. Provider payments are refunded at the provider, not here.
Invoice requests. startPayment stores a manual request first, then claims its hand-over on
the row and calls onRequest; an open request is handed over once, and asking again only
refreshes its details, price and time. When onRequest answers an Err or throws, the claim is
released: the buyer sees billing.payment_failed, the admin page still lists the request, and the
next ask hands it over. A claim left without an answer (the process stopped while onRequest ran)
blocks other asks for a minute; the first ask after that hands the request over again. Invoice details are refused with a code per field:
billing.invoice_field_required, billing.invoice_field_too_long (the copy names the limit:
200, 32, 500) or billing.invoice_field_control_characters (line breaks, tabs and other control
characters, in every field, so a name cannot add lines to the owner's mail; the database refuses
them too). expireStaleRequests(ctx) (/server) closes requests nobody asked again for in
requests.expireAfterDays days; it returns { expired }, and a repeated run closes nothing new. Run it daily (see
"Scheduled runs" below).
Reminder mail. With mailing({ ... }) in the config, sendAccessReminders(ctx)
(@softure-ai/billing/mailing) mails the accounts due a reminder. Run it daily (see "Scheduled runs" below).
Each run mails the four states the notice shows: the trial or dated paid access ending (from
trial.reminderDays / paid.reminderDays days left, "ends on {date}") and ended ("has ended",
from the day it ended through catchUpDays days after it, default 3, so turning reminders on never
mails accounts that lapsed long ago; an account created without a trial gets no "trial ended" mail).
Lifetime access gets nothing. The mail is transactional (no unsubscribe footer: it is an account
notice), in the app's locale, with the notice's link text and the absolute payment page URL. Each
account gets one mail per kind and end (scope billing.<kind>:<account id>:<end> in
mailing.deliveries): a run repeated the same day, or two runs at once, send nothing new, while an
extended trial or a renewal is a new window. The run is mailing's runDeliveries, so it takes its
options: dryRun (reads the ledger, sends and writes nothing; sent counts what would go out),
limit (most mails handed to the provider; the rest is counted in remaining), retakeUncertain
(the operator's word to resend claims old enough that their mail may have gone out), onDelivery
(each outcome with the reminder it was for, { accountId, kind, scope }, never the address; an account
buildMail skipped gets no call), stopOnRetryLater (default true) and the claim windows. Its
own: catchUpDays (0 to 365) and pauseMs between two mails the provider was called for (default
500, Resend's two requests per second). The summary is runDeliveries' (sent, skipped when the
template returned null, done, inFlight, uncertain, rejected by reason, retryLater,
halted, remaining) plus due. The run stops at a refused account, a spent quota and the first
mail the provider could not take now; the next run resumes there. A database failure throws; the
next run resumes. Candidates come from two range queries, the stored ends and
auth.users.created_at (indexed by auth's 0004) for accounts without a row, never a scan of every
account.
The app's own template and scopes: buildMail(context) gets { reminder, lastDay, link, mail, ctx } (the due
account's userId, email, kind, endsAt and endsAtMicros, the last day as the locale writes it, the payment URL, the
package's own mail and the module context the run was given: ctx.db, ctx.clock and ctx.config, the job's own
under createBillingJobHandlers, so the template can read the account at send time; it runs in a dry run too, so it
must not write) and returns { subject, text, html } (html may be left out for a plain-text mail), or null to send that account nothing this run (a
later run asks again); it may be async (to read the account's name). getScope(reminder) replaces the delivery
scope: an app moving from its own job passes the scope that job wrote to mailing.deliveries, so accounts it
already mailed are not mailed again. endsAtMicros is the end exactly as stored (microseconds since
the epoch, a decimal string, equal to (extract(epoch from <column>) * 1000000)::bigint::text), for a
scope that job built in SQL; endsAt is a Date and drops the microseconds. The default scope keeps
milliseconds, as earlier versions wrote it. A scope must stay unique per account, kind and end.
await sendAccessReminders(ctx, {
buildMail: ({ reminder, lastDay, link, mail }) =>
reminder.kind === "trial-ending" ? renderTrialEndingMail({ lastDay, link }) : mail,
getScope: (reminder) => `app.${reminder.kind}:${reminder.userId}:${reminder.endsAtMicros}`,
limit: 100, // a shared daily quota
});Scheduled runs. With @softure-ai/jobs, both run as daily schedules (00:00 UTC) of the app's job worker: add
billing's schedules to the jobs config and its handlers to the worker's.
// softure.config.ts
import { billingJobSchedules } from "@softure-ai/billing/jobs";
jobs({ schedules: { ...billingJobSchedules } }); // billing.access-reminders and billing.expire-requests, daily// src/jobs/handlers.ts
import { createBillingJobHandlers } from "@softure-ai/billing/jobs";
export const handlers: JobHandlers = {
// `billingJobHandlers` is the same with the package's own mail and defaults.
...createBillingJobHandlers({ reminders: { buildMail, getScope, limit: 100 } }), // sendAccessReminders' options
};billing.access-remindersrunssendAccessReminderswithreminders(every option butdryRun). A mail the provider could not take now (mailing.unavailable) makes the job retry with the queue's backoff; the delivery ledger keeps the mails already sent from going out twice. A refused sending account or a spent quota (mailing.provider_refused,mailing.quota_exceeded) fails the job at once, since a retry minutes later rarely helps; the next day's run resumes. Needsmailing({ ... }): without it every run throws, so an app without mailing schedules only the expiry,schedules: { "billing.expire-requests": billingJobSchedules["billing.expire-requests"] }.billing.expire-requestsrunsexpireStaleRequests.- Another period: write the entry yourself, e.g.
"billing.access-reminders": { every: "6h" }; repeated runs send nothing twice. Queue settings (maxAttempts,backoff) go injobs({ queues })as for any queue. - The entry loads without
@softure-ai/mailing; the reminder handler loads the mailing adapter on its first run.
Without jobs, a cron job (or a platform scheduler) runs a script daily:
// scripts/billing-daily.ts (cron: 0 3 * * *)
import { sendAccessReminders } from "@softure-ai/billing/mailing";
import { expireStaleRequests } from "@softure-ai/billing/server";
import { systemClock } from "@softure-ai/core";
import { createDatabase } from "@softure-ai/db";
import config from "../softure.config.ts";
if (config.database === null) throw new Error("billing-daily: the config has no database");
const database = await createDatabase(config.database.url, { max: 1 });
try {
const ctx = { db: database.db, clock: systemClock, config };
console.log(JSON.stringify({ expiry: await expireStaleRequests(ctx), reminders: await sendAccessReminders(ctx) }));
} finally {
await database.close();
}Guard every write action of the app, before reading any input:
"use server";
import { requireWriteAccess } from "@softure-ai/billing/next";
export async function saveNote(formData: FormData) {
const access = await requireWriteAccess(); // no session: redirects to the login page
if (!access.ok) return access; // Err("billing.read_only"), for the form to show
// ... the app's own authorization and the write, as access.value.user
}Show where the account stands in any server component (both render nothing without a session):
import { CurrentAccessBadge, CurrentAccessNotice } from "@softure-ai/billing/next";
import Link from "next/link";
<CurrentAccessBadge />
<CurrentAccessNotice LinkComponent={Link} />Both take options (and AccessBadge / AccessNotice in /ui the same props):
unlimitedAfterDays={1826} makes the badge read "Unlimited access" (messages.badge.unlimited, success tone,
data-unlimited="true") for trial or dated paid access with more days left than that, e.g. the long trials an app
gives invited accounts; unset, it never does. compact writes a shorter detail: "5 days" for a trial
(formatDayCount) and until 11/30/2026 for paid access (formatShortLastDay). tones sets the status colour
(neutral, success, warning, danger) per kind, trial, trial-ending, paid, paid-ending, lifetime,
unlimited or read-only, keeping the default for the kinds left out; getAccessBadgeKind(entitlement, {
unlimitedAfterDays }) (/ui) names the kind for the app's own use. texts words any kind the app's way, one
function per kind receiving { kind, entitlement, daysLeft, endsAt, text } (text is the badge's own) and returning
{ status, detail?, short? }; kinds left out keep the badge's text. variant is full (default), short (the
short text, or the status) or responsive (short below the md breakpoint, full from it). srPrefix is read by
screen readers before the label and not shown. toneScope="pill" puts the tone on all the pill's text instead of
the status alone.
<CurrentAccessBadge
srPrefix="Access status:"
variant="responsive"
toneScope="pill"
texts={{
trial: ({ daysLeft }) => ({ status: `Trial · ${daysLeft} days`, short: `${daysLeft} d` }),
"paid-ending": ({ daysLeft }) => ({ status: `Paid · ${daysLeft} days` }),
lifetime: () => ({ status: "Unlimited access" }),
}}
/>texts are functions, so pass them from a server component (as CurrentAccessBadge is) or a client one rendering
AccessBadge, not across the server-client boundary. The app's words go through its own message layer. The notice's tones={{ ending, ended }} sets its
frame, neutral (the raised surface) or danger (default neutral and danger). extraActions adds the app's
own actions after the payment link, in the actions slot, whenever the notice shows (ending or ended access), e.g. a
link to the app's data export that answers "did I lose anything?" in the read-only state:
<CurrentAccessNotice
LinkComponent={Link}
extraActions={<Link href="/account/export">Download your data</Link>}
/>The nodes are the app's own, so their link component, wording and style come from the app (an unstyled notice
leaves them as they are). To show an action only when access has ended, render AccessNotice (/ui) with
getCurrentEntitlement() and pass extraActions when entitlement.status === "read_only".
Server functions, for scripts and other hosts (@softure-ai/billing/server):
grantPlanManually(ctx, { userId, planId, adminId, requestId? }) grants one payment of a plan,
records it in the account's history (closing the request when given) and returns
{ grantId, entitlement } (Err<billing.plan_unknown | billing.account_unknown |
billing.lifetime_active | billing.request_closed> otherwise); grantLifetimeManually(ctx, { userId,
adminId }) grants lifetime access without a plan and records it the same way, under the plan id
LIFETIME_GRANT_PLAN_ID ("_lifetime", which no declared plan can take) with no price
(Err<billing.account_unknown | billing.lifetime_active> otherwise; its usage limits are
paid.limits); grantPaymentRequest(ctx, { requestId,
adminId }) does it for an open request; revokeManualGrant(ctx, { grantId, adminId }) takes one
back (Err<billing.grant_revoked> when it was revoked before); getAccountHistory(ctx, userId),
listOpenRequests(ctx, limit?), dismissPaymentRequest(ctx, requestId). grantPlan(ctx, userId,
planId) grants without a record: nothing to revoke, not in the history; scripts that grant for
an admin use grantPlanManually. startPayment(ctx, input) counts the billing-payment bucket
per account, checks the plan, lifetime access and the invoice details (parseInvoiceDetails, a zod
schema, invoiceDetailsSchema from the root entry), stores a request for a provider that hands
requests over and calls the provider once per open request (see "Invoice requests");
expireStaleRequests(ctx) closes requests older than requests.expireAfterDays; findAccountByEmail(ctx, email),
findAccountById(ctx, id), getBillingPlans(config).
receiveStripeWebhook(ctx, { payload, signature, secret }) is the route without Next;
recordPayment(ctx, { provider, checkoutId, paymentId, userId, planId, amount, currency }) and
refundPayment(ctx, { provider, paymentId }) are its two writes, for another provider's webhook.
getEntitlement(ctx, userId) returns the Entitlement or null for an unknown account;
checkWriteAccess(ctx, userId) returns Ok<Entitlement> or Err<billing.read_only | billing.account_unknown>;
changeEntitlement(ctx, userId, event) returns the Entitlement after the change or
Err<billing.end_not_in_future | billing.account_unknown>. A refused event writes nothing. event
may also be a function of the current record, run under the row's lock (how grantPlan extends a
period without losing a concurrent grant).
Scripts. @softure-ai/billing/scripts builds ops scripts on @softure-ai/ops/scripts (dry run by
default, --commit writes, one transaction), for an operator without the admin page or at a
terminal: grant-plan, grant-lifetime, revoke-grant, extend-trial and entitlement-status here, import-entitlements and pin-trials under
"Existing accounts" below. The app bundles them like its other scripts and runs them with its
database URL:
// scripts/grant-plan.ts: npm run grant-plan -- [email protected] --plan=monthly [--commit]
import { createGrantPlanScript } from "@softure-ai/billing/scripts";
import { runOpsScript } from "@softure-ai/ops/scripts";
import config from "../softure.config";
process.exitCode = await runOpsScript({ script: createGrantPlanScript(config), argv: process.argv.slice(2), config });grant-plan --email=… --plan=<plan id>grants one payment of a declared plan throughgrantPlanManually(no admin:granted_byis null), so it is in the account's history and the admin page can revoke it. Refuses an undeclared plan (naming the declared ones), an unknown email and an account with lifetime access.grant-lifetime --email=…grants lifetime access, free of charge and without a plan, throughgrantLifetimeManually(no admin), for an app whose plans are all dated: the owner's own account or a complimentary one. It is in the account's history and revocable like a plan grant. Refuses an unknown email and an account with lifetime access. On a VPS deployed with@softure-ai/deployit runs in the app container (deploy.sh run), since it needs no file.revoke-grant --email=… --grant=<id>revokes one active manual grant of that account throughrevokeManualGrant(a script's or the admin page's) and takes back what it added. Refuses an unknown email and an id that is not an active manual grant of the account (another account's, revoked, mistyped).
All three print the account's state before and after: the user id (never the email), the
entitlement and the active manual grants with their ids, newest first; a dry run of either script
shows the id revoke-grant takes. createGrantPlanScript(config, { clock? }),
createGrantLifetimeScript(config, { clock? }) and createRevokeGrantScript(config, { clock? }) take a clock for tests (executeOpsScript).
extend-trial --email=…|--user=<account id> --until=YYYY-MM-DD|--days=Nextends one account's trial throughextendTrialManually(no admin:extended_byis null), so it is in the account's history like the admin page's "Extend a trial".--untilis the trial's new last day, as in the form (the trial ends when the next day begins in the app's time zone).--days(1 to 36500) adds days of access after the current last day, or from today when the trial has ended: a trial through 19 October extended by 10 runs through 29 October; one that ended before today, extended by 10, runs through the tenth day counting today. It never writes paid access and refuses an unknown email or id, a last day that has passed and one the trial already reaches (naming its current last day). It printsbeforeandafteras{ userId, trialEndsAt, access },afterwith the newextensionId.createExtendTrialScript(config, { clock? }).
// scripts/extend-trial.ts: npm run extend-trial -- [email protected] --days=365 [--commit]
import { createExtendTrialScript } from "@softure-ai/billing/scripts";
import { runOpsScript } from "@softure-ai/ops/scripts";
import config from "../softure.config";
process.exitCode = await runOpsScript({ script: createExtendTrialScript(config), argv: process.argv.slice(2), config });entitlement-status --email=…|--user=<account id>shows where one account stands and writes nothing, with or without--commit(the runner's dry-run lines still print;beforeandafterare the same reading):{ userId, state, trialLastDay, paidLastDay, isLifetime, access },stateone oftrial,paid,lifetime,read_only, the last daysYYYY-MM-DDin the app's time zone (paidLastDaynull when no dated paid access was ever bought). Never the email; an unknown email or id is refused.createEntitlementStatusScript(config, { clock? }).
Usage limits. A meter is a kebab-case name (ai-calls) some limits block declares; its limit
is a count per calendar month in config.timezone (from the start of the 1st), or null for none.
billing({
trial: { days: 14, limits: { "ai-calls": 20 } },
paid: { limits: { "ai-calls": 200 } },
readOnly: { limits: {} },
plans: [
{ id: "pro", name: { en: "Pro" }, price: { amount: 4900, currency: "PLN" }, period: "month", limits: { "ai-calls": 2000 } },
],
});Which limit applies. The trial's block during the trial,
readOnly.limitsonce the account is read-only, and for paid access the plan'slimits, elsepaid.limits. A declared meter a block leaves out has limit 0 there: deny by default,nullwhen a state is unlimited.The plan paying for today is read from the grants billing records, never stored apart: a lifetime payment still paid for or an active lifetime grant wins, else the earliest provider payment or manual grant whose period has not ended (the one holding today, or the first one after the trial it was bought during). Refunds and revokes keep those periods true, so the plan follows them. Paid access no recorded grant explains (
importEntitlement, agrantevent applied by hand) has no plan and takespaid.limits; so do the meters of a plan id the config no longer declares.getLimits(ctx, userId)(/server) answers{ status, planId, limits }with every declared meter, or null without an account.consumeUsage(ctx, { userId, meter, amount?, period? })addsamount(1 by default) to the account's count for the current month and returnsOk({ meter, period, used, limit, remaining, periodStart, resetsAt }); when the amount would pass the limit it consumes nothing and returnsbilling.quota_exhaustedwith the same state inusage(copy:messages.errors.billing.quota_exhausted). It is one conditional upsert on the account's row for the meter and month, so concurrent consumers never pass the limit together. An undeclared meter or an amount that is not a whole number from 1 throws: that is a bug in the app.With the app's write. Pass a transaction's context and the quota commits or rolls back with the app's own write:
await db.transaction(async (tx) => { const consumed = await consumeUsage({ ...ctx, db: tx }, { userId, meter: "ai-calls" }); if (!consumed.ok) return consumed; // ... the app's insert ... });The account's row for that meter stays locked until the transaction ends, so keep it short: the account's other consumers of that meter wait for it.
getUsage(ctx, { userId, meter })reads the same state without consuming.When a limit changes (another plan, the trial ends) what was used this month stays: a lower limit refuses until the next month. A new month starts from 0. Rows of past months stay in
billing.usageuntil the account is deleted.
Existing accounts. An account without a billing.entitlements row is on the trial derived from
its creation day (§5), so turning billing on for accounts that already exist would make every one
older than trial.days read-only at once. Three tools, in this order, keep their access:
A trial floor,
billing({ trial: { startsAt: "2026-11-01" } }): every account created before that local day gets itstrial.daysfrom it, on every read, without a write; accounts created on or after it keep their own trial. The reminder mail sees the floored trials too, so all those accounts get their trial-ending mail in the same window.An import of what the old system knew (e.g. its
trial_ends_at,paid_untilcolumns):// scripts/import-entitlements.ts: npm run import-entitlements -- --file=entitlements.json [--commit] import { createImportEntitlementsScript } from "@softure-ai/billing/scripts"; import { runOpsScript } from "@softure-ai/ops/scripts"; import config from "../softure.config"; process.exitCode = await runOpsScript({ script: createImportEntitlementsScript(config), argv: process.argv.slice(2), config });The file (its path relative to the working directory) is a JSON array, at most 50,000 rows:
[ { "email": "[email protected]", "trialEndsAt": "2026-08-15T00:00:00+02:00", "paidUntil": "2027-01-01T00:00:00+01:00" }, { "email": "[email protected]", "isLifetime": true } ]Each row needs
emailand at least one oftrialEndsAt,paidUntil(ISO 8601 with an offset, the first instant without access, asbilling.entitlementsstores it;nullfor none) andisLifetime. Each is merged onto the account's current record (its row, or its derived and floored trial) by theimportevent: an end only moves later, lifetime only turns on, so an import never takes access away and running the same file again changes nothing. An imported end earlier than the account's own is therefore not recorded; past ends later than it are (an ended paid period shows aspaid_ended). The import is not a grant: it is not in the account's history and is not revocable from the admin page (correct a mistake withchangeEntitlement'srevokeorshorten). The whole file is one transaction and refused as a whole for a file that cannot be read, a row that fails the format, an email repeated in the file (compared as auth stores emails) or an email no account has; refusals name row numbers, never emails. The report counts the named accounts by state (trial,paid,lifetime,readOnly)beforeandafter. Split a very large file: every row takes its locks until the end of the run.importEntitlement(ctx, { userId, trialEndsAt?, paidUntil?, isLifetime? })(/server) is the same merge for an app that migrates in its own code; it returns theEntitlementorErr<billing.account_unknown>.An exact import (
--exact,importEntitlement(ctx, { ...row, mode: "replace" })) stores each row as given instead: when the old system had shortened access (a trial counted from a later day thancreated_at, a revoked purchase), a merge would give the account back its longer derived trial, the exact import keeps it ended.trialEndsAtomitted ornullkeeps the derived trial,paidUntilnullmeans none. It writes only an account's first row: an account that already has one (a change, a pin) is refused withbilling.entitlement_exists, unless its row holds exactly that record, so running the same file again changes nothing. The script refuses the whole file, naming the rows, when any account already has a different row. Run it before anything writes rows (beforepin-trialsand before the app opens billing to its accounts).A pin before any change of
trial.days,trial.startsAtorconfig.timezone:npm run pin-trials [-- --commit](createPinTrialsScript(config), no arguments) writes the trial every account without a row is on, exactly as reads derive it, into a row, so the change moves no existing trial and applies to new accounts only. Rows written meanwhile by a change are kept; a second run pins nothing. The report givesaccountsWithoutRowbeforeandafter, andpinned.pinDerivedTrials(ctx)(/server) is the same step, returning how many rows it wrote.
Both scripts take { clock? } for tests, like the plan scripts.
A payment provider (PaymentProvider from the root entry) has a name, says whether the page
collects invoice details (collectsInvoiceDetails) and whether it hands requests to the owner
instead of sending the buyer to a checkout (handsOverRequests: billing then stores the request
before the call and makes the call once per open request), and implements
startPayment(ctx, { plan, account, invoice, returnUrl }), which resolves with
{ type: "redirect", url } (a hosted checkout; the action redirects there), { type: "requested" }
(handed over, only for handsOverRequests: true; the page confirms) or
Err<billing.payment_failed>. Granting access afterwards goes through grantPlanManually (an
admin) or recordPayment (a webhook).
5. Migrations and tables
migrations/0001_create_entitlements.sql creates billing.entitlements:
| Column | Meaning |
| --- | --- |
| user_id | uuid, primary key, references auth.users(id) ON DELETE CASCADE. |
| trial_ends_at | The first instant the trial no longer covers. |
| paid_until | The first instant dated paid access no longer covers; NULL when never paid or revoked. Kept under lifetime (since 0003). |
| is_lifetime | Paid access without an end; it wins over paid_until. |
| created_at, updated_at | The first change and the last one. |
No row until something changes. Reads never write: an account without a row gets its trial
derived from auth.users.created_at, trial.days, trial.startsAt and config.timezone, and
auth's single onRegistered hook stays free for the app. Accounts created before billing was
enabled are on that derived trial too, so without trial.startsAt or an import those older than
trial.days are read-only from the first read (see "Existing accounts" in §4). The first change
(changeEntitlement, an import, pin-trials) stores the derived trial end with the event applied,
so the trial end never moves when a row appears. Until then every read derives it again, so for
accounts without a row:
| Config change | Effect |
| --- | --- |
| shorter trial.days | every derived trial ends earlier: accounts past the new end are read-only at once |
| longer trial.days | every derived trial ends later: accounts whose trial had ended can write again |
| trial.startsAt set, moved or removed | the trial of every account created before the (old or new) floor day moves with it |
| config.timezone | every derived trial ends at the start of the same local day in the new zone, hours earlier or later |
Run pin-trials before such a change to keep existing trials where they are.
migrations/0002_create_payments.sql creates billing.payments, one row per paid provider checkout:
| Column | Meaning |
| --- | --- |
| id | uuid, primary key. |
| user_id | The account, references auth.users(id) ON DELETE CASCADE. |
| provider | The adapter's name, stripe. |
| checkout_id | The provider's checkout (cs_...); unique per provider: a checkout grants once. |
| payment_id | The provider's payment (pi_...) refunds name; unique per provider; NULL for a free checkout. |
| plan_id, amount, currency | The plan and what the provider charged, in the currency's minor unit. |
| status, paid_at, refunded_at | paid or refunded; a CHECK ties refunded_at to the status. |
| grant_kind, granted_from, granted_until | What the payment granted (0003): period with its start and end, or lifetime with no dates; all NULL for rows recorded before. A CHECK (payments_grant_shape) ties the dates to the kind. A partial refund moves granted_until back by the days it took. |
| refunded_amount | The total refunded so far, in the currency's minor unit (0005): amount once refunded, below it while paid (CHECK payments_refunded_amount_by_status). |
| taken_back_days | The local days refunds took from the payment's period so far (0007); a failed refund gives back its share. |
| refunds_seen_at | When Stripe took the newest charge state billing recorded (0007, the event's created); NULL before any refund. |
| pending_refunded_amount, pending_refunds_seen_at | The newest charge state billing did not apply because it reported no more than billing counted (0009): Stripe's raw amount_refunded and the event's created, applied by a later failure; both NULL when none is kept (CHECK payments_pending_charge_state_shape). |
migrations/0003_record_payment_grants.sql adds the grant columns and drops the CHECK that kept
paid_until NULL under lifetime. migrations/0005_record_refunded_amounts.sql adds
refunded_amount (set to amount on payments refunded before).
migrations/0006_record_request_handover_and_prices.sql adds handed_over_at (set to
requested_at on open requests, which were all handed over), the price columns of both manual
tables, the expired status and the control-character CHECKs.
migrations/0007_record_failed_refunds.sql adds taken_back_days (0 on payments refunded before:
their failure gives back no days) and refunds_seen_at (set to refunded_at, or the migration's
time, on payments with a refund), and creates billing.refund_failures, one row per failed refund:
payment_id (references billing.payments(id) ON DELETE CASCADE), refund_id, amount,
refund_created_at, failed_at (the event's created) and recorded_at, primary key
(payment_id, refund_id).
migrations/0008_record_request_handover_claims.sql adds handover_claimed_at; handed_over_at
keeps its values, so requests from before count as handed over.
migrations/0009_record_pending_charge_states.sql adds pending_refunded_amount and
pending_refunds_seen_at (NULL on every payment from before: nothing was kept).
The insert, the grant and its grant columns share a transaction, as do the refund's conditional update and the change it makes, so a delivery seen twice changes nothing. Every write takes the account first (like the privacy erase); a refund and a manual revoke then take the entitlement before their own row, since moving the later periods back updates other rows under that lock.
migrations/0004_create_requests_and_grants.sql creates the manual payments' two tables:
billing.payment_requests, one row per invoice request:
| Column | Meaning |
| --- | --- |
| id, user_id, plan_id | The request, its account (ON DELETE CASCADE) and the plan asked for. |
| invoice_name, invoice_tax_id, invoice_address | The details as typed, kept only while the request is open: closing it clears them (CHECK payment_requests_details_while_open). No control characters in new values (0006, CHECKs payment_requests_invoice_*_printable, NOT VALID: older rows are not rewritten). |
| status, requested_at, closed_at | open, granted, dismissed or expired (0006); a CHECK ties closed_at to the status. requested_at is the last ask. |
| handed_over_at | When the hand-over to the owner answered Ok (0006; since 0008, before it the claim time): never handed over again once set. |
| handover_claimed_at | When an ask claimed the hand-over (0008); NULL when none runs, the claim failed and was released, or the hand-over answered. A claim a minute old is taken over by the next ask. |
| amount, currency | The plan's price at the last ask, in the currency's minor unit (0006); NULL on rows stored before. |
One open request per account and plan (partial unique index payment_requests_one_open): asking
again refreshes its details and time.
billing.manual_grants, one row per plan an admin granted:
| Column | Meaning |
| --- | --- |
| id, user_id, plan_id | The grant, its account (ON DELETE CASCADE) and the plan. |
| request_id | The request it answered (unique), or NULL for a grant by email. |
| granted_by, revoked_by | The admins (ON DELETE SET NULL). |
| granted_at, grant_kind, granted_from, granted_until | What it added, as billing.payments records it (CHECK manual_grants_grant_shape). |
| status, revoked_at | active or revoked; CHECKs tie revoked_at and revoked_by to the status. |
| amount, currency | What it was granted for (0006): the price its request quoted, else the plan's price when granted; NULL on rows stored before. |
migrations/0010_record_trial_extensions_and_index_foreign_keys.sql creates
billing.trial_extensions, one row per trial an admin extended:
| Column | Meaning |
| --- | --- |
| id, user_id | The extension and its account (ON DELETE CASCADE). |
| extended_by | The admin (ON DELETE SET NULL); NULL for one made without an admin. |
| extended_at | When it was extended. |
| previous_ends_at, ends_at | The trial end before and after (CHECK: later, and after extended_at). |
It also indexes the foreign keys payment_requests.user_id, manual_grants.granted_by and
manual_grants.revoked_by (partial, IS NOT NULL), so deleting an account or an admin does not
scan those tables; every billing foreign key now has an index that starts with its column.
migrations/0011_create_usage.sql creates billing.usage, the ledger behind consumeUsage:
| Column | Meaning |
| --- | --- |
| user_id | The account (ON DELETE CASCADE). |
| meter | The meter, kebab-case, at most 64 characters (CHECK). |
| period, period_start | month and the first instant of that calendar month in config.timezone. |
| used | What the account used of the meter in that period (CHECK: from 0). |
| updated_at | The last consumption. |
The primary key is (user_id, meter, period, period_start), which also serves the account's
foreign key.
A grant and the request it closes share a transaction; a grant and a revoke take the account, then the entitlement, then their row (a conditional update), the order of a refund.
Entitlement state in SQL. migrations/0012_create_entitlement_state_functions.sql creates two functions, the
stable contract for an app's own SQL: a change to the package's rules or tables keeps their names and columns, so
an app never re-implements the state machine over billing.entitlements.
billing.entitlement_states(as_of timestamptz, trial_days integer, time_zone text, trial_starts_on date DEFAULT NULL): one row per account inauth.users.billing.entitlement_state(user_id uuid, as_of, trial_days, time_zone, trial_starts_on): the same columns withoutuser_idfor one account; no row when no account has this id.
| Column | Meaning |
| --- | --- |
| user_id | The account (entitlement_states only). |
| status | trial, paid or read_only, as getEntitlement() answers at as_of. |
| ends_at | When the trial or dated paid access ends; NULL for lifetime access and when read-only. |
| read_only_since, read_only_reason | When write access ended and why (trial_ended or paid_ended); NULL unless read-only. |
| trial_ends_at, paid_until, is_lifetime | The record the state comes from: the stored row, or the derived trial. |
| is_stored | Whether the account has a row in billing.entitlements. |
The trial of an account without a row depends on trial.days, trial.startsAt and config.timezone, which the
database does not know, so they are arguments. In TypeScript, getEntitlementStatesSql(ctx, asOf?) (/server)
fills them from the config (and as_of from the clock), so nothing is copied:
import { getEntitlementStatesSql } from "@softure-ai/billing/server";
import { sql } from "drizzle-orm";
// Accounts read-only after their trial, with no paid access on record: an offer mail.
const states = getEntitlementStatesSql(ctx);
const { rows } = await ctx.db.execute(sql`
select u.id, u.email from auth.users u
join ${states} s on s.user_id = u.id
where s.status = 'read_only' and s.read_only_reason = 'trial_ended' and s.paid_until is null
`);A plain SQL report passes the app's values itself, e.g. for billing({ trial: { days: 14 } }) in Warsaw:
`select status, co
