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