@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.
- package/README.md +109 -25
- package/agent-meta/manifest.json +2 -2
- package/agent-meta/skills/server-payment/SKILL.md +475 -52
- package/build/actions/index.d.ts +1 -0
- package/build/actions/index.d.ts.map +1 -1
- package/build/actions/index.js +1 -0
- package/build/actions/index.js.map +1 -1
- package/build/actions/resync-subscriptions.d.ts +3 -0
- package/build/actions/resync-subscriptions.d.ts.map +1 -0
- package/build/actions/resync-subscriptions.js +7 -0
- package/build/actions/resync-subscriptions.js.map +1 -0
- package/build/actions/resync.d.ts +1 -0
- package/build/actions/resync.d.ts.map +1 -1
- package/build/actions/resync.js +7 -3
- package/build/actions/resync.js.map +1 -1
- package/build/actions/webhook.d.ts.map +1 -1
- package/build/actions/webhook.js +6 -3
- package/build/actions/webhook.js.map +1 -1
- package/build/config.d.ts +60 -5
- package/build/config.d.ts.map +1 -1
- package/build/config.js +237 -5
- package/build/config.js.map +1 -1
- package/build/consts.d.ts +75 -3
- package/build/consts.d.ts.map +1 -1
- package/build/consts.js +94 -5
- package/build/consts.js.map +1 -1
- package/build/consumer/capture.d.ts +74 -0
- package/build/consumer/capture.d.ts.map +1 -0
- package/build/consumer/capture.js +291 -0
- package/build/consumer/capture.js.map +1 -0
- package/build/consumer/format.d.ts +27 -0
- package/build/consumer/format.d.ts.map +1 -0
- package/build/consumer/format.js +81 -0
- package/build/consumer/format.js.map +1 -0
- package/build/consumer/handlers.d.ts +28 -0
- package/build/consumer/handlers.d.ts.map +1 -0
- package/build/consumer/handlers.js +173 -0
- package/build/consumer/handlers.js.map +1 -0
- package/build/consumer/index.d.ts +7 -0
- package/build/consumer/index.d.ts.map +1 -0
- package/build/consumer/index.js +6 -0
- package/build/consumer/index.js.map +1 -0
- package/build/consumer/mail.d.ts +27 -0
- package/build/consumer/mail.d.ts.map +1 -0
- package/build/consumer/mail.js +314 -0
- package/build/consumer/mail.js.map +1 -0
- package/build/consumer/origin.d.ts +14 -0
- package/build/consumer/origin.d.ts.map +1 -0
- package/build/consumer/origin.js +47 -0
- package/build/consumer/origin.js.map +1 -0
- package/build/consumer/reconcile.d.ts +12 -0
- package/build/consumer/reconcile.d.ts.map +1 -0
- package/build/consumer/reconcile.js +317 -0
- package/build/consumer/reconcile.js.map +1 -0
- package/build/consumer/records.d.ts +78 -0
- package/build/consumer/records.d.ts.map +1 -0
- package/build/consumer/records.js +296 -0
- package/build/consumer/records.js.map +1 -0
- package/build/consumer/service.d.ts +51 -0
- package/build/consumer/service.d.ts.map +1 -0
- package/build/consumer/service.js +760 -0
- package/build/consumer/service.js.map +1 -0
- package/build/consumer/withdrawal.d.ts +57 -0
- package/build/consumer/withdrawal.d.ts.map +1 -0
- package/build/consumer/withdrawal.js +247 -0
- package/build/consumer/withdrawal.js.map +1 -0
- package/build/entitlement.d.ts +10 -0
- package/build/entitlement.d.ts.map +1 -0
- package/build/entitlement.js +69 -0
- package/build/entitlement.js.map +1 -0
- package/build/entrypoints.d.ts +1 -1
- package/build/entrypoints.d.ts.map +1 -1
- package/build/entrypoints.js +2 -1
- package/build/entrypoints.js.map +1 -1
- package/build/gate.d.ts +32 -4
- package/build/gate.d.ts.map +1 -1
- package/build/gate.js +59 -19
- package/build/gate.js.map +1 -1
- package/build/index.d.ts +18 -3
- package/build/index.d.ts.map +1 -1
- package/build/index.js +15 -3
- package/build/index.js.map +1 -1
- package/build/limit.d.ts +13 -0
- package/build/limit.d.ts.map +1 -0
- package/build/limit.js +47 -0
- package/build/limit.js.map +1 -0
- package/build/model.d.ts +10 -1
- package/build/model.d.ts.map +1 -1
- package/build/model.js +215 -17
- package/build/model.js.map +1 -1
- package/build/observer.d.ts +9 -0
- package/build/observer.d.ts.map +1 -1
- package/build/observer.js +38 -12
- package/build/observer.js.map +1 -1
- package/build/plan.d.ts +22 -0
- package/build/plan.d.ts.map +1 -0
- package/build/plan.js +72 -0
- package/build/plan.js.map +1 -0
- package/build/plugins/checkout-plugins.d.ts +49 -0
- package/build/plugins/checkout-plugins.d.ts.map +1 -0
- package/build/plugins/checkout-plugins.js +124 -0
- package/build/plugins/checkout-plugins.js.map +1 -0
- package/build/plugins/estimate.d.ts +43 -0
- package/build/plugins/estimate.d.ts.map +1 -0
- package/build/plugins/estimate.js +268 -0
- package/build/plugins/estimate.js.map +1 -0
- package/build/plugins/events.d.ts +45 -10
- package/build/plugins/events.d.ts.map +1 -1
- package/build/plugins/events.js +605 -136
- package/build/plugins/events.js.map +1 -1
- package/build/plugins/fx.d.ts +31 -0
- package/build/plugins/fx.d.ts.map +1 -0
- package/build/plugins/fx.js +81 -0
- package/build/plugins/fx.js.map +1 -0
- package/build/plugins/portal.d.ts +37 -0
- package/build/plugins/portal.d.ts.map +1 -0
- package/build/plugins/portal.js +265 -0
- package/build/plugins/portal.js.map +1 -0
- package/build/plugins/refunds.d.ts +33 -0
- package/build/plugins/refunds.d.ts.map +1 -0
- package/build/plugins/refunds.js +80 -0
- package/build/plugins/refunds.js.map +1 -0
- package/build/plugins/stripe.d.ts +36 -4
- package/build/plugins/stripe.d.ts.map +1 -1
- package/build/plugins/stripe.js +492 -97
- package/build/plugins/stripe.js.map +1 -1
- package/build/plugins/webhook-manager.d.ts +46 -0
- package/build/plugins/webhook-manager.d.ts.map +1 -0
- package/build/plugins/webhook-manager.js +231 -0
- package/build/plugins/webhook-manager.js.map +1 -0
- package/build/reconcile.d.ts +15 -0
- package/build/reconcile.d.ts.map +1 -0
- package/build/reconcile.js +88 -0
- package/build/reconcile.js.map +1 -0
- package/build/resource.d.ts +12 -1
- package/build/resource.d.ts.map +1 -1
- package/build/resource.js +97 -6
- package/build/resource.js.map +1 -1
- package/build/service.d.ts +28 -3
- package/build/service.d.ts.map +1 -1
- package/build/service.js +146 -19
- package/build/service.js.map +1 -1
- package/build/subscription.d.ts +48 -0
- package/build/subscription.d.ts.map +1 -0
- package/build/subscription.js +176 -0
- package/build/subscription.js.map +1 -0
- package/build/sync.d.ts +28 -1
- package/build/sync.d.ts.map +1 -1
- package/build/sync.js +208 -31
- package/build/sync.js.map +1 -1
- package/build/types.d.ts +1115 -36
- package/build/types.d.ts.map +1 -1
- package/build/usage.d.ts +63 -0
- package/build/usage.d.ts.map +1 -0
- package/build/usage.js +363 -0
- package/build/usage.js.map +1 -0
- package/build/utils.d.ts +52 -1
- package/build/utils.d.ts.map +1 -1
- package/build/utils.js +69 -7
- package/build/utils.js.map +1 -1
- package/package.json +17 -13
- package/src/actions/index.ts +1 -0
- package/src/actions/resync-subscriptions.ts +10 -0
- package/src/actions/resync.ts +6 -3
- package/src/actions/webhook.ts +5 -3
- package/src/config.ts +264 -8
- package/src/consts.ts +114 -6
- package/src/consumer/capture.ts +362 -0
- package/src/consumer/format.ts +90 -0
- package/src/consumer/handlers.ts +211 -0
- package/src/consumer/index.ts +6 -0
- package/src/consumer/mail.ts +368 -0
- package/src/consumer/origin.ts +63 -0
- package/src/consumer/reconcile.ts +329 -0
- package/src/consumer/records.ts +374 -0
- package/src/consumer/service.ts +868 -0
- package/src/consumer/withdrawal.ts +302 -0
- package/src/entitlement.ts +84 -0
- package/src/entrypoints.ts +2 -1
- package/src/gate.ts +88 -21
- package/src/index.ts +24 -3
- package/src/limit.ts +57 -0
- package/src/model.ts +237 -18
- package/src/observer.ts +44 -11
- package/src/plan.ts +89 -0
- package/src/plugins/checkout-plugins.ts +155 -0
- package/src/plugins/estimate.ts +339 -0
- package/src/plugins/events.ts +677 -121
- package/src/plugins/fx.ts +122 -0
- package/src/plugins/portal.ts +306 -0
- package/src/plugins/refunds.ts +108 -0
- package/src/plugins/stripe.ts +581 -96
- package/src/plugins/webhook-manager.ts +270 -0
- package/src/reconcile.ts +103 -0
- package/src/resource.ts +152 -7
- package/src/service.ts +174 -18
- package/src/subscription.ts +231 -0
- package/src/sync.ts +249 -29
- package/src/types.ts +1227 -32
- package/src/usage.ts +453 -0
- package/src/utils.ts +127 -10
- package/tests/checkout-consumer.spec.ts +348 -0
- package/tests/checkout-plugins.spec.ts +164 -0
- package/tests/checkout.spec.ts +184 -83
- package/tests/consumer-events.spec.ts +218 -0
- package/tests/consumer-fixtures.ts +132 -0
- package/tests/consumer-ops.spec.ts +351 -0
- package/tests/consumer-rights.integration.spec.ts +150 -0
- package/tests/consumer-rights.spec.ts +501 -0
- package/tests/context.ts +109 -0
- package/tests/entitlement.spec.ts +103 -0
- package/tests/estimate.spec.ts +240 -0
- package/tests/events.spec.ts +356 -0
- package/tests/fake-stripe.ts +972 -0
- package/tests/gate.spec.ts +62 -72
- package/tests/limit-gate.spec.ts +68 -0
- package/tests/portal.spec.ts +200 -0
- package/tests/protocol.spec.ts +23 -6
- package/tests/sync.spec.ts +114 -0
- package/tests/usage.integration.spec.ts +101 -0
- package/tests/usage.spec.ts +171 -0
- package/tests/webhook-manager.spec.ts +152 -0
package/build/plugins/events.js
CHANGED
|
@@ -1,34 +1,210 @@
|
|
|
1
1
|
import { Mutex } from 'async-mutex';
|
|
2
|
-
import { CheckoutPricingMode,
|
|
3
|
-
import { STRIPE_PAYGATE_ALIAS } from '../consts.js';
|
|
4
|
-
import {
|
|
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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
const
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
case '
|
|
25
|
-
|
|
26
|
-
case '
|
|
27
|
-
|
|
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
|
-
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
||
|
|
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
|
-
|
|
66
|
-
|
|
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',
|
|
73
|
-
chargeAmountMinor: chargedMinor, currency,
|
|
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
|
-
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
await
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
140
|
-
|
|
388
|
+
if (row == null) {
|
|
389
|
+
console.warn(`[payment] completed checkout "${session.id}" names an unknown subscription "${subscriptionId}"`);
|
|
390
|
+
return;
|
|
141
391
|
}
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
-
|
|
189
|
-
|
|
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
|