@saasicat/persistence-testing 1.0.0-rc.16 → 1.0.0-rc.18

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 CHANGED
@@ -25,6 +25,12 @@ Verified scenarios:
25
25
  - one promo redemption per subscription (unique guard)
26
26
  - audit write → query roundtrip incl. `actorTag` wildcard filters
27
27
  - MFA secret roundtrip
28
+ - a gateway event is claimed once per account, a session is confirmed once however many events
29
+ report it, an event that changed nothing gives its session back while staying claimed itself, and
30
+ a claim rolled back with its transaction is free for the retry
31
+ - a confirmed payment method replaces the one in use and keeps it as history, per subscriber
32
+ - a change of payment method a tenant started is completed once, and only for the account, session
33
+ and subscriber it was started for
28
34
  - the applied settings: one row per installation, replaced only by a writer that
29
35
  read its current fingerprint — so replicas starting together record one change,
30
36
  concurrently — with the change and the record it supersedes landing together;
@@ -94,7 +100,14 @@ persistenceAdapterContract({
94
100
  }),
95
101
  // The parts this adapter deliberately does not provide. A part named here
96
102
  // that the harness does provide fails the suite as well.
97
- gaps: ['subscriptionContracts', 'subscribers', 'checkoutOffers', 'appliedSettings'],
103
+ gaps: [
104
+ 'subscriptionContracts',
105
+ 'subscribers',
106
+ 'paymentEventLog',
107
+ 'subscriberPaymentMethods',
108
+ 'checkoutOffers',
109
+ 'appliedSettings',
110
+ ],
98
111
  });
99
112
  ```
100
113
 
package/dist/.build-stamp CHANGED
@@ -1 +1 @@
1
- 1ca79840c535585e92be3594dc7e1b240dfd47e78e98d7577fe19a1cd7e51faf
1
+ ec457c9ef09c51121503a82c971c3de2ba82aead3348d11c442cc6018f83d99e
package/dist/index.cjs CHANGED
@@ -79,6 +79,39 @@ function partiesWith(subscriberId, legalName) {
79
79
  }
80
80
  };
81
81
  }
82
+ function eventAt(gatewayAccount, eventId, about = {}) {
83
+ return {
84
+ gatewayAccount,
85
+ eventId,
86
+ provider: "stripe",
87
+ sessionId: `cs_${eventId}`,
88
+ kind: "payment-method-confirmed",
89
+ summary: { type: "card", last4: "4242" },
90
+ ...about
91
+ };
92
+ }
93
+ var CARD = {
94
+ type: "card",
95
+ brand: "visa",
96
+ last4: "4242",
97
+ expiryMonth: 12,
98
+ expiryYear: 2030,
99
+ country: null,
100
+ bankCode: null,
101
+ mandateReference: null,
102
+ customerRef: "cus_1",
103
+ paymentMethodRef: "pm_card"
104
+ };
105
+ function paymentMethodFor(subscriberId, paymentMethodRef, confirmedAt, gatewayAccount = "stripe-main") {
106
+ return {
107
+ ...CARD,
108
+ paymentMethodRef,
109
+ subscriberId,
110
+ gatewayAccount,
111
+ provider: "stripe",
112
+ confirmedAt: new Date(confirmedAt)
113
+ };
114
+ }
82
115
  function subscriberFor(tenantId, legalName) {
83
116
  return {
84
117
  tenantId,
@@ -236,6 +269,14 @@ var CONTRACT_GAPS = {
236
269
  reason: "adapter provides no SubscriberRepository",
237
270
  present: ({ adapter }) => Boolean(adapter.subscriberRepository)
238
271
  },
272
+ paymentEventLog: {
273
+ reason: "adapter provides no PaymentEventLog",
274
+ present: ({ adapter }) => Boolean(adapter.paymentEventLog)
275
+ },
276
+ subscriberPaymentMethods: {
277
+ reason: "adapter provides no SubscriberPaymentMethodRepository, or no subscriber seed for it",
278
+ present: ({ adapter, seed }) => Boolean(adapter.subscriberPaymentMethodRepository && seed.createSubscriber)
279
+ },
239
280
  checkoutOffers: {
240
281
  reason: "adapter provides no CheckoutOfferRepository",
241
282
  present: ({ adapter }) => Boolean(adapter.checkoutOfferRepository)
@@ -2144,6 +2185,484 @@ function persistenceAdapterContract(options) {
2144
2185
  "the contract followed a correction of the live record"
2145
2186
  );
2146
2187
  });
2188
+ (0, import_node_test.test)("a gateway event is claimed once per account, and a duplicate leaves the transaction usable", async (t) => {
2189
+ const { adapter } = harness;
2190
+ const log = adapter.paymentEventLog;
2191
+ if (!log) {
2192
+ missing(t, "paymentEventLog");
2193
+ return;
2194
+ }
2195
+ const claims = await adapter.transactionRunner.run(async (tx) => [
2196
+ await log.claim(eventAt("stripe-main", "evt_1"), tx),
2197
+ await log.claim(eventAt("stripe-main", "evt_1"), tx),
2198
+ // The same identifier from another account is another event.
2199
+ await log.claim(eventAt("stripe-old", "evt_1"), tx),
2200
+ // A duplicate that raised would have aborted the transaction here.
2201
+ await log.claim(eventAt("stripe-main", "evt_2"), tx)
2202
+ ]);
2203
+ import_strict.default.deepEqual(claims, [true, false, true, true]);
2204
+ const later = await adapter.transactionRunner.run(
2205
+ (tx) => log.claim(eventAt("stripe-main", "evt_1"), tx)
2206
+ );
2207
+ import_strict.default.equal(later, false, "a committed claim was claimed again");
2208
+ });
2209
+ (0, import_node_test.test)("one gateway session is confirmed once, however many events report it", async (t) => {
2210
+ const { adapter } = harness;
2211
+ const log = adapter.paymentEventLog;
2212
+ if (!log) {
2213
+ missing(t, "paymentEventLog");
2214
+ return;
2215
+ }
2216
+ const session = "cs_reported_twice";
2217
+ const claims = await adapter.transactionRunner.run(async (tx) => [
2218
+ await log.claim(
2219
+ eventAt("stripe-main", "evt_form_done", { sessionId: session }),
2220
+ tx
2221
+ ),
2222
+ // The same session, reported again under another identifier:
2223
+ // recording it would set the session's payment method up twice.
2224
+ await log.claim(
2225
+ eventAt("stripe-main", "evt_method_on", { sessionId: session }),
2226
+ tx
2227
+ ),
2228
+ // Another account's session of that name is another session.
2229
+ await log.claim(eventAt("stripe-old", "evt_elsewhere", { sessionId: session }), tx),
2230
+ // A kind that says nothing about the session being confirmed.
2231
+ await log.claim(
2232
+ eventAt("stripe-main", "evt_gave_up", {
2233
+ sessionId: session,
2234
+ kind: "payment-method-setup-failed"
2235
+ }),
2236
+ tx
2237
+ ),
2238
+ // Events about no session at all do not collide with each other.
2239
+ await log.claim(
2240
+ eventAt("stripe-main", "evt_other_1", { sessionId: null, kind: "unhandled" }),
2241
+ tx
2242
+ ),
2243
+ await log.claim(
2244
+ eventAt("stripe-main", "evt_other_2", { sessionId: null, kind: "unhandled" }),
2245
+ tx
2246
+ )
2247
+ ]);
2248
+ import_strict.default.deepEqual(claims, [true, false, true, true, true, true]);
2249
+ const later = await adapter.transactionRunner.run(
2250
+ (tx) => log.claim(eventAt("stripe-main", "evt_late", { sessionId: session }), tx)
2251
+ );
2252
+ import_strict.default.equal(later, false, "a session already confirmed was confirmed again");
2253
+ });
2254
+ (0, import_node_test.test)("a claim rolled back with its transaction is free for the retry", async (t) => {
2255
+ const { adapter } = harness;
2256
+ const log = adapter.paymentEventLog;
2257
+ if (!log) {
2258
+ missing(t, "paymentEventLog");
2259
+ return;
2260
+ }
2261
+ await import_strict.default.rejects(
2262
+ adapter.transactionRunner.run(async (tx) => {
2263
+ import_strict.default.equal(await log.claim(eventAt("stripe-main", "evt_retry"), tx), true);
2264
+ throw new Error("recording what the event changes failed");
2265
+ }),
2266
+ /recording what the event changes failed/
2267
+ );
2268
+ const retry = await adapter.transactionRunner.run(
2269
+ (tx) => log.claim(eventAt("stripe-main", "evt_retry"), tx)
2270
+ );
2271
+ import_strict.default.equal(retry, true, "the retry was discarded as a duplicate");
2272
+ });
2273
+ (0, import_node_test.test)("a delivery that meets a claim still open waits for it, and answers by its outcome", async (t) => {
2274
+ const { adapter } = harness;
2275
+ const log = adapter.paymentEventLog;
2276
+ if (!log) {
2277
+ missing(t, "paymentEventLog");
2278
+ return;
2279
+ }
2280
+ if (!adapter.capabilities.pessimisticLocking) {
2281
+ t.skip(
2282
+ "adapter declares no pessimistic locking: an open claim cannot be waited on"
2283
+ );
2284
+ return;
2285
+ }
2286
+ const [committed, afterCommit] = await Promise.all([
2287
+ adapter.transactionRunner.run(async (tx) => {
2288
+ const claimed = await log.claim(eventAt("stripe-main", "evt_race"), tx);
2289
+ await sleep(LOCK_HOLD_MS);
2290
+ return claimed;
2291
+ }),
2292
+ sleep(LOCK_HOLD_MS / 3).then(
2293
+ () => adapter.transactionRunner.run(
2294
+ (tx) => log.claim(eventAt("stripe-main", "evt_race"), tx)
2295
+ )
2296
+ )
2297
+ ]);
2298
+ import_strict.default.deepEqual([committed, afterCommit], [true, false]);
2299
+ const [rolledBack, afterRollback] = await Promise.allSettled([
2300
+ adapter.transactionRunner.run(async (tx) => {
2301
+ await log.claim(eventAt("stripe-main", "evt_race_back"), tx);
2302
+ await sleep(LOCK_HOLD_MS);
2303
+ throw new Error("the first delivery failed");
2304
+ }),
2305
+ sleep(LOCK_HOLD_MS / 3).then(
2306
+ () => adapter.transactionRunner.run(
2307
+ (tx) => log.claim(eventAt("stripe-main", "evt_race_back"), tx)
2308
+ )
2309
+ )
2310
+ ]);
2311
+ import_strict.default.equal(rolledBack.status, "rejected");
2312
+ import_strict.default.deepEqual(afterRollback, { status: "fulfilled", value: true });
2313
+ });
2314
+ (0, import_node_test.test)("an event that changed nothing gives its session back, and stays claimed itself", async (t) => {
2315
+ const { adapter } = harness;
2316
+ const log = adapter.paymentEventLog;
2317
+ if (!log) {
2318
+ missing(t, "paymentEventLog");
2319
+ return;
2320
+ }
2321
+ const session = "cs_nothing_to_do";
2322
+ const released = await adapter.transactionRunner.run(async (tx) => {
2323
+ await log.claim(eventAt("stripe-main", "evt_missed", { sessionId: session }), tx);
2324
+ await log.releaseSession("stripe-main", "evt_missed", tx);
2325
+ return [
2326
+ // The next event about that session is handled …
2327
+ await log.claim(
2328
+ eventAt("stripe-main", "evt_correct", { sessionId: session }),
2329
+ tx
2330
+ ),
2331
+ // … while the event that released it stays claimed.
2332
+ await log.claim(eventAt("stripe-main", "evt_missed", { sessionId: null }), tx)
2333
+ ];
2334
+ });
2335
+ import_strict.default.deepEqual(released, [true, false]);
2336
+ const again = await adapter.transactionRunner.run(
2337
+ (tx) => log.claim(eventAt("stripe-main", "evt_after_correct", { sessionId: session }), tx)
2338
+ );
2339
+ import_strict.default.equal(again, false, "the session was confirmed and is not free again");
2340
+ });
2341
+ (0, import_node_test.test)("two events confirming one session at once end with one claim", async (t) => {
2342
+ const { adapter } = harness;
2343
+ const log = adapter.paymentEventLog;
2344
+ if (!log) {
2345
+ missing(t, "paymentEventLog");
2346
+ return;
2347
+ }
2348
+ if (!adapter.capabilities.pessimisticLocking) {
2349
+ t.skip(
2350
+ "adapter declares no pessimistic locking: an open claim cannot be waited on"
2351
+ );
2352
+ return;
2353
+ }
2354
+ const session = "cs_at_once";
2355
+ const [first, second] = await Promise.all([
2356
+ adapter.transactionRunner.run(async (tx) => {
2357
+ const claimed = await log.claim(
2358
+ eventAt("stripe-main", "evt_at_once_a", { sessionId: session }),
2359
+ tx
2360
+ );
2361
+ await sleep(LOCK_HOLD_MS);
2362
+ return claimed;
2363
+ }),
2364
+ sleep(LOCK_HOLD_MS / 3).then(
2365
+ () => adapter.transactionRunner.run(
2366
+ (tx) => log.claim(
2367
+ eventAt("stripe-main", "evt_at_once_b", { sessionId: session }),
2368
+ tx
2369
+ )
2370
+ )
2371
+ )
2372
+ ]);
2373
+ import_strict.default.deepEqual([first, second], [true, false]);
2374
+ });
2375
+ (0, import_node_test.test)("a confirmed payment method becomes the subscriber's, with its references and masked details", async (t) => {
2376
+ const methods = harness.adapter.subscriberPaymentMethodRepository;
2377
+ const createSubscriber = harness.seed.createSubscriber;
2378
+ if (!methods || !createSubscriber) {
2379
+ missing(t, "subscriberPaymentMethods");
2380
+ return;
2381
+ }
2382
+ const { subscriberId } = await createSubscriber({ legalName: "Karte GmbH" });
2383
+ const debit = {
2384
+ ...paymentMethodFor(subscriberId, "pm_sepa", "2026-09-01T10:00:00.000Z"),
2385
+ type: "sepa_debit",
2386
+ brand: null,
2387
+ last4: "3000",
2388
+ expiryMonth: null,
2389
+ expiryYear: null,
2390
+ country: "DE",
2391
+ bankCode: "37040044",
2392
+ mandateReference: "MANDATE-1"
2393
+ };
2394
+ const result = await methods.recordConfirmed(debit);
2395
+ import_strict.default.equal(result.outcome, "activated");
2396
+ const { id, createdAt, ...stored } = result.method;
2397
+ import_strict.default.ok(id);
2398
+ import_strict.default.ok(createdAt instanceof Date);
2399
+ import_strict.default.deepEqual(stored, { ...debit, status: "ACTIVE", replacedAt: null });
2400
+ import_strict.default.deepEqual(await methods.findActive(subscriberId), result.method);
2401
+ import_strict.default.deepEqual(
2402
+ await methods.findByReference("stripe-main", "pm_sepa"),
2403
+ result.method
2404
+ );
2405
+ import_strict.default.equal(await methods.findByReference("stripe-old", "pm_sepa"), null);
2406
+ const other = await createSubscriber({ legalName: "Second Customer GmbH" });
2407
+ import_strict.default.equal(await methods.findActive(other.subscriberId), null);
2408
+ });
2409
+ (0, import_node_test.test)("a newer payment method takes over, and the one it replaced stays as history", async (t) => {
2410
+ const methods = harness.adapter.subscriberPaymentMethodRepository;
2411
+ const createSubscriber = harness.seed.createSubscriber;
2412
+ if (!methods || !createSubscriber) {
2413
+ missing(t, "subscriberPaymentMethods");
2414
+ return;
2415
+ }
2416
+ const { subscriberId } = await createSubscriber({ legalName: "Wechsel GmbH" });
2417
+ await methods.recordConfirmed(
2418
+ paymentMethodFor(subscriberId, "pm_first", "2026-09-01T10:00:00.000Z")
2419
+ );
2420
+ const second = await methods.recordConfirmed(
2421
+ paymentMethodFor(subscriberId, "pm_second", "2026-09-02T10:00:00.000Z")
2422
+ );
2423
+ import_strict.default.equal(second.outcome, "activated");
2424
+ import_strict.default.equal((await methods.findActive(subscriberId))?.paymentMethodRef, "pm_second");
2425
+ const first = await methods.findByReference("stripe-main", "pm_first");
2426
+ import_strict.default.equal(first?.status, "REPLACED");
2427
+ import_strict.default.equal(first?.replacedAt?.toISOString(), "2026-09-02T10:00:00.000Z");
2428
+ });
2429
+ (0, import_node_test.test)("a confirmation recorded again is recognised, and changes nothing", async (t) => {
2430
+ const methods = harness.adapter.subscriberPaymentMethodRepository;
2431
+ const createSubscriber = harness.seed.createSubscriber;
2432
+ if (!methods || !createSubscriber) {
2433
+ missing(t, "subscriberPaymentMethods");
2434
+ return;
2435
+ }
2436
+ const { subscriberId } = await createSubscriber({ legalName: "Doppelt GmbH" });
2437
+ const confirmation = paymentMethodFor(
2438
+ subscriberId,
2439
+ "pm_twice",
2440
+ "2026-09-01T10:00:00.000Z"
2441
+ );
2442
+ const first = await methods.recordConfirmed(confirmation);
2443
+ const again = await methods.recordConfirmed({
2444
+ ...confirmation,
2445
+ confirmedAt: /* @__PURE__ */ new Date("2026-09-03T10:00:00.000Z")
2446
+ });
2447
+ import_strict.default.equal(again.outcome, "already-recorded");
2448
+ import_strict.default.deepEqual(again.method, first.method);
2449
+ import_strict.default.deepEqual(await methods.findActive(subscriberId), first.method);
2450
+ });
2451
+ (0, import_node_test.test)("a confirmation older than the payment method in use is recorded as already replaced", async (t) => {
2452
+ const methods = harness.adapter.subscriberPaymentMethodRepository;
2453
+ const createSubscriber = harness.seed.createSubscriber;
2454
+ if (!methods || !createSubscriber) {
2455
+ missing(t, "subscriberPaymentMethods");
2456
+ return;
2457
+ }
2458
+ const { subscriberId } = await createSubscriber({ legalName: "Reihenfolge GmbH" });
2459
+ await methods.recordConfirmed(
2460
+ paymentMethodFor(subscriberId, "pm_later", "2026-09-02T10:00:00.000Z")
2461
+ );
2462
+ const earlier = await methods.recordConfirmed(
2463
+ paymentMethodFor(subscriberId, "pm_earlier", "2026-09-01T10:00:00.000Z")
2464
+ );
2465
+ import_strict.default.equal(earlier.outcome, "superseded");
2466
+ import_strict.default.equal(earlier.method.status, "REPLACED");
2467
+ import_strict.default.equal(earlier.method.replacedAt?.toISOString(), "2026-09-02T10:00:00.000Z");
2468
+ import_strict.default.equal((await methods.findActive(subscriberId))?.paymentMethodRef, "pm_later");
2469
+ });
2470
+ (0, import_node_test.test)("two confirmations for one subscriber at once leave one payment method in use", async (t) => {
2471
+ const methods = harness.adapter.subscriberPaymentMethodRepository;
2472
+ const createSubscriber = harness.seed.createSubscriber;
2473
+ if (!methods || !createSubscriber) {
2474
+ missing(t, "subscriberPaymentMethods");
2475
+ return;
2476
+ }
2477
+ const { subscriberId } = await createSubscriber({ legalName: "Gleichzeitig GmbH" });
2478
+ const results = await Promise.all([
2479
+ methods.recordConfirmed(
2480
+ paymentMethodFor(subscriberId, "pm_at_once_a", "2026-09-01T10:00:00.000Z")
2481
+ ),
2482
+ methods.recordConfirmed(
2483
+ paymentMethodFor(subscriberId, "pm_at_once_b", "2026-09-01T10:00:01.000Z")
2484
+ )
2485
+ ]);
2486
+ for (const result of results) {
2487
+ import_strict.default.ok(
2488
+ result.outcome === "activated" || result.outcome === "superseded",
2489
+ result.outcome
2490
+ );
2491
+ }
2492
+ const statuses = await Promise.all(
2493
+ ["pm_at_once_a", "pm_at_once_b"].map(
2494
+ async (ref) => (await methods.findByReference("stripe-main", ref))?.status
2495
+ )
2496
+ );
2497
+ import_strict.default.deepEqual(statuses, ["REPLACED", "ACTIVE"]);
2498
+ import_strict.default.equal(
2499
+ (await methods.findActive(subscriberId))?.paymentMethodRef,
2500
+ "pm_at_once_b"
2501
+ );
2502
+ });
2503
+ (0, import_node_test.test)("a payment method written on a transaction is undone with it", async (t) => {
2504
+ const { adapter } = harness;
2505
+ const methods = adapter.subscriberPaymentMethodRepository;
2506
+ const createSubscriber = harness.seed.createSubscriber;
2507
+ if (!methods || !createSubscriber) {
2508
+ missing(t, "subscriberPaymentMethods");
2509
+ return;
2510
+ }
2511
+ const { subscriberId } = await createSubscriber({ legalName: "Rolled Back GmbH" });
2512
+ await import_strict.default.rejects(
2513
+ adapter.transactionRunner.run(async (tx) => {
2514
+ await methods.recordConfirmed(
2515
+ paymentMethodFor(
2516
+ subscriberId,
2517
+ "pm_rolled_back",
2518
+ "2026-09-01T10:00:00.000Z"
2519
+ ),
2520
+ tx
2521
+ );
2522
+ throw new Error("the activation failed after all");
2523
+ }),
2524
+ /the activation failed after all/
2525
+ );
2526
+ import_strict.default.equal(await methods.findActive(subscriberId), null);
2527
+ import_strict.default.equal(await methods.findByReference("stripe-main", "pm_rolled_back"), null);
2528
+ });
2529
+ (0, import_node_test.test)("a payment method for a subscriber that does not exist is refused", async (t) => {
2530
+ const methods = harness.adapter.subscriberPaymentMethodRepository;
2531
+ if (!methods || !harness.seed.createSubscriber) {
2532
+ missing(t, "subscriberPaymentMethods");
2533
+ return;
2534
+ }
2535
+ await import_strict.default.rejects(
2536
+ methods.recordConfirmed(
2537
+ paymentMethodFor(
2538
+ "subscriber-nobody-created",
2539
+ "pm_nobody",
2540
+ "2026-09-01T10:00:00.000Z"
2541
+ )
2542
+ )
2543
+ );
2544
+ import_strict.default.equal(await methods.findByReference("stripe-main", "pm_nobody"), null);
2545
+ });
2546
+ (0, import_node_test.test)("the accounts in use are those holding a payment method in use, each once", async (t) => {
2547
+ const methods = harness.adapter.subscriberPaymentMethodRepository;
2548
+ const createSubscriber = harness.seed.createSubscriber;
2549
+ if (!methods || !createSubscriber) {
2550
+ missing(t, "subscriberPaymentMethods");
2551
+ return;
2552
+ }
2553
+ import_strict.default.deepEqual(await methods.accountsInUse(), []);
2554
+ const moved = await createSubscriber({ legalName: "Umgezogen GmbH" });
2555
+ const stayed = await createSubscriber({ legalName: "Geblieben GmbH" });
2556
+ await methods.recordConfirmed(
2557
+ paymentMethodFor(
2558
+ moved.subscriberId,
2559
+ "pm_old_account",
2560
+ "2026-09-01T10:00:00.000Z",
2561
+ "stripe-old"
2562
+ )
2563
+ );
2564
+ await methods.recordConfirmed(
2565
+ paymentMethodFor(
2566
+ moved.subscriberId,
2567
+ "pm_new_account",
2568
+ "2026-09-02T10:00:00.000Z",
2569
+ "stripe-main"
2570
+ )
2571
+ );
2572
+ await methods.recordConfirmed(
2573
+ paymentMethodFor(
2574
+ stayed.subscriberId,
2575
+ "pm_main",
2576
+ "2026-09-02T10:00:00.000Z",
2577
+ "stripe-main"
2578
+ )
2579
+ );
2580
+ import_strict.default.deepEqual(await methods.accountsInUse(), ["stripe-main"]);
2581
+ });
2582
+ (0, import_node_test.test)("a setup is completed once, and only by the account, session and subscriber it was started with", async (t) => {
2583
+ const methods = harness.adapter.subscriberPaymentMethodRepository;
2584
+ const createSubscriber = harness.seed.createSubscriber;
2585
+ if (!methods || !createSubscriber) {
2586
+ missing(t, "subscriberPaymentMethods");
2587
+ return;
2588
+ }
2589
+ const { subscriberId } = await createSubscriber({ legalName: "Setup GmbH" });
2590
+ const other = await createSubscriber({ legalName: "Other Tenant GmbH" });
2591
+ await methods.recordSetup({
2592
+ subscriberId,
2593
+ gatewayAccount: "stripe-main",
2594
+ sessionRef: "cs_setup",
2595
+ customerRef: "cus_setup",
2596
+ startedAt: /* @__PURE__ */ new Date("2026-09-15T10:00:00.000Z")
2597
+ });
2598
+ const at = /* @__PURE__ */ new Date("2026-09-15T10:05:00.000Z");
2599
+ const match = { gatewayAccount: "stripe-main", sessionRef: "cs_setup", subscriberId };
2600
+ import_strict.default.equal(
2601
+ await methods.completeSetup({ ...match, subscriberId: other.subscriberId }, at),
2602
+ false
2603
+ );
2604
+ import_strict.default.equal(
2605
+ await methods.completeSetup({ ...match, gatewayAccount: "stripe-old" }, at),
2606
+ false
2607
+ );
2608
+ import_strict.default.equal(
2609
+ await methods.completeSetup({ ...match, sessionRef: "cs_nobody_opened" }, at),
2610
+ false
2611
+ );
2612
+ import_strict.default.equal(await methods.completeSetup(match, at), true);
2613
+ import_strict.default.equal(
2614
+ await methods.completeSetup(match, at),
2615
+ false,
2616
+ "a setup was completed twice"
2617
+ );
2618
+ });
2619
+ (0, import_node_test.test)("a setup completed on a transaction that rolls back is open again", async (t) => {
2620
+ const { adapter } = harness;
2621
+ const methods = adapter.subscriberPaymentMethodRepository;
2622
+ const createSubscriber = harness.seed.createSubscriber;
2623
+ if (!methods || !createSubscriber) {
2624
+ missing(t, "subscriberPaymentMethods");
2625
+ return;
2626
+ }
2627
+ const { subscriberId } = await createSubscriber({ legalName: "Retry GmbH" });
2628
+ const match = { gatewayAccount: "stripe-main", sessionRef: "cs_retry", subscriberId };
2629
+ await methods.recordSetup({
2630
+ ...match,
2631
+ customerRef: "cus_retry",
2632
+ startedAt: /* @__PURE__ */ new Date("2026-09-15T10:00:00.000Z")
2633
+ });
2634
+ const at = /* @__PURE__ */ new Date("2026-09-15T10:05:00.000Z");
2635
+ await import_strict.default.rejects(
2636
+ adapter.transactionRunner.run(async (tx) => {
2637
+ import_strict.default.equal(await methods.completeSetup(match, at, tx), true);
2638
+ throw new Error("recording the payment method failed");
2639
+ }),
2640
+ /recording the payment method failed/
2641
+ );
2642
+ import_strict.default.equal(
2643
+ await methods.completeSetup(match, at),
2644
+ true,
2645
+ "the rollback kept the completion"
2646
+ );
2647
+ });
2648
+ (0, import_node_test.test)("one session is one setup, however often it is recorded", async (t) => {
2649
+ const methods = harness.adapter.subscriberPaymentMethodRepository;
2650
+ const createSubscriber = harness.seed.createSubscriber;
2651
+ if (!methods || !createSubscriber) {
2652
+ missing(t, "subscriberPaymentMethods");
2653
+ return;
2654
+ }
2655
+ const { subscriberId } = await createSubscriber({ legalName: "Once GmbH" });
2656
+ const setup = {
2657
+ subscriberId,
2658
+ gatewayAccount: "stripe-main",
2659
+ sessionRef: "cs_once",
2660
+ customerRef: "cus_once",
2661
+ startedAt: /* @__PURE__ */ new Date("2026-09-15T10:00:00.000Z")
2662
+ };
2663
+ await methods.recordSetup(setup);
2664
+ await import_strict.default.rejects(methods.recordSetup(setup));
2665
+ });
2147
2666
  (0, import_node_test.test)("an offer is consumed once, whoever asks first", async (t) => {
2148
2667
  const offers = harness.adapter.checkoutOfferRepository;
2149
2668
  if (!offers) {
package/dist/index.d.cts CHANGED
@@ -1,4 +1,4 @@
1
- import { PersistenceCapabilities, TransactionRunner, SubscriptionRepository, PlanVersionRepository, PromoCodeRepository, PromoCodeRedemptionRepository, MfaPort, AuditPort, AuditQueryPort, SubscriptionContractRepository, SubscriberRepository, CheckoutOfferRepository, TenantSubscriptionWritePort, BundleRepository, SubscriptionBundleRepository, PlanRepository, PromoSubscriptionLookup, AppliedSettingsPort } from '@saasicat/core';
1
+ import { PersistenceCapabilities, TransactionRunner, SubscriptionRepository, PlanVersionRepository, PromoCodeRepository, PromoCodeRedemptionRepository, MfaPort, AuditPort, AuditQueryPort, SubscriptionContractRepository, SubscriberRepository, PaymentEventLog, SubscriberPaymentMethodRepository, CheckoutOfferRepository, TenantSubscriptionWritePort, BundleRepository, SubscriptionBundleRepository, PlanRepository, PromoSubscriptionLookup, AppliedSettingsPort } from '@saasicat/core';
2
2
 
3
3
  /**
4
4
  * Port instances under test. Required members define the minimum an adapter
@@ -26,6 +26,19 @@ interface ContractAdapterInstances {
26
26
  * through `seed.createSubscriber` rather than through this port.
27
27
  */
28
28
  subscriberRepository?: SubscriberRepository;
29
+ /**
30
+ * Enables the gateway event scenarios: an event is claimed once per
31
+ * account, a duplicate leaves the caller's transaction usable, and a claim
32
+ * rolled back with its transaction is free for the gateway's retry.
33
+ */
34
+ paymentEventLog?: PaymentEventLog;
35
+ /**
36
+ * Enables the payment method scenarios: one payment method in use per
37
+ * subscriber however many confirmations arrive at once, the one it
38
+ * replaced kept as history, and a confirmation recorded twice recognised.
39
+ * Its subscribers come from `seed.createSubscriber`.
40
+ */
41
+ subscriberPaymentMethodRepository?: SubscriberPaymentMethodRepository;
29
42
  /**
30
43
  * Enables the checkout offer scenarios: an offer is consumed once, and a
31
44
  * consume on a transaction that rolls back leaves it open. Neither shipped
@@ -160,7 +173,7 @@ interface PersistenceContractHarness {
160
173
  * Each names the members its scenarios need; `contract.ts` holds the list with
161
174
  * what each one checks.
162
175
  */
163
- type ContractGap = 'atomicPlanBinding' | 'atomicOnboarding' | 'promoCodes' | 'promoCodeRedemptions' | 'promoSubscriptionLookup' | 'planRepository' | 'planLifecycle' | 'planRetirement' | 'planVersionReads' | 'planVersionRetirement' | 'bundleRepository' | 'bundleValidity' | 'bundleDraftDiscard' | 'bundleDraftPublish' | 'bundleRetirement' | 'bundleBookings' | 'halfCancelledBookingSeed' | 'countByPlanVersionId' | 'audit' | 'mfa' | 'subscriptionContracts' | 'subscribers' | 'checkoutOffers' | 'appliedSettings';
176
+ type ContractGap = 'atomicPlanBinding' | 'atomicOnboarding' | 'promoCodes' | 'promoCodeRedemptions' | 'promoSubscriptionLookup' | 'planRepository' | 'planLifecycle' | 'planRetirement' | 'planVersionReads' | 'planVersionRetirement' | 'bundleRepository' | 'bundleValidity' | 'bundleDraftDiscard' | 'bundleDraftPublish' | 'bundleRetirement' | 'bundleBookings' | 'halfCancelledBookingSeed' | 'countByPlanVersionId' | 'audit' | 'mfa' | 'subscriptionContracts' | 'subscribers' | 'paymentEventLog' | 'subscriberPaymentMethods' | 'checkoutOffers' | 'appliedSettings';
164
177
  interface PersistenceAdapterContractOptions {
165
178
  /** Display name in the test output, e.g. `'adapter-prisma @ postgres16'`. */
166
179
  name: string;
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { PersistenceCapabilities, TransactionRunner, SubscriptionRepository, PlanVersionRepository, PromoCodeRepository, PromoCodeRedemptionRepository, MfaPort, AuditPort, AuditQueryPort, SubscriptionContractRepository, SubscriberRepository, CheckoutOfferRepository, TenantSubscriptionWritePort, BundleRepository, SubscriptionBundleRepository, PlanRepository, PromoSubscriptionLookup, AppliedSettingsPort } from '@saasicat/core';
1
+ import { PersistenceCapabilities, TransactionRunner, SubscriptionRepository, PlanVersionRepository, PromoCodeRepository, PromoCodeRedemptionRepository, MfaPort, AuditPort, AuditQueryPort, SubscriptionContractRepository, SubscriberRepository, PaymentEventLog, SubscriberPaymentMethodRepository, CheckoutOfferRepository, TenantSubscriptionWritePort, BundleRepository, SubscriptionBundleRepository, PlanRepository, PromoSubscriptionLookup, AppliedSettingsPort } from '@saasicat/core';
2
2
 
3
3
  /**
4
4
  * Port instances under test. Required members define the minimum an adapter
@@ -26,6 +26,19 @@ interface ContractAdapterInstances {
26
26
  * through `seed.createSubscriber` rather than through this port.
27
27
  */
28
28
  subscriberRepository?: SubscriberRepository;
29
+ /**
30
+ * Enables the gateway event scenarios: an event is claimed once per
31
+ * account, a duplicate leaves the caller's transaction usable, and a claim
32
+ * rolled back with its transaction is free for the gateway's retry.
33
+ */
34
+ paymentEventLog?: PaymentEventLog;
35
+ /**
36
+ * Enables the payment method scenarios: one payment method in use per
37
+ * subscriber however many confirmations arrive at once, the one it
38
+ * replaced kept as history, and a confirmation recorded twice recognised.
39
+ * Its subscribers come from `seed.createSubscriber`.
40
+ */
41
+ subscriberPaymentMethodRepository?: SubscriberPaymentMethodRepository;
29
42
  /**
30
43
  * Enables the checkout offer scenarios: an offer is consumed once, and a
31
44
  * consume on a transaction that rolls back leaves it open. Neither shipped
@@ -160,7 +173,7 @@ interface PersistenceContractHarness {
160
173
  * Each names the members its scenarios need; `contract.ts` holds the list with
161
174
  * what each one checks.
162
175
  */
163
- type ContractGap = 'atomicPlanBinding' | 'atomicOnboarding' | 'promoCodes' | 'promoCodeRedemptions' | 'promoSubscriptionLookup' | 'planRepository' | 'planLifecycle' | 'planRetirement' | 'planVersionReads' | 'planVersionRetirement' | 'bundleRepository' | 'bundleValidity' | 'bundleDraftDiscard' | 'bundleDraftPublish' | 'bundleRetirement' | 'bundleBookings' | 'halfCancelledBookingSeed' | 'countByPlanVersionId' | 'audit' | 'mfa' | 'subscriptionContracts' | 'subscribers' | 'checkoutOffers' | 'appliedSettings';
176
+ type ContractGap = 'atomicPlanBinding' | 'atomicOnboarding' | 'promoCodes' | 'promoCodeRedemptions' | 'promoSubscriptionLookup' | 'planRepository' | 'planLifecycle' | 'planRetirement' | 'planVersionReads' | 'planVersionRetirement' | 'bundleRepository' | 'bundleValidity' | 'bundleDraftDiscard' | 'bundleDraftPublish' | 'bundleRetirement' | 'bundleBookings' | 'halfCancelledBookingSeed' | 'countByPlanVersionId' | 'audit' | 'mfa' | 'subscriptionContracts' | 'subscribers' | 'paymentEventLog' | 'subscriberPaymentMethods' | 'checkoutOffers' | 'appliedSettings';
164
177
  interface PersistenceAdapterContractOptions {
165
178
  /** Display name in the test output, e.g. `'adapter-prisma @ postgres16'`. */
166
179
  name: string;
package/dist/index.js CHANGED
@@ -43,6 +43,39 @@ function partiesWith(subscriberId, legalName) {
43
43
  }
44
44
  };
45
45
  }
46
+ function eventAt(gatewayAccount, eventId, about = {}) {
47
+ return {
48
+ gatewayAccount,
49
+ eventId,
50
+ provider: "stripe",
51
+ sessionId: `cs_${eventId}`,
52
+ kind: "payment-method-confirmed",
53
+ summary: { type: "card", last4: "4242" },
54
+ ...about
55
+ };
56
+ }
57
+ var CARD = {
58
+ type: "card",
59
+ brand: "visa",
60
+ last4: "4242",
61
+ expiryMonth: 12,
62
+ expiryYear: 2030,
63
+ country: null,
64
+ bankCode: null,
65
+ mandateReference: null,
66
+ customerRef: "cus_1",
67
+ paymentMethodRef: "pm_card"
68
+ };
69
+ function paymentMethodFor(subscriberId, paymentMethodRef, confirmedAt, gatewayAccount = "stripe-main") {
70
+ return {
71
+ ...CARD,
72
+ paymentMethodRef,
73
+ subscriberId,
74
+ gatewayAccount,
75
+ provider: "stripe",
76
+ confirmedAt: new Date(confirmedAt)
77
+ };
78
+ }
46
79
  function subscriberFor(tenantId, legalName) {
47
80
  return {
48
81
  tenantId,
@@ -200,6 +233,14 @@ var CONTRACT_GAPS = {
200
233
  reason: "adapter provides no SubscriberRepository",
201
234
  present: ({ adapter }) => Boolean(adapter.subscriberRepository)
202
235
  },
236
+ paymentEventLog: {
237
+ reason: "adapter provides no PaymentEventLog",
238
+ present: ({ adapter }) => Boolean(adapter.paymentEventLog)
239
+ },
240
+ subscriberPaymentMethods: {
241
+ reason: "adapter provides no SubscriberPaymentMethodRepository, or no subscriber seed for it",
242
+ present: ({ adapter, seed }) => Boolean(adapter.subscriberPaymentMethodRepository && seed.createSubscriber)
243
+ },
203
244
  checkoutOffers: {
204
245
  reason: "adapter provides no CheckoutOfferRepository",
205
246
  present: ({ adapter }) => Boolean(adapter.checkoutOfferRepository)
@@ -2108,6 +2149,484 @@ function persistenceAdapterContract(options) {
2108
2149
  "the contract followed a correction of the live record"
2109
2150
  );
2110
2151
  });
2152
+ test("a gateway event is claimed once per account, and a duplicate leaves the transaction usable", async (t) => {
2153
+ const { adapter } = harness;
2154
+ const log = adapter.paymentEventLog;
2155
+ if (!log) {
2156
+ missing(t, "paymentEventLog");
2157
+ return;
2158
+ }
2159
+ const claims = await adapter.transactionRunner.run(async (tx) => [
2160
+ await log.claim(eventAt("stripe-main", "evt_1"), tx),
2161
+ await log.claim(eventAt("stripe-main", "evt_1"), tx),
2162
+ // The same identifier from another account is another event.
2163
+ await log.claim(eventAt("stripe-old", "evt_1"), tx),
2164
+ // A duplicate that raised would have aborted the transaction here.
2165
+ await log.claim(eventAt("stripe-main", "evt_2"), tx)
2166
+ ]);
2167
+ assert.deepEqual(claims, [true, false, true, true]);
2168
+ const later = await adapter.transactionRunner.run(
2169
+ (tx) => log.claim(eventAt("stripe-main", "evt_1"), tx)
2170
+ );
2171
+ assert.equal(later, false, "a committed claim was claimed again");
2172
+ });
2173
+ test("one gateway session is confirmed once, however many events report it", async (t) => {
2174
+ const { adapter } = harness;
2175
+ const log = adapter.paymentEventLog;
2176
+ if (!log) {
2177
+ missing(t, "paymentEventLog");
2178
+ return;
2179
+ }
2180
+ const session = "cs_reported_twice";
2181
+ const claims = await adapter.transactionRunner.run(async (tx) => [
2182
+ await log.claim(
2183
+ eventAt("stripe-main", "evt_form_done", { sessionId: session }),
2184
+ tx
2185
+ ),
2186
+ // The same session, reported again under another identifier:
2187
+ // recording it would set the session's payment method up twice.
2188
+ await log.claim(
2189
+ eventAt("stripe-main", "evt_method_on", { sessionId: session }),
2190
+ tx
2191
+ ),
2192
+ // Another account's session of that name is another session.
2193
+ await log.claim(eventAt("stripe-old", "evt_elsewhere", { sessionId: session }), tx),
2194
+ // A kind that says nothing about the session being confirmed.
2195
+ await log.claim(
2196
+ eventAt("stripe-main", "evt_gave_up", {
2197
+ sessionId: session,
2198
+ kind: "payment-method-setup-failed"
2199
+ }),
2200
+ tx
2201
+ ),
2202
+ // Events about no session at all do not collide with each other.
2203
+ await log.claim(
2204
+ eventAt("stripe-main", "evt_other_1", { sessionId: null, kind: "unhandled" }),
2205
+ tx
2206
+ ),
2207
+ await log.claim(
2208
+ eventAt("stripe-main", "evt_other_2", { sessionId: null, kind: "unhandled" }),
2209
+ tx
2210
+ )
2211
+ ]);
2212
+ assert.deepEqual(claims, [true, false, true, true, true, true]);
2213
+ const later = await adapter.transactionRunner.run(
2214
+ (tx) => log.claim(eventAt("stripe-main", "evt_late", { sessionId: session }), tx)
2215
+ );
2216
+ assert.equal(later, false, "a session already confirmed was confirmed again");
2217
+ });
2218
+ test("a claim rolled back with its transaction is free for the retry", async (t) => {
2219
+ const { adapter } = harness;
2220
+ const log = adapter.paymentEventLog;
2221
+ if (!log) {
2222
+ missing(t, "paymentEventLog");
2223
+ return;
2224
+ }
2225
+ await assert.rejects(
2226
+ adapter.transactionRunner.run(async (tx) => {
2227
+ assert.equal(await log.claim(eventAt("stripe-main", "evt_retry"), tx), true);
2228
+ throw new Error("recording what the event changes failed");
2229
+ }),
2230
+ /recording what the event changes failed/
2231
+ );
2232
+ const retry = await adapter.transactionRunner.run(
2233
+ (tx) => log.claim(eventAt("stripe-main", "evt_retry"), tx)
2234
+ );
2235
+ assert.equal(retry, true, "the retry was discarded as a duplicate");
2236
+ });
2237
+ test("a delivery that meets a claim still open waits for it, and answers by its outcome", async (t) => {
2238
+ const { adapter } = harness;
2239
+ const log = adapter.paymentEventLog;
2240
+ if (!log) {
2241
+ missing(t, "paymentEventLog");
2242
+ return;
2243
+ }
2244
+ if (!adapter.capabilities.pessimisticLocking) {
2245
+ t.skip(
2246
+ "adapter declares no pessimistic locking: an open claim cannot be waited on"
2247
+ );
2248
+ return;
2249
+ }
2250
+ const [committed, afterCommit] = await Promise.all([
2251
+ adapter.transactionRunner.run(async (tx) => {
2252
+ const claimed = await log.claim(eventAt("stripe-main", "evt_race"), tx);
2253
+ await sleep(LOCK_HOLD_MS);
2254
+ return claimed;
2255
+ }),
2256
+ sleep(LOCK_HOLD_MS / 3).then(
2257
+ () => adapter.transactionRunner.run(
2258
+ (tx) => log.claim(eventAt("stripe-main", "evt_race"), tx)
2259
+ )
2260
+ )
2261
+ ]);
2262
+ assert.deepEqual([committed, afterCommit], [true, false]);
2263
+ const [rolledBack, afterRollback] = await Promise.allSettled([
2264
+ adapter.transactionRunner.run(async (tx) => {
2265
+ await log.claim(eventAt("stripe-main", "evt_race_back"), tx);
2266
+ await sleep(LOCK_HOLD_MS);
2267
+ throw new Error("the first delivery failed");
2268
+ }),
2269
+ sleep(LOCK_HOLD_MS / 3).then(
2270
+ () => adapter.transactionRunner.run(
2271
+ (tx) => log.claim(eventAt("stripe-main", "evt_race_back"), tx)
2272
+ )
2273
+ )
2274
+ ]);
2275
+ assert.equal(rolledBack.status, "rejected");
2276
+ assert.deepEqual(afterRollback, { status: "fulfilled", value: true });
2277
+ });
2278
+ test("an event that changed nothing gives its session back, and stays claimed itself", async (t) => {
2279
+ const { adapter } = harness;
2280
+ const log = adapter.paymentEventLog;
2281
+ if (!log) {
2282
+ missing(t, "paymentEventLog");
2283
+ return;
2284
+ }
2285
+ const session = "cs_nothing_to_do";
2286
+ const released = await adapter.transactionRunner.run(async (tx) => {
2287
+ await log.claim(eventAt("stripe-main", "evt_missed", { sessionId: session }), tx);
2288
+ await log.releaseSession("stripe-main", "evt_missed", tx);
2289
+ return [
2290
+ // The next event about that session is handled …
2291
+ await log.claim(
2292
+ eventAt("stripe-main", "evt_correct", { sessionId: session }),
2293
+ tx
2294
+ ),
2295
+ // … while the event that released it stays claimed.
2296
+ await log.claim(eventAt("stripe-main", "evt_missed", { sessionId: null }), tx)
2297
+ ];
2298
+ });
2299
+ assert.deepEqual(released, [true, false]);
2300
+ const again = await adapter.transactionRunner.run(
2301
+ (tx) => log.claim(eventAt("stripe-main", "evt_after_correct", { sessionId: session }), tx)
2302
+ );
2303
+ assert.equal(again, false, "the session was confirmed and is not free again");
2304
+ });
2305
+ test("two events confirming one session at once end with one claim", async (t) => {
2306
+ const { adapter } = harness;
2307
+ const log = adapter.paymentEventLog;
2308
+ if (!log) {
2309
+ missing(t, "paymentEventLog");
2310
+ return;
2311
+ }
2312
+ if (!adapter.capabilities.pessimisticLocking) {
2313
+ t.skip(
2314
+ "adapter declares no pessimistic locking: an open claim cannot be waited on"
2315
+ );
2316
+ return;
2317
+ }
2318
+ const session = "cs_at_once";
2319
+ const [first, second] = await Promise.all([
2320
+ adapter.transactionRunner.run(async (tx) => {
2321
+ const claimed = await log.claim(
2322
+ eventAt("stripe-main", "evt_at_once_a", { sessionId: session }),
2323
+ tx
2324
+ );
2325
+ await sleep(LOCK_HOLD_MS);
2326
+ return claimed;
2327
+ }),
2328
+ sleep(LOCK_HOLD_MS / 3).then(
2329
+ () => adapter.transactionRunner.run(
2330
+ (tx) => log.claim(
2331
+ eventAt("stripe-main", "evt_at_once_b", { sessionId: session }),
2332
+ tx
2333
+ )
2334
+ )
2335
+ )
2336
+ ]);
2337
+ assert.deepEqual([first, second], [true, false]);
2338
+ });
2339
+ test("a confirmed payment method becomes the subscriber's, with its references and masked details", async (t) => {
2340
+ const methods = harness.adapter.subscriberPaymentMethodRepository;
2341
+ const createSubscriber = harness.seed.createSubscriber;
2342
+ if (!methods || !createSubscriber) {
2343
+ missing(t, "subscriberPaymentMethods");
2344
+ return;
2345
+ }
2346
+ const { subscriberId } = await createSubscriber({ legalName: "Karte GmbH" });
2347
+ const debit = {
2348
+ ...paymentMethodFor(subscriberId, "pm_sepa", "2026-09-01T10:00:00.000Z"),
2349
+ type: "sepa_debit",
2350
+ brand: null,
2351
+ last4: "3000",
2352
+ expiryMonth: null,
2353
+ expiryYear: null,
2354
+ country: "DE",
2355
+ bankCode: "37040044",
2356
+ mandateReference: "MANDATE-1"
2357
+ };
2358
+ const result = await methods.recordConfirmed(debit);
2359
+ assert.equal(result.outcome, "activated");
2360
+ const { id, createdAt, ...stored } = result.method;
2361
+ assert.ok(id);
2362
+ assert.ok(createdAt instanceof Date);
2363
+ assert.deepEqual(stored, { ...debit, status: "ACTIVE", replacedAt: null });
2364
+ assert.deepEqual(await methods.findActive(subscriberId), result.method);
2365
+ assert.deepEqual(
2366
+ await methods.findByReference("stripe-main", "pm_sepa"),
2367
+ result.method
2368
+ );
2369
+ assert.equal(await methods.findByReference("stripe-old", "pm_sepa"), null);
2370
+ const other = await createSubscriber({ legalName: "Second Customer GmbH" });
2371
+ assert.equal(await methods.findActive(other.subscriberId), null);
2372
+ });
2373
+ test("a newer payment method takes over, and the one it replaced stays as history", async (t) => {
2374
+ const methods = harness.adapter.subscriberPaymentMethodRepository;
2375
+ const createSubscriber = harness.seed.createSubscriber;
2376
+ if (!methods || !createSubscriber) {
2377
+ missing(t, "subscriberPaymentMethods");
2378
+ return;
2379
+ }
2380
+ const { subscriberId } = await createSubscriber({ legalName: "Wechsel GmbH" });
2381
+ await methods.recordConfirmed(
2382
+ paymentMethodFor(subscriberId, "pm_first", "2026-09-01T10:00:00.000Z")
2383
+ );
2384
+ const second = await methods.recordConfirmed(
2385
+ paymentMethodFor(subscriberId, "pm_second", "2026-09-02T10:00:00.000Z")
2386
+ );
2387
+ assert.equal(second.outcome, "activated");
2388
+ assert.equal((await methods.findActive(subscriberId))?.paymentMethodRef, "pm_second");
2389
+ const first = await methods.findByReference("stripe-main", "pm_first");
2390
+ assert.equal(first?.status, "REPLACED");
2391
+ assert.equal(first?.replacedAt?.toISOString(), "2026-09-02T10:00:00.000Z");
2392
+ });
2393
+ test("a confirmation recorded again is recognised, and changes nothing", async (t) => {
2394
+ const methods = harness.adapter.subscriberPaymentMethodRepository;
2395
+ const createSubscriber = harness.seed.createSubscriber;
2396
+ if (!methods || !createSubscriber) {
2397
+ missing(t, "subscriberPaymentMethods");
2398
+ return;
2399
+ }
2400
+ const { subscriberId } = await createSubscriber({ legalName: "Doppelt GmbH" });
2401
+ const confirmation = paymentMethodFor(
2402
+ subscriberId,
2403
+ "pm_twice",
2404
+ "2026-09-01T10:00:00.000Z"
2405
+ );
2406
+ const first = await methods.recordConfirmed(confirmation);
2407
+ const again = await methods.recordConfirmed({
2408
+ ...confirmation,
2409
+ confirmedAt: /* @__PURE__ */ new Date("2026-09-03T10:00:00.000Z")
2410
+ });
2411
+ assert.equal(again.outcome, "already-recorded");
2412
+ assert.deepEqual(again.method, first.method);
2413
+ assert.deepEqual(await methods.findActive(subscriberId), first.method);
2414
+ });
2415
+ test("a confirmation older than the payment method in use is recorded as already replaced", async (t) => {
2416
+ const methods = harness.adapter.subscriberPaymentMethodRepository;
2417
+ const createSubscriber = harness.seed.createSubscriber;
2418
+ if (!methods || !createSubscriber) {
2419
+ missing(t, "subscriberPaymentMethods");
2420
+ return;
2421
+ }
2422
+ const { subscriberId } = await createSubscriber({ legalName: "Reihenfolge GmbH" });
2423
+ await methods.recordConfirmed(
2424
+ paymentMethodFor(subscriberId, "pm_later", "2026-09-02T10:00:00.000Z")
2425
+ );
2426
+ const earlier = await methods.recordConfirmed(
2427
+ paymentMethodFor(subscriberId, "pm_earlier", "2026-09-01T10:00:00.000Z")
2428
+ );
2429
+ assert.equal(earlier.outcome, "superseded");
2430
+ assert.equal(earlier.method.status, "REPLACED");
2431
+ assert.equal(earlier.method.replacedAt?.toISOString(), "2026-09-02T10:00:00.000Z");
2432
+ assert.equal((await methods.findActive(subscriberId))?.paymentMethodRef, "pm_later");
2433
+ });
2434
+ test("two confirmations for one subscriber at once leave one payment method in use", async (t) => {
2435
+ const methods = harness.adapter.subscriberPaymentMethodRepository;
2436
+ const createSubscriber = harness.seed.createSubscriber;
2437
+ if (!methods || !createSubscriber) {
2438
+ missing(t, "subscriberPaymentMethods");
2439
+ return;
2440
+ }
2441
+ const { subscriberId } = await createSubscriber({ legalName: "Gleichzeitig GmbH" });
2442
+ const results = await Promise.all([
2443
+ methods.recordConfirmed(
2444
+ paymentMethodFor(subscriberId, "pm_at_once_a", "2026-09-01T10:00:00.000Z")
2445
+ ),
2446
+ methods.recordConfirmed(
2447
+ paymentMethodFor(subscriberId, "pm_at_once_b", "2026-09-01T10:00:01.000Z")
2448
+ )
2449
+ ]);
2450
+ for (const result of results) {
2451
+ assert.ok(
2452
+ result.outcome === "activated" || result.outcome === "superseded",
2453
+ result.outcome
2454
+ );
2455
+ }
2456
+ const statuses = await Promise.all(
2457
+ ["pm_at_once_a", "pm_at_once_b"].map(
2458
+ async (ref) => (await methods.findByReference("stripe-main", ref))?.status
2459
+ )
2460
+ );
2461
+ assert.deepEqual(statuses, ["REPLACED", "ACTIVE"]);
2462
+ assert.equal(
2463
+ (await methods.findActive(subscriberId))?.paymentMethodRef,
2464
+ "pm_at_once_b"
2465
+ );
2466
+ });
2467
+ test("a payment method written on a transaction is undone with it", async (t) => {
2468
+ const { adapter } = harness;
2469
+ const methods = adapter.subscriberPaymentMethodRepository;
2470
+ const createSubscriber = harness.seed.createSubscriber;
2471
+ if (!methods || !createSubscriber) {
2472
+ missing(t, "subscriberPaymentMethods");
2473
+ return;
2474
+ }
2475
+ const { subscriberId } = await createSubscriber({ legalName: "Rolled Back GmbH" });
2476
+ await assert.rejects(
2477
+ adapter.transactionRunner.run(async (tx) => {
2478
+ await methods.recordConfirmed(
2479
+ paymentMethodFor(
2480
+ subscriberId,
2481
+ "pm_rolled_back",
2482
+ "2026-09-01T10:00:00.000Z"
2483
+ ),
2484
+ tx
2485
+ );
2486
+ throw new Error("the activation failed after all");
2487
+ }),
2488
+ /the activation failed after all/
2489
+ );
2490
+ assert.equal(await methods.findActive(subscriberId), null);
2491
+ assert.equal(await methods.findByReference("stripe-main", "pm_rolled_back"), null);
2492
+ });
2493
+ test("a payment method for a subscriber that does not exist is refused", async (t) => {
2494
+ const methods = harness.adapter.subscriberPaymentMethodRepository;
2495
+ if (!methods || !harness.seed.createSubscriber) {
2496
+ missing(t, "subscriberPaymentMethods");
2497
+ return;
2498
+ }
2499
+ await assert.rejects(
2500
+ methods.recordConfirmed(
2501
+ paymentMethodFor(
2502
+ "subscriber-nobody-created",
2503
+ "pm_nobody",
2504
+ "2026-09-01T10:00:00.000Z"
2505
+ )
2506
+ )
2507
+ );
2508
+ assert.equal(await methods.findByReference("stripe-main", "pm_nobody"), null);
2509
+ });
2510
+ test("the accounts in use are those holding a payment method in use, each once", async (t) => {
2511
+ const methods = harness.adapter.subscriberPaymentMethodRepository;
2512
+ const createSubscriber = harness.seed.createSubscriber;
2513
+ if (!methods || !createSubscriber) {
2514
+ missing(t, "subscriberPaymentMethods");
2515
+ return;
2516
+ }
2517
+ assert.deepEqual(await methods.accountsInUse(), []);
2518
+ const moved = await createSubscriber({ legalName: "Umgezogen GmbH" });
2519
+ const stayed = await createSubscriber({ legalName: "Geblieben GmbH" });
2520
+ await methods.recordConfirmed(
2521
+ paymentMethodFor(
2522
+ moved.subscriberId,
2523
+ "pm_old_account",
2524
+ "2026-09-01T10:00:00.000Z",
2525
+ "stripe-old"
2526
+ )
2527
+ );
2528
+ await methods.recordConfirmed(
2529
+ paymentMethodFor(
2530
+ moved.subscriberId,
2531
+ "pm_new_account",
2532
+ "2026-09-02T10:00:00.000Z",
2533
+ "stripe-main"
2534
+ )
2535
+ );
2536
+ await methods.recordConfirmed(
2537
+ paymentMethodFor(
2538
+ stayed.subscriberId,
2539
+ "pm_main",
2540
+ "2026-09-02T10:00:00.000Z",
2541
+ "stripe-main"
2542
+ )
2543
+ );
2544
+ assert.deepEqual(await methods.accountsInUse(), ["stripe-main"]);
2545
+ });
2546
+ test("a setup is completed once, and only by the account, session and subscriber it was started with", async (t) => {
2547
+ const methods = harness.adapter.subscriberPaymentMethodRepository;
2548
+ const createSubscriber = harness.seed.createSubscriber;
2549
+ if (!methods || !createSubscriber) {
2550
+ missing(t, "subscriberPaymentMethods");
2551
+ return;
2552
+ }
2553
+ const { subscriberId } = await createSubscriber({ legalName: "Setup GmbH" });
2554
+ const other = await createSubscriber({ legalName: "Other Tenant GmbH" });
2555
+ await methods.recordSetup({
2556
+ subscriberId,
2557
+ gatewayAccount: "stripe-main",
2558
+ sessionRef: "cs_setup",
2559
+ customerRef: "cus_setup",
2560
+ startedAt: /* @__PURE__ */ new Date("2026-09-15T10:00:00.000Z")
2561
+ });
2562
+ const at = /* @__PURE__ */ new Date("2026-09-15T10:05:00.000Z");
2563
+ const match = { gatewayAccount: "stripe-main", sessionRef: "cs_setup", subscriberId };
2564
+ assert.equal(
2565
+ await methods.completeSetup({ ...match, subscriberId: other.subscriberId }, at),
2566
+ false
2567
+ );
2568
+ assert.equal(
2569
+ await methods.completeSetup({ ...match, gatewayAccount: "stripe-old" }, at),
2570
+ false
2571
+ );
2572
+ assert.equal(
2573
+ await methods.completeSetup({ ...match, sessionRef: "cs_nobody_opened" }, at),
2574
+ false
2575
+ );
2576
+ assert.equal(await methods.completeSetup(match, at), true);
2577
+ assert.equal(
2578
+ await methods.completeSetup(match, at),
2579
+ false,
2580
+ "a setup was completed twice"
2581
+ );
2582
+ });
2583
+ test("a setup completed on a transaction that rolls back is open again", async (t) => {
2584
+ const { adapter } = harness;
2585
+ const methods = adapter.subscriberPaymentMethodRepository;
2586
+ const createSubscriber = harness.seed.createSubscriber;
2587
+ if (!methods || !createSubscriber) {
2588
+ missing(t, "subscriberPaymentMethods");
2589
+ return;
2590
+ }
2591
+ const { subscriberId } = await createSubscriber({ legalName: "Retry GmbH" });
2592
+ const match = { gatewayAccount: "stripe-main", sessionRef: "cs_retry", subscriberId };
2593
+ await methods.recordSetup({
2594
+ ...match,
2595
+ customerRef: "cus_retry",
2596
+ startedAt: /* @__PURE__ */ new Date("2026-09-15T10:00:00.000Z")
2597
+ });
2598
+ const at = /* @__PURE__ */ new Date("2026-09-15T10:05:00.000Z");
2599
+ await assert.rejects(
2600
+ adapter.transactionRunner.run(async (tx) => {
2601
+ assert.equal(await methods.completeSetup(match, at, tx), true);
2602
+ throw new Error("recording the payment method failed");
2603
+ }),
2604
+ /recording the payment method failed/
2605
+ );
2606
+ assert.equal(
2607
+ await methods.completeSetup(match, at),
2608
+ true,
2609
+ "the rollback kept the completion"
2610
+ );
2611
+ });
2612
+ test("one session is one setup, however often it is recorded", async (t) => {
2613
+ const methods = harness.adapter.subscriberPaymentMethodRepository;
2614
+ const createSubscriber = harness.seed.createSubscriber;
2615
+ if (!methods || !createSubscriber) {
2616
+ missing(t, "subscriberPaymentMethods");
2617
+ return;
2618
+ }
2619
+ const { subscriberId } = await createSubscriber({ legalName: "Once GmbH" });
2620
+ const setup = {
2621
+ subscriberId,
2622
+ gatewayAccount: "stripe-main",
2623
+ sessionRef: "cs_once",
2624
+ customerRef: "cus_once",
2625
+ startedAt: /* @__PURE__ */ new Date("2026-09-15T10:00:00.000Z")
2626
+ };
2627
+ await methods.recordSetup(setup);
2628
+ await assert.rejects(methods.recordSetup(setup));
2629
+ });
2111
2630
  test("an offer is consumed once, whoever asks first", async (t) => {
2112
2631
  const offers = harness.adapter.checkoutOfferRepository;
2113
2632
  if (!offers) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saasicat/persistence-testing",
3
- "version": "1.0.0-rc.16",
3
+ "version": "1.0.0-rc.18",
4
4
  "description": "Contract test kit for SaaSiCat persistence adapters: one node:test suite that every adapter (Prisma, Drizzle, ...) must pass against a real database — locks, transaction rollback, atomic promo claims, tenant isolation, audit/MFA roundtrips.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",
@@ -23,7 +23,7 @@
23
23
  "dist"
24
24
  ],
25
25
  "dependencies": {
26
- "@saasicat/core": "^1.0.0-rc.16"
26
+ "@saasicat/core": "^1.0.0-rc.18"
27
27
  },
28
28
  "devDependencies": {
29
29
  "@types/node": "^25.6.0",