@fantastic.dev/better-stripe 0.8.1 → 0.10.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 +12 -0
- package/MIGRATION.md +12 -0
- package/README.md +46 -2
- package/dist/client/billing/checkout.d.ts +1 -0
- package/dist/client/billing/checkout.d.ts.map +1 -1
- package/dist/client/billing/checkout.js +5 -1
- package/dist/client/billing/checkout.js.map +1 -1
- package/dist/client/billing/providerReads.d.ts +18 -0
- package/dist/client/billing/providerReads.d.ts.map +1 -0
- package/dist/client/billing/providerReads.js +15 -0
- package/dist/client/billing/providerReads.js.map +1 -0
- package/dist/client/billing/subscriptions.d.ts +1 -1
- package/dist/client/billing/subscriptions.d.ts.map +1 -1
- package/dist/client/billing/subscriptions.js.map +1 -1
- package/dist/client/connect/payoutSnapshot.d.ts +28 -0
- package/dist/client/connect/payoutSnapshot.d.ts.map +1 -0
- package/dist/client/connect/payoutSnapshot.js +81 -0
- package/dist/client/connect/payoutSnapshot.js.map +1 -0
- package/dist/client/connect/transfers.d.ts.map +1 -1
- package/dist/client/connect/transfers.js +13 -32
- package/dist/client/connect/transfers.js.map +1 -1
- package/dist/client/core/accounts.d.ts +12 -23
- package/dist/client/core/accounts.d.ts.map +1 -1
- package/dist/client/core/accounts.js +5 -1
- package/dist/client/core/accounts.js.map +1 -1
- package/dist/client/core/idempotency.d.ts +5 -0
- package/dist/client/core/idempotency.d.ts.map +1 -0
- package/dist/client/core/idempotency.js +10 -0
- package/dist/client/core/idempotency.js.map +1 -0
- package/dist/client/index.d.ts +31 -25
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +29 -52
- package/dist/client/index.js.map +1 -1
- package/dist/client/settlement/allocation.d.ts +30 -0
- package/dist/client/settlement/allocation.d.ts.map +1 -0
- package/dist/client/settlement/allocation.js +71 -0
- package/dist/client/settlement/allocation.js.map +1 -0
- package/dist/client/settlement/commissions.d.ts +27 -0
- package/dist/client/settlement/commissions.d.ts.map +1 -0
- package/dist/client/settlement/commissions.js +69 -0
- package/dist/client/settlement/commissions.js.map +1 -0
- package/dist/client/settlement/execution.d.ts +27 -0
- package/dist/client/settlement/execution.d.ts.map +1 -0
- package/dist/client/settlement/execution.js +70 -0
- package/dist/client/settlement/execution.js.map +1 -0
- package/dist/client/settlement/reconciliation.d.ts +10 -0
- package/dist/client/settlement/reconciliation.d.ts.map +1 -0
- package/dist/client/settlement/reconciliation.js +73 -0
- package/dist/client/settlement/reconciliation.js.map +1 -0
- package/dist/client/settlement/settleSale.d.ts +21 -0
- package/dist/client/settlement/settleSale.d.ts.map +1 -0
- package/dist/client/settlement/settleSale.js +27 -0
- package/dist/client/settlement/settleSale.js.map +1 -0
- package/dist/client/settlement/verifiedSale.d.ts +25 -0
- package/dist/client/settlement/verifiedSale.d.ts.map +1 -0
- package/dist/client/settlement/verifiedSale.js +100 -0
- package/dist/client/settlement/verifiedSale.js.map +1 -0
- package/dist/client/webhooks/asyncHooks.d.ts +5 -0
- package/dist/client/webhooks/asyncHooks.d.ts.map +1 -0
- package/dist/client/webhooks/asyncHooks.js +20 -0
- package/dist/client/webhooks/asyncHooks.js.map +1 -0
- package/dist/component/_generated/component.d.ts +115 -0
- package/dist/component/_generated/component.d.ts.map +1 -1
- package/dist/component/connect/queries.d.ts +30 -1
- package/dist/component/connect/queries.d.ts.map +1 -1
- package/dist/component/connect/queries.js +19 -2
- package/dist/component/connect/queries.js.map +1 -1
- package/dist/component/schema.d.ts +67 -0
- package/dist/component/schema.js +4 -0
- package/dist/component/schema.js.map +1 -1
- package/dist/component/settlement/mutations.d.ts +47 -0
- package/dist/component/settlement/mutations.d.ts.map +1 -0
- package/dist/component/settlement/mutations.js +104 -0
- package/dist/component/settlement/mutations.js.map +1 -0
- package/dist/component/settlement/queries.d.ts +29 -0
- package/dist/component/settlement/queries.d.ts.map +1 -0
- package/dist/component/settlement/queries.js +35 -0
- package/dist/component/settlement/queries.js.map +1 -0
- package/dist/component/settlement/schema.d.ts +99 -0
- package/dist/component/settlement/schema.d.ts.map +1 -0
- package/dist/component/settlement/schema.js +35 -0
- package/dist/component/settlement/schema.js.map +1 -0
- package/dist/react/workflows/AffiliatePayoutEditor.d.ts +41 -0
- package/dist/react/workflows/AffiliatePayoutEditor.d.ts.map +1 -0
- package/dist/react/workflows/AffiliatePayoutEditor.js +80 -0
- package/dist/react/workflows/AffiliatePayoutEditor.js.map +1 -0
- package/dist/react/workflows/AffiliateRateEditor.d.ts +26 -0
- package/dist/react/workflows/AffiliateRateEditor.d.ts.map +1 -0
- package/dist/react/workflows/AffiliateRateEditor.js +49 -0
- package/dist/react/workflows/AffiliateRateEditor.js.map +1 -0
- package/dist/react/workflows/Checkout.d.ts +3 -1
- package/dist/react/workflows/Checkout.d.ts.map +1 -1
- package/dist/react/workflows/Checkout.js +3 -2
- package/dist/react/workflows/Checkout.js.map +1 -1
- package/dist/react/workflows/CheckoutForms.d.ts +2 -0
- package/dist/react/workflows/CheckoutForms.d.ts.map +1 -1
- package/dist/react/workflows/CheckoutForms.js +22 -16
- package/dist/react/workflows/CheckoutForms.js.map +1 -1
- package/dist/react/workflows/IntegrationSwitch.js +2 -2
- package/dist/react/workflows/IntegrationSwitch.js.map +1 -1
- package/dist/react/workflows/Pagination.d.ts +32 -0
- package/dist/react/workflows/Pagination.d.ts.map +1 -0
- package/dist/react/workflows/Pagination.js +49 -0
- package/dist/react/workflows/Pagination.js.map +1 -0
- package/dist/react/workflows/Payouts.d.ts +3 -18
- package/dist/react/workflows/Payouts.d.ts.map +1 -1
- package/dist/react/workflows/Payouts.js +2 -2
- package/dist/react/workflows/Payouts.js.map +1 -1
- package/dist/react/workflows/PlanPricingEditor.d.ts +35 -0
- package/dist/react/workflows/PlanPricingEditor.d.ts.map +1 -0
- package/dist/react/workflows/PlanPricingEditor.js +64 -0
- package/dist/react/workflows/PlanPricingEditor.js.map +1 -0
- package/dist/react/workflows/index.d.ts +8 -0
- package/dist/react/workflows/index.d.ts.map +1 -1
- package/dist/react/workflows/index.js +4 -0
- package/dist/react/workflows/index.js.map +1 -1
- package/docs/react-workflows-audit.md +19 -0
- package/docs/react-workflows.md +94 -3
- package/docs/settlement.md +158 -0
- package/package.json +6 -4
- package/src/client/billing/checkout-idempotency.test.ts +82 -0
- package/src/client/billing/checkout.ts +6 -1
- package/src/client/billing/providerReads.test.ts +60 -0
- package/src/client/billing/providerReads.ts +30 -0
- package/src/client/billing/subscriptions.ts +1 -8
- package/src/client/connect/payoutSnapshot.test.ts +91 -0
- package/src/client/connect/payoutSnapshot.ts +130 -0
- package/src/client/connect/transfers.test.ts +15 -4
- package/src/client/connect/transfers.ts +13 -42
- package/src/client/core/account-idempotency.test.ts +78 -0
- package/src/client/core/accounts.ts +18 -25
- package/src/client/core/idempotency.test.ts +10 -0
- package/src/client/core/idempotency.ts +11 -0
- package/src/client/index.ts +84 -103
- package/src/client/settlement/allocation.test.ts +98 -0
- package/src/client/settlement/allocation.ts +110 -0
- package/src/client/settlement/commissions.test.ts +103 -0
- package/src/client/settlement/commissions.ts +125 -0
- package/src/client/settlement/execution.test.ts +186 -0
- package/src/client/settlement/execution.ts +134 -0
- package/src/client/settlement/reconciliation.ts +119 -0
- package/src/client/settlement/settleSale.ts +59 -0
- package/src/client/settlement/verifiedSale.test.ts +139 -0
- package/src/client/settlement/verifiedSale.ts +159 -0
- package/src/client/webhooks/asyncHooks.ts +38 -0
- package/src/client/webhooks/processors.test.ts +9 -9
- package/src/component/_generated/api.ts +4 -0
- package/src/component/_generated/component.ts +141 -0
- package/src/component/connect/queries.ts +26 -2
- package/src/component/earnings-totals.test.ts +13 -0
- package/src/component/schema.ts +8 -0
- package/src/component/settlement/mutations.ts +119 -0
- package/src/component/settlement/queries.ts +38 -0
- package/src/component/settlement/schema.ts +45 -0
- package/src/component/settlement-claims.test.ts +77 -0
- package/src/component/transfers.test.ts +48 -0
- package/src/react/components/EmbeddedDisputes.render.test.tsx +2 -2
- package/src/react/index.payment-element.test.ts +22 -37
- package/src/react/workflows/AffiliatePayoutEditor.test.tsx +90 -0
- package/src/react/workflows/AffiliatePayoutEditor.tsx +201 -0
- package/src/react/workflows/AffiliateRateEditor.test.tsx +36 -0
- package/src/react/workflows/AffiliateRateEditor.tsx +92 -0
- package/src/react/workflows/Checkout.test.tsx +30 -0
- package/src/react/workflows/Checkout.tsx +10 -3
- package/src/react/workflows/CheckoutForms.test.tsx +40 -0
- package/src/react/workflows/CheckoutForms.tsx +37 -16
- package/src/react/workflows/IntegrationSwitch.test.tsx +25 -0
- package/src/react/workflows/IntegrationSwitch.tsx +2 -2
- package/src/react/workflows/Pagination.test.tsx +53 -0
- package/src/react/workflows/Pagination.tsx +93 -0
- package/src/react/workflows/Payouts.test.tsx +33 -0
- package/src/react/workflows/Payouts.tsx +21 -15
- package/src/react/workflows/PlanPricingEditor.test.tsx +79 -0
- package/src/react/workflows/PlanPricingEditor.tsx +156 -0
- package/src/react/workflows/index.ts +22 -0
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import type Stripe from "stripe";
|
|
2
|
+
import { expect, it, vi } from "vitest";
|
|
3
|
+
import { getVerifiedSale } from "./verifiedSale.js";
|
|
4
|
+
function fixture() {
|
|
5
|
+
const payment = {
|
|
6
|
+
id: "pi_1",
|
|
7
|
+
status: "succeeded",
|
|
8
|
+
currency: "usd",
|
|
9
|
+
amount_received: 10000,
|
|
10
|
+
metadata: { seller: "store" },
|
|
11
|
+
latest_charge: {
|
|
12
|
+
id: "ch_1",
|
|
13
|
+
amount: 10000,
|
|
14
|
+
currency: "usd",
|
|
15
|
+
amount_refunded: 0,
|
|
16
|
+
disputed: false,
|
|
17
|
+
balance_transaction: { currency: "usd", fee: 320 },
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
const raw = {
|
|
21
|
+
paymentIntents: { retrieve: vi.fn().mockResolvedValue(payment) },
|
|
22
|
+
subscriptions: {
|
|
23
|
+
retrieve: vi.fn().mockResolvedValue({
|
|
24
|
+
id: "sub_1",
|
|
25
|
+
metadata: { ref: "a" },
|
|
26
|
+
items: {
|
|
27
|
+
data: [{ price: { id: "price_current", product: "prod_current" } }],
|
|
28
|
+
},
|
|
29
|
+
}),
|
|
30
|
+
},
|
|
31
|
+
invoices: {
|
|
32
|
+
retrieve: vi.fn().mockResolvedValue({
|
|
33
|
+
id: "in_1",
|
|
34
|
+
status: "paid",
|
|
35
|
+
amount_paid: 10000,
|
|
36
|
+
currency: "usd",
|
|
37
|
+
parent: { subscription_details: { subscription: "sub_1" } },
|
|
38
|
+
}),
|
|
39
|
+
},
|
|
40
|
+
invoicePayments: {
|
|
41
|
+
list: vi.fn().mockResolvedValue({
|
|
42
|
+
data: [{ status: "paid", payment: { payment_intent: "pi_1" } }],
|
|
43
|
+
has_more: false,
|
|
44
|
+
}),
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
return { payment, raw, stripe: raw as unknown as Stripe };
|
|
48
|
+
}
|
|
49
|
+
it("returns actual fees, verified invoice linkage and current price instead of stale metadata", async () => {
|
|
50
|
+
const { stripe, raw } = fixture();
|
|
51
|
+
expect(
|
|
52
|
+
await getVerifiedSale(stripe, {
|
|
53
|
+
paymentIntentId: "pi_1",
|
|
54
|
+
subscriptionId: "sub_1",
|
|
55
|
+
invoiceId: "in_1",
|
|
56
|
+
}),
|
|
57
|
+
).toMatchObject({
|
|
58
|
+
status: "ready",
|
|
59
|
+
chargeId: "ch_1",
|
|
60
|
+
processingFee: 320,
|
|
61
|
+
currency: "usd",
|
|
62
|
+
stripePriceId: "price_current",
|
|
63
|
+
stripeProductId: "prod_current",
|
|
64
|
+
invoiceId: "in_1",
|
|
65
|
+
});
|
|
66
|
+
expect(raw.paymentIntents.retrieve).toHaveBeenCalledWith("pi_1", {
|
|
67
|
+
expand: ["latest_charge.balance_transaction"],
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
it("reports missing fees and unsuccessful payments as non-ready, never zero fees", async () => {
|
|
71
|
+
const { stripe, raw, payment } = fixture();
|
|
72
|
+
raw.paymentIntents.retrieve.mockResolvedValue({
|
|
73
|
+
...payment,
|
|
74
|
+
latest_charge: { ...payment.latest_charge, balance_transaction: null },
|
|
75
|
+
});
|
|
76
|
+
expect(await getVerifiedSale(stripe, { paymentIntentId: "pi_1" })).toEqual({
|
|
77
|
+
status: "pending",
|
|
78
|
+
reason: "processing_fee_unavailable",
|
|
79
|
+
retryable: true,
|
|
80
|
+
});
|
|
81
|
+
raw.paymentIntents.retrieve.mockResolvedValue({
|
|
82
|
+
...payment,
|
|
83
|
+
status: "requires_payment_method",
|
|
84
|
+
});
|
|
85
|
+
expect(
|
|
86
|
+
await getVerifiedSale(stripe, { paymentIntentId: "pi_1" }),
|
|
87
|
+
).toMatchObject({ status: "not_payable", retryable: false });
|
|
88
|
+
});
|
|
89
|
+
it("rejects mismatched currencies and unrelated invoice payments", async () => {
|
|
90
|
+
const { stripe, raw, payment } = fixture();
|
|
91
|
+
raw.paymentIntents.retrieve.mockResolvedValue({
|
|
92
|
+
...payment,
|
|
93
|
+
latest_charge: {
|
|
94
|
+
...payment.latest_charge,
|
|
95
|
+
balance_transaction: { currency: "eur", fee: 320 },
|
|
96
|
+
},
|
|
97
|
+
});
|
|
98
|
+
await expect(
|
|
99
|
+
getVerifiedSale(stripe, { paymentIntentId: "pi_1" }),
|
|
100
|
+
).rejects.toThrow();
|
|
101
|
+
raw.paymentIntents.retrieve.mockResolvedValue(payment);
|
|
102
|
+
raw.invoicePayments.list.mockResolvedValue({
|
|
103
|
+
data: [{ status: "paid", payment: { payment_intent: "pi_other" } }],
|
|
104
|
+
has_more: false,
|
|
105
|
+
});
|
|
106
|
+
await expect(
|
|
107
|
+
getVerifiedSale(stripe, {
|
|
108
|
+
paymentIntentId: "pi_1",
|
|
109
|
+
invoiceId: "in_1",
|
|
110
|
+
subscriptionId: "sub_1",
|
|
111
|
+
}),
|
|
112
|
+
).rejects.toThrow();
|
|
113
|
+
});
|
|
114
|
+
it("marks processing payments as retryable without paying recipients", async () => {
|
|
115
|
+
const { stripe, raw, payment } = fixture();
|
|
116
|
+
raw.paymentIntents.retrieve.mockResolvedValue({
|
|
117
|
+
...payment,
|
|
118
|
+
status: "processing",
|
|
119
|
+
});
|
|
120
|
+
expect(await getVerifiedSale(stripe, { paymentIntentId: "pi_1" })).toEqual({
|
|
121
|
+
status: "pending",
|
|
122
|
+
reason: "payment_processing",
|
|
123
|
+
retryable: true,
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("refuses partial invoice payments rather than awarding a fixed commission more than once", async () => {
|
|
128
|
+
const { stripe, raw } = fixture();
|
|
129
|
+
raw.invoices.retrieve.mockResolvedValue({
|
|
130
|
+
id: "in_1",
|
|
131
|
+
status: "paid",
|
|
132
|
+
amount_paid: 20000,
|
|
133
|
+
currency: "usd",
|
|
134
|
+
parent: { subscription_details: { subscription: "sub_1" } },
|
|
135
|
+
});
|
|
136
|
+
await expect(
|
|
137
|
+
getVerifiedSale(stripe, { paymentIntentId: "pi_1", invoiceId: "in_1" }),
|
|
138
|
+
).rejects.toThrow("Partial invoice payments");
|
|
139
|
+
});
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import type Stripe from "stripe";
|
|
2
|
+
import { isBetterStripeError, throwStripeError } from "../errors.js";
|
|
3
|
+
export type VerifiedSaleOptions = {
|
|
4
|
+
paymentIntentId: string;
|
|
5
|
+
subscriptionId?: string;
|
|
6
|
+
invoiceId?: string;
|
|
7
|
+
};
|
|
8
|
+
export type VerifiedSale =
|
|
9
|
+
| { status: "pending" | "not_payable"; reason: string; retryable: boolean }
|
|
10
|
+
| {
|
|
11
|
+
status: "ready";
|
|
12
|
+
paymentIntentId: string;
|
|
13
|
+
chargeId: string;
|
|
14
|
+
gross: number;
|
|
15
|
+
processingFee: number;
|
|
16
|
+
currency: string;
|
|
17
|
+
metadata: Record<string, string>;
|
|
18
|
+
invoiceId?: string;
|
|
19
|
+
subscriptionId?: string;
|
|
20
|
+
stripePriceId?: string;
|
|
21
|
+
stripeProductId?: string;
|
|
22
|
+
};
|
|
23
|
+
const id = (value: string | { id: string } | null | undefined) =>
|
|
24
|
+
typeof value === "string" ? value : value?.id;
|
|
25
|
+
export async function getVerifiedSale(
|
|
26
|
+
stripe: Stripe,
|
|
27
|
+
opts: VerifiedSaleOptions,
|
|
28
|
+
): Promise<VerifiedSale> {
|
|
29
|
+
try {
|
|
30
|
+
const payment = await stripe.paymentIntents.retrieve(opts.paymentIntentId, {
|
|
31
|
+
expand: ["latest_charge.balance_transaction"],
|
|
32
|
+
});
|
|
33
|
+
if (payment.status === "processing")
|
|
34
|
+
return {
|
|
35
|
+
status: "pending",
|
|
36
|
+
reason: "payment_processing",
|
|
37
|
+
retryable: true,
|
|
38
|
+
};
|
|
39
|
+
if (payment.status !== "succeeded")
|
|
40
|
+
return {
|
|
41
|
+
status: "not_payable",
|
|
42
|
+
reason: "payment_not_succeeded",
|
|
43
|
+
retryable: false,
|
|
44
|
+
};
|
|
45
|
+
const charge = payment.latest_charge;
|
|
46
|
+
if (!charge || typeof charge === "string")
|
|
47
|
+
return {
|
|
48
|
+
status: "pending",
|
|
49
|
+
reason: "charge_unavailable",
|
|
50
|
+
retryable: true,
|
|
51
|
+
};
|
|
52
|
+
if (charge.amount_refunded > 0 || charge.disputed)
|
|
53
|
+
return {
|
|
54
|
+
status: "not_payable",
|
|
55
|
+
reason: "charge_refunded_or_disputed",
|
|
56
|
+
retryable: false,
|
|
57
|
+
};
|
|
58
|
+
const balance = charge.balance_transaction;
|
|
59
|
+
if (!balance || typeof balance === "string")
|
|
60
|
+
return {
|
|
61
|
+
status: "pending",
|
|
62
|
+
reason: "processing_fee_unavailable",
|
|
63
|
+
retryable: true,
|
|
64
|
+
};
|
|
65
|
+
if (
|
|
66
|
+
balance.currency !== payment.currency ||
|
|
67
|
+
charge.currency !== payment.currency
|
|
68
|
+
)
|
|
69
|
+
throwStripeError(
|
|
70
|
+
"INVALID_CONFIGURATION",
|
|
71
|
+
"Settlement currency must match the payment and processing currency.",
|
|
72
|
+
);
|
|
73
|
+
if (
|
|
74
|
+
![payment.amount_received, balance.fee].every(Number.isSafeInteger) ||
|
|
75
|
+
balance.fee < 0 ||
|
|
76
|
+
balance.fee > payment.amount_received ||
|
|
77
|
+
charge.amount !== payment.amount_received
|
|
78
|
+
)
|
|
79
|
+
throwStripeError(
|
|
80
|
+
"INVALID_CONFIGURATION",
|
|
81
|
+
"Unsupported payment or processing amounts.",
|
|
82
|
+
);
|
|
83
|
+
if (opts.subscriptionId && !opts.invoiceId)
|
|
84
|
+
throwStripeError(
|
|
85
|
+
"INVALID_CONFIGURATION",
|
|
86
|
+
"Subscription settlement requires a paid invoice.",
|
|
87
|
+
);
|
|
88
|
+
const invoice = opts.invoiceId
|
|
89
|
+
? await stripe.invoices.retrieve(opts.invoiceId)
|
|
90
|
+
: null;
|
|
91
|
+
if (invoice && invoice.status !== "paid")
|
|
92
|
+
return { status: "pending", reason: "invoice_not_paid", retryable: true };
|
|
93
|
+
const subscriptionId = id(
|
|
94
|
+
invoice?.parent?.subscription_details?.subscription,
|
|
95
|
+
);
|
|
96
|
+
if (
|
|
97
|
+
invoice &&
|
|
98
|
+
(invoice.currency !== payment.currency ||
|
|
99
|
+
(opts.subscriptionId && subscriptionId !== opts.subscriptionId))
|
|
100
|
+
)
|
|
101
|
+
throwStripeError(
|
|
102
|
+
"INVALID_CONFIGURATION",
|
|
103
|
+
"Invoice currency or subscription does not match settlement.",
|
|
104
|
+
);
|
|
105
|
+
if (invoice) {
|
|
106
|
+
if (invoice.amount_paid !== payment.amount_received)
|
|
107
|
+
throwStripeError(
|
|
108
|
+
"INVALID_CONFIGURATION",
|
|
109
|
+
"Partial invoice payments require an explicitly resolved invoice allocation.",
|
|
110
|
+
);
|
|
111
|
+
const linked = await stripe.invoicePayments.list({
|
|
112
|
+
invoice: invoice.id,
|
|
113
|
+
limit: 100,
|
|
114
|
+
status: "paid",
|
|
115
|
+
payment: { type: "payment_intent", payment_intent: payment.id },
|
|
116
|
+
});
|
|
117
|
+
if (
|
|
118
|
+
!linked.data.some(
|
|
119
|
+
(row) =>
|
|
120
|
+
row.status === "paid" &&
|
|
121
|
+
id(row.payment.payment_intent) === payment.id,
|
|
122
|
+
)
|
|
123
|
+
)
|
|
124
|
+
throwStripeError(
|
|
125
|
+
"INVALID_CONFIGURATION",
|
|
126
|
+
"Payment does not belong to this paid invoice.",
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
const subscription = subscriptionId
|
|
130
|
+
? await stripe.subscriptions.retrieve(subscriptionId)
|
|
131
|
+
: null;
|
|
132
|
+
if (subscription && subscription.items.data.length !== 1)
|
|
133
|
+
throwStripeError(
|
|
134
|
+
"INVALID_CONFIGURATION",
|
|
135
|
+
"Resolve multi-price subscription allocations explicitly.",
|
|
136
|
+
);
|
|
137
|
+
const price = subscription?.items.data[0]?.price;
|
|
138
|
+
const metadata = subscription?.metadata ?? payment.metadata;
|
|
139
|
+
return {
|
|
140
|
+
status: "ready",
|
|
141
|
+
paymentIntentId: payment.id,
|
|
142
|
+
chargeId: charge.id,
|
|
143
|
+
gross: payment.amount_received,
|
|
144
|
+
processingFee: balance.fee,
|
|
145
|
+
currency: payment.currency,
|
|
146
|
+
metadata,
|
|
147
|
+
invoiceId: invoice?.id,
|
|
148
|
+
subscriptionId,
|
|
149
|
+
stripePriceId: price?.id ?? metadata.stripePriceId,
|
|
150
|
+
stripeProductId: id(price?.product) ?? metadata.stripeProductId,
|
|
151
|
+
};
|
|
152
|
+
} catch (error) {
|
|
153
|
+
if (isBetterStripeError(error)) throw error;
|
|
154
|
+
throwStripeError(
|
|
155
|
+
"STRIPE_API_ERROR",
|
|
156
|
+
"Could not retrieve verified sale context.",
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { AsyncHooks, AsyncHookCtx } from "../types.js";
|
|
2
|
+
import type { AsyncHookName } from "../types/triggers.js";
|
|
3
|
+
/** Route async webhook hooks through the shared public callback contract. */
|
|
4
|
+
export function buildAsyncHookRoutes(hooks: AsyncHooks | undefined) {
|
|
5
|
+
const routes: Record<
|
|
6
|
+
AsyncHookName,
|
|
7
|
+
((ctx: AsyncHookCtx, doc: unknown) => Promise<void>) | undefined
|
|
8
|
+
> = {
|
|
9
|
+
afterAccountUpdated: hooks?.onAccountUpdated as
|
|
10
|
+
((ctx: AsyncHookCtx, doc: unknown) => Promise<void>) | undefined,
|
|
11
|
+
afterCheckoutCompleted: hooks?.onCheckoutCompleted as
|
|
12
|
+
((ctx: AsyncHookCtx, doc: unknown) => Promise<void>) | undefined,
|
|
13
|
+
afterSubscriptionUpdated: hooks?.onSubscriptionUpdated as
|
|
14
|
+
((ctx: AsyncHookCtx, doc: unknown) => Promise<void>) | undefined,
|
|
15
|
+
afterSubscriptionCanceled: hooks?.onSubscriptionCanceled as
|
|
16
|
+
((ctx: AsyncHookCtx, doc: unknown) => Promise<void>) | undefined,
|
|
17
|
+
afterTrialEnding: hooks?.onTrialEnding as
|
|
18
|
+
((ctx: AsyncHookCtx, doc: unknown) => Promise<void>) | undefined,
|
|
19
|
+
afterInvoicePaid: hooks?.onInvoicePaid as
|
|
20
|
+
((ctx: AsyncHookCtx, doc: unknown) => Promise<void>) | undefined,
|
|
21
|
+
afterInvoicePaymentFailed: hooks?.onInvoicePaymentFailed as
|
|
22
|
+
((ctx: AsyncHookCtx, doc: unknown) => Promise<void>) | undefined,
|
|
23
|
+
afterPaymentSucceeded: hooks?.onPaymentSucceeded as
|
|
24
|
+
((ctx: AsyncHookCtx, doc: unknown) => Promise<void>) | undefined,
|
|
25
|
+
afterPaymentFailed: hooks?.onPaymentFailed as
|
|
26
|
+
((ctx: AsyncHookCtx, doc: unknown) => Promise<void>) | undefined,
|
|
27
|
+
afterPayoutCompleted: hooks?.onPayoutCompleted as
|
|
28
|
+
((ctx: AsyncHookCtx, doc: unknown) => Promise<void>) | undefined,
|
|
29
|
+
afterRefundCreated: hooks?.onRefundCreated as
|
|
30
|
+
((ctx: AsyncHookCtx, doc: unknown) => Promise<void>) | undefined,
|
|
31
|
+
afterDisputeCreated: hooks?.onDisputeCreated as
|
|
32
|
+
((ctx: AsyncHookCtx, doc: unknown) => Promise<void>) | undefined,
|
|
33
|
+
afterDisputeClosed: hooks?.onDisputeClosed as
|
|
34
|
+
((ctx: AsyncHookCtx, doc: unknown) => Promise<void>) | undefined,
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
return routes;
|
|
38
|
+
}
|
|
@@ -1,8 +1,4 @@
|
|
|
1
1
|
// @vitest-environment edge-runtime
|
|
2
|
-
/**
|
|
3
|
-
* Drives Stripe event normalization through processEvent and checks the payloads
|
|
4
|
-
* passed to component mutations, including owner, fee, and trial fields.
|
|
5
|
-
*/
|
|
6
2
|
import type Stripe from "stripe";
|
|
7
3
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
8
4
|
|
|
@@ -12,7 +8,6 @@ import { processEvent } from "./processors.js";
|
|
|
12
8
|
|
|
13
9
|
const TO_REF = Symbol.for("toReferencePath");
|
|
14
10
|
|
|
15
|
-
/** Fake component exposing every query/mutation ref processors.ts resolves. */
|
|
16
11
|
function makeComponent() {
|
|
17
12
|
const refs = (namespace: string, names: string[]) =>
|
|
18
13
|
Object.fromEntries(
|
|
@@ -44,12 +39,16 @@ function makeCtx(opts?: { query?: unknown; queryThrows?: boolean }) {
|
|
|
44
39
|
const runQuery = vi.fn();
|
|
45
40
|
if (opts?.queryThrows) runQuery.mockRejectedValue(new Error("not found"));
|
|
46
41
|
else runQuery.mockResolvedValue(opts?.query ?? null);
|
|
47
|
-
//
|
|
48
|
-
// (BTS-63). Fake only its storage: the slice math is the REAL shared
|
|
49
|
-
// computeReversalSlices, applied to this ctx's ledger legs.
|
|
42
|
+
// Fake claim storage; use the shared reversal sizing math.
|
|
50
43
|
const runMutation = vi.fn(
|
|
51
44
|
async (ref: Record<symbol, string>, args: Record<string, unknown>) => {
|
|
52
45
|
const path = ref?.[TO_REF] ?? "";
|
|
46
|
+
if (
|
|
47
|
+
String(path).endsWith(
|
|
48
|
+
"settlement/mutations/reserveSettlementAllocation",
|
|
49
|
+
)
|
|
50
|
+
)
|
|
51
|
+
return args;
|
|
53
52
|
if (!String(path).endsWith("connect/mutations/claimReversalSlices")) {
|
|
54
53
|
return undefined;
|
|
55
54
|
}
|
|
@@ -107,7 +106,9 @@ function makeStripe() {
|
|
|
107
106
|
update: vi.fn().mockResolvedValue({}),
|
|
108
107
|
retrieve: vi.fn(),
|
|
109
108
|
},
|
|
109
|
+
charges: { retrieve: vi.fn().mockResolvedValue({ created: 1 }) },
|
|
110
110
|
transfers: {
|
|
111
|
+
list: vi.fn().mockResolvedValue({ data: [], has_more: false }),
|
|
111
112
|
create: vi.fn(async (_params: unknown) => ({
|
|
112
113
|
id: `tr_${++trCreateSeq}`,
|
|
113
114
|
})),
|
|
@@ -146,7 +147,6 @@ function makeStripe() {
|
|
|
146
147
|
retrieve: vi.fn(),
|
|
147
148
|
update: vi.fn().mockResolvedValue({}),
|
|
148
149
|
},
|
|
149
|
-
charges: { retrieve: vi.fn() },
|
|
150
150
|
};
|
|
151
151
|
}
|
|
152
152
|
|
|
@@ -25,6 +25,8 @@ import type * as lib_stripeFactory from "../lib/stripeFactory.js";
|
|
|
25
25
|
import type * as products_mutations from "../products/mutations.js";
|
|
26
26
|
import type * as products_queries from "../products/queries.js";
|
|
27
27
|
import type * as products_validators from "../products/validators.js";
|
|
28
|
+
import type * as settlement_mutations from "../settlement/mutations.js";
|
|
29
|
+
import type * as settlement_queries from "../settlement/queries.js";
|
|
28
30
|
import type * as webhooks_mutations from "../webhooks/mutations.js";
|
|
29
31
|
import type * as webhooks_queries from "../webhooks/queries.js";
|
|
30
32
|
import type * as webhooks_validators from "../webhooks/validators.js";
|
|
@@ -54,6 +56,8 @@ const fullApi: ApiFromModules<{
|
|
|
54
56
|
"products/mutations": typeof products_mutations;
|
|
55
57
|
"products/queries": typeof products_queries;
|
|
56
58
|
"products/validators": typeof products_validators;
|
|
59
|
+
"settlement/mutations": typeof settlement_mutations;
|
|
60
|
+
"settlement/queries": typeof settlement_queries;
|
|
57
61
|
"webhooks/mutations": typeof webhooks_mutations;
|
|
58
62
|
"webhooks/queries": typeof webhooks_queries;
|
|
59
63
|
"webhooks/validators": typeof webhooks_validators;
|
|
@@ -611,6 +611,7 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
|
|
|
611
611
|
destinationAccountId?: string;
|
|
612
612
|
feeCollectedAmount?: number;
|
|
613
613
|
feeRefundedAmount?: number;
|
|
614
|
+
isCollectionPaused?: boolean;
|
|
614
615
|
isTrialing: boolean;
|
|
615
616
|
metadata?: any;
|
|
616
617
|
orgId?: string;
|
|
@@ -1339,6 +1340,46 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
|
|
|
1339
1340
|
}>,
|
|
1340
1341
|
Name
|
|
1341
1342
|
>;
|
|
1343
|
+
paginateTransfersByAccount: FunctionReference<
|
|
1344
|
+
"query",
|
|
1345
|
+
"internal",
|
|
1346
|
+
{
|
|
1347
|
+
destinationAccountId: string;
|
|
1348
|
+
paginationOpts: {
|
|
1349
|
+
cursor: string | null;
|
|
1350
|
+
endCursor?: string | null;
|
|
1351
|
+
id?: number;
|
|
1352
|
+
maximumBytesRead?: number;
|
|
1353
|
+
maximumRowsRead?: number;
|
|
1354
|
+
numItems: number;
|
|
1355
|
+
};
|
|
1356
|
+
},
|
|
1357
|
+
{
|
|
1358
|
+
continueCursor: string;
|
|
1359
|
+
isDone: boolean;
|
|
1360
|
+
page: Array<{
|
|
1361
|
+
_creationTime: number;
|
|
1362
|
+
_id: string;
|
|
1363
|
+
amount: number;
|
|
1364
|
+
currency: string;
|
|
1365
|
+
destinationAccountId: string;
|
|
1366
|
+
metadata?: any;
|
|
1367
|
+
paymentId?: string;
|
|
1368
|
+
reinstatement?: boolean;
|
|
1369
|
+
reversalClaimedAmount?: number;
|
|
1370
|
+
reversalStatus?: "partially_reversed" | "fully_reversed";
|
|
1371
|
+
reversedAmount?: number;
|
|
1372
|
+
role?: "store" | "affiliate" | "other";
|
|
1373
|
+
sourceChargeId?: string;
|
|
1374
|
+
sourceInvoiceId?: string;
|
|
1375
|
+
status: "pending" | "paid" | "failed" | "reversed";
|
|
1376
|
+
stripeTransferId: string;
|
|
1377
|
+
}>;
|
|
1378
|
+
pageStatus?: "SplitRecommended" | "SplitRequired" | null;
|
|
1379
|
+
splitCursor?: string | null;
|
|
1380
|
+
},
|
|
1381
|
+
Name
|
|
1382
|
+
>;
|
|
1342
1383
|
};
|
|
1343
1384
|
};
|
|
1344
1385
|
core: {
|
|
@@ -1722,6 +1763,106 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
|
|
|
1722
1763
|
>;
|
|
1723
1764
|
};
|
|
1724
1765
|
};
|
|
1766
|
+
settlement: {
|
|
1767
|
+
mutations: {
|
|
1768
|
+
claimCommissionInvoice: FunctionReference<
|
|
1769
|
+
"mutation",
|
|
1770
|
+
"internal",
|
|
1771
|
+
{
|
|
1772
|
+
affiliateAccountId?: string;
|
|
1773
|
+
firstPaidInvoiceId: string;
|
|
1774
|
+
invoiceId: string;
|
|
1775
|
+
subscriptionId: string;
|
|
1776
|
+
term: "forever" | "one_time";
|
|
1777
|
+
},
|
|
1778
|
+
{
|
|
1779
|
+
claimKey: string;
|
|
1780
|
+
eligible: boolean;
|
|
1781
|
+
firstPaidInvoiceId: string;
|
|
1782
|
+
replay: boolean;
|
|
1783
|
+
term: "forever" | "one_time";
|
|
1784
|
+
},
|
|
1785
|
+
Name
|
|
1786
|
+
>;
|
|
1787
|
+
reserveSettlementAllocation: FunctionReference<
|
|
1788
|
+
"mutation",
|
|
1789
|
+
"internal",
|
|
1790
|
+
{
|
|
1791
|
+
adjustmentAmount?: number;
|
|
1792
|
+
currency: string;
|
|
1793
|
+
gross: number;
|
|
1794
|
+
paymentId?: string;
|
|
1795
|
+
paymentIntentId?: string;
|
|
1796
|
+
platformNet: number;
|
|
1797
|
+
processingFee: number;
|
|
1798
|
+
sourceChargeId: string;
|
|
1799
|
+
sourceInvoiceId?: string;
|
|
1800
|
+
transfers: Array<{
|
|
1801
|
+
amount: number;
|
|
1802
|
+
destinationAccountId: string;
|
|
1803
|
+
role: "store" | "affiliate" | "other";
|
|
1804
|
+
}>;
|
|
1805
|
+
},
|
|
1806
|
+
{
|
|
1807
|
+
_creationTime: number;
|
|
1808
|
+
_id: string;
|
|
1809
|
+
adjustmentAmount?: number;
|
|
1810
|
+
currency: string;
|
|
1811
|
+
gross: number;
|
|
1812
|
+
paymentId?: string;
|
|
1813
|
+
paymentIntentId?: string;
|
|
1814
|
+
platformNet: number;
|
|
1815
|
+
processingFee: number;
|
|
1816
|
+
sourceChargeId: string;
|
|
1817
|
+
sourceInvoiceId?: string;
|
|
1818
|
+
transfers: Array<{
|
|
1819
|
+
amount: number;
|
|
1820
|
+
destinationAccountId: string;
|
|
1821
|
+
role: "store" | "affiliate" | "other";
|
|
1822
|
+
}>;
|
|
1823
|
+
},
|
|
1824
|
+
Name
|
|
1825
|
+
>;
|
|
1826
|
+
};
|
|
1827
|
+
queries: {
|
|
1828
|
+
getCommissionTerm: FunctionReference<
|
|
1829
|
+
"query",
|
|
1830
|
+
"internal",
|
|
1831
|
+
{ subscriptionId: string },
|
|
1832
|
+
{
|
|
1833
|
+
affiliateAccountId?: string;
|
|
1834
|
+
firstPaidInvoiceId: string;
|
|
1835
|
+
subscriptionId: string;
|
|
1836
|
+
term: "forever" | "one_time";
|
|
1837
|
+
} | null,
|
|
1838
|
+
Name
|
|
1839
|
+
>;
|
|
1840
|
+
getSettlementAllocation: FunctionReference<
|
|
1841
|
+
"query",
|
|
1842
|
+
"internal",
|
|
1843
|
+
{ sourceChargeId: string },
|
|
1844
|
+
{
|
|
1845
|
+
_creationTime: number;
|
|
1846
|
+
_id: string;
|
|
1847
|
+
adjustmentAmount?: number;
|
|
1848
|
+
currency: string;
|
|
1849
|
+
gross: number;
|
|
1850
|
+
paymentId?: string;
|
|
1851
|
+
paymentIntentId?: string;
|
|
1852
|
+
platformNet: number;
|
|
1853
|
+
processingFee: number;
|
|
1854
|
+
sourceChargeId: string;
|
|
1855
|
+
sourceInvoiceId?: string;
|
|
1856
|
+
transfers: Array<{
|
|
1857
|
+
amount: number;
|
|
1858
|
+
destinationAccountId: string;
|
|
1859
|
+
role: "store" | "affiliate" | "other";
|
|
1860
|
+
}>;
|
|
1861
|
+
} | null,
|
|
1862
|
+
Name
|
|
1863
|
+
>;
|
|
1864
|
+
};
|
|
1865
|
+
};
|
|
1725
1866
|
webhooks: {
|
|
1726
1867
|
mutations: {
|
|
1727
1868
|
insertWebhookEvent: FunctionReference<
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
import { v } from "convex/values";
|
|
2
|
+
import { paginator } from "convex-helpers/server/pagination";
|
|
3
|
+
import schema from "../schema.js";
|
|
4
|
+
import {
|
|
5
|
+
paginationOptsValidator,
|
|
6
|
+
paginationResultValidator,
|
|
7
|
+
} from "convex/server";
|
|
2
8
|
|
|
3
9
|
import type { Doc } from "../_generated/dataModel";
|
|
4
10
|
import { query } from "../_generated/server";
|
|
@@ -343,6 +349,23 @@ export const listTransfersByAccount = query({
|
|
|
343
349
|
},
|
|
344
350
|
});
|
|
345
351
|
|
|
352
|
+
/** Newest transfers for a recipient, with bounded cursor pagination. */
|
|
353
|
+
export const paginateTransfersByAccount = query({
|
|
354
|
+
args: {
|
|
355
|
+
destinationAccountId: v.string(),
|
|
356
|
+
paginationOpts: paginationOptsValidator,
|
|
357
|
+
},
|
|
358
|
+
returns: paginationResultValidator(transferDocValidator),
|
|
359
|
+
handler: (ctx, args) =>
|
|
360
|
+
paginator(ctx.db, schema)
|
|
361
|
+
.query("transfers")
|
|
362
|
+
.withIndex("by_destination_account_id", (q) =>
|
|
363
|
+
q.eq("destinationAccountId", args.destinationAccountId),
|
|
364
|
+
)
|
|
365
|
+
.order("desc")
|
|
366
|
+
.paginate(args.paginationOpts),
|
|
367
|
+
});
|
|
368
|
+
|
|
346
369
|
// =============================================================================
|
|
347
370
|
// REVERSAL-CLAIM RECLAIM (BTS-74) — ops diagnostics for wedged legs
|
|
348
371
|
// =============================================================================
|
|
@@ -442,7 +465,7 @@ export const listWedgedReversalClaims = query({
|
|
|
442
465
|
* Exact earnings totals for one connected account (BTS-64): `gross` (sum of all
|
|
443
466
|
* transfer amounts, incl. reinstatements — the earnings view) and `reversed`
|
|
444
467
|
* (sum of all `reversedAmount`), iterated to completion so totals are never
|
|
445
|
-
* capped. `transfers` is a bounded preview (`previewLimit ?? 50` rows) for
|
|
468
|
+
* capped. `transfers` is a newest-first bounded preview (`previewLimit ?? 50` rows) for
|
|
446
469
|
* drill-down; use `gross`/`reversed`/`transferCount` for the money figures.
|
|
447
470
|
*/
|
|
448
471
|
export const getAccountEarnings = query({
|
|
@@ -467,7 +490,8 @@ export const getAccountEarnings = query({
|
|
|
467
490
|
.query("transfers")
|
|
468
491
|
.withIndex("by_destination_account_id", (q) =>
|
|
469
492
|
q.eq("destinationAccountId", args.destinationAccountId),
|
|
470
|
-
)
|
|
493
|
+
)
|
|
494
|
+
.order("desc")) {
|
|
471
495
|
gross += tr.amount;
|
|
472
496
|
reversed += tr.reversedAmount ?? 0;
|
|
473
497
|
transferCount += 1;
|
|
@@ -50,6 +50,18 @@ describe("getAccountEarnings — exact totals past the 50-row cap (BTS-64)", ()
|
|
|
50
50
|
expect(result.transferCount).toBe(120);
|
|
51
51
|
// The preview is still bounded (drill-down rows), but the totals are exact.
|
|
52
52
|
expect(result.transfers.length).toBe(50);
|
|
53
|
+
expect(result.transfers[0].stripeTransferId).toBe("tr_big_119");
|
|
54
|
+
expect(
|
|
55
|
+
result.transfers.some((row) => row.stripeTransferId === "tr_big_0"),
|
|
56
|
+
).toBe(false);
|
|
57
|
+
const newest = await t.query(api.connect.queries.getAccountEarnings, {
|
|
58
|
+
destinationAccountId: "acct_big",
|
|
59
|
+
previewLimit: 1,
|
|
60
|
+
});
|
|
61
|
+
expect(newest.transfers.map((row) => row.stripeTransferId)).toEqual([
|
|
62
|
+
"tr_big_119",
|
|
63
|
+
]);
|
|
64
|
+
expect(newest.gross).toBe(12000);
|
|
53
65
|
});
|
|
54
66
|
|
|
55
67
|
it("returns exact totals and all rows for a small account (regression)", async () => {
|
|
@@ -208,6 +220,7 @@ describe("aggregate read-limit failures (BTS-116)", () => {
|
|
|
208
220
|
"Too many documents read in a single function execution",
|
|
209
221
|
);
|
|
210
222
|
const rows = {
|
|
223
|
+
order: () => rows,
|
|
211
224
|
async *[Symbol.asyncIterator]() {
|
|
212
225
|
yield { amount: 100, reversedAmount: 10, status: "paid" };
|
|
213
226
|
throw readLimit;
|
package/src/component/schema.ts
CHANGED
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
import {
|
|
2
|
+
allocationsTable,
|
|
3
|
+
commissionTermsTable,
|
|
4
|
+
commissionDecisionsTable,
|
|
5
|
+
} from "./settlement/schema";
|
|
1
6
|
import { defineSchema } from "convex/server";
|
|
2
7
|
|
|
3
8
|
import {
|
|
@@ -19,6 +24,9 @@ import { pricesTable, productsTable } from "./products/schema";
|
|
|
19
24
|
import { webhookEventsTable } from "./webhooks/schema";
|
|
20
25
|
|
|
21
26
|
export default defineSchema({
|
|
27
|
+
settlementAllocations: allocationsTable,
|
|
28
|
+
commissionTerms: commissionTermsTable,
|
|
29
|
+
commissionDecisions: commissionDecisionsTable,
|
|
22
30
|
// Core
|
|
23
31
|
accounts: accountsTable,
|
|
24
32
|
|