@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/service.ts
CHANGED
|
@@ -1,35 +1,191 @@
|
|
|
1
|
+
import type Stripe from 'stripe'
|
|
1
2
|
import { createService } from '@owlmeans/context'
|
|
2
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
appendPaymentService, DEFAULT_ALIAS as PAYMENT_SERVICE, ENTITLEMENT_GATE, INTERNAL_PAYGATE, LIMIT_GATE,
|
|
5
|
+
PaygateError, ProductError, SubscriptionStatus, UnknownPlan,
|
|
6
|
+
} from '@owlmeans/payment'
|
|
3
7
|
import type { Context as ApiContext } from '@owlmeans/server-api'
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
8
|
+
import { assertPlanDeclarations } from './config.js'
|
|
9
|
+
import {
|
|
10
|
+
ENTITLEMENT_SERVICE, GATEWAY_SERVICE, RES_PAYGATE_CUSTOMER, RES_PAYMENT_FINGERPRINT, RES_PAYMENT_FULFILLMENT,
|
|
11
|
+
RES_PAYMENT_SUBSCRIPTION, RES_PAYMENT_USAGE, RES_PAYMENT_USAGE_COUNTER, RES_PAYMENT_WEBHOOK,
|
|
12
|
+
} from './consts.js'
|
|
13
|
+
import { registerConsumerRights } from './consumer/service.js'
|
|
14
|
+
import { makeEntitlementService } from './entitlement.js'
|
|
15
|
+
import { makeCapabilityGate } from './gate.js'
|
|
16
|
+
import { makeLimitGate } from './limit.js'
|
|
6
17
|
import { appendCompletionObserver } from './observer.js'
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
import
|
|
18
|
+
import { findPlan, findProduct, planRank } from './plan.js'
|
|
19
|
+
import { resyncStripeSubscription, resyncStripeSubscriptions } from './plugins/events.js'
|
|
20
|
+
import { makeEstimateCache, estimateStripePrice } from './plugins/estimate.js'
|
|
21
|
+
import { createPortalLink, ensurePortalConfiguration } from './plugins/portal.js'
|
|
22
|
+
import { makeCheckoutPluginRegistry, narrowAmountFor } from './plugins/checkout-plugins.js'
|
|
23
|
+
import { consumablePlanOf, createCheckoutLink } from './plugins/stripe.js'
|
|
24
|
+
import { ensureWebhookEndpoint } from './plugins/webhook-manager.js'
|
|
25
|
+
import {
|
|
26
|
+
makeFingerprintResource, makeFulfillmentResource, makePaygateCustomerResource, makeSubscriptionResource,
|
|
27
|
+
makeUsageCounterResource, makeUsageResource, makeWebhookResource,
|
|
28
|
+
} from './resource.js'
|
|
29
|
+
import { commitSubscription } from './subscription.js'
|
|
30
|
+
import { syncedPlanPrices, syncStripeProducts } from './sync.js'
|
|
31
|
+
import { consumerRightsOf, stripeClient, subscriptions } from './utils.js'
|
|
32
|
+
import type {
|
|
33
|
+
Config, Context, GatewayService, GrantInternalPlanOptions, PaymentGatewayOptions, PaymentPlan,
|
|
34
|
+
PaymentSubscriptionRecord,
|
|
35
|
+
} from './types.js'
|
|
11
36
|
|
|
12
|
-
export
|
|
37
|
+
export interface StripeBootstrapOptions {
|
|
38
|
+
/** Re-verify what the stored fingerprints say is in place. */
|
|
39
|
+
force?: boolean
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Bring Stripe to what this deployment declares, each step on its own so one failing does not block
|
|
44
|
+
* the others: products and prices, the customer-portal configuration, the webhook endpoint.
|
|
45
|
+
*/
|
|
46
|
+
export const bootstrapStripe = async (
|
|
47
|
+
ctx: ApiContext, stripe: Stripe, opts: StripeBootstrapOptions = {},
|
|
48
|
+
): Promise<void> => {
|
|
49
|
+
const steps: Array<[string, () => Promise<unknown>]> = [
|
|
50
|
+
['products', async () => await syncStripeProducts(ctx, stripe)],
|
|
51
|
+
['portal', async () => await ensurePortalConfiguration(ctx, stripe, opts)],
|
|
52
|
+
['webhook', async () => await ensureWebhookEndpoint(ctx, stripe, opts)],
|
|
53
|
+
]
|
|
54
|
+
for (const [name, step] of steps) {
|
|
55
|
+
try {
|
|
56
|
+
await step()
|
|
57
|
+
} catch (error) {
|
|
58
|
+
console.error(`[payment] Stripe ${name} bootstrap failed`, error)
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Grant a plan without a paygate — the free plan every entity holds, or a complimentary one
|
|
65
|
+
* (`force`). One row per grant (`free:<entityId>` / `internal:<planSku>:<entityId>`), upserted: its
|
|
66
|
+
* `createdAt` survives a re-grant, and observers hear `created` once.
|
|
67
|
+
*/
|
|
68
|
+
export const grantInternalPlan = async (
|
|
69
|
+
ctx: ApiContext, entityId: string, planSku: string, opts: GrantInternalPlanOptions = {},
|
|
70
|
+
): Promise<PaymentSubscriptionRecord> => {
|
|
71
|
+
const plan = await findPlan(ctx, planSku)
|
|
72
|
+
if (plan == null) {
|
|
73
|
+
throw new UnknownPlan(planSku)
|
|
74
|
+
}
|
|
75
|
+
if (plan.free !== true && opts.force !== true) {
|
|
76
|
+
throw new ProductError(`internal-grant:${planSku}`)
|
|
77
|
+
}
|
|
78
|
+
const product = await findProduct(ctx, plan.productSku)
|
|
79
|
+
const externalId = plan.free === true ? `free:${entityId}` : `internal:${planSku}:${entityId}`
|
|
80
|
+
const previous = await subscriptions(ctx).byExternalId(externalId, INTERNAL_PAYGATE)
|
|
81
|
+
const now = new Date()
|
|
82
|
+
const { periodEnd: _periodEnd, endedAt: _endedAt, canceledAt: _canceledAt, ...kept } = previous ?? {}
|
|
83
|
+
|
|
84
|
+
const next = {
|
|
85
|
+
...kept,
|
|
86
|
+
entityId,
|
|
87
|
+
planSku,
|
|
88
|
+
productSku: plan.productSku,
|
|
89
|
+
service: previous?.service ?? product?.services?.[0] ?? ctx.cfg.service,
|
|
90
|
+
paygate: INTERNAL_PAYGATE,
|
|
91
|
+
externalId,
|
|
92
|
+
status: SubscriptionStatus.Active,
|
|
93
|
+
rank: planRank(plan),
|
|
94
|
+
...(opts.periodEnd != null ? { periodEnd: opts.periodEnd } : {}),
|
|
95
|
+
createdAt: previous?.createdAt ?? now,
|
|
96
|
+
updatedAt: now,
|
|
97
|
+
} as PaymentSubscriptionRecord
|
|
98
|
+
const { record } = await commitSubscription(ctx, previous, next)
|
|
99
|
+
|
|
100
|
+
return record ?? next
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const unmanaged = (): never => { throw new PaygateError('unmanaged') }
|
|
104
|
+
|
|
105
|
+
export const makeGatewayService = (
|
|
106
|
+
alias: string = GATEWAY_SERVICE, opts: Pick<PaymentGatewayOptions, 'manage'> = {},
|
|
107
|
+
): GatewayService => {
|
|
108
|
+
const managed = opts.manage !== false
|
|
109
|
+
// One estimate cache per gateway SERVICE instance, never module-level: several service
|
|
110
|
+
// instances (several tests, several deployments in one process) must never share hits.
|
|
111
|
+
const estimateCache = makeEstimateCache()
|
|
112
|
+
// Checkout plugins are seated per gateway instance, like the estimate cache.
|
|
113
|
+
const plugins = makeCheckoutPluginRegistry()
|
|
13
114
|
const service = createService<GatewayService>(alias, {
|
|
14
|
-
|
|
15
|
-
|
|
115
|
+
managed,
|
|
116
|
+
createLink: async (ctx, params) => managed
|
|
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),
|
|
128
|
+
portalLink: async (ctx, entityId, link) => managed
|
|
129
|
+
? await createPortalLink(ctx, await stripeClient(ctx), entityId, link) : unmanaged(),
|
|
130
|
+
grantInternalPlan: async (ctx, entityId, planSku, grant) => await grantInternalPlan(ctx, entityId, planSku, grant),
|
|
131
|
+
resyncSubscription: async (ctx, ref) => managed
|
|
132
|
+
? await resyncStripeSubscription(ctx, await stripeClient(ctx), ref) : unmanaged(),
|
|
133
|
+
resyncAll: async ctx => managed
|
|
134
|
+
? await resyncStripeSubscriptions(ctx, await stripeClient(ctx)) : unmanaged(),
|
|
135
|
+
estimatePrice: async (ctx, params) => managed
|
|
136
|
+
? await estimateStripePrice(ctx, await stripeClient(ctx), params, estimateCache) : unmanaged(),
|
|
16
137
|
}, service => async () => {
|
|
138
|
+
const ctx = service.assertCtx() as unknown as ApiContext
|
|
139
|
+
assertPlanDeclarations(ctx.cfg)
|
|
17
140
|
service.initialized = true
|
|
18
|
-
|
|
19
|
-
|
|
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)
|
|
144
|
+
if (managed) {
|
|
145
|
+
void ctx.waitForInitialized().then(async () => {
|
|
146
|
+
await bootstrapStripe(ctx, await stripeClient(ctx))
|
|
147
|
+
}).catch(error => { console.error('[payment] Stripe bootstrap failed', error) })
|
|
148
|
+
}
|
|
20
149
|
})
|
|
150
|
+
|
|
21
151
|
return service
|
|
22
152
|
}
|
|
23
153
|
|
|
154
|
+
/**
|
|
155
|
+
* Register the payment resources, the catalogue service, the completion observer, the gateway, both
|
|
156
|
+
* gate services, the entitlement service and the consumer-rights records and service — each only
|
|
157
|
+
* when not registered yet.
|
|
158
|
+
*
|
|
159
|
+
* `manage: false` registers the same surface for a process that reads entitlements but never talks
|
|
160
|
+
* to Stripe.
|
|
161
|
+
*/
|
|
24
162
|
export const appendPaymentGatewayService = <C extends Config, T extends Context<C>>(
|
|
25
|
-
ctx: T, opts?:
|
|
163
|
+
ctx: T, opts?: PaymentGatewayOptions,
|
|
26
164
|
): T => {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
165
|
+
const makers = [
|
|
166
|
+
[RES_PAYGATE_CUSTOMER, makePaygateCustomerResource],
|
|
167
|
+
[RES_PAYMENT_SUBSCRIPTION, makeSubscriptionResource],
|
|
168
|
+
[RES_PAYMENT_FULFILLMENT, makeFulfillmentResource],
|
|
169
|
+
[RES_PAYMENT_WEBHOOK, makeWebhookResource],
|
|
170
|
+
[RES_PAYMENT_USAGE, makeUsageResource],
|
|
171
|
+
[RES_PAYMENT_USAGE_COUNTER, makeUsageCounterResource],
|
|
172
|
+
[RES_PAYMENT_FINGERPRINT, makeFingerprintResource],
|
|
173
|
+
] as const
|
|
174
|
+
for (const [alias, maker] of makers) {
|
|
175
|
+
if (!ctx.hasResource(alias)) {
|
|
176
|
+
ctx.registerResource(maker(opts?.dbAlias, opts?.serviceAlias) as never)
|
|
177
|
+
}
|
|
178
|
+
}
|
|
30
179
|
if (!ctx.hasService(PAYMENT_SERVICE)) appendPaymentService(ctx as never)
|
|
31
180
|
appendCompletionObserver(ctx)
|
|
32
|
-
ctx.registerService(makeGatewayService())
|
|
33
|
-
if (!ctx.hasService(ENTITLEMENT_GATE)) ctx.registerService(
|
|
181
|
+
if (!ctx.hasService(GATEWAY_SERVICE)) ctx.registerService(makeGatewayService(GATEWAY_SERVICE, { manage: opts?.manage }))
|
|
182
|
+
if (!ctx.hasService(ENTITLEMENT_GATE)) ctx.registerService(makeCapabilityGate())
|
|
183
|
+
if (!ctx.hasService(LIMIT_GATE)) ctx.registerService(makeLimitGate())
|
|
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')
|
|
189
|
+
|
|
34
190
|
return ctx
|
|
35
191
|
}
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import { ENTITLING_STATUSES, SubscriptionStatus } from '@owlmeans/payment'
|
|
2
|
+
import type { Context as ApiContext } from '@owlmeans/server-api'
|
|
3
|
+
import { findPlan } from './plan.js'
|
|
4
|
+
import { compact, isDuplicateKey, observer, subscriptions } from './utils.js'
|
|
5
|
+
import type {
|
|
6
|
+
PaymentSubscriptionRecord, PropagatedState, SubscriptionChange, SubscriptionSnapshot,
|
|
7
|
+
} from './types.js'
|
|
8
|
+
|
|
9
|
+
export interface CommitOptions {
|
|
10
|
+
/** The paygate event being applied; a repeat of the last applied one is ignored. */
|
|
11
|
+
eventId?: string
|
|
12
|
+
/** A paid renewal invoice. */
|
|
13
|
+
renewal?: boolean
|
|
14
|
+
invoiceId?: string
|
|
15
|
+
/** The paygate announced the trial ends soon. */
|
|
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>
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface CommitResult {
|
|
26
|
+
record: PaymentSubscriptionRecord | null
|
|
27
|
+
change: SubscriptionChange | null
|
|
28
|
+
/** The stored subscription state changed. */
|
|
29
|
+
updated: boolean
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const ENDED: readonly SubscriptionStatus[] = [SubscriptionStatus.Canceled, SubscriptionStatus.Ended]
|
|
33
|
+
|
|
34
|
+
const timeOf = (value: Date | null | undefined): number | null => value == null ? null : new Date(value).getTime()
|
|
35
|
+
|
|
36
|
+
/** The inputs classification compares. */
|
|
37
|
+
export const propagatedStateOf = (record: PaymentSubscriptionRecord, renewedInvoiceId?: string): PropagatedState => compact({
|
|
38
|
+
planSku: record.planSku,
|
|
39
|
+
rank: record.rank,
|
|
40
|
+
status: record.status,
|
|
41
|
+
cancelAtPeriodEnd: record.cancelAtPeriodEnd === true,
|
|
42
|
+
pausedAt: record.pausedAt ?? undefined,
|
|
43
|
+
renewedInvoiceId: renewedInvoiceId ?? undefined,
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* What one subscription change means — the first rule that matches:
|
|
48
|
+
* `created` (first entitling state ever propagated), `canceled`, `paused`, `resumed`,
|
|
49
|
+
* `upgraded`/`downgraded` (plan change by rank; equal rank reads as an upgrade),
|
|
50
|
+
* `cancel-scheduled`, `cancel-undone`, `renewed` (once per invoice), `past-due`, `suspended`,
|
|
51
|
+
* `trial-ending`; otherwise nothing an observer is told about.
|
|
52
|
+
*/
|
|
53
|
+
export const classifySubscriptionChange = (
|
|
54
|
+
previous: PropagatedState | null, current: PaymentSubscriptionRecord, opts: CommitOptions = {},
|
|
55
|
+
): SubscriptionChange | null => {
|
|
56
|
+
const active = ENTITLING_STATUSES.includes(current.status)
|
|
57
|
+
if (previous == null) {
|
|
58
|
+
return active ? 'created' : null
|
|
59
|
+
}
|
|
60
|
+
if (ENDED.includes(current.status) && !ENDED.includes(previous.status)) {
|
|
61
|
+
return 'canceled'
|
|
62
|
+
}
|
|
63
|
+
if (current.pausedAt != null && previous.pausedAt == null) {
|
|
64
|
+
return 'paused'
|
|
65
|
+
}
|
|
66
|
+
if ((current.pausedAt == null && previous.pausedAt != null)
|
|
67
|
+
|| (previous.status === SubscriptionStatus.Suspended && active)) {
|
|
68
|
+
return 'resumed'
|
|
69
|
+
}
|
|
70
|
+
if (current.planSku !== previous.planSku) {
|
|
71
|
+
return current.rank < previous.rank ? 'downgraded' : 'upgraded'
|
|
72
|
+
}
|
|
73
|
+
if (previous.cancelAtPeriodEnd !== true && current.cancelAtPeriodEnd === true) {
|
|
74
|
+
return 'cancel-scheduled'
|
|
75
|
+
}
|
|
76
|
+
if (previous.cancelAtPeriodEnd === true && current.cancelAtPeriodEnd !== true) {
|
|
77
|
+
return 'cancel-undone'
|
|
78
|
+
}
|
|
79
|
+
if (opts.renewal === true && opts.invoiceId != null && opts.invoiceId !== previous.renewedInvoiceId) {
|
|
80
|
+
return 'renewed'
|
|
81
|
+
}
|
|
82
|
+
if (current.status === SubscriptionStatus.PastDue && previous.status !== SubscriptionStatus.PastDue) {
|
|
83
|
+
return 'past-due'
|
|
84
|
+
}
|
|
85
|
+
if (current.status === SubscriptionStatus.Suspended && previous.status !== SubscriptionStatus.Suspended) {
|
|
86
|
+
return 'suspended'
|
|
87
|
+
}
|
|
88
|
+
if (opts.trialEnding === true) {
|
|
89
|
+
return 'trial-ending'
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return null
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** The idempotency key a consumer keys the side effect of a change by. */
|
|
96
|
+
export const subscriptionEventKey = (
|
|
97
|
+
record: PaymentSubscriptionRecord, change: SubscriptionChange, opts: CommitOptions = {},
|
|
98
|
+
): string => {
|
|
99
|
+
const base = `subscription:${record.externalId}:${change}`
|
|
100
|
+
switch (change) {
|
|
101
|
+
case 'created':
|
|
102
|
+
return `${base}:${new Date(record.createdAt).toISOString()}`
|
|
103
|
+
case 'renewed':
|
|
104
|
+
return `${base}:${opts.invoiceId ?? record.latestInvoiceId ?? 'unknown'}`
|
|
105
|
+
case 'upgraded':
|
|
106
|
+
case 'downgraded':
|
|
107
|
+
return `${base}:${record.planSku}`
|
|
108
|
+
case 'trial-ending':
|
|
109
|
+
return `${base}:${record.trialEnd != null ? new Date(record.trialEnd).toISOString() : 'unknown'}`
|
|
110
|
+
default:
|
|
111
|
+
return `${base}:${opts.eventId ?? `sync-${new Date(record.updatedAt ?? Date.now()).toISOString()}`}`
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** A subscription as observers see it; `state` overlays what was last propagated. */
|
|
116
|
+
export const snapshotOf = async (
|
|
117
|
+
ctx: ApiContext, record: PaymentSubscriptionRecord, state?: PropagatedState,
|
|
118
|
+
): Promise<SubscriptionSnapshot> => {
|
|
119
|
+
const planSku = state?.planSku ?? record.planSku
|
|
120
|
+
const plan = await findPlan(ctx, planSku)
|
|
121
|
+
|
|
122
|
+
return compact<SubscriptionSnapshot>({
|
|
123
|
+
entityId: record.entityId,
|
|
124
|
+
planSku,
|
|
125
|
+
productSku: plan?.productSku ?? record.productSku,
|
|
126
|
+
rank: state?.rank ?? record.rank,
|
|
127
|
+
status: state?.status ?? record.status,
|
|
128
|
+
paygate: record.paygate,
|
|
129
|
+
subscriptionId: record.externalId,
|
|
130
|
+
service: record.service,
|
|
131
|
+
periodStart: record.periodStart ?? undefined,
|
|
132
|
+
periodEnd: record.periodEnd ?? undefined,
|
|
133
|
+
cancelAtPeriodEnd: state != null ? state.cancelAtPeriodEnd : record.cancelAtPeriodEnd ?? undefined,
|
|
134
|
+
trialEnd: record.trialEnd ?? undefined,
|
|
135
|
+
pausedAt: state != null ? state.pausedAt : record.pausedAt ?? undefined,
|
|
136
|
+
createdAt: record.createdAt ?? undefined,
|
|
137
|
+
capabilities: plan?.capabilities,
|
|
138
|
+
limits: plan?.limits,
|
|
139
|
+
})
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const MATERIAL: Array<keyof PaymentSubscriptionRecord> = [
|
|
143
|
+
'entityId', 'planSku', 'productSku', 'service', 'itemId', 'priceId', 'status', 'externalStatus', 'rank',
|
|
144
|
+
'periodStart', 'periodEnd', 'cancelAtPeriodEnd', 'canceledAt', 'endedAt', 'pausedAt', 'trialEnd',
|
|
145
|
+
'latestInvoiceId', 'customerId',
|
|
146
|
+
]
|
|
147
|
+
|
|
148
|
+
const sameValue = (left: unknown, right: unknown): boolean => {
|
|
149
|
+
if (left == null || right == null) {
|
|
150
|
+
return left == null && right == null
|
|
151
|
+
}
|
|
152
|
+
if (left instanceof Date || right instanceof Date) {
|
|
153
|
+
return timeOf(left as Date) === timeOf(right as Date)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return left === right
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export const materiallyDiffers = (previous: PaymentSubscriptionRecord, next: PaymentSubscriptionRecord): boolean =>
|
|
160
|
+
MATERIAL.some(field => !sameValue(previous[field], next[field]))
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Store a subscription's new state, tell observers what changed, then record what was told.
|
|
164
|
+
*
|
|
165
|
+
* The state is written BEFORE observers run, so an observer reading entitlements sees the new plan.
|
|
166
|
+
* What classification compares against is the state last PROPAGATED (`propagated`), stamped only
|
|
167
|
+
* after every observer succeeded — so an observer that throws leaves the change to be classified
|
|
168
|
+
* again, identically, when the paygate retries. `lastEventId` is stamped with it.
|
|
169
|
+
*/
|
|
170
|
+
export const commitSubscription = async (
|
|
171
|
+
ctx: ApiContext, previous: PaymentSubscriptionRecord | null, next: PaymentSubscriptionRecord,
|
|
172
|
+
opts: CommitOptions = {},
|
|
173
|
+
): Promise<CommitResult> => {
|
|
174
|
+
const resource = subscriptions(ctx)
|
|
175
|
+
const updated = previous == null || materiallyDiffers(previous, next)
|
|
176
|
+
const prior = previous?.propagated ?? null
|
|
177
|
+
const change = classifySubscriptionChange(prior, next, opts)
|
|
178
|
+
const at = new Date()
|
|
179
|
+
|
|
180
|
+
const finalize = (record: PaymentSubscriptionRecord): PaymentSubscriptionRecord => {
|
|
181
|
+
const tracked = prior != null || change === 'created'
|
|
182
|
+
return {
|
|
183
|
+
...record,
|
|
184
|
+
...(opts.eventId != null ? { lastEventId: opts.eventId } : {}),
|
|
185
|
+
...(tracked ? {
|
|
186
|
+
propagated: propagatedStateOf(record, change === 'renewed' ? opts.invoiceId : prior?.renewedInvoiceId),
|
|
187
|
+
} : {}),
|
|
188
|
+
...(change === 'created' && record.initialPropagatedAt == null ? { initialPropagatedAt: at } : {}),
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const write = async (record: PaymentSubscriptionRecord): Promise<PaymentSubscriptionRecord> => {
|
|
193
|
+
if (record.id != null) {
|
|
194
|
+
return await resource.update(record)
|
|
195
|
+
}
|
|
196
|
+
try {
|
|
197
|
+
return await resource.create(record)
|
|
198
|
+
} catch (error) {
|
|
199
|
+
if (!isDuplicateKey(error)) {
|
|
200
|
+
throw error
|
|
201
|
+
}
|
|
202
|
+
const winner = await resource.byExternalId(record.externalId, record.paygate)
|
|
203
|
+
return await resource.update({ ...record, id: winner?.id })
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (change == null) {
|
|
208
|
+
const quiet = previous != null && !updated && (opts.eventId == null || previous.lastEventId === opts.eventId)
|
|
209
|
+
&& (prior == null || !stateDiffers(prior, next))
|
|
210
|
+
return { record: quiet ? previous : await write(finalize(next)), change: null, updated }
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const stored = await write(next)
|
|
214
|
+
await opts.beforePropagate?.(stored, change)
|
|
215
|
+
await observer(ctx).propagateSubscription({
|
|
216
|
+
change,
|
|
217
|
+
previous: prior != null && previous != null ? await snapshotOf(ctx, previous, prior) : null,
|
|
218
|
+
current: await snapshotOf(ctx, stored),
|
|
219
|
+
active: ENTITLING_STATUSES.includes(stored.status),
|
|
220
|
+
eventKey: subscriptionEventKey(stored, change, opts),
|
|
221
|
+
...(opts.invoiceId != null ? { invoiceId: opts.invoiceId } : {}),
|
|
222
|
+
...(opts.eventId != null ? { externalEventId: opts.eventId } : {}),
|
|
223
|
+
}, ctx)
|
|
224
|
+
|
|
225
|
+
return { record: await resource.update(finalize(stored)), change, updated }
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const stateDiffers = (state: PropagatedState, record: PaymentSubscriptionRecord): boolean =>
|
|
229
|
+
state.planSku !== record.planSku || state.rank !== record.rank || state.status !== record.status
|
|
230
|
+
|| (state.cancelAtPeriodEnd === true) !== (record.cancelAtPeriodEnd === true)
|
|
231
|
+
|| timeOf(state.pausedAt) !== timeOf(record.pausedAt)
|