@softure-ai/billing 0.0.0-stage → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (294) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +699 -2
  3. package/dist/calendar.d.ts +26 -0
  4. package/dist/calendar.d.ts.map +1 -0
  5. package/dist/calendar.js +81 -0
  6. package/dist/calendar.js.map +1 -0
  7. package/dist/contract.d.ts +187 -0
  8. package/dist/contract.d.ts.map +1 -0
  9. package/dist/contract.js +6 -0
  10. package/dist/contract.js.map +1 -0
  11. package/dist/currency-digits.d.ts +3 -0
  12. package/dist/currency-digits.d.ts.map +1 -0
  13. package/dist/currency-digits.js +32 -0
  14. package/dist/currency-digits.js.map +1 -0
  15. package/dist/entitlement.d.ts +25 -0
  16. package/dist/entitlement.d.ts.map +1 -0
  17. package/dist/entitlement.js +75 -0
  18. package/dist/entitlement.js.map +1 -0
  19. package/dist/fields.d.ts +25 -0
  20. package/dist/fields.d.ts.map +1 -0
  21. package/dist/fields.js +27 -0
  22. package/dist/fields.js.map +1 -0
  23. package/dist/index.d.ts +274 -0
  24. package/dist/index.d.ts.map +1 -0
  25. package/dist/index.js +73 -0
  26. package/dist/index.js.map +1 -0
  27. package/dist/invoice.d.ts +29 -0
  28. package/dist/invoice.d.ts.map +1 -0
  29. package/dist/invoice.js +35 -0
  30. package/dist/invoice.js.map +1 -0
  31. package/dist/mailing/index.d.ts +2 -0
  32. package/dist/mailing/index.d.ts.map +1 -0
  33. package/dist/mailing/index.js +4 -0
  34. package/dist/mailing/index.js.map +1 -0
  35. package/dist/mailing/reminder-mail.d.ts +50 -0
  36. package/dist/mailing/reminder-mail.d.ts.map +1 -0
  37. package/dist/mailing/reminder-mail.js +71 -0
  38. package/dist/mailing/reminder-mail.js.map +1 -0
  39. package/dist/manual.d.ts +12 -0
  40. package/dist/manual.d.ts.map +1 -0
  41. package/dist/manual.js +22 -0
  42. package/dist/manual.js.map +1 -0
  43. package/dist/messages/en.d.ts +176 -0
  44. package/dist/messages/en.d.ts.map +1 -0
  45. package/dist/messages/en.js +151 -0
  46. package/dist/messages/en.js.map +1 -0
  47. package/dist/messages/index.d.ts +359 -0
  48. package/dist/messages/index.d.ts.map +1 -0
  49. package/dist/messages/index.js +14 -0
  50. package/dist/messages/index.js.map +1 -0
  51. package/dist/messages/pl.d.ts +3 -0
  52. package/dist/messages/pl.d.ts.map +1 -0
  53. package/dist/messages/pl.js +151 -0
  54. package/dist/messages/pl.js.map +1 -0
  55. package/dist/next/access.d.ts +16 -0
  56. package/dist/next/access.d.ts.map +1 -0
  57. package/dist/next/access.js +24 -0
  58. package/dist/next/access.js.map +1 -0
  59. package/dist/next/actions.d.ts +24 -0
  60. package/dist/next/actions.d.ts.map +1 -0
  61. package/dist/next/actions.js +158 -0
  62. package/dist/next/actions.js.map +1 -0
  63. package/dist/next/context.d.ts +4 -0
  64. package/dist/next/context.d.ts.map +1 -0
  65. package/dist/next/context.js +17 -0
  66. package/dist/next/context.js.map +1 -0
  67. package/dist/next/current-entitlement.d.ts +18 -0
  68. package/dist/next/current-entitlement.d.ts.map +1 -0
  69. package/dist/next/current-entitlement.js +26 -0
  70. package/dist/next/current-entitlement.js.map +1 -0
  71. package/dist/next/index.d.ts +8 -0
  72. package/dist/next/index.d.ts.map +1 -0
  73. package/dist/next/index.js +12 -0
  74. package/dist/next/index.js.map +1 -0
  75. package/dist/next/pages.d.ts +24 -0
  76. package/dist/next/pages.d.ts.map +1 -0
  77. package/dist/next/pages.js +166 -0
  78. package/dist/next/pages.js.map +1 -0
  79. package/dist/next/pricing.d.ts +12 -0
  80. package/dist/next/pricing.d.ts.map +1 -0
  81. package/dist/next/pricing.js +20 -0
  82. package/dist/next/pricing.js.map +1 -0
  83. package/dist/next/route.d.ts +11 -0
  84. package/dist/next/route.d.ts.map +1 -0
  85. package/dist/next/route.js +56 -0
  86. package/dist/next/route.js.map +1 -0
  87. package/dist/options.d.ts +85 -0
  88. package/dist/options.d.ts.map +1 -0
  89. package/dist/options.js +121 -0
  90. package/dist/options.js.map +1 -0
  91. package/dist/payment.d.ts +61 -0
  92. package/dist/payment.d.ts.map +1 -0
  93. package/dist/payment.js +12 -0
  94. package/dist/payment.js.map +1 -0
  95. package/dist/plans.d.ts +20 -0
  96. package/dist/plans.d.ts.map +1 -0
  97. package/dist/plans.js +53 -0
  98. package/dist/plans.js.map +1 -0
  99. package/dist/price.d.ts +12 -0
  100. package/dist/price.d.ts.map +1 -0
  101. package/dist/price.js +37 -0
  102. package/dist/price.js.map +1 -0
  103. package/dist/refund.d.ts +70 -0
  104. package/dist/refund.d.ts.map +1 -0
  105. package/dist/refund.js +109 -0
  106. package/dist/refund.js.map +1 -0
  107. package/dist/reminder.d.ts +30 -0
  108. package/dist/reminder.d.ts.map +1 -0
  109. package/dist/reminder.js +34 -0
  110. package/dist/reminder.js.map +1 -0
  111. package/dist/schema.d.ts +1023 -0
  112. package/dist/schema.d.ts.map +1 -0
  113. package/dist/schema.js +77 -0
  114. package/dist/schema.js.map +1 -0
  115. package/dist/scripts/entitlement-scripts.d.ts +17 -0
  116. package/dist/scripts/entitlement-scripts.d.ts.map +1 -0
  117. package/dist/scripts/entitlement-scripts.js +167 -0
  118. package/dist/scripts/entitlement-scripts.js.map +1 -0
  119. package/dist/scripts/index.d.ts +3 -0
  120. package/dist/scripts/index.d.ts.map +1 -0
  121. package/dist/scripts/index.js +5 -0
  122. package/dist/scripts/index.js.map +1 -0
  123. package/dist/scripts/plan-scripts.d.ts +19 -0
  124. package/dist/scripts/plan-scripts.d.ts.map +1 -0
  125. package/dist/scripts/plan-scripts.js +106 -0
  126. package/dist/scripts/plan-scripts.js.map +1 -0
  127. package/dist/server/entitlements.d.ts +69 -0
  128. package/dist/server/entitlements.d.ts.map +1 -0
  129. package/dist/server/entitlements.js +202 -0
  130. package/dist/server/entitlements.js.map +1 -0
  131. package/dist/server/grants.d.ts +74 -0
  132. package/dist/server/grants.d.ts.map +1 -0
  133. package/dist/server/grants.js +174 -0
  134. package/dist/server/grants.js.map +1 -0
  135. package/dist/server/health.d.ts +3 -0
  136. package/dist/server/health.d.ts.map +1 -0
  137. package/dist/server/health.js +17 -0
  138. package/dist/server/health.js.map +1 -0
  139. package/dist/server/index.d.ts +12 -0
  140. package/dist/server/index.d.ts.map +1 -0
  141. package/dist/server/index.js +14 -0
  142. package/dist/server/index.js.map +1 -0
  143. package/dist/server/options.d.ts +20 -0
  144. package/dist/server/options.d.ts.map +1 -0
  145. package/dist/server/options.js +38 -0
  146. package/dist/server/options.js.map +1 -0
  147. package/dist/server/payments.d.ts +140 -0
  148. package/dist/server/payments.d.ts.map +1 -0
  149. package/dist/server/payments.js +339 -0
  150. package/dist/server/payments.js.map +1 -0
  151. package/dist/server/plans.d.ts +50 -0
  152. package/dist/server/plans.d.ts.map +1 -0
  153. package/dist/server/plans.js +129 -0
  154. package/dist/server/plans.js.map +1 -0
  155. package/dist/server/privacy.d.ts +77 -0
  156. package/dist/server/privacy.d.ts.map +1 -0
  157. package/dist/server/privacy.js +110 -0
  158. package/dist/server/privacy.js.map +1 -0
  159. package/dist/server/reminders.d.ts +20 -0
  160. package/dist/server/reminders.d.ts.map +1 -0
  161. package/dist/server/reminders.js +85 -0
  162. package/dist/server/reminders.js.map +1 -0
  163. package/dist/server/requests.d.ts +80 -0
  164. package/dist/server/requests.d.ts.map +1 -0
  165. package/dist/server/requests.js +155 -0
  166. package/dist/server/requests.js.map +1 -0
  167. package/dist/server/setup.d.ts +9 -0
  168. package/dist/server/setup.d.ts.map +1 -0
  169. package/dist/server/setup.js +34 -0
  170. package/dist/server/setup.js.map +1 -0
  171. package/dist/server/take-back.d.ts +80 -0
  172. package/dist/server/take-back.d.ts.map +1 -0
  173. package/dist/server/take-back.js +138 -0
  174. package/dist/server/take-back.js.map +1 -0
  175. package/dist/server/user-id.d.ts +4 -0
  176. package/dist/server/user-id.d.ts.map +1 -0
  177. package/dist/server/user-id.js +11 -0
  178. package/dist/server/user-id.js.map +1 -0
  179. package/dist/stripe-currency.d.ts +27 -0
  180. package/dist/stripe-currency.d.ts.map +1 -0
  181. package/dist/stripe-currency.js +57 -0
  182. package/dist/stripe-currency.js.map +1 -0
  183. package/dist/stripe-webhook.d.ts +101 -0
  184. package/dist/stripe-webhook.d.ts.map +1 -0
  185. package/dist/stripe-webhook.js +209 -0
  186. package/dist/stripe-webhook.js.map +1 -0
  187. package/dist/stripe.d.ts +25 -0
  188. package/dist/stripe.d.ts.map +1 -0
  189. package/dist/stripe.js +116 -0
  190. package/dist/stripe.js.map +1 -0
  191. package/dist/ui/access-badge.d.ts +16 -0
  192. package/dist/ui/access-badge.d.ts.map +1 -0
  193. package/dist/ui/access-badge.js +43 -0
  194. package/dist/ui/access-badge.js.map +1 -0
  195. package/dist/ui/access-notice.d.ts +20 -0
  196. package/dist/ui/access-notice.d.ts.map +1 -0
  197. package/dist/ui/access-notice.js +41 -0
  198. package/dist/ui/access-notice.js.map +1 -0
  199. package/dist/ui/format.d.ts +11 -0
  200. package/dist/ui/format.d.ts.map +1 -0
  201. package/dist/ui/format.js +21 -0
  202. package/dist/ui/format.js.map +1 -0
  203. package/dist/ui/grant-form.d.ts +18 -0
  204. package/dist/ui/grant-form.d.ts.map +1 -0
  205. package/dist/ui/grant-form.js +23 -0
  206. package/dist/ui/grant-form.js.map +1 -0
  207. package/dist/ui/grant-history.d.ts +39 -0
  208. package/dist/ui/grant-history.d.ts.map +1 -0
  209. package/dist/ui/grant-history.js +36 -0
  210. package/dist/ui/grant-history.js.map +1 -0
  211. package/dist/ui/index.d.ts +9 -0
  212. package/dist/ui/index.d.ts.map +1 -0
  213. package/dist/ui/index.js +12 -0
  214. package/dist/ui/index.js.map +1 -0
  215. package/dist/ui/payment-form.d.ts +23 -0
  216. package/dist/ui/payment-form.d.ts.map +1 -0
  217. package/dist/ui/payment-form.js +34 -0
  218. package/dist/ui/payment-form.js.map +1 -0
  219. package/dist/ui/payment-requests.d.ts +30 -0
  220. package/dist/ui/payment-requests.d.ts.map +1 -0
  221. package/dist/ui/payment-requests.js +31 -0
  222. package/dist/ui/payment-requests.js.map +1 -0
  223. package/dist/ui/pricing-tiles.d.ts +22 -0
  224. package/dist/ui/pricing-tiles.d.ts.map +1 -0
  225. package/dist/ui/pricing-tiles.js +40 -0
  226. package/dist/ui/pricing-tiles.js.map +1 -0
  227. package/migrations/0001_create_entitlements.sql +16 -0
  228. package/migrations/0002_create_payments.sql +30 -0
  229. package/migrations/0003_record_payment_grants.sql +25 -0
  230. package/migrations/0004_create_requests_and_grants.sql +58 -0
  231. package/migrations/0005_record_refunded_amounts.sql +18 -0
  232. package/migrations/0006_record_request_handover_and_prices.sql +38 -0
  233. package/migrations/0007_record_failed_refunds.sql +27 -0
  234. package/migrations/0008_record_request_handover_claims.sql +9 -0
  235. package/migrations/0009_record_pending_charge_states.sql +16 -0
  236. package/module.json +23 -0
  237. package/package.json +81 -4
  238. package/src/calendar.ts +90 -0
  239. package/src/contract.ts +181 -0
  240. package/src/currency-digits.ts +37 -0
  241. package/src/entitlement.ts +84 -0
  242. package/src/fields.ts +37 -0
  243. package/src/index.ts +163 -0
  244. package/src/invoice.ts +58 -0
  245. package/src/mailing/index.ts +11 -0
  246. package/src/mailing/reminder-mail.ts +108 -0
  247. package/src/manual.ts +31 -0
  248. package/src/messages/en.ts +150 -0
  249. package/src/messages/index.ts +18 -0
  250. package/src/messages/pl.ts +152 -0
  251. package/src/next/access.tsx +55 -0
  252. package/src/next/actions.ts +176 -0
  253. package/src/next/context.ts +18 -0
  254. package/src/next/current-entitlement.ts +36 -0
  255. package/src/next/index.ts +11 -0
  256. package/src/next/next-modules.d.ts +21 -0
  257. package/src/next/pages.tsx +267 -0
  258. package/src/next/pricing.tsx +39 -0
  259. package/src/next/route.ts +57 -0
  260. package/src/options.ts +128 -0
  261. package/src/payment.ts +77 -0
  262. package/src/plans.ts +63 -0
  263. package/src/price.ts +50 -0
  264. package/src/refund.ts +135 -0
  265. package/src/reminder.ts +52 -0
  266. package/src/schema.ts +86 -0
  267. package/src/scripts/entitlement-scripts.ts +188 -0
  268. package/src/scripts/index.ts +11 -0
  269. package/src/scripts/plan-scripts.ts +143 -0
  270. package/src/server/entitlements.ts +227 -0
  271. package/src/server/grants.ts +227 -0
  272. package/src/server/health.ts +18 -0
  273. package/src/server/index.ts +83 -0
  274. package/src/server/options.ts +52 -0
  275. package/src/server/payments.ts +434 -0
  276. package/src/server/plans.ts +143 -0
  277. package/src/server/privacy.ts +189 -0
  278. package/src/server/reminders.ts +113 -0
  279. package/src/server/requests.ts +201 -0
  280. package/src/server/setup.ts +37 -0
  281. package/src/server/take-back.ts +190 -0
  282. package/src/server/user-id.ts +12 -0
  283. package/src/stripe-currency.ts +65 -0
  284. package/src/stripe-webhook.ts +278 -0
  285. package/src/stripe.ts +130 -0
  286. package/src/ui/access-badge.tsx +64 -0
  287. package/src/ui/access-notice.tsx +73 -0
  288. package/src/ui/format.ts +25 -0
  289. package/src/ui/grant-form.tsx +69 -0
  290. package/src/ui/grant-history.tsx +127 -0
  291. package/src/ui/index.ts +25 -0
  292. package/src/ui/payment-form.tsx +111 -0
  293. package/src/ui/payment-requests.tsx +126 -0
  294. package/src/ui/pricing-tiles.tsx +105 -0
package/README.md CHANGED
@@ -1,3 +1,700 @@
1
- # Temporary Holding Version
1
+ # @softure-ai/billing
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ **Status:** wave 3 · entitlements and the write guard (MO-1); plans, pricing tiles, the payment page
4
+ and the manual adapter (MO-2); Stripe Checkout with verified webhooks and refunds (MO-3); stored
5
+ invoice requests, recorded manual grants with revoke and an account history (FU-9); reminder mail
6
+ before and after access ends (FU-6) · depends on: core, db, ui, security, auth (mailing optional, for
7
+ `@softure-ai/billing/mailing`)
8
+
9
+ Decides whether an account may still write: a trial every account starts with, paid access (dated
10
+ or lifetime) and a read-only state once both end. It replaces FIRE_TRACKER's access logic
11
+ (`src/lib/access.ts`, `src/db/access.ts`, `src/components/{access-badge,access-notice*}.tsx`), with
12
+ the `paid_until` and `trial_ends_at` columns moved off the users table into `billing.entitlements`
13
+ and the hand-written guard replaced by a pure state machine. FIRE's hard-coded prices and its access
14
+ script become plans in the config, a payment page and an admin page that grants a plan. Accounts
15
+ that exist when billing is turned on keep their access through a trial floor (`trial.startsAt`), an
16
+ import of what the old system knew (`import-entitlements`) and a pin step for derived trials
17
+ (`pin-trials`); see "Existing accounts" in §4.
18
+
19
+ ## 1. What it provides
20
+
21
+ - **A pure state machine** (`resolveEntitlement`, `applyEntitlementEvent` from the root entry): a
22
+ record (trial end, paid until, lifetime) and an instant give `trial | paid | read_only`, with the
23
+ days left and whether the reminder window is open; an event (`grant`, `grant_lifetime`, `revoke`,
24
+ `shorten`, `end_lifetime`, `extend_trial`, `import`) gives the next record. No database and no clock.
25
+ - **`billing.entitlements`**, at most one row per account, apart from `auth.users`. An account
26
+ without a row is on the trial that starts at its `auth.users.created_at` (see §5).
27
+ - **The write guard**: `requireWriteAccess()` (`/next`) for server actions, `checkWriteAccess()`
28
+ (`/server`) for other hosts.
29
+ - **`getEntitlement()`** and `getCurrentEntitlement()` to read where an account stands, and
30
+ **`changeEntitlement()`**, the one write path (grants, revokes, trial extensions) the payment
31
+ adapters build on.
32
+ - **`AccessBadge` and `AccessNotice`** (`/ui`), standalone with slots, `unstyled` and messages, and
33
+ `CurrentAccessBadge` / `CurrentAccessNotice` (`/next`) wired to the signed-in account.
34
+ - **Plans in the config** (`billing({ plans })`): name, description, price in the currency's minor
35
+ unit, period (days, weeks, months, years or lifetime), feature lines; `formatPrice` and the
36
+ period copy follow the app's locale.
37
+ - **`PricingTiles`** (`/ui`) and **`Pricing`** (`/next`, wired to the config), a **`PaymentPage`**
38
+ to mount at `routes.payment`, and a **`BillingAdminPage`** at `routes.admin` where an admin
39
+ works the open invoice requests (grant or dismiss each), grants a plan by email, and looks up an
40
+ account's history of grants and payments with a revoke button on each manual grant.
41
+ - **A `PaymentProvider` interface** and its first adapter, **`manual({ onRequest })`**: the buyer
42
+ requests an invoice, the app hands the request to its owner (a mail, a ticket), and the owner
43
+ grants the plan once it is paid. The request is stored in `billing.payment_requests` before it is
44
+ handed over, reaches the owner once however often the buyer asks again, and stays until the
45
+ admin grants or dismisses it or it expires (`expireStaleRequests`). **`grantPlanManually()`** grants and records a plan in
46
+ `billing.manual_grants`; **`revokeManualGrant()`** takes back only what one grant added.
47
+ - **`stripe()`**, a card, BLIK and transfer adapter on Stripe Checkout (one-time payments), and
48
+ **`stripeWebhookRoute`** (`/next`): a verified Stripe webhook that grants a paid checkout's plan
49
+ and, on a refund, takes back what that one payment granted (a partial refund its share, by the
50
+ `partialRefunds` policy), exactly once per payment, recorded in `billing.payments`, and gives it
51
+ back when the refund fails (see "Refunds" and "Failed refunds" below).
52
+ - **Reminder mail** (`@softure-ai/billing/mailing`): `sendAccessReminders(ctx)` mails every
53
+ account whose trial or dated paid access is in its reminder window, or ended in the last few
54
+ days, once per account and window through `@softure-ai/mailing`'s delivery ledger; the app runs
55
+ it on a schedule (see "Reminder mail" in §4). `findAccessReminders` (`/server`) is the same list
56
+ without mail, for an app that sends its own.
57
+ - **Plan scripts** (`@softure-ai/billing/scripts`): `grant-plan` and `revoke-grant`, ops scripts
58
+ (dry run by default, `--commit` writes) for a host without the admin page; their grants are in
59
+ the account's history like the admin page's (see "Scripts" in §4).
60
+ - **Existing accounts**: `trial.startsAt` gives accounts created before a chosen day a trial from
61
+ that day; `import-entitlements` (`importEntitlement()` on the server) records the trial ends, paid
62
+ periods and lifetime access another system knew, never shortening access; `pin-trials`
63
+ (`pinDerivedTrials()`) writes every derived trial into a row before a config change would move it
64
+ (see "Existing accounts" in §4).
65
+ - Export and deletion of the entitlement row, the payments, the invoice requests and the manual grants (`@softure-ai/privacy`), and a health check for
66
+ `GET /api/health`.
67
+
68
+ ## 2. Installation
69
+
70
+ ```bash
71
+ npm install @softure-ai/billing @softure-ai/auth @softure-ai/security @softure-ai/core @softure-ai/db @softure-ai/ui drizzle-orm zod
72
+ ```
73
+
74
+ Peer dependencies: `next` 16, `react` 19, `drizzle-orm`; `@softure-ai/mailing` (optional) for
75
+ `@softure-ai/billing/mailing`. The module depends on `security` and `auth`; a configuration without
76
+ them fails at startup.
77
+
78
+ ## 3. Configuration
79
+
80
+ ```ts
81
+ import { billing, BILLING_RATE_LIMIT_BUCKETS, manual } from "@softure-ai/billing";
82
+
83
+ // in defineSoftureConfig({ timezone: "Europe/Warsaw", modules: [...] }):
84
+ security({ buckets: { ...AUTH_RATE_LIMIT_BUCKETS, ...BILLING_RATE_LIMIT_BUCKETS } }),
85
+ // ... auth(...), then:
86
+ billing({
87
+ trial: { days: 14, reminderDays: 3 },
88
+ paid: { reminderDays: 7 },
89
+ plans: [
90
+ { id: "monthly", name: { en: "Monthly", pl: "..." }, price: { amount: 2900, currency: "PLN" }, period: "month", features: [{ en: "Unlimited notes" }] },
91
+ { id: "yearly", name: { en: "Yearly" }, price: { amount: 29000, currency: "PLN" }, period: "year", isFeatured: true },
92
+ { id: "lifetime", name: { en: "Lifetime" }, price: { amount: 79000, currency: "PLN" }, period: "lifetime" },
93
+ ],
94
+ payment: manual({ onRequest: async (request, ctx) => sendInvoiceRequestMail(request, ctx) }),
95
+ }),
96
+ ```
97
+
98
+ | Option | Type | Default | Meaning |
99
+ | --- | --- | --- | --- |
100
+ | `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. |
101
+ | `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`. |
102
+ | `trial.reminderDays` | integer 0 to 365 | `3` | From how many days left the trial counts as ending (badge tone, notice). `0`: never. |
103
+ | `paid.reminderDays` | integer 0 to 365 | `7` | The same for dated paid access. Lifetime access never ends. |
104
+ | `plans` | array, at most 12 | `[]` | The plans, in the order the tiles show them (see below). |
105
+ | `payment` | `PaymentProvider` | — | The adapter the payment page uses: `stripe()` or `manual({ onRequest })`. The payment page throws without one. |
106
+ | `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. |
107
+ | `requests.expireAfterDays` | 1-365 | `30` | Days an open invoice request waits, counted from the buyer's last ask; `expireStaleRequests` (run daily, see "Invoice requests" in §4) then closes it as `expired` and clears its invoice details. |
108
+ | `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. |
109
+ | `routes.payment` | path | `/payment` | Where `PaymentPage` is mounted; the notice and the tiles link there, and Stripe Checkout returns there. |
110
+ | `routes.admin` | path | `/admin/billing` | Where `BillingAdminPage` is mounted; its actions revalidate it, and the account lookup sends the admin there with `?account=<id>`. |
111
+ | `routes.webhook` | path | `/api/billing/webhook` | Where `stripeWebhookRoute` is mounted (the Stripe endpoint's URL). |
112
+ | `messages` | partial `en` / `pl` | — | Copy overrides. |
113
+
114
+ **A plan** is `{ id, name, description?, price: { amount, currency }, period, features?, isFeatured? }`:
115
+ `id` kebab-case and unique; `name`, `description` and each feature line a text per locale with at
116
+ least `en`; `amount` an integer in the currency's minor unit as billing pins it (2900 is 29.00 PLN,
117
+ 1500 is ¥1,500, 1500 is ISK 1,500, 1250 is KWD 1.250, 2950 is HUF 29.50);
118
+ `currency` an upper-case ISO 4217 code billing knows; `period` `"day"`, `"week"`, `"month"`, `"year"`,
119
+ `"lifetime"` or `{ unit, count }` such as `{ unit: "month", count: 3 }`. A plan has one currency; a
120
+ second currency is a second plan. Plans live in the config, not in a table: a price change is a
121
+ deploy, and a payment record (with the price paid) belongs to the provider.
122
+
123
+ **Minor units.** The digits of each currency's minor unit come from a table billing pins
124
+ (`CURRENCY_MINOR_UNIT_DIGITS`): ISO 4217 List One of 2024-06-25 without funds and units that are not
125
+ prices, MGA counted without a minor unit (its subunit is a fifth, as Stripe counts it) and XCG added.
126
+ The runtime's `Intl` only supplies the notation (symbol, separators, where the sign goes), so a price
127
+ means the same amount on every Node build; `Intl`'s own digits follow its CLDR data and have changed
128
+ between builds (HUF had 0 on some and 2 on others). A code outside the table (HRK, SLL, a typo) is
129
+ refused when the config loads. Where `Intl` on current runtimes counts other digits, an amount
130
+ written against `Intl`'s unit must be converted: AFN, ALL, IRR, KPW, LAK, LBP, MMK, RSD, SOS, SYP and
131
+ YER have two decimals here (ALL 1,500 is `150000`), IQD three (IQD 25,000 is `25000000`), and HUF and
132
+ TWD two on every runtime.
133
+
134
+ **A paid period** runs in local calendar days like a trial, the start day included: a month granted
135
+ on 3 October covers every day to 2 November and ends when 3 November begins. It starts when the
136
+ access the account already has ends (a running trial or paid access), so paying early loses no day,
137
+ and each grant adds one period. A month keeps the day of the month where it can: from 31 January it
138
+ ends with 27 February (the 28th starts the next period), and renewals then continue from the 28th.
139
+ A lifetime plan grants lifetime access; a dated grant to a lifetime account changes nothing.
140
+
141
+ **Stripe.** `stripe({ secretKey?, apiBase?, fetch? })` reads `STRIPE_SECRET_KEY` on every payment
142
+ (so the config loads at build time without it) and creates one Checkout session per payment
143
+ (`mode: "payment"`): the plan's price as a one-off line item in its currency, the plan's name in the
144
+ app's locale, the buyer's email, and the account id and plan id in the session's and the
145
+ PaymentIntent's metadata (`softure_user_id`, `softure_plan_id`). The buyer returns to
146
+ `routes.payment` with `?checkout=success` (the page thanks them; access follows the webhook, usually
147
+ within seconds) or `?plan=<id>&checkout=cancelled`. Payment methods are the ones enabled in the
148
+ Stripe dashboard (cards, BLIK, Przelewy24, transfers). A refusal, a timeout or a missing key is
149
+ `billing.payment_failed` for the buyer and one log line (HTTP status and Stripe's error type and
150
+ code, never its message or the key). Subscriptions are not used: each payment buys one period, and
151
+ renewing is paying again, as with `manual()`.
152
+
153
+ **Stripe's currency units.** Stripe takes amounts in its own unit per currency
154
+ ([currency guide](https://docs.stripe.com/currencies)), which is not always billing's: ISK and UGX
155
+ have no decimals in ISO 4217 but two (always `00`) at Stripe. Plans stay in billing's unit;
156
+ `stripe()` converts what it sends (ISK 1,500 goes as `150000`) and the webhook converts
157
+ what Stripe reports back, so payments and refunds are stored and shown in the plan's unit. HUF and
158
+ TWD need nothing: Stripe's divisible-by-100 rule for them is for payouts, not charges. A price
159
+ `stripe()` cannot charge exactly is refused when the config loads, naming the plan: a three-decimal
160
+ amount (BHD, JOD, KWD, OMR, TND) whose last digit is not 0, or an amount finer than Stripe's unit
161
+ (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
162
+ Stripe checks them at Checkout (`billing.payment_failed` and a log line).
163
+
164
+ **Days and time zones.** Trials end at the start of a local day in `config.timezone`: a 14-day
165
+ trial begun at any hour of 3 October ends when 17 October begins there, so 16 October is its last
166
+ day. Days left count local calendar days, today included (1 on the last day). Access covers every
167
+ instant before its end; at the end itself the account is read-only. Paid access wins over a trial;
168
+ a trial that outlasts paid access takes over again when the payment ends.
169
+
170
+ ## 4. Mounting
171
+
172
+ Mount the payment page at `routes.payment` and the admin page at `routes.admin`, one line each:
173
+
174
+ ```ts
175
+ // app/payment/page.tsx
176
+ export { PaymentPage as default } from "@softure-ai/billing/next";
177
+ // app/admin/billing/page.tsx
178
+ export { BillingAdminPage as default } from "@softure-ai/billing/next";
179
+ // app/api/billing/webhook/route.ts (with stripe(); public, outside any auth guard)
180
+ export { stripeWebhookRoute as POST } from "@softure-ai/billing/next";
181
+ ```
182
+
183
+ **The Stripe webhook.** In the Stripe dashboard, add an endpoint at `<appOrigin>/api/billing/webhook`
184
+ for `checkout.session.completed`, `checkout.session.async_payment_succeeded`, `charge.refunded` and
185
+ `refund.failed`, and put its signing secret in `STRIPE_WEBHOOK_SECRET` (locally: `stripe listen --forward-to
186
+ localhost:3000/api/billing/webhook` prints one). The route checks `Stripe-Signature` (HMAC-SHA256,
187
+ at most five minutes old, any `v1` entry during a secret rotation) before it parses the body (at
188
+ most 256 KiB) or touches the database, then:
189
+
190
+ | Delivery | Effect | Answer |
191
+ | --- | --- | --- |
192
+ | 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 |
193
+ | the same checkout again (a retry, or both events of a delayed payment) | nothing | 200 |
194
+ | a checkout still waiting for a transfer, a charge with nothing refunded, any other event, a session billing did not create | nothing | 200 |
195
+ | `charge.refunded` with `refunded: true` for a stored payment | the payment is marked refunded and what it granted taken back, once | 200 |
196
+ | `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 |
197
+ | `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 |
198
+ | `charge.refunded` or a Refund event without the event's `created` | nothing | 400 |
199
+ | 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 |
200
+ | no or a wrong signature, a replay, a body that is not a Stripe event | nothing | 400 |
201
+ | no `STRIPE_WEBHOOK_SECRET`, a database failure | nothing; Stripe retries | 500 |
202
+
203
+ **Refunds.** Each payment row records what its grant added: a period (from where access ended, the
204
+ trial's end or the payment's instant, to the period's end) or lifetime access. A full refund takes
205
+ back only that, with the pure `getRefundEvent` (root entry):
206
+
207
+ - **A period** loses its unused days, `[max(from, now), until)`, counted in local days of the app's
208
+ time zone: the dated end moves back by that many days. Access ahead of now is one unbroken run
209
+ (every grant starts where running access ends), so the other stacked periods, manual grants and
210
+ the trial keep their length. A period already used up takes nothing back, and an old payment
211
+ refunded after a lapse never touches a newer period. The stored periods of the payments stacked
212
+ after it move back by the same days, so a later refund of one of them takes back the right
213
+ days. A dated end moved to the trial's end or before it drops paid access: the account is back
214
+ on its trial.
215
+ - **A lifetime** ends lifetime access unless another lifetime payment of the account is still
216
+ `paid` or an active manual lifetime grant (`billing.manual_grants`) still gives it. Lifetime
217
+ keeps the dated end beside it (a grant on lifetime still extends it), so the months bought next
218
+ to a refunded lifetime stay.
219
+ - **A payment stored before grants were recorded** (no grant columns) revokes paid access, as
220
+ before.
221
+
222
+ **Partial refunds.** A payment keeps the total refunded so far (`refunded_amount`, from Stripe's
223
+ cumulative `amount_refunded`) and stays `paid` until that total reaches its amount. Under
224
+ `partialRefunds: "pro_rata"` (the default) each partial refund takes back the share of the period's
225
+ unused days that the newly refunded money is of the money not refunded before, rounded down to
226
+ whole days (the account keeps a part of a day), and the payment's stored period ends that many days
227
+ earlier; the periods stacked after it move back too. Refunding 14.50 of a 29.00 month bought for 31
228
+ unused days takes back 15. The refund that completes the amount is a full refund, so partial refunds
229
+ that add up to the payment end exactly where one full refund at the time of the last one would.
230
+ Under `"keep_access"` (refunds as goodwill or compensation) a partial refund takes nothing back,
231
+ and the completing one takes back every unused day left. Either way a partial refund never ends a
232
+ lifetime nor revokes a payment stored before grants were recorded: only the completing refund does.
233
+
234
+ **Failed refunds.** A refund the bank or card refuses after Stripe reported it (`refund.failed`)
235
+ gives back what it took, once per refund id (`billing.refund_failures`):
236
+
237
+ - the payment's `refunded_amount` drops by the refund's amount, and a payment refunded in full is
238
+ `paid` again;
239
+ - a lifetime payment refunded in full gives lifetime access back;
240
+ - a period gets back days: each payment keeps the local days refunds took from it
241
+ (`taken_back_days`), and a failure gives back the failed money's share of them under
242
+ `pro_rata` (rounded down; the failure that leaves nothing refunded gives back the rest), all of
243
+ them under `keep_access` (only the completing refund took any). While the payment is still paid
244
+ and its period still ahead, the days go back right after that period and the periods stacked
245
+ after it move forward again, the refund in reverse. Otherwise (refunded in full, or the period
246
+ used up) they are a grant at the end of the account's access, from the latest of the trial's end,
247
+ dated access and now, and the payment's stored period becomes that grant;
248
+ - a payment stored before grants were recorded gets its total and status back, not its access.
249
+
250
+ Stripe does not order events, so billing keeps the time of the newest charge state it recorded
251
+ (`refunds_seen_at`, the event's `created`). A failure of a refund created after that state, or failed
252
+ before it, was never counted: it is recorded and gives back nothing. A charge state taken before a
253
+ failure (a late retry of `charge.refunded`) still counts the failed refund; billing subtracts it, so
254
+ the delivery changes nothing. A lower `amount_refunded` on its own is never read as a failure.
255
+
256
+ A new refund can be reported before the failure of an earlier one: the bank refuses refund A, then
257
+ refund B's `charge.refunded` arrives with an `amount_refunded` that no longer counts A, so it is not
258
+ above what billing recorded. When such a state is newer than every one billing recorded, the
259
+ payment keeps it (`pending_refunded_amount`, `pending_refunds_seen_at`; only the newest) and the
260
+ delivery answers `duplicate`. Each failure billing records then gives back what the failed refund
261
+ took and applies the kept state if, less the failures it still counts, it reports more than billing
262
+ now counts: B is taken back once, by the `partialRefunds` policy (in full when it completes the
263
+ amount). A kept state waits through as many failures as it needs, and a state applied at or after
264
+ its time clears it.
265
+
266
+ The decision is made under the entitlement row's lock, so a lifetime bought at the same moment is
267
+ either seen or granted after the refund.
268
+
269
+ There is no rate limit on the route: Stripe sends from a few addresses, and an unsigned request costs
270
+ one HMAC.
271
+
272
+ `PaymentPage` needs a session (a visitor goes to the login page and back) and shows the account's
273
+ badge and the plans; with `?plan=<id>` it shows the order and the provider's form: the invoice
274
+ details for `manual()`, one checkout button for a hosted provider. An account with lifetime access
275
+ sees that it has nothing left to pay for instead of the order (`startPayment` refuses it with
276
+ `billing.lifetime_active`). Show the plans anywhere else, e.g. a public pricing page, with
277
+ `<Pricing LinkComponent={Link} />`.
278
+
279
+ **The admin page.** `BillingAdminPage` answers "not found" to anyone without `adminRole`; every
280
+ action checks the role from the session again before reading its form. It has three cards:
281
+
282
+ - **Invoice requests**: the open requests, oldest first, with the account, the plan, when it was
283
+ asked for, the price it quoted and the invoice details (the latest ones: a buyer who asks again
284
+ 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
285
+ transaction (`grantPaymentRequest`); **Dismiss** closes it without a grant; **History** opens the
286
+ account's history. A request is granted or dismissed once: a second click finds it closed.
287
+ - **Grant access**: a plan for the account with a given email, recorded like a request's grant.
288
+ An account with lifetime access is refused (`billing.lifetime_active`): a dated period under
289
+ lifetime would be invisible.
290
+ - **Account history**: the email lookup sends the admin to `?account=<id>` (no address in a URL),
291
+ which shows the account's badge and its manual grants and provider payments, newest first, each
292
+ with its price, the access it added and its state. **Revoke** on an active manual grant takes back only
293
+ what it added, like a refund: a period loses its unused days and the periods stored after it
294
+ (manual or paid) move back; a lifetime ends unless another active manual lifetime or a paid
295
+ lifetime payment still gives it. Provider payments are refunded at the provider, not here.
296
+
297
+ **Invoice requests.** `startPayment` stores a manual request first, then claims its hand-over on
298
+ the row and calls `onRequest`; an open request is handed over once, and asking again only
299
+ refreshes its details, price and time. When `onRequest` answers an `Err` or throws, the claim is
300
+ released: the buyer sees `billing.payment_failed`, the admin page still lists the request, and the
301
+ next ask hands it over. A claim left without an answer (the process stopped while `onRequest` ran)
302
+ 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:
303
+ `billing.invoice_field_required`, `billing.invoice_field_too_long` (the copy names the limit:
304
+ 200, 32, 500) or `billing.invoice_field_control_characters` (line breaks, tabs and other control
305
+ characters, in every field, so a name cannot add lines to the owner's mail; the database refuses
306
+ them too). Run `expireStaleRequests(ctx)` (`/server`) daily, like the reminder mail, to close
307
+ requests nobody asked again for in `requests.expireAfterDays` days; it returns `{ expired }`, and
308
+ a repeated run closes nothing new:
309
+
310
+ ```ts
311
+ // scripts/expire-invoice-requests.ts (cron: 0 3 * * *)
312
+ import { expireStaleRequests } from "@softure-ai/billing/server";
313
+ import { systemClock } from "@softure-ai/core";
314
+ import { createDatabase } from "@softure-ai/db";
315
+ import config from "../softure.config.ts";
316
+
317
+ if (config.database === null) throw new Error("expire-invoice-requests: the config has no database");
318
+ const database = await createDatabase(config.database.url, { max: 1 });
319
+ try {
320
+ console.log(JSON.stringify(await expireStaleRequests({ db: database.db, clock: systemClock, config })));
321
+ } finally {
322
+ await database.close();
323
+ }
324
+ ```
325
+
326
+ **Reminder mail.** With `mailing({ ... })` in the config, run `sendAccessReminders` on a schedule,
327
+ e.g. a daily cron job (or a platform scheduler) running a script:
328
+
329
+ ```ts
330
+ // scripts/send-access-reminders.ts (cron: 0 9 * * *)
331
+ import { sendAccessReminders } from "@softure-ai/billing/mailing";
332
+ import { systemClock } from "@softure-ai/core";
333
+ import { createDatabase } from "@softure-ai/db";
334
+ import config from "../softure.config.ts";
335
+
336
+ if (config.database === null) throw new Error("send-access-reminders: the config has no database");
337
+ const database = await createDatabase(config.database.url, { max: 1 });
338
+ try {
339
+ console.log(JSON.stringify(await sendAccessReminders({ db: database.db, clock: systemClock, config })));
340
+ } finally {
341
+ await database.close();
342
+ }
343
+ ```
344
+
345
+ Each run mails the four states the notice shows: the trial or dated paid access ending (from
346
+ `trial.reminderDays` / `paid.reminderDays` days left, "ends on {date}") and ended ("has ended",
347
+ from the day it ended through `catchUpDays` days after it, default 3, so turning reminders on never
348
+ mails accounts that lapsed long ago; an account created without a trial gets no "trial ended" mail).
349
+ Lifetime access gets nothing. The mail is transactional (no unsubscribe footer: it is an account
350
+ notice), in the app's locale, with the notice's link text and the absolute payment page URL. Each
351
+ account gets one mail per kind and end (scope `billing.<kind>:<account id>:<end>` in
352
+ `mailing.deliveries`): a run repeated the same day, or two runs at once, send nothing new, while an
353
+ extended trial or a renewal is a new window. Options: `catchUpDays` (0 to 365), `pauseMs` between two
354
+ mails the provider was called for (default 500, Resend's two requests per second). The summary counts
355
+ `due`, `sent`, `skipped` (sent by an earlier run, or another run is sending it), `rejected` (refused
356
+ for good) and `retryLater` (the provider was unavailable; the next run sends it). A database failure
357
+ throws; the next run resumes. Candidates come from two range queries, the stored ends and
358
+ `auth.users.created_at` (indexed by auth's `0004`) for accounts without a row, never a scan of every
359
+ account.
360
+
361
+ Guard every write action of the app, before reading any input:
362
+
363
+ ```ts
364
+ "use server";
365
+ import { requireWriteAccess } from "@softure-ai/billing/next";
366
+
367
+ export async function saveNote(formData: FormData) {
368
+ const access = await requireWriteAccess(); // no session: redirects to the login page
369
+ if (!access.ok) return access; // Err("billing.read_only"), for the form to show
370
+ // ... the app's own authorization and the write, as access.value.user
371
+ }
372
+ ```
373
+
374
+ Show where the account stands in any server component (both render nothing without a session):
375
+
376
+ ```tsx
377
+ import { CurrentAccessBadge, CurrentAccessNotice } from "@softure-ai/billing/next";
378
+ import Link from "next/link";
379
+
380
+ <CurrentAccessBadge />
381
+ <CurrentAccessNotice LinkComponent={Link} />
382
+ ```
383
+
384
+ Server functions, for scripts and other hosts (`@softure-ai/billing/server`):
385
+ `grantPlanManually(ctx, { userId, planId, adminId, requestId? })` grants one payment of a plan,
386
+ records it in the account's history (closing the request when given) and returns
387
+ `{ grantId, entitlement }` (`Err<billing.plan_unknown | billing.account_unknown |
388
+ billing.lifetime_active | billing.request_closed>` otherwise); `grantPaymentRequest(ctx, { requestId,
389
+ adminId })` does it for an open request; `revokeManualGrant(ctx, { grantId, adminId })` takes one
390
+ back (`Err<billing.grant_revoked>` when it was revoked before); `getAccountHistory(ctx, userId)`,
391
+ `listOpenRequests(ctx, limit?)`, `dismissPaymentRequest(ctx, requestId)`. `grantPlan(ctx, userId,
392
+ planId)` grants without a record: nothing to revoke, not in the history; scripts that grant for
393
+ an admin use `grantPlanManually`. `startPayment(ctx, input)` counts the `billing-payment` bucket
394
+ per account, checks the plan, lifetime access and the invoice details (`parseInvoiceDetails`, a zod
395
+ schema, `invoiceDetailsSchema` from the root entry), stores a request for a provider that hands
396
+ requests over and calls the provider once per open request (see "Invoice requests");
397
+ `expireStaleRequests(ctx)` closes requests older than `requests.expireAfterDays`; `findAccountByEmail(ctx, email)`,
398
+ `findAccountById(ctx, id)`, `getBillingPlans(config)`.
399
+ `receiveStripeWebhook(ctx, { payload, signature, secret })` is the route without Next;
400
+ `recordPayment(ctx, { provider, checkoutId, paymentId, userId, planId, amount, currency })` and
401
+ `refundPayment(ctx, { provider, paymentId })` are its two writes, for another provider's webhook.
402
+ `getEntitlement(ctx, userId)` returns the `Entitlement` or null for an unknown account;
403
+ `checkWriteAccess(ctx, userId)` returns `Ok<Entitlement>` or `Err<billing.read_only | billing.account_unknown>`;
404
+ `changeEntitlement(ctx, userId, event)` returns the `Entitlement` after the change or
405
+ `Err<billing.end_not_in_future | billing.account_unknown>`. A refused event writes nothing. `event`
406
+ may also be a function of the current record, run under the row's lock (how `grantPlan` extends a
407
+ period without losing a concurrent grant).
408
+
409
+ **Scripts.** `@softure-ai/billing/scripts` builds ops scripts on `@softure-ai/ops/scripts` (dry run by
410
+ default, `--commit` writes, one transaction), for an operator without the admin page or at a
411
+ terminal: `grant-plan` and `revoke-grant` here, `import-entitlements` and `pin-trials` under
412
+ "Existing accounts" below. The app bundles them like its other scripts and runs them with its
413
+ database URL:
414
+
415
+ ```ts
416
+ // scripts/grant-plan.ts: npm run grant-plan -- --email=member@example.com --plan=monthly [--commit]
417
+ import { createGrantPlanScript } from "@softure-ai/billing/scripts";
418
+ import { runOpsScript } from "@softure-ai/ops/scripts";
419
+ import config from "../softure.config";
420
+
421
+ process.exitCode = await runOpsScript({ script: createGrantPlanScript(config), argv: process.argv.slice(2), config });
422
+ ```
423
+
424
+ - `grant-plan --email=… --plan=<plan id>` grants one payment of a declared plan through
425
+ `grantPlanManually` (no admin: `granted_by` is null), so it is in the account's history and the
426
+ admin page can revoke it. Refuses an undeclared plan (naming the declared ones), an unknown email
427
+ and an account with lifetime access.
428
+ - `revoke-grant --email=… --grant=<id>` revokes one active manual grant of that account through
429
+ `revokeManualGrant` (a script's or the admin page's) and takes back what it added. Refuses an
430
+ unknown email and an id that is not an active manual grant of the account (another account's,
431
+ revoked, mistyped).
432
+
433
+ Both print the account's state `before` and `after`: the user id (never the email), the
434
+ entitlement and the active manual grants with their ids, newest first; a dry run of either script
435
+ shows the id `revoke-grant` takes. `createGrantPlanScript(config, { clock? })` and
436
+ `createRevokeGrantScript(config, { clock? })` take a clock for tests (`executeOpsScript`).
437
+
438
+ **Existing accounts.** An account without a `billing.entitlements` row is on the trial derived from
439
+ its creation day (§5), so turning billing on for accounts that already exist would make every one
440
+ older than `trial.days` read-only at once. Three tools, in this order, keep their access:
441
+
442
+ 1. **A trial floor**, `billing({ trial: { startsAt: "2026-11-01" } })`: every account created before
443
+ that local day gets its `trial.days` from it, on every read, without a write; accounts created on
444
+ or after it keep their own trial. The reminder mail sees the floored trials too, so all those
445
+ accounts get their trial-ending mail in the same window.
446
+ 2. **An import** of what the old system knew (FIRE_TRACKER's `trial_ends_at`, `paid_until`):
447
+
448
+ ```ts
449
+ // scripts/import-entitlements.ts: npm run import-entitlements -- --file=entitlements.json [--commit]
450
+ import { createImportEntitlementsScript } from "@softure-ai/billing/scripts";
451
+ import { runOpsScript } from "@softure-ai/ops/scripts";
452
+ import config from "../softure.config";
453
+
454
+ process.exitCode = await runOpsScript({ script: createImportEntitlementsScript(config), argv: process.argv.slice(2), config });
455
+ ```
456
+
457
+ The file (its path relative to the working directory) is a JSON array, at most 50,000 rows:
458
+
459
+ ```json
460
+ [
461
+ { "email": "ada@example.com", "trialEndsAt": "2026-08-15T00:00:00+02:00", "paidUntil": "2027-01-01T00:00:00+01:00" },
462
+ { "email": "grace@example.com", "isLifetime": true }
463
+ ]
464
+ ```
465
+
466
+ Each row needs `email` and at least one of `trialEndsAt`, `paidUntil` (ISO 8601 with an offset,
467
+ the first instant without access, as `billing.entitlements` stores it; `null` for none) and
468
+ `isLifetime`. Each is merged onto the account's current record (its row, or its derived and
469
+ floored trial) by the `import` event: an end only moves later, lifetime only turns on, so an
470
+ import never takes access away and running the same file again changes nothing. An imported end
471
+ earlier than the account's own is therefore not recorded; past ends later than it are (an ended
472
+ paid period shows as `paid_ended`). The import is not a grant: it is not in the account's history
473
+ and is not revocable from the admin page (correct a mistake with `changeEntitlement`'s `revoke` or
474
+ `shorten`). The whole file is one transaction and refused as a whole for a file that cannot be
475
+ read, a row that fails the format, an email repeated in the file (compared as auth stores emails)
476
+ or an email no account has; refusals name row numbers, never emails. The report counts the named
477
+ accounts by state (`trial`, `paid`, `lifetime`, `readOnly`) `before` and `after`. Split a very
478
+ large file: every row takes its locks until the end of the run. `importEntitlement(ctx, { userId,
479
+ trialEndsAt?, paidUntil?, isLifetime? })` (`/server`) is the same merge for an app that migrates in
480
+ its own code; it returns the `Entitlement` or `Err<billing.account_unknown>`.
481
+ 3. **A pin** before any change of `trial.days`, `trial.startsAt` or `config.timezone`:
482
+ `npm run pin-trials [-- --commit]` (`createPinTrialsScript(config)`, no arguments) writes the trial
483
+ every account without a row is on, exactly as reads derive it, into a row, so the change moves no
484
+ existing trial and applies to new accounts only. Rows written meanwhile by a change are kept; a
485
+ second run pins nothing. The report gives `accountsWithoutRow` `before` and `after`, and `pinned`.
486
+ `pinDerivedTrials(ctx)` (`/server`) is the same step, returning how many rows it wrote.
487
+
488
+ Both scripts take `{ clock? }` for tests, like the plan scripts.
489
+
490
+ **A payment provider** (`PaymentProvider` from the root entry) has a `name`, says whether the page
491
+ collects invoice details (`collectsInvoiceDetails`) and whether it hands requests to the owner
492
+ instead of sending the buyer to a checkout (`handsOverRequests`: billing then stores the request
493
+ before the call and makes the call once per open request), and implements
494
+ `startPayment(ctx, { plan, account, invoice, returnUrl })`, which resolves with
495
+ `{ type: "redirect", url }` (a hosted checkout; the action redirects there), `{ type: "requested" }`
496
+ (handed over, only for `handsOverRequests: true`; the page confirms) or
497
+ `Err<billing.payment_failed>`. Granting access afterwards goes through `grantPlanManually` (an
498
+ admin) or `recordPayment` (a webhook).
499
+
500
+ ## 5. Migrations and tables
501
+
502
+ `migrations/0001_create_entitlements.sql` creates `billing.entitlements`:
503
+
504
+ | Column | Meaning |
505
+ | --- | --- |
506
+ | `user_id` | `uuid`, primary key, references `auth.users(id)` `ON DELETE CASCADE`. |
507
+ | `trial_ends_at` | The first instant the trial no longer covers. |
508
+ | `paid_until` | The first instant dated paid access no longer covers; NULL when never paid or revoked. Kept under lifetime (since `0003`). |
509
+ | `is_lifetime` | Paid access without an end; it wins over `paid_until`. |
510
+ | `created_at`, `updated_at` | The first change and the last one. |
511
+
512
+ **No row until something changes.** Reads never write: an account without a row gets its trial
513
+ derived from `auth.users.created_at`, `trial.days`, `trial.startsAt` and `config.timezone`, and
514
+ auth's single `onRegistered` hook stays free for the app. Accounts created before billing was
515
+ enabled are on that derived trial too, so without `trial.startsAt` or an import those older than
516
+ `trial.days` are read-only from the first read (see "Existing accounts" in §4). The first change
517
+ (`changeEntitlement`, an import, `pin-trials`) stores the derived trial end with the event applied,
518
+ so the trial end never moves when a row appears. Until then every read derives it again, so for
519
+ accounts without a row:
520
+
521
+ | Config change | Effect |
522
+ | --- | --- |
523
+ | shorter `trial.days` | every derived trial ends earlier: accounts past the new end are read-only at once |
524
+ | longer `trial.days` | every derived trial ends later: accounts whose trial had ended can write again |
525
+ | `trial.startsAt` set, moved or removed | the trial of every account created before the (old or new) floor day moves with it |
526
+ | `config.timezone` | every derived trial ends at the start of the same local day in the new zone, hours earlier or later |
527
+
528
+ Run `pin-trials` before such a change to keep existing trials where they are.
529
+
530
+ `migrations/0002_create_payments.sql` creates `billing.payments`, one row per paid provider checkout:
531
+
532
+ | Column | Meaning |
533
+ | --- | --- |
534
+ | `id` | `uuid`, primary key. |
535
+ | `user_id` | The account, references `auth.users(id)` `ON DELETE CASCADE`. |
536
+ | `provider` | The adapter's name, `stripe`. |
537
+ | `checkout_id` | The provider's checkout (`cs_...`); unique per provider: a checkout grants once. |
538
+ | `payment_id` | The provider's payment (`pi_...`) refunds name; unique per provider; NULL for a free checkout. |
539
+ | `plan_id`, `amount`, `currency` | The plan and what the provider charged, in the currency's minor unit. |
540
+ | `status`, `paid_at`, `refunded_at` | `paid` or `refunded`; a CHECK ties `refunded_at` to the status. |
541
+ | `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. |
542
+ | `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`). |
543
+ | `taken_back_days` | The local days refunds took from the payment's period so far (`0007`); a failed refund gives back its share. |
544
+ | `refunds_seen_at` | When Stripe took the newest charge state billing recorded (`0007`, the event's `created`); NULL before any refund. |
545
+ | `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`). |
546
+
547
+ `migrations/0003_record_payment_grants.sql` adds the grant columns and drops the CHECK that kept
548
+ `paid_until` NULL under lifetime. `migrations/0005_record_refunded_amounts.sql` adds
549
+ `refunded_amount` (set to `amount` on payments refunded before).
550
+ `migrations/0006_record_request_handover_and_prices.sql` adds `handed_over_at` (set to
551
+ `requested_at` on open requests, which were all handed over), the price columns of both manual
552
+ tables, the `expired` status and the control-character CHECKs.
553
+ `migrations/0007_record_failed_refunds.sql` adds `taken_back_days` (0 on payments refunded before:
554
+ their failure gives back no days) and `refunds_seen_at` (set to `refunded_at`, or the migration's
555
+ time, on payments with a refund), and creates `billing.refund_failures`, one row per failed refund:
556
+ `payment_id` (references `billing.payments(id)` `ON DELETE CASCADE`), `refund_id`, `amount`,
557
+ `refund_created_at`, `failed_at` (the event's `created`) and `recorded_at`, primary key
558
+ `(payment_id, refund_id)`.
559
+ `migrations/0008_record_request_handover_claims.sql` adds `handover_claimed_at`; `handed_over_at`
560
+ keeps its values, so requests from before count as handed over.
561
+ `migrations/0009_record_pending_charge_states.sql` adds `pending_refunded_amount` and
562
+ `pending_refunds_seen_at` (NULL on every payment from before: nothing was kept).
563
+
564
+ The insert, the grant and its grant columns share a transaction, as do the refund's conditional update and the change it makes,
565
+ so a delivery seen twice changes nothing. Every write takes the account first (like the privacy
566
+ erase); a refund and a manual revoke then take the entitlement before their own row, since moving
567
+ the later periods back updates other rows under that lock.
568
+
569
+ `migrations/0004_create_requests_and_grants.sql` creates the manual payments' two tables:
570
+
571
+ `billing.payment_requests`, one row per invoice request:
572
+
573
+ | Column | Meaning |
574
+ | --- | --- |
575
+ | `id`, `user_id`, `plan_id` | The request, its account (`ON DELETE CASCADE`) and the plan asked for. |
576
+ | `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). |
577
+ | `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. |
578
+ | `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. |
579
+ | `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. |
580
+ | `amount`, `currency` | The plan's price at the last ask, in the currency's minor unit (`0006`); NULL on rows stored before. |
581
+
582
+ One open request per account and plan (partial unique index `payment_requests_one_open`): asking
583
+ again refreshes its details and time.
584
+
585
+ `billing.manual_grants`, one row per plan an admin granted:
586
+
587
+ | Column | Meaning |
588
+ | --- | --- |
589
+ | `id`, `user_id`, `plan_id` | The grant, its account (`ON DELETE CASCADE`) and the plan. |
590
+ | `request_id` | The request it answered (unique), or NULL for a grant by email. |
591
+ | `granted_by`, `revoked_by` | The admins (`ON DELETE SET NULL`). |
592
+ | `granted_at`, `grant_kind`, `granted_from`, `granted_until` | What it added, as `billing.payments` records it (CHECK `manual_grants_grant_shape`). |
593
+ | `status`, `revoked_at` | `active` or `revoked`; CHECKs tie `revoked_at` and `revoked_by` to the status. |
594
+ | `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. |
595
+
596
+ A grant and the request it closes share a transaction; a grant and a revoke take the account, then
597
+ the entitlement, then their row (a conditional update), the order of a refund.
598
+
599
+ ## 6. Environment variables
600
+
601
+ | Name | Required | Meaning |
602
+ | --- | --- | --- |
603
+ | `STRIPE_SECRET_KEY` | with `stripe()` unless `stripe({ secretKey })` | The secret API key (`sk_test_...` in the sandbox), read on every payment. |
604
+ | `STRIPE_WEBHOOK_SECRET` | when `stripeWebhookRoute` is mounted | The webhook endpoint's signing secret (`whsec_...`). |
605
+
606
+ ## 7. Switches
607
+
608
+ None.
609
+
610
+ ## 8. Appearance
611
+
612
+ `AccessBadge` takes `classNames` for its slots `root`, `status` and `detail`; the status colour
613
+ follows the state (`--sft-color-foreground` for a trial, `success` when paid, `warning` in a
614
+ reminder window, `danger` when read-only). It sets `data-status` and, in a reminder window,
615
+ `data-ending="true"` for app styles. `AccessNotice` takes `root`, `message` and `actions` and
616
+ renders the `@softure-ai/ui` `ButtonLink`; ended access uses the danger surface of `FormError`.
617
+ Both accept `unstyled`. `PricingTiles` takes `root`, `tile`, `badge`, `name`, `description`,
618
+ `priceRow`, `price`, `period`, `features`, `feature`, `featureIcon` and `action`; a featured or chosen
619
+ tile gets `--sft-border-strong` and a shadow, and sets `data-plan`, `data-featured` and
620
+ `aria-current`. `PaymentForm` and `GrantForm` take `root`, `form` and `notice`.
621
+
622
+ ## 9. Copy
623
+
624
+ `billingMessages` (`en`, `pl`): `badge` (status names, `daysLeft` plural forms, `until`), `notice`
625
+ (the four notices and their two link texts), `pricing` (period plural forms per unit, `lifetime`,
626
+ `featured`, `choose`, `empty`), `reminderMail` (`subject` and `body` of `trialEnding`,
627
+ `paidEnding`, `trialEnded` and `paidEnded`; the link text is the notice's), `payment` (the payment page, the invoice form and the notices after a
628
+ hosted checkout, `checkoutSuccess` and `checkoutCancelled`), `admin` (the
629
+ grant form) and `errors`. Plan names, descriptions and features come from the config, per locale. `{date}` is the last day of access in
630
+ the app's locale and time zone, `{count}` the days left. Override them with
631
+ `billing({ messages: { en: { notice: { choosePlan: "See plans" } } } })`.
632
+
633
+ ## 10. Hooks
634
+
635
+ `manual({ onRequest(request, ctx) })` receives every invoice request (plan, account, invoice
636
+ details, return URL) and resolves with `Ok` once handed over, or an `Err` the buyer sees as
637
+ `billing.payment_failed`. Apps react to a change in their own code around `changeEntitlement` and
638
+ `grantPlan`. The Stripe webhook has no hook yet; its effect shows in `getEntitlement`.
639
+
640
+ ## 11. GDPR
641
+
642
+ - Export: the account's entitlement row (`trialEndsAt`, `paidUntil`, `isLifetime`, `createdAt`,
643
+ `updatedAt`), or `entitlement: null` for an account without one, its payments oldest first
644
+ (`provider`, `checkoutId`, `paymentId`, `planId`, `amount`, `currency`, `status`, `paidAt`,
645
+ `refundedAt`, `refundedAmount`, `grantKind`, `grantedFrom`, `grantedUntil`), the refunds of them that
646
+ failed (`paymentId`, `refundId`, `amount`, `refundCreatedAt`, `failedAt`), its invoice requests (`planId`, the
647
+ invoice details while open, `amount`, `currency`, `status`, `requestedAt`, `closedAt`) and the
648
+ plans granted to it by hand (`planId`, `grantedAt`, the grant, `status`, `revokedAt`, `amount`,
649
+ `currency`). Which admin granted or revoked
650
+ is the admin's data and stays out of the account's export.
651
+ - Deletion: the row, the payments (their failed refunds with them), the requests and the manual grants, and the foreign keys remove
652
+ them with the account too; an erased admin's id is cleared from the grants they made.
653
+ Stripe keeps its own record of each payment (the controller's accounting record there).
654
+ - Reminder mail: what was sent is in `mailing.deliveries` under a recipient key (never the
655
+ address) and a scope naming the account id and the end; mailing keeps that ledger after an account
656
+ is deleted (see its README §11), when the id no longer points at anyone.
657
+ - Retention: invoice details are personal data the app needs only until the request is handled,
658
+ so granting or dismissing it erases them, and `expireStaleRequests` erases them from a request
659
+ nobody asked again for in `requests.expireAfterDays` days (30 by default). What `onRequest` delivered (the mail to the owner) and
660
+ the issued invoice are the app's and the owner's own records.
661
+
662
+ ## 12. Limitations
663
+
664
+ - A refund that reaches the app before its checkout (Stripe does not order events) finds no
665
+ payment and is not retried.
666
+ - A failed refund gives back a share of the days refunds took, not the exact days that refund took
667
+ at its time: under `pro_rata` a failed completing refund may give back a day more or less than it
668
+ took, while failures that undo every refund give back exactly what was taken. Payments refunded
669
+ before migration `0007` recorded no taken days, so their failure gives back the status and the
670
+ amount only. Times are Stripe's whole seconds: a refund created in the second of a charge state
671
+ counts as included in it.
672
+ - A new refund kept until a late failure explains it (see "Failed refunds") takes back its share
673
+ of the days unused when that failure arrives, not when Stripe reported the refund, as a late
674
+ `charge.refunded` delivery would. A state reported before migration `0009` was not kept.
675
+ - The charge's currency is not compared with the payment's: a Checkout payment has one charge, in
676
+ the session's currency.
677
+ - A dated manual grant keeps its length when a refund or a revoke takes back another period.
678
+ - A refund of a period moves the dated end back by local days; a `grant { until }` an app applies
679
+ by hand with an end inside the stack is not a period of its own and shifts with it.
680
+ - A payment recorded before migration `0003` has no grant: its refund revokes all paid access.
681
+ - The Stripe adapter is tested against the sandbox's Checkout API and with a browser payment in the
682
+ sandbox whose webhook Stripe delivers through `stripe listen` (both only when `STRIPE_SECRET_KEY`
683
+ holds a test key; the example's `e2e/billing-checkout.stripe-sandbox.spec.ts`), and with signed
684
+ webhook fixtures. Only the card is paid end to end; BLIK and Przelewy24 are not.
685
+ - A grant through `grantPlan` (or a raw `changeEntitlement`, an import) is not recorded: it is not
686
+ in the history and cannot be revoked; the `grant-plan` script records its grants.
687
+ - The owner hears of an open request once: a buyer who corrects the details later changes the
688
+ admin page, not the mail already sent. Two asks at once for the same plan hand over once; if that
689
+ hand-over fails, the other ask has already answered "sent", and the next ask retries.
690
+ - A hand-over cut off after its claim (the process stopped while `onRequest` ran) is handed over
691
+ by the first ask a minute later; an ask within that minute answers "sent" without one. If the
692
+ owner's mail did go out before the stop (or `onRequest` answered but recording it failed), the
693
+ retry mails again: after a crash the hand-over is at least once.
694
+ - The admin page lists up to 50 open requests and 100 entries of each source in a history; there
695
+ is no paging.
696
+ - The write guard is per action: a read-only account can still call a write the app did not guard.
697
+ - Reminder mail is plain text with a minimal HTML body; there is no app template for it, and it
698
+ uses the app's locale (accounts have none of their own).
699
+ - No history of entitlement changes beyond grants: a row holds the current state; provider
700
+ payments and manual grants are stored, trial extensions and raw events are not.