@softure-ai/billing 0.0.0-stage → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (294) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +699 -2
  3. package/dist/calendar.d.ts +26 -0
  4. package/dist/calendar.d.ts.map +1 -0
  5. package/dist/calendar.js +81 -0
  6. package/dist/calendar.js.map +1 -0
  7. package/dist/contract.d.ts +187 -0
  8. package/dist/contract.d.ts.map +1 -0
  9. package/dist/contract.js +6 -0
  10. package/dist/contract.js.map +1 -0
  11. package/dist/currency-digits.d.ts +3 -0
  12. package/dist/currency-digits.d.ts.map +1 -0
  13. package/dist/currency-digits.js +32 -0
  14. package/dist/currency-digits.js.map +1 -0
  15. package/dist/entitlement.d.ts +25 -0
  16. package/dist/entitlement.d.ts.map +1 -0
  17. package/dist/entitlement.js +75 -0
  18. package/dist/entitlement.js.map +1 -0
  19. package/dist/fields.d.ts +25 -0
  20. package/dist/fields.d.ts.map +1 -0
  21. package/dist/fields.js +27 -0
  22. package/dist/fields.js.map +1 -0
  23. package/dist/index.d.ts +274 -0
  24. package/dist/index.d.ts.map +1 -0
  25. package/dist/index.js +73 -0
  26. package/dist/index.js.map +1 -0
  27. package/dist/invoice.d.ts +29 -0
  28. package/dist/invoice.d.ts.map +1 -0
  29. package/dist/invoice.js +35 -0
  30. package/dist/invoice.js.map +1 -0
  31. package/dist/mailing/index.d.ts +2 -0
  32. package/dist/mailing/index.d.ts.map +1 -0
  33. package/dist/mailing/index.js +4 -0
  34. package/dist/mailing/index.js.map +1 -0
  35. package/dist/mailing/reminder-mail.d.ts +50 -0
  36. package/dist/mailing/reminder-mail.d.ts.map +1 -0
  37. package/dist/mailing/reminder-mail.js +71 -0
  38. package/dist/mailing/reminder-mail.js.map +1 -0
  39. package/dist/manual.d.ts +12 -0
  40. package/dist/manual.d.ts.map +1 -0
  41. package/dist/manual.js +22 -0
  42. package/dist/manual.js.map +1 -0
  43. package/dist/messages/en.d.ts +176 -0
  44. package/dist/messages/en.d.ts.map +1 -0
  45. package/dist/messages/en.js +151 -0
  46. package/dist/messages/en.js.map +1 -0
  47. package/dist/messages/index.d.ts +359 -0
  48. package/dist/messages/index.d.ts.map +1 -0
  49. package/dist/messages/index.js +14 -0
  50. package/dist/messages/index.js.map +1 -0
  51. package/dist/messages/pl.d.ts +3 -0
  52. package/dist/messages/pl.d.ts.map +1 -0
  53. package/dist/messages/pl.js +151 -0
  54. package/dist/messages/pl.js.map +1 -0
  55. package/dist/next/access.d.ts +16 -0
  56. package/dist/next/access.d.ts.map +1 -0
  57. package/dist/next/access.js +24 -0
  58. package/dist/next/access.js.map +1 -0
  59. package/dist/next/actions.d.ts +24 -0
  60. package/dist/next/actions.d.ts.map +1 -0
  61. package/dist/next/actions.js +158 -0
  62. package/dist/next/actions.js.map +1 -0
  63. package/dist/next/context.d.ts +4 -0
  64. package/dist/next/context.d.ts.map +1 -0
  65. package/dist/next/context.js +17 -0
  66. package/dist/next/context.js.map +1 -0
  67. package/dist/next/current-entitlement.d.ts +18 -0
  68. package/dist/next/current-entitlement.d.ts.map +1 -0
  69. package/dist/next/current-entitlement.js +26 -0
  70. package/dist/next/current-entitlement.js.map +1 -0
  71. package/dist/next/index.d.ts +8 -0
  72. package/dist/next/index.d.ts.map +1 -0
  73. package/dist/next/index.js +12 -0
  74. package/dist/next/index.js.map +1 -0
  75. package/dist/next/pages.d.ts +24 -0
  76. package/dist/next/pages.d.ts.map +1 -0
  77. package/dist/next/pages.js +166 -0
  78. package/dist/next/pages.js.map +1 -0
  79. package/dist/next/pricing.d.ts +12 -0
  80. package/dist/next/pricing.d.ts.map +1 -0
  81. package/dist/next/pricing.js +20 -0
  82. package/dist/next/pricing.js.map +1 -0
  83. package/dist/next/route.d.ts +11 -0
  84. package/dist/next/route.d.ts.map +1 -0
  85. package/dist/next/route.js +56 -0
  86. package/dist/next/route.js.map +1 -0
  87. package/dist/options.d.ts +85 -0
  88. package/dist/options.d.ts.map +1 -0
  89. package/dist/options.js +121 -0
  90. package/dist/options.js.map +1 -0
  91. package/dist/payment.d.ts +61 -0
  92. package/dist/payment.d.ts.map +1 -0
  93. package/dist/payment.js +12 -0
  94. package/dist/payment.js.map +1 -0
  95. package/dist/plans.d.ts +20 -0
  96. package/dist/plans.d.ts.map +1 -0
  97. package/dist/plans.js +53 -0
  98. package/dist/plans.js.map +1 -0
  99. package/dist/price.d.ts +12 -0
  100. package/dist/price.d.ts.map +1 -0
  101. package/dist/price.js +37 -0
  102. package/dist/price.js.map +1 -0
  103. package/dist/refund.d.ts +70 -0
  104. package/dist/refund.d.ts.map +1 -0
  105. package/dist/refund.js +109 -0
  106. package/dist/refund.js.map +1 -0
  107. package/dist/reminder.d.ts +30 -0
  108. package/dist/reminder.d.ts.map +1 -0
  109. package/dist/reminder.js +34 -0
  110. package/dist/reminder.js.map +1 -0
  111. package/dist/schema.d.ts +1023 -0
  112. package/dist/schema.d.ts.map +1 -0
  113. package/dist/schema.js +77 -0
  114. package/dist/schema.js.map +1 -0
  115. package/dist/scripts/entitlement-scripts.d.ts +17 -0
  116. package/dist/scripts/entitlement-scripts.d.ts.map +1 -0
  117. package/dist/scripts/entitlement-scripts.js +167 -0
  118. package/dist/scripts/entitlement-scripts.js.map +1 -0
  119. package/dist/scripts/index.d.ts +3 -0
  120. package/dist/scripts/index.d.ts.map +1 -0
  121. package/dist/scripts/index.js +5 -0
  122. package/dist/scripts/index.js.map +1 -0
  123. package/dist/scripts/plan-scripts.d.ts +19 -0
  124. package/dist/scripts/plan-scripts.d.ts.map +1 -0
  125. package/dist/scripts/plan-scripts.js +106 -0
  126. package/dist/scripts/plan-scripts.js.map +1 -0
  127. package/dist/server/entitlements.d.ts +69 -0
  128. package/dist/server/entitlements.d.ts.map +1 -0
  129. package/dist/server/entitlements.js +202 -0
  130. package/dist/server/entitlements.js.map +1 -0
  131. package/dist/server/grants.d.ts +74 -0
  132. package/dist/server/grants.d.ts.map +1 -0
  133. package/dist/server/grants.js +174 -0
  134. package/dist/server/grants.js.map +1 -0
  135. package/dist/server/health.d.ts +3 -0
  136. package/dist/server/health.d.ts.map +1 -0
  137. package/dist/server/health.js +17 -0
  138. package/dist/server/health.js.map +1 -0
  139. package/dist/server/index.d.ts +12 -0
  140. package/dist/server/index.d.ts.map +1 -0
  141. package/dist/server/index.js +14 -0
  142. package/dist/server/index.js.map +1 -0
  143. package/dist/server/options.d.ts +20 -0
  144. package/dist/server/options.d.ts.map +1 -0
  145. package/dist/server/options.js +38 -0
  146. package/dist/server/options.js.map +1 -0
  147. package/dist/server/payments.d.ts +140 -0
  148. package/dist/server/payments.d.ts.map +1 -0
  149. package/dist/server/payments.js +339 -0
  150. package/dist/server/payments.js.map +1 -0
  151. package/dist/server/plans.d.ts +50 -0
  152. package/dist/server/plans.d.ts.map +1 -0
  153. package/dist/server/plans.js +129 -0
  154. package/dist/server/plans.js.map +1 -0
  155. package/dist/server/privacy.d.ts +77 -0
  156. package/dist/server/privacy.d.ts.map +1 -0
  157. package/dist/server/privacy.js +110 -0
  158. package/dist/server/privacy.js.map +1 -0
  159. package/dist/server/reminders.d.ts +20 -0
  160. package/dist/server/reminders.d.ts.map +1 -0
  161. package/dist/server/reminders.js +85 -0
  162. package/dist/server/reminders.js.map +1 -0
  163. package/dist/server/requests.d.ts +80 -0
  164. package/dist/server/requests.d.ts.map +1 -0
  165. package/dist/server/requests.js +155 -0
  166. package/dist/server/requests.js.map +1 -0
  167. package/dist/server/setup.d.ts +9 -0
  168. package/dist/server/setup.d.ts.map +1 -0
  169. package/dist/server/setup.js +34 -0
  170. package/dist/server/setup.js.map +1 -0
  171. package/dist/server/take-back.d.ts +80 -0
  172. package/dist/server/take-back.d.ts.map +1 -0
  173. package/dist/server/take-back.js +138 -0
  174. package/dist/server/take-back.js.map +1 -0
  175. package/dist/server/user-id.d.ts +4 -0
  176. package/dist/server/user-id.d.ts.map +1 -0
  177. package/dist/server/user-id.js +11 -0
  178. package/dist/server/user-id.js.map +1 -0
  179. package/dist/stripe-currency.d.ts +27 -0
  180. package/dist/stripe-currency.d.ts.map +1 -0
  181. package/dist/stripe-currency.js +57 -0
  182. package/dist/stripe-currency.js.map +1 -0
  183. package/dist/stripe-webhook.d.ts +101 -0
  184. package/dist/stripe-webhook.d.ts.map +1 -0
  185. package/dist/stripe-webhook.js +209 -0
  186. package/dist/stripe-webhook.js.map +1 -0
  187. package/dist/stripe.d.ts +25 -0
  188. package/dist/stripe.d.ts.map +1 -0
  189. package/dist/stripe.js +116 -0
  190. package/dist/stripe.js.map +1 -0
  191. package/dist/ui/access-badge.d.ts +16 -0
  192. package/dist/ui/access-badge.d.ts.map +1 -0
  193. package/dist/ui/access-badge.js +43 -0
  194. package/dist/ui/access-badge.js.map +1 -0
  195. package/dist/ui/access-notice.d.ts +20 -0
  196. package/dist/ui/access-notice.d.ts.map +1 -0
  197. package/dist/ui/access-notice.js +41 -0
  198. package/dist/ui/access-notice.js.map +1 -0
  199. package/dist/ui/format.d.ts +11 -0
  200. package/dist/ui/format.d.ts.map +1 -0
  201. package/dist/ui/format.js +21 -0
  202. package/dist/ui/format.js.map +1 -0
  203. package/dist/ui/grant-form.d.ts +18 -0
  204. package/dist/ui/grant-form.d.ts.map +1 -0
  205. package/dist/ui/grant-form.js +23 -0
  206. package/dist/ui/grant-form.js.map +1 -0
  207. package/dist/ui/grant-history.d.ts +39 -0
  208. package/dist/ui/grant-history.d.ts.map +1 -0
  209. package/dist/ui/grant-history.js +36 -0
  210. package/dist/ui/grant-history.js.map +1 -0
  211. package/dist/ui/index.d.ts +9 -0
  212. package/dist/ui/index.d.ts.map +1 -0
  213. package/dist/ui/index.js +12 -0
  214. package/dist/ui/index.js.map +1 -0
  215. package/dist/ui/payment-form.d.ts +23 -0
  216. package/dist/ui/payment-form.d.ts.map +1 -0
  217. package/dist/ui/payment-form.js +34 -0
  218. package/dist/ui/payment-form.js.map +1 -0
  219. package/dist/ui/payment-requests.d.ts +30 -0
  220. package/dist/ui/payment-requests.d.ts.map +1 -0
  221. package/dist/ui/payment-requests.js +31 -0
  222. package/dist/ui/payment-requests.js.map +1 -0
  223. package/dist/ui/pricing-tiles.d.ts +22 -0
  224. package/dist/ui/pricing-tiles.d.ts.map +1 -0
  225. package/dist/ui/pricing-tiles.js +40 -0
  226. package/dist/ui/pricing-tiles.js.map +1 -0
  227. package/migrations/0001_create_entitlements.sql +16 -0
  228. package/migrations/0002_create_payments.sql +30 -0
  229. package/migrations/0003_record_payment_grants.sql +25 -0
  230. package/migrations/0004_create_requests_and_grants.sql +58 -0
  231. package/migrations/0005_record_refunded_amounts.sql +18 -0
  232. package/migrations/0006_record_request_handover_and_prices.sql +38 -0
  233. package/migrations/0007_record_failed_refunds.sql +27 -0
  234. package/migrations/0008_record_request_handover_claims.sql +9 -0
  235. package/migrations/0009_record_pending_charge_states.sql +16 -0
  236. package/module.json +23 -0
  237. package/package.json +81 -4
  238. package/src/calendar.ts +90 -0
  239. package/src/contract.ts +181 -0
  240. package/src/currency-digits.ts +37 -0
  241. package/src/entitlement.ts +84 -0
  242. package/src/fields.ts +37 -0
  243. package/src/index.ts +163 -0
  244. package/src/invoice.ts +58 -0
  245. package/src/mailing/index.ts +11 -0
  246. package/src/mailing/reminder-mail.ts +108 -0
  247. package/src/manual.ts +31 -0
  248. package/src/messages/en.ts +150 -0
  249. package/src/messages/index.ts +18 -0
  250. package/src/messages/pl.ts +152 -0
  251. package/src/next/access.tsx +55 -0
  252. package/src/next/actions.ts +176 -0
  253. package/src/next/context.ts +18 -0
  254. package/src/next/current-entitlement.ts +36 -0
  255. package/src/next/index.ts +11 -0
  256. package/src/next/next-modules.d.ts +21 -0
  257. package/src/next/pages.tsx +267 -0
  258. package/src/next/pricing.tsx +39 -0
  259. package/src/next/route.ts +57 -0
  260. package/src/options.ts +128 -0
  261. package/src/payment.ts +77 -0
  262. package/src/plans.ts +63 -0
  263. package/src/price.ts +50 -0
  264. package/src/refund.ts +135 -0
  265. package/src/reminder.ts +52 -0
  266. package/src/schema.ts +86 -0
  267. package/src/scripts/entitlement-scripts.ts +188 -0
  268. package/src/scripts/index.ts +11 -0
  269. package/src/scripts/plan-scripts.ts +143 -0
  270. package/src/server/entitlements.ts +227 -0
  271. package/src/server/grants.ts +227 -0
  272. package/src/server/health.ts +18 -0
  273. package/src/server/index.ts +83 -0
  274. package/src/server/options.ts +52 -0
  275. package/src/server/payments.ts +434 -0
  276. package/src/server/plans.ts +143 -0
  277. package/src/server/privacy.ts +189 -0
  278. package/src/server/reminders.ts +113 -0
  279. package/src/server/requests.ts +201 -0
  280. package/src/server/setup.ts +37 -0
  281. package/src/server/take-back.ts +190 -0
  282. package/src/server/user-id.ts +12 -0
  283. package/src/stripe-currency.ts +65 -0
  284. package/src/stripe-webhook.ts +278 -0
  285. package/src/stripe.ts +130 -0
  286. package/src/ui/access-badge.tsx +64 -0
  287. package/src/ui/access-notice.tsx +73 -0
  288. package/src/ui/format.ts +25 -0
  289. package/src/ui/grant-form.tsx +69 -0
  290. package/src/ui/grant-history.tsx +127 -0
  291. package/src/ui/index.ts +25 -0
  292. package/src/ui/payment-form.tsx +111 -0
  293. package/src/ui/payment-requests.tsx +126 -0
  294. package/src/ui/pricing-tiles.tsx +105 -0
package/src/price.ts ADDED
@@ -0,0 +1,50 @@
1
+ // Prices of plans as the app shows them: the amount counts the currency's minor unit as billing
2
+ // pins it (`src/currency-digits.ts`, ISO 4217), and the locale's `Intl` only supplies the notation,
3
+ // so "PLN 29.00" in en and its Polish notation (a decimal comma, the currency sign after) come from
4
+ // one config, and no runtime's CLDR can change the amount.
5
+ import type { Locale } from "@softure-ai/core";
6
+ import type { PlanPrice } from "./contract.js";
7
+ import { CURRENCY_MINOR_UNIT_DIGITS } from "./currency-digits.js";
8
+
9
+ const formatters = new Map<string, Intl.NumberFormat>();
10
+
11
+ /** The pinned digits, or undefined for a code billing does not accept. */
12
+ function findMinorUnitDigits(currency: string): number | undefined {
13
+ return Object.hasOwn(CURRENCY_MINOR_UNIT_DIGITS, currency) ? CURRENCY_MINOR_UNIT_DIGITS[currency] : undefined;
14
+ }
15
+
16
+ function getFormatter(locale: Locale, currency: string, digits: number | undefined): Intl.NumberFormat {
17
+ const key = `${locale}:${currency}`;
18
+ let format = formatters.get(key);
19
+ if (format === undefined) {
20
+ format = new Intl.NumberFormat(
21
+ locale,
22
+ digits === undefined ? { style: "currency", currency } : { style: "currency", currency, minimumFractionDigits: digits, maximumFractionDigits: digits },
23
+ );
24
+ formatters.set(key, format);
25
+ }
26
+ return format;
27
+ }
28
+
29
+ /** Whether billing accepts `currency`: an upper-case ISO 4217 code in its pinned table. */
30
+ export function isSupportedCurrency(currency: string): boolean {
31
+ return findMinorUnitDigits(currency) !== undefined;
32
+ }
33
+
34
+ /** Digits of the currency's minor unit: 2 for PLN, EUR and HUF, 0 for JPY, 3 for KWD. */
35
+ export function getMinorUnitDigits(currency: string): number {
36
+ const digits = findMinorUnitDigits(currency);
37
+ // A config with this code is refused when it loads, so reaching here is a bug.
38
+ if (digits === undefined) throw new Error(`getMinorUnitDigits: billing does not know the currency "${currency}"`);
39
+ return digits;
40
+ }
41
+
42
+ /**
43
+ * The price in the locale's notation, e.g. "PLN 29.00" in en. A stored price in a code billing no
44
+ * longer accepts is shown with `Intl`'s own digits, so a history page never breaks on it.
45
+ */
46
+ export function formatPrice(price: PlanPrice, locale: Locale): string {
47
+ const digits = findMinorUnitDigits(price.currency);
48
+ const format = getFormatter(locale, price.currency, digits);
49
+ return format.format(price.amount / 10 ** (digits ?? format.resolvedOptions().maximumFractionDigits ?? 2));
50
+ }
package/src/refund.ts ADDED
@@ -0,0 +1,135 @@
1
+ // Refunds, pure: what one payment of a plan added to an account (stored with the payment) and the
2
+ // change a refund of it makes. A full refund takes back the part of the payment's period not used
3
+ // yet. Access ahead of now is one unbroken run (every grant starts where running access ends), so
4
+ // taking back those days moves the dated end back by as many local days; a period already used up
5
+ // takes nothing back, and a lapse between payments never lets an old refund eat a new period.
6
+ // A partial refund takes back the same share of those days as the share of the money not refunded
7
+ // before that it returns, rounded down: the refund that completes the amount has a share of one, so
8
+ // partial refunds that add up to the whole payment take back what one full refund would. A refund
9
+ // that fails later gives back the failed money's share of the days refunds took.
10
+ import { getDayNumber, getStartOfDay } from "./calendar.js";
11
+ import type { EntitlementEvent, EntitlementRecord, PaymentGrant } from "./contract.js";
12
+
13
+ /** The later of two instants. */
14
+ function getLater(first: Date, second: Date): Date {
15
+ return first > second ? first : second;
16
+ }
17
+
18
+ /** Where a grant added to `record` at `now` starts: the latest of the trial's end, dated paid access and `now`. */
19
+ export function getGrantStart(record: EntitlementRecord, now: Date): Date {
20
+ return [record.trialEndsAt, ...(record.paidUntil === null ? [] : [record.paidUntil])].reduce(getLater, now);
21
+ }
22
+
23
+ /**
24
+ * What a plan's event added to `record`: the period from where access ended (the trial, dated paid
25
+ * access or `now`, as `getPlanGrant` starts it) to the event's end, or lifetime access. Null for an
26
+ * event that is not a plan grant.
27
+ */
28
+ export function getPaymentGrant(record: EntitlementRecord, event: EntitlementEvent, now: Date): PaymentGrant | null {
29
+ switch (event.type) {
30
+ case "grant_lifetime":
31
+ return { kind: "lifetime" };
32
+ case "grant": {
33
+ const from = getGrantStart(record, now);
34
+ return from < event.until ? { kind: "period", from, until: event.until } : null;
35
+ }
36
+ default:
37
+ return null;
38
+ }
39
+ }
40
+
41
+ /**
42
+ * `instant` moved back by `days` local days in `timezone`, at the same local time of day (the first
43
+ * instant of the day when that time does not exist there).
44
+ */
45
+ export function moveBackByDays(instant: Date, days: number, timezone: string): Date {
46
+ const day = getDayNumber(instant, timezone);
47
+ const timeOfDay = instant.getTime() - getStartOfDay(day, timezone).getTime();
48
+ return new Date(getStartOfDay(day - days, timezone).getTime() + timeOfDay);
49
+ }
50
+
51
+ /**
52
+ * The local days of a period not used yet at `now`, `[max(from, now), until)`, today included; 0
53
+ * once the period has ended. A refund moves dated access, and every period stacked after this one,
54
+ * back by this many days.
55
+ */
56
+ export function getUnusedDays(grant: Extract<PaymentGrant, { kind: "period" }>, now: Date, timezone: string): number {
57
+ if (grant.until <= now) return 0;
58
+ return Math.max(0, getDayNumber(grant.until, timezone) - getDayNumber(getLater(grant.from, now), timezone));
59
+ }
60
+
61
+ /** The part of a payment a refund returns: `refunded` newly refunded out of the `outstanding` amount not refunded before. */
62
+ export interface RefundShare {
63
+ readonly refunded: number;
64
+ readonly outstanding: number;
65
+ }
66
+
67
+ export interface RefundTiming {
68
+ readonly now: Date;
69
+ readonly timezone: string;
70
+ /** The part of the payment refunded; omitted for a full refund. */
71
+ readonly share?: RefundShare;
72
+ }
73
+
74
+ /** Whether `share` returns everything not refunded before (or is absent: a full refund). */
75
+ export function isFullShare(share: RefundShare | undefined): boolean {
76
+ return share === undefined || share.refunded >= share.outstanding;
77
+ }
78
+
79
+ /**
80
+ * The local days a refund takes back from a period: all its unused days (`getUnusedDays`) for a
81
+ * full share, else that share of them rounded down, so the account keeps any part of a day.
82
+ */
83
+ export function getTakenBackDays(grant: Extract<PaymentGrant, { kind: "period" }>, timing: RefundTiming): number {
84
+ const unusedDays = getUnusedDays(grant, timing.now, timing.timezone);
85
+ const { share } = timing;
86
+ if (share === undefined || isFullShare(share)) return unusedDays;
87
+ if (share.refunded <= 0) return 0;
88
+ // Integers: at most 10^8 minor units times a period's days stays a safe integer.
89
+ return Math.floor((unusedDays * share.refunded) / share.outstanding);
90
+ }
91
+
92
+ /**
93
+ * The change a refund of a payment that granted `grant` makes to `record`: a period moves dated
94
+ * paid access back by the days it takes back (`getTakenBackDays`), and a full refund of a lifetime
95
+ * ends lifetime access. Null when nothing is taken back (the period is used up, the share rounds to
96
+ * no day, a partial refund of a lifetime, or dated access is already gone).
97
+ */
98
+ export function getRefundEvent(record: EntitlementRecord, grant: PaymentGrant, timing: RefundTiming): EntitlementEvent | null {
99
+ if (grant.kind === "lifetime") return isFullShare(timing.share) ? { type: "end_lifetime" } : null;
100
+ const days = getTakenBackDays(grant, timing);
101
+ if (record.paidUntil === null || days === 0) return null;
102
+ return { type: "shorten", until: moveBackByDays(record.paidUntil, days, timing.timezone) };
103
+ }
104
+
105
+ /** `instant` moved forward by `days` local days in `timezone`, as `moveBackByDays` moves it back. */
106
+ export function moveForwardByDays(instant: Date, days: number, timezone: string): Date {
107
+ return moveBackByDays(instant, -days, timezone);
108
+ }
109
+
110
+ export interface RestoredDaysInput {
111
+ /** The local days refunds took from the payment's period so far. */
112
+ readonly takenBackDays: number;
113
+ /** The payment's refunded total before the failure. */
114
+ readonly refundedAmount: number;
115
+ /** The part of that total the failure gives back (at most `refundedAmount`). */
116
+ readonly restoredAmount: number;
117
+ /** The app's `partialRefunds` policy. */
118
+ readonly policy: "pro_rata" | "keep_access";
119
+ }
120
+
121
+ /**
122
+ * The local days a failed refund gives back. Under `pro_rata` the share of the days refunds took
123
+ * that the failed money is of the money refunded, rounded down, and every day once nothing stays
124
+ * refunded, so failures that undo every refund give back exactly what was taken. Under
125
+ * `keep_access` only the refund that completed the payment took days, so any failure gives them
126
+ * all back: the payment is no longer refunded in full.
127
+ */
128
+ export function getRestoredDays({ takenBackDays, refundedAmount, restoredAmount, policy }: RestoredDaysInput): number {
129
+ if (takenBackDays <= 0 || restoredAmount <= 0 || refundedAmount <= 0) return 0;
130
+ const remaining = refundedAmount - restoredAmount;
131
+ if (remaining <= 0) return takenBackDays;
132
+ if (policy === "keep_access") return takenBackDays;
133
+ // Integers: at most 10^8 minor units times a period's days stays a safe integer.
134
+ return Math.floor((takenBackDays * restoredAmount) / refundedAmount);
135
+ }
@@ -0,0 +1,52 @@
1
+ // Which reminder mail an account is due, as a pure rule: the four states the in-app notice shows
2
+ // (trial or paid access ending, trial or paid access ended), decided from the account's record by
3
+ // the state machine. An ended mail is due only for a few days after the end, so turning reminders
4
+ // on never mails every account that lapsed long ago.
5
+ import { getDayNumber } from "./calendar.js";
6
+ import type { EntitlementRecord } from "./contract.js";
7
+ import { resolveEntitlement, type EntitlementPolicy } from "./entitlement.js";
8
+
9
+ export const ACCESS_REMINDER_KINDS = ["trial-ending", "paid-ending", "trial-ended", "paid-ended"] as const;
10
+ export type AccessReminderKind = (typeof ACCESS_REMINDER_KINDS)[number];
11
+
12
+ /** The longest catch-up window for ended mails, in days. */
13
+ export const MAX_CATCH_UP_DAYS = 365;
14
+ /** Days after the day access ended during which its ended mail is still due. */
15
+ export const DEFAULT_CATCH_UP_DAYS = 3;
16
+
17
+ export interface AccessReminder {
18
+ readonly kind: AccessReminderKind;
19
+ /** The first instant without the access the mail is about. */
20
+ readonly endsAt: Date;
21
+ }
22
+
23
+ export interface AccessReminderInput {
24
+ readonly record: EntitlementRecord;
25
+ /** `auth.users.created_at`: a trial that ended by then was never a trial. */
26
+ readonly accountCreatedAt: Date;
27
+ readonly now: Date;
28
+ readonly policy: EntitlementPolicy;
29
+ /** An ended mail is due from the day access ended through this many local days after it. */
30
+ readonly catchUpDays: number;
31
+ }
32
+
33
+ /** The reminder `input.record` is due at `input.now`, or null (lifetime, outside every window). */
34
+ export function getAccessReminder(input: AccessReminderInput): AccessReminder | null {
35
+ const { record, accountCreatedAt, now, policy, catchUpDays } = input;
36
+ const entitlement = resolveEntitlement(record, now, policy);
37
+ if (entitlement.status !== "read_only") {
38
+ if (!entitlement.isEnding || entitlement.endsAt === null) return null;
39
+ return { kind: entitlement.status === "trial" ? "trial-ending" : "paid-ending", endsAt: entitlement.endsAt };
40
+ }
41
+ if (getDayNumber(now, policy.timezone) - getDayNumber(entitlement.since, policy.timezone) > catchUpDays) return null;
42
+ if (entitlement.reason === "paid_ended") return { kind: "paid-ended", endsAt: entitlement.since };
43
+ return entitlement.since > accountCreatedAt ? { kind: "trial-ended", endsAt: entitlement.since } : null;
44
+ }
45
+
46
+ /**
47
+ * The delivery scope of a reminder in `mailing.deliveries`: one mail per account, kind and end, so
48
+ * a new end (an extended trial, a renewal, a refund) is a new window and the same end never mails twice.
49
+ */
50
+ export function getAccessReminderScope(kind: AccessReminderKind, userId: string, endsAt: Date): string {
51
+ return `billing.${kind}:${userId}:${String(endsAt.getTime())}`;
52
+ }
package/src/schema.ts ADDED
@@ -0,0 +1,86 @@
1
+ // Drizzle view of the module's tables (migrations/0001_create_entitlements.sql,
2
+ // 0002_create_payments.sql, 0003_record_payment_grants.sql, 0004_create_requests_and_grants.sql,
3
+ // 0005_record_refunded_amounts.sql, 0006_record_request_handover_and_prices.sql,
4
+ // 0007_record_failed_refunds.sql, 0008_record_request_handover_claims.sql and
5
+ // 0009_record_pending_charge_states.sql).
6
+ // The migrations are the source of truth; this file only types the queries.
7
+ import { bigint, boolean, integer, pgSchema, primaryKey, text, timestamp, uuid } from "drizzle-orm/pg-core";
8
+
9
+ export const billingSchema = pgSchema("billing");
10
+
11
+ export const entitlements = billingSchema.table("entitlements", {
12
+ userId: uuid("user_id").primaryKey(),
13
+ trialEndsAt: timestamp("trial_ends_at", { withTimezone: true }).notNull(),
14
+ paidUntil: timestamp("paid_until", { withTimezone: true }),
15
+ isLifetime: boolean("is_lifetime").notNull().default(false),
16
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull(),
17
+ updatedAt: timestamp("updated_at", { withTimezone: true }).notNull(),
18
+ });
19
+
20
+ export const payments = billingSchema.table("payments", {
21
+ id: uuid("id").primaryKey().defaultRandom(),
22
+ userId: uuid("user_id").notNull(),
23
+ provider: text("provider").notNull(),
24
+ checkoutId: text("checkout_id").notNull(),
25
+ paymentId: text("payment_id"),
26
+ planId: text("plan_id").notNull(),
27
+ amount: bigint("amount", { mode: "number" }).notNull(),
28
+ currency: text("currency").notNull(),
29
+ status: text("status", { enum: ["paid", "refunded"] }).notNull(),
30
+ paidAt: timestamp("paid_at", { withTimezone: true }).notNull(),
31
+ refundedAt: timestamp("refunded_at", { withTimezone: true }),
32
+ grantKind: text("grant_kind", { enum: ["period", "lifetime"] }),
33
+ grantedFrom: timestamp("granted_from", { withTimezone: true }),
34
+ grantedUntil: timestamp("granted_until", { withTimezone: true }),
35
+ refundedAmount: bigint("refunded_amount", { mode: "number" }).notNull().default(0),
36
+ takenBackDays: integer("taken_back_days").notNull().default(0),
37
+ refundsSeenAt: timestamp("refunds_seen_at", { withTimezone: true }),
38
+ pendingRefundedAmount: bigint("pending_refunded_amount", { mode: "number" }),
39
+ pendingRefundsSeenAt: timestamp("pending_refunds_seen_at", { withTimezone: true }),
40
+ });
41
+
42
+ export const refundFailures = billingSchema.table(
43
+ "refund_failures",
44
+ {
45
+ paymentId: uuid("payment_id").notNull(),
46
+ refundId: text("refund_id").notNull(),
47
+ amount: bigint("amount", { mode: "number" }).notNull(),
48
+ refundCreatedAt: timestamp("refund_created_at", { withTimezone: true }).notNull(),
49
+ failedAt: timestamp("failed_at", { withTimezone: true }).notNull(),
50
+ recordedAt: timestamp("recorded_at", { withTimezone: true }).notNull(),
51
+ },
52
+ (table) => [primaryKey({ columns: [table.paymentId, table.refundId] })],
53
+ );
54
+
55
+ export const paymentRequests = billingSchema.table("payment_requests", {
56
+ id: uuid("id").primaryKey().defaultRandom(),
57
+ userId: uuid("user_id").notNull(),
58
+ planId: text("plan_id").notNull(),
59
+ invoiceName: text("invoice_name"),
60
+ invoiceTaxId: text("invoice_tax_id"),
61
+ invoiceAddress: text("invoice_address"),
62
+ status: text("status", { enum: ["open", "granted", "dismissed", "expired"] }).notNull(),
63
+ requestedAt: timestamp("requested_at", { withTimezone: true }).notNull(),
64
+ closedAt: timestamp("closed_at", { withTimezone: true }),
65
+ handedOverAt: timestamp("handed_over_at", { withTimezone: true }),
66
+ handoverClaimedAt: timestamp("handover_claimed_at", { withTimezone: true }),
67
+ amount: bigint("amount", { mode: "number" }),
68
+ currency: text("currency"),
69
+ });
70
+
71
+ export const manualGrants = billingSchema.table("manual_grants", {
72
+ id: uuid("id").primaryKey().defaultRandom(),
73
+ userId: uuid("user_id").notNull(),
74
+ planId: text("plan_id").notNull(),
75
+ requestId: uuid("request_id"),
76
+ grantedBy: uuid("granted_by"),
77
+ grantedAt: timestamp("granted_at", { withTimezone: true }).notNull(),
78
+ grantKind: text("grant_kind", { enum: ["period", "lifetime"] }).notNull(),
79
+ grantedFrom: timestamp("granted_from", { withTimezone: true }),
80
+ grantedUntil: timestamp("granted_until", { withTimezone: true }),
81
+ status: text("status", { enum: ["active", "revoked"] }).notNull(),
82
+ revokedAt: timestamp("revoked_at", { withTimezone: true }),
83
+ revokedBy: uuid("revoked_by"),
84
+ amount: bigint("amount", { mode: "number" }),
85
+ currency: text("currency"),
86
+ });
@@ -0,0 +1,188 @@
1
+ // The operator's tools for turning billing on for accounts that already exist: safe ops scripts
2
+ // (`@softure-ai/ops/scripts`), dry run by default, `--commit` writes. `import-entitlements` records
3
+ // what another system knew (trial ends, paid periods, lifetime access) from a JSON file through
4
+ // `importEntitlement`; `pin-trials` writes every derived trial into a row before a config change
5
+ // would move it (`pinDerivedTrials`). Reports and refusals carry counts and row numbers, never an
6
+ // email.
7
+ import { readFile } from "node:fs/promises";
8
+ import { users } from "@softure-ai/auth";
9
+ import { ok, systemClock, type Clock, type SoftureConfig } from "@softure-ai/core";
10
+ import type { Queryable } from "@softure-ai/db";
11
+ import { defineOpsScript, refuseOpsScript, type OpsScript } from "@softure-ai/ops/scripts";
12
+ import { count, eq, isNull } from "drizzle-orm";
13
+ import { z } from "zod";
14
+ import { entitlements } from "../schema.js";
15
+ import { getEntitlement, importEntitlement, pinDerivedTrials, type BillingContext } from "../server/entitlements.js";
16
+ import { findAccountByEmail } from "../server/plans.js";
17
+
18
+ export interface ImportEntitlementsScriptArgs {
19
+ readonly file: string;
20
+ }
21
+
22
+ export type PinTrialsScriptArgs = Record<string, never>;
23
+
24
+ export interface EntitlementScriptOptions {
25
+ /** The time stored as the rows' `created_at` / `updated_at`; the system clock by default. */
26
+ readonly clock?: Clock;
27
+ }
28
+
29
+ /** The most rows one import file holds; split a larger one. */
30
+ export const MAX_IMPORT_ROWS = 50_000;
31
+ /** The most problems a refusal lists. */
32
+ const MAX_LISTED = 10;
33
+
34
+ const importArgs = z.strictObject({
35
+ file: z.string().min(1, "--file=<path to a JSON file> is required"),
36
+ });
37
+
38
+ const pinArgs = z.strictObject({});
39
+
40
+ const instant = z.iso
41
+ .datetime({ offset: true, message: "must be an ISO 8601 date-time with an offset, e.g. 2026-11-01T00:00:00+01:00" })
42
+ .transform((text) => new Date(text));
43
+
44
+ const importRow = z
45
+ .strictObject({
46
+ email: z.string().trim().min(1, "is required").max(320),
47
+ trialEndsAt: instant.nullable().optional(),
48
+ paidUntil: instant.nullable().optional(),
49
+ isLifetime: z.boolean().optional(),
50
+ })
51
+ .refine((row) => (row.trialEndsAt ?? row.paidUntil ?? row.isLifetime) != null, "needs trialEndsAt, paidUntil or isLifetime");
52
+
53
+ const importFile = z.array(importRow).min(1, "holds no rows").max(MAX_IMPORT_ROWS, `holds more than ${String(MAX_IMPORT_ROWS)} rows; split it`);
54
+
55
+ type ImportRow = z.output<typeof importRow>;
56
+
57
+ /** Where the imported accounts stand: how many are in each state. */
58
+ interface ImportSummary {
59
+ readonly accounts: number;
60
+ readonly trial: number;
61
+ readonly paid: number;
62
+ readonly lifetime: number;
63
+ readonly readOnly: number;
64
+ }
65
+
66
+ /** "rows 3, 7 and 12" style, at most `MAX_LISTED` of them. */
67
+ function describeRows(rows: readonly number[]): string {
68
+ const listed = rows.slice(0, MAX_LISTED).map(String);
69
+ const more = rows.length > MAX_LISTED ? ` and ${String(rows.length - MAX_LISTED)} more` : "";
70
+ return `${rows.length === 1 ? "row" : "rows"} ${listed.join(", ")}${more}`;
71
+ }
72
+
73
+ /** The file's rows, or why they cannot be imported. Row numbers count from 1. */
74
+ async function readImportFile(path: string): Promise<{ readonly ok: true; readonly rows: readonly ImportRow[] } | { readonly ok: false; readonly reason: string }> {
75
+ let text: string;
76
+ try {
77
+ text = await readFile(path, "utf8");
78
+ } catch (error) {
79
+ const code = error instanceof Error && "code" in error ? String(error.code) : "unknown error";
80
+ return { ok: false, reason: `cannot read the file "${path}" (${code})` };
81
+ }
82
+ let json: unknown;
83
+ try {
84
+ json = JSON.parse(text);
85
+ } catch {
86
+ return { ok: false, reason: `the file "${path}" is not valid JSON` };
87
+ }
88
+ const parsed = importFile.safeParse(json);
89
+ if (!parsed.success) {
90
+ const problems = parsed.error.issues.slice(0, MAX_LISTED).map((issue) => {
91
+ const [index, ...field] = issue.path;
92
+ if (typeof index !== "number") return `the file ${issue.message}`;
93
+ return `row ${String(index + 1)}${field.length > 0 ? ` ${field.join(".")}` : ""}: ${issue.message}`;
94
+ });
95
+ return { ok: false, reason: `the file must be a JSON array of { email, trialEndsAt?, paidUntil?, isLifetime? }: ${problems.join("; ")}` };
96
+ }
97
+ return { ok: true, rows: parsed.data };
98
+ }
99
+
100
+ /** Row numbers of emails seen earlier in the file (compared as auth stores emails). */
101
+ function findRepeatedRows(rows: readonly ImportRow[]): number[] {
102
+ const seen = new Set<string>();
103
+ const repeated: number[] = [];
104
+ rows.forEach((row, index) => {
105
+ const key = row.email.toLowerCase();
106
+ if (seen.has(key)) repeated.push(index + 1);
107
+ seen.add(key);
108
+ });
109
+ return repeated;
110
+ }
111
+
112
+ async function summarize(ctx: BillingContext, userIds: readonly string[]): Promise<ImportSummary> {
113
+ const summary = { accounts: userIds.length, trial: 0, paid: 0, lifetime: 0, readOnly: 0 };
114
+ for (const userId of userIds) {
115
+ const entitlement = await getEntitlement(ctx, userId);
116
+ // The accounts were found under the script's transaction; one erased meanwhile counts as none.
117
+ if (entitlement === null) continue;
118
+ if (entitlement.status === "read_only") summary.readOnly += 1;
119
+ else if (entitlement.status === "trial") summary.trial += 1;
120
+ else if (entitlement.endsAt === null) summary.lifetime += 1;
121
+ else summary.paid += 1;
122
+ }
123
+ return summary;
124
+ }
125
+
126
+ /** `import-entitlements --file=…`: records trial ends, paid periods and lifetime access from a JSON file. */
127
+ export function createImportEntitlementsScript(config: SoftureConfig, options: EntitlementScriptOptions = {}): OpsScript<ImportEntitlementsScriptArgs> {
128
+ const clock = options.clock ?? systemClock;
129
+ return defineOpsScript({
130
+ name: "import-entitlements",
131
+ description: "Records the trial ends, paid periods and lifetime access in a JSON file for the accounts it names by email; never shortens access.",
132
+ usage: ["--file=<path to a JSON array of { email, trialEndsAt?, paidUntil?, isLifetime? }>"],
133
+ args: importArgs,
134
+ run: async (tx, args) => {
135
+ const ctx: BillingContext = { db: tx, clock, config };
136
+ const file = await readImportFile(args.file);
137
+ if (!file.ok) return refuseOpsScript(file.reason);
138
+ const repeated = findRepeatedRows(file.rows);
139
+ if (repeated.length > 0) return refuseOpsScript(`${describeRows(repeated)} repeat an email named earlier in the file`);
140
+
141
+ const userIds: string[] = [];
142
+ const unknown: number[] = [];
143
+ for (const [index, row] of file.rows.entries()) {
144
+ const account = await findAccountByEmail(ctx, row.email);
145
+ if (account === null) unknown.push(index + 1);
146
+ else userIds.push(account.id);
147
+ }
148
+ if (unknown.length > 0) return refuseOpsScript(`${describeRows(unknown)} name no account; nothing was imported`);
149
+
150
+ const before = await summarize(ctx, userIds);
151
+ for (const [index, row] of file.rows.entries()) {
152
+ const userId = userIds[index];
153
+ // One id per row: every row found an account above.
154
+ if (userId === undefined) throw new Error(`import-entitlements: row ${String(index + 1)} lost its account`);
155
+ const imported = await importEntitlement(ctx, { userId, trialEndsAt: row.trialEndsAt, paidUntil: row.paidUntil, isLifetime: row.isLifetime });
156
+ // Erased between the lookup and the change's lock.
157
+ if (!imported.ok) return refuseOpsScript(`${describeRows([index + 1])} names an account that was deleted meanwhile; nothing was imported`);
158
+ }
159
+ return ok({ before, after: await summarize(ctx, userIds) });
160
+ },
161
+ });
162
+ }
163
+
164
+ async function countAccountsWithoutRow(db: Queryable): Promise<number> {
165
+ const [row] = await db
166
+ .select({ total: count() })
167
+ .from(users)
168
+ .leftJoin(entitlements, eq(entitlements.userId, users.id))
169
+ .where(isNull(entitlements.userId));
170
+ return row?.total ?? 0;
171
+ }
172
+
173
+ /** `pin-trials`: writes every derived trial into a row, so a config change moves none of them. */
174
+ export function createPinTrialsScript(config: SoftureConfig, options: EntitlementScriptOptions = {}): OpsScript<PinTrialsScriptArgs> {
175
+ const clock = options.clock ?? systemClock;
176
+ return defineOpsScript({
177
+ name: "pin-trials",
178
+ description: "Writes the trial every account without an entitlement row is on into a row, before trial.days, trial.startsAt or the time zone changes.",
179
+ usage: [],
180
+ args: pinArgs,
181
+ run: async (tx) => {
182
+ const ctx: BillingContext = { db: tx, clock, config };
183
+ const before = { accountsWithoutRow: await countAccountsWithoutRow(tx) };
184
+ const pinned = await pinDerivedTrials(ctx);
185
+ return ok({ before, after: { accountsWithoutRow: await countAccountsWithoutRow(tx), pinned } });
186
+ },
187
+ });
188
+ }
@@ -0,0 +1,11 @@
1
+ // `@softure-ai/billing/scripts`: ops scripts that grant a plan, revoke a manual grant, import
2
+ // entitlements and pin derived trials (see the README, "Scripts" and "Existing accounts").
3
+ export {
4
+ createImportEntitlementsScript,
5
+ createPinTrialsScript,
6
+ MAX_IMPORT_ROWS,
7
+ type EntitlementScriptOptions,
8
+ type ImportEntitlementsScriptArgs,
9
+ type PinTrialsScriptArgs,
10
+ } from "./entitlement-scripts.js";
11
+ export { createGrantPlanScript, createRevokeGrantScript, type GrantPlanScriptArgs, type PlanScriptOptions, type RevokeGrantScriptArgs } from "./plan-scripts.js";
@@ -0,0 +1,143 @@
1
+ // The operator's way to grant a plan and revoke a manual grant without the admin page: safe ops
2
+ // scripts (`@softure-ai/ops/scripts`), dry run by default, `--commit` writes. They write through
3
+ // `grantPlanManually` and `revokeManualGrant`, so a script's grant is in the account's history like
4
+ // the admin page's, and either side can revoke the other's. Reports carry the user id, the
5
+ // entitlement and the active manual grants (with the ids `revoke-grant` takes), never the email.
6
+ import { ok, systemClock, type Clock, type SoftureConfig } from "@softure-ai/core";
7
+ import type { Queryable } from "@softure-ai/db";
8
+ import { defineOpsScript, refuseOpsScript, type OpsScript } from "@softure-ai/ops/scripts";
9
+ import { and, eq } from "drizzle-orm";
10
+ import { z } from "zod";
11
+ import type { Entitlement, PaymentGrant } from "../contract.js";
12
+ import { findPlan } from "../plans.js";
13
+ import { manualGrants } from "../schema.js";
14
+ import { getEntitlement, type BillingContext } from "../server/entitlements.js";
15
+ import { getAccountHistory, grantPlanManually, revokeManualGrant } from "../server/grants.js";
16
+ import { findAccountByEmail, getBillingPlans } from "../server/plans.js";
17
+ import { isUuid } from "../server/user-id.js";
18
+
19
+ export interface GrantPlanScriptArgs {
20
+ readonly email: string;
21
+ readonly plan: string;
22
+ }
23
+
24
+ export interface RevokeGrantScriptArgs {
25
+ readonly email: string;
26
+ readonly grant: string;
27
+ }
28
+
29
+ export interface PlanScriptOptions {
30
+ /** The time stored as `granted_at` / `revoked_at`; the system clock by default. */
31
+ readonly clock?: Clock;
32
+ }
33
+
34
+ const EMAIL = z.string().min(1, "--email=<account email> is required");
35
+
36
+ const grantPlanArgs = z.strictObject({
37
+ email: EMAIL,
38
+ plan: z.string().min(1, "--plan=<plan id> is required"),
39
+ });
40
+
41
+ const revokeGrantArgs = z.strictObject({
42
+ email: EMAIL,
43
+ grant: z.string().min(1, "--grant=<manual grant id> is required"),
44
+ });
45
+
46
+ const NO_ACCOUNT = "no account has this email";
47
+
48
+ /** An active manual grant as the report lists it. */
49
+ interface ActiveGrant {
50
+ readonly id: string;
51
+ readonly planId: string;
52
+ readonly grantedAt: Date;
53
+ readonly grant: PaymentGrant;
54
+ }
55
+
56
+ interface AccountState {
57
+ readonly userId: string;
58
+ readonly access: Entitlement | null;
59
+ /** Newest first, as the admin page's history lists them. */
60
+ readonly grants: readonly ActiveGrant[];
61
+ }
62
+
63
+ async function describeAccount(ctx: BillingContext, userId: string): Promise<AccountState> {
64
+ const history = await getAccountHistory(ctx, userId);
65
+ const grants = history.flatMap((entry) =>
66
+ entry.source === "manual" && entry.status === "active" ? [{ id: entry.id, planId: entry.planId, grantedAt: entry.at, grant: entry.grant }] : [],
67
+ );
68
+ return { userId, access: await getEntitlement(ctx, userId), grants };
69
+ }
70
+
71
+ function describeDeclared(config: SoftureConfig): string {
72
+ return getBillingPlans(config)
73
+ .map((plan) => plan.id)
74
+ .join(", ");
75
+ }
76
+
77
+ /** Whether `grantId` is an active manual grant of the account (its own select: the history is capped). */
78
+ async function hasActiveGrant(db: Queryable, userId: string, grantId: string): Promise<boolean> {
79
+ if (!isUuid(grantId)) return false;
80
+ const [row] = await db
81
+ .select({ id: manualGrants.id })
82
+ .from(manualGrants)
83
+ .where(and(eq(manualGrants.id, grantId), eq(manualGrants.userId, userId), eq(manualGrants.status, "active")));
84
+ return row !== undefined;
85
+ }
86
+
87
+ /** `grant-plan --email=… --plan=…`: grants one payment of a declared plan by hand and records it. */
88
+ export function createGrantPlanScript(config: SoftureConfig, options: PlanScriptOptions = {}): OpsScript<GrantPlanScriptArgs> {
89
+ const clock = options.clock ?? systemClock;
90
+ return defineOpsScript({
91
+ name: "grant-plan",
92
+ description: "Grants one payment of a plan to the account with the given email, recorded in its billing history.",
93
+ usage: ["--email=<account email>", "--plan=<plan id from billing({ plans })>"],
94
+ args: grantPlanArgs,
95
+ run: async (tx, args) => {
96
+ const ctx: BillingContext = { db: tx, clock, config };
97
+ if (findPlan(getBillingPlans(config), args.plan) === undefined) {
98
+ return refuseOpsScript(`plan "${args.plan}" is not declared (declared: ${describeDeclared(config)})`);
99
+ }
100
+ const account = await findAccountByEmail(ctx, args.email);
101
+ if (account === null) return refuseOpsScript(NO_ACCOUNT);
102
+ const before = await describeAccount(ctx, account.id);
103
+ const granted = await grantPlanManually(ctx, { userId: account.id, planId: args.plan, adminId: null });
104
+ if (!granted.ok) {
105
+ switch (granted.error) {
106
+ case "billing.lifetime_active":
107
+ return refuseOpsScript("the account has lifetime access already");
108
+ case "billing.account_unknown":
109
+ // Deleted between the lookup and the grant's lock.
110
+ return refuseOpsScript(NO_ACCOUNT);
111
+ case "billing.plan_unknown":
112
+ case "billing.request_closed":
113
+ // The plan was checked above and no request is given.
114
+ throw new Error(`grant-plan: granting plan "${args.plan}" failed with ${granted.error}`);
115
+ }
116
+ }
117
+ return ok({ before, after: await describeAccount(ctx, account.id) });
118
+ },
119
+ });
120
+ }
121
+
122
+ /** `revoke-grant --email=… --grant=…`: revokes one active manual grant and takes back what it added. */
123
+ export function createRevokeGrantScript(config: SoftureConfig, options: PlanScriptOptions = {}): OpsScript<RevokeGrantScriptArgs> {
124
+ const clock = options.clock ?? systemClock;
125
+ return defineOpsScript({
126
+ name: "revoke-grant",
127
+ description: "Revokes a manual plan grant of the account with the given email and takes back what it added.",
128
+ usage: ["--email=<account email>", "--grant=<manual grant id, listed by a dry run of either script>"],
129
+ args: revokeGrantArgs,
130
+ run: async (tx, args) => {
131
+ const ctx: BillingContext = { db: tx, clock, config };
132
+ const account = await findAccountByEmail(ctx, args.email);
133
+ if (account === null) return refuseOpsScript(NO_ACCOUNT);
134
+ const before = await describeAccount(ctx, account.id);
135
+ const noGrant = `the account has no active manual grant "${args.grant}"`;
136
+ if (!(await hasActiveGrant(tx, account.id, args.grant))) return refuseOpsScript(noGrant);
137
+ const revoked = await revokeManualGrant(ctx, { grantId: args.grant, adminId: null });
138
+ // Revoked by the admin page between the check and the revoke's lock.
139
+ if (!revoked.ok) return refuseOpsScript(noGrant);
140
+ return ok({ before, after: await describeAccount(ctx, account.id) });
141
+ },
142
+ });
143
+ }