@owlmeans/server-payment 0.1.18-rc.2 → 0.1.18-rc.21

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 (222) hide show
  1. package/README.md +109 -25
  2. package/agent-meta/manifest.json +2 -2
  3. package/agent-meta/skills/server-payment/SKILL.md +475 -52
  4. package/build/actions/index.d.ts +1 -0
  5. package/build/actions/index.d.ts.map +1 -1
  6. package/build/actions/index.js +1 -0
  7. package/build/actions/index.js.map +1 -1
  8. package/build/actions/resync-subscriptions.d.ts +3 -0
  9. package/build/actions/resync-subscriptions.d.ts.map +1 -0
  10. package/build/actions/resync-subscriptions.js +7 -0
  11. package/build/actions/resync-subscriptions.js.map +1 -0
  12. package/build/actions/resync.d.ts +1 -0
  13. package/build/actions/resync.d.ts.map +1 -1
  14. package/build/actions/resync.js +7 -3
  15. package/build/actions/resync.js.map +1 -1
  16. package/build/actions/webhook.d.ts.map +1 -1
  17. package/build/actions/webhook.js +6 -3
  18. package/build/actions/webhook.js.map +1 -1
  19. package/build/config.d.ts +60 -5
  20. package/build/config.d.ts.map +1 -1
  21. package/build/config.js +237 -5
  22. package/build/config.js.map +1 -1
  23. package/build/consts.d.ts +75 -3
  24. package/build/consts.d.ts.map +1 -1
  25. package/build/consts.js +94 -5
  26. package/build/consts.js.map +1 -1
  27. package/build/consumer/capture.d.ts +74 -0
  28. package/build/consumer/capture.d.ts.map +1 -0
  29. package/build/consumer/capture.js +291 -0
  30. package/build/consumer/capture.js.map +1 -0
  31. package/build/consumer/format.d.ts +27 -0
  32. package/build/consumer/format.d.ts.map +1 -0
  33. package/build/consumer/format.js +81 -0
  34. package/build/consumer/format.js.map +1 -0
  35. package/build/consumer/handlers.d.ts +28 -0
  36. package/build/consumer/handlers.d.ts.map +1 -0
  37. package/build/consumer/handlers.js +173 -0
  38. package/build/consumer/handlers.js.map +1 -0
  39. package/build/consumer/index.d.ts +7 -0
  40. package/build/consumer/index.d.ts.map +1 -0
  41. package/build/consumer/index.js +6 -0
  42. package/build/consumer/index.js.map +1 -0
  43. package/build/consumer/mail.d.ts +27 -0
  44. package/build/consumer/mail.d.ts.map +1 -0
  45. package/build/consumer/mail.js +314 -0
  46. package/build/consumer/mail.js.map +1 -0
  47. package/build/consumer/origin.d.ts +14 -0
  48. package/build/consumer/origin.d.ts.map +1 -0
  49. package/build/consumer/origin.js +47 -0
  50. package/build/consumer/origin.js.map +1 -0
  51. package/build/consumer/reconcile.d.ts +12 -0
  52. package/build/consumer/reconcile.d.ts.map +1 -0
  53. package/build/consumer/reconcile.js +317 -0
  54. package/build/consumer/reconcile.js.map +1 -0
  55. package/build/consumer/records.d.ts +78 -0
  56. package/build/consumer/records.d.ts.map +1 -0
  57. package/build/consumer/records.js +296 -0
  58. package/build/consumer/records.js.map +1 -0
  59. package/build/consumer/service.d.ts +51 -0
  60. package/build/consumer/service.d.ts.map +1 -0
  61. package/build/consumer/service.js +760 -0
  62. package/build/consumer/service.js.map +1 -0
  63. package/build/consumer/withdrawal.d.ts +57 -0
  64. package/build/consumer/withdrawal.d.ts.map +1 -0
  65. package/build/consumer/withdrawal.js +247 -0
  66. package/build/consumer/withdrawal.js.map +1 -0
  67. package/build/entitlement.d.ts +10 -0
  68. package/build/entitlement.d.ts.map +1 -0
  69. package/build/entitlement.js +69 -0
  70. package/build/entitlement.js.map +1 -0
  71. package/build/entrypoints.d.ts +1 -1
  72. package/build/entrypoints.d.ts.map +1 -1
  73. package/build/entrypoints.js +2 -1
  74. package/build/entrypoints.js.map +1 -1
  75. package/build/gate.d.ts +32 -4
  76. package/build/gate.d.ts.map +1 -1
  77. package/build/gate.js +59 -19
  78. package/build/gate.js.map +1 -1
  79. package/build/index.d.ts +18 -3
  80. package/build/index.d.ts.map +1 -1
  81. package/build/index.js +15 -3
  82. package/build/index.js.map +1 -1
  83. package/build/limit.d.ts +13 -0
  84. package/build/limit.d.ts.map +1 -0
  85. package/build/limit.js +47 -0
  86. package/build/limit.js.map +1 -0
  87. package/build/model.d.ts +10 -1
  88. package/build/model.d.ts.map +1 -1
  89. package/build/model.js +215 -17
  90. package/build/model.js.map +1 -1
  91. package/build/observer.d.ts +9 -0
  92. package/build/observer.d.ts.map +1 -1
  93. package/build/observer.js +38 -12
  94. package/build/observer.js.map +1 -1
  95. package/build/plan.d.ts +22 -0
  96. package/build/plan.d.ts.map +1 -0
  97. package/build/plan.js +72 -0
  98. package/build/plan.js.map +1 -0
  99. package/build/plugins/checkout-plugins.d.ts +49 -0
  100. package/build/plugins/checkout-plugins.d.ts.map +1 -0
  101. package/build/plugins/checkout-plugins.js +124 -0
  102. package/build/plugins/checkout-plugins.js.map +1 -0
  103. package/build/plugins/estimate.d.ts +43 -0
  104. package/build/plugins/estimate.d.ts.map +1 -0
  105. package/build/plugins/estimate.js +268 -0
  106. package/build/plugins/estimate.js.map +1 -0
  107. package/build/plugins/events.d.ts +45 -10
  108. package/build/plugins/events.d.ts.map +1 -1
  109. package/build/plugins/events.js +605 -136
  110. package/build/plugins/events.js.map +1 -1
  111. package/build/plugins/fx.d.ts +31 -0
  112. package/build/plugins/fx.d.ts.map +1 -0
  113. package/build/plugins/fx.js +81 -0
  114. package/build/plugins/fx.js.map +1 -0
  115. package/build/plugins/portal.d.ts +37 -0
  116. package/build/plugins/portal.d.ts.map +1 -0
  117. package/build/plugins/portal.js +265 -0
  118. package/build/plugins/portal.js.map +1 -0
  119. package/build/plugins/refunds.d.ts +33 -0
  120. package/build/plugins/refunds.d.ts.map +1 -0
  121. package/build/plugins/refunds.js +80 -0
  122. package/build/plugins/refunds.js.map +1 -0
  123. package/build/plugins/stripe.d.ts +36 -4
  124. package/build/plugins/stripe.d.ts.map +1 -1
  125. package/build/plugins/stripe.js +492 -97
  126. package/build/plugins/stripe.js.map +1 -1
  127. package/build/plugins/webhook-manager.d.ts +46 -0
  128. package/build/plugins/webhook-manager.d.ts.map +1 -0
  129. package/build/plugins/webhook-manager.js +231 -0
  130. package/build/plugins/webhook-manager.js.map +1 -0
  131. package/build/reconcile.d.ts +15 -0
  132. package/build/reconcile.d.ts.map +1 -0
  133. package/build/reconcile.js +88 -0
  134. package/build/reconcile.js.map +1 -0
  135. package/build/resource.d.ts +12 -1
  136. package/build/resource.d.ts.map +1 -1
  137. package/build/resource.js +97 -6
  138. package/build/resource.js.map +1 -1
  139. package/build/service.d.ts +28 -3
  140. package/build/service.d.ts.map +1 -1
  141. package/build/service.js +146 -19
  142. package/build/service.js.map +1 -1
  143. package/build/subscription.d.ts +48 -0
  144. package/build/subscription.d.ts.map +1 -0
  145. package/build/subscription.js +176 -0
  146. package/build/subscription.js.map +1 -0
  147. package/build/sync.d.ts +28 -1
  148. package/build/sync.d.ts.map +1 -1
  149. package/build/sync.js +208 -31
  150. package/build/sync.js.map +1 -1
  151. package/build/types.d.ts +1115 -36
  152. package/build/types.d.ts.map +1 -1
  153. package/build/usage.d.ts +63 -0
  154. package/build/usage.d.ts.map +1 -0
  155. package/build/usage.js +363 -0
  156. package/build/usage.js.map +1 -0
  157. package/build/utils.d.ts +52 -1
  158. package/build/utils.d.ts.map +1 -1
  159. package/build/utils.js +69 -7
  160. package/build/utils.js.map +1 -1
  161. package/package.json +17 -13
  162. package/src/actions/index.ts +1 -0
  163. package/src/actions/resync-subscriptions.ts +10 -0
  164. package/src/actions/resync.ts +6 -3
  165. package/src/actions/webhook.ts +5 -3
  166. package/src/config.ts +264 -8
  167. package/src/consts.ts +114 -6
  168. package/src/consumer/capture.ts +362 -0
  169. package/src/consumer/format.ts +90 -0
  170. package/src/consumer/handlers.ts +211 -0
  171. package/src/consumer/index.ts +6 -0
  172. package/src/consumer/mail.ts +368 -0
  173. package/src/consumer/origin.ts +63 -0
  174. package/src/consumer/reconcile.ts +329 -0
  175. package/src/consumer/records.ts +374 -0
  176. package/src/consumer/service.ts +868 -0
  177. package/src/consumer/withdrawal.ts +302 -0
  178. package/src/entitlement.ts +84 -0
  179. package/src/entrypoints.ts +2 -1
  180. package/src/gate.ts +88 -21
  181. package/src/index.ts +24 -3
  182. package/src/limit.ts +57 -0
  183. package/src/model.ts +237 -18
  184. package/src/observer.ts +44 -11
  185. package/src/plan.ts +89 -0
  186. package/src/plugins/checkout-plugins.ts +155 -0
  187. package/src/plugins/estimate.ts +339 -0
  188. package/src/plugins/events.ts +677 -121
  189. package/src/plugins/fx.ts +122 -0
  190. package/src/plugins/portal.ts +306 -0
  191. package/src/plugins/refunds.ts +108 -0
  192. package/src/plugins/stripe.ts +581 -96
  193. package/src/plugins/webhook-manager.ts +270 -0
  194. package/src/reconcile.ts +103 -0
  195. package/src/resource.ts +152 -7
  196. package/src/service.ts +174 -18
  197. package/src/subscription.ts +231 -0
  198. package/src/sync.ts +249 -29
  199. package/src/types.ts +1227 -32
  200. package/src/usage.ts +453 -0
  201. package/src/utils.ts +127 -10
  202. package/tests/checkout-consumer.spec.ts +348 -0
  203. package/tests/checkout-plugins.spec.ts +164 -0
  204. package/tests/checkout.spec.ts +184 -83
  205. package/tests/consumer-events.spec.ts +218 -0
  206. package/tests/consumer-fixtures.ts +132 -0
  207. package/tests/consumer-ops.spec.ts +351 -0
  208. package/tests/consumer-rights.integration.spec.ts +150 -0
  209. package/tests/consumer-rights.spec.ts +501 -0
  210. package/tests/context.ts +109 -0
  211. package/tests/entitlement.spec.ts +103 -0
  212. package/tests/estimate.spec.ts +240 -0
  213. package/tests/events.spec.ts +356 -0
  214. package/tests/fake-stripe.ts +972 -0
  215. package/tests/gate.spec.ts +62 -72
  216. package/tests/limit-gate.spec.ts +68 -0
  217. package/tests/portal.spec.ts +200 -0
  218. package/tests/protocol.spec.ts +23 -6
  219. package/tests/sync.spec.ts +114 -0
  220. package/tests/usage.integration.spec.ts +101 -0
  221. package/tests/usage.spec.ts +171 -0
  222. package/tests/webhook-manager.spec.ts +152 -0
@@ -0,0 +1,374 @@
1
+ import {
2
+ billingLanguageOf, chargeCurrencyOf, ConsumerRegion, ConsumerRightsError, ENTITLING_STATUSES, inScope, PurchaseKind,
3
+ regionOf, withdrawalOpen,
4
+ } from '@owlmeans/payment'
5
+ import type { BillingProfileView, ConsumerRightsPolicy, PurchaseView } from '@owlmeans/payment'
6
+ import type { Context as ApiContext } from '@owlmeans/server-api'
7
+ import { PURCHASE_ID_PREFIX, STRIPE_PAYGATE_ALIAS } from '../consts.js'
8
+ import {
9
+ billingProfiles, compact, conditionalDelete, consumerEvents, fulfillments, isDuplicateKey, paygateCustomers, purchases,
10
+ stripePricingConfig, subscriptions,
11
+ } from '../utils.js'
12
+ import { makeContractRef, normalizeEmail } from './format.js'
13
+ import type {
14
+ BillingProfileRecord, BillingProfileSource, ConsumerEventRecord, LockOptions, PaymentSubscriptionRecord,
15
+ PurchaseRecord, PurchaseRef, UnlockOptions,
16
+ } from '../types.js'
17
+
18
+ export const purchaseIdOf = (externalId: string): string => `${PURCHASE_ID_PREFIX}:${externalId}`
19
+
20
+ /** A locked profile as the wire sees it. */
21
+ export const profileViewOf = (record: BillingProfileRecord, policy: ConsumerRightsPolicy | null): BillingProfileView => ({
22
+ country: record.country,
23
+ region: record.region,
24
+ currency: record.currency,
25
+ locked: true,
26
+ lockedAt: new Date(record.lockedAt),
27
+ language: record.language,
28
+ inScope: inScope(record.region, record.country, policy),
29
+ })
30
+
31
+ /** No profile yet: nothing locked, the default legal language, an unknown buyer (protected by default). */
32
+ export const unlockedProfileView = (policy: ConsumerRightsPolicy | null): BillingProfileView => ({
33
+ country: null,
34
+ region: null,
35
+ currency: null,
36
+ locked: false,
37
+ language: policy?.defaultLanguage ?? 'en',
38
+ inScope: inScope(null, null, policy),
39
+ })
40
+
41
+ /**
42
+ * Append one audit step. A failure to write it is logged and never fails the act it describes —
43
+ * the act's own record (consent, declaration, purchase) is already written.
44
+ */
45
+ export const recordEvent = async (
46
+ ctx: ApiContext, event: Omit<ConsumerEventRecord, 'at' | 'id'> & { at?: Date },
47
+ ): Promise<void> => {
48
+ try {
49
+ await consumerEvents(ctx).create(compact({ ...event, at: event.at ?? new Date() }) as ConsumerEventRecord)
50
+ } catch (error) {
51
+ console.error(`[payment] consumer event "${event.action}" of "${event.recordId}" not recorded`, error)
52
+ }
53
+ }
54
+
55
+ /** Whether a step already succeeded (or a mail was deliberately skipped) for a record. */
56
+ export const hasEvent = async (
57
+ ctx: ApiContext, recordId: string, action: ConsumerEventRecord['action'], step?: string, ok?: boolean,
58
+ ): Promise<boolean> => await consumerEvents(ctx).load(compact({
59
+ recordId, action, ...(step != null ? { step } : {}), ...(ok != null ? { ok } : {}),
60
+ })) != null
61
+
62
+ /** The currency of the entity's entitling Stripe subscription — two of one customer cannot differ. */
63
+ export const activeSubscriptionCurrency = async (ctx: ApiContext, entityId: string): Promise<string | undefined> => {
64
+ const row = await entitlingStripeSubscription(ctx, entityId)
65
+
66
+ return row?.currency ?? undefined
67
+ }
68
+
69
+ /** The entity's highest-ranked entitling Stripe subscription, or `null`. */
70
+ export const entitlingStripeSubscription = async (
71
+ ctx: ApiContext, entityId: string,
72
+ ): Promise<PaymentSubscriptionRecord | null> => await subscriptions(ctx).load(
73
+ { entityId, paygate: STRIPE_PAYGATE_ALIAS, status: [...ENTITLING_STATUSES] },
74
+ { sort: [{ field: 'rank', order: 'desc' }, { field: 'createdAt', order: 'desc' }] },
75
+ )
76
+
77
+ /** Whether the entity ever paid through the paygate (a fulfilled checkout or a propagated subscription). */
78
+ export const hasPaid = async (ctx: ApiContext, entityId: string): Promise<boolean> =>
79
+ await fulfillments(ctx).load({ entityId, paygate: STRIPE_PAYGATE_ALIAS, fulfilledAt: { $exists: true } }) != null
80
+ || await subscriptions(ctx).load({ entityId, paygate: STRIPE_PAYGATE_ALIAS, initialPropagatedAt: { $exists: true } }) != null
81
+
82
+ export interface LockInput extends LockOptions {
83
+ entityId: string
84
+ country: string
85
+ source: BillingProfileSource
86
+ }
87
+
88
+ export interface LockResult {
89
+ record: BillingProfileRecord
90
+ created: boolean
91
+ /** The lock exists with another country: recorded as `lock-mismatch`, never relocked. */
92
+ mismatch: boolean
93
+ }
94
+
95
+ /**
96
+ * Fix an entity's billing country — the FIRST write wins (a unique index on `entityId` settles a
97
+ * race). A later, different country is only a `lock-mismatch` event; only a `manual` lock with
98
+ * `force` replaces the country, audited as `relock`. The charge currency is fixed with it: the
99
+ * currency of an entitling paygate subscription when one exists (a customer's subscriptions share
100
+ * one currency), else the region's (`policy.currencies`), else the given one.
101
+ */
102
+ export const lockProfile = async (
103
+ ctx: ApiContext, policy: ConsumerRightsPolicy | null, input: LockInput,
104
+ ): Promise<LockResult> => {
105
+ const country = input.country.trim().toUpperCase()
106
+ if (!/^[A-Z]{2}$/.test(country)) {
107
+ throw new ConsumerRightsError(`lock:country:${input.country}`)
108
+ }
109
+ const resource = billingProfiles(ctx)
110
+ const now = new Date()
111
+ const existing = await resource.byEntity(input.entityId)
112
+ if (existing != null) {
113
+ return await compareLock(ctx, policy, existing, input, country, now)
114
+ }
115
+
116
+ const region = regionOf(country, policy) ?? ConsumerRegion.Other
117
+ const settlement = (await stripePricingConfig(ctx))?.settlementCurrency?.toLowerCase()
118
+ const currency = (await activeSubscriptionCurrency(ctx, input.entityId))
119
+ ?? (policy?.currencies != null && Object.keys(policy.currencies).length > 0
120
+ ? chargeCurrencyOf(region, policy, input.currency ?? settlement ?? 'usd')
121
+ : input.currency ?? settlement ?? 'usd')
122
+ const record = compact({
123
+ entityId: input.entityId,
124
+ country,
125
+ region,
126
+ currency: currency.toLowerCase(),
127
+ language: input.language ?? billingLanguageOf(country, policy),
128
+ source: input.source,
129
+ paygate: STRIPE_PAYGATE_ALIAS,
130
+ customerId: input.customerId,
131
+ sessionId: input.sessionId,
132
+ ipCountry: input.ipCountry?.toUpperCase(),
133
+ email: input.email,
134
+ name: input.name,
135
+ business: input.business,
136
+ lockedAt: now,
137
+ createdAt: now,
138
+ }) as BillingProfileRecord
139
+ try {
140
+ const created = await resource.create(record)
141
+ await recordEvent(ctx, {
142
+ recordId: input.entityId, recordKind: 'profile', entityId: input.entityId, action: 'lock', ok: true,
143
+ detail: JSON.stringify(compact({
144
+ source: input.source, country, currency: created.currency, sessionId: input.sessionId,
145
+ ipCountry: input.ipCountry?.toUpperCase(),
146
+ ipMismatch: input.ipCountry != null ? input.ipCountry.toUpperCase() !== country : undefined,
147
+ by: input.by, reason: input.reason,
148
+ })),
149
+ })
150
+
151
+ return { record: created, created: true, mismatch: false }
152
+ } catch (error) {
153
+ if (!isDuplicateKey(error)) {
154
+ throw error
155
+ }
156
+ const winner = await resource.byEntity(input.entityId)
157
+ if (winner == null) {
158
+ throw error
159
+ }
160
+
161
+ return await compareLock(ctx, policy, winner, input, country, now)
162
+ }
163
+ }
164
+
165
+ const compareLock = async (
166
+ ctx: ApiContext, policy: ConsumerRightsPolicy | null, existing: BillingProfileRecord, input: LockInput,
167
+ country: string, now: Date,
168
+ ): Promise<LockResult> => {
169
+ if (input.source === 'manual' && input.force === true) {
170
+ const region = regionOf(country, policy) ?? ConsumerRegion.Other
171
+ const updated = await billingProfiles(ctx).update(compact({
172
+ ...existing,
173
+ country,
174
+ region,
175
+ currency: (input.currency ?? (country === existing.country ? existing.currency
176
+ : chargeCurrencyOf(region, policy, existing.currency))).toLowerCase(),
177
+ language: input.language ?? (country === existing.country ? existing.language : billingLanguageOf(country, policy)),
178
+ source: 'manual' as const,
179
+ updatedAt: now,
180
+ }))
181
+ await recordEvent(ctx, {
182
+ recordId: existing.entityId, recordKind: 'profile', entityId: existing.entityId, action: 'relock', ok: true,
183
+ detail: JSON.stringify(compact({
184
+ from: existing.country, to: country, currency: updated.currency, by: input.by, reason: input.reason,
185
+ })),
186
+ })
187
+
188
+ return { record: updated, created: false, mismatch: false }
189
+ }
190
+ const mismatch = existing.country !== country
191
+ if (mismatch) {
192
+ await recordEvent(ctx, {
193
+ recordId: existing.entityId, recordKind: 'profile', entityId: existing.entityId, action: 'lock-mismatch',
194
+ ok: true,
195
+ detail: JSON.stringify(compact({
196
+ locked: existing.country, observed: country, source: input.source, sessionId: input.sessionId,
197
+ ipCountry: input.ipCountry,
198
+ })),
199
+ })
200
+ }
201
+
202
+ return { record: existing, created: false, mismatch }
203
+ }
204
+
205
+ /**
206
+ * An operator removes an entity's lock: the profile row is deleted (only while it still holds the
207
+ * country read — a concurrent relock survives) and an `unlock` event keeps the whole row, `by` and
208
+ * `reason`. `null` when nothing was locked.
209
+ */
210
+ export const unlockProfile = async (
211
+ ctx: ApiContext, entityId: string, opts: UnlockOptions = {},
212
+ ): Promise<BillingProfileRecord | null> => {
213
+ const resource = billingProfiles(ctx)
214
+ const existing = await resource.byEntity(entityId)
215
+ if (existing == null) {
216
+ return null
217
+ }
218
+ if (!await conditionalDelete(resource, { entityId, country: existing.country })) {
219
+ throw new ConsumerRightsError(`unlock:changed:${entityId}`)
220
+ }
221
+ const { id: _id, ...row } = existing
222
+ await recordEvent(ctx, {
223
+ recordId: entityId, recordKind: 'profile', entityId, action: 'unlock', ok: true,
224
+ detail: JSON.stringify(compact({ from: existing.country, by: opts.by, reason: opts.reason, profile: row })),
225
+ })
226
+
227
+ return existing
228
+ }
229
+
230
+ /**
231
+ * Whether an operator unlocked the entity — then no lock is taken from its paygate customer's saved
232
+ * address (at checkout or in reconcile): its next completed purchase locks it.
233
+ */
234
+ export const wasUnlocked = async (ctx: ApiContext, entityId: string): Promise<boolean> =>
235
+ await hasEvent(ctx, entityId, 'unlock')
236
+
237
+ /** Inside its window at `at`: in scope, before the deadline, neither withdrawn from nor refunded. */
238
+ export const windowOpen = (purchase: PurchaseRecord, at: Date = new Date()): boolean =>
239
+ purchase.inScope && withdrawalOpen({
240
+ deadline: purchase.deadline != null ? new Date(purchase.deadline) : null,
241
+ withdrawnAt: purchase.withdrawnAt ?? null,
242
+ refundedAt: purchase.refundedAt ?? null,
243
+ }, at)
244
+
245
+ export const purchaseViewOf = (purchase: PurchaseRecord, withdrawable: boolean): PurchaseView => compact({
246
+ purchaseId: purchase.purchaseId,
247
+ contractRef: purchase.contractRef,
248
+ kind: purchase.kind,
249
+ purchasedAt: new Date(purchase.purchasedAt),
250
+ deadline: purchase.deadline != null ? new Date(purchase.deadline) : undefined,
251
+ productSku: purchase.productSku,
252
+ planSku: purchase.planSku ?? undefined,
253
+ amountTotalMinor: purchase.amountTotalMinor,
254
+ currency: purchase.currency,
255
+ consentedAt: purchase.consentedAt != null ? new Date(purchase.consentedAt) : undefined,
256
+ withdrawnAt: purchase.withdrawnAt != null ? new Date(purchase.withdrawnAt) : undefined,
257
+ withdrawable,
258
+ }) as PurchaseView
259
+
260
+ export const purchaseRefOf = (purchase: PurchaseRecord): PurchaseRef => compact({
261
+ purchaseId: purchase.purchaseId,
262
+ kind: purchase.kind,
263
+ entityId: purchase.entityId,
264
+ contractRef: purchase.contractRef,
265
+ productSku: purchase.productSku,
266
+ planSku: purchase.planSku ?? undefined,
267
+ sessionId: purchase.sessionId ?? undefined,
268
+ subscriptionId: purchase.subscriptionId ?? undefined,
269
+ invoiceId: purchase.invoiceId ?? undefined,
270
+ purchasedAt: new Date(purchase.purchasedAt),
271
+ }) as PurchaseRef
272
+
273
+ /** The open, unconsented top-up windows of an entity — what a performance consent covers. */
274
+ export const unconsentedWindows = async (ctx: ApiContext, entityId: string, at: Date = new Date()): Promise<PurchaseRecord[]> =>
275
+ (await purchases(ctx).list({
276
+ entityId, inScope: true, kind: PurchaseKind.TopUp, consentedAt: null, withdrawnAt: null, refundedAt: null,
277
+ deadline: { $gt: at },
278
+ }, { size: 100, sort: [{ field: 'deadline', order: 'desc' }] })).items
279
+
280
+ const CONTRACT_REF_ATTEMPTS = 6
281
+
282
+ export type PurchaseDraft = Omit<PurchaseRecord, 'id' | 'contractRef' | 'createdAt'>
283
+
284
+ /**
285
+ * Write a purchase once: an existing row for the same purchase (or the same checkout session) is
286
+ * returned as it is. A contract-reference collision retries with a fresh reference.
287
+ */
288
+ export const createPurchase = async (
289
+ ctx: ApiContext, draft: PurchaseDraft,
290
+ ): Promise<{ record: PurchaseRecord, created: boolean }> => {
291
+ const resource = purchases(ctx)
292
+ const existing = await resource.byPurchaseId(draft.purchaseId)
293
+ ?? (draft.sessionId != null ? await resource.load({ sessionId: draft.sessionId }) : null)
294
+ if (existing != null) {
295
+ return { record: existing, created: false }
296
+ }
297
+ let last: unknown = null
298
+ for (let attempt = 0; attempt < CONTRACT_REF_ATTEMPTS; attempt++) {
299
+ try {
300
+ const record = await resource.create(compact({
301
+ ...draft, contractRef: makeContractRef(new Date(draft.purchasedAt)), createdAt: new Date(),
302
+ }) as PurchaseRecord)
303
+
304
+ return { record, created: true }
305
+ } catch (error) {
306
+ if (!isDuplicateKey(error)) {
307
+ throw error
308
+ }
309
+ last = error
310
+ const winner = await resource.byPurchaseId(draft.purchaseId)
311
+ ?? (draft.sessionId != null ? await resource.load({ sessionId: draft.sessionId }) : null)
312
+ if (winner != null) {
313
+ return { record: winner, created: false }
314
+ }
315
+ }
316
+ }
317
+ console.error('[payment] no free contract reference after retries', last)
318
+ throw new ConsumerRightsError('contract-ref')
319
+ }
320
+
321
+ /** Set fields on a purchase without replacing it (a concurrent conditional write survives). */
322
+ export const patchPurchase = async (
323
+ ctx: ApiContext, purchaseId: string, fields: Partial<PurchaseRecord>,
324
+ ): Promise<void> => {
325
+ const set = Object.fromEntries(Object.entries({ ...fields, updatedAt: new Date() }).filter(([, value]) => value !== undefined))
326
+ const resource = purchases(ctx)
327
+ const collection = (resource as unknown as { collection?: { updateOne?: unknown } }).collection
328
+ if (collection?.updateOne != null) {
329
+ await (collection.updateOne as (filter: object, update: object) => Promise<unknown>)({ purchaseId }, { $set: set })
330
+ return
331
+ }
332
+ const current = await resource.byPurchaseId(purchaseId)
333
+ if (current != null) {
334
+ await resource.update({ ...current, ...set })
335
+ }
336
+ }
337
+
338
+ /** The e-mail a consumer-rights mail reaches the organization at when no person named one. */
339
+ export const contactEmailOf = async (ctx: ApiContext, entityId: string): Promise<string | undefined> => {
340
+ const profile = await billingProfiles(ctx).byEntity(entityId)
341
+ if (profile?.email != null && profile.email !== '') {
342
+ return profile.email
343
+ }
344
+ const latest = await purchases(ctx).load({ entityId, email: { $exists: true } }, {
345
+ sort: [{ field: 'purchasedAt', order: 'desc' }],
346
+ })
347
+ if (latest?.email != null && latest.email !== '') {
348
+ return latest.email
349
+ }
350
+ const customer = await paygateCustomers(ctx).byEntity(entityId, STRIPE_PAYGATE_ALIAS)
351
+
352
+ return customer?.email ?? undefined
353
+ }
354
+
355
+ /**
356
+ * Whether an e-mail address belongs to a purchase: its checkout e-mail, the organization's
357
+ * profile e-mail or its paygate customer's — compared case-insensitively.
358
+ */
359
+ export const emailMatches = async (ctx: ApiContext, purchase: PurchaseRecord, email: string): Promise<boolean> => {
360
+ const wanted = normalizeEmail(email)
361
+ if (wanted === '') {
362
+ return false
363
+ }
364
+ if (normalizeEmail(purchase.email) === wanted) {
365
+ return true
366
+ }
367
+ const profile = await billingProfiles(ctx).byEntity(purchase.entityId)
368
+ if (normalizeEmail(profile?.email) === wanted) {
369
+ return true
370
+ }
371
+ const customer = await paygateCustomers(ctx).byEntity(purchase.entityId, STRIPE_PAYGATE_ALIAS)
372
+
373
+ return normalizeEmail(customer?.email) === wanted
374
+ }