@owlmeans/server-payment 0.1.18-rc.0
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 +55 -0
- package/agent-meta/manifest.json +16 -0
- package/agent-meta/skills/server-payment/SKILL.md +80 -0
- package/build/actions/index.d.ts +3 -0
- package/build/actions/index.d.ts.map +1 -0
- package/build/actions/index.js +3 -0
- package/build/actions/index.js.map +1 -0
- package/build/actions/resync.d.ts +2 -0
- package/build/actions/resync.d.ts.map +1 -0
- package/build/actions/resync.js +11 -0
- package/build/actions/resync.js.map +1 -0
- package/build/actions/webhook.d.ts +4 -0
- package/build/actions/webhook.d.ts.map +1 -0
- package/build/actions/webhook.js +11 -0
- package/build/actions/webhook.js.map +1 -0
- package/build/config.d.ts +8 -0
- package/build/config.d.ts.map +1 -0
- package/build/config.js +46 -0
- package/build/config.js.map +1 -0
- package/build/consts.d.ts +26 -0
- package/build/consts.d.ts.map +1 -0
- package/build/consts.js +33 -0
- package/build/consts.js.map +1 -0
- package/build/entrypoints.d.ts +5 -0
- package/build/entrypoints.d.ts.map +1 -0
- package/build/entrypoints.js +10 -0
- package/build/entrypoints.js.map +1 -0
- package/build/gate.d.ts +10 -0
- package/build/gate.d.ts.map +1 -0
- package/build/gate.js +37 -0
- package/build/gate.js.map +1 -0
- package/build/index.d.ts +12 -0
- package/build/index.d.ts.map +1 -0
- package/build/index.js +11 -0
- package/build/index.js.map +1 -0
- package/build/model.d.ts +6 -0
- package/build/model.d.ts.map +1 -0
- package/build/model.js +33 -0
- package/build/model.js.map +1 -0
- package/build/observer.d.ts +4 -0
- package/build/observer.d.ts.map +1 -0
- package/build/observer.js +24 -0
- package/build/observer.js.map +1 -0
- package/build/plugins/events.d.ts +16 -0
- package/build/plugins/events.d.ts.map +1 -0
- package/build/plugins/events.js +195 -0
- package/build/plugins/events.js.map +1 -0
- package/build/plugins/stripe.d.ts +14 -0
- package/build/plugins/stripe.d.ts.map +1 -0
- package/build/plugins/stripe.js +166 -0
- package/build/plugins/stripe.js.map +1 -0
- package/build/resource.d.ts +6 -0
- package/build/resource.d.ts.map +1 -0
- package/build/resource.js +32 -0
- package/build/resource.js.map +1 -0
- package/build/service.d.ts +4 -0
- package/build/service.d.ts.map +1 -0
- package/build/service.js +35 -0
- package/build/service.js.map +1 -0
- package/build/sync.d.ts +5 -0
- package/build/sync.d.ts.map +1 -0
- package/build/sync.js +93 -0
- package/build/sync.js.map +1 -0
- package/build/types.d.ts +189 -0
- package/build/types.d.ts.map +1 -0
- package/build/types.js +2 -0
- package/build/types.js.map +1 -0
- package/build/utils.d.ts +14 -0
- package/build/utils.d.ts.map +1 -0
- package/build/utils.js +18 -0
- package/build/utils.js.map +1 -0
- package/package.json +54 -0
- package/src/actions/index.ts +2 -0
- package/src/actions/resync.ts +13 -0
- package/src/actions/webhook.ts +12 -0
- package/src/config.ts +53 -0
- package/src/consts.ts +47 -0
- package/src/entrypoints.ts +10 -0
- package/src/gate.ts +51 -0
- package/src/index.ts +13 -0
- package/src/model.ts +37 -0
- package/src/observer.ts +27 -0
- package/src/plugins/events.ts +181 -0
- package/src/plugins/stripe.ts +183 -0
- package/src/resource.ts +51 -0
- package/src/service.ts +35 -0
- package/src/sync.ts +97 -0
- package/src/types.ts +157 -0
- package/src/utils.ts +33 -0
- package/tests/checkout.spec.ts +135 -0
- package/tests/gate.spec.ts +97 -0
- package/tests/protocol.spec.ts +20 -0
- package/tests/tsconfig.json +5 -0
- package/tsconfig.json +11 -0
package/src/types.ts
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import type { InitializedService, LazyService } from '@owlmeans/context'
|
|
2
|
+
import type { ResourceRecord } from '@owlmeans/resource'
|
|
3
|
+
import type { MongoResource } from '@owlmeans/mongo-resource'
|
|
4
|
+
import type { PluginConfig } from '@owlmeans/config'
|
|
5
|
+
import type { PermissionSet } from '@owlmeans/auth'
|
|
6
|
+
import type { Config as ApiConfig, Context as ApiContext } from '@owlmeans/server-api'
|
|
7
|
+
import type {
|
|
8
|
+
AmountCheckoutPolicy, CheckoutPricingMode, LimitConfig, PlanDuration, Product, ProductPlan,
|
|
9
|
+
ProductType, QuantityCheckoutPolicy, SubscriptionStatus,
|
|
10
|
+
} from '@owlmeans/payment'
|
|
11
|
+
|
|
12
|
+
export interface Config extends ApiConfig {}
|
|
13
|
+
export interface Context<C extends Config = Config> extends ApiContext<C> {}
|
|
14
|
+
|
|
15
|
+
export interface PaymentProduct extends Product {
|
|
16
|
+
taxCode?: string
|
|
17
|
+
unitLabel?: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface PaymentPlan extends ProductPlan {
|
|
21
|
+
recurring?: { interval: 'month' | 'year' }
|
|
22
|
+
minQuantity?: number
|
|
23
|
+
maxQuantity?: number
|
|
24
|
+
defaultQuantity?: number
|
|
25
|
+
pricingMode?: CheckoutPricingMode
|
|
26
|
+
amountPolicy?: AmountCheckoutPolicy
|
|
27
|
+
quantityPolicy?: QuantityCheckoutPolicy
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface PaymentProductDef {
|
|
31
|
+
sku: string
|
|
32
|
+
type: ProductType
|
|
33
|
+
services: string[]
|
|
34
|
+
gateways?: string[]
|
|
35
|
+
taxCode?: string
|
|
36
|
+
name: string
|
|
37
|
+
description?: string
|
|
38
|
+
unitLabel?: string
|
|
39
|
+
defaultLng?: string
|
|
40
|
+
capabilities?: PermissionSet[]
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface PaymentPlanDef {
|
|
44
|
+
productSku: string
|
|
45
|
+
sku: string
|
|
46
|
+
duration: PlanDuration
|
|
47
|
+
/** Reference unit price in major units; amount checkout uses it only for fulfillment conversion. */
|
|
48
|
+
price: number
|
|
49
|
+
currency?: string
|
|
50
|
+
recurring?: { interval: 'month' | 'year' }
|
|
51
|
+
order?: number
|
|
52
|
+
title?: string
|
|
53
|
+
capabilities?: PermissionSet[]
|
|
54
|
+
limits?: { [key: string]: LimitConfig }
|
|
55
|
+
pricingMode?: CheckoutPricingMode
|
|
56
|
+
amountPolicy?: AmountCheckoutPolicy
|
|
57
|
+
quantityPolicy?: QuantityCheckoutPolicy
|
|
58
|
+
/** Legacy quantity declarations retained for source compatibility. */
|
|
59
|
+
minQuantity?: number
|
|
60
|
+
maxQuantity?: number
|
|
61
|
+
defaultQuantity?: number
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface StripePluginConfig extends PluginConfig { api: string; webhook: string }
|
|
65
|
+
|
|
66
|
+
export interface CreateLinkParams {
|
|
67
|
+
productSku: string
|
|
68
|
+
/** Stable database key resolved by the application before this in-process call. */
|
|
69
|
+
entityId: string
|
|
70
|
+
profileId?: string
|
|
71
|
+
service: string
|
|
72
|
+
planSku?: string
|
|
73
|
+
amountMinor?: number
|
|
74
|
+
successUrl?: string
|
|
75
|
+
cancelUrl?: string
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface PaymentPlugin {
|
|
79
|
+
createLink: (ctx: ApiContext, params: CreateLinkParams) => Promise<string>
|
|
80
|
+
handleWebhook: (request: unknown, ctx: ApiContext) => Promise<void>
|
|
81
|
+
initialize?: (ctx: ApiContext) => Promise<void>
|
|
82
|
+
manageSubscription: (ctx: ApiContext, entityId: string, returnUrl: string) => Promise<string>
|
|
83
|
+
}
|
|
84
|
+
export interface GatewayService extends InitializedService {
|
|
85
|
+
createLink: PaymentPlugin['createLink']
|
|
86
|
+
manageSubscription: PaymentPlugin['manageSubscription']
|
|
87
|
+
}
|
|
88
|
+
export interface PaymentResourceOptions { dbAlias?: string; serviceAlias?: string }
|
|
89
|
+
|
|
90
|
+
interface TopUpBase {
|
|
91
|
+
entityId: string
|
|
92
|
+
productSku: string
|
|
93
|
+
planSku?: string
|
|
94
|
+
service: string
|
|
95
|
+
paygate: string
|
|
96
|
+
externalId: string
|
|
97
|
+
}
|
|
98
|
+
export interface QuantityTopUpCompletion extends TopUpBase {
|
|
99
|
+
mode: 'quantity'
|
|
100
|
+
units: number
|
|
101
|
+
}
|
|
102
|
+
export interface AmountTopUpCompletion extends TopUpBase {
|
|
103
|
+
mode: 'amount'
|
|
104
|
+
amountMinor: number
|
|
105
|
+
chargeAmountMinor: number
|
|
106
|
+
currency: string
|
|
107
|
+
}
|
|
108
|
+
export type TopUpCompletion = QuantityTopUpCompletion | AmountTopUpCompletion
|
|
109
|
+
|
|
110
|
+
export interface SubscriptionCompletion {
|
|
111
|
+
entityId: string
|
|
112
|
+
status: SubscriptionStatus
|
|
113
|
+
productSku: string
|
|
114
|
+
planSku: string
|
|
115
|
+
service: string
|
|
116
|
+
paygate: string
|
|
117
|
+
externalId: string
|
|
118
|
+
isNew: boolean
|
|
119
|
+
capabilities?: PermissionSet[]
|
|
120
|
+
limits?: { [key: string]: LimitConfig }
|
|
121
|
+
}
|
|
122
|
+
export interface TopUpCallback { (completion: TopUpCompletion, ctx: ApiContext): Promise<void> }
|
|
123
|
+
export interface SubscriptionCallback { (completion: SubscriptionCompletion, ctx: ApiContext): Promise<void> }
|
|
124
|
+
export interface CompletionObserver extends LazyService {
|
|
125
|
+
onTopUp: (cb: TopUpCallback) => void
|
|
126
|
+
onSubscription: (cb: SubscriptionCallback) => void
|
|
127
|
+
propagateTopUp: (completion: TopUpCompletion, ctx: ApiContext) => Promise<void>
|
|
128
|
+
propagateSubscription: (completion: SubscriptionCompletion, ctx: ApiContext) => Promise<void>
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export interface PaygateCustomerRecord extends ResourceRecord {
|
|
132
|
+
paygate: string; externalId: string; entityId?: string; profileId?: string
|
|
133
|
+
email?: string; name?: string; taxId?: string
|
|
134
|
+
}
|
|
135
|
+
export interface PaygateCustomerResource extends MongoResource<PaygateCustomerRecord> {
|
|
136
|
+
loadByPgId: (externalId: string, paygate: string) => Promise<PaygateCustomerRecord | null>
|
|
137
|
+
byEntity: (entityId: string, paygate: string) => Promise<PaygateCustomerRecord | null>
|
|
138
|
+
}
|
|
139
|
+
export interface PaymentSubscriptionRecord extends ResourceRecord {
|
|
140
|
+
sku: string; productSku: string; entityId: string; service: string; paygate: string
|
|
141
|
+
externalId: string; status: SubscriptionStatus; kind: 'consumable' | 'subscription'
|
|
142
|
+
capabilities?: PermissionSet[]; limits?: { [key: string]: LimitConfig }
|
|
143
|
+
pricingMode?: CheckoutPricingMode; units?: number; amountMinor?: number
|
|
144
|
+
chargeAmountMinor?: number; currency?: string
|
|
145
|
+
createdAt: Date; endsAt?: Date; trialUntil?: Date; canceledAt?: Date
|
|
146
|
+
fulfilledAt?: Date; initialPropagatedAt?: Date
|
|
147
|
+
}
|
|
148
|
+
export interface PaymentSubscriptionResource extends MongoResource<PaymentSubscriptionRecord> {
|
|
149
|
+
byExternalId: (externalId: string, paygate: string) => Promise<PaymentSubscriptionRecord | null>
|
|
150
|
+
}
|
|
151
|
+
export interface FingerprintRecord extends ResourceRecord {
|
|
152
|
+
sku: string; hash: string; productId?: string; updatedAt: Date
|
|
153
|
+
}
|
|
154
|
+
export interface FingerprintResource extends MongoResource<FingerprintRecord> {
|
|
155
|
+
bySku: (sku: string) => Promise<FingerprintRecord | null>
|
|
156
|
+
clear: () => Promise<void>
|
|
157
|
+
}
|
package/src/utils.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import Stripe from 'stripe'
|
|
2
|
+
import { PLUGINS } from '@owlmeans/config'
|
|
3
|
+
import { DEFAULT_ALIAS as PAYMENT_SERVICE, SubscriptionStatus } from '@owlmeans/payment'
|
|
4
|
+
import type { PaymentService } from '@owlmeans/payment'
|
|
5
|
+
import type { Context as ApiContext } from '@owlmeans/server-api'
|
|
6
|
+
import {
|
|
7
|
+
GATEWAY_SERVICE, PAYMENT_OBSERVER, RES_PAYGATE_CUSTOMER, RES_PAYMENT_FINGERPRINT,
|
|
8
|
+
RES_PAYMENT_SUBSCRIPTION, STRIPE_PLUGIN_CONFIG,
|
|
9
|
+
} from './consts.js'
|
|
10
|
+
import type {
|
|
11
|
+
CompletionObserver, FingerprintResource, GatewayService, PaygateCustomerResource,
|
|
12
|
+
PaymentSubscriptionRecord, PaymentSubscriptionResource, StripePluginConfig,
|
|
13
|
+
} from './types.js'
|
|
14
|
+
|
|
15
|
+
export const payment = (ctx: ApiContext): PaymentService => ctx.service<PaymentService>(PAYMENT_SERVICE)
|
|
16
|
+
export const gateway = (ctx: ApiContext): GatewayService => ctx.service<GatewayService>(GATEWAY_SERVICE)
|
|
17
|
+
export const observer = (ctx: ApiContext): CompletionObserver => ctx.service<CompletionObserver>(PAYMENT_OBSERVER)
|
|
18
|
+
export const paygateCustomers = (ctx: ApiContext): PaygateCustomerResource =>
|
|
19
|
+
ctx.resource<PaygateCustomerResource>(RES_PAYGATE_CUSTOMER)
|
|
20
|
+
export const subscriptions = (ctx: ApiContext): PaymentSubscriptionResource =>
|
|
21
|
+
ctx.resource<PaymentSubscriptionResource>(RES_PAYMENT_SUBSCRIPTION)
|
|
22
|
+
export const fingerprints = (ctx: ApiContext): FingerprintResource =>
|
|
23
|
+
ctx.resource<FingerprintResource>(RES_PAYMENT_FINGERPRINT)
|
|
24
|
+
export const stripeConfig = async (ctx: ApiContext): Promise<StripePluginConfig> =>
|
|
25
|
+
await (ctx as never as { getConfigResource: (alias: string) => { get: (id: string) => Promise<unknown> } })
|
|
26
|
+
.getConfigResource(PLUGINS).get(STRIPE_PLUGIN_CONFIG) as StripePluginConfig
|
|
27
|
+
export const stripeClient = async (ctx: ApiContext): Promise<Stripe> => new Stripe((await stripeConfig(ctx)).api)
|
|
28
|
+
export const activeSubscription = async (
|
|
29
|
+
ctx: ApiContext, entityId: string, productSku: string,
|
|
30
|
+
): Promise<PaymentSubscriptionRecord | null> => await subscriptions(ctx).load({
|
|
31
|
+
entityId, productSku, kind: 'subscription',
|
|
32
|
+
status: [SubscriptionStatus.Active, SubscriptionStatus.Trial],
|
|
33
|
+
})
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import { CheckoutPricingMode, PlanDuration, PlanStatus, ProductType } from '@owlmeans/payment'
|
|
3
|
+
import { PAYMENT_OBSERVER, RES_PAYMENT_SUBSCRIPTION } from '../src/consts.js'
|
|
4
|
+
import { createEventHandler } from '../src/plugins/events.js'
|
|
5
|
+
import { amountCheckoutLineItem, quantityCheckoutLineItem } from '../src/plugins/stripe.js'
|
|
6
|
+
import type { PaymentPlan, PaymentProduct, PaymentSubscriptionRecord, TopUpCompletion } from '../src/types.js'
|
|
7
|
+
|
|
8
|
+
const product: PaymentProduct = {
|
|
9
|
+
type: ProductType.Consumable, sku: 'credits', title: 'Credits', services: ['app'],
|
|
10
|
+
}
|
|
11
|
+
const plan: PaymentPlan = {
|
|
12
|
+
productSku: product.sku, sku: 'credits-unit', title: 'Credits', status: PlanStatus.Active,
|
|
13
|
+
duration: PlanDuration.Consumable, price: 0.02, currency: 'usd',
|
|
14
|
+
pricingMode: CheckoutPricingMode.Amount,
|
|
15
|
+
amountPolicy: {
|
|
16
|
+
currency: 'usd', minimumMinor: 500, maximumMinor: 50_000, defaultMinor: 1_000,
|
|
17
|
+
presetsMinor: [1_000, 2_000, 5_000, 10_000], fixedMinor: 0, rateBps: 200,
|
|
18
|
+
},
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const paid = (overrides: Record<string, unknown> = {}) => ({
|
|
22
|
+
id: 'cs_amount', mode: 'payment', payment_status: 'paid', customer: 'cus_1',
|
|
23
|
+
currency: 'usd', amount_subtotal: 1021,
|
|
24
|
+
metadata: {
|
|
25
|
+
pricingMode: 'amount', amountMinor: '1000', chargeAmountMinor: '1021', currency: 'usd',
|
|
26
|
+
entityId: 'entity-1', service: 'app', productSku: 'credits', planSku: 'credits-unit',
|
|
27
|
+
},
|
|
28
|
+
...overrides,
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
const harness = () => {
|
|
32
|
+
const records: PaymentSubscriptionRecord[] = []
|
|
33
|
+
const completions: TopUpCompletion[] = []
|
|
34
|
+
const subscriptions = {
|
|
35
|
+
byExternalId: async (id: string) => records.find(record => record.externalId === id) ?? null,
|
|
36
|
+
create: async (record: PaymentSubscriptionRecord) => {
|
|
37
|
+
const created = { ...record, id: String(records.length + 1) }
|
|
38
|
+
records.push(created)
|
|
39
|
+
return created
|
|
40
|
+
},
|
|
41
|
+
save: async (record: PaymentSubscriptionRecord) => {
|
|
42
|
+
const index = records.findIndex(item => item.id === record.id)
|
|
43
|
+
records[index] = { ...record }
|
|
44
|
+
return records[index]
|
|
45
|
+
},
|
|
46
|
+
}
|
|
47
|
+
const observer = { propagateTopUp: async (completion: TopUpCompletion) => { completions.push(completion) } }
|
|
48
|
+
const context = {
|
|
49
|
+
resource: (alias: string) => alias === RES_PAYMENT_SUBSCRIPTION ? subscriptions : undefined,
|
|
50
|
+
service: (alias: string) => alias === PAYMENT_OBSERVER ? observer : undefined,
|
|
51
|
+
}
|
|
52
|
+
const stripe = { checkout: { sessions: { listLineItems: async () => ({ data: [{ quantity: 7 }] }) } } }
|
|
53
|
+
return { records, completions, context, stripe }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
describe('Stripe amount checkout', () => {
|
|
57
|
+
test('uses one inline, tax-exclusive item and no reusable Price', () => {
|
|
58
|
+
const result = amountCheckoutLineItem(product, plan, 1_000)
|
|
59
|
+
expect(result.chargeMinor).toBe(1_021)
|
|
60
|
+
expect(result.lineItem).toEqual({
|
|
61
|
+
price_data: { product: 'credits', currency: 'usd', unit_amount: 1_021, tax_behavior: 'exclusive' },
|
|
62
|
+
quantity: 1,
|
|
63
|
+
})
|
|
64
|
+
expect(result.lineItem.adjustable_quantity).toBeUndefined()
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
test('keeps reusable Price and adjustable quantity for quantity checkout', () => {
|
|
68
|
+
expect(quantityCheckoutLineItem({ id: 'price_1' } as never, {
|
|
69
|
+
minimum: 5, maximum: 500, default: 10,
|
|
70
|
+
})).toEqual({
|
|
71
|
+
price: 'price_1', quantity: 10,
|
|
72
|
+
adjustable_quantity: { enabled: true, minimum: 5, maximum: 500 },
|
|
73
|
+
})
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
test('fulfills a paid amount without crediting the adjustment', async () => {
|
|
77
|
+
const h = harness()
|
|
78
|
+
await createEventHandler(h.context as never, h.stripe as never).process({
|
|
79
|
+
type: 'checkout.session.completed', data: { object: paid() },
|
|
80
|
+
} as never)
|
|
81
|
+
expect(h.completions).toEqual([expect.objectContaining({ mode: 'amount', amountMinor: 1_000, chargeAmountMinor: 1_021 })])
|
|
82
|
+
expect(h.records[0]?.fulfilledAt).toBeInstanceOf(Date)
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
test('does not fulfill an unpaid completion; asynchronous success does', async () => {
|
|
86
|
+
const h = harness()
|
|
87
|
+
const handler = createEventHandler(h.context as never, h.stripe as never)
|
|
88
|
+
await handler.process({
|
|
89
|
+
type: 'checkout.session.completed', data: { object: paid({ payment_status: 'unpaid' }) },
|
|
90
|
+
} as never)
|
|
91
|
+
expect(h.completions).toHaveLength(0)
|
|
92
|
+
await handler.process({ type: 'checkout.session.async_payment_succeeded', data: { object: paid() } } as never)
|
|
93
|
+
expect(h.completions).toHaveLength(1)
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
test('rejects currency/subtotal metadata mismatches and is idempotent on retries', async () => {
|
|
97
|
+
const h = harness()
|
|
98
|
+
const handler = createEventHandler(h.context as never, h.stripe as never)
|
|
99
|
+
await expect(handler.process({
|
|
100
|
+
type: 'checkout.session.completed', data: { object: paid({ amount_subtotal: 1020 }) },
|
|
101
|
+
} as never)).rejects.toThrow()
|
|
102
|
+
await handler.process({ type: 'checkout.session.completed', data: { object: paid() } } as never)
|
|
103
|
+
await handler.process({ type: 'checkout.session.completed', data: { object: paid() } } as never)
|
|
104
|
+
expect(h.completions).toHaveLength(1)
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
test('retries fulfillment when the application observer fails', async () => {
|
|
108
|
+
const h = harness()
|
|
109
|
+
let attempts = 0
|
|
110
|
+
;(h.context.service(PAYMENT_OBSERVER) as never as { propagateTopUp: () => Promise<void> })
|
|
111
|
+
.propagateTopUp = async () => {
|
|
112
|
+
attempts++
|
|
113
|
+
if (attempts === 1) throw new Error('ledger unavailable')
|
|
114
|
+
h.completions.push({ mode: 'amount' } as never)
|
|
115
|
+
}
|
|
116
|
+
const handler = createEventHandler(h.context as never, h.stripe as never)
|
|
117
|
+
const event = { type: 'checkout.session.completed', data: { object: paid() } } as never
|
|
118
|
+
await expect(handler.process(event)).rejects.toThrow('ledger unavailable')
|
|
119
|
+
expect(h.records[0]?.fulfilledAt).toBeUndefined()
|
|
120
|
+
await handler.process(event)
|
|
121
|
+
expect(attempts).toBe(2)
|
|
122
|
+
expect(h.records[0]?.fulfilledAt).toBeInstanceOf(Date)
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
test('legacy sessions without pricing metadata still fulfill by quantity', async () => {
|
|
126
|
+
const h = harness()
|
|
127
|
+
const legacy = paid({ id: 'cs_legacy', metadata: {
|
|
128
|
+
entityId: 'entity-1', service: 'app', productSku: 'credits', planSku: 'credits-unit',
|
|
129
|
+
} })
|
|
130
|
+
await createEventHandler(h.context as never, h.stripe as never).process({
|
|
131
|
+
type: 'checkout.session.completed', data: { object: legacy },
|
|
132
|
+
} as never)
|
|
133
|
+
expect(h.completions[0]).toEqual(expect.objectContaining({ mode: 'quantity', units: 7 }))
|
|
134
|
+
})
|
|
135
|
+
})
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import { AppType, makeBasicContext } from '@owlmeans/context'
|
|
3
|
+
import type { BasicConfig } from '@owlmeans/context'
|
|
4
|
+
import type { AbstractRequest, AbstractResponse, GateService } from '@owlmeans/entrypoint'
|
|
5
|
+
import type { Auth } from '@owlmeans/auth'
|
|
6
|
+
import { AuthForbidden, AuthRole } from '@owlmeans/auth'
|
|
7
|
+
import { CAPABILITY_FEATURE_SCOPE, ENTITLEMENT_GATE } from '@owlmeans/payment'
|
|
8
|
+
import { SubscriptionStatus } from '@owlmeans/payment'
|
|
9
|
+
import { filterRecords } from '@owlmeans/resource'
|
|
10
|
+
import type { Criteria, ResourceRecord } from '@owlmeans/resource'
|
|
11
|
+
import { RES_PAYMENT_SUBSCRIPTION } from '../src/consts.js'
|
|
12
|
+
import { makeEntitlementGate } from '../src/gate.js'
|
|
13
|
+
|
|
14
|
+
const record = (status: SubscriptionStatus, capabilities?: unknown[]) => ({
|
|
15
|
+
entityId: 'entity-1',
|
|
16
|
+
productSku: 'pro',
|
|
17
|
+
status,
|
|
18
|
+
capabilities: capabilities ?? [
|
|
19
|
+
{ scope: 'renewable', permissions: { production: 1 } },
|
|
20
|
+
{ scope: CAPABILITY_FEATURE_SCOPE, permissions: { 'branding--whitelabel': true } },
|
|
21
|
+
],
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
/** The gate must fail closed, including when its subscription store is unreadable. */
|
|
25
|
+
const makeGate = async (answer: () => unknown): Promise<GateService> => {
|
|
26
|
+
const ctx = makeBasicContext<BasicConfig>({
|
|
27
|
+
ready: false,
|
|
28
|
+
service: 'server-payment-tests',
|
|
29
|
+
type: AppType.Backend,
|
|
30
|
+
})
|
|
31
|
+
const resource = {
|
|
32
|
+
alias: RES_PAYMENT_SUBSCRIPTION,
|
|
33
|
+
list: async (where?: Criteria<ResourceRecord>) => {
|
|
34
|
+
const items = filterRecords(answer() as ResourceRecord[], where)
|
|
35
|
+
return { items, total: items.length }
|
|
36
|
+
},
|
|
37
|
+
registerContext: () => resource,
|
|
38
|
+
init: async () => undefined,
|
|
39
|
+
ready: async () => true,
|
|
40
|
+
}
|
|
41
|
+
ctx.registerResource(resource as never)
|
|
42
|
+
ctx.registerService(makeEntitlementGate())
|
|
43
|
+
ctx.configure()
|
|
44
|
+
await ctx.init()
|
|
45
|
+
|
|
46
|
+
return ctx.service<GateService>(ENTITLEMENT_GATE)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const request = (auth: Partial<Auth> | null = {}): AbstractRequest => ({
|
|
50
|
+
alias: 'test', headers: {}, params: {}, query: {}, body: {}, path: '/',
|
|
51
|
+
...(auth != null ? {
|
|
52
|
+
auth: {
|
|
53
|
+
type: 'ed25519-basic-token', token: 't', userId: 'u', role: AuthRole.User,
|
|
54
|
+
scopes: [], entitySlug: 'entity-1', isUser: true, createdAt: new Date(), ...auth,
|
|
55
|
+
} satisfies Auth,
|
|
56
|
+
} : {}),
|
|
57
|
+
}) as unknown as AbstractRequest
|
|
58
|
+
|
|
59
|
+
const res = {} as AbstractResponse<unknown>
|
|
60
|
+
|
|
61
|
+
describe('@owlmeans/server-payment — the entitlement gate', () => {
|
|
62
|
+
test('passes capabilities from active and trial subscriptions', async () => {
|
|
63
|
+
for (const status of [SubscriptionStatus.Active, SubscriptionStatus.Trial]) {
|
|
64
|
+
const gate = await makeGate(() => [record(status)])
|
|
65
|
+
await gate.assert(request(), res, ['feature:branding--whitelabel'])
|
|
66
|
+
}
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
test('refuses cancelled, missing, and unrelated capabilities', async () => {
|
|
70
|
+
const canceled = await makeGate(() => [record(SubscriptionStatus.Canceled)])
|
|
71
|
+
await expect(canceled.assert(request(), res, ['feature:branding--whitelabel']))
|
|
72
|
+
.rejects.toBeInstanceOf(AuthForbidden)
|
|
73
|
+
const unrelated = await makeGate(() => [record(SubscriptionStatus.Active)])
|
|
74
|
+
await expect(unrelated.assert(request(), res, ['feature:domain--custom']))
|
|
75
|
+
.rejects.toBeInstanceOf(AuthForbidden)
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
test('treats gate parameters as any-of', async () => {
|
|
79
|
+
const gate = await makeGate(() => [record(SubscriptionStatus.Active)])
|
|
80
|
+
await gate.assert(request(), res, ['feature:nothing--here', 'feature:branding--whitelabel'])
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
test('refuses missing authentication or organization before reading storage', async () => {
|
|
84
|
+
const unread = await makeGate(() => { throw new Error('must not be reached') })
|
|
85
|
+
await expect(unread.assert(request(null), res, ['feature:branding--whitelabel']))
|
|
86
|
+
.rejects.toBeInstanceOf(AuthForbidden)
|
|
87
|
+
const noEntity = await makeGate(() => [])
|
|
88
|
+
await expect(noEntity.assert(request({ entitySlug: undefined }), res, ['feature:branding--whitelabel']))
|
|
89
|
+
.rejects.toBeInstanceOf(AuthForbidden)
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
test('refuses when the subscription store is unreadable', async () => {
|
|
93
|
+
const gate = await makeGate(() => { throw new Error('mongo is down') })
|
|
94
|
+
await expect(gate.assert(request(), res, ['feature:branding--whitelabel']))
|
|
95
|
+
.rejects.toBeInstanceOf(AuthForbidden)
|
|
96
|
+
})
|
|
97
|
+
})
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import type { ParamsOf, ResponseOf } from '@owlmeans/entrypoint'
|
|
3
|
+
import { paymentGate, paymentGateProtocols } from '../src/index.js'
|
|
4
|
+
|
|
5
|
+
describe('server payment protocols', () => {
|
|
6
|
+
test('bind webhook params and resync response to their declarations', () => {
|
|
7
|
+
const params: ParamsOf<typeof paymentGate.webhook> = { paygate: 'stripe' }
|
|
8
|
+
const response: ResponseOf<typeof paymentGate.resync> = { ok: true }
|
|
9
|
+
|
|
10
|
+
expect(params.paygate).toBe('stripe')
|
|
11
|
+
expect(response.ok).toBe(true)
|
|
12
|
+
expect(paymentGate.webhook.route.route.parent).toBe(paymentGate.base.alias)
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
test('exports a flattened list of immutable protocol objects', () => {
|
|
16
|
+
expect(paymentGateProtocols).toContain(paymentGate.webhook)
|
|
17
|
+
expect(paymentGateProtocols.every(item => item.kind === 'entrypoint-protocol')).toBe(true)
|
|
18
|
+
expect(Object.isFrozen(paymentGate.resync)).toBe(true)
|
|
19
|
+
})
|
|
20
|
+
})
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
{
|
|
2
|
+
"extends": [
|
|
3
|
+
"@owlmeans/dep-config/tsconfig.base.json",
|
|
4
|
+
"@owlmeans/dep-config/tsconfig.node.json"
|
|
5
|
+
],
|
|
6
|
+
"compilerOptions": {
|
|
7
|
+
"rootDir": "./src/",
|
|
8
|
+
"outDir": "./build/"
|
|
9
|
+
},
|
|
10
|
+
"exclude": ["./dist/**/*", "./build/**/*", "./*.ts", "./tests/**/*"]
|
|
11
|
+
}
|