@owlmeans/server-payment 0.1.18-rc.2 → 0.1.18-rc.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +109 -25
- package/agent-meta/manifest.json +2 -2
- package/agent-meta/skills/server-payment/SKILL.md +475 -52
- package/build/actions/index.d.ts +1 -0
- package/build/actions/index.d.ts.map +1 -1
- package/build/actions/index.js +1 -0
- package/build/actions/index.js.map +1 -1
- package/build/actions/resync-subscriptions.d.ts +3 -0
- package/build/actions/resync-subscriptions.d.ts.map +1 -0
- package/build/actions/resync-subscriptions.js +7 -0
- package/build/actions/resync-subscriptions.js.map +1 -0
- package/build/actions/resync.d.ts +1 -0
- package/build/actions/resync.d.ts.map +1 -1
- package/build/actions/resync.js +7 -3
- package/build/actions/resync.js.map +1 -1
- package/build/actions/webhook.d.ts.map +1 -1
- package/build/actions/webhook.js +6 -3
- package/build/actions/webhook.js.map +1 -1
- package/build/config.d.ts +60 -5
- package/build/config.d.ts.map +1 -1
- package/build/config.js +237 -5
- package/build/config.js.map +1 -1
- package/build/consts.d.ts +75 -3
- package/build/consts.d.ts.map +1 -1
- package/build/consts.js +94 -5
- package/build/consts.js.map +1 -1
- package/build/consumer/capture.d.ts +74 -0
- package/build/consumer/capture.d.ts.map +1 -0
- package/build/consumer/capture.js +291 -0
- package/build/consumer/capture.js.map +1 -0
- package/build/consumer/format.d.ts +27 -0
- package/build/consumer/format.d.ts.map +1 -0
- package/build/consumer/format.js +81 -0
- package/build/consumer/format.js.map +1 -0
- package/build/consumer/handlers.d.ts +28 -0
- package/build/consumer/handlers.d.ts.map +1 -0
- package/build/consumer/handlers.js +173 -0
- package/build/consumer/handlers.js.map +1 -0
- package/build/consumer/index.d.ts +7 -0
- package/build/consumer/index.d.ts.map +1 -0
- package/build/consumer/index.js +6 -0
- package/build/consumer/index.js.map +1 -0
- package/build/consumer/mail.d.ts +27 -0
- package/build/consumer/mail.d.ts.map +1 -0
- package/build/consumer/mail.js +314 -0
- package/build/consumer/mail.js.map +1 -0
- package/build/consumer/origin.d.ts +14 -0
- package/build/consumer/origin.d.ts.map +1 -0
- package/build/consumer/origin.js +47 -0
- package/build/consumer/origin.js.map +1 -0
- package/build/consumer/reconcile.d.ts +12 -0
- package/build/consumer/reconcile.d.ts.map +1 -0
- package/build/consumer/reconcile.js +317 -0
- package/build/consumer/reconcile.js.map +1 -0
- package/build/consumer/records.d.ts +78 -0
- package/build/consumer/records.d.ts.map +1 -0
- package/build/consumer/records.js +296 -0
- package/build/consumer/records.js.map +1 -0
- package/build/consumer/service.d.ts +51 -0
- package/build/consumer/service.d.ts.map +1 -0
- package/build/consumer/service.js +760 -0
- package/build/consumer/service.js.map +1 -0
- package/build/consumer/withdrawal.d.ts +57 -0
- package/build/consumer/withdrawal.d.ts.map +1 -0
- package/build/consumer/withdrawal.js +247 -0
- package/build/consumer/withdrawal.js.map +1 -0
- package/build/entitlement.d.ts +10 -0
- package/build/entitlement.d.ts.map +1 -0
- package/build/entitlement.js +69 -0
- package/build/entitlement.js.map +1 -0
- package/build/entrypoints.d.ts +1 -1
- package/build/entrypoints.d.ts.map +1 -1
- package/build/entrypoints.js +2 -1
- package/build/entrypoints.js.map +1 -1
- package/build/gate.d.ts +32 -4
- package/build/gate.d.ts.map +1 -1
- package/build/gate.js +59 -19
- package/build/gate.js.map +1 -1
- package/build/index.d.ts +18 -3
- package/build/index.d.ts.map +1 -1
- package/build/index.js +15 -3
- package/build/index.js.map +1 -1
- package/build/limit.d.ts +13 -0
- package/build/limit.d.ts.map +1 -0
- package/build/limit.js +47 -0
- package/build/limit.js.map +1 -0
- package/build/model.d.ts +10 -1
- package/build/model.d.ts.map +1 -1
- package/build/model.js +215 -17
- package/build/model.js.map +1 -1
- package/build/observer.d.ts +9 -0
- package/build/observer.d.ts.map +1 -1
- package/build/observer.js +38 -12
- package/build/observer.js.map +1 -1
- package/build/plan.d.ts +22 -0
- package/build/plan.d.ts.map +1 -0
- package/build/plan.js +72 -0
- package/build/plan.js.map +1 -0
- package/build/plugins/checkout-plugins.d.ts +49 -0
- package/build/plugins/checkout-plugins.d.ts.map +1 -0
- package/build/plugins/checkout-plugins.js +124 -0
- package/build/plugins/checkout-plugins.js.map +1 -0
- package/build/plugins/estimate.d.ts +43 -0
- package/build/plugins/estimate.d.ts.map +1 -0
- package/build/plugins/estimate.js +268 -0
- package/build/plugins/estimate.js.map +1 -0
- package/build/plugins/events.d.ts +45 -10
- package/build/plugins/events.d.ts.map +1 -1
- package/build/plugins/events.js +605 -136
- package/build/plugins/events.js.map +1 -1
- package/build/plugins/fx.d.ts +31 -0
- package/build/plugins/fx.d.ts.map +1 -0
- package/build/plugins/fx.js +81 -0
- package/build/plugins/fx.js.map +1 -0
- package/build/plugins/portal.d.ts +37 -0
- package/build/plugins/portal.d.ts.map +1 -0
- package/build/plugins/portal.js +265 -0
- package/build/plugins/portal.js.map +1 -0
- package/build/plugins/refunds.d.ts +33 -0
- package/build/plugins/refunds.d.ts.map +1 -0
- package/build/plugins/refunds.js +80 -0
- package/build/plugins/refunds.js.map +1 -0
- package/build/plugins/stripe.d.ts +36 -4
- package/build/plugins/stripe.d.ts.map +1 -1
- package/build/plugins/stripe.js +492 -97
- package/build/plugins/stripe.js.map +1 -1
- package/build/plugins/webhook-manager.d.ts +46 -0
- package/build/plugins/webhook-manager.d.ts.map +1 -0
- package/build/plugins/webhook-manager.js +231 -0
- package/build/plugins/webhook-manager.js.map +1 -0
- package/build/reconcile.d.ts +15 -0
- package/build/reconcile.d.ts.map +1 -0
- package/build/reconcile.js +88 -0
- package/build/reconcile.js.map +1 -0
- package/build/resource.d.ts +12 -1
- package/build/resource.d.ts.map +1 -1
- package/build/resource.js +97 -6
- package/build/resource.js.map +1 -1
- package/build/service.d.ts +28 -3
- package/build/service.d.ts.map +1 -1
- package/build/service.js +146 -19
- package/build/service.js.map +1 -1
- package/build/subscription.d.ts +48 -0
- package/build/subscription.d.ts.map +1 -0
- package/build/subscription.js +176 -0
- package/build/subscription.js.map +1 -0
- package/build/sync.d.ts +28 -1
- package/build/sync.d.ts.map +1 -1
- package/build/sync.js +208 -31
- package/build/sync.js.map +1 -1
- package/build/types.d.ts +1115 -36
- package/build/types.d.ts.map +1 -1
- package/build/usage.d.ts +63 -0
- package/build/usage.d.ts.map +1 -0
- package/build/usage.js +363 -0
- package/build/usage.js.map +1 -0
- package/build/utils.d.ts +52 -1
- package/build/utils.d.ts.map +1 -1
- package/build/utils.js +69 -7
- package/build/utils.js.map +1 -1
- package/package.json +17 -13
- package/src/actions/index.ts +1 -0
- package/src/actions/resync-subscriptions.ts +10 -0
- package/src/actions/resync.ts +6 -3
- package/src/actions/webhook.ts +5 -3
- package/src/config.ts +264 -8
- package/src/consts.ts +114 -6
- package/src/consumer/capture.ts +362 -0
- package/src/consumer/format.ts +90 -0
- package/src/consumer/handlers.ts +211 -0
- package/src/consumer/index.ts +6 -0
- package/src/consumer/mail.ts +368 -0
- package/src/consumer/origin.ts +63 -0
- package/src/consumer/reconcile.ts +329 -0
- package/src/consumer/records.ts +374 -0
- package/src/consumer/service.ts +868 -0
- package/src/consumer/withdrawal.ts +302 -0
- package/src/entitlement.ts +84 -0
- package/src/entrypoints.ts +2 -1
- package/src/gate.ts +88 -21
- package/src/index.ts +24 -3
- package/src/limit.ts +57 -0
- package/src/model.ts +237 -18
- package/src/observer.ts +44 -11
- package/src/plan.ts +89 -0
- package/src/plugins/checkout-plugins.ts +155 -0
- package/src/plugins/estimate.ts +339 -0
- package/src/plugins/events.ts +677 -121
- package/src/plugins/fx.ts +122 -0
- package/src/plugins/portal.ts +306 -0
- package/src/plugins/refunds.ts +108 -0
- package/src/plugins/stripe.ts +581 -96
- package/src/plugins/webhook-manager.ts +270 -0
- package/src/reconcile.ts +103 -0
- package/src/resource.ts +152 -7
- package/src/service.ts +174 -18
- package/src/subscription.ts +231 -0
- package/src/sync.ts +249 -29
- package/src/types.ts +1227 -32
- package/src/usage.ts +453 -0
- package/src/utils.ts +127 -10
- package/tests/checkout-consumer.spec.ts +348 -0
- package/tests/checkout-plugins.spec.ts +164 -0
- package/tests/checkout.spec.ts +184 -83
- package/tests/consumer-events.spec.ts +218 -0
- package/tests/consumer-fixtures.ts +132 -0
- package/tests/consumer-ops.spec.ts +351 -0
- package/tests/consumer-rights.integration.spec.ts +150 -0
- package/tests/consumer-rights.spec.ts +501 -0
- package/tests/context.ts +109 -0
- package/tests/entitlement.spec.ts +103 -0
- package/tests/estimate.spec.ts +240 -0
- package/tests/events.spec.ts +356 -0
- package/tests/fake-stripe.ts +972 -0
- package/tests/gate.spec.ts +62 -72
- package/tests/limit-gate.spec.ts +68 -0
- package/tests/portal.spec.ts +200 -0
- package/tests/protocol.spec.ts +23 -6
- package/tests/sync.spec.ts +114 -0
- package/tests/usage.integration.spec.ts +101 -0
- package/tests/usage.spec.ts +171 -0
- package/tests/webhook-manager.spec.ts +152 -0
package/src/consts.ts
CHANGED
|
@@ -1,21 +1,114 @@
|
|
|
1
|
-
import { contract, protocol,
|
|
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
|
-
|
|
15
|
-
|
|
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',
|
|
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, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
36
|
+
.replace(/"/g, '"').replace(/'/g, ''')
|
|
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
|
+
}
|