@fantastic.dev/better-stripe 0.10.2 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (140) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/README.md +220 -4
  3. package/dist/client/billing/balanceFunding.d.ts +36 -0
  4. package/dist/client/billing/balanceFunding.d.ts.map +1 -0
  5. package/dist/client/billing/balanceFunding.js +107 -0
  6. package/dist/client/billing/balanceFunding.js.map +1 -0
  7. package/dist/client/billing/invoicePreview.d.ts +28 -0
  8. package/dist/client/billing/invoicePreview.d.ts.map +1 -0
  9. package/dist/client/billing/invoicePreview.js +56 -0
  10. package/dist/client/billing/invoicePreview.js.map +1 -0
  11. package/dist/client/charge-currency-format.d.ts +3 -0
  12. package/dist/client/charge-currency-format.d.ts.map +1 -0
  13. package/dist/client/charge-currency-format.js +22 -0
  14. package/dist/client/charge-currency-format.js.map +1 -0
  15. package/dist/client/charge-currency.d.ts +14 -0
  16. package/dist/client/charge-currency.d.ts.map +1 -0
  17. package/dist/client/charge-currency.js +85 -0
  18. package/dist/client/charge-currency.js.map +1 -0
  19. package/dist/client/costRecovery.d.ts +115 -0
  20. package/dist/client/costRecovery.d.ts.map +1 -0
  21. package/dist/client/costRecovery.js +50 -0
  22. package/dist/client/costRecovery.js.map +1 -0
  23. package/dist/client/index.d.ts +28 -64
  24. package/dist/client/index.d.ts.map +1 -1
  25. package/dist/client/index.js +25 -0
  26. package/dist/client/index.js.map +1 -1
  27. package/dist/client/products/prices.d.ts +4 -2
  28. package/dist/client/products/prices.d.ts.map +1 -1
  29. package/dist/client/products/prices.js +5 -4
  30. package/dist/client/products/prices.js.map +1 -1
  31. package/dist/client/products/products.d.ts +4 -2
  32. package/dist/client/products/products.d.ts.map +1 -1
  33. package/dist/client/products/products.js +7 -5
  34. package/dist/client/products/products.js.map +1 -1
  35. package/dist/client/settlement/processingCosts.d.ts +56 -0
  36. package/dist/client/settlement/processingCosts.d.ts.map +1 -0
  37. package/dist/client/settlement/processingCosts.js +173 -0
  38. package/dist/client/settlement/processingCosts.js.map +1 -0
  39. package/dist/client/utils/webhookEndpoints.d.ts +1 -1
  40. package/dist/client/utils/webhookEndpoints.d.ts.map +1 -1
  41. package/dist/client/utils/webhookEndpoints.js +1 -0
  42. package/dist/client/utils/webhookEndpoints.js.map +1 -1
  43. package/dist/client/webhooks/processors.d.ts.map +1 -1
  44. package/dist/client/webhooks/processors.js +1 -0
  45. package/dist/client/webhooks/processors.js.map +1 -1
  46. package/dist/component/_generated/component.d.ts +244 -0
  47. package/dist/component/_generated/component.d.ts.map +1 -1
  48. package/dist/component/costRecovery/claims.d.ts +68 -0
  49. package/dist/component/costRecovery/claims.d.ts.map +1 -0
  50. package/dist/component/costRecovery/claims.js +85 -0
  51. package/dist/component/costRecovery/claims.js.map +1 -0
  52. package/dist/component/costRecovery/completion.d.ts +33 -0
  53. package/dist/component/costRecovery/completion.d.ts.map +1 -0
  54. package/dist/component/costRecovery/completion.js +69 -0
  55. package/dist/component/costRecovery/completion.js.map +1 -0
  56. package/dist/component/costRecovery/helpers.d.ts +62 -0
  57. package/dist/component/costRecovery/helpers.d.ts.map +1 -0
  58. package/dist/component/costRecovery/helpers.js +71 -0
  59. package/dist/component/costRecovery/helpers.js.map +1 -0
  60. package/dist/component/costRecovery/mutations.d.ts +138 -0
  61. package/dist/component/costRecovery/mutations.d.ts.map +1 -0
  62. package/dist/component/costRecovery/mutations.js +36 -0
  63. package/dist/component/costRecovery/mutations.js.map +1 -0
  64. package/dist/component/costRecovery/queries.d.ts +102 -0
  65. package/dist/component/costRecovery/queries.d.ts.map +1 -0
  66. package/dist/component/costRecovery/queries.js +73 -0
  67. package/dist/component/costRecovery/queries.js.map +1 -0
  68. package/dist/component/costRecovery/reservations.d.ts +83 -0
  69. package/dist/component/costRecovery/reservations.d.ts.map +1 -0
  70. package/dist/component/costRecovery/reservations.js +134 -0
  71. package/dist/component/costRecovery/reservations.js.map +1 -0
  72. package/dist/component/costRecovery/schema.d.ts +340 -0
  73. package/dist/component/costRecovery/schema.d.ts.map +1 -0
  74. package/dist/component/costRecovery/schema.js +109 -0
  75. package/dist/component/costRecovery/schema.js.map +1 -0
  76. package/dist/component/schema.d.ts +119 -0
  77. package/dist/component/schema.js +5 -0
  78. package/dist/component/schema.js.map +1 -1
  79. package/dist/react/components/PriceCard.d.ts +3 -1
  80. package/dist/react/components/PriceCard.d.ts.map +1 -1
  81. package/dist/react/components/PriceCard.js +2 -2
  82. package/dist/react/components/PriceCard.js.map +1 -1
  83. package/dist/react/index.d.ts +1 -0
  84. package/dist/react/index.d.ts.map +1 -1
  85. package/dist/react/index.js +1 -0
  86. package/dist/react/index.js.map +1 -1
  87. package/dist/react/lib/price-helpers.d.ts +4 -6
  88. package/dist/react/lib/price-helpers.d.ts.map +1 -1
  89. package/dist/react/lib/price-helpers.js +16 -9
  90. package/dist/react/lib/price-helpers.js.map +1 -1
  91. package/dist/react/workflows/PlanPricingEditor.d.ts +4 -0
  92. package/dist/react/workflows/PlanPricingEditor.d.ts.map +1 -1
  93. package/dist/react/workflows/PlanPricingEditor.js +12 -9
  94. package/dist/react/workflows/PlanPricingEditor.js.map +1 -1
  95. package/docs/react-workflows.md +91 -0
  96. package/package.json +2 -2
  97. package/src/client/billing/balanceFunding.test.ts +257 -0
  98. package/src/client/billing/balanceFunding.ts +183 -0
  99. package/src/client/billing/balanceFundingTransport.test.ts +89 -0
  100. package/src/client/billing/invoicePreview.test.ts +123 -0
  101. package/src/client/billing/invoicePreview.ts +94 -0
  102. package/src/client/billing/invoicePreviewTransport.test.ts +70 -0
  103. package/src/client/charge-currency-format.ts +29 -0
  104. package/src/client/charge-currency.test.ts +113 -0
  105. package/src/client/charge-currency.ts +119 -0
  106. package/src/client/costRecovery.test.ts +76 -0
  107. package/src/client/costRecovery.ts +149 -0
  108. package/src/client/index.ts +76 -73
  109. package/src/client/products/catalog-idempotency.test.ts +116 -0
  110. package/src/client/products/prices.ts +18 -11
  111. package/src/client/products/products.ts +17 -11
  112. package/src/client/settlement/processingCosts.test.ts +349 -0
  113. package/src/client/settlement/processingCosts.ts +315 -0
  114. package/src/client/settlement/processingCostsTransport.test.ts +135 -0
  115. package/src/client/utils/webhookEndpoints.test.ts +5 -0
  116. package/src/client/utils/webhookEndpoints.ts +1 -0
  117. package/src/client/webhooks/hooks.test.ts +9 -0
  118. package/src/client/webhooks/payout-canceled.integration.test.ts +192 -0
  119. package/src/client/webhooks/processors.ts +1 -0
  120. package/src/component/_generated/api.ts +12 -0
  121. package/src/component/_generated/component.ts +308 -0
  122. package/src/component/cost-recovery.test.ts +344 -0
  123. package/src/component/costRecovery/claims.ts +109 -0
  124. package/src/component/costRecovery/completion.ts +92 -0
  125. package/src/component/costRecovery/helpers.ts +83 -0
  126. package/src/component/costRecovery/mutations.ts +43 -0
  127. package/src/component/costRecovery/queries.ts +88 -0
  128. package/src/component/costRecovery/reservations.ts +164 -0
  129. package/src/component/costRecovery/schema.ts +127 -0
  130. package/src/component/schema.ts +10 -0
  131. package/src/react/components/PayoutSchedule.render.test.tsx +15 -0
  132. package/src/react/components/PriceCard.render.test.tsx +37 -0
  133. package/src/react/components/PriceCard.tsx +9 -1
  134. package/src/react/index.test.ts +5 -0
  135. package/src/react/index.ts +7 -0
  136. package/src/react/lib/price-helpers.test.ts +15 -2
  137. package/src/react/lib/price-helpers.ts +25 -8
  138. package/src/react/workflows/Billing.test.tsx +1 -1
  139. package/src/react/workflows/PlanPricingEditor.currency.test.tsx +164 -0
  140. package/src/react/workflows/PlanPricingEditor.tsx +26 -10
@@ -1592,6 +1592,314 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
1592
1592
  >;
1593
1593
  };
1594
1594
  };
1595
+ costRecovery: {
1596
+ mutations: {
1597
+ finish: FunctionReference<
1598
+ "mutation",
1599
+ "internal",
1600
+ { groupKey: string; limit?: number; outcome: "collect" | "release" },
1601
+ {
1602
+ group: {
1603
+ _creationTime: number;
1604
+ _id: string;
1605
+ accountId: string;
1606
+ amount: number;
1607
+ currency: string;
1608
+ groupKey: string;
1609
+ maxCredit: number;
1610
+ maxDebit: number;
1611
+ pendingOperations: number;
1612
+ scopeKey: string;
1613
+ sourceGross?: number;
1614
+ sourceId: string;
1615
+ sourceKind: "settlement" | "invoice";
1616
+ sourceProcessingFee?: number;
1617
+ status:
1618
+ | "open"
1619
+ | "sealed"
1620
+ | "collecting"
1621
+ | "releasing"
1622
+ | "collected"
1623
+ | "released";
1624
+ };
1625
+ isDone: boolean;
1626
+ },
1627
+ Name
1628
+ >;
1629
+ record: FunctionReference<
1630
+ "mutation",
1631
+ "internal",
1632
+ {
1633
+ accountId: string;
1634
+ amountDue: number;
1635
+ claimKey: string;
1636
+ currency: string;
1637
+ revision: number;
1638
+ scopeKey: string;
1639
+ sourceChargeId: string;
1640
+ sourceGross: number;
1641
+ sourceProcessingFee: number;
1642
+ },
1643
+ {
1644
+ _creationTime: number;
1645
+ _id: string;
1646
+ accountId: string;
1647
+ amountDue: number;
1648
+ claimKey: string;
1649
+ collectedAmount: number;
1650
+ currency: string;
1651
+ outstanding: number;
1652
+ reservedAmount: number;
1653
+ revision: number;
1654
+ scopeKey: string;
1655
+ sign: "positive" | "negative" | "zero";
1656
+ sourceChargeId: string;
1657
+ sourceGross: number;
1658
+ sourceProcessingFee: number;
1659
+ },
1660
+ Name
1661
+ >;
1662
+ reserve: FunctionReference<
1663
+ "mutation",
1664
+ "internal",
1665
+ {
1666
+ accountId: string;
1667
+ currency: string;
1668
+ groupKey: string;
1669
+ limit?: number;
1670
+ maxCredit: number;
1671
+ maxDebit: number;
1672
+ operationKey: string;
1673
+ scopeKey: string;
1674
+ sourceGross?: number;
1675
+ sourceId: string;
1676
+ sourceKind: "settlement" | "invoice";
1677
+ sourceProcessingFee?: number;
1678
+ },
1679
+ {
1680
+ group: {
1681
+ _creationTime: number;
1682
+ _id: string;
1683
+ accountId: string;
1684
+ amount: number;
1685
+ currency: string;
1686
+ groupKey: string;
1687
+ maxCredit: number;
1688
+ maxDebit: number;
1689
+ pendingOperations: number;
1690
+ scopeKey: string;
1691
+ sourceGross?: number;
1692
+ sourceId: string;
1693
+ sourceKind: "settlement" | "invoice";
1694
+ sourceProcessingFee?: number;
1695
+ status:
1696
+ | "open"
1697
+ | "sealed"
1698
+ | "collecting"
1699
+ | "releasing"
1700
+ | "collected"
1701
+ | "released";
1702
+ };
1703
+ reservation: {
1704
+ _creationTime: number;
1705
+ _id: string;
1706
+ allocations: Array<{
1707
+ amount: number;
1708
+ claimId: string;
1709
+ claimKey: string;
1710
+ }>;
1711
+ amount: number;
1712
+ groupKey: string;
1713
+ limit: number;
1714
+ operationKey: string;
1715
+ status: "reserved" | "collected" | "released" | "empty";
1716
+ };
1717
+ },
1718
+ Name
1719
+ >;
1720
+ rollback: FunctionReference<
1721
+ "mutation",
1722
+ "internal",
1723
+ { groupKey: string; returnedPrincipal: number; revision: number },
1724
+ {
1725
+ _creationTime: number;
1726
+ _id: string;
1727
+ accountId: string;
1728
+ amountDue: number;
1729
+ claimKey: string;
1730
+ collectedAmount: number;
1731
+ currency: string;
1732
+ outstanding: number;
1733
+ reservedAmount: number;
1734
+ revision: number;
1735
+ scopeKey: string;
1736
+ sign: "positive" | "negative" | "zero";
1737
+ sourceChargeId: string;
1738
+ sourceGross: number;
1739
+ sourceProcessingFee: number;
1740
+ },
1741
+ Name
1742
+ >;
1743
+ seal: FunctionReference<
1744
+ "mutation",
1745
+ "internal",
1746
+ { groupKey: string },
1747
+ {
1748
+ _creationTime: number;
1749
+ _id: string;
1750
+ accountId: string;
1751
+ amount: number;
1752
+ currency: string;
1753
+ groupKey: string;
1754
+ maxCredit: number;
1755
+ maxDebit: number;
1756
+ pendingOperations: number;
1757
+ scopeKey: string;
1758
+ sourceGross?: number;
1759
+ sourceId: string;
1760
+ sourceKind: "settlement" | "invoice";
1761
+ sourceProcessingFee?: number;
1762
+ status:
1763
+ | "open"
1764
+ | "sealed"
1765
+ | "collecting"
1766
+ | "releasing"
1767
+ | "collected"
1768
+ | "released";
1769
+ },
1770
+ Name
1771
+ >;
1772
+ };
1773
+ queries: {
1774
+ balance: FunctionReference<
1775
+ "query",
1776
+ "internal",
1777
+ { accountId: string; currency: string; scopeKey: string },
1778
+ {
1779
+ accountId: string;
1780
+ activeGroupKey?: string;
1781
+ amountDue: number;
1782
+ collectedAmount: number;
1783
+ currency: string;
1784
+ outstanding: number;
1785
+ reservedAmount: number;
1786
+ scopeKey: string;
1787
+ },
1788
+ Name
1789
+ >;
1790
+ claim: FunctionReference<
1791
+ "query",
1792
+ "internal",
1793
+ { claimKey: string },
1794
+ {
1795
+ _creationTime: number;
1796
+ _id: string;
1797
+ accountId: string;
1798
+ amountDue: number;
1799
+ claimKey: string;
1800
+ collectedAmount: number;
1801
+ currency: string;
1802
+ outstanding: number;
1803
+ reservedAmount: number;
1804
+ revision: number;
1805
+ scopeKey: string;
1806
+ sign: "positive" | "negative" | "zero";
1807
+ sourceChargeId: string;
1808
+ sourceGross: number;
1809
+ sourceProcessingFee: number;
1810
+ } | null,
1811
+ Name
1812
+ >;
1813
+ group: FunctionReference<
1814
+ "query",
1815
+ "internal",
1816
+ { groupKey: string },
1817
+ {
1818
+ _creationTime: number;
1819
+ _id: string;
1820
+ accountId: string;
1821
+ amount: number;
1822
+ currency: string;
1823
+ groupKey: string;
1824
+ maxCredit: number;
1825
+ maxDebit: number;
1826
+ pendingOperations: number;
1827
+ scopeKey: string;
1828
+ sourceGross?: number;
1829
+ sourceId: string;
1830
+ sourceKind: "settlement" | "invoice";
1831
+ sourceProcessingFee?: number;
1832
+ status:
1833
+ | "open"
1834
+ | "sealed"
1835
+ | "collecting"
1836
+ | "releasing"
1837
+ | "collected"
1838
+ | "released";
1839
+ } | null,
1840
+ Name
1841
+ >;
1842
+ listClaims: FunctionReference<
1843
+ "query",
1844
+ "internal",
1845
+ {
1846
+ accountId: string;
1847
+ currency: string;
1848
+ paginationOpts: {
1849
+ cursor: string | null;
1850
+ endCursor?: string | null;
1851
+ id?: number;
1852
+ maximumBytesRead?: number;
1853
+ maximumRowsRead?: number;
1854
+ numItems: number;
1855
+ };
1856
+ scopeKey: string;
1857
+ },
1858
+ {
1859
+ continueCursor: string;
1860
+ isDone: boolean;
1861
+ page: Array<{
1862
+ _creationTime: number;
1863
+ _id: string;
1864
+ accountId: string;
1865
+ amountDue: number;
1866
+ claimKey: string;
1867
+ collectedAmount: number;
1868
+ currency: string;
1869
+ outstanding: number;
1870
+ reservedAmount: number;
1871
+ revision: number;
1872
+ scopeKey: string;
1873
+ sign: "positive" | "negative" | "zero";
1874
+ sourceChargeId: string;
1875
+ sourceGross: number;
1876
+ sourceProcessingFee: number;
1877
+ }>;
1878
+ },
1879
+ Name
1880
+ >;
1881
+ reservation: FunctionReference<
1882
+ "query",
1883
+ "internal",
1884
+ { operationKey: string },
1885
+ {
1886
+ _creationTime: number;
1887
+ _id: string;
1888
+ allocations: Array<{
1889
+ amount: number;
1890
+ claimId: string;
1891
+ claimKey: string;
1892
+ }>;
1893
+ amount: number;
1894
+ groupKey: string;
1895
+ limit: number;
1896
+ operationKey: string;
1897
+ status: "reserved" | "collected" | "released" | "empty";
1898
+ } | null,
1899
+ Name
1900
+ >;
1901
+ };
1902
+ };
1595
1903
  products: {
1596
1904
  mutations: {
1597
1905
  upsertPrice: FunctionReference<
@@ -0,0 +1,344 @@
1
+ // @vitest-environment edge-runtime
2
+ import { convexTest } from "convex-test";
3
+ import { makeFunctionReference } from "convex/server";
4
+ import { expect, it } from "vitest";
5
+ import schema from "./schema.js";
6
+ const modules = import.meta.glob("./**/*.*s");
7
+ const m = (name: string) =>
8
+ makeFunctionReference<"mutation">(`costRecovery/${name}`);
9
+ const q = (name: string) =>
10
+ makeFunctionReference<"query">(`costRecovery/queries:${name}`);
11
+ const scope = { accountId: "acct_store", scopeKey: "store", currency: "usd" };
12
+ const observation = (claimKey: string, amountDue: number, revision = 1) => ({
13
+ ...scope,
14
+ claimKey,
15
+ sourceChargeId: `ch_${claimKey}`,
16
+ sourceGross: 10000,
17
+ sourceProcessingFee: 320,
18
+ amountDue,
19
+ revision,
20
+ });
21
+ const reserve = (operationKey: string, groupKey = "group", limit = 100) => ({
22
+ ...scope,
23
+ groupKey,
24
+ operationKey,
25
+ sourceKind: "settlement" as const,
26
+ sourceId: "ch_funding",
27
+ sourceGross: 10000,
28
+ sourceProcessingFee: 320,
29
+ maxDebit: 10000,
30
+ maxCredit: 10000,
31
+ limit,
32
+ });
33
+ it("preserves identity, ignores stale observations and rejects conflicting replays", async () => {
34
+ const t = convexTest(schema, modules);
35
+ const first = await t.mutation(
36
+ m("mutations:record"),
37
+ observation("a", 500, 2),
38
+ );
39
+ expect(
40
+ await t.mutation(m("mutations:record"), observation("a", 900, 1)),
41
+ ).toEqual(first);
42
+ expect(
43
+ await t.mutation(m("mutations:record"), observation("a", 500, 2)),
44
+ ).toEqual(first);
45
+ await expect(
46
+ t.mutation(m("mutations:record"), observation("a", 800, 2)),
47
+ ).rejects.toThrow(/revision/);
48
+ await expect(
49
+ t.mutation(m("mutations:record"), {
50
+ ...observation("a", 500, 3),
51
+ accountId: "acct_other",
52
+ }),
53
+ ).rejects.toThrow(/identity/);
54
+ await expect(
55
+ t.mutation(m("mutations:record"), {
56
+ ...observation("a", 500, 3),
57
+ sourceProcessingFee: 0,
58
+ }),
59
+ ).rejects.toThrow(/identity/);
60
+ });
61
+ it("nets unseen credits and isolates currencies/scopes beyond one history page", async () => {
62
+ const t = convexTest(schema, modules);
63
+ for (let i = 0; i < 125; i++)
64
+ await t.mutation(m("mutations:record"), observation(`a${i}`, 10));
65
+ await t.mutation(m("mutations:record"), observation("credit", -1200));
66
+ await t.mutation(m("mutations:record"), {
67
+ ...observation("eur", 9000),
68
+ currency: "eur",
69
+ });
70
+ await t.mutation(m("mutations:record"), {
71
+ ...observation("other", 9000),
72
+ scopeKey: "other",
73
+ });
74
+ const r = await t.mutation(m("mutations:reserve"), reserve("batch-1"));
75
+ expect(r.reservation.amount).toBe(50);
76
+ expect((await t.query(q("balance"), scope)).outstanding).toBe(0);
77
+ const claims: unknown[] = [];
78
+ let cursor: string | null = null;
79
+ do {
80
+ const page: { page: unknown[]; isDone: boolean; continueCursor: string } =
81
+ await t.query(q("listClaims"), {
82
+ ...scope,
83
+ paginationOpts: { numItems: 20, cursor },
84
+ });
85
+ claims.push(...page.page);
86
+ cursor = page.isDone ? null : page.continueCursor;
87
+ } while (cursor);
88
+ expect(claims).toHaveLength(126);
89
+ });
90
+ it("reserves in bounded batches and seals only a fully prepared group", async () => {
91
+ const t = convexTest(schema, modules);
92
+ for (let i = 0; i < 110; i++)
93
+ await t.mutation(m("mutations:record"), observation(`a${i}`, 10));
94
+ const first = await t.mutation(m("mutations:reserve"), reserve("batch-1"));
95
+ expect(first.reservation.amount).toBe(1000);
96
+ await expect(
97
+ t.mutation(m("mutations:seal"), { groupKey: "group" }),
98
+ ).rejects.toThrow(/batch/);
99
+ const second = await t.mutation(m("mutations:reserve"), reserve("batch-2"));
100
+ expect(second.reservation.amount).toBe(100);
101
+ expect(
102
+ (await t.mutation(m("mutations:seal"), { groupKey: "group" })).amount,
103
+ ).toBe(1100);
104
+ await expect(
105
+ t.mutation(m("mutations:reserve"), reserve("batch-3")),
106
+ ).rejects.toThrow(/sealed/);
107
+ const partial = await t.mutation(m("mutations:finish"), {
108
+ groupKey: "group",
109
+ outcome: "collect",
110
+ limit: 1,
111
+ });
112
+ expect(partial.isDone).toBe(false);
113
+ const done = await t.mutation(m("mutations:finish"), {
114
+ groupKey: "group",
115
+ outcome: "collect",
116
+ limit: 1,
117
+ });
118
+ expect(done.isDone).toBe(true);
119
+ expect(await t.query(q("balance"), scope)).toMatchObject({
120
+ collectedAmount: 1100,
121
+ reservedAmount: 0,
122
+ outstanding: 0,
123
+ });
124
+ expect(
125
+ (
126
+ await t.mutation(m("mutations:finish"), {
127
+ groupKey: "group",
128
+ outcome: "collect",
129
+ limit: 1,
130
+ })
131
+ ).isDone,
132
+ ).toBe(true);
133
+ await expect(
134
+ t.mutation(m("mutations:finish"), {
135
+ groupKey: "group",
136
+ outcome: "release",
137
+ limit: 1,
138
+ }),
139
+ ).rejects.toThrow(/outcome/);
140
+ });
141
+ it("serializes provider boundaries and recovers the same reservation after a worker dies", async () => {
142
+ const t = convexTest(schema, modules);
143
+ await t.mutation(m("mutations:record"), observation("a", 500));
144
+ const first = await t.mutation(m("mutations:reserve"), reserve("same"));
145
+ expect(await t.mutation(m("mutations:reserve"), reserve("same"))).toEqual(
146
+ first,
147
+ );
148
+ await expect(
149
+ t.mutation(m("mutations:reserve"), reserve("other", "other-group")),
150
+ ).rejects.toThrow(/pending/);
151
+ await t.mutation(m("mutations:seal"), { groupKey: "group" });
152
+ await t.mutation(m("mutations:finish"), {
153
+ groupKey: "group",
154
+ outcome: "release",
155
+ limit: 10,
156
+ });
157
+ expect(await t.query(q("balance"), scope)).toMatchObject({
158
+ outstanding: 500,
159
+ collectedAmount: 0,
160
+ reservedAmount: 0,
161
+ });
162
+ expect(
163
+ (await t.mutation(m("mutations:reserve"), reserve("next", "next-group")))
164
+ .reservation.amount,
165
+ ).toBe(500);
166
+ });
167
+ it("handles source credits while reserved without making them spendable at a different boundary", async () => {
168
+ const t = convexTest(schema, modules);
169
+ await t.mutation(m("mutations:record"), observation("a", 500));
170
+ await t.mutation(m("mutations:reserve"), reserve("debit"));
171
+ await t.mutation(m("mutations:record"), observation("a", 0, 2));
172
+ await expect(
173
+ t.mutation(m("mutations:reserve"), reserve("credit", "elsewhere")),
174
+ ).rejects.toThrow(/pending/);
175
+ const credit = await t.mutation(m("mutations:reserve"), reserve("offset"));
176
+ expect(credit.reservation.amount).toBe(-500);
177
+ expect(
178
+ (await t.mutation(m("mutations:seal"), { groupKey: "group" })).amount,
179
+ ).toBe(0);
180
+ await t.mutation(m("mutations:finish"), {
181
+ groupKey: "group",
182
+ outcome: "collect",
183
+ limit: 10,
184
+ });
185
+ expect(await t.query(q("balance"), scope)).toMatchObject({
186
+ amountDue: 0,
187
+ collectedAmount: 0,
188
+ reservedAmount: 0,
189
+ outstanding: 0,
190
+ });
191
+ });
192
+ it("restores signed recovered costs proportionally when the funding sale is returned", async () => {
193
+ const t = convexTest(schema, modules);
194
+ await t.mutation(m("mutations:record"), observation("a", 501));
195
+ await t.mutation(m("mutations:reserve"), reserve("debit"));
196
+ await t.mutation(m("mutations:seal"), { groupKey: "group" });
197
+ await t.mutation(m("mutations:finish"), {
198
+ groupKey: "group",
199
+ outcome: "collect",
200
+ limit: 10,
201
+ });
202
+ const rollback = { groupKey: "group", returnedPrincipal: 5000, revision: 1 };
203
+ expect(await t.mutation(m("mutations:rollback"), rollback)).toMatchObject({
204
+ amountDue: 251,
205
+ sourceGross: 10000,
206
+ sourceProcessingFee: 320,
207
+ });
208
+ await t.mutation(m("mutations:rollback"), rollback);
209
+ expect((await t.query(q("balance"), scope)).outstanding).toBe(251);
210
+ await t.mutation(m("mutations:rollback"), {
211
+ ...rollback,
212
+ returnedPrincipal: 10000,
213
+ revision: 2,
214
+ });
215
+ expect((await t.query(q("balance"), scope)).outstanding).toBe(501);
216
+ await t.mutation(m("mutations:rollback"), {
217
+ ...rollback,
218
+ returnedPrincipal: 0,
219
+ revision: 3,
220
+ });
221
+ expect((await t.query(q("balance"), scope)).outstanding).toBe(0);
222
+ });
223
+ it("permits only one concurrent external recovery boundary", async () => {
224
+ const t = convexTest(schema, modules);
225
+ await t.mutation(m("mutations:record"), observation("race", 500));
226
+ const results = await Promise.allSettled([
227
+ t.mutation(m("mutations:reserve"), reserve("one", "one")),
228
+ t.mutation(m("mutations:reserve"), reserve("two", "two")),
229
+ ]);
230
+ expect(results.filter((r) => r.status === "fulfilled")).toHaveLength(1);
231
+ expect(results.filter((r) => r.status === "rejected")).toHaveLength(1);
232
+ expect(await t.query(q("balance"), scope)).toMatchObject({
233
+ reservedAmount: 500,
234
+ outstanding: 0,
235
+ collectedAmount: 0,
236
+ });
237
+ });
238
+ it("holds a credit created after sealing until the original debit is resolved", async () => {
239
+ const t = convexTest(schema, modules);
240
+ await t.mutation(m("mutations:record"), observation("a", 500));
241
+ await t.mutation(m("mutations:reserve"), reserve("debit"));
242
+ await t.mutation(m("mutations:seal"), { groupKey: "group" });
243
+ await t.mutation(m("mutations:record"), observation("a", 0, 2));
244
+ await expect(
245
+ t.mutation(m("mutations:reserve"), reserve("credit", "credit")),
246
+ ).rejects.toThrow(/pending/);
247
+ await t.mutation(m("mutations:finish"), {
248
+ groupKey: "group",
249
+ outcome: "collect",
250
+ });
251
+ expect(await t.query(q("balance"), scope)).toMatchObject({
252
+ collectedAmount: 500,
253
+ outstanding: -500,
254
+ });
255
+ expect(
256
+ (await t.mutation(m("mutations:reserve"), reserve("credit", "credit")))
257
+ .reservation.amount,
258
+ ).toBe(-500);
259
+ await t.mutation(m("mutations:seal"), { groupKey: "credit" });
260
+ await t.mutation(m("mutations:finish"), {
261
+ groupKey: "credit",
262
+ outcome: "collect",
263
+ });
264
+ expect(await t.query(q("balance"), scope)).toMatchObject({
265
+ amountDue: 0,
266
+ collectedAmount: 0,
267
+ outstanding: 0,
268
+ });
269
+ });
270
+ it("enforces a group-wide budget across batches and rounds signed credit rollback symmetrically", async () => {
271
+ const t = convexTest(schema, modules);
272
+ for (let i = 0; i < 3; i++)
273
+ await t.mutation(m("mutations:record"), observation(`a${i}`, 10));
274
+ const opts = { ...reserve("batch1", "limited", 1), maxDebit: 15 };
275
+ expect(
276
+ (await t.mutation(m("mutations:reserve"), opts)).reservation.amount,
277
+ ).toBe(10);
278
+ expect(
279
+ (
280
+ await t.mutation(m("mutations:reserve"), {
281
+ ...opts,
282
+ operationKey: "batch2",
283
+ })
284
+ ).reservation.amount,
285
+ ).toBe(5);
286
+ await expect(
287
+ t.mutation(m("mutations:reserve"), { ...opts, maxDebit: 100 }),
288
+ ).rejects.toThrow(/identity/);
289
+ await t.mutation(m("mutations:seal"), { groupKey: "limited" });
290
+ await t.mutation(m("mutations:finish"), {
291
+ groupKey: "limited",
292
+ outcome: "collect",
293
+ });
294
+ expect((await t.query(q("balance"), scope)).outstanding).toBe(15);
295
+ const creditScope = { ...scope, scopeKey: "credits" };
296
+ await t.mutation(m("mutations:record"), {
297
+ ...observation("credit", -501),
298
+ ...creditScope,
299
+ });
300
+ await t.mutation(m("mutations:reserve"), {
301
+ ...reserve("credit", "credit"),
302
+ ...creditScope,
303
+ });
304
+ await t.mutation(m("mutations:seal"), { groupKey: "credit" });
305
+ await t.mutation(m("mutations:finish"), {
306
+ groupKey: "credit",
307
+ outcome: "collect",
308
+ });
309
+ expect(
310
+ (
311
+ await t.mutation(m("mutations:rollback"), {
312
+ groupKey: "credit",
313
+ returnedPrincipal: 5000,
314
+ revision: 1,
315
+ })
316
+ ).amountDue,
317
+ ).toBe(-251);
318
+ });
319
+ it("does not let an empty old group clear another group's lock, and reset includes new tables", async () => {
320
+ const t = convexTest(schema, modules);
321
+ await t.mutation(m("mutations:reserve"), {
322
+ ...reserve("empty", "empty"),
323
+ maxDebit: 0,
324
+ maxCredit: 0,
325
+ });
326
+ await t.mutation(m("mutations:record"), observation("a", 100));
327
+ await t.mutation(m("mutations:reserve"), reserve("active", "active"));
328
+ await t.mutation(m("mutations:seal"), { groupKey: "empty" });
329
+ await t.mutation(m("mutations:finish"), {
330
+ groupKey: "empty",
331
+ outcome: "collect",
332
+ });
333
+ expect((await t.query(q("balance"), scope)).activeGroupKey).toBe("active");
334
+ await t.mutation(
335
+ makeFunctionReference<"mutation">("core/mutations:clearAllTables"),
336
+ {},
337
+ );
338
+ expect(await t.query(q("balance"), scope)).toMatchObject({
339
+ amountDue: 0,
340
+ collectedAmount: 0,
341
+ reservedAmount: 0,
342
+ outstanding: 0,
343
+ });
344
+ });