@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
|
@@ -0,0 +1,972 @@
|
|
|
1
|
+
import Stripe from 'stripe'
|
|
2
|
+
import { config as serverConfig, makeServerContext } from '@owlmeans/server-context'
|
|
3
|
+
import type { ServerConfig } from '@owlmeans/server-context'
|
|
4
|
+
import { AppType } from '@owlmeans/context'
|
|
5
|
+
import {
|
|
6
|
+
CheckoutPricingMode, LimitKind, LimitWindow, PlanDuration, ProductType,
|
|
7
|
+
} from '@owlmeans/payment'
|
|
8
|
+
import type { PlanCapability } from '@owlmeans/payment'
|
|
9
|
+
import { applyQuery, firstMatch, matchCriteria, RecordExists, UnknownRecordError } from '@owlmeans/resource'
|
|
10
|
+
import type { Criteria, ListOptions, ResourceRecord } from '@owlmeans/resource'
|
|
11
|
+
import type { MongoResource } from '@owlmeans/mongo-resource'
|
|
12
|
+
import { makeConsoleMailerService, MAILER_SERVICE } from '@owlmeans/mailer'
|
|
13
|
+
import type { MailMessage } from '@owlmeans/mailer'
|
|
14
|
+
import type { Context as ApiContext } from '@owlmeans/server-api'
|
|
15
|
+
import {
|
|
16
|
+
declareConsumerRights, declarePaymentPlan, declarePaymentPricing, declarePaymentProduct, portalBranding,
|
|
17
|
+
stripeSecrets,
|
|
18
|
+
} from '../src/config.js'
|
|
19
|
+
import {
|
|
20
|
+
RES_BILLING_PROFILE, RES_CONSUMER_CONSENT, RES_CONSUMER_DECLARATION, RES_CONSUMER_EVENT, RES_PAYGATE_CUSTOMER,
|
|
21
|
+
RES_PAYMENT_FINGERPRINT, RES_PAYMENT_FULFILLMENT, RES_PAYMENT_PURCHASE, RES_PAYMENT_SUBSCRIPTION,
|
|
22
|
+
RES_PAYMENT_USAGE, RES_PAYMENT_USAGE_COUNTER, RES_PAYMENT_WEBHOOK,
|
|
23
|
+
} from '../src/consts.js'
|
|
24
|
+
import { appendConsumerRights } from '../src/consumer/service.js'
|
|
25
|
+
import {
|
|
26
|
+
makeBillingProfileResource, makeConsumerConsentResource, makeConsumerDeclarationResource,
|
|
27
|
+
makeConsumerEventResource, makeFingerprintResource, makeFulfillmentResource, makePaygateCustomerResource,
|
|
28
|
+
makePurchaseResource, makeSubscriptionResource, makeUsageCounterResource, makeUsageResource, makeWebhookResource,
|
|
29
|
+
} from '../src/resource.js'
|
|
30
|
+
import { appendPaymentGatewayService } from '../src/service.js'
|
|
31
|
+
import { observer } from '../src/utils.js'
|
|
32
|
+
import type {
|
|
33
|
+
CancellationEvent, Config, ConsentEvent, ConsumerRightsDef, ConsumerRightsOptions, DisputeEvent, PaymentFailedEvent,
|
|
34
|
+
PaymentPlanDef, PortalBrandingDef, PricingDef, RefundEvent, SubscriptionEvent, TopUpCompletion, UsageMeter,
|
|
35
|
+
WithdrawalEvent,
|
|
36
|
+
} from '../src/types.js'
|
|
37
|
+
|
|
38
|
+
// -----------------------------------------------------------------------------------------------
|
|
39
|
+
// Catalogue — neutral names only
|
|
40
|
+
// -----------------------------------------------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
export const SERVICE = 'app'
|
|
43
|
+
export const HOST = 'api.example.com'
|
|
44
|
+
export const PLANS_PRODUCT = 'app-plans'
|
|
45
|
+
export const CREDITS_PRODUCT = 'app-credits'
|
|
46
|
+
export const FREE = 'free-plan'
|
|
47
|
+
export const PRO = 'pro-monthly'
|
|
48
|
+
export const TEAM = 'team-monthly'
|
|
49
|
+
export const CREDIT_UNIT = 'app-credit-unit'
|
|
50
|
+
export const CAP_WHITELABEL = 'feature:whitelabel'
|
|
51
|
+
export const CAP_BASIC = 'feature:basic'
|
|
52
|
+
export const CAP_PREVIEW = 'feature:preview'
|
|
53
|
+
|
|
54
|
+
const DAY = 24 * 60 * 60 * 1000
|
|
55
|
+
export const future = (days = 30): Date => new Date(Date.now() + days * DAY)
|
|
56
|
+
export const past = (days = 30): Date => new Date(Date.now() - days * DAY)
|
|
57
|
+
|
|
58
|
+
export interface CatalogueOptions {
|
|
59
|
+
/** Declare the free plan (default true). */
|
|
60
|
+
free?: boolean
|
|
61
|
+
/** Until when the `feature:preview` promo of the free plan runs (default: in 30 days). */
|
|
62
|
+
previewUntil?: Date
|
|
63
|
+
/** Until when the `reports` promo of the pro plan runs (default: in 30 days). */
|
|
64
|
+
reportsUntil?: Date
|
|
65
|
+
/** Extra plans. */
|
|
66
|
+
plans?: PaymentPlanDef[]
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export const declareTestCatalogue = (cfg: Config, opts: CatalogueOptions = {}): void => {
|
|
70
|
+
declarePaymentProduct(cfg, {
|
|
71
|
+
sku: CREDITS_PRODUCT, type: ProductType.Consumable, services: [SERVICE], name: 'Credits',
|
|
72
|
+
})
|
|
73
|
+
declarePaymentPlan(cfg, {
|
|
74
|
+
productSku: CREDITS_PRODUCT, sku: CREDIT_UNIT, duration: PlanDuration.Consumable, price: 0.02,
|
|
75
|
+
pricingMode: CheckoutPricingMode.Amount,
|
|
76
|
+
amountPolicy: {
|
|
77
|
+
currency: 'usd', minimumMinor: 500, maximumMinor: 50_000, defaultMinor: 1_000,
|
|
78
|
+
presetsMinor: [1_000, 2_000, 5_000, 10_000], fixedMinor: 0, rateBps: 200,
|
|
79
|
+
},
|
|
80
|
+
})
|
|
81
|
+
declarePaymentProduct(cfg, {
|
|
82
|
+
sku: PLANS_PRODUCT, type: ProductType.Service, services: [SERVICE], name: 'Plans',
|
|
83
|
+
})
|
|
84
|
+
if (opts.free !== false) {
|
|
85
|
+
declarePaymentPlan(cfg, {
|
|
86
|
+
productSku: PLANS_PRODUCT, sku: FREE, duration: PlanDuration.Monthly, rank: 0, free: true, price: 0,
|
|
87
|
+
capabilities: [
|
|
88
|
+
{ scope: 'feature', permissions: { basic: true } },
|
|
89
|
+
{ scope: 'feature', permissions: { preview: true }, promo: { until: opts.previewUntil ?? future() } },
|
|
90
|
+
] satisfies PlanCapability[],
|
|
91
|
+
limits: {
|
|
92
|
+
seats: { kind: LimitKind.Occupancy, limit: 0 },
|
|
93
|
+
exports: { kind: LimitKind.Window, window: LimitWindow.Month, limit: 1 },
|
|
94
|
+
reports: { kind: LimitKind.Lifetime, limit: 1 },
|
|
95
|
+
},
|
|
96
|
+
})
|
|
97
|
+
}
|
|
98
|
+
declarePaymentPlan(cfg, {
|
|
99
|
+
productSku: PLANS_PRODUCT, sku: PRO, duration: PlanDuration.Monthly, rank: 10, price: 20,
|
|
100
|
+
recurring: { interval: 'month' },
|
|
101
|
+
capabilities: [{ scope: 'feature', permissions: { basic: true, whitelabel: true, preview: true } }],
|
|
102
|
+
limits: {
|
|
103
|
+
seats: { kind: LimitKind.Occupancy, limit: 2 },
|
|
104
|
+
exports: { kind: LimitKind.Window, window: LimitWindow.Day, limit: 3 },
|
|
105
|
+
reports: {
|
|
106
|
+
kind: LimitKind.Lifetime, limit: 4, promo: { until: opts.reportsUntil ?? future(), grandfather: true },
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
})
|
|
110
|
+
declarePaymentPlan(cfg, {
|
|
111
|
+
productSku: PLANS_PRODUCT, sku: TEAM, duration: PlanDuration.Monthly, rank: 20, price: 50,
|
|
112
|
+
recurring: { interval: 'month' },
|
|
113
|
+
capabilities: [{ scope: 'feature', permissions: { basic: true, whitelabel: true, preview: true } }],
|
|
114
|
+
limits: {
|
|
115
|
+
seats: { kind: LimitKind.Occupancy, limit: 10 },
|
|
116
|
+
exports: { kind: LimitKind.Window, window: LimitWindow.Day, limit: 30 },
|
|
117
|
+
reports: { kind: LimitKind.Lifetime, limit: 40 },
|
|
118
|
+
},
|
|
119
|
+
})
|
|
120
|
+
for (const plan of opts.plans ?? []) {
|
|
121
|
+
declarePaymentPlan(cfg, plan)
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// -----------------------------------------------------------------------------------------------
|
|
126
|
+
// Fake Stripe
|
|
127
|
+
// -----------------------------------------------------------------------------------------------
|
|
128
|
+
|
|
129
|
+
type Rec = Record<string, any>
|
|
130
|
+
|
|
131
|
+
export type FakeFailure = string | Error
|
|
132
|
+
|
|
133
|
+
export interface FakeStripeState {
|
|
134
|
+
apiVersion: string
|
|
135
|
+
/** Every SDK method called, in order: `webhookEndpoints.create`, … */
|
|
136
|
+
calls: string[]
|
|
137
|
+
webhookEndpoints: Rec[]
|
|
138
|
+
portalConfigurations: Rec[]
|
|
139
|
+
/** The params every portal session was asked for with. */
|
|
140
|
+
portalSessions: Rec[]
|
|
141
|
+
checkoutSessions: Rec[]
|
|
142
|
+
subscriptions: Record<string, Rec>
|
|
143
|
+
invoices: Record<string, Rec>
|
|
144
|
+
charges: Record<string, Rec>
|
|
145
|
+
refunds: Rec[]
|
|
146
|
+
prices: Rec[]
|
|
147
|
+
products: Record<string, Rec>
|
|
148
|
+
customers: Record<string, Rec>
|
|
149
|
+
lineItemQuantity: number
|
|
150
|
+
seq: number
|
|
151
|
+
/** Every `tax.calculations.create` params, in call order. */
|
|
152
|
+
taxCalculations: Rec[]
|
|
153
|
+
/** Flat-percentage rates a country charges (uppercase alpha-2). Absent/empty: `not_collecting`. */
|
|
154
|
+
taxRates: Record<string, Array<{ type: string, percentage: string }>>
|
|
155
|
+
/** Countries whose calculation throws `customer_tax_location_invalid` (simulates a missing ZIP). */
|
|
156
|
+
taxInvalidCountries: string[]
|
|
157
|
+
/** Countries Stripe Tax does not cover: a `not_supported`, zero-tax breakdown row. */
|
|
158
|
+
taxUnsupportedCountries: string[]
|
|
159
|
+
taxSettings: Rec
|
|
160
|
+
/** Every `rawRequest` call, in order: `{ method, path, params }`. */
|
|
161
|
+
rawRequests: Rec[]
|
|
162
|
+
/** FX Quotes rates by lowercase source currency. */
|
|
163
|
+
fxRates: Record<string, { exchangeRate: number, baseRate?: number, referenceRate?: number, fxFeeRate?: number }>
|
|
164
|
+
/** Every `rawRequest` to `/v1/fx_quotes` throws. */
|
|
165
|
+
fxUnavailable: boolean
|
|
166
|
+
/** Completed or open checkout sessions `checkout.sessions.list` answers. */
|
|
167
|
+
listedSessions: Rec[]
|
|
168
|
+
creditNotes: Rec[]
|
|
169
|
+
/** Every `creditNotes.preview` params. */
|
|
170
|
+
creditNotePreviews: Rec[]
|
|
171
|
+
/** Every call's request options (`idempotencyKey`), by method. */
|
|
172
|
+
requestOptions: Record<string, Rec[]>
|
|
173
|
+
/**
|
|
174
|
+
* Methods that throw on their next call: name → an error message (a Stripe invalid-request
|
|
175
|
+
* error), an error to throw as it is, or a list of those — one per call, in order.
|
|
176
|
+
*/
|
|
177
|
+
failures: Record<string, FakeFailure | FakeFailure[]>
|
|
178
|
+
/** Every `subscriptions.update` / `.cancel` params, in order. */
|
|
179
|
+
subscriptionChanges: Rec[]
|
|
180
|
+
expiredSessions: string[]
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** The API version the installed SDK defaults to, read from a real client. */
|
|
184
|
+
export const SDK_API_VERSION = (new Stripe('sk_test_offline') as unknown as { getApiField: (key: string) => string })
|
|
185
|
+
.getApiField('version')
|
|
186
|
+
|
|
187
|
+
export const missing = (what: string): Error => Object.assign(new Error(`No such ${what}`), {
|
|
188
|
+
type: 'StripeInvalidRequestError', code: 'resource_missing', statusCode: 404,
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
/** A Stripe `invalid_request_error`, duck-typed the same way `missing` fakes `resource_missing`. */
|
|
192
|
+
export const invalidRequest = (code: string): Error => Object.assign(new Error(code), {
|
|
193
|
+
type: 'StripeInvalidRequestError', code, statusCode: 400,
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
const page = (items: Rec[], params: Rec = {}) => {
|
|
197
|
+
let start = 0
|
|
198
|
+
if (params.starting_after != null) {
|
|
199
|
+
start = items.findIndex(item => item.id === params.starting_after) + 1
|
|
200
|
+
}
|
|
201
|
+
const limit = params.limit ?? 10
|
|
202
|
+
const data = items.slice(start, start + limit)
|
|
203
|
+
|
|
204
|
+
return { object: 'list', data: structuredClone(data), has_more: start + limit < items.length }
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export const makeFakeStripe = (initial: Partial<FakeStripeState> = {}): { stripe: Stripe, state: FakeStripeState } => {
|
|
208
|
+
const state: FakeStripeState = {
|
|
209
|
+
apiVersion: SDK_API_VERSION, calls: [], webhookEndpoints: [], portalConfigurations: [], portalSessions: [],
|
|
210
|
+
checkoutSessions: [], subscriptions: {}, invoices: {}, charges: {}, refunds: [], prices: [], products: {},
|
|
211
|
+
customers: {}, lineItemQuantity: 7, seq: 0,
|
|
212
|
+
taxCalculations: [], taxRates: {}, taxInvalidCountries: [], taxUnsupportedCountries: [],
|
|
213
|
+
taxSettings: { defaults: { tax_behavior: null, tax_code: null }, head_office: null, status: 'active', status_details: {} },
|
|
214
|
+
rawRequests: [], fxRates: {}, fxUnavailable: false,
|
|
215
|
+
listedSessions: [], creditNotes: [], creditNotePreviews: [], requestOptions: {}, failures: {},
|
|
216
|
+
subscriptionChanges: [], expiredSessions: [],
|
|
217
|
+
...initial,
|
|
218
|
+
}
|
|
219
|
+
const next = (prefix: string): string => `${prefix}_${++state.seq}`
|
|
220
|
+
const call = (name: string, options?: Rec): void => {
|
|
221
|
+
state.calls.push(name)
|
|
222
|
+
if (options != null) (state.requestOptions[name] = state.requestOptions[name] ?? []).push(options)
|
|
223
|
+
const failure = state.failures[name]
|
|
224
|
+
if (failure != null) {
|
|
225
|
+
const next = Array.isArray(failure) ? failure.shift() : failure
|
|
226
|
+
if (!Array.isArray(failure) || failure.length === 0) delete state.failures[name]
|
|
227
|
+
if (next != null) throw typeof next === 'string' ? invalidRequest(next) : next
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
const find = (list: Rec[], id: string, what: string): Rec => {
|
|
231
|
+
const found = list.find(item => item.id === id)
|
|
232
|
+
if (found == null) throw missing(what)
|
|
233
|
+
return found
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const stripe = {
|
|
237
|
+
getApiField: (key: string) => key === 'version' ? state.apiVersion : undefined,
|
|
238
|
+
webhookEndpoints: {
|
|
239
|
+
create: async (params: Rec) => {
|
|
240
|
+
call('webhookEndpoints.create')
|
|
241
|
+
const endpoint = {
|
|
242
|
+
id: next('we'), object: 'webhook_endpoint', url: params.url, enabled_events: [...params.enabled_events],
|
|
243
|
+
api_version: params.api_version, description: params.description, metadata: { ...params.metadata },
|
|
244
|
+
status: 'enabled', secret: next('whsec'),
|
|
245
|
+
}
|
|
246
|
+
state.webhookEndpoints.push(endpoint)
|
|
247
|
+
return structuredClone(endpoint)
|
|
248
|
+
},
|
|
249
|
+
update: async (id: string, params: Rec) => {
|
|
250
|
+
call('webhookEndpoints.update')
|
|
251
|
+
const endpoint = find(state.webhookEndpoints, id, 'webhook endpoint')
|
|
252
|
+
Object.assign(endpoint, params)
|
|
253
|
+
const { secret: _secret, ...rest } = endpoint
|
|
254
|
+
return structuredClone(rest)
|
|
255
|
+
},
|
|
256
|
+
retrieve: async (id: string) => {
|
|
257
|
+
call('webhookEndpoints.retrieve')
|
|
258
|
+
const { secret: _secret, ...rest } = find(state.webhookEndpoints, id, 'webhook endpoint')
|
|
259
|
+
return structuredClone(rest)
|
|
260
|
+
},
|
|
261
|
+
del: async (id: string) => {
|
|
262
|
+
call('webhookEndpoints.del')
|
|
263
|
+
find(state.webhookEndpoints, id, 'webhook endpoint')
|
|
264
|
+
state.webhookEndpoints = state.webhookEndpoints.filter(item => item.id !== id)
|
|
265
|
+
return { id, object: 'webhook_endpoint', deleted: true }
|
|
266
|
+
},
|
|
267
|
+
list: async (params: Rec = {}) => {
|
|
268
|
+
call('webhookEndpoints.list')
|
|
269
|
+
return page(state.webhookEndpoints.map(({ secret: _secret, ...rest }) => rest), params)
|
|
270
|
+
},
|
|
271
|
+
},
|
|
272
|
+
billingPortal: {
|
|
273
|
+
configurations: {
|
|
274
|
+
create: async (params: Rec) => {
|
|
275
|
+
call('billingPortal.configurations.create')
|
|
276
|
+
const configuration = { id: next('bpc'), object: 'billing_portal.configuration', active: true, ...structuredClone(params) }
|
|
277
|
+
state.portalConfigurations.push(configuration)
|
|
278
|
+
return structuredClone(configuration)
|
|
279
|
+
},
|
|
280
|
+
retrieve: async (id: string) => {
|
|
281
|
+
call('billingPortal.configurations.retrieve')
|
|
282
|
+
return structuredClone(find(state.portalConfigurations, id, 'configuration'))
|
|
283
|
+
},
|
|
284
|
+
update: async (id: string, params: Rec) => {
|
|
285
|
+
call('billingPortal.configurations.update')
|
|
286
|
+
const configuration = find(state.portalConfigurations, id, 'configuration')
|
|
287
|
+
Object.assign(configuration, structuredClone(params))
|
|
288
|
+
return structuredClone(configuration)
|
|
289
|
+
},
|
|
290
|
+
list: async (params: Rec = {}) => {
|
|
291
|
+
call('billingPortal.configurations.list')
|
|
292
|
+
return page(state.portalConfigurations.filter(item => params.active == null || item.active === params.active), params)
|
|
293
|
+
},
|
|
294
|
+
},
|
|
295
|
+
sessions: {
|
|
296
|
+
create: async (params: Rec) => {
|
|
297
|
+
call('billingPortal.sessions.create')
|
|
298
|
+
state.portalSessions.push(structuredClone(params))
|
|
299
|
+
return { id: next('bps'), url: `https://billing.example.test/p/${state.seq}` }
|
|
300
|
+
},
|
|
301
|
+
},
|
|
302
|
+
},
|
|
303
|
+
subscriptions: {
|
|
304
|
+
retrieve: async (id: string) => {
|
|
305
|
+
call('subscriptions.retrieve')
|
|
306
|
+
const subscription = state.subscriptions[id]
|
|
307
|
+
if (subscription == null) throw missing('subscription')
|
|
308
|
+
return structuredClone(subscription)
|
|
309
|
+
},
|
|
310
|
+
update: async (id: string, params: Rec, options?: Rec) => {
|
|
311
|
+
call('subscriptions.update', options)
|
|
312
|
+
const subscription = state.subscriptions[id]
|
|
313
|
+
if (subscription == null) throw missing('subscription')
|
|
314
|
+
state.subscriptionChanges.push({ id, method: 'update', ...structuredClone(params) })
|
|
315
|
+
Object.assign(subscription, params)
|
|
316
|
+
return structuredClone(subscription)
|
|
317
|
+
},
|
|
318
|
+
cancel: async (id: string, params: Rec = {}, options?: Rec) => {
|
|
319
|
+
call('subscriptions.cancel', options)
|
|
320
|
+
const subscription = state.subscriptions[id]
|
|
321
|
+
if (subscription == null) throw missing('subscription')
|
|
322
|
+
state.subscriptionChanges.push({ id, method: 'cancel', ...structuredClone(params) })
|
|
323
|
+
subscription.status = 'canceled'
|
|
324
|
+
return structuredClone(subscription)
|
|
325
|
+
},
|
|
326
|
+
},
|
|
327
|
+
creditNotes: {
|
|
328
|
+
preview: async (params: Rec) => {
|
|
329
|
+
call('creditNotes.preview')
|
|
330
|
+
state.creditNotePreviews.push(structuredClone(params))
|
|
331
|
+
const invoice = state.invoices[params.invoice]
|
|
332
|
+
if (invoice == null) throw missing('invoice')
|
|
333
|
+
const rate = invoice.subtotal > 0 ? (invoice.tax ?? 0) / invoice.subtotal : 0
|
|
334
|
+
const amount = params.lines.reduce((sum: number, line: Rec) => sum + (line.amount ?? 0), 0)
|
|
335
|
+
const tax = Math.round(amount * rate)
|
|
336
|
+
return { id: 'cnpreview', object: 'credit_note', amount: amount + tax, total: amount + tax, subtotal: amount, currency: invoice.currency }
|
|
337
|
+
},
|
|
338
|
+
create: async (params: Rec, options?: Rec) => {
|
|
339
|
+
call('creditNotes.create', options)
|
|
340
|
+
const invoice = state.invoices[params.invoice]
|
|
341
|
+
if (invoice == null) throw missing('invoice')
|
|
342
|
+
const rate = invoice.subtotal > 0 ? (invoice.tax ?? 0) / invoice.subtotal : 0
|
|
343
|
+
const amount = params.lines.reduce((sum: number, line: Rec) => sum + (line.amount ?? 0), 0)
|
|
344
|
+
const note = {
|
|
345
|
+
id: next('cn'), object: 'credit_note', invoice: params.invoice, refund: params.refund, status: 'issued',
|
|
346
|
+
total: amount + Math.round(amount * rate), currency: invoice.currency, memo: params.memo,
|
|
347
|
+
metadata: { ...params.metadata }, lines: structuredClone(params.lines),
|
|
348
|
+
}
|
|
349
|
+
state.creditNotes.push(note)
|
|
350
|
+
return structuredClone(note)
|
|
351
|
+
},
|
|
352
|
+
list: async (params: Rec = {}) => {
|
|
353
|
+
call('creditNotes.list')
|
|
354
|
+
return page(state.creditNotes.filter(note => params.invoice == null || note.invoice === params.invoice), params)
|
|
355
|
+
},
|
|
356
|
+
},
|
|
357
|
+
invoices: {
|
|
358
|
+
retrieve: async (id: string) => {
|
|
359
|
+
call('invoices.retrieve')
|
|
360
|
+
const invoice = state.invoices[id]
|
|
361
|
+
if (invoice == null) throw missing('invoice')
|
|
362
|
+
return structuredClone(invoice)
|
|
363
|
+
},
|
|
364
|
+
},
|
|
365
|
+
charges: {
|
|
366
|
+
retrieve: async (id: string) => {
|
|
367
|
+
call('charges.retrieve')
|
|
368
|
+
const charge = state.charges[id]
|
|
369
|
+
if (charge == null) throw missing('charge')
|
|
370
|
+
return structuredClone(charge)
|
|
371
|
+
},
|
|
372
|
+
},
|
|
373
|
+
refunds: {
|
|
374
|
+
list: async (params: Rec = {}) => {
|
|
375
|
+
call('refunds.list')
|
|
376
|
+
return page(state.refunds.filter(refund => (params.charge == null || refund.charge === params.charge)
|
|
377
|
+
&& (params.payment_intent == null || refund.payment_intent === params.payment_intent)), params)
|
|
378
|
+
},
|
|
379
|
+
create: async (params: Rec, options?: Rec) => {
|
|
380
|
+
call('refunds.create', options)
|
|
381
|
+
const refund = {
|
|
382
|
+
id: next('re'), object: 'refund', amount: params.amount, currency: params.currency ?? 'eur', status: 'succeeded',
|
|
383
|
+
payment_intent: params.payment_intent, charge: null, reason: params.reason, metadata: { ...params.metadata },
|
|
384
|
+
}
|
|
385
|
+
state.refunds.push(refund)
|
|
386
|
+
return structuredClone(refund)
|
|
387
|
+
},
|
|
388
|
+
},
|
|
389
|
+
prices: {
|
|
390
|
+
list: async (params: Rec = {}) => {
|
|
391
|
+
call('prices.list')
|
|
392
|
+
return page(state.prices.filter(price => (params.product == null || price.product === params.product)
|
|
393
|
+
&& (params.active == null || price.active === params.active)
|
|
394
|
+
&& (params.lookup_keys == null || params.lookup_keys.includes(price.lookup_key))), { limit: 100, ...params })
|
|
395
|
+
},
|
|
396
|
+
create: async (params: Rec) => {
|
|
397
|
+
call('prices.create')
|
|
398
|
+
// Stripe's own default: a price created with no `tax_behavior` is `unspecified`.
|
|
399
|
+
const price = { id: next('price'), active: true, tax_behavior: 'unspecified', ...structuredClone(params) }
|
|
400
|
+
state.prices.push(price)
|
|
401
|
+
return structuredClone(price)
|
|
402
|
+
},
|
|
403
|
+
update: async (id: string, params: Rec) => {
|
|
404
|
+
call('prices.update')
|
|
405
|
+
const price = find(state.prices, id, 'price')
|
|
406
|
+
if (
|
|
407
|
+
params.tax_behavior != null && price.tax_behavior != null && price.tax_behavior !== 'unspecified'
|
|
408
|
+
&& price.tax_behavior !== params.tax_behavior
|
|
409
|
+
) {
|
|
410
|
+
// Real Stripe: once a price's `tax_behavior` is `exclusive` or `inclusive`, it cannot change.
|
|
411
|
+
throw invalidRequest('parameter_invalid_empty')
|
|
412
|
+
}
|
|
413
|
+
Object.assign(price, params)
|
|
414
|
+
return structuredClone(price)
|
|
415
|
+
},
|
|
416
|
+
},
|
|
417
|
+
products: {
|
|
418
|
+
retrieve: async (id: string) => {
|
|
419
|
+
call('products.retrieve')
|
|
420
|
+
if (state.products[id] == null) throw missing('product')
|
|
421
|
+
return structuredClone(state.products[id])
|
|
422
|
+
},
|
|
423
|
+
update: async (id: string, params: Rec) => {
|
|
424
|
+
call('products.update')
|
|
425
|
+
Object.assign(state.products[id], params)
|
|
426
|
+
return structuredClone(state.products[id])
|
|
427
|
+
},
|
|
428
|
+
create: async (params: Rec) => {
|
|
429
|
+
call('products.create')
|
|
430
|
+
state.products[params.id] = structuredClone(params)
|
|
431
|
+
return structuredClone(params)
|
|
432
|
+
},
|
|
433
|
+
},
|
|
434
|
+
customers: {
|
|
435
|
+
retrieve: async (id: string) => {
|
|
436
|
+
call('customers.retrieve')
|
|
437
|
+
if (state.customers[id] == null) throw missing('customer')
|
|
438
|
+
return structuredClone(state.customers[id])
|
|
439
|
+
},
|
|
440
|
+
create: async (params: Rec) => {
|
|
441
|
+
call('customers.create')
|
|
442
|
+
const customer = { id: next('cus'), object: 'customer', address: null, ...structuredClone(params) }
|
|
443
|
+
state.customers[customer.id] = customer
|
|
444
|
+
return structuredClone(customer)
|
|
445
|
+
},
|
|
446
|
+
update: async (id: string, params: Rec) => {
|
|
447
|
+
call('customers.update')
|
|
448
|
+
const customer = state.customers[id]
|
|
449
|
+
if (customer == null) throw missing('customer')
|
|
450
|
+
Object.assign(customer, params)
|
|
451
|
+
return structuredClone(customer)
|
|
452
|
+
},
|
|
453
|
+
},
|
|
454
|
+
checkout: {
|
|
455
|
+
sessions: {
|
|
456
|
+
create: async (params: Rec) => {
|
|
457
|
+
call('checkout.sessions.create')
|
|
458
|
+
state.checkoutSessions.push(structuredClone(params))
|
|
459
|
+
return { id: next('cs'), url: `https://checkout.example.test/${state.seq}` }
|
|
460
|
+
},
|
|
461
|
+
expire: async (id: string) => {
|
|
462
|
+
call('checkout.sessions.expire')
|
|
463
|
+
state.expiredSessions.push(id)
|
|
464
|
+
return { id, status: 'expired' }
|
|
465
|
+
},
|
|
466
|
+
list: async (params: Rec = {}) => {
|
|
467
|
+
call('checkout.sessions.list')
|
|
468
|
+
return page(state.listedSessions.filter(session => (params.status == null || session.status === params.status)
|
|
469
|
+
&& (params.created?.gte == null || session.created >= params.created.gte)), params)
|
|
470
|
+
},
|
|
471
|
+
listLineItems: async () => {
|
|
472
|
+
call('checkout.sessions.listLineItems')
|
|
473
|
+
return { data: [{ quantity: state.lineItemQuantity }] }
|
|
474
|
+
},
|
|
475
|
+
},
|
|
476
|
+
},
|
|
477
|
+
tax: {
|
|
478
|
+
calculations: {
|
|
479
|
+
/**
|
|
480
|
+
* A flat-percentage tax model, entirely driven by `state.taxRates` / `taxInvalidCountries`
|
|
481
|
+
* / `taxUnsupportedCountries` — real enough to exercise the status mapping and the
|
|
482
|
+
* scalability rule, never a stand-in for Stripe's actual jurisdiction logic.
|
|
483
|
+
*/
|
|
484
|
+
create: async (params: Rec) => {
|
|
485
|
+
call('tax.calculations.create')
|
|
486
|
+
state.taxCalculations.push(structuredClone(params))
|
|
487
|
+
const country = params.customer_details?.address?.country as string | undefined
|
|
488
|
+
if (country != null && state.taxInvalidCountries.includes(country)) {
|
|
489
|
+
throw invalidRequest('customer_tax_location_invalid')
|
|
490
|
+
}
|
|
491
|
+
const lineItem = params.line_items[0]
|
|
492
|
+
const amount: number = lineItem.amount
|
|
493
|
+
const inclusive = lineItem.tax_behavior === 'inclusive'
|
|
494
|
+
const reverseCharge = (params.customer_details?.tax_ids ?? []).length > 0
|
|
495
|
+
const rates = reverseCharge ? [] : (country != null ? state.taxRates[country] ?? [] : [])
|
|
496
|
+
const unsupported = !reverseCharge && rates.length === 0
|
|
497
|
+
&& country != null && state.taxUnsupportedCountries.includes(country)
|
|
498
|
+
|
|
499
|
+
const breakdown = reverseCharge
|
|
500
|
+
? [{
|
|
501
|
+
amount: 0, inclusive, taxable_amount: amount, taxability_reason: 'reverse_charge',
|
|
502
|
+
tax_rate_details: { country, state: null, percentage_decimal: '0', tax_type: 'vat' },
|
|
503
|
+
}]
|
|
504
|
+
: unsupported
|
|
505
|
+
? [{
|
|
506
|
+
amount: 0, inclusive, taxable_amount: amount, taxability_reason: 'not_supported',
|
|
507
|
+
tax_rate_details: null,
|
|
508
|
+
}]
|
|
509
|
+
: rates.length === 0
|
|
510
|
+
? [{
|
|
511
|
+
amount: 0, inclusive, taxable_amount: amount, taxability_reason: 'not_collecting',
|
|
512
|
+
tax_rate_details: { country, state: null, percentage_decimal: '0', tax_type: 'vat' },
|
|
513
|
+
}]
|
|
514
|
+
: rates.map(rate => ({
|
|
515
|
+
amount: Math.round(amount * Number(rate.percentage) / 100), inclusive, taxable_amount: amount,
|
|
516
|
+
taxability_reason: 'standard_rated',
|
|
517
|
+
tax_rate_details: { country, state: null, percentage_decimal: rate.percentage, tax_type: rate.type },
|
|
518
|
+
}))
|
|
519
|
+
const taxTotal = breakdown.reduce((sum, row) => sum + row.amount, 0)
|
|
520
|
+
|
|
521
|
+
return {
|
|
522
|
+
id: next('taxcalc'), object: 'tax.calculation', currency: params.currency,
|
|
523
|
+
amount_total: inclusive ? amount : amount + taxTotal,
|
|
524
|
+
tax_amount_exclusive: inclusive ? 0 : taxTotal,
|
|
525
|
+
tax_amount_inclusive: inclusive ? taxTotal : 0,
|
|
526
|
+
tax_breakdown: breakdown,
|
|
527
|
+
}
|
|
528
|
+
},
|
|
529
|
+
},
|
|
530
|
+
settings: {
|
|
531
|
+
retrieve: async () => {
|
|
532
|
+
call('tax.settings.retrieve')
|
|
533
|
+
return structuredClone(state.taxSettings)
|
|
534
|
+
},
|
|
535
|
+
},
|
|
536
|
+
},
|
|
537
|
+
rawRequest: async (method: string, path: string, params: Rec = {}) => {
|
|
538
|
+
call('rawRequest')
|
|
539
|
+
state.rawRequests.push({ method, path, params: structuredClone(params) })
|
|
540
|
+
if (path !== '/v1/fx_quotes') {
|
|
541
|
+
throw new Error(`fake-stripe: unhandled rawRequest path "${path}"`)
|
|
542
|
+
}
|
|
543
|
+
if (state.fxUnavailable) {
|
|
544
|
+
throw new Error('fx_quotes unavailable')
|
|
545
|
+
}
|
|
546
|
+
const local = params['from_currencies[]'] as string
|
|
547
|
+
const rate = state.fxRates[local]
|
|
548
|
+
if (rate == null) {
|
|
549
|
+
return { rates: {} }
|
|
550
|
+
}
|
|
551
|
+
return {
|
|
552
|
+
rates: {
|
|
553
|
+
[local]: {
|
|
554
|
+
exchange_rate: rate.exchangeRate,
|
|
555
|
+
rate_details: {
|
|
556
|
+
base_rate: rate.baseRate ?? rate.exchangeRate,
|
|
557
|
+
reference_rate: rate.referenceRate ?? rate.baseRate ?? rate.exchangeRate,
|
|
558
|
+
fx_fee_rate: rate.fxFeeRate ?? 0.02,
|
|
559
|
+
},
|
|
560
|
+
},
|
|
561
|
+
},
|
|
562
|
+
}
|
|
563
|
+
},
|
|
564
|
+
webhooks: {
|
|
565
|
+
/** Accepts the signature `sig:<secret>` over any body. */
|
|
566
|
+
constructEventAsync: async (raw: string | Buffer, signature: string, secret: string) => {
|
|
567
|
+
if (signature !== `sig:${secret}`) throw new Error('bad signature')
|
|
568
|
+
return JSON.parse(raw.toString())
|
|
569
|
+
},
|
|
570
|
+
},
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
return { stripe: stripe as unknown as Stripe, state }
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
// -----------------------------------------------------------------------------------------------
|
|
577
|
+
// Stripe objects
|
|
578
|
+
// -----------------------------------------------------------------------------------------------
|
|
579
|
+
|
|
580
|
+
let objectSeq = 0
|
|
581
|
+
const epoch = (date: Date): number => Math.floor(date.getTime() / 1000)
|
|
582
|
+
|
|
583
|
+
export interface SubscriptionShape {
|
|
584
|
+
id?: string
|
|
585
|
+
customer?: string
|
|
586
|
+
entityId?: string
|
|
587
|
+
planSku?: string
|
|
588
|
+
status?: Stripe.Subscription.Status
|
|
589
|
+
itemId?: string
|
|
590
|
+
priceId?: string
|
|
591
|
+
periodStart?: Date
|
|
592
|
+
periodEnd?: Date
|
|
593
|
+
cancelAtPeriodEnd?: boolean
|
|
594
|
+
pauseCollection?: Rec | null
|
|
595
|
+
trialEnd?: Date
|
|
596
|
+
latestInvoice?: string
|
|
597
|
+
created?: Date
|
|
598
|
+
endedAt?: Date
|
|
599
|
+
defaultPaymentMethod?: string
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
export const subscriptionOf = (shape: SubscriptionShape = {}): Stripe.Subscription => ({
|
|
603
|
+
id: shape.id ?? 'sub_1',
|
|
604
|
+
object: 'subscription',
|
|
605
|
+
customer: shape.customer ?? 'cus_1',
|
|
606
|
+
status: shape.status ?? 'active',
|
|
607
|
+
metadata: { ...(shape.entityId !== undefined ? { entityId: shape.entityId } : { entityId: 'entity-1' }) },
|
|
608
|
+
items: {
|
|
609
|
+
object: 'list', has_more: false, url: '',
|
|
610
|
+
data: [{ id: shape.itemId ?? 'si_1', price: { id: shape.priceId ?? `price_${shape.planSku ?? PRO}`, lookup_key: shape.planSku ?? PRO } }],
|
|
611
|
+
},
|
|
612
|
+
current_period_start: epoch(shape.periodStart ?? past(1)),
|
|
613
|
+
current_period_end: epoch(shape.periodEnd ?? future(29)),
|
|
614
|
+
cancel_at_period_end: shape.cancelAtPeriodEnd ?? false,
|
|
615
|
+
canceled_at: null,
|
|
616
|
+
ended_at: shape.endedAt != null ? epoch(shape.endedAt) : null,
|
|
617
|
+
pause_collection: shape.pauseCollection ?? null,
|
|
618
|
+
trial_end: shape.trialEnd != null ? epoch(shape.trialEnd) : null,
|
|
619
|
+
latest_invoice: shape.latestInvoice ?? 'in_1',
|
|
620
|
+
created: epoch(shape.created ?? past(1)),
|
|
621
|
+
default_payment_method: shape.defaultPaymentMethod ?? 'pm_1',
|
|
622
|
+
} as unknown as Stripe.Subscription)
|
|
623
|
+
|
|
624
|
+
export const eventOf = (type: string, object: unknown, overrides: Rec = {}): Stripe.Event => ({
|
|
625
|
+
id: `evt_${++objectSeq}`, object: 'event', type, created: epoch(new Date()), data: { object }, ...overrides,
|
|
626
|
+
} as unknown as Stripe.Event)
|
|
627
|
+
|
|
628
|
+
// -----------------------------------------------------------------------------------------------
|
|
629
|
+
// In-memory resources
|
|
630
|
+
// -----------------------------------------------------------------------------------------------
|
|
631
|
+
|
|
632
|
+
const duplicate = (): Error => Object.assign(new Error('E11000 duplicate key error'), { code: 11000 })
|
|
633
|
+
|
|
634
|
+
const plain = (value: unknown): unknown => value instanceof Date ? value.getTime() : value
|
|
635
|
+
|
|
636
|
+
const withoutUndefined = <T extends Rec>(record: T): T =>
|
|
637
|
+
Object.fromEntries(Object.entries(record).filter(([, value]) => value !== undefined)) as T
|
|
638
|
+
|
|
639
|
+
const applyUpdate = (doc: Rec, update: Rec, inserting: boolean): void => {
|
|
640
|
+
for (const [key, value] of Object.entries(update.$set ?? {})) {
|
|
641
|
+
if (value === undefined) continue
|
|
642
|
+
doc[key] = value
|
|
643
|
+
}
|
|
644
|
+
for (const [key, value] of Object.entries(update.$inc ?? {})) {
|
|
645
|
+
doc[key] = (doc[key] ?? 0) + (value as number)
|
|
646
|
+
}
|
|
647
|
+
for (const key of Object.keys(update.$unset ?? {})) {
|
|
648
|
+
delete doc[key]
|
|
649
|
+
}
|
|
650
|
+
if (inserting) {
|
|
651
|
+
Object.assign(doc, update.$setOnInsert ?? {})
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
export interface MemoryStore {
|
|
656
|
+
rows: Rec[]
|
|
657
|
+
/** Make the next calls of a method throw. */
|
|
658
|
+
failing: Set<string>
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
/**
|
|
662
|
+
* Turn a real resource (its maker's indexes and helpers kept) into an in-memory one over the shared
|
|
663
|
+
* criteria engine, including the raw collection calls the package makes: the conditional counter
|
|
664
|
+
* upsert, `$inc` / `$set` / `$unset`, `$match` + `$group` aggregation and `distinct`.
|
|
665
|
+
*/
|
|
666
|
+
export const memoryResource = <T extends ResourceRecord>(resource: MongoResource<T>): MemoryStore => {
|
|
667
|
+
const store: MemoryStore = { rows: [], failing: new Set() }
|
|
668
|
+
const unique = (resource.indexes ?? []).filter(index => index.options?.unique === true)
|
|
669
|
+
.map(index => ({ keys: Object.keys(index.index as Rec), sparse: index.options?.sparse === true }))
|
|
670
|
+
let seq = 0
|
|
671
|
+
const guard = (method: string): void => {
|
|
672
|
+
if (store.failing.has(method)) throw new Error(`${resource.alias}.${method} unavailable`)
|
|
673
|
+
}
|
|
674
|
+
// A sparse unique index ignores a document that carries none of its keys (as Mongo does).
|
|
675
|
+
const violates = (doc: Rec, self?: Rec): boolean => unique.some(({ keys, sparse }) =>
|
|
676
|
+
!(sparse && keys.every(key => doc[key] == null)) && store.rows.some(row =>
|
|
677
|
+
row !== self && keys.every(key => plain(row[key] ?? null) === plain(doc[key] ?? null))))
|
|
678
|
+
const out = (row: Rec): T => structuredClone(row) as T
|
|
679
|
+
const byId = (id: string) => store.rows.find(row => row.id === id)
|
|
680
|
+
|
|
681
|
+
const target = resource as unknown as Rec
|
|
682
|
+
target.init = async () => undefined
|
|
683
|
+
target.get = async (idOrWhere: string | Criteria<T>, opts?: { sort?: never[] }) => {
|
|
684
|
+
guard('get')
|
|
685
|
+
const row = typeof idOrWhere === 'string' ? byId(idOrWhere) : firstMatch(store.rows, idOrWhere as Criteria<any>, opts)
|
|
686
|
+
if (row == null) throw new UnknownRecordError(JSON.stringify(idOrWhere))
|
|
687
|
+
return out(row)
|
|
688
|
+
}
|
|
689
|
+
target.load = async (idOrWhere: string | Criteria<T>, opts?: { sort?: never[] }) => {
|
|
690
|
+
guard('load')
|
|
691
|
+
const row = typeof idOrWhere === 'string' ? byId(idOrWhere) : firstMatch(store.rows, idOrWhere as Criteria<any>, opts)
|
|
692
|
+
return row == null ? null : out(row)
|
|
693
|
+
}
|
|
694
|
+
target.list = async (where?: Criteria<T>, opts?: ListOptions<T>) => {
|
|
695
|
+
guard('list')
|
|
696
|
+
const result = applyQuery(store.rows, where as Criteria<any>, opts as ListOptions<any>)
|
|
697
|
+
return { ...result, items: result.items.map(out) }
|
|
698
|
+
}
|
|
699
|
+
target.count = async (where?: Criteria<T>) => store.rows.filter(row => matchCriteria(row, where)).length
|
|
700
|
+
target.create = async (record: Rec) => {
|
|
701
|
+
guard('create')
|
|
702
|
+
if (record.id != null) throw new RecordExists('id-present')
|
|
703
|
+
const doc = withoutUndefined(structuredClone(record))
|
|
704
|
+
delete doc.id
|
|
705
|
+
if (violates(doc)) throw duplicate()
|
|
706
|
+
doc.id = `${resource.alias}-${++seq}`
|
|
707
|
+
store.rows.push(doc)
|
|
708
|
+
return out(doc)
|
|
709
|
+
}
|
|
710
|
+
target.update = async (record: Rec) => {
|
|
711
|
+
guard('update')
|
|
712
|
+
const existing = byId(record.id)
|
|
713
|
+
if (existing == null) throw new UnknownRecordError(String(record.id))
|
|
714
|
+
const doc = withoutUndefined(structuredClone(record))
|
|
715
|
+
if (violates(doc, existing)) throw duplicate()
|
|
716
|
+
store.rows[store.rows.indexOf(existing)] = doc
|
|
717
|
+
return out(doc)
|
|
718
|
+
}
|
|
719
|
+
target.save = async (record: Rec) => record.id != null ? await target.update(record) : await target.create(record)
|
|
720
|
+
target.delete = async (id: string) => {
|
|
721
|
+
const row = byId(id)
|
|
722
|
+
if (row == null) return null
|
|
723
|
+
store.rows = store.rows.filter(item => item !== row)
|
|
724
|
+
return out(row)
|
|
725
|
+
}
|
|
726
|
+
target.purge = async (where: Criteria<T>) => {
|
|
727
|
+
const before = store.rows.length
|
|
728
|
+
store.rows = store.rows.filter(row => !matchCriteria(row, where))
|
|
729
|
+
return before - store.rows.length
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
const matchRaw = (filter: Rec) => store.rows.find(row => matchCriteria(row, filter))
|
|
733
|
+
const upsert = (filter: Rec, update: Rec, opts: Rec = {}): { doc: Rec | null, inserted: boolean } => {
|
|
734
|
+
const found = matchRaw(filter)
|
|
735
|
+
if (found != null) {
|
|
736
|
+
applyUpdate(found, update, false)
|
|
737
|
+
return { doc: found, inserted: false }
|
|
738
|
+
}
|
|
739
|
+
if (opts.upsert !== true) return { doc: null, inserted: false }
|
|
740
|
+
const doc: Rec = Object.fromEntries(Object.entries(filter).filter(([, value]) =>
|
|
741
|
+
value === null || typeof value !== 'object' || value instanceof Date))
|
|
742
|
+
applyUpdate(doc, update, true)
|
|
743
|
+
if (violates(doc)) throw duplicate()
|
|
744
|
+
doc.id = `${resource.alias}-${++seq}`
|
|
745
|
+
store.rows.push(doc)
|
|
746
|
+
return { doc, inserted: true }
|
|
747
|
+
}
|
|
748
|
+
target.collection = {
|
|
749
|
+
findOneAndUpdate: async (filter: Rec, update: Rec, opts: Rec = {}) => {
|
|
750
|
+
guard('findOneAndUpdate')
|
|
751
|
+
const before = matchRaw(filter)
|
|
752
|
+
const snapshot = before != null ? structuredClone(before) : null
|
|
753
|
+
const { doc } = upsert(filter, update, opts)
|
|
754
|
+
if (doc == null) return null
|
|
755
|
+
const result = opts.returnDocument === 'after' ? doc : snapshot
|
|
756
|
+
return result == null ? null : { ...structuredClone(result), _id: result.id }
|
|
757
|
+
},
|
|
758
|
+
updateOne: async (filter: Rec, update: Rec, opts: Rec = {}) => {
|
|
759
|
+
guard('updateOne')
|
|
760
|
+
// Like Mongo: an update is checked against the unique indexes before it lands.
|
|
761
|
+
const found = matchRaw(filter)
|
|
762
|
+
if (found != null) {
|
|
763
|
+
const candidate = structuredClone(found)
|
|
764
|
+
applyUpdate(candidate, update, false)
|
|
765
|
+
if (violates(candidate, found)) throw duplicate()
|
|
766
|
+
}
|
|
767
|
+
const { doc, inserted } = upsert(filter, update, opts)
|
|
768
|
+
return { matchedCount: doc != null && !inserted ? 1 : 0, upsertedCount: inserted ? 1 : 0 }
|
|
769
|
+
},
|
|
770
|
+
deleteOne: async (filter: Rec) => {
|
|
771
|
+
const row = matchRaw(filter)
|
|
772
|
+
store.rows = store.rows.filter(item => item !== row)
|
|
773
|
+
return { deletedCount: row != null ? 1 : 0 }
|
|
774
|
+
},
|
|
775
|
+
distinct: async (field: string, filter: Rec = {}) =>
|
|
776
|
+
[...new Set(store.rows.filter(row => matchCriteria(row, filter)).map(row => row[field]))],
|
|
777
|
+
aggregate: (pipeline: Rec[]) => ({
|
|
778
|
+
toArray: async () => {
|
|
779
|
+
guard('aggregate')
|
|
780
|
+
let docs: Rec[] = store.rows.map(row => structuredClone(row))
|
|
781
|
+
for (const stage of pipeline) {
|
|
782
|
+
if (stage.$match != null) {
|
|
783
|
+
docs = docs.filter(doc => matchCriteria(doc, stage.$match))
|
|
784
|
+
} else if (stage.$group != null) {
|
|
785
|
+
const groups = new Map<string, Rec>()
|
|
786
|
+
const { _id: key, ...accumulators } = stage.$group
|
|
787
|
+
for (const doc of docs) {
|
|
788
|
+
const id = Object.fromEntries(Object.entries(key as Rec).map(([name, ref]) => [name, doc[(ref as string).slice(1)]]))
|
|
789
|
+
const hash = JSON.stringify(id)
|
|
790
|
+
const group = groups.get(hash) ?? { _id: id }
|
|
791
|
+
for (const [name, spec] of Object.entries(accumulators as Rec)) {
|
|
792
|
+
group[name] = (group[name] ?? 0) + (doc[(spec.$sum as string).slice(1)] ?? 0)
|
|
793
|
+
}
|
|
794
|
+
groups.set(hash, group)
|
|
795
|
+
}
|
|
796
|
+
docs = [...groups.values()]
|
|
797
|
+
} else {
|
|
798
|
+
throw new Error(`unsupported stage ${JSON.stringify(stage)}`)
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
return docs
|
|
802
|
+
},
|
|
803
|
+
}),
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
return store
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
// -----------------------------------------------------------------------------------------------
|
|
810
|
+
// Context
|
|
811
|
+
// -----------------------------------------------------------------------------------------------
|
|
812
|
+
|
|
813
|
+
export interface FakeContextOptions {
|
|
814
|
+
catalogue?: CatalogueOptions
|
|
815
|
+
/** Declare the catalogue yourself instead. */
|
|
816
|
+
declare?: (cfg: Config) => void
|
|
817
|
+
/** The public host of the service (default `api.example.com`). */
|
|
818
|
+
host?: string
|
|
819
|
+
/** The configured webhook override secret. */
|
|
820
|
+
webhookSecret?: string
|
|
821
|
+
portal?: PortalBrandingDef
|
|
822
|
+
pricing?: PricingDef
|
|
823
|
+
stripe?: Partial<FakeStripeState>
|
|
824
|
+
/** Declare a consumer-rights policy (with trader and mail options). */
|
|
825
|
+
consumerRights?: ConsumerRightsDef
|
|
826
|
+
/** The consumer-rights usage meter. */
|
|
827
|
+
meter?: UsageMeter
|
|
828
|
+
/** Register a console mailer under `MAILER_SERVICE` (default true). */
|
|
829
|
+
mailer?: boolean
|
|
830
|
+
/** Register the gateway before the application's consumer-rights call (default: after). */
|
|
831
|
+
gatewayFirst?: boolean
|
|
832
|
+
/** The application's consumer-rights options (default: managed through the fake, with `meter`). */
|
|
833
|
+
rights?: (stripe: Stripe) => ConsumerRightsOptions
|
|
834
|
+
/** The gateway's `manage` (default false). */
|
|
835
|
+
gatewayManage?: boolean
|
|
836
|
+
/** Run on the context after the registrations, before it is configured and initialized. */
|
|
837
|
+
wire?: (ctx: ApiContext) => void
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
export interface Observed {
|
|
841
|
+
topUp: TopUpCompletion[]
|
|
842
|
+
subscription: SubscriptionEvent[]
|
|
843
|
+
refund: RefundEvent[]
|
|
844
|
+
dispute: DisputeEvent[]
|
|
845
|
+
paymentFailed: PaymentFailedEvent[]
|
|
846
|
+
consent: ConsentEvent[]
|
|
847
|
+
withdrawal: WithdrawalEvent[]
|
|
848
|
+
cancellation: CancellationEvent[]
|
|
849
|
+
/** Throw from the next `n` subscription callbacks. */
|
|
850
|
+
failSubscription: number
|
|
851
|
+
failTopUp: number
|
|
852
|
+
failWithdrawal: number
|
|
853
|
+
/** Run inside every top-up / subscription callback, before it records the event. */
|
|
854
|
+
onTopUp?: (event: TopUpCompletion) => Promise<void>
|
|
855
|
+
onSubscription?: (event: SubscriptionEvent) => Promise<void>
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
export interface FakeContext {
|
|
859
|
+
ctx: ApiContext
|
|
860
|
+
stripe: Stripe
|
|
861
|
+
state: FakeStripeState
|
|
862
|
+
stores: Record<string, MemoryStore>
|
|
863
|
+
observed: Observed
|
|
864
|
+
/** Every mail the console mailer took. */
|
|
865
|
+
mails: MailMessage[]
|
|
866
|
+
/** The console mailer — replace its `send` to simulate a transport failure. */
|
|
867
|
+
mailer: ReturnType<typeof makeConsoleMailerService>
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
/**
|
|
871
|
+
* A real server context — real catalogue, payment service, observer, gateway (unmanaged), gates and
|
|
872
|
+
* entitlement service — over in-memory resources, with a fake Stripe for the plugin functions.
|
|
873
|
+
*/
|
|
874
|
+
export const makeFakeContext = async (opts: FakeContextOptions = {}): Promise<FakeContext> => {
|
|
875
|
+
const cfg = serverConfig<ServerConfig>(SERVICE, {
|
|
876
|
+
services: {
|
|
877
|
+
[SERVICE]: { service: SERVICE, type: AppType.Backend, host: opts.host ?? HOST },
|
|
878
|
+
},
|
|
879
|
+
} as Partial<ServerConfig>) as unknown as Config
|
|
880
|
+
if (opts.declare != null) {
|
|
881
|
+
opts.declare(cfg)
|
|
882
|
+
} else {
|
|
883
|
+
declareTestCatalogue(cfg, opts.catalogue)
|
|
884
|
+
}
|
|
885
|
+
stripeSecrets(cfg, { api: 'sk_test_offline', ...(opts.webhookSecret != null ? { webhook: opts.webhookSecret } : {}) })
|
|
886
|
+
if (opts.portal != null) {
|
|
887
|
+
portalBranding(cfg, opts.portal)
|
|
888
|
+
}
|
|
889
|
+
if (opts.pricing != null) {
|
|
890
|
+
declarePaymentPricing(cfg, opts.pricing)
|
|
891
|
+
}
|
|
892
|
+
if (opts.consumerRights != null) {
|
|
893
|
+
declareConsumerRights(cfg, opts.consumerRights)
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
const ctx = makeServerContext(cfg as unknown as ServerConfig) as unknown as ApiContext
|
|
897
|
+
const stores: Record<string, MemoryStore> = {}
|
|
898
|
+
const resources = [
|
|
899
|
+
[RES_PAYGATE_CUSTOMER, makePaygateCustomerResource()],
|
|
900
|
+
[RES_PAYMENT_SUBSCRIPTION, makeSubscriptionResource()],
|
|
901
|
+
[RES_PAYMENT_FULFILLMENT, makeFulfillmentResource()],
|
|
902
|
+
[RES_PAYMENT_WEBHOOK, makeWebhookResource()],
|
|
903
|
+
[RES_PAYMENT_USAGE, makeUsageResource()],
|
|
904
|
+
[RES_PAYMENT_USAGE_COUNTER, makeUsageCounterResource()],
|
|
905
|
+
[RES_PAYMENT_FINGERPRINT, makeFingerprintResource()],
|
|
906
|
+
[RES_BILLING_PROFILE, makeBillingProfileResource()],
|
|
907
|
+
[RES_PAYMENT_PURCHASE, makePurchaseResource()],
|
|
908
|
+
[RES_CONSUMER_CONSENT, makeConsumerConsentResource()],
|
|
909
|
+
[RES_CONSUMER_DECLARATION, makeConsumerDeclarationResource()],
|
|
910
|
+
[RES_CONSUMER_EVENT, makeConsumerEventResource()],
|
|
911
|
+
] as const
|
|
912
|
+
for (const [alias, resource] of resources) {
|
|
913
|
+
stores[alias] = memoryResource(resource as unknown as MongoResource<ResourceRecord>)
|
|
914
|
+
ctx.registerResource(resource as never)
|
|
915
|
+
}
|
|
916
|
+
const { stripe, state } = makeFakeStripe(opts.stripe)
|
|
917
|
+
const mailer = makeConsoleMailerService(MAILER_SERVICE)
|
|
918
|
+
if (opts.mailer !== false) {
|
|
919
|
+
ctx.registerService(mailer)
|
|
920
|
+
}
|
|
921
|
+
// The consumer-rights service manages the paygate through the fake; the gateway stays unmanaged.
|
|
922
|
+
const rights = opts.rights?.(stripe)
|
|
923
|
+
?? { manage: true, stripe: async () => stripe, ...(opts.meter != null ? { usage: opts.meter } : {}) }
|
|
924
|
+
const gatewayOpts = { manage: opts.gatewayManage ?? false }
|
|
925
|
+
if (opts.gatewayFirst === true) {
|
|
926
|
+
appendPaymentGatewayService(ctx as never, gatewayOpts)
|
|
927
|
+
appendConsumerRights(ctx as never, rights)
|
|
928
|
+
} else {
|
|
929
|
+
appendConsumerRights(ctx as never, rights)
|
|
930
|
+
appendPaymentGatewayService(ctx as never, gatewayOpts)
|
|
931
|
+
}
|
|
932
|
+
opts.wire?.(ctx)
|
|
933
|
+
ctx.configure()
|
|
934
|
+
await ctx.init()
|
|
935
|
+
|
|
936
|
+
const observed: Observed = {
|
|
937
|
+
topUp: [], subscription: [], refund: [], dispute: [], paymentFailed: [], consent: [], withdrawal: [],
|
|
938
|
+
cancellation: [], failSubscription: 0, failTopUp: 0, failWithdrawal: 0,
|
|
939
|
+
}
|
|
940
|
+
const completions = observer(ctx)
|
|
941
|
+
await completions.ready()
|
|
942
|
+
completions.onTopUp(async event => {
|
|
943
|
+
if (observed.failTopUp > 0) {
|
|
944
|
+
observed.failTopUp--
|
|
945
|
+
throw new Error('ledger unavailable')
|
|
946
|
+
}
|
|
947
|
+
await observed.onTopUp?.(event)
|
|
948
|
+
observed.topUp.push(event)
|
|
949
|
+
})
|
|
950
|
+
completions.onSubscription(async event => {
|
|
951
|
+
if (observed.failSubscription > 0) {
|
|
952
|
+
observed.failSubscription--
|
|
953
|
+
throw new Error('observer unavailable')
|
|
954
|
+
}
|
|
955
|
+
await observed.onSubscription?.(event)
|
|
956
|
+
observed.subscription.push(event)
|
|
957
|
+
})
|
|
958
|
+
completions.onRefund(async event => { observed.refund.push(event) })
|
|
959
|
+
completions.onDispute(async event => { observed.dispute.push(event) })
|
|
960
|
+
completions.onPaymentFailed(async event => { observed.paymentFailed.push(event) })
|
|
961
|
+
completions.onConsent(async event => { observed.consent.push(event) })
|
|
962
|
+
completions.onWithdrawal(async event => {
|
|
963
|
+
if (observed.failWithdrawal > 0) {
|
|
964
|
+
observed.failWithdrawal--
|
|
965
|
+
throw new Error('ledger unavailable')
|
|
966
|
+
}
|
|
967
|
+
observed.withdrawal.push(event)
|
|
968
|
+
})
|
|
969
|
+
completions.onCancellation(async event => { observed.cancellation.push(event) })
|
|
970
|
+
|
|
971
|
+
return { ctx, stripe, state, stores, observed, mails: mailer.captured, mailer }
|
|
972
|
+
}
|