@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.
- package/README.md +1 -1
- package/agent-meta/manifest.json +2 -2
- package/agent-meta/skills/server-payment/SKILL.md +402 -260
- package/build/config.d.ts +15 -1
- package/build/config.d.ts.map +1 -1
- package/build/config.js +99 -3
- package/build/config.js.map +1 -1
- package/build/consts.d.ts +27 -0
- package/build/consts.d.ts.map +1 -1
- package/build/consts.js +27 -0
- package/build/consts.js.map +1 -1
- package/build/consumer/capture.d.ts +74 -0
- package/build/consumer/capture.d.ts.map +1 -0
- package/build/consumer/capture.js +291 -0
- package/build/consumer/capture.js.map +1 -0
- package/build/consumer/format.d.ts +27 -0
- package/build/consumer/format.d.ts.map +1 -0
- package/build/consumer/format.js +81 -0
- package/build/consumer/format.js.map +1 -0
- package/build/consumer/handlers.d.ts +28 -0
- package/build/consumer/handlers.d.ts.map +1 -0
- package/build/consumer/handlers.js +173 -0
- package/build/consumer/handlers.js.map +1 -0
- package/build/consumer/index.d.ts +7 -0
- package/build/consumer/index.d.ts.map +1 -0
- package/build/consumer/index.js +6 -0
- package/build/consumer/index.js.map +1 -0
- package/build/consumer/mail.d.ts +27 -0
- package/build/consumer/mail.d.ts.map +1 -0
- package/build/consumer/mail.js +314 -0
- package/build/consumer/mail.js.map +1 -0
- package/build/consumer/origin.d.ts +14 -0
- package/build/consumer/origin.d.ts.map +1 -0
- package/build/consumer/origin.js +47 -0
- package/build/consumer/origin.js.map +1 -0
- package/build/consumer/reconcile.d.ts +12 -0
- package/build/consumer/reconcile.d.ts.map +1 -0
- package/build/consumer/reconcile.js +317 -0
- package/build/consumer/reconcile.js.map +1 -0
- package/build/consumer/records.d.ts +78 -0
- package/build/consumer/records.d.ts.map +1 -0
- package/build/consumer/records.js +296 -0
- package/build/consumer/records.js.map +1 -0
- package/build/consumer/service.d.ts +51 -0
- package/build/consumer/service.d.ts.map +1 -0
- package/build/consumer/service.js +760 -0
- package/build/consumer/service.js.map +1 -0
- package/build/consumer/withdrawal.d.ts +57 -0
- package/build/consumer/withdrawal.d.ts.map +1 -0
- package/build/consumer/withdrawal.js +247 -0
- package/build/consumer/withdrawal.js.map +1 -0
- package/build/index.d.ts +4 -2
- package/build/index.d.ts.map +1 -1
- package/build/index.js +4 -2
- package/build/index.js.map +1 -1
- package/build/model.d.ts +6 -1
- package/build/model.d.ts.map +1 -1
- package/build/model.js +134 -4
- package/build/model.js.map +1 -1
- package/build/observer.d.ts +4 -0
- package/build/observer.d.ts.map +1 -1
- package/build/observer.js +13 -0
- package/build/observer.js.map +1 -1
- package/build/plugins/checkout-plugins.d.ts +49 -0
- package/build/plugins/checkout-plugins.d.ts.map +1 -0
- package/build/plugins/checkout-plugins.js +124 -0
- package/build/plugins/checkout-plugins.js.map +1 -0
- package/build/plugins/estimate.d.ts.map +1 -1
- package/build/plugins/estimate.js +41 -8
- package/build/plugins/estimate.js.map +1 -1
- package/build/plugins/events.d.ts +2 -0
- package/build/plugins/events.d.ts.map +1 -1
- package/build/plugins/events.js +126 -9
- package/build/plugins/events.js.map +1 -1
- package/build/plugins/fx.d.ts +5 -0
- package/build/plugins/fx.d.ts.map +1 -1
- package/build/plugins/fx.js +25 -0
- package/build/plugins/fx.js.map +1 -1
- package/build/plugins/portal.d.ts.map +1 -1
- package/build/plugins/portal.js +11 -1
- package/build/plugins/portal.js.map +1 -1
- package/build/plugins/stripe.d.ts +21 -2
- package/build/plugins/stripe.d.ts.map +1 -1
- package/build/plugins/stripe.js +397 -61
- package/build/plugins/stripe.js.map +1 -1
- package/build/resource.d.ts +8 -1
- package/build/resource.d.ts.map +1 -1
- package/build/resource.js +59 -2
- package/build/resource.js.map +1 -1
- package/build/service.d.ts +2 -1
- package/build/service.d.ts.map +1 -1
- package/build/service.js +28 -5
- package/build/service.js.map +1 -1
- package/build/subscription.d.ts +6 -0
- package/build/subscription.d.ts.map +1 -1
- package/build/subscription.js +1 -0
- package/build/subscription.js.map +1 -1
- package/build/sync.d.ts +7 -0
- package/build/sync.d.ts.map +1 -1
- package/build/sync.js +108 -15
- package/build/sync.js.map +1 -1
- package/build/types.d.ts +707 -4
- package/build/types.d.ts.map +1 -1
- package/build/utils.d.ts +22 -1
- package/build/utils.d.ts.map +1 -1
- package/build/utils.js +27 -1
- package/build/utils.js.map +1 -1
- package/package.json +14 -13
- package/src/config.ts +111 -7
- package/src/consts.ts +34 -0
- package/src/consumer/capture.ts +362 -0
- package/src/consumer/format.ts +90 -0
- package/src/consumer/handlers.ts +211 -0
- package/src/consumer/index.ts +6 -0
- package/src/consumer/mail.ts +368 -0
- package/src/consumer/origin.ts +63 -0
- package/src/consumer/reconcile.ts +329 -0
- package/src/consumer/records.ts +374 -0
- package/src/consumer/service.ts +868 -0
- package/src/consumer/withdrawal.ts +302 -0
- package/src/index.ts +6 -4
- package/src/model.ts +148 -6
- package/src/observer.ts +15 -2
- package/src/plugins/checkout-plugins.ts +155 -0
- package/src/plugins/estimate.ts +49 -9
- package/src/plugins/events.ts +135 -11
- package/src/plugins/fx.ts +29 -0
- package/src/plugins/portal.ts +11 -1
- package/src/plugins/stripe.ts +476 -60
- package/src/resource.ts +87 -4
- package/src/service.ts +28 -6
- package/src/subscription.ts +7 -0
- package/src/sync.ts +124 -17
- package/src/types.ts +756 -6
- package/src/utils.ts +56 -7
- package/tests/checkout-consumer.spec.ts +348 -0
- package/tests/checkout-plugins.spec.ts +164 -0
- package/tests/consumer-events.spec.ts +218 -0
- package/tests/consumer-fixtures.ts +132 -0
- package/tests/consumer-ops.spec.ts +351 -0
- package/tests/consumer-rights.integration.spec.ts +150 -0
- package/tests/consumer-rights.spec.ts +501 -0
- package/tests/context.ts +20 -2
- 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,
|
|
30
|
-
|
|
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 {
|
|
3
|
+
import {
|
|
4
|
+
CancellationKindSchema, CheckoutPricingModeSchema, ConsentKindSchema, ConsumerRegionSchema,
|
|
5
|
+
DeclarationChannelSchema, DeclarationKindSchema, PurchaseKindSchema, SubscriptionStatusSchema,
|
|
6
|
+
} from '@owlmeans/payment'
|
|
4
7
|
import type {
|
|
5
|
-
|
|
6
|
-
|
|
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,
|
|
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,
|
|
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
|
|