@owlmeans/server-payment 0.1.18-rc.20 → 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 (144) hide show
  1. package/README.md +1 -1
  2. package/agent-meta/manifest.json +2 -2
  3. package/agent-meta/skills/server-payment/SKILL.md +402 -260
  4. package/build/config.d.ts +15 -1
  5. package/build/config.d.ts.map +1 -1
  6. package/build/config.js +99 -3
  7. package/build/config.js.map +1 -1
  8. package/build/consts.d.ts +27 -0
  9. package/build/consts.d.ts.map +1 -1
  10. package/build/consts.js +27 -0
  11. package/build/consts.js.map +1 -1
  12. package/build/consumer/capture.d.ts +74 -0
  13. package/build/consumer/capture.d.ts.map +1 -0
  14. package/build/consumer/capture.js +291 -0
  15. package/build/consumer/capture.js.map +1 -0
  16. package/build/consumer/format.d.ts +27 -0
  17. package/build/consumer/format.d.ts.map +1 -0
  18. package/build/consumer/format.js +81 -0
  19. package/build/consumer/format.js.map +1 -0
  20. package/build/consumer/handlers.d.ts +28 -0
  21. package/build/consumer/handlers.d.ts.map +1 -0
  22. package/build/consumer/handlers.js +173 -0
  23. package/build/consumer/handlers.js.map +1 -0
  24. package/build/consumer/index.d.ts +7 -0
  25. package/build/consumer/index.d.ts.map +1 -0
  26. package/build/consumer/index.js +6 -0
  27. package/build/consumer/index.js.map +1 -0
  28. package/build/consumer/mail.d.ts +27 -0
  29. package/build/consumer/mail.d.ts.map +1 -0
  30. package/build/consumer/mail.js +314 -0
  31. package/build/consumer/mail.js.map +1 -0
  32. package/build/consumer/origin.d.ts +14 -0
  33. package/build/consumer/origin.d.ts.map +1 -0
  34. package/build/consumer/origin.js +47 -0
  35. package/build/consumer/origin.js.map +1 -0
  36. package/build/consumer/reconcile.d.ts +12 -0
  37. package/build/consumer/reconcile.d.ts.map +1 -0
  38. package/build/consumer/reconcile.js +317 -0
  39. package/build/consumer/reconcile.js.map +1 -0
  40. package/build/consumer/records.d.ts +78 -0
  41. package/build/consumer/records.d.ts.map +1 -0
  42. package/build/consumer/records.js +296 -0
  43. package/build/consumer/records.js.map +1 -0
  44. package/build/consumer/service.d.ts +51 -0
  45. package/build/consumer/service.d.ts.map +1 -0
  46. package/build/consumer/service.js +760 -0
  47. package/build/consumer/service.js.map +1 -0
  48. package/build/consumer/withdrawal.d.ts +57 -0
  49. package/build/consumer/withdrawal.d.ts.map +1 -0
  50. package/build/consumer/withdrawal.js +247 -0
  51. package/build/consumer/withdrawal.js.map +1 -0
  52. package/build/index.d.ts +4 -2
  53. package/build/index.d.ts.map +1 -1
  54. package/build/index.js +4 -2
  55. package/build/index.js.map +1 -1
  56. package/build/model.d.ts +6 -1
  57. package/build/model.d.ts.map +1 -1
  58. package/build/model.js +134 -4
  59. package/build/model.js.map +1 -1
  60. package/build/observer.d.ts +4 -0
  61. package/build/observer.d.ts.map +1 -1
  62. package/build/observer.js +13 -0
  63. package/build/observer.js.map +1 -1
  64. package/build/plugins/checkout-plugins.d.ts +49 -0
  65. package/build/plugins/checkout-plugins.d.ts.map +1 -0
  66. package/build/plugins/checkout-plugins.js +124 -0
  67. package/build/plugins/checkout-plugins.js.map +1 -0
  68. package/build/plugins/estimate.d.ts.map +1 -1
  69. package/build/plugins/estimate.js +41 -8
  70. package/build/plugins/estimate.js.map +1 -1
  71. package/build/plugins/events.d.ts +2 -0
  72. package/build/plugins/events.d.ts.map +1 -1
  73. package/build/plugins/events.js +126 -9
  74. package/build/plugins/events.js.map +1 -1
  75. package/build/plugins/fx.d.ts +5 -0
  76. package/build/plugins/fx.d.ts.map +1 -1
  77. package/build/plugins/fx.js +25 -0
  78. package/build/plugins/fx.js.map +1 -1
  79. package/build/plugins/portal.d.ts.map +1 -1
  80. package/build/plugins/portal.js +11 -1
  81. package/build/plugins/portal.js.map +1 -1
  82. package/build/plugins/stripe.d.ts +21 -2
  83. package/build/plugins/stripe.d.ts.map +1 -1
  84. package/build/plugins/stripe.js +397 -61
  85. package/build/plugins/stripe.js.map +1 -1
  86. package/build/resource.d.ts +8 -1
  87. package/build/resource.d.ts.map +1 -1
  88. package/build/resource.js +59 -2
  89. package/build/resource.js.map +1 -1
  90. package/build/service.d.ts +2 -1
  91. package/build/service.d.ts.map +1 -1
  92. package/build/service.js +28 -5
  93. package/build/service.js.map +1 -1
  94. package/build/subscription.d.ts +6 -0
  95. package/build/subscription.d.ts.map +1 -1
  96. package/build/subscription.js +1 -0
  97. package/build/subscription.js.map +1 -1
  98. package/build/sync.d.ts +7 -0
  99. package/build/sync.d.ts.map +1 -1
  100. package/build/sync.js +108 -15
  101. package/build/sync.js.map +1 -1
  102. package/build/types.d.ts +707 -4
  103. package/build/types.d.ts.map +1 -1
  104. package/build/utils.d.ts +22 -1
  105. package/build/utils.d.ts.map +1 -1
  106. package/build/utils.js +27 -1
  107. package/build/utils.js.map +1 -1
  108. package/package.json +14 -13
  109. package/src/config.ts +111 -7
  110. package/src/consts.ts +34 -0
  111. package/src/consumer/capture.ts +362 -0
  112. package/src/consumer/format.ts +90 -0
  113. package/src/consumer/handlers.ts +211 -0
  114. package/src/consumer/index.ts +6 -0
  115. package/src/consumer/mail.ts +368 -0
  116. package/src/consumer/origin.ts +63 -0
  117. package/src/consumer/reconcile.ts +329 -0
  118. package/src/consumer/records.ts +374 -0
  119. package/src/consumer/service.ts +868 -0
  120. package/src/consumer/withdrawal.ts +302 -0
  121. package/src/index.ts +6 -4
  122. package/src/model.ts +148 -6
  123. package/src/observer.ts +15 -2
  124. package/src/plugins/checkout-plugins.ts +155 -0
  125. package/src/plugins/estimate.ts +49 -9
  126. package/src/plugins/events.ts +135 -11
  127. package/src/plugins/fx.ts +29 -0
  128. package/src/plugins/portal.ts +11 -1
  129. package/src/plugins/stripe.ts +476 -60
  130. package/src/resource.ts +87 -4
  131. package/src/service.ts +28 -6
  132. package/src/subscription.ts +7 -0
  133. package/src/sync.ts +124 -17
  134. package/src/types.ts +756 -6
  135. package/src/utils.ts +56 -7
  136. package/tests/checkout-consumer.spec.ts +348 -0
  137. package/tests/checkout-plugins.spec.ts +164 -0
  138. package/tests/consumer-events.spec.ts +218 -0
  139. package/tests/consumer-fixtures.ts +132 -0
  140. package/tests/consumer-ops.spec.ts +351 -0
  141. package/tests/consumer-rights.integration.spec.ts +150 -0
  142. package/tests/consumer-rights.spec.ts +501 -0
  143. package/tests/context.ts +20 -2
  144. package/tests/fake-stripe.ts +188 -18
@@ -0,0 +1,362 @@
1
+ import type Stripe from 'stripe'
2
+ import {
3
+ billingLanguageOf, ConsentKind, CONSUMER_RIGHTS_COPY_VERSION, inScope, PurchaseKind, regionOf,
4
+ withdrawalDeadlineOf,
5
+ } from '@owlmeans/payment'
6
+ import type { ConsumerRightsPolicy } from '@owlmeans/payment'
7
+ import type { Context as ApiContext } from '@owlmeans/server-api'
8
+ import { STRIPE_PAYGATE_ALIAS } from '../consts.js'
9
+ import {
10
+ billingProfiles, compact, conditionalSet, consumerConsents, dateOf, idOf, isMissingObject, payment, purchases,
11
+ } from '../utils.js'
12
+ import { sendConsumerMail } from './mail.js'
13
+ import {
14
+ createPurchase, hasEvent, lockProfile, patchPurchase, purchaseIdOf,
15
+ } from './records.js'
16
+ import type { PurchaseDraft } from './records.js'
17
+ import type { BillingProfileRecord, PaymentSubscriptionRecord, PurchaseRecord } from '../types.js'
18
+
19
+ /** What a completed Checkout Session says about its buyer and totals. */
20
+ export interface SessionEvidence {
21
+ country?: string
22
+ email?: string
23
+ name?: string
24
+ business?: boolean
25
+ currency: string
26
+ subtotalMinor: number
27
+ taxMinor: number
28
+ totalMinor: number
29
+ presentmentCurrency?: string
30
+ presentmentAmountMinor?: number
31
+ termsAccepted?: boolean
32
+ }
33
+
34
+ /**
35
+ * The buyer and totals of a Checkout Session. `presentment_details` (Adaptive Pricing) is not typed
36
+ * by the pinned SDK, hence the narrow accessor.
37
+ */
38
+ export const sessionEvidenceOf = (session: Stripe.Checkout.Session): SessionEvidence => {
39
+ const details = session.customer_details
40
+ const presentment = (session as unknown as {
41
+ presentment_details?: { presentment_amount?: number, presentment_currency?: string } | null
42
+ }).presentment_details
43
+ const subtotal = session.amount_subtotal ?? 0
44
+ const tax = session.total_details?.amount_tax ?? 0
45
+
46
+ return compact({
47
+ country: details?.address?.country?.toUpperCase() ?? undefined,
48
+ email: details?.email ?? undefined,
49
+ name: details?.name ?? undefined,
50
+ business: (details?.tax_ids?.length ?? 0) > 0 || details?.tax_exempt === 'reverse' ? true : undefined,
51
+ currency: (session.currency ?? '').toLowerCase(),
52
+ subtotalMinor: subtotal,
53
+ taxMinor: tax,
54
+ totalMinor: session.amount_total ?? subtotal + tax,
55
+ presentmentCurrency: presentment?.presentment_currency?.toLowerCase(),
56
+ presentmentAmountMinor: presentment?.presentment_amount,
57
+ termsAccepted: session.consent?.terms_of_service === 'accepted' ? true
58
+ : session.consent?.terms_of_service != null ? false : undefined,
59
+ }) as SessionEvidence
60
+ }
61
+
62
+ /** What an invoice adds to a purchase: its number, its line and the payment behind it. */
63
+ export interface InvoiceEvidence {
64
+ invoiceNumber?: string
65
+ invoiceLineId?: string
66
+ paymentIntentId?: string
67
+ country?: string
68
+ subtotalMinor?: number
69
+ taxMinor?: number
70
+ totalMinor?: number
71
+ currency?: string
72
+ }
73
+
74
+ /**
75
+ * An invoice's evidence, best effort: an unreachable invoice is logged and yields nothing — the
76
+ * withdrawal reads it again when it needs it. `invoice.payment_intent` is top-level on the pinned
77
+ * API version.
78
+ */
79
+ export const invoiceEvidenceOf = async (stripe: Stripe | null | undefined, invoiceId: string | undefined): Promise<InvoiceEvidence> => {
80
+ if (stripe == null || invoiceId == null) {
81
+ return {}
82
+ }
83
+ try {
84
+ const invoice = await stripe.invoices.retrieve(invoiceId)
85
+ const typed = invoice as unknown as { payment_intent?: string | { id?: string } | null, tax?: number | null }
86
+
87
+ return compact({
88
+ invoiceNumber: invoice.number ?? undefined,
89
+ invoiceLineId: invoice.lines?.data?.[0]?.id,
90
+ paymentIntentId: idOf(typed.payment_intent),
91
+ country: invoice.customer_address?.country?.toUpperCase() ?? undefined,
92
+ subtotalMinor: invoice.subtotal ?? undefined,
93
+ taxMinor: typed.tax ?? undefined,
94
+ totalMinor: invoice.total ?? undefined,
95
+ currency: invoice.currency ?? undefined,
96
+ }) as InvoiceEvidence
97
+ } catch (error) {
98
+ if (!isMissingObject(error)) {
99
+ console.warn(`[payment] invoice "${invoiceId}" unreadable for its purchase`, error)
100
+ }
101
+ return {}
102
+ }
103
+ }
104
+
105
+ const deadlineOf = (policy: ConsumerRightsPolicy, scoped: boolean, purchasedAt: Date): Date | undefined =>
106
+ scoped ? withdrawalDeadlineOf(purchasedAt, policy) : undefined
107
+
108
+ /** Lock the entity's billing country from a completed checkout, when the policy locks countries. */
109
+ const lockFromSession = async (
110
+ ctx: ApiContext, policy: ConsumerRightsPolicy, entityId: string, session: Stripe.Checkout.Session,
111
+ evidence: SessionEvidence,
112
+ ): Promise<BillingProfileRecord | null> => {
113
+ const metadata = session.metadata ?? {}
114
+ const country = evidence.country ?? metadata.country
115
+ if (!policy.mechanisms.countryLock || country == null || country === '') {
116
+ return null
117
+ }
118
+ const { record } = await lockProfile(ctx, policy, {
119
+ entityId, country, source: 'checkout', customerId: idOf(session.customer), sessionId: session.id,
120
+ ipCountry: metadata.ipCountry, email: evidence.email, name: evidence.name, business: evidence.business,
121
+ currency: evidence.currency, language: metadata.language,
122
+ })
123
+
124
+ return record
125
+ }
126
+
127
+ /**
128
+ * Mail the purchase confirmation once per purchase: only the delivery that claims the purchase's
129
+ * `confirmationMailAt` (a conditional write — concurrent deliveries in several processes, a
130
+ * redelivery, the checkout refining a subscription) sends it; one that failed is retried by
131
+ * `reconcile`, never here. A purchase mailed before the claim existed is recognised by its event.
132
+ */
133
+ const confirmPurchase = async (ctx: ApiContext, policy: ConsumerRightsPolicy, purchase: PurchaseRecord): Promise<void> => {
134
+ if (!policy.mechanisms.purchaseConfirmation || !purchase.inScope) {
135
+ return
136
+ }
137
+ if (await hasEvent(ctx, purchase.purchaseId, 'mail', 'purchase')) {
138
+ return
139
+ }
140
+ const claimed = await conditionalSet(purchases(ctx), { purchaseId: purchase.purchaseId, confirmationMailAt: null }, {
141
+ confirmationMailAt: new Date(),
142
+ })
143
+ if (!claimed) {
144
+ return
145
+ }
146
+ await sendConsumerMail(ctx, policy, 'purchase', purchase.purchaseId)
147
+ }
148
+
149
+ export interface CapturedPurchase {
150
+ purchase: PurchaseRecord
151
+ created: boolean
152
+ }
153
+
154
+ /**
155
+ * A completed, paid ONE-TIME checkout as a purchase — the window and the contract registry. Runs
156
+ * BEFORE the credits are granted: the billing country is locked first (first write wins; another
157
+ * country is a `lock-mismatch` event), then the purchase row is written, then its confirmation is
158
+ * mailed. `null` when no consumer-rights policy is declared.
159
+ */
160
+ export const capturePaymentPurchase = async (
161
+ ctx: ApiContext, stripe: Stripe | null, session: Stripe.Checkout.Session,
162
+ extra: { netAmountMinor?: number, amountCurrency?: string, units?: number, taxBehavior?: string, at?: Date } = {},
163
+ opts: { mail?: boolean } = {},
164
+ ): Promise<CapturedPurchase | null> => {
165
+ const policy = await payment(ctx).consumerRightsPolicy()
166
+ if (policy == null) {
167
+ return null
168
+ }
169
+ const metadata = session.metadata ?? {}
170
+ const entityId = metadata.entityId
171
+ if (entityId == null || metadata.productSku == null) {
172
+ return null
173
+ }
174
+ const evidence = sessionEvidenceOf(session)
175
+ const profile = await lockFromSession(ctx, policy, entityId, session, evidence)
176
+ const existing = await purchases(ctx).byPurchaseId(purchaseIdOf(session.id))
177
+ if (existing != null) {
178
+ if (opts.mail !== false) await confirmPurchase(ctx, policy, existing)
179
+ return { purchase: existing, created: false }
180
+ }
181
+
182
+ const country = evidence.country ?? metadata.country ?? profile?.country
183
+ const region = regionOf(country, policy)
184
+ // Protected when either the buyer's own address or the organization's locked country is in scope.
185
+ const scoped = inScope(region, country, policy) || (profile != null && inScope(profile.region, profile.country, policy))
186
+ const invoice = await invoiceEvidenceOf(stripe, idOf(session.invoice))
187
+ const purchasedAt = extra.at ?? new Date()
188
+ const draft = compact({
189
+ purchaseId: purchaseIdOf(session.id),
190
+ entityId,
191
+ kind: PurchaseKind.TopUp,
192
+ paygate: STRIPE_PAYGATE_ALIAS,
193
+ sessionId: session.id,
194
+ paymentIntentId: idOf(session.payment_intent) ?? invoice.paymentIntentId,
195
+ invoiceId: idOf(session.invoice),
196
+ invoiceNumber: invoice.invoiceNumber,
197
+ invoiceLineId: invoice.invoiceLineId,
198
+ productSku: metadata.productSku,
199
+ planSku: metadata.planSku,
200
+ profileId: metadata.profileId,
201
+ country,
202
+ region: region ?? undefined,
203
+ ipCountry: metadata.ipCountry,
204
+ inScope: scoped,
205
+ language: profile?.language ?? metadata.language ?? billingLanguageOf(country, policy),
206
+ email: evidence.email,
207
+ name: evidence.name,
208
+ business: evidence.business,
209
+ currency: evidence.currency,
210
+ amountSubtotalMinor: evidence.subtotalMinor,
211
+ amountTaxMinor: evidence.taxMinor,
212
+ amountTotalMinor: evidence.totalMinor,
213
+ presentmentCurrency: evidence.presentmentCurrency,
214
+ presentmentAmountMinor: evidence.presentmentAmountMinor,
215
+ netAmountMinor: extra.netAmountMinor,
216
+ amountCurrency: extra.amountCurrency,
217
+ units: extra.units,
218
+ taxBehavior: extra.taxBehavior,
219
+ termsAccepted: evidence.termsAccepted,
220
+ textVersion: metadata.termsVersion ?? policy.textVersion,
221
+ copyVersion: metadata.copyVersion ?? CONSUMER_RIGHTS_COPY_VERSION,
222
+ purchasedAt,
223
+ deadline: deadlineOf(policy, scoped, purchasedAt),
224
+ }) as PurchaseDraft
225
+ const { record, created } = await createPurchase(ctx, draft)
226
+ if (opts.mail !== false) await confirmPurchase(ctx, policy, record)
227
+
228
+ return { purchase: record, created }
229
+ }
230
+
231
+ /** The start request a subscription's metadata names, when it is a real start request of that entity. */
232
+ const startRequestOf = async (ctx: ApiContext, entityId: string, id: string | undefined) => {
233
+ if (id == null || id === '') {
234
+ return null
235
+ }
236
+ const consent = await consumerConsents(ctx).load(id).catch(() => null)
237
+
238
+ return consent != null && consent.kind === ConsentKind.SubscriptionStart && consent.entityId === entityId ? consent : null
239
+ }
240
+
241
+ /**
242
+ * A subscription's FIRST invoice as a purchase — called while the subscription is committed, BEFORE
243
+ * the `created` observers grant anything, so the window exists before the bundle can be spent. The
244
+ * buyer's country and totals are refined when the checkout completes. The start request (when the
245
+ * metadata names one) is the purchase's consent: `servicesStartedAt` and `consentedAt`.
246
+ */
247
+ export const captureSubscriptionPurchase = async (
248
+ ctx: ApiContext, stripe: Stripe | null, subscription: Stripe.Subscription, row: PaymentSubscriptionRecord,
249
+ ): Promise<CapturedPurchase | null> => {
250
+ const policy = await payment(ctx).consumerRightsPolicy()
251
+ if (policy == null) {
252
+ return null
253
+ }
254
+ const purchaseId = purchaseIdOf(subscription.id)
255
+ const existing = await purchases(ctx).byPurchaseId(purchaseId)
256
+ if (existing != null) {
257
+ return { purchase: existing, created: false }
258
+ }
259
+ const metadata = subscription.metadata ?? {}
260
+ const item = subscription.items?.data?.[0]
261
+ const invoiceId = idOf(subscription.latest_invoice)
262
+ const invoice = await invoiceEvidenceOf(stripe, invoiceId)
263
+ const profile = await billingProfiles(ctx).byEntity(row.entityId)
264
+ const country = invoice.country ?? profile?.country ?? metadata.country
265
+ const region = regionOf(country, policy)
266
+ const scoped = inScope(region, country, policy) || (profile != null && inScope(profile.region, profile.country, policy))
267
+ const start = await startRequestOf(ctx, row.entityId, metadata.startRequestId)
268
+ const subtotal = invoice.subtotalMinor ?? (item?.price?.unit_amount ?? 0) * (item?.quantity ?? 1)
269
+ const tax = invoice.taxMinor ?? 0
270
+ const purchasedAt = dateOf(subscription.created) ?? new Date()
271
+ const draft = compact({
272
+ purchaseId,
273
+ entityId: row.entityId,
274
+ kind: PurchaseKind.Subscription,
275
+ paygate: STRIPE_PAYGATE_ALIAS,
276
+ subscriptionId: subscription.id,
277
+ paymentIntentId: invoice.paymentIntentId,
278
+ invoiceId,
279
+ invoiceNumber: invoice.invoiceNumber,
280
+ invoiceLineId: invoice.invoiceLineId,
281
+ productSku: row.productSku,
282
+ planSku: row.planSku,
283
+ profileId: metadata.profileId,
284
+ country,
285
+ region: region ?? undefined,
286
+ ipCountry: metadata.ipCountry,
287
+ inScope: scoped,
288
+ language: profile?.language ?? metadata.language ?? billingLanguageOf(country, policy),
289
+ currency: (invoice.currency ?? subscription.currency ?? item?.price?.currency ?? 'usd').toLowerCase(),
290
+ amountSubtotalMinor: subtotal,
291
+ amountTaxMinor: tax,
292
+ amountTotalMinor: invoice.totalMinor ?? subtotal + tax,
293
+ taxBehavior: item?.price?.tax_behavior ?? undefined,
294
+ textVersion: metadata.termsVersion ?? policy.textVersion,
295
+ copyVersion: metadata.copyVersion ?? CONSUMER_RIGHTS_COPY_VERSION,
296
+ startRequestId: start?.id,
297
+ servicesStartedAt: start?.decidedAt,
298
+ consentId: start?.id,
299
+ consentedAt: start?.decidedAt,
300
+ purchasedAt,
301
+ deadline: deadlineOf(policy, scoped, purchasedAt),
302
+ }) as PurchaseDraft
303
+ const { record, created } = await createPurchase(ctx, draft)
304
+
305
+ return { purchase: record, created }
306
+ }
307
+
308
+ /**
309
+ * Refine a subscription purchase with its completed checkout: the buyer's own country (and so the
310
+ * scope and deadline), e-mail, the charged totals, the terms acceptance and the session id; lock
311
+ * the billing country; mail the confirmation once.
312
+ */
313
+ export const completeSubscriptionPurchase = async (
314
+ ctx: ApiContext, stripe: Stripe | null, session: Stripe.Checkout.Session, purchase: PurchaseRecord,
315
+ opts: { mail?: boolean } = {},
316
+ ): Promise<PurchaseRecord> => {
317
+ const policy = await payment(ctx).consumerRightsPolicy()
318
+ if (policy == null) {
319
+ return purchase
320
+ }
321
+ const evidence = sessionEvidenceOf(session)
322
+ const profile = await lockFromSession(ctx, policy, purchase.entityId, session, evidence)
323
+ const country = evidence.country ?? purchase.country ?? profile?.country
324
+ const region = regionOf(country, policy)
325
+ const scoped = inScope(region, country, policy) || (profile != null && inScope(profile.region, profile.country, policy))
326
+ const invoice = purchase.invoiceLineId == null || purchase.paymentIntentId == null
327
+ ? await invoiceEvidenceOf(stripe, purchase.invoiceId ?? idOf(session.invoice)) : {}
328
+ const purchasedAt = new Date(purchase.purchasedAt)
329
+ const fields = compact({
330
+ sessionId: session.id,
331
+ invoiceId: purchase.invoiceId ?? idOf(session.invoice),
332
+ invoiceNumber: purchase.invoiceNumber ?? invoice.invoiceNumber,
333
+ invoiceLineId: purchase.invoiceLineId ?? invoice.invoiceLineId,
334
+ paymentIntentId: purchase.paymentIntentId ?? invoice.paymentIntentId,
335
+ country,
336
+ region: region ?? undefined,
337
+ inScope: scoped,
338
+ deadline: scoped ? purchase.deadline ?? withdrawalDeadlineOf(purchasedAt, policy) : undefined,
339
+ email: evidence.email ?? purchase.email,
340
+ name: evidence.name ?? purchase.name,
341
+ business: evidence.business ?? purchase.business,
342
+ currency: evidence.currency !== '' ? evidence.currency : purchase.currency,
343
+ amountSubtotalMinor: evidence.subtotalMinor,
344
+ amountTaxMinor: evidence.taxMinor,
345
+ amountTotalMinor: evidence.totalMinor,
346
+ presentmentCurrency: evidence.presentmentCurrency,
347
+ presentmentAmountMinor: evidence.presentmentAmountMinor,
348
+ termsAccepted: evidence.termsAccepted,
349
+ ipCountry: purchase.ipCountry ?? session.metadata?.ipCountry,
350
+ language: profile?.language ?? purchase.language,
351
+ }) as Partial<PurchaseRecord>
352
+ await patchPurchase(ctx, purchase.purchaseId, fields)
353
+ const updated = { ...purchase, ...fields } as PurchaseRecord
354
+ if (!scoped && purchase.deadline != null) {
355
+ // Out of scope after all (the buyer's own country): no window.
356
+ await patchPurchase(ctx, purchase.purchaseId, { deadline: null as unknown as undefined })
357
+ delete updated.deadline
358
+ }
359
+ if (opts.mail !== false) await confirmPurchase(ctx, policy, updated)
360
+
361
+ return updated
362
+ }
@@ -0,0 +1,90 @@
1
+ import { randomInt } from 'node:crypto'
2
+ import { lastWithdrawalDayOf } from '@owlmeans/payment'
3
+ import { CONTRACT_REF_ALPHABET, RESERVED_MAIL_TLDS } from '../consts.js'
4
+
5
+ /** `CR-YYMMDD-XXXXXX` — the purchase's UTC date and six characters of an unambiguous alphabet. */
6
+ export const makeContractRef = (at: Date = new Date()): string => {
7
+ const date = [at.getUTCFullYear() % 100, at.getUTCMonth() + 1, at.getUTCDate()]
8
+ .map(part => String(part).padStart(2, '0')).join('')
9
+ const suffix = Array.from({ length: 6 }, () => CONTRACT_REF_ALPHABET[randomInt(CONTRACT_REF_ALPHABET.length)]).join('')
10
+
11
+ return `CR-${date}-${suffix}`
12
+ }
13
+
14
+ /** A contract reference as a person may type it: trimmed, upper-cased, look-alike dashes unified. */
15
+ export const normalizeContractRef = (value: string): string =>
16
+ value.trim().toUpperCase().replace(/[‐-―−\s]+/g, '-')
17
+
18
+ export const normalizeEmail = (value: string | null | undefined): string => (value ?? '').trim().toLowerCase()
19
+
20
+ /**
21
+ * Whether an address is on a reserved top-level domain (`.test`, `.example`, `.invalid`,
22
+ * `.localhost` — RFC 2606 / 6761), any subdomain included: `a@shop.test`, `a@mail.shop.test`,
23
+ * `a@localhost`. Case, surrounding spaces, a display-name form (`Name <a@shop.test>`) and a
24
+ * trailing root dot are all read through.
25
+ */
26
+ export const isReservedAddress = (address: string): boolean => {
27
+ const bare = normalizeEmail(address).replace(/^.*<([^>]*)>.*$/, '$1')
28
+ const domain = (bare.split('@').pop() ?? '').trim().replace(/\.+$/, '')
29
+ const tld = domain.split('.').pop() ?? ''
30
+
31
+ return RESERVED_MAIL_TLDS.includes(tld)
32
+ }
33
+
34
+ export const escapeHtml = (value: string): string => value
35
+ .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
36
+ .replace(/"/g, '&quot;').replace(/'/g, '&#39;')
37
+
38
+ const fractionDigitsOf = (currency: string): number => {
39
+ try {
40
+ return new Intl.NumberFormat('en', { style: 'currency', currency: currency.toUpperCase() })
41
+ .resolvedOptions().maximumFractionDigits ?? 2
42
+ } catch {
43
+ return 2
44
+ }
45
+ }
46
+
47
+ /** Minor units as a localized amount with its currency (`12,56 €`, `$12.56`). */
48
+ export const formatMoney = (amountMinor: number, currency: string, lng: string): string => {
49
+ const digits = fractionDigitsOf(currency)
50
+ const major = amountMinor / 10 ** digits
51
+ try {
52
+ return new Intl.NumberFormat(lng, { style: 'currency', currency: currency.toUpperCase() }).format(major)
53
+ } catch {
54
+ return `${major.toFixed(digits)} ${currency.toUpperCase()}`
55
+ }
56
+ }
57
+
58
+ /** A date and time in UTC, localized (`23 September 2026, 14:05`). */
59
+ export const formatDateTime = (at: Date, lng: string): string => {
60
+ try {
61
+ return new Intl.DateTimeFormat(lng, { dateStyle: 'long', timeStyle: 'short', timeZone: 'UTC' }).format(at)
62
+ } catch {
63
+ return at.toISOString().replace('T', ' ').slice(0, 16)
64
+ }
65
+ }
66
+
67
+ /** A calendar date in UTC, localized. */
68
+ export const formatDate = (at: Date, lng: string): string => {
69
+ try {
70
+ return new Intl.DateTimeFormat(lng, { dateStyle: 'long', timeZone: 'UTC' }).format(at)
71
+ } catch {
72
+ return at.toISOString().slice(0, 10)
73
+ }
74
+ }
75
+
76
+ /**
77
+ * The last included day of a window whose deadline is EXCLUSIVE (the first instant it is over),
78
+ * as a UTC date — the copy says "until the end of <date>": 13 October 00:00 UTC is "12 October".
79
+ */
80
+ export const formatDeadline = (deadline: Date, lng: string): string =>
81
+ formatDate(lastWithdrawalDayOf(deadline), lng)
82
+
83
+ /** A country's name in a language, else its code. */
84
+ export const countryName = (country: string, lng: string): string => {
85
+ try {
86
+ return new Intl.DisplayNames([lng], { type: 'region' }).of(country.toUpperCase()) ?? country
87
+ } catch {
88
+ return country
89
+ }
90
+ }
@@ -0,0 +1,211 @@
1
+ import { randomBytes } from 'node:crypto'
2
+ import { AuthForbidden } from '@owlmeans/auth'
3
+ import type { AbstractRequest, EntrypointProtocolDeclaration } from '@owlmeans/entrypoint'
4
+ import { ConsumerRightsError, DeclarationChannel } from '@owlmeans/payment'
5
+ import type {
6
+ CheckoutReadProtocols, ConsumerRightsAccountProtocols, ConsumerRightsPublicProtocols, ConsumerRightsPublicView,
7
+ DeclarationReceipt,
8
+ } from '@owlmeans/payment'
9
+ import { handlers } from '@owlmeans/server-api'
10
+ import type { Context as ApiContext } from '@owlmeans/server-api'
11
+ import { bind } from '@owlmeans/server-entrypoint'
12
+ import type { ServerProtocolEntrypoint } from '@owlmeans/server-entrypoint'
13
+ import { CONSUMER_RIGHTS_SERVICE, GATEWAY_SERVICE } from '../consts.js'
14
+ import { payment } from '../utils.js'
15
+ import { requestOriginOf } from './origin.js'
16
+ import { unlockedProfileView } from './records.js'
17
+ import type {
18
+ CheckoutReadHandlerOptions, ConsumerRightsHandlerOptions, ConsumerRightsService, ConsumerSubject, Context,
19
+ GatewayService, RequestOrigin,
20
+ } from '../types.js'
21
+
22
+ type Bound = ServerProtocolEntrypoint<EntrypointProtocolDeclaration>
23
+
24
+ /** The protocol tree `makeConsumerRightsProtocols` builds, with or without its public subtree. */
25
+ export type ConsumerRightsTree = ConsumerRightsAccountProtocols & { public?: ConsumerRightsPublicProtocols }
26
+
27
+ const defaultEntity = (req: AbstractRequest, _ctx?: ApiContext): string | null => req.entity?.id ?? null
28
+
29
+ const DEFAULT_PUBLIC_MIN_MS = 1000
30
+
31
+ const sleep = async (ms: number): Promise<void> => await new Promise(resolve => setTimeout(resolve, ms))
32
+
33
+ /** Answer no sooner than `minMs` after the start — a matched declaration takes as long as an unmatched one. */
34
+ const padded = async <T>(minMs: number, run: () => Promise<T>): Promise<T> => {
35
+ const started = Date.now()
36
+ try {
37
+ return await run()
38
+ } finally {
39
+ const left = minMs - (Date.now() - started)
40
+ if (left > 0) await sleep(left)
41
+ }
42
+ }
43
+
44
+ /** The public answer: what was declared and when — never a status, an amount or a match. */
45
+ const publicPart = (receipt: DeclarationReceipt): DeclarationReceipt => ({
46
+ declarationId: receipt.declarationId, receivedAt: receipt.receivedAt, content: receipt.content, mailed: receipt.mailed,
47
+ })
48
+
49
+ /** A form a bot filled (the honeypot) gets the same shape of answer, and nothing is recorded or sent. */
50
+ const decoyReceipt = (content: Record<string, string>): DeclarationReceipt => ({
51
+ declarationId: randomBytes(12).toString('hex'), receivedAt: new Date(), content, mailed: false,
52
+ })
53
+
54
+ const typed = (fields: Record<string, string | undefined>): Record<string, string> =>
55
+ Object.fromEntries(Object.entries(fields).filter((entry): entry is [string, string] =>
56
+ typeof entry[1] === 'string' && entry[1].trim() !== ''))
57
+
58
+ /**
59
+ * Server bindings of `makeConsumerRightsProtocols`' tree over the consumer-rights service. The
60
+ * account routes act for the request's organization (`resolveEntity`, default `req.entity.id`);
61
+ * the money-moving acts (consent, start request, withdrawal, cancellation) pass `guardMoney` first
62
+ * (refuse an API key there). The public routes — the statutory functions without a login — need
63
+ * `throttle` (a wiring error otherwise), drop a filled honeypot silently, and answer every
64
+ * declaration with the same receipt shape after at least `publicMinMs`, matched or not. Every hook
65
+ * gets the request's context as its last argument.
66
+ *
67
+ * @throws SyntaxError when the tree has a public subtree and no `throttle` is given
68
+ */
69
+ export const consumerRightsEntrypoints = (
70
+ protocols: ConsumerRightsTree, opts: ConsumerRightsHandlerOptions = {},
71
+ ): Bound[] => {
72
+ if (protocols.public != null && opts.throttle == null) {
73
+ throw new SyntaxError('consumer-rights: the public routes need a throttle')
74
+ }
75
+ const api = handlers<Context>()
76
+ const serviceOf = (ctx: Context): ConsumerRightsService =>
77
+ ctx.service<ConsumerRightsService>(opts.serviceAlias ?? CONSUMER_RIGHTS_SERVICE)
78
+ const apiCtx = (ctx: Context): ApiContext => ctx as unknown as ApiContext
79
+ const entityOf = (req: AbstractRequest, ctx: Context): string => {
80
+ const entityId = (opts.resolveEntity ?? defaultEntity)(req, apiCtx(ctx))
81
+ if (entityId == null || entityId === '') {
82
+ throw new AuthForbidden('entity')
83
+ }
84
+
85
+ return entityId
86
+ }
87
+ const subjectFor = async (req: AbstractRequest, ctx: Context): Promise<ConsumerSubject> => ({
88
+ ...(await opts.subjectOf?.(req, apiCtx(ctx)) ?? {}),
89
+ entityId: entityOf(req, ctx),
90
+ channel: DeclarationChannel.InApp,
91
+ })
92
+ const metaOf = (req: AbstractRequest, ctx: Context): RequestOrigin =>
93
+ opts.metaOf != null ? opts.metaOf(req, apiCtx(ctx)) : requestOriginOf(req)
94
+ const guard = async (req: AbstractRequest, action: string, ctx: Context): Promise<void> => {
95
+ await opts.guardMoney?.(req, action, apiCtx(ctx))
96
+ }
97
+ const minMs = opts.publicMinMs ?? DEFAULT_PUBLIC_MIN_MS
98
+
99
+ const bound: Bound[] = [
100
+ bind(protocols.base),
101
+ bind(protocols.profile, api.request(protocols.profile, async (req, ctx) =>
102
+ await serviceOf(ctx).profile(entityOf(req, ctx))
103
+ ?? unlockedProfileView(await payment(ctx as unknown as ApiContext).consumerRightsPolicy()))),
104
+ bind(protocols.purchases, api.request(protocols.purchases, async (req, ctx) =>
105
+ ({ purchases: await serviceOf(ctx).purchases(entityOf(req, ctx)) }))),
106
+ bind(protocols.consent, api.request(protocols.consent, async (req, ctx) =>
107
+ await serviceOf(ctx).consentView(entityOf(req, ctx)))),
108
+ bind(protocols.giveConsent, api.body(protocols.giveConsent, async (body, ctx, req) => {
109
+ await guard(req, 'consent', ctx)
110
+ return await serviceOf(ctx).recordConsent(await subjectFor(req, ctx), body, metaOf(req, ctx))
111
+ })),
112
+ bind(protocols.start, api.request(protocols.start, async (req, ctx) =>
113
+ await serviceOf(ctx).startView(entityOf(req, ctx), String(req.query.planSku)))),
114
+ bind(protocols.requestStart, api.body(protocols.requestStart, async (body, ctx, req) => {
115
+ await guard(req, 'start', ctx)
116
+ const plan = await opts.planNameOf?.(body.planSku, body.language, req, apiCtx(ctx))
117
+ return await serviceOf(ctx).recordStartRequest(await subjectFor(req, ctx), body, metaOf(req, ctx), plan != null ? { plan } : {})
118
+ })),
119
+ bind(protocols.withdrawals, api.request(protocols.withdrawals, async (req, ctx) => {
120
+ const subject = await subjectFor(req, ctx)
121
+ return await serviceOf(ctx).withdrawalCandidates(subject.entityId, subject)
122
+ })),
123
+ bind(protocols.withdraw, api.body(protocols.withdraw, async (body, ctx, req) => {
124
+ await guard(req, 'withdraw', ctx)
125
+ return await serviceOf(ctx).withdraw(await subjectFor(req, ctx), body, metaOf(req, ctx))
126
+ })),
127
+ bind(protocols.cancel, api.body(protocols.cancel, async (body, ctx, req) => {
128
+ await guard(req, 'cancel', ctx)
129
+ return await serviceOf(ctx).cancel(await subjectFor(req, ctx), body, metaOf(req, ctx))
130
+ })),
131
+ ] as Bound[]
132
+
133
+ const pub = protocols.public
134
+ if (pub == null) {
135
+ return bound
136
+ }
137
+ const throttle = opts.throttle as NonNullable<ConsumerRightsHandlerOptions['throttle']>
138
+
139
+ return [
140
+ ...bound,
141
+ bind(pub.base),
142
+ bind(pub.policy, api.request(pub.policy, async (_req, ctx) => {
143
+ const policy = await payment(ctx as unknown as ApiContext).consumerRightsPolicy()
144
+ if (policy == null) {
145
+ throw new ConsumerRightsError('policy:none')
146
+ }
147
+ const view: ConsumerRightsPublicView = {
148
+ mechanisms: { withdrawal: policy.mechanisms.withdrawal, cancellation: policy.mechanisms.cancellation },
149
+ languages: Object.keys(policy.links),
150
+ links: policy.links,
151
+ textVersion: policy.textVersion,
152
+ }
153
+ return view
154
+ })),
155
+ bind(pub.withdraw, api.body(pub.withdraw, async (body, ctx, req) => {
156
+ const origin = metaOf(req, ctx)
157
+ await throttle(req, { action: 'withdrawal', email: body.email, ...(origin.ip != null ? { ip: origin.ip } : {}) }, apiCtx(ctx))
158
+ return await padded(minMs, async () => {
159
+ const content = typed({ name: body.name, contract: body.contractRef, email: body.email })
160
+ if (body.honeypot != null && body.honeypot !== '') {
161
+ return decoyReceipt(content)
162
+ }
163
+ return publicPart(await serviceOf(ctx).withdraw(null, body, origin))
164
+ })
165
+ })),
166
+ bind(pub.cancel, api.body(pub.cancel, async (body, ctx, req) => {
167
+ const origin = metaOf(req, ctx)
168
+ await throttle(req, { action: 'cancellation', email: body.email, ...(origin.ip != null ? { ip: origin.ip } : {}) }, apiCtx(ctx))
169
+ return await padded(minMs, async () => {
170
+ const content = typed({
171
+ name: body.name, contract: body.contractRef, email: body.email, kind: body.kind, reason: body.reason,
172
+ date: body.effective === 'date' ? body.date : undefined,
173
+ })
174
+ if (body.honeypot != null && body.honeypot !== '') {
175
+ return decoyReceipt(content)
176
+ }
177
+ return publicPart(await serviceOf(ctx).cancel(null, body, origin))
178
+ })
179
+ })),
180
+ ] as Bound[]
181
+ }
182
+
183
+ /**
184
+ * Server bindings of `makeCheckoutReadProtocols`' tree: the entity's amount policy as the checkout
185
+ * plugins narrow it now (the same computation the checkout enforces) and the synced plan prices.
186
+ */
187
+ export const checkoutReadEntrypoints = (protocols: CheckoutReadProtocols, opts: CheckoutReadHandlerOptions = {}): Bound[] => {
188
+ const api = handlers<Context>()
189
+ const gatewayOf = (ctx: Context): GatewayService => ctx.service<GatewayService>(opts.gatewayAlias ?? GATEWAY_SERVICE)
190
+ const entityOf = (req: AbstractRequest, ctx: Context): string => {
191
+ const entityId = (opts.resolveEntity ?? defaultEntity)(req, ctx as unknown as ApiContext)
192
+ if (entityId == null || entityId === '') {
193
+ throw new AuthForbidden('entity')
194
+ }
195
+
196
+ return entityId
197
+ }
198
+
199
+ return [
200
+ bind(protocols.base),
201
+ bind(protocols.amountPolicy, api.request(protocols.amountPolicy, async (req, ctx) => {
202
+ const { productSku, planSku } = req.query as { productSku: string, planSku?: string }
203
+ return await gatewayOf(ctx).amountPolicy(ctx as unknown as ApiContext, entityOf(req, ctx), productSku, planSku ?? undefined)
204
+ })),
205
+ bind(protocols.planPrices, api.request(protocols.planPrices, async (req, ctx) => {
206
+ entityOf(req, ctx)
207
+ const { productSku } = req.query as { productSku: string }
208
+ return { prices: await gatewayOf(ctx).planPrices(ctx as unknown as ApiContext, productSku) }
209
+ })),
210
+ ] as Bound[]
211
+ }
@@ -0,0 +1,6 @@
1
+ export { requestOriginOf } from './origin.js'
2
+ export { appendConsumerRights, makeConsumerRightsService } from './service.js'
3
+ export { consumerRightsEntrypoints, checkoutReadEntrypoints } from './handlers.js'
4
+ export type { ConsumerRightsTree } from './handlers.js'
5
+ export { traderIdentityOf } from './mail.js'
6
+ export { makeContractRef, isReservedAddress } from './format.js'