@owlmeans/server-payment 0.1.18-rc.20 → 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 +1 -1
- package/agent-meta/manifest.json +2 -2
- package/agent-meta/skills/server-payment/SKILL.md +402 -260
- package/build/config.d.ts +15 -1
- package/build/config.d.ts.map +1 -1
- package/build/config.js +99 -3
- package/build/config.js.map +1 -1
- package/build/consts.d.ts +27 -0
- package/build/consts.d.ts.map +1 -1
- package/build/consts.js +27 -0
- 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/index.d.ts +4 -2
- package/build/index.d.ts.map +1 -1
- package/build/index.js +4 -2
- package/build/index.js.map +1 -1
- package/build/model.d.ts +6 -1
- package/build/model.d.ts.map +1 -1
- package/build/model.js +134 -4
- package/build/model.js.map +1 -1
- package/build/observer.d.ts +4 -0
- package/build/observer.d.ts.map +1 -1
- package/build/observer.js +13 -0
- package/build/observer.js.map +1 -1
- 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.map +1 -1
- package/build/plugins/estimate.js +41 -8
- package/build/plugins/estimate.js.map +1 -1
- package/build/plugins/events.d.ts +2 -0
- package/build/plugins/events.d.ts.map +1 -1
- package/build/plugins/events.js +126 -9
- package/build/plugins/events.js.map +1 -1
- package/build/plugins/fx.d.ts +5 -0
- package/build/plugins/fx.d.ts.map +1 -1
- package/build/plugins/fx.js +25 -0
- package/build/plugins/fx.js.map +1 -1
- package/build/plugins/portal.d.ts.map +1 -1
- package/build/plugins/portal.js +11 -1
- package/build/plugins/portal.js.map +1 -1
- package/build/plugins/stripe.d.ts +21 -2
- package/build/plugins/stripe.d.ts.map +1 -1
- package/build/plugins/stripe.js +397 -61
- package/build/plugins/stripe.js.map +1 -1
- package/build/resource.d.ts +8 -1
- package/build/resource.d.ts.map +1 -1
- package/build/resource.js +59 -2
- package/build/resource.js.map +1 -1
- package/build/service.d.ts +2 -1
- package/build/service.d.ts.map +1 -1
- package/build/service.js +28 -5
- package/build/service.js.map +1 -1
- package/build/subscription.d.ts +6 -0
- package/build/subscription.d.ts.map +1 -1
- package/build/subscription.js +1 -0
- package/build/subscription.js.map +1 -1
- package/build/sync.d.ts +7 -0
- package/build/sync.d.ts.map +1 -1
- package/build/sync.js +108 -15
- package/build/sync.js.map +1 -1
- package/build/types.d.ts +707 -4
- package/build/types.d.ts.map +1 -1
- package/build/utils.d.ts +22 -1
- package/build/utils.d.ts.map +1 -1
- package/build/utils.js +27 -1
- package/build/utils.js.map +1 -1
- package/package.json +14 -13
- package/src/config.ts +111 -7
- package/src/consts.ts +34 -0
- 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/index.ts +6 -4
- package/src/model.ts +148 -6
- package/src/observer.ts +15 -2
- package/src/plugins/checkout-plugins.ts +155 -0
- package/src/plugins/estimate.ts +49 -9
- package/src/plugins/events.ts +135 -11
- package/src/plugins/fx.ts +29 -0
- package/src/plugins/portal.ts +11 -1
- package/src/plugins/stripe.ts +476 -60
- package/src/resource.ts +87 -4
- package/src/service.ts +28 -6
- package/src/subscription.ts +7 -0
- package/src/sync.ts +124 -17
- package/src/types.ts +756 -6
- package/src/utils.ts +56 -7
- package/tests/checkout-consumer.spec.ts +348 -0
- package/tests/checkout-plugins.spec.ts +164 -0
- 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 +20 -2
- package/tests/fake-stripe.ts +188 -18
package/src/resource.ts
CHANGED
|
@@ -1,18 +1,22 @@
|
|
|
1
1
|
import type { ResourceMaker } from '@owlmeans/resource'
|
|
2
2
|
import { makeMongoResource } from '@owlmeans/mongo-resource'
|
|
3
3
|
import {
|
|
4
|
-
|
|
4
|
+
RES_BILLING_PROFILE, RES_CONSUMER_CONSENT, RES_CONSUMER_DECLARATION, RES_CONSUMER_EVENT, RES_PAYGATE_CUSTOMER,
|
|
5
|
+
RES_PAYMENT_FINGERPRINT, RES_PAYMENT_FULFILLMENT, RES_PAYMENT_PURCHASE, RES_PAYMENT_SUBSCRIPTION,
|
|
5
6
|
RES_PAYMENT_USAGE, RES_PAYMENT_USAGE_COUNTER, RES_PAYMENT_WEBHOOK,
|
|
6
7
|
} from './consts.js'
|
|
7
8
|
import {
|
|
8
|
-
|
|
9
|
-
|
|
9
|
+
BillingProfileSchema, ConsumerConsentSchema, ConsumerDeclarationSchema, ConsumerEventSchema, FingerprintSchema,
|
|
10
|
+
PaygateCustomerSchema, PaymentFulfillmentSchema, PaymentSubscriptionSchema, PaymentUsageCounterSchema,
|
|
11
|
+
PaymentUsageSchema, PaymentWebhookSchema, PurchaseSchema,
|
|
10
12
|
} from './model.js'
|
|
11
13
|
import type {
|
|
14
|
+
BillingProfileRecord, BillingProfileResource, ConsumerConsentRecord, ConsumerConsentResource,
|
|
15
|
+
ConsumerDeclarationRecord, ConsumerDeclarationResource, ConsumerEventRecord, ConsumerEventResource,
|
|
12
16
|
FingerprintRecord, FingerprintResource, PaygateCustomerRecord, PaygateCustomerResource,
|
|
13
17
|
PaymentFulfillmentRecord, PaymentFulfillmentResource, PaymentSubscriptionRecord,
|
|
14
18
|
PaymentSubscriptionResource, PaymentUsageCounterRecord, PaymentUsageCounterResource, PaymentUsageRecord,
|
|
15
|
-
PaymentUsageResource, PaymentWebhookRecord, PaymentWebhookResource,
|
|
19
|
+
PaymentUsageResource, PaymentWebhookRecord, PaymentWebhookResource, PurchaseRecord, PurchaseResource,
|
|
16
20
|
} from './types.js'
|
|
17
21
|
|
|
18
22
|
export const makePaygateCustomerResource: ResourceMaker<PaygateCustomerRecord, PaygateCustomerResource> = (
|
|
@@ -111,3 +115,82 @@ export const makeFingerprintResource: ResourceMaker<FingerprintRecord, Fingerpri
|
|
|
111
115
|
resource.index('sku', { sku: 1 }, { unique: true })
|
|
112
116
|
return resource
|
|
113
117
|
}
|
|
118
|
+
|
|
119
|
+
export const makeBillingProfileResource: ResourceMaker<BillingProfileRecord, BillingProfileResource> = (
|
|
120
|
+
dbAlias, serviceAlias,
|
|
121
|
+
) => {
|
|
122
|
+
const resource = makeMongoResource<BillingProfileRecord, BillingProfileResource>(
|
|
123
|
+
RES_BILLING_PROFILE, dbAlias, serviceAlias,
|
|
124
|
+
)
|
|
125
|
+
resource.byEntity = async entityId => await resource.load({ entityId })
|
|
126
|
+
resource.schema = BillingProfileSchema
|
|
127
|
+
// One profile per organization: the unique index is what makes the first lock the only one.
|
|
128
|
+
resource.index('entity', { entityId: 1 }, { unique: true })
|
|
129
|
+
resource.index('customer', { paygate: 1, customerId: 1 }, { sparse: true })
|
|
130
|
+
return resource
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export const makePurchaseResource: ResourceMaker<PurchaseRecord, PurchaseResource> = (dbAlias, serviceAlias) => {
|
|
134
|
+
const resource = makeMongoResource<PurchaseRecord, PurchaseResource>(RES_PAYMENT_PURCHASE, dbAlias, serviceAlias)
|
|
135
|
+
resource.byPurchaseId = async purchaseId => await resource.load({ purchaseId })
|
|
136
|
+
resource.schema = PurchaseSchema
|
|
137
|
+
resource.index('purchase', { purchaseId: 1 }, { unique: true })
|
|
138
|
+
resource.index('contract', { contractRef: 1 }, { unique: true })
|
|
139
|
+
resource.index('entityDeadline', { entityId: 1, deadline: -1 })
|
|
140
|
+
resource.index('entityPurchased', { entityId: 1, purchasedAt: -1 })
|
|
141
|
+
// Single-field on purpose: a compound sparse index still indexes rows missing only `sessionId`
|
|
142
|
+
// (subscription purchases before their checkout is captured), and unique would collide on them.
|
|
143
|
+
resource.index('session', { sessionId: 1 }, { unique: true, sparse: true })
|
|
144
|
+
resource.index('subscription', { paygate: 1, subscriptionId: 1 }, { sparse: true })
|
|
145
|
+
resource.index('invoice', { invoiceId: 1 }, { sparse: true })
|
|
146
|
+
resource.index('invoiceNumber', { invoiceNumber: 1 }, { sparse: true })
|
|
147
|
+
resource.index('paymentIntent', { paygate: 1, paymentIntentId: 1 }, { sparse: true })
|
|
148
|
+
return resource
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export const makeConsumerConsentResource: ResourceMaker<ConsumerConsentRecord, ConsumerConsentResource> = (
|
|
152
|
+
dbAlias, serviceAlias,
|
|
153
|
+
) => {
|
|
154
|
+
const resource = makeMongoResource<ConsumerConsentRecord, ConsumerConsentResource>(
|
|
155
|
+
RES_CONSUMER_CONSENT, dbAlias, serviceAlias,
|
|
156
|
+
)
|
|
157
|
+
resource.schema = ConsumerConsentSchema
|
|
158
|
+
resource.index('entityDecided', { entityId: 1, decidedAt: -1 })
|
|
159
|
+
resource.index('kindPlan', { kind: 1, entityId: 1, planSku: 1, decidedAt: -1 })
|
|
160
|
+
return resource
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export const makeConsumerDeclarationResource: ResourceMaker<ConsumerDeclarationRecord, ConsumerDeclarationResource> = (
|
|
164
|
+
dbAlias, serviceAlias,
|
|
165
|
+
) => {
|
|
166
|
+
const resource = makeMongoResource<ConsumerDeclarationRecord, ConsumerDeclarationResource>(
|
|
167
|
+
RES_CONSUMER_DECLARATION, dbAlias, serviceAlias,
|
|
168
|
+
)
|
|
169
|
+
resource.schema = ConsumerDeclarationSchema
|
|
170
|
+
resource.index('kindReceived', { kind: 1, receivedAt: -1 })
|
|
171
|
+
resource.index('entityReceived', { entityId: 1, receivedAt: -1 }, { sparse: true })
|
|
172
|
+
resource.index('purchase', { purchaseId: 1 }, { sparse: true })
|
|
173
|
+
return resource
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export const makeConsumerEventResource: ResourceMaker<ConsumerEventRecord, ConsumerEventResource> = (
|
|
177
|
+
dbAlias, serviceAlias,
|
|
178
|
+
) => {
|
|
179
|
+
const resource = makeMongoResource<ConsumerEventRecord, ConsumerEventResource>(
|
|
180
|
+
RES_CONSUMER_EVENT, dbAlias, serviceAlias,
|
|
181
|
+
)
|
|
182
|
+
resource.schema = ConsumerEventSchema
|
|
183
|
+
resource.index('record', { recordId: 1, at: 1 })
|
|
184
|
+
resource.index('entityAt', { entityId: 1, at: -1 }, { sparse: true })
|
|
185
|
+
resource.index('actionOk', { action: 1, ok: 1, at: 1 })
|
|
186
|
+
return resource
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** The consumer-rights resources, alias → maker — registered by the gateway, each only when absent. */
|
|
190
|
+
export const CONSUMER_RIGHTS_RESOURCE_MAKERS = [
|
|
191
|
+
[RES_BILLING_PROFILE, makeBillingProfileResource],
|
|
192
|
+
[RES_PAYMENT_PURCHASE, makePurchaseResource],
|
|
193
|
+
[RES_CONSUMER_CONSENT, makeConsumerConsentResource],
|
|
194
|
+
[RES_CONSUMER_DECLARATION, makeConsumerDeclarationResource],
|
|
195
|
+
[RES_CONSUMER_EVENT, makeConsumerEventResource],
|
|
196
|
+
] as const
|
package/src/service.ts
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
ENTITLEMENT_SERVICE, GATEWAY_SERVICE, RES_PAYGATE_CUSTOMER, RES_PAYMENT_FINGERPRINT, RES_PAYMENT_FULFILLMENT,
|
|
11
11
|
RES_PAYMENT_SUBSCRIPTION, RES_PAYMENT_USAGE, RES_PAYMENT_USAGE_COUNTER, RES_PAYMENT_WEBHOOK,
|
|
12
12
|
} from './consts.js'
|
|
13
|
+
import { registerConsumerRights } from './consumer/service.js'
|
|
13
14
|
import { makeEntitlementService } from './entitlement.js'
|
|
14
15
|
import { makeCapabilityGate } from './gate.js'
|
|
15
16
|
import { makeLimitGate } from './limit.js'
|
|
@@ -18,17 +19,18 @@ import { findPlan, findProduct, planRank } from './plan.js'
|
|
|
18
19
|
import { resyncStripeSubscription, resyncStripeSubscriptions } from './plugins/events.js'
|
|
19
20
|
import { makeEstimateCache, estimateStripePrice } from './plugins/estimate.js'
|
|
20
21
|
import { createPortalLink, ensurePortalConfiguration } from './plugins/portal.js'
|
|
21
|
-
import {
|
|
22
|
+
import { makeCheckoutPluginRegistry, narrowAmountFor } from './plugins/checkout-plugins.js'
|
|
23
|
+
import { consumablePlanOf, createCheckoutLink } from './plugins/stripe.js'
|
|
22
24
|
import { ensureWebhookEndpoint } from './plugins/webhook-manager.js'
|
|
23
25
|
import {
|
|
24
26
|
makeFingerprintResource, makeFulfillmentResource, makePaygateCustomerResource, makeSubscriptionResource,
|
|
25
27
|
makeUsageCounterResource, makeUsageResource, makeWebhookResource,
|
|
26
28
|
} from './resource.js'
|
|
27
29
|
import { commitSubscription } from './subscription.js'
|
|
28
|
-
import { syncStripeProducts } from './sync.js'
|
|
29
|
-
import { stripeClient, subscriptions } from './utils.js'
|
|
30
|
+
import { syncedPlanPrices, syncStripeProducts } from './sync.js'
|
|
31
|
+
import { consumerRightsOf, stripeClient, subscriptions } from './utils.js'
|
|
30
32
|
import type {
|
|
31
|
-
Config, Context, GatewayService, GrantInternalPlanOptions, PaymentGatewayOptions,
|
|
33
|
+
Config, Context, GatewayService, GrantInternalPlanOptions, PaymentGatewayOptions, PaymentPlan,
|
|
32
34
|
PaymentSubscriptionRecord,
|
|
33
35
|
} from './types.js'
|
|
34
36
|
|
|
@@ -107,10 +109,22 @@ export const makeGatewayService = (
|
|
|
107
109
|
// One estimate cache per gateway SERVICE instance, never module-level: several service
|
|
108
110
|
// instances (several tests, several deployments in one process) must never share hits.
|
|
109
111
|
const estimateCache = makeEstimateCache()
|
|
112
|
+
// Checkout plugins are seated per gateway instance, like the estimate cache.
|
|
113
|
+
const plugins = makeCheckoutPluginRegistry()
|
|
110
114
|
const service = createService<GatewayService>(alias, {
|
|
111
115
|
managed,
|
|
112
116
|
createLink: async (ctx, params) => managed
|
|
113
|
-
? await createCheckoutLink(ctx, await stripeClient(ctx), params) : unmanaged(),
|
|
117
|
+
? await createCheckoutLink(ctx, await stripeClient(ctx), params, plugins.list()) : unmanaged(),
|
|
118
|
+
use: plugin => { plugins.use(plugin) },
|
|
119
|
+
checkoutPlugins: () => plugins.list(),
|
|
120
|
+
amountPolicy: async (ctx, entityId, productSku, planSku) => {
|
|
121
|
+
const { plan } = await consumablePlanOf(ctx, productSku, planSku)
|
|
122
|
+
if (plan.amountPolicy == null) throw new ProductError(`amount-policy:${plan.sku}`)
|
|
123
|
+
return await narrowAmountFor(ctx, plugins.list(), {
|
|
124
|
+
entityId, productSku, planSku: (plan as PaymentPlan).sku, base: plan.amountPolicy, at: new Date(),
|
|
125
|
+
})
|
|
126
|
+
},
|
|
127
|
+
planPrices: async (ctx, productSku) => await syncedPlanPrices(ctx, productSku),
|
|
114
128
|
portalLink: async (ctx, entityId, link) => managed
|
|
115
129
|
? await createPortalLink(ctx, await stripeClient(ctx), entityId, link) : unmanaged(),
|
|
116
130
|
grantInternalPlan: async (ctx, entityId, planSku, grant) => await grantInternalPlan(ctx, entityId, planSku, grant),
|
|
@@ -124,6 +138,9 @@ export const makeGatewayService = (
|
|
|
124
138
|
const ctx = service.assertCtx() as unknown as ApiContext
|
|
125
139
|
assertPlanDeclarations(ctx.cfg)
|
|
126
140
|
service.initialized = true
|
|
141
|
+
// The consumer-rights service is lazy (reachable while the application is wired): initialize it
|
|
142
|
+
// with the gateway, so its boot checks run at boot.
|
|
143
|
+
consumerRightsOf(ctx)
|
|
127
144
|
if (managed) {
|
|
128
145
|
void ctx.waitForInitialized().then(async () => {
|
|
129
146
|
await bootstrapStripe(ctx, await stripeClient(ctx))
|
|
@@ -136,7 +153,8 @@ export const makeGatewayService = (
|
|
|
136
153
|
|
|
137
154
|
/**
|
|
138
155
|
* Register the payment resources, the catalogue service, the completion observer, the gateway, both
|
|
139
|
-
* gate services and the
|
|
156
|
+
* gate services, the entitlement service and the consumer-rights records and service — each only
|
|
157
|
+
* when not registered yet.
|
|
140
158
|
*
|
|
141
159
|
* `manage: false` registers the same surface for a process that reads entitlements but never talks
|
|
142
160
|
* to Stripe.
|
|
@@ -164,6 +182,10 @@ export const appendPaymentGatewayService = <C extends Config, T extends Context<
|
|
|
164
182
|
if (!ctx.hasService(ENTITLEMENT_GATE)) ctx.registerService(makeCapabilityGate())
|
|
165
183
|
if (!ctx.hasService(LIMIT_GATE)) ctx.registerService(makeLimitGate())
|
|
166
184
|
if (!ctx.hasService(ENTITLEMENT_SERVICE)) ctx.registerService(makeEntitlementService())
|
|
185
|
+
// The consumer-rights records and service, with this gateway's `manage` unless the application
|
|
186
|
+
// gave its own (before or after this call): the webhook writes purchases and locks, and an
|
|
187
|
+
// unmanaged process still reads and asserts consent.
|
|
188
|
+
registerConsumerRights(ctx, { manage: opts?.manage, dbAlias: opts?.dbAlias, serviceAlias: opts?.serviceAlias }, 'gateway')
|
|
167
189
|
|
|
168
190
|
return ctx
|
|
169
191
|
}
|
package/src/subscription.ts
CHANGED
|
@@ -14,6 +14,12 @@ export interface CommitOptions {
|
|
|
14
14
|
invoiceId?: string
|
|
15
15
|
/** The paygate announced the trial ends soon. */
|
|
16
16
|
trialEnding?: boolean
|
|
17
|
+
/**
|
|
18
|
+
* Runs after the new state is written and BEFORE observers hear the change — what must exist
|
|
19
|
+
* before an observer grants anything (a subscription's purchase row). A throw propagates: the
|
|
20
|
+
* observers are not told and the paygate retries.
|
|
21
|
+
*/
|
|
22
|
+
beforePropagate?: (record: PaymentSubscriptionRecord, change: SubscriptionChange) => Promise<void>
|
|
17
23
|
}
|
|
18
24
|
|
|
19
25
|
export interface CommitResult {
|
|
@@ -205,6 +211,7 @@ export const commitSubscription = async (
|
|
|
205
211
|
}
|
|
206
212
|
|
|
207
213
|
const stored = await write(next)
|
|
214
|
+
await opts.beforePropagate?.(stored, change)
|
|
208
215
|
await observer(ctx).propagateSubscription({
|
|
209
216
|
change,
|
|
210
217
|
previous: prior != null && previous != null ? await snapshotOf(ctx, previous, prior) : null,
|
package/src/sync.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto'
|
|
2
2
|
import type Stripe from 'stripe'
|
|
3
3
|
import { CheckoutPricingMode, ProductType, TaxBehavior } from '@owlmeans/payment'
|
|
4
|
+
import type { PlanPriceView } from '@owlmeans/payment'
|
|
4
5
|
import type { Context as ApiContext } from '@owlmeans/server-api'
|
|
5
6
|
import { STRIPE_PAYGATE_ALIAS } from './consts.js'
|
|
6
7
|
import { fingerprints, payment, stripeClient, stripePricingConfig } from './utils.js'
|
|
7
|
-
import type { PaymentPlan, PaymentProduct } from './types.js'
|
|
8
|
+
import type { PaymentPlan, PaymentProduct, SyncedPrice, SyncedPriceOption } from './types.js'
|
|
8
9
|
import { settlementAmount } from './plugins/fx.js'
|
|
9
10
|
import type { StripeFxRateCache } from './plugins/fx.js'
|
|
10
11
|
|
|
@@ -14,6 +15,39 @@ interface ResolvedPlan {
|
|
|
14
15
|
currency: string
|
|
15
16
|
sourceUnitAmount: number
|
|
16
17
|
sourceCurrency: string
|
|
18
|
+
/** `currency_options` besides `currency`, sorted by currency. */
|
|
19
|
+
options: SyncedPriceOption[]
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The exact prices a plan is also charged in: its declared `currencyPrices`, and its catalogue
|
|
24
|
+
* currency when that is one of the consumer-rights region currencies but not the Price's default
|
|
25
|
+
* (a USD catalogue price synced as EUR keeps an exact USD option). A declared price in the default
|
|
26
|
+
* currency replaces the converted default amount instead.
|
|
27
|
+
*/
|
|
28
|
+
const optionsOf = (
|
|
29
|
+
plan: PaymentPlan, currency: string, sourceUnitAmount: number, sourceCurrency: string, regionCurrencies: Set<string>,
|
|
30
|
+
): { unitAmount?: number, options: SyncedPriceOption[] } => {
|
|
31
|
+
const options = new Map<string, number>()
|
|
32
|
+
if (sourceCurrency !== currency && regionCurrencies.has(sourceCurrency)) {
|
|
33
|
+
options.set(sourceCurrency, sourceUnitAmount)
|
|
34
|
+
}
|
|
35
|
+
let unitAmount: number | undefined
|
|
36
|
+
for (const [raw, amount] of Object.entries(plan.currencyPrices ?? {})) {
|
|
37
|
+
const code = raw.toLowerCase()
|
|
38
|
+
const minor = Math.round(amount * 100)
|
|
39
|
+
if (code === currency) {
|
|
40
|
+
unitAmount = minor
|
|
41
|
+
} else {
|
|
42
|
+
options.set(code, minor)
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
...(unitAmount != null ? { unitAmount } : {}),
|
|
48
|
+
options: [...options.entries()].map(([code, amount]) => ({ currency: code, unitAmount: amount }))
|
|
49
|
+
.sort((a, b) => a.currency.localeCompare(b.currency)),
|
|
50
|
+
}
|
|
17
51
|
}
|
|
18
52
|
|
|
19
53
|
export const planLookupKey = (product: PaymentProduct, plan: PaymentPlan): string =>
|
|
@@ -54,11 +88,13 @@ const fingerprintOf = (
|
|
|
54
88
|
description: product.description ?? null, taxCode: product.taxCode ?? null,
|
|
55
89
|
unitLabel: product.unitLabel ?? null, services: [...(product.services ?? [])].sort(),
|
|
56
90
|
behavior,
|
|
57
|
-
plans: plans.map(({ plan, unitAmount, currency, sourceUnitAmount, sourceCurrency }) => ({
|
|
91
|
+
plans: plans.map(({ plan, unitAmount, currency, sourceUnitAmount, sourceCurrency, options }) => ({
|
|
58
92
|
sku: plan.sku, price: plan.price, currency, unitAmount, sourceUnitAmount, sourceCurrency,
|
|
59
93
|
duration: plan.duration, recurring: plan.recurring ?? null, pricingMode: plan.pricingMode ?? null,
|
|
60
94
|
amountPolicy: plan.amountPolicy ?? null, quantityPolicy: plan.quantityPolicy ?? null,
|
|
61
95
|
lookup: planLookupKey(product, plan),
|
|
96
|
+
// Only when present, so a catalogue without options keeps the fingerprint it always had.
|
|
97
|
+
...(options.length > 0 ? { options } : {}),
|
|
62
98
|
})).sort((a, b) => a.sku.localeCompare(b.sku)),
|
|
63
99
|
})).digest('hex')
|
|
64
100
|
|
|
@@ -78,7 +114,23 @@ const ensureStripeProduct = async (stripe: Stripe, product: PaymentProduct): Pro
|
|
|
78
114
|
}
|
|
79
115
|
|
|
80
116
|
const activePrices = async (stripe: Stripe, product: PaymentProduct): Promise<Stripe.Price[]> =>
|
|
81
|
-
(await stripe.prices.list({ product: product.sku, active: true, limit: 100 })).data
|
|
117
|
+
(await stripe.prices.list({ product: product.sku, active: true, limit: 100, expand: ['data.currency_options'] })).data
|
|
118
|
+
|
|
119
|
+
/** A price's `currency_options` besides its default currency (read only once expanded). */
|
|
120
|
+
const priceOptionsOf = (price: Stripe.Price): Record<string, { unit_amount?: number | null, tax_behavior?: string | null }> =>
|
|
121
|
+
Object.fromEntries(Object.entries(price.currency_options ?? {}).filter(([code]) => code !== price.currency))
|
|
122
|
+
|
|
123
|
+
/** Whether a price carries exactly these options (and, with a declared behavior, with that behavior). */
|
|
124
|
+
const optionsMatch = (price: Stripe.Price, options: SyncedPriceOption[], behavior: TaxBehavior | null): boolean => {
|
|
125
|
+
const current = priceOptionsOf(price)
|
|
126
|
+
if (Object.keys(current).length !== options.length) {
|
|
127
|
+
return false
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return options.every(option => current[option.currency]?.unit_amount === option.unitAmount
|
|
131
|
+
&& (behavior == null || current[option.currency]?.tax_behavior == null
|
|
132
|
+
|| current[option.currency]?.tax_behavior === 'unspecified' || current[option.currency]?.tax_behavior === behavior))
|
|
133
|
+
}
|
|
82
134
|
|
|
83
135
|
const deactivateAmountPrice = async (stripe: Stripe, product: PaymentProduct, plan: PaymentPlan): Promise<void> => {
|
|
84
136
|
const lookup = planLookupKey(product, plan)
|
|
@@ -133,11 +185,11 @@ const applyUnspecifiedBehavior = async (
|
|
|
133
185
|
const ensureStripePrice = async (
|
|
134
186
|
stripe: Stripe, product: PaymentProduct, resolved: ResolvedPlan,
|
|
135
187
|
behavior: TaxBehavior | null, migrateUnspecifiedPrices: boolean,
|
|
136
|
-
): Promise<
|
|
137
|
-
const { plan, unitAmount, currency } = resolved
|
|
188
|
+
): Promise<Stripe.Price | null> => {
|
|
189
|
+
const { plan, unitAmount, currency, options } = resolved
|
|
138
190
|
if (plan.pricingMode === CheckoutPricingMode.Amount) {
|
|
139
191
|
await deactivateAmountPrice(stripe, product, plan)
|
|
140
|
-
return
|
|
192
|
+
return null
|
|
141
193
|
}
|
|
142
194
|
const lookupKey = planLookupKey(product, plan)
|
|
143
195
|
const recurring = plan.recurring != null
|
|
@@ -145,21 +197,30 @@ const ensureStripePrice = async (
|
|
|
145
197
|
const existing = await activePrices(stripe, product)
|
|
146
198
|
const candidate = existing.find(price => price.lookup_key === lookupKey && price.unit_amount === unitAmount
|
|
147
199
|
&& price.currency === currency
|
|
148
|
-
&& ((price.recurring?.interval ?? null) === (recurring?.interval ?? null))
|
|
200
|
+
&& ((price.recurring?.interval ?? null) === (recurring?.interval ?? null))
|
|
201
|
+
&& optionsMatch(price, options, behavior))
|
|
149
202
|
if (candidate != null && !opposesBehavior(candidate, behavior)) {
|
|
150
203
|
if (behavior != null && candidate.tax_behavior === 'unspecified') {
|
|
151
204
|
await applyUnspecifiedBehavior(stripe, candidate, lookupKey, behavior, migrateUnspecifiedPrices)
|
|
152
205
|
}
|
|
153
|
-
return
|
|
206
|
+
return candidate
|
|
154
207
|
}
|
|
208
|
+
// A changed option replaces the Price like any other change — options are never edited in place,
|
|
209
|
+
// so a subscriber keeps exactly the Price (and currency amounts) they accepted.
|
|
155
210
|
for (const price of existing.filter(item => item.lookup_key === lookupKey)) {
|
|
156
211
|
await stripe.prices.update(price.id, { active: false })
|
|
157
212
|
}
|
|
158
|
-
|
|
213
|
+
|
|
214
|
+
return await stripe.prices.create({
|
|
159
215
|
product: product.sku, currency, unit_amount: unitAmount, lookup_key: lookupKey,
|
|
160
216
|
transfer_lookup_key: true, nickname: plan.sku,
|
|
161
217
|
...(recurring != null ? { recurring } : { billing_scheme: 'per_unit' }),
|
|
162
218
|
...(behavior != null ? { tax_behavior: behavior } : {}),
|
|
219
|
+
...(options.length > 0 ? {
|
|
220
|
+
currency_options: Object.fromEntries(options.map(option => [option.currency, {
|
|
221
|
+
unit_amount: option.unitAmount, ...(behavior != null ? { tax_behavior: behavior } : {}),
|
|
222
|
+
}])),
|
|
223
|
+
} : {}),
|
|
163
224
|
metadata: { sku: plan.sku, ...(product.services && { services: product.services.join(',') }) },
|
|
164
225
|
})
|
|
165
226
|
}
|
|
@@ -179,32 +240,78 @@ export const syncStripeProducts = async (ctx: ApiContext, stripe: Stripe): Promi
|
|
|
179
240
|
const behavior = (await payment(ctx).pricingPolicy()).tax.behavior ?? null
|
|
180
241
|
const migrateUnspecifiedPrices = (await stripePricingConfig(ctx))?.migrateUnspecifiedPrices ?? false
|
|
181
242
|
const fxRates: StripeFxRateCache = new Map()
|
|
243
|
+
const rights = await payment(ctx).consumerRightsPolicy()
|
|
244
|
+
const regionCurrencies = new Set(Object.values(rights?.currencies ?? {})
|
|
245
|
+
.filter((code): code is string => typeof code === 'string').map(code => code.toLowerCase()))
|
|
182
246
|
for (const { product, plans } of await stripePlansOf(ctx)) {
|
|
183
247
|
const resolvedPlans: ResolvedPlan[] = []
|
|
184
248
|
for (const plan of plans) {
|
|
185
249
|
const sourceUnitAmount = Math.round(plan.price * 100)
|
|
186
250
|
const sourceCurrency = (plan.currency ?? 'usd').toLowerCase()
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
251
|
+
if (plan.pricingMode === CheckoutPricingMode.Amount) {
|
|
252
|
+
resolvedPlans.push({ plan, unitAmount: sourceUnitAmount, currency: sourceCurrency, sourceUnitAmount, sourceCurrency, options: [] })
|
|
253
|
+
continue
|
|
254
|
+
}
|
|
255
|
+
const settled = await settlementAmount(ctx, stripe, sourceUnitAmount, sourceCurrency, fxRates)
|
|
256
|
+
const { unitAmount, options } = optionsOf(plan, settled.currency, sourceUnitAmount, sourceCurrency, regionCurrencies)
|
|
257
|
+
resolvedPlans.push({
|
|
258
|
+
plan, unitAmount: unitAmount ?? settled.amountMinor, currency: settled.currency, sourceUnitAmount, sourceCurrency,
|
|
259
|
+
options,
|
|
260
|
+
})
|
|
191
261
|
}
|
|
192
262
|
const hash = fingerprintOf(product, resolvedPlans, behavior)
|
|
193
263
|
const stored = await fpRes.bySku(product.sku)
|
|
194
|
-
|
|
264
|
+
// A row from before prices were persisted syncs once more, so `planPrices` can read it.
|
|
265
|
+
if (stored != null && stored.hash === hash && stored.prices != null) continue
|
|
195
266
|
const stripeProduct = await ensureStripeProduct(stripe, product)
|
|
267
|
+
const prices: SyncedPrice[] = []
|
|
268
|
+
const syncedAt = new Date()
|
|
196
269
|
for (const resolved of resolvedPlans) {
|
|
197
|
-
await ensureStripePrice(stripe, product, resolved, behavior, migrateUnspecifiedPrices)
|
|
270
|
+
const price = await ensureStripePrice(stripe, product, resolved, behavior, migrateUnspecifiedPrices)
|
|
271
|
+
if (price != null) {
|
|
272
|
+
prices.push({
|
|
273
|
+
planSku: resolved.plan.sku, priceId: price.id, lookupKey: planLookupKey(product, resolved.plan),
|
|
274
|
+
currency: resolved.currency, unitAmount: resolved.unitAmount, options: resolved.options,
|
|
275
|
+
...(behavior != null ? { taxBehavior: behavior } : price.tax_behavior != null ? { taxBehavior: price.tax_behavior } : {}),
|
|
276
|
+
...(resolved.plan.recurring != null ? { interval: resolved.plan.recurring.interval } : {}),
|
|
277
|
+
sourceUnitAmount: resolved.sourceUnitAmount, sourceCurrency: resolved.sourceCurrency, syncedAt,
|
|
278
|
+
})
|
|
279
|
+
}
|
|
198
280
|
}
|
|
199
281
|
if (stored != null) {
|
|
200
|
-
await fpRes.update({ ...stored, hash, productId: stripeProduct.id, updatedAt: new Date() })
|
|
282
|
+
await fpRes.update({ ...stored, hash, productId: stripeProduct.id, prices, updatedAt: new Date() })
|
|
201
283
|
} else {
|
|
202
|
-
await fpRes.create({ sku: product.sku, hash, productId: stripeProduct.id, updatedAt: new Date() })
|
|
284
|
+
await fpRes.create({ sku: product.sku, hash, productId: stripeProduct.id, prices, updatedAt: new Date() })
|
|
203
285
|
}
|
|
204
286
|
console.info(`[payment] synced product '${product.sku}' to Stripe (${plans.length} plan(s))`)
|
|
205
287
|
}
|
|
206
288
|
}
|
|
207
289
|
|
|
290
|
+
/**
|
|
291
|
+
* The prices a product's plans are charged at, per currency, as the last sync stored them — no
|
|
292
|
+
* paygate call, so it works in an unmanaged process. One entry for each Price's default currency
|
|
293
|
+
* (`default: true`) and one per currency option.
|
|
294
|
+
*/
|
|
295
|
+
export const syncedPlanPrices = async (ctx: ApiContext, productSku: string): Promise<PlanPriceView[]> => {
|
|
296
|
+
const row = await fingerprints(ctx).bySku(productSku)
|
|
297
|
+
const behaviorOf = (value: string | undefined): TaxBehavior | undefined =>
|
|
298
|
+
value === TaxBehavior.Exclusive || value === TaxBehavior.Inclusive ? value : undefined
|
|
299
|
+
const views: PlanPriceView[] = []
|
|
300
|
+
for (const price of row?.prices ?? []) {
|
|
301
|
+
const taxBehavior = behaviorOf(price.taxBehavior ?? undefined)
|
|
302
|
+
const shared = {
|
|
303
|
+
planSku: price.planSku, ...(taxBehavior != null ? { taxBehavior } : {}),
|
|
304
|
+
...(price.interval != null ? { interval: price.interval } : {}),
|
|
305
|
+
}
|
|
306
|
+
views.push({ ...shared, currency: price.currency, unitAmountMinor: price.unitAmount, default: true })
|
|
307
|
+
for (const option of price.options ?? []) {
|
|
308
|
+
views.push({ ...shared, currency: option.currency, unitAmountMinor: option.unitAmount, default: false })
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
return views
|
|
313
|
+
}
|
|
314
|
+
|
|
208
315
|
/** `syncStripeProducts` with this context's own Stripe client. */
|
|
209
316
|
export const syncPaymentProducts = async (ctx: ApiContext): Promise<void> =>
|
|
210
317
|
await syncStripeProducts(ctx, await stripeClient(ctx))
|