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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -94,7 +94,7 @@ persistenceAdapterContract({
94
94
  }),
95
95
  // The parts this adapter deliberately does not provide. A part named here
96
96
  // that the harness does provide fails the suite as well.
97
- gaps: ['subscriptionContracts', 'checkoutOffers', 'appliedSettings'],
97
+ gaps: ['subscriptionContracts', 'subscribers', 'checkoutOffers', 'appliedSettings'],
98
98
  });
99
99
  ```
100
100
 
package/dist/.build-stamp CHANGED
@@ -1 +1 @@
1
- 8fdc45ae34c5869710281576820410215b122b89385485ab7973ebfc8359157b
1
+ 1ca79840c535585e92be3594dc7e1b240dfd47e78e98d7577fe19a1cd7e51faf
package/dist/index.cjs CHANGED
@@ -53,9 +53,51 @@ var OFFER = {
53
53
  effectiveGross: 58.31
54
54
  }
55
55
  };
56
- function contractFromOffer(offerId) {
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 subscriberFor(tenantId, legalName) {
83
+ return {
84
+ tenantId,
85
+ legalName,
86
+ vatId: null,
87
+ taxNumber: null,
88
+ addressLine1: null,
89
+ addressLine2: null,
90
+ postalCode: null,
91
+ city: null,
92
+ country: null,
93
+ invoiceEmail: null,
94
+ customerNumberPrefix: ""
95
+ };
96
+ }
97
+ function contractFromOffer(offerId, parties) {
57
98
  return {
58
99
  tenantId: `tenant-${offerId}`,
100
+ parties,
59
101
  effectiveFrom: /* @__PURE__ */ new Date("2026-01-01T00:00:00.000Z"),
60
102
  originalOfferId: offerId,
61
103
  priceSnapshot: {
@@ -187,8 +229,12 @@ var CONTRACT_GAPS = {
187
229
  present: ({ adapter }) => Boolean(adapter.mfa)
188
230
  },
189
231
  subscriptionContracts: {
190
- reason: "adapter provides no SubscriptionContractRepository",
191
- present: ({ adapter }) => Boolean(adapter.subscriptionContractRepository)
232
+ reason: "adapter provides no SubscriptionContractRepository, or no subscriber seed for it",
233
+ present: ({ adapter, seed }) => Boolean(adapter.subscriptionContractRepository && seed.createSubscriber)
234
+ },
235
+ subscribers: {
236
+ reason: "adapter provides no SubscriberRepository",
237
+ present: ({ adapter }) => Boolean(adapter.subscriberRepository)
192
238
  },
193
239
  checkoutOffers: {
194
240
  reason: "adapter provides no CheckoutOfferRepository",
@@ -1383,14 +1429,18 @@ function persistenceAdapterContract(options) {
1383
1429
  });
1384
1430
  (0, import_node_test.test)("a contract keeps what was agreed, and ending it does not rewrite it", async (t) => {
1385
1431
  const contracts = harness.adapter.subscriptionContractRepository;
1386
- if (!contracts) {
1432
+ const createSubscriber = harness.seed.createSubscriber;
1433
+ if (!contracts || !createSubscriber) {
1387
1434
  missing(t, "subscriptionContracts");
1388
1435
  return;
1389
1436
  }
1390
1437
  const tenantId = "tenant-contract-lifecycle";
1391
1438
  const signedAt = /* @__PURE__ */ new Date("2026-01-01T00:00:00.000Z");
1439
+ const { subscriberId } = await createSubscriber({ legalName: "Meier GmbH" });
1440
+ const parties = partiesWith(subscriberId, "Meier GmbH");
1392
1441
  const created = await contracts.create({
1393
1442
  tenantId,
1443
+ parties,
1394
1444
  effectiveFrom: signedAt,
1395
1445
  priceSnapshot: {
1396
1446
  currency: "EUR",
@@ -1451,6 +1501,10 @@ function persistenceAdapterContract(options) {
1451
1501
  });
1452
1502
  import_strict.default.equal(created.status, "active");
1453
1503
  import_strict.default.equal(created.tenantId, tenantId);
1504
+ import_strict.default.equal(created.subscriberId, subscriberId);
1505
+ import_strict.default.deepEqual(created.subscriber, parties.subscriber);
1506
+ import_strict.default.deepEqual(created.issuer, parties.issuer);
1507
+ import_strict.default.equal(created.partiesMigrated, false);
1454
1508
  import_strict.default.equal(created.lineItems.length, 2);
1455
1509
  import_strict.default.deepEqual(created.originalBundleVersionIds, ["bundle-version-1"]);
1456
1510
  import_strict.default.deepEqual(created.termsSnapshot, { noticePeriodDays: 30 });
@@ -1483,6 +1537,8 @@ function persistenceAdapterContract(options) {
1483
1537
  import_strict.default.equal(bundleLine.metadata, null);
1484
1538
  const readBack = await contracts.findById(created.id);
1485
1539
  import_strict.default.ok(readBack, "contract expected by id");
1540
+ import_strict.default.deepEqual(readBack.subscriber, parties.subscriber, "the copy is stored");
1541
+ import_strict.default.deepEqual(readBack.issuer, parties.issuer);
1486
1542
  import_strict.default.equal(
1487
1543
  readBack.lineItems.length,
1488
1544
  2,
@@ -1539,11 +1595,14 @@ function persistenceAdapterContract(options) {
1539
1595
  });
1540
1596
  (0, import_node_test.test)("a successor takes over without erasing the contract it replaces", async (t) => {
1541
1597
  const contracts = harness.adapter.subscriptionContractRepository;
1542
- if (!contracts) {
1598
+ const createSubscriber = harness.seed.createSubscriber;
1599
+ if (!contracts || !createSubscriber) {
1543
1600
  missing(t, "subscriptionContracts");
1544
1601
  return;
1545
1602
  }
1546
1603
  const tenantId = "tenant-contract-succession";
1604
+ const { subscriberId } = await createSubscriber({ legalName: "Nachfolger KG" });
1605
+ const parties = partiesWith(subscriberId, "Nachfolger KG");
1547
1606
  const handover = /* @__PURE__ */ new Date("2026-04-01T00:00:00.000Z");
1548
1607
  const lineAt = (priceNet, priceGross) => ({
1549
1608
  kind: "plan",
@@ -1575,6 +1634,7 @@ function persistenceAdapterContract(options) {
1575
1634
  });
1576
1635
  const first = await contracts.create({
1577
1636
  tenantId,
1637
+ parties,
1578
1638
  effectiveFrom: /* @__PURE__ */ new Date("2026-01-01T00:00:00.000Z"),
1579
1639
  priceSnapshot: priceAt(19.9, 23.68),
1580
1640
  lineItems: [lineAt(19.9, 23.68)]
@@ -1592,6 +1652,7 @@ function persistenceAdapterContract(options) {
1592
1652
  });
1593
1653
  const second = await contracts.create({
1594
1654
  tenantId,
1655
+ parties,
1595
1656
  effectiveFrom: handover,
1596
1657
  priceSnapshot: priceAt(24.9, 29.63),
1597
1658
  lineItems: [lineAt(24.9, 29.63)]
@@ -1641,13 +1702,17 @@ function persistenceAdapterContract(options) {
1641
1702
  });
1642
1703
  (0, import_node_test.test)("a line keeps the currency and the tax it was booked with", async (t) => {
1643
1704
  const contracts = harness.adapter.subscriptionContractRepository;
1644
- if (!contracts) {
1705
+ const createSubscriber = harness.seed.createSubscriber;
1706
+ if (!contracts || !createSubscriber) {
1645
1707
  missing(t, "subscriptionContracts");
1646
1708
  return;
1647
1709
  }
1648
1710
  const tenantId = "tenant-contract-money-facts";
1711
+ const { subscriberId } = await createSubscriber({ legalName: "Z\xFCrich AG" });
1712
+ const parties = { ...partiesWith(subscriberId, "Z\xFCrich AG"), issuer: null };
1649
1713
  const created = await contracts.create({
1650
1714
  tenantId,
1715
+ parties,
1651
1716
  effectiveFrom: /* @__PURE__ */ new Date("2026-01-01T00:00:00.000Z"),
1652
1717
  priceSnapshot: {
1653
1718
  currency: "CHF",
@@ -1722,9 +1787,9 @@ function persistenceAdapterContract(options) {
1722
1787
  }
1723
1788
  });
1724
1789
  (0, import_node_test.test)("a contract written on a transaction is undone with it, and found by its offer", async (t) => {
1725
- const { adapter } = harness;
1790
+ const { adapter, seed } = harness;
1726
1791
  const contracts = adapter.subscriptionContractRepository;
1727
- if (!contracts) {
1792
+ if (!contracts || !seed.createSubscriber) {
1728
1793
  missing(t, "subscriptionContracts");
1729
1794
  return;
1730
1795
  }
@@ -1732,9 +1797,11 @@ function persistenceAdapterContract(options) {
1732
1797
  t.skip("adapter declares no transaction capability");
1733
1798
  return;
1734
1799
  }
1800
+ const { subscriberId } = await seed.createSubscriber({ legalName: "Angebot GmbH" });
1801
+ const parties = partiesWith(subscriberId, "Angebot GmbH");
1735
1802
  await import_strict.default.rejects(
1736
1803
  adapter.transactionRunner.run(async (tx) => {
1737
- await contracts.create(contractFromOffer("offer-rolled-back"), tx);
1804
+ await contracts.create(contractFromOffer("offer-rolled-back", parties), tx);
1738
1805
  throw new Error("the conclusion fails after the contract is written");
1739
1806
  })
1740
1807
  );
@@ -1744,13 +1811,339 @@ function persistenceAdapterContract(options) {
1744
1811
  "the contract outlived its transaction"
1745
1812
  );
1746
1813
  const kept = await adapter.transactionRunner.run(
1747
- (tx) => contracts.create(contractFromOffer("offer-kept"), tx)
1814
+ (tx) => contracts.create(contractFromOffer("offer-kept", parties), tx)
1748
1815
  );
1749
1816
  const found = await contracts.findByOriginalOfferId("offer-kept");
1750
1817
  import_strict.default.equal(found?.id, kept.id);
1751
1818
  import_strict.default.equal(found?.lineItems.length, 1, "with its lines");
1752
1819
  import_strict.default.equal(await contracts.findByOriginalOfferId("offer-nobody-concluded"), null);
1753
1820
  });
1821
+ (0, import_node_test.test)("a subscriber is created live for its tenant, and numbered by the database", async (t) => {
1822
+ const subscribers = harness.adapter.subscriberRepository;
1823
+ if (!subscribers) {
1824
+ missing(t, "subscribers");
1825
+ return;
1826
+ }
1827
+ const first = await subscribers.createForTenant({
1828
+ ...subscriberFor("tenant-subscriber-first", "Erste Autohaus GmbH"),
1829
+ vatId: "DE123456789",
1830
+ addressLine1: "Hauptstra\xDFe 1",
1831
+ postalCode: "10115",
1832
+ city: "Berlin",
1833
+ country: "DE",
1834
+ invoiceEmail: "rechnung@erste.example",
1835
+ customerNumberPrefix: "K-"
1836
+ });
1837
+ const second = await subscribers.createForTenant(
1838
+ subscriberFor("tenant-subscriber-second", "Zweiter Verein e.V.")
1839
+ );
1840
+ import_strict.default.ok(first && second, "both tenants had no subscriber");
1841
+ const numberOf = (customerNumber) => Number(customerNumber.replace("K-", ""));
1842
+ import_strict.default.ok(first.customerNumber.startsWith("K-"), first.customerNumber);
1843
+ import_strict.default.equal(numberOf(second.customerNumber), numberOf(first.customerNumber) + 1);
1844
+ import_strict.default.ok(numberOf(first.customerNumber) >= 10001, first.customerNumber);
1845
+ const found = await subscribers.findByTenantId("tenant-subscriber-first");
1846
+ import_strict.default.deepEqual(
1847
+ found && {
1848
+ id: found.id,
1849
+ customerNumber: found.customerNumber,
1850
+ tenantId: found.tenantId,
1851
+ legalName: found.legalName,
1852
+ vatId: found.vatId,
1853
+ taxNumber: found.taxNumber,
1854
+ addressLine1: found.addressLine1,
1855
+ addressLine2: found.addressLine2,
1856
+ postalCode: found.postalCode,
1857
+ city: found.city,
1858
+ country: found.country,
1859
+ invoiceEmail: found.invoiceEmail,
1860
+ migrated: found.migrated
1861
+ },
1862
+ {
1863
+ id: first.id,
1864
+ customerNumber: first.customerNumber,
1865
+ tenantId: "tenant-subscriber-first",
1866
+ legalName: "Erste Autohaus GmbH",
1867
+ vatId: "DE123456789",
1868
+ // The nullable half, so an adapter writing '' is caught too.
1869
+ taxNumber: null,
1870
+ addressLine1: "Hauptstra\xDFe 1",
1871
+ addressLine2: null,
1872
+ postalCode: "10115",
1873
+ city: "Berlin",
1874
+ country: "DE",
1875
+ invoiceEmail: "rechnung@erste.example",
1876
+ migrated: false
1877
+ }
1878
+ );
1879
+ import_strict.default.equal(
1880
+ (await subscribers.findById(second.id))?.tenantId,
1881
+ "tenant-subscriber-second"
1882
+ );
1883
+ import_strict.default.equal(await subscribers.findByTenantId("tenant-without-subscriber"), null);
1884
+ import_strict.default.equal(await subscribers.findById("subscriber-nobody-created"), null);
1885
+ });
1886
+ (0, import_node_test.test)("a tenant has one live subscriber: a second is refused, and the transaction survives it", async (t) => {
1887
+ const { adapter } = harness;
1888
+ const subscribers = adapter.subscriberRepository;
1889
+ if (!subscribers) {
1890
+ missing(t, "subscribers");
1891
+ return;
1892
+ }
1893
+ if (!adapter.capabilities.transactions) {
1894
+ t.skip("adapter declares no transaction capability");
1895
+ return;
1896
+ }
1897
+ const first = await subscribers.createForTenant(
1898
+ subscriberFor("tenant-one-subscriber", "Einzig GmbH")
1899
+ );
1900
+ const afterRefusal = await adapter.transactionRunner.run(async (tx) => {
1901
+ const refused = await subscribers.createForTenant(
1902
+ subscriberFor("tenant-one-subscriber", "Doppelt GmbH"),
1903
+ tx
1904
+ );
1905
+ import_strict.default.equal(refused, null, "a second live subscriber was created");
1906
+ return subscribers.createForTenant(
1907
+ subscriberFor("tenant-after-refusal", "Danach GmbH"),
1908
+ tx
1909
+ );
1910
+ });
1911
+ import_strict.default.equal(
1912
+ (await subscribers.findByTenantId("tenant-one-subscriber"))?.id,
1913
+ first?.id
1914
+ );
1915
+ import_strict.default.equal(
1916
+ (await subscribers.findByTenantId("tenant-after-refusal"))?.id,
1917
+ afterRefusal?.id,
1918
+ "the transaction did not survive the refusal"
1919
+ );
1920
+ });
1921
+ (0, import_node_test.test)("creating a subscriber for one tenant twice at once ends with one", async (t) => {
1922
+ const subscribers = harness.adapter.subscriberRepository;
1923
+ if (!subscribers) {
1924
+ missing(t, "subscribers");
1925
+ return;
1926
+ }
1927
+ const results = await Promise.all([
1928
+ subscribers.createForTenant(subscriberFor("tenant-at-once", "Gleichzeitig A")),
1929
+ subscribers.createForTenant(subscriberFor("tenant-at-once", "Gleichzeitig B"))
1930
+ ]);
1931
+ const created = results.filter((result) => result !== null);
1932
+ import_strict.default.equal(created.length, 1, `${created.length} subscribers were created`);
1933
+ import_strict.default.equal((await subscribers.findByTenantId("tenant-at-once"))?.id, created[0].id);
1934
+ });
1935
+ (0, import_node_test.test)("a subscriber written on a transaction is undone with it", async (t) => {
1936
+ const { adapter } = harness;
1937
+ const subscribers = adapter.subscriberRepository;
1938
+ if (!subscribers) {
1939
+ missing(t, "subscribers");
1940
+ return;
1941
+ }
1942
+ if (!adapter.capabilities.transactions) {
1943
+ t.skip("adapter declares no transaction capability");
1944
+ return;
1945
+ }
1946
+ await import_strict.default.rejects(
1947
+ adapter.transactionRunner.run(async (tx) => {
1948
+ await subscribers.createForTenant(
1949
+ subscriberFor("tenant-rolled-back", "Zur\xFCckgerollt GmbH"),
1950
+ tx
1951
+ );
1952
+ throw new Error("the tenant is not created after all");
1953
+ })
1954
+ );
1955
+ import_strict.default.equal(await subscribers.findByTenantId("tenant-rolled-back"), null);
1956
+ import_strict.default.ok(
1957
+ await subscribers.createForTenant(
1958
+ subscriberFor("tenant-rolled-back", "Zur\xFCckgerollt GmbH")
1959
+ )
1960
+ );
1961
+ });
1962
+ (0, import_node_test.test)("a contact change writes what it names and keeps the rest", async (t) => {
1963
+ const subscribers = harness.adapter.subscriberRepository;
1964
+ if (!subscribers) {
1965
+ missing(t, "subscribers");
1966
+ return;
1967
+ }
1968
+ const created = await subscribers.createForTenant({
1969
+ ...subscriberFor("tenant-contact", "Kontakt GmbH"),
1970
+ addressLine2: "Hinterhaus",
1971
+ city: "Hamburg"
1972
+ });
1973
+ import_strict.default.ok(created);
1974
+ const changed = await subscribers.updateContact(created.id, {
1975
+ city: "Bremen",
1976
+ addressLine2: null,
1977
+ invoiceEmail: "buchhaltung@kontakt.example"
1978
+ });
1979
+ import_strict.default.deepEqual(
1980
+ changed && [
1981
+ changed.city,
1982
+ changed.addressLine2,
1983
+ changed.invoiceEmail,
1984
+ changed.legalName,
1985
+ changed.tenantId
1986
+ ],
1987
+ ["Bremen", null, "buchhaltung@kontakt.example", "Kontakt GmbH", "tenant-contact"]
1988
+ );
1989
+ import_strict.default.equal((await subscribers.findById(created.id))?.city, "Bremen");
1990
+ import_strict.default.equal(
1991
+ await subscribers.updateContact("subscriber-nobody-created", { city: "Kiel" }),
1992
+ null
1993
+ );
1994
+ });
1995
+ (0, import_node_test.test)("a correction records the values it replaced, and nothing when nothing moves", async (t) => {
1996
+ const subscribers = harness.adapter.subscriberRepository;
1997
+ if (!subscribers) {
1998
+ missing(t, "subscribers");
1999
+ return;
2000
+ }
2001
+ const created = await subscribers.createForTenant(
2002
+ subscriberFor("tenant-correction", "Mueller GmbH")
2003
+ );
2004
+ import_strict.default.ok(created);
2005
+ const at = (day) => new Date(Date.UTC(2026, 8, day));
2006
+ const first = await subscribers.correctIdentity(created.id, {
2007
+ corrected: { legalName: "M\xFCller GmbH", vatId: "DE123456789", taxNumber: null },
2008
+ reason: "Umlaut lost when the registration was typed",
2009
+ correctedBy: "operator:anna",
2010
+ correctedAt: at(1)
2011
+ });
2012
+ import_strict.default.deepEqual(
2013
+ first?.correction && {
2014
+ previous: first.correction.previous,
2015
+ corrected: first.correction.corrected,
2016
+ reason: first.correction.reason,
2017
+ correctedBy: first.correction.correctedBy,
2018
+ correctedAt: first.correction.correctedAt.getTime()
2019
+ },
2020
+ {
2021
+ previous: { legalName: "Mueller GmbH", vatId: null },
2022
+ corrected: { legalName: "M\xFCller GmbH", vatId: "DE123456789" },
2023
+ reason: "Umlaut lost when the registration was typed",
2024
+ correctedBy: "operator:anna",
2025
+ correctedAt: at(1).getTime()
2026
+ }
2027
+ );
2028
+ import_strict.default.equal(first?.subscriber.legalName, "M\xFCller GmbH");
2029
+ import_strict.default.equal((await subscribers.findById(created.id))?.vatId, "DE123456789");
2030
+ const unchanged = await subscribers.correctIdentity(created.id, {
2031
+ corrected: { legalName: "M\xFCller GmbH" },
2032
+ reason: "Clicked twice",
2033
+ correctedBy: "operator:anna",
2034
+ correctedAt: at(2)
2035
+ });
2036
+ import_strict.default.equal(
2037
+ unchanged?.correction,
2038
+ null,
2039
+ "a correction that moved nothing was recorded"
2040
+ );
2041
+ await subscribers.correctIdentity(created.id, {
2042
+ corrected: { vatId: "DE999999999" },
2043
+ reason: "Wrong VAT id on the first correction",
2044
+ correctedBy: "operator:ben",
2045
+ correctedAt: at(3)
2046
+ });
2047
+ const listed = await subscribers.listCorrections(created.id);
2048
+ import_strict.default.deepEqual(
2049
+ listed.map((correction) => correction.reason),
2050
+ [
2051
+ "Wrong VAT id on the first correction",
2052
+ "Umlaut lost when the registration was typed"
2053
+ ],
2054
+ "corrections come back the latest first, and only the two that moved something"
2055
+ );
2056
+ import_strict.default.equal(
2057
+ await subscribers.correctIdentity("subscriber-nobody-created", {
2058
+ corrected: { legalName: "Niemand" },
2059
+ reason: "none",
2060
+ correctedBy: "operator:anna",
2061
+ correctedAt: at(4)
2062
+ }),
2063
+ null
2064
+ );
2065
+ });
2066
+ (0, import_node_test.test)("two corrections at once each record the value the other left behind", async (t) => {
2067
+ const { adapter } = harness;
2068
+ const subscribers = adapter.subscriberRepository;
2069
+ if (!subscribers) {
2070
+ missing(t, "subscribers");
2071
+ return;
2072
+ }
2073
+ if (!adapter.capabilities.transactions || !adapter.capabilities.pessimisticLocking) {
2074
+ t.skip("adapter declares no transactions or no row locks");
2075
+ return;
2076
+ }
2077
+ const created = await subscribers.createForTenant(
2078
+ subscriberFor("tenant-corrected-twice", "Original GmbH")
2079
+ );
2080
+ import_strict.default.ok(created);
2081
+ const correctTo = (legalName) => adapter.transactionRunner.run(async (tx) => {
2082
+ await subscribers.correctIdentity(
2083
+ created.id,
2084
+ {
2085
+ corrected: { legalName },
2086
+ reason: `to ${legalName}`,
2087
+ correctedBy: "operator:anna",
2088
+ correctedAt: /* @__PURE__ */ new Date()
2089
+ },
2090
+ tx
2091
+ );
2092
+ await sleep(LOCK_HOLD_MS);
2093
+ });
2094
+ await Promise.all([
2095
+ correctTo("Erste Korrektur GmbH"),
2096
+ correctTo("Zweite Korrektur GmbH")
2097
+ ]);
2098
+ const listed = await subscribers.listCorrections(created.id);
2099
+ import_strict.default.equal(listed.length, 2);
2100
+ const replaced = listed.map((correction) => correction.previous.legalName).sort();
2101
+ const written = listed.map((correction) => correction.corrected.legalName);
2102
+ const current = (await subscribers.findById(created.id))?.legalName;
2103
+ import_strict.default.ok(replaced.includes("Original GmbH"), JSON.stringify(listed));
2104
+ const secondReplaced = replaced.find((name) => name !== "Original GmbH");
2105
+ import_strict.default.ok(
2106
+ secondReplaced !== void 0 && written.includes(secondReplaced),
2107
+ `a correction recorded a value it did not replace: ${JSON.stringify(listed)}`
2108
+ );
2109
+ import_strict.default.ok(current !== void 0 && written.includes(current));
2110
+ import_strict.default.notEqual(secondReplaced, current);
2111
+ });
2112
+ (0, import_node_test.test)("a contract keeps its subscriber's copy after the subscriber is corrected", async (t) => {
2113
+ const { adapter } = harness;
2114
+ const subscribers = adapter.subscriberRepository;
2115
+ const contracts = adapter.subscriptionContractRepository;
2116
+ if (!subscribers) {
2117
+ missing(t, "subscribers");
2118
+ return;
2119
+ }
2120
+ if (!contracts || !harness.seed.createSubscriber) {
2121
+ missing(t, "subscriptionContracts");
2122
+ return;
2123
+ }
2124
+ const subscriber = await subscribers.createForTenant(
2125
+ subscriberFor("tenant-copy-kept", "Vorher GmbH")
2126
+ );
2127
+ import_strict.default.ok(subscriber);
2128
+ const parties = partiesWith(subscriber.id, "Vorher GmbH");
2129
+ const contract = await contracts.create({
2130
+ ...contractFromOffer("offer-copy-kept", parties),
2131
+ tenantId: "tenant-copy-kept"
2132
+ });
2133
+ await subscribers.correctIdentity(subscriber.id, {
2134
+ corrected: { legalName: "Nachher GmbH" },
2135
+ reason: "Change of name of the same company",
2136
+ correctedBy: "operator:anna",
2137
+ correctedAt: /* @__PURE__ */ new Date()
2138
+ });
2139
+ const readBack = await contracts.findById(contract.id);
2140
+ import_strict.default.equal(readBack?.subscriberId, subscriber.id);
2141
+ import_strict.default.equal(
2142
+ readBack?.subscriber.legalName,
2143
+ "Vorher GmbH",
2144
+ "the contract followed a correction of the live record"
2145
+ );
2146
+ });
1754
2147
  (0, import_node_test.test)("an offer is consumed once, whoever asks first", async (t) => {
1755
2148
  const offers = harness.adapter.checkoutOfferRepository;
1756
2149
  if (!offers) {
package/dist/index.d.cts CHANGED
@@ -1,4 +1,4 @@
1
- import { PersistenceCapabilities, TransactionRunner, SubscriptionRepository, PlanVersionRepository, PromoCodeRepository, PromoCodeRedemptionRepository, MfaPort, AuditPort, AuditQueryPort, SubscriptionContractRepository, CheckoutOfferRepository, TenantSubscriptionWritePort, BundleRepository, SubscriptionBundleRepository, PlanRepository, PromoSubscriptionLookup, AppliedSettingsPort } from '@saasicat/core';
1
+ import { PersistenceCapabilities, TransactionRunner, SubscriptionRepository, PlanVersionRepository, PromoCodeRepository, PromoCodeRedemptionRepository, MfaPort, AuditPort, AuditQueryPort, SubscriptionContractRepository, SubscriberRepository, CheckoutOfferRepository, TenantSubscriptionWritePort, BundleRepository, SubscriptionBundleRepository, PlanRepository, PromoSubscriptionLookup, AppliedSettingsPort } from '@saasicat/core';
2
2
 
3
3
  /**
4
4
  * Port instances under test. Required members define the minimum an adapter
@@ -18,6 +18,14 @@ interface ContractAdapterInstances {
18
18
  audit?: AuditPort;
19
19
  auditQuery?: AuditQueryPort;
20
20
  subscriptionContractRepository?: SubscriptionContractRepository;
21
+ /**
22
+ * Enables the subscriber scenarios: a customer number the database
23
+ * counts, one live subscriber per tenant however many callers create one
24
+ * at once, and a correction that records the values it replaced. A
25
+ * contract names its subscriber, so the contract scenarios write theirs
26
+ * through `seed.createSubscriber` rather than through this port.
27
+ */
28
+ subscriberRepository?: SubscriberRepository;
21
29
  /**
22
30
  * Enables the checkout offer scenarios: an offer is consumed once, and a
23
31
  * consume on a transaction that rolls back leaves it open. Neither shipped
@@ -124,6 +132,19 @@ interface ContractSeed {
124
132
  }): Promise<{
125
133
  promoCodeId: string;
126
134
  }>;
135
+ /**
136
+ * A subscriber row for a contract scenario to name, linked to no tenant.
137
+ *
138
+ * A fixture writer for the reason `createBundleVersion` is one: the
139
+ * subscriber repository is a subject of its own scenarios, and a contract
140
+ * scenario that failed because of it would say the wrong thing. Optional
141
+ * like the port it stands beside; the contract scenarios report it missing.
142
+ */
143
+ createSubscriber?(input: {
144
+ legalName: string;
145
+ }): Promise<{
146
+ subscriberId: string;
147
+ }>;
127
148
  }
128
149
  interface PersistenceContractHarness {
129
150
  adapter: ContractAdapterInstances;
@@ -139,7 +160,7 @@ interface PersistenceContractHarness {
139
160
  * Each names the members its scenarios need; `contract.ts` holds the list with
140
161
  * what each one checks.
141
162
  */
142
- type ContractGap = 'atomicPlanBinding' | 'atomicOnboarding' | 'promoCodes' | 'promoCodeRedemptions' | 'promoSubscriptionLookup' | 'planRepository' | 'planLifecycle' | 'planRetirement' | 'planVersionReads' | 'planVersionRetirement' | 'bundleRepository' | 'bundleValidity' | 'bundleDraftDiscard' | 'bundleDraftPublish' | 'bundleRetirement' | 'bundleBookings' | 'halfCancelledBookingSeed' | 'countByPlanVersionId' | 'audit' | 'mfa' | 'subscriptionContracts' | 'checkoutOffers' | 'appliedSettings';
163
+ type ContractGap = 'atomicPlanBinding' | 'atomicOnboarding' | 'promoCodes' | 'promoCodeRedemptions' | 'promoSubscriptionLookup' | 'planRepository' | 'planLifecycle' | 'planRetirement' | 'planVersionReads' | 'planVersionRetirement' | 'bundleRepository' | 'bundleValidity' | 'bundleDraftDiscard' | 'bundleDraftPublish' | 'bundleRetirement' | 'bundleBookings' | 'halfCancelledBookingSeed' | 'countByPlanVersionId' | 'audit' | 'mfa' | 'subscriptionContracts' | 'subscribers' | 'checkoutOffers' | 'appliedSettings';
143
164
  interface PersistenceAdapterContractOptions {
144
165
  /** Display name in the test output, e.g. `'adapter-prisma @ postgres16'`. */
145
166
  name: string;
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { PersistenceCapabilities, TransactionRunner, SubscriptionRepository, PlanVersionRepository, PromoCodeRepository, PromoCodeRedemptionRepository, MfaPort, AuditPort, AuditQueryPort, SubscriptionContractRepository, CheckoutOfferRepository, TenantSubscriptionWritePort, BundleRepository, SubscriptionBundleRepository, PlanRepository, PromoSubscriptionLookup, AppliedSettingsPort } from '@saasicat/core';
1
+ import { PersistenceCapabilities, TransactionRunner, SubscriptionRepository, PlanVersionRepository, PromoCodeRepository, PromoCodeRedemptionRepository, MfaPort, AuditPort, AuditQueryPort, SubscriptionContractRepository, SubscriberRepository, CheckoutOfferRepository, TenantSubscriptionWritePort, BundleRepository, SubscriptionBundleRepository, PlanRepository, PromoSubscriptionLookup, AppliedSettingsPort } from '@saasicat/core';
2
2
 
3
3
  /**
4
4
  * Port instances under test. Required members define the minimum an adapter
@@ -18,6 +18,14 @@ interface ContractAdapterInstances {
18
18
  audit?: AuditPort;
19
19
  auditQuery?: AuditQueryPort;
20
20
  subscriptionContractRepository?: SubscriptionContractRepository;
21
+ /**
22
+ * Enables the subscriber scenarios: a customer number the database
23
+ * counts, one live subscriber per tenant however many callers create one
24
+ * at once, and a correction that records the values it replaced. A
25
+ * contract names its subscriber, so the contract scenarios write theirs
26
+ * through `seed.createSubscriber` rather than through this port.
27
+ */
28
+ subscriberRepository?: SubscriberRepository;
21
29
  /**
22
30
  * Enables the checkout offer scenarios: an offer is consumed once, and a
23
31
  * consume on a transaction that rolls back leaves it open. Neither shipped
@@ -124,6 +132,19 @@ interface ContractSeed {
124
132
  }): Promise<{
125
133
  promoCodeId: string;
126
134
  }>;
135
+ /**
136
+ * A subscriber row for a contract scenario to name, linked to no tenant.
137
+ *
138
+ * A fixture writer for the reason `createBundleVersion` is one: the
139
+ * subscriber repository is a subject of its own scenarios, and a contract
140
+ * scenario that failed because of it would say the wrong thing. Optional
141
+ * like the port it stands beside; the contract scenarios report it missing.
142
+ */
143
+ createSubscriber?(input: {
144
+ legalName: string;
145
+ }): Promise<{
146
+ subscriberId: string;
147
+ }>;
127
148
  }
128
149
  interface PersistenceContractHarness {
129
150
  adapter: ContractAdapterInstances;
@@ -139,7 +160,7 @@ interface PersistenceContractHarness {
139
160
  * Each names the members its scenarios need; `contract.ts` holds the list with
140
161
  * what each one checks.
141
162
  */
142
- type ContractGap = 'atomicPlanBinding' | 'atomicOnboarding' | 'promoCodes' | 'promoCodeRedemptions' | 'promoSubscriptionLookup' | 'planRepository' | 'planLifecycle' | 'planRetirement' | 'planVersionReads' | 'planVersionRetirement' | 'bundleRepository' | 'bundleValidity' | 'bundleDraftDiscard' | 'bundleDraftPublish' | 'bundleRetirement' | 'bundleBookings' | 'halfCancelledBookingSeed' | 'countByPlanVersionId' | 'audit' | 'mfa' | 'subscriptionContracts' | 'checkoutOffers' | 'appliedSettings';
163
+ type ContractGap = 'atomicPlanBinding' | 'atomicOnboarding' | 'promoCodes' | 'promoCodeRedemptions' | 'promoSubscriptionLookup' | 'planRepository' | 'planLifecycle' | 'planRetirement' | 'planVersionReads' | 'planVersionRetirement' | 'bundleRepository' | 'bundleValidity' | 'bundleDraftDiscard' | 'bundleDraftPublish' | 'bundleRetirement' | 'bundleBookings' | 'halfCancelledBookingSeed' | 'countByPlanVersionId' | 'audit' | 'mfa' | 'subscriptionContracts' | 'subscribers' | 'checkoutOffers' | 'appliedSettings';
143
164
  interface PersistenceAdapterContractOptions {
144
165
  /** Display name in the test output, e.g. `'adapter-prisma @ postgres16'`. */
145
166
  name: string;
package/dist/index.js CHANGED
@@ -17,9 +17,51 @@ var OFFER = {
17
17
  effectiveGross: 58.31
18
18
  }
19
19
  };
20
- function contractFromOffer(offerId) {
20
+ function partiesWith(subscriberId, legalName) {
21
+ return {
22
+ subscriberId,
23
+ subscriber: {
24
+ customerNumber: "K-10001",
25
+ legalName,
26
+ vatId: "DE123456789",
27
+ taxNumber: null,
28
+ addressLine1: "Hauptstra\xDFe 1",
29
+ addressLine2: null,
30
+ postalCode: "10115",
31
+ city: "Berlin",
32
+ country: "DE"
33
+ },
34
+ issuer: {
35
+ legalName: "Example Software GmbH",
36
+ vatId: "DE987654321",
37
+ taxNumber: "12/345/67890",
38
+ addressLine1: "Werkstra\xDFe 5",
39
+ addressLine2: null,
40
+ postalCode: "80331",
41
+ city: "M\xFCnchen",
42
+ country: "DE"
43
+ }
44
+ };
45
+ }
46
+ function subscriberFor(tenantId, legalName) {
47
+ return {
48
+ tenantId,
49
+ legalName,
50
+ vatId: null,
51
+ taxNumber: null,
52
+ addressLine1: null,
53
+ addressLine2: null,
54
+ postalCode: null,
55
+ city: null,
56
+ country: null,
57
+ invoiceEmail: null,
58
+ customerNumberPrefix: ""
59
+ };
60
+ }
61
+ function contractFromOffer(offerId, parties) {
21
62
  return {
22
63
  tenantId: `tenant-${offerId}`,
64
+ parties,
23
65
  effectiveFrom: /* @__PURE__ */ new Date("2026-01-01T00:00:00.000Z"),
24
66
  originalOfferId: offerId,
25
67
  priceSnapshot: {
@@ -151,8 +193,12 @@ var CONTRACT_GAPS = {
151
193
  present: ({ adapter }) => Boolean(adapter.mfa)
152
194
  },
153
195
  subscriptionContracts: {
154
- reason: "adapter provides no SubscriptionContractRepository",
155
- present: ({ adapter }) => Boolean(adapter.subscriptionContractRepository)
196
+ reason: "adapter provides no SubscriptionContractRepository, or no subscriber seed for it",
197
+ present: ({ adapter, seed }) => Boolean(adapter.subscriptionContractRepository && seed.createSubscriber)
198
+ },
199
+ subscribers: {
200
+ reason: "adapter provides no SubscriberRepository",
201
+ present: ({ adapter }) => Boolean(adapter.subscriberRepository)
156
202
  },
157
203
  checkoutOffers: {
158
204
  reason: "adapter provides no CheckoutOfferRepository",
@@ -1347,14 +1393,18 @@ function persistenceAdapterContract(options) {
1347
1393
  });
1348
1394
  test("a contract keeps what was agreed, and ending it does not rewrite it", async (t) => {
1349
1395
  const contracts = harness.adapter.subscriptionContractRepository;
1350
- if (!contracts) {
1396
+ const createSubscriber = harness.seed.createSubscriber;
1397
+ if (!contracts || !createSubscriber) {
1351
1398
  missing(t, "subscriptionContracts");
1352
1399
  return;
1353
1400
  }
1354
1401
  const tenantId = "tenant-contract-lifecycle";
1355
1402
  const signedAt = /* @__PURE__ */ new Date("2026-01-01T00:00:00.000Z");
1403
+ const { subscriberId } = await createSubscriber({ legalName: "Meier GmbH" });
1404
+ const parties = partiesWith(subscriberId, "Meier GmbH");
1356
1405
  const created = await contracts.create({
1357
1406
  tenantId,
1407
+ parties,
1358
1408
  effectiveFrom: signedAt,
1359
1409
  priceSnapshot: {
1360
1410
  currency: "EUR",
@@ -1415,6 +1465,10 @@ function persistenceAdapterContract(options) {
1415
1465
  });
1416
1466
  assert.equal(created.status, "active");
1417
1467
  assert.equal(created.tenantId, tenantId);
1468
+ assert.equal(created.subscriberId, subscriberId);
1469
+ assert.deepEqual(created.subscriber, parties.subscriber);
1470
+ assert.deepEqual(created.issuer, parties.issuer);
1471
+ assert.equal(created.partiesMigrated, false);
1418
1472
  assert.equal(created.lineItems.length, 2);
1419
1473
  assert.deepEqual(created.originalBundleVersionIds, ["bundle-version-1"]);
1420
1474
  assert.deepEqual(created.termsSnapshot, { noticePeriodDays: 30 });
@@ -1447,6 +1501,8 @@ function persistenceAdapterContract(options) {
1447
1501
  assert.equal(bundleLine.metadata, null);
1448
1502
  const readBack = await contracts.findById(created.id);
1449
1503
  assert.ok(readBack, "contract expected by id");
1504
+ assert.deepEqual(readBack.subscriber, parties.subscriber, "the copy is stored");
1505
+ assert.deepEqual(readBack.issuer, parties.issuer);
1450
1506
  assert.equal(
1451
1507
  readBack.lineItems.length,
1452
1508
  2,
@@ -1503,11 +1559,14 @@ function persistenceAdapterContract(options) {
1503
1559
  });
1504
1560
  test("a successor takes over without erasing the contract it replaces", async (t) => {
1505
1561
  const contracts = harness.adapter.subscriptionContractRepository;
1506
- if (!contracts) {
1562
+ const createSubscriber = harness.seed.createSubscriber;
1563
+ if (!contracts || !createSubscriber) {
1507
1564
  missing(t, "subscriptionContracts");
1508
1565
  return;
1509
1566
  }
1510
1567
  const tenantId = "tenant-contract-succession";
1568
+ const { subscriberId } = await createSubscriber({ legalName: "Nachfolger KG" });
1569
+ const parties = partiesWith(subscriberId, "Nachfolger KG");
1511
1570
  const handover = /* @__PURE__ */ new Date("2026-04-01T00:00:00.000Z");
1512
1571
  const lineAt = (priceNet, priceGross) => ({
1513
1572
  kind: "plan",
@@ -1539,6 +1598,7 @@ function persistenceAdapterContract(options) {
1539
1598
  });
1540
1599
  const first = await contracts.create({
1541
1600
  tenantId,
1601
+ parties,
1542
1602
  effectiveFrom: /* @__PURE__ */ new Date("2026-01-01T00:00:00.000Z"),
1543
1603
  priceSnapshot: priceAt(19.9, 23.68),
1544
1604
  lineItems: [lineAt(19.9, 23.68)]
@@ -1556,6 +1616,7 @@ function persistenceAdapterContract(options) {
1556
1616
  });
1557
1617
  const second = await contracts.create({
1558
1618
  tenantId,
1619
+ parties,
1559
1620
  effectiveFrom: handover,
1560
1621
  priceSnapshot: priceAt(24.9, 29.63),
1561
1622
  lineItems: [lineAt(24.9, 29.63)]
@@ -1605,13 +1666,17 @@ function persistenceAdapterContract(options) {
1605
1666
  });
1606
1667
  test("a line keeps the currency and the tax it was booked with", async (t) => {
1607
1668
  const contracts = harness.adapter.subscriptionContractRepository;
1608
- if (!contracts) {
1669
+ const createSubscriber = harness.seed.createSubscriber;
1670
+ if (!contracts || !createSubscriber) {
1609
1671
  missing(t, "subscriptionContracts");
1610
1672
  return;
1611
1673
  }
1612
1674
  const tenantId = "tenant-contract-money-facts";
1675
+ const { subscriberId } = await createSubscriber({ legalName: "Z\xFCrich AG" });
1676
+ const parties = { ...partiesWith(subscriberId, "Z\xFCrich AG"), issuer: null };
1613
1677
  const created = await contracts.create({
1614
1678
  tenantId,
1679
+ parties,
1615
1680
  effectiveFrom: /* @__PURE__ */ new Date("2026-01-01T00:00:00.000Z"),
1616
1681
  priceSnapshot: {
1617
1682
  currency: "CHF",
@@ -1686,9 +1751,9 @@ function persistenceAdapterContract(options) {
1686
1751
  }
1687
1752
  });
1688
1753
  test("a contract written on a transaction is undone with it, and found by its offer", async (t) => {
1689
- const { adapter } = harness;
1754
+ const { adapter, seed } = harness;
1690
1755
  const contracts = adapter.subscriptionContractRepository;
1691
- if (!contracts) {
1756
+ if (!contracts || !seed.createSubscriber) {
1692
1757
  missing(t, "subscriptionContracts");
1693
1758
  return;
1694
1759
  }
@@ -1696,9 +1761,11 @@ function persistenceAdapterContract(options) {
1696
1761
  t.skip("adapter declares no transaction capability");
1697
1762
  return;
1698
1763
  }
1764
+ const { subscriberId } = await seed.createSubscriber({ legalName: "Angebot GmbH" });
1765
+ const parties = partiesWith(subscriberId, "Angebot GmbH");
1699
1766
  await assert.rejects(
1700
1767
  adapter.transactionRunner.run(async (tx) => {
1701
- await contracts.create(contractFromOffer("offer-rolled-back"), tx);
1768
+ await contracts.create(contractFromOffer("offer-rolled-back", parties), tx);
1702
1769
  throw new Error("the conclusion fails after the contract is written");
1703
1770
  })
1704
1771
  );
@@ -1708,13 +1775,339 @@ function persistenceAdapterContract(options) {
1708
1775
  "the contract outlived its transaction"
1709
1776
  );
1710
1777
  const kept = await adapter.transactionRunner.run(
1711
- (tx) => contracts.create(contractFromOffer("offer-kept"), tx)
1778
+ (tx) => contracts.create(contractFromOffer("offer-kept", parties), tx)
1712
1779
  );
1713
1780
  const found = await contracts.findByOriginalOfferId("offer-kept");
1714
1781
  assert.equal(found?.id, kept.id);
1715
1782
  assert.equal(found?.lineItems.length, 1, "with its lines");
1716
1783
  assert.equal(await contracts.findByOriginalOfferId("offer-nobody-concluded"), null);
1717
1784
  });
1785
+ test("a subscriber is created live for its tenant, and numbered by the database", async (t) => {
1786
+ const subscribers = harness.adapter.subscriberRepository;
1787
+ if (!subscribers) {
1788
+ missing(t, "subscribers");
1789
+ return;
1790
+ }
1791
+ const first = await subscribers.createForTenant({
1792
+ ...subscriberFor("tenant-subscriber-first", "Erste Autohaus GmbH"),
1793
+ vatId: "DE123456789",
1794
+ addressLine1: "Hauptstra\xDFe 1",
1795
+ postalCode: "10115",
1796
+ city: "Berlin",
1797
+ country: "DE",
1798
+ invoiceEmail: "rechnung@erste.example",
1799
+ customerNumberPrefix: "K-"
1800
+ });
1801
+ const second = await subscribers.createForTenant(
1802
+ subscriberFor("tenant-subscriber-second", "Zweiter Verein e.V.")
1803
+ );
1804
+ assert.ok(first && second, "both tenants had no subscriber");
1805
+ const numberOf = (customerNumber) => Number(customerNumber.replace("K-", ""));
1806
+ assert.ok(first.customerNumber.startsWith("K-"), first.customerNumber);
1807
+ assert.equal(numberOf(second.customerNumber), numberOf(first.customerNumber) + 1);
1808
+ assert.ok(numberOf(first.customerNumber) >= 10001, first.customerNumber);
1809
+ const found = await subscribers.findByTenantId("tenant-subscriber-first");
1810
+ assert.deepEqual(
1811
+ found && {
1812
+ id: found.id,
1813
+ customerNumber: found.customerNumber,
1814
+ tenantId: found.tenantId,
1815
+ legalName: found.legalName,
1816
+ vatId: found.vatId,
1817
+ taxNumber: found.taxNumber,
1818
+ addressLine1: found.addressLine1,
1819
+ addressLine2: found.addressLine2,
1820
+ postalCode: found.postalCode,
1821
+ city: found.city,
1822
+ country: found.country,
1823
+ invoiceEmail: found.invoiceEmail,
1824
+ migrated: found.migrated
1825
+ },
1826
+ {
1827
+ id: first.id,
1828
+ customerNumber: first.customerNumber,
1829
+ tenantId: "tenant-subscriber-first",
1830
+ legalName: "Erste Autohaus GmbH",
1831
+ vatId: "DE123456789",
1832
+ // The nullable half, so an adapter writing '' is caught too.
1833
+ taxNumber: null,
1834
+ addressLine1: "Hauptstra\xDFe 1",
1835
+ addressLine2: null,
1836
+ postalCode: "10115",
1837
+ city: "Berlin",
1838
+ country: "DE",
1839
+ invoiceEmail: "rechnung@erste.example",
1840
+ migrated: false
1841
+ }
1842
+ );
1843
+ assert.equal(
1844
+ (await subscribers.findById(second.id))?.tenantId,
1845
+ "tenant-subscriber-second"
1846
+ );
1847
+ assert.equal(await subscribers.findByTenantId("tenant-without-subscriber"), null);
1848
+ assert.equal(await subscribers.findById("subscriber-nobody-created"), null);
1849
+ });
1850
+ test("a tenant has one live subscriber: a second is refused, and the transaction survives it", async (t) => {
1851
+ const { adapter } = harness;
1852
+ const subscribers = adapter.subscriberRepository;
1853
+ if (!subscribers) {
1854
+ missing(t, "subscribers");
1855
+ return;
1856
+ }
1857
+ if (!adapter.capabilities.transactions) {
1858
+ t.skip("adapter declares no transaction capability");
1859
+ return;
1860
+ }
1861
+ const first = await subscribers.createForTenant(
1862
+ subscriberFor("tenant-one-subscriber", "Einzig GmbH")
1863
+ );
1864
+ const afterRefusal = await adapter.transactionRunner.run(async (tx) => {
1865
+ const refused = await subscribers.createForTenant(
1866
+ subscriberFor("tenant-one-subscriber", "Doppelt GmbH"),
1867
+ tx
1868
+ );
1869
+ assert.equal(refused, null, "a second live subscriber was created");
1870
+ return subscribers.createForTenant(
1871
+ subscriberFor("tenant-after-refusal", "Danach GmbH"),
1872
+ tx
1873
+ );
1874
+ });
1875
+ assert.equal(
1876
+ (await subscribers.findByTenantId("tenant-one-subscriber"))?.id,
1877
+ first?.id
1878
+ );
1879
+ assert.equal(
1880
+ (await subscribers.findByTenantId("tenant-after-refusal"))?.id,
1881
+ afterRefusal?.id,
1882
+ "the transaction did not survive the refusal"
1883
+ );
1884
+ });
1885
+ test("creating a subscriber for one tenant twice at once ends with one", async (t) => {
1886
+ const subscribers = harness.adapter.subscriberRepository;
1887
+ if (!subscribers) {
1888
+ missing(t, "subscribers");
1889
+ return;
1890
+ }
1891
+ const results = await Promise.all([
1892
+ subscribers.createForTenant(subscriberFor("tenant-at-once", "Gleichzeitig A")),
1893
+ subscribers.createForTenant(subscriberFor("tenant-at-once", "Gleichzeitig B"))
1894
+ ]);
1895
+ const created = results.filter((result) => result !== null);
1896
+ assert.equal(created.length, 1, `${created.length} subscribers were created`);
1897
+ assert.equal((await subscribers.findByTenantId("tenant-at-once"))?.id, created[0].id);
1898
+ });
1899
+ test("a subscriber written on a transaction is undone with it", async (t) => {
1900
+ const { adapter } = harness;
1901
+ const subscribers = adapter.subscriberRepository;
1902
+ if (!subscribers) {
1903
+ missing(t, "subscribers");
1904
+ return;
1905
+ }
1906
+ if (!adapter.capabilities.transactions) {
1907
+ t.skip("adapter declares no transaction capability");
1908
+ return;
1909
+ }
1910
+ await assert.rejects(
1911
+ adapter.transactionRunner.run(async (tx) => {
1912
+ await subscribers.createForTenant(
1913
+ subscriberFor("tenant-rolled-back", "Zur\xFCckgerollt GmbH"),
1914
+ tx
1915
+ );
1916
+ throw new Error("the tenant is not created after all");
1917
+ })
1918
+ );
1919
+ assert.equal(await subscribers.findByTenantId("tenant-rolled-back"), null);
1920
+ assert.ok(
1921
+ await subscribers.createForTenant(
1922
+ subscriberFor("tenant-rolled-back", "Zur\xFCckgerollt GmbH")
1923
+ )
1924
+ );
1925
+ });
1926
+ test("a contact change writes what it names and keeps the rest", async (t) => {
1927
+ const subscribers = harness.adapter.subscriberRepository;
1928
+ if (!subscribers) {
1929
+ missing(t, "subscribers");
1930
+ return;
1931
+ }
1932
+ const created = await subscribers.createForTenant({
1933
+ ...subscriberFor("tenant-contact", "Kontakt GmbH"),
1934
+ addressLine2: "Hinterhaus",
1935
+ city: "Hamburg"
1936
+ });
1937
+ assert.ok(created);
1938
+ const changed = await subscribers.updateContact(created.id, {
1939
+ city: "Bremen",
1940
+ addressLine2: null,
1941
+ invoiceEmail: "buchhaltung@kontakt.example"
1942
+ });
1943
+ assert.deepEqual(
1944
+ changed && [
1945
+ changed.city,
1946
+ changed.addressLine2,
1947
+ changed.invoiceEmail,
1948
+ changed.legalName,
1949
+ changed.tenantId
1950
+ ],
1951
+ ["Bremen", null, "buchhaltung@kontakt.example", "Kontakt GmbH", "tenant-contact"]
1952
+ );
1953
+ assert.equal((await subscribers.findById(created.id))?.city, "Bremen");
1954
+ assert.equal(
1955
+ await subscribers.updateContact("subscriber-nobody-created", { city: "Kiel" }),
1956
+ null
1957
+ );
1958
+ });
1959
+ test("a correction records the values it replaced, and nothing when nothing moves", async (t) => {
1960
+ const subscribers = harness.adapter.subscriberRepository;
1961
+ if (!subscribers) {
1962
+ missing(t, "subscribers");
1963
+ return;
1964
+ }
1965
+ const created = await subscribers.createForTenant(
1966
+ subscriberFor("tenant-correction", "Mueller GmbH")
1967
+ );
1968
+ assert.ok(created);
1969
+ const at = (day) => new Date(Date.UTC(2026, 8, day));
1970
+ const first = await subscribers.correctIdentity(created.id, {
1971
+ corrected: { legalName: "M\xFCller GmbH", vatId: "DE123456789", taxNumber: null },
1972
+ reason: "Umlaut lost when the registration was typed",
1973
+ correctedBy: "operator:anna",
1974
+ correctedAt: at(1)
1975
+ });
1976
+ assert.deepEqual(
1977
+ first?.correction && {
1978
+ previous: first.correction.previous,
1979
+ corrected: first.correction.corrected,
1980
+ reason: first.correction.reason,
1981
+ correctedBy: first.correction.correctedBy,
1982
+ correctedAt: first.correction.correctedAt.getTime()
1983
+ },
1984
+ {
1985
+ previous: { legalName: "Mueller GmbH", vatId: null },
1986
+ corrected: { legalName: "M\xFCller GmbH", vatId: "DE123456789" },
1987
+ reason: "Umlaut lost when the registration was typed",
1988
+ correctedBy: "operator:anna",
1989
+ correctedAt: at(1).getTime()
1990
+ }
1991
+ );
1992
+ assert.equal(first?.subscriber.legalName, "M\xFCller GmbH");
1993
+ assert.equal((await subscribers.findById(created.id))?.vatId, "DE123456789");
1994
+ const unchanged = await subscribers.correctIdentity(created.id, {
1995
+ corrected: { legalName: "M\xFCller GmbH" },
1996
+ reason: "Clicked twice",
1997
+ correctedBy: "operator:anna",
1998
+ correctedAt: at(2)
1999
+ });
2000
+ assert.equal(
2001
+ unchanged?.correction,
2002
+ null,
2003
+ "a correction that moved nothing was recorded"
2004
+ );
2005
+ await subscribers.correctIdentity(created.id, {
2006
+ corrected: { vatId: "DE999999999" },
2007
+ reason: "Wrong VAT id on the first correction",
2008
+ correctedBy: "operator:ben",
2009
+ correctedAt: at(3)
2010
+ });
2011
+ const listed = await subscribers.listCorrections(created.id);
2012
+ assert.deepEqual(
2013
+ listed.map((correction) => correction.reason),
2014
+ [
2015
+ "Wrong VAT id on the first correction",
2016
+ "Umlaut lost when the registration was typed"
2017
+ ],
2018
+ "corrections come back the latest first, and only the two that moved something"
2019
+ );
2020
+ assert.equal(
2021
+ await subscribers.correctIdentity("subscriber-nobody-created", {
2022
+ corrected: { legalName: "Niemand" },
2023
+ reason: "none",
2024
+ correctedBy: "operator:anna",
2025
+ correctedAt: at(4)
2026
+ }),
2027
+ null
2028
+ );
2029
+ });
2030
+ test("two corrections at once each record the value the other left behind", async (t) => {
2031
+ const { adapter } = harness;
2032
+ const subscribers = adapter.subscriberRepository;
2033
+ if (!subscribers) {
2034
+ missing(t, "subscribers");
2035
+ return;
2036
+ }
2037
+ if (!adapter.capabilities.transactions || !adapter.capabilities.pessimisticLocking) {
2038
+ t.skip("adapter declares no transactions or no row locks");
2039
+ return;
2040
+ }
2041
+ const created = await subscribers.createForTenant(
2042
+ subscriberFor("tenant-corrected-twice", "Original GmbH")
2043
+ );
2044
+ assert.ok(created);
2045
+ const correctTo = (legalName) => adapter.transactionRunner.run(async (tx) => {
2046
+ await subscribers.correctIdentity(
2047
+ created.id,
2048
+ {
2049
+ corrected: { legalName },
2050
+ reason: `to ${legalName}`,
2051
+ correctedBy: "operator:anna",
2052
+ correctedAt: /* @__PURE__ */ new Date()
2053
+ },
2054
+ tx
2055
+ );
2056
+ await sleep(LOCK_HOLD_MS);
2057
+ });
2058
+ await Promise.all([
2059
+ correctTo("Erste Korrektur GmbH"),
2060
+ correctTo("Zweite Korrektur GmbH")
2061
+ ]);
2062
+ const listed = await subscribers.listCorrections(created.id);
2063
+ assert.equal(listed.length, 2);
2064
+ const replaced = listed.map((correction) => correction.previous.legalName).sort();
2065
+ const written = listed.map((correction) => correction.corrected.legalName);
2066
+ const current = (await subscribers.findById(created.id))?.legalName;
2067
+ assert.ok(replaced.includes("Original GmbH"), JSON.stringify(listed));
2068
+ const secondReplaced = replaced.find((name) => name !== "Original GmbH");
2069
+ assert.ok(
2070
+ secondReplaced !== void 0 && written.includes(secondReplaced),
2071
+ `a correction recorded a value it did not replace: ${JSON.stringify(listed)}`
2072
+ );
2073
+ assert.ok(current !== void 0 && written.includes(current));
2074
+ assert.notEqual(secondReplaced, current);
2075
+ });
2076
+ test("a contract keeps its subscriber's copy after the subscriber is corrected", async (t) => {
2077
+ const { adapter } = harness;
2078
+ const subscribers = adapter.subscriberRepository;
2079
+ const contracts = adapter.subscriptionContractRepository;
2080
+ if (!subscribers) {
2081
+ missing(t, "subscribers");
2082
+ return;
2083
+ }
2084
+ if (!contracts || !harness.seed.createSubscriber) {
2085
+ missing(t, "subscriptionContracts");
2086
+ return;
2087
+ }
2088
+ const subscriber = await subscribers.createForTenant(
2089
+ subscriberFor("tenant-copy-kept", "Vorher GmbH")
2090
+ );
2091
+ assert.ok(subscriber);
2092
+ const parties = partiesWith(subscriber.id, "Vorher GmbH");
2093
+ const contract = await contracts.create({
2094
+ ...contractFromOffer("offer-copy-kept", parties),
2095
+ tenantId: "tenant-copy-kept"
2096
+ });
2097
+ await subscribers.correctIdentity(subscriber.id, {
2098
+ corrected: { legalName: "Nachher GmbH" },
2099
+ reason: "Change of name of the same company",
2100
+ correctedBy: "operator:anna",
2101
+ correctedAt: /* @__PURE__ */ new Date()
2102
+ });
2103
+ const readBack = await contracts.findById(contract.id);
2104
+ assert.equal(readBack?.subscriberId, subscriber.id);
2105
+ assert.equal(
2106
+ readBack?.subscriber.legalName,
2107
+ "Vorher GmbH",
2108
+ "the contract followed a correction of the live record"
2109
+ );
2110
+ });
1718
2111
  test("an offer is consumed once, whoever asks first", async (t) => {
1719
2112
  const offers = harness.adapter.checkoutOfferRepository;
1720
2113
  if (!offers) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saasicat/persistence-testing",
3
- "version": "1.0.0-rc.15",
3
+ "version": "1.0.0-rc.16",
4
4
  "description": "Contract test kit for SaaSiCat persistence adapters: one node:test suite that every adapter (Prisma, Drizzle, ...) must pass against a real database — locks, transaction rollback, atomic promo claims, tenant isolation, audit/MFA roundtrips.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",
@@ -23,7 +23,7 @@
23
23
  "dist"
24
24
  ],
25
25
  "dependencies": {
26
- "@saasicat/core": "^1.0.0-rc.15"
26
+ "@saasicat/core": "^1.0.0-rc.16"
27
27
  },
28
28
  "devDependencies": {
29
29
  "@types/node": "^25.6.0",