@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
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import type Stripe from 'stripe'
|
|
2
|
+
import { PaygateError } from '@owlmeans/payment'
|
|
3
|
+
import type { Context as ApiContext } from '@owlmeans/server-api'
|
|
4
|
+
import { STRIPE_FX_QUOTES_API_VERSION } from '../consts.js'
|
|
5
|
+
import { stripePricingConfig } from '../utils.js'
|
|
6
|
+
|
|
7
|
+
interface FxQuoteResponse {
|
|
8
|
+
rates?: Record<string, {
|
|
9
|
+
exchange_rate?: number
|
|
10
|
+
rate_details?: { base_rate?: number; fx_fee_rate?: number; reference_rate?: number }
|
|
11
|
+
}>
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface StripeFxRate {
|
|
15
|
+
fromCurrency: string
|
|
16
|
+
toCurrency: string
|
|
17
|
+
/** Stripe's fee-inclusive conversion rate. */
|
|
18
|
+
exchangeRate: number
|
|
19
|
+
/** Market/reference rate used to translate catalogue value into settlement value. */
|
|
20
|
+
referenceRate: number
|
|
21
|
+
fxFeeRate?: number
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** One unlocked Stripe FX quote. `toCurrency` units per one `fromCurrency` unit. */
|
|
25
|
+
export const stripeFxRate = async (
|
|
26
|
+
stripe: Stripe, fromCurrency: string, toCurrency: string, apiVersion: string,
|
|
27
|
+
): Promise<StripeFxRate | null> => {
|
|
28
|
+
const from = fromCurrency.toLowerCase()
|
|
29
|
+
const to = toCurrency.toLowerCase()
|
|
30
|
+
if (from === to) return { fromCurrency: from, toCurrency: to, exchangeRate: 1, referenceRate: 1 }
|
|
31
|
+
const response = await stripe.rawRequest(
|
|
32
|
+
'POST', '/v1/fx_quotes',
|
|
33
|
+
{ to_currency: to, 'from_currencies[]': from, lock_duration: 'none' },
|
|
34
|
+
{ apiVersion },
|
|
35
|
+
) as Stripe.Response<FxQuoteResponse>
|
|
36
|
+
const rate = response.rates?.[from]
|
|
37
|
+
if (rate?.exchange_rate == null) return null
|
|
38
|
+
return {
|
|
39
|
+
fromCurrency: from,
|
|
40
|
+
toCurrency: to,
|
|
41
|
+
exchangeRate: rate.exchange_rate,
|
|
42
|
+
referenceRate: rate.rate_details?.reference_rate ?? rate.rate_details?.base_rate ?? rate.exchange_rate,
|
|
43
|
+
...(rate.rate_details?.fx_fee_rate != null ? { fxFeeRate: rate.rate_details.fx_fee_rate } : {}),
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Convert whole source minor units at the reference rate, rounding up so value is never lost. */
|
|
48
|
+
export const convertMinor = (amountMinor: number, rate: number): number => {
|
|
49
|
+
const converted = Math.ceil(amountMinor * rate)
|
|
50
|
+
if (!Number.isSafeInteger(amountMinor) || amountMinor < 0 || !Number.isFinite(rate) || rate <= 0
|
|
51
|
+
|| !Number.isSafeInteger(converted)) {
|
|
52
|
+
throw new PaygateError('fx-amount')
|
|
53
|
+
}
|
|
54
|
+
return converted
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface SettlementAmount {
|
|
58
|
+
amountMinor: number
|
|
59
|
+
currency: string
|
|
60
|
+
sourceAmountMinor: number
|
|
61
|
+
sourceCurrency: string
|
|
62
|
+
referenceRate: number
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export type StripeFxRateCache = Map<string, Promise<StripeFxRate | null>>
|
|
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
|
+
|
|
96
|
+
/** Translate a catalogue amount into the configured Stripe settlement currency. */
|
|
97
|
+
export const settlementAmount = async (
|
|
98
|
+
ctx: ApiContext, stripe: Stripe, sourceAmountMinor: number, sourceCurrency: string,
|
|
99
|
+
cache?: StripeFxRateCache,
|
|
100
|
+
): Promise<SettlementAmount> => {
|
|
101
|
+
const source = sourceCurrency.toLowerCase()
|
|
102
|
+
const pricing = await stripePricingConfig(ctx)
|
|
103
|
+
const currency = pricing?.settlementCurrency?.toLowerCase() ?? source
|
|
104
|
+
if (currency === source) {
|
|
105
|
+
return {
|
|
106
|
+
amountMinor: sourceAmountMinor, currency, sourceAmountMinor, sourceCurrency: source, referenceRate: 1,
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
const apiVersion = pricing?.fxApiVersion ?? STRIPE_FX_QUOTES_API_VERSION
|
|
110
|
+
const key = `${source}:${currency}:${apiVersion}`
|
|
111
|
+
let pending = cache?.get(key)
|
|
112
|
+
if (pending == null) {
|
|
113
|
+
pending = stripeFxRate(stripe, source, currency, apiVersion)
|
|
114
|
+
cache?.set(key, pending)
|
|
115
|
+
}
|
|
116
|
+
const quote = await pending
|
|
117
|
+
if (quote == null) throw new PaygateError(`fx-rate:${source}:${currency}`)
|
|
118
|
+
return {
|
|
119
|
+
amountMinor: convertMinor(sourceAmountMinor, quote.referenceRate), currency,
|
|
120
|
+
sourceAmountMinor, sourceCurrency: source, referenceRate: quote.referenceRate,
|
|
121
|
+
}
|
|
122
|
+
}
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import type Stripe from 'stripe'
|
|
3
|
+
import {
|
|
4
|
+
ENTITLING_STATUSES, PortalFlow, PortalUnavailable, ProductError, UnknownPlan,
|
|
5
|
+
} from '@owlmeans/payment'
|
|
6
|
+
import type { Context as ApiContext } from '@owlmeans/server-api'
|
|
7
|
+
import {
|
|
8
|
+
FINGERPRINT_PORTAL, STRIPE_DEPLOYMENT_KEY, STRIPE_OWNER_KEY, STRIPE_OWNER_VALUE, STRIPE_PAYGATE_ALIAS,
|
|
9
|
+
} from '../consts.js'
|
|
10
|
+
import { findPlan, findProduct, planRank } from '../plan.js'
|
|
11
|
+
import { planLookupKey, stripePlansOf } from '../sync.js'
|
|
12
|
+
import {
|
|
13
|
+
fingerprints, isMissingObject, paygateCustomers, payment, portalBrandingConfig, subscriptions,
|
|
14
|
+
} from '../utils.js'
|
|
15
|
+
import { webhookUrlOf } from './webhook-manager.js'
|
|
16
|
+
import type { PaymentProduct, PaymentSubscriptionRecord, PortalLinkOptions } from '../types.js'
|
|
17
|
+
|
|
18
|
+
type ConfigurationParams = Stripe.BillingPortal.ConfigurationCreateParams
|
|
19
|
+
|
|
20
|
+
export const portalFingerprintSku = (service: string): string => `${FINGERPRINT_PORTAL}:${service}`
|
|
21
|
+
|
|
22
|
+
/** The recurring plans sold through Stripe, per product — what the portal may switch between. */
|
|
23
|
+
const recurringCatalog = async (ctx: ApiContext): Promise<Array<{ product: PaymentProduct, lookupKeys: string[], hashable: unknown[] }>> => {
|
|
24
|
+
// The declared tax behavior decides which Stripe price id `sync.ts` keeps for a plan (an
|
|
25
|
+
// in-place `unspecified` update keeps it, an opposite behavior replaces it) — hashed here too,
|
|
26
|
+
// so a behavior change refreshes this configuration's `products[].prices` to the new ids.
|
|
27
|
+
const behavior = (await payment(ctx).pricingPolicy()).tax.behavior ?? null
|
|
28
|
+
const result: Array<{ product: PaymentProduct, lookupKeys: string[], hashable: unknown[] }> = []
|
|
29
|
+
for (const { product, plans } of await stripePlansOf(ctx)) {
|
|
30
|
+
const recurring = plans.filter(plan => plan.recurring != null)
|
|
31
|
+
if (recurring.length === 0) {
|
|
32
|
+
continue
|
|
33
|
+
}
|
|
34
|
+
result.push({
|
|
35
|
+
product,
|
|
36
|
+
lookupKeys: recurring.map(plan => planLookupKey(product, plan)),
|
|
37
|
+
hashable: recurring.map(plan => ({
|
|
38
|
+
sku: plan.sku, price: plan.price, currency: plan.currency ?? 'usd', interval: plan.recurring?.interval,
|
|
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 } : {}),
|
|
42
|
+
})).sort((a, b) => a.sku.localeCompare(b.sku)),
|
|
43
|
+
})
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return result.sort((a, b) => a.product.sku.localeCompare(b.product.sku))
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const activeRecurringPrices = async (stripe: Stripe, productSku: string): Promise<Stripe.Price[]> =>
|
|
50
|
+
(await stripe.prices.list({ product: productSku, active: true, limit: 100 })).data
|
|
51
|
+
.filter(price => price.recurring != null)
|
|
52
|
+
|
|
53
|
+
export interface EnsurePortalOptions {
|
|
54
|
+
force?: boolean
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
type Claim = 'ours' | 'foreign' | 'unclaimed'
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Whose a configuration is by its metadata: `ours` carries this service AND this deployment's key;
|
|
61
|
+
* `foreign` is tagged for another deployment or another service; `unclaimed` names no deployment.
|
|
62
|
+
*/
|
|
63
|
+
const claimOf = (metadata: Stripe.Metadata | null | undefined, service: string, deployment: string): Claim => {
|
|
64
|
+
const owned = metadata?.[STRIPE_OWNER_KEY] === STRIPE_OWNER_VALUE
|
|
65
|
+
const tagged = metadata?.[STRIPE_DEPLOYMENT_KEY]
|
|
66
|
+
if (owned && metadata?.service === service && tagged === deployment) {
|
|
67
|
+
return 'ours'
|
|
68
|
+
}
|
|
69
|
+
if ((owned && metadata?.service !== service) || (tagged != null && tagged !== '')) {
|
|
70
|
+
return 'foreign'
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return 'unclaimed'
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const listActiveConfigurations = async (stripe: Stripe): Promise<Stripe.BillingPortal.Configuration[]> => {
|
|
77
|
+
const configurations: Stripe.BillingPortal.Configuration[] = []
|
|
78
|
+
let startingAfter: string | undefined
|
|
79
|
+
for (;;) {
|
|
80
|
+
const page = await stripe.billingPortal.configurations.list({
|
|
81
|
+
active: true, limit: 100, ...(startingAfter != null ? { starting_after: startingAfter } : {}),
|
|
82
|
+
})
|
|
83
|
+
configurations.push(...page.data)
|
|
84
|
+
if (!page.has_more || page.data.length === 0) {
|
|
85
|
+
return configurations
|
|
86
|
+
}
|
|
87
|
+
startingAfter = page.data[page.data.length - 1].id
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Keep this deployment's own Stripe customer-portal configuration: customer, invoice and payment
|
|
93
|
+
* method self-service, cancellation at period end, and switching between the active recurring
|
|
94
|
+
* prices of every product sold through Stripe (both subscription features off when there are none).
|
|
95
|
+
*
|
|
96
|
+
* A deployment's identity is its webhook URL (`webhookUrlOf`) — also when that URL is undeliverable,
|
|
97
|
+
* as on a local run. Several deployments of one service may share a Stripe account, so each owns a
|
|
98
|
+
* configuration of its own, tagged `{ owlmeans: 'payment', service, deployment: <webhook URL> }`.
|
|
99
|
+
*
|
|
100
|
+
* - An unchanged declaration (catalogue, branding, deployment key) makes no paygate call.
|
|
101
|
+
* - The configuration the `portal:<service>` fingerprint row names is updated in place — unless its
|
|
102
|
+
* metadata tags it for another deployment, which is never overwritten; this deployment then
|
|
103
|
+
* proceeds as though it held no row.
|
|
104
|
+
* - Without a usable row, an active configuration tagged with exactly this service and deployment
|
|
105
|
+
* key is adopted. Nothing else is: not an untagged one, not one carrying only the service label,
|
|
106
|
+
* not one tagged for another deployment.
|
|
107
|
+
* - Otherwise a new configuration is created. Stripe cannot delete a portal configuration, so one
|
|
108
|
+
* this deployment can no longer identify stays in the account, and a lost row creates a new one
|
|
109
|
+
* unless its tagged configuration is found.
|
|
110
|
+
*/
|
|
111
|
+
export const ensurePortalConfiguration = async (
|
|
112
|
+
ctx: ApiContext, stripe: Stripe, opts: EnsurePortalOptions = {},
|
|
113
|
+
): Promise<string | null> => {
|
|
114
|
+
const service = ctx.cfg.service
|
|
115
|
+
const deployment = webhookUrlOf(ctx)
|
|
116
|
+
const sku = portalFingerprintSku(service)
|
|
117
|
+
const branding = await portalBrandingConfig(ctx)
|
|
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()
|
|
123
|
+
const hash = createHash('sha256').update(JSON.stringify({
|
|
124
|
+
service,
|
|
125
|
+
deployment,
|
|
126
|
+
...(countryLock ? { countryLock } : {}),
|
|
127
|
+
...(regionCurrencies.length > 0 ? { regionCurrencies } : {}),
|
|
128
|
+
branding: branding != null ? {
|
|
129
|
+
headline: branding.headline ?? null, privacyPolicyUrl: branding.privacyPolicyUrl ?? null,
|
|
130
|
+
termsOfServiceUrl: branding.termsOfServiceUrl ?? null, returnUrl: branding.returnUrl ?? null,
|
|
131
|
+
} : null,
|
|
132
|
+
products: catalog.map(entry => ({ product: entry.product.sku, plans: entry.hashable })),
|
|
133
|
+
})).digest('hex')
|
|
134
|
+
|
|
135
|
+
const stored = await fingerprints(ctx).bySku(sku)
|
|
136
|
+
if (stored != null && stored.hash === hash && stored.externalId != null && opts.force !== true) {
|
|
137
|
+
return stored.externalId
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const products: Array<{ product: string, prices: string[] }> = []
|
|
141
|
+
for (const entry of catalog) {
|
|
142
|
+
const prices = (await activeRecurringPrices(stripe, entry.product.sku)).map(price => price.id)
|
|
143
|
+
if (prices.length > 0) {
|
|
144
|
+
products.push({ product: entry.product.sku, prices })
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
const cancelable = catalog.length > 0
|
|
148
|
+
const switchable = products.length > 0
|
|
149
|
+
const metadata = { [STRIPE_OWNER_KEY]: STRIPE_OWNER_VALUE, service, [STRIPE_DEPLOYMENT_KEY]: deployment }
|
|
150
|
+
const params: ConfigurationParams = {
|
|
151
|
+
features: {
|
|
152
|
+
customer_update: {
|
|
153
|
+
enabled: true, allowed_updates: countryLock ? ['email', 'tax_id'] : ['email', 'address', 'tax_id'],
|
|
154
|
+
},
|
|
155
|
+
invoice_history: { enabled: true },
|
|
156
|
+
payment_method_update: { enabled: true },
|
|
157
|
+
subscription_cancel: cancelable
|
|
158
|
+
? { enabled: true, mode: 'at_period_end', proration_behavior: 'none' }
|
|
159
|
+
: { enabled: false },
|
|
160
|
+
subscription_update: switchable
|
|
161
|
+
? {
|
|
162
|
+
enabled: true, default_allowed_updates: ['price'], products,
|
|
163
|
+
proration_behavior: 'create_prorations',
|
|
164
|
+
}
|
|
165
|
+
: { enabled: false },
|
|
166
|
+
},
|
|
167
|
+
...(branding != null ? {
|
|
168
|
+
business_profile: {
|
|
169
|
+
...(branding.headline != null ? { headline: branding.headline } : {}),
|
|
170
|
+
...(branding.privacyPolicyUrl != null ? { privacy_policy_url: branding.privacyPolicyUrl } : {}),
|
|
171
|
+
...(branding.termsOfServiceUrl != null ? { terms_of_service_url: branding.termsOfServiceUrl } : {}),
|
|
172
|
+
},
|
|
173
|
+
default_return_url: branding.returnUrl,
|
|
174
|
+
} : {}),
|
|
175
|
+
metadata,
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
let configurationId: string | null = null
|
|
179
|
+
if (stored?.externalId != null) {
|
|
180
|
+
try {
|
|
181
|
+
const current = await stripe.billingPortal.configurations.retrieve(stored.externalId)
|
|
182
|
+
if (claimOf(current.metadata, service, deployment) !== 'foreign') {
|
|
183
|
+
await stripe.billingPortal.configurations.update(current.id, params)
|
|
184
|
+
configurationId = current.id
|
|
185
|
+
}
|
|
186
|
+
} catch (error) {
|
|
187
|
+
if (!isMissingObject(error)) {
|
|
188
|
+
throw error
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
if (configurationId == null) {
|
|
193
|
+
const existing = (await listActiveConfigurations(stripe))
|
|
194
|
+
.find(configuration => claimOf(configuration.metadata, service, deployment) === 'ours')
|
|
195
|
+
if (existing != null) {
|
|
196
|
+
await stripe.billingPortal.configurations.update(existing.id, params)
|
|
197
|
+
configurationId = existing.id
|
|
198
|
+
} else {
|
|
199
|
+
configurationId = (await stripe.billingPortal.configurations.create(params)).id
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const now = new Date()
|
|
204
|
+
if (stored != null) {
|
|
205
|
+
await fingerprints(ctx).update({ ...stored, hash, externalId: configurationId, updatedAt: now })
|
|
206
|
+
} else {
|
|
207
|
+
await fingerprints(ctx).create({ sku, hash, externalId: configurationId, updatedAt: now })
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return configurationId
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** The entity's highest-ranked entitling Stripe subscription, or `null`. */
|
|
214
|
+
const entitlingStripeSubscription = async (
|
|
215
|
+
ctx: ApiContext, entityId: string,
|
|
216
|
+
): Promise<PaymentSubscriptionRecord | null> => await subscriptions(ctx).load(
|
|
217
|
+
{ entityId, paygate: STRIPE_PAYGATE_ALIAS, status: [...ENTITLING_STATUSES] },
|
|
218
|
+
{ sort: [{ field: 'rank', order: 'desc' }, { field: 'createdAt', order: 'desc' }] },
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* A Stripe customer-portal session for one entity. `Manage` opens the portal home and
|
|
223
|
+
* `PaymentMethod` the payment method form; `Cancel`, `Update` and `Change` act on the entity's
|
|
224
|
+
* entitling Stripe subscription (`Change` confirms a switch of its one item to `planSku`'s price).
|
|
225
|
+
*
|
|
226
|
+
* @throws PortalUnavailable('customer' | 'subscription' | 'plan' | 'item')
|
|
227
|
+
*/
|
|
228
|
+
export const createPortalLink = async (
|
|
229
|
+
ctx: ApiContext, stripe: Stripe, entityId: string, opts: PortalLinkOptions,
|
|
230
|
+
): Promise<string> => {
|
|
231
|
+
const customer = await paygateCustomers(ctx).byEntity(entityId, STRIPE_PAYGATE_ALIAS)
|
|
232
|
+
if (customer == null || customer.deletedAt != null) {
|
|
233
|
+
throw new PortalUnavailable('customer')
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const sku = portalFingerprintSku(ctx.cfg.service)
|
|
237
|
+
let configuration = (await fingerprints(ctx).bySku(sku))?.externalId ?? null
|
|
238
|
+
if (configuration == null) {
|
|
239
|
+
await ensurePortalConfiguration(ctx, stripe).catch(error => {
|
|
240
|
+
console.error('[payment] portal configuration unavailable', error)
|
|
241
|
+
})
|
|
242
|
+
configuration = (await fingerprints(ctx).bySku(sku))?.externalId ?? null
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
let flowData: Stripe.BillingPortal.SessionCreateParams.FlowData | undefined
|
|
246
|
+
switch (opts.flow) {
|
|
247
|
+
case PortalFlow.Manage:
|
|
248
|
+
break
|
|
249
|
+
case PortalFlow.PaymentMethod:
|
|
250
|
+
flowData = { type: 'payment_method_update' }
|
|
251
|
+
break
|
|
252
|
+
case PortalFlow.Cancel:
|
|
253
|
+
case PortalFlow.Update:
|
|
254
|
+
case PortalFlow.Change: {
|
|
255
|
+
const subscription = await entitlingStripeSubscription(ctx, entityId)
|
|
256
|
+
if (subscription == null) {
|
|
257
|
+
throw new PortalUnavailable('subscription')
|
|
258
|
+
}
|
|
259
|
+
if (opts.flow === PortalFlow.Cancel) {
|
|
260
|
+
flowData = { type: 'subscription_cancel', subscription_cancel: { subscription: subscription.externalId } }
|
|
261
|
+
} else if (opts.flow === PortalFlow.Update) {
|
|
262
|
+
flowData = { type: 'subscription_update', subscription_update: { subscription: subscription.externalId } }
|
|
263
|
+
} else {
|
|
264
|
+
if (opts.planSku == null || opts.planSku === '') {
|
|
265
|
+
throw new PortalUnavailable('plan')
|
|
266
|
+
}
|
|
267
|
+
const plan = await findPlan(ctx, opts.planSku)
|
|
268
|
+
const product = plan != null ? await findProduct(ctx, plan.productSku) : null
|
|
269
|
+
if (plan == null || product == null) {
|
|
270
|
+
throw new UnknownPlan(opts.planSku)
|
|
271
|
+
}
|
|
272
|
+
if (subscription.itemId == null) {
|
|
273
|
+
throw new PortalUnavailable('item')
|
|
274
|
+
}
|
|
275
|
+
const lookupKey = planLookupKey(product, plan)
|
|
276
|
+
const [price] = (await stripe.prices.list({ lookup_keys: [lookupKey], active: true, limit: 1 })).data
|
|
277
|
+
if (price == null) {
|
|
278
|
+
throw new ProductError(`price:${lookupKey}`)
|
|
279
|
+
}
|
|
280
|
+
flowData = {
|
|
281
|
+
type: 'subscription_update_confirm',
|
|
282
|
+
subscription_update_confirm: {
|
|
283
|
+
subscription: subscription.externalId,
|
|
284
|
+
items: [{ id: subscription.itemId, price: price.id, quantity: 1 }],
|
|
285
|
+
},
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
break
|
|
289
|
+
}
|
|
290
|
+
default:
|
|
291
|
+
throw new PortalUnavailable(`flow:${String(opts.flow)}`)
|
|
292
|
+
}
|
|
293
|
+
if (flowData != null) {
|
|
294
|
+
flowData.after_completion = { type: 'redirect', redirect: { return_url: opts.returnUrl } }
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const session = await stripe.billingPortal.sessions.create({
|
|
298
|
+
customer: customer.externalId,
|
|
299
|
+
return_url: opts.returnUrl,
|
|
300
|
+
...(configuration != null ? { configuration } : {}),
|
|
301
|
+
...(flowData != null ? { flow_data: flowData } : {}),
|
|
302
|
+
})
|
|
303
|
+
|
|
304
|
+
return session.url
|
|
305
|
+
}
|
|
306
|
+
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import type Stripe from 'stripe'
|
|
2
|
+
import type { Context as ApiContext } from '@owlmeans/server-api'
|
|
3
|
+
import { STRIPE_PAYGATE_ALIAS } from '../consts.js'
|
|
4
|
+
import { fulfillments, idOf, isMissingObject, subscriptions } from '../utils.js'
|
|
5
|
+
import type { PaymentFulfillmentRecord, PaymentSubscriptionRecord } from '../types.js'
|
|
6
|
+
|
|
7
|
+
/** What a refund or a dispute is about: a one-time fulfillment, or one invoice of a subscription. */
|
|
8
|
+
export type PaymentTarget =
|
|
9
|
+
| { kind: 'fulfillment', record: PaymentFulfillmentRecord, charge: Stripe.Charge | null, paymentIntentId?: string }
|
|
10
|
+
| {
|
|
11
|
+
kind: 'subscription', record: PaymentSubscriptionRecord, charge: Stripe.Charge | null,
|
|
12
|
+
paymentIntentId?: string, invoiceId: string,
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface PaymentReference {
|
|
16
|
+
paymentIntentId?: string
|
|
17
|
+
chargeId?: string
|
|
18
|
+
invoiceId?: string
|
|
19
|
+
/** The charge, when the event already carries it. */
|
|
20
|
+
charge?: Stripe.Charge | null
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** A charge by id, or `null` when the paygate has none. */
|
|
24
|
+
export const retrieveCharge = async (stripe: Stripe, chargeId: string): Promise<Stripe.Charge | null> => {
|
|
25
|
+
try {
|
|
26
|
+
return await stripe.charges.retrieve(chargeId)
|
|
27
|
+
} catch (error) {
|
|
28
|
+
if (isMissingObject(error)) {
|
|
29
|
+
return null
|
|
30
|
+
}
|
|
31
|
+
throw error
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const subscriptionOfInvoice = async (
|
|
36
|
+
ctx: ApiContext, stripe: Stripe, invoiceId: string,
|
|
37
|
+
): Promise<PaymentSubscriptionRecord | null> => {
|
|
38
|
+
const latest = await subscriptions(ctx).load({ paygate: STRIPE_PAYGATE_ALIAS, latestInvoiceId: invoiceId })
|
|
39
|
+
if (latest != null) {
|
|
40
|
+
return latest
|
|
41
|
+
}
|
|
42
|
+
let invoice: Stripe.Invoice
|
|
43
|
+
try {
|
|
44
|
+
invoice = await stripe.invoices.retrieve(invoiceId)
|
|
45
|
+
} catch (error) {
|
|
46
|
+
if (isMissingObject(error)) {
|
|
47
|
+
return null
|
|
48
|
+
}
|
|
49
|
+
throw error
|
|
50
|
+
}
|
|
51
|
+
// `invoice.subscription` is top-level on the API version the client is pinned to.
|
|
52
|
+
const subscriptionId = idOf((invoice as unknown as { subscription?: string | { id?: string } | null }).subscription)
|
|
53
|
+
|
|
54
|
+
return subscriptionId != null
|
|
55
|
+
? await subscriptions(ctx).byExternalId(subscriptionId, STRIPE_PAYGATE_ALIAS)
|
|
56
|
+
: null
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Resolve a refund or a dispute to the record it is about: by payment intent to a fulfillment;
|
|
61
|
+
* by charge to a fulfillment (stored charge, else the charge's payment intent — whose charge id is
|
|
62
|
+
* then remembered), else the charge's invoice; by invoice to the subscription whose latest invoice
|
|
63
|
+
* it is, else the invoice's subscription. `null` when nothing here paid for it.
|
|
64
|
+
*/
|
|
65
|
+
export const resolvePaymentTarget = async (
|
|
66
|
+
ctx: ApiContext, stripe: Stripe, ref: PaymentReference,
|
|
67
|
+
): Promise<PaymentTarget | null> => {
|
|
68
|
+
const ledger = fulfillments(ctx)
|
|
69
|
+
let charge = ref.charge ?? null
|
|
70
|
+
let paymentIntentId = ref.paymentIntentId
|
|
71
|
+
const chargeId = ref.chargeId ?? charge?.id
|
|
72
|
+
|
|
73
|
+
if (paymentIntentId != null) {
|
|
74
|
+
const record = await ledger.load({ paygate: STRIPE_PAYGATE_ALIAS, paymentIntentId })
|
|
75
|
+
if (record != null) {
|
|
76
|
+
return { kind: 'fulfillment', record, charge, paymentIntentId }
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
let invoiceId = ref.invoiceId
|
|
81
|
+
if (chargeId != null) {
|
|
82
|
+
const record = await ledger.load({ paygate: STRIPE_PAYGATE_ALIAS, chargeId })
|
|
83
|
+
if (record != null) {
|
|
84
|
+
return { kind: 'fulfillment', record, charge, paymentIntentId: paymentIntentId ?? record.paymentIntentId }
|
|
85
|
+
}
|
|
86
|
+
charge = charge ?? await retrieveCharge(stripe, chargeId)
|
|
87
|
+
const chargeIntent = idOf(charge?.payment_intent)
|
|
88
|
+
if (chargeIntent != null && chargeIntent !== paymentIntentId) {
|
|
89
|
+
paymentIntentId = chargeIntent
|
|
90
|
+
const byIntent = await ledger.load({ paygate: STRIPE_PAYGATE_ALIAS, paymentIntentId: chargeIntent })
|
|
91
|
+
if (byIntent != null) {
|
|
92
|
+
const record = byIntent.chargeId == null ? await ledger.update({ ...byIntent, chargeId }) : byIntent
|
|
93
|
+
return { kind: 'fulfillment', record, charge, paymentIntentId: chargeIntent }
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
// `charge.invoice` is top-level on the API version the client is pinned to.
|
|
97
|
+
invoiceId = invoiceId ?? idOf((charge as unknown as { invoice?: string | { id?: string } | null } | null)?.invoice)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (invoiceId != null) {
|
|
101
|
+
const record = await subscriptionOfInvoice(ctx, stripe, invoiceId)
|
|
102
|
+
if (record != null) {
|
|
103
|
+
return { kind: 'subscription', record, charge, paymentIntentId, invoiceId }
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return null
|
|
108
|
+
}
|