@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.
- package/CHANGELOG.md +13 -0
- package/README.md +64 -8
- package/dist/client/costRecovery-invoices.d.ts +38 -0
- package/dist/client/costRecovery-invoices.d.ts.map +1 -0
- package/dist/client/costRecovery-invoices.js +105 -0
- package/dist/client/costRecovery-invoices.js.map +1 -0
- package/dist/client/costRecovery.d.ts +43 -2
- package/dist/client/costRecovery.d.ts.map +1 -1
- package/dist/client/costRecovery.js +69 -2
- package/dist/client/costRecovery.js.map +1 -1
- package/dist/client/index.js +1 -1
- package/dist/client/index.js.map +1 -1
- package/dist/component/_generated/component.d.ts +78 -0
- package/dist/component/_generated/component.d.ts.map +1 -1
- package/dist/component/costRecovery/claims.d.ts.map +1 -1
- package/dist/component/costRecovery/claims.js +15 -8
- package/dist/component/costRecovery/claims.js.map +1 -1
- package/dist/component/costRecovery/completion.d.ts +6 -0
- package/dist/component/costRecovery/completion.d.ts.map +1 -1
- package/dist/component/costRecovery/helpers.d.ts +6 -0
- package/dist/component/costRecovery/helpers.d.ts.map +1 -1
- package/dist/component/costRecovery/mutations.d.ts +72 -0
- package/dist/component/costRecovery/mutations.d.ts.map +1 -1
- package/dist/component/costRecovery/mutations.js +57 -0
- package/dist/component/costRecovery/mutations.js.map +1 -1
- package/dist/component/costRecovery/queries.d.ts +6 -0
- package/dist/component/costRecovery/queries.d.ts.map +1 -1
- package/dist/component/costRecovery/reservations.d.ts +12 -0
- package/dist/component/costRecovery/reservations.d.ts.map +1 -1
- package/dist/component/costRecovery/schema.d.ts +44 -2
- package/dist/component/costRecovery/schema.d.ts.map +1 -1
- package/dist/component/costRecovery/schema.js +6 -0
- package/dist/component/costRecovery/schema.js.map +1 -1
- package/dist/component/schema.d.ts +17 -1
- package/dist/react/workflows/Collections.d.ts.map +1 -1
- package/dist/react/workflows/Collections.js +2 -1
- package/dist/react/workflows/Collections.js.map +1 -1
- package/dist/react/workflows/Refunds.d.ts +14 -3
- package/dist/react/workflows/Refunds.d.ts.map +1 -1
- package/dist/react/workflows/Refunds.js +58 -16
- package/dist/react/workflows/Refunds.js.map +1 -1
- package/dist/react/workflows/index.d.ts +3 -1
- package/dist/react/workflows/index.d.ts.map +1 -1
- package/dist/react/workflows/index.js +2 -1
- package/dist/react/workflows/index.js.map +1 -1
- package/dist/react/workflows/refund-preview.d.ts +16 -0
- package/dist/react/workflows/refund-preview.d.ts.map +1 -0
- package/dist/react/workflows/refund-preview.js +38 -0
- package/dist/react/workflows/refund-preview.js.map +1 -0
- package/docs/react-workflows.md +79 -4
- package/package.json +1 -1
- package/src/client/costRecovery-invoices.test.ts +401 -0
- package/src/client/costRecovery-invoices.transport.test.ts +107 -0
- package/src/client/costRecovery-invoices.ts +144 -0
- package/src/client/costRecovery.ts +107 -2
- package/src/client/index.ts +1 -1
- package/src/component/_generated/component.ts +99 -0
- package/src/component/costRecovery/claims.ts +18 -8
- package/src/component/costRecovery/mutations.ts +72 -0
- package/src/component/costRecovery/schema.ts +8 -0
- package/src/react/workflows/Collections.test.tsx +36 -0
- package/src/react/workflows/Collections.tsx +3 -1
- package/src/react/workflows/Refunds.currency.test.tsx +290 -0
- package/src/react/workflows/Refunds.tsx +115 -25
- package/src/react/workflows/index.ts +8 -1
- package/src/react/workflows/refund-preview.test.ts +65 -0
- 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
|
-
/**
|
|
79
|
+
/** Host authorizes policy and scope. Provider methods verify invoice outcomes. */
|
|
69
80
|
export class CostRecoveryClient {
|
|
70
|
-
constructor(
|
|
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
|
}
|
package/src/client/index.ts
CHANGED
|
@@ -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
|
|
87
|
-
|
|
86
|
+
(group.sourceKind === "settlement"
|
|
87
|
+
? !group.sourceGross
|
|
88
|
+
: !group.invoiceFunding)
|
|
88
89
|
)
|
|
89
90
|
throw new Error(
|
|
90
|
-
"Rollback requires a collected
|
|
91
|
+
"Rollback requires a collected group with original funding economics.",
|
|
91
92
|
);
|
|
92
|
-
|
|
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(
|
|
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
|
-
|
|
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"),
|