@saasicat/persistence-testing 1.0.0-rc.2 → 1.0.0-rc.20
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 +72 -11
- package/dist/.build-stamp +1 -1
- package/dist/index.cjs +2335 -36
- package/dist/index.d.cts +106 -7
- package/dist/index.d.ts +106 -7
- package/dist/index.js +2335 -36
- package/package.json +2 -2
package/dist/index.cjs
CHANGED
|
@@ -38,12 +38,276 @@ module.exports = __toCommonJS(index_exports);
|
|
|
38
38
|
var import_strict = __toESM(require("node:assert/strict"), 1);
|
|
39
39
|
var import_node_test = require("node:test");
|
|
40
40
|
var LOCK_HOLD_MS = 150;
|
|
41
|
+
var OFFER = {
|
|
42
|
+
planKey: "STANDARD",
|
|
43
|
+
planVersionId: null,
|
|
44
|
+
billingCycle: "monthly",
|
|
45
|
+
priceBreakdown: {
|
|
46
|
+
currency: "EUR",
|
|
47
|
+
billingCycle: "monthly",
|
|
48
|
+
planNet: 49,
|
|
49
|
+
bundlesNet: 0,
|
|
50
|
+
regularNet: 49,
|
|
51
|
+
effectiveNet: 49,
|
|
52
|
+
vatRate: 19,
|
|
53
|
+
effectiveGross: 58.31
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
function partiesWith(subscriberId, legalName) {
|
|
57
|
+
return {
|
|
58
|
+
subscriberId,
|
|
59
|
+
subscriber: {
|
|
60
|
+
customerNumber: "K-10001",
|
|
61
|
+
legalName,
|
|
62
|
+
vatId: "DE123456789",
|
|
63
|
+
taxNumber: null,
|
|
64
|
+
addressLine1: "Hauptstra\xDFe 1",
|
|
65
|
+
addressLine2: null,
|
|
66
|
+
postalCode: "10115",
|
|
67
|
+
city: "Berlin",
|
|
68
|
+
country: "DE"
|
|
69
|
+
},
|
|
70
|
+
issuer: {
|
|
71
|
+
legalName: "Example Software GmbH",
|
|
72
|
+
vatId: "DE987654321",
|
|
73
|
+
taxNumber: "12/345/67890",
|
|
74
|
+
addressLine1: "Werkstra\xDFe 5",
|
|
75
|
+
addressLine2: null,
|
|
76
|
+
postalCode: "80331",
|
|
77
|
+
city: "M\xFCnchen",
|
|
78
|
+
country: "DE"
|
|
79
|
+
}
|
|
80
|
+
};
|
|
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
|
+
}
|
|
115
|
+
function subscriberFor(tenantId, legalName) {
|
|
116
|
+
return {
|
|
117
|
+
tenantId,
|
|
118
|
+
legalName,
|
|
119
|
+
vatId: null,
|
|
120
|
+
taxNumber: null,
|
|
121
|
+
addressLine1: null,
|
|
122
|
+
addressLine2: null,
|
|
123
|
+
postalCode: null,
|
|
124
|
+
city: null,
|
|
125
|
+
country: null,
|
|
126
|
+
invoiceEmail: null,
|
|
127
|
+
customerNumberPrefix: ""
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
function contractFromOffer(offerId, parties) {
|
|
131
|
+
return {
|
|
132
|
+
tenantId: `tenant-${offerId}`,
|
|
133
|
+
parties,
|
|
134
|
+
effectiveFrom: /* @__PURE__ */ new Date("2026-01-01T00:00:00.000Z"),
|
|
135
|
+
originalOfferId: offerId,
|
|
136
|
+
priceSnapshot: {
|
|
137
|
+
currency: "EUR",
|
|
138
|
+
billingCycle: "monthly",
|
|
139
|
+
subtotalNet: 49,
|
|
140
|
+
discountNet: 0,
|
|
141
|
+
totalNet: 49,
|
|
142
|
+
vatRate: 19,
|
|
143
|
+
totalGross: 58.31
|
|
144
|
+
},
|
|
145
|
+
lineItems: [
|
|
146
|
+
{
|
|
147
|
+
kind: "plan",
|
|
148
|
+
sourceKey: "STANDARD",
|
|
149
|
+
sourceVersionId: null,
|
|
150
|
+
titleSnapshot: "Standard",
|
|
151
|
+
descriptionSnapshot: null,
|
|
152
|
+
quantity: 1,
|
|
153
|
+
unit: null,
|
|
154
|
+
priceNet: 49,
|
|
155
|
+
priceGross: 58.31,
|
|
156
|
+
billingCycle: "monthly",
|
|
157
|
+
currency: "EUR",
|
|
158
|
+
taxRate: 19,
|
|
159
|
+
taxAmount: 9.31,
|
|
160
|
+
minimumTermUntil: null,
|
|
161
|
+
featuresSnapshot: [],
|
|
162
|
+
quotaEffectsSnapshot: {},
|
|
163
|
+
metadata: null
|
|
164
|
+
}
|
|
165
|
+
]
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
var CONTRACT_GAPS = {
|
|
169
|
+
atomicPlanBinding: {
|
|
170
|
+
reason: "adapter does not expose atomic plan-binding writes",
|
|
171
|
+
present: ({ adapter }) => Boolean(adapter.tenantSubscriptionWrite)
|
|
172
|
+
},
|
|
173
|
+
atomicOnboarding: {
|
|
174
|
+
reason: "adapter does not expose atomic onboarding writes",
|
|
175
|
+
present: ({ adapter }) => Boolean(adapter.tenantSubscriptionWrite?.applyOnboardingSelection)
|
|
176
|
+
},
|
|
177
|
+
promoCodes: {
|
|
178
|
+
reason: "adapter provides no PromoCodeRepository",
|
|
179
|
+
present: ({ adapter }) => Boolean(adapter.promoCodeRepository)
|
|
180
|
+
},
|
|
181
|
+
promoCodeRedemptions: {
|
|
182
|
+
reason: "adapter provides no PromoCodeRedemptionRepository",
|
|
183
|
+
present: ({ adapter }) => Boolean(adapter.promoCodeRedemptionRepository)
|
|
184
|
+
},
|
|
185
|
+
promoSubscriptionLookup: {
|
|
186
|
+
reason: "adapter provides no PromoSubscriptionLookup",
|
|
187
|
+
present: ({ adapter }) => Boolean(adapter.promoSubscriptionLookup)
|
|
188
|
+
},
|
|
189
|
+
planRepository: {
|
|
190
|
+
reason: "adapter provides no PlanRepository",
|
|
191
|
+
present: ({ adapter }) => Boolean(adapter.planRepository)
|
|
192
|
+
},
|
|
193
|
+
planLifecycle: {
|
|
194
|
+
reason: "adapter provides no time-aware PlanRepository lifecycle",
|
|
195
|
+
present: ({ adapter }) => {
|
|
196
|
+
const repository = adapter.planRepository;
|
|
197
|
+
return Boolean(
|
|
198
|
+
repository?.createPlanVersionDraft && repository.publishPlanVersionDraft && repository.findVersionById && repository.findActivePlanVersion
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
},
|
|
202
|
+
planRetirement: {
|
|
203
|
+
reason: "adapter provides no PlanRepository that retires and finds plans by key",
|
|
204
|
+
present: ({ adapter }) => Boolean(adapter.planRepository?.softDelete && adapter.planRepository.findByKey)
|
|
205
|
+
},
|
|
206
|
+
planVersionReads: {
|
|
207
|
+
reason: "adapter provides no PlanRepository that reads versions by plan key",
|
|
208
|
+
present: ({ adapter }) => {
|
|
209
|
+
const repository = adapter.planRepository;
|
|
210
|
+
return Boolean(
|
|
211
|
+
repository?.listVersions && repository.findCurrentDraft && repository.findLatestLivePlanVersion
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
},
|
|
215
|
+
planVersionRetirement: {
|
|
216
|
+
reason: "adapter provides no PlanRepository that reads versions and retires plans",
|
|
217
|
+
present: ({ adapter }) => {
|
|
218
|
+
const repository = adapter.planRepository;
|
|
219
|
+
return Boolean(
|
|
220
|
+
repository?.createPlanVersionDraft && repository.publishPlanVersionDraft && repository.listVersions && repository.findCurrentDraft && repository.findLatestLivePlanVersion && repository.softDelete
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
},
|
|
224
|
+
bundleRepository: {
|
|
225
|
+
reason: "adapter provides no BundleRepository",
|
|
226
|
+
present: ({ adapter }) => Boolean(adapter.bundleRepository)
|
|
227
|
+
},
|
|
228
|
+
bundleValidity: {
|
|
229
|
+
reason: "adapter provides no time-aware BundleRepository",
|
|
230
|
+
present: ({ adapter }) => Boolean(adapter.bundleRepository?.findActiveBundleVersion)
|
|
231
|
+
},
|
|
232
|
+
bundleDraftDiscard: {
|
|
233
|
+
reason: "adapter provides no BundleRepository that discards drafts",
|
|
234
|
+
present: ({ adapter }) => Boolean(adapter.bundleRepository?.deleteDraft)
|
|
235
|
+
},
|
|
236
|
+
bundleDraftPublish: {
|
|
237
|
+
reason: "adapter provides no BundleRepository that publishes drafts",
|
|
238
|
+
present: ({ adapter }) => Boolean(adapter.bundleRepository?.publishDraft)
|
|
239
|
+
},
|
|
240
|
+
bundleRetirement: {
|
|
241
|
+
reason: "adapter provides no BundleRepository that retires and finds bundles by key",
|
|
242
|
+
present: ({ adapter }) => Boolean(adapter.bundleRepository?.softDelete && adapter.bundleRepository.findByKey)
|
|
243
|
+
},
|
|
244
|
+
bundleBookings: {
|
|
245
|
+
reason: "adapter provides no SubscriptionBundleRepository or bundle catalog",
|
|
246
|
+
present: ({ adapter, seed }) => Boolean(adapter.subscriptionBundleRepository && seed.createBundleVersion)
|
|
247
|
+
},
|
|
248
|
+
halfCancelledBookingSeed: {
|
|
249
|
+
reason: "adapter harness cannot write the half-cancelled shape",
|
|
250
|
+
present: ({ seed }) => Boolean(seed.clearBookingRequestDate)
|
|
251
|
+
},
|
|
252
|
+
countByPlanVersionId: {
|
|
253
|
+
reason: "adapter does not implement countByPlanVersionId (fail-closed fallback)",
|
|
254
|
+
present: ({ adapter }) => Boolean(adapter.subscriptionRepository.countByPlanVersionId)
|
|
255
|
+
},
|
|
256
|
+
audit: {
|
|
257
|
+
reason: "adapter provides no AuditPort/AuditQueryPort pair",
|
|
258
|
+
present: ({ adapter }) => Boolean(adapter.audit && adapter.auditQuery)
|
|
259
|
+
},
|
|
260
|
+
mfa: {
|
|
261
|
+
reason: "adapter provides no MfaPort",
|
|
262
|
+
present: ({ adapter }) => Boolean(adapter.mfa)
|
|
263
|
+
},
|
|
264
|
+
subscriptionContracts: {
|
|
265
|
+
reason: "adapter provides no SubscriptionContractRepository, or no subscriber seed for it",
|
|
266
|
+
present: ({ adapter, seed }) => Boolean(adapter.subscriptionContractRepository && seed.createSubscriber)
|
|
267
|
+
},
|
|
268
|
+
subscribers: {
|
|
269
|
+
reason: "adapter provides no SubscriberRepository",
|
|
270
|
+
present: ({ adapter }) => Boolean(adapter.subscriberRepository)
|
|
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
|
+
},
|
|
280
|
+
checkoutOffers: {
|
|
281
|
+
reason: "adapter provides no CheckoutOfferRepository",
|
|
282
|
+
present: ({ adapter }) => Boolean(adapter.checkoutOfferRepository)
|
|
283
|
+
},
|
|
284
|
+
appliedSettings: {
|
|
285
|
+
reason: "adapter provides no AppliedSettingsPort",
|
|
286
|
+
present: ({ adapter }) => Boolean(adapter.appliedSettings)
|
|
287
|
+
}
|
|
288
|
+
};
|
|
41
289
|
function sleep(ms) {
|
|
42
290
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
43
291
|
}
|
|
44
292
|
function persistenceAdapterContract(options) {
|
|
293
|
+
const declaredGaps = new Set(options.gaps ?? []);
|
|
294
|
+
let harness;
|
|
295
|
+
function missing(t, gap) {
|
|
296
|
+
const { reason, present } = CONTRACT_GAPS[gap];
|
|
297
|
+
if (present(harness)) {
|
|
298
|
+
import_strict.default.fail(
|
|
299
|
+
`'${gap}' counts as provided, yet this scenario found a member it needs missing or not callable. Check that the harness wires the port itself; if it does, the contract's check for '${gap}' and this scenario disagree, which is a defect in @saasicat/persistence-testing.`
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
if (declaredGaps.has(gap)) {
|
|
303
|
+
t.skip(reason);
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
import_strict.default.fail(
|
|
307
|
+
`${reason}. Wire it into the harness, or declare \`gaps: ['${gap}']\` in persistenceAdapterContract if the adapter deliberately does not provide it.`
|
|
308
|
+
);
|
|
309
|
+
}
|
|
45
310
|
(0, import_node_test.describe)(`persistence adapter contract: ${options.name}`, () => {
|
|
46
|
-
let harness;
|
|
47
311
|
(0, import_node_test.before)(async () => {
|
|
48
312
|
harness = await options.create();
|
|
49
313
|
});
|
|
@@ -53,6 +317,20 @@ function persistenceAdapterContract(options) {
|
|
|
53
317
|
(0, import_node_test.beforeEach)(async () => {
|
|
54
318
|
await harness.reset();
|
|
55
319
|
});
|
|
320
|
+
(0, import_node_test.test)("the declared gaps are exactly the parts the harness does not provide", () => {
|
|
321
|
+
const gaps = Object.keys(CONTRACT_GAPS);
|
|
322
|
+
const absent = gaps.filter((gap) => !CONTRACT_GAPS[gap].present(harness));
|
|
323
|
+
const known = (gap) => Object.prototype.hasOwnProperty.call(CONTRACT_GAPS, gap);
|
|
324
|
+
const unknown = [...declaredGaps].filter((gap) => !known(gap));
|
|
325
|
+
const stale = [...declaredGaps].filter((gap) => known(gap) && !absent.includes(gap));
|
|
326
|
+
const undeclared = absent.filter((gap) => !declaredGaps.has(gap));
|
|
327
|
+
const problems = [
|
|
328
|
+
unknown.length > 0 && `declared as gaps but not parts of the contract: ${unknown.join(", ")} (ContractGap lists the names)`,
|
|
329
|
+
stale.length > 0 && `declared as gaps but wired into the harness: ${stale.join(", ")}`,
|
|
330
|
+
undeclared.length > 0 && `not wired into the harness and not declared as gaps: ${undeclared.join(", ")}`
|
|
331
|
+
].filter(Boolean);
|
|
332
|
+
import_strict.default.deepEqual(problems, [], problems.join("; "));
|
|
333
|
+
});
|
|
56
334
|
(0, import_node_test.test)("findByTenantId returns the tenant subscription with plan-version limits", async () => {
|
|
57
335
|
const { seed, adapter } = harness;
|
|
58
336
|
const { planVersionId } = await seed.createPlanVersion({
|
|
@@ -127,7 +405,7 @@ function persistenceAdapterContract(options) {
|
|
|
127
405
|
(0, import_node_test.test)("immediate plan change binds plan and active PlanVersion consistently", async (t) => {
|
|
128
406
|
const { seed, adapter } = harness;
|
|
129
407
|
if (!adapter.tenantSubscriptionWrite) {
|
|
130
|
-
t
|
|
408
|
+
missing(t, "atomicPlanBinding");
|
|
131
409
|
return;
|
|
132
410
|
}
|
|
133
411
|
const oldVersion = await seed.createPlanVersion({
|
|
@@ -149,13 +427,19 @@ function persistenceAdapterContract(options) {
|
|
|
149
427
|
plan: "STARTER",
|
|
150
428
|
planVersionId: oldVersion.planVersionId
|
|
151
429
|
});
|
|
152
|
-
await adapter.tenantSubscriptionWrite.changePlanImmediate(
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
430
|
+
const change = await adapter.tenantSubscriptionWrite.changePlanImmediate(
|
|
431
|
+
"tenant-plan-change",
|
|
432
|
+
{
|
|
433
|
+
planId: "PRO",
|
|
434
|
+
cycle: "YEARLY",
|
|
435
|
+
periodStart: null,
|
|
436
|
+
periodEnd: null,
|
|
437
|
+
nextStatus: null,
|
|
438
|
+
// The row has no cancellation, so this claims it.
|
|
439
|
+
expectedCanceledAt: null
|
|
440
|
+
}
|
|
441
|
+
);
|
|
442
|
+
import_strict.default.equal(change.claimed, true, "the plan write did not claim the row");
|
|
159
443
|
const changed = await adapter.subscriptionRepository.findByTenantId("tenant-plan-change");
|
|
160
444
|
import_strict.default.ok(changed, "changed subscription expected");
|
|
161
445
|
import_strict.default.equal(changed.plan, "PRO");
|
|
@@ -166,12 +450,12 @@ function persistenceAdapterContract(options) {
|
|
|
166
450
|
const { seed, adapter } = harness;
|
|
167
451
|
const writer = adapter.tenantSubscriptionWrite;
|
|
168
452
|
if (!writer?.applyOnboardingSelection) {
|
|
169
|
-
t
|
|
453
|
+
missing(t, "atomicOnboarding");
|
|
170
454
|
return;
|
|
171
455
|
}
|
|
172
456
|
const redemptions = adapter.promoCodeRedemptionRepository;
|
|
173
457
|
if (!redemptions) {
|
|
174
|
-
t
|
|
458
|
+
missing(t, "promoCodeRedemptions");
|
|
175
459
|
return;
|
|
176
460
|
}
|
|
177
461
|
const oldVersion = await seed.createPlanVersion({
|
|
@@ -206,7 +490,8 @@ function persistenceAdapterContract(options) {
|
|
|
206
490
|
cycle: "MONTHLY",
|
|
207
491
|
periodStart: null,
|
|
208
492
|
periodEnd: null,
|
|
209
|
-
nextStatus: null
|
|
493
|
+
nextStatus: null,
|
|
494
|
+
expectedCanceledAt: null
|
|
210
495
|
},
|
|
211
496
|
async (tx, callbackSubscriptionId) => {
|
|
212
497
|
import_strict.default.equal(callbackSubscriptionId, subscriptionId);
|
|
@@ -249,15 +534,14 @@ function persistenceAdapterContract(options) {
|
|
|
249
534
|
(0, import_node_test.test)("plan lifecycle keeps semantic identity and auto-succeeds validity windows", async (t) => {
|
|
250
535
|
const repository = harness.adapter.planRepository;
|
|
251
536
|
if (!repository?.createPlanVersionDraft || !repository.publishPlanVersionDraft || !repository.findVersionById || !repository.findActivePlanVersion) {
|
|
252
|
-
t
|
|
537
|
+
missing(t, "planLifecycle");
|
|
253
538
|
return;
|
|
254
539
|
}
|
|
255
540
|
const plan = await repository.create({
|
|
256
|
-
projectKey: options.projectKey,
|
|
257
541
|
planKey: "STANDARD",
|
|
258
542
|
label: "Standard"
|
|
259
543
|
});
|
|
260
|
-
import_strict.default.equal(plan.
|
|
544
|
+
import_strict.default.equal(plan.planKey, "STANDARD");
|
|
261
545
|
const firstDraft = await repository.createPlanVersionDraft({
|
|
262
546
|
planId: "STANDARD",
|
|
263
547
|
features: ["CORE"],
|
|
@@ -314,15 +598,14 @@ function persistenceAdapterContract(options) {
|
|
|
314
598
|
(0, import_node_test.test)("bundle lifecycle roundtrips validity and auto-succeeds atomically", async (t) => {
|
|
315
599
|
const repository = harness.adapter.bundleRepository;
|
|
316
600
|
if (!repository?.findActiveBundleVersion) {
|
|
317
|
-
t
|
|
601
|
+
missing(t, "bundleValidity");
|
|
318
602
|
return;
|
|
319
603
|
}
|
|
320
604
|
const bundle = await repository.create({
|
|
321
|
-
projectKey: options.projectKey,
|
|
322
605
|
bundleKey: "REPORTING",
|
|
323
606
|
label: "Reporting"
|
|
324
607
|
});
|
|
325
|
-
import_strict.default.equal(bundle.
|
|
608
|
+
import_strict.default.equal(bundle.bundleKey, "REPORTING");
|
|
326
609
|
const firstDraft = await repository.createDraft({
|
|
327
610
|
bundleId: bundle.id,
|
|
328
611
|
features: ["REPORTS"],
|
|
@@ -370,10 +653,457 @@ function persistenceAdapterContract(options) {
|
|
|
370
653
|
second.id
|
|
371
654
|
);
|
|
372
655
|
});
|
|
656
|
+
(0, import_node_test.test)("a booking keeps the rhythm and the window it was made in", async (t) => {
|
|
657
|
+
const repository = harness.adapter.subscriptionBundleRepository;
|
|
658
|
+
const { seed } = harness;
|
|
659
|
+
if (!repository || !seed.createBundleVersion) {
|
|
660
|
+
missing(t, "bundleBookings");
|
|
661
|
+
return;
|
|
662
|
+
}
|
|
663
|
+
const { planVersionId } = await seed.createPlanVersion({
|
|
664
|
+
planKey: "PRO",
|
|
665
|
+
version: 1,
|
|
666
|
+
quotas: {},
|
|
667
|
+
features: ["CORE"],
|
|
668
|
+
published: true
|
|
669
|
+
});
|
|
670
|
+
const { subscriptionId } = await seed.createSubscription({
|
|
671
|
+
tenantId: "tenant-bundle-period",
|
|
672
|
+
plan: "PRO",
|
|
673
|
+
planVersionId,
|
|
674
|
+
billingCycle: "YEARLY"
|
|
675
|
+
});
|
|
676
|
+
const { bundleVersionId } = await seed.createBundleVersion({
|
|
677
|
+
bundleKey: "ANALYTICS",
|
|
678
|
+
features: ["REPORTS"]
|
|
679
|
+
});
|
|
680
|
+
const booked = await repository.add({
|
|
681
|
+
subscriptionId,
|
|
682
|
+
bundleVersionId,
|
|
683
|
+
startedAt: /* @__PURE__ */ new Date("2026-02-21T00:00:00.000Z"),
|
|
684
|
+
minimumTermEndsAt: null,
|
|
685
|
+
billingCycle: "MONTHLY",
|
|
686
|
+
currentPeriodStart: /* @__PURE__ */ new Date("2026-02-21T00:00:00.000Z"),
|
|
687
|
+
currentPeriodEnd: /* @__PURE__ */ new Date("2026-02-28T00:00:00.000Z")
|
|
688
|
+
});
|
|
689
|
+
import_strict.default.equal(booked.billingCycle, "MONTHLY");
|
|
690
|
+
const [readBack] = await repository.listBySubscription(subscriptionId);
|
|
691
|
+
import_strict.default.ok(readBack, "the booking must be readable back");
|
|
692
|
+
import_strict.default.equal(readBack.billingCycle, "MONTHLY");
|
|
693
|
+
import_strict.default.equal(
|
|
694
|
+
readBack.currentPeriodEnd?.toISOString(),
|
|
695
|
+
"2026-02-28T00:00:00.000Z",
|
|
696
|
+
"the period end must survive the round trip"
|
|
697
|
+
);
|
|
698
|
+
import_strict.default.equal(readBack.currentPeriodStart?.toISOString(), "2026-02-21T00:00:00.000Z");
|
|
699
|
+
const legacy = await repository.add({
|
|
700
|
+
subscriptionId,
|
|
701
|
+
bundleVersionId,
|
|
702
|
+
startedAt: /* @__PURE__ */ new Date("2026-01-01T00:00:00.000Z"),
|
|
703
|
+
minimumTermEndsAt: null
|
|
704
|
+
});
|
|
705
|
+
const legacyReadBack = await repository.findById(legacy.id);
|
|
706
|
+
import_strict.default.ok(legacyReadBack, "the legacy booking must be readable back");
|
|
707
|
+
import_strict.default.equal(legacyReadBack.billingCycle, null);
|
|
708
|
+
import_strict.default.equal(legacyReadBack.currentPeriodStart, null);
|
|
709
|
+
import_strict.default.equal(legacyReadBack.currentPeriodEnd, null);
|
|
710
|
+
});
|
|
711
|
+
(0, import_node_test.test)("a second cancellation of one booking is refused, not applied", async (t) => {
|
|
712
|
+
const repository = harness.adapter.subscriptionBundleRepository;
|
|
713
|
+
const { seed } = harness;
|
|
714
|
+
if (!repository || !seed.createBundleVersion) {
|
|
715
|
+
missing(t, "bundleBookings");
|
|
716
|
+
return;
|
|
717
|
+
}
|
|
718
|
+
const { planVersionId } = await seed.createPlanVersion({
|
|
719
|
+
planKey: "PRO",
|
|
720
|
+
version: 1,
|
|
721
|
+
quotas: {},
|
|
722
|
+
features: ["CORE"],
|
|
723
|
+
published: true
|
|
724
|
+
});
|
|
725
|
+
const { subscriptionId } = await seed.createSubscription({
|
|
726
|
+
tenantId: "tenant-double-cancel",
|
|
727
|
+
plan: "PRO",
|
|
728
|
+
planVersionId
|
|
729
|
+
});
|
|
730
|
+
const { bundleVersionId } = await seed.createBundleVersion({
|
|
731
|
+
bundleKey: "ANALYTICS",
|
|
732
|
+
features: ["REPORTS"]
|
|
733
|
+
});
|
|
734
|
+
const booking = await repository.add({
|
|
735
|
+
subscriptionId,
|
|
736
|
+
bundleVersionId,
|
|
737
|
+
startedAt: /* @__PURE__ */ new Date("2026-01-01T00:00:00.000Z"),
|
|
738
|
+
minimumTermEndsAt: null
|
|
739
|
+
});
|
|
740
|
+
const first = await repository.cancel(booking.id, {
|
|
741
|
+
canceledAt: /* @__PURE__ */ new Date("2026-03-01T00:00:00.000Z"),
|
|
742
|
+
canceledEffectiveAt: /* @__PURE__ */ new Date("2026-04-01T00:00:00.000Z")
|
|
743
|
+
});
|
|
744
|
+
import_strict.default.equal(first.canceledEffectiveAt?.toISOString(), "2026-04-01T00:00:00.000Z");
|
|
745
|
+
await import_strict.default.rejects(
|
|
746
|
+
() => repository.cancel(booking.id, {
|
|
747
|
+
canceledAt: /* @__PURE__ */ new Date("2026-03-02T00:00:00.000Z"),
|
|
748
|
+
canceledEffectiveAt: /* @__PURE__ */ new Date("2026-09-01T00:00:00.000Z")
|
|
749
|
+
}),
|
|
750
|
+
"a second cancellation must be refused"
|
|
751
|
+
);
|
|
752
|
+
const readBack = await repository.findById(booking.id);
|
|
753
|
+
import_strict.default.equal(
|
|
754
|
+
readBack?.canceledEffectiveAt?.toISOString(),
|
|
755
|
+
"2026-04-01T00:00:00.000Z",
|
|
756
|
+
"the first cancellation must still stand"
|
|
757
|
+
);
|
|
758
|
+
await repository.reactivate(booking.id);
|
|
759
|
+
const again = await repository.cancel(booking.id, {
|
|
760
|
+
canceledAt: /* @__PURE__ */ new Date("2026-03-02T00:00:00.000Z"),
|
|
761
|
+
canceledEffectiveAt: /* @__PURE__ */ new Date("2026-09-01T00:00:00.000Z")
|
|
762
|
+
});
|
|
763
|
+
import_strict.default.equal(again.canceledEffectiveAt?.toISOString(), "2026-09-01T00:00:00.000Z");
|
|
764
|
+
});
|
|
765
|
+
(0, import_node_test.test)("a subscription's bookings come back newest first", async (t) => {
|
|
766
|
+
const repository = harness.adapter.subscriptionBundleRepository;
|
|
767
|
+
const { seed } = harness;
|
|
768
|
+
if (!repository || !seed.createBundleVersion) {
|
|
769
|
+
missing(t, "bundleBookings");
|
|
770
|
+
return;
|
|
771
|
+
}
|
|
772
|
+
const { planVersionId } = await seed.createPlanVersion({
|
|
773
|
+
planKey: "PRO",
|
|
774
|
+
version: 1,
|
|
775
|
+
quotas: {},
|
|
776
|
+
features: ["CORE"],
|
|
777
|
+
published: true
|
|
778
|
+
});
|
|
779
|
+
const { subscriptionId } = await seed.createSubscription({
|
|
780
|
+
tenantId: "tenant-booking-order",
|
|
781
|
+
plan: "PRO",
|
|
782
|
+
planVersionId
|
|
783
|
+
});
|
|
784
|
+
for (const [key, startedAt] of [
|
|
785
|
+
["OLDEST", "2026-01-01T00:00:00.000Z"],
|
|
786
|
+
["MIDDLE", "2026-02-01T00:00:00.000Z"],
|
|
787
|
+
["NEWEST", "2026-03-01T00:00:00.000Z"]
|
|
788
|
+
]) {
|
|
789
|
+
const { bundleVersionId } = await seed.createBundleVersion({
|
|
790
|
+
bundleKey: key,
|
|
791
|
+
features: ["REPORTS"]
|
|
792
|
+
});
|
|
793
|
+
await repository.add({
|
|
794
|
+
subscriptionId,
|
|
795
|
+
bundleVersionId,
|
|
796
|
+
startedAt: new Date(startedAt),
|
|
797
|
+
minimumTermEndsAt: null
|
|
798
|
+
});
|
|
799
|
+
}
|
|
800
|
+
const listed = await repository.listBySubscription(subscriptionId);
|
|
801
|
+
import_strict.default.deepEqual(
|
|
802
|
+
listed.map((row) => row.startedAt.toISOString()),
|
|
803
|
+
[
|
|
804
|
+
"2026-03-01T00:00:00.000Z",
|
|
805
|
+
"2026-02-01T00:00:00.000Z",
|
|
806
|
+
"2026-01-01T00:00:00.000Z"
|
|
807
|
+
]
|
|
808
|
+
);
|
|
809
|
+
});
|
|
810
|
+
(0, import_node_test.test)("a booking with no request date is active, whatever its effective date says", async (t) => {
|
|
811
|
+
const repository = harness.adapter.subscriptionBundleRepository;
|
|
812
|
+
const { seed } = harness;
|
|
813
|
+
if (!repository || !seed.createBundleVersion) {
|
|
814
|
+
missing(t, "bundleBookings");
|
|
815
|
+
return;
|
|
816
|
+
}
|
|
817
|
+
const { planVersionId } = await seed.createPlanVersion({
|
|
818
|
+
planKey: "PRO",
|
|
819
|
+
version: 1,
|
|
820
|
+
quotas: {},
|
|
821
|
+
features: ["CORE"],
|
|
822
|
+
published: true
|
|
823
|
+
});
|
|
824
|
+
const { subscriptionId } = await seed.createSubscription({
|
|
825
|
+
tenantId: "tenant-half-cancelled",
|
|
826
|
+
plan: "PRO",
|
|
827
|
+
planVersionId
|
|
828
|
+
});
|
|
829
|
+
const { bundleVersionId } = await seed.createBundleVersion({
|
|
830
|
+
bundleKey: "ANALYTICS",
|
|
831
|
+
features: ["REPORTS"]
|
|
832
|
+
});
|
|
833
|
+
const booking = await repository.add({
|
|
834
|
+
subscriptionId,
|
|
835
|
+
bundleVersionId,
|
|
836
|
+
startedAt: /* @__PURE__ */ new Date("2026-01-01T00:00:00.000Z"),
|
|
837
|
+
minimumTermEndsAt: null
|
|
838
|
+
});
|
|
839
|
+
await repository.cancel(booking.id, {
|
|
840
|
+
canceledAt: /* @__PURE__ */ new Date("2026-02-01T00:00:00.000Z"),
|
|
841
|
+
canceledEffectiveAt: /* @__PURE__ */ new Date("2026-03-01T00:00:00.000Z")
|
|
842
|
+
});
|
|
843
|
+
const clearRequestDate = harness.seed.clearBookingRequestDate;
|
|
844
|
+
if (!clearRequestDate) {
|
|
845
|
+
missing(t, "halfCancelledBookingSeed");
|
|
846
|
+
return;
|
|
847
|
+
}
|
|
848
|
+
await clearRequestDate(booking.id);
|
|
849
|
+
const active = await repository.listActiveBySubscription(
|
|
850
|
+
subscriptionId,
|
|
851
|
+
/* @__PURE__ */ new Date("2026-06-01T00:00:00.000Z")
|
|
852
|
+
);
|
|
853
|
+
import_strict.default.equal(active.length, 1, "no request date means nobody asked to cancel it");
|
|
854
|
+
import_strict.default.equal(
|
|
855
|
+
await repository.countActiveByBundleVersionId(
|
|
856
|
+
bundleVersionId,
|
|
857
|
+
/* @__PURE__ */ new Date("2026-06-01T00:00:00.000Z")
|
|
858
|
+
),
|
|
859
|
+
1
|
|
860
|
+
);
|
|
861
|
+
});
|
|
862
|
+
(0, import_node_test.test)("discarding a draft cannot remove a version published meanwhile", async (t) => {
|
|
863
|
+
const catalog = harness.adapter.bundleRepository;
|
|
864
|
+
const discardDraft = catalog?.deleteDraft?.bind(catalog);
|
|
865
|
+
if (!catalog || !discardDraft) {
|
|
866
|
+
missing(t, "bundleDraftDiscard");
|
|
867
|
+
return;
|
|
868
|
+
}
|
|
869
|
+
const bundle = await catalog.create({
|
|
870
|
+
bundleKey: "RACE",
|
|
871
|
+
label: "Race"
|
|
872
|
+
});
|
|
873
|
+
const draft = await catalog.createDraft({
|
|
874
|
+
bundleId: bundle.id,
|
|
875
|
+
features: ["REPORTS"],
|
|
876
|
+
quotas: {}
|
|
877
|
+
});
|
|
878
|
+
await catalog.publishDraft(draft.id, {
|
|
879
|
+
publishedByUserId: null,
|
|
880
|
+
publishedChanges: [],
|
|
881
|
+
nonRegressive: true,
|
|
882
|
+
validFrom: /* @__PURE__ */ new Date("2026-01-01T00:00:00.000Z"),
|
|
883
|
+
validUntil: null
|
|
884
|
+
});
|
|
885
|
+
await import_strict.default.rejects(
|
|
886
|
+
() => discardDraft(draft.id),
|
|
887
|
+
"a published version must not be discardable"
|
|
888
|
+
);
|
|
889
|
+
import_strict.default.ok(
|
|
890
|
+
await catalog.findVersionById(draft.id),
|
|
891
|
+
"and it must still be there afterwards"
|
|
892
|
+
);
|
|
893
|
+
});
|
|
894
|
+
(0, import_node_test.test)("publishing one draft twice claims it once, and the windows stay adjacent", async (t) => {
|
|
895
|
+
const catalog = harness.adapter.bundleRepository;
|
|
896
|
+
const publish = catalog?.publishDraft?.bind(catalog);
|
|
897
|
+
if (!catalog || !publish) {
|
|
898
|
+
missing(t, "bundleDraftPublish");
|
|
899
|
+
return;
|
|
900
|
+
}
|
|
901
|
+
const bundle = await catalog.create({
|
|
902
|
+
bundleKey: "CLAIM",
|
|
903
|
+
label: "Claim"
|
|
904
|
+
});
|
|
905
|
+
const first = await catalog.createDraft({
|
|
906
|
+
bundleId: bundle.id,
|
|
907
|
+
features: ["A"],
|
|
908
|
+
quotas: {}
|
|
909
|
+
});
|
|
910
|
+
await publish(first.id, {
|
|
911
|
+
publishedByUserId: null,
|
|
912
|
+
publishedChanges: [],
|
|
913
|
+
nonRegressive: true,
|
|
914
|
+
validFrom: /* @__PURE__ */ new Date("2026-01-01T00:00:00.000Z"),
|
|
915
|
+
validUntil: null
|
|
916
|
+
});
|
|
917
|
+
const second = await catalog.createDraft({
|
|
918
|
+
bundleId: bundle.id,
|
|
919
|
+
baseVersionId: first.id,
|
|
920
|
+
features: ["A", "B"],
|
|
921
|
+
quotas: {}
|
|
922
|
+
});
|
|
923
|
+
const publishedAt = /* @__PURE__ */ new Date("2026-03-01T00:00:00.000Z");
|
|
924
|
+
await publish(second.id, {
|
|
925
|
+
publishedByUserId: null,
|
|
926
|
+
publishedChanges: [],
|
|
927
|
+
nonRegressive: true,
|
|
928
|
+
validFrom: publishedAt,
|
|
929
|
+
validUntil: null
|
|
930
|
+
});
|
|
931
|
+
await import_strict.default.rejects(
|
|
932
|
+
() => publish(second.id, {
|
|
933
|
+
publishedByUserId: null,
|
|
934
|
+
publishedChanges: [],
|
|
935
|
+
nonRegressive: true,
|
|
936
|
+
validFrom: /* @__PURE__ */ new Date("2026-06-01T00:00:00.000Z"),
|
|
937
|
+
validUntil: null
|
|
938
|
+
}),
|
|
939
|
+
"a version that is already published must not be published again"
|
|
940
|
+
);
|
|
941
|
+
const successor = await catalog.findVersionById(second.id);
|
|
942
|
+
const predecessor = await catalog.findVersionById(first.id);
|
|
943
|
+
import_strict.default.equal(
|
|
944
|
+
successor?.validFrom && new Date(successor.validFrom).toISOString(),
|
|
945
|
+
publishedAt.toISOString(),
|
|
946
|
+
"the winning date must still stand"
|
|
947
|
+
);
|
|
948
|
+
import_strict.default.ok(predecessor?.supersededAt, "the predecessor must be superseded");
|
|
949
|
+
if (predecessor?.validUntil) {
|
|
950
|
+
const closesAt = new Date(predecessor.validUntil);
|
|
951
|
+
import_strict.default.equal(
|
|
952
|
+
closesAt.toISOString().slice(0, 10),
|
|
953
|
+
"2026-02-28",
|
|
954
|
+
"the predecessor must close the day before its successor opens"
|
|
955
|
+
);
|
|
956
|
+
}
|
|
957
|
+
});
|
|
958
|
+
(0, import_node_test.test)("a plan key names one plan for the whole installation", async (t) => {
|
|
959
|
+
const repository = harness.adapter.planRepository;
|
|
960
|
+
if (!repository) {
|
|
961
|
+
missing(t, "planRepository");
|
|
962
|
+
return;
|
|
963
|
+
}
|
|
964
|
+
await repository.create({ planKey: "DOUBLE", label: "First" });
|
|
965
|
+
await import_strict.default.rejects(
|
|
966
|
+
() => repository.create({ planKey: "DOUBLE", label: "Second" }),
|
|
967
|
+
"a plan key is taken once"
|
|
968
|
+
);
|
|
969
|
+
});
|
|
970
|
+
(0, import_node_test.test)("a bundle key names one bundle for the whole installation", async (t) => {
|
|
971
|
+
const catalog = harness.adapter.bundleRepository;
|
|
972
|
+
if (!catalog) {
|
|
973
|
+
missing(t, "bundleRepository");
|
|
974
|
+
return;
|
|
975
|
+
}
|
|
976
|
+
await catalog.create({ bundleKey: "DOUBLE", label: "First" });
|
|
977
|
+
await import_strict.default.rejects(
|
|
978
|
+
() => catalog.create({ bundleKey: "DOUBLE", label: "Second" }),
|
|
979
|
+
"a bundle key is taken once"
|
|
980
|
+
);
|
|
981
|
+
});
|
|
982
|
+
(0, import_node_test.test)("a retired plan still occupies its key", async (t) => {
|
|
983
|
+
const repository = harness.adapter.planRepository;
|
|
984
|
+
const retire = repository?.softDelete?.bind(repository);
|
|
985
|
+
const byKey = repository?.findByKey?.bind(repository);
|
|
986
|
+
if (!repository || !retire || !byKey) {
|
|
987
|
+
missing(t, "planRetirement");
|
|
988
|
+
return;
|
|
989
|
+
}
|
|
990
|
+
const plan = await repository.create({ planKey: "RETIRED_PLAN", label: "Retired" });
|
|
991
|
+
await retire(plan.id);
|
|
992
|
+
const stillThere = await byKey("RETIRED_PLAN");
|
|
993
|
+
import_strict.default.equal(stillThere?.id, plan.id, "the key is not free again");
|
|
994
|
+
import_strict.default.ok(stillThere?.deletedAt, "and the row says it is retired");
|
|
995
|
+
import_strict.default.equal(
|
|
996
|
+
(await repository.list({})).some((row) => row.id === plan.id),
|
|
997
|
+
false,
|
|
998
|
+
"a retired plan is not in the catalogue an operator browses"
|
|
999
|
+
);
|
|
1000
|
+
});
|
|
1001
|
+
(0, import_node_test.test)("a plan key no plan has finds no versions, rather than failing", async (t) => {
|
|
1002
|
+
const repository = harness.adapter.planRepository;
|
|
1003
|
+
if (!repository?.listVersions || !repository.findCurrentDraft || !repository.findLatestLivePlanVersion) {
|
|
1004
|
+
missing(t, "planVersionReads");
|
|
1005
|
+
return;
|
|
1006
|
+
}
|
|
1007
|
+
const entitlementVersions = harness.adapter.planVersionRepository;
|
|
1008
|
+
import_strict.default.deepEqual(await repository.listVersions("NO_SUCH_PLAN"), []);
|
|
1009
|
+
import_strict.default.equal(await repository.findCurrentDraft("NO_SUCH_PLAN"), null);
|
|
1010
|
+
import_strict.default.equal(await repository.findLatestLivePlanVersion("NO_SUCH_PLAN"), null);
|
|
1011
|
+
if (repository.findActivePlanVersion) {
|
|
1012
|
+
import_strict.default.equal(
|
|
1013
|
+
await repository.findActivePlanVersion("NO_SUCH_PLAN", /* @__PURE__ */ new Date()),
|
|
1014
|
+
null
|
|
1015
|
+
);
|
|
1016
|
+
}
|
|
1017
|
+
import_strict.default.equal(await entitlementVersions.findLatestLive("NO_SUCH_PLAN"), null);
|
|
1018
|
+
if (entitlementVersions.findActive) {
|
|
1019
|
+
import_strict.default.equal(
|
|
1020
|
+
await entitlementVersions.findActive("NO_SUCH_PLAN", /* @__PURE__ */ new Date()),
|
|
1021
|
+
null
|
|
1022
|
+
);
|
|
1023
|
+
}
|
|
1024
|
+
});
|
|
1025
|
+
(0, import_node_test.test)("retiring a plan hides none of its versions", async (t) => {
|
|
1026
|
+
const repository = harness.adapter.planRepository;
|
|
1027
|
+
if (!repository?.createPlanVersionDraft || !repository.publishPlanVersionDraft || !repository.listVersions || !repository.findCurrentDraft || !repository.findLatestLivePlanVersion || !repository.softDelete) {
|
|
1028
|
+
missing(t, "planVersionRetirement");
|
|
1029
|
+
return;
|
|
1030
|
+
}
|
|
1031
|
+
const listVersions = repository.listVersions.bind(repository);
|
|
1032
|
+
const findCurrentDraft = repository.findCurrentDraft.bind(repository);
|
|
1033
|
+
const findLatestLive = repository.findLatestLivePlanVersion.bind(repository);
|
|
1034
|
+
const entitlementVersions = harness.adapter.planVersionRepository;
|
|
1035
|
+
const plan = await repository.create({ planKey: "RETIRING", label: "Retiring" });
|
|
1036
|
+
const firstDraft = await repository.createPlanVersionDraft({
|
|
1037
|
+
planId: "RETIRING",
|
|
1038
|
+
features: ["CORE"],
|
|
1039
|
+
quotas: { users: 5 },
|
|
1040
|
+
monthlyNet: "10.00",
|
|
1041
|
+
yearlyNet: "100.00",
|
|
1042
|
+
validFrom: "2026-01-01"
|
|
1043
|
+
});
|
|
1044
|
+
const live = await repository.publishPlanVersionDraft(firstDraft.id, {
|
|
1045
|
+
publishedByUserId: null,
|
|
1046
|
+
publishedChanges: [],
|
|
1047
|
+
nonRegressive: true,
|
|
1048
|
+
validFrom: /* @__PURE__ */ new Date("2026-01-01T00:00:00.000Z"),
|
|
1049
|
+
validUntil: null
|
|
1050
|
+
});
|
|
1051
|
+
const openDraft = await repository.createPlanVersionDraft({
|
|
1052
|
+
planId: "RETIRING",
|
|
1053
|
+
baseVersionId: live.id,
|
|
1054
|
+
features: ["CORE", "PLUS"],
|
|
1055
|
+
quotas: { users: 10 },
|
|
1056
|
+
monthlyNet: "15.00",
|
|
1057
|
+
yearlyNet: "150.00",
|
|
1058
|
+
validFrom: "2026-06-01"
|
|
1059
|
+
});
|
|
1060
|
+
const reads = async () => ({
|
|
1061
|
+
versions: (await listVersions("RETIRING")).map((row) => row.id),
|
|
1062
|
+
draft: (await findCurrentDraft("RETIRING"))?.id ?? null,
|
|
1063
|
+
latestLive: (await findLatestLive("RETIRING"))?.id ?? null,
|
|
1064
|
+
entitlementFeatures: (await entitlementVersions.findLatestLive("RETIRING"))?.features ?? null
|
|
1065
|
+
});
|
|
1066
|
+
const beforeRetiring = await reads();
|
|
1067
|
+
import_strict.default.deepEqual(
|
|
1068
|
+
{
|
|
1069
|
+
versions: beforeRetiring.versions,
|
|
1070
|
+
draft: beforeRetiring.draft,
|
|
1071
|
+
latestLive: beforeRetiring.latestLive
|
|
1072
|
+
},
|
|
1073
|
+
{ versions: [live.id, openDraft.id], draft: openDraft.id, latestLive: live.id },
|
|
1074
|
+
"a live plan reads its versions, so the comparison below has a subject"
|
|
1075
|
+
);
|
|
1076
|
+
await repository.softDelete(plan.id);
|
|
1077
|
+
import_strict.default.deepEqual(await reads(), beforeRetiring, "retiring the plan hid its versions");
|
|
1078
|
+
});
|
|
1079
|
+
(0, import_node_test.test)("a retired bundle still occupies its key", async (t) => {
|
|
1080
|
+
const catalog = harness.adapter.bundleRepository;
|
|
1081
|
+
const retire = catalog?.softDelete?.bind(catalog);
|
|
1082
|
+
const byKey = catalog?.findByKey?.bind(catalog);
|
|
1083
|
+
if (!catalog || !retire || !byKey) {
|
|
1084
|
+
missing(t, "bundleRetirement");
|
|
1085
|
+
return;
|
|
1086
|
+
}
|
|
1087
|
+
const bundle = await catalog.create({
|
|
1088
|
+
bundleKey: "RETIRED_KEY",
|
|
1089
|
+
label: "Retired"
|
|
1090
|
+
});
|
|
1091
|
+
import_strict.default.equal((await byKey("RETIRED_KEY"))?.id, bundle.id);
|
|
1092
|
+
await retire(bundle.id);
|
|
1093
|
+
const stillThere = await byKey("RETIRED_KEY");
|
|
1094
|
+
import_strict.default.equal(stillThere?.id, bundle.id, "the key is not free again");
|
|
1095
|
+
import_strict.default.ok(stillThere?.deletedAt, "and the row says it is retired");
|
|
1096
|
+
const listed = await catalog.list({});
|
|
1097
|
+
import_strict.default.equal(
|
|
1098
|
+
listed.some((row) => row.id === bundle.id),
|
|
1099
|
+
false,
|
|
1100
|
+
"a retired bundle is not in the catalogue an operator browses"
|
|
1101
|
+
);
|
|
1102
|
+
});
|
|
373
1103
|
(0, import_node_test.test)("countByPlanVersionId counts current AND pending bindings in one query", async (t) => {
|
|
374
1104
|
const { seed, adapter } = harness;
|
|
375
1105
|
if (!adapter.subscriptionRepository.countByPlanVersionId) {
|
|
376
|
-
t
|
|
1106
|
+
missing(t, "countByPlanVersionId");
|
|
377
1107
|
return;
|
|
378
1108
|
}
|
|
379
1109
|
const v1 = await seed.createPlanVersion({
|
|
@@ -413,9 +1143,7 @@ function persistenceAdapterContract(options) {
|
|
|
413
1143
|
return;
|
|
414
1144
|
}
|
|
415
1145
|
if (!adapter.promoCodeRedemptionRepository) {
|
|
416
|
-
t
|
|
417
|
-
"adapter provides no PromoCodeRedemptionRepository (needed as tx write probe)"
|
|
418
|
-
);
|
|
1146
|
+
missing(t, "promoCodeRedemptions");
|
|
419
1147
|
return;
|
|
420
1148
|
}
|
|
421
1149
|
const redemptions = adapter.promoCodeRedemptionRepository;
|
|
@@ -498,7 +1226,7 @@ function persistenceAdapterContract(options) {
|
|
|
498
1226
|
(0, import_node_test.test)("concurrent claimSlot grants exactly maxRedemptions slots", async (t) => {
|
|
499
1227
|
const { seed, adapter } = harness;
|
|
500
1228
|
if (!adapter.promoCodeRepository) {
|
|
501
|
-
t
|
|
1229
|
+
missing(t, "promoCodes");
|
|
502
1230
|
return;
|
|
503
1231
|
}
|
|
504
1232
|
const promoCodes = adapter.promoCodeRepository;
|
|
@@ -519,7 +1247,7 @@ function persistenceAdapterContract(options) {
|
|
|
519
1247
|
(0, import_node_test.test)("claimSlot / markExhaustedIfFull / releaseSlot lifecycle", async (t) => {
|
|
520
1248
|
const { seed, adapter } = harness;
|
|
521
1249
|
if (!adapter.promoCodeRepository) {
|
|
522
|
-
t
|
|
1250
|
+
missing(t, "promoCodes");
|
|
523
1251
|
return;
|
|
524
1252
|
}
|
|
525
1253
|
const promoCodes = adapter.promoCodeRepository;
|
|
@@ -539,7 +1267,7 @@ function persistenceAdapterContract(options) {
|
|
|
539
1267
|
(0, import_node_test.test)("a subscription cannot redeem twice (unique guard)", async (t) => {
|
|
540
1268
|
const { seed, adapter } = harness;
|
|
541
1269
|
if (!adapter.promoCodeRedemptionRepository) {
|
|
542
|
-
t
|
|
1270
|
+
missing(t, "promoCodeRedemptions");
|
|
543
1271
|
return;
|
|
544
1272
|
}
|
|
545
1273
|
const redemptions = adapter.promoCodeRedemptionRepository;
|
|
@@ -579,7 +1307,7 @@ function persistenceAdapterContract(options) {
|
|
|
579
1307
|
(0, import_node_test.test)("audit write \u2192 query roundtrip with actorTag filters", async (t) => {
|
|
580
1308
|
const { adapter } = harness;
|
|
581
1309
|
if (!adapter.audit || !adapter.auditQuery) {
|
|
582
|
-
t
|
|
1310
|
+
missing(t, "audit");
|
|
583
1311
|
return;
|
|
584
1312
|
}
|
|
585
1313
|
await adapter.audit.write({
|
|
@@ -619,7 +1347,7 @@ function persistenceAdapterContract(options) {
|
|
|
619
1347
|
(0, import_node_test.test)("MFA secret roundtrip", async (t) => {
|
|
620
1348
|
const { adapter } = harness;
|
|
621
1349
|
if (!adapter.mfa) {
|
|
622
|
-
t
|
|
1350
|
+
missing(t, "mfa");
|
|
623
1351
|
return;
|
|
624
1352
|
}
|
|
625
1353
|
import_strict.default.equal(await adapter.mfa.getSecret("admin-1"), null);
|
|
@@ -634,7 +1362,7 @@ function persistenceAdapterContract(options) {
|
|
|
634
1362
|
(0, import_node_test.test)("finds the subscription the id names, not merely a subscription", async (t) => {
|
|
635
1363
|
const { adapter, seed } = harness;
|
|
636
1364
|
if (!adapter.promoSubscriptionLookup) {
|
|
637
|
-
t
|
|
1365
|
+
missing(t, "promoSubscriptionLookup");
|
|
638
1366
|
return;
|
|
639
1367
|
}
|
|
640
1368
|
const { planVersionId } = await seed.createPlanVersion({
|
|
@@ -663,7 +1391,7 @@ function persistenceAdapterContract(options) {
|
|
|
663
1391
|
(0, import_node_test.test)("returns null for an id that does not exist", async (t) => {
|
|
664
1392
|
const { adapter, seed } = harness;
|
|
665
1393
|
if (!adapter.promoSubscriptionLookup) {
|
|
666
|
-
t
|
|
1394
|
+
missing(t, "promoSubscriptionLookup");
|
|
667
1395
|
return;
|
|
668
1396
|
}
|
|
669
1397
|
const { planVersionId } = await seed.createPlanVersion({
|
|
@@ -684,7 +1412,7 @@ function persistenceAdapterContract(options) {
|
|
|
684
1412
|
(0, import_node_test.test)("carries the fields a promo rule reads: cycle and start date", async (t) => {
|
|
685
1413
|
const { adapter, seed } = harness;
|
|
686
1414
|
if (!adapter.promoSubscriptionLookup) {
|
|
687
|
-
t
|
|
1415
|
+
missing(t, "promoSubscriptionLookup");
|
|
688
1416
|
return;
|
|
689
1417
|
}
|
|
690
1418
|
const { planVersionId } = await seed.createPlanVersion({
|
|
@@ -719,7 +1447,7 @@ function persistenceAdapterContract(options) {
|
|
|
719
1447
|
(0, import_node_test.test)("reads inside a transaction, so validation and redemption agree", async (t) => {
|
|
720
1448
|
const { adapter, seed } = harness;
|
|
721
1449
|
if (!adapter.promoSubscriptionLookup) {
|
|
722
|
-
t
|
|
1450
|
+
missing(t, "promoSubscriptionLookup");
|
|
723
1451
|
return;
|
|
724
1452
|
}
|
|
725
1453
|
const { planVersionId } = await seed.createPlanVersion({
|
|
@@ -740,13 +1468,1584 @@ function persistenceAdapterContract(options) {
|
|
|
740
1468
|
import_strict.default.equal(seen?.id, subscriptionId);
|
|
741
1469
|
import_strict.default.equal(seen?.tenantId, "tenant-a");
|
|
742
1470
|
});
|
|
743
|
-
(0, import_node_test.test)("
|
|
744
|
-
|
|
745
|
-
|
|
1471
|
+
(0, import_node_test.test)("a contract keeps what was agreed, and ending it does not rewrite it", async (t) => {
|
|
1472
|
+
const contracts = harness.adapter.subscriptionContractRepository;
|
|
1473
|
+
const createSubscriber = harness.seed.createSubscriber;
|
|
1474
|
+
if (!contracts || !createSubscriber) {
|
|
1475
|
+
missing(t, "subscriptionContracts");
|
|
746
1476
|
return;
|
|
747
1477
|
}
|
|
748
|
-
|
|
749
|
-
|
|
1478
|
+
const tenantId = "tenant-contract-lifecycle";
|
|
1479
|
+
const signedAt = /* @__PURE__ */ new Date("2026-01-01T00:00:00.000Z");
|
|
1480
|
+
const { subscriberId } = await createSubscriber({ legalName: "Meier GmbH" });
|
|
1481
|
+
const parties = partiesWith(subscriberId, "Meier GmbH");
|
|
1482
|
+
const created = await contracts.create({
|
|
1483
|
+
tenantId,
|
|
1484
|
+
parties,
|
|
1485
|
+
effectiveFrom: signedAt,
|
|
1486
|
+
priceSnapshot: {
|
|
1487
|
+
currency: "EUR",
|
|
1488
|
+
billingCycle: "monthly",
|
|
1489
|
+
subtotalNet: 29.9,
|
|
1490
|
+
discountNet: 0,
|
|
1491
|
+
totalNet: 29.9,
|
|
1492
|
+
vatRate: 19,
|
|
1493
|
+
totalGross: 35.58
|
|
1494
|
+
},
|
|
1495
|
+
entitlementSnapshot: {
|
|
1496
|
+
plan: "STANDARD",
|
|
1497
|
+
features: ["CORE"],
|
|
1498
|
+
quotas: { users: 5 }
|
|
1499
|
+
},
|
|
1500
|
+
originalBundleVersionIds: ["bundle-version-1"],
|
|
1501
|
+
termsSnapshot: { noticePeriodDays: 30 },
|
|
1502
|
+
lineItems: [
|
|
1503
|
+
{
|
|
1504
|
+
kind: "plan",
|
|
1505
|
+
sourceKey: "STANDARD",
|
|
1506
|
+
sourceVersionId: "plan-version-1",
|
|
1507
|
+
titleSnapshot: "Standard",
|
|
1508
|
+
descriptionSnapshot: "The plan as it was signed",
|
|
1509
|
+
quantity: 1,
|
|
1510
|
+
unit: null,
|
|
1511
|
+
priceNet: 19.9,
|
|
1512
|
+
priceGross: 23.68,
|
|
1513
|
+
billingCycle: "monthly",
|
|
1514
|
+
currency: "EUR",
|
|
1515
|
+
taxRate: 19,
|
|
1516
|
+
taxAmount: 3.78,
|
|
1517
|
+
minimumTermUntil: /* @__PURE__ */ new Date("2027-01-01T00:00:00.000Z"),
|
|
1518
|
+
featuresSnapshot: ["CORE"],
|
|
1519
|
+
quotaEffectsSnapshot: { users: 5 },
|
|
1520
|
+
metadata: { origin: "onboarding" }
|
|
1521
|
+
},
|
|
1522
|
+
{
|
|
1523
|
+
kind: "bundle",
|
|
1524
|
+
sourceKey: "EXTRA-SEATS",
|
|
1525
|
+
sourceVersionId: "bundle-version-1",
|
|
1526
|
+
titleSnapshot: "Extra seats",
|
|
1527
|
+
descriptionSnapshot: null,
|
|
1528
|
+
quantity: 1,
|
|
1529
|
+
unit: "seat",
|
|
1530
|
+
priceNet: 10,
|
|
1531
|
+
priceGross: 11.9,
|
|
1532
|
+
billingCycle: "monthly",
|
|
1533
|
+
currency: "EUR",
|
|
1534
|
+
taxRate: 19,
|
|
1535
|
+
taxAmount: 1.9,
|
|
1536
|
+
minimumTermUntil: null,
|
|
1537
|
+
featuresSnapshot: [],
|
|
1538
|
+
quotaEffectsSnapshot: { users: 5 },
|
|
1539
|
+
metadata: null
|
|
1540
|
+
}
|
|
1541
|
+
]
|
|
1542
|
+
});
|
|
1543
|
+
import_strict.default.equal(created.status, "active");
|
|
1544
|
+
import_strict.default.equal(created.tenantId, tenantId);
|
|
1545
|
+
import_strict.default.equal(created.subscriberId, subscriberId);
|
|
1546
|
+
import_strict.default.deepEqual(created.subscriber, parties.subscriber);
|
|
1547
|
+
import_strict.default.deepEqual(created.issuer, parties.issuer);
|
|
1548
|
+
import_strict.default.equal(created.partiesMigrated, false);
|
|
1549
|
+
import_strict.default.equal(created.lineItems.length, 2);
|
|
1550
|
+
import_strict.default.deepEqual(created.originalBundleVersionIds, ["bundle-version-1"]);
|
|
1551
|
+
import_strict.default.deepEqual(created.termsSnapshot, { noticePeriodDays: 30 });
|
|
1552
|
+
const planLine = created.lineItems.find((item) => item.kind === "plan");
|
|
1553
|
+
import_strict.default.ok(planLine, "plan line expected");
|
|
1554
|
+
import_strict.default.equal(planLine.priceNet, 19.9, "money must survive the round trip unrounded");
|
|
1555
|
+
import_strict.default.equal(planLine.priceGross, 23.68);
|
|
1556
|
+
import_strict.default.equal(planLine.billingCycle, "monthly");
|
|
1557
|
+
import_strict.default.deepEqual(planLine.quotaEffectsSnapshot, { users: 5 });
|
|
1558
|
+
import_strict.default.equal(planLine.descriptionSnapshot, "The plan as it was signed");
|
|
1559
|
+
import_strict.default.equal(
|
|
1560
|
+
planLine.minimumTermUntil?.getTime(),
|
|
1561
|
+
(/* @__PURE__ */ new Date("2027-01-01T00:00:00.000Z")).getTime(),
|
|
1562
|
+
"the commitment is part of what was agreed"
|
|
1563
|
+
);
|
|
1564
|
+
import_strict.default.deepEqual(planLine.metadata, { origin: "onboarding" });
|
|
1565
|
+
import_strict.default.equal(planLine.currency, "EUR", "the line says what it was booked in");
|
|
1566
|
+
import_strict.default.equal(planLine.taxRate, 19, "the rate is a recorded fact, not the ratio");
|
|
1567
|
+
import_strict.default.equal(planLine.taxAmount, 3.78);
|
|
1568
|
+
import_strict.default.equal(
|
|
1569
|
+
Math.round((planLine.priceNet + planLine.taxAmount) * 100) / 100,
|
|
1570
|
+
planLine.priceGross,
|
|
1571
|
+
"the tax closes the gap between net and gross"
|
|
1572
|
+
);
|
|
1573
|
+
const bundleLine = created.lineItems.find((item) => item.kind === "bundle");
|
|
1574
|
+
import_strict.default.ok(bundleLine, "bundle line expected");
|
|
1575
|
+
import_strict.default.equal(bundleLine.descriptionSnapshot, null);
|
|
1576
|
+
import_strict.default.equal(bundleLine.unit, "seat");
|
|
1577
|
+
import_strict.default.equal(bundleLine.minimumTermUntil, null);
|
|
1578
|
+
import_strict.default.equal(bundleLine.metadata, null);
|
|
1579
|
+
const readBack = await contracts.findById(created.id);
|
|
1580
|
+
import_strict.default.ok(readBack, "contract expected by id");
|
|
1581
|
+
import_strict.default.deepEqual(readBack.subscriber, parties.subscriber, "the copy is stored");
|
|
1582
|
+
import_strict.default.deepEqual(readBack.issuer, parties.issuer);
|
|
1583
|
+
import_strict.default.equal(
|
|
1584
|
+
readBack.lineItems.length,
|
|
1585
|
+
2,
|
|
1586
|
+
"lines belong to the contract, not the call"
|
|
1587
|
+
);
|
|
1588
|
+
import_strict.default.equal(
|
|
1589
|
+
(await contracts.findActiveByTenantId(
|
|
1590
|
+
tenantId,
|
|
1591
|
+
/* @__PURE__ */ new Date("2026-06-01T00:00:00.000Z")
|
|
1592
|
+
))?.id,
|
|
1593
|
+
created.id
|
|
1594
|
+
);
|
|
1595
|
+
import_strict.default.equal(
|
|
1596
|
+
await contracts.findActiveByTenantId(
|
|
1597
|
+
tenantId,
|
|
1598
|
+
/* @__PURE__ */ new Date("2025-12-31T23:59:59.999Z")
|
|
1599
|
+
),
|
|
1600
|
+
null,
|
|
1601
|
+
"a contract is not active before it starts"
|
|
1602
|
+
);
|
|
1603
|
+
const endsAt = /* @__PURE__ */ new Date("2026-07-01T00:00:00.000Z");
|
|
1604
|
+
const terminated = await contracts.terminate(created.id, {
|
|
1605
|
+
effectiveUntil: endsAt,
|
|
1606
|
+
status: null
|
|
1607
|
+
});
|
|
1608
|
+
import_strict.default.equal(
|
|
1609
|
+
terminated.status,
|
|
1610
|
+
"active",
|
|
1611
|
+
"a null status leaves the contract in the state it had"
|
|
1612
|
+
);
|
|
1613
|
+
import_strict.default.equal(terminated.effectiveUntil?.getTime(), endsAt.getTime());
|
|
1614
|
+
import_strict.default.equal(terminated.lineItems.length, 2, "ending a contract keeps its lines");
|
|
1615
|
+
import_strict.default.equal(
|
|
1616
|
+
terminated.priceSnapshot.totalNet,
|
|
1617
|
+
created.priceSnapshot.totalNet,
|
|
1618
|
+
"ending a contract does not restate its price"
|
|
1619
|
+
);
|
|
1620
|
+
const afterEnd = await contracts.findById(created.id);
|
|
1621
|
+
import_strict.default.ok(afterEnd, "a terminated contract is still readable");
|
|
1622
|
+
import_strict.default.equal(afterEnd.lineItems.length, 2);
|
|
1623
|
+
import_strict.default.equal(
|
|
1624
|
+
(await contracts.findActiveByTenantId(
|
|
1625
|
+
tenantId,
|
|
1626
|
+
/* @__PURE__ */ new Date("2026-06-30T00:00:00.000Z")
|
|
1627
|
+
))?.id,
|
|
1628
|
+
created.id,
|
|
1629
|
+
"active up to the moment it ends"
|
|
1630
|
+
);
|
|
1631
|
+
import_strict.default.equal(
|
|
1632
|
+
await contracts.findActiveByTenantId(tenantId, endsAt),
|
|
1633
|
+
null,
|
|
1634
|
+
"and not at that moment"
|
|
1635
|
+
);
|
|
1636
|
+
});
|
|
1637
|
+
(0, import_node_test.test)("a successor takes over without erasing the contract it replaces", async (t) => {
|
|
1638
|
+
const contracts = harness.adapter.subscriptionContractRepository;
|
|
1639
|
+
const createSubscriber = harness.seed.createSubscriber;
|
|
1640
|
+
if (!contracts || !createSubscriber) {
|
|
1641
|
+
missing(t, "subscriptionContracts");
|
|
1642
|
+
return;
|
|
1643
|
+
}
|
|
1644
|
+
const tenantId = "tenant-contract-succession";
|
|
1645
|
+
const { subscriberId } = await createSubscriber({ legalName: "Nachfolger KG" });
|
|
1646
|
+
const parties = partiesWith(subscriberId, "Nachfolger KG");
|
|
1647
|
+
const handover = /* @__PURE__ */ new Date("2026-04-01T00:00:00.000Z");
|
|
1648
|
+
const lineAt = (priceNet, priceGross) => ({
|
|
1649
|
+
kind: "plan",
|
|
1650
|
+
sourceKey: "STANDARD",
|
|
1651
|
+
sourceVersionId: null,
|
|
1652
|
+
titleSnapshot: "Standard",
|
|
1653
|
+
descriptionSnapshot: null,
|
|
1654
|
+
quantity: 1,
|
|
1655
|
+
unit: null,
|
|
1656
|
+
priceNet,
|
|
1657
|
+
priceGross,
|
|
1658
|
+
billingCycle: "monthly",
|
|
1659
|
+
currency: "EUR",
|
|
1660
|
+
taxRate: 19,
|
|
1661
|
+
taxAmount: Math.round((priceGross - priceNet) * 100) / 100,
|
|
1662
|
+
minimumTermUntil: null,
|
|
1663
|
+
featuresSnapshot: [],
|
|
1664
|
+
quotaEffectsSnapshot: {},
|
|
1665
|
+
metadata: null
|
|
1666
|
+
});
|
|
1667
|
+
const priceAt = (net, gross) => ({
|
|
1668
|
+
currency: "EUR",
|
|
1669
|
+
billingCycle: "monthly",
|
|
1670
|
+
subtotalNet: net,
|
|
1671
|
+
discountNet: 0,
|
|
1672
|
+
totalNet: net,
|
|
1673
|
+
vatRate: 19,
|
|
1674
|
+
totalGross: gross
|
|
1675
|
+
});
|
|
1676
|
+
const first = await contracts.create({
|
|
1677
|
+
tenantId,
|
|
1678
|
+
parties,
|
|
1679
|
+
effectiveFrom: /* @__PURE__ */ new Date("2026-01-01T00:00:00.000Z"),
|
|
1680
|
+
priceSnapshot: priceAt(19.9, 23.68),
|
|
1681
|
+
lineItems: [lineAt(19.9, 23.68)]
|
|
1682
|
+
});
|
|
1683
|
+
import_strict.default.equal(
|
|
1684
|
+
(await contracts.findActiveByTenantId(
|
|
1685
|
+
tenantId,
|
|
1686
|
+
/* @__PURE__ */ new Date("2026-03-31T00:00:00.000Z")
|
|
1687
|
+
))?.id,
|
|
1688
|
+
first.id
|
|
1689
|
+
);
|
|
1690
|
+
await contracts.terminate(first.id, {
|
|
1691
|
+
effectiveUntil: handover,
|
|
1692
|
+
status: "superseded"
|
|
1693
|
+
});
|
|
1694
|
+
const second = await contracts.create({
|
|
1695
|
+
tenantId,
|
|
1696
|
+
parties,
|
|
1697
|
+
effectiveFrom: handover,
|
|
1698
|
+
priceSnapshot: priceAt(24.9, 29.63),
|
|
1699
|
+
lineItems: [lineAt(24.9, 29.63)]
|
|
1700
|
+
});
|
|
1701
|
+
import_strict.default.equal(
|
|
1702
|
+
(await contracts.findActiveByTenantId(tenantId, handover))?.id,
|
|
1703
|
+
second.id,
|
|
1704
|
+
"the successor takes over at the moment the predecessor ends"
|
|
1705
|
+
);
|
|
1706
|
+
import_strict.default.equal(
|
|
1707
|
+
await contracts.findActiveByTenantId(
|
|
1708
|
+
tenantId,
|
|
1709
|
+
/* @__PURE__ */ new Date("2026-03-31T00:00:00.000Z")
|
|
1710
|
+
),
|
|
1711
|
+
null,
|
|
1712
|
+
"a superseded contract is not live at any asOf"
|
|
1713
|
+
);
|
|
1714
|
+
const superseded = await contracts.findById(first.id);
|
|
1715
|
+
import_strict.default.equal(superseded?.status, "superseded");
|
|
1716
|
+
import_strict.default.equal(
|
|
1717
|
+
superseded?.priceSnapshot.totalNet,
|
|
1718
|
+
19.9,
|
|
1719
|
+
"the replaced contract keeps the price it was signed at"
|
|
1720
|
+
);
|
|
1721
|
+
const history = await contracts.list({ tenantId });
|
|
1722
|
+
import_strict.default.equal(history.length, 2, "both contracts remain in the history");
|
|
1723
|
+
import_strict.default.deepEqual(
|
|
1724
|
+
history.map((contract) => contract.lineItems.map((item) => item.priceNet)),
|
|
1725
|
+
[[24.9], [19.9]]
|
|
1726
|
+
);
|
|
1727
|
+
import_strict.default.deepEqual(
|
|
1728
|
+
history.map((contract) => contract.id),
|
|
1729
|
+
[second.id, first.id],
|
|
1730
|
+
"newest first"
|
|
1731
|
+
);
|
|
1732
|
+
import_strict.default.deepEqual(
|
|
1733
|
+
(await contracts.list({ tenantId, asOf: /* @__PURE__ */ new Date("2026-03-31T00:00:00.000Z") })).map((contract) => contract.id),
|
|
1734
|
+
[first.id],
|
|
1735
|
+
"asOf narrows the history to what was in force then"
|
|
1736
|
+
);
|
|
1737
|
+
import_strict.default.deepEqual(
|
|
1738
|
+
(await contracts.list({ tenantId, status: "superseded" })).map(
|
|
1739
|
+
(contract) => contract.id
|
|
1740
|
+
),
|
|
1741
|
+
[first.id]
|
|
1742
|
+
);
|
|
1743
|
+
});
|
|
1744
|
+
(0, import_node_test.test)("a line keeps the currency and the tax it was booked with", async (t) => {
|
|
1745
|
+
const contracts = harness.adapter.subscriptionContractRepository;
|
|
1746
|
+
const createSubscriber = harness.seed.createSubscriber;
|
|
1747
|
+
if (!contracts || !createSubscriber) {
|
|
1748
|
+
missing(t, "subscriptionContracts");
|
|
1749
|
+
return;
|
|
1750
|
+
}
|
|
1751
|
+
const tenantId = "tenant-contract-money-facts";
|
|
1752
|
+
const { subscriberId } = await createSubscriber({ legalName: "Z\xFCrich AG" });
|
|
1753
|
+
const parties = { ...partiesWith(subscriberId, "Z\xFCrich AG"), issuer: null };
|
|
1754
|
+
const created = await contracts.create({
|
|
1755
|
+
tenantId,
|
|
1756
|
+
parties,
|
|
1757
|
+
effectiveFrom: /* @__PURE__ */ new Date("2026-01-01T00:00:00.000Z"),
|
|
1758
|
+
priceSnapshot: {
|
|
1759
|
+
currency: "CHF",
|
|
1760
|
+
billingCycle: "monthly",
|
|
1761
|
+
subtotalNet: 100,
|
|
1762
|
+
discountNet: 10,
|
|
1763
|
+
totalNet: 90,
|
|
1764
|
+
vatRate: 8.1,
|
|
1765
|
+
totalGross: 97.29
|
|
1766
|
+
},
|
|
1767
|
+
lineItems: [
|
|
1768
|
+
{
|
|
1769
|
+
kind: "plan",
|
|
1770
|
+
sourceKey: "STANDARD",
|
|
1771
|
+
sourceVersionId: null,
|
|
1772
|
+
titleSnapshot: "Standard",
|
|
1773
|
+
descriptionSnapshot: null,
|
|
1774
|
+
quantity: 1,
|
|
1775
|
+
unit: null,
|
|
1776
|
+
priceNet: 100,
|
|
1777
|
+
priceGross: 108.1,
|
|
1778
|
+
billingCycle: "monthly",
|
|
1779
|
+
// Not the installation this suite's other contracts
|
|
1780
|
+
// run under, and not a whole number of per cent —
|
|
1781
|
+
// a rate stored as an integer, or a currency taken
|
|
1782
|
+
// from a default, passes every other case here.
|
|
1783
|
+
currency: "CHF",
|
|
1784
|
+
taxRate: 8.1,
|
|
1785
|
+
taxAmount: 8.1,
|
|
1786
|
+
minimumTermUntil: null,
|
|
1787
|
+
featuresSnapshot: [],
|
|
1788
|
+
quotaEffectsSnapshot: {},
|
|
1789
|
+
metadata: null
|
|
1790
|
+
},
|
|
1791
|
+
{
|
|
1792
|
+
kind: "discount",
|
|
1793
|
+
sourceKey: "WELCOME10",
|
|
1794
|
+
sourceVersionId: null,
|
|
1795
|
+
titleSnapshot: "Welcome discount",
|
|
1796
|
+
descriptionSnapshot: null,
|
|
1797
|
+
quantity: 1,
|
|
1798
|
+
unit: null,
|
|
1799
|
+
priceNet: -10,
|
|
1800
|
+
priceGross: -10.81,
|
|
1801
|
+
billingCycle: "monthly",
|
|
1802
|
+
currency: "CHF",
|
|
1803
|
+
taxRate: 8.1,
|
|
1804
|
+
taxAmount: -0.81,
|
|
1805
|
+
minimumTermUntil: null,
|
|
1806
|
+
featuresSnapshot: [],
|
|
1807
|
+
quotaEffectsSnapshot: {},
|
|
1808
|
+
metadata: null
|
|
1809
|
+
}
|
|
1810
|
+
]
|
|
1811
|
+
});
|
|
1812
|
+
const read = await contracts.findById(created.id);
|
|
1813
|
+
const plan = read?.lineItems.find((item) => item.kind === "plan");
|
|
1814
|
+
const discount = read?.lineItems.find((item) => item.kind === "discount");
|
|
1815
|
+
import_strict.default.ok(plan && discount, "both lines expected");
|
|
1816
|
+
import_strict.default.equal(plan.currency, "CHF");
|
|
1817
|
+
import_strict.default.equal(plan.taxRate, 8.1, "a fractional rate survives the column");
|
|
1818
|
+
import_strict.default.equal(plan.taxAmount, 8.1);
|
|
1819
|
+
import_strict.default.equal(discount.currency, "CHF");
|
|
1820
|
+
import_strict.default.equal(discount.taxRate, 8.1);
|
|
1821
|
+
import_strict.default.equal(discount.taxAmount, -0.81);
|
|
1822
|
+
for (const line of [plan, discount]) {
|
|
1823
|
+
import_strict.default.equal(
|
|
1824
|
+
Math.round((line.priceNet + line.taxAmount) * 100) / 100,
|
|
1825
|
+
line.priceGross,
|
|
1826
|
+
"net plus tax is gross, on every line"
|
|
1827
|
+
);
|
|
1828
|
+
}
|
|
1829
|
+
});
|
|
1830
|
+
(0, import_node_test.test)("a contract written on a transaction is undone with it, and found by its offer", async (t) => {
|
|
1831
|
+
const { adapter, seed } = harness;
|
|
1832
|
+
const contracts = adapter.subscriptionContractRepository;
|
|
1833
|
+
if (!contracts || !seed.createSubscriber) {
|
|
1834
|
+
missing(t, "subscriptionContracts");
|
|
1835
|
+
return;
|
|
1836
|
+
}
|
|
1837
|
+
if (!adapter.capabilities.transactions) {
|
|
1838
|
+
t.skip("adapter declares no transaction capability");
|
|
1839
|
+
return;
|
|
1840
|
+
}
|
|
1841
|
+
const { subscriberId } = await seed.createSubscriber({ legalName: "Angebot GmbH" });
|
|
1842
|
+
const parties = partiesWith(subscriberId, "Angebot GmbH");
|
|
1843
|
+
await import_strict.default.rejects(
|
|
1844
|
+
adapter.transactionRunner.run(async (tx) => {
|
|
1845
|
+
await contracts.create(contractFromOffer("offer-rolled-back", parties), tx);
|
|
1846
|
+
throw new Error("the conclusion fails after the contract is written");
|
|
1847
|
+
})
|
|
1848
|
+
);
|
|
1849
|
+
import_strict.default.equal(
|
|
1850
|
+
await contracts.findByOriginalOfferId("offer-rolled-back"),
|
|
1851
|
+
null,
|
|
1852
|
+
"the contract outlived its transaction"
|
|
1853
|
+
);
|
|
1854
|
+
const kept = await adapter.transactionRunner.run(
|
|
1855
|
+
(tx) => contracts.create(contractFromOffer("offer-kept", parties), tx)
|
|
1856
|
+
);
|
|
1857
|
+
const found = await contracts.findByOriginalOfferId("offer-kept");
|
|
1858
|
+
import_strict.default.equal(found?.id, kept.id);
|
|
1859
|
+
import_strict.default.equal(found?.lineItems.length, 1, "with its lines");
|
|
1860
|
+
import_strict.default.equal(await contracts.findByOriginalOfferId("offer-nobody-concluded"), null);
|
|
1861
|
+
});
|
|
1862
|
+
(0, import_node_test.test)("a subscriber is created live for its tenant, and numbered by the database", async (t) => {
|
|
1863
|
+
const subscribers = harness.adapter.subscriberRepository;
|
|
1864
|
+
if (!subscribers) {
|
|
1865
|
+
missing(t, "subscribers");
|
|
1866
|
+
return;
|
|
1867
|
+
}
|
|
1868
|
+
const first = await subscribers.createForTenant({
|
|
1869
|
+
...subscriberFor("tenant-subscriber-first", "Erste Autohaus GmbH"),
|
|
1870
|
+
vatId: "DE123456789",
|
|
1871
|
+
addressLine1: "Hauptstra\xDFe 1",
|
|
1872
|
+
postalCode: "10115",
|
|
1873
|
+
city: "Berlin",
|
|
1874
|
+
country: "DE",
|
|
1875
|
+
invoiceEmail: "rechnung@erste.example",
|
|
1876
|
+
customerNumberPrefix: "K-"
|
|
1877
|
+
});
|
|
1878
|
+
const second = await subscribers.createForTenant(
|
|
1879
|
+
subscriberFor("tenant-subscriber-second", "Zweiter Verein e.V.")
|
|
1880
|
+
);
|
|
1881
|
+
import_strict.default.ok(first && second, "both tenants had no subscriber");
|
|
1882
|
+
const numberOf = (customerNumber) => Number(customerNumber.replace("K-", ""));
|
|
1883
|
+
import_strict.default.ok(first.customerNumber.startsWith("K-"), first.customerNumber);
|
|
1884
|
+
import_strict.default.equal(numberOf(second.customerNumber), numberOf(first.customerNumber) + 1);
|
|
1885
|
+
import_strict.default.ok(numberOf(first.customerNumber) >= 10001, first.customerNumber);
|
|
1886
|
+
const found = await subscribers.findByTenantId("tenant-subscriber-first");
|
|
1887
|
+
import_strict.default.deepEqual(
|
|
1888
|
+
found && {
|
|
1889
|
+
id: found.id,
|
|
1890
|
+
customerNumber: found.customerNumber,
|
|
1891
|
+
tenantId: found.tenantId,
|
|
1892
|
+
legalName: found.legalName,
|
|
1893
|
+
vatId: found.vatId,
|
|
1894
|
+
taxNumber: found.taxNumber,
|
|
1895
|
+
addressLine1: found.addressLine1,
|
|
1896
|
+
addressLine2: found.addressLine2,
|
|
1897
|
+
postalCode: found.postalCode,
|
|
1898
|
+
city: found.city,
|
|
1899
|
+
country: found.country,
|
|
1900
|
+
invoiceEmail: found.invoiceEmail,
|
|
1901
|
+
migrated: found.migrated
|
|
1902
|
+
},
|
|
1903
|
+
{
|
|
1904
|
+
id: first.id,
|
|
1905
|
+
customerNumber: first.customerNumber,
|
|
1906
|
+
tenantId: "tenant-subscriber-first",
|
|
1907
|
+
legalName: "Erste Autohaus GmbH",
|
|
1908
|
+
vatId: "DE123456789",
|
|
1909
|
+
// The nullable half, so an adapter writing '' is caught too.
|
|
1910
|
+
taxNumber: null,
|
|
1911
|
+
addressLine1: "Hauptstra\xDFe 1",
|
|
1912
|
+
addressLine2: null,
|
|
1913
|
+
postalCode: "10115",
|
|
1914
|
+
city: "Berlin",
|
|
1915
|
+
country: "DE",
|
|
1916
|
+
invoiceEmail: "rechnung@erste.example",
|
|
1917
|
+
migrated: false
|
|
1918
|
+
}
|
|
1919
|
+
);
|
|
1920
|
+
import_strict.default.equal(
|
|
1921
|
+
(await subscribers.findById(second.id))?.tenantId,
|
|
1922
|
+
"tenant-subscriber-second"
|
|
1923
|
+
);
|
|
1924
|
+
import_strict.default.equal(await subscribers.findByTenantId("tenant-without-subscriber"), null);
|
|
1925
|
+
import_strict.default.equal(await subscribers.findById("subscriber-nobody-created"), null);
|
|
1926
|
+
});
|
|
1927
|
+
(0, import_node_test.test)("a tenant has one live subscriber: a second is refused, and the transaction survives it", async (t) => {
|
|
1928
|
+
const { adapter } = harness;
|
|
1929
|
+
const subscribers = adapter.subscriberRepository;
|
|
1930
|
+
if (!subscribers) {
|
|
1931
|
+
missing(t, "subscribers");
|
|
1932
|
+
return;
|
|
1933
|
+
}
|
|
1934
|
+
if (!adapter.capabilities.transactions) {
|
|
1935
|
+
t.skip("adapter declares no transaction capability");
|
|
1936
|
+
return;
|
|
1937
|
+
}
|
|
1938
|
+
const first = await subscribers.createForTenant(
|
|
1939
|
+
subscriberFor("tenant-one-subscriber", "Einzig GmbH")
|
|
1940
|
+
);
|
|
1941
|
+
const afterRefusal = await adapter.transactionRunner.run(async (tx) => {
|
|
1942
|
+
const refused = await subscribers.createForTenant(
|
|
1943
|
+
subscriberFor("tenant-one-subscriber", "Doppelt GmbH"),
|
|
1944
|
+
tx
|
|
1945
|
+
);
|
|
1946
|
+
import_strict.default.equal(refused, null, "a second live subscriber was created");
|
|
1947
|
+
return subscribers.createForTenant(
|
|
1948
|
+
subscriberFor("tenant-after-refusal", "Danach GmbH"),
|
|
1949
|
+
tx
|
|
1950
|
+
);
|
|
1951
|
+
});
|
|
1952
|
+
import_strict.default.equal(
|
|
1953
|
+
(await subscribers.findByTenantId("tenant-one-subscriber"))?.id,
|
|
1954
|
+
first?.id
|
|
1955
|
+
);
|
|
1956
|
+
import_strict.default.equal(
|
|
1957
|
+
(await subscribers.findByTenantId("tenant-after-refusal"))?.id,
|
|
1958
|
+
afterRefusal?.id,
|
|
1959
|
+
"the transaction did not survive the refusal"
|
|
1960
|
+
);
|
|
1961
|
+
});
|
|
1962
|
+
(0, import_node_test.test)("creating a subscriber for one tenant twice at once ends with one", async (t) => {
|
|
1963
|
+
const subscribers = harness.adapter.subscriberRepository;
|
|
1964
|
+
if (!subscribers) {
|
|
1965
|
+
missing(t, "subscribers");
|
|
1966
|
+
return;
|
|
1967
|
+
}
|
|
1968
|
+
const results = await Promise.all([
|
|
1969
|
+
subscribers.createForTenant(subscriberFor("tenant-at-once", "Gleichzeitig A")),
|
|
1970
|
+
subscribers.createForTenant(subscriberFor("tenant-at-once", "Gleichzeitig B"))
|
|
1971
|
+
]);
|
|
1972
|
+
const created = results.filter((result) => result !== null);
|
|
1973
|
+
import_strict.default.equal(created.length, 1, `${created.length} subscribers were created`);
|
|
1974
|
+
import_strict.default.equal((await subscribers.findByTenantId("tenant-at-once"))?.id, created[0].id);
|
|
1975
|
+
});
|
|
1976
|
+
(0, import_node_test.test)("a subscriber written on a transaction is undone with it", async (t) => {
|
|
1977
|
+
const { adapter } = harness;
|
|
1978
|
+
const subscribers = adapter.subscriberRepository;
|
|
1979
|
+
if (!subscribers) {
|
|
1980
|
+
missing(t, "subscribers");
|
|
1981
|
+
return;
|
|
1982
|
+
}
|
|
1983
|
+
if (!adapter.capabilities.transactions) {
|
|
1984
|
+
t.skip("adapter declares no transaction capability");
|
|
1985
|
+
return;
|
|
1986
|
+
}
|
|
1987
|
+
await import_strict.default.rejects(
|
|
1988
|
+
adapter.transactionRunner.run(async (tx) => {
|
|
1989
|
+
await subscribers.createForTenant(
|
|
1990
|
+
subscriberFor("tenant-rolled-back", "Zur\xFCckgerollt GmbH"),
|
|
1991
|
+
tx
|
|
1992
|
+
);
|
|
1993
|
+
throw new Error("the tenant is not created after all");
|
|
1994
|
+
})
|
|
1995
|
+
);
|
|
1996
|
+
import_strict.default.equal(await subscribers.findByTenantId("tenant-rolled-back"), null);
|
|
1997
|
+
import_strict.default.ok(
|
|
1998
|
+
await subscribers.createForTenant(
|
|
1999
|
+
subscriberFor("tenant-rolled-back", "Zur\xFCckgerollt GmbH")
|
|
2000
|
+
)
|
|
2001
|
+
);
|
|
2002
|
+
});
|
|
2003
|
+
(0, import_node_test.test)("a contact change writes what it names and keeps the rest", async (t) => {
|
|
2004
|
+
const subscribers = harness.adapter.subscriberRepository;
|
|
2005
|
+
if (!subscribers) {
|
|
2006
|
+
missing(t, "subscribers");
|
|
2007
|
+
return;
|
|
2008
|
+
}
|
|
2009
|
+
const created = await subscribers.createForTenant({
|
|
2010
|
+
...subscriberFor("tenant-contact", "Kontakt GmbH"),
|
|
2011
|
+
addressLine2: "Hinterhaus",
|
|
2012
|
+
city: "Hamburg"
|
|
2013
|
+
});
|
|
2014
|
+
import_strict.default.ok(created);
|
|
2015
|
+
const changed = await subscribers.updateContact(created.id, {
|
|
2016
|
+
city: "Bremen",
|
|
2017
|
+
addressLine2: null,
|
|
2018
|
+
invoiceEmail: "buchhaltung@kontakt.example"
|
|
2019
|
+
});
|
|
2020
|
+
import_strict.default.deepEqual(
|
|
2021
|
+
changed && [
|
|
2022
|
+
changed.city,
|
|
2023
|
+
changed.addressLine2,
|
|
2024
|
+
changed.invoiceEmail,
|
|
2025
|
+
changed.legalName,
|
|
2026
|
+
changed.tenantId
|
|
2027
|
+
],
|
|
2028
|
+
["Bremen", null, "buchhaltung@kontakt.example", "Kontakt GmbH", "tenant-contact"]
|
|
2029
|
+
);
|
|
2030
|
+
import_strict.default.equal((await subscribers.findById(created.id))?.city, "Bremen");
|
|
2031
|
+
import_strict.default.equal(
|
|
2032
|
+
await subscribers.updateContact("subscriber-nobody-created", { city: "Kiel" }),
|
|
2033
|
+
null
|
|
2034
|
+
);
|
|
2035
|
+
});
|
|
2036
|
+
(0, import_node_test.test)("a correction records the values it replaced, and nothing when nothing moves", async (t) => {
|
|
2037
|
+
const subscribers = harness.adapter.subscriberRepository;
|
|
2038
|
+
if (!subscribers) {
|
|
2039
|
+
missing(t, "subscribers");
|
|
2040
|
+
return;
|
|
2041
|
+
}
|
|
2042
|
+
const created = await subscribers.createForTenant(
|
|
2043
|
+
subscriberFor("tenant-correction", "Mueller GmbH")
|
|
2044
|
+
);
|
|
2045
|
+
import_strict.default.ok(created);
|
|
2046
|
+
const at = (day) => new Date(Date.UTC(2026, 8, day));
|
|
2047
|
+
const first = await subscribers.correctIdentity(created.id, {
|
|
2048
|
+
corrected: { legalName: "M\xFCller GmbH", vatId: "DE123456789", taxNumber: null },
|
|
2049
|
+
reason: "Umlaut lost when the registration was typed",
|
|
2050
|
+
correctedBy: "operator:anna",
|
|
2051
|
+
correctedAt: at(1)
|
|
2052
|
+
});
|
|
2053
|
+
import_strict.default.deepEqual(
|
|
2054
|
+
first?.correction && {
|
|
2055
|
+
previous: first.correction.previous,
|
|
2056
|
+
corrected: first.correction.corrected,
|
|
2057
|
+
reason: first.correction.reason,
|
|
2058
|
+
correctedBy: first.correction.correctedBy,
|
|
2059
|
+
correctedAt: first.correction.correctedAt.getTime()
|
|
2060
|
+
},
|
|
2061
|
+
{
|
|
2062
|
+
previous: { legalName: "Mueller GmbH", vatId: null },
|
|
2063
|
+
corrected: { legalName: "M\xFCller GmbH", vatId: "DE123456789" },
|
|
2064
|
+
reason: "Umlaut lost when the registration was typed",
|
|
2065
|
+
correctedBy: "operator:anna",
|
|
2066
|
+
correctedAt: at(1).getTime()
|
|
2067
|
+
}
|
|
2068
|
+
);
|
|
2069
|
+
import_strict.default.equal(first?.subscriber.legalName, "M\xFCller GmbH");
|
|
2070
|
+
import_strict.default.equal((await subscribers.findById(created.id))?.vatId, "DE123456789");
|
|
2071
|
+
const unchanged = await subscribers.correctIdentity(created.id, {
|
|
2072
|
+
corrected: { legalName: "M\xFCller GmbH" },
|
|
2073
|
+
reason: "Clicked twice",
|
|
2074
|
+
correctedBy: "operator:anna",
|
|
2075
|
+
correctedAt: at(2)
|
|
2076
|
+
});
|
|
2077
|
+
import_strict.default.equal(
|
|
2078
|
+
unchanged?.correction,
|
|
2079
|
+
null,
|
|
2080
|
+
"a correction that moved nothing was recorded"
|
|
2081
|
+
);
|
|
2082
|
+
await subscribers.correctIdentity(created.id, {
|
|
2083
|
+
corrected: { vatId: "DE999999999" },
|
|
2084
|
+
reason: "Wrong VAT id on the first correction",
|
|
2085
|
+
correctedBy: "operator:ben",
|
|
2086
|
+
correctedAt: at(3)
|
|
2087
|
+
});
|
|
2088
|
+
const listed = await subscribers.listCorrections(created.id);
|
|
2089
|
+
import_strict.default.deepEqual(
|
|
2090
|
+
listed.map((correction) => correction.reason),
|
|
2091
|
+
[
|
|
2092
|
+
"Wrong VAT id on the first correction",
|
|
2093
|
+
"Umlaut lost when the registration was typed"
|
|
2094
|
+
],
|
|
2095
|
+
"corrections come back the latest first, and only the two that moved something"
|
|
2096
|
+
);
|
|
2097
|
+
import_strict.default.equal(
|
|
2098
|
+
await subscribers.correctIdentity("subscriber-nobody-created", {
|
|
2099
|
+
corrected: { legalName: "Niemand" },
|
|
2100
|
+
reason: "none",
|
|
2101
|
+
correctedBy: "operator:anna",
|
|
2102
|
+
correctedAt: at(4)
|
|
2103
|
+
}),
|
|
2104
|
+
null
|
|
2105
|
+
);
|
|
2106
|
+
});
|
|
2107
|
+
(0, import_node_test.test)("two corrections at once each record the value the other left behind", async (t) => {
|
|
2108
|
+
const { adapter } = harness;
|
|
2109
|
+
const subscribers = adapter.subscriberRepository;
|
|
2110
|
+
if (!subscribers) {
|
|
2111
|
+
missing(t, "subscribers");
|
|
2112
|
+
return;
|
|
2113
|
+
}
|
|
2114
|
+
if (!adapter.capabilities.transactions || !adapter.capabilities.pessimisticLocking) {
|
|
2115
|
+
t.skip("adapter declares no transactions or no row locks");
|
|
2116
|
+
return;
|
|
2117
|
+
}
|
|
2118
|
+
const created = await subscribers.createForTenant(
|
|
2119
|
+
subscriberFor("tenant-corrected-twice", "Original GmbH")
|
|
2120
|
+
);
|
|
2121
|
+
import_strict.default.ok(created);
|
|
2122
|
+
const correctTo = (legalName) => adapter.transactionRunner.run(async (tx) => {
|
|
2123
|
+
await subscribers.correctIdentity(
|
|
2124
|
+
created.id,
|
|
2125
|
+
{
|
|
2126
|
+
corrected: { legalName },
|
|
2127
|
+
reason: `to ${legalName}`,
|
|
2128
|
+
correctedBy: "operator:anna",
|
|
2129
|
+
correctedAt: /* @__PURE__ */ new Date()
|
|
2130
|
+
},
|
|
2131
|
+
tx
|
|
2132
|
+
);
|
|
2133
|
+
await sleep(LOCK_HOLD_MS);
|
|
2134
|
+
});
|
|
2135
|
+
await Promise.all([
|
|
2136
|
+
correctTo("Erste Korrektur GmbH"),
|
|
2137
|
+
correctTo("Zweite Korrektur GmbH")
|
|
2138
|
+
]);
|
|
2139
|
+
const listed = await subscribers.listCorrections(created.id);
|
|
2140
|
+
import_strict.default.equal(listed.length, 2);
|
|
2141
|
+
const replaced = listed.map((correction) => correction.previous.legalName).sort();
|
|
2142
|
+
const written = listed.map((correction) => correction.corrected.legalName);
|
|
2143
|
+
const current = (await subscribers.findById(created.id))?.legalName;
|
|
2144
|
+
import_strict.default.ok(replaced.includes("Original GmbH"), JSON.stringify(listed));
|
|
2145
|
+
const secondReplaced = replaced.find((name) => name !== "Original GmbH");
|
|
2146
|
+
import_strict.default.ok(
|
|
2147
|
+
secondReplaced !== void 0 && written.includes(secondReplaced),
|
|
2148
|
+
`a correction recorded a value it did not replace: ${JSON.stringify(listed)}`
|
|
2149
|
+
);
|
|
2150
|
+
import_strict.default.ok(current !== void 0 && written.includes(current));
|
|
2151
|
+
import_strict.default.notEqual(secondReplaced, current);
|
|
2152
|
+
});
|
|
2153
|
+
(0, import_node_test.test)("a contract keeps its subscriber's copy after the subscriber is corrected", async (t) => {
|
|
2154
|
+
const { adapter } = harness;
|
|
2155
|
+
const subscribers = adapter.subscriberRepository;
|
|
2156
|
+
const contracts = adapter.subscriptionContractRepository;
|
|
2157
|
+
if (!subscribers) {
|
|
2158
|
+
missing(t, "subscribers");
|
|
2159
|
+
return;
|
|
2160
|
+
}
|
|
2161
|
+
if (!contracts || !harness.seed.createSubscriber) {
|
|
2162
|
+
missing(t, "subscriptionContracts");
|
|
2163
|
+
return;
|
|
2164
|
+
}
|
|
2165
|
+
const subscriber = await subscribers.createForTenant(
|
|
2166
|
+
subscriberFor("tenant-copy-kept", "Vorher GmbH")
|
|
2167
|
+
);
|
|
2168
|
+
import_strict.default.ok(subscriber);
|
|
2169
|
+
const parties = partiesWith(subscriber.id, "Vorher GmbH");
|
|
2170
|
+
const contract = await contracts.create({
|
|
2171
|
+
...contractFromOffer("offer-copy-kept", parties),
|
|
2172
|
+
tenantId: "tenant-copy-kept"
|
|
2173
|
+
});
|
|
2174
|
+
await subscribers.correctIdentity(subscriber.id, {
|
|
2175
|
+
corrected: { legalName: "Nachher GmbH" },
|
|
2176
|
+
reason: "Change of name of the same company",
|
|
2177
|
+
correctedBy: "operator:anna",
|
|
2178
|
+
correctedAt: /* @__PURE__ */ new Date()
|
|
2179
|
+
});
|
|
2180
|
+
const readBack = await contracts.findById(contract.id);
|
|
2181
|
+
import_strict.default.equal(readBack?.subscriberId, subscriber.id);
|
|
2182
|
+
import_strict.default.equal(
|
|
2183
|
+
readBack?.subscriber.legalName,
|
|
2184
|
+
"Vorher GmbH",
|
|
2185
|
+
"the contract followed a correction of the live record"
|
|
2186
|
+
);
|
|
2187
|
+
});
|
|
2188
|
+
(0, import_node_test.test)("the contracts still running say which issuer each names", async (t) => {
|
|
2189
|
+
const { adapter } = harness;
|
|
2190
|
+
const contracts = adapter.subscriptionContractRepository;
|
|
2191
|
+
const createSubscriber = harness.seed.createSubscriber;
|
|
2192
|
+
if (!contracts || !createSubscriber) {
|
|
2193
|
+
missing(t, "subscriptionContracts");
|
|
2194
|
+
return;
|
|
2195
|
+
}
|
|
2196
|
+
const { subscriberId } = await createSubscriber({ legalName: "Meier GmbH" });
|
|
2197
|
+
const parties = partiesWith(subscriberId, "Meier GmbH");
|
|
2198
|
+
const written = async (offerId, overrides) => contracts.create({ ...contractFromOffer(offerId, parties), ...overrides });
|
|
2199
|
+
const oldest = await written("running-oldest", {
|
|
2200
|
+
effectiveFrom: /* @__PURE__ */ new Date("2026-01-01T00:00:00.000Z")
|
|
2201
|
+
});
|
|
2202
|
+
const scheduled = await written("running-scheduled", {
|
|
2203
|
+
effectiveFrom: /* @__PURE__ */ new Date("2026-02-01T00:00:00.000Z"),
|
|
2204
|
+
status: "scheduled"
|
|
2205
|
+
});
|
|
2206
|
+
const lapsed = await written("running-lapsed", {
|
|
2207
|
+
effectiveFrom: /* @__PURE__ */ new Date("2026-03-01T00:00:00.000Z"),
|
|
2208
|
+
effectiveUntil: /* @__PURE__ */ new Date("2026-04-01T00:00:00.000Z")
|
|
2209
|
+
});
|
|
2210
|
+
const ending = await written("running-ending", {
|
|
2211
|
+
effectiveFrom: /* @__PURE__ */ new Date("2026-04-01T00:00:00.000Z"),
|
|
2212
|
+
effectiveUntil: /* @__PURE__ */ new Date("2026-12-01T00:00:00.000Z")
|
|
2213
|
+
});
|
|
2214
|
+
const noIssuer = await written("running-no-issuer", {
|
|
2215
|
+
effectiveFrom: /* @__PURE__ */ new Date("2026-05-01T00:00:00.000Z"),
|
|
2216
|
+
parties: { ...parties, issuer: null }
|
|
2217
|
+
});
|
|
2218
|
+
await written("running-terminated", {
|
|
2219
|
+
effectiveFrom: /* @__PURE__ */ new Date("2025-01-01T00:00:00.000Z"),
|
|
2220
|
+
status: "terminated"
|
|
2221
|
+
});
|
|
2222
|
+
await written("running-superseded", {
|
|
2223
|
+
effectiveFrom: /* @__PURE__ */ new Date("2025-02-01T00:00:00.000Z"),
|
|
2224
|
+
status: "superseded"
|
|
2225
|
+
});
|
|
2226
|
+
const asOf = /* @__PURE__ */ new Date("2026-06-01T00:00:00.000Z");
|
|
2227
|
+
const listed = await contracts.listRunningIssuers(10, asOf);
|
|
2228
|
+
import_strict.default.equal(listed.total, 4, "a contract that ended or was ended is not running");
|
|
2229
|
+
import_strict.default.deepEqual(
|
|
2230
|
+
listed.contracts.map((row) => row.id),
|
|
2231
|
+
[oldest.id, scheduled.id, ending.id, noIssuer.id],
|
|
2232
|
+
"oldest first, and the one whose term ran out is not among them"
|
|
2233
|
+
);
|
|
2234
|
+
import_strict.default.ok(
|
|
2235
|
+
!listed.contracts.some((row) => row.id === lapsed.id),
|
|
2236
|
+
"a window that closed ends a contract, whatever its status still says"
|
|
2237
|
+
);
|
|
2238
|
+
const earlier = await contracts.listRunningIssuers(
|
|
2239
|
+
10,
|
|
2240
|
+
/* @__PURE__ */ new Date("2026-03-15T00:00:00.000Z")
|
|
2241
|
+
);
|
|
2242
|
+
import_strict.default.equal(earlier.total, 5);
|
|
2243
|
+
import_strict.default.ok(earlier.contracts.some((row) => row.id === lapsed.id));
|
|
2244
|
+
import_strict.default.equal(listed.contracts[0].tenantId, oldest.tenantId);
|
|
2245
|
+
import_strict.default.equal(
|
|
2246
|
+
listed.contracts[0].effectiveFrom.getTime(),
|
|
2247
|
+
(/* @__PURE__ */ new Date("2026-01-01T00:00:00.000Z")).getTime()
|
|
2248
|
+
);
|
|
2249
|
+
import_strict.default.equal(listed.contracts[0].issuerLegalName, "Example Software GmbH");
|
|
2250
|
+
import_strict.default.equal(
|
|
2251
|
+
listed.contracts[3].issuerLegalName,
|
|
2252
|
+
null,
|
|
2253
|
+
"a contract with no issuer copy says so rather than inventing one"
|
|
2254
|
+
);
|
|
2255
|
+
const capped = await contracts.listRunningIssuers(2, asOf);
|
|
2256
|
+
import_strict.default.equal(capped.total, 4, "the limit caps the list, not the count");
|
|
2257
|
+
import_strict.default.deepEqual(
|
|
2258
|
+
capped.contracts.map((row) => row.id),
|
|
2259
|
+
[oldest.id, scheduled.id]
|
|
2260
|
+
);
|
|
2261
|
+
const counted = await contracts.listRunningIssuers(0, asOf);
|
|
2262
|
+
import_strict.default.equal(counted.total, 4);
|
|
2263
|
+
import_strict.default.deepEqual(counted.contracts, []);
|
|
2264
|
+
});
|
|
2265
|
+
(0, import_node_test.test)("a gateway event is claimed once per account, and a duplicate leaves the transaction usable", async (t) => {
|
|
2266
|
+
const { adapter } = harness;
|
|
2267
|
+
const log = adapter.paymentEventLog;
|
|
2268
|
+
if (!log) {
|
|
2269
|
+
missing(t, "paymentEventLog");
|
|
2270
|
+
return;
|
|
2271
|
+
}
|
|
2272
|
+
const claims = await adapter.transactionRunner.run(async (tx) => [
|
|
2273
|
+
await log.claim(eventAt("stripe-main", "evt_1"), tx),
|
|
2274
|
+
await log.claim(eventAt("stripe-main", "evt_1"), tx),
|
|
2275
|
+
// The same identifier from another account is another event.
|
|
2276
|
+
await log.claim(eventAt("stripe-old", "evt_1"), tx),
|
|
2277
|
+
// A duplicate that raised would have aborted the transaction here.
|
|
2278
|
+
await log.claim(eventAt("stripe-main", "evt_2"), tx)
|
|
2279
|
+
]);
|
|
2280
|
+
import_strict.default.deepEqual(claims, [true, false, true, true]);
|
|
2281
|
+
const later = await adapter.transactionRunner.run(
|
|
2282
|
+
(tx) => log.claim(eventAt("stripe-main", "evt_1"), tx)
|
|
2283
|
+
);
|
|
2284
|
+
import_strict.default.equal(later, false, "a committed claim was claimed again");
|
|
2285
|
+
});
|
|
2286
|
+
(0, import_node_test.test)("one gateway session is confirmed once, however many events report it", async (t) => {
|
|
2287
|
+
const { adapter } = harness;
|
|
2288
|
+
const log = adapter.paymentEventLog;
|
|
2289
|
+
if (!log) {
|
|
2290
|
+
missing(t, "paymentEventLog");
|
|
2291
|
+
return;
|
|
2292
|
+
}
|
|
2293
|
+
const session = "cs_reported_twice";
|
|
2294
|
+
const claims = await adapter.transactionRunner.run(async (tx) => [
|
|
2295
|
+
await log.claim(
|
|
2296
|
+
eventAt("stripe-main", "evt_form_done", { sessionId: session }),
|
|
2297
|
+
tx
|
|
2298
|
+
),
|
|
2299
|
+
// The same session, reported again under another identifier:
|
|
2300
|
+
// recording it would set the session's payment method up twice.
|
|
2301
|
+
await log.claim(
|
|
2302
|
+
eventAt("stripe-main", "evt_method_on", { sessionId: session }),
|
|
2303
|
+
tx
|
|
2304
|
+
),
|
|
2305
|
+
// Another account's session of that name is another session.
|
|
2306
|
+
await log.claim(eventAt("stripe-old", "evt_elsewhere", { sessionId: session }), tx),
|
|
2307
|
+
// A kind that says nothing about the session being confirmed.
|
|
2308
|
+
await log.claim(
|
|
2309
|
+
eventAt("stripe-main", "evt_gave_up", {
|
|
2310
|
+
sessionId: session,
|
|
2311
|
+
kind: "payment-method-setup-failed"
|
|
2312
|
+
}),
|
|
2313
|
+
tx
|
|
2314
|
+
),
|
|
2315
|
+
// Events about no session at all do not collide with each other.
|
|
2316
|
+
await log.claim(
|
|
2317
|
+
eventAt("stripe-main", "evt_other_1", { sessionId: null, kind: "unhandled" }),
|
|
2318
|
+
tx
|
|
2319
|
+
),
|
|
2320
|
+
await log.claim(
|
|
2321
|
+
eventAt("stripe-main", "evt_other_2", { sessionId: null, kind: "unhandled" }),
|
|
2322
|
+
tx
|
|
2323
|
+
)
|
|
2324
|
+
]);
|
|
2325
|
+
import_strict.default.deepEqual(claims, [true, false, true, true, true, true]);
|
|
2326
|
+
const later = await adapter.transactionRunner.run(
|
|
2327
|
+
(tx) => log.claim(eventAt("stripe-main", "evt_late", { sessionId: session }), tx)
|
|
2328
|
+
);
|
|
2329
|
+
import_strict.default.equal(later, false, "a session already confirmed was confirmed again");
|
|
2330
|
+
});
|
|
2331
|
+
(0, import_node_test.test)("a claim rolled back with its transaction is free for the retry", async (t) => {
|
|
2332
|
+
const { adapter } = harness;
|
|
2333
|
+
const log = adapter.paymentEventLog;
|
|
2334
|
+
if (!log) {
|
|
2335
|
+
missing(t, "paymentEventLog");
|
|
2336
|
+
return;
|
|
2337
|
+
}
|
|
2338
|
+
await import_strict.default.rejects(
|
|
2339
|
+
adapter.transactionRunner.run(async (tx) => {
|
|
2340
|
+
import_strict.default.equal(await log.claim(eventAt("stripe-main", "evt_retry"), tx), true);
|
|
2341
|
+
throw new Error("recording what the event changes failed");
|
|
2342
|
+
}),
|
|
2343
|
+
/recording what the event changes failed/
|
|
2344
|
+
);
|
|
2345
|
+
const retry = await adapter.transactionRunner.run(
|
|
2346
|
+
(tx) => log.claim(eventAt("stripe-main", "evt_retry"), tx)
|
|
2347
|
+
);
|
|
2348
|
+
import_strict.default.equal(retry, true, "the retry was discarded as a duplicate");
|
|
2349
|
+
});
|
|
2350
|
+
(0, import_node_test.test)("a delivery that meets a claim still open waits for it, and answers by its outcome", async (t) => {
|
|
2351
|
+
const { adapter } = harness;
|
|
2352
|
+
const log = adapter.paymentEventLog;
|
|
2353
|
+
if (!log) {
|
|
2354
|
+
missing(t, "paymentEventLog");
|
|
2355
|
+
return;
|
|
2356
|
+
}
|
|
2357
|
+
if (!adapter.capabilities.pessimisticLocking) {
|
|
2358
|
+
t.skip(
|
|
2359
|
+
"adapter declares no pessimistic locking: an open claim cannot be waited on"
|
|
2360
|
+
);
|
|
2361
|
+
return;
|
|
2362
|
+
}
|
|
2363
|
+
const [committed, afterCommit] = await Promise.all([
|
|
2364
|
+
adapter.transactionRunner.run(async (tx) => {
|
|
2365
|
+
const claimed = await log.claim(eventAt("stripe-main", "evt_race"), tx);
|
|
2366
|
+
await sleep(LOCK_HOLD_MS);
|
|
2367
|
+
return claimed;
|
|
2368
|
+
}),
|
|
2369
|
+
sleep(LOCK_HOLD_MS / 3).then(
|
|
2370
|
+
() => adapter.transactionRunner.run(
|
|
2371
|
+
(tx) => log.claim(eventAt("stripe-main", "evt_race"), tx)
|
|
2372
|
+
)
|
|
2373
|
+
)
|
|
2374
|
+
]);
|
|
2375
|
+
import_strict.default.deepEqual([committed, afterCommit], [true, false]);
|
|
2376
|
+
const [rolledBack, afterRollback] = await Promise.allSettled([
|
|
2377
|
+
adapter.transactionRunner.run(async (tx) => {
|
|
2378
|
+
await log.claim(eventAt("stripe-main", "evt_race_back"), tx);
|
|
2379
|
+
await sleep(LOCK_HOLD_MS);
|
|
2380
|
+
throw new Error("the first delivery failed");
|
|
2381
|
+
}),
|
|
2382
|
+
sleep(LOCK_HOLD_MS / 3).then(
|
|
2383
|
+
() => adapter.transactionRunner.run(
|
|
2384
|
+
(tx) => log.claim(eventAt("stripe-main", "evt_race_back"), tx)
|
|
2385
|
+
)
|
|
2386
|
+
)
|
|
2387
|
+
]);
|
|
2388
|
+
import_strict.default.equal(rolledBack.status, "rejected");
|
|
2389
|
+
import_strict.default.deepEqual(afterRollback, { status: "fulfilled", value: true });
|
|
2390
|
+
});
|
|
2391
|
+
(0, import_node_test.test)("an event that changed nothing gives its session back, and stays claimed itself", async (t) => {
|
|
2392
|
+
const { adapter } = harness;
|
|
2393
|
+
const log = adapter.paymentEventLog;
|
|
2394
|
+
if (!log) {
|
|
2395
|
+
missing(t, "paymentEventLog");
|
|
2396
|
+
return;
|
|
2397
|
+
}
|
|
2398
|
+
const session = "cs_nothing_to_do";
|
|
2399
|
+
const released = await adapter.transactionRunner.run(async (tx) => {
|
|
2400
|
+
await log.claim(eventAt("stripe-main", "evt_missed", { sessionId: session }), tx);
|
|
2401
|
+
await log.releaseSession("stripe-main", "evt_missed", tx);
|
|
2402
|
+
return [
|
|
2403
|
+
// The next event about that session is handled …
|
|
2404
|
+
await log.claim(
|
|
2405
|
+
eventAt("stripe-main", "evt_correct", { sessionId: session }),
|
|
2406
|
+
tx
|
|
2407
|
+
),
|
|
2408
|
+
// … while the event that released it stays claimed.
|
|
2409
|
+
await log.claim(eventAt("stripe-main", "evt_missed", { sessionId: null }), tx)
|
|
2410
|
+
];
|
|
2411
|
+
});
|
|
2412
|
+
import_strict.default.deepEqual(released, [true, false]);
|
|
2413
|
+
const again = await adapter.transactionRunner.run(
|
|
2414
|
+
(tx) => log.claim(eventAt("stripe-main", "evt_after_correct", { sessionId: session }), tx)
|
|
2415
|
+
);
|
|
2416
|
+
import_strict.default.equal(again, false, "the session was confirmed and is not free again");
|
|
2417
|
+
});
|
|
2418
|
+
(0, import_node_test.test)("two events confirming one session at once end with one claim", async (t) => {
|
|
2419
|
+
const { adapter } = harness;
|
|
2420
|
+
const log = adapter.paymentEventLog;
|
|
2421
|
+
if (!log) {
|
|
2422
|
+
missing(t, "paymentEventLog");
|
|
2423
|
+
return;
|
|
2424
|
+
}
|
|
2425
|
+
if (!adapter.capabilities.pessimisticLocking) {
|
|
2426
|
+
t.skip(
|
|
2427
|
+
"adapter declares no pessimistic locking: an open claim cannot be waited on"
|
|
2428
|
+
);
|
|
2429
|
+
return;
|
|
2430
|
+
}
|
|
2431
|
+
const session = "cs_at_once";
|
|
2432
|
+
const [first, second] = await Promise.all([
|
|
2433
|
+
adapter.transactionRunner.run(async (tx) => {
|
|
2434
|
+
const claimed = await log.claim(
|
|
2435
|
+
eventAt("stripe-main", "evt_at_once_a", { sessionId: session }),
|
|
2436
|
+
tx
|
|
2437
|
+
);
|
|
2438
|
+
await sleep(LOCK_HOLD_MS);
|
|
2439
|
+
return claimed;
|
|
2440
|
+
}),
|
|
2441
|
+
sleep(LOCK_HOLD_MS / 3).then(
|
|
2442
|
+
() => adapter.transactionRunner.run(
|
|
2443
|
+
(tx) => log.claim(
|
|
2444
|
+
eventAt("stripe-main", "evt_at_once_b", { sessionId: session }),
|
|
2445
|
+
tx
|
|
2446
|
+
)
|
|
2447
|
+
)
|
|
2448
|
+
)
|
|
2449
|
+
]);
|
|
2450
|
+
import_strict.default.deepEqual([first, second], [true, false]);
|
|
2451
|
+
});
|
|
2452
|
+
(0, import_node_test.test)("a confirmed payment method becomes the subscriber's, with its references and masked details", async (t) => {
|
|
2453
|
+
const methods = harness.adapter.subscriberPaymentMethodRepository;
|
|
2454
|
+
const createSubscriber = harness.seed.createSubscriber;
|
|
2455
|
+
if (!methods || !createSubscriber) {
|
|
2456
|
+
missing(t, "subscriberPaymentMethods");
|
|
2457
|
+
return;
|
|
2458
|
+
}
|
|
2459
|
+
const { subscriberId } = await createSubscriber({ legalName: "Karte GmbH" });
|
|
2460
|
+
const debit = {
|
|
2461
|
+
...paymentMethodFor(subscriberId, "pm_sepa", "2026-09-01T10:00:00.000Z"),
|
|
2462
|
+
type: "sepa_debit",
|
|
2463
|
+
brand: null,
|
|
2464
|
+
last4: "3000",
|
|
2465
|
+
expiryMonth: null,
|
|
2466
|
+
expiryYear: null,
|
|
2467
|
+
country: "DE",
|
|
2468
|
+
bankCode: "37040044",
|
|
2469
|
+
mandateReference: "MANDATE-1"
|
|
2470
|
+
};
|
|
2471
|
+
const result = await methods.recordConfirmed(debit);
|
|
2472
|
+
import_strict.default.equal(result.outcome, "activated");
|
|
2473
|
+
const { id, createdAt, ...stored } = result.method;
|
|
2474
|
+
import_strict.default.ok(id);
|
|
2475
|
+
import_strict.default.ok(createdAt instanceof Date);
|
|
2476
|
+
import_strict.default.deepEqual(stored, { ...debit, status: "ACTIVE", replacedAt: null });
|
|
2477
|
+
import_strict.default.deepEqual(await methods.findActive(subscriberId), result.method);
|
|
2478
|
+
import_strict.default.deepEqual(
|
|
2479
|
+
await methods.findByReference("stripe-main", "pm_sepa"),
|
|
2480
|
+
result.method
|
|
2481
|
+
);
|
|
2482
|
+
import_strict.default.equal(await methods.findByReference("stripe-old", "pm_sepa"), null);
|
|
2483
|
+
const other = await createSubscriber({ legalName: "Second Customer GmbH" });
|
|
2484
|
+
import_strict.default.equal(await methods.findActive(other.subscriberId), null);
|
|
2485
|
+
});
|
|
2486
|
+
(0, import_node_test.test)("a newer payment method takes over, and the one it replaced stays as history", async (t) => {
|
|
2487
|
+
const methods = harness.adapter.subscriberPaymentMethodRepository;
|
|
2488
|
+
const createSubscriber = harness.seed.createSubscriber;
|
|
2489
|
+
if (!methods || !createSubscriber) {
|
|
2490
|
+
missing(t, "subscriberPaymentMethods");
|
|
2491
|
+
return;
|
|
2492
|
+
}
|
|
2493
|
+
const { subscriberId } = await createSubscriber({ legalName: "Wechsel GmbH" });
|
|
2494
|
+
await methods.recordConfirmed(
|
|
2495
|
+
paymentMethodFor(subscriberId, "pm_first", "2026-09-01T10:00:00.000Z")
|
|
2496
|
+
);
|
|
2497
|
+
const second = await methods.recordConfirmed(
|
|
2498
|
+
paymentMethodFor(subscriberId, "pm_second", "2026-09-02T10:00:00.000Z")
|
|
2499
|
+
);
|
|
2500
|
+
import_strict.default.equal(second.outcome, "activated");
|
|
2501
|
+
import_strict.default.equal((await methods.findActive(subscriberId))?.paymentMethodRef, "pm_second");
|
|
2502
|
+
const first = await methods.findByReference("stripe-main", "pm_first");
|
|
2503
|
+
import_strict.default.equal(first?.status, "REPLACED");
|
|
2504
|
+
import_strict.default.equal(first?.replacedAt?.toISOString(), "2026-09-02T10:00:00.000Z");
|
|
2505
|
+
});
|
|
2506
|
+
(0, import_node_test.test)("a confirmation recorded again is recognised, and changes nothing", async (t) => {
|
|
2507
|
+
const methods = harness.adapter.subscriberPaymentMethodRepository;
|
|
2508
|
+
const createSubscriber = harness.seed.createSubscriber;
|
|
2509
|
+
if (!methods || !createSubscriber) {
|
|
2510
|
+
missing(t, "subscriberPaymentMethods");
|
|
2511
|
+
return;
|
|
2512
|
+
}
|
|
2513
|
+
const { subscriberId } = await createSubscriber({ legalName: "Doppelt GmbH" });
|
|
2514
|
+
const confirmation = paymentMethodFor(
|
|
2515
|
+
subscriberId,
|
|
2516
|
+
"pm_twice",
|
|
2517
|
+
"2026-09-01T10:00:00.000Z"
|
|
2518
|
+
);
|
|
2519
|
+
const first = await methods.recordConfirmed(confirmation);
|
|
2520
|
+
const again = await methods.recordConfirmed({
|
|
2521
|
+
...confirmation,
|
|
2522
|
+
confirmedAt: /* @__PURE__ */ new Date("2026-09-03T10:00:00.000Z")
|
|
2523
|
+
});
|
|
2524
|
+
import_strict.default.equal(again.outcome, "already-recorded");
|
|
2525
|
+
import_strict.default.deepEqual(again.method, first.method);
|
|
2526
|
+
import_strict.default.deepEqual(await methods.findActive(subscriberId), first.method);
|
|
2527
|
+
});
|
|
2528
|
+
(0, import_node_test.test)("a confirmation older than the payment method in use is recorded as already replaced", async (t) => {
|
|
2529
|
+
const methods = harness.adapter.subscriberPaymentMethodRepository;
|
|
2530
|
+
const createSubscriber = harness.seed.createSubscriber;
|
|
2531
|
+
if (!methods || !createSubscriber) {
|
|
2532
|
+
missing(t, "subscriberPaymentMethods");
|
|
2533
|
+
return;
|
|
2534
|
+
}
|
|
2535
|
+
const { subscriberId } = await createSubscriber({ legalName: "Reihenfolge GmbH" });
|
|
2536
|
+
await methods.recordConfirmed(
|
|
2537
|
+
paymentMethodFor(subscriberId, "pm_later", "2026-09-02T10:00:00.000Z")
|
|
2538
|
+
);
|
|
2539
|
+
const earlier = await methods.recordConfirmed(
|
|
2540
|
+
paymentMethodFor(subscriberId, "pm_earlier", "2026-09-01T10:00:00.000Z")
|
|
2541
|
+
);
|
|
2542
|
+
import_strict.default.equal(earlier.outcome, "superseded");
|
|
2543
|
+
import_strict.default.equal(earlier.method.status, "REPLACED");
|
|
2544
|
+
import_strict.default.equal(earlier.method.replacedAt?.toISOString(), "2026-09-02T10:00:00.000Z");
|
|
2545
|
+
import_strict.default.equal((await methods.findActive(subscriberId))?.paymentMethodRef, "pm_later");
|
|
2546
|
+
});
|
|
2547
|
+
(0, import_node_test.test)("two confirmations for one subscriber at once leave one payment method in use", async (t) => {
|
|
2548
|
+
const methods = harness.adapter.subscriberPaymentMethodRepository;
|
|
2549
|
+
const createSubscriber = harness.seed.createSubscriber;
|
|
2550
|
+
if (!methods || !createSubscriber) {
|
|
2551
|
+
missing(t, "subscriberPaymentMethods");
|
|
2552
|
+
return;
|
|
2553
|
+
}
|
|
2554
|
+
const { subscriberId } = await createSubscriber({ legalName: "Gleichzeitig GmbH" });
|
|
2555
|
+
const results = await Promise.all([
|
|
2556
|
+
methods.recordConfirmed(
|
|
2557
|
+
paymentMethodFor(subscriberId, "pm_at_once_a", "2026-09-01T10:00:00.000Z")
|
|
2558
|
+
),
|
|
2559
|
+
methods.recordConfirmed(
|
|
2560
|
+
paymentMethodFor(subscriberId, "pm_at_once_b", "2026-09-01T10:00:01.000Z")
|
|
2561
|
+
)
|
|
2562
|
+
]);
|
|
2563
|
+
for (const result of results) {
|
|
2564
|
+
import_strict.default.ok(
|
|
2565
|
+
result.outcome === "activated" || result.outcome === "superseded",
|
|
2566
|
+
result.outcome
|
|
2567
|
+
);
|
|
2568
|
+
}
|
|
2569
|
+
const statuses = await Promise.all(
|
|
2570
|
+
["pm_at_once_a", "pm_at_once_b"].map(
|
|
2571
|
+
async (ref) => (await methods.findByReference("stripe-main", ref))?.status
|
|
2572
|
+
)
|
|
2573
|
+
);
|
|
2574
|
+
import_strict.default.deepEqual(statuses, ["REPLACED", "ACTIVE"]);
|
|
2575
|
+
import_strict.default.equal(
|
|
2576
|
+
(await methods.findActive(subscriberId))?.paymentMethodRef,
|
|
2577
|
+
"pm_at_once_b"
|
|
2578
|
+
);
|
|
2579
|
+
});
|
|
2580
|
+
(0, import_node_test.test)("a payment method written on a transaction is undone with it", async (t) => {
|
|
2581
|
+
const { adapter } = harness;
|
|
2582
|
+
const methods = adapter.subscriberPaymentMethodRepository;
|
|
2583
|
+
const createSubscriber = harness.seed.createSubscriber;
|
|
2584
|
+
if (!methods || !createSubscriber) {
|
|
2585
|
+
missing(t, "subscriberPaymentMethods");
|
|
2586
|
+
return;
|
|
2587
|
+
}
|
|
2588
|
+
const { subscriberId } = await createSubscriber({ legalName: "Rolled Back GmbH" });
|
|
2589
|
+
await import_strict.default.rejects(
|
|
2590
|
+
adapter.transactionRunner.run(async (tx) => {
|
|
2591
|
+
await methods.recordConfirmed(
|
|
2592
|
+
paymentMethodFor(
|
|
2593
|
+
subscriberId,
|
|
2594
|
+
"pm_rolled_back",
|
|
2595
|
+
"2026-09-01T10:00:00.000Z"
|
|
2596
|
+
),
|
|
2597
|
+
tx
|
|
2598
|
+
);
|
|
2599
|
+
throw new Error("the activation failed after all");
|
|
2600
|
+
}),
|
|
2601
|
+
/the activation failed after all/
|
|
2602
|
+
);
|
|
2603
|
+
import_strict.default.equal(await methods.findActive(subscriberId), null);
|
|
2604
|
+
import_strict.default.equal(await methods.findByReference("stripe-main", "pm_rolled_back"), null);
|
|
2605
|
+
});
|
|
2606
|
+
(0, import_node_test.test)("a payment method for a subscriber that does not exist is refused", async (t) => {
|
|
2607
|
+
const methods = harness.adapter.subscriberPaymentMethodRepository;
|
|
2608
|
+
if (!methods || !harness.seed.createSubscriber) {
|
|
2609
|
+
missing(t, "subscriberPaymentMethods");
|
|
2610
|
+
return;
|
|
2611
|
+
}
|
|
2612
|
+
await import_strict.default.rejects(
|
|
2613
|
+
methods.recordConfirmed(
|
|
2614
|
+
paymentMethodFor(
|
|
2615
|
+
"subscriber-nobody-created",
|
|
2616
|
+
"pm_nobody",
|
|
2617
|
+
"2026-09-01T10:00:00.000Z"
|
|
2618
|
+
)
|
|
2619
|
+
)
|
|
2620
|
+
);
|
|
2621
|
+
import_strict.default.equal(await methods.findByReference("stripe-main", "pm_nobody"), null);
|
|
2622
|
+
});
|
|
2623
|
+
(0, import_node_test.test)("the accounts in use are those holding a payment method in use, each once", async (t) => {
|
|
2624
|
+
const methods = harness.adapter.subscriberPaymentMethodRepository;
|
|
2625
|
+
const createSubscriber = harness.seed.createSubscriber;
|
|
2626
|
+
if (!methods || !createSubscriber) {
|
|
2627
|
+
missing(t, "subscriberPaymentMethods");
|
|
2628
|
+
return;
|
|
2629
|
+
}
|
|
2630
|
+
import_strict.default.deepEqual(await methods.accountsInUse(), []);
|
|
2631
|
+
const moved = await createSubscriber({ legalName: "Umgezogen GmbH" });
|
|
2632
|
+
const stayed = await createSubscriber({ legalName: "Geblieben GmbH" });
|
|
2633
|
+
await methods.recordConfirmed(
|
|
2634
|
+
paymentMethodFor(
|
|
2635
|
+
moved.subscriberId,
|
|
2636
|
+
"pm_old_account",
|
|
2637
|
+
"2026-09-01T10:00:00.000Z",
|
|
2638
|
+
"stripe-old"
|
|
2639
|
+
)
|
|
2640
|
+
);
|
|
2641
|
+
await methods.recordConfirmed(
|
|
2642
|
+
paymentMethodFor(
|
|
2643
|
+
moved.subscriberId,
|
|
2644
|
+
"pm_new_account",
|
|
2645
|
+
"2026-09-02T10:00:00.000Z",
|
|
2646
|
+
"stripe-main"
|
|
2647
|
+
)
|
|
2648
|
+
);
|
|
2649
|
+
await methods.recordConfirmed(
|
|
2650
|
+
paymentMethodFor(
|
|
2651
|
+
stayed.subscriberId,
|
|
2652
|
+
"pm_main",
|
|
2653
|
+
"2026-09-02T10:00:00.000Z",
|
|
2654
|
+
"stripe-main"
|
|
2655
|
+
)
|
|
2656
|
+
);
|
|
2657
|
+
import_strict.default.deepEqual(await methods.accountsInUse(), ["stripe-main"]);
|
|
2658
|
+
});
|
|
2659
|
+
(0, import_node_test.test)("a setup is completed once, and only by the account, session and subscriber it was started with", async (t) => {
|
|
2660
|
+
const methods = harness.adapter.subscriberPaymentMethodRepository;
|
|
2661
|
+
const createSubscriber = harness.seed.createSubscriber;
|
|
2662
|
+
if (!methods || !createSubscriber) {
|
|
2663
|
+
missing(t, "subscriberPaymentMethods");
|
|
2664
|
+
return;
|
|
2665
|
+
}
|
|
2666
|
+
const { subscriberId } = await createSubscriber({ legalName: "Setup GmbH" });
|
|
2667
|
+
const other = await createSubscriber({ legalName: "Other Tenant GmbH" });
|
|
2668
|
+
await methods.recordSetup({
|
|
2669
|
+
subscriberId,
|
|
2670
|
+
gatewayAccount: "stripe-main",
|
|
2671
|
+
sessionRef: "cs_setup",
|
|
2672
|
+
customerRef: "cus_setup",
|
|
2673
|
+
startedAt: /* @__PURE__ */ new Date("2026-09-15T10:00:00.000Z")
|
|
2674
|
+
});
|
|
2675
|
+
const at = /* @__PURE__ */ new Date("2026-09-15T10:05:00.000Z");
|
|
2676
|
+
const match = { gatewayAccount: "stripe-main", sessionRef: "cs_setup", subscriberId };
|
|
2677
|
+
import_strict.default.equal(
|
|
2678
|
+
await methods.completeSetup({ ...match, subscriberId: other.subscriberId }, at),
|
|
2679
|
+
false
|
|
2680
|
+
);
|
|
2681
|
+
import_strict.default.equal(
|
|
2682
|
+
await methods.completeSetup({ ...match, gatewayAccount: "stripe-old" }, at),
|
|
2683
|
+
false
|
|
2684
|
+
);
|
|
2685
|
+
import_strict.default.equal(
|
|
2686
|
+
await methods.completeSetup({ ...match, sessionRef: "cs_nobody_opened" }, at),
|
|
2687
|
+
false
|
|
2688
|
+
);
|
|
2689
|
+
import_strict.default.equal(await methods.completeSetup(match, at), true);
|
|
2690
|
+
import_strict.default.equal(
|
|
2691
|
+
await methods.completeSetup(match, at),
|
|
2692
|
+
false,
|
|
2693
|
+
"a setup was completed twice"
|
|
2694
|
+
);
|
|
2695
|
+
});
|
|
2696
|
+
(0, import_node_test.test)("a setup completed on a transaction that rolls back is open again", async (t) => {
|
|
2697
|
+
const { adapter } = harness;
|
|
2698
|
+
const methods = adapter.subscriberPaymentMethodRepository;
|
|
2699
|
+
const createSubscriber = harness.seed.createSubscriber;
|
|
2700
|
+
if (!methods || !createSubscriber) {
|
|
2701
|
+
missing(t, "subscriberPaymentMethods");
|
|
2702
|
+
return;
|
|
2703
|
+
}
|
|
2704
|
+
const { subscriberId } = await createSubscriber({ legalName: "Retry GmbH" });
|
|
2705
|
+
const match = { gatewayAccount: "stripe-main", sessionRef: "cs_retry", subscriberId };
|
|
2706
|
+
await methods.recordSetup({
|
|
2707
|
+
...match,
|
|
2708
|
+
customerRef: "cus_retry",
|
|
2709
|
+
startedAt: /* @__PURE__ */ new Date("2026-09-15T10:00:00.000Z")
|
|
2710
|
+
});
|
|
2711
|
+
const at = /* @__PURE__ */ new Date("2026-09-15T10:05:00.000Z");
|
|
2712
|
+
await import_strict.default.rejects(
|
|
2713
|
+
adapter.transactionRunner.run(async (tx) => {
|
|
2714
|
+
import_strict.default.equal(await methods.completeSetup(match, at, tx), true);
|
|
2715
|
+
throw new Error("recording the payment method failed");
|
|
2716
|
+
}),
|
|
2717
|
+
/recording the payment method failed/
|
|
2718
|
+
);
|
|
2719
|
+
import_strict.default.equal(
|
|
2720
|
+
await methods.completeSetup(match, at),
|
|
2721
|
+
true,
|
|
2722
|
+
"the rollback kept the completion"
|
|
2723
|
+
);
|
|
2724
|
+
});
|
|
2725
|
+
(0, import_node_test.test)("one session is one setup, however often it is recorded", async (t) => {
|
|
2726
|
+
const methods = harness.adapter.subscriberPaymentMethodRepository;
|
|
2727
|
+
const createSubscriber = harness.seed.createSubscriber;
|
|
2728
|
+
if (!methods || !createSubscriber) {
|
|
2729
|
+
missing(t, "subscriberPaymentMethods");
|
|
2730
|
+
return;
|
|
2731
|
+
}
|
|
2732
|
+
const { subscriberId } = await createSubscriber({ legalName: "Once GmbH" });
|
|
2733
|
+
const setup = {
|
|
2734
|
+
subscriberId,
|
|
2735
|
+
gatewayAccount: "stripe-main",
|
|
2736
|
+
sessionRef: "cs_once",
|
|
2737
|
+
customerRef: "cus_once",
|
|
2738
|
+
startedAt: /* @__PURE__ */ new Date("2026-09-15T10:00:00.000Z")
|
|
2739
|
+
};
|
|
2740
|
+
await methods.recordSetup(setup);
|
|
2741
|
+
await import_strict.default.rejects(methods.recordSetup(setup));
|
|
2742
|
+
});
|
|
2743
|
+
(0, import_node_test.test)("an offer is consumed once, whoever asks first", async (t) => {
|
|
2744
|
+
const offers = harness.adapter.checkoutOfferRepository;
|
|
2745
|
+
if (!offers) {
|
|
2746
|
+
missing(t, "checkoutOffers");
|
|
2747
|
+
return;
|
|
2748
|
+
}
|
|
2749
|
+
const offer = await offers.create(OFFER);
|
|
2750
|
+
import_strict.default.equal(offer.status, "open");
|
|
2751
|
+
const attempts = await Promise.allSettled([
|
|
2752
|
+
offers.consume(offer.id),
|
|
2753
|
+
offers.consume(offer.id)
|
|
2754
|
+
]);
|
|
2755
|
+
import_strict.default.equal(
|
|
2756
|
+
attempts.filter((attempt) => attempt.status === "fulfilled").length,
|
|
2757
|
+
1,
|
|
2758
|
+
"exactly one consume wins"
|
|
2759
|
+
);
|
|
2760
|
+
const consumed = await offers.findById(offer.id);
|
|
2761
|
+
import_strict.default.equal(consumed?.status, "consumed");
|
|
2762
|
+
import_strict.default.ok(consumed?.consumedAt, "and it records when");
|
|
2763
|
+
await import_strict.default.rejects(() => offers.consume(offer.id), "a later consume is refused");
|
|
2764
|
+
import_strict.default.equal(
|
|
2765
|
+
(await offers.findById(offer.id))?.consumedAt,
|
|
2766
|
+
consumed.consumedAt,
|
|
2767
|
+
"and changes nothing"
|
|
2768
|
+
);
|
|
2769
|
+
});
|
|
2770
|
+
(0, import_node_test.test)("a consume on a transaction that rolls back leaves the offer open", async (t) => {
|
|
2771
|
+
const { adapter } = harness;
|
|
2772
|
+
const offers = adapter.checkoutOfferRepository;
|
|
2773
|
+
if (!offers) {
|
|
2774
|
+
missing(t, "checkoutOffers");
|
|
2775
|
+
return;
|
|
2776
|
+
}
|
|
2777
|
+
if (!adapter.capabilities.transactions) {
|
|
2778
|
+
t.skip("adapter declares no transaction capability");
|
|
2779
|
+
return;
|
|
2780
|
+
}
|
|
2781
|
+
const offer = await offers.create(OFFER);
|
|
2782
|
+
await import_strict.default.rejects(
|
|
2783
|
+
adapter.transactionRunner.run(async (tx) => {
|
|
2784
|
+
await offers.consume(offer.id, tx);
|
|
2785
|
+
throw new Error("the contract after the consume fails");
|
|
2786
|
+
})
|
|
2787
|
+
);
|
|
2788
|
+
const afterwards = await offers.findById(offer.id);
|
|
2789
|
+
import_strict.default.equal(afterwards?.status, "open", "the consume outlived its transaction");
|
|
2790
|
+
import_strict.default.equal(afterwards?.consumedAt, null);
|
|
2791
|
+
import_strict.default.equal((await offers.consume(offer.id)).status, "consumed");
|
|
2792
|
+
});
|
|
2793
|
+
const SETTINGS = {
|
|
2794
|
+
app: { name: "Demo" },
|
|
2795
|
+
currency: "EUR",
|
|
2796
|
+
vatRate: 19,
|
|
2797
|
+
tenantBilling: {
|
|
2798
|
+
cancellationNoticeDays: { monthly: 14, yearly: 90 },
|
|
2799
|
+
selfServiceBlockedPlans: { asTarget: ["ENTERPRISE"], asSource: [] }
|
|
2800
|
+
}
|
|
2801
|
+
};
|
|
2802
|
+
const SOURCE = "/srv/app/config/saas.yaml";
|
|
2803
|
+
const FIRST_START = /* @__PURE__ */ new Date("2026-09-01T06:30:00.000Z");
|
|
2804
|
+
const SECOND_START = /* @__PURE__ */ new Date("2026-09-02T06:30:00.000Z");
|
|
2805
|
+
const applied = (fingerprint, appliedAt, settings = SETTINGS) => ({ fingerprint, settings, source: SOURCE, appliedAt });
|
|
2806
|
+
const changeTo = (current, noticedAt, previous = SETTINGS) => ({ noticedAt, source: SOURCE, previous, current });
|
|
2807
|
+
const TWENTY = { ...SETTINGS, vatRate: 20 };
|
|
2808
|
+
const TWENTY_ONE = { ...SETTINGS, vatRate: 21 };
|
|
2809
|
+
(0, import_node_test.test)("no record before the first boot that could write one", async (t) => {
|
|
2810
|
+
const port = harness.adapter.appliedSettings;
|
|
2811
|
+
if (!port) {
|
|
2812
|
+
missing(t, "appliedSettings");
|
|
2813
|
+
return;
|
|
2814
|
+
}
|
|
2815
|
+
import_strict.default.equal(await port.readApplied(), null);
|
|
2816
|
+
import_strict.default.deepEqual(await port.listChanges(), []);
|
|
2817
|
+
});
|
|
2818
|
+
(0, import_node_test.test)("the record comes back as it was written \u2014 values, source and moment", async (t) => {
|
|
2819
|
+
const port = harness.adapter.appliedSettings;
|
|
2820
|
+
if (!port) {
|
|
2821
|
+
missing(t, "appliedSettings");
|
|
2822
|
+
return;
|
|
2823
|
+
}
|
|
2824
|
+
import_strict.default.equal(await port.writeApplied(applied("sha256-a", FIRST_START), null), true);
|
|
2825
|
+
const read = await port.readApplied();
|
|
2826
|
+
import_strict.default.ok(read, "a record expected");
|
|
2827
|
+
import_strict.default.equal(read.fingerprint, "sha256-a");
|
|
2828
|
+
import_strict.default.equal(read.source, SOURCE);
|
|
2829
|
+
import_strict.default.equal(read.appliedAt.toISOString(), FIRST_START.toISOString());
|
|
2830
|
+
import_strict.default.deepEqual(read.settings, SETTINGS);
|
|
2831
|
+
});
|
|
2832
|
+
(0, import_node_test.test)("writing again replaces the one row rather than adding a second", async (t) => {
|
|
2833
|
+
const port = harness.adapter.appliedSettings;
|
|
2834
|
+
if (!port) {
|
|
2835
|
+
missing(t, "appliedSettings");
|
|
2836
|
+
return;
|
|
2837
|
+
}
|
|
2838
|
+
await port.writeApplied(applied("sha256-a", FIRST_START), null);
|
|
2839
|
+
import_strict.default.equal(
|
|
2840
|
+
await port.writeApplied(applied("sha256-b", SECOND_START, TWENTY), "sha256-a"),
|
|
2841
|
+
true
|
|
2842
|
+
);
|
|
2843
|
+
const read = await port.readApplied();
|
|
2844
|
+
import_strict.default.equal(read?.fingerprint, "sha256-b");
|
|
2845
|
+
import_strict.default.equal(read?.appliedAt.toISOString(), SECOND_START.toISOString());
|
|
2846
|
+
import_strict.default.equal((read?.settings).vatRate, 20);
|
|
2847
|
+
});
|
|
2848
|
+
(0, import_node_test.test)("the first record is written once: a second writer that read none is refused", async (t) => {
|
|
2849
|
+
const port = harness.adapter.appliedSettings;
|
|
2850
|
+
if (!port) {
|
|
2851
|
+
missing(t, "appliedSettings");
|
|
2852
|
+
return;
|
|
2853
|
+
}
|
|
2854
|
+
import_strict.default.equal(await port.writeApplied(applied("sha256-a", FIRST_START), null), true);
|
|
2855
|
+
import_strict.default.equal(await port.writeApplied(applied("sha256-b", SECOND_START), null), false);
|
|
2856
|
+
import_strict.default.equal((await port.readApplied())?.fingerprint, "sha256-a");
|
|
2857
|
+
});
|
|
2858
|
+
(0, import_node_test.test)("a write guarded on a fingerprint the row no longer carries is refused", async (t) => {
|
|
2859
|
+
const port = harness.adapter.appliedSettings;
|
|
2860
|
+
if (!port) {
|
|
2861
|
+
missing(t, "appliedSettings");
|
|
2862
|
+
return;
|
|
2863
|
+
}
|
|
2864
|
+
await port.writeApplied(applied("sha256-a", FIRST_START), null);
|
|
2865
|
+
import_strict.default.equal(
|
|
2866
|
+
await port.writeApplied(applied("sha256-b", SECOND_START), "sha256-a"),
|
|
2867
|
+
true
|
|
2868
|
+
);
|
|
2869
|
+
import_strict.default.equal(
|
|
2870
|
+
await port.writeApplied(applied("sha256-c", SECOND_START), "sha256-a"),
|
|
2871
|
+
false
|
|
2872
|
+
);
|
|
2873
|
+
const read = await port.readApplied();
|
|
2874
|
+
import_strict.default.equal(
|
|
2875
|
+
read?.fingerprint,
|
|
2876
|
+
"sha256-b",
|
|
2877
|
+
"the row stands as the first writer left it"
|
|
2878
|
+
);
|
|
2879
|
+
import_strict.default.equal(read?.appliedAt.toISOString(), SECOND_START.toISOString());
|
|
2880
|
+
});
|
|
2881
|
+
(0, import_node_test.test)("a change lands with the record it supersedes, and is listed newest first", async (t) => {
|
|
2882
|
+
const port = harness.adapter.appliedSettings;
|
|
2883
|
+
if (!port) {
|
|
2884
|
+
missing(t, "appliedSettings");
|
|
2885
|
+
return;
|
|
2886
|
+
}
|
|
2887
|
+
await port.writeApplied(applied("sha256-a", FIRST_START), null);
|
|
2888
|
+
const first = await port.recordChange(
|
|
2889
|
+
changeTo(TWENTY, FIRST_START),
|
|
2890
|
+
applied("sha256-b", FIRST_START, TWENTY),
|
|
2891
|
+
"sha256-a"
|
|
2892
|
+
);
|
|
2893
|
+
const second = await port.recordChange(
|
|
2894
|
+
changeTo(TWENTY_ONE, SECOND_START, TWENTY),
|
|
2895
|
+
applied("sha256-c", SECOND_START, TWENTY_ONE),
|
|
2896
|
+
"sha256-b"
|
|
2897
|
+
);
|
|
2898
|
+
import_strict.default.ok(first && second, "both guards held");
|
|
2899
|
+
import_strict.default.ok(first.id && second.id && first.id !== second.id, "two distinct ids");
|
|
2900
|
+
import_strict.default.equal(first.acknowledgedAt, null);
|
|
2901
|
+
import_strict.default.equal(first.acknowledgedBy, null);
|
|
2902
|
+
import_strict.default.equal(
|
|
2903
|
+
(await port.readApplied())?.fingerprint,
|
|
2904
|
+
"sha256-c",
|
|
2905
|
+
"the record moved with the change"
|
|
2906
|
+
);
|
|
2907
|
+
const listed = await port.listChanges();
|
|
2908
|
+
import_strict.default.deepEqual(
|
|
2909
|
+
listed.map((c) => c.id),
|
|
2910
|
+
[second.id, first.id]
|
|
2911
|
+
);
|
|
2912
|
+
import_strict.default.deepEqual(listed[1].previous, SETTINGS);
|
|
2913
|
+
import_strict.default.equal(listed[1].current.vatRate, 20);
|
|
2914
|
+
import_strict.default.deepEqual(
|
|
2915
|
+
(await port.listChanges({ limit: 1 })).map((c) => c.id),
|
|
2916
|
+
[second.id]
|
|
2917
|
+
);
|
|
2918
|
+
});
|
|
2919
|
+
(0, import_node_test.test)("a change whose record has moved on is refused whole: no change, and the record as it was", async (t) => {
|
|
2920
|
+
const port = harness.adapter.appliedSettings;
|
|
2921
|
+
if (!port) {
|
|
2922
|
+
missing(t, "appliedSettings");
|
|
2923
|
+
return;
|
|
2924
|
+
}
|
|
2925
|
+
await port.writeApplied(applied("sha256-a", FIRST_START), null);
|
|
2926
|
+
const refused = await port.recordChange(
|
|
2927
|
+
changeTo(TWENTY, SECOND_START),
|
|
2928
|
+
applied("sha256-b", SECOND_START, TWENTY),
|
|
2929
|
+
"sha256-stale"
|
|
2930
|
+
);
|
|
2931
|
+
import_strict.default.equal(refused, null);
|
|
2932
|
+
import_strict.default.deepEqual(await port.listChanges(), [], "no change without its record");
|
|
2933
|
+
const read = await port.readApplied();
|
|
2934
|
+
import_strict.default.equal(read?.fingerprint, "sha256-a");
|
|
2935
|
+
import_strict.default.equal(read?.appliedAt.toISOString(), FIRST_START.toISOString());
|
|
2936
|
+
});
|
|
2937
|
+
(0, import_node_test.test)("starts noticing the same difference at once record it once", async (t) => {
|
|
2938
|
+
const port = harness.adapter.appliedSettings;
|
|
2939
|
+
if (!port) {
|
|
2940
|
+
missing(t, "appliedSettings");
|
|
2941
|
+
return;
|
|
2942
|
+
}
|
|
2943
|
+
await port.writeApplied(applied("sha256-a", FIRST_START), null);
|
|
2944
|
+
const attempts = await Promise.all(
|
|
2945
|
+
[1, 2, 3].map(
|
|
2946
|
+
() => port.recordChange(
|
|
2947
|
+
changeTo(TWENTY, SECOND_START),
|
|
2948
|
+
applied("sha256-b", SECOND_START, TWENTY),
|
|
2949
|
+
"sha256-a"
|
|
2950
|
+
)
|
|
2951
|
+
)
|
|
2952
|
+
);
|
|
2953
|
+
const recorded = attempts.filter((change) => change !== null);
|
|
2954
|
+
import_strict.default.equal(recorded.length, 1, "exactly one start records the change");
|
|
2955
|
+
import_strict.default.deepEqual(
|
|
2956
|
+
(await port.listChanges()).map((c) => c.id),
|
|
2957
|
+
[recorded[0]?.id]
|
|
2958
|
+
);
|
|
2959
|
+
import_strict.default.equal((await port.readApplied())?.fingerprint, "sha256-b");
|
|
2960
|
+
});
|
|
2961
|
+
(0, import_node_test.test)("several first starts write the record once", async (t) => {
|
|
2962
|
+
const port = harness.adapter.appliedSettings;
|
|
2963
|
+
if (!port) {
|
|
2964
|
+
missing(t, "appliedSettings");
|
|
2965
|
+
return;
|
|
2966
|
+
}
|
|
2967
|
+
const written = await Promise.all(
|
|
2968
|
+
[1, 2, 3].map(() => port.writeApplied(applied("sha256-a", FIRST_START), null))
|
|
2969
|
+
);
|
|
2970
|
+
import_strict.default.equal(written.filter(Boolean).length, 1, "exactly one start writes it");
|
|
2971
|
+
import_strict.default.equal((await port.readApplied())?.fingerprint, "sha256-a");
|
|
2972
|
+
});
|
|
2973
|
+
(0, import_node_test.test)("changes are listed in the order they were recorded, latest first \u2014 not by the moment they carry", async (t) => {
|
|
2974
|
+
const port = harness.adapter.appliedSettings;
|
|
2975
|
+
if (!port) {
|
|
2976
|
+
missing(t, "appliedSettings");
|
|
2977
|
+
return;
|
|
2978
|
+
}
|
|
2979
|
+
await port.writeApplied(applied("sha256-0", FIRST_START, {}), null);
|
|
2980
|
+
const earlierMove = await port.recordChange(
|
|
2981
|
+
changeTo({ vatRate: 1 }, SECOND_START, {}),
|
|
2982
|
+
applied("sha256-1", SECOND_START, { vatRate: 1 }),
|
|
2983
|
+
"sha256-0"
|
|
2984
|
+
);
|
|
2985
|
+
const laterMove = await port.recordChange(
|
|
2986
|
+
changeTo({ vatRate: 2 }, FIRST_START, { vatRate: 1 }),
|
|
2987
|
+
applied("sha256-2", FIRST_START, { vatRate: 2 }),
|
|
2988
|
+
"sha256-1"
|
|
2989
|
+
);
|
|
2990
|
+
import_strict.default.ok(earlierMove && laterMove, "both guards held");
|
|
2991
|
+
import_strict.default.deepEqual(
|
|
2992
|
+
(await port.listChanges()).map((c) => c.id),
|
|
2993
|
+
[laterMove.id, earlierMove.id]
|
|
2994
|
+
);
|
|
2995
|
+
import_strict.default.deepEqual(
|
|
2996
|
+
(await port.listChanges({ limit: 1 })).map((c) => c.id),
|
|
2997
|
+
[laterMove.id]
|
|
2998
|
+
);
|
|
2999
|
+
import_strict.default.deepEqual(
|
|
3000
|
+
(await port.listChanges()).map((c) => c.id),
|
|
3001
|
+
[laterMove.id, earlierMove.id],
|
|
3002
|
+
"the same order the second time it is asked"
|
|
3003
|
+
);
|
|
3004
|
+
});
|
|
3005
|
+
(0, import_node_test.test)("acknowledging a change is recorded once, and filters it out of what is owed", async (t) => {
|
|
3006
|
+
const port = harness.adapter.appliedSettings;
|
|
3007
|
+
if (!port) {
|
|
3008
|
+
missing(t, "appliedSettings");
|
|
3009
|
+
return;
|
|
3010
|
+
}
|
|
3011
|
+
await port.writeApplied(applied("sha256-a", FIRST_START), null);
|
|
3012
|
+
const change = await port.recordChange(
|
|
3013
|
+
changeTo(TWENTY, FIRST_START),
|
|
3014
|
+
applied("sha256-b", FIRST_START, TWENTY),
|
|
3015
|
+
"sha256-a"
|
|
3016
|
+
);
|
|
3017
|
+
const open = await port.recordChange(
|
|
3018
|
+
changeTo(TWENTY_ONE, SECOND_START, TWENTY),
|
|
3019
|
+
applied("sha256-c", SECOND_START, TWENTY_ONE),
|
|
3020
|
+
"sha256-b"
|
|
3021
|
+
);
|
|
3022
|
+
import_strict.default.ok(change && open, "both guards held");
|
|
3023
|
+
const seenAt = /* @__PURE__ */ new Date("2026-09-03T08:00:00.000Z");
|
|
3024
|
+
const acknowledged = await port.acknowledgeChange(
|
|
3025
|
+
change.id,
|
|
3026
|
+
"web:ops@example.com:s1",
|
|
3027
|
+
seenAt
|
|
3028
|
+
);
|
|
3029
|
+
import_strict.default.equal(acknowledged?.acknowledgedAt?.toISOString(), seenAt.toISOString());
|
|
3030
|
+
import_strict.default.equal(acknowledged?.acknowledgedBy, "web:ops@example.com:s1");
|
|
3031
|
+
const again = await port.acknowledgeChange(
|
|
3032
|
+
change.id,
|
|
3033
|
+
"web:other@example.com:s2",
|
|
3034
|
+
/* @__PURE__ */ new Date("2026-09-04T08:00:00.000Z")
|
|
3035
|
+
);
|
|
3036
|
+
import_strict.default.equal(again?.acknowledgedAt?.toISOString(), seenAt.toISOString());
|
|
3037
|
+
import_strict.default.equal(again?.acknowledgedBy, "web:ops@example.com:s1");
|
|
3038
|
+
import_strict.default.deepEqual(
|
|
3039
|
+
(await port.listChanges({ acknowledged: false })).map((c) => c.id),
|
|
3040
|
+
[open.id]
|
|
3041
|
+
);
|
|
3042
|
+
import_strict.default.deepEqual(
|
|
3043
|
+
(await port.listChanges({ acknowledged: true })).map((c) => c.id),
|
|
3044
|
+
[change.id]
|
|
3045
|
+
);
|
|
3046
|
+
import_strict.default.equal(
|
|
3047
|
+
await port.acknowledgeChange("no-such-change", "web:ops@example.com:s1", seenAt),
|
|
3048
|
+
null
|
|
750
3049
|
);
|
|
751
3050
|
});
|
|
752
3051
|
});
|