@fantastic.dev/better-stripe 0.9.0 → 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 +6 -0
- package/MIGRATION.md +12 -0
- package/README.md +29 -1
- 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 +80 -0
- package/dist/component/_generated/component.d.ts.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/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/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/docs/react-workflows.md +5 -2
- package/docs/settlement.md +158 -0
- package/package.json +2 -1
- 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 +100 -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/react/components/EmbeddedDisputes.render.test.tsx +2 -2
- package/src/react/index.payment-element.test.ts +22 -37
- package/src/react/workflows/Checkout.test.tsx +30 -0
- package/src/react/workflows/Checkout.tsx +10 -3
- package/src/react/workflows/Payouts.test.tsx +33 -0
- package/src/react/workflows/Payouts.tsx +21 -15
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import {
|
|
2
|
+
sourceTransfers,
|
|
3
|
+
matchingTransfer,
|
|
4
|
+
recordSettlementTransfer,
|
|
5
|
+
} from "./reconciliation.js";
|
|
6
|
+
import type Stripe from "stripe";
|
|
7
|
+
import type { SplitTransfer } from "../core/fees.js";
|
|
8
|
+
import type { Component, RunCtx } from "../helpers.js";
|
|
9
|
+
import { runMutationOrThrow } from "../helpers.js";
|
|
10
|
+
import { componentRef } from "../webhooks/helpers.js";
|
|
11
|
+
export type ResolvedSettlement = {
|
|
12
|
+
sourceChargeId: string;
|
|
13
|
+
paymentIntentId?: string;
|
|
14
|
+
sourceInvoiceId?: string;
|
|
15
|
+
gross: number;
|
|
16
|
+
currency: string;
|
|
17
|
+
processingFee: number;
|
|
18
|
+
platformNet: number;
|
|
19
|
+
adjustmentAmount?: number;
|
|
20
|
+
transfers: SplitTransfer[];
|
|
21
|
+
paymentId?: string;
|
|
22
|
+
};
|
|
23
|
+
export type SettlementResult = ResolvedSettlement & {
|
|
24
|
+
status: "settled";
|
|
25
|
+
executedTransfers: {
|
|
26
|
+
stripeTransferId: string;
|
|
27
|
+
destinationAccountId: string;
|
|
28
|
+
role: string;
|
|
29
|
+
amount: number;
|
|
30
|
+
}[];
|
|
31
|
+
};
|
|
32
|
+
type Ledger = {
|
|
33
|
+
stripeTransferId: string;
|
|
34
|
+
destinationAccountId: string;
|
|
35
|
+
role?: string;
|
|
36
|
+
amount: number;
|
|
37
|
+
currency: string;
|
|
38
|
+
};
|
|
39
|
+
/** Shared transfer engine for webhook splits and verified, host-resolved allocations. */
|
|
40
|
+
export async function executeResolvedTransfers(
|
|
41
|
+
stripe: Stripe,
|
|
42
|
+
component: Component,
|
|
43
|
+
ctx: RunCtx,
|
|
44
|
+
opts: ResolvedSettlement,
|
|
45
|
+
): Promise<SettlementResult> {
|
|
46
|
+
const saved = (await runMutationOrThrow(
|
|
47
|
+
ctx,
|
|
48
|
+
componentRef(component, "settlement/mutations/reserveSettlementAllocation"),
|
|
49
|
+
opts,
|
|
50
|
+
)) as ResolvedSettlement;
|
|
51
|
+
const claim: ResolvedSettlement = {
|
|
52
|
+
sourceChargeId: saved.sourceChargeId,
|
|
53
|
+
paymentIntentId: saved.paymentIntentId,
|
|
54
|
+
sourceInvoiceId: saved.sourceInvoiceId,
|
|
55
|
+
gross: saved.gross,
|
|
56
|
+
currency: saved.currency,
|
|
57
|
+
processingFee: saved.processingFee,
|
|
58
|
+
platformNet: saved.platformNet,
|
|
59
|
+
adjustmentAmount: saved.adjustmentAmount,
|
|
60
|
+
transfers: saved.transfers,
|
|
61
|
+
paymentId: saved.paymentId,
|
|
62
|
+
};
|
|
63
|
+
const existing = ((await ctx.runQuery(
|
|
64
|
+
componentRef(component, "connect/queries/listTransfersByCharge"),
|
|
65
|
+
{ sourceChargeId: claim.sourceChargeId },
|
|
66
|
+
)) ?? []) as Ledger[];
|
|
67
|
+
let provider = await sourceTransfers(stripe, claim.sourceChargeId);
|
|
68
|
+
if (
|
|
69
|
+
claim.transfers.some(
|
|
70
|
+
(leg) =>
|
|
71
|
+
leg.amount > 0 &&
|
|
72
|
+
!existing.some(
|
|
73
|
+
(t) =>
|
|
74
|
+
t.destinationAccountId === leg.destinationAccountId &&
|
|
75
|
+
t.role === leg.role,
|
|
76
|
+
) &&
|
|
77
|
+
!matchingTransfer(provider, claim, leg),
|
|
78
|
+
)
|
|
79
|
+
) {
|
|
80
|
+
provider = [
|
|
81
|
+
...new Map(
|
|
82
|
+
[
|
|
83
|
+
...provider,
|
|
84
|
+
...(await sourceTransfers(stripe, claim.sourceChargeId, true)),
|
|
85
|
+
].map((t) => [t.id, t]),
|
|
86
|
+
).values(),
|
|
87
|
+
];
|
|
88
|
+
}
|
|
89
|
+
const executedTransfers: SettlementResult["executedTransfers"] = [];
|
|
90
|
+
for (const leg of claim.transfers) {
|
|
91
|
+
if (leg.amount === 0) continue;
|
|
92
|
+
const recorded = existing.find(
|
|
93
|
+
(t) =>
|
|
94
|
+
t.destinationAccountId === leg.destinationAccountId &&
|
|
95
|
+
t.role === leg.role,
|
|
96
|
+
);
|
|
97
|
+
if (
|
|
98
|
+
recorded &&
|
|
99
|
+
(recorded.amount !== leg.amount || recorded.currency !== claim.currency)
|
|
100
|
+
)
|
|
101
|
+
throw new Error("Recorded transfer conflicts with immutable settlement.");
|
|
102
|
+
let transferId = recorded?.stripeTransferId;
|
|
103
|
+
if (!transferId) {
|
|
104
|
+
const recovered = matchingTransfer(provider, claim, leg);
|
|
105
|
+
const transfer =
|
|
106
|
+
recovered ??
|
|
107
|
+
(await stripe.transfers.create(
|
|
108
|
+
{
|
|
109
|
+
amount: leg.amount,
|
|
110
|
+
currency: claim.currency,
|
|
111
|
+
destination: leg.destinationAccountId,
|
|
112
|
+
source_transaction: claim.sourceChargeId,
|
|
113
|
+
transfer_group: claim.sourceChargeId,
|
|
114
|
+
metadata: {
|
|
115
|
+
bsRole: leg.role,
|
|
116
|
+
bsSourceCharge: claim.sourceChargeId,
|
|
117
|
+
},
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
idempotencyKey: `bs_split_${claim.sourceChargeId}_${leg.destinationAccountId}_${leg.role}`,
|
|
121
|
+
},
|
|
122
|
+
));
|
|
123
|
+
transferId = transfer.id;
|
|
124
|
+
await recordSettlementTransfer(component, ctx, claim, leg, transfer);
|
|
125
|
+
}
|
|
126
|
+
executedTransfers.push({
|
|
127
|
+
stripeTransferId: transferId,
|
|
128
|
+
destinationAccountId: leg.destinationAccountId,
|
|
129
|
+
role: leg.role,
|
|
130
|
+
amount: leg.amount,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
return { ...claim, status: "settled", executedTransfers };
|
|
134
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import type Stripe from "stripe";
|
|
2
|
+
import type { SplitTransfer } from "../core/fees.js";
|
|
3
|
+
import type { Component, RunCtx } from "../helpers.js";
|
|
4
|
+
import { runMutationOrThrow } from "../helpers.js";
|
|
5
|
+
import { componentRef } from "../webhooks/helpers.js";
|
|
6
|
+
import type { ResolvedSettlement } from "./execution.js";
|
|
7
|
+
export async function sourceTransfers(
|
|
8
|
+
stripe: Stripe,
|
|
9
|
+
sourceChargeId: string,
|
|
10
|
+
legacy = false,
|
|
11
|
+
): Promise<Stripe.Transfer[]> {
|
|
12
|
+
const rows: Stripe.Transfer[] = [];
|
|
13
|
+
let cursor: string | undefined;
|
|
14
|
+
const charge = legacy ? await stripe.charges.retrieve(sourceChargeId) : null;
|
|
15
|
+
const created =
|
|
16
|
+
charge && charge.created <= Math.floor(Date.now() / 1000)
|
|
17
|
+
? { gte: charge.created }
|
|
18
|
+
: undefined;
|
|
19
|
+
for (let page = 0; page < 100; page++) {
|
|
20
|
+
const batch = await stripe.transfers.list({
|
|
21
|
+
...(legacy ? { created } : { transfer_group: sourceChargeId }),
|
|
22
|
+
limit: 100,
|
|
23
|
+
starting_after: cursor,
|
|
24
|
+
});
|
|
25
|
+
rows.push(...batch.data);
|
|
26
|
+
if (!batch.has_more) return rows;
|
|
27
|
+
const next = batch.data[batch.data.length - 1]?.id;
|
|
28
|
+
if (!next || next === cursor)
|
|
29
|
+
throw new Error("Settlement transfer reconciliation did not advance.");
|
|
30
|
+
cursor = next;
|
|
31
|
+
}
|
|
32
|
+
throw new Error(
|
|
33
|
+
"Settlement transfer reconciliation exceeded its page bound; review before retrying.",
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
export function matchingTransfer(
|
|
37
|
+
provider: Stripe.Transfer[],
|
|
38
|
+
claim: ResolvedSettlement,
|
|
39
|
+
leg: SplitTransfer,
|
|
40
|
+
) {
|
|
41
|
+
const matches = provider.filter(
|
|
42
|
+
(t) =>
|
|
43
|
+
(typeof t.destination === "string"
|
|
44
|
+
? t.destination
|
|
45
|
+
: t.destination?.id) === leg.destinationAccountId &&
|
|
46
|
+
(t.metadata.bsRole ?? t.metadata.role) === leg.role &&
|
|
47
|
+
(t.metadata.bsSourceCharge ?? t.metadata.storeSaleChargeId) ===
|
|
48
|
+
claim.sourceChargeId,
|
|
49
|
+
);
|
|
50
|
+
if (
|
|
51
|
+
matches.length > 1 ||
|
|
52
|
+
matches.some(
|
|
53
|
+
(t) =>
|
|
54
|
+
t.amount !== leg.amount ||
|
|
55
|
+
t.currency !== claim.currency ||
|
|
56
|
+
t.source_transaction !== claim.sourceChargeId,
|
|
57
|
+
)
|
|
58
|
+
)
|
|
59
|
+
throw new Error(
|
|
60
|
+
"Provider transfers conflict with claimed settlement; manual review required.",
|
|
61
|
+
);
|
|
62
|
+
return matches[0];
|
|
63
|
+
}
|
|
64
|
+
export async function recordSettlementTransfer(
|
|
65
|
+
component: Component,
|
|
66
|
+
ctx: RunCtx,
|
|
67
|
+
claim: ResolvedSettlement,
|
|
68
|
+
leg: SplitTransfer,
|
|
69
|
+
transfer: Stripe.Transfer,
|
|
70
|
+
) {
|
|
71
|
+
await runMutationOrThrow(
|
|
72
|
+
ctx,
|
|
73
|
+
componentRef(component, "connect/mutations/upsertTransfer"),
|
|
74
|
+
{
|
|
75
|
+
stripeTransferId: transfer.id,
|
|
76
|
+
sourceChargeId: claim.sourceChargeId,
|
|
77
|
+
sourceInvoiceId: claim.sourceInvoiceId,
|
|
78
|
+
destinationAccountId: leg.destinationAccountId,
|
|
79
|
+
amount: leg.amount,
|
|
80
|
+
currency: claim.currency,
|
|
81
|
+
role: leg.role,
|
|
82
|
+
status: "paid",
|
|
83
|
+
paymentId: claim.paymentId,
|
|
84
|
+
reversedAmount: transfer.amount_reversed ?? 0,
|
|
85
|
+
},
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
/** Recover provider-success/ledger-failure legs before sizing refunds or disputes. */
|
|
89
|
+
export async function recoverSettlementTransfers(
|
|
90
|
+
stripe: Stripe,
|
|
91
|
+
component: Component,
|
|
92
|
+
ctx: RunCtx,
|
|
93
|
+
sourceChargeId: string,
|
|
94
|
+
) {
|
|
95
|
+
const claim = (await ctx.runQuery(
|
|
96
|
+
componentRef(component, "settlement/queries/getSettlementAllocation"),
|
|
97
|
+
{ sourceChargeId },
|
|
98
|
+
)) as ResolvedSettlement | null;
|
|
99
|
+
if (!claim || typeof claim.sourceChargeId !== "string") return;
|
|
100
|
+
let provider = await sourceTransfers(stripe, sourceChargeId);
|
|
101
|
+
if (
|
|
102
|
+
claim.transfers.some(
|
|
103
|
+
(leg) => leg.amount > 0 && !matchingTransfer(provider, claim, leg),
|
|
104
|
+
)
|
|
105
|
+
)
|
|
106
|
+
provider = [
|
|
107
|
+
...new Map(
|
|
108
|
+
[
|
|
109
|
+
...provider,
|
|
110
|
+
...(await sourceTransfers(stripe, sourceChargeId, true)),
|
|
111
|
+
].map((t) => [t.id, t]),
|
|
112
|
+
).values(),
|
|
113
|
+
];
|
|
114
|
+
for (const leg of claim.transfers) {
|
|
115
|
+
const transfer = matchingTransfer(provider, claim, leg);
|
|
116
|
+
if (transfer)
|
|
117
|
+
await recordSettlementTransfer(component, ctx, claim, leg, transfer);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type Stripe from "stripe";
|
|
2
|
+
import type { SplitTransfer } from "../core/fees.js";
|
|
3
|
+
import type { Component, RunCtx } from "../helpers.js";
|
|
4
|
+
import { componentRef } from "../webhooks/helpers.js";
|
|
5
|
+
import { throwStripeError } from "../errors.js";
|
|
6
|
+
import { getVerifiedSale, type VerifiedSaleOptions } from "./verifiedSale.js";
|
|
7
|
+
import {
|
|
8
|
+
executeResolvedTransfers,
|
|
9
|
+
type SettlementResult,
|
|
10
|
+
} from "./execution.js";
|
|
11
|
+
export type SettleSaleOptions = VerifiedSaleOptions & {
|
|
12
|
+
allocation: {
|
|
13
|
+
gross: number;
|
|
14
|
+
currency: string;
|
|
15
|
+
processingFee: number;
|
|
16
|
+
platformNet: number;
|
|
17
|
+
adjustmentAmount?: number;
|
|
18
|
+
transfers: SplitTransfer[];
|
|
19
|
+
};
|
|
20
|
+
paymentId?: string;
|
|
21
|
+
onSettled?: (result: SettlementResult) => Promise<void>;
|
|
22
|
+
};
|
|
23
|
+
export async function settleSale(
|
|
24
|
+
stripe: Stripe,
|
|
25
|
+
component: Component,
|
|
26
|
+
ctx: RunCtx,
|
|
27
|
+
opts: SettleSaleOptions,
|
|
28
|
+
): Promise<
|
|
29
|
+
| SettlementResult
|
|
30
|
+
| Exclude<Awaited<ReturnType<typeof getVerifiedSale>>, { status: "ready" }>
|
|
31
|
+
> {
|
|
32
|
+
const sale = await getVerifiedSale(stripe, opts);
|
|
33
|
+
if (sale.status !== "ready") return sale;
|
|
34
|
+
const prior = (await ctx.runQuery(
|
|
35
|
+
componentRef(component, "settlement/queries/getSettlementAllocation"),
|
|
36
|
+
{
|
|
37
|
+
sourceChargeId: sale.chargeId,
|
|
38
|
+
},
|
|
39
|
+
)) as SettlementResult | null;
|
|
40
|
+
const allocation = prior ?? opts.allocation;
|
|
41
|
+
if (
|
|
42
|
+
allocation.gross !== sale.gross ||
|
|
43
|
+
allocation.currency !== sale.currency ||
|
|
44
|
+
allocation.processingFee !== sale.processingFee
|
|
45
|
+
)
|
|
46
|
+
throwStripeError(
|
|
47
|
+
"INVALID_CONFIGURATION",
|
|
48
|
+
"Allocation must use verified payment currency, gross and actual processing cost.",
|
|
49
|
+
);
|
|
50
|
+
const result = await executeResolvedTransfers(stripe, component, ctx, {
|
|
51
|
+
...opts.allocation,
|
|
52
|
+
sourceChargeId: sale.chargeId,
|
|
53
|
+
paymentIntentId: sale.paymentIntentId,
|
|
54
|
+
sourceInvoiceId: sale.invoiceId,
|
|
55
|
+
paymentId: opts.paymentId,
|
|
56
|
+
});
|
|
57
|
+
await opts.onSettled?.(result);
|
|
58
|
+
return result;
|
|
59
|
+
}
|
|
@@ -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
|
+
}
|