@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.
Files changed (222) hide show
  1. package/README.md +109 -25
  2. package/agent-meta/manifest.json +2 -2
  3. package/agent-meta/skills/server-payment/SKILL.md +475 -52
  4. package/build/actions/index.d.ts +1 -0
  5. package/build/actions/index.d.ts.map +1 -1
  6. package/build/actions/index.js +1 -0
  7. package/build/actions/index.js.map +1 -1
  8. package/build/actions/resync-subscriptions.d.ts +3 -0
  9. package/build/actions/resync-subscriptions.d.ts.map +1 -0
  10. package/build/actions/resync-subscriptions.js +7 -0
  11. package/build/actions/resync-subscriptions.js.map +1 -0
  12. package/build/actions/resync.d.ts +1 -0
  13. package/build/actions/resync.d.ts.map +1 -1
  14. package/build/actions/resync.js +7 -3
  15. package/build/actions/resync.js.map +1 -1
  16. package/build/actions/webhook.d.ts.map +1 -1
  17. package/build/actions/webhook.js +6 -3
  18. package/build/actions/webhook.js.map +1 -1
  19. package/build/config.d.ts +60 -5
  20. package/build/config.d.ts.map +1 -1
  21. package/build/config.js +237 -5
  22. package/build/config.js.map +1 -1
  23. package/build/consts.d.ts +75 -3
  24. package/build/consts.d.ts.map +1 -1
  25. package/build/consts.js +94 -5
  26. package/build/consts.js.map +1 -1
  27. package/build/consumer/capture.d.ts +74 -0
  28. package/build/consumer/capture.d.ts.map +1 -0
  29. package/build/consumer/capture.js +291 -0
  30. package/build/consumer/capture.js.map +1 -0
  31. package/build/consumer/format.d.ts +27 -0
  32. package/build/consumer/format.d.ts.map +1 -0
  33. package/build/consumer/format.js +81 -0
  34. package/build/consumer/format.js.map +1 -0
  35. package/build/consumer/handlers.d.ts +28 -0
  36. package/build/consumer/handlers.d.ts.map +1 -0
  37. package/build/consumer/handlers.js +173 -0
  38. package/build/consumer/handlers.js.map +1 -0
  39. package/build/consumer/index.d.ts +7 -0
  40. package/build/consumer/index.d.ts.map +1 -0
  41. package/build/consumer/index.js +6 -0
  42. package/build/consumer/index.js.map +1 -0
  43. package/build/consumer/mail.d.ts +27 -0
  44. package/build/consumer/mail.d.ts.map +1 -0
  45. package/build/consumer/mail.js +314 -0
  46. package/build/consumer/mail.js.map +1 -0
  47. package/build/consumer/origin.d.ts +14 -0
  48. package/build/consumer/origin.d.ts.map +1 -0
  49. package/build/consumer/origin.js +47 -0
  50. package/build/consumer/origin.js.map +1 -0
  51. package/build/consumer/reconcile.d.ts +12 -0
  52. package/build/consumer/reconcile.d.ts.map +1 -0
  53. package/build/consumer/reconcile.js +317 -0
  54. package/build/consumer/reconcile.js.map +1 -0
  55. package/build/consumer/records.d.ts +78 -0
  56. package/build/consumer/records.d.ts.map +1 -0
  57. package/build/consumer/records.js +296 -0
  58. package/build/consumer/records.js.map +1 -0
  59. package/build/consumer/service.d.ts +51 -0
  60. package/build/consumer/service.d.ts.map +1 -0
  61. package/build/consumer/service.js +760 -0
  62. package/build/consumer/service.js.map +1 -0
  63. package/build/consumer/withdrawal.d.ts +57 -0
  64. package/build/consumer/withdrawal.d.ts.map +1 -0
  65. package/build/consumer/withdrawal.js +247 -0
  66. package/build/consumer/withdrawal.js.map +1 -0
  67. package/build/entitlement.d.ts +10 -0
  68. package/build/entitlement.d.ts.map +1 -0
  69. package/build/entitlement.js +69 -0
  70. package/build/entitlement.js.map +1 -0
  71. package/build/entrypoints.d.ts +1 -1
  72. package/build/entrypoints.d.ts.map +1 -1
  73. package/build/entrypoints.js +2 -1
  74. package/build/entrypoints.js.map +1 -1
  75. package/build/gate.d.ts +32 -4
  76. package/build/gate.d.ts.map +1 -1
  77. package/build/gate.js +59 -19
  78. package/build/gate.js.map +1 -1
  79. package/build/index.d.ts +18 -3
  80. package/build/index.d.ts.map +1 -1
  81. package/build/index.js +15 -3
  82. package/build/index.js.map +1 -1
  83. package/build/limit.d.ts +13 -0
  84. package/build/limit.d.ts.map +1 -0
  85. package/build/limit.js +47 -0
  86. package/build/limit.js.map +1 -0
  87. package/build/model.d.ts +10 -1
  88. package/build/model.d.ts.map +1 -1
  89. package/build/model.js +215 -17
  90. package/build/model.js.map +1 -1
  91. package/build/observer.d.ts +9 -0
  92. package/build/observer.d.ts.map +1 -1
  93. package/build/observer.js +38 -12
  94. package/build/observer.js.map +1 -1
  95. package/build/plan.d.ts +22 -0
  96. package/build/plan.d.ts.map +1 -0
  97. package/build/plan.js +72 -0
  98. package/build/plan.js.map +1 -0
  99. package/build/plugins/checkout-plugins.d.ts +49 -0
  100. package/build/plugins/checkout-plugins.d.ts.map +1 -0
  101. package/build/plugins/checkout-plugins.js +124 -0
  102. package/build/plugins/checkout-plugins.js.map +1 -0
  103. package/build/plugins/estimate.d.ts +43 -0
  104. package/build/plugins/estimate.d.ts.map +1 -0
  105. package/build/plugins/estimate.js +268 -0
  106. package/build/plugins/estimate.js.map +1 -0
  107. package/build/plugins/events.d.ts +45 -10
  108. package/build/plugins/events.d.ts.map +1 -1
  109. package/build/plugins/events.js +605 -136
  110. package/build/plugins/events.js.map +1 -1
  111. package/build/plugins/fx.d.ts +31 -0
  112. package/build/plugins/fx.d.ts.map +1 -0
  113. package/build/plugins/fx.js +81 -0
  114. package/build/plugins/fx.js.map +1 -0
  115. package/build/plugins/portal.d.ts +37 -0
  116. package/build/plugins/portal.d.ts.map +1 -0
  117. package/build/plugins/portal.js +265 -0
  118. package/build/plugins/portal.js.map +1 -0
  119. package/build/plugins/refunds.d.ts +33 -0
  120. package/build/plugins/refunds.d.ts.map +1 -0
  121. package/build/plugins/refunds.js +80 -0
  122. package/build/plugins/refunds.js.map +1 -0
  123. package/build/plugins/stripe.d.ts +36 -4
  124. package/build/plugins/stripe.d.ts.map +1 -1
  125. package/build/plugins/stripe.js +492 -97
  126. package/build/plugins/stripe.js.map +1 -1
  127. package/build/plugins/webhook-manager.d.ts +46 -0
  128. package/build/plugins/webhook-manager.d.ts.map +1 -0
  129. package/build/plugins/webhook-manager.js +231 -0
  130. package/build/plugins/webhook-manager.js.map +1 -0
  131. package/build/reconcile.d.ts +15 -0
  132. package/build/reconcile.d.ts.map +1 -0
  133. package/build/reconcile.js +88 -0
  134. package/build/reconcile.js.map +1 -0
  135. package/build/resource.d.ts +12 -1
  136. package/build/resource.d.ts.map +1 -1
  137. package/build/resource.js +97 -6
  138. package/build/resource.js.map +1 -1
  139. package/build/service.d.ts +28 -3
  140. package/build/service.d.ts.map +1 -1
  141. package/build/service.js +146 -19
  142. package/build/service.js.map +1 -1
  143. package/build/subscription.d.ts +48 -0
  144. package/build/subscription.d.ts.map +1 -0
  145. package/build/subscription.js +176 -0
  146. package/build/subscription.js.map +1 -0
  147. package/build/sync.d.ts +28 -1
  148. package/build/sync.d.ts.map +1 -1
  149. package/build/sync.js +208 -31
  150. package/build/sync.js.map +1 -1
  151. package/build/types.d.ts +1115 -36
  152. package/build/types.d.ts.map +1 -1
  153. package/build/usage.d.ts +63 -0
  154. package/build/usage.d.ts.map +1 -0
  155. package/build/usage.js +363 -0
  156. package/build/usage.js.map +1 -0
  157. package/build/utils.d.ts +52 -1
  158. package/build/utils.d.ts.map +1 -1
  159. package/build/utils.js +69 -7
  160. package/build/utils.js.map +1 -1
  161. package/package.json +17 -13
  162. package/src/actions/index.ts +1 -0
  163. package/src/actions/resync-subscriptions.ts +10 -0
  164. package/src/actions/resync.ts +6 -3
  165. package/src/actions/webhook.ts +5 -3
  166. package/src/config.ts +264 -8
  167. package/src/consts.ts +114 -6
  168. package/src/consumer/capture.ts +362 -0
  169. package/src/consumer/format.ts +90 -0
  170. package/src/consumer/handlers.ts +211 -0
  171. package/src/consumer/index.ts +6 -0
  172. package/src/consumer/mail.ts +368 -0
  173. package/src/consumer/origin.ts +63 -0
  174. package/src/consumer/reconcile.ts +329 -0
  175. package/src/consumer/records.ts +374 -0
  176. package/src/consumer/service.ts +868 -0
  177. package/src/consumer/withdrawal.ts +302 -0
  178. package/src/entitlement.ts +84 -0
  179. package/src/entrypoints.ts +2 -1
  180. package/src/gate.ts +88 -21
  181. package/src/index.ts +24 -3
  182. package/src/limit.ts +57 -0
  183. package/src/model.ts +237 -18
  184. package/src/observer.ts +44 -11
  185. package/src/plan.ts +89 -0
  186. package/src/plugins/checkout-plugins.ts +155 -0
  187. package/src/plugins/estimate.ts +339 -0
  188. package/src/plugins/events.ts +677 -121
  189. package/src/plugins/fx.ts +122 -0
  190. package/src/plugins/portal.ts +306 -0
  191. package/src/plugins/refunds.ts +108 -0
  192. package/src/plugins/stripe.ts +581 -96
  193. package/src/plugins/webhook-manager.ts +270 -0
  194. package/src/reconcile.ts +103 -0
  195. package/src/resource.ts +152 -7
  196. package/src/service.ts +174 -18
  197. package/src/subscription.ts +231 -0
  198. package/src/sync.ts +249 -29
  199. package/src/types.ts +1227 -32
  200. package/src/usage.ts +453 -0
  201. package/src/utils.ts +127 -10
  202. package/tests/checkout-consumer.spec.ts +348 -0
  203. package/tests/checkout-plugins.spec.ts +164 -0
  204. package/tests/checkout.spec.ts +184 -83
  205. package/tests/consumer-events.spec.ts +218 -0
  206. package/tests/consumer-fixtures.ts +132 -0
  207. package/tests/consumer-ops.spec.ts +351 -0
  208. package/tests/consumer-rights.integration.spec.ts +150 -0
  209. package/tests/consumer-rights.spec.ts +501 -0
  210. package/tests/context.ts +109 -0
  211. package/tests/entitlement.spec.ts +103 -0
  212. package/tests/estimate.spec.ts +240 -0
  213. package/tests/events.spec.ts +356 -0
  214. package/tests/fake-stripe.ts +972 -0
  215. package/tests/gate.spec.ts +62 -72
  216. package/tests/limit-gate.spec.ts +68 -0
  217. package/tests/portal.spec.ts +200 -0
  218. package/tests/protocol.spec.ts +23 -6
  219. package/tests/sync.spec.ts +114 -0
  220. package/tests/usage.integration.spec.ts +101 -0
  221. package/tests/usage.spec.ts +171 -0
  222. 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
+ }