@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,329 @@
|
|
|
1
|
+
import type Stripe from 'stripe'
|
|
2
|
+
import { MAILER_SERVICE } from '@owlmeans/mailer'
|
|
3
|
+
import {
|
|
4
|
+
CancellationStatus, DeclarationKind, ENTITLING_STATUSES, PurchaseKind, WithdrawalStatus,
|
|
5
|
+
} from '@owlmeans/payment'
|
|
6
|
+
import type { Context as ApiContext } from '@owlmeans/server-api'
|
|
7
|
+
import { PURCHASE_BACKFILL_DAYS, STRIPE_PAYGATE_ALIAS } from '../consts.js'
|
|
8
|
+
import {
|
|
9
|
+
billingProfiles, consumerConsents, consumerDeclarations, consumerEvents, consumerMailConfig, errorText, idOf,
|
|
10
|
+
paygateCustomers, payment, purchases, subscriptions,
|
|
11
|
+
} from '../utils.js'
|
|
12
|
+
import {
|
|
13
|
+
capturePaymentPurchase, captureSubscriptionPurchase, completeSubscriptionPurchase,
|
|
14
|
+
} from './capture.js'
|
|
15
|
+
import { sendConsumerMail } from './mail.js'
|
|
16
|
+
import { hasEvent, hasPaid, lockProfile, purchaseIdOf, wasUnlocked } from './records.js'
|
|
17
|
+
import {
|
|
18
|
+
notifyCancellation, notifyConsent, notifyWithdrawal, scheduleCancellation,
|
|
19
|
+
} from './service.js'
|
|
20
|
+
import type { ConsumerRightsInternals } from './service.js'
|
|
21
|
+
import { executeWithdrawal, retryCreditNote } from './withdrawal.js'
|
|
22
|
+
import type {
|
|
23
|
+
ConsumerEventRecord, ConsumerMailKind, ConsumerReconcileOptions, ConsumerReconcileResult, UsageReading,
|
|
24
|
+
} from '../types.js'
|
|
25
|
+
|
|
26
|
+
const DAY_MS = 86_400_000
|
|
27
|
+
/** A paygate step failed this often is left to an operator. */
|
|
28
|
+
const MAX_ATTEMPTS = 5
|
|
29
|
+
|
|
30
|
+
/** What `withdraw` computed for a declaration, read back from its `computed` event. */
|
|
31
|
+
const computedOf = async (ctx: ApiContext, withdrawalId: string) => {
|
|
32
|
+
const event = await consumerEvents(ctx).load({ recordId: withdrawalId, action: 'computed', ok: true })
|
|
33
|
+
if (event == null) {
|
|
34
|
+
return null
|
|
35
|
+
}
|
|
36
|
+
const detail = JSON.parse(event.detail ?? '{}') as {
|
|
37
|
+
reading?: UsageReading, deducted?: number, netMinor?: number, unitsReturned?: number,
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
reading: detail.reading ?? { granted: 0, used: 0, usedAfter: 0 },
|
|
42
|
+
deducted: detail.deducted ?? 0,
|
|
43
|
+
unitsReturned: detail.unitsReturned ?? 0,
|
|
44
|
+
netMinor: detail.netMinor ?? 0,
|
|
45
|
+
refundMinor: event.amountMinor ?? 0,
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const failures = async (ctx: ApiContext, recordId: string, action: ConsumerEventRecord['action']): Promise<number> =>
|
|
50
|
+
await consumerEvents(ctx).count({ recordId, action, ok: false })
|
|
51
|
+
|
|
52
|
+
/** Every distinct (record, step) with a failed event since `since` and no success after all. */
|
|
53
|
+
const pendingSteps = async (
|
|
54
|
+
ctx: ApiContext, action: ConsumerEventRecord['action'], since: Date, limit: number,
|
|
55
|
+
): Promise<ConsumerEventRecord[]> => {
|
|
56
|
+
const { items } = await consumerEvents(ctx).list({ action, ok: false, at: { $gte: since } }, {
|
|
57
|
+
size: limit * 4, sort: [{ field: 'at', order: 'desc' }],
|
|
58
|
+
})
|
|
59
|
+
const seen = new Set<string>()
|
|
60
|
+
const pending: ConsumerEventRecord[] = []
|
|
61
|
+
for (const event of items) {
|
|
62
|
+
const key = `${event.recordId}:${event.step ?? ''}`
|
|
63
|
+
if (seen.has(key)) continue
|
|
64
|
+
seen.add(key)
|
|
65
|
+
if (!await hasEvent(ctx, event.recordId, action, event.step ?? undefined, true)) {
|
|
66
|
+
pending.push(event)
|
|
67
|
+
}
|
|
68
|
+
if (pending.length >= limit) break
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return pending
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const retryWithdrawals = async (
|
|
75
|
+
ctx: ApiContext, stripe: Stripe, since: Date, limit: number, result: ConsumerReconcileResult,
|
|
76
|
+
): Promise<void> => {
|
|
77
|
+
const { items } = await consumerDeclarations(ctx).list({
|
|
78
|
+
kind: DeclarationKind.Withdrawal, status: WithdrawalStatus.Processing, matched: true, receivedAt: { $gte: since },
|
|
79
|
+
}, { size: limit * 2, sort: [{ field: 'receivedAt', order: 'asc' }] })
|
|
80
|
+
let handled = 0
|
|
81
|
+
for (const declaration of items) {
|
|
82
|
+
if (handled >= limit) break
|
|
83
|
+
const id = declaration.id as string
|
|
84
|
+
const purchase = declaration.purchaseId != null ? await purchases(ctx).byPurchaseId(declaration.purchaseId) : null
|
|
85
|
+
// A declaration that lost the race for its purchase is a duplicate — never executed.
|
|
86
|
+
if (purchase == null || purchase.withdrawalId !== id) continue
|
|
87
|
+
const computed = await computedOf(ctx, id)
|
|
88
|
+
const refundDone = (declaration.refundMinor ?? 0) <= 0 || await hasEvent(ctx, id, 'refund', undefined, true)
|
|
89
|
+
const cancelDone = purchase.kind !== PurchaseKind.Subscription || await hasEvent(ctx, id, 'subscription-cancel', undefined, true)
|
|
90
|
+
if (refundDone && cancelDone) {
|
|
91
|
+
const refundEvent = await consumerEvents(ctx).load({ recordId: id, action: 'refund', ok: true })
|
|
92
|
+
const noteFailed = await consumerEvents(ctx).load({ recordId: id, action: 'credit-note', ok: false, step: null })
|
|
93
|
+
if (refundEvent?.externalId != null && noteFailed != null && computed != null
|
|
94
|
+
&& !await hasEvent(ctx, id, 'credit-note', undefined, true)) {
|
|
95
|
+
const attempt = await failures(ctx, id, 'credit-note')
|
|
96
|
+
if (attempt < MAX_ATTEMPTS) {
|
|
97
|
+
handled++
|
|
98
|
+
result.retried++
|
|
99
|
+
if (!await retryCreditNote(ctx, stripe, declaration, purchase, computed.netMinor, refundEvent.externalId, attempt)) {
|
|
100
|
+
result.failed++
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
continue
|
|
105
|
+
}
|
|
106
|
+
const attempt = Math.max(await failures(ctx, id, 'refund'), await failures(ctx, id, 'subscription-cancel'))
|
|
107
|
+
if (attempt >= MAX_ATTEMPTS) continue
|
|
108
|
+
handled++
|
|
109
|
+
result.retried++
|
|
110
|
+
try {
|
|
111
|
+
const execution = await executeWithdrawal(ctx, stripe, declaration, purchase, {
|
|
112
|
+
refundMinor: declaration.refundMinor ?? computed?.refundMinor ?? 0, netMinor: computed?.netMinor ?? 0,
|
|
113
|
+
}, { attempt })
|
|
114
|
+
if (!execution.ok) {
|
|
115
|
+
result.failed++
|
|
116
|
+
continue
|
|
117
|
+
}
|
|
118
|
+
if (!await hasEvent(ctx, id, 'observers', 'withdrawal')) {
|
|
119
|
+
await notifyWithdrawal(ctx, declaration, purchase, WithdrawalStatus.Refunded, computed, execution)
|
|
120
|
+
}
|
|
121
|
+
} catch (error) {
|
|
122
|
+
result.failed++
|
|
123
|
+
console.error(`[payment] reconcile: withdrawal "${id}" failed`, error)
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const retryCancellations = async (
|
|
129
|
+
ctx: ApiContext, stripe: Stripe, since: Date, limit: number, result: ConsumerReconcileResult,
|
|
130
|
+
): Promise<void> => {
|
|
131
|
+
const { items } = await consumerDeclarations(ctx).list({
|
|
132
|
+
kind: DeclarationKind.Cancellation, status: CancellationStatus.Scheduled, matched: true, receivedAt: { $gte: since },
|
|
133
|
+
}, { size: limit * 2, sort: [{ field: 'receivedAt', order: 'asc' }] })
|
|
134
|
+
let handled = 0
|
|
135
|
+
for (const declaration of items) {
|
|
136
|
+
if (handled >= limit) break
|
|
137
|
+
const id = declaration.id as string
|
|
138
|
+
if (declaration.subscriptionId == null || await hasEvent(ctx, id, 'cancel-scheduled', undefined, true)) continue
|
|
139
|
+
const attempt = await failures(ctx, id, 'cancel-scheduled')
|
|
140
|
+
const row = await subscriptions(ctx).byExternalId(declaration.subscriptionId, STRIPE_PAYGATE_ALIAS)
|
|
141
|
+
if (row == null || attempt >= MAX_ATTEMPTS) continue
|
|
142
|
+
handled++
|
|
143
|
+
result.retried++
|
|
144
|
+
if (!await scheduleCancellation(ctx, stripe, declaration, row, attempt)) {
|
|
145
|
+
result.failed++
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const retryMails = async (ctx: ApiContext, since: Date, limit: number, result: ConsumerReconcileResult): Promise<void> => {
|
|
151
|
+
const policy = await payment(ctx).consumerRightsPolicy()
|
|
152
|
+
const alias = (await consumerMailConfig(ctx))?.alias ?? MAILER_SERVICE
|
|
153
|
+
if (policy == null || (ctx as unknown as { hasService?: (alias: string) => boolean }).hasService?.(alias) !== true) {
|
|
154
|
+
return
|
|
155
|
+
}
|
|
156
|
+
for (const event of await pendingSteps(ctx, 'mail', since, limit)) {
|
|
157
|
+
if (event.step == null) continue
|
|
158
|
+
if (await sendConsumerMail(ctx, policy, event.step as ConsumerMailKind, event.recordId)) {
|
|
159
|
+
result.mailed++
|
|
160
|
+
} else if (!await hasEvent(ctx, event.recordId, 'mail', event.step, true)) {
|
|
161
|
+
result.failed++
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const retryObservers = async (ctx: ApiContext, since: Date, limit: number, result: ConsumerReconcileResult): Promise<void> => {
|
|
167
|
+
for (const event of await pendingSteps(ctx, 'observers', since, limit)) {
|
|
168
|
+
let told = false
|
|
169
|
+
try {
|
|
170
|
+
if (event.step === 'consent') {
|
|
171
|
+
const consent = await consumerConsents(ctx).load(event.recordId)
|
|
172
|
+
told = consent != null && await notifyConsent(ctx, consent)
|
|
173
|
+
} else if (event.step === 'withdrawal') {
|
|
174
|
+
const declaration = await consumerDeclarations(ctx).load(event.recordId)
|
|
175
|
+
const purchase = declaration?.purchaseId != null ? await purchases(ctx).byPurchaseId(declaration.purchaseId) : null
|
|
176
|
+
if (declaration != null && purchase != null) {
|
|
177
|
+
const refunded = await hasEvent(ctx, event.recordId, 'refund', undefined, true) || (declaration.refundMinor ?? 0) <= 0
|
|
178
|
+
const refund = await consumerEvents(ctx).load({ recordId: event.recordId, action: 'refund', ok: true })
|
|
179
|
+
const note = await consumerEvents(ctx).load({ recordId: event.recordId, action: 'credit-note', ok: true })
|
|
180
|
+
told = await notifyWithdrawal(
|
|
181
|
+
ctx, declaration, purchase,
|
|
182
|
+
declaration.status === WithdrawalStatus.Processing && refunded ? WithdrawalStatus.Refunded : WithdrawalStatus.Review,
|
|
183
|
+
await computedOf(ctx, event.recordId),
|
|
184
|
+
refund != null ? {
|
|
185
|
+
ok: true, refundId: refund.externalId ?? undefined, creditNoteId: note?.externalId ?? undefined,
|
|
186
|
+
refundedMinor: refund.amountMinor ?? 0, needsReview: false,
|
|
187
|
+
subscriptionCanceled: await hasEvent(ctx, event.recordId, 'subscription-cancel', undefined, true),
|
|
188
|
+
} : null,
|
|
189
|
+
)
|
|
190
|
+
}
|
|
191
|
+
} else if (event.step === 'cancellation') {
|
|
192
|
+
const declaration = await consumerDeclarations(ctx).load(event.recordId)
|
|
193
|
+
if (declaration != null) {
|
|
194
|
+
const scheduled = await hasEvent(ctx, event.recordId, 'cancel-scheduled', undefined, true)
|
|
195
|
+
told = await notifyCancellation(ctx, declaration, declaration.status === CancellationStatus.Scheduled && !scheduled
|
|
196
|
+
? CancellationStatus.Received : declaration.status as CancellationStatus)
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
} catch (error) {
|
|
200
|
+
console.error(`[payment] reconcile: observers of "${event.recordId}" failed`, error)
|
|
201
|
+
}
|
|
202
|
+
if (told) {
|
|
203
|
+
result.observed++
|
|
204
|
+
} else {
|
|
205
|
+
result.failed++
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Completed checkouts of the last days that have no purchase row (records only — no mail). */
|
|
211
|
+
const backfillPurchases = async (ctx: ApiContext, stripe: Stripe, limit: number, result: ConsumerReconcileResult): Promise<void> => {
|
|
212
|
+
const from = Math.floor((Date.now() - PURCHASE_BACKFILL_DAYS * DAY_MS) / 1000)
|
|
213
|
+
let startingAfter: string | undefined
|
|
214
|
+
let scanned = 0
|
|
215
|
+
for (;;) {
|
|
216
|
+
const page = await stripe.checkout.sessions.list({
|
|
217
|
+
created: { gte: from }, status: 'complete', limit: 100,
|
|
218
|
+
...(startingAfter != null ? { starting_after: startingAfter } : {}),
|
|
219
|
+
})
|
|
220
|
+
for (const session of page.data) {
|
|
221
|
+
scanned++
|
|
222
|
+
if (result.backfilled >= limit) return
|
|
223
|
+
const metadata = session.metadata ?? {}
|
|
224
|
+
if (metadata.entityId == null || metadata.productSku == null) continue
|
|
225
|
+
try {
|
|
226
|
+
if (session.mode === 'payment' && session.payment_status === 'paid') {
|
|
227
|
+
if (await purchases(ctx).load({ sessionId: session.id }) != null) continue
|
|
228
|
+
const amount = Number(metadata.amountMinor)
|
|
229
|
+
const captured = await capturePaymentPurchase(ctx, stripe, session, {
|
|
230
|
+
...(Number.isSafeInteger(amount) && amount > 0 ? { netAmountMinor: amount } : {}),
|
|
231
|
+
...(metadata.amountCurrency != null ? { amountCurrency: metadata.amountCurrency } : {}),
|
|
232
|
+
at: new Date(session.created * 1000),
|
|
233
|
+
}, { mail: false })
|
|
234
|
+
if (captured?.created === true) result.backfilled++
|
|
235
|
+
} else if (session.mode === 'subscription') {
|
|
236
|
+
const subscriptionId = idOf(session.subscription)
|
|
237
|
+
if (subscriptionId == null || await purchases(ctx).byPurchaseId(purchaseIdOf(subscriptionId)) != null) continue
|
|
238
|
+
const row = await subscriptions(ctx).byExternalId(subscriptionId, STRIPE_PAYGATE_ALIAS)
|
|
239
|
+
if (row == null || !ENTITLING_STATUSES.includes(row.status)) continue
|
|
240
|
+
const subscription = await stripe.subscriptions.retrieve(subscriptionId)
|
|
241
|
+
const captured = await captureSubscriptionPurchase(ctx, stripe, subscription, row)
|
|
242
|
+
if (captured != null) {
|
|
243
|
+
await completeSubscriptionPurchase(ctx, stripe, session, captured.purchase, { mail: false })
|
|
244
|
+
if (captured.created) result.backfilled++
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
} catch (error) {
|
|
248
|
+
result.failed++
|
|
249
|
+
console.error(`[payment] reconcile: backfill of "${session.id}" failed`, error)
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
if (!page.has_more || page.data.length === 0 || scanned >= limit * 20) return
|
|
253
|
+
startingAfter = page.data[page.data.length - 1].id
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Organizations that paid before countries were locked: locked from their paygate customer's
|
|
259
|
+
* address — never one an operator unlocked (its next completed purchase locks it).
|
|
260
|
+
*/
|
|
261
|
+
const lockLegacy = async (ctx: ApiContext, stripe: Stripe, limit: number, result: ConsumerReconcileResult): Promise<void> => {
|
|
262
|
+
const policy = await payment(ctx).consumerRightsPolicy()
|
|
263
|
+
if (policy?.mechanisms.countryLock !== true) {
|
|
264
|
+
return
|
|
265
|
+
}
|
|
266
|
+
const { items } = await paygateCustomers(ctx).list({ paygate: STRIPE_PAYGATE_ALIAS, deletedAt: null }, { size: limit * 4 })
|
|
267
|
+
for (const customer of items) {
|
|
268
|
+
if (result.locked >= limit) return
|
|
269
|
+
if (customer.entityId == null || await billingProfiles(ctx).byEntity(customer.entityId) != null) continue
|
|
270
|
+
if (!await hasPaid(ctx, customer.entityId) || await wasUnlocked(ctx, customer.entityId)) continue
|
|
271
|
+
try {
|
|
272
|
+
let country = customer.country
|
|
273
|
+
if (country == null) {
|
|
274
|
+
const retrieved = await stripe.customers.retrieve(customer.externalId)
|
|
275
|
+
country = (retrieved as Stripe.DeletedCustomer).deleted ? undefined : (retrieved as Stripe.Customer).address?.country ?? undefined
|
|
276
|
+
}
|
|
277
|
+
if (country == null) continue
|
|
278
|
+
const { created } = await lockProfile(ctx, policy, {
|
|
279
|
+
entityId: customer.entityId, country, source: 'customer', customerId: customer.externalId,
|
|
280
|
+
email: customer.email ?? undefined,
|
|
281
|
+
})
|
|
282
|
+
if (created) result.locked++
|
|
283
|
+
} catch (error) {
|
|
284
|
+
result.failed++
|
|
285
|
+
console.error(`[payment] reconcile: legacy lock of "${customer.entityId}" failed: ${errorText(error)}`)
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* The consumer-rights repair an application runs nightly: retry the paygate steps of withdrawals
|
|
292
|
+
* (refunds, credit notes, subscription cancels) and scheduled cancellations, failed mails and
|
|
293
|
+
* failed observers; backfill purchases of completed checkouts of the last 16 days that have no
|
|
294
|
+
* row; lock organizations that paid before countries were locked. The paygate steps, the
|
|
295
|
+
* backfill and the legacy locks need a managed service. Each step handles at most `limit` items.
|
|
296
|
+
*/
|
|
297
|
+
export const reconcileConsumerRights = async (
|
|
298
|
+
ctx: ApiContext, internals: ConsumerRightsInternals, opts: ConsumerReconcileOptions = {},
|
|
299
|
+
): Promise<ConsumerReconcileResult> => {
|
|
300
|
+
const result: ConsumerReconcileResult = { retried: 0, mailed: 0, observed: 0, backfilled: 0, locked: 0, failed: 0 }
|
|
301
|
+
if (await payment(ctx).consumerRightsPolicy() == null) {
|
|
302
|
+
return result
|
|
303
|
+
}
|
|
304
|
+
const limit = Math.max(1, opts.limit ?? 50)
|
|
305
|
+
const since = opts.since ?? new Date(Date.now() - 30 * DAY_MS)
|
|
306
|
+
const stripe = internals.managed ? await internals.stripe(ctx) : null
|
|
307
|
+
const steps: Array<[string, () => Promise<void>]> = [
|
|
308
|
+
...(stripe != null ? [
|
|
309
|
+
['withdrawals', async () => { await retryWithdrawals(ctx, stripe, since, limit, result) }],
|
|
310
|
+
['cancellations', async () => { await retryCancellations(ctx, stripe, since, limit, result) }],
|
|
311
|
+
] as Array<[string, () => Promise<void>]> : []),
|
|
312
|
+
['mails', async () => { await retryMails(ctx, since, limit, result) }],
|
|
313
|
+
['observers', async () => { await retryObservers(ctx, since, limit, result) }],
|
|
314
|
+
...(stripe != null ? [
|
|
315
|
+
['backfill', async () => { await backfillPurchases(ctx, stripe, limit, result) }],
|
|
316
|
+
['legacy locks', async () => { await lockLegacy(ctx, stripe, limit, result) }],
|
|
317
|
+
] as Array<[string, () => Promise<void>]> : []),
|
|
318
|
+
]
|
|
319
|
+
for (const [name, step] of steps) {
|
|
320
|
+
try {
|
|
321
|
+
await step()
|
|
322
|
+
} catch (error) {
|
|
323
|
+
result.failed++
|
|
324
|
+
console.error(`[payment] consumer-rights reconcile step "${name}" failed`, error)
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
return result
|
|
329
|
+
}
|