@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
package/src/consts.ts CHANGED
@@ -1,21 +1,114 @@
1
- import { contract, protocol, protocols, schema, typed } from '@owlmeans/entrypoint'
1
+ import { contract, protocol, schema, typed } from '@owlmeans/entrypoint'
2
2
  import { backend, route, RouteMethod } from '@owlmeans/route'
3
3
  import { GUARD_ED25519 } from '@owlmeans/server-app'
4
+ import { INTERNAL_PAYGATE, LIMIT_GATE } from '@owlmeans/payment'
4
5
  import type { JSONSchemaType } from 'ajv'
5
6
 
7
+ export { INTERNAL_PAYGATE, LIMIT_GATE }
8
+
6
9
  export const STRIPE_PAYGATE_ALIAS = 'stripe'
7
10
  export const STRIPE_PLUGIN_CONFIG = '_external:stripe'
11
+ export const STRIPE_PORTAL_PLUGIN_CONFIG = '_external:stripe-portal'
12
+ /** Stripe-only pricing settings (FX Quotes API version, the unspecified-price migration switch) — never advertised to the browser, unlike the `PricingPolicy` record itself. */
13
+ export const STRIPE_PRICING_PLUGIN_CONFIG = '_external:stripe-pricing'
8
14
  export const STRIPE_SIGNATURE = 'Stripe-Signature'
15
+
16
+ /**
17
+ * The FX Quotes API is a Stripe PREVIEW endpoint as of this writing: it answers only a
18
+ * `Stripe-Version` header naming a preview version, never the SDK's pinned stable one. Verify this
19
+ * string against https://docs.stripe.com/api/fx_quotes/create before relying on it, and override it
20
+ * with `declarePaymentPricing({ stripe: { fxApiVersion } })` once Stripe moves the API or renames
21
+ * the preview.
22
+ */
23
+ export const STRIPE_FX_QUOTES_API_VERSION = '2025-07-30.preview'
9
24
  export const GATEWAY_SERVICE = 'payment-gateway'
10
25
  export const PAYMENT_OBSERVER = 'payment-observer'
26
+ export const ENTITLEMENT_SERVICE = 'payment-entitlement'
27
+
11
28
  export const RES_PAYGATE_CUSTOMER = 'payment-paygate-customer'
12
29
  export const RES_PAYMENT_SUBSCRIPTION = 'payment-subscription'
30
+ export const RES_PAYMENT_FULFILLMENT = 'payment-fulfillment'
31
+ export const RES_PAYMENT_WEBHOOK = 'payment-webhook'
32
+ export const RES_PAYMENT_USAGE = 'payment-usage'
33
+ export const RES_PAYMENT_USAGE_COUNTER = 'payment-usage-counter'
13
34
  export const RES_PAYMENT_FINGERPRINT = 'payment-fingerprint'
14
- export const COMPLETION_TOP_UP = 'top-up'
15
- export const COMPLETION_SUBSCRIPTION = 'subscription'
35
+
36
+ /** One per organization: the billing country fixed at the first purchase. */
37
+ export const RES_BILLING_PROFILE = 'payment-billing-profile'
38
+ /** One per paid checkout (a top-up, a subscription's first invoice): the contract and its window. */
39
+ export const RES_PAYMENT_PURCHASE = 'payment-purchase'
40
+ /** Append-only: express consents to early performance and subscription start requests. */
41
+ export const RES_CONSUMER_CONSENT = 'payment-consumer-consent'
42
+ /** Append-only: withdrawal and cancellation declarations. */
43
+ export const RES_CONSUMER_DECLARATION = 'payment-consumer-declaration'
44
+ /** Append-only: every execution and audit step of the consumer-rights flows. */
45
+ export const RES_CONSUMER_EVENT = 'payment-consumer-event'
46
+
47
+ export const CONSUMER_RIGHTS_SERVICE = 'payment-consumer-rights'
48
+ /** The consumer-rights mail options (trader, sender, archive copies) — never advertised. */
49
+ export const CONSUMER_RIGHTS_MAIL_PLUGIN_CONFIG = '_external:payment-consumer-mail'
50
+
51
+ /** Stripe's own bounds of a Checkout Session's `expires_at`, from now. */
52
+ export const STRIPE_SESSION_TTL_MIN_SECONDS = 30 * 60
53
+ export const STRIPE_SESSION_TTL_MAX_SECONDS = 24 * 60 * 60
54
+
55
+ /** How far back `reconcile` looks for completed sessions that have no purchase row. */
56
+ export const PURCHASE_BACKFILL_DAYS = 16
57
+
58
+ /** Top-level domains that never receive mail (RFC 2606 / 6761) — e2e addresses are recorded as skipped. */
59
+ export const RESERVED_MAIL_TLDS: readonly string[] = Object.freeze(['test', 'example', 'invalid', 'localhost'])
60
+
61
+ /** The purchase id prefix of a Stripe purchase: `stripe:<session id>` or `stripe:<subscription id>`. */
62
+ export const PURCHASE_ID_PREFIX = 'stripe'
63
+
64
+ /**
65
+ * The contract reference alphabet: digits and capitals without the look-alikes 0/O, 1/I/L, so a
66
+ * reference read aloud or typed from paper is never ambiguous.
67
+ */
68
+ export const CONTRACT_REF_ALPHABET = '23456789ABCDEFGHJKMNPQRSTUVWXYZ'
69
+
70
+ /** Fingerprint sku prefix of a portal configuration: `portal:<service>`. */
71
+ export const FINGERPRINT_PORTAL = 'portal'
72
+
73
+ /**
74
+ * The metadata every Stripe object this package creates carries (`{ owlmeans: 'payment', service }`).
75
+ * It labels the object for an operator; several deployments of one service share it, so it never
76
+ * decides on its own that an object belongs to this deployment.
77
+ */
78
+ export const STRIPE_OWNER_KEY = 'owlmeans'
79
+ export const STRIPE_OWNER_VALUE = 'payment'
80
+
81
+ /**
82
+ * The metadata key naming the ONE deployment a portal configuration belongs to, valued with that
83
+ * deployment's webhook URL (`webhookUrlOf`). Only an exact match lets a deployment adopt a
84
+ * configuration it holds no fingerprint row for.
85
+ */
86
+ export const STRIPE_DEPLOYMENT_KEY = 'deployment'
87
+
88
+ /**
89
+ * Every Stripe event the managed webhook endpoint subscribes to.
90
+ *
91
+ * `invoice.upcoming` is enabled although nothing handles it (it carries no invoice id and nothing
92
+ * is due), so an application can observe it without a Stripe dashboard change.
93
+ */
94
+ export const WEBHOOK_EVENTS: readonly string[] = Object.freeze([
95
+ 'checkout.session.completed', 'checkout.session.async_payment_succeeded',
96
+ 'checkout.session.async_payment_failed', 'checkout.session.expired',
97
+ 'customer.created', 'customer.updated', 'customer.deleted',
98
+ 'customer.subscription.created', 'customer.subscription.updated', 'customer.subscription.deleted',
99
+ 'customer.subscription.paused', 'customer.subscription.resumed', 'customer.subscription.trial_will_end',
100
+ 'customer.subscription.pending_update_applied', 'customer.subscription.pending_update_expired',
101
+ 'invoice.paid', 'invoice.payment_failed', 'invoice.payment_action_required', 'invoice.upcoming',
102
+ 'invoice.marked_uncollectible', 'invoice.voided',
103
+ 'charge.refunded', 'refund.created', 'refund.updated', 'refund.failed',
104
+ 'charge.dispute.created', 'charge.dispute.closed', 'charge.dispute.funds_withdrawn',
105
+ 'charge.dispute.funds_reinstated',
106
+ ])
16
107
 
17
108
  export interface PaygateParams { paygate: string }
18
109
  export interface ResyncResult { ok: boolean }
110
+ export interface ResyncSubscriptionsResult { scanned: number; updated: number }
111
+
19
112
  const PaygateParamsSchema = schema<PaygateParams>({
20
113
  type: 'object', properties: { paygate: { type: 'string' } }, required: ['paygate'],
21
114
  additionalProperties: false,
@@ -24,24 +117,39 @@ const ResyncResultSchema = schema<ResyncResult>({
24
117
  type: 'object', properties: { ok: { type: 'boolean' } }, required: ['ok'],
25
118
  additionalProperties: false,
26
119
  } as JSONSchemaType<ResyncResult>)
120
+ const ResyncSubscriptionsResultSchema = schema<ResyncSubscriptionsResult>({
121
+ type: 'object',
122
+ properties: { scanned: { type: 'number' }, updated: { type: 'number' } },
123
+ required: ['scanned', 'updated'],
124
+ additionalProperties: false,
125
+ } as JSONSchemaType<ResyncSubscriptionsResult>)
27
126
 
28
127
  const aliases = {
29
- base: 'payment-gate', webhook: 'payment-gate:webhook', resync: 'payment-gate:resync',
128
+ base: 'payment-gate',
129
+ webhook: 'payment-gate:webhook',
130
+ resync: 'payment-gate:resync',
131
+ resyncSubscriptions: 'payment-gate:resync-subscriptions',
30
132
  } as const
31
133
  const base = protocol(route(aliases.base, '/payment-gate', backend()), contract())
32
134
 
33
135
  /** Embedded gateway protocol tree; the alias strings are private adapter details. */
34
136
  export const paymentGate = {
35
137
  base,
138
+ /** Public: Stripe signs the raw body, so no application guard may sit in front of it. */
36
139
  webhook: protocol(
37
140
  route(aliases.webhook, '/webhook/:paygate', backend({ parent: base, method: RouteMethod.POST })),
38
141
  contract.request({ params: PaygateParamsSchema }, typed<undefined>()),
39
142
  ),
143
+ /** Re-sync products, prices, the portal configuration and the webhook endpoint. */
40
144
  resync: protocol(
41
145
  route(aliases.resync, '/resync', backend({ parent: base, method: RouteMethod.POST })),
42
146
  contract(ResyncResultSchema),
43
147
  { guards: GUARD_ED25519 },
44
148
  ),
149
+ /** Re-read every live paygate subscription and apply it as a webhook would. */
150
+ resyncSubscriptions: protocol(
151
+ route(aliases.resyncSubscriptions, '/resync-subscriptions', backend({ parent: base, method: RouteMethod.POST })),
152
+ contract(ResyncSubscriptionsResultSchema),
153
+ { guards: GUARD_ED25519 },
154
+ ),
45
155
  } as const
46
-
47
- export const paymentGateProtocols = protocols(paymentGate)
@@ -0,0 +1,362 @@
1
+ import type Stripe from 'stripe'
2
+ import {
3
+ billingLanguageOf, ConsentKind, CONSUMER_RIGHTS_COPY_VERSION, inScope, PurchaseKind, regionOf,
4
+ withdrawalDeadlineOf,
5
+ } from '@owlmeans/payment'
6
+ import type { ConsumerRightsPolicy } from '@owlmeans/payment'
7
+ import type { Context as ApiContext } from '@owlmeans/server-api'
8
+ import { STRIPE_PAYGATE_ALIAS } from '../consts.js'
9
+ import {
10
+ billingProfiles, compact, conditionalSet, consumerConsents, dateOf, idOf, isMissingObject, payment, purchases,
11
+ } from '../utils.js'
12
+ import { sendConsumerMail } from './mail.js'
13
+ import {
14
+ createPurchase, hasEvent, lockProfile, patchPurchase, purchaseIdOf,
15
+ } from './records.js'
16
+ import type { PurchaseDraft } from './records.js'
17
+ import type { BillingProfileRecord, PaymentSubscriptionRecord, PurchaseRecord } from '../types.js'
18
+
19
+ /** What a completed Checkout Session says about its buyer and totals. */
20
+ export interface SessionEvidence {
21
+ country?: string
22
+ email?: string
23
+ name?: string
24
+ business?: boolean
25
+ currency: string
26
+ subtotalMinor: number
27
+ taxMinor: number
28
+ totalMinor: number
29
+ presentmentCurrency?: string
30
+ presentmentAmountMinor?: number
31
+ termsAccepted?: boolean
32
+ }
33
+
34
+ /**
35
+ * The buyer and totals of a Checkout Session. `presentment_details` (Adaptive Pricing) is not typed
36
+ * by the pinned SDK, hence the narrow accessor.
37
+ */
38
+ export const sessionEvidenceOf = (session: Stripe.Checkout.Session): SessionEvidence => {
39
+ const details = session.customer_details
40
+ const presentment = (session as unknown as {
41
+ presentment_details?: { presentment_amount?: number, presentment_currency?: string } | null
42
+ }).presentment_details
43
+ const subtotal = session.amount_subtotal ?? 0
44
+ const tax = session.total_details?.amount_tax ?? 0
45
+
46
+ return compact({
47
+ country: details?.address?.country?.toUpperCase() ?? undefined,
48
+ email: details?.email ?? undefined,
49
+ name: details?.name ?? undefined,
50
+ business: (details?.tax_ids?.length ?? 0) > 0 || details?.tax_exempt === 'reverse' ? true : undefined,
51
+ currency: (session.currency ?? '').toLowerCase(),
52
+ subtotalMinor: subtotal,
53
+ taxMinor: tax,
54
+ totalMinor: session.amount_total ?? subtotal + tax,
55
+ presentmentCurrency: presentment?.presentment_currency?.toLowerCase(),
56
+ presentmentAmountMinor: presentment?.presentment_amount,
57
+ termsAccepted: session.consent?.terms_of_service === 'accepted' ? true
58
+ : session.consent?.terms_of_service != null ? false : undefined,
59
+ }) as SessionEvidence
60
+ }
61
+
62
+ /** What an invoice adds to a purchase: its number, its line and the payment behind it. */
63
+ export interface InvoiceEvidence {
64
+ invoiceNumber?: string
65
+ invoiceLineId?: string
66
+ paymentIntentId?: string
67
+ country?: string
68
+ subtotalMinor?: number
69
+ taxMinor?: number
70
+ totalMinor?: number
71
+ currency?: string
72
+ }
73
+
74
+ /**
75
+ * An invoice's evidence, best effort: an unreachable invoice is logged and yields nothing — the
76
+ * withdrawal reads it again when it needs it. `invoice.payment_intent` is top-level on the pinned
77
+ * API version.
78
+ */
79
+ export const invoiceEvidenceOf = async (stripe: Stripe | null | undefined, invoiceId: string | undefined): Promise<InvoiceEvidence> => {
80
+ if (stripe == null || invoiceId == null) {
81
+ return {}
82
+ }
83
+ try {
84
+ const invoice = await stripe.invoices.retrieve(invoiceId)
85
+ const typed = invoice as unknown as { payment_intent?: string | { id?: string } | null, tax?: number | null }
86
+
87
+ return compact({
88
+ invoiceNumber: invoice.number ?? undefined,
89
+ invoiceLineId: invoice.lines?.data?.[0]?.id,
90
+ paymentIntentId: idOf(typed.payment_intent),
91
+ country: invoice.customer_address?.country?.toUpperCase() ?? undefined,
92
+ subtotalMinor: invoice.subtotal ?? undefined,
93
+ taxMinor: typed.tax ?? undefined,
94
+ totalMinor: invoice.total ?? undefined,
95
+ currency: invoice.currency ?? undefined,
96
+ }) as InvoiceEvidence
97
+ } catch (error) {
98
+ if (!isMissingObject(error)) {
99
+ console.warn(`[payment] invoice "${invoiceId}" unreadable for its purchase`, error)
100
+ }
101
+ return {}
102
+ }
103
+ }
104
+
105
+ const deadlineOf = (policy: ConsumerRightsPolicy, scoped: boolean, purchasedAt: Date): Date | undefined =>
106
+ scoped ? withdrawalDeadlineOf(purchasedAt, policy) : undefined
107
+
108
+ /** Lock the entity's billing country from a completed checkout, when the policy locks countries. */
109
+ const lockFromSession = async (
110
+ ctx: ApiContext, policy: ConsumerRightsPolicy, entityId: string, session: Stripe.Checkout.Session,
111
+ evidence: SessionEvidence,
112
+ ): Promise<BillingProfileRecord | null> => {
113
+ const metadata = session.metadata ?? {}
114
+ const country = evidence.country ?? metadata.country
115
+ if (!policy.mechanisms.countryLock || country == null || country === '') {
116
+ return null
117
+ }
118
+ const { record } = await lockProfile(ctx, policy, {
119
+ entityId, country, source: 'checkout', customerId: idOf(session.customer), sessionId: session.id,
120
+ ipCountry: metadata.ipCountry, email: evidence.email, name: evidence.name, business: evidence.business,
121
+ currency: evidence.currency, language: metadata.language,
122
+ })
123
+
124
+ return record
125
+ }
126
+
127
+ /**
128
+ * Mail the purchase confirmation once per purchase: only the delivery that claims the purchase's
129
+ * `confirmationMailAt` (a conditional write — concurrent deliveries in several processes, a
130
+ * redelivery, the checkout refining a subscription) sends it; one that failed is retried by
131
+ * `reconcile`, never here. A purchase mailed before the claim existed is recognised by its event.
132
+ */
133
+ const confirmPurchase = async (ctx: ApiContext, policy: ConsumerRightsPolicy, purchase: PurchaseRecord): Promise<void> => {
134
+ if (!policy.mechanisms.purchaseConfirmation || !purchase.inScope) {
135
+ return
136
+ }
137
+ if (await hasEvent(ctx, purchase.purchaseId, 'mail', 'purchase')) {
138
+ return
139
+ }
140
+ const claimed = await conditionalSet(purchases(ctx), { purchaseId: purchase.purchaseId, confirmationMailAt: null }, {
141
+ confirmationMailAt: new Date(),
142
+ })
143
+ if (!claimed) {
144
+ return
145
+ }
146
+ await sendConsumerMail(ctx, policy, 'purchase', purchase.purchaseId)
147
+ }
148
+
149
+ export interface CapturedPurchase {
150
+ purchase: PurchaseRecord
151
+ created: boolean
152
+ }
153
+
154
+ /**
155
+ * A completed, paid ONE-TIME checkout as a purchase — the window and the contract registry. Runs
156
+ * BEFORE the credits are granted: the billing country is locked first (first write wins; another
157
+ * country is a `lock-mismatch` event), then the purchase row is written, then its confirmation is
158
+ * mailed. `null` when no consumer-rights policy is declared.
159
+ */
160
+ export const capturePaymentPurchase = async (
161
+ ctx: ApiContext, stripe: Stripe | null, session: Stripe.Checkout.Session,
162
+ extra: { netAmountMinor?: number, amountCurrency?: string, units?: number, taxBehavior?: string, at?: Date } = {},
163
+ opts: { mail?: boolean } = {},
164
+ ): Promise<CapturedPurchase | null> => {
165
+ const policy = await payment(ctx).consumerRightsPolicy()
166
+ if (policy == null) {
167
+ return null
168
+ }
169
+ const metadata = session.metadata ?? {}
170
+ const entityId = metadata.entityId
171
+ if (entityId == null || metadata.productSku == null) {
172
+ return null
173
+ }
174
+ const evidence = sessionEvidenceOf(session)
175
+ const profile = await lockFromSession(ctx, policy, entityId, session, evidence)
176
+ const existing = await purchases(ctx).byPurchaseId(purchaseIdOf(session.id))
177
+ if (existing != null) {
178
+ if (opts.mail !== false) await confirmPurchase(ctx, policy, existing)
179
+ return { purchase: existing, created: false }
180
+ }
181
+
182
+ const country = evidence.country ?? metadata.country ?? profile?.country
183
+ const region = regionOf(country, policy)
184
+ // Protected when either the buyer's own address or the organization's locked country is in scope.
185
+ const scoped = inScope(region, country, policy) || (profile != null && inScope(profile.region, profile.country, policy))
186
+ const invoice = await invoiceEvidenceOf(stripe, idOf(session.invoice))
187
+ const purchasedAt = extra.at ?? new Date()
188
+ const draft = compact({
189
+ purchaseId: purchaseIdOf(session.id),
190
+ entityId,
191
+ kind: PurchaseKind.TopUp,
192
+ paygate: STRIPE_PAYGATE_ALIAS,
193
+ sessionId: session.id,
194
+ paymentIntentId: idOf(session.payment_intent) ?? invoice.paymentIntentId,
195
+ invoiceId: idOf(session.invoice),
196
+ invoiceNumber: invoice.invoiceNumber,
197
+ invoiceLineId: invoice.invoiceLineId,
198
+ productSku: metadata.productSku,
199
+ planSku: metadata.planSku,
200
+ profileId: metadata.profileId,
201
+ country,
202
+ region: region ?? undefined,
203
+ ipCountry: metadata.ipCountry,
204
+ inScope: scoped,
205
+ language: profile?.language ?? metadata.language ?? billingLanguageOf(country, policy),
206
+ email: evidence.email,
207
+ name: evidence.name,
208
+ business: evidence.business,
209
+ currency: evidence.currency,
210
+ amountSubtotalMinor: evidence.subtotalMinor,
211
+ amountTaxMinor: evidence.taxMinor,
212
+ amountTotalMinor: evidence.totalMinor,
213
+ presentmentCurrency: evidence.presentmentCurrency,
214
+ presentmentAmountMinor: evidence.presentmentAmountMinor,
215
+ netAmountMinor: extra.netAmountMinor,
216
+ amountCurrency: extra.amountCurrency,
217
+ units: extra.units,
218
+ taxBehavior: extra.taxBehavior,
219
+ termsAccepted: evidence.termsAccepted,
220
+ textVersion: metadata.termsVersion ?? policy.textVersion,
221
+ copyVersion: metadata.copyVersion ?? CONSUMER_RIGHTS_COPY_VERSION,
222
+ purchasedAt,
223
+ deadline: deadlineOf(policy, scoped, purchasedAt),
224
+ }) as PurchaseDraft
225
+ const { record, created } = await createPurchase(ctx, draft)
226
+ if (opts.mail !== false) await confirmPurchase(ctx, policy, record)
227
+
228
+ return { purchase: record, created }
229
+ }
230
+
231
+ /** The start request a subscription's metadata names, when it is a real start request of that entity. */
232
+ const startRequestOf = async (ctx: ApiContext, entityId: string, id: string | undefined) => {
233
+ if (id == null || id === '') {
234
+ return null
235
+ }
236
+ const consent = await consumerConsents(ctx).load(id).catch(() => null)
237
+
238
+ return consent != null && consent.kind === ConsentKind.SubscriptionStart && consent.entityId === entityId ? consent : null
239
+ }
240
+
241
+ /**
242
+ * A subscription's FIRST invoice as a purchase — called while the subscription is committed, BEFORE
243
+ * the `created` observers grant anything, so the window exists before the bundle can be spent. The
244
+ * buyer's country and totals are refined when the checkout completes. The start request (when the
245
+ * metadata names one) is the purchase's consent: `servicesStartedAt` and `consentedAt`.
246
+ */
247
+ export const captureSubscriptionPurchase = async (
248
+ ctx: ApiContext, stripe: Stripe | null, subscription: Stripe.Subscription, row: PaymentSubscriptionRecord,
249
+ ): Promise<CapturedPurchase | null> => {
250
+ const policy = await payment(ctx).consumerRightsPolicy()
251
+ if (policy == null) {
252
+ return null
253
+ }
254
+ const purchaseId = purchaseIdOf(subscription.id)
255
+ const existing = await purchases(ctx).byPurchaseId(purchaseId)
256
+ if (existing != null) {
257
+ return { purchase: existing, created: false }
258
+ }
259
+ const metadata = subscription.metadata ?? {}
260
+ const item = subscription.items?.data?.[0]
261
+ const invoiceId = idOf(subscription.latest_invoice)
262
+ const invoice = await invoiceEvidenceOf(stripe, invoiceId)
263
+ const profile = await billingProfiles(ctx).byEntity(row.entityId)
264
+ const country = invoice.country ?? profile?.country ?? metadata.country
265
+ const region = regionOf(country, policy)
266
+ const scoped = inScope(region, country, policy) || (profile != null && inScope(profile.region, profile.country, policy))
267
+ const start = await startRequestOf(ctx, row.entityId, metadata.startRequestId)
268
+ const subtotal = invoice.subtotalMinor ?? (item?.price?.unit_amount ?? 0) * (item?.quantity ?? 1)
269
+ const tax = invoice.taxMinor ?? 0
270
+ const purchasedAt = dateOf(subscription.created) ?? new Date()
271
+ const draft = compact({
272
+ purchaseId,
273
+ entityId: row.entityId,
274
+ kind: PurchaseKind.Subscription,
275
+ paygate: STRIPE_PAYGATE_ALIAS,
276
+ subscriptionId: subscription.id,
277
+ paymentIntentId: invoice.paymentIntentId,
278
+ invoiceId,
279
+ invoiceNumber: invoice.invoiceNumber,
280
+ invoiceLineId: invoice.invoiceLineId,
281
+ productSku: row.productSku,
282
+ planSku: row.planSku,
283
+ profileId: metadata.profileId,
284
+ country,
285
+ region: region ?? undefined,
286
+ ipCountry: metadata.ipCountry,
287
+ inScope: scoped,
288
+ language: profile?.language ?? metadata.language ?? billingLanguageOf(country, policy),
289
+ currency: (invoice.currency ?? subscription.currency ?? item?.price?.currency ?? 'usd').toLowerCase(),
290
+ amountSubtotalMinor: subtotal,
291
+ amountTaxMinor: tax,
292
+ amountTotalMinor: invoice.totalMinor ?? subtotal + tax,
293
+ taxBehavior: item?.price?.tax_behavior ?? undefined,
294
+ textVersion: metadata.termsVersion ?? policy.textVersion,
295
+ copyVersion: metadata.copyVersion ?? CONSUMER_RIGHTS_COPY_VERSION,
296
+ startRequestId: start?.id,
297
+ servicesStartedAt: start?.decidedAt,
298
+ consentId: start?.id,
299
+ consentedAt: start?.decidedAt,
300
+ purchasedAt,
301
+ deadline: deadlineOf(policy, scoped, purchasedAt),
302
+ }) as PurchaseDraft
303
+ const { record, created } = await createPurchase(ctx, draft)
304
+
305
+ return { purchase: record, created }
306
+ }
307
+
308
+ /**
309
+ * Refine a subscription purchase with its completed checkout: the buyer's own country (and so the
310
+ * scope and deadline), e-mail, the charged totals, the terms acceptance and the session id; lock
311
+ * the billing country; mail the confirmation once.
312
+ */
313
+ export const completeSubscriptionPurchase = async (
314
+ ctx: ApiContext, stripe: Stripe | null, session: Stripe.Checkout.Session, purchase: PurchaseRecord,
315
+ opts: { mail?: boolean } = {},
316
+ ): Promise<PurchaseRecord> => {
317
+ const policy = await payment(ctx).consumerRightsPolicy()
318
+ if (policy == null) {
319
+ return purchase
320
+ }
321
+ const evidence = sessionEvidenceOf(session)
322
+ const profile = await lockFromSession(ctx, policy, purchase.entityId, session, evidence)
323
+ const country = evidence.country ?? purchase.country ?? profile?.country
324
+ const region = regionOf(country, policy)
325
+ const scoped = inScope(region, country, policy) || (profile != null && inScope(profile.region, profile.country, policy))
326
+ const invoice = purchase.invoiceLineId == null || purchase.paymentIntentId == null
327
+ ? await invoiceEvidenceOf(stripe, purchase.invoiceId ?? idOf(session.invoice)) : {}
328
+ const purchasedAt = new Date(purchase.purchasedAt)
329
+ const fields = compact({
330
+ sessionId: session.id,
331
+ invoiceId: purchase.invoiceId ?? idOf(session.invoice),
332
+ invoiceNumber: purchase.invoiceNumber ?? invoice.invoiceNumber,
333
+ invoiceLineId: purchase.invoiceLineId ?? invoice.invoiceLineId,
334
+ paymentIntentId: purchase.paymentIntentId ?? invoice.paymentIntentId,
335
+ country,
336
+ region: region ?? undefined,
337
+ inScope: scoped,
338
+ deadline: scoped ? purchase.deadline ?? withdrawalDeadlineOf(purchasedAt, policy) : undefined,
339
+ email: evidence.email ?? purchase.email,
340
+ name: evidence.name ?? purchase.name,
341
+ business: evidence.business ?? purchase.business,
342
+ currency: evidence.currency !== '' ? evidence.currency : purchase.currency,
343
+ amountSubtotalMinor: evidence.subtotalMinor,
344
+ amountTaxMinor: evidence.taxMinor,
345
+ amountTotalMinor: evidence.totalMinor,
346
+ presentmentCurrency: evidence.presentmentCurrency,
347
+ presentmentAmountMinor: evidence.presentmentAmountMinor,
348
+ termsAccepted: evidence.termsAccepted,
349
+ ipCountry: purchase.ipCountry ?? session.metadata?.ipCountry,
350
+ language: profile?.language ?? purchase.language,
351
+ }) as Partial<PurchaseRecord>
352
+ await patchPurchase(ctx, purchase.purchaseId, fields)
353
+ const updated = { ...purchase, ...fields } as PurchaseRecord
354
+ if (!scoped && purchase.deadline != null) {
355
+ // Out of scope after all (the buyer's own country): no window.
356
+ await patchPurchase(ctx, purchase.purchaseId, { deadline: null as unknown as undefined })
357
+ delete updated.deadline
358
+ }
359
+ if (opts.mail !== false) await confirmPurchase(ctx, policy, updated)
360
+
361
+ return updated
362
+ }
@@ -0,0 +1,90 @@
1
+ import { randomInt } from 'node:crypto'
2
+ import { lastWithdrawalDayOf } from '@owlmeans/payment'
3
+ import { CONTRACT_REF_ALPHABET, RESERVED_MAIL_TLDS } from '../consts.js'
4
+
5
+ /** `CR-YYMMDD-XXXXXX` — the purchase's UTC date and six characters of an unambiguous alphabet. */
6
+ export const makeContractRef = (at: Date = new Date()): string => {
7
+ const date = [at.getUTCFullYear() % 100, at.getUTCMonth() + 1, at.getUTCDate()]
8
+ .map(part => String(part).padStart(2, '0')).join('')
9
+ const suffix = Array.from({ length: 6 }, () => CONTRACT_REF_ALPHABET[randomInt(CONTRACT_REF_ALPHABET.length)]).join('')
10
+
11
+ return `CR-${date}-${suffix}`
12
+ }
13
+
14
+ /** A contract reference as a person may type it: trimmed, upper-cased, look-alike dashes unified. */
15
+ export const normalizeContractRef = (value: string): string =>
16
+ value.trim().toUpperCase().replace(/[‐-―−\s]+/g, '-')
17
+
18
+ export const normalizeEmail = (value: string | null | undefined): string => (value ?? '').trim().toLowerCase()
19
+
20
+ /**
21
+ * Whether an address is on a reserved top-level domain (`.test`, `.example`, `.invalid`,
22
+ * `.localhost` — RFC 2606 / 6761), any subdomain included: `a@shop.test`, `a@mail.shop.test`,
23
+ * `a@localhost`. Case, surrounding spaces, a display-name form (`Name <a@shop.test>`) and a
24
+ * trailing root dot are all read through.
25
+ */
26
+ export const isReservedAddress = (address: string): boolean => {
27
+ const bare = normalizeEmail(address).replace(/^.*<([^>]*)>.*$/, '$1')
28
+ const domain = (bare.split('@').pop() ?? '').trim().replace(/\.+$/, '')
29
+ const tld = domain.split('.').pop() ?? ''
30
+
31
+ return RESERVED_MAIL_TLDS.includes(tld)
32
+ }
33
+
34
+ export const escapeHtml = (value: string): string => value
35
+ .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
36
+ .replace(/"/g, '&quot;').replace(/'/g, '&#39;')
37
+
38
+ const fractionDigitsOf = (currency: string): number => {
39
+ try {
40
+ return new Intl.NumberFormat('en', { style: 'currency', currency: currency.toUpperCase() })
41
+ .resolvedOptions().maximumFractionDigits ?? 2
42
+ } catch {
43
+ return 2
44
+ }
45
+ }
46
+
47
+ /** Minor units as a localized amount with its currency (`12,56 €`, `$12.56`). */
48
+ export const formatMoney = (amountMinor: number, currency: string, lng: string): string => {
49
+ const digits = fractionDigitsOf(currency)
50
+ const major = amountMinor / 10 ** digits
51
+ try {
52
+ return new Intl.NumberFormat(lng, { style: 'currency', currency: currency.toUpperCase() }).format(major)
53
+ } catch {
54
+ return `${major.toFixed(digits)} ${currency.toUpperCase()}`
55
+ }
56
+ }
57
+
58
+ /** A date and time in UTC, localized (`23 September 2026, 14:05`). */
59
+ export const formatDateTime = (at: Date, lng: string): string => {
60
+ try {
61
+ return new Intl.DateTimeFormat(lng, { dateStyle: 'long', timeStyle: 'short', timeZone: 'UTC' }).format(at)
62
+ } catch {
63
+ return at.toISOString().replace('T', ' ').slice(0, 16)
64
+ }
65
+ }
66
+
67
+ /** A calendar date in UTC, localized. */
68
+ export const formatDate = (at: Date, lng: string): string => {
69
+ try {
70
+ return new Intl.DateTimeFormat(lng, { dateStyle: 'long', timeZone: 'UTC' }).format(at)
71
+ } catch {
72
+ return at.toISOString().slice(0, 10)
73
+ }
74
+ }
75
+
76
+ /**
77
+ * The last included day of a window whose deadline is EXCLUSIVE (the first instant it is over),
78
+ * as a UTC date — the copy says "until the end of <date>": 13 October 00:00 UTC is "12 October".
79
+ */
80
+ export const formatDeadline = (deadline: Date, lng: string): string =>
81
+ formatDate(lastWithdrawalDayOf(deadline), lng)
82
+
83
+ /** A country's name in a language, else its code. */
84
+ export const countryName = (country: string, lng: string): string => {
85
+ try {
86
+ return new Intl.DisplayNames([lng], { type: 'region' }).of(country.toUpperCase()) ?? country
87
+ } catch {
88
+ return country
89
+ }
90
+ }