@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,302 @@
1
+ import type Stripe from 'stripe'
2
+ import {
3
+ consumerText, oneTimeWithdrawalRefund, PurchaseKind, subscriptionWithdrawalRefund,
4
+ } from '@owlmeans/payment'
5
+ import type { WithdrawalEstimate } from '@owlmeans/payment'
6
+ import type { Context as ApiContext } from '@owlmeans/server-api'
7
+ import { STRIPE_OWNER_KEY, STRIPE_OWNER_VALUE, STRIPE_PAYGATE_ALIAS } from '../consts.js'
8
+ import { findPlan } from '../plan.js'
9
+ import { compact, errorText, isMissingObject, subscriptions } from '../utils.js'
10
+ import { invoiceEvidenceOf } from './capture.js'
11
+ import { formatDate } from './format.js'
12
+ import { hasEvent, patchPurchase, purchaseRefOf, recordEvent } from './records.js'
13
+ import type {
14
+ ConsumerDeclarationRecord, PaymentSubscriptionRecord, PurchaseRecord, UsageMeter, UsageReading,
15
+ } from '../types.js'
16
+
17
+ const DAY_MS = 86_400_000
18
+
19
+ /** What a withdrawal of one purchase reimburses, as computed at one instant. */
20
+ export interface WithdrawalComputation {
21
+ reading: UsageReading
22
+ /** Gross, tax included — what is refunded. */
23
+ refundMinor: number
24
+ /** Net of tax — the credit note's line amount. */
25
+ netMinor: number
26
+ estimate: WithdrawalEstimate
27
+ /** The units the deduction counts: used after consent, plus settled debt and earlier claw-backs. */
28
+ deducted: number
29
+ /** What the application takes back from the balance: the purchase's units still unused. */
30
+ unitsReturned: number
31
+ }
32
+
33
+ /**
34
+ * The refund of a withdrawal from one purchase, in the consumer's favour (`@owlmeans/payment`
35
+ * calculators). The meter's deduction is `usedAfter + settled + clawed`: units used after consent
36
+ * (before it the consumer bears no cost), units that paid an earlier overdraft, units already
37
+ * refunded by money. A subscription's `time` components count from its start request; without
38
+ * one nothing is deducted for time.
39
+ */
40
+ export const computeWithdrawal = async (
41
+ ctx: ApiContext, meter: UsageMeter, purchase: PurchaseRecord, at: Date,
42
+ ): Promise<WithdrawalComputation> => {
43
+ const reading = await meter.used(ctx, compact({
44
+ entityId: purchase.entityId, purchase: purchaseRefOf(purchase),
45
+ after: purchase.consentedAt != null ? new Date(purchase.consentedAt) : undefined, at,
46
+ }))
47
+ const deducted = Math.max(0, reading.usedAfter) + Math.max(0, reading.settled ?? 0) + Math.max(0, reading.clawed ?? 0)
48
+ const unitsReturned = Math.max(0, Math.floor(reading.remaining
49
+ ?? reading.granted - reading.used - (reading.settled ?? 0) - (reading.clawed ?? 0)))
50
+ const paid = purchase.amountTotalMinor
51
+ const refunded = purchase.refundedMinor ?? 0
52
+ const net = purchase.amountSubtotalMinor
53
+ // The earlier refunds' net share, so the credit note never credits more than is left.
54
+ const refundedNet = paid > 0 ? Math.floor(refunded * net / paid) : 0
55
+
56
+ if (purchase.kind === PurchaseKind.TopUp) {
57
+ const gross = oneTimeWithdrawalRefund({ paidMinor: paid, refundedMinor: refunded, unitsGranted: reading.granted, unitsUsed: deducted })
58
+ const netRefund = oneTimeWithdrawalRefund({ paidMinor: net, refundedMinor: refundedNet, unitsGranted: reading.granted, unitsUsed: deducted })
59
+ const open = Math.max(0, paid - refunded)
60
+
61
+ return {
62
+ reading, deducted, unitsReturned,
63
+ refundMinor: gross.refundMinor,
64
+ netMinor: netRefund.refundMinor,
65
+ estimate: {
66
+ refundMinor: gross.refundMinor, currency: purchase.currency, timeDeductionMinor: 0,
67
+ unitsDeductionMinor: Math.max(0, open - gross.refundMinor),
68
+ unitsUsed: Math.min(deducted, reading.granted), unitsGranted: reading.granted,
69
+ },
70
+ }
71
+ }
72
+
73
+ const subscription = purchase.subscriptionId != null
74
+ ? await subscriptions(ctx).byExternalId(purchase.subscriptionId, STRIPE_PAYGATE_ALIAS) : null
75
+ const plan = purchase.planSku != null ? await findPlan(ctx, purchase.planSku) : null
76
+ const purchasedAt = new Date(purchase.purchasedAt)
77
+ const periodStart = subscription?.periodStart != null ? new Date(subscription.periodStart) : purchasedAt
78
+ const periodEnd = subscription?.periodEnd != null ? new Date(subscription.periodEnd)
79
+ : new Date(periodStart.getTime() + (plan?.recurring?.interval === 'year' ? 365 : 30) * DAY_MS)
80
+ const result = subscriptionWithdrawalRefund({
81
+ paidMinor: paid, refundedMinor: refunded, netMinor: net,
82
+ components: plan?.withdrawal?.components,
83
+ periodStart, periodEnd,
84
+ servicesRequestedAt: purchase.servicesStartedAt != null ? new Date(purchase.servicesStartedAt) : null,
85
+ withdrawnAt: at,
86
+ units: { granted: reading.granted, used: deducted },
87
+ })
88
+
89
+ return {
90
+ reading, deducted, unitsReturned,
91
+ refundMinor: result.refundMinor,
92
+ netMinor: result.refundNetMinor,
93
+ estimate: compact({
94
+ refundMinor: result.refundMinor, currency: purchase.currency, timeDeductionMinor: result.timeDeductionMinor,
95
+ unitsDeductionMinor: result.unitsDeductionMinor, elapsedDays: result.elapsedDays, periodDays: result.periodDays,
96
+ unitsUsed: result.unitsUsed, unitsGranted: result.unitsGranted,
97
+ }) as WithdrawalEstimate,
98
+ }
99
+ }
100
+
101
+ export interface WithdrawalExecution {
102
+ /** Every paygate step succeeded (or had nothing to do). */
103
+ ok: boolean
104
+ refundId?: string
105
+ creditNoteId?: string
106
+ refundedMinor: number
107
+ subscriptionCanceled: boolean
108
+ /** A step can never succeed without an operator (no payment to refund). */
109
+ needsReview: boolean
110
+ }
111
+
112
+ /** A Stripe error that says the subscription is already gone. */
113
+ const alreadyCanceled = (error: unknown): boolean => isMissingObject(error)
114
+ || /canceled subscription|already (been )?cancel/i.test((error as { message?: string } | null)?.message ?? '')
115
+
116
+ /** A refund this withdrawal already made (found by its metadata) — what a retry adopts. */
117
+ const existingRefund = async (stripe: Stripe, paymentIntentId: string, withdrawalId: string): Promise<Stripe.Refund | null> => {
118
+ const { data } = await stripe.refunds.list({ payment_intent: paymentIntentId, limit: 100 })
119
+
120
+ return data.find(refund => refund.metadata?.withdrawalId === withdrawalId && refund.status !== 'failed'
121
+ && refund.status !== 'canceled') ?? null
122
+ }
123
+
124
+ /** A credit note this withdrawal already made — what a retry adopts. */
125
+ const existingCreditNote = async (stripe: Stripe, invoiceId: string, withdrawalId: string): Promise<Stripe.CreditNote | null> => {
126
+ const { data } = await stripe.creditNotes.list({ invoice: invoiceId, limit: 100 })
127
+
128
+ return data.find(note => note.metadata?.withdrawalId === withdrawalId && note.status !== 'void') ?? null
129
+ }
130
+
131
+ /**
132
+ * Stripe replays the stored answer of an idempotency key — a failure too — for a day: a retry
133
+ * uses a fresh key (`…:<attempt>`) and first adopts what an earlier attempt may have made.
134
+ */
135
+ const keyOf = (withdrawalId: string, step: string, attempt: number): Stripe.RequestOptions => ({
136
+ idempotencyKey: attempt > 0 ? `withdrawal:${withdrawalId}:${step}:${attempt}` : `withdrawal:${withdrawalId}:${step}`,
137
+ })
138
+
139
+ /**
140
+ * Execute a withdrawal at the paygate, the first attempt of every call under the idempotency key
141
+ * `withdrawal:<id>:<step>` (a retry by `reconcile` adopts what an earlier attempt made):
142
+ *
143
+ * 1. a subscription purchase: `subscriptions.cancel` at once, without proration or a final invoice;
144
+ * 2. an invoice-backed purchase: `creditNotes.preview` of the invoice line at the net refund, then
145
+ * OUR `refunds.create` for the previewed total (metadata `withdrawalId` — the refund webhook then
146
+ * tells observers not to claw back), then `creditNotes.create` linking that refund — the
147
+ * corrective tax document; a failed credit note keeps the plain refund and is recorded;
148
+ * 3. no invoice: a plain proportional refund on the payment intent.
149
+ *
150
+ * Steps that already succeeded (a recorded `ok` event) are skipped. Every step is an event.
151
+ */
152
+ export const executeWithdrawal = async (
153
+ ctx: ApiContext, stripe: Stripe, declaration: ConsumerDeclarationRecord, purchase: PurchaseRecord,
154
+ computation: { refundMinor: number, netMinor: number }, opts: { attempt?: number } = {},
155
+ ): Promise<WithdrawalExecution> => {
156
+ const withdrawalId = declaration.id as string
157
+ const attempt = opts.attempt ?? 0
158
+ const key = (step: string): Stripe.RequestOptions => keyOf(withdrawalId, step, attempt)
159
+ const event = (action: 'refund' | 'credit-note' | 'subscription-cancel', fields: Record<string, unknown>) =>
160
+ recordEvent(ctx, {
161
+ recordId: withdrawalId, recordKind: 'declaration', entityId: purchase.entityId, action, ok: false, ...fields,
162
+ } as Parameters<typeof recordEvent>[1])
163
+ const outcome: WithdrawalExecution = { ok: true, refundedMinor: 0, subscriptionCanceled: false, needsReview: false }
164
+
165
+ if (purchase.kind === PurchaseKind.Subscription && purchase.subscriptionId != null) {
166
+ if (await hasEvent(ctx, withdrawalId, 'subscription-cancel', undefined, true)) {
167
+ outcome.subscriptionCanceled = true
168
+ } else {
169
+ try {
170
+ await stripe.subscriptions.cancel(purchase.subscriptionId, {
171
+ prorate: false, invoice_now: false, cancellation_details: { comment: `withdrawal:${withdrawalId}` },
172
+ }, key('subscription-cancel'))
173
+ outcome.subscriptionCanceled = true
174
+ await event('subscription-cancel', { ok: true, externalId: purchase.subscriptionId })
175
+ } catch (error) {
176
+ if (alreadyCanceled(error)) {
177
+ outcome.subscriptionCanceled = true
178
+ await event('subscription-cancel', { ok: true, externalId: purchase.subscriptionId, detail: '{"already":true}' })
179
+ } else {
180
+ outcome.ok = false
181
+ await event('subscription-cancel', { externalId: purchase.subscriptionId, error: errorText(error) })
182
+ }
183
+ }
184
+ if (outcome.subscriptionCanceled) {
185
+ const row: PaymentSubscriptionRecord | null = await subscriptions(ctx).byExternalId(purchase.subscriptionId, STRIPE_PAYGATE_ALIAS)
186
+ if (row != null && row.withdrawnAt == null) {
187
+ await subscriptions(ctx).update({ ...row, withdrawnAt: new Date(declaration.receivedAt) })
188
+ }
189
+ }
190
+ }
191
+ }
192
+
193
+ if (await hasEvent(ctx, withdrawalId, 'refund', undefined, true)) {
194
+ return { ...outcome, refundedMinor: declaration.refundMinor ?? computation.refundMinor }
195
+ }
196
+ if (computation.refundMinor <= 0) {
197
+ return outcome
198
+ }
199
+
200
+ let paymentIntentId = purchase.paymentIntentId
201
+ let invoiceLineId = purchase.invoiceLineId
202
+ if (purchase.invoiceId != null && (paymentIntentId == null || invoiceLineId == null)) {
203
+ const invoice = await invoiceEvidenceOf(stripe, purchase.invoiceId)
204
+ paymentIntentId = paymentIntentId ?? invoice.paymentIntentId
205
+ invoiceLineId = invoiceLineId ?? invoice.invoiceLineId
206
+ await patchPurchase(ctx, purchase.purchaseId, compact({
207
+ paymentIntentId, invoiceLineId, invoiceNumber: purchase.invoiceNumber ?? invoice.invoiceNumber,
208
+ }))
209
+ }
210
+ if (paymentIntentId == null) {
211
+ await event('refund', { error: 'no-payment-intent', amountMinor: computation.refundMinor, currency: purchase.currency })
212
+ return { ...outcome, ok: false, needsReview: true }
213
+ }
214
+
215
+ const open = Math.max(0, purchase.amountTotalMinor - (purchase.refundedMinor ?? 0))
216
+ let amount = Math.min(open, computation.refundMinor)
217
+ let lines: Stripe.CreditNoteCreateParams.Line[] | null = null
218
+ if (purchase.invoiceId != null && invoiceLineId != null && computation.netMinor > 0) {
219
+ lines = [{ type: 'invoice_line_item', invoice_line_item: invoiceLineId, amount: computation.netMinor }]
220
+ try {
221
+ const preview = await stripe.creditNotes.preview({ invoice: purchase.invoiceId, lines })
222
+ amount = Math.min(open, preview.total)
223
+ } catch (error) {
224
+ lines = null
225
+ await event('credit-note', { step: 'preview', error: errorText(error) })
226
+ }
227
+ }
228
+ if (amount <= 0) {
229
+ return outcome
230
+ }
231
+
232
+ let refund: Stripe.Refund
233
+ try {
234
+ refund = (attempt > 0 ? await existingRefund(stripe, paymentIntentId, withdrawalId) : null)
235
+ ?? await stripe.refunds.create({
236
+ payment_intent: paymentIntentId, amount, reason: 'requested_by_customer',
237
+ metadata: { [STRIPE_OWNER_KEY]: STRIPE_OWNER_VALUE, withdrawalId, purchaseId: purchase.purchaseId },
238
+ }, key('refund'))
239
+ } catch (error) {
240
+ await event('refund', { error: errorText(error), amountMinor: amount, currency: purchase.currency })
241
+ return { ...outcome, ok: false }
242
+ }
243
+ await event('refund', { ok: true, externalId: refund.id, amountMinor: refund.amount, currency: refund.currency })
244
+ outcome.refundId = refund.id
245
+ outcome.refundedMinor = refund.amount
246
+
247
+ if (lines != null && purchase.invoiceId != null) {
248
+ try {
249
+ const note = await stripe.creditNotes.create({
250
+ invoice: purchase.invoiceId, lines, refund: refund.id,
251
+ memo: consumerText(declaration.language, 'credit-note.memo', {
252
+ contractRef: purchase.contractRef, date: formatDate(new Date(declaration.receivedAt), declaration.language),
253
+ }),
254
+ metadata: { withdrawalId, purchaseId: purchase.purchaseId },
255
+ }, key('credit-note'))
256
+ outcome.creditNoteId = note.id
257
+ await event('credit-note', { ok: true, externalId: note.id, amountMinor: note.total, currency: note.currency })
258
+ } catch (error) {
259
+ // The refund stands; the corrective document is retried by reconcile.
260
+ await event('credit-note', { error: errorText(error), detail: JSON.stringify({ refundId: refund.id }) })
261
+ }
262
+ }
263
+
264
+ return outcome
265
+ }
266
+
267
+ /** Retry a credit note that failed after its refund succeeded. */
268
+ export const retryCreditNote = async (
269
+ ctx: ApiContext, stripe: Stripe, declaration: ConsumerDeclarationRecord, purchase: PurchaseRecord,
270
+ netMinor: number, refundId: string, attempt: number,
271
+ ): Promise<boolean> => {
272
+ const withdrawalId = declaration.id as string
273
+ let invoiceLineId = purchase.invoiceLineId
274
+ if (invoiceLineId == null && purchase.invoiceId != null) {
275
+ invoiceLineId = (await invoiceEvidenceOf(stripe, purchase.invoiceId)).invoiceLineId
276
+ }
277
+ if (purchase.invoiceId == null || invoiceLineId == null || netMinor <= 0) {
278
+ return false
279
+ }
280
+ try {
281
+ const note = await existingCreditNote(stripe, purchase.invoiceId, withdrawalId) ?? await stripe.creditNotes.create({
282
+ invoice: purchase.invoiceId,
283
+ lines: [{ type: 'invoice_line_item', invoice_line_item: invoiceLineId, amount: netMinor }],
284
+ refund: refundId,
285
+ memo: consumerText(declaration.language, 'credit-note.memo', {
286
+ contractRef: purchase.contractRef, date: formatDate(new Date(declaration.receivedAt), declaration.language),
287
+ }),
288
+ metadata: { withdrawalId, purchaseId: purchase.purchaseId },
289
+ }, keyOf(withdrawalId, 'credit-note', attempt))
290
+ await recordEvent(ctx, {
291
+ recordId: withdrawalId, recordKind: 'declaration', entityId: purchase.entityId, action: 'credit-note', ok: true,
292
+ externalId: note.id, amountMinor: note.total, currency: note.currency,
293
+ })
294
+ return true
295
+ } catch (error) {
296
+ await recordEvent(ctx, {
297
+ recordId: withdrawalId, recordKind: 'declaration', entityId: purchase.entityId, action: 'credit-note', ok: false,
298
+ error: errorText(error), detail: JSON.stringify({ refundId }),
299
+ })
300
+ return false
301
+ }
302
+ }
package/src/index.ts CHANGED
@@ -15,8 +15,10 @@ export {
15
15
  classifySubscriptionChange, commitSubscription, propagatedStateOf, subscriptionEventKey,
16
16
  } from './subscription.js'
17
17
  export type { CommitOptions, CommitResult } from './subscription.js'
18
- export { planLookupKey, syncPaymentProducts, syncStripeProducts } from './sync.js'
18
+ export { planLookupKey, syncedPlanPrices, syncPaymentProducts, syncStripeProducts } from './sync.js'
19
19
  export { amountCheckoutLineItem, quantityCheckoutLineItem } from './plugins/stripe.js'
20
+ export { assertAmountAllowed, narrowAmountFor, sessionTtlOf } from './plugins/checkout-plugins.js'
21
+ export * from './consumer/index.js'
20
22
  export { makeEstimateCache, estimateStripePrice } from './plugins/estimate.js'
21
23
  export type { EstimateCache } from './plugins/estimate.js'
22
24
  export { applySubscription, createEventHandler, mapStatus } from './plugins/events.js'
@@ -26,7 +28,7 @@ export {
26
28
  } from './plugins/webhook-manager.js'
27
29
  export { ensurePortalConfiguration } from './plugins/portal.js'
28
30
  export {
29
- activeSubscription, apiVersionOf, entitlements, fingerprints, fulfillments, gateway, observer,
30
- paygateCustomers, payment, paymentWebhooks, stripeClient, stripeConfig, subscriptions, usageCounters,
31
- usageEvents,
31
+ activeSubscription, apiVersionOf, billingProfiles, consumerConsents, consumerDeclarations, consumerEvents,
32
+ consumerRights, consumerRightsOf, entitlements, fingerprints, fulfillments, gateway, observer, paygateCustomers,
33
+ payment, paymentWebhooks, purchases, stripeClient, stripeConfig, subscriptions, usageCounters, usageEvents,
32
34
  } from './utils.js'
package/src/model.ts CHANGED
@@ -1,9 +1,13 @@
1
1
  import type { JSONSchemaType } from 'ajv'
2
2
  import { DateSchema } from '@owlmeans/auth'
3
- import { CheckoutPricingModeSchema, SubscriptionStatusSchema } from '@owlmeans/payment'
3
+ import {
4
+ CancellationKindSchema, CheckoutPricingModeSchema, ConsentKindSchema, ConsumerRegionSchema,
5
+ DeclarationChannelSchema, DeclarationKindSchema, PurchaseKindSchema, SubscriptionStatusSchema,
6
+ } from '@owlmeans/payment'
4
7
  import type {
5
- FingerprintRecord, PaygateCustomerRecord, PaymentFulfillmentRecord, PaymentSubscriptionRecord,
6
- PaymentUsageCounterRecord, PaymentUsageRecord, PaymentWebhookRecord,
8
+ BillingProfileRecord, ConsumerConsentRecord, ConsumerDeclarationRecord, ConsumerEventRecord, FingerprintRecord,
9
+ PaygateCustomerRecord, PaymentFulfillmentRecord, PaymentSubscriptionRecord, PaymentUsageCounterRecord,
10
+ PaymentUsageRecord, PaymentWebhookRecord, PurchaseRecord,
7
11
  } from './types.js'
8
12
 
9
13
  /**
@@ -26,7 +30,7 @@ export const PaygateCustomerSchema = {
26
30
  type: 'object',
27
31
  properties: {
28
32
  id, paygate: str, externalId: str, entityId: optStr, profileId: optStr, email: optStr, name: optStr,
29
- taxId: optStr, deletedAt: optDate,
33
+ taxId: optStr, country: optStr, currency: optStr, deletedAt: optDate,
30
34
  },
31
35
  required: ['paygate', 'externalId'],
32
36
  additionalProperties: false,
@@ -40,7 +44,10 @@ export const PaymentSubscriptionSchema = {
40
44
  periodStart: optDate, periodEnd: optDate, cancelAtPeriodEnd: optBool, canceledAt: optDate,
41
45
  endedAt: optDate, pausedAt: optDate, trialEnd: optDate, latestInvoiceId: optStr, customerId: optStr,
42
46
  disputedAt: optDate, disputeStatus: optStr, createdAt: date, updatedAt: optDate, syncedAt: optDate,
43
- lastEventId: optStr, initialPropagatedAt: optDate,
47
+ lastEventId: optStr, initialPropagatedAt: optDate, currency: optStr,
48
+ checkoutSessionId: optStr, purchaseId: optStr, firstInvoiceId: optStr, country: optStr, email: optStr,
49
+ amountTotalMinor: optNum, amountTaxMinor: optNum, termsAccepted: optBool, startRequestId: optStr,
50
+ withdrawnAt: optDate,
44
51
  propagated: {
45
52
  type: 'object',
46
53
  nullable: true,
@@ -67,6 +74,8 @@ export const PaymentFulfillmentSchema = {
67
74
  chargeAmountMinor: optNum, currency: optStr, createdAt: date,
68
75
  fulfilledAt: optDate, failedAt: optDate, refundedMinor: optNum, refundedAt: optDate,
69
76
  disputedAt: optDate, disputeStatus: optStr,
77
+ country: optStr, email: optStr, profileId: optStr, amountTotalMinor: optNum, amountTaxMinor: optNum,
78
+ termsAccepted: optBool, purchaseId: optStr,
70
79
  },
71
80
  required: ['entityId', 'productSku', 'service', 'paygate', 'externalId', 'mode', 'createdAt'],
72
81
  additionalProperties: false,
@@ -107,8 +116,141 @@ export const PaymentUsageCounterSchema = {
107
116
  export const FingerprintSchema = {
108
117
  type: 'object',
109
118
  properties: {
110
- id, sku: str, hash: str, productId: optStr, externalId: optStr, updatedAt: date,
119
+ id, sku: str, hash: str, productId: optStr, externalId: optStr,
120
+ prices: {
121
+ type: 'array',
122
+ nullable: true,
123
+ items: {
124
+ type: 'object',
125
+ properties: {
126
+ planSku: str, priceId: str, lookupKey: str, currency: str, unitAmount: num,
127
+ // An array, never a currency-keyed map: the resource coerces a map's values to strings.
128
+ options: {
129
+ type: 'array',
130
+ items: {
131
+ type: 'object', properties: { currency: str, unitAmount: num },
132
+ required: ['currency', 'unitAmount'], additionalProperties: false,
133
+ },
134
+ },
135
+ taxBehavior: optStr, interval: optStr, sourceUnitAmount: num, sourceCurrency: str, syncedAt: date,
136
+ },
137
+ required: [
138
+ 'planSku', 'priceId', 'lookupKey', 'currency', 'unitAmount', 'options', 'sourceUnitAmount',
139
+ 'sourceCurrency', 'syncedAt',
140
+ ],
141
+ additionalProperties: false,
142
+ },
143
+ },
144
+ updatedAt: date,
111
145
  },
112
146
  required: ['sku', 'hash', 'updatedAt'],
113
147
  additionalProperties: false,
114
148
  } as unknown as JSONSchemaType<FingerprintRecord>
149
+
150
+ /** An optional enum: `null` must be listed for a validator to accept it. */
151
+ const nullableEnum = (values: readonly unknown[]) => ({ type: 'string', enum: [...values, null], nullable: true })
152
+
153
+ /** The request evidence every consumer act carries (`RequestOrigin`). */
154
+ const origin = {
155
+ ip: optStr, forwardedFor: optStr, userAgent: optStr, ipCountry: optStr, acceptLanguage: optStr, via: optStr,
156
+ } as const
157
+
158
+ const links = {
159
+ type: 'object',
160
+ properties: {
161
+ billingTerms: str, withdrawalInformation: optStr, withdrawalForm: optStr, withdrawalFunction: optStr,
162
+ cancellation: optStr,
163
+ },
164
+ required: ['billingTerms'],
165
+ additionalProperties: false,
166
+ } as const
167
+
168
+ export const BillingProfileSchema = {
169
+ type: 'object',
170
+ properties: {
171
+ id, entityId: str, country: str, region: ConsumerRegionSchema, currency: str, language: str,
172
+ source: { type: 'string', enum: ['checkout', 'customer', 'manual'] }, paygate: str, customerId: optStr,
173
+ sessionId: optStr, ipCountry: optStr, email: optStr, name: optStr, business: optBool, lockedAt: date,
174
+ createdAt: date, updatedAt: optDate,
175
+ },
176
+ required: ['entityId', 'country', 'region', 'currency', 'language', 'source', 'paygate', 'lockedAt', 'createdAt'],
177
+ additionalProperties: false,
178
+ } as unknown as JSONSchemaType<BillingProfileRecord>
179
+
180
+ export const PurchaseSchema = {
181
+ type: 'object',
182
+ properties: {
183
+ id, purchaseId: str, contractRef: str, entityId: str, kind: PurchaseKindSchema, paygate: str,
184
+ sessionId: optStr, subscriptionId: optStr, paymentIntentId: optStr, invoiceId: optStr, invoiceNumber: optStr,
185
+ invoiceLineId: optStr, productSku: str, planSku: optStr, profileId: optStr, country: optStr,
186
+ region: nullableEnum(ConsumerRegionSchema.enum), ipCountry: optStr, inScope: { type: 'boolean' },
187
+ language: str, email: optStr, name: optStr, business: optBool, currency: str, amountSubtotalMinor: num,
188
+ amountTaxMinor: num, amountTotalMinor: num, presentmentCurrency: optStr, presentmentAmountMinor: optNum,
189
+ netAmountMinor: optNum, amountCurrency: optStr, units: optNum, taxBehavior: optStr, termsAccepted: optBool,
190
+ textVersion: optStr, copyVersion: optStr, startRequestId: optStr, servicesStartedAt: optDate,
191
+ confirmationMailAt: optDate, purchasedAt: date, deadline: optDate, consentId: optStr, consentedAt: optDate,
192
+ withdrawalId: optStr, withdrawnAt: optDate, refundedMinor: optNum, refundedAt: optDate, cancellationId: optStr,
193
+ cancelEffectiveAt: optDate, createdAt: date, updatedAt: optDate,
194
+ },
195
+ required: [
196
+ 'purchaseId', 'contractRef', 'entityId', 'kind', 'paygate', 'productSku', 'inScope', 'language', 'currency',
197
+ 'amountSubtotalMinor', 'amountTaxMinor', 'amountTotalMinor', 'purchasedAt', 'createdAt',
198
+ ],
199
+ additionalProperties: false,
200
+ } as unknown as JSONSchemaType<PurchaseRecord>
201
+
202
+ export const ConsumerConsentSchema = {
203
+ type: 'object',
204
+ properties: {
205
+ id, kind: ConsentKindSchema, entityId: str, profileId: optStr, name: optStr, email: optStr,
206
+ purchaseIds: { type: 'array', items: str },
207
+ planSku: optStr, planName: optStr, textVersion: str, copyVersion: str, language: str, uiLanguage: optStr, trader: str,
208
+ text: {
209
+ type: 'object',
210
+ properties: { request: str, acknowledgement: str, checkbox: str },
211
+ required: ['request', 'acknowledgement', 'checkbox'],
212
+ additionalProperties: false,
213
+ },
214
+ links, deadline: optDate, decidedAt: date, expiresAt: optDate, ...origin,
215
+ },
216
+ required: [
217
+ 'kind', 'entityId', 'purchaseIds', 'textVersion', 'copyVersion', 'language', 'trader', 'text', 'links',
218
+ 'decidedAt',
219
+ ],
220
+ additionalProperties: false,
221
+ } as unknown as JSONSchemaType<ConsumerConsentRecord>
222
+
223
+ export const ConsumerDeclarationSchema = {
224
+ type: 'object',
225
+ properties: {
226
+ id, kind: DeclarationKindSchema, channel: DeclarationChannelSchema, entityId: optStr, purchaseId: optStr,
227
+ subscriptionId: optStr, contractRef: optStr, name: str, email: str,
228
+ cancellationKind: nullableEnum(CancellationKindSchema.enum), reason: optStr,
229
+ effective: nullableEnum(['earliest', 'date']), requestedDate: optStr,
230
+ language: str, textVersion: optStr, copyVersion: str, receivedAt: date, matched: { type: 'boolean' },
231
+ profileId: optStr, duplicateOf: optStr, status: str, refundMinor: optNum, currency: optStr,
232
+ effectiveAt: optDate, ...origin,
233
+ },
234
+ required: ['kind', 'channel', 'name', 'email', 'language', 'copyVersion', 'receivedAt', 'matched', 'status'],
235
+ additionalProperties: false,
236
+ } as unknown as JSONSchemaType<ConsumerDeclarationRecord>
237
+
238
+ export const ConsumerEventSchema = {
239
+ type: 'object',
240
+ properties: {
241
+ id, recordId: str,
242
+ recordKind: { type: 'string', enum: ['purchase', 'consent', 'declaration', 'profile', 'checkout'] },
243
+ entityId: optStr,
244
+ action: {
245
+ type: 'string',
246
+ enum: [
247
+ 'mail', 'computed', 'meter', 'refund', 'credit-note', 'subscription-cancel', 'cancel-scheduled', 'observers',
248
+ 'lock', 'lock-mismatch', 'relock', 'unlock', 'duplicate', 'checkout-terms-fallback',
249
+ ],
250
+ },
251
+ step: optStr, ok: { type: 'boolean' }, skipped: optBool, externalId: optStr, amountMinor: optNum,
252
+ currency: optStr, detail: optStr, error: optStr, at: date,
253
+ },
254
+ required: ['recordId', 'recordKind', 'action', 'ok', 'at'],
255
+ additionalProperties: false,
256
+ } as unknown as JSONSchemaType<ConsumerEventRecord>
package/src/observer.ts CHANGED
@@ -1,14 +1,18 @@
1
1
  import { createLazyService } from '@owlmeans/context'
2
2
  import { PAYMENT_OBSERVER } from './consts.js'
3
3
  import type {
4
- CompletionObserver, Config, Context, DisputeCallback, PaymentFailedCallback, RefundCallback,
5
- SubscriptionCallback, TopUpCallback,
4
+ CancellationCallback, CompletionObserver, Config, ConsentCallback, Context, DisputeCallback,
5
+ PaymentFailedCallback, RefundCallback, SubscriptionCallback, TopUpCallback, WithdrawalCallback,
6
6
  } from './types.js'
7
7
 
8
8
  /**
9
9
  * The application's side of payment completion. Callbacks run sequentially and are awaited; a
10
10
  * throw escapes to the webhook so the paygate delivers the event again — every callback must be
11
11
  * idempotent by the `eventKey` (`externalId` for a top-up) it is handed.
12
+ *
13
+ * The consumer-rights callbacks (`onConsent`, `onWithdrawal`, `onCancellation`) run after the
14
+ * records and the paygate steps of their act; a throw is recorded as an `observers` event and
15
+ * retried by the consumer-rights `reconcile()` — they, too, are idempotent by `eventKey`.
12
16
  */
13
17
  export const makeCompletionObserverService = (
14
18
  alias: string = PAYMENT_OBSERVER,
@@ -18,6 +22,9 @@ export const makeCompletionObserverService = (
18
22
  const refund: RefundCallback[] = []
19
23
  const dispute: DisputeCallback[] = []
20
24
  const paymentFailed: PaymentFailedCallback[] = []
25
+ const consent: ConsentCallback[] = []
26
+ const withdrawal: WithdrawalCallback[] = []
27
+ const cancellation: CancellationCallback[] = []
21
28
 
22
29
  const run = async <E>(callbacks: Array<(event: E, ctx: never) => Promise<void>>, event: E, ctx: unknown) => {
23
30
  for (const callback of callbacks) {
@@ -31,11 +38,17 @@ export const makeCompletionObserverService = (
31
38
  onRefund: cb => { refund.push(cb) },
32
39
  onDispute: cb => { dispute.push(cb) },
33
40
  onPaymentFailed: cb => { paymentFailed.push(cb) },
41
+ onConsent: cb => { consent.push(cb) },
42
+ onWithdrawal: cb => { withdrawal.push(cb) },
43
+ onCancellation: cb => { cancellation.push(cb) },
34
44
  propagateTopUp: async (completion, ctx) => { await run(topUp, completion, ctx) },
35
45
  propagateSubscription: async (event, ctx) => { await run(subscription, event, ctx) },
36
46
  propagateRefund: async (event, ctx) => { await run(refund, event, ctx) },
37
47
  propagateDispute: async (event, ctx) => { await run(dispute, event, ctx) },
38
48
  propagatePaymentFailed: async (event, ctx) => { await run(paymentFailed, event, ctx) },
49
+ propagateConsent: async (event, ctx) => { await run(consent, event, ctx) },
50
+ propagateWithdrawal: async (event, ctx) => { await run(withdrawal, event, ctx) },
51
+ propagateCancellation: async (event, ctx) => { await run(cancellation, event, ctx) },
39
52
  }, service => async () => { service.initialized = true })
40
53
  }
41
54