@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
@@ -1,40 +1,271 @@
1
1
  import type Stripe from 'stripe'
2
2
  import { Mutex } from 'async-mutex'
3
3
  import {
4
- CheckoutPricingMode, PaygateError, PlanDuration, SubscriptionStatus,
4
+ CheckoutPricingMode, ENTITLING_STATUSES, PaygateError, SubscriptionStatus, TERMINAL_STATUSES,
5
5
  } from '@owlmeans/payment'
6
6
  import type { Context as ApiContext } from '@owlmeans/server-api'
7
- import { STRIPE_PAYGATE_ALIAS } from '../consts.js'
8
- import { observer, payment, paygateCustomers, subscriptions } from '../utils.js'
9
- import type { PaymentPlan, PaymentSubscriptionRecord, TopUpCompletion } from '../types.js'
7
+ import { GATEWAY_SERVICE, STRIPE_PAYGATE_ALIAS } from '../consts.js'
8
+ import { findPlan, planRank } from '../plan.js'
9
+ import { commitSubscription } from '../subscription.js'
10
+ import type { CommitResult } from '../subscription.js'
11
+ import {
12
+ compact, dateOf, fulfillments, gateway, idOf, isDuplicateKey, isMissingObject, observer, paygateCustomers,
13
+ purchases, subscriptions,
14
+ } from '../utils.js'
15
+ import {
16
+ capturePaymentPurchase, captureSubscriptionPurchase, completeSubscriptionPurchase, sessionEvidenceOf,
17
+ } from '../consumer/capture.js'
18
+ import { patchPurchase, purchaseIdOf } from '../consumer/records.js'
19
+ import { settleCheckout } from './checkout-plugins.js'
20
+ import { resolvePaymentTarget, retrieveCharge } from './refunds.js'
21
+ import type { PaymentTarget } from './refunds.js'
22
+ import type {
23
+ CheckoutOutcome, CheckoutPlugin, DisputePhase, PaymentFulfillmentRecord, PaymentSubscriptionRecord,
24
+ SubscriptionRef, TopUpCompletion,
25
+ } from '../types.js'
10
26
 
11
27
  const customerMutex: Record<string, Mutex> = {}
12
- const lockOn = async (customerId: string) => {
13
- customerMutex[customerId] = customerMutex[customerId] ?? new Mutex()
14
- return customerMutex[customerId].acquire()
15
- }
16
- const unlockOn = (customerId: string) => {
17
- const mutex = customerMutex[customerId]
18
- if (mutex != null) {
19
- mutex.release()
20
- if (!mutex.isLocked()) delete customerMutex[customerId]
28
+
29
+ /** Serialize the work on one paygate customer within this process. */
30
+ const withCustomerLock = async <T>(customerId: string | undefined, fn: () => Promise<T>): Promise<T> => {
31
+ const key = customerId ?? ''
32
+ const mutex = customerMutex[key] = customerMutex[key] ?? new Mutex()
33
+ const release = await mutex.acquire()
34
+ try {
35
+ return await fn()
36
+ } finally {
37
+ release()
38
+ if (!mutex.isLocked()) {
39
+ delete customerMutex[key]
40
+ }
21
41
  }
22
42
  }
23
- const customerIdOf = (value: string | { id?: string } | null): string =>
24
- typeof value === 'string' ? value : (value?.id ?? '')
25
- const mapStatus = (status: Stripe.Subscription.Status): SubscriptionStatus => {
26
- switch (status) {
27
- case 'active': return SubscriptionStatus.Active
28
- case 'trialing': return SubscriptionStatus.Trial
29
- case 'past_due':
30
- case 'unpaid': return SubscriptionStatus.Expired
31
- case 'canceled': return SubscriptionStatus.Canceled
32
- case 'incomplete':
33
- case 'incomplete_expired':
43
+
44
+ /**
45
+ * Stripe's subscription status as a `SubscriptionStatus`. `past_due` still entitles (flagged);
46
+ * `unpaid`, `paused` and paused collection revoke until resumed.
47
+ */
48
+ export const mapStatus = (subscription: Pick<Stripe.Subscription, 'status' | 'pause_collection'>): SubscriptionStatus => {
49
+ switch (subscription.status) {
50
+ case 'active':
51
+ return subscription.pause_collection != null ? SubscriptionStatus.Suspended : SubscriptionStatus.Active
52
+ case 'trialing':
53
+ return subscription.pause_collection != null ? SubscriptionStatus.Suspended : SubscriptionStatus.Trial
54
+ case 'past_due': return SubscriptionStatus.PastDue
55
+ case 'unpaid': return SubscriptionStatus.Suspended
34
56
  case 'paused': return SubscriptionStatus.Suspended
57
+ case 'incomplete': return SubscriptionStatus.Created
58
+ case 'incomplete_expired': return SubscriptionStatus.Ended
59
+ case 'canceled': return SubscriptionStatus.Canceled
35
60
  default: return SubscriptionStatus.Created
36
61
  }
37
62
  }
63
+
64
+ const isPaused = (subscription: Pick<Stripe.Subscription, 'status' | 'pause_collection'>): boolean =>
65
+ subscription.status === 'paused'
66
+ || (subscription.pause_collection != null && (subscription.status === 'active' || subscription.status === 'trialing'))
67
+
68
+ /**
69
+ * The current period. Top-level on the API version the client is pinned to; read through a narrow
70
+ * accessor because later versions move it onto the subscription items.
71
+ */
72
+ const periodOf = (subscription: Stripe.Subscription): { start?: Date, end?: Date } => {
73
+ const typed = subscription as unknown as { current_period_start?: number | null, current_period_end?: number | null }
74
+ return { start: dateOf(typed.current_period_start), end: dateOf(typed.current_period_end) }
75
+ }
76
+
77
+ /** `invoice.subscription`, top-level on the pinned API version. */
78
+ const subscriptionIdOfInvoice = (invoice: Stripe.Invoice): string | undefined =>
79
+ idOf((invoice as unknown as { subscription?: string | { id?: string } | null }).subscription)
80
+
81
+ export interface ApplyOptions {
82
+ source: 'webhook' | 'resync'
83
+ eventId?: string
84
+ /** `event.created` (epoch seconds) of a webhook payload; an older payload than the stored state is skipped. */
85
+ eventCreated?: number
86
+ renewal?: boolean
87
+ invoiceId?: string
88
+ trialEnding?: boolean
89
+ /** Store this status whatever the payload says (a deleted subscription is canceled). */
90
+ forced?: SubscriptionStatus
91
+ /** The paygate client — lets the first commit read the first invoice for its purchase row. */
92
+ stripe?: Stripe
93
+ }
94
+
95
+ const secondsFloor = (at: Date): Date => new Date(Math.floor(at.getTime() / 1000) * 1000)
96
+
97
+ /**
98
+ * Apply one Stripe subscription — from a webhook payload or a fresh retrieval — to its row, and
99
+ * tell observers what changed. Shared by every webhook and by resync.
100
+ */
101
+ export const applySubscription = async (
102
+ ctx: ApiContext, subscription: Stripe.Subscription, opts: ApplyOptions,
103
+ ): Promise<CommitResult> => {
104
+ const metadata = subscription.metadata ?? {}
105
+ const customerId = idOf(subscription.customer)
106
+ const previous = await subscriptions(ctx).byExternalId(subscription.id, STRIPE_PAYGATE_ALIAS)
107
+
108
+ let entityId: string | undefined = metadata.entityId ?? previous?.entityId
109
+ if (entityId == null && customerId != null) {
110
+ entityId = (await paygateCustomers(ctx).loadByPgId(customerId, STRIPE_PAYGATE_ALIAS))?.entityId ?? undefined
111
+ }
112
+ if (entityId == null) {
113
+ console.warn(`[payment] subscription "${subscription.id}" belongs to no known entity; ignored`)
114
+ return { record: previous, change: null, updated: false }
115
+ }
116
+ if (opts.eventId != null && previous?.lastEventId === opts.eventId) {
117
+ return { record: previous, change: null, updated: false }
118
+ }
119
+
120
+ const now = new Date()
121
+ const syncedAt = opts.eventCreated != null ? new Date(opts.eventCreated * 1000) : secondsFloor(now)
122
+ if (opts.eventCreated != null && previous?.syncedAt != null
123
+ && syncedAt.getTime() < new Date(previous.syncedAt).getTime()) {
124
+ return { record: previous, change: null, updated: false }
125
+ }
126
+
127
+ const item = subscription.items?.data?.[0]
128
+ // A price recreated by `sync.ts` (an opposite `tax_behavior`) loses its lookup key; its sku
129
+ // metadata survives, so a subscription switched to it in the portal still resolves its plan.
130
+ const planSku = item?.price?.lookup_key ?? item?.price?.metadata?.sku ?? metadata.planSku ?? previous?.planSku
131
+ if (planSku == null) {
132
+ console.warn(`[payment] subscription "${subscription.id}" names no plan; ignored`)
133
+ return { record: previous, change: null, updated: false }
134
+ }
135
+ const plan = await findPlan(ctx, planSku)
136
+ const productSku = plan?.productSku ?? previous?.productSku ?? metadata.productSku ?? planSku
137
+ const paused = opts.forced == null && isPaused(subscription)
138
+ const period = periodOf(subscription)
139
+ const terminal = opts.forced != null && TERMINAL_STATUSES.includes(opts.forced)
140
+
141
+ const next = compact({
142
+ ...(previous ?? {}),
143
+ entityId,
144
+ planSku,
145
+ productSku,
146
+ service: metadata.service ?? previous?.service ?? productSku,
147
+ paygate: STRIPE_PAYGATE_ALIAS,
148
+ externalId: subscription.id,
149
+ itemId: item?.id,
150
+ priceId: item?.price?.id,
151
+ status: opts.forced ?? mapStatus(subscription),
152
+ externalStatus: subscription.status,
153
+ rank: plan != null ? planRank(plan) : previous?.rank ?? 0,
154
+ periodStart: period.start,
155
+ periodEnd: period.end,
156
+ cancelAtPeriodEnd: subscription.cancel_at_period_end === true,
157
+ canceledAt: dateOf(subscription.canceled_at),
158
+ endedAt: dateOf(subscription.ended_at) ?? (terminal ? previous?.endedAt ?? now : undefined),
159
+ pausedAt: paused ? previous?.pausedAt ?? now : undefined,
160
+ trialEnd: dateOf(subscription.trial_end),
161
+ latestInvoiceId: opts.invoiceId ?? idOf(subscription.latest_invoice) ?? previous?.latestInvoiceId,
162
+ customerId,
163
+ currency: subscription.currency?.toLowerCase() ?? previous?.currency,
164
+ createdAt: previous?.createdAt ?? dateOf(subscription.created) ?? now,
165
+ updatedAt: now,
166
+ syncedAt,
167
+ }) as PaymentSubscriptionRecord
168
+ // `compact` drops every field the payload cleared — the stored record is replaced as a whole.
169
+
170
+ return await commitSubscription(ctx, previous, next, {
171
+ eventId: opts.eventId, renewal: opts.renewal, invoiceId: opts.invoiceId, trialEnding: opts.trialEnding,
172
+ // The first invoice is a purchase: its window exists before an observer grants the bundle.
173
+ beforePropagate: async (record, change) => {
174
+ if (change === 'created') {
175
+ await captureSubscriptionPurchase(ctx, opts.stripe ?? null, subscription, record)
176
+ }
177
+ },
178
+ })
179
+ }
180
+
181
+ const retrieveSubscription = async (stripe: Stripe, id: string): Promise<Stripe.Subscription | null> => {
182
+ try {
183
+ return await stripe.subscriptions.retrieve(id)
184
+ } catch (error) {
185
+ if (isMissingObject(error)) {
186
+ return null
187
+ }
188
+ throw error
189
+ }
190
+ }
191
+
192
+ /** A row whose paygate subscription no longer exists is canceled (observers hear `canceled`). */
193
+ const cancelMissing = async (ctx: ApiContext, externalId: string): Promise<CommitResult> => {
194
+ const previous = await subscriptions(ctx).byExternalId(externalId, STRIPE_PAYGATE_ALIAS)
195
+ if (previous == null || TERMINAL_STATUSES.includes(previous.status)) {
196
+ return { record: previous, change: null, updated: false }
197
+ }
198
+ const now = new Date()
199
+
200
+ return await commitSubscription(ctx, previous, {
201
+ ...previous, status: SubscriptionStatus.Canceled, endedAt: previous.endedAt ?? now, updatedAt: now,
202
+ syncedAt: secondsFloor(now),
203
+ })
204
+ }
205
+
206
+ /**
207
+ * Re-read paygate subscriptions — one by id, or every row of one entity — and apply each as a
208
+ * webhook would. A subscription the paygate no longer has is canceled.
209
+ *
210
+ * @returns how many rows changed
211
+ */
212
+ export const resyncStripeSubscription = async (
213
+ ctx: ApiContext, stripe: Stripe, ref: SubscriptionRef,
214
+ ): Promise<number> => {
215
+ const ids = new Set<string>()
216
+ if (ref.subscriptionId != null) {
217
+ ids.add(ref.subscriptionId)
218
+ }
219
+ if (ref.entityId != null) {
220
+ const { items } = await subscriptions(ctx).list({ entityId: ref.entityId, paygate: STRIPE_PAYGATE_ALIAS }, { size: 0 })
221
+ items.forEach(row => ids.add(row.externalId))
222
+ }
223
+
224
+ let updated = 0
225
+ for (const id of ids) {
226
+ const subscription = await retrieveSubscription(stripe, id)
227
+ const customerId = subscription != null ? idOf(subscription.customer) : undefined
228
+ const result = await withCustomerLock(customerId, async () => subscription != null
229
+ ? await applySubscription(ctx, subscription, { source: 'resync', stripe })
230
+ : await cancelMissing(ctx, id))
231
+ if (result.updated) {
232
+ updated++
233
+ }
234
+ }
235
+
236
+ return updated
237
+ }
238
+
239
+ const RESYNC_PAGE = 200
240
+
241
+ /** Resync every paygate subscription that is not already terminal. Errors per row are logged. */
242
+ export const resyncStripeSubscriptions = async (
243
+ ctx: ApiContext, stripe: Stripe,
244
+ ): Promise<{ scanned: number, updated: number }> => {
245
+ const ids: string[] = []
246
+ for (let page = 0; ; page++) {
247
+ const { items } = await subscriptions(ctx).list(
248
+ { paygate: STRIPE_PAYGATE_ALIAS, status: { $nin: [...TERMINAL_STATUSES] } },
249
+ { size: RESYNC_PAGE, page, sort: ['createdAt'] },
250
+ )
251
+ ids.push(...items.map(row => row.externalId))
252
+ if (items.length < RESYNC_PAGE) {
253
+ break
254
+ }
255
+ }
256
+
257
+ let updated = 0
258
+ for (const subscriptionId of ids) {
259
+ try {
260
+ updated += await resyncStripeSubscription(ctx, stripe, { subscriptionId })
261
+ } catch (error) {
262
+ console.error(`[payment] resync of "${subscriptionId}" failed`, error)
263
+ }
264
+ }
265
+
266
+ return { scanned: ids.length, updated }
267
+ }
268
+
38
269
  const integerMetadata = (metadata: Stripe.Metadata, key: string): number => {
39
270
  const raw = metadata[key]
40
271
  const value = raw == null || raw.trim() === '' ? Number.NaN : Number(raw)
@@ -42,42 +273,111 @@ const integerMetadata = (metadata: Stripe.Metadata, key: string): number => {
42
273
  return value
43
274
  }
44
275
 
45
- export const createEventHandler = (context: ApiContext, stripe: Stripe) => {
276
+ const DISPUTE_PHASES: Record<string, DisputePhase> = {
277
+ 'charge.dispute.created': 'opened',
278
+ 'charge.dispute.funds_withdrawn': 'funds-withdrawn',
279
+ 'charge.dispute.funds_reinstated': 'funds-reinstated',
280
+ 'charge.dispute.closed': 'closed',
281
+ }
282
+
283
+ type EventHandler = (event: Stripe.Event) => Promise<void>
284
+
285
+ /** The registered checkout plugins — none in a context without a gateway service. */
286
+ const checkoutPluginsOf = (ctx: ApiContext): readonly CheckoutPlugin[] =>
287
+ (ctx as unknown as { hasService?: (alias: string) => boolean }).hasService?.(GATEWAY_SERVICE) === true
288
+ ? gateway(ctx).checkoutPlugins?.() ?? [] : []
289
+
290
+ /**
291
+ * The Stripe event dispatch table. `process` runs the handler of an event type and ignores every
292
+ * other type; a throw escapes so Stripe delivers the event again.
293
+ */
294
+ export const createEventHandler = (ctx: ApiContext, stripe: Stripe) => {
295
+ const upsertCustomer = async (customer: Stripe.Customer): Promise<void> => {
296
+ await withCustomerLock(customer.id, async () => {
297
+ const resource = paygateCustomers(ctx)
298
+ const existing = await resource.loadByPgId(customer.id, STRIPE_PAYGATE_ALIAS)
299
+ const data = compact({
300
+ email: customer.email ?? undefined, name: customer.name ?? undefined,
301
+ taxId: customer.tax_ids?.data?.[0]?.value ?? undefined,
302
+ country: customer.address?.country?.toUpperCase() ?? undefined,
303
+ currency: customer.currency?.toLowerCase() ?? undefined,
304
+ entityId: customer.metadata?.entityId ?? existing?.entityId ?? undefined,
305
+ profileId: customer.metadata?.profileId ?? existing?.profileId ?? undefined,
306
+ })
307
+ if (existing == null) {
308
+ try {
309
+ await resource.create({ paygate: STRIPE_PAYGATE_ALIAS, externalId: customer.id, ...data })
310
+ } catch (error) {
311
+ if (!isDuplicateKey(error)) throw error
312
+ }
313
+ } else {
314
+ const { deletedAt: _deleted, ...kept } = existing
315
+ await resource.update({ ...kept, ...data })
316
+ }
317
+ })
318
+ }
319
+
320
+ const markCustomerDeleted = async (customer: Stripe.DeletedCustomer | Stripe.Customer): Promise<void> => {
321
+ const resource = paygateCustomers(ctx)
322
+ const existing = await resource.loadByPgId(customer.id, STRIPE_PAYGATE_ALIAS)
323
+ if (existing != null && existing.deletedAt == null) {
324
+ await resource.update({ ...existing, deletedAt: new Date() })
325
+ }
326
+ }
327
+
328
+ const settle = async (session: Stripe.Checkout.Session, outcome: CheckoutOutcome, entityId?: string): Promise<void> => {
329
+ const metadata = session.metadata ?? {}
330
+ const owner = entityId ?? metadata.entityId
331
+ if (owner == null) return
332
+ const amount = Number(metadata.amountMinor)
333
+ await settleCheckout(ctx, checkoutPluginsOf(ctx), compact({
334
+ entityId: owner, productSku: metadata.productSku, planSku: metadata.planSku, sessionId: session.id, outcome,
335
+ amountMinor: Number.isSafeInteger(amount) && amount > 0 ? amount : undefined, at: new Date(),
336
+ }))
337
+ }
338
+
46
339
  const fulfillPayment = async (session: Stripe.Checkout.Session): Promise<void> => {
340
+ if (session.mode === 'subscription') {
341
+ await completeSubscriptionCheckout(session)
342
+ return
343
+ }
47
344
  if (session.mode !== 'payment' || session.payment_status !== 'paid') return
48
345
  const metadata = session.metadata ?? {}
49
346
  if (metadata.entityId == null || metadata.service == null || metadata.productSku == null) {
50
347
  throw new PaygateError('metadata:owner')
51
348
  }
52
- const customerId = customerIdOf(session.customer)
53
- await lockOn(customerId)
54
- try {
55
- let stored = await subscriptions(context).byExternalId(session.id, STRIPE_PAYGATE_ALIAS)
349
+ await withCustomerLock(idOf(session.customer), async () => {
350
+ const ledger = fulfillments(ctx)
351
+ let stored = await ledger.byExternalId(session.id, STRIPE_PAYGATE_ALIAS)
56
352
  if (stored?.fulfilledAt != null) return
57
353
 
58
354
  const amountMode = metadata.pricingMode === CheckoutPricingMode.Amount
355
+ const base = {
356
+ entityId: metadata.entityId, productSku: metadata.productSku, planSku: metadata.planSku,
357
+ service: metadata.service, paygate: STRIPE_PAYGATE_ALIAS, externalId: session.id,
358
+ }
59
359
  let completion: TopUpCompletion
60
- let record: PaymentSubscriptionRecord
360
+ let record: Partial<PaymentFulfillmentRecord>
61
361
  if (amountMode) {
62
362
  const amountMinor = integerMetadata(metadata, 'amountMinor')
63
363
  const chargedMinor = integerMetadata(metadata, 'chargeAmountMinor')
64
364
  const currency = metadata.currency?.toLowerCase()
365
+ const sourceChargedMinor = metadata.sourceChargeAmountMinor == null
366
+ ? chargedMinor : integerMetadata(metadata, 'sourceChargeAmountMinor')
367
+ const amountCurrency = (metadata.amountCurrency ?? currency)?.toLowerCase()
65
368
  if (currency == null || session.currency?.toLowerCase() !== currency
66
- || session.amount_subtotal !== chargedMinor || amountMinor <= 0 || chargedMinor < amountMinor) {
369
+ || amountCurrency == null || session.amount_subtotal !== chargedMinor
370
+ || amountMinor <= 0 || chargedMinor <= 0 || sourceChargedMinor < amountMinor
371
+ || (amountCurrency === currency && sourceChargedMinor !== chargedMinor)) {
67
372
  throw new PaygateError('amount-mismatch')
68
373
  }
69
374
  record = {
70
- sku: metadata.planSku ?? metadata.productSku, productSku: metadata.productSku,
71
- entityId: metadata.entityId, service: metadata.service, paygate: STRIPE_PAYGATE_ALIAS,
72
- externalId: session.id, status: SubscriptionStatus.Consumable, kind: 'consumable',
73
- pricingMode: CheckoutPricingMode.Amount, amountMinor, chargeAmountMinor: chargedMinor,
74
- currency, createdAt: new Date(),
375
+ mode: CheckoutPricingMode.Amount, amountMinor, sourceChargeAmountMinor: sourceChargedMinor,
376
+ amountCurrency, chargeAmountMinor: chargedMinor, currency,
75
377
  }
76
378
  completion = {
77
- mode: 'amount', entityId: metadata.entityId, amountMinor,
78
- chargeAmountMinor: chargedMinor, currency, productSku: metadata.productSku,
79
- planSku: metadata.planSku, service: metadata.service,
80
- paygate: STRIPE_PAYGATE_ALIAS, externalId: session.id,
379
+ ...base, mode: 'amount', amountMinor, sourceChargeAmountMinor: sourceChargedMinor,
380
+ amountCurrency, chargeAmountMinor: chargedMinor, currency,
81
381
  }
82
382
  } else {
83
383
  if (metadata.currency != null && session.currency?.toLowerCase() !== metadata.currency.toLowerCase()) {
@@ -86,96 +386,352 @@ export const createEventHandler = (context: ApiContext, stripe: Stripe) => {
86
386
  const lineItems = await stripe.checkout.sessions.listLineItems(session.id, { limit: 100 })
87
387
  const units = lineItems.data.reduce((sum, line) => sum + (line.quantity ?? 0), 0)
88
388
  if (units <= 0) throw new PaygateError('units')
89
- record = {
90
- sku: metadata.planSku ?? metadata.productSku, productSku: metadata.productSku,
91
- entityId: metadata.entityId, service: metadata.service, paygate: STRIPE_PAYGATE_ALIAS,
92
- externalId: session.id, status: SubscriptionStatus.Consumable, kind: 'consumable', units,
93
- pricingMode: CheckoutPricingMode.Quantity,
94
- capabilities: [{ scope: PlanDuration.Consumable, permissions: { units } }],
95
- createdAt: new Date(),
389
+ record = { mode: CheckoutPricingMode.Quantity, units, ...(session.currency != null ? { currency: session.currency } : {}) }
390
+ completion = { ...base, mode: 'quantity', units }
391
+ }
392
+
393
+ // The purchase — the withdrawal window and the contract — exists BEFORE the credits do; the
394
+ // billing country is locked with it.
395
+ const captured = await capturePaymentPurchase(ctx, stripe, session, compact({
396
+ netAmountMinor: completion.mode === 'amount' ? completion.amountMinor : undefined,
397
+ amountCurrency: completion.mode === 'amount' ? completion.amountCurrency : undefined,
398
+ units: completion.mode === 'quantity' ? completion.units : undefined,
399
+ }))
400
+ const evidence = sessionEvidenceOf(session)
401
+ const identifiers = compact({
402
+ paymentIntentId: idOf(session.payment_intent), invoiceId: idOf(session.invoice),
403
+ })
404
+ const evidenceFields = compact({
405
+ country: evidence.country, email: evidence.email, profileId: metadata.profileId,
406
+ amountTotalMinor: session.amount_total ?? undefined, amountTaxMinor: session.total_details?.amount_tax ?? undefined,
407
+ termsAccepted: evidence.termsAccepted, purchaseId: captured?.purchase.purchaseId,
408
+ })
409
+ if (stored == null) {
410
+ try {
411
+ stored = await ledger.create(compact({ ...base, ...record, ...identifiers, ...evidenceFields, createdAt: new Date() }) as PaymentFulfillmentRecord)
412
+ } catch (error) {
413
+ if (!isDuplicateKey(error)) throw error
414
+ stored = await ledger.byExternalId(session.id, STRIPE_PAYGATE_ALIAS)
415
+ if (stored == null || stored.fulfilledAt != null) return
96
416
  }
97
- completion = {
98
- mode: 'quantity', entityId: metadata.entityId, units, productSku: metadata.productSku,
99
- planSku: metadata.planSku, service: metadata.service,
100
- paygate: STRIPE_PAYGATE_ALIAS, externalId: session.id,
417
+ } else if ((stored.paymentIntentId == null && identifiers.paymentIntentId != null)
418
+ || (stored.purchaseId == null && evidenceFields.purchaseId != null)) {
419
+ stored = await ledger.update({ ...stored, ...identifiers, ...evidenceFields })
420
+ }
421
+ await observer(ctx).propagateTopUp(completion, ctx)
422
+ await ledger.update({ ...stored, fulfilledAt: new Date() })
423
+ })
424
+ await settle(session, 'paid')
425
+ }
426
+
427
+ /**
428
+ * A completed SUBSCRIPTION checkout: the subscription is applied if no webhook applied it yet
429
+ * (its first commit writes the purchase before the `created` observers), then the purchase is
430
+ * refined with the buyer's own country and totals, the billing country is locked, the evidence is
431
+ * stored on the subscription row and the confirmation mailed.
432
+ */
433
+ const completeSubscriptionCheckout = async (session: Stripe.Checkout.Session): Promise<void> => {
434
+ if (session.payment_status !== 'paid' && session.payment_status !== 'no_payment_required') return
435
+ const subscriptionId = idOf(session.subscription)
436
+ if (subscriptionId == null) return
437
+ await withCustomerLock(idOf(session.customer), async () => {
438
+ let row = await subscriptions(ctx).byExternalId(subscriptionId, STRIPE_PAYGATE_ALIAS)
439
+ let subscription: Stripe.Subscription | null = null
440
+ if (row?.propagated == null) {
441
+ subscription = await retrieveSubscription(stripe, subscriptionId)
442
+ if (subscription != null) {
443
+ row = (await applySubscription(ctx, subscription, { source: 'webhook', stripe })).record ?? row
444
+ }
445
+ }
446
+ if (row == null) {
447
+ console.warn(`[payment] completed checkout "${session.id}" names an unknown subscription "${subscriptionId}"`)
448
+ return
449
+ }
450
+ let purchase = await purchases(ctx).byPurchaseId(purchaseIdOf(subscriptionId))
451
+ if (purchase == null && ENTITLING_STATUSES.includes(row.status)) {
452
+ subscription = subscription ?? await retrieveSubscription(stripe, subscriptionId)
453
+ purchase = subscription != null
454
+ ? (await captureSubscriptionPurchase(ctx, stripe, subscription, row))?.purchase ?? null : null
455
+ }
456
+ if (purchase != null) {
457
+ purchase = await completeSubscriptionPurchase(ctx, stripe, session, purchase)
458
+ }
459
+ const evidence = sessionEvidenceOf(session)
460
+ const metadata = session.metadata ?? {}
461
+ const current = await subscriptions(ctx).byExternalId(subscriptionId, STRIPE_PAYGATE_ALIAS) ?? row
462
+ await subscriptions(ctx).update(compact({
463
+ ...current,
464
+ checkoutSessionId: session.id,
465
+ purchaseId: purchase?.purchaseId ?? current.purchaseId,
466
+ firstInvoiceId: current.firstInvoiceId ?? idOf(session.invoice),
467
+ currency: current.currency ?? (evidence.currency !== '' ? evidence.currency : undefined),
468
+ country: evidence.country ?? current.country,
469
+ email: evidence.email ?? current.email,
470
+ amountTotalMinor: evidence.totalMinor,
471
+ amountTaxMinor: evidence.taxMinor,
472
+ termsAccepted: evidence.termsAccepted ?? current.termsAccepted,
473
+ startRequestId: metadata.startRequestId ?? purchase?.startRequestId ?? current.startRequestId,
474
+ }))
475
+ })
476
+ await settle(session, 'paid')
477
+ }
478
+
479
+ const checkoutFailed = async (session: Stripe.Checkout.Session): Promise<void> => {
480
+ const metadata = session.metadata ?? {}
481
+ const customerId = idOf(session.customer)
482
+ const entityId = metadata.entityId
483
+ ?? (customerId != null ? (await paygateCustomers(ctx).loadByPgId(customerId, STRIPE_PAYGATE_ALIAS))?.entityId : undefined)
484
+ if (entityId == null) {
485
+ console.warn(`[payment] failed checkout "${session.id}" belongs to no known entity`)
486
+ return
487
+ }
488
+ if (session.mode === 'payment' && metadata.productSku != null && metadata.service != null) {
489
+ const ledger = fulfillments(ctx)
490
+ const stored = await ledger.byExternalId(session.id, STRIPE_PAYGATE_ALIAS)
491
+ if (stored == null) {
492
+ try {
493
+ await ledger.create(compact({
494
+ entityId, productSku: metadata.productSku, planSku: metadata.planSku, service: metadata.service,
495
+ paygate: STRIPE_PAYGATE_ALIAS, externalId: session.id,
496
+ mode: metadata.pricingMode === CheckoutPricingMode.Amount ? CheckoutPricingMode.Amount : CheckoutPricingMode.Quantity,
497
+ paymentIntentId: idOf(session.payment_intent), createdAt: new Date(), failedAt: new Date(),
498
+ }) as PaymentFulfillmentRecord)
499
+ } catch (error) {
500
+ if (!isDuplicateKey(error)) throw error
101
501
  }
502
+ } else if (stored.failedAt == null && stored.fulfilledAt == null) {
503
+ await ledger.update({ ...stored, failedAt: new Date() })
504
+ }
505
+ }
506
+ await observer(ctx).propagatePaymentFailed(compact({
507
+ entityId, kind: 'checkout' as const, externalId: session.id,
508
+ subscriptionId: idOf(session.subscription), eventKey: `payment-failed:${session.id}:0`,
509
+ }), ctx)
510
+ await settle(session, 'failed', entityId)
511
+ }
512
+
513
+ const purgeExpiredCheckout = async (session: Stripe.Checkout.Session): Promise<void> => {
514
+ await fulfillments(ctx).purge({ paygate: STRIPE_PAYGATE_ALIAS, externalId: session.id, fulfilledAt: null })
515
+ await settle(session, 'expired')
516
+ }
517
+
518
+ const applyFromEvent = (opts: Partial<ApplyOptions> = {}): EventHandler => async event => {
519
+ const subscription = event.data.object as Stripe.Subscription
520
+ await withCustomerLock(idOf(subscription.customer), async () => {
521
+ await applySubscription(ctx, subscription, {
522
+ source: 'webhook', eventId: event.id, eventCreated: event.created, stripe, ...opts,
523
+ })
524
+ })
525
+ }
526
+
527
+ const reapplyInvoiceSubscription = async (
528
+ event: Stripe.Event, invoice: Stripe.Invoice, opts: Partial<ApplyOptions> = {},
529
+ ): Promise<CommitResult | null> => {
530
+ const subscriptionId = subscriptionIdOfInvoice(invoice)
531
+ if (subscriptionId == null) return null
532
+ const subscription = await retrieveSubscription(stripe, subscriptionId)
533
+ if (subscription == null) {
534
+ return await withCustomerLock(idOf(invoice.customer), async () => await cancelMissing(ctx, subscriptionId))
535
+ }
536
+
537
+ return await withCustomerLock(idOf(subscription.customer), async () =>
538
+ await applySubscription(ctx, subscription, { source: 'webhook', eventId: event.id, stripe, ...opts }))
539
+ }
540
+
541
+ const invoicePaid: EventHandler = async event => {
542
+ const invoice = event.data.object as Stripe.Invoice
543
+ if (invoice.billing_reason === 'subscription_cycle') {
544
+ await reapplyInvoiceSubscription(event, invoice, { renewal: true, invoiceId: invoice.id })
545
+ return
546
+ }
547
+ await refreshLatestInvoice(invoice, invoice.id)
548
+ }
549
+
550
+ const refreshLatestInvoice = async (invoice: Stripe.Invoice, latestInvoiceId?: string): Promise<void> => {
551
+ const subscriptionId = subscriptionIdOfInvoice(invoice)
552
+ if (subscriptionId == null) return
553
+ await withCustomerLock(idOf(invoice.customer), async () => {
554
+ const row = await subscriptions(ctx).byExternalId(subscriptionId, STRIPE_PAYGATE_ALIAS)
555
+ if (row == null) return
556
+ let latest = latestInvoiceId
557
+ if (latest == null) {
558
+ const subscription = await retrieveSubscription(stripe, subscriptionId)
559
+ latest = idOf(subscription?.latest_invoice)
102
560
  }
103
- if (stored == null) stored = await subscriptions(context).create(record)
104
- await observer(context).propagateTopUp(completion, context)
105
- await subscriptions(context).save({ ...stored, fulfilledAt: new Date() })
106
- } finally {
107
- unlockOn(customerId)
561
+ if (latest != null && row.latestInvoiceId !== latest) {
562
+ await subscriptions(ctx).update({ ...row, latestInvoiceId: latest, updatedAt: new Date() })
563
+ }
564
+ })
565
+ }
566
+
567
+ const invoiceFailed: EventHandler = async event => {
568
+ const invoice = event.data.object as Stripe.Invoice
569
+ const result = await reapplyInvoiceSubscription(event, invoice)
570
+ const customerId = idOf(invoice.customer)
571
+ const entityId = result?.record?.entityId
572
+ ?? (customerId != null ? (await paygateCustomers(ctx).loadByPgId(customerId, STRIPE_PAYGATE_ALIAS))?.entityId : undefined)
573
+ if (entityId == null) {
574
+ console.warn(`[payment] failed invoice "${invoice.id}" belongs to no known entity`)
575
+ return
576
+ }
577
+ const attempt = invoice.attempt_count ?? 0
578
+ await observer(ctx).propagatePaymentFailed(compact({
579
+ entityId, kind: 'invoice' as const, externalId: invoice.id, subscriptionId: subscriptionIdOfInvoice(invoice),
580
+ invoiceId: invoice.id, attempt, actionRequired: event.type === 'invoice.payment_action_required',
581
+ nextAttemptAt: dateOf(invoice.next_payment_attempt), eventKey: `payment-failed:${invoice.id}:${attempt}`,
582
+ }), ctx)
583
+ }
584
+
585
+ const targetFields = (target: PaymentTarget) => target.kind === 'fulfillment'
586
+ ? compact({
587
+ entityId: target.record.entityId, target: target.kind, productSku: target.record.productSku,
588
+ planSku: target.record.planSku ?? undefined, externalId: target.record.externalId,
589
+ netAmountMinor: target.record.amountMinor ?? undefined,
590
+ chargeAmountMinor: target.record.chargeAmountMinor ?? undefined,
591
+ })
592
+ : compact({
593
+ entityId: target.record.entityId, target: target.kind, productSku: target.record.productSku,
594
+ planSku: target.record.planSku, externalId: target.record.externalId,
595
+ })
596
+
597
+ /** A refund of a purchase's payment moves its refunded total; a whole refund closes its window. */
598
+ const refundPurchase = async (target: PaymentTarget, refundedTotal: number, paid: number | undefined): Promise<void> => {
599
+ const purchaseId = purchaseIdOf(target.record.externalId)
600
+ const purchase = await purchases(ctx).byPurchaseId(purchaseId)
601
+ if (purchase == null || (target.kind === 'subscription' && purchase.invoiceId != null && purchase.invoiceId !== target.invoiceId)) {
602
+ return
603
+ }
604
+ await patchPurchase(ctx, purchaseId, compact({
605
+ refundedMinor: Math.max(purchase.refundedMinor ?? 0, refundedTotal),
606
+ refundedAt: purchase.refundedAt ?? (paid != null && refundedTotal >= paid ? new Date() : undefined),
607
+ }))
608
+ }
609
+
610
+ const processRefund = async (refund: Stripe.Refund, charge?: Stripe.Charge | null): Promise<void> => {
611
+ if (refund.status !== 'succeeded') return
612
+ const target = await resolvePaymentTarget(ctx, stripe, {
613
+ paymentIntentId: idOf(refund.payment_intent), chargeId: idOf(refund.charge), charge,
614
+ })
615
+ if (target == null) {
616
+ console.warn(`[payment] refund "${refund.id}" matches no fulfillment or subscription`)
617
+ return
618
+ }
619
+ const chargeId = idOf(refund.charge)
620
+ const refunded = target.charge ?? (chargeId != null ? await retrieveCharge(stripe, chargeId) : null)
621
+ const refundedTotal = Math.max(refunded?.amount_refunded ?? 0, refund.amount)
622
+ const paid = refunded?.amount
623
+ if (target.kind === 'fulfillment') {
624
+ await fulfillments(ctx).update(compact({
625
+ ...target.record, refundedMinor: Math.max(target.record.refundedMinor ?? 0, refundedTotal),
626
+ refundedAt: new Date(), chargeId: target.record.chargeId ?? refunded?.id,
627
+ }))
628
+ }
629
+ await refundPurchase(target, refundedTotal, paid)
630
+ const metadata = { ...(refund.metadata ?? {}) } as Record<string, string>
631
+ await observer(ctx).propagateRefund(compact({
632
+ ...targetFields(target),
633
+ refundId: refund.id,
634
+ paymentIntentId: target.paymentIntentId ?? idOf(refund.payment_intent),
635
+ invoiceId: target.kind === 'subscription' ? target.invoiceId : target.record.invoiceId ?? undefined,
636
+ amountMinor: refund.amount,
637
+ refundedTotalMinor: refundedTotal,
638
+ paidMinor: paid,
639
+ currency: refund.currency,
640
+ partial: paid != null ? refundedTotal < paid : false,
641
+ eventKey: `refund:${refund.id}`,
642
+ metadata: Object.keys(metadata).length > 0 ? metadata : undefined,
643
+ // Set by a withdrawal's own refund: its observer takes back the unused units, `onRefund` must not.
644
+ withdrawalId: metadata.withdrawalId != null && metadata.withdrawalId !== '' ? metadata.withdrawalId : undefined,
645
+ }) as Parameters<ReturnType<typeof observer>['propagateRefund']>[0], ctx)
646
+ }
647
+
648
+ const chargeRefunded: EventHandler = async event => {
649
+ const charge = event.data.object as Stripe.Charge
650
+ const refunds = await stripe.refunds.list({ charge: charge.id, limit: 100 })
651
+ for (const refund of refunds.data) {
652
+ await processRefund(refund, charge)
108
653
  }
109
654
  }
110
655
 
111
- const handler = {
112
- process: async (event: Stripe.Event) => {
113
- if (event.type in handler) await (handler as never as Record<string, (event: Stripe.Event) => Promise<void>>)[event.type](event)
656
+ const refundChanged: EventHandler = async event => {
657
+ await processRefund(event.data.object as Stripe.Refund)
658
+ }
659
+
660
+ const disputeChanged: EventHandler = async event => {
661
+ const dispute = event.data.object as Stripe.Dispute
662
+ const phase = DISPUTE_PHASES[event.type]
663
+ const target = await resolvePaymentTarget(ctx, stripe, {
664
+ paymentIntentId: idOf(dispute.payment_intent), chargeId: idOf(dispute.charge),
665
+ })
666
+ if (target == null) {
667
+ console.warn(`[payment] dispute "${dispute.id}" matches no fulfillment or subscription`)
668
+ return
669
+ }
670
+ const marks = { disputedAt: target.record.disputedAt ?? new Date(), disputeStatus: dispute.status }
671
+ if (target.kind === 'fulfillment') {
672
+ await fulfillments(ctx).update({ ...target.record, ...marks })
673
+ } else {
674
+ await subscriptions(ctx).update({ ...(target.record as PaymentSubscriptionRecord), ...marks })
675
+ }
676
+ await observer(ctx).propagateDispute(compact({
677
+ ...targetFields(target), disputeId: dispute.id, phase, status: dispute.status,
678
+ amountMinor: dispute.amount, currency: dispute.currency, eventKey: `dispute:${dispute.id}:${phase}`,
679
+ }) as Parameters<ReturnType<typeof observer>['propagateDispute']>[0], ctx)
680
+ }
681
+
682
+ const handlers: Record<string, EventHandler> = {
683
+ 'customer.created': async event => { await upsertCustomer(event.data.object as Stripe.Customer) },
684
+ 'customer.updated': async event => { await upsertCustomer(event.data.object as Stripe.Customer) },
685
+ 'customer.deleted': async event => { await markCustomerDeleted(event.data.object as Stripe.Customer) },
686
+
687
+ 'checkout.session.completed': async event => { await fulfillPayment(event.data.object as Stripe.Checkout.Session) },
688
+ 'checkout.session.async_payment_succeeded': async event => {
689
+ await fulfillPayment(event.data.object as Stripe.Checkout.Session)
114
690
  },
115
- 'customer.created': async (event: Stripe.CustomerCreatedEvent) => handler._upsertCustomer(event.data.object),
116
- 'customer.updated': async (event: Stripe.CustomerUpdatedEvent) => handler._upsertCustomer(event.data.object),
117
- 'checkout.session.completed': async (event: Stripe.CheckoutSessionCompletedEvent) => fulfillPayment(event.data.object),
118
- 'checkout.session.async_payment_succeeded': async (event: Stripe.CheckoutSessionAsyncPaymentSucceededEvent) =>
119
- fulfillPayment(event.data.object),
120
- 'customer.subscription.created': async (event: Stripe.CustomerSubscriptionCreatedEvent) => handler._syncSubscription(event.data.object),
121
- 'customer.subscription.updated': async (event: Stripe.CustomerSubscriptionUpdatedEvent) => handler._syncSubscription(event.data.object),
122
- 'customer.subscription.deleted': async (event: Stripe.CustomerSubscriptionDeletedEvent) =>
123
- handler._syncSubscription(event.data.object, SubscriptionStatus.Canceled),
124
-
125
- _upsertCustomer: async (customer: Stripe.Customer) => {
126
- const resource = paygateCustomers(context)
127
- await lockOn(customer.id)
128
- try {
129
- const existing = await resource.loadByPgId(customer.id, STRIPE_PAYGATE_ALIAS)
130
- const data = {
131
- email: customer.email ?? undefined, name: customer.name ?? undefined,
132
- taxId: customer.tax_ids?.data?.[0]?.value ?? undefined,
133
- entityId: customer.metadata?.entityId ?? existing?.entityId,
134
- profileId: customer.metadata?.profileId ?? existing?.profileId,
135
- }
136
- if (existing == null) await resource.create({ paygate: STRIPE_PAYGATE_ALIAS, externalId: customer.id, ...data })
137
- else { Object.assign(existing, data); await resource.save(existing) }
138
- } finally { unlockOn(customer.id) }
691
+ 'checkout.session.async_payment_failed': async event => {
692
+ await checkoutFailed(event.data.object as Stripe.Checkout.Session)
693
+ },
694
+ 'checkout.session.expired': async event => {
695
+ await purgeExpiredCheckout(event.data.object as Stripe.Checkout.Session)
139
696
  },
140
697
 
141
- _syncSubscription: async (pgSubscription: Stripe.Subscription, forced?: SubscriptionStatus) => {
142
- const metadata = pgSubscription.metadata ?? {}
143
- const customerId = customerIdOf(pgSubscription.customer)
144
- await lockOn(customerId)
145
- try {
146
- const status = forced ?? mapStatus(pgSubscription.status)
147
- for (const item of pgSubscription.items.data) {
148
- const planSku = item.price.lookup_key ?? metadata.planSku
149
- const entityId = metadata.entityId
150
- if (planSku == null || entityId == null) continue
151
- let plan: PaymentPlan | null = null
152
- try { plan = await payment(context).plan(planSku) as PaymentPlan } catch { /* archived plan */ }
153
- const productSku = plan?.productSku ?? metadata.productSku ?? planSku
154
- const service = metadata.service ?? productSku
155
- let record = await subscriptions(context).byExternalId(item.id, STRIPE_PAYGATE_ALIAS)
156
- const isNew = record?.initialPropagatedAt == null
157
- const values: Partial<PaymentSubscriptionRecord> = {
158
- sku: planSku, productSku, entityId, service, paygate: STRIPE_PAYGATE_ALIAS,
159
- externalId: item.id, status, kind: 'subscription',
160
- ...(plan?.capabilities != null && { capabilities: plan.capabilities }),
161
- ...(plan?.limits != null && { limits: plan.limits }),
162
- endsAt: (pgSubscription as never as { current_period_end?: number }).current_period_end != null
163
- ? new Date((pgSubscription as never as { current_period_end: number }).current_period_end * 1000) : undefined,
164
- canceledAt: status === SubscriptionStatus.Canceled ? new Date() : undefined,
165
- }
166
- if (record == null) record = await subscriptions(context).create({ createdAt: new Date(), ...values } as PaymentSubscriptionRecord)
167
- else { Object.assign(record, values); await subscriptions(context).save(record) }
168
- await observer(context).propagateSubscription({
169
- entityId, status, productSku, planSku, service, paygate: STRIPE_PAYGATE_ALIAS,
170
- externalId: item.id, isNew, capabilities: plan?.capabilities, limits: plan?.limits,
171
- }, context)
172
- if (isNew) {
173
- record.initialPropagatedAt = new Date()
174
- await subscriptions(context).save(record)
175
- }
176
- }
177
- } finally { unlockOn(customerId) }
698
+ 'customer.subscription.created': applyFromEvent(),
699
+ 'customer.subscription.updated': applyFromEvent(),
700
+ 'customer.subscription.pending_update_applied': applyFromEvent(),
701
+ 'customer.subscription.pending_update_expired': applyFromEvent(),
702
+ 'customer.subscription.paused': applyFromEvent(),
703
+ 'customer.subscription.resumed': applyFromEvent(),
704
+ 'customer.subscription.deleted': applyFromEvent({ forced: SubscriptionStatus.Canceled }),
705
+ 'customer.subscription.trial_will_end': applyFromEvent({ trialEnding: true }),
706
+
707
+ 'invoice.paid': invoicePaid,
708
+ 'invoice.payment_failed': invoiceFailed,
709
+ 'invoice.payment_action_required': invoiceFailed,
710
+ // Enabled on the endpoint for an application's own use; it has no invoice id and nothing is due.
711
+ 'invoice.upcoming': async () => undefined,
712
+ 'invoice.marked_uncollectible': async event => {
713
+ await reapplyInvoiceSubscription(event, event.data.object as Stripe.Invoice)
714
+ },
715
+ 'invoice.voided': async event => { await refreshLatestInvoice(event.data.object as Stripe.Invoice) },
716
+
717
+ 'charge.refunded': chargeRefunded,
718
+ 'refund.created': refundChanged,
719
+ 'refund.updated': refundChanged,
720
+ 'refund.failed': async () => undefined,
721
+
722
+ 'charge.dispute.created': disputeChanged,
723
+ 'charge.dispute.funds_withdrawn': disputeChanged,
724
+ 'charge.dispute.funds_reinstated': disputeChanged,
725
+ 'charge.dispute.closed': disputeChanged,
726
+ }
727
+
728
+ return {
729
+ handlers,
730
+ process: async (event: Stripe.Event): Promise<void> => {
731
+ const handler = handlers[event.type]
732
+ if (handler != null) {
733
+ await handler(event)
734
+ }
178
735
  },
179
736
  }
180
- return handler
181
737
  }