@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.
Files changed (222) hide show
  1. package/README.md +109 -25
  2. package/agent-meta/manifest.json +2 -2
  3. package/agent-meta/skills/server-payment/SKILL.md +475 -52
  4. package/build/actions/index.d.ts +1 -0
  5. package/build/actions/index.d.ts.map +1 -1
  6. package/build/actions/index.js +1 -0
  7. package/build/actions/index.js.map +1 -1
  8. package/build/actions/resync-subscriptions.d.ts +3 -0
  9. package/build/actions/resync-subscriptions.d.ts.map +1 -0
  10. package/build/actions/resync-subscriptions.js +7 -0
  11. package/build/actions/resync-subscriptions.js.map +1 -0
  12. package/build/actions/resync.d.ts +1 -0
  13. package/build/actions/resync.d.ts.map +1 -1
  14. package/build/actions/resync.js +7 -3
  15. package/build/actions/resync.js.map +1 -1
  16. package/build/actions/webhook.d.ts.map +1 -1
  17. package/build/actions/webhook.js +6 -3
  18. package/build/actions/webhook.js.map +1 -1
  19. package/build/config.d.ts +60 -5
  20. package/build/config.d.ts.map +1 -1
  21. package/build/config.js +237 -5
  22. package/build/config.js.map +1 -1
  23. package/build/consts.d.ts +75 -3
  24. package/build/consts.d.ts.map +1 -1
  25. package/build/consts.js +94 -5
  26. package/build/consts.js.map +1 -1
  27. package/build/consumer/capture.d.ts +74 -0
  28. package/build/consumer/capture.d.ts.map +1 -0
  29. package/build/consumer/capture.js +291 -0
  30. package/build/consumer/capture.js.map +1 -0
  31. package/build/consumer/format.d.ts +27 -0
  32. package/build/consumer/format.d.ts.map +1 -0
  33. package/build/consumer/format.js +81 -0
  34. package/build/consumer/format.js.map +1 -0
  35. package/build/consumer/handlers.d.ts +28 -0
  36. package/build/consumer/handlers.d.ts.map +1 -0
  37. package/build/consumer/handlers.js +173 -0
  38. package/build/consumer/handlers.js.map +1 -0
  39. package/build/consumer/index.d.ts +7 -0
  40. package/build/consumer/index.d.ts.map +1 -0
  41. package/build/consumer/index.js +6 -0
  42. package/build/consumer/index.js.map +1 -0
  43. package/build/consumer/mail.d.ts +27 -0
  44. package/build/consumer/mail.d.ts.map +1 -0
  45. package/build/consumer/mail.js +314 -0
  46. package/build/consumer/mail.js.map +1 -0
  47. package/build/consumer/origin.d.ts +14 -0
  48. package/build/consumer/origin.d.ts.map +1 -0
  49. package/build/consumer/origin.js +47 -0
  50. package/build/consumer/origin.js.map +1 -0
  51. package/build/consumer/reconcile.d.ts +12 -0
  52. package/build/consumer/reconcile.d.ts.map +1 -0
  53. package/build/consumer/reconcile.js +317 -0
  54. package/build/consumer/reconcile.js.map +1 -0
  55. package/build/consumer/records.d.ts +78 -0
  56. package/build/consumer/records.d.ts.map +1 -0
  57. package/build/consumer/records.js +296 -0
  58. package/build/consumer/records.js.map +1 -0
  59. package/build/consumer/service.d.ts +51 -0
  60. package/build/consumer/service.d.ts.map +1 -0
  61. package/build/consumer/service.js +760 -0
  62. package/build/consumer/service.js.map +1 -0
  63. package/build/consumer/withdrawal.d.ts +57 -0
  64. package/build/consumer/withdrawal.d.ts.map +1 -0
  65. package/build/consumer/withdrawal.js +247 -0
  66. package/build/consumer/withdrawal.js.map +1 -0
  67. package/build/entitlement.d.ts +10 -0
  68. package/build/entitlement.d.ts.map +1 -0
  69. package/build/entitlement.js +69 -0
  70. package/build/entitlement.js.map +1 -0
  71. package/build/entrypoints.d.ts +1 -1
  72. package/build/entrypoints.d.ts.map +1 -1
  73. package/build/entrypoints.js +2 -1
  74. package/build/entrypoints.js.map +1 -1
  75. package/build/gate.d.ts +32 -4
  76. package/build/gate.d.ts.map +1 -1
  77. package/build/gate.js +59 -19
  78. package/build/gate.js.map +1 -1
  79. package/build/index.d.ts +18 -3
  80. package/build/index.d.ts.map +1 -1
  81. package/build/index.js +15 -3
  82. package/build/index.js.map +1 -1
  83. package/build/limit.d.ts +13 -0
  84. package/build/limit.d.ts.map +1 -0
  85. package/build/limit.js +47 -0
  86. package/build/limit.js.map +1 -0
  87. package/build/model.d.ts +10 -1
  88. package/build/model.d.ts.map +1 -1
  89. package/build/model.js +215 -17
  90. package/build/model.js.map +1 -1
  91. package/build/observer.d.ts +9 -0
  92. package/build/observer.d.ts.map +1 -1
  93. package/build/observer.js +38 -12
  94. package/build/observer.js.map +1 -1
  95. package/build/plan.d.ts +22 -0
  96. package/build/plan.d.ts.map +1 -0
  97. package/build/plan.js +72 -0
  98. package/build/plan.js.map +1 -0
  99. package/build/plugins/checkout-plugins.d.ts +49 -0
  100. package/build/plugins/checkout-plugins.d.ts.map +1 -0
  101. package/build/plugins/checkout-plugins.js +124 -0
  102. package/build/plugins/checkout-plugins.js.map +1 -0
  103. package/build/plugins/estimate.d.ts +43 -0
  104. package/build/plugins/estimate.d.ts.map +1 -0
  105. package/build/plugins/estimate.js +268 -0
  106. package/build/plugins/estimate.js.map +1 -0
  107. package/build/plugins/events.d.ts +45 -10
  108. package/build/plugins/events.d.ts.map +1 -1
  109. package/build/plugins/events.js +605 -136
  110. package/build/plugins/events.js.map +1 -1
  111. package/build/plugins/fx.d.ts +31 -0
  112. package/build/plugins/fx.d.ts.map +1 -0
  113. package/build/plugins/fx.js +81 -0
  114. package/build/plugins/fx.js.map +1 -0
  115. package/build/plugins/portal.d.ts +37 -0
  116. package/build/plugins/portal.d.ts.map +1 -0
  117. package/build/plugins/portal.js +265 -0
  118. package/build/plugins/portal.js.map +1 -0
  119. package/build/plugins/refunds.d.ts +33 -0
  120. package/build/plugins/refunds.d.ts.map +1 -0
  121. package/build/plugins/refunds.js +80 -0
  122. package/build/plugins/refunds.js.map +1 -0
  123. package/build/plugins/stripe.d.ts +36 -4
  124. package/build/plugins/stripe.d.ts.map +1 -1
  125. package/build/plugins/stripe.js +492 -97
  126. package/build/plugins/stripe.js.map +1 -1
  127. package/build/plugins/webhook-manager.d.ts +46 -0
  128. package/build/plugins/webhook-manager.d.ts.map +1 -0
  129. package/build/plugins/webhook-manager.js +231 -0
  130. package/build/plugins/webhook-manager.js.map +1 -0
  131. package/build/reconcile.d.ts +15 -0
  132. package/build/reconcile.d.ts.map +1 -0
  133. package/build/reconcile.js +88 -0
  134. package/build/reconcile.js.map +1 -0
  135. package/build/resource.d.ts +12 -1
  136. package/build/resource.d.ts.map +1 -1
  137. package/build/resource.js +97 -6
  138. package/build/resource.js.map +1 -1
  139. package/build/service.d.ts +28 -3
  140. package/build/service.d.ts.map +1 -1
  141. package/build/service.js +146 -19
  142. package/build/service.js.map +1 -1
  143. package/build/subscription.d.ts +48 -0
  144. package/build/subscription.d.ts.map +1 -0
  145. package/build/subscription.js +176 -0
  146. package/build/subscription.js.map +1 -0
  147. package/build/sync.d.ts +28 -1
  148. package/build/sync.d.ts.map +1 -1
  149. package/build/sync.js +208 -31
  150. package/build/sync.js.map +1 -1
  151. package/build/types.d.ts +1115 -36
  152. package/build/types.d.ts.map +1 -1
  153. package/build/usage.d.ts +63 -0
  154. package/build/usage.d.ts.map +1 -0
  155. package/build/usage.js +363 -0
  156. package/build/usage.js.map +1 -0
  157. package/build/utils.d.ts +52 -1
  158. package/build/utils.d.ts.map +1 -1
  159. package/build/utils.js +69 -7
  160. package/build/utils.js.map +1 -1
  161. package/package.json +17 -13
  162. package/src/actions/index.ts +1 -0
  163. package/src/actions/resync-subscriptions.ts +10 -0
  164. package/src/actions/resync.ts +6 -3
  165. package/src/actions/webhook.ts +5 -3
  166. package/src/config.ts +264 -8
  167. package/src/consts.ts +114 -6
  168. package/src/consumer/capture.ts +362 -0
  169. package/src/consumer/format.ts +90 -0
  170. package/src/consumer/handlers.ts +211 -0
  171. package/src/consumer/index.ts +6 -0
  172. package/src/consumer/mail.ts +368 -0
  173. package/src/consumer/origin.ts +63 -0
  174. package/src/consumer/reconcile.ts +329 -0
  175. package/src/consumer/records.ts +374 -0
  176. package/src/consumer/service.ts +868 -0
  177. package/src/consumer/withdrawal.ts +302 -0
  178. package/src/entitlement.ts +84 -0
  179. package/src/entrypoints.ts +2 -1
  180. package/src/gate.ts +88 -21
  181. package/src/index.ts +24 -3
  182. package/src/limit.ts +57 -0
  183. package/src/model.ts +237 -18
  184. package/src/observer.ts +44 -11
  185. package/src/plan.ts +89 -0
  186. package/src/plugins/checkout-plugins.ts +155 -0
  187. package/src/plugins/estimate.ts +339 -0
  188. package/src/plugins/events.ts +677 -121
  189. package/src/plugins/fx.ts +122 -0
  190. package/src/plugins/portal.ts +306 -0
  191. package/src/plugins/refunds.ts +108 -0
  192. package/src/plugins/stripe.ts +581 -96
  193. package/src/plugins/webhook-manager.ts +270 -0
  194. package/src/reconcile.ts +103 -0
  195. package/src/resource.ts +152 -7
  196. package/src/service.ts +174 -18
  197. package/src/subscription.ts +231 -0
  198. package/src/sync.ts +249 -29
  199. package/src/types.ts +1227 -32
  200. package/src/usage.ts +453 -0
  201. package/src/utils.ts +127 -10
  202. package/tests/checkout-consumer.spec.ts +348 -0
  203. package/tests/checkout-plugins.spec.ts +164 -0
  204. package/tests/checkout.spec.ts +184 -83
  205. package/tests/consumer-events.spec.ts +218 -0
  206. package/tests/consumer-fixtures.ts +132 -0
  207. package/tests/consumer-ops.spec.ts +351 -0
  208. package/tests/consumer-rights.integration.spec.ts +150 -0
  209. package/tests/consumer-rights.spec.ts +501 -0
  210. package/tests/context.ts +109 -0
  211. package/tests/entitlement.spec.ts +103 -0
  212. package/tests/estimate.spec.ts +240 -0
  213. package/tests/events.spec.ts +356 -0
  214. package/tests/fake-stripe.ts +972 -0
  215. package/tests/gate.spec.ts +62 -72
  216. package/tests/limit-gate.spec.ts +68 -0
  217. package/tests/portal.spec.ts +200 -0
  218. package/tests/protocol.spec.ts +23 -6
  219. package/tests/sync.spec.ts +114 -0
  220. package/tests/usage.integration.spec.ts +101 -0
  221. package/tests/usage.spec.ts +171 -0
  222. 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
+ }