@softure-ai/billing 0.0.0-stage → 0.1.6

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