@fantastic.dev/better-stripe 0.19.0 → 0.20.1
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/README.md +22 -3
- package/dist/client/connect/transferReceipt.d.ts.map +1 -1
- package/dist/client/connect/transferReceipt.js +1 -14
- package/dist/client/connect/transferReceipt.js.map +1 -1
- package/dist/component/_generated/component.d.ts +297 -0
- package/dist/component/_generated/component.d.ts.map +1 -1
- package/dist/component/earnings/triggers.d.ts +134 -0
- package/dist/component/earnings/triggers.d.ts.map +1 -1
- package/dist/component/payoutRelease/allocation.d.ts +48 -0
- package/dist/component/payoutRelease/allocation.d.ts.map +1 -0
- package/dist/component/payoutRelease/allocation.js +98 -0
- package/dist/component/payoutRelease/allocation.js.map +1 -0
- package/dist/component/payoutRelease/helpers.d.ts +106 -0
- package/dist/component/payoutRelease/helpers.d.ts.map +1 -0
- package/dist/component/payoutRelease/helpers.js +119 -0
- package/dist/component/payoutRelease/helpers.js.map +1 -0
- package/dist/component/payoutRelease/mutations.d.ts +218 -0
- package/dist/component/payoutRelease/mutations.d.ts.map +1 -0
- package/dist/component/payoutRelease/mutations.js +254 -0
- package/dist/component/payoutRelease/mutations.js.map +1 -0
- package/dist/component/payoutRelease/queries.d.ts +84 -0
- package/dist/component/payoutRelease/queries.d.ts.map +1 -0
- package/dist/component/payoutRelease/queries.js +51 -0
- package/dist/component/payoutRelease/queries.js.map +1 -0
- package/dist/component/payoutRelease/schema.d.ts +289 -0
- package/dist/component/payoutRelease/schema.d.ts.map +1 -0
- package/dist/component/payoutRelease/schema.js +56 -0
- package/dist/component/payoutRelease/schema.js.map +1 -0
- package/dist/component/schema.d.ts +110 -0
- package/dist/component/schema.js +3 -0
- package/dist/component/schema.js.map +1 -1
- package/docs/accounts-v2-preview.md +39 -1
- package/docs/react-workflows.md +31 -0
- package/docs/settlement.md +31 -0
- package/package.json +1 -1
- package/src/client/connect/payoutAllocation.test.ts +292 -0
- package/src/client/connect/transferReceipt.ts +1 -14
- package/src/component/_generated/api.ts +8 -0
- package/src/component/_generated/component.ts +372 -0
- package/src/component/payout-release.test.ts +588 -0
- package/src/component/payoutRelease/allocation.ts +147 -0
- package/src/component/payoutRelease/helpers.ts +168 -0
- package/src/component/payoutRelease/mutations.ts +309 -0
- package/src/component/payoutRelease/queries.ts +56 -0
- package/src/component/payoutRelease/schema.ts +69 -0
- package/src/component/schema.ts +6 -0
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
export type PayoutFundingSource = {
|
|
2
|
+
stripeTransferId: string;
|
|
3
|
+
currency: string;
|
|
4
|
+
matureNet: number;
|
|
5
|
+
paidAmount: number;
|
|
6
|
+
reservedAmount: number;
|
|
7
|
+
pendingCredits: number;
|
|
8
|
+
role?: "store" | "affiliate" | "other";
|
|
9
|
+
};
|
|
10
|
+
export type PayoutAllocationOptions = {
|
|
11
|
+
currency: string;
|
|
12
|
+
threshold: number;
|
|
13
|
+
providerAvailable: number;
|
|
14
|
+
independentLiabilities: number;
|
|
15
|
+
sources: PayoutFundingSource[];
|
|
16
|
+
};
|
|
17
|
+
export type RecipientPayoutPlan = {
|
|
18
|
+
currency: string;
|
|
19
|
+
matureUnpaid: number;
|
|
20
|
+
pendingCredits: number;
|
|
21
|
+
/** Stripe does not expose a reliable numeric floor for every account. */
|
|
22
|
+
providerMinimum: null;
|
|
23
|
+
} & (
|
|
24
|
+
| {
|
|
25
|
+
status: "held";
|
|
26
|
+
reason: "below_threshold" | "insufficient_available";
|
|
27
|
+
amount: 0;
|
|
28
|
+
}
|
|
29
|
+
| {
|
|
30
|
+
status: "qualified";
|
|
31
|
+
amount: number;
|
|
32
|
+
allocations: {
|
|
33
|
+
stripeTransferId: string;
|
|
34
|
+
amount: number;
|
|
35
|
+
role?: PayoutFundingSource["role"];
|
|
36
|
+
}[];
|
|
37
|
+
}
|
|
38
|
+
);
|
|
39
|
+
function money(value: number) {
|
|
40
|
+
if (!Number.isSafeInteger(value))
|
|
41
|
+
throw new Error("Unsafe payout arithmetic.");
|
|
42
|
+
return value;
|
|
43
|
+
}
|
|
44
|
+
function nonnegative(value: number) {
|
|
45
|
+
if (money(value) < 0) throw new Error("Invalid payout accounting amount.");
|
|
46
|
+
}
|
|
47
|
+
function currency(value: string) {
|
|
48
|
+
if (value.length !== 3 || !/^[a-z]{3}$/.test(value))
|
|
49
|
+
throw new Error("Invalid payout currency.");
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Pure allocation step, not authorization to call Stripe. Inputs must be a
|
|
53
|
+
* complete, verified still-unpaid snapshot for this recipient. Policy controls,
|
|
54
|
+
* source ownership/maturity, prior payout adoption, independent liabilities and
|
|
55
|
+
* atomic reservations must be established by the execution boundary.
|
|
56
|
+
*
|
|
57
|
+
* Negative source entitlement survives refunds after payout. Pending credits
|
|
58
|
+
* never qualify. Each source's reservations reduce entitlement; they are not
|
|
59
|
+
* subtracted again from fresh provider availability, which may already include
|
|
60
|
+
* the provider debit for those same in-flight payouts.
|
|
61
|
+
*/
|
|
62
|
+
export function planRecipientPayout(
|
|
63
|
+
opts: PayoutAllocationOptions,
|
|
64
|
+
): RecipientPayoutPlan {
|
|
65
|
+
currency(opts.currency);
|
|
66
|
+
if (money(opts.threshold) <= 0) throw new Error("Invalid payout threshold.");
|
|
67
|
+
money(opts.providerAvailable);
|
|
68
|
+
nonnegative(opts.independentLiabilities);
|
|
69
|
+
const seen = new Set<string>();
|
|
70
|
+
for (const source of opts.sources) {
|
|
71
|
+
if (
|
|
72
|
+
!/^tr_\w+$/.test(source.stripeTransferId) ||
|
|
73
|
+
source.stripeTransferId.trim() !== source.stripeTransferId ||
|
|
74
|
+
source.stripeTransferId.length > 100
|
|
75
|
+
)
|
|
76
|
+
throw new Error("Invalid payout source identity.");
|
|
77
|
+
if (seen.has(source.stripeTransferId))
|
|
78
|
+
throw new Error("Duplicate payout source.");
|
|
79
|
+
seen.add(source.stripeTransferId);
|
|
80
|
+
currency(source.currency);
|
|
81
|
+
money(source.matureNet);
|
|
82
|
+
nonnegative(source.paidAmount);
|
|
83
|
+
nonnegative(source.reservedAmount);
|
|
84
|
+
nonnegative(source.pendingCredits);
|
|
85
|
+
if (
|
|
86
|
+
source.role !== undefined &&
|
|
87
|
+
!["store", "affiliate", "other"].includes(source.role)
|
|
88
|
+
)
|
|
89
|
+
throw new Error("Invalid payout source role.");
|
|
90
|
+
}
|
|
91
|
+
const sources = opts.sources
|
|
92
|
+
.filter((source) => source.currency === opts.currency)
|
|
93
|
+
.sort((a, b) =>
|
|
94
|
+
a.stripeTransferId < b.stripeTransferId
|
|
95
|
+
? -1
|
|
96
|
+
: a.stripeTransferId > b.stripeTransferId
|
|
97
|
+
? 1
|
|
98
|
+
: 0,
|
|
99
|
+
);
|
|
100
|
+
let matureUnpaid = 0;
|
|
101
|
+
let pendingCredits = 0;
|
|
102
|
+
const credits = sources.map((source) => {
|
|
103
|
+
const unpaid = money(
|
|
104
|
+
money(source.matureNet - source.paidAmount) - source.reservedAmount,
|
|
105
|
+
);
|
|
106
|
+
matureUnpaid = money(matureUnpaid + unpaid);
|
|
107
|
+
pendingCredits = money(pendingCredits + source.pendingCredits);
|
|
108
|
+
return { source, unpaid };
|
|
109
|
+
});
|
|
110
|
+
const totals = {
|
|
111
|
+
currency: opts.currency,
|
|
112
|
+
matureUnpaid,
|
|
113
|
+
pendingCredits,
|
|
114
|
+
providerMinimum: null,
|
|
115
|
+
};
|
|
116
|
+
const providerAvailable = money(
|
|
117
|
+
opts.providerAvailable - opts.independentLiabilities,
|
|
118
|
+
);
|
|
119
|
+
if (matureUnpaid < opts.threshold)
|
|
120
|
+
return { ...totals, status: "held", reason: "below_threshold", amount: 0 };
|
|
121
|
+
const amount = Math.min(matureUnpaid, providerAvailable);
|
|
122
|
+
if (amount < opts.threshold)
|
|
123
|
+
return {
|
|
124
|
+
...totals,
|
|
125
|
+
status: "held",
|
|
126
|
+
reason: "insufficient_available",
|
|
127
|
+
amount: 0,
|
|
128
|
+
};
|
|
129
|
+
let remaining = amount;
|
|
130
|
+
const allocations: Extract<
|
|
131
|
+
RecipientPayoutPlan,
|
|
132
|
+
{ status: "qualified" }
|
|
133
|
+
>["allocations"] = [];
|
|
134
|
+
for (const { source, unpaid } of credits) {
|
|
135
|
+
const allocated = Math.min(Math.max(0, unpaid), remaining);
|
|
136
|
+
if (allocated) {
|
|
137
|
+
allocations.push({
|
|
138
|
+
stripeTransferId: source.stripeTransferId,
|
|
139
|
+
amount: allocated,
|
|
140
|
+
...(source.role === undefined ? {} : { role: source.role }),
|
|
141
|
+
});
|
|
142
|
+
remaining = money(remaining - allocated);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
if (remaining !== 0) throw new Error("Invalid payout source allocation.");
|
|
146
|
+
return { ...totals, status: "qualified", amount, allocations };
|
|
147
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import type { Infer } from "convex/values";
|
|
2
|
+
import type { transferDocValidator } from "../connect/validators.js";
|
|
3
|
+
import type { MutationCtx } from "../_generated/server.js";
|
|
4
|
+
import type { Doc } from "../_generated/dataModel.js";
|
|
5
|
+
import { readPolicy, validateScope } from "../payoutPolicy/helpers.js";
|
|
6
|
+
import type { observation } from "./schema.js";
|
|
7
|
+
import { addSummaryAmounts } from "../earnings/arithmetic.js";
|
|
8
|
+
export const add = addSummaryAmounts;
|
|
9
|
+
export function transferFingerprint(t: Infer<typeof transferDocValidator>) {
|
|
10
|
+
return JSON.stringify([
|
|
11
|
+
t.stripeTransferId,
|
|
12
|
+
t.destinationAccountId,
|
|
13
|
+
t.sourceChargeId,
|
|
14
|
+
t.amount,
|
|
15
|
+
t.currency,
|
|
16
|
+
t.role,
|
|
17
|
+
t.status,
|
|
18
|
+
t.reversedAmount ?? 0,
|
|
19
|
+
t.reversalClaimedAmount ?? 0,
|
|
20
|
+
t.reinstatement ?? false,
|
|
21
|
+
]);
|
|
22
|
+
}
|
|
23
|
+
export function validateIdentity(args: {
|
|
24
|
+
accountId: string;
|
|
25
|
+
ownerKey: string;
|
|
26
|
+
operationKey: string;
|
|
27
|
+
}) {
|
|
28
|
+
validateScope(args);
|
|
29
|
+
if (
|
|
30
|
+
!args.operationKey.trim() ||
|
|
31
|
+
args.operationKey.length > 180 ||
|
|
32
|
+
/[\r\n]/.test(args.operationKey)
|
|
33
|
+
)
|
|
34
|
+
throw new Error("Invalid payout claim identity.");
|
|
35
|
+
}
|
|
36
|
+
export async function claimByKey(
|
|
37
|
+
ctx: MutationCtx,
|
|
38
|
+
args: { accountId: string; ownerKey: string; operationKey: string },
|
|
39
|
+
) {
|
|
40
|
+
validateIdentity(args);
|
|
41
|
+
const claim = await ctx.db
|
|
42
|
+
.query("payoutReleaseClaims")
|
|
43
|
+
.withIndex("by_operation", (q) => q.eq("operationKey", args.operationKey))
|
|
44
|
+
.unique();
|
|
45
|
+
if (
|
|
46
|
+
claim &&
|
|
47
|
+
(claim.accountId !== args.accountId || claim.ownerKey !== args.ownerKey)
|
|
48
|
+
)
|
|
49
|
+
throw new Error("Payout claim identity does not match.");
|
|
50
|
+
return claim;
|
|
51
|
+
}
|
|
52
|
+
export async function readyPolicy(
|
|
53
|
+
ctx: MutationCtx,
|
|
54
|
+
args: { accountId: string; ownerKey: string; expectedRevision: number },
|
|
55
|
+
) {
|
|
56
|
+
const policy = await readPolicy(ctx, args);
|
|
57
|
+
if (
|
|
58
|
+
!Number.isSafeInteger(args.expectedRevision) ||
|
|
59
|
+
policy?.revision !== args.expectedRevision ||
|
|
60
|
+
policy.mode !== "custom" ||
|
|
61
|
+
policy.status !== "ready" ||
|
|
62
|
+
!policy.holdDeadline ||
|
|
63
|
+
policy.holdDeadline <= Date.now()
|
|
64
|
+
)
|
|
65
|
+
throw new Error("Payout policy is not ready at this revision.");
|
|
66
|
+
return policy;
|
|
67
|
+
}
|
|
68
|
+
/** Full local account membership is checked under OCC before any reservation. */
|
|
69
|
+
export async function checkSnapshot(
|
|
70
|
+
ctx: MutationCtx,
|
|
71
|
+
accountId: string,
|
|
72
|
+
observations: Infer<typeof observation>[],
|
|
73
|
+
fresh: boolean,
|
|
74
|
+
) {
|
|
75
|
+
const rows = await ctx.db
|
|
76
|
+
.query("transfers")
|
|
77
|
+
.withIndex("by_destination_account_id", (q) =>
|
|
78
|
+
q.eq("destinationAccountId", accountId),
|
|
79
|
+
)
|
|
80
|
+
.take(101);
|
|
81
|
+
if (
|
|
82
|
+
rows.length > 100 ||
|
|
83
|
+
observations.length !== rows.length ||
|
|
84
|
+
new Set(observations.map((o) => o.stripeTransferId)).size !== rows.length
|
|
85
|
+
)
|
|
86
|
+
throw new Error("Payout source snapshot is incomplete or duplicated.");
|
|
87
|
+
const byId = new Map(rows.map((row) => [row.stripeTransferId, row]));
|
|
88
|
+
const allocated = await ctx.db
|
|
89
|
+
.query("payoutSourceAllocations")
|
|
90
|
+
.withIndex("by_account", (q) => q.eq("accountId", accountId))
|
|
91
|
+
.take(101);
|
|
92
|
+
if (
|
|
93
|
+
allocated.length > 100 ||
|
|
94
|
+
allocated.some((source) => !byId.has(source.stripeTransferId))
|
|
95
|
+
)
|
|
96
|
+
throw new Error("Payout source allocation snapshot is incomplete.");
|
|
97
|
+
const receiptIds = new Set<string>();
|
|
98
|
+
for (const o of observations) {
|
|
99
|
+
const row = byId.get(o.stripeTransferId);
|
|
100
|
+
if (
|
|
101
|
+
!row ||
|
|
102
|
+
transferFingerprint(row) !== o.ledgerFingerprint ||
|
|
103
|
+
(row.status !== "paid" && row.status !== "reversed") ||
|
|
104
|
+
(row.reversalClaimedAmount ?? 0) > (row.reversedAmount ?? 0)
|
|
105
|
+
)
|
|
106
|
+
throw new Error("Payout source snapshot changed or is not ready.");
|
|
107
|
+
if (
|
|
108
|
+
fresh &&
|
|
109
|
+
(!Number.isSafeInteger(o.observedAt) ||
|
|
110
|
+
o.observedAt > Date.now() ||
|
|
111
|
+
o.observedAt < Date.now() - 30000)
|
|
112
|
+
)
|
|
113
|
+
throw new Error("Payout source observation is stale or in the future.");
|
|
114
|
+
if (
|
|
115
|
+
!o.transactionIds.length ||
|
|
116
|
+
o.transactionIds.length > 1000 ||
|
|
117
|
+
new Set(o.transactionIds).size !== o.transactionIds.length ||
|
|
118
|
+
o.transactionIds.some((id) => !/^txn_\w+$/.test(id) || id.trim() !== id)
|
|
119
|
+
)
|
|
120
|
+
throw new Error("Invalid payout source receipt identities.");
|
|
121
|
+
for (const id of o.transactionIds) {
|
|
122
|
+
if (receiptIds.has(id))
|
|
123
|
+
throw new Error("Duplicate payout source receipt.");
|
|
124
|
+
receiptIds.add(id);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return byId;
|
|
128
|
+
}
|
|
129
|
+
export async function sourceById(ctx: MutationCtx, stripeTransferId: string) {
|
|
130
|
+
return ctx.db
|
|
131
|
+
.query("payoutSourceAllocations")
|
|
132
|
+
.withIndex("by_transfer", (q) => q.eq("stripeTransferId", stripeTransferId))
|
|
133
|
+
.unique();
|
|
134
|
+
}
|
|
135
|
+
export async function moveAllocations(
|
|
136
|
+
ctx: MutationCtx,
|
|
137
|
+
claim: Doc<"payoutReleaseClaims">,
|
|
138
|
+
transition: "pay" | "release" | "return",
|
|
139
|
+
) {
|
|
140
|
+
for (const allocation of claim.allocations) {
|
|
141
|
+
const source = await sourceById(ctx, allocation.stripeTransferId);
|
|
142
|
+
if (
|
|
143
|
+
!source ||
|
|
144
|
+
source.accountId !== claim.accountId ||
|
|
145
|
+
source.ownerKey !== claim.ownerKey ||
|
|
146
|
+
source.currency !== claim.currency
|
|
147
|
+
)
|
|
148
|
+
throw new Error("Payout source allocation identity changed.");
|
|
149
|
+
const reservedAmount = add(
|
|
150
|
+
source.reservedAmount,
|
|
151
|
+
transition === "return" ? 0 : -allocation.amount,
|
|
152
|
+
);
|
|
153
|
+
const paidAmount = add(
|
|
154
|
+
source.paidAmount,
|
|
155
|
+
transition === "pay"
|
|
156
|
+
? allocation.amount
|
|
157
|
+
: transition === "return"
|
|
158
|
+
? -allocation.amount
|
|
159
|
+
: 0,
|
|
160
|
+
);
|
|
161
|
+
if (reservedAmount < 0 || paidAmount < 0)
|
|
162
|
+
throw new Error("Payout source allocation underflow.");
|
|
163
|
+
await ctx.db.patch("payoutSourceAllocations", source._id, {
|
|
164
|
+
reservedAmount,
|
|
165
|
+
paidAmount,
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
}
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
import { v } from "convex/values";
|
|
2
|
+
import { mutation } from "../_generated/server.js";
|
|
3
|
+
import { identity, observation, claimDoc } from "./schema.js";
|
|
4
|
+
import {
|
|
5
|
+
claimByKey,
|
|
6
|
+
readyPolicy,
|
|
7
|
+
checkSnapshot,
|
|
8
|
+
sourceById,
|
|
9
|
+
moveAllocations,
|
|
10
|
+
validateIdentity,
|
|
11
|
+
add,
|
|
12
|
+
} from "./helpers.js";
|
|
13
|
+
import { planRecipientPayout, type PayoutFundingSource } from "./allocation.js";
|
|
14
|
+
const held = v.object({
|
|
15
|
+
status: v.literal("held"),
|
|
16
|
+
reason: v.union(
|
|
17
|
+
v.literal("below_threshold"),
|
|
18
|
+
v.literal("insufficient_available"),
|
|
19
|
+
v.literal("pending_claim"),
|
|
20
|
+
),
|
|
21
|
+
amount: v.literal(0),
|
|
22
|
+
matureUnpaid: v.optional(v.number()),
|
|
23
|
+
pendingCredits: v.optional(v.number()),
|
|
24
|
+
currency: v.string(),
|
|
25
|
+
providerMinimum: v.null(),
|
|
26
|
+
});
|
|
27
|
+
/** Trusted server package boundary only: provider observations are verified upstream. Not a Stripe executor. */
|
|
28
|
+
export const claim = mutation({
|
|
29
|
+
args: {
|
|
30
|
+
...identity,
|
|
31
|
+
expectedRevision: v.number(),
|
|
32
|
+
currency: v.string(),
|
|
33
|
+
providerAvailable: v.number(),
|
|
34
|
+
independentLiabilities: v.number(),
|
|
35
|
+
observations: v.array(observation),
|
|
36
|
+
},
|
|
37
|
+
returns: v.union(claimDoc, held),
|
|
38
|
+
handler: async (ctx, args) => {
|
|
39
|
+
validateIdentity(args);
|
|
40
|
+
const existing = await claimByKey(ctx, args);
|
|
41
|
+
if (existing) {
|
|
42
|
+
if (
|
|
43
|
+
existing.currency !== args.currency ||
|
|
44
|
+
existing.expectedRevision !== args.expectedRevision
|
|
45
|
+
)
|
|
46
|
+
throw new Error("Payout claim identity cannot be reused.");
|
|
47
|
+
return existing;
|
|
48
|
+
}
|
|
49
|
+
const policy = await readyPolicy(ctx, args);
|
|
50
|
+
const minimum = policy.thresholds.find(
|
|
51
|
+
(value) => value.currency === args.currency,
|
|
52
|
+
);
|
|
53
|
+
if (!minimum) throw new Error("Payout policy currency is not configured.");
|
|
54
|
+
const pending = await ctx.db
|
|
55
|
+
.query("payoutReleaseClaims")
|
|
56
|
+
.withIndex("by_account_status", (q) =>
|
|
57
|
+
q.eq("accountId", args.accountId).eq("status", "reserved"),
|
|
58
|
+
)
|
|
59
|
+
.first();
|
|
60
|
+
if (pending)
|
|
61
|
+
return {
|
|
62
|
+
status: "held" as const,
|
|
63
|
+
reason: "pending_claim" as const,
|
|
64
|
+
amount: 0 as const,
|
|
65
|
+
currency: args.currency,
|
|
66
|
+
providerMinimum: null,
|
|
67
|
+
};
|
|
68
|
+
const rows = await checkSnapshot(
|
|
69
|
+
ctx,
|
|
70
|
+
args.accountId,
|
|
71
|
+
args.observations,
|
|
72
|
+
true,
|
|
73
|
+
);
|
|
74
|
+
const sources: PayoutFundingSource[] = [];
|
|
75
|
+
for (const observation of args.observations) {
|
|
76
|
+
const prior = await sourceById(ctx, observation.stripeTransferId);
|
|
77
|
+
if (
|
|
78
|
+
prior &&
|
|
79
|
+
(prior.accountId !== args.accountId ||
|
|
80
|
+
prior.ownerKey !== args.ownerKey ||
|
|
81
|
+
prior.currency !== observation.currency)
|
|
82
|
+
)
|
|
83
|
+
throw new Error("Payout source allocation identity changed.");
|
|
84
|
+
sources.push({
|
|
85
|
+
...observation,
|
|
86
|
+
role: rows.get(observation.stripeTransferId)!.role,
|
|
87
|
+
paidAmount: prior?.paidAmount ?? 0,
|
|
88
|
+
reservedAmount: prior?.reservedAmount ?? 0,
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
const plan = planRecipientPayout({
|
|
92
|
+
currency: args.currency,
|
|
93
|
+
threshold: minimum.amount,
|
|
94
|
+
providerAvailable: args.providerAvailable,
|
|
95
|
+
independentLiabilities: args.independentLiabilities,
|
|
96
|
+
sources,
|
|
97
|
+
});
|
|
98
|
+
if (plan.status === "held") return plan;
|
|
99
|
+
for (const allocation of plan.allocations) {
|
|
100
|
+
const prior = await sourceById(ctx, allocation.stripeTransferId);
|
|
101
|
+
if (prior)
|
|
102
|
+
await ctx.db.patch("payoutSourceAllocations", prior._id, {
|
|
103
|
+
reservedAmount: add(prior.reservedAmount, allocation.amount),
|
|
104
|
+
});
|
|
105
|
+
else
|
|
106
|
+
await ctx.db.insert("payoutSourceAllocations", {
|
|
107
|
+
accountId: args.accountId,
|
|
108
|
+
ownerKey: args.ownerKey,
|
|
109
|
+
stripeTransferId: allocation.stripeTransferId,
|
|
110
|
+
currency: args.currency,
|
|
111
|
+
paidAmount: 0,
|
|
112
|
+
reservedAmount: allocation.amount,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
const id = await ctx.db.insert("payoutReleaseClaims", {
|
|
116
|
+
accountId: args.accountId,
|
|
117
|
+
ownerKey: args.ownerKey,
|
|
118
|
+
operationKey: args.operationKey,
|
|
119
|
+
expectedRevision: args.expectedRevision,
|
|
120
|
+
currency: args.currency,
|
|
121
|
+
amount: plan.amount,
|
|
122
|
+
allocations: plan.allocations,
|
|
123
|
+
observations: args.observations,
|
|
124
|
+
status: "reserved",
|
|
125
|
+
});
|
|
126
|
+
return (await ctx.db.get("payoutReleaseClaims", id))!;
|
|
127
|
+
},
|
|
128
|
+
});
|
|
129
|
+
export const beginAttempt = mutation({
|
|
130
|
+
args: identity,
|
|
131
|
+
returns: claimDoc,
|
|
132
|
+
handler: async (ctx, args) => {
|
|
133
|
+
const claim = await claimByKey(ctx, args);
|
|
134
|
+
if (!claim || claim.status !== "reserved")
|
|
135
|
+
throw new Error("Payout claim is not reserved.");
|
|
136
|
+
if (claim.attemptedAt !== undefined) return claim;
|
|
137
|
+
await readyPolicy(ctx, claim);
|
|
138
|
+
await checkSnapshot(ctx, claim.accountId, claim.observations, true);
|
|
139
|
+
await ctx.db.patch("payoutReleaseClaims", claim._id, {
|
|
140
|
+
attemptedAt: Date.now(),
|
|
141
|
+
});
|
|
142
|
+
return (await ctx.db.get("payoutReleaseClaims", claim._id))!;
|
|
143
|
+
},
|
|
144
|
+
});
|
|
145
|
+
export const cancelUnattempted = mutation({
|
|
146
|
+
args: identity,
|
|
147
|
+
returns: claimDoc,
|
|
148
|
+
handler: async (ctx, args) => {
|
|
149
|
+
const claim = await claimByKey(ctx, args);
|
|
150
|
+
if (
|
|
151
|
+
!claim ||
|
|
152
|
+
claim.attemptedAt !== undefined ||
|
|
153
|
+
(claim.status !== "reserved" && claim.status !== "canceled")
|
|
154
|
+
)
|
|
155
|
+
throw new Error("An attempted payout claim cannot be canceled.");
|
|
156
|
+
if (claim.status === "canceled") return claim;
|
|
157
|
+
await moveAllocations(ctx, claim, "release");
|
|
158
|
+
await ctx.db.patch("payoutReleaseClaims", claim._id, {
|
|
159
|
+
status: "canceled",
|
|
160
|
+
});
|
|
161
|
+
return (await ctx.db.get("payoutReleaseClaims", claim._id))!;
|
|
162
|
+
},
|
|
163
|
+
});
|
|
164
|
+
export const recordOutcome = mutation({
|
|
165
|
+
args: {
|
|
166
|
+
...identity,
|
|
167
|
+
stripePayoutId: v.string(),
|
|
168
|
+
amount: v.number(),
|
|
169
|
+
currency: v.string(),
|
|
170
|
+
providerStatus: v.union(
|
|
171
|
+
v.literal("pending"),
|
|
172
|
+
v.literal("in_transit"),
|
|
173
|
+
v.literal("paid"),
|
|
174
|
+
v.literal("failed"),
|
|
175
|
+
v.literal("canceled"),
|
|
176
|
+
),
|
|
177
|
+
returnTransactionId: v.optional(v.string()),
|
|
178
|
+
},
|
|
179
|
+
returns: claimDoc,
|
|
180
|
+
handler: async (ctx, args) => {
|
|
181
|
+
const claim = await claimByKey(ctx, args);
|
|
182
|
+
if (
|
|
183
|
+
!claim ||
|
|
184
|
+
claim.attemptedAt === undefined ||
|
|
185
|
+
claim.status === "canceled" ||
|
|
186
|
+
claim.status === "rejected"
|
|
187
|
+
)
|
|
188
|
+
throw new Error("Payout receipt has no attempted claim.");
|
|
189
|
+
if (
|
|
190
|
+
!/^po_\w+$/.test(args.stripePayoutId) ||
|
|
191
|
+
args.stripePayoutId.trim() !== args.stripePayoutId ||
|
|
192
|
+
args.currency !== claim.currency ||
|
|
193
|
+
args.amount !== claim.amount ||
|
|
194
|
+
(claim.stripePayoutId !== undefined &&
|
|
195
|
+
claim.stripePayoutId !== args.stripePayoutId)
|
|
196
|
+
)
|
|
197
|
+
throw new Error("Payout receipt identity or amount does not match.");
|
|
198
|
+
const adopted = await ctx.db
|
|
199
|
+
.query("payoutReleaseClaims")
|
|
200
|
+
.withIndex("by_provider_payout", (q) =>
|
|
201
|
+
q.eq("stripePayoutId", args.stripePayoutId),
|
|
202
|
+
)
|
|
203
|
+
.unique();
|
|
204
|
+
if (adopted && adopted._id !== claim._id)
|
|
205
|
+
throw new Error("Payout receipt identity is already allocated.");
|
|
206
|
+
if (
|
|
207
|
+
args.returnTransactionId !== undefined &&
|
|
208
|
+
(!/^txn_\w+$/.test(args.returnTransactionId) ||
|
|
209
|
+
args.returnTransactionId.trim() !== args.returnTransactionId)
|
|
210
|
+
)
|
|
211
|
+
throw new Error("Invalid payout return receipt identity.");
|
|
212
|
+
const returned =
|
|
213
|
+
(args.providerStatus === "failed" ||
|
|
214
|
+
args.providerStatus === "canceled") &&
|
|
215
|
+
args.returnTransactionId !== undefined;
|
|
216
|
+
if (returned) {
|
|
217
|
+
const adoptedReturn = await ctx.db
|
|
218
|
+
.query("payoutReleaseClaims")
|
|
219
|
+
.withIndex("by_return_receipt", (q) =>
|
|
220
|
+
q.eq("returnTransactionId", args.returnTransactionId),
|
|
221
|
+
)
|
|
222
|
+
.unique();
|
|
223
|
+
if (adoptedReturn && adoptedReturn._id !== claim._id)
|
|
224
|
+
throw new Error("Payout return receipt is already allocated.");
|
|
225
|
+
}
|
|
226
|
+
if (claim.status === "returned") {
|
|
227
|
+
if (returned && args.returnTransactionId !== claim.returnTransactionId)
|
|
228
|
+
throw new Error("Payout return receipt identity changed.");
|
|
229
|
+
return claim;
|
|
230
|
+
}
|
|
231
|
+
if (returned) {
|
|
232
|
+
await moveAllocations(
|
|
233
|
+
ctx,
|
|
234
|
+
claim,
|
|
235
|
+
claim.status === "paid" ? "return" : "release",
|
|
236
|
+
);
|
|
237
|
+
await ctx.db.patch("payoutReleaseClaims", claim._id, {
|
|
238
|
+
status: "returned",
|
|
239
|
+
stripePayoutId: args.stripePayoutId,
|
|
240
|
+
returnTransactionId: args.returnTransactionId,
|
|
241
|
+
});
|
|
242
|
+
} else if (args.providerStatus === "paid" && claim.status !== "paid") {
|
|
243
|
+
await moveAllocations(ctx, claim, "pay");
|
|
244
|
+
await ctx.db.patch("payoutReleaseClaims", claim._id, {
|
|
245
|
+
status: "paid",
|
|
246
|
+
stripePayoutId: args.stripePayoutId,
|
|
247
|
+
});
|
|
248
|
+
} else if (claim.status !== "paid") {
|
|
249
|
+
await ctx.db.patch("payoutReleaseClaims", claim._id, {
|
|
250
|
+
status: "submitted",
|
|
251
|
+
stripePayoutId: args.stripePayoutId,
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
return (await ctx.db.get("payoutReleaseClaims", claim._id))!;
|
|
255
|
+
},
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
/** Only a verified, no-object rejection within the original idempotency window. */
|
|
259
|
+
export const recordRejection = mutation({
|
|
260
|
+
args: {
|
|
261
|
+
...identity,
|
|
262
|
+
requestId: v.string(),
|
|
263
|
+
code: v.union(
|
|
264
|
+
v.literal("balance_insufficient"),
|
|
265
|
+
v.literal("amount_too_small"),
|
|
266
|
+
),
|
|
267
|
+
},
|
|
268
|
+
returns: claimDoc,
|
|
269
|
+
handler: async (ctx, args) => {
|
|
270
|
+
const claim = await claimByKey(ctx, args);
|
|
271
|
+
if (
|
|
272
|
+
!claim ||
|
|
273
|
+
!/^req_\w+$/.test(args.requestId) ||
|
|
274
|
+
args.requestId.trim() !== args.requestId
|
|
275
|
+
)
|
|
276
|
+
throw new Error("Invalid payout rejection receipt.");
|
|
277
|
+
if (claim.status === "rejected") {
|
|
278
|
+
if (
|
|
279
|
+
claim.rejection?.requestId !== args.requestId ||
|
|
280
|
+
claim.rejection.code !== args.code
|
|
281
|
+
)
|
|
282
|
+
throw new Error("Payout rejection receipt identity changed.");
|
|
283
|
+
return claim;
|
|
284
|
+
}
|
|
285
|
+
if (
|
|
286
|
+
claim.status !== "reserved" ||
|
|
287
|
+
claim.stripePayoutId ||
|
|
288
|
+
claim.attemptedAt === undefined ||
|
|
289
|
+
Date.now() - claim.attemptedAt >= 23 * 60 * 60 * 1000
|
|
290
|
+
)
|
|
291
|
+
throw new Error(
|
|
292
|
+
"An accepted or uncertain expired payout attempt cannot be released as a rejection.",
|
|
293
|
+
);
|
|
294
|
+
const adoptedRejection = await ctx.db
|
|
295
|
+
.query("payoutReleaseClaims")
|
|
296
|
+
.withIndex("by_rejection_request", (q) =>
|
|
297
|
+
q.eq("rejection.requestId", args.requestId),
|
|
298
|
+
)
|
|
299
|
+
.unique();
|
|
300
|
+
if (adoptedRejection && adoptedRejection._id !== claim._id)
|
|
301
|
+
throw new Error("Payout rejection receipt is already allocated.");
|
|
302
|
+
await moveAllocations(ctx, claim, "release");
|
|
303
|
+
await ctx.db.patch("payoutReleaseClaims", claim._id, {
|
|
304
|
+
status: "rejected",
|
|
305
|
+
rejection: { requestId: args.requestId, code: args.code },
|
|
306
|
+
});
|
|
307
|
+
return (await ctx.db.get("payoutReleaseClaims", claim._id))!;
|
|
308
|
+
},
|
|
309
|
+
});
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { paginationOptsValidator } from "convex/server";
|
|
2
|
+
import { v } from "convex/values";
|
|
3
|
+
import { query } from "../_generated/server.js";
|
|
4
|
+
import { identity, claimDoc } from "./schema.js";
|
|
5
|
+
import { validateIdentity } from "./helpers.js";
|
|
6
|
+
import { readPolicy } from "../payoutPolicy/helpers.js";
|
|
7
|
+
export const get = query({
|
|
8
|
+
args: identity,
|
|
9
|
+
returns: v.union(claimDoc, v.null()),
|
|
10
|
+
handler: async (ctx, args) => {
|
|
11
|
+
validateIdentity(args);
|
|
12
|
+
await readPolicy(ctx, args);
|
|
13
|
+
const claim = await ctx.db
|
|
14
|
+
.query("payoutReleaseClaims")
|
|
15
|
+
.withIndex("by_operation", (q) => q.eq("operationKey", args.operationKey))
|
|
16
|
+
.unique();
|
|
17
|
+
if (
|
|
18
|
+
claim &&
|
|
19
|
+
(claim.accountId !== args.accountId || claim.ownerKey !== args.ownerKey)
|
|
20
|
+
)
|
|
21
|
+
throw new Error("Payout claim identity does not match.");
|
|
22
|
+
return claim;
|
|
23
|
+
},
|
|
24
|
+
});
|
|
25
|
+
export const pending = query({
|
|
26
|
+
args: {
|
|
27
|
+
accountId: v.string(),
|
|
28
|
+
ownerKey: v.string(),
|
|
29
|
+
paginationOpts: paginationOptsValidator,
|
|
30
|
+
},
|
|
31
|
+
returns: v.object({
|
|
32
|
+
page: v.array(claimDoc),
|
|
33
|
+
continueCursor: v.string(),
|
|
34
|
+
isDone: v.boolean(),
|
|
35
|
+
}),
|
|
36
|
+
handler: async (ctx, args) => {
|
|
37
|
+
await readPolicy(ctx, args);
|
|
38
|
+
if (
|
|
39
|
+
!Number.isInteger(args.paginationOpts.numItems) ||
|
|
40
|
+
args.paginationOpts.numItems < 1 ||
|
|
41
|
+
args.paginationOpts.numItems > 100
|
|
42
|
+
)
|
|
43
|
+
throw new Error("Invalid payout claim page.");
|
|
44
|
+
const result = await ctx.db
|
|
45
|
+
.query("payoutReleaseClaims")
|
|
46
|
+
.withIndex("by_account_status", (q) =>
|
|
47
|
+
q.eq("accountId", args.accountId).eq("status", "reserved"),
|
|
48
|
+
)
|
|
49
|
+
.paginate(args.paginationOpts);
|
|
50
|
+
return {
|
|
51
|
+
page: result.page,
|
|
52
|
+
continueCursor: result.continueCursor,
|
|
53
|
+
isDone: result.isDone,
|
|
54
|
+
};
|
|
55
|
+
},
|
|
56
|
+
});
|