@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
@@ -0,0 +1,227 @@
1
+ // Reading and changing an account's entitlement. An account without a row is on the trial that
2
+ // starts at its `auth.users.created_at` (or at `trial.startsAt` when later), derived on every read,
3
+ // so reads never write. A change pins that derived record into a row first, then applies the event
4
+ // under the row's lock.
5
+ import { users } from "@softure-ai/auth";
6
+ import { err, ok, type Err, type ModuleContext, type Ok } from "@softure-ai/core";
7
+ import type { Queryable } from "@softure-ai/db";
8
+ import { and, asc, eq, gt, isNull } from "drizzle-orm";
9
+ import { getDayNumber, getStartOfDay, parseDay } from "../calendar.js";
10
+ import type { BillingErrorCode, Entitlement, EntitlementEvent, EntitlementRecord } from "../contract.js";
11
+ import { applyEntitlementEvent, resolveEntitlement } from "../entitlement.js";
12
+ import { entitlements } from "../schema.js";
13
+ import { getBillingOptions, getEntitlementPolicy } from "./options.js";
14
+ import { isUserId } from "./user-id.js";
15
+
16
+ export type BillingContext = ModuleContext<Queryable>;
17
+
18
+ /**
19
+ * The local day an account without a row starts its trial on: the day it was created, or
20
+ * `trial.startsAt` when that is later.
21
+ */
22
+ function getTrialStartDay(ctx: Pick<BillingContext, "config">, accountCreatedAt: Date): number {
23
+ const createdDay = getDayNumber(accountCreatedAt, ctx.config.timezone);
24
+ const floorDay = getTrialFloorDay(ctx);
25
+ return floorDay === null ? createdDay : Math.max(createdDay, floorDay);
26
+ }
27
+
28
+ /** The day number of `trial.startsAt`, or null without one. */
29
+ export function getTrialFloorDay(ctx: Pick<BillingContext, "config">): number | null {
30
+ const { startsAt } = getBillingOptions(ctx.config).trial;
31
+ if (startsAt === undefined) return null;
32
+ const day = parseDay(startsAt);
33
+ // The option's schema refused anything else at startup.
34
+ if (day === null) throw new Error(`@softure-ai/billing: trial.startsAt "${startsAt}" is not a calendar day`);
35
+ return day;
36
+ }
37
+
38
+ /**
39
+ * The trial an account without a row is on: `trial.days` from the day it was created, or from
40
+ * `trial.startsAt` for an account created before that day.
41
+ */
42
+ export function getDefaultRecord(ctx: Pick<BillingContext, "config">, accountCreatedAt: Date): EntitlementRecord {
43
+ const trialEndsAt = getStartOfDay(getTrialStartDay(ctx, accountCreatedAt) + getBillingOptions(ctx.config).trial.days, ctx.config.timezone);
44
+ return { trialEndsAt, paidUntil: null, isLifetime: false };
45
+ }
46
+
47
+ export interface PinEntitlementRowInput {
48
+ readonly userId: string;
49
+ /** The account's `auth.users.created_at`, which its derived trial starts from. */
50
+ readonly accountCreatedAt: Date;
51
+ }
52
+
53
+ /**
54
+ * Writes the account's derived trial into a row when it has none (exactly what reads derive), so a
55
+ * check made before the first change has a row to lock: a concurrent change that inserted first is
56
+ * waited for. Returns whether this call inserted the row. The caller holds the account (key share)
57
+ * and runs it in its transaction.
58
+ */
59
+ export async function pinEntitlementRow(ctx: Pick<BillingContext, "db" | "config" | "clock">, input: PinEntitlementRowInput): Promise<boolean> {
60
+ const now = ctx.clock.now();
61
+ const inserted = await ctx.db
62
+ .insert(entitlements)
63
+ .values({ userId: input.userId, ...getDefaultRecord(ctx, input.accountCreatedAt), createdAt: now, updatedAt: now })
64
+ .onConflictDoNothing({ target: entitlements.userId })
65
+ .returning();
66
+ return inserted.length > 0;
67
+ }
68
+
69
+ /** The account's stored or derived record, or null when no account has this id. */
70
+ export async function findEntitlementRecord(ctx: Pick<BillingContext, "db" | "config">, userId: string): Promise<EntitlementRecord | null> {
71
+ if (!isUserId(userId)) return null;
72
+ const [row] = await ctx.db
73
+ .select({
74
+ accountCreatedAt: users.createdAt,
75
+ trialEndsAt: entitlements.trialEndsAt,
76
+ paidUntil: entitlements.paidUntil,
77
+ isLifetime: entitlements.isLifetime,
78
+ })
79
+ .from(users)
80
+ .leftJoin(entitlements, eq(entitlements.userId, users.id))
81
+ .where(eq(users.id, userId));
82
+ if (row === undefined) return null;
83
+ if (row.trialEndsAt === null) return getDefaultRecord(ctx, row.accountCreatedAt);
84
+ return { trialEndsAt: row.trialEndsAt, paidUntil: row.paidUntil, isLifetime: row.isLifetime ?? false };
85
+ }
86
+
87
+ /** Where the account stands now, or null when no account has this id. Database errors propagate. */
88
+ export async function getEntitlement(ctx: BillingContext, userId: string): Promise<Entitlement | null> {
89
+ const record = await findEntitlementRecord(ctx, userId);
90
+ return record === null ? null : resolveEntitlement(record, ctx.clock.now(), getEntitlementPolicy(ctx.config));
91
+ }
92
+
93
+ /**
94
+ * The write guard without a framework: `Ok` with the entitlement when the account may write,
95
+ * `billing.read_only` when it may only read, `billing.account_unknown` without an account.
96
+ */
97
+ export async function checkWriteAccess(ctx: BillingContext, userId: string): Promise<Ok<Entitlement> | Err<"billing.read_only" | "billing.account_unknown">> {
98
+ const entitlement = await getEntitlement(ctx, userId);
99
+ if (entitlement === null) return err("billing.account_unknown");
100
+ return entitlement.status === "read_only" ? err("billing.read_only") : ok(entitlement);
101
+ }
102
+
103
+ /** The stored record of an account, locked until the transaction ends, or undefined. */
104
+ async function lockStoredRecord(tx: Queryable, userId: string): Promise<EntitlementRecord | undefined> {
105
+ const [row] = await tx
106
+ .select({ trialEndsAt: entitlements.trialEndsAt, paidUntil: entitlements.paidUntil, isLifetime: entitlements.isLifetime })
107
+ .from(entitlements)
108
+ .where(eq(entitlements.userId, userId))
109
+ .for("update");
110
+ return row;
111
+ }
112
+
113
+ /** The event to apply, decided from the account's current record under its lock. */
114
+ export type EntitlementEventResolver = (record: EntitlementRecord, now: Date) => EntitlementEvent;
115
+
116
+ /**
117
+ * Applies `event` to the account's entitlement in one transaction and returns where it stands
118
+ * after; a refused event writes nothing. An account without a row gets one holding its derived
119
+ * trial with the event applied, so the trial end never moves when a row appears. A concurrent
120
+ * change that inserted first is waited for and applied on. Database errors propagate.
121
+ *
122
+ * `event` may be a function of the current record (a paid period that starts where access ends,
123
+ * `grantPlan`); it runs under the lock, so two changes at once both count.
124
+ */
125
+ export async function changeEntitlement(
126
+ ctx: BillingContext,
127
+ userId: string,
128
+ event: EntitlementEvent | EntitlementEventResolver,
129
+ ): Promise<Ok<Entitlement> | Err<BillingErrorCode>> {
130
+ if (!isUserId(userId)) return err("billing.account_unknown");
131
+ const resolveEvent: EntitlementEventResolver = typeof event === "function" ? event : () => event;
132
+ return ctx.db.transaction(async (tx) => {
133
+ const now = ctx.clock.now();
134
+ const policy = getEntitlementPolicy(ctx.config);
135
+ // A shared lock: the account cannot be deleted under the change, other changes still read it.
136
+ const [account] = await tx.select({ createdAt: users.createdAt }).from(users).where(eq(users.id, userId)).for("key share");
137
+ if (account === undefined) return err("billing.account_unknown");
138
+
139
+ const stored = await lockStoredRecord(tx, userId);
140
+ if (stored === undefined) {
141
+ const derived = getDefaultRecord(ctx, account.createdAt);
142
+ const next = applyEntitlementEvent(derived, resolveEvent(derived, now), now);
143
+ if (!next.ok) return next;
144
+ const inserted = await tx
145
+ .insert(entitlements)
146
+ .values({ userId, ...next.value, createdAt: now, updatedAt: now })
147
+ .onConflictDoNothing({ target: entitlements.userId })
148
+ .returning();
149
+ if (inserted.length > 0) return ok(resolveEntitlement(next.value, now, policy));
150
+ }
151
+
152
+ // The row existed, or a concurrent change inserted it after the read above (the insert waited
153
+ // for that transaction, so the row is visible now).
154
+ const current = stored ?? (await lockStoredRecord(tx, userId));
155
+ if (current === undefined) throw new Error("@softure-ai/billing: an entitlement row vanished while it was being changed");
156
+ const next = applyEntitlementEvent(current, resolveEvent(current, now), now);
157
+ if (!next.ok) return next;
158
+ await tx
159
+ .update(entitlements)
160
+ .set({ ...next.value, updatedAt: now })
161
+ .where(eq(entitlements.userId, userId));
162
+ return ok(resolveEntitlement(next.value, now, policy));
163
+ });
164
+ }
165
+
166
+ export interface ImportEntitlementInput {
167
+ readonly userId: string;
168
+ /** The trial end the other system knew; omitted or null keeps the account's own. */
169
+ readonly trialEndsAt?: Date | null;
170
+ /** The end of the paid period it knew; omitted or null adds none. */
171
+ readonly paidUntil?: Date | null;
172
+ /** Whether it had lifetime access. */
173
+ readonly isLifetime?: boolean;
174
+ }
175
+
176
+ /**
177
+ * Records what another system knew about an account (a trial end, a paid period, lifetime access)
178
+ * through `changeEntitlement`: merged onto the account's current record, each end only moving
179
+ * later, so an import never takes access away and a repeat changes nothing. Not a recorded grant:
180
+ * it is not in the account's history. Database errors propagate.
181
+ */
182
+ export async function importEntitlement(ctx: BillingContext, input: ImportEntitlementInput): Promise<Ok<Entitlement> | Err<"billing.account_unknown">> {
183
+ const changed = await changeEntitlement(ctx, input.userId, {
184
+ type: "import",
185
+ trialEndsAt: input.trialEndsAt ?? null,
186
+ paidUntil: input.paidUntil ?? null,
187
+ isLifetime: input.isLifetime ?? false,
188
+ });
189
+ if (changed.ok) return changed;
190
+ if (changed.error === "billing.account_unknown") return err(changed.error);
191
+ // An import event is never refused.
192
+ throw new Error(`@softure-ai/billing: importing an entitlement failed with ${changed.error}`);
193
+ }
194
+
195
+ /** How many accounts `pinDerivedTrials` reads and writes at a time. */
196
+ const PIN_BATCH_SIZE = 500;
197
+
198
+ /**
199
+ * Writes the derived trial of every account without a row into a row (exactly what reads derive,
200
+ * `trial.startsAt` included), so a later change of `trial.days`, `trial.startsAt` or the time zone
201
+ * moves no existing trial. A row written meanwhile by a change is kept. Returns how many rows it
202
+ * wrote. Run it in a transaction (the `pin-trials` script does) to pin all or nothing. Database
203
+ * errors propagate.
204
+ */
205
+ export async function pinDerivedTrials(ctx: BillingContext): Promise<number> {
206
+ const now = ctx.clock.now();
207
+ let pinned = 0;
208
+ let after: string | null = null;
209
+ for (;;) {
210
+ const accounts: { id: string; createdAt: Date }[] = await ctx.db
211
+ .select({ id: users.id, createdAt: users.createdAt })
212
+ .from(users)
213
+ .leftJoin(entitlements, eq(entitlements.userId, users.id))
214
+ .where(after === null ? isNull(entitlements.userId) : and(isNull(entitlements.userId), gt(users.id, after)))
215
+ .orderBy(asc(users.id))
216
+ .limit(PIN_BATCH_SIZE);
217
+ const last = accounts.at(-1);
218
+ if (last === undefined) return pinned;
219
+ const inserted = await ctx.db
220
+ .insert(entitlements)
221
+ .values(accounts.map((account) => ({ userId: account.id, ...getDefaultRecord(ctx, account.createdAt), createdAt: now, updatedAt: now })))
222
+ .onConflictDoNothing({ target: entitlements.userId })
223
+ .returning();
224
+ pinned += inserted.length;
225
+ after = last.id;
226
+ }
227
+ }
@@ -0,0 +1,227 @@
1
+ // Plans an admin grants by hand (the manual adapter's second half): each grant is recorded in
2
+ // `billing.manual_grants` with what it added, who granted it and the request it answered, so a
3
+ // mistaken one is revoked by taking back only that, and an account's history lists it beside its
4
+ // provider payments. Locks follow `refundPayment`'s order (account, then the entitlement, then the
5
+ // grant or request row), so none of them deadlock. A grant pins the account's derived row before it
6
+ // locks it, so its lifetime check holds even before the account's first change.
7
+ import { users } from "@softure-ai/auth";
8
+ import { err, ok, type Err, type Ok } from "@softure-ai/core";
9
+ import { and, desc, eq } from "drizzle-orm";
10
+ import type { AdminErrorCode, Entitlement, PaymentGrant, PlanPrice } from "../contract.js";
11
+ import { findPlan } from "../plans.js";
12
+ import { entitlements, manualGrants, paymentRequests, payments } from "../schema.js";
13
+ import { findEntitlementRecord, pinEntitlementRow, type BillingContext } from "./entitlements.js";
14
+ import { getGrantColumns, readGrant } from "./payments.js";
15
+ import { applyPlan, getBillingPlans } from "./plans.js";
16
+ import { findOpenRequest, getClosedRequestColumns, readPrice } from "./requests.js";
17
+ import { hasActiveManualLifetime, hasPaidLifetimePayment, lockEntitlementRow, takeBackGrant } from "./take-back.js";
18
+ import { isUserId, isUuid } from "./user-id.js";
19
+
20
+ export interface GrantPlanManuallyInput {
21
+ readonly userId: string;
22
+ readonly planId: string;
23
+ /** The admin who grants; null for a grant without one (a script). */
24
+ readonly adminId: string | null;
25
+ /** The open request this grant answers; it is closed in the same transaction. */
26
+ readonly requestId?: string;
27
+ }
28
+
29
+ export interface ManualGrantResult {
30
+ readonly grantId: string;
31
+ /** Where the account stands after the grant. */
32
+ readonly entitlement: Entitlement;
33
+ }
34
+
35
+ export type GrantPlanManuallyError = "billing.plan_unknown" | "billing.account_unknown" | "billing.lifetime_active" | "billing.request_closed";
36
+
37
+ /**
38
+ * Grants one payment of a plan by hand and records it, in one transaction: a paid period that
39
+ * starts when the account's current access ends, or lifetime access. Refuses an account that has
40
+ * lifetime access already, and a request that is no longer open; every refusal writes nothing.
41
+ * Database errors propagate.
42
+ */
43
+ export async function grantPlanManually(ctx: BillingContext, input: GrantPlanManuallyInput): Promise<Ok<ManualGrantResult> | Err<GrantPlanManuallyError>> {
44
+ const plan = findPlan(getBillingPlans(ctx.config), input.planId);
45
+ if (plan === undefined) return err("billing.plan_unknown");
46
+ if (!isUserId(input.userId)) return err("billing.account_unknown");
47
+ if (input.requestId !== undefined && !isUuid(input.requestId)) return err("billing.request_closed");
48
+ return ctx.db.transaction(async (tx) => {
49
+ const now = ctx.clock.now();
50
+ // A shared lock: the account cannot be deleted before the grant below.
51
+ const [account] = await tx.select({ createdAt: users.createdAt }).from(users).where(eq(users.id, input.userId)).for("key share");
52
+ if (account === undefined) return err("billing.account_unknown");
53
+ // The entitlement pinned and locked before the check, so a lifetime granted meanwhile is seen
54
+ // here, also by a grant that started before the account had a row.
55
+ const isPinned = await pinEntitlementRow({ ...ctx, db: tx }, { userId: input.userId, accountCreatedAt: account.createdAt });
56
+ await lockEntitlementRow(tx, input.userId);
57
+ const record = await findEntitlementRecord({ ...ctx, db: tx }, input.userId);
58
+ if (record?.isLifetime === true) return err("billing.lifetime_active");
59
+
60
+ // The price the grant is for: what its request quoted, else the plan's price now.
61
+ let price: PlanPrice = plan.price;
62
+ if (input.requestId !== undefined) {
63
+ const [closed] = await tx
64
+ .update(paymentRequests)
65
+ .set(getClosedRequestColumns("granted", now))
66
+ .where(and(eq(paymentRequests.id, input.requestId), eq(paymentRequests.userId, input.userId), eq(paymentRequests.status, "open")))
67
+ .returning();
68
+ // The request was granted or dismissed meanwhile, or is another account's: undo the pin, so
69
+ // the refusal writes nothing (the row is this transaction's, nobody else has seen it).
70
+ if (closed === undefined) {
71
+ if (isPinned) await tx.delete(entitlements).where(eq(entitlements.userId, input.userId));
72
+ return err("billing.request_closed");
73
+ }
74
+ price = readPrice(closed.amount, closed.currency) ?? price;
75
+ }
76
+
77
+ const applied = await applyPlan({ ...ctx, db: tx }, input.userId, input.planId);
78
+ // The plan and the locked account were checked above, and a plan grant always ends later.
79
+ if (!applied.ok) throw new Error(`@softure-ai/billing: granting plan "${input.planId}" by hand failed with ${applied.error}`);
80
+ const { grant } = applied.value;
81
+ // A plan grant always adds a period of at least a day, or lifetime access.
82
+ if (grant === null) throw new Error(`@softure-ai/billing: plan "${input.planId}" was granted by hand but added nothing`);
83
+ const [row] = await tx
84
+ .insert(manualGrants)
85
+ .values({
86
+ userId: input.userId,
87
+ planId: input.planId,
88
+ requestId: input.requestId ?? null,
89
+ grantedBy: input.adminId,
90
+ grantedAt: now,
91
+ ...getGrantColumns(grant),
92
+ grantKind: grant.kind,
93
+ status: "active",
94
+ amount: price.amount,
95
+ currency: price.currency,
96
+ })
97
+ .returning();
98
+ if (row === undefined) throw new Error("@softure-ai/billing: recording a manual grant returned no row");
99
+ return ok({ grantId: row.id, entitlement: applied.value.entitlement });
100
+ });
101
+ }
102
+
103
+ export interface GrantPaymentRequestInput {
104
+ readonly requestId: string;
105
+ readonly adminId: string | null;
106
+ }
107
+
108
+ /** Grants the plan an open request asked for and closes the request (`grantPlanManually`). */
109
+ export async function grantPaymentRequest(ctx: BillingContext, input: GrantPaymentRequestInput): Promise<Ok<ManualGrantResult> | Err<GrantPlanManuallyError>> {
110
+ const request = await findOpenRequest(ctx.db, input.requestId);
111
+ if (request === undefined) return err("billing.request_closed");
112
+ return grantPlanManually(ctx, { userId: request.userId, planId: request.planId, adminId: input.adminId, requestId: input.requestId });
113
+ }
114
+
115
+ export interface RevokeManualGrantInput {
116
+ readonly grantId: string;
117
+ readonly adminId: string | null;
118
+ }
119
+
120
+ /**
121
+ * Revokes a manual grant and takes back what it added, in one transaction: a period loses its
122
+ * unused days (later periods move back), a lifetime ends unless another active manual lifetime or a
123
+ * paid lifetime payment still gives it. `billing.grant_revoked` when it was revoked before or never
124
+ * existed. Database errors propagate.
125
+ */
126
+ export async function revokeManualGrant(ctx: BillingContext, input: RevokeManualGrantInput): Promise<Ok<Entitlement> | Err<Extract<AdminErrorCode, "billing.grant_revoked">>> {
127
+ if (!isUuid(input.grantId)) return err("billing.grant_revoked");
128
+ return ctx.db.transaction(async (tx) => {
129
+ const now = ctx.clock.now();
130
+ const [found] = await tx.select({ userId: manualGrants.userId }).from(manualGrants).where(eq(manualGrants.id, input.grantId));
131
+ if (found === undefined) return err("billing.grant_revoked");
132
+ // The account first (the lock order of every change), the entitlement, then the conditional update.
133
+ await tx.select({ id: users.id }).from(users).where(eq(users.id, found.userId)).for("key share");
134
+ await lockEntitlementRow(tx, found.userId);
135
+ const [revoked] = await tx
136
+ .update(manualGrants)
137
+ .set({ status: "revoked", revokedAt: now, revokedBy: input.adminId })
138
+ .where(and(eq(manualGrants.id, input.grantId), eq(manualGrants.status, "active")))
139
+ .returning();
140
+ // Revoked by a concurrent click, or erased with the account in the meantime.
141
+ if (revoked === undefined) return err("billing.grant_revoked");
142
+ const { entitlement } = await takeBackGrant(
143
+ { ...ctx, db: tx },
144
+ {
145
+ userId: found.userId,
146
+ grant: readGrant(revoked),
147
+ now,
148
+ hasOtherLifetime: async () => (await hasActiveManualLifetime(tx, found.userId, revoked.id)) || (await hasPaidLifetimePayment(tx, found.userId)),
149
+ },
150
+ );
151
+ return ok(entitlement);
152
+ });
153
+ }
154
+
155
+ /** One line of an account's history: a grant typed in by an admin, or a payment a provider reported. */
156
+ export type AccountHistoryEntry =
157
+ | {
158
+ readonly source: "manual";
159
+ readonly id: string;
160
+ readonly planId: string;
161
+ readonly at: Date;
162
+ readonly grant: PaymentGrant;
163
+ readonly status: "active" | "revoked";
164
+ readonly revokedAt: Date | null;
165
+ /** Whether it answered an invoice request. */
166
+ readonly isFromRequest: boolean;
167
+ /** What it was granted for; null for a grant recorded before prices were. */
168
+ readonly price: PlanPrice | null;
169
+ }
170
+ | {
171
+ readonly source: "provider";
172
+ readonly id: string;
173
+ readonly provider: string;
174
+ readonly planId: string;
175
+ readonly at: Date;
176
+ /** Null for a payment recorded before grants were. */
177
+ readonly grant: PaymentGrant | null;
178
+ readonly amount: number;
179
+ readonly currency: string;
180
+ readonly status: "paid" | "refunded";
181
+ readonly refundedAt: Date | null;
182
+ /** The total refunded so far: part of `amount` while the payment is still paid. */
183
+ readonly refundedAmount: number;
184
+ };
185
+
186
+ /** How many entries of each source the history reads. */
187
+ export const ACCOUNT_HISTORY_LIMIT = 100;
188
+
189
+ /** The account's manual grants and provider payments, newest first. Database errors propagate. */
190
+ export async function getAccountHistory(ctx: Pick<BillingContext, "db">, userId: string): Promise<readonly AccountHistoryEntry[]> {
191
+ if (!isUserId(userId)) return [];
192
+ const grantRows = await ctx.db
193
+ .select()
194
+ .from(manualGrants)
195
+ .where(eq(manualGrants.userId, userId))
196
+ .orderBy(desc(manualGrants.grantedAt), desc(manualGrants.id))
197
+ .limit(ACCOUNT_HISTORY_LIMIT);
198
+ const paymentRows = await ctx.db
199
+ .select()
200
+ .from(payments)
201
+ .where(eq(payments.userId, userId))
202
+ .orderBy(desc(payments.paidAt), desc(payments.id))
203
+ .limit(ACCOUNT_HISTORY_LIMIT);
204
+ const entries: AccountHistoryEntry[] = [];
205
+ for (const row of grantRows) {
206
+ const grant = readGrant(row);
207
+ // The CHECK on manual_grants requires a complete grant on every row.
208
+ if (grant === null) throw new Error(`@softure-ai/billing: manual grant ${row.id} has no grant recorded`);
209
+ entries.push({ source: "manual", id: row.id, planId: row.planId, at: row.grantedAt, grant, status: row.status, revokedAt: row.revokedAt, isFromRequest: row.requestId !== null, price: readPrice(row.amount, row.currency) });
210
+ }
211
+ for (const row of paymentRows) {
212
+ entries.push({
213
+ source: "provider",
214
+ id: row.id,
215
+ provider: row.provider,
216
+ planId: row.planId,
217
+ at: row.paidAt,
218
+ grant: readGrant(row),
219
+ amount: row.amount,
220
+ currency: row.currency,
221
+ status: row.status,
222
+ refundedAt: row.refundedAt,
223
+ refundedAmount: row.refundedAmount,
224
+ });
225
+ }
226
+ return entries.sort((first, second) => second.at.getTime() - first.at.getTime());
227
+ }
@@ -0,0 +1,18 @@
1
+ // The module's readiness probe for `GET /api/health` of `@softure-ai/ops`: the entitlements,
2
+ // payments, payment requests and manual grants tables exist and answer, i.e. `softure migrate` ran,
3
+ // and the setup is sound (`adminRole` is declared), so a deploy fails before traffic. It reads no rows.
4
+ import { ok, type HealthCheck } from "@softure-ai/core";
5
+ import type { Queryable } from "@softure-ai/db";
6
+ import { sql } from "drizzle-orm";
7
+ import { assertAdminRoleDeclared } from "./setup.js";
8
+
9
+ export const checkBillingTables: HealthCheck = async (context) => {
10
+ assertAdminRoleDeclared(context.config);
11
+ // Core types `db` as unknown; the health route passes the @softure-ai/db handle.
12
+ const db = context.db as Queryable;
13
+ await db.execute(sql`select 1 from billing.entitlements limit 0`);
14
+ await db.execute(sql`select 1 from billing.payments limit 0`);
15
+ await db.execute(sql`select 1 from billing.payment_requests limit 0`);
16
+ await db.execute(sql`select 1 from billing.manual_grants limit 0`);
17
+ return ok();
18
+ };
@@ -0,0 +1,83 @@
1
+ // Server-only API of @softure-ai/billing. Every function receives the module context
2
+ // (`{ db, clock, config }`) and never reads request scope; `next/*` imports are not allowed here.
3
+ export {
4
+ changeEntitlement,
5
+ checkWriteAccess,
6
+ findEntitlementRecord,
7
+ getDefaultRecord,
8
+ getEntitlement,
9
+ importEntitlement,
10
+ pinDerivedTrials,
11
+ type BillingContext,
12
+ type EntitlementEventResolver,
13
+ type ImportEntitlementInput,
14
+ } from "./entitlements.js";
15
+ export {
16
+ ACCOUNT_HISTORY_LIMIT,
17
+ getAccountHistory,
18
+ grantPaymentRequest,
19
+ grantPlanManually,
20
+ revokeManualGrant,
21
+ type AccountHistoryEntry,
22
+ type GrantPaymentRequestInput,
23
+ type GrantPlanManuallyError,
24
+ type GrantPlanManuallyInput,
25
+ type ManualGrantResult,
26
+ type RevokeManualGrantInput,
27
+ } from "./grants.js";
28
+ export { checkBillingTables } from "./health.js";
29
+ export { findAccessReminders, type AccessReminderDue, type FindAccessRemindersOptions } from "./reminders.js";
30
+ export {
31
+ failRefund,
32
+ receiveStripeWebhook,
33
+ recordPayment,
34
+ refundPayment,
35
+ STRIPE_PROVIDER,
36
+ type FailRefundInput,
37
+ type PaymentOutcome,
38
+ type ReceiveStripeWebhookInput,
39
+ type RecordPaymentError,
40
+ type RecordPaymentInput,
41
+ type RefundPaymentInput,
42
+ type StripeWebhookReceipt,
43
+ } from "./payments.js";
44
+ export {
45
+ getBillingMessages,
46
+ getBillingModule,
47
+ getBillingOptions,
48
+ getBillingRoutes,
49
+ getEntitlementPolicy,
50
+ type BillingRoutes,
51
+ } from "./options.js";
52
+ export {
53
+ findAccountByEmail,
54
+ findAccountById,
55
+ getBillingPlans,
56
+ getPaymentProvider,
57
+ grantPlan,
58
+ startPayment,
59
+ type StartPaymentInput,
60
+ type StartPaymentResult,
61
+ } from "./plans.js";
62
+ export { parseInvoiceDetails, type InvoiceDetailsError, type InvoiceInput } from "../invoice.js";
63
+ export {
64
+ billingPrivacyContributor,
65
+ deleteBillingUserData,
66
+ exportBillingUserData,
67
+ type BillingManualGrantData,
68
+ type BillingPaymentData,
69
+ type BillingPaymentRequestData,
70
+ type BillingRefundFailureData,
71
+ type BillingUserData,
72
+ } from "./privacy.js";
73
+ export {
74
+ dismissPaymentRequest,
75
+ expireStaleRequests,
76
+ listOpenRequests,
77
+ OPEN_REQUESTS_LIMIT,
78
+ recordPaymentRequest,
79
+ type ExpiredRequestsSummary,
80
+ type OpenPaymentRequest,
81
+ type RecordPaymentRequestInput,
82
+ } from "./requests.js";
83
+ export { assertAdminRoleDeclared, assertPaymentSetup, PAYMENT_BUCKET } from "./setup.js";
@@ -0,0 +1,52 @@
1
+ // The billing options of the running app, read from the configuration in the module context.
2
+ import { getModule, type AnySoftureModule, type SoftureConfig } from "@softure-ai/core";
3
+ import type { EntitlementPolicy } from "../entitlement.js";
4
+ import type { BillingMessages } from "../messages/index.js";
5
+ import type { BillingOptions } from "../options.js";
6
+
7
+ const MODULE_ID = "billing";
8
+
9
+ /** The enabled module. Throws when the app did not enable it: calling its functions then is a bug. */
10
+ export function getBillingModule(config: SoftureConfig): AnySoftureModule {
11
+ const module = getModule(config, MODULE_ID);
12
+ if (module === undefined) {
13
+ throw new Error("@softure-ai/billing: the module is not enabled; add billing({ ... }) to modules in softure.config.ts");
14
+ }
15
+ return module;
16
+ }
17
+
18
+ export function getBillingOptions(config: SoftureConfig): BillingOptions {
19
+ // The module factory parsed these options with billingOptionsSchema.
20
+ return getBillingModule(config).options as BillingOptions;
21
+ }
22
+
23
+ /** The module's copy in the app's locale, with the app's overrides applied. */
24
+ export function getBillingMessages(config: SoftureConfig): BillingMessages {
25
+ // The module factory merged the dictionaries; their shape is the module's own.
26
+ return getBillingModule(config).messages[config.locale] as BillingMessages;
27
+ }
28
+
29
+ export interface BillingRoutes {
30
+ /** The payment page (`PaymentPage`), where the notices and the pricing tiles send an account to pay. */
31
+ readonly payment: string;
32
+ /** The admin page (`BillingAdminPage`), refreshed after an admin action and linked from a request to its account. */
33
+ readonly admin: string;
34
+ }
35
+
36
+ /** The module's routes with the app's overrides applied. */
37
+ export function getBillingRoutes(config: SoftureConfig): BillingRoutes {
38
+ const routes = getBillingModule(config).routes;
39
+ const read = (name: keyof BillingRoutes): string => {
40
+ const path = routes[name];
41
+ // The manifest declares every route, so a missing one means a broken module definition.
42
+ if (path === undefined) throw new Error(`@softure-ai/billing: route "${name}" is missing from the module manifest`);
43
+ return path;
44
+ };
45
+ return { payment: read("payment"), admin: read("admin") };
46
+ }
47
+
48
+ /** The state machine's policy from the options and the app's time zone. */
49
+ export function getEntitlementPolicy(config: SoftureConfig): EntitlementPolicy {
50
+ const options = getBillingOptions(config);
51
+ return { timezone: config.timezone, trialReminderDays: options.trial.reminderDays, paidReminderDays: options.paid.reminderDays };
52
+ }