@owlmeans/server-payment 0.1.18-rc.20 → 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 +1 -1
- package/agent-meta/manifest.json +2 -2
- package/agent-meta/skills/server-payment/SKILL.md +402 -260
- package/build/config.d.ts +15 -1
- package/build/config.d.ts.map +1 -1
- package/build/config.js +99 -3
- package/build/config.js.map +1 -1
- package/build/consts.d.ts +27 -0
- package/build/consts.d.ts.map +1 -1
- package/build/consts.js +27 -0
- 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/index.d.ts +4 -2
- package/build/index.d.ts.map +1 -1
- package/build/index.js +4 -2
- package/build/index.js.map +1 -1
- package/build/model.d.ts +6 -1
- package/build/model.d.ts.map +1 -1
- package/build/model.js +134 -4
- package/build/model.js.map +1 -1
- package/build/observer.d.ts +4 -0
- package/build/observer.d.ts.map +1 -1
- package/build/observer.js +13 -0
- package/build/observer.js.map +1 -1
- 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.map +1 -1
- package/build/plugins/estimate.js +41 -8
- package/build/plugins/estimate.js.map +1 -1
- package/build/plugins/events.d.ts +2 -0
- package/build/plugins/events.d.ts.map +1 -1
- package/build/plugins/events.js +126 -9
- package/build/plugins/events.js.map +1 -1
- package/build/plugins/fx.d.ts +5 -0
- package/build/plugins/fx.d.ts.map +1 -1
- package/build/plugins/fx.js +25 -0
- package/build/plugins/fx.js.map +1 -1
- package/build/plugins/portal.d.ts.map +1 -1
- package/build/plugins/portal.js +11 -1
- package/build/plugins/portal.js.map +1 -1
- package/build/plugins/stripe.d.ts +21 -2
- package/build/plugins/stripe.d.ts.map +1 -1
- package/build/plugins/stripe.js +397 -61
- package/build/plugins/stripe.js.map +1 -1
- package/build/resource.d.ts +8 -1
- package/build/resource.d.ts.map +1 -1
- package/build/resource.js +59 -2
- package/build/resource.js.map +1 -1
- package/build/service.d.ts +2 -1
- package/build/service.d.ts.map +1 -1
- package/build/service.js +28 -5
- package/build/service.js.map +1 -1
- package/build/subscription.d.ts +6 -0
- package/build/subscription.d.ts.map +1 -1
- package/build/subscription.js +1 -0
- package/build/subscription.js.map +1 -1
- package/build/sync.d.ts +7 -0
- package/build/sync.d.ts.map +1 -1
- package/build/sync.js +108 -15
- package/build/sync.js.map +1 -1
- package/build/types.d.ts +707 -4
- package/build/types.d.ts.map +1 -1
- package/build/utils.d.ts +22 -1
- package/build/utils.d.ts.map +1 -1
- package/build/utils.js +27 -1
- package/build/utils.js.map +1 -1
- package/package.json +14 -13
- package/src/config.ts +111 -7
- package/src/consts.ts +34 -0
- 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/index.ts +6 -4
- package/src/model.ts +148 -6
- package/src/observer.ts +15 -2
- package/src/plugins/checkout-plugins.ts +155 -0
- package/src/plugins/estimate.ts +49 -9
- package/src/plugins/events.ts +135 -11
- package/src/plugins/fx.ts +29 -0
- package/src/plugins/portal.ts +11 -1
- package/src/plugins/stripe.ts +476 -60
- package/src/resource.ts +87 -4
- package/src/service.ts +28 -6
- package/src/subscription.ts +7 -0
- package/src/sync.ts +124 -17
- package/src/types.ts +756 -6
- package/src/utils.ts +56 -7
- package/tests/checkout-consumer.spec.ts +348 -0
- package/tests/checkout-plugins.spec.ts +164 -0
- 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 +20 -2
- package/tests/fake-stripe.ts +188 -18
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { CheckoutLimitExceeded, narrowAmountPolicy } from '@owlmeans/payment'
|
|
2
|
+
import type { AmountNarrowing, AmountPolicyView } from '@owlmeans/payment'
|
|
3
|
+
import type { Context as ApiContext } from '@owlmeans/server-api'
|
|
4
|
+
import { STRIPE_SESSION_TTL_MAX_SECONDS, STRIPE_SESSION_TTL_MIN_SECONDS } from '../consts.js'
|
|
5
|
+
import type {
|
|
6
|
+
CheckoutAttempt, CheckoutNarrowInput, CheckoutPlugin, CheckoutSettled,
|
|
7
|
+
} from '../types.js'
|
|
8
|
+
|
|
9
|
+
export interface CheckoutPluginRegistry {
|
|
10
|
+
use: (plugin: CheckoutPlugin) => void
|
|
11
|
+
list: () => readonly CheckoutPlugin[]
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The gateway's checkout plugins, the way `ExecutionService.use` seats its own: a plugin with an
|
|
16
|
+
* alias already registered replaces it in place (a layer wired twice must not answer twice), one
|
|
17
|
+
* without an alias is appended.
|
|
18
|
+
*/
|
|
19
|
+
export const makeCheckoutPluginRegistry = (): CheckoutPluginRegistry => {
|
|
20
|
+
const plugins: CheckoutPlugin[] = []
|
|
21
|
+
|
|
22
|
+
return {
|
|
23
|
+
use: plugin => {
|
|
24
|
+
const at = plugin.alias != null ? plugins.findIndex(entry => entry.alias === plugin.alias) : -1
|
|
25
|
+
if (at < 0) {
|
|
26
|
+
plugins.push(plugin)
|
|
27
|
+
} else {
|
|
28
|
+
plugins[at] = plugin
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
list: () => [...plugins],
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* An entity's amount policy as every plugin narrows it now — the ONE computation both
|
|
37
|
+
* `gateway.amountPolicy` and checkout enforcement use, so a control and a refusal cannot disagree.
|
|
38
|
+
* A plugin's error propagates: a narrowing that cannot be computed fails closed.
|
|
39
|
+
*/
|
|
40
|
+
export const narrowAmountFor = async (
|
|
41
|
+
ctx: ApiContext, plugins: readonly CheckoutPlugin[], input: CheckoutNarrowInput,
|
|
42
|
+
): Promise<AmountPolicyView> => {
|
|
43
|
+
const narrowings: AmountNarrowing[] = []
|
|
44
|
+
for (const plugin of plugins) {
|
|
45
|
+
if (plugin.narrow == null) {
|
|
46
|
+
continue
|
|
47
|
+
}
|
|
48
|
+
const narrowing = await plugin.narrow(ctx, input)
|
|
49
|
+
if (narrowing != null) {
|
|
50
|
+
narrowings.push(narrowing)
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return narrowAmountPolicy(input.base, narrowings, {
|
|
55
|
+
productSku: input.productSku, ...(input.planSku != null ? { planSku: input.planSku } : {}),
|
|
56
|
+
})
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Refuse an amount the narrowed view does not allow: any amount while `blocked`, else one above
|
|
61
|
+
* the narrowed maximum.
|
|
62
|
+
*
|
|
63
|
+
* @throws CheckoutLimitExceeded (409)
|
|
64
|
+
*/
|
|
65
|
+
export const assertAmountAllowed = (view: AmountPolicyView, amountMinor: number): void => {
|
|
66
|
+
const limit = view.limit
|
|
67
|
+
if (limit == null || (!limit.blocked && amountMinor <= limit.maximumMinor)) {
|
|
68
|
+
return
|
|
69
|
+
}
|
|
70
|
+
throw new CheckoutLimitExceeded({
|
|
71
|
+
reason: limit.reason ?? 'limit', maximumMinor: limit.maximumMinor, currency: limit.currency,
|
|
72
|
+
...(limit.resetsAt != null ? { resetsAt: limit.resetsAt } : {}),
|
|
73
|
+
})
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The session lifetime: the smallest `sessionTtlSeconds` any plugin declares, clamped to Stripe's
|
|
78
|
+
* 30 minutes – 24 hours; `undefined` when none declares one (Stripe's own default).
|
|
79
|
+
*/
|
|
80
|
+
export const sessionTtlOf = (plugins: readonly CheckoutPlugin[]): number | undefined => {
|
|
81
|
+
const declared = plugins
|
|
82
|
+
.map(plugin => plugin.sessionTtlSeconds)
|
|
83
|
+
.filter((ttl): ttl is number => typeof ttl === 'number' && Number.isFinite(ttl))
|
|
84
|
+
if (declared.length === 0) {
|
|
85
|
+
return undefined
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return Math.min(STRIPE_SESSION_TTL_MAX_SECONDS, Math.max(STRIPE_SESSION_TTL_MIN_SECONDS, Math.floor(Math.min(...declared))))
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** A plugin that admitted an attempt, with the reservation it holds. */
|
|
92
|
+
export interface Admitted {
|
|
93
|
+
plugin: CheckoutPlugin
|
|
94
|
+
reservationId?: string
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Tell plugins how a checkout ended. A plugin's error is logged, never raised: a hold carries its
|
|
99
|
+
* own TTL, and the webhook that reports the outcome must not be redelivered for it.
|
|
100
|
+
*/
|
|
101
|
+
export const settleCheckout = async (
|
|
102
|
+
ctx: ApiContext, plugins: readonly CheckoutPlugin[], settled: CheckoutSettled,
|
|
103
|
+
): Promise<void> => {
|
|
104
|
+
for (const plugin of plugins) {
|
|
105
|
+
if (plugin.settled == null) {
|
|
106
|
+
continue
|
|
107
|
+
}
|
|
108
|
+
try {
|
|
109
|
+
await plugin.settled(ctx, settled)
|
|
110
|
+
} catch (error) {
|
|
111
|
+
console.error(`[payment] checkout plugin "${plugin.alias ?? 'anonymous'}" failed to settle`, error)
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Release what admitted plugins hold for a checkout that never became usable. */
|
|
117
|
+
export const releaseAdmitted = async (
|
|
118
|
+
ctx: ApiContext, admitted: readonly Admitted[], attempt: CheckoutAttempt, sessionId?: string,
|
|
119
|
+
): Promise<void> => {
|
|
120
|
+
for (const { plugin, reservationId } of admitted) {
|
|
121
|
+
await settleCheckout(ctx, [plugin], {
|
|
122
|
+
entityId: attempt.entityId, productSku: attempt.productSku,
|
|
123
|
+
...(attempt.planSku != null ? { planSku: attempt.planSku } : {}),
|
|
124
|
+
...(sessionId != null ? { sessionId } : {}),
|
|
125
|
+
...(reservationId != null ? { reservationId } : {}),
|
|
126
|
+
...(attempt.amountMinor != null ? { amountMinor: attempt.amountMinor } : {}),
|
|
127
|
+
outcome: 'failed', at: new Date(),
|
|
128
|
+
})
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Ask every plugin to admit the attempt, in order. A veto releases what the earlier plugins
|
|
134
|
+
* admitted and propagates.
|
|
135
|
+
*/
|
|
136
|
+
export const admitCheckout = async (
|
|
137
|
+
ctx: ApiContext, plugins: readonly CheckoutPlugin[], attempt: CheckoutAttempt,
|
|
138
|
+
): Promise<Admitted[]> => {
|
|
139
|
+
const admitted: Admitted[] = []
|
|
140
|
+
for (const plugin of plugins) {
|
|
141
|
+
if (plugin.admit == null) {
|
|
142
|
+
admitted.push({ plugin })
|
|
143
|
+
continue
|
|
144
|
+
}
|
|
145
|
+
try {
|
|
146
|
+
const admission = await plugin.admit(ctx, attempt)
|
|
147
|
+
admitted.push({ plugin, ...(admission?.reservationId != null ? { reservationId: admission.reservationId } : {}) })
|
|
148
|
+
} catch (error) {
|
|
149
|
+
await releaseAdmitted(ctx, admitted.filter(entry => entry.plugin.admit != null), attempt)
|
|
150
|
+
throw error
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return admitted
|
|
155
|
+
}
|
package/src/plugins/estimate.ts
CHANGED
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
import type Stripe from 'stripe'
|
|
2
2
|
import {
|
|
3
|
-
chargeAmountMinor, CheckoutPricingMode, currencyOfCountry, ProductError, ratePpmOf,
|
|
4
|
-
TaxEstimateStatus, TaxType, UnknownProduct,
|
|
3
|
+
chargeAmountMinor, chargeCurrencyOf, CheckoutPricingMode, currencyOfCountry, ProductError, ratePpmOf,
|
|
4
|
+
regionOf, TaxBehavior, TaxEstimateStatus, TaxType, UnknownProduct,
|
|
5
5
|
} from '@owlmeans/payment'
|
|
6
6
|
import type { PriceEstimate, TaxEstimate, TaxRateEstimate } from '@owlmeans/payment'
|
|
7
7
|
import type { Context as ApiContext } from '@owlmeans/server-api'
|
|
8
8
|
import { STRIPE_FX_QUOTES_API_VERSION, STRIPE_PAYGATE_ALIAS } from '../consts.js'
|
|
9
9
|
import { findProduct } from '../plan.js'
|
|
10
10
|
import { isSoldThrough } from '../sync.js'
|
|
11
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
consumerRightsOf, fingerprints, isMissingObject, paygateCustomers, payment, stripePricingConfig,
|
|
13
|
+
} from '../utils.js'
|
|
12
14
|
import type { PaymentPlan, PaymentProduct, PriceEstimateParams } from '../types.js'
|
|
13
15
|
import { stripeFxRate } from './fx.js'
|
|
14
16
|
|
|
@@ -158,6 +160,30 @@ const referenceOf = (plan: PaymentPlan): { subtotalMinor: number; currency: stri
|
|
|
158
160
|
return { subtotalMinor: Math.round(plan.price * 100), currency: (plan.currency ?? 'usd').toLowerCase() }
|
|
159
161
|
}
|
|
160
162
|
|
|
163
|
+
/**
|
|
164
|
+
* The reference amount in the currency the buyer is charged in: a recurring or quantity plan's
|
|
165
|
+
* synced price in that currency (its default or an option — no paygate call), else the plan's own
|
|
166
|
+
* catalogue reference. An amount plan keeps its policy currency.
|
|
167
|
+
*/
|
|
168
|
+
const chargedReferenceOf = async (
|
|
169
|
+
ctx: ApiContext, product: PaymentProduct, plan: PaymentPlan, chargeCurrency: string | null,
|
|
170
|
+
): Promise<{ subtotalMinor: number; currency: string }> => {
|
|
171
|
+
const reference = referenceOf(plan)
|
|
172
|
+
if (chargeCurrency == null || plan.pricingMode === CheckoutPricingMode.Amount || chargeCurrency === reference.currency && plan.currencyPrices == null) {
|
|
173
|
+
return reference
|
|
174
|
+
}
|
|
175
|
+
const synced = (await fingerprints(ctx).bySku(product.sku))?.prices?.find(price => price.planSku === plan.sku)
|
|
176
|
+
if (synced == null) {
|
|
177
|
+
return reference
|
|
178
|
+
}
|
|
179
|
+
if (synced.currency === chargeCurrency) {
|
|
180
|
+
return { subtotalMinor: synced.unitAmount, currency: chargeCurrency }
|
|
181
|
+
}
|
|
182
|
+
const option = synced.options.find(entry => entry.currency === chargeCurrency)
|
|
183
|
+
|
|
184
|
+
return option != null ? { subtotalMinor: option.unitAmount, currency: chargeCurrency } : reference
|
|
185
|
+
}
|
|
186
|
+
|
|
161
187
|
/** A placeholder estimate — no known tax, shown by its `status`, never by its zeroed numbers. */
|
|
162
188
|
const unresolvedEstimate = (status: TaxEstimateStatus, subtotalMinor: number): TaxEstimate => ({
|
|
163
189
|
status, subtotalMinor, taxMinor: 0, totalMinor: subtotalMinor, scalable: false, rates: [],
|
|
@@ -214,10 +240,13 @@ export const estimateStripePrice = async (
|
|
|
214
240
|
|
|
215
241
|
const pricing = await payment(ctx).pricingPolicy()
|
|
216
242
|
const behavior = pricing.tax.behavior ?? TaxBehavior.Exclusive
|
|
217
|
-
const
|
|
243
|
+
const rights = await payment(ctx).consumerRightsPolicy()
|
|
244
|
+
// A locked billing country overrides whatever the request names: a picker shows it, locked.
|
|
245
|
+
const profile = rights != null ? await consumerRightsOf(ctx)?.profile(params.entityId) ?? null : null
|
|
246
|
+
const locked = profile?.locked === true && profile.country != null
|
|
218
247
|
|
|
219
|
-
let country = params.country?.toUpperCase()
|
|
220
|
-
let source: 'request' | 'customer' | undefined = country != null ? 'request' : undefined
|
|
248
|
+
let country = locked ? profile.country as string : params.country?.toUpperCase()
|
|
249
|
+
let source: 'request' | 'customer' | 'profile' | undefined = locked ? 'profile' : country != null ? 'request' : undefined
|
|
221
250
|
let matchingTaxIds: Array<{ type: string; value: string }> = []
|
|
222
251
|
let taxabilityOverride: 'customer_exempt' | 'reverse_charge' | undefined
|
|
223
252
|
|
|
@@ -236,8 +265,16 @@ export const estimateStripePrice = async (
|
|
|
236
265
|
}
|
|
237
266
|
}
|
|
238
267
|
|
|
268
|
+
const region = rights != null ? regionOf(country, rights) : null
|
|
269
|
+
const settlementCurrency = (await stripePricingConfig(ctx))?.settlementCurrency?.toLowerCase()
|
|
270
|
+
const { subtotalMinor, currency } = await chargedReferenceOf(ctx, product, plan, rights != null && rights.currencies != null
|
|
271
|
+
&& Object.keys(rights.currencies).length > 0
|
|
272
|
+
? (profile?.currency ?? chargeCurrencyOf(region, rights, settlementCurrency ?? (plan.currency ?? 'usd'))).toLowerCase()
|
|
273
|
+
: null)
|
|
274
|
+
const where = { ...(region != null ? { region } : {}), ...(locked ? { locked: true } : {}) }
|
|
275
|
+
|
|
239
276
|
if (country == null) {
|
|
240
|
-
return { currency, behavior, tax: unresolvedEstimate(TaxEstimateStatus.LocationRequired, subtotalMinor) }
|
|
277
|
+
return { currency, behavior, tax: unresolvedEstimate(TaxEstimateStatus.LocationRequired, subtotalMinor), ...where }
|
|
241
278
|
}
|
|
242
279
|
|
|
243
280
|
const cacheKey = JSON.stringify([
|
|
@@ -276,9 +313,12 @@ export const estimateStripePrice = async (
|
|
|
276
313
|
? taxEstimateOf(outcome.calculation, behavior, subtotalMinor)
|
|
277
314
|
: unresolvedEstimate(TaxEstimateStatus.AtCheckout, subtotalMinor)
|
|
278
315
|
|
|
279
|
-
const result: PriceEstimate = { country, source, currency, behavior, tax }
|
|
316
|
+
const result: PriceEstimate = { country, source, currency, behavior, tax, ...where }
|
|
317
|
+
// Adaptive Pricing — and so a local-currency line — applies only to a session charged in the
|
|
318
|
+
// settlement currency; a forced region currency (exact USD) is shown as it is.
|
|
319
|
+
const adaptive = rights?.currencies == null || currency === (settlementCurrency ?? currency)
|
|
280
320
|
|
|
281
|
-
if (pricing.currency.estimate && pricing.currency.adaptive === true) {
|
|
321
|
+
if (pricing.currency.estimate && pricing.currency.adaptive === true && adaptive) {
|
|
282
322
|
const localCurrency = currencyOfCountry(country)
|
|
283
323
|
if (localCurrency != null && localCurrency !== currency) {
|
|
284
324
|
const stripePricing = await stripePricingConfig(ctx)
|
package/src/plugins/events.ts
CHANGED
|
@@ -1,21 +1,27 @@
|
|
|
1
1
|
import type Stripe from 'stripe'
|
|
2
2
|
import { Mutex } from 'async-mutex'
|
|
3
3
|
import {
|
|
4
|
-
CheckoutPricingMode, PaygateError, SubscriptionStatus, TERMINAL_STATUSES,
|
|
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'
|
|
7
|
+
import { GATEWAY_SERVICE, STRIPE_PAYGATE_ALIAS } from '../consts.js'
|
|
8
8
|
import { findPlan, planRank } from '../plan.js'
|
|
9
9
|
import { commitSubscription } from '../subscription.js'
|
|
10
10
|
import type { CommitResult } from '../subscription.js'
|
|
11
11
|
import {
|
|
12
|
-
compact, dateOf, fulfillments, idOf, isDuplicateKey, isMissingObject, observer, paygateCustomers,
|
|
13
|
-
subscriptions,
|
|
12
|
+
compact, dateOf, fulfillments, gateway, idOf, isDuplicateKey, isMissingObject, observer, paygateCustomers,
|
|
13
|
+
purchases, subscriptions,
|
|
14
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'
|
|
15
20
|
import { resolvePaymentTarget, retrieveCharge } from './refunds.js'
|
|
16
21
|
import type { PaymentTarget } from './refunds.js'
|
|
17
22
|
import type {
|
|
18
|
-
|
|
23
|
+
CheckoutOutcome, CheckoutPlugin, DisputePhase, PaymentFulfillmentRecord, PaymentSubscriptionRecord,
|
|
24
|
+
SubscriptionRef, TopUpCompletion,
|
|
19
25
|
} from '../types.js'
|
|
20
26
|
|
|
21
27
|
const customerMutex: Record<string, Mutex> = {}
|
|
@@ -82,6 +88,8 @@ export interface ApplyOptions {
|
|
|
82
88
|
trialEnding?: boolean
|
|
83
89
|
/** Store this status whatever the payload says (a deleted subscription is canceled). */
|
|
84
90
|
forced?: SubscriptionStatus
|
|
91
|
+
/** The paygate client — lets the first commit read the first invoice for its purchase row. */
|
|
92
|
+
stripe?: Stripe
|
|
85
93
|
}
|
|
86
94
|
|
|
87
95
|
const secondsFloor = (at: Date): Date => new Date(Math.floor(at.getTime() / 1000) * 1000)
|
|
@@ -152,6 +160,7 @@ export const applySubscription = async (
|
|
|
152
160
|
trialEnd: dateOf(subscription.trial_end),
|
|
153
161
|
latestInvoiceId: opts.invoiceId ?? idOf(subscription.latest_invoice) ?? previous?.latestInvoiceId,
|
|
154
162
|
customerId,
|
|
163
|
+
currency: subscription.currency?.toLowerCase() ?? previous?.currency,
|
|
155
164
|
createdAt: previous?.createdAt ?? dateOf(subscription.created) ?? now,
|
|
156
165
|
updatedAt: now,
|
|
157
166
|
syncedAt,
|
|
@@ -160,6 +169,12 @@ export const applySubscription = async (
|
|
|
160
169
|
|
|
161
170
|
return await commitSubscription(ctx, previous, next, {
|
|
162
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
|
+
},
|
|
163
178
|
})
|
|
164
179
|
}
|
|
165
180
|
|
|
@@ -211,7 +226,7 @@ export const resyncStripeSubscription = async (
|
|
|
211
226
|
const subscription = await retrieveSubscription(stripe, id)
|
|
212
227
|
const customerId = subscription != null ? idOf(subscription.customer) : undefined
|
|
213
228
|
const result = await withCustomerLock(customerId, async () => subscription != null
|
|
214
|
-
? await applySubscription(ctx, subscription, { source: 'resync' })
|
|
229
|
+
? await applySubscription(ctx, subscription, { source: 'resync', stripe })
|
|
215
230
|
: await cancelMissing(ctx, id))
|
|
216
231
|
if (result.updated) {
|
|
217
232
|
updated++
|
|
@@ -267,6 +282,11 @@ const DISPUTE_PHASES: Record<string, DisputePhase> = {
|
|
|
267
282
|
|
|
268
283
|
type EventHandler = (event: Stripe.Event) => Promise<void>
|
|
269
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
|
+
|
|
270
290
|
/**
|
|
271
291
|
* The Stripe event dispatch table. `process` runs the handler of an event type and ignores every
|
|
272
292
|
* other type; a throw escapes so Stripe delivers the event again.
|
|
@@ -279,6 +299,8 @@ export const createEventHandler = (ctx: ApiContext, stripe: Stripe) => {
|
|
|
279
299
|
const data = compact({
|
|
280
300
|
email: customer.email ?? undefined, name: customer.name ?? undefined,
|
|
281
301
|
taxId: customer.tax_ids?.data?.[0]?.value ?? undefined,
|
|
302
|
+
country: customer.address?.country?.toUpperCase() ?? undefined,
|
|
303
|
+
currency: customer.currency?.toLowerCase() ?? undefined,
|
|
282
304
|
entityId: customer.metadata?.entityId ?? existing?.entityId ?? undefined,
|
|
283
305
|
profileId: customer.metadata?.profileId ?? existing?.profileId ?? undefined,
|
|
284
306
|
})
|
|
@@ -303,7 +325,22 @@ export const createEventHandler = (ctx: ApiContext, stripe: Stripe) => {
|
|
|
303
325
|
}
|
|
304
326
|
}
|
|
305
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
|
+
|
|
306
339
|
const fulfillPayment = async (session: Stripe.Checkout.Session): Promise<void> => {
|
|
340
|
+
if (session.mode === 'subscription') {
|
|
341
|
+
await completeSubscriptionCheckout(session)
|
|
342
|
+
return
|
|
343
|
+
}
|
|
307
344
|
if (session.mode !== 'payment' || session.payment_status !== 'paid') return
|
|
308
345
|
const metadata = session.metadata ?? {}
|
|
309
346
|
if (metadata.entityId == null || metadata.service == null || metadata.productSku == null) {
|
|
@@ -353,23 +390,90 @@ export const createEventHandler = (ctx: ApiContext, stripe: Stripe) => {
|
|
|
353
390
|
completion = { ...base, mode: 'quantity', units }
|
|
354
391
|
}
|
|
355
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)
|
|
356
401
|
const identifiers = compact({
|
|
357
402
|
paymentIntentId: idOf(session.payment_intent), invoiceId: idOf(session.invoice),
|
|
358
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
|
+
})
|
|
359
409
|
if (stored == null) {
|
|
360
410
|
try {
|
|
361
|
-
stored = await ledger.create(compact({ ...base, ...record, ...identifiers, createdAt: new Date() }) as PaymentFulfillmentRecord)
|
|
411
|
+
stored = await ledger.create(compact({ ...base, ...record, ...identifiers, ...evidenceFields, createdAt: new Date() }) as PaymentFulfillmentRecord)
|
|
362
412
|
} catch (error) {
|
|
363
413
|
if (!isDuplicateKey(error)) throw error
|
|
364
414
|
stored = await ledger.byExternalId(session.id, STRIPE_PAYGATE_ALIAS)
|
|
365
415
|
if (stored == null || stored.fulfilledAt != null) return
|
|
366
416
|
}
|
|
367
|
-
} else if (stored.paymentIntentId == null && identifiers.paymentIntentId != null)
|
|
368
|
-
stored
|
|
417
|
+
} else if ((stored.paymentIntentId == null && identifiers.paymentIntentId != null)
|
|
418
|
+
|| (stored.purchaseId == null && evidenceFields.purchaseId != null)) {
|
|
419
|
+
stored = await ledger.update({ ...stored, ...identifiers, ...evidenceFields })
|
|
369
420
|
}
|
|
370
421
|
await observer(ctx).propagateTopUp(completion, ctx)
|
|
371
422
|
await ledger.update({ ...stored, fulfilledAt: new Date() })
|
|
372
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')
|
|
373
477
|
}
|
|
374
478
|
|
|
375
479
|
const checkoutFailed = async (session: Stripe.Checkout.Session): Promise<void> => {
|
|
@@ -403,17 +507,19 @@ export const createEventHandler = (ctx: ApiContext, stripe: Stripe) => {
|
|
|
403
507
|
entityId, kind: 'checkout' as const, externalId: session.id,
|
|
404
508
|
subscriptionId: idOf(session.subscription), eventKey: `payment-failed:${session.id}:0`,
|
|
405
509
|
}), ctx)
|
|
510
|
+
await settle(session, 'failed', entityId)
|
|
406
511
|
}
|
|
407
512
|
|
|
408
513
|
const purgeExpiredCheckout = async (session: Stripe.Checkout.Session): Promise<void> => {
|
|
409
514
|
await fulfillments(ctx).purge({ paygate: STRIPE_PAYGATE_ALIAS, externalId: session.id, fulfilledAt: null })
|
|
515
|
+
await settle(session, 'expired')
|
|
410
516
|
}
|
|
411
517
|
|
|
412
518
|
const applyFromEvent = (opts: Partial<ApplyOptions> = {}): EventHandler => async event => {
|
|
413
519
|
const subscription = event.data.object as Stripe.Subscription
|
|
414
520
|
await withCustomerLock(idOf(subscription.customer), async () => {
|
|
415
521
|
await applySubscription(ctx, subscription, {
|
|
416
|
-
source: 'webhook', eventId: event.id, eventCreated: event.created, ...opts,
|
|
522
|
+
source: 'webhook', eventId: event.id, eventCreated: event.created, stripe, ...opts,
|
|
417
523
|
})
|
|
418
524
|
})
|
|
419
525
|
}
|
|
@@ -429,7 +535,7 @@ export const createEventHandler = (ctx: ApiContext, stripe: Stripe) => {
|
|
|
429
535
|
}
|
|
430
536
|
|
|
431
537
|
return await withCustomerLock(idOf(subscription.customer), async () =>
|
|
432
|
-
await applySubscription(ctx, subscription, { source: 'webhook', eventId: event.id, ...opts }))
|
|
538
|
+
await applySubscription(ctx, subscription, { source: 'webhook', eventId: event.id, stripe, ...opts }))
|
|
433
539
|
}
|
|
434
540
|
|
|
435
541
|
const invoicePaid: EventHandler = async event => {
|
|
@@ -488,6 +594,19 @@ export const createEventHandler = (ctx: ApiContext, stripe: Stripe) => {
|
|
|
488
594
|
planSku: target.record.planSku, externalId: target.record.externalId,
|
|
489
595
|
})
|
|
490
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
|
+
|
|
491
610
|
const processRefund = async (refund: Stripe.Refund, charge?: Stripe.Charge | null): Promise<void> => {
|
|
492
611
|
if (refund.status !== 'succeeded') return
|
|
493
612
|
const target = await resolvePaymentTarget(ctx, stripe, {
|
|
@@ -507,6 +626,8 @@ export const createEventHandler = (ctx: ApiContext, stripe: Stripe) => {
|
|
|
507
626
|
refundedAt: new Date(), chargeId: target.record.chargeId ?? refunded?.id,
|
|
508
627
|
}))
|
|
509
628
|
}
|
|
629
|
+
await refundPurchase(target, refundedTotal, paid)
|
|
630
|
+
const metadata = { ...(refund.metadata ?? {}) } as Record<string, string>
|
|
510
631
|
await observer(ctx).propagateRefund(compact({
|
|
511
632
|
...targetFields(target),
|
|
512
633
|
refundId: refund.id,
|
|
@@ -518,6 +639,9 @@ export const createEventHandler = (ctx: ApiContext, stripe: Stripe) => {
|
|
|
518
639
|
currency: refund.currency,
|
|
519
640
|
partial: paid != null ? refundedTotal < paid : false,
|
|
520
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,
|
|
521
645
|
}) as Parameters<ReturnType<typeof observer>['propagateRefund']>[0], ctx)
|
|
522
646
|
}
|
|
523
647
|
|
package/src/plugins/fx.ts
CHANGED
|
@@ -64,6 +64,35 @@ export interface SettlementAmount {
|
|
|
64
64
|
|
|
65
65
|
export type StripeFxRateCache = Map<string, Promise<StripeFxRate | null>>
|
|
66
66
|
|
|
67
|
+
/**
|
|
68
|
+
* A catalogue amount in the currency the buyer is charged in: unchanged — no FX call — when the
|
|
69
|
+
* currencies are equal; otherwise converted at Stripe's FX reference rate, rounded up.
|
|
70
|
+
*/
|
|
71
|
+
export const chargeAmount = async (
|
|
72
|
+
ctx: ApiContext, stripe: Stripe, sourceAmountMinor: number, sourceCurrency: string, chargeCurrency: string,
|
|
73
|
+
cache?: StripeFxRateCache,
|
|
74
|
+
): Promise<SettlementAmount> => {
|
|
75
|
+
const source = sourceCurrency.toLowerCase()
|
|
76
|
+
const currency = chargeCurrency.toLowerCase()
|
|
77
|
+
if (currency === source) {
|
|
78
|
+
return { amountMinor: sourceAmountMinor, currency, sourceAmountMinor, sourceCurrency: source, referenceRate: 1 }
|
|
79
|
+
}
|
|
80
|
+
const apiVersion = (await stripePricingConfig(ctx))?.fxApiVersion ?? STRIPE_FX_QUOTES_API_VERSION
|
|
81
|
+
const key = `${source}:${currency}:${apiVersion}`
|
|
82
|
+
let pending = cache?.get(key)
|
|
83
|
+
if (pending == null) {
|
|
84
|
+
pending = stripeFxRate(stripe, source, currency, apiVersion)
|
|
85
|
+
cache?.set(key, pending)
|
|
86
|
+
}
|
|
87
|
+
const quote = await pending
|
|
88
|
+
if (quote == null) throw new PaygateError(`fx-rate:${source}:${currency}`)
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
amountMinor: convertMinor(sourceAmountMinor, quote.referenceRate), currency,
|
|
92
|
+
sourceAmountMinor, sourceCurrency: source, referenceRate: quote.referenceRate,
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
67
96
|
/** Translate a catalogue amount into the configured Stripe settlement currency. */
|
|
68
97
|
export const settlementAmount = async (
|
|
69
98
|
ctx: ApiContext, stripe: Stripe, sourceAmountMinor: number, sourceCurrency: string,
|
package/src/plugins/portal.ts
CHANGED
|
@@ -37,6 +37,8 @@ const recurringCatalog = async (ctx: ApiContext): Promise<Array<{ product: Payme
|
|
|
37
37
|
hashable: recurring.map(plan => ({
|
|
38
38
|
sku: plan.sku, price: plan.price, currency: plan.currency ?? 'usd', interval: plan.recurring?.interval,
|
|
39
39
|
rank: planRank(plan), behavior,
|
|
40
|
+
// A changed currency option replaces the Price — the portal's price list must follow it.
|
|
41
|
+
...(plan.currencyPrices != null ? { currencyPrices: plan.currencyPrices } : {}),
|
|
40
42
|
})).sort((a, b) => a.sku.localeCompare(b.sku)),
|
|
41
43
|
})
|
|
42
44
|
}
|
|
@@ -114,9 +116,15 @@ export const ensurePortalConfiguration = async (
|
|
|
114
116
|
const sku = portalFingerprintSku(service)
|
|
115
117
|
const branding = await portalBrandingConfig(ctx)
|
|
116
118
|
const catalog = await recurringCatalog(ctx)
|
|
119
|
+
const rights = await payment(ctx).consumerRightsPolicy()
|
|
120
|
+
// A locked billing country is never edited in the portal: tax follows the saved address.
|
|
121
|
+
const countryLock = rights?.mechanisms.countryLock === true
|
|
122
|
+
const regionCurrencies = Object.values(rights?.currencies ?? {}).filter(code => code != null).sort()
|
|
117
123
|
const hash = createHash('sha256').update(JSON.stringify({
|
|
118
124
|
service,
|
|
119
125
|
deployment,
|
|
126
|
+
...(countryLock ? { countryLock } : {}),
|
|
127
|
+
...(regionCurrencies.length > 0 ? { regionCurrencies } : {}),
|
|
120
128
|
branding: branding != null ? {
|
|
121
129
|
headline: branding.headline ?? null, privacyPolicyUrl: branding.privacyPolicyUrl ?? null,
|
|
122
130
|
termsOfServiceUrl: branding.termsOfServiceUrl ?? null, returnUrl: branding.returnUrl ?? null,
|
|
@@ -141,7 +149,9 @@ export const ensurePortalConfiguration = async (
|
|
|
141
149
|
const metadata = { [STRIPE_OWNER_KEY]: STRIPE_OWNER_VALUE, service, [STRIPE_DEPLOYMENT_KEY]: deployment }
|
|
142
150
|
const params: ConfigurationParams = {
|
|
143
151
|
features: {
|
|
144
|
-
customer_update: {
|
|
152
|
+
customer_update: {
|
|
153
|
+
enabled: true, allowed_updates: countryLock ? ['email', 'tax_id'] : ['email', 'address', 'tax_id'],
|
|
154
|
+
},
|
|
145
155
|
invoice_history: { enabled: true },
|
|
146
156
|
payment_method_update: { enabled: true },
|
|
147
157
|
subscription_cancel: cancelable
|