@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
package/src/plugins/stripe.ts
CHANGED
|
@@ -1,17 +1,38 @@
|
|
|
1
1
|
import type Stripe from 'stripe'
|
|
2
2
|
import {
|
|
3
|
-
assertCheckoutAmount,
|
|
4
|
-
|
|
3
|
+
assertCheckoutAmount, billingLanguageOf, BillingCountryLocked, chargeAmountMinor, chargeCurrencyOf,
|
|
4
|
+
CheckoutPricingMode, CONSUMER_RIGHTS_COPY_VERSION, ConsumerRightsError, consumerText, inScope, linksOf,
|
|
5
|
+
PaygateError, ProductError, ProductType, regionOf, TaxBehavior, WebhookSetupError,
|
|
5
6
|
} from '@owlmeans/payment'
|
|
6
|
-
import type { PricingPolicy } from '@owlmeans/payment'
|
|
7
|
+
import type { BillingProfileView, ConsumerRegion, ConsumerRightsPolicy, PricingPolicy } from '@owlmeans/payment'
|
|
7
8
|
import type { Context as ApiContext } from '@owlmeans/server-api'
|
|
8
9
|
import { STRIPE_PAYGATE_ALIAS, STRIPE_SIGNATURE } from '../consts.js'
|
|
9
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
compact, consumerRightsOf, errorText, fingerprints, paygateCustomers, payment, stripePricingConfig,
|
|
12
|
+
} from '../utils.js'
|
|
10
13
|
import { isSoldThrough, planLookupKey } from '../sync.js'
|
|
14
|
+
import { countryName, formatMoney } from '../consumer/format.js'
|
|
15
|
+
import { hasPaid, recordEvent, wasUnlocked } from '../consumer/records.js'
|
|
16
|
+
import {
|
|
17
|
+
admitCheckout, assertAmountAllowed, narrowAmountFor, releaseAdmitted, sessionTtlOf,
|
|
18
|
+
} from './checkout-plugins.js'
|
|
19
|
+
import type { Admitted } from './checkout-plugins.js'
|
|
11
20
|
import { createEventHandler } from './events.js'
|
|
12
|
-
import { settlementAmount } from './fx.js'
|
|
21
|
+
import { chargeAmount, settlementAmount } from './fx.js'
|
|
13
22
|
import { stripeWebhookSecrets } from './webhook-manager.js'
|
|
14
|
-
import type {
|
|
23
|
+
import type {
|
|
24
|
+
CheckoutAttempt, CheckoutPlugin, CheckoutTextContext, CreateLinkParams, PaymentPlan, PaymentProduct,
|
|
25
|
+
} from '../types.js'
|
|
26
|
+
|
|
27
|
+
/** Stripe's limit on every `custom_text` message. */
|
|
28
|
+
const CUSTOM_TEXT_MAX = 1200
|
|
29
|
+
|
|
30
|
+
interface CheckoutOptionsFlags {
|
|
31
|
+
/** The billing country is locked and the saved customer address carries it: never overwrite it. */
|
|
32
|
+
locked?: boolean
|
|
33
|
+
/** Adaptive Pricing is allowed on this session (the charge currency is the settlement currency). */
|
|
34
|
+
adaptive?: boolean
|
|
35
|
+
}
|
|
15
36
|
|
|
16
37
|
/**
|
|
17
38
|
* A Checkout Session's tax and currency options, entirely driven by the declared `PricingPolicy` —
|
|
@@ -20,20 +41,24 @@ import type { CreateLinkParams, PaymentPlan, PaymentProduct } from '../types.js'
|
|
|
20
41
|
*
|
|
21
42
|
* `customer_update.address` lets automatic tax use the billing address Checkout just collected
|
|
22
43
|
* rather than only a previously saved one; `customer_update.name` lets tax-id collection save the
|
|
23
|
-
* business name it collects. Each is included only for the concern that needs it.
|
|
44
|
+
* business name it collects. Each is included only for the concern that needs it. Under a country
|
|
45
|
+
* lock the saved address is kept (`address: 'never'`, collection `'auto'`), so tax follows the
|
|
46
|
+
* locked country and Checkout cannot move it.
|
|
24
47
|
*/
|
|
25
|
-
const checkoutOptions = (
|
|
48
|
+
const checkoutOptions = (
|
|
49
|
+
policy: PricingPolicy, promotions: boolean, flags: CheckoutOptionsFlags = {},
|
|
50
|
+
): Partial<Stripe.Checkout.SessionCreateParams> => {
|
|
26
51
|
const customerUpdate: Stripe.Checkout.SessionCreateParams.CustomerUpdate = {}
|
|
27
|
-
if (policy.tax.automatic) customerUpdate.address = 'auto'
|
|
52
|
+
if (policy.tax.automatic) customerUpdate.address = flags.locked === true ? 'never' : 'auto'
|
|
28
53
|
if (policy.tax.collectTaxId) customerUpdate.name = 'auto'
|
|
29
54
|
|
|
30
55
|
return {
|
|
31
56
|
...(policy.tax.automatic
|
|
32
|
-
? { automatic_tax: { enabled: true }, billing_address_collection: 'required' as const }
|
|
57
|
+
? { automatic_tax: { enabled: true }, billing_address_collection: flags.locked === true ? 'auto' as const : 'required' as const }
|
|
33
58
|
: {}),
|
|
34
59
|
...(policy.tax.collectTaxId ? { tax_id_collection: { enabled: true } } : {}),
|
|
35
60
|
...(Object.keys(customerUpdate).length > 0 ? { customer_update: customerUpdate } : {}),
|
|
36
|
-
...(policy.currency.adaptive === true ? { adaptive_pricing: { enabled: true } } : {}),
|
|
61
|
+
...(policy.currency.adaptive === true && flags.adaptive !== false ? { adaptive_pricing: { enabled: true } } : {}),
|
|
37
62
|
allow_promotion_codes: promotions,
|
|
38
63
|
}
|
|
39
64
|
}
|
|
@@ -125,92 +150,483 @@ export const quantityCheckoutLineItem = (
|
|
|
125
150
|
quantity: policy.default,
|
|
126
151
|
})
|
|
127
152
|
|
|
153
|
+
/** The plan an amount/quantity checkout of a consumable product sells: the named one, else the first. */
|
|
154
|
+
export const consumablePlanOf = async (
|
|
155
|
+
ctx: ApiContext, productSku: string, planSku?: string,
|
|
156
|
+
): Promise<{ product: PaymentProduct, plan: PaymentPlan }> => {
|
|
157
|
+
const product = await payment(ctx).product(productSku) as PaymentProduct
|
|
158
|
+
const plans = (await payment(ctx).allPlans(product.sku) as PaymentPlan[])
|
|
159
|
+
.filter(plan => isSoldThrough(product, plan, STRIPE_PAYGATE_ALIAS))
|
|
160
|
+
if (planSku != null && !plans.some(plan => plan.sku === planSku)) {
|
|
161
|
+
throw new ProductError(`plan:${planSku}`)
|
|
162
|
+
}
|
|
163
|
+
const plan = plans.find(item => item.sku === planSku) ?? plans[0]
|
|
164
|
+
if (plan == null) throw new ProductError('plan')
|
|
165
|
+
|
|
166
|
+
return { product, plan }
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** What the consumer-rights policy makes of this checkout's buyer. */
|
|
170
|
+
interface BuyerContext {
|
|
171
|
+
policy: ConsumerRightsPolicy | null
|
|
172
|
+
profile: BillingProfileView | null
|
|
173
|
+
country?: string
|
|
174
|
+
region: ConsumerRegion | null
|
|
175
|
+
inScope: boolean
|
|
176
|
+
language: string
|
|
177
|
+
/** `null`: the policy names no region currencies — the legacy settlement behaviour. */
|
|
178
|
+
chargeCurrency: string | null
|
|
179
|
+
/** A locked profile whose country the saved customer address carries. */
|
|
180
|
+
addressLocked: boolean
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* The buyer as the consumer-rights policy sees it — a locked profile overrides the declared
|
|
185
|
+
* country (a different one is `BillingCountryLocked`), an organization that already paid before
|
|
186
|
+
* its country was locked is locked lazily from its paygate customer's address, and a locked
|
|
187
|
+
* customer address that no longer carries the locked country refuses (the operator relocks).
|
|
188
|
+
*/
|
|
189
|
+
const buyerOf = async (
|
|
190
|
+
ctx: ApiContext, stripe: Stripe, params: CreateLinkParams, catalogueCurrency: string,
|
|
191
|
+
): Promise<{ buyer: BuyerContext, customer: Stripe.Customer }> => {
|
|
192
|
+
const policy = await payment(ctx).consumerRightsPolicy()
|
|
193
|
+
const rights = policy != null ? consumerRightsOf(ctx) : null
|
|
194
|
+
let profile = rights != null ? await rights.profile(params.entityId) : null
|
|
195
|
+
const declared = params.country != null && params.country.trim() !== '' ? params.country.trim().toUpperCase() : undefined
|
|
196
|
+
if (profile?.locked === true && declared != null && declared !== profile.country) {
|
|
197
|
+
throw new BillingCountryLocked({ country: profile.country as string, requested: declared })
|
|
198
|
+
}
|
|
199
|
+
let customer = await ensureStripeCustomer(ctx, stripe, params)
|
|
200
|
+
const customerCountry = customer.address?.country?.toUpperCase() ?? undefined
|
|
201
|
+
if (rights != null && policy?.mechanisms.countryLock === true && profile == null && customerCountry != null
|
|
202
|
+
&& await hasPaid(ctx, params.entityId) && !await wasUnlocked(ctx, params.entityId)) {
|
|
203
|
+
// An organization that paid before countries were locked: its saved address is its country.
|
|
204
|
+
// One an operator unlocked is locked again by its next completed purchase instead.
|
|
205
|
+
profile = await rights.lock(params.entityId, customerCountry, 'customer', { customerId: customer.id })
|
|
206
|
+
}
|
|
207
|
+
if (policy != null && profile == null && declared != null && customerCountry == null) {
|
|
208
|
+
// Preselect the declared country on Checkout's address form; never over a saved address.
|
|
209
|
+
customer = await stripe.customers.update(customer.id, { address: { country: declared } })
|
|
210
|
+
}
|
|
211
|
+
if (profile?.locked === true && declared != null && declared !== profile.country) {
|
|
212
|
+
throw new BillingCountryLocked({ country: profile.country as string, requested: declared })
|
|
213
|
+
}
|
|
214
|
+
if (profile?.locked === true && customerCountry != null && customerCountry !== profile.country) {
|
|
215
|
+
throw new BillingCountryLocked({ country: profile.country as string, requested: customerCountry })
|
|
216
|
+
}
|
|
217
|
+
const country = profile?.country ?? declared
|
|
218
|
+
const region = policy != null ? regionOf(country, policy) : null
|
|
219
|
+
const settlement = (await stripePricingConfig(ctx))?.settlementCurrency?.toLowerCase()
|
|
220
|
+
const regional = policy?.currencies != null && Object.keys(policy.currencies).length > 0
|
|
221
|
+
|
|
222
|
+
return {
|
|
223
|
+
customer,
|
|
224
|
+
buyer: {
|
|
225
|
+
policy,
|
|
226
|
+
profile,
|
|
227
|
+
...(country != null ? { country } : {}),
|
|
228
|
+
region,
|
|
229
|
+
inScope: policy != null ? inScope(region, country, policy) : false,
|
|
230
|
+
language: params.consumerLanguage ?? profile?.language ?? billingLanguageOf(country, policy),
|
|
231
|
+
chargeCurrency: regional
|
|
232
|
+
? (profile?.currency ?? chargeCurrencyOf(region, policy, settlement ?? catalogueCurrency)).toLowerCase()
|
|
233
|
+
: null,
|
|
234
|
+
addressLocked: profile?.locked === true && customerCountry != null,
|
|
235
|
+
},
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* The consumer-rights metadata every session (and its subscription) carries. `termsCollected`
|
|
241
|
+
* says whether Checkout asked for the terms checkbox — `'false'` also after the fallback of a
|
|
242
|
+
* Dashboard without a terms URL.
|
|
243
|
+
*/
|
|
244
|
+
const consumerMetadata = (buyer: BuyerContext, params: CreateLinkParams, startRequestId?: string): Record<string, string> =>
|
|
245
|
+
buyer.policy == null ? {} : compact({
|
|
246
|
+
region: buyer.region ?? undefined,
|
|
247
|
+
country: buyer.country,
|
|
248
|
+
language: buyer.language,
|
|
249
|
+
termsVersion: buyer.policy.textVersion,
|
|
250
|
+
copyVersion: CONSUMER_RIGHTS_COPY_VERSION,
|
|
251
|
+
termsCollected: String(buyer.policy.mechanisms.checkoutTerms === true),
|
|
252
|
+
ipCountry: params.ipCountry?.toUpperCase(),
|
|
253
|
+
startRequestId,
|
|
254
|
+
}) as Record<string, string>
|
|
255
|
+
|
|
256
|
+
const limited = (message: string, field: string): string => {
|
|
257
|
+
if (message.length > CUSTOM_TEXT_MAX) {
|
|
258
|
+
throw new ConsumerRightsError(`copy:length:${field}`)
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
return message
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** The terms checkbox and its text, when the policy asks for them on every checkout. */
|
|
265
|
+
const termsOptions = (buyer: BuyerContext): Partial<Stripe.Checkout.SessionCreateParams> & { terms?: string } => {
|
|
266
|
+
const policy = buyer.policy
|
|
267
|
+
if (policy?.mechanisms.checkoutTerms !== true) {
|
|
268
|
+
return {}
|
|
269
|
+
}
|
|
270
|
+
const links = linksOf(policy, buyer.language)
|
|
271
|
+
const withInformation = buyer.inScope && links.withdrawalInformation != null
|
|
272
|
+
const message = limited(consumerText(buyer.language, `checkout.terms-acceptance.${withInformation ? 'in-scope' : 'other'}`, {
|
|
273
|
+
billingTerms: links.billingTerms, ...(withInformation ? { withdrawalInformation: links.withdrawalInformation as string } : {}),
|
|
274
|
+
}), 'terms')
|
|
275
|
+
|
|
276
|
+
return { consent_collection: { terms_of_service: 'required' }, terms: message }
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const textContextOf = (buyer: BuyerContext, currency: string, extra: Partial<CheckoutTextContext> = {}): CheckoutTextContext => compact({
|
|
280
|
+
language: buyer.language, currency, region: buyer.region, country: buyer.country, ...extra,
|
|
281
|
+
}) as CheckoutTextContext
|
|
282
|
+
|
|
283
|
+
const submitTextOf = (params: CreateLinkParams, context: CheckoutTextContext): string | undefined => {
|
|
284
|
+
if (params.submitText == null) {
|
|
285
|
+
return undefined
|
|
286
|
+
}
|
|
287
|
+
const message = typeof params.submitText === 'function' ? params.submitText(context) : params.submitText
|
|
288
|
+
|
|
289
|
+
return message.trim() === '' ? undefined : message
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** Whether the synced reusable price of a plan can be charged in `currency` (its default or an option). */
|
|
293
|
+
const priceCarries = async (
|
|
294
|
+
ctx: ApiContext, product: PaymentProduct, plan: PaymentPlan, price: Stripe.Price, currency: string,
|
|
295
|
+
): Promise<{ carries: boolean, unitAmount?: number }> => {
|
|
296
|
+
if (price.currency === currency) {
|
|
297
|
+
return { carries: true, ...(price.unit_amount != null ? { unitAmount: price.unit_amount } : {}) }
|
|
298
|
+
}
|
|
299
|
+
const listed = (price as unknown as { currency_options?: Record<string, { unit_amount?: number | null }> }).currency_options?.[currency]
|
|
300
|
+
if (listed != null) {
|
|
301
|
+
return { carries: true, ...(listed.unit_amount != null ? { unitAmount: listed.unit_amount } : {}) }
|
|
302
|
+
}
|
|
303
|
+
const synced = (await fingerprints(ctx).bySku(product.sku))?.prices?.find(entry => entry.planSku === plan.sku && entry.priceId === price.id)
|
|
304
|
+
const option = synced?.options.find(entry => entry.currency === currency)
|
|
305
|
+
|
|
306
|
+
return option != null ? { carries: true, unitAmount: option.unitAmount } : { carries: false }
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
interface StripeErrorShape {
|
|
310
|
+
type?: string
|
|
311
|
+
rawType?: string
|
|
312
|
+
code?: string
|
|
313
|
+
param?: string
|
|
314
|
+
message?: string
|
|
315
|
+
raw?: { type?: string, code?: string, param?: string, message?: string }
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Stripe refuses `consent_collection.terms_of_service` while the account has no terms-of-service URL
|
|
320
|
+
* in its Dashboard (Settings → Public details): an `invalid_request_error` on the param
|
|
321
|
+
* `consent_collection[terms_of_service]` saying "You cannot collect consent to your terms of service
|
|
322
|
+
* unless a URL is set in the Stripe Dashboard …". Matched on the param with a terms message, or on
|
|
323
|
+
* the message alone; any other error type never matches.
|
|
324
|
+
*/
|
|
325
|
+
export const isMissingTermsUrl = (error: unknown): boolean => {
|
|
326
|
+
if (error == null || typeof error !== 'object') {
|
|
327
|
+
return false
|
|
328
|
+
}
|
|
329
|
+
const typed = error as StripeErrorShape
|
|
330
|
+
const kind = typed.rawType ?? typed.raw?.type ?? typed.type
|
|
331
|
+
if (kind != null && kind !== 'invalid_request_error' && kind !== 'StripeInvalidRequestError') {
|
|
332
|
+
return false
|
|
333
|
+
}
|
|
334
|
+
const param = (typed.param ?? typed.raw?.param ?? '').replace(/\s/g, '')
|
|
335
|
+
const message = typed.message ?? typed.raw?.message ?? ''
|
|
336
|
+
const aboutTerms = /terms of service/i.test(message)
|
|
337
|
+
const onTermsParam = /^consent_collection(\[|\.)terms_of_service\]?$/.test(param)
|
|
338
|
+
|
|
339
|
+
return (onTermsParam && aboutTerms) || (aboutTerms && /\burl\b/i.test(message) && /dashboard/i.test(message))
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/** The same session without the terms checkbox and its text — and saying so in its metadata. */
|
|
343
|
+
const withoutTerms = (params: Stripe.Checkout.SessionCreateParams): Stripe.Checkout.SessionCreateParams => {
|
|
344
|
+
const { consent_collection: consent, custom_text: customText, ...rest } = params
|
|
345
|
+
const { terms_of_service: _terms, ...otherConsent } = consent ?? {}
|
|
346
|
+
const { terms_of_service_acceptance: _acceptance, ...otherText } = customText ?? {}
|
|
347
|
+
const marked = (metadata: Stripe.MetadataParam | undefined): Stripe.MetadataParam | undefined =>
|
|
348
|
+
metadata != null ? { ...metadata, termsCollected: 'false' } : metadata
|
|
349
|
+
|
|
350
|
+
return {
|
|
351
|
+
...rest,
|
|
352
|
+
...(Object.keys(otherConsent).length > 0 ? { consent_collection: otherConsent } : {}),
|
|
353
|
+
...(Object.keys(otherText).length > 0 ? { custom_text: otherText } : {}),
|
|
354
|
+
metadata: marked(rest.metadata) ?? { termsCollected: 'false' },
|
|
355
|
+
...(rest.subscription_data != null
|
|
356
|
+
? { subscription_data: { ...rest.subscription_data, metadata: marked(rest.subscription_data.metadata) ?? { termsCollected: 'false' } } }
|
|
357
|
+
: {}),
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/** One console warning per context — a process builds one. */
|
|
362
|
+
const warnedTermsFallback = new WeakSet<object>()
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* A Dashboard without a terms-of-service URL must never stop a payment: the session is created
|
|
366
|
+
* once more without the checkbox, an operator is told (once), and the degraded checkout is audited
|
|
367
|
+
* as a `checkout-terms-fallback` event of the organization (`externalId` = the session).
|
|
368
|
+
*/
|
|
369
|
+
const createWithoutTerms = async (
|
|
370
|
+
ctx: ApiContext, stripe: Stripe, attempt: CheckoutAttempt, params: Stripe.Checkout.SessionCreateParams, refusal: unknown,
|
|
371
|
+
): Promise<Stripe.Checkout.Session> => {
|
|
372
|
+
const stripeMessage = errorText(refusal)
|
|
373
|
+
if (!warnedTermsFallback.has(ctx)) {
|
|
374
|
+
warnedTermsFallback.add(ctx)
|
|
375
|
+
console.warn('[payment] Stripe refused the terms-of-service checkbox: this account has no terms of service URL. '
|
|
376
|
+
+ 'Checkouts continue WITHOUT the checkbox (metadata termsCollected=false). Operator: set the Billing Terms URL '
|
|
377
|
+
+ 'in the Stripe Dashboard → Settings → Public details (test and live mode alike).')
|
|
378
|
+
}
|
|
379
|
+
const typed = refusal as StripeErrorShape
|
|
380
|
+
const detail = JSON.stringify(compact({
|
|
381
|
+
message: stripeMessage, code: typed.code ?? typed.raw?.code, param: typed.param ?? typed.raw?.param,
|
|
382
|
+
mode: attempt.mode, productSku: attempt.productSku, planSku: attempt.planSku,
|
|
383
|
+
}))
|
|
384
|
+
try {
|
|
385
|
+
const session = await stripe.checkout.sessions.create(withoutTerms(params))
|
|
386
|
+
await recordEvent(ctx, {
|
|
387
|
+
recordId: attempt.entityId, recordKind: 'checkout', entityId: attempt.entityId, action: 'checkout-terms-fallback',
|
|
388
|
+
ok: false, externalId: session.id, detail,
|
|
389
|
+
})
|
|
390
|
+
|
|
391
|
+
return session
|
|
392
|
+
} catch (error) {
|
|
393
|
+
await recordEvent(ctx, {
|
|
394
|
+
recordId: attempt.entityId, recordKind: 'checkout', entityId: attempt.entityId, action: 'checkout-terms-fallback',
|
|
395
|
+
ok: false, detail, error: errorText(error),
|
|
396
|
+
})
|
|
397
|
+
throw error
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Create the session, then tell the plugins it exists. A plugin's `created` that throws expires
|
|
403
|
+
* the fresh session and releases every admission before the error propagates. A session Stripe
|
|
404
|
+
* refuses for the terms checkbox alone (no terms URL in the Dashboard) is created once more
|
|
405
|
+
* without it — under the same admissions.
|
|
406
|
+
*/
|
|
407
|
+
const createSession = async (
|
|
408
|
+
ctx: ApiContext, stripe: Stripe, plugins: readonly CheckoutPlugin[], attempt: CheckoutAttempt,
|
|
409
|
+
params: Stripe.Checkout.SessionCreateParams,
|
|
410
|
+
): Promise<string> => {
|
|
411
|
+
const admitted: Admitted[] = await admitCheckout(ctx, plugins, attempt)
|
|
412
|
+
const holders = admitted.filter(entry => entry.plugin.admit != null)
|
|
413
|
+
let session: Stripe.Checkout.Session
|
|
414
|
+
try {
|
|
415
|
+
try {
|
|
416
|
+
session = await stripe.checkout.sessions.create(params)
|
|
417
|
+
} catch (error) {
|
|
418
|
+
if (params.consent_collection?.terms_of_service !== 'required' || !isMissingTermsUrl(error)) {
|
|
419
|
+
throw error
|
|
420
|
+
}
|
|
421
|
+
session = await createWithoutTerms(ctx, stripe, attempt, params, error)
|
|
422
|
+
}
|
|
423
|
+
} catch (error) {
|
|
424
|
+
await releaseAdmitted(ctx, holders, attempt)
|
|
425
|
+
throw error
|
|
426
|
+
}
|
|
427
|
+
if (session.url == null) {
|
|
428
|
+
await releaseAdmitted(ctx, holders, attempt, session.id)
|
|
429
|
+
throw new PaygateError('session')
|
|
430
|
+
}
|
|
431
|
+
for (const { plugin, reservationId } of admitted) {
|
|
432
|
+
if (plugin.created == null) {
|
|
433
|
+
continue
|
|
434
|
+
}
|
|
435
|
+
try {
|
|
436
|
+
await plugin.created(ctx, { ...attempt, sessionId: session.id, url: session.url, ...(reservationId != null ? { reservationId } : {}) })
|
|
437
|
+
} catch (error) {
|
|
438
|
+
try {
|
|
439
|
+
await stripe.checkout.sessions.expire(session.id)
|
|
440
|
+
} catch (expireError) {
|
|
441
|
+
console.error(`[payment] could not expire checkout "${session.id}" after a plugin refused it`, expireError)
|
|
442
|
+
}
|
|
443
|
+
await releaseAdmitted(ctx, holders, attempt, session.id)
|
|
444
|
+
throw error
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
return session.url
|
|
449
|
+
}
|
|
450
|
+
|
|
128
451
|
/**
|
|
129
452
|
* A Stripe Checkout URL: an amount or quantity purchase of a consumable product, or a subscription
|
|
130
453
|
* to `planSku` (else the product's first recurring plan). A free plan is never checked out.
|
|
454
|
+
*
|
|
455
|
+
* With a consumer-rights policy: a locked billing country overrides the declared one (another is
|
|
456
|
+
* `BillingCountryLocked`); the charge currency is the profile's, else the region's; an amount
|
|
457
|
+
* checkout is narrowed by the checkout plugins (`CheckoutLimitExceeded`) and charged without FX
|
|
458
|
+
* when its policy currency is the charge currency; a subscription needs a fresh start request
|
|
459
|
+
* bound to its plan; the terms checkbox and the legal submit texts come from the copy.
|
|
131
460
|
*/
|
|
132
|
-
export const createCheckoutLink = async (
|
|
461
|
+
export const createCheckoutLink = async (
|
|
462
|
+
ctx: ApiContext, stripe: Stripe, params: CreateLinkParams, plugins: readonly CheckoutPlugin[] = [],
|
|
463
|
+
): Promise<string> => {
|
|
133
464
|
const product = await payment(ctx).product(params.productSku) as PaymentProduct
|
|
134
465
|
const plans = (await payment(ctx).allPlans(product.sku) as PaymentPlan[])
|
|
135
466
|
.filter(plan => isSoldThrough(product, plan, STRIPE_PAYGATE_ALIAS))
|
|
136
467
|
if (params.planSku != null && !plans.some(plan => plan.sku === params.planSku)) {
|
|
137
468
|
throw new ProductError(`plan:${params.planSku}`)
|
|
138
469
|
}
|
|
139
|
-
const
|
|
470
|
+
const consumable = product.type === ProductType.Consumable
|
|
471
|
+
const plan = consumable
|
|
472
|
+
? plans.find(item => item.sku === params.planSku) ?? plans[0]
|
|
473
|
+
: plans.find(item => item.sku === params.planSku) ?? plans.find(item => item.recurring != null) ?? plans[0]
|
|
474
|
+
if (plan == null) throw new ProductError('plan')
|
|
475
|
+
const catalogueCurrency = (plan.amountPolicy?.currency ?? plan.currency ?? 'usd').toLowerCase()
|
|
476
|
+
const { buyer, customer } = await buyerOf(ctx, stripe, params, catalogueCurrency)
|
|
140
477
|
const pricing = await payment(ctx).pricingPolicy()
|
|
478
|
+
const settlement = (await stripePricingConfig(ctx))?.settlementCurrency?.toLowerCase()
|
|
479
|
+
const ttl = sessionTtlOf(plugins)
|
|
480
|
+
const now = new Date()
|
|
481
|
+
const expiresAt = ttl != null ? new Date((Math.floor(now.getTime() / 1000) + ttl) * 1000) : undefined
|
|
482
|
+
const expiry = expiresAt != null ? { expires_at: Math.floor(expiresAt.getTime() / 1000) } : {}
|
|
483
|
+
const { terms, ...termsParams } = termsOptions(buyer)
|
|
484
|
+
const base = {
|
|
485
|
+
entityId: params.entityId, productSku: product.sku, planSku: plan.sku, at: now,
|
|
486
|
+
...(ttl != null ? { sessionTtlSeconds: ttl } : {}), ...(expiresAt != null ? { expiresAt } : {}),
|
|
487
|
+
}
|
|
141
488
|
|
|
142
|
-
if (
|
|
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
|
-
|
|
489
|
+
if (consumable && plan.pricingMode === CheckoutPricingMode.Amount) {
|
|
490
|
+
if (params.amountMinor == null) throw new ProductError('amount')
|
|
491
|
+
const { chargeMinor: sourceChargeMinor, currency: amountCurrency } = amountCheckoutLineItem(
|
|
492
|
+
product, plan, params.amountMinor, pricing.tax.behavior,
|
|
493
|
+
)
|
|
494
|
+
const view = await narrowAmountFor(ctx, plugins, {
|
|
495
|
+
entityId: params.entityId, productSku: product.sku, planSku: plan.sku,
|
|
496
|
+
base: plan.amountPolicy as NonNullable<PaymentPlan['amountPolicy']>, at: now,
|
|
497
|
+
})
|
|
498
|
+
assertAmountAllowed(view, params.amountMinor)
|
|
499
|
+
const charged = buyer.chargeCurrency != null
|
|
500
|
+
? await chargeAmount(ctx, stripe, sourceChargeMinor, amountCurrency, buyer.chargeCurrency)
|
|
501
|
+
: await settlementAmount(ctx, stripe, sourceChargeMinor, amountCurrency)
|
|
502
|
+
const adaptive = buyer.chargeCurrency == null || charged.currency === (settlement ?? amountCurrency)
|
|
503
|
+
const context = textContextOf(buyer, charged.currency, { unitAmountMinor: charged.amountMinor })
|
|
504
|
+
const submit = submitTextOf(params, context)
|
|
505
|
+
?? (buyer.policy != null && buyer.inScope && buyer.country != null
|
|
506
|
+
? consumerText(buyer.language, 'checkout.top-up', {
|
|
507
|
+
product: product.title, country: countryName(buyer.country, buyer.language),
|
|
508
|
+
})
|
|
509
|
+
: undefined)
|
|
510
|
+
const customText = compact({
|
|
511
|
+
...(submit != null ? { submit: { message: limited(submit, 'submit') } } : {}),
|
|
512
|
+
...(terms != null ? { terms_of_service_acceptance: { message: terms } } : {}),
|
|
513
|
+
})
|
|
514
|
+
const lineItem: Stripe.Checkout.SessionCreateParams.LineItem = {
|
|
515
|
+
price_data: {
|
|
516
|
+
product: product.sku, currency: charged.currency, unit_amount: charged.amountMinor,
|
|
517
|
+
tax_behavior: pricing.tax.behavior,
|
|
518
|
+
},
|
|
519
|
+
quantity: 1,
|
|
173
520
|
}
|
|
174
521
|
|
|
175
|
-
|
|
522
|
+
return await createSession(ctx, stripe, plugins, {
|
|
523
|
+
...base, mode: 'amount', amountMinor: params.amountMinor, amountCurrency, chargeMinor: charged.amountMinor,
|
|
524
|
+
currency: charged.currency,
|
|
525
|
+
}, {
|
|
526
|
+
mode: 'payment', line_items: [lineItem], invoice_creation: { enabled: true },
|
|
527
|
+
...checkoutOptions(pricing, false, { locked: buyer.addressLocked, adaptive }),
|
|
528
|
+
...termsParams,
|
|
529
|
+
...(Object.keys(customText).length > 0 ? { custom_text: customText } : {}),
|
|
530
|
+
...expiry,
|
|
531
|
+
...sharedSession(customer, params, product, plan, {
|
|
532
|
+
pricingMode: CheckoutPricingMode.Amount,
|
|
533
|
+
currency: charged.currency,
|
|
534
|
+
amountCurrency,
|
|
535
|
+
amountMinor: String(params.amountMinor),
|
|
536
|
+
sourceChargeAmountMinor: String(sourceChargeMinor),
|
|
537
|
+
chargeAmountMinor: String(charged.amountMinor),
|
|
538
|
+
...consumerMetadata(buyer, params),
|
|
539
|
+
}),
|
|
540
|
+
})
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
const price = await findPrice(stripe, product.sku, planLookupKey(product, plan))
|
|
544
|
+
const forced = buyer.chargeCurrency != null
|
|
545
|
+
? await priceCarries(ctx, product, plan, price, buyer.chargeCurrency) : { carries: false }
|
|
546
|
+
if (buyer.chargeCurrency != null && !forced.carries) {
|
|
547
|
+
console.warn(`[payment] plan "${plan.sku}" has no ${buyer.chargeCurrency.toUpperCase()} price; Stripe picks the currency`)
|
|
548
|
+
}
|
|
549
|
+
const currency = forced.carries ? buyer.chargeCurrency as string : price.currency
|
|
550
|
+
const adaptive = buyer.chargeCurrency == null || currency === (settlement ?? price.currency)
|
|
551
|
+
// Forced only when the synced price carries it: a session currency the price lacks is refused.
|
|
552
|
+
const currencyParam = forced.carries ? { currency } : {}
|
|
553
|
+
|
|
554
|
+
if (consumable) {
|
|
176
555
|
const quantityPolicy = plan.quantityPolicy ?? {
|
|
177
556
|
minimum: plan.minQuantity ?? 1,
|
|
178
557
|
maximum: plan.maxQuantity ?? Math.max(100_000, plan.minQuantity ?? 1),
|
|
179
558
|
default: plan.defaultQuantity ?? plan.minQuantity ?? 1,
|
|
180
559
|
}
|
|
181
|
-
const
|
|
560
|
+
const submit = submitTextOf(params, textContextOf(buyer, currency, compact({ unitAmountMinor: forced.unitAmount ?? price.unit_amount ?? undefined })))
|
|
561
|
+
const customText = compact({
|
|
562
|
+
...(submit != null ? { submit: { message: limited(submit, 'submit') } } : {}),
|
|
563
|
+
...(terms != null ? { terms_of_service_acceptance: { message: terms } } : {}),
|
|
564
|
+
})
|
|
565
|
+
|
|
566
|
+
return await createSession(ctx, stripe, plugins, { ...base, mode: 'quantity', currency }, {
|
|
182
567
|
mode: 'payment',
|
|
183
568
|
line_items: [quantityCheckoutLineItem(price, quantityPolicy)],
|
|
184
569
|
invoice_creation: { enabled: true },
|
|
185
|
-
...
|
|
186
|
-
...
|
|
570
|
+
...currencyParam,
|
|
571
|
+
...checkoutOptions(pricing, true, { locked: buyer.addressLocked, adaptive }),
|
|
572
|
+
...termsParams,
|
|
573
|
+
...(Object.keys(customText).length > 0 ? { custom_text: customText } : {}),
|
|
574
|
+
...expiry,
|
|
575
|
+
...sharedSession(customer, params, product, plan, {
|
|
576
|
+
pricingMode: CheckoutPricingMode.Quantity,
|
|
577
|
+
...(forced.carries ? { currency } : {}),
|
|
578
|
+
...consumerMetadata(buyer, params),
|
|
579
|
+
}),
|
|
187
580
|
})
|
|
188
|
-
if (session.url == null) throw new PaygateError('session')
|
|
189
|
-
return session.url
|
|
190
581
|
}
|
|
191
582
|
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
const price = await findPrice(stripe, product.sku, planLookupKey(product, plan))
|
|
583
|
+
// A subscription starts services at once: in scope (or unknown and protected) it needs the
|
|
584
|
+
// consumer's express start request, fresh and bound to this organization and plan.
|
|
585
|
+
const start = buyer.policy != null ? await consumerRightsOf(ctx)?.assertStartRequest(params.entityId, plan.sku, params.startRequestId) ?? null : null
|
|
196
586
|
const subscriptionPaymentMethodTypes = (
|
|
197
587
|
await stripePricingConfig(ctx)
|
|
198
588
|
)?.subscriptionPaymentMethodTypes as Stripe.Checkout.SessionCreateParams.PaymentMethodType[] | undefined
|
|
199
|
-
const
|
|
589
|
+
const unitAmount = forced.unitAmount ?? price.unit_amount ?? undefined
|
|
590
|
+
const interval = plan.recurring?.interval
|
|
591
|
+
const context = textContextOf(buyer, currency, compact({ unitAmountMinor: unitAmount, interval }))
|
|
592
|
+
const renewal = buyer.policy != null && unitAmount != null && interval != null
|
|
593
|
+
? consumerText(buyer.language, `checkout.renewal.${interval}`, {
|
|
594
|
+
price: consumerText(buyer.language, `checkout.price.${pricing.tax.behavior === TaxBehavior.Inclusive ? 'inclusive' : 'exclusive'}`, {
|
|
595
|
+
amount: formatMoney(unitAmount, currency, buyer.language),
|
|
596
|
+
}),
|
|
597
|
+
})
|
|
598
|
+
: undefined
|
|
599
|
+
const submit = submitTextOf(params, context) ?? renewal
|
|
600
|
+
const links = buyer.policy != null ? linksOf(buyer.policy, buyer.language) : null
|
|
601
|
+
const afterSubmit = buyer.policy?.mechanisms.cancellation === true && links?.cancellation != null
|
|
602
|
+
? consumerText(buyer.language, 'checkout.renewal.after-submit', { cancelUrl: links.cancellation })
|
|
603
|
+
: undefined
|
|
604
|
+
const customText = compact({
|
|
605
|
+
...(submit != null ? { submit: { message: limited(submit, 'submit') } } : {}),
|
|
606
|
+
...(afterSubmit != null ? { after_submit: { message: limited(afterSubmit, 'after-submit') } } : {}),
|
|
607
|
+
...(terms != null ? { terms_of_service_acceptance: { message: terms } } : {}),
|
|
608
|
+
})
|
|
609
|
+
const startRequestId = start?.id ?? undefined
|
|
610
|
+
const metadata = consumerMetadata(buyer, params, startRequestId)
|
|
611
|
+
|
|
612
|
+
return await createSession(ctx, stripe, plugins, { ...base, mode: 'subscription', currency }, {
|
|
200
613
|
mode: 'subscription', line_items: [{ price: price.id, quantity: 1 }],
|
|
614
|
+
...currencyParam,
|
|
201
615
|
...(subscriptionPaymentMethodTypes != null ? { payment_method_types: subscriptionPaymentMethodTypes } : {}),
|
|
202
|
-
...(
|
|
616
|
+
...(Object.keys(customText).length > 0 ? { custom_text: customText } : {}),
|
|
203
617
|
subscription_data: {
|
|
204
618
|
metadata: {
|
|
205
619
|
pricingMode: CheckoutPricingMode.Quantity, entityId: params.entityId,
|
|
206
620
|
service: params.service, productSku: product.sku, planSku: plan.sku,
|
|
621
|
+
...(params.profileId != null ? { profileId: params.profileId } : {}),
|
|
622
|
+
...metadata,
|
|
207
623
|
},
|
|
208
624
|
},
|
|
209
|
-
...checkoutOptions(pricing, true),
|
|
210
|
-
...
|
|
625
|
+
...checkoutOptions(pricing, true, { locked: buyer.addressLocked, adaptive }),
|
|
626
|
+
...termsParams,
|
|
627
|
+
...expiry,
|
|
628
|
+
...sharedSession(customer, params, product, plan, metadata),
|
|
211
629
|
})
|
|
212
|
-
if (session.url == null) throw new PaygateError('session')
|
|
213
|
-
return session.url
|
|
214
630
|
}
|
|
215
631
|
|
|
216
632
|
interface WebhookRequest {
|