@fantastic.dev/better-stripe 0.25.1 → 0.26.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 +11 -0
- package/README.md +4 -1
- package/dist/client/connect/payoutExecution.d.ts.map +1 -1
- package/dist/client/connect/payoutExecution.js +8 -83
- package/dist/client/connect/payoutExecution.js.map +1 -1
- package/dist/client/connect/payoutExecutionTestHelpers.d.ts +2 -10
- package/dist/client/connect/payoutExecutionTestHelpers.d.ts.map +1 -1
- package/dist/client/connect/payoutExecutionTestHelpers.js.map +1 -1
- package/dist/client/connect/payoutPreview.d.ts +20 -0
- package/dist/client/connect/payoutPreview.d.ts.map +1 -0
- package/dist/client/connect/payoutPreview.js +44 -0
- package/dist/client/connect/payoutPreview.js.map +1 -0
- package/dist/client/connect/payoutQualification.d.ts +30 -0
- package/dist/client/connect/payoutQualification.d.ts.map +1 -0
- package/dist/client/connect/payoutQualification.js +98 -0
- package/dist/client/connect/payoutQualification.js.map +1 -0
- package/dist/client/payoutPolicy.d.ts +3 -0
- package/dist/client/payoutPolicy.d.ts.map +1 -1
- package/dist/client/payoutPolicy.js +4 -0
- package/dist/client/payoutPolicy.js.map +1 -1
- package/dist/component/_generated/component.d.ts +39 -0
- package/dist/component/_generated/component.d.ts.map +1 -1
- package/dist/component/affiliateRecovery/accounting.d.ts +2 -2
- package/dist/component/affiliateRecovery/accounting.d.ts.map +1 -1
- package/dist/component/affiliateRecovery/accounting.js.map +1 -1
- package/dist/component/payoutRelease/helpers.d.ts +4 -4
- package/dist/component/payoutRelease/helpers.d.ts.map +1 -1
- package/dist/component/payoutRelease/helpers.js.map +1 -1
- package/dist/component/payoutRelease/mutations.d.ts.map +1 -1
- package/dist/component/payoutRelease/mutations.js +2 -29
- package/dist/component/payoutRelease/mutations.js.map +1 -1
- package/dist/component/payoutRelease/planning.d.ts +27 -0
- package/dist/component/payoutRelease/planning.d.ts.map +1 -0
- package/dist/component/payoutRelease/planning.js +35 -0
- package/dist/component/payoutRelease/planning.js.map +1 -0
- package/dist/component/payoutRelease/preview.d.ts +117 -0
- package/dist/component/payoutRelease/preview.d.ts.map +1 -0
- package/dist/component/payoutRelease/preview.js +82 -0
- package/dist/component/payoutRelease/preview.js.map +1 -0
- package/dist/component/payoutRelease/queries.d.ts +39 -0
- package/dist/component/payoutRelease/queries.d.ts.map +1 -1
- package/dist/component/payoutRelease/queries.js +6 -0
- package/dist/component/payoutRelease/queries.js.map +1 -1
- package/docs/settlement.md +57 -0
- package/package.json +1 -1
- package/src/client/connect/payoutExecution.ts +12 -99
- package/src/client/connect/payoutExecutionTestHelpers.ts +15 -13
- package/src/client/connect/payoutPreview.test.ts +269 -0
- package/src/client/connect/payoutPreview.ts +77 -0
- package/src/client/connect/payoutQualification.ts +145 -0
- package/src/client/payoutPolicy.ts +14 -0
- package/src/component/_generated/api.ts +4 -0
- package/src/component/_generated/component.ts +42 -0
- package/src/component/affiliateRecovery/accounting.ts +2 -2
- package/src/component/payoutRelease/helpers.ts +7 -4
- package/src/component/payoutRelease/mutations.ts +2 -37
- package/src/component/payoutRelease/planning.ts +55 -0
- package/src/component/payoutRelease/preview.ts +118 -0
- package/src/component/payoutRelease/queries.ts +6 -0
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import type Stripe from "stripe";
|
|
2
|
+
import type { Infer } from "convex/values";
|
|
3
|
+
import type { Component, RunCtx } from "../helpers.js";
|
|
4
|
+
import { componentRef } from "../webhooks/helpers.js";
|
|
5
|
+
import type {
|
|
6
|
+
claimDoc,
|
|
7
|
+
observation,
|
|
8
|
+
} from "../../component/payoutRelease/schema.js";
|
|
9
|
+
import type { transferDocValidator } from "../../component/connect/validators.js";
|
|
10
|
+
import type { policyDoc } from "../../component/payoutPolicy/schema.js";
|
|
11
|
+
import { transferFingerprint } from "../../component/payoutRelease/helpers.js";
|
|
12
|
+
import {
|
|
13
|
+
getTransferFundingReceipt,
|
|
14
|
+
type TransferReceiptTransaction,
|
|
15
|
+
} from "./transferReceipt.js";
|
|
16
|
+
import { verifyPayoutHistory } from "./payoutHistory.js";
|
|
17
|
+
import { reconcileRecipientPayout } from "./payoutReconciliation.js";
|
|
18
|
+
type Held = { status: "held"; reason: string };
|
|
19
|
+
type Snapshot =
|
|
20
|
+
| {
|
|
21
|
+
status: "ready";
|
|
22
|
+
transfers: Infer<typeof transferDocValidator>[];
|
|
23
|
+
claims: Infer<typeof claimDoc>[];
|
|
24
|
+
}
|
|
25
|
+
| Held;
|
|
26
|
+
export type PayoutControlVerification = () => Promise<
|
|
27
|
+
{ status: "verified"; policy: Infer<typeof policyDoc> } | { status: string }
|
|
28
|
+
>;
|
|
29
|
+
export type PayoutProofScope = {
|
|
30
|
+
accountId: string;
|
|
31
|
+
ownerKey: string;
|
|
32
|
+
currency: string;
|
|
33
|
+
};
|
|
34
|
+
/** Shared provider qualification. It can reconcile existing receipts, but never creates a bank payout. */
|
|
35
|
+
export async function collectPayoutQualification(
|
|
36
|
+
stripe: Stripe,
|
|
37
|
+
component: Component,
|
|
38
|
+
ctx: RunCtx,
|
|
39
|
+
opts: PayoutProofScope,
|
|
40
|
+
maxPages: number,
|
|
41
|
+
): Promise<
|
|
42
|
+
| Held
|
|
43
|
+
| {
|
|
44
|
+
status: "verified";
|
|
45
|
+
observations: Infer<typeof observation>[];
|
|
46
|
+
destinationId: string;
|
|
47
|
+
providerAvailable: number;
|
|
48
|
+
observedAt: number;
|
|
49
|
+
}
|
|
50
|
+
> {
|
|
51
|
+
const snapshot = (await ctx.runQuery(
|
|
52
|
+
componentRef(component, "payoutRelease/queries/snapshot"),
|
|
53
|
+
{ accountId: opts.accountId, ownerKey: opts.ownerKey },
|
|
54
|
+
)) as Snapshot;
|
|
55
|
+
if (snapshot.status !== "ready") return snapshot;
|
|
56
|
+
const banks: string[] = [];
|
|
57
|
+
let bankCursor: string | undefined;
|
|
58
|
+
for (let page = 0; ; page++) {
|
|
59
|
+
if (page >= maxPages)
|
|
60
|
+
return { status: "held", reason: "bank_history_incomplete" };
|
|
61
|
+
const batch = await stripe.accounts.listExternalAccounts(opts.accountId, {
|
|
62
|
+
object: "bank_account",
|
|
63
|
+
limit: 100,
|
|
64
|
+
...(bankCursor ? { starting_after: bankCursor } : {}),
|
|
65
|
+
});
|
|
66
|
+
for (const bank of batch.data)
|
|
67
|
+
if (
|
|
68
|
+
bank.object === "bank_account" &&
|
|
69
|
+
bank.currency === opts.currency &&
|
|
70
|
+
bank.default_for_currency &&
|
|
71
|
+
bank.status !== "errored"
|
|
72
|
+
)
|
|
73
|
+
banks.push(bank.id);
|
|
74
|
+
if (!batch.has_more) break;
|
|
75
|
+
const next = batch.data[batch.data.length - 1]?.id;
|
|
76
|
+
if (!next || next === bankCursor)
|
|
77
|
+
return { status: "held", reason: "bank_history_incomplete" };
|
|
78
|
+
bankCursor = next;
|
|
79
|
+
}
|
|
80
|
+
if (banks.length !== 1 || !/^ba_\w+$/.test(banks[0]))
|
|
81
|
+
return { status: "held", reason: "bank_destination_unavailable" };
|
|
82
|
+
const destinationId = banks[0];
|
|
83
|
+
const transactions: TransferReceiptTransaction[] = [];
|
|
84
|
+
for (const claim of snapshot.claims) {
|
|
85
|
+
if (
|
|
86
|
+
claim.currency !== opts.currency ||
|
|
87
|
+
claim.status === "canceled" ||
|
|
88
|
+
claim.status === "rejected"
|
|
89
|
+
)
|
|
90
|
+
continue;
|
|
91
|
+
if (claim.attemptedAt === undefined)
|
|
92
|
+
return { status: "held", reason: "pending_claim" };
|
|
93
|
+
const result = await reconcileRecipientPayout(stripe, component, ctx, {
|
|
94
|
+
accountId: opts.accountId,
|
|
95
|
+
ownerKey: opts.ownerKey,
|
|
96
|
+
operationKey: claim.operationKey,
|
|
97
|
+
maxPages,
|
|
98
|
+
});
|
|
99
|
+
if (result.status !== "updated")
|
|
100
|
+
return {
|
|
101
|
+
status: "held" as const,
|
|
102
|
+
reason:
|
|
103
|
+
result.status === "held"
|
|
104
|
+
? result.reason
|
|
105
|
+
: "historical_claim_terminal",
|
|
106
|
+
};
|
|
107
|
+
for (const t of [result.receipt.debit, result.receipt.credit])
|
|
108
|
+
if (t)
|
|
109
|
+
transactions.push({ ...t, sourceId: result.receipt.stripePayoutId });
|
|
110
|
+
}
|
|
111
|
+
const observations = [];
|
|
112
|
+
for (const row of snapshot.transfers) {
|
|
113
|
+
const receipt = await getTransferFundingReceipt(stripe, component, ctx, {
|
|
114
|
+
accountId: opts.accountId,
|
|
115
|
+
stripeTransferId: row.stripeTransferId,
|
|
116
|
+
});
|
|
117
|
+
if (receipt.status !== "ready")
|
|
118
|
+
return { status: "held", reason: receipt.reason };
|
|
119
|
+
observations.push({
|
|
120
|
+
stripeTransferId: row.stripeTransferId,
|
|
121
|
+
currency: receipt.settlementCurrency,
|
|
122
|
+
matureNet: receipt.matureNet,
|
|
123
|
+
pendingCredits: receipt.pendingCredits,
|
|
124
|
+
ledgerFingerprint: transferFingerprint(row),
|
|
125
|
+
observedAt: receipt.observedAt,
|
|
126
|
+
transactionIds: receipt.transactions.map((t) => t.id),
|
|
127
|
+
});
|
|
128
|
+
if (receipt.settlementCurrency === opts.currency)
|
|
129
|
+
transactions.push(...receipt.transactions);
|
|
130
|
+
}
|
|
131
|
+
const history = await verifyPayoutHistory(stripe, {
|
|
132
|
+
accountId: opts.accountId,
|
|
133
|
+
currency: opts.currency,
|
|
134
|
+
transactions,
|
|
135
|
+
maxPages,
|
|
136
|
+
});
|
|
137
|
+
if (history.status !== "verified") return history;
|
|
138
|
+
return {
|
|
139
|
+
status: "verified",
|
|
140
|
+
observations,
|
|
141
|
+
destinationId,
|
|
142
|
+
providerAvailable: history.providerAvailable,
|
|
143
|
+
observedAt: history.observedAt,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
import {
|
|
2
|
+
previewRecipientPayout,
|
|
3
|
+
type PayoutPreviewOptions,
|
|
4
|
+
} from "./connect/payoutPreview.js";
|
|
5
|
+
export type * from "./connect/payoutPreview.js";
|
|
1
6
|
import { PayoutWorkerClient } from "./connect/payoutWorker.js";
|
|
2
7
|
export type * from "./connect/payoutWorker.js";
|
|
3
8
|
import {
|
|
@@ -107,6 +112,15 @@ const toProvider = (
|
|
|
107
112
|
/** Host authenticates owner/policy/consent. Provider I/O stays in this package. */
|
|
108
113
|
export class PayoutPolicyClient {
|
|
109
114
|
readonly worker: PayoutWorkerClient;
|
|
115
|
+
preview(ctx: RunCtx, opts: PayoutPreviewOptions) {
|
|
116
|
+
return previewRecipientPayout(
|
|
117
|
+
this.provider(),
|
|
118
|
+
this.component,
|
|
119
|
+
ctx,
|
|
120
|
+
opts,
|
|
121
|
+
() => this.verify(ctx, opts),
|
|
122
|
+
);
|
|
123
|
+
}
|
|
110
124
|
constructor(
|
|
111
125
|
private readonly component: Component,
|
|
112
126
|
private readonly provider: () => Stripe,
|
|
@@ -56,6 +56,8 @@ import type * as payoutRelease_fees from "../payoutRelease/fees.js";
|
|
|
56
56
|
import type * as payoutRelease_helpers from "../payoutRelease/helpers.js";
|
|
57
57
|
import type * as payoutRelease_mutations from "../payoutRelease/mutations.js";
|
|
58
58
|
import type * as payoutRelease_observation from "../payoutRelease/observation.js";
|
|
59
|
+
import type * as payoutRelease_planning from "../payoutRelease/planning.js";
|
|
60
|
+
import type * as payoutRelease_preview from "../payoutRelease/preview.js";
|
|
59
61
|
import type * as payoutRelease_queries from "../payoutRelease/queries.js";
|
|
60
62
|
import type * as payoutSnapshots_localState from "../payoutSnapshots/localState.js";
|
|
61
63
|
import type * as payoutSnapshots_mutations from "../payoutSnapshots/mutations.js";
|
|
@@ -136,6 +138,8 @@ const fullApi: ApiFromModules<{
|
|
|
136
138
|
"payoutRelease/helpers": typeof payoutRelease_helpers;
|
|
137
139
|
"payoutRelease/mutations": typeof payoutRelease_mutations;
|
|
138
140
|
"payoutRelease/observation": typeof payoutRelease_observation;
|
|
141
|
+
"payoutRelease/planning": typeof payoutRelease_planning;
|
|
142
|
+
"payoutRelease/preview": typeof payoutRelease_preview;
|
|
139
143
|
"payoutRelease/queries": typeof payoutRelease_queries;
|
|
140
144
|
"payoutSnapshots/localState": typeof payoutSnapshots_localState;
|
|
141
145
|
"payoutSnapshots/mutations": typeof payoutSnapshots_mutations;
|
|
@@ -3608,6 +3608,48 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
|
|
|
3608
3608
|
},
|
|
3609
3609
|
Name
|
|
3610
3610
|
>;
|
|
3611
|
+
preview: FunctionReference<
|
|
3612
|
+
"query",
|
|
3613
|
+
"internal",
|
|
3614
|
+
{
|
|
3615
|
+
accountId: string;
|
|
3616
|
+
currency: string;
|
|
3617
|
+
expectedRevision: number;
|
|
3618
|
+
independentLiabilities: number;
|
|
3619
|
+
observations: Array<{
|
|
3620
|
+
currency: string;
|
|
3621
|
+
ledgerFingerprint: string;
|
|
3622
|
+
matureNet: number;
|
|
3623
|
+
observedAt: number;
|
|
3624
|
+
pendingCredits: number;
|
|
3625
|
+
stripeTransferId: string;
|
|
3626
|
+
transactionIds: Array<string>;
|
|
3627
|
+
}>;
|
|
3628
|
+
ownerKey: string;
|
|
3629
|
+
providerAvailable: number;
|
|
3630
|
+
},
|
|
3631
|
+
| { amount: 0; reason: string; status: "held" }
|
|
3632
|
+
| {
|
|
3633
|
+
amount: number;
|
|
3634
|
+
confirmedFees: number;
|
|
3635
|
+
currency: string;
|
|
3636
|
+
independentLiabilities: number;
|
|
3637
|
+
matureUnpaid: number;
|
|
3638
|
+
observedAt: number;
|
|
3639
|
+
pendingCredits: number;
|
|
3640
|
+
providerAvailable: number;
|
|
3641
|
+
providerMinimum: null;
|
|
3642
|
+
reason?: string;
|
|
3643
|
+
sources: Array<{
|
|
3644
|
+
matureUnpaid: number;
|
|
3645
|
+
pendingCredits: number;
|
|
3646
|
+
role: "store" | "affiliate" | "other";
|
|
3647
|
+
}>;
|
|
3648
|
+
status: "held" | "qualified";
|
|
3649
|
+
threshold: number;
|
|
3650
|
+
},
|
|
3651
|
+
Name
|
|
3652
|
+
>;
|
|
3611
3653
|
snapshot: FunctionReference<
|
|
3612
3654
|
"query",
|
|
3613
3655
|
"internal",
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Doc } from "../_generated/dataModel.js";
|
|
2
|
-
import type { MutationCtx } from "../_generated/server.js";
|
|
2
|
+
import type { MutationCtx, QueryCtx } from "../_generated/server.js";
|
|
3
3
|
export type Obligation = Omit<
|
|
4
4
|
Doc<"affiliateObligations">,
|
|
5
5
|
"_id" | "_creationTime"
|
|
@@ -13,7 +13,7 @@ export const due = (row: Obligation) =>
|
|
|
13
13
|
export const credit = (row: Obligation) =>
|
|
14
14
|
row.needsReconciliation ? 0 : Math.max(0, netRecovery(row) - target(row));
|
|
15
15
|
export async function balance(
|
|
16
|
-
ctx: MutationCtx,
|
|
16
|
+
ctx: MutationCtx | QueryCtx,
|
|
17
17
|
account: string,
|
|
18
18
|
currency: string,
|
|
19
19
|
) {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Infer } from "convex/values";
|
|
2
2
|
import type { transferDocValidator } from "../connect/validators.js";
|
|
3
|
-
import type { MutationCtx } from "../_generated/server.js";
|
|
3
|
+
import type { MutationCtx, QueryCtx } from "../_generated/server.js";
|
|
4
4
|
import type { Doc } from "../_generated/dataModel.js";
|
|
5
5
|
import { readPolicy, validateScope } from "../payoutPolicy/helpers.js";
|
|
6
6
|
import type { observation } from "./schema.js";
|
|
@@ -50,7 +50,7 @@ export async function claimByKey(
|
|
|
50
50
|
return claim;
|
|
51
51
|
}
|
|
52
52
|
export async function readyPolicy(
|
|
53
|
-
ctx: MutationCtx,
|
|
53
|
+
ctx: MutationCtx | QueryCtx,
|
|
54
54
|
args: { accountId: string; ownerKey: string; expectedRevision: number },
|
|
55
55
|
) {
|
|
56
56
|
const policy = await readPolicy(ctx, args);
|
|
@@ -67,7 +67,7 @@ export async function readyPolicy(
|
|
|
67
67
|
}
|
|
68
68
|
/** Full local account membership is checked under OCC before any reservation. */
|
|
69
69
|
export async function checkSnapshot(
|
|
70
|
-
ctx: MutationCtx,
|
|
70
|
+
ctx: MutationCtx | QueryCtx,
|
|
71
71
|
accountId: string,
|
|
72
72
|
observations: Infer<typeof observation>[],
|
|
73
73
|
fresh: boolean,
|
|
@@ -126,7 +126,10 @@ export async function checkSnapshot(
|
|
|
126
126
|
}
|
|
127
127
|
return byId;
|
|
128
128
|
}
|
|
129
|
-
export async function sourceById(
|
|
129
|
+
export async function sourceById(
|
|
130
|
+
ctx: MutationCtx | QueryCtx,
|
|
131
|
+
stripeTransferId: string,
|
|
132
|
+
) {
|
|
130
133
|
return ctx.db
|
|
131
134
|
.query("payoutSourceAllocations")
|
|
132
135
|
.withIndex("by_transfer", (q) => q.eq("stripeTransferId", stripeTransferId))
|
|
@@ -19,7 +19,7 @@ import {
|
|
|
19
19
|
validateIdentity,
|
|
20
20
|
add,
|
|
21
21
|
} from "./helpers.js";
|
|
22
|
-
import {
|
|
22
|
+
import { readPayoutPlan } from "./planning.js";
|
|
23
23
|
const held = v.object({
|
|
24
24
|
status: v.literal("held"),
|
|
25
25
|
reason: v.union(
|
|
@@ -63,10 +63,6 @@ export const claim = mutation({
|
|
|
63
63
|
return existing;
|
|
64
64
|
}
|
|
65
65
|
const policy = await readyPolicy(ctx, args);
|
|
66
|
-
const minimum = policy.thresholds.find(
|
|
67
|
-
(value) => value.currency === args.currency,
|
|
68
|
-
);
|
|
69
|
-
if (!minimum) throw new Error("Payout policy currency is not configured.");
|
|
70
66
|
const pending = await ctx.db
|
|
71
67
|
.query("payoutReleaseClaims")
|
|
72
68
|
.withIndex("by_account_status", (q) =>
|
|
@@ -81,38 +77,7 @@ export const claim = mutation({
|
|
|
81
77
|
currency: args.currency,
|
|
82
78
|
providerMinimum: null,
|
|
83
79
|
};
|
|
84
|
-
const
|
|
85
|
-
ctx,
|
|
86
|
-
args.accountId,
|
|
87
|
-
args.observations,
|
|
88
|
-
true,
|
|
89
|
-
);
|
|
90
|
-
const sources: PayoutFundingSource[] = [];
|
|
91
|
-
for (const observation of args.observations) {
|
|
92
|
-
const prior = await sourceById(ctx, observation.stripeTransferId);
|
|
93
|
-
if (
|
|
94
|
-
prior &&
|
|
95
|
-
(prior.accountId !== args.accountId ||
|
|
96
|
-
prior.ownerKey !== args.ownerKey ||
|
|
97
|
-
prior.currency !== observation.currency)
|
|
98
|
-
)
|
|
99
|
-
throw new Error("Payout source allocation identity changed.");
|
|
100
|
-
sources.push({
|
|
101
|
-
...observation,
|
|
102
|
-
role: rows.get(observation.stripeTransferId)!.role,
|
|
103
|
-
paidAmount: prior?.paidAmount ?? 0,
|
|
104
|
-
reservedAmount: prior?.reservedAmount ?? 0,
|
|
105
|
-
});
|
|
106
|
-
}
|
|
107
|
-
const fees = await readFees(ctx, args);
|
|
108
|
-
const plan = planRecipientPayout({
|
|
109
|
-
currency: args.currency,
|
|
110
|
-
threshold: minimum.amount,
|
|
111
|
-
providerAvailable: args.providerAvailable,
|
|
112
|
-
independentLiabilities: args.independentLiabilities,
|
|
113
|
-
confirmedFees: fees?.amount ?? 0,
|
|
114
|
-
sources,
|
|
115
|
-
});
|
|
80
|
+
const { plan, fees } = await readPayoutPlan(ctx, args, policy);
|
|
116
81
|
if (plan.status === "held") return plan;
|
|
117
82
|
await reserveRecoveryGuard(ctx, args, plan.amount);
|
|
118
83
|
for (const allocation of plan.allocations) {
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { Infer } from "convex/values";
|
|
2
|
+
import type { MutationCtx, QueryCtx } from "../_generated/server.js";
|
|
3
|
+
import type { policyDoc } from "../payoutPolicy/schema.js";
|
|
4
|
+
import type { observation } from "./observation.js";
|
|
5
|
+
import { checkSnapshot, sourceById } from "./helpers.js";
|
|
6
|
+
import { readFees } from "./fees.js";
|
|
7
|
+
import { planRecipientPayout, type PayoutFundingSource } from "./allocation.js";
|
|
8
|
+
export async function readPayoutPlan(
|
|
9
|
+
ctx: MutationCtx | QueryCtx,
|
|
10
|
+
args: {
|
|
11
|
+
accountId: string;
|
|
12
|
+
ownerKey: string;
|
|
13
|
+
currency: string;
|
|
14
|
+
observations: Infer<typeof observation>[];
|
|
15
|
+
providerAvailable: number;
|
|
16
|
+
independentLiabilities: number;
|
|
17
|
+
},
|
|
18
|
+
policy: Infer<typeof policyDoc>,
|
|
19
|
+
) {
|
|
20
|
+
const minimum = policy.thresholds.find((t) => t.currency === args.currency);
|
|
21
|
+
if (!minimum) throw new Error("Payout policy currency is not configured.");
|
|
22
|
+
const rows = await checkSnapshot(
|
|
23
|
+
ctx,
|
|
24
|
+
args.accountId,
|
|
25
|
+
args.observations,
|
|
26
|
+
true,
|
|
27
|
+
);
|
|
28
|
+
const sources: PayoutFundingSource[] = [];
|
|
29
|
+
for (const observation of args.observations) {
|
|
30
|
+
const prior = await sourceById(ctx, observation.stripeTransferId);
|
|
31
|
+
if (
|
|
32
|
+
prior &&
|
|
33
|
+
(prior.accountId !== args.accountId ||
|
|
34
|
+
prior.ownerKey !== args.ownerKey ||
|
|
35
|
+
prior.currency !== observation.currency)
|
|
36
|
+
)
|
|
37
|
+
throw new Error("Payout source allocation identity changed.");
|
|
38
|
+
sources.push({
|
|
39
|
+
...observation,
|
|
40
|
+
role: rows.get(observation.stripeTransferId)!.role,
|
|
41
|
+
paidAmount: prior?.paidAmount ?? 0,
|
|
42
|
+
reservedAmount: prior?.reservedAmount ?? 0,
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
const fees = await readFees(ctx, args);
|
|
46
|
+
const plan = planRecipientPayout({
|
|
47
|
+
currency: args.currency,
|
|
48
|
+
threshold: minimum.amount,
|
|
49
|
+
providerAvailable: args.providerAvailable,
|
|
50
|
+
independentLiabilities: args.independentLiabilities,
|
|
51
|
+
confirmedFees: fees?.amount ?? 0,
|
|
52
|
+
sources,
|
|
53
|
+
});
|
|
54
|
+
return { plan, sources, fees, threshold: minimum.amount };
|
|
55
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { v } from "convex/values";
|
|
2
|
+
import type { MutationCtx, QueryCtx } from "../_generated/server.js";
|
|
3
|
+
import type { Infer } from "convex/values";
|
|
4
|
+
import { scopeFields } from "../payoutPolicy/schema.js";
|
|
5
|
+
import { observation } from "./observation.js";
|
|
6
|
+
import { readyPolicy, add } from "./helpers.js";
|
|
7
|
+
import { readPayoutPlan } from "./planning.js";
|
|
8
|
+
import { balance } from "../affiliateRecovery/accounting.js";
|
|
9
|
+
export const previewFields = {
|
|
10
|
+
...scopeFields,
|
|
11
|
+
expectedRevision: v.number(),
|
|
12
|
+
currency: v.string(),
|
|
13
|
+
providerAvailable: v.number(),
|
|
14
|
+
independentLiabilities: v.number(),
|
|
15
|
+
observations: v.array(observation),
|
|
16
|
+
};
|
|
17
|
+
const held = v.object({
|
|
18
|
+
status: v.literal("held"),
|
|
19
|
+
reason: v.string(),
|
|
20
|
+
amount: v.literal(0),
|
|
21
|
+
});
|
|
22
|
+
export const previewResult = v.union(
|
|
23
|
+
held,
|
|
24
|
+
v.object({
|
|
25
|
+
status: v.union(v.literal("held"), v.literal("qualified")),
|
|
26
|
+
reason: v.optional(v.string()),
|
|
27
|
+
currency: v.string(),
|
|
28
|
+
amount: v.number(),
|
|
29
|
+
threshold: v.number(),
|
|
30
|
+
providerMinimum: v.null(),
|
|
31
|
+
matureUnpaid: v.number(),
|
|
32
|
+
pendingCredits: v.number(),
|
|
33
|
+
confirmedFees: v.number(),
|
|
34
|
+
independentLiabilities: v.number(),
|
|
35
|
+
providerAvailable: v.number(),
|
|
36
|
+
observedAt: v.number(),
|
|
37
|
+
sources: v.array(
|
|
38
|
+
v.object({
|
|
39
|
+
role: v.union(
|
|
40
|
+
v.literal("store"),
|
|
41
|
+
v.literal("affiliate"),
|
|
42
|
+
v.literal("other"),
|
|
43
|
+
),
|
|
44
|
+
matureUnpaid: v.number(),
|
|
45
|
+
pendingCredits: v.number(),
|
|
46
|
+
}),
|
|
47
|
+
),
|
|
48
|
+
}),
|
|
49
|
+
);
|
|
50
|
+
type Args = {
|
|
51
|
+
[K in keyof typeof previewFields]: Infer<(typeof previewFields)[K]>;
|
|
52
|
+
};
|
|
53
|
+
/** Trusted provider observations, informational only. No reservation or monetary state change. */
|
|
54
|
+
export async function previewPlan(
|
|
55
|
+
ctx: MutationCtx | QueryCtx,
|
|
56
|
+
args: Args,
|
|
57
|
+
): Promise<Infer<typeof previewResult>> {
|
|
58
|
+
const policy = await readyPolicy(ctx, args);
|
|
59
|
+
const recovery = await balance(ctx, args.accountId, args.currency);
|
|
60
|
+
if (
|
|
61
|
+
recovery &&
|
|
62
|
+
(recovery.outstandingDebt > 0 ||
|
|
63
|
+
recovery.reservedRecovery > 0 ||
|
|
64
|
+
recovery.reservedRestoration > 0 ||
|
|
65
|
+
recovery.activePayoutKey)
|
|
66
|
+
)
|
|
67
|
+
return { status: "held", reason: "affiliate_recovery_pending", amount: 0 };
|
|
68
|
+
const pending = await ctx.db
|
|
69
|
+
.query("payoutReleaseClaims")
|
|
70
|
+
.withIndex("by_account_status", (q) =>
|
|
71
|
+
q.eq("accountId", args.accountId).eq("status", "reserved"),
|
|
72
|
+
)
|
|
73
|
+
.first();
|
|
74
|
+
if (pending) return { status: "held", reason: "pending_claim", amount: 0 };
|
|
75
|
+
const {
|
|
76
|
+
plan,
|
|
77
|
+
sources: funding,
|
|
78
|
+
fees,
|
|
79
|
+
threshold,
|
|
80
|
+
} = await readPayoutPlan(ctx, args, policy);
|
|
81
|
+
const sources = (["store", "affiliate", "other"] as const).map((role) => {
|
|
82
|
+
let matureUnpaid = 0,
|
|
83
|
+
pendingCredits = 0;
|
|
84
|
+
for (const source of funding)
|
|
85
|
+
if (
|
|
86
|
+
source.currency === args.currency &&
|
|
87
|
+
(source.role ?? "other") === role
|
|
88
|
+
) {
|
|
89
|
+
matureUnpaid = add(
|
|
90
|
+
matureUnpaid,
|
|
91
|
+
add(
|
|
92
|
+
add(source.matureNet, -source.paidAmount),
|
|
93
|
+
-source.reservedAmount,
|
|
94
|
+
),
|
|
95
|
+
);
|
|
96
|
+
pendingCredits = add(pendingCredits, source.pendingCredits);
|
|
97
|
+
}
|
|
98
|
+
return { role, matureUnpaid, pendingCredits };
|
|
99
|
+
});
|
|
100
|
+
return {
|
|
101
|
+
status: plan.status,
|
|
102
|
+
...(plan.status === "held" ? { reason: plan.reason } : {}),
|
|
103
|
+
currency: plan.currency,
|
|
104
|
+
amount: plan.amount,
|
|
105
|
+
threshold,
|
|
106
|
+
providerMinimum: null,
|
|
107
|
+
matureUnpaid: plan.matureUnpaid,
|
|
108
|
+
pendingCredits: plan.pendingCredits,
|
|
109
|
+
confirmedFees: fees?.amount ?? 0,
|
|
110
|
+
independentLiabilities: args.independentLiabilities,
|
|
111
|
+
providerAvailable: args.providerAvailable,
|
|
112
|
+
observedAt: Math.min(
|
|
113
|
+
Date.now(),
|
|
114
|
+
...args.observations.map((o) => o.observedAt),
|
|
115
|
+
),
|
|
116
|
+
sources,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { previewFields, previewResult, previewPlan } from "./preview.js";
|
|
1
2
|
import { paginator } from "convex-helpers/server/pagination";
|
|
2
3
|
import schema from "../schema.js";
|
|
3
4
|
import { transferDocValidator } from "../connect/validators.js";
|
|
@@ -8,6 +9,11 @@ import { query } from "../_generated/server.js";
|
|
|
8
9
|
import { identity, claimDoc } from "./schema.js";
|
|
9
10
|
import { validateIdentity } from "./helpers.js";
|
|
10
11
|
import { readPolicy } from "../payoutPolicy/helpers.js";
|
|
12
|
+
export const preview = query({
|
|
13
|
+
args: previewFields,
|
|
14
|
+
returns: previewResult,
|
|
15
|
+
handler: previewPlan,
|
|
16
|
+
});
|
|
11
17
|
export const get = query({
|
|
12
18
|
args: identity,
|
|
13
19
|
returns: v.union(claimDoc, v.null()),
|