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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (222) hide show
  1. package/README.md +109 -25
  2. package/agent-meta/manifest.json +2 -2
  3. package/agent-meta/skills/server-payment/SKILL.md +475 -52
  4. package/build/actions/index.d.ts +1 -0
  5. package/build/actions/index.d.ts.map +1 -1
  6. package/build/actions/index.js +1 -0
  7. package/build/actions/index.js.map +1 -1
  8. package/build/actions/resync-subscriptions.d.ts +3 -0
  9. package/build/actions/resync-subscriptions.d.ts.map +1 -0
  10. package/build/actions/resync-subscriptions.js +7 -0
  11. package/build/actions/resync-subscriptions.js.map +1 -0
  12. package/build/actions/resync.d.ts +1 -0
  13. package/build/actions/resync.d.ts.map +1 -1
  14. package/build/actions/resync.js +7 -3
  15. package/build/actions/resync.js.map +1 -1
  16. package/build/actions/webhook.d.ts.map +1 -1
  17. package/build/actions/webhook.js +6 -3
  18. package/build/actions/webhook.js.map +1 -1
  19. package/build/config.d.ts +60 -5
  20. package/build/config.d.ts.map +1 -1
  21. package/build/config.js +237 -5
  22. package/build/config.js.map +1 -1
  23. package/build/consts.d.ts +75 -3
  24. package/build/consts.d.ts.map +1 -1
  25. package/build/consts.js +94 -5
  26. package/build/consts.js.map +1 -1
  27. package/build/consumer/capture.d.ts +74 -0
  28. package/build/consumer/capture.d.ts.map +1 -0
  29. package/build/consumer/capture.js +291 -0
  30. package/build/consumer/capture.js.map +1 -0
  31. package/build/consumer/format.d.ts +27 -0
  32. package/build/consumer/format.d.ts.map +1 -0
  33. package/build/consumer/format.js +81 -0
  34. package/build/consumer/format.js.map +1 -0
  35. package/build/consumer/handlers.d.ts +28 -0
  36. package/build/consumer/handlers.d.ts.map +1 -0
  37. package/build/consumer/handlers.js +173 -0
  38. package/build/consumer/handlers.js.map +1 -0
  39. package/build/consumer/index.d.ts +7 -0
  40. package/build/consumer/index.d.ts.map +1 -0
  41. package/build/consumer/index.js +6 -0
  42. package/build/consumer/index.js.map +1 -0
  43. package/build/consumer/mail.d.ts +27 -0
  44. package/build/consumer/mail.d.ts.map +1 -0
  45. package/build/consumer/mail.js +314 -0
  46. package/build/consumer/mail.js.map +1 -0
  47. package/build/consumer/origin.d.ts +14 -0
  48. package/build/consumer/origin.d.ts.map +1 -0
  49. package/build/consumer/origin.js +47 -0
  50. package/build/consumer/origin.js.map +1 -0
  51. package/build/consumer/reconcile.d.ts +12 -0
  52. package/build/consumer/reconcile.d.ts.map +1 -0
  53. package/build/consumer/reconcile.js +317 -0
  54. package/build/consumer/reconcile.js.map +1 -0
  55. package/build/consumer/records.d.ts +78 -0
  56. package/build/consumer/records.d.ts.map +1 -0
  57. package/build/consumer/records.js +296 -0
  58. package/build/consumer/records.js.map +1 -0
  59. package/build/consumer/service.d.ts +51 -0
  60. package/build/consumer/service.d.ts.map +1 -0
  61. package/build/consumer/service.js +760 -0
  62. package/build/consumer/service.js.map +1 -0
  63. package/build/consumer/withdrawal.d.ts +57 -0
  64. package/build/consumer/withdrawal.d.ts.map +1 -0
  65. package/build/consumer/withdrawal.js +247 -0
  66. package/build/consumer/withdrawal.js.map +1 -0
  67. package/build/entitlement.d.ts +10 -0
  68. package/build/entitlement.d.ts.map +1 -0
  69. package/build/entitlement.js +69 -0
  70. package/build/entitlement.js.map +1 -0
  71. package/build/entrypoints.d.ts +1 -1
  72. package/build/entrypoints.d.ts.map +1 -1
  73. package/build/entrypoints.js +2 -1
  74. package/build/entrypoints.js.map +1 -1
  75. package/build/gate.d.ts +32 -4
  76. package/build/gate.d.ts.map +1 -1
  77. package/build/gate.js +59 -19
  78. package/build/gate.js.map +1 -1
  79. package/build/index.d.ts +18 -3
  80. package/build/index.d.ts.map +1 -1
  81. package/build/index.js +15 -3
  82. package/build/index.js.map +1 -1
  83. package/build/limit.d.ts +13 -0
  84. package/build/limit.d.ts.map +1 -0
  85. package/build/limit.js +47 -0
  86. package/build/limit.js.map +1 -0
  87. package/build/model.d.ts +10 -1
  88. package/build/model.d.ts.map +1 -1
  89. package/build/model.js +215 -17
  90. package/build/model.js.map +1 -1
  91. package/build/observer.d.ts +9 -0
  92. package/build/observer.d.ts.map +1 -1
  93. package/build/observer.js +38 -12
  94. package/build/observer.js.map +1 -1
  95. package/build/plan.d.ts +22 -0
  96. package/build/plan.d.ts.map +1 -0
  97. package/build/plan.js +72 -0
  98. package/build/plan.js.map +1 -0
  99. package/build/plugins/checkout-plugins.d.ts +49 -0
  100. package/build/plugins/checkout-plugins.d.ts.map +1 -0
  101. package/build/plugins/checkout-plugins.js +124 -0
  102. package/build/plugins/checkout-plugins.js.map +1 -0
  103. package/build/plugins/estimate.d.ts +43 -0
  104. package/build/plugins/estimate.d.ts.map +1 -0
  105. package/build/plugins/estimate.js +268 -0
  106. package/build/plugins/estimate.js.map +1 -0
  107. package/build/plugins/events.d.ts +45 -10
  108. package/build/plugins/events.d.ts.map +1 -1
  109. package/build/plugins/events.js +605 -136
  110. package/build/plugins/events.js.map +1 -1
  111. package/build/plugins/fx.d.ts +31 -0
  112. package/build/plugins/fx.d.ts.map +1 -0
  113. package/build/plugins/fx.js +81 -0
  114. package/build/plugins/fx.js.map +1 -0
  115. package/build/plugins/portal.d.ts +37 -0
  116. package/build/plugins/portal.d.ts.map +1 -0
  117. package/build/plugins/portal.js +265 -0
  118. package/build/plugins/portal.js.map +1 -0
  119. package/build/plugins/refunds.d.ts +33 -0
  120. package/build/plugins/refunds.d.ts.map +1 -0
  121. package/build/plugins/refunds.js +80 -0
  122. package/build/plugins/refunds.js.map +1 -0
  123. package/build/plugins/stripe.d.ts +36 -4
  124. package/build/plugins/stripe.d.ts.map +1 -1
  125. package/build/plugins/stripe.js +492 -97
  126. package/build/plugins/stripe.js.map +1 -1
  127. package/build/plugins/webhook-manager.d.ts +46 -0
  128. package/build/plugins/webhook-manager.d.ts.map +1 -0
  129. package/build/plugins/webhook-manager.js +231 -0
  130. package/build/plugins/webhook-manager.js.map +1 -0
  131. package/build/reconcile.d.ts +15 -0
  132. package/build/reconcile.d.ts.map +1 -0
  133. package/build/reconcile.js +88 -0
  134. package/build/reconcile.js.map +1 -0
  135. package/build/resource.d.ts +12 -1
  136. package/build/resource.d.ts.map +1 -1
  137. package/build/resource.js +97 -6
  138. package/build/resource.js.map +1 -1
  139. package/build/service.d.ts +28 -3
  140. package/build/service.d.ts.map +1 -1
  141. package/build/service.js +146 -19
  142. package/build/service.js.map +1 -1
  143. package/build/subscription.d.ts +48 -0
  144. package/build/subscription.d.ts.map +1 -0
  145. package/build/subscription.js +176 -0
  146. package/build/subscription.js.map +1 -0
  147. package/build/sync.d.ts +28 -1
  148. package/build/sync.d.ts.map +1 -1
  149. package/build/sync.js +208 -31
  150. package/build/sync.js.map +1 -1
  151. package/build/types.d.ts +1115 -36
  152. package/build/types.d.ts.map +1 -1
  153. package/build/usage.d.ts +63 -0
  154. package/build/usage.d.ts.map +1 -0
  155. package/build/usage.js +363 -0
  156. package/build/usage.js.map +1 -0
  157. package/build/utils.d.ts +52 -1
  158. package/build/utils.d.ts.map +1 -1
  159. package/build/utils.js +69 -7
  160. package/build/utils.js.map +1 -1
  161. package/package.json +17 -13
  162. package/src/actions/index.ts +1 -0
  163. package/src/actions/resync-subscriptions.ts +10 -0
  164. package/src/actions/resync.ts +6 -3
  165. package/src/actions/webhook.ts +5 -3
  166. package/src/config.ts +264 -8
  167. package/src/consts.ts +114 -6
  168. package/src/consumer/capture.ts +362 -0
  169. package/src/consumer/format.ts +90 -0
  170. package/src/consumer/handlers.ts +211 -0
  171. package/src/consumer/index.ts +6 -0
  172. package/src/consumer/mail.ts +368 -0
  173. package/src/consumer/origin.ts +63 -0
  174. package/src/consumer/reconcile.ts +329 -0
  175. package/src/consumer/records.ts +374 -0
  176. package/src/consumer/service.ts +868 -0
  177. package/src/consumer/withdrawal.ts +302 -0
  178. package/src/entitlement.ts +84 -0
  179. package/src/entrypoints.ts +2 -1
  180. package/src/gate.ts +88 -21
  181. package/src/index.ts +24 -3
  182. package/src/limit.ts +57 -0
  183. package/src/model.ts +237 -18
  184. package/src/observer.ts +44 -11
  185. package/src/plan.ts +89 -0
  186. package/src/plugins/checkout-plugins.ts +155 -0
  187. package/src/plugins/estimate.ts +339 -0
  188. package/src/plugins/events.ts +677 -121
  189. package/src/plugins/fx.ts +122 -0
  190. package/src/plugins/portal.ts +306 -0
  191. package/src/plugins/refunds.ts +108 -0
  192. package/src/plugins/stripe.ts +581 -96
  193. package/src/plugins/webhook-manager.ts +270 -0
  194. package/src/reconcile.ts +103 -0
  195. package/src/resource.ts +152 -7
  196. package/src/service.ts +174 -18
  197. package/src/subscription.ts +231 -0
  198. package/src/sync.ts +249 -29
  199. package/src/types.ts +1227 -32
  200. package/src/usage.ts +453 -0
  201. package/src/utils.ts +127 -10
  202. package/tests/checkout-consumer.spec.ts +348 -0
  203. package/tests/checkout-plugins.spec.ts +164 -0
  204. package/tests/checkout.spec.ts +184 -83
  205. package/tests/consumer-events.spec.ts +218 -0
  206. package/tests/consumer-fixtures.ts +132 -0
  207. package/tests/consumer-ops.spec.ts +351 -0
  208. package/tests/consumer-rights.integration.spec.ts +150 -0
  209. package/tests/consumer-rights.spec.ts +501 -0
  210. package/tests/context.ts +109 -0
  211. package/tests/entitlement.spec.ts +103 -0
  212. package/tests/estimate.spec.ts +240 -0
  213. package/tests/events.spec.ts +356 -0
  214. package/tests/fake-stripe.ts +972 -0
  215. package/tests/gate.spec.ts +62 -72
  216. package/tests/limit-gate.spec.ts +68 -0
  217. package/tests/portal.spec.ts +200 -0
  218. package/tests/protocol.spec.ts +23 -6
  219. package/tests/sync.spec.ts +114 -0
  220. package/tests/usage.integration.spec.ts +101 -0
  221. package/tests/usage.spec.ts +171 -0
  222. package/tests/webhook-manager.spec.ts +152 -0
@@ -0,0 +1,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
+ }