@fantastic.dev/better-stripe 0.12.0 → 0.13.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 (67) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +64 -8
  3. package/dist/client/costRecovery-invoices.d.ts +38 -0
  4. package/dist/client/costRecovery-invoices.d.ts.map +1 -0
  5. package/dist/client/costRecovery-invoices.js +105 -0
  6. package/dist/client/costRecovery-invoices.js.map +1 -0
  7. package/dist/client/costRecovery.d.ts +43 -2
  8. package/dist/client/costRecovery.d.ts.map +1 -1
  9. package/dist/client/costRecovery.js +69 -2
  10. package/dist/client/costRecovery.js.map +1 -1
  11. package/dist/client/index.js +1 -1
  12. package/dist/client/index.js.map +1 -1
  13. package/dist/component/_generated/component.d.ts +78 -0
  14. package/dist/component/_generated/component.d.ts.map +1 -1
  15. package/dist/component/costRecovery/claims.d.ts.map +1 -1
  16. package/dist/component/costRecovery/claims.js +15 -8
  17. package/dist/component/costRecovery/claims.js.map +1 -1
  18. package/dist/component/costRecovery/completion.d.ts +6 -0
  19. package/dist/component/costRecovery/completion.d.ts.map +1 -1
  20. package/dist/component/costRecovery/helpers.d.ts +6 -0
  21. package/dist/component/costRecovery/helpers.d.ts.map +1 -1
  22. package/dist/component/costRecovery/mutations.d.ts +72 -0
  23. package/dist/component/costRecovery/mutations.d.ts.map +1 -1
  24. package/dist/component/costRecovery/mutations.js +57 -0
  25. package/dist/component/costRecovery/mutations.js.map +1 -1
  26. package/dist/component/costRecovery/queries.d.ts +6 -0
  27. package/dist/component/costRecovery/queries.d.ts.map +1 -1
  28. package/dist/component/costRecovery/reservations.d.ts +12 -0
  29. package/dist/component/costRecovery/reservations.d.ts.map +1 -1
  30. package/dist/component/costRecovery/schema.d.ts +44 -2
  31. package/dist/component/costRecovery/schema.d.ts.map +1 -1
  32. package/dist/component/costRecovery/schema.js +6 -0
  33. package/dist/component/costRecovery/schema.js.map +1 -1
  34. package/dist/component/schema.d.ts +17 -1
  35. package/dist/react/workflows/Collections.d.ts.map +1 -1
  36. package/dist/react/workflows/Collections.js +2 -1
  37. package/dist/react/workflows/Collections.js.map +1 -1
  38. package/dist/react/workflows/Refunds.d.ts +14 -3
  39. package/dist/react/workflows/Refunds.d.ts.map +1 -1
  40. package/dist/react/workflows/Refunds.js +58 -16
  41. package/dist/react/workflows/Refunds.js.map +1 -1
  42. package/dist/react/workflows/index.d.ts +3 -1
  43. package/dist/react/workflows/index.d.ts.map +1 -1
  44. package/dist/react/workflows/index.js +2 -1
  45. package/dist/react/workflows/index.js.map +1 -1
  46. package/dist/react/workflows/refund-preview.d.ts +16 -0
  47. package/dist/react/workflows/refund-preview.d.ts.map +1 -0
  48. package/dist/react/workflows/refund-preview.js +38 -0
  49. package/dist/react/workflows/refund-preview.js.map +1 -0
  50. package/docs/react-workflows.md +79 -4
  51. package/package.json +1 -1
  52. package/src/client/costRecovery-invoices.test.ts +401 -0
  53. package/src/client/costRecovery-invoices.transport.test.ts +107 -0
  54. package/src/client/costRecovery-invoices.ts +144 -0
  55. package/src/client/costRecovery.ts +107 -2
  56. package/src/client/index.ts +1 -1
  57. package/src/component/_generated/component.ts +99 -0
  58. package/src/component/costRecovery/claims.ts +18 -8
  59. package/src/component/costRecovery/mutations.ts +72 -0
  60. package/src/component/costRecovery/schema.ts +8 -0
  61. package/src/react/workflows/Collections.test.tsx +36 -0
  62. package/src/react/workflows/Collections.tsx +3 -1
  63. package/src/react/workflows/Refunds.currency.test.tsx +290 -0
  64. package/src/react/workflows/Refunds.tsx +115 -25
  65. package/src/react/workflows/index.ts +8 -1
  66. package/src/react/workflows/refund-preview.test.ts +65 -0
  67. package/src/react/workflows/refund-preview.ts +56 -0
@@ -0,0 +1,107 @@
1
+ import Stripe from "stripe";
2
+ import { componentsGeneric } from "convex/server";
3
+ import { expect, it } from "vitest";
4
+ import { BetterStripe } from "./index.js";
5
+ import { getStripeClient } from "./helpers.js";
6
+ import type { Component, RunCtx } from "./helpers.js";
7
+ import type { RecoveryGroup } from "./costRecovery.js";
8
+ it.each(["2026-09-30.endive", "2026-09-30.preview"] as const)(
9
+ "preserves %s on every SDK page and finds existing items beyond the first page",
10
+ async (version) => {
11
+ const key = `sk_test_recovery_transport_${version}`;
12
+ const sdk = getStripeClient(key, version);
13
+ const group = {
14
+ _id: "group-durable",
15
+ _creationTime: 0,
16
+ accountId: "acct_owner",
17
+ scopeKey: "store",
18
+ currency: "usd",
19
+ groupKey: "in:1",
20
+ sourceKind: "invoice",
21
+ sourceId: "in_1",
22
+ status: "sealed",
23
+ amount: 320,
24
+ pendingOperations: 1,
25
+ maxDebit: 1000,
26
+ maxCredit: 1000,
27
+ } as RecoveryGroup;
28
+ const paths: string[] = [];
29
+ (
30
+ sdk as unknown as { _setApiField(field: string, value: unknown): void }
31
+ )._setApiField("httpClient", {
32
+ getClientName: () => "test",
33
+ makeRequest: async (
34
+ ...[, , path, method, headers]: Parameters<
35
+ ReturnType<typeof Stripe.createNodeHttpClient>["makeRequest"]
36
+ >
37
+ ) => {
38
+ expect(headers["Stripe-Version"]).toBe(version);
39
+ expect(method).toBe("GET");
40
+ paths.push(path);
41
+ const data = path.startsWith("/v1/invoices/")
42
+ ? {
43
+ id: "in_1",
44
+ customer_account: group.accountId,
45
+ currency: "usd",
46
+ status: "draft",
47
+ }
48
+ : path.includes("starting_after=")
49
+ ? {
50
+ object: "list",
51
+ has_more: false,
52
+ data: [
53
+ {
54
+ id: "ii_existing",
55
+ invoice: "in_1",
56
+ customer_account: group.accountId,
57
+ currency: "usd",
58
+ amount: 320,
59
+ discountable: false,
60
+ metadata: { bsRecoveryGroup: group._id },
61
+ },
62
+ ],
63
+ }
64
+ : {
65
+ object: "list",
66
+ has_more: true,
67
+ data: Array.from({ length: 100 }, (_, i) => ({
68
+ id: `ii_${i}`,
69
+ metadata: {},
70
+ })),
71
+ };
72
+ return {
73
+ getStatusCode: () => 200,
74
+ getHeaders: () => ({}),
75
+ getRawResponse: () => ({}),
76
+ toJSON: async () => data,
77
+ toStream: () => {
78
+ throw new Error("unexpected stream");
79
+ },
80
+ };
81
+ },
82
+ });
83
+ let bound: string | undefined;
84
+ const ctx = {
85
+ runQuery: async () => group,
86
+ runMutation: async (_ref: unknown, args: { invoiceItemId: string }) => {
87
+ bound = args.invoiceItemId;
88
+ return { ...group, invoiceItemId: bound };
89
+ },
90
+ } as unknown as RunCtx;
91
+ const better = new BetterStripe(
92
+ componentsGeneric().betterStripe as unknown as Component,
93
+ { STRIPE_SECRET_KEY: key, stripeApiVersion: version },
94
+ );
95
+ const receipt = await better.costRecovery.attachInvoice(ctx, {
96
+ accountId: group.accountId,
97
+ scopeKey: group.scopeKey,
98
+ currency: group.currency,
99
+ groupKey: group.groupKey,
100
+ });
101
+ expect(receipt.invoiceItemId).toBe("ii_existing");
102
+ expect(bound).toBe("ii_existing");
103
+ expect(paths).toHaveLength(3);
104
+ expect(paths[2]).toContain("starting_after=ii_99");
105
+ expect(paths[2]).toContain("invoice=in_1");
106
+ },
107
+ );
@@ -0,0 +1,144 @@
1
+ import { getProcessingCostContext } from "./settlement/processingCosts.js";
2
+ import type Stripe from "stripe";
3
+ import type { RecoveryGroup, RecoveryScope } from "./costRecovery.js";
4
+ export type RecoveryInvoiceOptions = RecoveryScope & { groupKey: string };
5
+ const marker = "bsRecoveryGroup";
6
+ const id = (value: string | { id: string } | null) =>
7
+ typeof value === "string" ? value : value?.id;
8
+
9
+ /** One immutable item per sealed group. Host serializes provider operations per group. */
10
+ export async function attachRecoveryInvoice(
11
+ stripe: Stripe,
12
+ group: RecoveryGroup | null,
13
+ opts: RecoveryInvoiceOptions,
14
+ bind: (invoiceItemId: string) => Promise<RecoveryGroup>,
15
+ create = true,
16
+ ) {
17
+ if (
18
+ !group ||
19
+ group.groupKey !== opts.groupKey ||
20
+ group.sourceKind !== "invoice" ||
21
+ group.accountId !== opts.accountId ||
22
+ group.scopeKey !== opts.scopeKey ||
23
+ group.currency !== opts.currency
24
+ )
25
+ throw new Error(
26
+ "Recovery invoice scope does not match the authorized group.",
27
+ );
28
+ if (
29
+ group.status === "open" ||
30
+ (create && ["released", "releasing"].includes(group.status))
31
+ )
32
+ throw new Error("Invoice recovery group must be sealed and active.");
33
+ const invoice = await stripe.invoices.retrieve(group.sourceId);
34
+ if (
35
+ invoice.id !== group.sourceId ||
36
+ invoice.customer_account !== group.accountId
37
+ )
38
+ throw new Error("Recovery invoice ownership does not match.");
39
+ if (invoice.currency !== group.currency)
40
+ throw new Error("Recovery invoice currency does not match.");
41
+ let found: Stripe.InvoiceItem | undefined;
42
+ for await (const item of stripe.invoiceItems.list({
43
+ invoice: invoice.id,
44
+ limit: 100,
45
+ })) {
46
+ if (item.metadata?.[marker] !== group._id) continue;
47
+ if (found)
48
+ throw new Error(
49
+ "Duplicate recovery invoice items require reconciliation.",
50
+ );
51
+ if (
52
+ id(item.invoice) !== invoice.id ||
53
+ item.customer_account !== group.accountId ||
54
+ item.currency !== group.currency ||
55
+ item.amount !== group.amount ||
56
+ item.discountable ||
57
+ (group.invoiceItemId && group.invoiceItemId !== item.id)
58
+ )
59
+ throw new Error(
60
+ "Recovery invoice item economics or identity do not match.",
61
+ );
62
+ found = item;
63
+ }
64
+ if (!found) {
65
+ if (group.invoiceItemId)
66
+ throw new Error(
67
+ "Bound recovery invoice item is missing; reconcile before retrying.",
68
+ );
69
+ if (!create)
70
+ throw new Error(
71
+ "Recovery invoice item is missing; no collection can be confirmed.",
72
+ );
73
+ if (group.status !== "sealed")
74
+ throw new Error(
75
+ "New recovery items require a sealed, uncompleted group.",
76
+ );
77
+ if (invoice.status !== "draft")
78
+ throw new Error("New recovery items require a draft invoice.");
79
+ found = await stripe.invoiceItems.create(
80
+ {
81
+ invoice: invoice.id,
82
+ customer_account: group.accountId,
83
+ currency: group.currency,
84
+ amount: group.amount,
85
+ discountable: false,
86
+ description: "Refund and dispute cost adjustment",
87
+ metadata: { [marker]: group._id },
88
+ },
89
+ { idempotencyKey: `better-stripe:recovery:${group._id}` },
90
+ );
91
+ }
92
+ const bound = group.invoiceItemId === found.id ? group : await bind(found.id);
93
+ return {
94
+ group: bound,
95
+ invoiceItemId: found.id,
96
+ invoiceStatus: invoice.status,
97
+ };
98
+ }
99
+
100
+ /** Refuse ambiguous cash/credit or multi-payment allocations instead of inventing economics. */
101
+ export async function readInvoiceFunding(stripe: Stripe, group: RecoveryGroup) {
102
+ const invoice = await stripe.invoices.retrieve(group.sourceId);
103
+ if (
104
+ invoice.customer_account !== group.accountId ||
105
+ invoice.currency !== group.currency ||
106
+ invoice.status !== "paid"
107
+ )
108
+ throw new Error(
109
+ "Invoice funding ownership, currency or paid state does not match.",
110
+ );
111
+ const payments: Stripe.InvoicePayment[] = [];
112
+ for await (const payment of stripe.invoicePayments.list({
113
+ invoice: group.sourceId,
114
+ limit: 100,
115
+ })) {
116
+ if (
117
+ id(payment.invoice) !== group.sourceId ||
118
+ payment.currency !== group.currency
119
+ )
120
+ throw new Error("Invoice payment identity or currency does not match.");
121
+ if (payment.status === "paid") payments.push(payment);
122
+ }
123
+ const payment = payments[0];
124
+ if (
125
+ payments.length !== 1 ||
126
+ payment?.payment.type !== "payment_intent" ||
127
+ !payment.payment.payment_intent ||
128
+ payment.amount_paid !== invoice.amount_paid
129
+ )
130
+ return {
131
+ status: "not_ready" as const,
132
+ reason: "invoice_funding_allocation_required" as const,
133
+ };
134
+ const cost = await getProcessingCostContext(stripe, {
135
+ paymentIntentId: id(payment.payment.payment_intent)!,
136
+ });
137
+ if (cost.status === "not_ready") return cost;
138
+ if (cost.currency !== group.currency || cost.gross !== invoice.amount_paid)
139
+ return {
140
+ status: "not_ready" as const,
141
+ reason: "invoice_funding_allocation_required" as const,
142
+ };
143
+ return cost;
144
+ }
@@ -1,3 +1,8 @@
1
+ import type Stripe from "stripe";
2
+ import {
3
+ readInvoiceFunding,
4
+ attachRecoveryInvoice,
5
+ } from "./costRecovery-invoices.js";
1
6
  import type { Component, RunCtx } from "./helpers.js";
2
7
  import { runMutationOrThrow } from "./helpers.js";
3
8
  import { componentRef } from "./webhooks/helpers.js";
@@ -52,6 +57,12 @@ export type RecoveryGroup = RecoveryGroupOptions & {
52
57
  _creationTime: number;
53
58
  amount: number;
54
59
  pendingOperations: number;
60
+ invoiceItemId?: string;
61
+ invoiceFunding?: {
62
+ sourceChargeId: string;
63
+ sourceGross: number;
64
+ sourceProcessingFee: number;
65
+ };
55
66
  status:
56
67
  "open" | "sealed" | "collecting" | "releasing" | "collected" | "released";
57
68
  };
@@ -65,9 +76,12 @@ export type RecoveryReservation = {
65
76
  allocations: { claimId: string; claimKey: string; amount: number }[];
66
77
  status: "reserved" | "collected" | "released" | "empty";
67
78
  };
68
- /** Durable bookkeeping only. Host authorizes policy and verifies provider outcomes. */
79
+ /** Host authorizes policy and scope. Provider methods verify invoice outcomes. */
69
80
  export class CostRecoveryClient {
70
- constructor(private readonly component: Component) {}
81
+ constructor(
82
+ private readonly component: Component,
83
+ private readonly provider?: () => Stripe,
84
+ ) {}
71
85
  private async mutate<T>(
72
86
  ctx: RunCtx,
73
87
  path: string,
@@ -89,6 +103,97 @@ export class CostRecoveryClient {
89
103
  args,
90
104
  )) as T;
91
105
  }
106
+ /** Host authorizes the account, scope and invoice group; call from an action. */
107
+ async attachInvoice(ctx: RunCtx, opts: RecoveryScope & { groupKey: string }) {
108
+ if (!this.provider)
109
+ throw new Error("Invoice recovery requires a Stripe client.");
110
+ const group = await this.getGroup(ctx, { groupKey: opts.groupKey });
111
+ return attachRecoveryInvoice(
112
+ this.provider(),
113
+ group,
114
+ opts,
115
+ (invoiceItemId) =>
116
+ this.mutate<RecoveryGroup>(ctx, "mutations/bindInvoiceItem", {
117
+ groupKey: opts.groupKey,
118
+ invoiceItemId,
119
+ }),
120
+ );
121
+ }
122
+ /** Re-read Stripe; keep unpaid items reserved. Repeat until isDone after terminal outcomes. */
123
+ async reconcileInvoice(
124
+ ctx: RunCtx,
125
+ opts: RecoveryScope & { groupKey: string; releaseUncollectible?: boolean },
126
+ ) {
127
+ if (!this.provider)
128
+ throw new Error("Invoice recovery requires a Stripe client.");
129
+ const group = await this.getGroup(ctx, { groupKey: opts.groupKey });
130
+ const receipt = await attachRecoveryInvoice(
131
+ this.provider(),
132
+ group,
133
+ opts,
134
+ (invoiceItemId) =>
135
+ this.mutate<RecoveryGroup>(ctx, "mutations/bindInvoiceItem", {
136
+ groupKey: opts.groupKey,
137
+ invoiceItemId,
138
+ }),
139
+ false,
140
+ );
141
+ const outcome =
142
+ receipt.invoiceStatus === "paid"
143
+ ? "collect"
144
+ : receipt.invoiceStatus === "void" ||
145
+ (receipt.invoiceStatus === "uncollectible" &&
146
+ opts.releaseUncollectible === true)
147
+ ? "release"
148
+ : null;
149
+ if (!outcome) return { ...receipt, isDone: false };
150
+ return {
151
+ ...receipt,
152
+ ...(await this.finish(ctx, { groupKey: opts.groupKey, outcome })),
153
+ };
154
+ }
155
+ /** Reconcile a returned funding charge without changing the original cost claims. */
156
+ async rollbackInvoice(
157
+ ctx: RunCtx,
158
+ opts: RecoveryScope & { groupKey: string; revision: number },
159
+ ) {
160
+ if (!this.provider)
161
+ throw new Error("Invoice recovery requires a Stripe client.");
162
+ const stripe = this.provider();
163
+ const group = await this.getGroup(ctx, { groupKey: opts.groupKey });
164
+ const receipt = await attachRecoveryInvoice(
165
+ stripe,
166
+ group,
167
+ opts,
168
+ (invoiceItemId) =>
169
+ this.mutate<RecoveryGroup>(ctx, "mutations/bindInvoiceItem", {
170
+ groupKey: opts.groupKey,
171
+ invoiceItemId,
172
+ }),
173
+ false,
174
+ );
175
+ if (
176
+ receipt.group.status !== "collected" ||
177
+ receipt.invoiceStatus !== "paid"
178
+ )
179
+ throw new Error("Invoice rollback requires verified collection.");
180
+ const cost = await readInvoiceFunding(stripe, receipt.group);
181
+ if (cost.status === "not_ready") return cost;
182
+ await this.mutate<RecoveryGroup>(ctx, "mutations/bindInvoiceFunding", {
183
+ groupKey: opts.groupKey,
184
+ sourceChargeId: cost.chargeId,
185
+ sourceGross: cost.gross,
186
+ sourceProcessingFee: cost.processingFee,
187
+ });
188
+ return {
189
+ status: "ready" as const,
190
+ claim: await this.rollback(ctx, {
191
+ groupKey: opts.groupKey,
192
+ returnedPrincipal: cost.principalReturned,
193
+ revision: opts.revision,
194
+ }),
195
+ };
196
+ }
92
197
  recordClaim(ctx: RunCtx, opts: RecoveryObservation) {
93
198
  return this.mutate<RecoveryClaim>(ctx, "mutations/record", opts);
94
199
  }
@@ -1265,7 +1265,7 @@ export class BetterStripe {
1265
1265
  return providerReads.getCouponFromStripe(this.stripe(), opts);
1266
1266
  }
1267
1267
  get costRecovery() {
1268
- return new CostRecoveryClient(this.component);
1268
+ return new CostRecoveryClient(this.component, () => this.stripe());
1269
1269
  }
1270
1270
 
1271
1271
  async getProcessingCostContext(
@@ -1594,6 +1594,81 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
1594
1594
  };
1595
1595
  costRecovery: {
1596
1596
  mutations: {
1597
+ bindInvoiceFunding: FunctionReference<
1598
+ "mutation",
1599
+ "internal",
1600
+ {
1601
+ groupKey: string;
1602
+ sourceChargeId: string;
1603
+ sourceGross: number;
1604
+ sourceProcessingFee: number;
1605
+ },
1606
+ {
1607
+ _creationTime: number;
1608
+ _id: string;
1609
+ accountId: string;
1610
+ amount: number;
1611
+ currency: string;
1612
+ groupKey: string;
1613
+ invoiceFunding?: {
1614
+ sourceChargeId: string;
1615
+ sourceGross: number;
1616
+ sourceProcessingFee: number;
1617
+ };
1618
+ invoiceItemId?: string;
1619
+ maxCredit: number;
1620
+ maxDebit: number;
1621
+ pendingOperations: number;
1622
+ scopeKey: string;
1623
+ sourceGross?: number;
1624
+ sourceId: string;
1625
+ sourceKind: "settlement" | "invoice";
1626
+ sourceProcessingFee?: number;
1627
+ status:
1628
+ | "open"
1629
+ | "sealed"
1630
+ | "collecting"
1631
+ | "releasing"
1632
+ | "collected"
1633
+ | "released";
1634
+ },
1635
+ Name
1636
+ >;
1637
+ bindInvoiceItem: FunctionReference<
1638
+ "mutation",
1639
+ "internal",
1640
+ { groupKey: string; invoiceItemId: string },
1641
+ {
1642
+ _creationTime: number;
1643
+ _id: string;
1644
+ accountId: string;
1645
+ amount: number;
1646
+ currency: string;
1647
+ groupKey: string;
1648
+ invoiceFunding?: {
1649
+ sourceChargeId: string;
1650
+ sourceGross: number;
1651
+ sourceProcessingFee: number;
1652
+ };
1653
+ invoiceItemId?: string;
1654
+ maxCredit: number;
1655
+ maxDebit: number;
1656
+ pendingOperations: number;
1657
+ scopeKey: string;
1658
+ sourceGross?: number;
1659
+ sourceId: string;
1660
+ sourceKind: "settlement" | "invoice";
1661
+ sourceProcessingFee?: number;
1662
+ status:
1663
+ | "open"
1664
+ | "sealed"
1665
+ | "collecting"
1666
+ | "releasing"
1667
+ | "collected"
1668
+ | "released";
1669
+ },
1670
+ Name
1671
+ >;
1597
1672
  finish: FunctionReference<
1598
1673
  "mutation",
1599
1674
  "internal",
@@ -1606,6 +1681,12 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
1606
1681
  amount: number;
1607
1682
  currency: string;
1608
1683
  groupKey: string;
1684
+ invoiceFunding?: {
1685
+ sourceChargeId: string;
1686
+ sourceGross: number;
1687
+ sourceProcessingFee: number;
1688
+ };
1689
+ invoiceItemId?: string;
1609
1690
  maxCredit: number;
1610
1691
  maxDebit: number;
1611
1692
  pendingOperations: number;
@@ -1684,6 +1765,12 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
1684
1765
  amount: number;
1685
1766
  currency: string;
1686
1767
  groupKey: string;
1768
+ invoiceFunding?: {
1769
+ sourceChargeId: string;
1770
+ sourceGross: number;
1771
+ sourceProcessingFee: number;
1772
+ };
1773
+ invoiceItemId?: string;
1687
1774
  maxCredit: number;
1688
1775
  maxDebit: number;
1689
1776
  pendingOperations: number;
@@ -1751,6 +1838,12 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
1751
1838
  amount: number;
1752
1839
  currency: string;
1753
1840
  groupKey: string;
1841
+ invoiceFunding?: {
1842
+ sourceChargeId: string;
1843
+ sourceGross: number;
1844
+ sourceProcessingFee: number;
1845
+ };
1846
+ invoiceItemId?: string;
1754
1847
  maxCredit: number;
1755
1848
  maxDebit: number;
1756
1849
  pendingOperations: number;
@@ -1821,6 +1914,12 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
1821
1914
  amount: number;
1822
1915
  currency: string;
1823
1916
  groupKey: string;
1917
+ invoiceFunding?: {
1918
+ sourceChargeId: string;
1919
+ sourceGross: number;
1920
+ sourceProcessingFee: number;
1921
+ };
1922
+ invoiceItemId?: string;
1824
1923
  maxCredit: number;
1825
1924
  maxDebit: number;
1826
1925
  pendingOperations: number;
@@ -83,16 +83,28 @@ export async function rollbackClaim(
83
83
  const group = await getGroup(ctx, args.groupKey);
84
84
  if (
85
85
  group.status !== "collected" ||
86
- group.sourceKind !== "settlement" ||
87
- !group.sourceGross
86
+ (group.sourceKind === "settlement"
87
+ ? !group.sourceGross
88
+ : !group.invoiceFunding)
88
89
  )
89
90
  throw new Error(
90
- "Rollback requires a collected settlement group with original funding economics.",
91
+ "Rollback requires a collected group with original funding economics.",
91
92
  );
92
- if (args.returnedPrincipal < 0 || args.returnedPrincipal > group.sourceGross)
93
+ const funding =
94
+ group.sourceKind === "settlement"
95
+ ? {
96
+ sourceChargeId: group.sourceId,
97
+ sourceGross: group.sourceGross!,
98
+ sourceProcessingFee: group.sourceProcessingFee!,
99
+ }
100
+ : group.invoiceFunding!;
101
+ if (
102
+ args.returnedPrincipal < 0 ||
103
+ args.returnedPrincipal > funding.sourceGross
104
+ )
93
105
  throw new Error("Returned principal exceeds the original funding sale.");
94
106
  const abs = BigInt(Math.abs(group.amount)),
95
- gross = BigInt(group.sourceGross),
107
+ gross = BigInt(funding.sourceGross),
96
108
  principal = BigInt(args.returnedPrincipal);
97
109
  const rounded = Number((abs * principal + gross / 2n) / gross);
98
110
  return recordClaim(ctx, {
@@ -100,9 +112,7 @@ export async function rollbackClaim(
100
112
  scopeKey: group.scopeKey,
101
113
  currency: group.currency,
102
114
  claimKey: `rollback:${group.groupKey}`,
103
- sourceChargeId: group.sourceId,
104
- sourceGross: group.sourceGross,
105
- sourceProcessingFee: group.sourceProcessingFee!,
115
+ ...funding,
106
116
  amountDue: group.amount < 0 ? -rounded : rounded,
107
117
  revision: args.revision,
108
118
  });
@@ -1,3 +1,4 @@
1
+ import { getGroup, integer } from "./helpers.js";
1
2
  import { v } from "convex/values";
2
3
  import { mutation } from "../_generated/server.js";
3
4
  import { recordClaim, rollbackClaim } from "./claims.js";
@@ -41,3 +42,74 @@ export const finish = mutation({
41
42
  returns: v.object({ group: groupDoc, isDone: v.boolean() }),
42
43
  handler: finishGroup,
43
44
  });
45
+
46
+ export const bindInvoiceItem = mutation({
47
+ args: { groupKey: v.string(), invoiceItemId: v.string() },
48
+ returns: groupDoc,
49
+ handler: async (ctx, args) => {
50
+ const group = await ctx.db
51
+ .query("recoveryGroups")
52
+ .withIndex("by_key", (q) => q.eq("groupKey", args.groupKey))
53
+ .unique();
54
+ if (
55
+ !group ||
56
+ group.sourceKind !== "invoice" ||
57
+ group.status === "open" ||
58
+ group.status === "released" ||
59
+ group.status === "releasing"
60
+ )
61
+ throw new Error("Invoice recovery group must be sealed and active.");
62
+ if (
63
+ !args.invoiceItemId ||
64
+ (group.invoiceItemId && group.invoiceItemId !== args.invoiceItemId)
65
+ )
66
+ throw new Error("Recovery invoice item identity cannot change.");
67
+ await ctx.db.patch("recoveryGroups", group._id, {
68
+ invoiceItemId: args.invoiceItemId,
69
+ });
70
+ return (await ctx.db.get("recoveryGroups", group._id))!;
71
+ },
72
+ });
73
+
74
+ export const bindInvoiceFunding = mutation({
75
+ args: {
76
+ groupKey: v.string(),
77
+ sourceChargeId: v.string(),
78
+ sourceGross: v.number(),
79
+ sourceProcessingFee: v.number(),
80
+ },
81
+ returns: groupDoc,
82
+ handler: async (ctx, args) => {
83
+ const group = await getGroup(ctx, args.groupKey);
84
+ if (group.sourceKind !== "invoice" || group.status !== "collected")
85
+ throw new Error(
86
+ "Invoice funding requires a collected invoice recovery group.",
87
+ );
88
+ integer(args.sourceGross);
89
+ integer(args.sourceProcessingFee);
90
+ if (
91
+ !args.sourceChargeId ||
92
+ args.sourceGross <= 0 ||
93
+ args.sourceProcessingFee < 0
94
+ )
95
+ throw new Error("Invalid invoice funding economics.");
96
+ const funding = {
97
+ sourceChargeId: args.sourceChargeId,
98
+ sourceGross: args.sourceGross,
99
+ sourceProcessingFee: args.sourceProcessingFee,
100
+ };
101
+ if (
102
+ group.invoiceFunding &&
103
+ Object.keys(funding).some(
104
+ (key) =>
105
+ group.invoiceFunding![key as keyof typeof funding] !==
106
+ funding[key as keyof typeof funding],
107
+ )
108
+ )
109
+ throw new Error("Original invoice funding economics cannot change.");
110
+ await ctx.db.patch("recoveryGroups", group._id, {
111
+ invoiceFunding: funding,
112
+ });
113
+ return (await ctx.db.get("recoveryGroups", group._id))!;
114
+ },
115
+ });
@@ -56,6 +56,14 @@ export const groupFields = {
56
56
  ...groupInput,
57
57
  amount: v.number(),
58
58
  pendingOperations: v.number(),
59
+ invoiceItemId: v.optional(v.string()),
60
+ invoiceFunding: v.optional(
61
+ v.object({
62
+ sourceChargeId: v.string(),
63
+ sourceGross: v.number(),
64
+ sourceProcessingFee: v.number(),
65
+ }),
66
+ ),
59
67
  status: v.union(
60
68
  v.literal("open"),
61
69
  v.literal("sealed"),