@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/stripe.ts
CHANGED
|
@@ -1,44 +1,97 @@
|
|
|
1
|
-
import Stripe from 'stripe'
|
|
1
|
+
import type Stripe from 'stripe'
|
|
2
2
|
import {
|
|
3
|
-
assertCheckoutAmount,
|
|
3
|
+
assertCheckoutAmount, billingLanguageOf, BillingCountryLocked, chargeAmountMinor, chargeCurrencyOf,
|
|
4
|
+
CheckoutPricingMode, CONSUMER_RIGHTS_COPY_VERSION, ConsumerRightsError, consumerText, inScope, linksOf,
|
|
5
|
+
PaygateError, ProductError, ProductType, regionOf, TaxBehavior, WebhookSetupError,
|
|
4
6
|
} from '@owlmeans/payment'
|
|
7
|
+
import type { BillingProfileView, ConsumerRegion, ConsumerRightsPolicy, PricingPolicy } from '@owlmeans/payment'
|
|
5
8
|
import type { Context as ApiContext } from '@owlmeans/server-api'
|
|
6
9
|
import { STRIPE_PAYGATE_ALIAS, STRIPE_SIGNATURE } from '../consts.js'
|
|
7
|
-
import {
|
|
8
|
-
|
|
10
|
+
import {
|
|
11
|
+
compact, consumerRightsOf, errorText, fingerprints, paygateCustomers, payment, stripePricingConfig,
|
|
12
|
+
} from '../utils.js'
|
|
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'
|
|
9
20
|
import { createEventHandler } from './events.js'
|
|
10
|
-
import
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
21
|
+
import { chargeAmount, settlementAmount } from './fx.js'
|
|
22
|
+
import { stripeWebhookSecrets } from './webhook-manager.js'
|
|
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
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* A Checkout Session's tax and currency options, entirely driven by the declared `PricingPolicy` —
|
|
39
|
+
* an undeclared one (`DEFAULT_PRICING_POLICY`) reproduces exactly what every session hard-coded
|
|
40
|
+
* before this policy existed: automatic tax and tax-id collection on, no Adaptive Pricing.
|
|
41
|
+
*
|
|
42
|
+
* `customer_update.address` lets automatic tax use the billing address Checkout just collected
|
|
43
|
+
* rather than only a previously saved one; `customer_update.name` lets tax-id collection save the
|
|
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.
|
|
47
|
+
*/
|
|
48
|
+
const checkoutOptions = (
|
|
49
|
+
policy: PricingPolicy, promotions: boolean, flags: CheckoutOptionsFlags = {},
|
|
50
|
+
): Partial<Stripe.Checkout.SessionCreateParams> => {
|
|
51
|
+
const customerUpdate: Stripe.Checkout.SessionCreateParams.CustomerUpdate = {}
|
|
52
|
+
if (policy.tax.automatic) customerUpdate.address = flags.locked === true ? 'never' : 'auto'
|
|
53
|
+
if (policy.tax.collectTaxId) customerUpdate.name = 'auto'
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
...(policy.tax.automatic
|
|
57
|
+
? { automatic_tax: { enabled: true }, billing_address_collection: flags.locked === true ? 'auto' as const : 'required' as const }
|
|
58
|
+
: {}),
|
|
59
|
+
...(policy.tax.collectTaxId ? { tax_id_collection: { enabled: true } } : {}),
|
|
60
|
+
...(Object.keys(customerUpdate).length > 0 ? { customer_update: customerUpdate } : {}),
|
|
61
|
+
...(policy.currency.adaptive === true && flags.adaptive !== false ? { adaptive_pricing: { enabled: true } } : {}),
|
|
62
|
+
allow_promotion_codes: promotions,
|
|
63
|
+
}
|
|
64
|
+
}
|
|
19
65
|
|
|
20
66
|
const ensureStripeCustomer = async (
|
|
21
67
|
ctx: ApiContext, stripe: Stripe, params: CreateLinkParams,
|
|
22
68
|
): Promise<Stripe.Customer> => {
|
|
23
69
|
const resource = paygateCustomers(ctx)
|
|
24
70
|
const existing = await resource.byEntity(params.entityId, STRIPE_PAYGATE_ALIAS)
|
|
25
|
-
if (existing != null) {
|
|
71
|
+
if (existing != null && existing.deletedAt == null) {
|
|
26
72
|
const retrieved = await stripe.customers.retrieve(existing.externalId)
|
|
27
|
-
if (!(retrieved as Stripe.DeletedCustomer).deleted)
|
|
73
|
+
if (!(retrieved as Stripe.DeletedCustomer).deleted) {
|
|
74
|
+
const customer = retrieved as Stripe.Customer
|
|
75
|
+
if (params.locale != null && customer.preferred_locales?.[0] !== params.locale) {
|
|
76
|
+
return await stripe.customers.update(customer.id, { preferred_locales: [params.locale] })
|
|
77
|
+
}
|
|
78
|
+
return customer
|
|
79
|
+
}
|
|
28
80
|
}
|
|
29
81
|
const created = await stripe.customers.create({
|
|
30
82
|
metadata: {
|
|
31
83
|
entityId: params.entityId, ...(params.profileId && { profileId: params.profileId }),
|
|
32
84
|
service: params.service,
|
|
33
85
|
},
|
|
86
|
+
...(params.locale != null ? { preferred_locales: [params.locale] } : {}),
|
|
34
87
|
})
|
|
35
88
|
if (existing != null) {
|
|
36
|
-
|
|
37
|
-
await resource.
|
|
89
|
+
const { deletedAt: _deleted, ...kept } = existing
|
|
90
|
+
await resource.update({ ...kept, externalId: created.id })
|
|
38
91
|
} else {
|
|
39
92
|
await resource.create({
|
|
40
93
|
paygate: STRIPE_PAYGATE_ALIAS, externalId: created.id, entityId: params.entityId,
|
|
41
|
-
profileId: params.profileId,
|
|
94
|
+
...(params.profileId != null ? { profileId: params.profileId } : {}),
|
|
42
95
|
})
|
|
43
96
|
}
|
|
44
97
|
return created
|
|
@@ -53,10 +106,11 @@ const findPrice = async (stripe: Stripe, productSku: string, lookupKey: string):
|
|
|
53
106
|
const sharedSession = (
|
|
54
107
|
customer: Stripe.Customer, params: CreateLinkParams, product: PaymentProduct,
|
|
55
108
|
plan: PaymentPlan, metadata: Record<string, string>,
|
|
56
|
-
): Pick<Stripe.Checkout.SessionCreateParams, 'customer' | 'success_url' | 'cancel_url' | 'metadata'> => ({
|
|
109
|
+
): Pick<Stripe.Checkout.SessionCreateParams, 'customer' | 'success_url' | 'cancel_url' | 'metadata' | 'locale'> => ({
|
|
57
110
|
customer: customer.id,
|
|
58
111
|
success_url: params.successUrl,
|
|
59
112
|
cancel_url: params.cancelUrl ?? params.successUrl,
|
|
113
|
+
...(params.locale != null ? { locale: params.locale as Stripe.Checkout.SessionCreateParams.Locale } : {}),
|
|
60
114
|
metadata: {
|
|
61
115
|
pricingMode: plan.pricingMode ?? CheckoutPricingMode.Quantity,
|
|
62
116
|
currency: (plan.currency ?? 'usd').toLowerCase(),
|
|
@@ -70,7 +124,7 @@ const sharedSession = (
|
|
|
70
124
|
})
|
|
71
125
|
|
|
72
126
|
export const amountCheckoutLineItem = (
|
|
73
|
-
product: PaymentProduct, plan: PaymentPlan, amountMinor: number,
|
|
127
|
+
product: PaymentProduct, plan: PaymentPlan, amountMinor: number, behavior: TaxBehavior = TaxBehavior.Exclusive,
|
|
74
128
|
): { lineItem: Stripe.Checkout.SessionCreateParams.LineItem; chargeMinor: number; currency: string } => {
|
|
75
129
|
if (plan.amountPolicy == null) throw new ProductError(`amount-policy:${plan.sku}`)
|
|
76
130
|
assertCheckoutAmount(plan.amountPolicy, amountMinor)
|
|
@@ -79,7 +133,7 @@ export const amountCheckoutLineItem = (
|
|
|
79
133
|
return {
|
|
80
134
|
lineItem: {
|
|
81
135
|
price_data: {
|
|
82
|
-
product: product.sku, currency, unit_amount: chargeMinor, tax_behavior:
|
|
136
|
+
product: product.sku, currency, unit_amount: chargeMinor, tax_behavior: behavior,
|
|
83
137
|
},
|
|
84
138
|
quantity: 1,
|
|
85
139
|
},
|
|
@@ -96,88 +150,519 @@ export const quantityCheckoutLineItem = (
|
|
|
96
150
|
quantity: policy.default,
|
|
97
151
|
})
|
|
98
152
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
+
}
|
|
126
341
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
|
132
420
|
}
|
|
133
|
-
|
|
134
|
-
mode: 'payment',
|
|
135
|
-
line_items: [quantityCheckoutLineItem(price, policy)],
|
|
136
|
-
invoice_creation: { enabled: true },
|
|
137
|
-
...taxOptions(true),
|
|
138
|
-
...sharedSession(customer, params, product, plan, { pricingMode: CheckoutPricingMode.Quantity }),
|
|
139
|
-
})
|
|
140
|
-
if (session.url == null) throw new PaygateError('session')
|
|
141
|
-
return session.url
|
|
421
|
+
session = await createWithoutTerms(ctx, stripe, attempt, params, error)
|
|
142
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
|
+
|
|
451
|
+
/**
|
|
452
|
+
* A Stripe Checkout URL: an amount or quantity purchase of a consumable product, or a subscription
|
|
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.
|
|
460
|
+
*/
|
|
461
|
+
export const createCheckoutLink = async (
|
|
462
|
+
ctx: ApiContext, stripe: Stripe, params: CreateLinkParams, plugins: readonly CheckoutPlugin[] = [],
|
|
463
|
+
): Promise<string> => {
|
|
464
|
+
const product = await payment(ctx).product(params.productSku) as PaymentProduct
|
|
465
|
+
const plans = (await payment(ctx).allPlans(product.sku) as PaymentPlan[])
|
|
466
|
+
.filter(plan => isSoldThrough(product, plan, STRIPE_PAYGATE_ALIAS))
|
|
467
|
+
if (params.planSku != null && !plans.some(plan => plan.sku === params.planSku)) {
|
|
468
|
+
throw new ProductError(`plan:${params.planSku}`)
|
|
469
|
+
}
|
|
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)
|
|
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
|
+
}
|
|
143
488
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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,
|
|
155
518
|
},
|
|
156
|
-
|
|
157
|
-
|
|
519
|
+
quantity: 1,
|
|
520
|
+
}
|
|
521
|
+
|
|
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
|
+
}),
|
|
158
540
|
})
|
|
159
|
-
|
|
160
|
-
return session.url
|
|
161
|
-
},
|
|
541
|
+
}
|
|
162
542
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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) {
|
|
555
|
+
const quantityPolicy = plan.quantityPolicy ?? {
|
|
556
|
+
minimum: plan.minQuantity ?? 1,
|
|
557
|
+
maximum: plan.maxQuantity ?? Math.max(100_000, plan.minQuantity ?? 1),
|
|
558
|
+
default: plan.defaultQuantity ?? plan.minQuantity ?? 1,
|
|
169
559
|
}
|
|
170
|
-
const
|
|
171
|
-
const
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
},
|
|
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
|
+
})
|
|
176
565
|
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
566
|
+
return await createSession(ctx, stripe, plugins, { ...base, mode: 'quantity', currency }, {
|
|
567
|
+
mode: 'payment',
|
|
568
|
+
line_items: [quantityCheckoutLineItem(price, quantityPolicy)],
|
|
569
|
+
invoice_creation: { enabled: true },
|
|
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
|
+
}),
|
|
580
|
+
})
|
|
581
|
+
}
|
|
582
|
+
|
|
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
|
|
586
|
+
const subscriptionPaymentMethodTypes = (
|
|
587
|
+
await stripePricingConfig(ctx)
|
|
588
|
+
)?.subscriptionPaymentMethodTypes as Stripe.Checkout.SessionCreateParams.PaymentMethodType[] | undefined
|
|
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 }, {
|
|
613
|
+
mode: 'subscription', line_items: [{ price: price.id, quantity: 1 }],
|
|
614
|
+
...currencyParam,
|
|
615
|
+
...(subscriptionPaymentMethodTypes != null ? { payment_method_types: subscriptionPaymentMethodTypes } : {}),
|
|
616
|
+
...(Object.keys(customText).length > 0 ? { custom_text: customText } : {}),
|
|
617
|
+
subscription_data: {
|
|
618
|
+
metadata: {
|
|
619
|
+
pricingMode: CheckoutPricingMode.Quantity, entityId: params.entityId,
|
|
620
|
+
service: params.service, productSku: product.sku, planSku: plan.sku,
|
|
621
|
+
...(params.profileId != null ? { profileId: params.profileId } : {}),
|
|
622
|
+
...metadata,
|
|
623
|
+
},
|
|
624
|
+
},
|
|
625
|
+
...checkoutOptions(pricing, true, { locked: buyer.addressLocked, adaptive }),
|
|
626
|
+
...termsParams,
|
|
627
|
+
...expiry,
|
|
628
|
+
...sharedSession(customer, params, product, plan, metadata),
|
|
629
|
+
})
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
interface WebhookRequest {
|
|
633
|
+
original?: { rawBody?: string | Buffer }
|
|
634
|
+
rawBody?: string | Buffer
|
|
635
|
+
headers: Record<string, string | string[] | undefined>
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
/**
|
|
639
|
+
* Verify a Stripe webhook against the configured override secret, then the managed endpoint's
|
|
640
|
+
* stored one, and dispatch it.
|
|
641
|
+
*
|
|
642
|
+
* @throws PaygateError('signature') | WebhookSetupError('secret')
|
|
643
|
+
*/
|
|
644
|
+
export const handleStripeWebhook = async (ctx: ApiContext, stripe: Stripe, request: unknown): Promise<void> => {
|
|
645
|
+
const typed = request as WebhookRequest
|
|
646
|
+
const rawBody = typed.original?.rawBody ?? typed.rawBody
|
|
647
|
+
const signature = typed.headers[STRIPE_SIGNATURE.toLowerCase()]
|
|
648
|
+
if (rawBody == null || typeof signature !== 'string') throw new PaygateError('signature')
|
|
649
|
+
|
|
650
|
+
const secrets = await stripeWebhookSecrets(ctx)
|
|
651
|
+
if (secrets.length === 0) {
|
|
652
|
+
throw new WebhookSetupError('secret')
|
|
653
|
+
}
|
|
654
|
+
let event: Stripe.Event | null = null
|
|
655
|
+
for (const secret of secrets) {
|
|
656
|
+
try {
|
|
657
|
+
event = await stripe.webhooks.constructEventAsync(rawBody, signature, secret)
|
|
658
|
+
break
|
|
659
|
+
} catch {
|
|
660
|
+
// Try the next secret; a signature none of them verifies is refused below.
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
if (event == null) {
|
|
664
|
+
throw new PaygateError('signature')
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
await createEventHandler(ctx, stripe).process(event)
|
|
183
668
|
}
|