@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.
Files changed (94) hide show
  1. package/README.md +55 -0
  2. package/agent-meta/manifest.json +16 -0
  3. package/agent-meta/skills/server-payment/SKILL.md +80 -0
  4. package/build/actions/index.d.ts +3 -0
  5. package/build/actions/index.d.ts.map +1 -0
  6. package/build/actions/index.js +3 -0
  7. package/build/actions/index.js.map +1 -0
  8. package/build/actions/resync.d.ts +2 -0
  9. package/build/actions/resync.d.ts.map +1 -0
  10. package/build/actions/resync.js +11 -0
  11. package/build/actions/resync.js.map +1 -0
  12. package/build/actions/webhook.d.ts +4 -0
  13. package/build/actions/webhook.d.ts.map +1 -0
  14. package/build/actions/webhook.js +11 -0
  15. package/build/actions/webhook.js.map +1 -0
  16. package/build/config.d.ts +8 -0
  17. package/build/config.d.ts.map +1 -0
  18. package/build/config.js +46 -0
  19. package/build/config.js.map +1 -0
  20. package/build/consts.d.ts +26 -0
  21. package/build/consts.d.ts.map +1 -0
  22. package/build/consts.js +33 -0
  23. package/build/consts.js.map +1 -0
  24. package/build/entrypoints.d.ts +5 -0
  25. package/build/entrypoints.d.ts.map +1 -0
  26. package/build/entrypoints.js +10 -0
  27. package/build/entrypoints.js.map +1 -0
  28. package/build/gate.d.ts +10 -0
  29. package/build/gate.d.ts.map +1 -0
  30. package/build/gate.js +37 -0
  31. package/build/gate.js.map +1 -0
  32. package/build/index.d.ts +12 -0
  33. package/build/index.d.ts.map +1 -0
  34. package/build/index.js +11 -0
  35. package/build/index.js.map +1 -0
  36. package/build/model.d.ts +6 -0
  37. package/build/model.d.ts.map +1 -0
  38. package/build/model.js +33 -0
  39. package/build/model.js.map +1 -0
  40. package/build/observer.d.ts +4 -0
  41. package/build/observer.d.ts.map +1 -0
  42. package/build/observer.js +24 -0
  43. package/build/observer.js.map +1 -0
  44. package/build/plugins/events.d.ts +16 -0
  45. package/build/plugins/events.d.ts.map +1 -0
  46. package/build/plugins/events.js +195 -0
  47. package/build/plugins/events.js.map +1 -0
  48. package/build/plugins/stripe.d.ts +14 -0
  49. package/build/plugins/stripe.d.ts.map +1 -0
  50. package/build/plugins/stripe.js +166 -0
  51. package/build/plugins/stripe.js.map +1 -0
  52. package/build/resource.d.ts +6 -0
  53. package/build/resource.d.ts.map +1 -0
  54. package/build/resource.js +32 -0
  55. package/build/resource.js.map +1 -0
  56. package/build/service.d.ts +4 -0
  57. package/build/service.d.ts.map +1 -0
  58. package/build/service.js +35 -0
  59. package/build/service.js.map +1 -0
  60. package/build/sync.d.ts +5 -0
  61. package/build/sync.d.ts.map +1 -0
  62. package/build/sync.js +93 -0
  63. package/build/sync.js.map +1 -0
  64. package/build/types.d.ts +189 -0
  65. package/build/types.d.ts.map +1 -0
  66. package/build/types.js +2 -0
  67. package/build/types.js.map +1 -0
  68. package/build/utils.d.ts +14 -0
  69. package/build/utils.d.ts.map +1 -0
  70. package/build/utils.js +18 -0
  71. package/build/utils.js.map +1 -0
  72. package/package.json +54 -0
  73. package/src/actions/index.ts +2 -0
  74. package/src/actions/resync.ts +13 -0
  75. package/src/actions/webhook.ts +12 -0
  76. package/src/config.ts +53 -0
  77. package/src/consts.ts +47 -0
  78. package/src/entrypoints.ts +10 -0
  79. package/src/gate.ts +51 -0
  80. package/src/index.ts +13 -0
  81. package/src/model.ts +37 -0
  82. package/src/observer.ts +27 -0
  83. package/src/plugins/events.ts +181 -0
  84. package/src/plugins/stripe.ts +183 -0
  85. package/src/resource.ts +51 -0
  86. package/src/service.ts +35 -0
  87. package/src/sync.ts +97 -0
  88. package/src/types.ts +157 -0
  89. package/src/utils.ts +33 -0
  90. package/tests/checkout.spec.ts +135 -0
  91. package/tests/gate.spec.ts +97 -0
  92. package/tests/protocol.spec.ts +20 -0
  93. package/tests/tsconfig.json +5 -0
  94. 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
+ })
@@ -0,0 +1,5 @@
1
+ {
2
+ "extends": ["@owlmeans/dep-config/tsconfig.base.json"],
3
+ "compilerOptions": { "types": ["bun"], "rootDir": "../", "noEmit": true },
4
+ "include": ["./**/*", "../src/**/*"]
5
+ }
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
+ }