@fantastic.dev/better-stripe 0.10.2 → 0.12.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 +23 -0
- package/README.md +220 -4
- package/dist/client/billing/balanceFunding.d.ts +36 -0
- package/dist/client/billing/balanceFunding.d.ts.map +1 -0
- package/dist/client/billing/balanceFunding.js +107 -0
- package/dist/client/billing/balanceFunding.js.map +1 -0
- package/dist/client/billing/invoicePreview.d.ts +28 -0
- package/dist/client/billing/invoicePreview.d.ts.map +1 -0
- package/dist/client/billing/invoicePreview.js +56 -0
- package/dist/client/billing/invoicePreview.js.map +1 -0
- package/dist/client/charge-currency-format.d.ts +3 -0
- package/dist/client/charge-currency-format.d.ts.map +1 -0
- package/dist/client/charge-currency-format.js +22 -0
- package/dist/client/charge-currency-format.js.map +1 -0
- package/dist/client/charge-currency.d.ts +14 -0
- package/dist/client/charge-currency.d.ts.map +1 -0
- package/dist/client/charge-currency.js +85 -0
- package/dist/client/charge-currency.js.map +1 -0
- package/dist/client/costRecovery.d.ts +115 -0
- package/dist/client/costRecovery.d.ts.map +1 -0
- package/dist/client/costRecovery.js +50 -0
- package/dist/client/costRecovery.js.map +1 -0
- package/dist/client/index.d.ts +28 -64
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +25 -0
- package/dist/client/index.js.map +1 -1
- package/dist/client/products/prices.d.ts +4 -2
- package/dist/client/products/prices.d.ts.map +1 -1
- package/dist/client/products/prices.js +5 -4
- package/dist/client/products/prices.js.map +1 -1
- package/dist/client/products/products.d.ts +4 -2
- package/dist/client/products/products.d.ts.map +1 -1
- package/dist/client/products/products.js +7 -5
- package/dist/client/products/products.js.map +1 -1
- package/dist/client/settlement/processingCosts.d.ts +56 -0
- package/dist/client/settlement/processingCosts.d.ts.map +1 -0
- package/dist/client/settlement/processingCosts.js +173 -0
- package/dist/client/settlement/processingCosts.js.map +1 -0
- package/dist/client/utils/webhookEndpoints.d.ts +1 -1
- package/dist/client/utils/webhookEndpoints.d.ts.map +1 -1
- package/dist/client/utils/webhookEndpoints.js +1 -0
- package/dist/client/utils/webhookEndpoints.js.map +1 -1
- package/dist/client/webhooks/processors.d.ts.map +1 -1
- package/dist/client/webhooks/processors.js +1 -0
- package/dist/client/webhooks/processors.js.map +1 -1
- package/dist/component/_generated/component.d.ts +244 -0
- package/dist/component/_generated/component.d.ts.map +1 -1
- package/dist/component/costRecovery/claims.d.ts +68 -0
- package/dist/component/costRecovery/claims.d.ts.map +1 -0
- package/dist/component/costRecovery/claims.js +85 -0
- package/dist/component/costRecovery/claims.js.map +1 -0
- package/dist/component/costRecovery/completion.d.ts +33 -0
- package/dist/component/costRecovery/completion.d.ts.map +1 -0
- package/dist/component/costRecovery/completion.js +69 -0
- package/dist/component/costRecovery/completion.js.map +1 -0
- package/dist/component/costRecovery/helpers.d.ts +62 -0
- package/dist/component/costRecovery/helpers.d.ts.map +1 -0
- package/dist/component/costRecovery/helpers.js +71 -0
- package/dist/component/costRecovery/helpers.js.map +1 -0
- package/dist/component/costRecovery/mutations.d.ts +138 -0
- package/dist/component/costRecovery/mutations.d.ts.map +1 -0
- package/dist/component/costRecovery/mutations.js +36 -0
- package/dist/component/costRecovery/mutations.js.map +1 -0
- package/dist/component/costRecovery/queries.d.ts +102 -0
- package/dist/component/costRecovery/queries.d.ts.map +1 -0
- package/dist/component/costRecovery/queries.js +73 -0
- package/dist/component/costRecovery/queries.js.map +1 -0
- package/dist/component/costRecovery/reservations.d.ts +83 -0
- package/dist/component/costRecovery/reservations.d.ts.map +1 -0
- package/dist/component/costRecovery/reservations.js +134 -0
- package/dist/component/costRecovery/reservations.js.map +1 -0
- package/dist/component/costRecovery/schema.d.ts +340 -0
- package/dist/component/costRecovery/schema.d.ts.map +1 -0
- package/dist/component/costRecovery/schema.js +109 -0
- package/dist/component/costRecovery/schema.js.map +1 -0
- package/dist/component/schema.d.ts +119 -0
- package/dist/component/schema.js +5 -0
- package/dist/component/schema.js.map +1 -1
- package/dist/react/components/PriceCard.d.ts +3 -1
- package/dist/react/components/PriceCard.d.ts.map +1 -1
- package/dist/react/components/PriceCard.js +2 -2
- package/dist/react/components/PriceCard.js.map +1 -1
- package/dist/react/index.d.ts +1 -0
- package/dist/react/index.d.ts.map +1 -1
- package/dist/react/index.js +1 -0
- package/dist/react/index.js.map +1 -1
- package/dist/react/lib/price-helpers.d.ts +4 -6
- package/dist/react/lib/price-helpers.d.ts.map +1 -1
- package/dist/react/lib/price-helpers.js +16 -9
- package/dist/react/lib/price-helpers.js.map +1 -1
- package/dist/react/workflows/PlanPricingEditor.d.ts +4 -0
- package/dist/react/workflows/PlanPricingEditor.d.ts.map +1 -1
- package/dist/react/workflows/PlanPricingEditor.js +12 -9
- package/dist/react/workflows/PlanPricingEditor.js.map +1 -1
- package/docs/react-workflows.md +91 -0
- package/package.json +2 -2
- package/src/client/billing/balanceFunding.test.ts +257 -0
- package/src/client/billing/balanceFunding.ts +183 -0
- package/src/client/billing/balanceFundingTransport.test.ts +89 -0
- package/src/client/billing/invoicePreview.test.ts +123 -0
- package/src/client/billing/invoicePreview.ts +94 -0
- package/src/client/billing/invoicePreviewTransport.test.ts +70 -0
- package/src/client/charge-currency-format.ts +29 -0
- package/src/client/charge-currency.test.ts +113 -0
- package/src/client/charge-currency.ts +119 -0
- package/src/client/costRecovery.test.ts +76 -0
- package/src/client/costRecovery.ts +149 -0
- package/src/client/index.ts +76 -73
- package/src/client/products/catalog-idempotency.test.ts +116 -0
- package/src/client/products/prices.ts +18 -11
- package/src/client/products/products.ts +17 -11
- package/src/client/settlement/processingCosts.test.ts +349 -0
- package/src/client/settlement/processingCosts.ts +315 -0
- package/src/client/settlement/processingCostsTransport.test.ts +135 -0
- package/src/client/utils/webhookEndpoints.test.ts +5 -0
- package/src/client/utils/webhookEndpoints.ts +1 -0
- package/src/client/webhooks/hooks.test.ts +9 -0
- package/src/client/webhooks/payout-canceled.integration.test.ts +192 -0
- package/src/client/webhooks/processors.ts +1 -0
- package/src/component/_generated/api.ts +12 -0
- package/src/component/_generated/component.ts +308 -0
- package/src/component/cost-recovery.test.ts +344 -0
- package/src/component/costRecovery/claims.ts +109 -0
- package/src/component/costRecovery/completion.ts +92 -0
- package/src/component/costRecovery/helpers.ts +83 -0
- package/src/component/costRecovery/mutations.ts +43 -0
- package/src/component/costRecovery/queries.ts +88 -0
- package/src/component/costRecovery/reservations.ts +164 -0
- package/src/component/costRecovery/schema.ts +127 -0
- package/src/component/schema.ts +10 -0
- package/src/react/components/PayoutSchedule.render.test.tsx +15 -0
- package/src/react/components/PriceCard.render.test.tsx +37 -0
- package/src/react/components/PriceCard.tsx +9 -1
- package/src/react/index.test.ts +5 -0
- package/src/react/index.ts +7 -0
- package/src/react/lib/price-helpers.test.ts +15 -2
- package/src/react/lib/price-helpers.ts +25 -8
- package/src/react/workflows/Billing.test.tsx +1 -1
- package/src/react/workflows/PlanPricingEditor.currency.test.tsx +164 -0
- package/src/react/workflows/PlanPricingEditor.tsx +26 -10
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { v } from "convex/values";
|
|
2
|
+
import { type MutationCtx } from "../_generated/server.js";
|
|
3
|
+
import { observationFields, rollbackArgs } from "./schema.js";
|
|
4
|
+
import {
|
|
5
|
+
adjustTotals,
|
|
6
|
+
getGroup,
|
|
7
|
+
integer,
|
|
8
|
+
sign,
|
|
9
|
+
validateScope,
|
|
10
|
+
} from "./helpers.js";
|
|
11
|
+
import type { Infer } from "convex/values";
|
|
12
|
+
const _observation = v.object(observationFields);
|
|
13
|
+
export async function recordClaim(
|
|
14
|
+
ctx: MutationCtx,
|
|
15
|
+
args: Infer<typeof _observation>,
|
|
16
|
+
) {
|
|
17
|
+
validateScope(args);
|
|
18
|
+
[
|
|
19
|
+
args.amountDue,
|
|
20
|
+
args.sourceGross,
|
|
21
|
+
args.sourceProcessingFee,
|
|
22
|
+
args.revision,
|
|
23
|
+
].forEach(integer);
|
|
24
|
+
if (
|
|
25
|
+
!args.claimKey ||
|
|
26
|
+
!args.sourceChargeId ||
|
|
27
|
+
args.sourceGross <= 0 ||
|
|
28
|
+
args.sourceProcessingFee < 0 ||
|
|
29
|
+
args.revision < 0
|
|
30
|
+
)
|
|
31
|
+
throw new Error("Invalid recovery claim observation.");
|
|
32
|
+
const existing = await ctx.db
|
|
33
|
+
.query("recoveryClaims")
|
|
34
|
+
.withIndex("by_key", (q) => q.eq("claimKey", args.claimKey))
|
|
35
|
+
.unique();
|
|
36
|
+
if (existing) {
|
|
37
|
+
for (const key of [
|
|
38
|
+
"accountId",
|
|
39
|
+
"scopeKey",
|
|
40
|
+
"currency",
|
|
41
|
+
"sourceChargeId",
|
|
42
|
+
"sourceGross",
|
|
43
|
+
"sourceProcessingFee",
|
|
44
|
+
] as const)
|
|
45
|
+
if (existing[key] !== args[key])
|
|
46
|
+
throw new Error("Recovery claim source identity cannot change.");
|
|
47
|
+
if (args.revision < existing.revision) return existing;
|
|
48
|
+
if (args.revision === existing.revision) {
|
|
49
|
+
if (args.amountDue !== existing.amountDue)
|
|
50
|
+
throw new Error(
|
|
51
|
+
"Recovery revision cannot be reused with a different amount.",
|
|
52
|
+
);
|
|
53
|
+
return existing;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
const delta = integer(args.amountDue - (existing?.amountDue ?? 0));
|
|
57
|
+
const collectedAmount = existing?.collectedAmount ?? 0,
|
|
58
|
+
reservedAmount = existing?.reservedAmount ?? 0;
|
|
59
|
+
const outstanding = integer(
|
|
60
|
+
args.amountDue - collectedAmount - reservedAmount,
|
|
61
|
+
);
|
|
62
|
+
const fields = {
|
|
63
|
+
...args,
|
|
64
|
+
collectedAmount,
|
|
65
|
+
reservedAmount,
|
|
66
|
+
outstanding,
|
|
67
|
+
sign: sign(outstanding),
|
|
68
|
+
};
|
|
69
|
+
let id = existing?._id;
|
|
70
|
+
if (id) await ctx.db.patch("recoveryClaims", id, fields);
|
|
71
|
+
else id = await ctx.db.insert("recoveryClaims", fields);
|
|
72
|
+
await adjustTotals(ctx, args, { amountDue: delta, outstanding: delta });
|
|
73
|
+
return (await ctx.db.get("recoveryClaims", id))!;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const _rollbackArgs = v.object(rollbackArgs);
|
|
77
|
+
export async function rollbackClaim(
|
|
78
|
+
ctx: MutationCtx,
|
|
79
|
+
args: Infer<typeof _rollbackArgs>,
|
|
80
|
+
) {
|
|
81
|
+
integer(args.returnedPrincipal);
|
|
82
|
+
integer(args.revision);
|
|
83
|
+
const group = await getGroup(ctx, args.groupKey);
|
|
84
|
+
if (
|
|
85
|
+
group.status !== "collected" ||
|
|
86
|
+
group.sourceKind !== "settlement" ||
|
|
87
|
+
!group.sourceGross
|
|
88
|
+
)
|
|
89
|
+
throw new Error(
|
|
90
|
+
"Rollback requires a collected settlement group with original funding economics.",
|
|
91
|
+
);
|
|
92
|
+
if (args.returnedPrincipal < 0 || args.returnedPrincipal > group.sourceGross)
|
|
93
|
+
throw new Error("Returned principal exceeds the original funding sale.");
|
|
94
|
+
const abs = BigInt(Math.abs(group.amount)),
|
|
95
|
+
gross = BigInt(group.sourceGross),
|
|
96
|
+
principal = BigInt(args.returnedPrincipal);
|
|
97
|
+
const rounded = Number((abs * principal + gross / 2n) / gross);
|
|
98
|
+
return recordClaim(ctx, {
|
|
99
|
+
accountId: group.accountId,
|
|
100
|
+
scopeKey: group.scopeKey,
|
|
101
|
+
currency: group.currency,
|
|
102
|
+
claimKey: `rollback:${group.groupKey}`,
|
|
103
|
+
sourceChargeId: group.sourceId,
|
|
104
|
+
sourceGross: group.sourceGross,
|
|
105
|
+
sourceProcessingFee: group.sourceProcessingFee!,
|
|
106
|
+
amountDue: group.amount < 0 ? -rounded : rounded,
|
|
107
|
+
revision: args.revision,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { v } from "convex/values";
|
|
2
|
+
import type { MutationCtx } from "../_generated/server.js";
|
|
3
|
+
import { finishArgs } from "./schema.js";
|
|
4
|
+
import {
|
|
5
|
+
adjustTotals,
|
|
6
|
+
balance,
|
|
7
|
+
batchLimit,
|
|
8
|
+
getGroup,
|
|
9
|
+
integer,
|
|
10
|
+
sign,
|
|
11
|
+
} from "./helpers.js";
|
|
12
|
+
|
|
13
|
+
import type { Infer } from "convex/values";
|
|
14
|
+
const _finishArgs = v.object(finishArgs);
|
|
15
|
+
export async function finishGroup(
|
|
16
|
+
ctx: MutationCtx,
|
|
17
|
+
args: Infer<typeof _finishArgs>,
|
|
18
|
+
) {
|
|
19
|
+
const limit = args.limit ?? 10;
|
|
20
|
+
batchLimit(limit, 10);
|
|
21
|
+
const group = await getGroup(ctx, args.groupKey);
|
|
22
|
+
const collect = args.outcome === "collect",
|
|
23
|
+
terminal = collect ? "collected" : "released",
|
|
24
|
+
progress = collect ? "collecting" : "releasing";
|
|
25
|
+
if (group.status === terminal) return { group, isDone: true };
|
|
26
|
+
if (
|
|
27
|
+
(collect && ["released", "releasing"].includes(group.status)) ||
|
|
28
|
+
(!collect && ["collected", "collecting"].includes(group.status))
|
|
29
|
+
)
|
|
30
|
+
throw new Error("Recovery outcome cannot change.");
|
|
31
|
+
if (collect && group.status === "open")
|
|
32
|
+
throw new Error("Seal recovery before confirming provider collection.");
|
|
33
|
+
const operations = await ctx.db
|
|
34
|
+
.query("recoveryReservations")
|
|
35
|
+
.withIndex("by_group_status", (q) =>
|
|
36
|
+
q.eq("groupKey", group.groupKey).eq("status", "reserved"),
|
|
37
|
+
)
|
|
38
|
+
.take(limit);
|
|
39
|
+
let amount = 0;
|
|
40
|
+
for (const operation of operations) {
|
|
41
|
+
for (const allocation of operation.allocations) {
|
|
42
|
+
const claim = await ctx.db.get("recoveryClaims", allocation.claimId);
|
|
43
|
+
if (
|
|
44
|
+
!claim ||
|
|
45
|
+
claim.accountId !== group.accountId ||
|
|
46
|
+
claim.scopeKey !== group.scopeKey ||
|
|
47
|
+
claim.currency !== group.currency
|
|
48
|
+
)
|
|
49
|
+
throw new Error("Recovery allocation identity does not match.");
|
|
50
|
+
const reservedAmount = integer(claim.reservedAmount - allocation.amount);
|
|
51
|
+
const collectedAmount = integer(
|
|
52
|
+
claim.collectedAmount + (collect ? allocation.amount : 0),
|
|
53
|
+
);
|
|
54
|
+
const outstanding = integer(
|
|
55
|
+
claim.amountDue - collectedAmount - reservedAmount,
|
|
56
|
+
);
|
|
57
|
+
await ctx.db.patch("recoveryClaims", claim._id, {
|
|
58
|
+
reservedAmount,
|
|
59
|
+
collectedAmount,
|
|
60
|
+
outstanding,
|
|
61
|
+
sign: sign(outstanding),
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
await ctx.db.patch("recoveryReservations", operation._id, {
|
|
65
|
+
status: terminal,
|
|
66
|
+
});
|
|
67
|
+
amount = integer(amount + operation.amount);
|
|
68
|
+
}
|
|
69
|
+
await adjustTotals(ctx, group, {
|
|
70
|
+
reservedAmount: -amount,
|
|
71
|
+
collectedAmount: collect ? amount : 0,
|
|
72
|
+
outstanding: collect ? 0 : amount,
|
|
73
|
+
});
|
|
74
|
+
const pendingOperations = group.pendingOperations - operations.length;
|
|
75
|
+
if (pendingOperations < 0)
|
|
76
|
+
throw new Error("Recovery operation count does not reconcile.");
|
|
77
|
+
await ctx.db.patch("recoveryGroups", group._id, {
|
|
78
|
+
pendingOperations,
|
|
79
|
+
status: pendingOperations ? progress : terminal,
|
|
80
|
+
});
|
|
81
|
+
if (!pendingOperations) {
|
|
82
|
+
const ledger = await balance(ctx, group);
|
|
83
|
+
if (ledger.activeGroupKey === group.groupKey)
|
|
84
|
+
await ctx.db.patch("recoveryBalances", ledger._id, {
|
|
85
|
+
activeGroupKey: undefined,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
return {
|
|
89
|
+
group: (await ctx.db.get("recoveryGroups", group._id))!,
|
|
90
|
+
isDone: pendingOperations === 0,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import type { MutationCtx, QueryCtx } from "../_generated/server.js";
|
|
2
|
+
import type { Doc } from "../_generated/dataModel.js";
|
|
3
|
+
export type Scope = { accountId: string; scopeKey: string; currency: string };
|
|
4
|
+
export function integer(value: number) {
|
|
5
|
+
if (!Number.isSafeInteger(value))
|
|
6
|
+
throw new Error("Recovery amounts must be safe integers.");
|
|
7
|
+
return value;
|
|
8
|
+
}
|
|
9
|
+
export function validateScope(scope: Scope) {
|
|
10
|
+
if (!scope.accountId || !scope.scopeKey || !/^[a-z]{3}$/.test(scope.currency))
|
|
11
|
+
throw new Error("Recovery account, scope and currency are required.");
|
|
12
|
+
}
|
|
13
|
+
export const sign = (value: number) =>
|
|
14
|
+
value > 0
|
|
15
|
+
? ("positive" as const)
|
|
16
|
+
: value < 0
|
|
17
|
+
? ("negative" as const)
|
|
18
|
+
: ("zero" as const);
|
|
19
|
+
export const scopeOnly = (scope: Scope): Scope => ({
|
|
20
|
+
accountId: scope.accountId,
|
|
21
|
+
scopeKey: scope.scopeKey,
|
|
22
|
+
currency: scope.currency,
|
|
23
|
+
});
|
|
24
|
+
export const zeroTotals = {
|
|
25
|
+
amountDue: 0,
|
|
26
|
+
collectedAmount: 0,
|
|
27
|
+
reservedAmount: 0,
|
|
28
|
+
outstanding: 0,
|
|
29
|
+
};
|
|
30
|
+
export function readBalance(ctx: QueryCtx | MutationCtx, scope: Scope) {
|
|
31
|
+
return ctx.db
|
|
32
|
+
.query("recoveryBalances")
|
|
33
|
+
.withIndex("by_scope", (q) =>
|
|
34
|
+
q
|
|
35
|
+
.eq("accountId", scope.accountId)
|
|
36
|
+
.eq("scopeKey", scope.scopeKey)
|
|
37
|
+
.eq("currency", scope.currency),
|
|
38
|
+
)
|
|
39
|
+
.unique();
|
|
40
|
+
}
|
|
41
|
+
export async function balance(ctx: MutationCtx, scope: Scope) {
|
|
42
|
+
const found = await readBalance(ctx, scope);
|
|
43
|
+
if (found) return found;
|
|
44
|
+
const id = await ctx.db.insert("recoveryBalances", {
|
|
45
|
+
...scopeOnly(scope),
|
|
46
|
+
...zeroTotals,
|
|
47
|
+
});
|
|
48
|
+
return (await ctx.db.get("recoveryBalances", id))!;
|
|
49
|
+
}
|
|
50
|
+
export async function adjustTotals(
|
|
51
|
+
ctx: MutationCtx,
|
|
52
|
+
scope: Scope,
|
|
53
|
+
delta: Partial<typeof zeroTotals>,
|
|
54
|
+
) {
|
|
55
|
+
const stored = await balance(ctx, scope);
|
|
56
|
+
const next = { ...zeroTotals };
|
|
57
|
+
for (const key of Object.keys(zeroTotals) as (keyof typeof zeroTotals)[])
|
|
58
|
+
next[key] = integer(stored[key] + (delta[key] ?? 0));
|
|
59
|
+
if (
|
|
60
|
+
next.outstanding !==
|
|
61
|
+
integer(next.amountDue - next.collectedAmount - next.reservedAmount)
|
|
62
|
+
)
|
|
63
|
+
throw new Error("Recovery totals do not reconcile.");
|
|
64
|
+
await ctx.db.patch("recoveryBalances", stored._id, next);
|
|
65
|
+
}
|
|
66
|
+
export function groupTarget(group: Doc<"recoveryGroups">, outstanding: number) {
|
|
67
|
+
return Math.max(
|
|
68
|
+
-group.maxCredit,
|
|
69
|
+
Math.min(group.maxDebit, integer(outstanding + group.amount)),
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
export async function getGroup(ctx: MutationCtx, key: string) {
|
|
73
|
+
const group = await ctx.db
|
|
74
|
+
.query("recoveryGroups")
|
|
75
|
+
.withIndex("by_key", (q) => q.eq("groupKey", key))
|
|
76
|
+
.unique();
|
|
77
|
+
if (!group) throw new Error("Recovery group is missing.");
|
|
78
|
+
return group;
|
|
79
|
+
}
|
|
80
|
+
export function batchLimit(limit: number, max = 100) {
|
|
81
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > max)
|
|
82
|
+
throw new Error(`Recovery batch limit must be between 1 and ${max}.`);
|
|
83
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { v } from "convex/values";
|
|
2
|
+
import { mutation } from "../_generated/server.js";
|
|
3
|
+
import { recordClaim, rollbackClaim } from "./claims.js";
|
|
4
|
+
import { reserveGroup, sealGroup } from "./reservations.js";
|
|
5
|
+
import { finishGroup } from "./completion.js";
|
|
6
|
+
import {
|
|
7
|
+
observationFields,
|
|
8
|
+
claimDoc,
|
|
9
|
+
groupDoc,
|
|
10
|
+
reservationDoc,
|
|
11
|
+
reserveArgs,
|
|
12
|
+
rollbackArgs,
|
|
13
|
+
finishArgs,
|
|
14
|
+
} from "./schema.js";
|
|
15
|
+
export const record = mutation({
|
|
16
|
+
args: observationFields,
|
|
17
|
+
returns: claimDoc,
|
|
18
|
+
handler: async (ctx, args) => {
|
|
19
|
+
if (args.claimKey.startsWith("rollback:"))
|
|
20
|
+
throw new Error("Rollback claim keys are reserved.");
|
|
21
|
+
return recordClaim(ctx, args);
|
|
22
|
+
},
|
|
23
|
+
});
|
|
24
|
+
export const rollback = mutation({
|
|
25
|
+
args: rollbackArgs,
|
|
26
|
+
returns: claimDoc,
|
|
27
|
+
handler: rollbackClaim,
|
|
28
|
+
});
|
|
29
|
+
export const reserve = mutation({
|
|
30
|
+
args: reserveArgs,
|
|
31
|
+
returns: v.object({ reservation: reservationDoc, group: groupDoc }),
|
|
32
|
+
handler: reserveGroup,
|
|
33
|
+
});
|
|
34
|
+
export const seal = mutation({
|
|
35
|
+
args: { groupKey: v.string() },
|
|
36
|
+
returns: groupDoc,
|
|
37
|
+
handler: sealGroup,
|
|
38
|
+
});
|
|
39
|
+
export const finish = mutation({
|
|
40
|
+
args: finishArgs,
|
|
41
|
+
returns: v.object({ group: groupDoc, isDone: v.boolean() }),
|
|
42
|
+
handler: finishGroup,
|
|
43
|
+
});
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { paginationOptsValidator } from "convex/server";
|
|
2
|
+
import { v } from "convex/values";
|
|
3
|
+
import { query } from "../_generated/server.js";
|
|
4
|
+
import {
|
|
5
|
+
balanceFields,
|
|
6
|
+
claimDoc,
|
|
7
|
+
groupDoc,
|
|
8
|
+
scopeFields,
|
|
9
|
+
reservationDoc,
|
|
10
|
+
} from "./schema.js";
|
|
11
|
+
import {
|
|
12
|
+
readBalance,
|
|
13
|
+
scopeOnly,
|
|
14
|
+
validateScope,
|
|
15
|
+
batchLimit,
|
|
16
|
+
} from "./helpers.js";
|
|
17
|
+
export const balance = query({
|
|
18
|
+
args: scopeFields,
|
|
19
|
+
returns: v.object(balanceFields),
|
|
20
|
+
handler: async (ctx, args) => {
|
|
21
|
+
validateScope(args);
|
|
22
|
+
const stored = await readBalance(ctx, args);
|
|
23
|
+
return {
|
|
24
|
+
...scopeOnly(args),
|
|
25
|
+
amountDue: stored?.amountDue ?? 0,
|
|
26
|
+
collectedAmount: stored?.collectedAmount ?? 0,
|
|
27
|
+
reservedAmount: stored?.reservedAmount ?? 0,
|
|
28
|
+
outstanding: stored?.outstanding ?? 0,
|
|
29
|
+
...(stored?.activeGroupKey
|
|
30
|
+
? { activeGroupKey: stored.activeGroupKey }
|
|
31
|
+
: {}),
|
|
32
|
+
};
|
|
33
|
+
},
|
|
34
|
+
});
|
|
35
|
+
export const listClaims = query({
|
|
36
|
+
args: { ...scopeFields, paginationOpts: paginationOptsValidator },
|
|
37
|
+
returns: v.object({
|
|
38
|
+
page: v.array(claimDoc),
|
|
39
|
+
continueCursor: v.string(),
|
|
40
|
+
isDone: v.boolean(),
|
|
41
|
+
}),
|
|
42
|
+
handler: async (ctx, args) => {
|
|
43
|
+
validateScope(args);
|
|
44
|
+
batchLimit(args.paginationOpts.numItems);
|
|
45
|
+
const result = await ctx.db
|
|
46
|
+
.query("recoveryClaims")
|
|
47
|
+
.withIndex("by_scope", (q) =>
|
|
48
|
+
q
|
|
49
|
+
.eq("accountId", args.accountId)
|
|
50
|
+
.eq("scopeKey", args.scopeKey)
|
|
51
|
+
.eq("currency", args.currency),
|
|
52
|
+
)
|
|
53
|
+
.order("desc")
|
|
54
|
+
.paginate(args.paginationOpts);
|
|
55
|
+
return {
|
|
56
|
+
page: result.page,
|
|
57
|
+
continueCursor: result.continueCursor,
|
|
58
|
+
isDone: result.isDone,
|
|
59
|
+
};
|
|
60
|
+
},
|
|
61
|
+
});
|
|
62
|
+
export const group = query({
|
|
63
|
+
args: { groupKey: v.string() },
|
|
64
|
+
returns: v.union(groupDoc, v.null()),
|
|
65
|
+
handler: (ctx, args) =>
|
|
66
|
+
ctx.db
|
|
67
|
+
.query("recoveryGroups")
|
|
68
|
+
.withIndex("by_key", (q) => q.eq("groupKey", args.groupKey))
|
|
69
|
+
.unique(),
|
|
70
|
+
});
|
|
71
|
+
export const reservation = query({
|
|
72
|
+
args: { operationKey: v.string() },
|
|
73
|
+
returns: v.union(reservationDoc, v.null()),
|
|
74
|
+
handler: (ctx, args) =>
|
|
75
|
+
ctx.db
|
|
76
|
+
.query("recoveryReservations")
|
|
77
|
+
.withIndex("by_key", (q) => q.eq("operationKey", args.operationKey))
|
|
78
|
+
.unique(),
|
|
79
|
+
});
|
|
80
|
+
export const claim = query({
|
|
81
|
+
args: { claimKey: v.string() },
|
|
82
|
+
returns: v.union(claimDoc, v.null()),
|
|
83
|
+
handler: (ctx, args) =>
|
|
84
|
+
ctx.db
|
|
85
|
+
.query("recoveryClaims")
|
|
86
|
+
.withIndex("by_key", (q) => q.eq("claimKey", args.claimKey))
|
|
87
|
+
.unique(),
|
|
88
|
+
});
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { v } from "convex/values";
|
|
2
|
+
import type { MutationCtx } from "../_generated/server.js";
|
|
3
|
+
import { reserveArgs } from "./schema.js";
|
|
4
|
+
import {
|
|
5
|
+
balance,
|
|
6
|
+
batchLimit,
|
|
7
|
+
getGroup,
|
|
8
|
+
groupTarget,
|
|
9
|
+
integer,
|
|
10
|
+
sign,
|
|
11
|
+
validateScope,
|
|
12
|
+
} from "./helpers.js";
|
|
13
|
+
|
|
14
|
+
import type { Infer } from "convex/values";
|
|
15
|
+
const _reserveArgs = v.object(reserveArgs);
|
|
16
|
+
export async function reserveGroup(
|
|
17
|
+
ctx: MutationCtx,
|
|
18
|
+
args: Infer<typeof _reserveArgs>,
|
|
19
|
+
) {
|
|
20
|
+
validateScope(args);
|
|
21
|
+
const limit = args.limit ?? 100;
|
|
22
|
+
batchLimit(limit);
|
|
23
|
+
[args.maxDebit, args.maxCredit].forEach(integer);
|
|
24
|
+
if (
|
|
25
|
+
!args.groupKey.trim() ||
|
|
26
|
+
!args.operationKey.trim() ||
|
|
27
|
+
!args.sourceId ||
|
|
28
|
+
args.maxDebit < 0 ||
|
|
29
|
+
args.maxCredit < 0
|
|
30
|
+
)
|
|
31
|
+
throw new Error("Invalid recovery operation identity or limits.");
|
|
32
|
+
if (args.sourceGross !== undefined && integer(args.sourceGross) <= 0)
|
|
33
|
+
throw new Error("Funding gross must be positive.");
|
|
34
|
+
if (
|
|
35
|
+
args.sourceProcessingFee !== undefined &&
|
|
36
|
+
integer(args.sourceProcessingFee) < 0
|
|
37
|
+
)
|
|
38
|
+
throw new Error("Funding processing fee must be nonnegative.");
|
|
39
|
+
if (
|
|
40
|
+
args.sourceKind === "settlement" &&
|
|
41
|
+
(!args.sourceGross || args.sourceProcessingFee === undefined)
|
|
42
|
+
)
|
|
43
|
+
throw new Error("Settlement recovery requires original funding gross.");
|
|
44
|
+
let group = await ctx.db
|
|
45
|
+
.query("recoveryGroups")
|
|
46
|
+
.withIndex("by_key", (q) => q.eq("groupKey", args.groupKey))
|
|
47
|
+
.unique();
|
|
48
|
+
const existing = await ctx.db
|
|
49
|
+
.query("recoveryReservations")
|
|
50
|
+
.withIndex("by_key", (q) => q.eq("operationKey", args.operationKey))
|
|
51
|
+
.unique();
|
|
52
|
+
if (group)
|
|
53
|
+
for (const key of [
|
|
54
|
+
"accountId",
|
|
55
|
+
"scopeKey",
|
|
56
|
+
"currency",
|
|
57
|
+
"sourceKind",
|
|
58
|
+
"sourceId",
|
|
59
|
+
"sourceGross",
|
|
60
|
+
"sourceProcessingFee",
|
|
61
|
+
"maxDebit",
|
|
62
|
+
"maxCredit",
|
|
63
|
+
] as const)
|
|
64
|
+
if (group[key] !== args[key])
|
|
65
|
+
throw new Error("Recovery group identity and limits cannot change.");
|
|
66
|
+
if (existing) {
|
|
67
|
+
if (
|
|
68
|
+
existing.groupKey !== args.groupKey ||
|
|
69
|
+
existing.limit !== limit ||
|
|
70
|
+
!group
|
|
71
|
+
)
|
|
72
|
+
throw new Error("Recovery operation identity cannot change.");
|
|
73
|
+
return { reservation: existing, group };
|
|
74
|
+
}
|
|
75
|
+
const ledger = await balance(ctx, args);
|
|
76
|
+
if (ledger.activeGroupKey && ledger.activeGroupKey !== args.groupKey)
|
|
77
|
+
throw new Error("Another recovery boundary is pending.");
|
|
78
|
+
if (group && group.status !== "open")
|
|
79
|
+
throw new Error(
|
|
80
|
+
"Recovery group is sealed; replay its existing operations.",
|
|
81
|
+
);
|
|
82
|
+
if (!group) {
|
|
83
|
+
const { operationKey: _operationKey, limit: _limit, ...input } = args;
|
|
84
|
+
const id = await ctx.db.insert("recoveryGroups", {
|
|
85
|
+
...input,
|
|
86
|
+
amount: 0,
|
|
87
|
+
pendingOperations: 0,
|
|
88
|
+
status: "open",
|
|
89
|
+
});
|
|
90
|
+
group = (await ctx.db.get("recoveryGroups", id))!;
|
|
91
|
+
}
|
|
92
|
+
const delta = integer(groupTarget(group, ledger.outstanding) - group.amount);
|
|
93
|
+
const direction = sign(delta);
|
|
94
|
+
let remaining = Math.abs(delta),
|
|
95
|
+
amount = 0;
|
|
96
|
+
const allocations: {
|
|
97
|
+
claimId: import("../_generated/dataModel.js").Id<"recoveryClaims">;
|
|
98
|
+
claimKey: string;
|
|
99
|
+
amount: number;
|
|
100
|
+
}[] = [];
|
|
101
|
+
if (direction !== "zero") {
|
|
102
|
+
const claims = await ctx.db
|
|
103
|
+
.query("recoveryClaims")
|
|
104
|
+
.withIndex("by_sign", (q) =>
|
|
105
|
+
q
|
|
106
|
+
.eq("accountId", args.accountId)
|
|
107
|
+
.eq("scopeKey", args.scopeKey)
|
|
108
|
+
.eq("currency", args.currency)
|
|
109
|
+
.eq("sign", direction),
|
|
110
|
+
)
|
|
111
|
+
.take(limit);
|
|
112
|
+
for (const claim of claims) {
|
|
113
|
+
if (!remaining) break;
|
|
114
|
+
const take =
|
|
115
|
+
Math.min(remaining, Math.abs(claim.outstanding)) * (delta < 0 ? -1 : 1);
|
|
116
|
+
const reservedAmount = integer(claim.reservedAmount + take),
|
|
117
|
+
outstanding = integer(claim.outstanding - take);
|
|
118
|
+
await ctx.db.patch("recoveryClaims", claim._id, {
|
|
119
|
+
reservedAmount,
|
|
120
|
+
outstanding,
|
|
121
|
+
sign: sign(outstanding),
|
|
122
|
+
});
|
|
123
|
+
allocations.push({
|
|
124
|
+
claimId: claim._id,
|
|
125
|
+
claimKey: claim.claimKey,
|
|
126
|
+
amount: take,
|
|
127
|
+
});
|
|
128
|
+
amount = integer(amount + take);
|
|
129
|
+
remaining -= Math.abs(take);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
const reservationId = await ctx.db.insert("recoveryReservations", {
|
|
133
|
+
operationKey: args.operationKey,
|
|
134
|
+
groupKey: args.groupKey,
|
|
135
|
+
amount,
|
|
136
|
+
limit,
|
|
137
|
+
allocations,
|
|
138
|
+
status: allocations.length ? "reserved" : "empty",
|
|
139
|
+
});
|
|
140
|
+
if (allocations.length) {
|
|
141
|
+
await ctx.db.patch("recoveryBalances", ledger._id, {
|
|
142
|
+
reservedAmount: integer(ledger.reservedAmount + amount),
|
|
143
|
+
outstanding: integer(ledger.outstanding - amount),
|
|
144
|
+
activeGroupKey: group.groupKey,
|
|
145
|
+
});
|
|
146
|
+
await ctx.db.patch("recoveryGroups", group._id, {
|
|
147
|
+
amount: integer(group.amount + amount),
|
|
148
|
+
pendingOperations: group.pendingOperations + 1,
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
return {
|
|
152
|
+
reservation: (await ctx.db.get("recoveryReservations", reservationId))!,
|
|
153
|
+
group: (await ctx.db.get("recoveryGroups", group._id))!,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
export async function sealGroup(ctx: MutationCtx, args: { groupKey: string }) {
|
|
157
|
+
const group = await getGroup(ctx, args.groupKey);
|
|
158
|
+
if (group.status !== "open") return group;
|
|
159
|
+
const ledger = await balance(ctx, group);
|
|
160
|
+
if (groupTarget(group, ledger.outstanding) !== group.amount)
|
|
161
|
+
throw new Error("Recovery needs another reservation batch before sealing.");
|
|
162
|
+
await ctx.db.patch("recoveryGroups", group._id, { status: "sealed" });
|
|
163
|
+
return (await ctx.db.get("recoveryGroups", group._id))!;
|
|
164
|
+
}
|