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