@fantastic.dev/better-stripe 0.11.0 → 0.13.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 +30 -0
- package/README.md +267 -2
- 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-invoices.d.ts +38 -0
- package/dist/client/costRecovery-invoices.d.ts.map +1 -0
- package/dist/client/costRecovery-invoices.js +105 -0
- package/dist/client/costRecovery-invoices.js.map +1 -0
- package/dist/client/costRecovery.d.ts +156 -0
- package/dist/client/costRecovery.d.ts.map +1 -0
- package/dist/client/costRecovery.js +117 -0
- package/dist/client/costRecovery.js.map +1 -0
- package/dist/client/index.d.ts +28 -66
- 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 +3 -2
- package/dist/client/products/prices.d.ts.map +1 -1
- package/dist/client/products/prices.js +0 -3
- package/dist/client/products/prices.js.map +1 -1
- package/dist/client/products/products.d.ts +3 -2
- package/dist/client/products/products.d.ts.map +1 -1
- package/dist/client/products/products.js +0 -3
- 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 +322 -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 +92 -0
- package/dist/component/costRecovery/claims.js.map +1 -0
- package/dist/component/costRecovery/completion.d.ts +39 -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 +68 -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 +210 -0
- package/dist/component/costRecovery/mutations.d.ts.map +1 -0
- package/dist/component/costRecovery/mutations.js +93 -0
- package/dist/component/costRecovery/mutations.js.map +1 -0
- package/dist/component/costRecovery/queries.d.ts +108 -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 +95 -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 +382 -0
- package/dist/component/costRecovery/schema.d.ts.map +1 -0
- package/dist/component/costRecovery/schema.js +115 -0
- package/dist/component/costRecovery/schema.js.map +1 -0
- package/dist/component/schema.d.ts +135 -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/Collections.d.ts.map +1 -1
- package/dist/react/workflows/Collections.js +2 -1
- package/dist/react/workflows/Collections.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/dist/react/workflows/Refunds.d.ts +14 -3
- package/dist/react/workflows/Refunds.d.ts.map +1 -1
- package/dist/react/workflows/Refunds.js +58 -16
- package/dist/react/workflows/Refunds.js.map +1 -1
- package/dist/react/workflows/index.d.ts +3 -1
- package/dist/react/workflows/index.d.ts.map +1 -1
- package/dist/react/workflows/index.js +2 -1
- package/dist/react/workflows/index.js.map +1 -1
- package/dist/react/workflows/refund-preview.d.ts +16 -0
- package/dist/react/workflows/refund-preview.d.ts.map +1 -0
- package/dist/react/workflows/refund-preview.js +38 -0
- package/dist/react/workflows/refund-preview.js.map +1 -0
- package/docs/react-workflows.md +167 -1
- 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-invoices.test.ts +401 -0
- package/src/client/costRecovery-invoices.transport.test.ts +107 -0
- package/src/client/costRecovery-invoices.ts +144 -0
- package/src/client/costRecovery.test.ts +76 -0
- package/src/client/costRecovery.ts +254 -0
- package/src/client/index.ts +76 -75
- package/src/client/products/prices.ts +13 -11
- package/src/client/products/products.ts +10 -10
- 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 +407 -0
- package/src/component/cost-recovery.test.ts +344 -0
- package/src/component/costRecovery/claims.ts +119 -0
- package/src/component/costRecovery/completion.ts +92 -0
- package/src/component/costRecovery/helpers.ts +83 -0
- package/src/component/costRecovery/mutations.ts +115 -0
- package/src/component/costRecovery/queries.ts +88 -0
- package/src/component/costRecovery/reservations.ts +164 -0
- package/src/component/costRecovery/schema.ts +135 -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/Collections.test.tsx +36 -0
- package/src/react/workflows/Collections.tsx +3 -1
- package/src/react/workflows/PlanPricingEditor.currency.test.tsx +164 -0
- package/src/react/workflows/PlanPricingEditor.tsx +26 -10
- package/src/react/workflows/Refunds.currency.test.tsx +290 -0
- package/src/react/workflows/Refunds.tsx +115 -25
- package/src/react/workflows/index.ts +8 -1
- package/src/react/workflows/refund-preview.test.ts +65 -0
- package/src/react/workflows/refund-preview.ts +56 -0
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { getGroup, integer } from "./helpers.js";
|
|
2
|
+
import { v } from "convex/values";
|
|
3
|
+
import { mutation } from "../_generated/server.js";
|
|
4
|
+
import { recordClaim, rollbackClaim } from "./claims.js";
|
|
5
|
+
import { reserveGroup, sealGroup } from "./reservations.js";
|
|
6
|
+
import { finishGroup } from "./completion.js";
|
|
7
|
+
import {
|
|
8
|
+
observationFields,
|
|
9
|
+
claimDoc,
|
|
10
|
+
groupDoc,
|
|
11
|
+
reservationDoc,
|
|
12
|
+
reserveArgs,
|
|
13
|
+
rollbackArgs,
|
|
14
|
+
finishArgs,
|
|
15
|
+
} from "./schema.js";
|
|
16
|
+
export const record = mutation({
|
|
17
|
+
args: observationFields,
|
|
18
|
+
returns: claimDoc,
|
|
19
|
+
handler: async (ctx, args) => {
|
|
20
|
+
if (args.claimKey.startsWith("rollback:"))
|
|
21
|
+
throw new Error("Rollback claim keys are reserved.");
|
|
22
|
+
return recordClaim(ctx, args);
|
|
23
|
+
},
|
|
24
|
+
});
|
|
25
|
+
export const rollback = mutation({
|
|
26
|
+
args: rollbackArgs,
|
|
27
|
+
returns: claimDoc,
|
|
28
|
+
handler: rollbackClaim,
|
|
29
|
+
});
|
|
30
|
+
export const reserve = mutation({
|
|
31
|
+
args: reserveArgs,
|
|
32
|
+
returns: v.object({ reservation: reservationDoc, group: groupDoc }),
|
|
33
|
+
handler: reserveGroup,
|
|
34
|
+
});
|
|
35
|
+
export const seal = mutation({
|
|
36
|
+
args: { groupKey: v.string() },
|
|
37
|
+
returns: groupDoc,
|
|
38
|
+
handler: sealGroup,
|
|
39
|
+
});
|
|
40
|
+
export const finish = mutation({
|
|
41
|
+
args: finishArgs,
|
|
42
|
+
returns: v.object({ group: groupDoc, isDone: v.boolean() }),
|
|
43
|
+
handler: finishGroup,
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
export const bindInvoiceItem = mutation({
|
|
47
|
+
args: { groupKey: v.string(), invoiceItemId: v.string() },
|
|
48
|
+
returns: groupDoc,
|
|
49
|
+
handler: async (ctx, args) => {
|
|
50
|
+
const group = await ctx.db
|
|
51
|
+
.query("recoveryGroups")
|
|
52
|
+
.withIndex("by_key", (q) => q.eq("groupKey", args.groupKey))
|
|
53
|
+
.unique();
|
|
54
|
+
if (
|
|
55
|
+
!group ||
|
|
56
|
+
group.sourceKind !== "invoice" ||
|
|
57
|
+
group.status === "open" ||
|
|
58
|
+
group.status === "released" ||
|
|
59
|
+
group.status === "releasing"
|
|
60
|
+
)
|
|
61
|
+
throw new Error("Invoice recovery group must be sealed and active.");
|
|
62
|
+
if (
|
|
63
|
+
!args.invoiceItemId ||
|
|
64
|
+
(group.invoiceItemId && group.invoiceItemId !== args.invoiceItemId)
|
|
65
|
+
)
|
|
66
|
+
throw new Error("Recovery invoice item identity cannot change.");
|
|
67
|
+
await ctx.db.patch("recoveryGroups", group._id, {
|
|
68
|
+
invoiceItemId: args.invoiceItemId,
|
|
69
|
+
});
|
|
70
|
+
return (await ctx.db.get("recoveryGroups", group._id))!;
|
|
71
|
+
},
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
export const bindInvoiceFunding = mutation({
|
|
75
|
+
args: {
|
|
76
|
+
groupKey: v.string(),
|
|
77
|
+
sourceChargeId: v.string(),
|
|
78
|
+
sourceGross: v.number(),
|
|
79
|
+
sourceProcessingFee: v.number(),
|
|
80
|
+
},
|
|
81
|
+
returns: groupDoc,
|
|
82
|
+
handler: async (ctx, args) => {
|
|
83
|
+
const group = await getGroup(ctx, args.groupKey);
|
|
84
|
+
if (group.sourceKind !== "invoice" || group.status !== "collected")
|
|
85
|
+
throw new Error(
|
|
86
|
+
"Invoice funding requires a collected invoice recovery group.",
|
|
87
|
+
);
|
|
88
|
+
integer(args.sourceGross);
|
|
89
|
+
integer(args.sourceProcessingFee);
|
|
90
|
+
if (
|
|
91
|
+
!args.sourceChargeId ||
|
|
92
|
+
args.sourceGross <= 0 ||
|
|
93
|
+
args.sourceProcessingFee < 0
|
|
94
|
+
)
|
|
95
|
+
throw new Error("Invalid invoice funding economics.");
|
|
96
|
+
const funding = {
|
|
97
|
+
sourceChargeId: args.sourceChargeId,
|
|
98
|
+
sourceGross: args.sourceGross,
|
|
99
|
+
sourceProcessingFee: args.sourceProcessingFee,
|
|
100
|
+
};
|
|
101
|
+
if (
|
|
102
|
+
group.invoiceFunding &&
|
|
103
|
+
Object.keys(funding).some(
|
|
104
|
+
(key) =>
|
|
105
|
+
group.invoiceFunding![key as keyof typeof funding] !==
|
|
106
|
+
funding[key as keyof typeof funding],
|
|
107
|
+
)
|
|
108
|
+
)
|
|
109
|
+
throw new Error("Original invoice funding economics cannot change.");
|
|
110
|
+
await ctx.db.patch("recoveryGroups", group._id, {
|
|
111
|
+
invoiceFunding: funding,
|
|
112
|
+
});
|
|
113
|
+
return (await ctx.db.get("recoveryGroups", group._id))!;
|
|
114
|
+
},
|
|
115
|
+
});
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { defineTable } from "convex/server";
|
|
2
|
+
import { v } from "convex/values";
|
|
3
|
+
export const scopeFields = {
|
|
4
|
+
accountId: v.string(),
|
|
5
|
+
scopeKey: v.string(),
|
|
6
|
+
currency: v.string(),
|
|
7
|
+
};
|
|
8
|
+
export const totalsFields = {
|
|
9
|
+
amountDue: v.number(),
|
|
10
|
+
collectedAmount: v.number(),
|
|
11
|
+
reservedAmount: v.number(),
|
|
12
|
+
outstanding: v.number(),
|
|
13
|
+
};
|
|
14
|
+
export const signValidator = v.union(
|
|
15
|
+
v.literal("positive"),
|
|
16
|
+
v.literal("negative"),
|
|
17
|
+
v.literal("zero"),
|
|
18
|
+
);
|
|
19
|
+
export const observationFields = {
|
|
20
|
+
...scopeFields,
|
|
21
|
+
claimKey: v.string(),
|
|
22
|
+
sourceChargeId: v.string(),
|
|
23
|
+
sourceGross: v.number(),
|
|
24
|
+
sourceProcessingFee: v.number(),
|
|
25
|
+
amountDue: v.number(),
|
|
26
|
+
revision: v.number(),
|
|
27
|
+
};
|
|
28
|
+
export const claimFields = {
|
|
29
|
+
...observationFields,
|
|
30
|
+
collectedAmount: v.number(),
|
|
31
|
+
reservedAmount: v.number(),
|
|
32
|
+
outstanding: v.number(),
|
|
33
|
+
sign: signValidator,
|
|
34
|
+
};
|
|
35
|
+
export const claimDoc = v.object({
|
|
36
|
+
...claimFields,
|
|
37
|
+
_id: v.id("recoveryClaims"),
|
|
38
|
+
_creationTime: v.number(),
|
|
39
|
+
});
|
|
40
|
+
export const balanceFields = {
|
|
41
|
+
...scopeFields,
|
|
42
|
+
...totalsFields,
|
|
43
|
+
activeGroupKey: v.optional(v.string()),
|
|
44
|
+
};
|
|
45
|
+
export const groupInput = {
|
|
46
|
+
...scopeFields,
|
|
47
|
+
groupKey: v.string(),
|
|
48
|
+
sourceKind: v.union(v.literal("settlement"), v.literal("invoice")),
|
|
49
|
+
sourceId: v.string(),
|
|
50
|
+
sourceGross: v.optional(v.number()),
|
|
51
|
+
sourceProcessingFee: v.optional(v.number()),
|
|
52
|
+
maxDebit: v.number(),
|
|
53
|
+
maxCredit: v.number(),
|
|
54
|
+
};
|
|
55
|
+
export const groupFields = {
|
|
56
|
+
...groupInput,
|
|
57
|
+
amount: v.number(),
|
|
58
|
+
pendingOperations: v.number(),
|
|
59
|
+
invoiceItemId: v.optional(v.string()),
|
|
60
|
+
invoiceFunding: v.optional(
|
|
61
|
+
v.object({
|
|
62
|
+
sourceChargeId: v.string(),
|
|
63
|
+
sourceGross: v.number(),
|
|
64
|
+
sourceProcessingFee: v.number(),
|
|
65
|
+
}),
|
|
66
|
+
),
|
|
67
|
+
status: v.union(
|
|
68
|
+
v.literal("open"),
|
|
69
|
+
v.literal("sealed"),
|
|
70
|
+
v.literal("collecting"),
|
|
71
|
+
v.literal("releasing"),
|
|
72
|
+
v.literal("collected"),
|
|
73
|
+
v.literal("released"),
|
|
74
|
+
),
|
|
75
|
+
};
|
|
76
|
+
export const groupDoc = v.object({
|
|
77
|
+
...groupFields,
|
|
78
|
+
_id: v.id("recoveryGroups"),
|
|
79
|
+
_creationTime: v.number(),
|
|
80
|
+
});
|
|
81
|
+
export const reservationFields = {
|
|
82
|
+
operationKey: v.string(),
|
|
83
|
+
groupKey: v.string(),
|
|
84
|
+
amount: v.number(),
|
|
85
|
+
limit: v.number(),
|
|
86
|
+
allocations: v.array(
|
|
87
|
+
v.object({
|
|
88
|
+
claimId: v.id("recoveryClaims"),
|
|
89
|
+
claimKey: v.string(),
|
|
90
|
+
amount: v.number(),
|
|
91
|
+
}),
|
|
92
|
+
),
|
|
93
|
+
status: v.union(
|
|
94
|
+
v.literal("reserved"),
|
|
95
|
+
v.literal("collected"),
|
|
96
|
+
v.literal("released"),
|
|
97
|
+
v.literal("empty"),
|
|
98
|
+
),
|
|
99
|
+
};
|
|
100
|
+
export const reservationDoc = v.object({
|
|
101
|
+
...reservationFields,
|
|
102
|
+
_id: v.id("recoveryReservations"),
|
|
103
|
+
_creationTime: v.number(),
|
|
104
|
+
});
|
|
105
|
+
export const claimsTable = defineTable(claimFields)
|
|
106
|
+
.index("by_key", ["claimKey"])
|
|
107
|
+
.index("by_scope", ["accountId", "scopeKey", "currency"])
|
|
108
|
+
.index("by_sign", ["accountId", "scopeKey", "currency", "sign"]);
|
|
109
|
+
export const balancesTable = defineTable(balanceFields).index("by_scope", [
|
|
110
|
+
"accountId",
|
|
111
|
+
"scopeKey",
|
|
112
|
+
"currency",
|
|
113
|
+
]);
|
|
114
|
+
export const groupsTable = defineTable(groupFields).index("by_key", [
|
|
115
|
+
"groupKey",
|
|
116
|
+
]);
|
|
117
|
+
export const reservationsTable = defineTable(reservationFields)
|
|
118
|
+
.index("by_key", ["operationKey"])
|
|
119
|
+
.index("by_group_status", ["groupKey", "status"]);
|
|
120
|
+
|
|
121
|
+
export const reserveArgs = {
|
|
122
|
+
...groupInput,
|
|
123
|
+
operationKey: v.string(),
|
|
124
|
+
limit: v.optional(v.number()),
|
|
125
|
+
};
|
|
126
|
+
export const finishArgs = {
|
|
127
|
+
groupKey: v.string(),
|
|
128
|
+
outcome: v.union(v.literal("collect"), v.literal("release")),
|
|
129
|
+
limit: v.optional(v.number()),
|
|
130
|
+
};
|
|
131
|
+
export const rollbackArgs = {
|
|
132
|
+
groupKey: v.string(),
|
|
133
|
+
returnedPrincipal: v.number(),
|
|
134
|
+
revision: v.number(),
|
|
135
|
+
};
|
package/src/component/schema.ts
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
import {
|
|
2
|
+
claimsTable,
|
|
3
|
+
balancesTable,
|
|
4
|
+
groupsTable,
|
|
5
|
+
reservationsTable,
|
|
6
|
+
} from "./costRecovery/schema";
|
|
1
7
|
import {
|
|
2
8
|
allocationsTable,
|
|
3
9
|
commissionTermsTable,
|
|
@@ -24,6 +30,10 @@ import { pricesTable, productsTable } from "./products/schema";
|
|
|
24
30
|
import { webhookEventsTable } from "./webhooks/schema";
|
|
25
31
|
|
|
26
32
|
export default defineSchema({
|
|
33
|
+
recoveryClaims: claimsTable,
|
|
34
|
+
recoveryBalances: balancesTable,
|
|
35
|
+
recoveryGroups: groupsTable,
|
|
36
|
+
recoveryReservations: reservationsTable,
|
|
27
37
|
settlementAllocations: allocationsTable,
|
|
28
38
|
commissionTerms: commissionTermsTable,
|
|
29
39
|
commissionDecisions: commissionDecisionsTable,
|
|
@@ -176,3 +176,18 @@ describe("PayoutSchedule", () => {
|
|
|
176
176
|
expect(BarrelExport).toBe(PayoutSchedule);
|
|
177
177
|
});
|
|
178
178
|
});
|
|
179
|
+
it.each(["ugx", "isk"])(
|
|
180
|
+
"preserves observed fractional %s balances",
|
|
181
|
+
(currency) => {
|
|
182
|
+
const html = renderToStaticMarkup(
|
|
183
|
+
createElement(PayoutSchedule, {
|
|
184
|
+
balance: { currency, available: 101, pending: -1 },
|
|
185
|
+
children: ({ availableLabel, pendingLabel }) =>
|
|
186
|
+
createElement("p", null, `${availableLabel}|${pendingLabel}`),
|
|
187
|
+
}),
|
|
188
|
+
);
|
|
189
|
+
expect(html).toBe(
|
|
190
|
+
`<p>${currency.toUpperCase()} 1.01|-${currency.toUpperCase()} 0.01</p>`,
|
|
191
|
+
);
|
|
192
|
+
},
|
|
193
|
+
);
|
|
@@ -30,6 +30,43 @@ const basePrice: PriceCardPrice = {
|
|
|
30
30
|
};
|
|
31
31
|
|
|
32
32
|
describe("PriceCard", () => {
|
|
33
|
+
it("accepts a translated custom price label and locale", () => {
|
|
34
|
+
const html = renderToStaticMarkup(
|
|
35
|
+
createElement(PriceCard, {
|
|
36
|
+
price: { ...basePrice, unitAmount: undefined },
|
|
37
|
+
customPriceLabel: "Preis auf Anfrage",
|
|
38
|
+
locale: "de-DE",
|
|
39
|
+
}),
|
|
40
|
+
);
|
|
41
|
+
expect(html).toContain("<p>Preis auf Anfrage</p>");
|
|
42
|
+
expect(
|
|
43
|
+
renderToStaticMarkup(
|
|
44
|
+
createElement(PriceCard, {
|
|
45
|
+
price: { ...basePrice, currency: "eur" },
|
|
46
|
+
locale: "de-DE",
|
|
47
|
+
}),
|
|
48
|
+
),
|
|
49
|
+
).toContain("20,00");
|
|
50
|
+
});
|
|
51
|
+
it("shows a zero-decimal charge without dividing by 100", () => {
|
|
52
|
+
const html = renderToStaticMarkup(
|
|
53
|
+
createElement(PriceCard, {
|
|
54
|
+
price: { ...basePrice, currency: "jpy", unitAmount: 500 },
|
|
55
|
+
}),
|
|
56
|
+
);
|
|
57
|
+
expect(html).toContain("<p>¥500/month</p>");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("does not present a custom price as free in render props", () => {
|
|
61
|
+
const html = renderToStaticMarkup(
|
|
62
|
+
createElement(PriceCard, {
|
|
63
|
+
price: { ...basePrice, unitAmount: null },
|
|
64
|
+
children: ({ formattedPrice }) =>
|
|
65
|
+
createElement("p", null, formattedPrice),
|
|
66
|
+
}),
|
|
67
|
+
);
|
|
68
|
+
expect(html).toBe("<p>Custom price</p>");
|
|
69
|
+
});
|
|
33
70
|
it("renders the formatted price inside the default card with role=option", () => {
|
|
34
71
|
const html = renderToStaticMarkup(
|
|
35
72
|
createElement(PriceCard, { price: basePrice }),
|
|
@@ -23,6 +23,8 @@ export type PriceCardProps = {
|
|
|
23
23
|
/** i18n overrides */
|
|
24
24
|
currentPlanLabel?: string;
|
|
25
25
|
selectLabel?: string;
|
|
26
|
+
customPriceLabel?: string;
|
|
27
|
+
locale?: string;
|
|
26
28
|
/** Render prop for full customization */
|
|
27
29
|
children?: (props: {
|
|
28
30
|
price: PriceCardPrice;
|
|
@@ -45,10 +47,16 @@ export function PriceCard({
|
|
|
45
47
|
onSelect,
|
|
46
48
|
currentPlanLabel = "Current plan",
|
|
47
49
|
selectLabel = "Select",
|
|
50
|
+
customPriceLabel = "Custom price",
|
|
51
|
+
locale = "en-US",
|
|
48
52
|
children,
|
|
49
53
|
className,
|
|
50
54
|
}: PriceCardProps) {
|
|
51
|
-
const formattedPrice = formatPriceWithInterval(
|
|
55
|
+
const formattedPrice = formatPriceWithInterval(
|
|
56
|
+
price,
|
|
57
|
+
locale,
|
|
58
|
+
customPriceLabel,
|
|
59
|
+
);
|
|
52
60
|
|
|
53
61
|
if (children) {
|
|
54
62
|
return (
|
package/src/react/index.test.ts
CHANGED
|
@@ -7,6 +7,11 @@ import { describe, expect, it } from "vitest";
|
|
|
7
7
|
import * as ReactExports from "./index.js";
|
|
8
8
|
|
|
9
9
|
describe("better-stripe/react barrel export", () => {
|
|
10
|
+
it("exports shared charge helpers with actual zero-decimal behavior", () => {
|
|
11
|
+
expect(ReactExports.parseChargeAmount("500", "jpy")).toBe(500);
|
|
12
|
+
expect(ReactExports.formatChargeAmount(500, "jpy")).toBe("¥500");
|
|
13
|
+
expect(ReactExports.getChargeCurrencyMetadata("isk")?.apiDecimals).toBe(2);
|
|
14
|
+
});
|
|
10
15
|
it("exports all documented hook factories", () => {
|
|
11
16
|
const hookFactories = [
|
|
12
17
|
"createUseAccount",
|
package/src/react/index.ts
CHANGED
|
@@ -283,3 +283,10 @@ export {
|
|
|
283
283
|
|
|
284
284
|
// Complete unstyled workflows
|
|
285
285
|
export * from "./workflows/index.js";
|
|
286
|
+
export {
|
|
287
|
+
chargeAmountToDecimal,
|
|
288
|
+
getChargeCurrencyMetadata,
|
|
289
|
+
parseChargeAmount,
|
|
290
|
+
formatChargeAmount,
|
|
291
|
+
type ChargeCurrencyMetadata,
|
|
292
|
+
} from "../client/charge-currency.js";
|
|
@@ -8,6 +8,10 @@ import {
|
|
|
8
8
|
} from "./price-helpers.js";
|
|
9
9
|
|
|
10
10
|
describe("formatPrice", () => {
|
|
11
|
+
it("keeps fractional compatibility units from observed balances and costs", () => {
|
|
12
|
+
expect(formatPrice(101, "ugx")).toContain("1.01");
|
|
13
|
+
expect(formatPrice(-1, "isk")).toBe("-ISK 0.01");
|
|
14
|
+
});
|
|
11
15
|
it("formats USD cents to dollars", () => {
|
|
12
16
|
expect(formatPrice(2000, "usd")).toBe("$20.00");
|
|
13
17
|
});
|
|
@@ -32,6 +36,15 @@ describe("formatPrice", () => {
|
|
|
32
36
|
});
|
|
33
37
|
|
|
34
38
|
describe("formatPriceWithInterval", () => {
|
|
39
|
+
it("supports translated custom prices without fabricating an interval", () => {
|
|
40
|
+
expect(
|
|
41
|
+
formatPriceWithInterval(
|
|
42
|
+
{ currency: "usd", type: "recurring", interval: "month", active: true },
|
|
43
|
+
"en-US",
|
|
44
|
+
"Contact us",
|
|
45
|
+
),
|
|
46
|
+
).toBe("Contact us");
|
|
47
|
+
});
|
|
35
48
|
it("shows amount only for one_time prices", () => {
|
|
36
49
|
const result = formatPriceWithInterval({
|
|
37
50
|
unitAmount: 2900,
|
|
@@ -80,7 +93,7 @@ describe("formatPriceWithInterval", () => {
|
|
|
80
93
|
expect(result).toBe("$50.00 every 3 months");
|
|
81
94
|
});
|
|
82
95
|
|
|
83
|
-
it("
|
|
96
|
+
it("keeps a custom/null amount distinct from a zero price", () => {
|
|
84
97
|
const result = formatPriceWithInterval({
|
|
85
98
|
unitAmount: null,
|
|
86
99
|
currency: "usd",
|
|
@@ -89,7 +102,7 @@ describe("formatPriceWithInterval", () => {
|
|
|
89
102
|
intervalCount: null,
|
|
90
103
|
active: true,
|
|
91
104
|
});
|
|
92
|
-
expect(result).toBe("
|
|
105
|
+
expect(result).toBe("Custom price");
|
|
93
106
|
});
|
|
94
107
|
});
|
|
95
108
|
|