@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
@@ -0,0 +1,189 @@
1
+ // The billing part of a GDPR export and deletion (`@softure-ai/privacy`): the account's entitlement
2
+ // row, its provider payments and their failed refunds, its invoice requests and the plans granted
3
+ // to it by hand. An account
4
+ // without an entitlement row has no stored entitlement (its trial is derived from the account). The
5
+ // provider keeps its own records of the payments. Which admin granted or revoked a plan is the
6
+ // admin's data, not the account's, and is left out of the export.
7
+ import { users } from "@softure-ai/auth";
8
+ import { ok, type ModuleContext, type Ok, type PrivacyContributor } from "@softure-ai/core";
9
+ import type { Queryable } from "@softure-ai/db";
10
+ import { asc, eq } from "drizzle-orm";
11
+ import { entitlements, manualGrants, paymentRequests, payments, refundFailures } from "../schema.js";
12
+ import { isUserId } from "./user-id.js";
13
+
14
+ /** One provider payment, as it appears in an export. */
15
+ export interface BillingPaymentData {
16
+ readonly provider: string;
17
+ readonly checkoutId: string;
18
+ readonly paymentId: string | null;
19
+ readonly planId: string;
20
+ readonly amount: number;
21
+ readonly currency: string;
22
+ readonly status: "paid" | "refunded";
23
+ readonly paidAt: Date;
24
+ readonly refundedAt: Date | null;
25
+ /** The total refunded so far, in the currency's minor unit. */
26
+ readonly refundedAmount: number;
27
+ /** What the payment granted: `period` (from, until) or `lifetime`; null when recorded before grants were. */
28
+ readonly grantKind: "period" | "lifetime" | null;
29
+ readonly grantedFrom: Date | null;
30
+ readonly grantedUntil: Date | null;
31
+ }
32
+
33
+ /** One provider refund that failed, as it appears in an export. */
34
+ export interface BillingRefundFailureData {
35
+ /** The provider's payment id of the refunded payment (`BillingPaymentData.paymentId`). */
36
+ readonly paymentId: string | null;
37
+ readonly refundId: string;
38
+ /** What the refund was for, in the currency's minor unit. */
39
+ readonly amount: number;
40
+ readonly refundCreatedAt: Date;
41
+ readonly failedAt: Date;
42
+ }
43
+
44
+ /** One invoice request, as it appears in an export; the details are gone once it was closed. */
45
+ export interface BillingPaymentRequestData {
46
+ readonly planId: string;
47
+ readonly invoiceName: string | null;
48
+ readonly invoiceTaxId: string | null;
49
+ readonly invoiceAddress: string | null;
50
+ /** The plan's price the request quoted, in the currency's minor unit; null before prices were recorded. */
51
+ readonly amount: number | null;
52
+ readonly currency: string | null;
53
+ readonly status: "open" | "granted" | "dismissed" | "expired";
54
+ readonly requestedAt: Date;
55
+ readonly closedAt: Date | null;
56
+ }
57
+
58
+ /** One plan granted by hand, as it appears in an export. */
59
+ export interface BillingManualGrantData {
60
+ readonly planId: string;
61
+ readonly grantedAt: Date;
62
+ readonly grantKind: "period" | "lifetime";
63
+ readonly grantedFrom: Date | null;
64
+ readonly grantedUntil: Date | null;
65
+ readonly status: "active" | "revoked";
66
+ readonly revokedAt: Date | null;
67
+ /** What it was granted for, in the currency's minor unit; null before prices were recorded. */
68
+ readonly amount: number | null;
69
+ readonly currency: string | null;
70
+ }
71
+
72
+ /** What billing holds about one user, as it appears in their export. */
73
+ export interface BillingUserData {
74
+ readonly entitlement: {
75
+ readonly trialEndsAt: Date;
76
+ readonly paidUntil: Date | null;
77
+ readonly isLifetime: boolean;
78
+ readonly createdAt: Date;
79
+ readonly updatedAt: Date;
80
+ } | null;
81
+ /** Oldest first. */
82
+ readonly payments: readonly BillingPaymentData[];
83
+ /** Oldest first. */
84
+ readonly refundFailures: readonly BillingRefundFailureData[];
85
+ /** Oldest first. */
86
+ readonly paymentRequests: readonly BillingPaymentRequestData[];
87
+ /** Oldest first. */
88
+ readonly manualGrants: readonly BillingManualGrantData[];
89
+ }
90
+
91
+ const EMPTY_USER_DATA: BillingUserData = { entitlement: null, payments: [], refundFailures: [], paymentRequests: [], manualGrants: [] };
92
+
93
+ export async function exportBillingUserData(context: ModuleContext, userId: string): Promise<Ok<BillingUserData>> {
94
+ if (!isUserId(userId)) return ok(EMPTY_USER_DATA);
95
+ // Core types `db` as unknown; privacy passes the @softure-ai/db handle or its transaction.
96
+ const db = context.db as Queryable;
97
+ const [row] = await db
98
+ .select({
99
+ trialEndsAt: entitlements.trialEndsAt,
100
+ paidUntil: entitlements.paidUntil,
101
+ isLifetime: entitlements.isLifetime,
102
+ createdAt: entitlements.createdAt,
103
+ updatedAt: entitlements.updatedAt,
104
+ })
105
+ .from(entitlements)
106
+ .where(eq(entitlements.userId, userId));
107
+ const paymentRows = await db
108
+ .select({
109
+ provider: payments.provider,
110
+ checkoutId: payments.checkoutId,
111
+ paymentId: payments.paymentId,
112
+ planId: payments.planId,
113
+ amount: payments.amount,
114
+ currency: payments.currency,
115
+ status: payments.status,
116
+ paidAt: payments.paidAt,
117
+ refundedAt: payments.refundedAt,
118
+ refundedAmount: payments.refundedAmount,
119
+ grantKind: payments.grantKind,
120
+ grantedFrom: payments.grantedFrom,
121
+ grantedUntil: payments.grantedUntil,
122
+ })
123
+ .from(payments)
124
+ .where(eq(payments.userId, userId))
125
+ .orderBy(asc(payments.paidAt), asc(payments.id));
126
+ const failureRows = await db
127
+ .select({
128
+ paymentId: payments.paymentId,
129
+ refundId: refundFailures.refundId,
130
+ amount: refundFailures.amount,
131
+ refundCreatedAt: refundFailures.refundCreatedAt,
132
+ failedAt: refundFailures.failedAt,
133
+ })
134
+ .from(refundFailures)
135
+ .innerJoin(payments, eq(payments.id, refundFailures.paymentId))
136
+ .where(eq(payments.userId, userId))
137
+ .orderBy(asc(refundFailures.failedAt), asc(refundFailures.refundId));
138
+ const requestRows = await db
139
+ .select({
140
+ planId: paymentRequests.planId,
141
+ invoiceName: paymentRequests.invoiceName,
142
+ invoiceTaxId: paymentRequests.invoiceTaxId,
143
+ invoiceAddress: paymentRequests.invoiceAddress,
144
+ amount: paymentRequests.amount,
145
+ currency: paymentRequests.currency,
146
+ status: paymentRequests.status,
147
+ requestedAt: paymentRequests.requestedAt,
148
+ closedAt: paymentRequests.closedAt,
149
+ })
150
+ .from(paymentRequests)
151
+ .where(eq(paymentRequests.userId, userId))
152
+ .orderBy(asc(paymentRequests.requestedAt), asc(paymentRequests.id));
153
+ const grantRows = await db
154
+ .select({
155
+ planId: manualGrants.planId,
156
+ grantedAt: manualGrants.grantedAt,
157
+ grantKind: manualGrants.grantKind,
158
+ grantedFrom: manualGrants.grantedFrom,
159
+ grantedUntil: manualGrants.grantedUntil,
160
+ status: manualGrants.status,
161
+ revokedAt: manualGrants.revokedAt,
162
+ amount: manualGrants.amount,
163
+ currency: manualGrants.currency,
164
+ })
165
+ .from(manualGrants)
166
+ .where(eq(manualGrants.userId, userId))
167
+ .orderBy(asc(manualGrants.grantedAt), asc(manualGrants.id));
168
+ return ok({ entitlement: row ?? null, payments: paymentRows, refundFailures: failureRows, paymentRequests: requestRows, manualGrants: grantRows });
169
+ }
170
+
171
+ export async function deleteBillingUserData(context: ModuleContext, userId: string): Promise<Ok<undefined>> {
172
+ if (!isUserId(userId)) return ok();
173
+ const db = context.db as Queryable;
174
+ // Lock the account first, in the order `changeEntitlement` takes its locks (account, then
175
+ // entitlement): a change running at the same time then waits instead of deadlocking the erase.
176
+ await db.select({ id: users.id }).from(users).where(eq(users.id, userId)).for("update");
177
+ await db.delete(entitlements).where(eq(entitlements.userId, userId));
178
+ // Their failed refunds go with them (ON DELETE CASCADE).
179
+ await db.delete(payments).where(eq(payments.userId, userId));
180
+ // Grants first: they reference the requests they answered.
181
+ await db.delete(manualGrants).where(eq(manualGrants.userId, userId));
182
+ await db.delete(paymentRequests).where(eq(paymentRequests.userId, userId));
183
+ return ok();
184
+ }
185
+
186
+ export const billingPrivacyContributor: PrivacyContributor = {
187
+ exportUserData: exportBillingUserData,
188
+ deleteUserData: deleteBillingUserData,
189
+ };
@@ -0,0 +1,113 @@
1
+ // The accounts due a reminder mail now. Two bounded range queries find the candidates: stored rows
2
+ // whose trial or dated paid access ends inside the widest window, and accounts without a row whose
3
+ // derived trial does (its end day is the creation day plus `trial.days`, so the window is a range
4
+ // on `auth.users.created_at`, indexed by auth, plus every account under `trial.startsAt` when the
5
+ // floor's trial ends in it). The pure rule then decides each candidate exactly,
6
+ // so the ranges only need to be wide enough, never exact.
7
+ import { users } from "@softure-ai/auth";
8
+ import { and, asc, eq, gte, isNull, lt, or, type SQL } from "drizzle-orm";
9
+ import { getDayNumber, getStartOfDay } from "../calendar.js";
10
+ import type { EntitlementRecord } from "../contract.js";
11
+ import { DEFAULT_CATCH_UP_DAYS, getAccessReminder, MAX_CATCH_UP_DAYS, type AccessReminderKind } from "../reminder.js";
12
+ import { entitlements } from "../schema.js";
13
+ import { getDefaultRecord, getTrialFloorDay, type BillingContext } from "./entitlements.js";
14
+ import { getBillingOptions, getEntitlementPolicy } from "./options.js";
15
+
16
+ export interface FindAccessRemindersOptions {
17
+ /** An ended mail is due from the day access ended through this many local days after it. Default 3. */
18
+ readonly catchUpDays?: number;
19
+ }
20
+
21
+ export interface AccessReminderDue {
22
+ readonly userId: string;
23
+ readonly email: string;
24
+ readonly kind: AccessReminderKind;
25
+ /** The first instant without the access the mail is about. */
26
+ readonly endsAt: Date;
27
+ }
28
+
29
+ interface Candidate {
30
+ readonly userId: string;
31
+ readonly email: string;
32
+ readonly createdAt: Date;
33
+ readonly record: EntitlementRecord;
34
+ }
35
+
36
+ /**
37
+ * Every account due a reminder at the clock's now, ordered by end, then account. Reads only.
38
+ * Throws for a `catchUpDays` that is not an integer from 0 to 365 (a caller bug); database errors
39
+ * propagate.
40
+ */
41
+ export async function findAccessReminders(ctx: BillingContext, options: FindAccessRemindersOptions = {}): Promise<AccessReminderDue[]> {
42
+ const catchUpDays = options.catchUpDays ?? DEFAULT_CATCH_UP_DAYS;
43
+ if (!Number.isInteger(catchUpDays) || catchUpDays < 0 || catchUpDays > MAX_CATCH_UP_DAYS) {
44
+ throw new Error(`@softure-ai/billing: catchUpDays must be an integer from 0 to ${String(MAX_CATCH_UP_DAYS)}, got ${String(catchUpDays)}`);
45
+ }
46
+ const now = ctx.clock.now();
47
+ const policy = getEntitlementPolicy(ctx.config);
48
+ const candidates = [...(await findStoredCandidates(ctx, now, catchUpDays)), ...(await findDerivedCandidates(ctx, now, catchUpDays))];
49
+
50
+ const due: AccessReminderDue[] = [];
51
+ for (const candidate of candidates) {
52
+ const reminder = getAccessReminder({ record: candidate.record, accountCreatedAt: candidate.createdAt, now, policy, catchUpDays });
53
+ if (reminder !== null) due.push({ userId: candidate.userId, email: candidate.email, ...reminder });
54
+ }
55
+ return due.sort((first, second) => first.endsAt.getTime() - second.endsAt.getTime() || first.userId.localeCompare(second.userId));
56
+ }
57
+
58
+ /** Stored rows without lifetime whose trial or paid end lies from the catch-up start to past the widest reminder window. */
59
+ async function findStoredCandidates(ctx: BillingContext, now: Date, catchUpDays: number): Promise<Candidate[]> {
60
+ const { timezone } = ctx.config;
61
+ const { trial, paid } = getBillingOptions(ctx.config);
62
+ const today = getDayNumber(now, timezone);
63
+ const from = getStartOfDay(today - catchUpDays, timezone);
64
+ const to = getStartOfDay(today + Math.max(trial.reminderDays, paid.reminderDays) + 1, timezone);
65
+ const rows = await ctx.db
66
+ .select({
67
+ userId: users.id,
68
+ email: users.email,
69
+ createdAt: users.createdAt,
70
+ trialEndsAt: entitlements.trialEndsAt,
71
+ paidUntil: entitlements.paidUntil,
72
+ isLifetime: entitlements.isLifetime,
73
+ })
74
+ .from(entitlements)
75
+ .innerJoin(users, eq(users.id, entitlements.userId))
76
+ .where(
77
+ and(
78
+ eq(entitlements.isLifetime, false),
79
+ or(and(gte(entitlements.trialEndsAt, from), lt(entitlements.trialEndsAt, to)), and(gte(entitlements.paidUntil, from), lt(entitlements.paidUntil, to))),
80
+ ),
81
+ )
82
+ .orderBy(asc(users.id));
83
+ return rows.map(({ trialEndsAt, paidUntil, isLifetime, ...account }) => ({ ...account, record: { trialEndsAt, paidUntil, isLifetime } }));
84
+ }
85
+
86
+ /**
87
+ * Accounts without a row whose derived trial ends in the same span: a trial ends at the start of
88
+ * the creation day plus `trial.days`, so the span moves back by `trial.days` onto `created_at`.
89
+ * Under `trial.startsAt` every account created before the floor day shares one trial end; when that
90
+ * end lies in the span, they are all candidates.
91
+ */
92
+ async function findDerivedCandidates(ctx: BillingContext, now: Date, catchUpDays: number): Promise<Candidate[]> {
93
+ const { timezone } = ctx.config;
94
+ const { trial } = getBillingOptions(ctx.config);
95
+ const today = getDayNumber(now, timezone);
96
+ const from = getStartOfDay(today - catchUpDays - trial.days, timezone);
97
+ const to = getStartOfDay(today + trial.reminderDays + 1 - trial.days, timezone);
98
+ let createdInSpan: SQL | undefined = and(gte(users.createdAt, from), lt(users.createdAt, to));
99
+ const floorDay = getTrialFloorDay(ctx);
100
+ if (floorDay !== null) {
101
+ const floorEndDay = floorDay + trial.days;
102
+ if (floorEndDay >= today - catchUpDays && floorEndDay <= today + trial.reminderDays) {
103
+ createdInSpan = or(createdInSpan, lt(users.createdAt, getStartOfDay(floorDay, timezone)));
104
+ }
105
+ }
106
+ const rows = await ctx.db
107
+ .select({ userId: users.id, email: users.email, createdAt: users.createdAt })
108
+ .from(users)
109
+ .leftJoin(entitlements, eq(entitlements.userId, users.id))
110
+ .where(and(isNull(entitlements.userId), createdInSpan))
111
+ .orderBy(asc(users.id));
112
+ return rows.map((account) => ({ ...account, record: getDefaultRecord(ctx, account.createdAt) }));
113
+ }
@@ -0,0 +1,201 @@
1
+ // Invoice requests in billing: stored before a provider hands a request over (the manual adapter),
2
+ // listed in the admin page until the admin grants or dismisses them or they expire. One open
3
+ // request per account and plan: asking again refreshes its details and price. The hand-over is
4
+ // claimed on the row (`handover_claimed_at`) and recorded once it answered (`handed_over_at`), so
5
+ // an open request reaches the owner once; a failed hand-over is released for the next ask, and a
6
+ // claim left without an answer (the process stopped) is taken over after a minute. Closing a request clears its invoice details, which are
7
+ // personal data the app no longer needs (the migration's CHECK holds closed rows empty).
8
+ import { users } from "@softure-ai/auth";
9
+ import { err, ok, type Err, type Ok } from "@softure-ai/core";
10
+ import type { Queryable } from "@softure-ai/db";
11
+ import { and, asc, eq, isNull, lt, lte, or, sql } from "drizzle-orm";
12
+ import type { PlanPrice } from "../contract.js";
13
+ import type { InvoiceDetails } from "../payment.js";
14
+ import { paymentRequests } from "../schema.js";
15
+ import type { BillingContext } from "./entitlements.js";
16
+ import { getBillingOptions } from "./options.js";
17
+ import { isUuid } from "./user-id.js";
18
+
19
+ const DAY_MS = 24 * 60 * 60 * 1000;
20
+
21
+ /** How long a hand-over claim blocks other asks: a provider's mail call takes seconds. */
22
+ const HANDOVER_CLAIM_TIMEOUT_MS = 60 * 1000;
23
+
24
+ /** How many open requests the admin page lists by default. */
25
+ export const OPEN_REQUESTS_LIMIT = 50;
26
+
27
+ export interface RecordPaymentRequestInput {
28
+ readonly userId: string;
29
+ readonly planId: string;
30
+ /** Null when the provider collects its own details. */
31
+ readonly invoice: InvoiceDetails | null;
32
+ /** The plan's price now: what the request quotes. */
33
+ readonly price: PlanPrice;
34
+ }
35
+
36
+ /** An open request, as the admin page lists it. */
37
+ export interface OpenPaymentRequest {
38
+ readonly id: string;
39
+ readonly userId: string;
40
+ readonly email: string;
41
+ readonly planId: string;
42
+ readonly invoice: InvoiceDetails | null;
43
+ /** The plan's price when it was asked for; null for a request stored before prices were. */
44
+ readonly price: PlanPrice | null;
45
+ readonly requestedAt: Date;
46
+ }
47
+
48
+ /** The columns that close a request: its status, when, and no invoice details any more. */
49
+ export function getClosedRequestColumns(status: "granted" | "dismissed" | "expired", now: Date) {
50
+ return { status, closedAt: now, invoiceName: null, invoiceTaxId: null, invoiceAddress: null };
51
+ }
52
+
53
+ /**
54
+ * Stores a request before it is handed over to the owner, or refreshes the account's open request
55
+ * for the same plan (its details, price and time; its hand-over and claim stay). Returns the
56
+ * request's id. Database errors propagate.
57
+ */
58
+ export async function recordPaymentRequest(ctx: Pick<BillingContext, "db" | "clock">, input: RecordPaymentRequestInput): Promise<string> {
59
+ const now = ctx.clock.now();
60
+ const details = {
61
+ invoiceName: input.invoice?.name ?? null,
62
+ invoiceTaxId: input.invoice?.taxId ?? null,
63
+ invoiceAddress: input.invoice?.address ?? null,
64
+ amount: input.price.amount,
65
+ currency: input.price.currency,
66
+ };
67
+ const [row] = await ctx.db
68
+ .insert(paymentRequests)
69
+ .values({ userId: input.userId, planId: input.planId, ...details, status: "open", requestedAt: now })
70
+ .onConflictDoUpdate({
71
+ target: [paymentRequests.userId, paymentRequests.planId],
72
+ targetWhere: sql`status = 'open'`,
73
+ set: { ...details, requestedAt: now },
74
+ })
75
+ .returning();
76
+ // An insert or an update of the conflicting row always returns it.
77
+ if (row === undefined) throw new Error("@softure-ai/billing: storing a payment request returned no row");
78
+ return row.id;
79
+ }
80
+
81
+ /**
82
+ * Claims the hand-over of an open request that was not handed over yet: the time it was claimed,
83
+ * or null when it was handed over already, is being handed over (a claim younger than
84
+ * `HANDOVER_CLAIM_TIMEOUT_MS`), or is no longer open. An older claim was left without an answer and
85
+ * is taken over. A conditional update, so two asks at once hand it over once. Database errors
86
+ * propagate.
87
+ */
88
+ export async function claimHandOver(ctx: Pick<BillingContext, "db" | "clock">, requestId: string): Promise<Date | null> {
89
+ const now = ctx.clock.now();
90
+ const staleBefore = new Date(now.getTime() - HANDOVER_CLAIM_TIMEOUT_MS);
91
+ const [claimed] = await ctx.db
92
+ .update(paymentRequests)
93
+ .set({ handoverClaimedAt: now })
94
+ .where(
95
+ and(
96
+ eq(paymentRequests.id, requestId),
97
+ eq(paymentRequests.status, "open"),
98
+ isNull(paymentRequests.handedOverAt),
99
+ or(isNull(paymentRequests.handoverClaimedAt), lte(paymentRequests.handoverClaimedAt, staleBefore)),
100
+ ),
101
+ )
102
+ .returning();
103
+ return claimed === undefined ? null : now;
104
+ }
105
+
106
+ /**
107
+ * Records a hand-over that answered: the request is never handed over again, whichever ask holds
108
+ * the claim now. The first record wins. Database errors propagate.
109
+ */
110
+ export async function confirmHandOver(ctx: Pick<BillingContext, "db" | "clock">, requestId: string): Promise<void> {
111
+ await ctx.db
112
+ .update(paymentRequests)
113
+ .set({ handedOverAt: ctx.clock.now(), handoverClaimedAt: null })
114
+ .where(and(eq(paymentRequests.id, requestId), isNull(paymentRequests.handedOverAt)));
115
+ }
116
+
117
+ /**
118
+ * Gives back a claim whose hand-over failed, so the next ask tries again. Only the claim made at
119
+ * `claimedAt` is cleared, never a later one. Database errors propagate.
120
+ */
121
+ export async function releaseHandOver(ctx: Pick<BillingContext, "db">, requestId: string, claimedAt: Date): Promise<void> {
122
+ await ctx.db
123
+ .update(paymentRequests)
124
+ .set({ handoverClaimedAt: null })
125
+ .where(and(eq(paymentRequests.id, requestId), eq(paymentRequests.handoverClaimedAt, claimedAt)));
126
+ }
127
+
128
+ /** The open requests, oldest first, with each account's email. */
129
+ export async function listOpenRequests(ctx: Pick<BillingContext, "db">, limit: number = OPEN_REQUESTS_LIMIT): Promise<readonly OpenPaymentRequest[]> {
130
+ const rows = await ctx.db
131
+ .select({
132
+ id: paymentRequests.id,
133
+ userId: paymentRequests.userId,
134
+ email: users.email,
135
+ planId: paymentRequests.planId,
136
+ invoiceName: paymentRequests.invoiceName,
137
+ invoiceTaxId: paymentRequests.invoiceTaxId,
138
+ invoiceAddress: paymentRequests.invoiceAddress,
139
+ amount: paymentRequests.amount,
140
+ currency: paymentRequests.currency,
141
+ requestedAt: paymentRequests.requestedAt,
142
+ })
143
+ .from(paymentRequests)
144
+ .innerJoin(users, eq(users.id, paymentRequests.userId))
145
+ .where(eq(paymentRequests.status, "open"))
146
+ .orderBy(asc(paymentRequests.requestedAt), asc(paymentRequests.id))
147
+ .limit(limit);
148
+ return rows.map(({ invoiceName, invoiceTaxId, invoiceAddress, amount, currency, ...row }) => ({
149
+ ...row,
150
+ invoice: invoiceName === null || invoiceAddress === null ? null : { name: invoiceName, taxId: invoiceTaxId, address: invoiceAddress },
151
+ price: readPrice(amount, currency),
152
+ }));
153
+ }
154
+
155
+ /** The open request with this id (its account and plan), or undefined. */
156
+ export async function findOpenRequest(db: Queryable, requestId: string): Promise<{ readonly userId: string; readonly planId: string } | undefined> {
157
+ if (!isUuid(requestId)) return undefined;
158
+ const [row] = await db
159
+ .select({ userId: paymentRequests.userId, planId: paymentRequests.planId })
160
+ .from(paymentRequests)
161
+ .where(and(eq(paymentRequests.id, requestId), eq(paymentRequests.status, "open")));
162
+ return row;
163
+ }
164
+
165
+ /** Closes an open request without a grant and clears its details; `billing.request_closed` when it is not open. */
166
+ export async function dismissPaymentRequest(ctx: Pick<BillingContext, "db" | "clock">, requestId: string): Promise<Ok<undefined> | Err<"billing.request_closed">> {
167
+ if (!isUuid(requestId)) return err("billing.request_closed");
168
+ const [closed] = await ctx.db
169
+ .update(paymentRequests)
170
+ .set(getClosedRequestColumns("dismissed", ctx.clock.now()))
171
+ .where(and(eq(paymentRequests.id, requestId), eq(paymentRequests.status, "open")))
172
+ .returning();
173
+ return closed === undefined ? err("billing.request_closed") : ok();
174
+ }
175
+
176
+ export interface ExpiredRequestsSummary {
177
+ /** Open requests closed as `expired` by this run. */
178
+ readonly expired: number;
179
+ }
180
+
181
+ /**
182
+ * Closes the open requests nobody asked again for in `requests.expireAfterDays` days as `expired`
183
+ * and clears their invoice details: personal data kept no longer than the request waits. Run it
184
+ * daily (a scheduler); a repeated run closes nothing new. Database errors propagate.
185
+ */
186
+ export async function expireStaleRequests(ctx: BillingContext): Promise<ExpiredRequestsSummary> {
187
+ const now = ctx.clock.now();
188
+ const { expireAfterDays } = getBillingOptions(ctx.config).requests;
189
+ const cutoff = new Date(now.getTime() - expireAfterDays * DAY_MS);
190
+ const expired = await ctx.db
191
+ .update(paymentRequests)
192
+ .set(getClosedRequestColumns("expired", now))
193
+ .where(and(eq(paymentRequests.status, "open"), lt(paymentRequests.requestedAt, cutoff)))
194
+ .returning();
195
+ return { expired: expired.length };
196
+ }
197
+
198
+ /** A stored price, or null when the row has none (a CHECK stores both or neither). */
199
+ export function readPrice(amount: number | null, currency: string | null): PlanPrice | null {
200
+ return amount === null || currency === null ? null : { amount, currency };
201
+ }
@@ -0,0 +1,37 @@
1
+ // What billing needs from the app's other modules, checked once per config so a setup mistake fails
2
+ // with one clear error instead of on each request: the rate limit bucket in `security({ buckets })`
3
+ // (before the first payment), and an `adminRole` that `auth({ roles })` declares (at the first
4
+ // billing request and in the readiness probe).
5
+ import { getDeclaredRoles, isDeclaredRole } from "@softure-ai/auth/server";
6
+ import { getModule, type SoftureConfig } from "@softure-ai/core";
7
+ import { getBillingOptions } from "./options.js";
8
+
9
+ export const PAYMENT_BUCKET = "billing-payment";
10
+
11
+ const checkedPaymentConfigs = new WeakSet<SoftureConfig>();
12
+ const checkedRoleConfigs = new WeakSet<SoftureConfig>();
13
+
14
+ export function assertPaymentSetup(config: SoftureConfig): void {
15
+ if (checkedPaymentConfigs.has(config)) return;
16
+ const security = getModule(config, "security")?.options as { buckets?: Readonly<Record<string, unknown>> } | undefined;
17
+ if (!Object.hasOwn(security?.buckets ?? {}, PAYMENT_BUCKET)) {
18
+ throw new Error(`@softure-ai/billing: security({ buckets }) lacks "${PAYMENT_BUCKET}"; spread BILLING_RATE_LIMIT_BUCKETS into it`);
19
+ }
20
+ checkedPaymentConfigs.add(config);
21
+ }
22
+
23
+ /**
24
+ * Throws when `billing({ adminRole })` is not a role of `auth({ roles })`: a misspelt role would
25
+ * otherwise surface only when an admin opens the admin page.
26
+ */
27
+ export function assertAdminRoleDeclared(config: SoftureConfig): void {
28
+ if (checkedRoleConfigs.has(config)) return;
29
+ const { adminRole } = getBillingOptions(config);
30
+ if (!isDeclaredRole(config, adminRole)) {
31
+ const declared = [...getDeclaredRoles(config)].join(", ");
32
+ throw new Error(
33
+ `@softure-ai/billing: billing({ adminRole }) is "${adminRole}", which auth({ roles }) does not declare (declared: ${declared}); declare it there or fix the name`,
34
+ );
35
+ }
36
+ checkedRoleConfigs.add(config);
37
+ }