@fantastic.dev/better-stripe 0.12.0 → 0.14.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 +24 -0
- package/README.md +93 -8
- package/dist/client/connect/disputes.d.ts +20 -0
- package/dist/client/connect/disputes.d.ts.map +1 -1
- package/dist/client/connect/disputes.js +55 -0
- package/dist/client/connect/disputes.js.map +1 -1
- package/dist/client/costRecovery-invoices.d.ts +44 -0
- package/dist/client/costRecovery-invoices.d.ts.map +1 -0
- package/dist/client/costRecovery-invoices.js +109 -0
- package/dist/client/costRecovery-invoices.js.map +1 -0
- package/dist/client/costRecovery.d.ts +59 -2
- package/dist/client/costRecovery.d.ts.map +1 -1
- package/dist/client/costRecovery.js +73 -2
- package/dist/client/costRecovery.js.map +1 -1
- package/dist/client/errors.d.ts +1 -1
- package/dist/client/errors.d.ts.map +1 -1
- package/dist/client/errors.js.map +1 -1
- package/dist/client/index.d.ts +5 -14
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +4 -8
- package/dist/client/index.js.map +1 -1
- package/dist/component/_generated/component.d.ts +137 -0
- package/dist/component/_generated/component.d.ts.map +1 -1
- package/dist/component/costRecovery/claims.d.ts +12 -0
- package/dist/component/costRecovery/claims.d.ts.map +1 -1
- package/dist/component/costRecovery/claims.js +15 -8
- package/dist/component/costRecovery/claims.js.map +1 -1
- package/dist/component/costRecovery/completion.d.ts +6 -0
- package/dist/component/costRecovery/completion.d.ts.map +1 -1
- package/dist/component/costRecovery/helpers.d.ts +6 -0
- package/dist/component/costRecovery/helpers.d.ts.map +1 -1
- package/dist/component/costRecovery/imports.d.ts +53 -0
- package/dist/component/costRecovery/imports.d.ts.map +1 -0
- package/dist/component/costRecovery/imports.js +67 -0
- package/dist/component/costRecovery/imports.js.map +1 -0
- package/dist/component/costRecovery/mutations.d.ts +119 -0
- package/dist/component/costRecovery/mutations.d.ts.map +1 -1
- package/dist/component/costRecovery/mutations.js +64 -1
- package/dist/component/costRecovery/mutations.js.map +1 -1
- package/dist/component/costRecovery/queries.d.ts +18 -0
- package/dist/component/costRecovery/queries.d.ts.map +1 -1
- package/dist/component/costRecovery/reservations.d.ts +12 -0
- package/dist/component/costRecovery/reservations.d.ts.map +1 -1
- package/dist/component/costRecovery/schema.d.ts +116 -4
- package/dist/component/costRecovery/schema.d.ts.map +1 -1
- package/dist/component/costRecovery/schema.js +19 -0
- package/dist/component/costRecovery/schema.js.map +1 -1
- package/dist/component/schema.d.ts +36 -2
- 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/EvidenceUpload.d.ts +18 -0
- package/dist/react/workflows/EvidenceUpload.d.ts.map +1 -0
- package/dist/react/workflows/EvidenceUpload.js +35 -0
- package/dist/react/workflows/EvidenceUpload.js.map +1 -0
- package/dist/react/workflows/Onboarding.d.ts +3 -3
- package/dist/react/workflows/Onboarding.d.ts.map +1 -1
- package/dist/react/workflows/Onboarding.js +8 -6
- package/dist/react/workflows/Onboarding.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 +5 -1
- package/dist/react/workflows/index.d.ts.map +1 -1
- package/dist/react/workflows/index.js +3 -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/dist/react/workflows/useWorkflowAction.d.ts +1 -1
- package/dist/react/workflows/useWorkflowAction.d.ts.map +1 -1
- package/dist/react/workflows/useWorkflowAction.js +6 -2
- package/dist/react/workflows/useWorkflowAction.js.map +1 -1
- package/docs/react-workflows.md +121 -4
- package/package.json +1 -1
- package/src/client/connect/dispute-upload.test.ts +208 -0
- package/src/client/connect/disputes.ts +103 -0
- package/src/client/costRecovery-import.test.ts +174 -0
- package/src/client/costRecovery-invoices.test.ts +470 -0
- package/src/client/costRecovery-invoices.transport.test.ts +107 -0
- package/src/client/costRecovery-invoices.ts +176 -0
- package/src/client/costRecovery.ts +123 -2
- package/src/client/errors.ts +1 -0
- package/src/client/index.ts +15 -14
- package/src/component/_generated/api.ts +2 -0
- package/src/component/_generated/component.ts +164 -0
- package/src/component/costRecovery/claims.ts +18 -8
- package/src/component/costRecovery/imports.ts +74 -0
- package/src/component/costRecovery/mutations.ts +80 -0
- package/src/component/costRecovery/schema.ts +21 -0
- package/src/react/workflows/Collections.test.tsx +36 -0
- package/src/react/workflows/Collections.tsx +3 -1
- package/src/react/workflows/EvidenceUpload.test.tsx +56 -0
- package/src/react/workflows/EvidenceUpload.tsx +71 -0
- package/src/react/workflows/Onboarding.test.tsx +73 -0
- package/src/react/workflows/Onboarding.tsx +8 -5
- package/src/react/workflows/Refunds.currency.test.tsx +290 -0
- package/src/react/workflows/Refunds.tsx +115 -25
- package/src/react/workflows/index.ts +11 -1
- package/src/react/workflows/refund-preview.test.ts +65 -0
- package/src/react/workflows/refund-preview.ts +56 -0
- package/src/react/workflows/useWorkflowAction.ts +6 -1
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { v, type Infer } from "convex/values";
|
|
2
|
+
import type { MutationCtx } from "../_generated/server.js";
|
|
3
|
+
import { importArgs } from "./schema.js";
|
|
4
|
+
import { recordClaim } from "./claims.js";
|
|
5
|
+
import { adjustTotals, balance, integer, sign } from "./helpers.js";
|
|
6
|
+
const _argsValidator = v.object(importArgs);
|
|
7
|
+
/** The host verifies historical collection; this operation performs no provider IO. */
|
|
8
|
+
export async function importOpeningClaim(
|
|
9
|
+
ctx: MutationCtx,
|
|
10
|
+
args: Infer<typeof _argsValidator>,
|
|
11
|
+
) {
|
|
12
|
+
integer(args.collectedAmount);
|
|
13
|
+
if (!args.operationKey.trim() || args.claimKey.startsWith("rollback:"))
|
|
14
|
+
throw new Error("Invalid recovery import identity.");
|
|
15
|
+
const imported = await ctx.db
|
|
16
|
+
.query("recoveryClaims")
|
|
17
|
+
.withIndex("by_import", (q) =>
|
|
18
|
+
q.eq("openingCollection.operationKey", args.operationKey),
|
|
19
|
+
)
|
|
20
|
+
.unique();
|
|
21
|
+
if (imported && imported.claimKey !== args.claimKey)
|
|
22
|
+
throw new Error("Recovery import identity cannot change.");
|
|
23
|
+
const existing = await ctx.db
|
|
24
|
+
.query("recoveryClaims")
|
|
25
|
+
.withIndex("by_key", (q) => q.eq("claimKey", args.claimKey))
|
|
26
|
+
.unique();
|
|
27
|
+
const openingCollection = {
|
|
28
|
+
operationKey: args.operationKey,
|
|
29
|
+
amountDue: args.amountDue,
|
|
30
|
+
revision: args.revision,
|
|
31
|
+
collectedAmount: args.collectedAmount,
|
|
32
|
+
};
|
|
33
|
+
if (existing) {
|
|
34
|
+
for (const key of [
|
|
35
|
+
"accountId",
|
|
36
|
+
"scopeKey",
|
|
37
|
+
"currency",
|
|
38
|
+
"sourceChargeId",
|
|
39
|
+
"sourceGross",
|
|
40
|
+
"sourceProcessingFee",
|
|
41
|
+
] as const)
|
|
42
|
+
if (existing[key] !== args[key])
|
|
43
|
+
throw new Error("Recovery import source identity cannot change.");
|
|
44
|
+
if (!existing.openingCollection)
|
|
45
|
+
throw new Error("Cannot import over an existing live recovery claim.");
|
|
46
|
+
for (const key of [
|
|
47
|
+
"operationKey",
|
|
48
|
+
"amountDue",
|
|
49
|
+
"revision",
|
|
50
|
+
"collectedAmount",
|
|
51
|
+
] as const)
|
|
52
|
+
if (existing.openingCollection[key] !== openingCollection[key])
|
|
53
|
+
throw new Error("Recovery opening snapshot cannot change.");
|
|
54
|
+
return existing;
|
|
55
|
+
}
|
|
56
|
+
if ((await balance(ctx, args)).activeGroupKey)
|
|
57
|
+
throw new Error(
|
|
58
|
+
"Cannot import while another recovery boundary is pending.",
|
|
59
|
+
);
|
|
60
|
+
const { operationKey: _operationKey, collectedAmount, ...observation } = args;
|
|
61
|
+
const claim = await recordClaim(ctx, observation);
|
|
62
|
+
const outstanding = integer(claim.amountDue - collectedAmount);
|
|
63
|
+
await ctx.db.patch("recoveryClaims", claim._id, {
|
|
64
|
+
collectedAmount,
|
|
65
|
+
outstanding,
|
|
66
|
+
sign: sign(outstanding),
|
|
67
|
+
openingCollection,
|
|
68
|
+
});
|
|
69
|
+
await adjustTotals(ctx, args, {
|
|
70
|
+
collectedAmount,
|
|
71
|
+
outstanding: -collectedAmount,
|
|
72
|
+
});
|
|
73
|
+
return (await ctx.db.get("recoveryClaims", claim._id))!;
|
|
74
|
+
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { importOpeningClaim } from "./imports.js";
|
|
2
|
+
import { getGroup, integer } from "./helpers.js";
|
|
1
3
|
import { v } from "convex/values";
|
|
2
4
|
import { mutation } from "../_generated/server.js";
|
|
3
5
|
import { recordClaim, rollbackClaim } from "./claims.js";
|
|
@@ -5,6 +7,7 @@ import { reserveGroup, sealGroup } from "./reservations.js";
|
|
|
5
7
|
import { finishGroup } from "./completion.js";
|
|
6
8
|
import {
|
|
7
9
|
observationFields,
|
|
10
|
+
importArgs,
|
|
8
11
|
claimDoc,
|
|
9
12
|
groupDoc,
|
|
10
13
|
reservationDoc,
|
|
@@ -41,3 +44,80 @@ export const finish = mutation({
|
|
|
41
44
|
returns: v.object({ group: groupDoc, isDone: v.boolean() }),
|
|
42
45
|
handler: finishGroup,
|
|
43
46
|
});
|
|
47
|
+
|
|
48
|
+
export const bindInvoiceItem = mutation({
|
|
49
|
+
args: { groupKey: v.string(), invoiceItemId: v.string() },
|
|
50
|
+
returns: groupDoc,
|
|
51
|
+
handler: async (ctx, args) => {
|
|
52
|
+
const group = await ctx.db
|
|
53
|
+
.query("recoveryGroups")
|
|
54
|
+
.withIndex("by_key", (q) => q.eq("groupKey", args.groupKey))
|
|
55
|
+
.unique();
|
|
56
|
+
if (
|
|
57
|
+
!group ||
|
|
58
|
+
group.sourceKind !== "invoice" ||
|
|
59
|
+
group.status === "open" ||
|
|
60
|
+
group.status === "released" ||
|
|
61
|
+
group.status === "releasing"
|
|
62
|
+
)
|
|
63
|
+
throw new Error("Invoice recovery group must be sealed and active.");
|
|
64
|
+
if (
|
|
65
|
+
!args.invoiceItemId ||
|
|
66
|
+
(group.invoiceItemId && group.invoiceItemId !== args.invoiceItemId)
|
|
67
|
+
)
|
|
68
|
+
throw new Error("Recovery invoice item identity cannot change.");
|
|
69
|
+
await ctx.db.patch("recoveryGroups", group._id, {
|
|
70
|
+
invoiceItemId: args.invoiceItemId,
|
|
71
|
+
});
|
|
72
|
+
return (await ctx.db.get("recoveryGroups", group._id))!;
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
export const bindInvoiceFunding = mutation({
|
|
77
|
+
args: {
|
|
78
|
+
groupKey: v.string(),
|
|
79
|
+
sourceChargeId: v.string(),
|
|
80
|
+
sourceGross: v.number(),
|
|
81
|
+
sourceProcessingFee: v.number(),
|
|
82
|
+
},
|
|
83
|
+
returns: groupDoc,
|
|
84
|
+
handler: async (ctx, args) => {
|
|
85
|
+
const group = await getGroup(ctx, args.groupKey);
|
|
86
|
+
if (group.sourceKind !== "invoice" || group.status !== "collected")
|
|
87
|
+
throw new Error(
|
|
88
|
+
"Invoice funding requires a collected invoice recovery group.",
|
|
89
|
+
);
|
|
90
|
+
integer(args.sourceGross);
|
|
91
|
+
integer(args.sourceProcessingFee);
|
|
92
|
+
if (
|
|
93
|
+
!args.sourceChargeId ||
|
|
94
|
+
args.sourceGross <= 0 ||
|
|
95
|
+
args.sourceProcessingFee < 0
|
|
96
|
+
)
|
|
97
|
+
throw new Error("Invalid invoice funding economics.");
|
|
98
|
+
const funding = {
|
|
99
|
+
sourceChargeId: args.sourceChargeId,
|
|
100
|
+
sourceGross: args.sourceGross,
|
|
101
|
+
sourceProcessingFee: args.sourceProcessingFee,
|
|
102
|
+
};
|
|
103
|
+
if (
|
|
104
|
+
group.invoiceFunding &&
|
|
105
|
+
Object.keys(funding).some(
|
|
106
|
+
(key) =>
|
|
107
|
+
group.invoiceFunding![key as keyof typeof funding] !==
|
|
108
|
+
funding[key as keyof typeof funding],
|
|
109
|
+
)
|
|
110
|
+
)
|
|
111
|
+
throw new Error("Original invoice funding economics cannot change.");
|
|
112
|
+
await ctx.db.patch("recoveryGroups", group._id, {
|
|
113
|
+
invoiceFunding: funding,
|
|
114
|
+
});
|
|
115
|
+
return (await ctx.db.get("recoveryGroups", group._id))!;
|
|
116
|
+
},
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
export const importClaim = mutation({
|
|
120
|
+
args: importArgs,
|
|
121
|
+
returns: claimDoc,
|
|
122
|
+
handler: importOpeningClaim,
|
|
123
|
+
});
|
|
@@ -25,12 +25,24 @@ export const observationFields = {
|
|
|
25
25
|
amountDue: v.number(),
|
|
26
26
|
revision: v.number(),
|
|
27
27
|
};
|
|
28
|
+
export const openingCollection = v.object({
|
|
29
|
+
operationKey: v.string(),
|
|
30
|
+
amountDue: v.number(),
|
|
31
|
+
revision: v.number(),
|
|
32
|
+
collectedAmount: v.number(),
|
|
33
|
+
});
|
|
34
|
+
export const importArgs = {
|
|
35
|
+
...observationFields,
|
|
36
|
+
operationKey: v.string(),
|
|
37
|
+
collectedAmount: v.number(),
|
|
38
|
+
};
|
|
28
39
|
export const claimFields = {
|
|
29
40
|
...observationFields,
|
|
30
41
|
collectedAmount: v.number(),
|
|
31
42
|
reservedAmount: v.number(),
|
|
32
43
|
outstanding: v.number(),
|
|
33
44
|
sign: signValidator,
|
|
45
|
+
openingCollection: v.optional(openingCollection),
|
|
34
46
|
};
|
|
35
47
|
export const claimDoc = v.object({
|
|
36
48
|
...claimFields,
|
|
@@ -56,6 +68,14 @@ export const groupFields = {
|
|
|
56
68
|
...groupInput,
|
|
57
69
|
amount: v.number(),
|
|
58
70
|
pendingOperations: v.number(),
|
|
71
|
+
invoiceItemId: v.optional(v.string()),
|
|
72
|
+
invoiceFunding: v.optional(
|
|
73
|
+
v.object({
|
|
74
|
+
sourceChargeId: v.string(),
|
|
75
|
+
sourceGross: v.number(),
|
|
76
|
+
sourceProcessingFee: v.number(),
|
|
77
|
+
}),
|
|
78
|
+
),
|
|
59
79
|
status: v.union(
|
|
60
80
|
v.literal("open"),
|
|
61
81
|
v.literal("sealed"),
|
|
@@ -96,6 +116,7 @@ export const reservationDoc = v.object({
|
|
|
96
116
|
});
|
|
97
117
|
export const claimsTable = defineTable(claimFields)
|
|
98
118
|
.index("by_key", ["claimKey"])
|
|
119
|
+
.index("by_import", ["openingCollection.operationKey"])
|
|
99
120
|
.index("by_scope", ["accountId", "scopeKey", "currency"])
|
|
100
121
|
.index("by_sign", ["accountId", "scopeKey", "currency", "sign"]);
|
|
101
122
|
export const balancesTable = defineTable(balanceFields).index("by_scope", [
|
|
@@ -84,3 +84,39 @@ it("preserves store attribution in referred sales and refund statuses in history
|
|
|
84
84
|
expect(screen.getByText("failed")).toBeTruthy();
|
|
85
85
|
expect(screen.getByRole("columnheader", { name: "Created" })).toBeTruthy();
|
|
86
86
|
});
|
|
87
|
+
|
|
88
|
+
it("renders observed refund units and unavailable data without throwing", () => {
|
|
89
|
+
render(
|
|
90
|
+
<RefundHistory
|
|
91
|
+
rows={[
|
|
92
|
+
{
|
|
93
|
+
stripeRefundId: "re_jpy",
|
|
94
|
+
amount: 500,
|
|
95
|
+
currency: "jpy",
|
|
96
|
+
status: "succeeded",
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
stripeRefundId: "re_ugx",
|
|
100
|
+
amount: 101,
|
|
101
|
+
currency: "ugx",
|
|
102
|
+
status: "pending",
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
stripeRefundId: "re_unknown",
|
|
106
|
+
amount: 100,
|
|
107
|
+
currency: "zzz",
|
|
108
|
+
status: "failed",
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
stripeRefundId: "re_invalid",
|
|
112
|
+
amount: -1,
|
|
113
|
+
currency: "usd",
|
|
114
|
+
status: "canceled",
|
|
115
|
+
},
|
|
116
|
+
]}
|
|
117
|
+
/>,
|
|
118
|
+
);
|
|
119
|
+
expect(screen.getByText("¥500")).toBeTruthy();
|
|
120
|
+
expect(screen.getByText("UGX 1.01")).toBeTruthy();
|
|
121
|
+
expect(screen.getAllByText("Amount unavailable")).toHaveLength(2);
|
|
122
|
+
});
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import type { CollectionProps, WorkflowColumn } from "./Collection.js";
|
|
3
3
|
import { Collection } from "./Collection.js";
|
|
4
4
|
import { formatPrice } from "../lib/price-helpers.js";
|
|
5
|
+
import { formatRefundAmount } from "./refund-preview.js";
|
|
5
6
|
export type ResourceCollectionProps<Row> = Omit<
|
|
6
7
|
CollectionProps<Row>,
|
|
7
8
|
"columns" | "getKey"
|
|
@@ -111,7 +112,8 @@ export function RefundHistory(props: ResourceCollectionProps<RefundRow>) {
|
|
|
111
112
|
id: "amount",
|
|
112
113
|
label: "Amount",
|
|
113
114
|
align: "end",
|
|
114
|
-
cell: (row) =>
|
|
115
|
+
cell: (row) =>
|
|
116
|
+
formatRefundAmount(row.amount, row.currency) ?? "Amount unavailable",
|
|
115
117
|
},
|
|
116
118
|
{ id: "status", label: "Status", cell: (row) => row.status },
|
|
117
119
|
{
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import {
|
|
3
|
+
act,
|
|
4
|
+
cleanup,
|
|
5
|
+
fireEvent,
|
|
6
|
+
render,
|
|
7
|
+
screen,
|
|
8
|
+
} from "@testing-library/react";
|
|
9
|
+
import { afterEach, expect, it, vi } from "vitest";
|
|
10
|
+
import { EvidenceUpload } from "./EvidenceUpload.js";
|
|
11
|
+
afterEach(cleanup);
|
|
12
|
+
it("uploads the selected file once, retains it on failure and retries as a draft", async () => {
|
|
13
|
+
let reject!: (error: Error) => void;
|
|
14
|
+
const upload = vi.fn(
|
|
15
|
+
() =>
|
|
16
|
+
new Promise<unknown>((_, fail) => {
|
|
17
|
+
reject = fail;
|
|
18
|
+
}),
|
|
19
|
+
);
|
|
20
|
+
render(<EvidenceUpload scopeKey="seller:dp_1" onUpload={upload} />);
|
|
21
|
+
const file = new File(["%PDF-1.7"], "proof.pdf", { type: "application/pdf" });
|
|
22
|
+
fireEvent.change(screen.getByLabelText("Evidence file"), {
|
|
23
|
+
target: { files: [file] },
|
|
24
|
+
});
|
|
25
|
+
fireEvent.click(screen.getByRole("button", { name: "Attach to draft" }));
|
|
26
|
+
fireEvent.click(screen.getByRole("button", { name: "Uploading…" }));
|
|
27
|
+
expect(upload).toHaveBeenCalledExactlyOnceWith(file);
|
|
28
|
+
await act(async () => {
|
|
29
|
+
reject(new Error("Upload unavailable"));
|
|
30
|
+
});
|
|
31
|
+
expect(screen.getByRole("alert").textContent).toBe("Upload unavailable");
|
|
32
|
+
upload.mockResolvedValue({});
|
|
33
|
+
fireEvent.click(screen.getByRole("button", { name: "Attach to draft" }));
|
|
34
|
+
await act(async () => {});
|
|
35
|
+
expect(upload).toHaveBeenCalledTimes(2);
|
|
36
|
+
expect(screen.getByRole("status").textContent).toBe(
|
|
37
|
+
"File attached to draft. Submit evidence separately when ready.",
|
|
38
|
+
);
|
|
39
|
+
});
|
|
40
|
+
it("forgets another dispute's selected file", () => {
|
|
41
|
+
const upload = vi.fn();
|
|
42
|
+
const { rerender } = render(
|
|
43
|
+
<EvidenceUpload scopeKey="seller:dp_1" onUpload={upload} />,
|
|
44
|
+
);
|
|
45
|
+
fireEvent.change(screen.getByLabelText("Evidence file"), {
|
|
46
|
+
target: { files: [new File(["a"], "a.pdf")] },
|
|
47
|
+
});
|
|
48
|
+
rerender(<EvidenceUpload scopeKey="seller:dp_2" onUpload={upload} />);
|
|
49
|
+
expect(
|
|
50
|
+
(
|
|
51
|
+
screen.getByRole("button", {
|
|
52
|
+
name: "Attach to draft",
|
|
53
|
+
}) as HTMLButtonElement
|
|
54
|
+
).disabled,
|
|
55
|
+
).toBe(true);
|
|
56
|
+
});
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useId, useState, type ReactNode } from "react";
|
|
3
|
+
import { useWorkflowAction } from "./useWorkflowAction.js";
|
|
4
|
+
|
|
5
|
+
export type EvidenceUploadProps = {
|
|
6
|
+
/** Include the authenticated scope and selected dispute. */
|
|
7
|
+
scopeKey: string;
|
|
8
|
+
/** Host authorizes the dispute and uploads bytes through BetterStripe server-side. */
|
|
9
|
+
onUpload: (file: File) => Promise<unknown>;
|
|
10
|
+
children?: (state: {
|
|
11
|
+
file: File | undefined;
|
|
12
|
+
setFile: (file: File | undefined) => void;
|
|
13
|
+
upload: () => Promise<unknown>;
|
|
14
|
+
isPending: boolean;
|
|
15
|
+
error: string | null;
|
|
16
|
+
isAttached: boolean;
|
|
17
|
+
}) => ReactNode;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/** Uploading only attaches to the draft. EvidenceForm owns explicit submission. */
|
|
21
|
+
export function EvidenceUpload(props: EvidenceUploadProps) {
|
|
22
|
+
return <EvidenceUploadBody key={props.scopeKey} {...props} />;
|
|
23
|
+
}
|
|
24
|
+
function EvidenceUploadBody(props: EvidenceUploadProps) {
|
|
25
|
+
const id = useId();
|
|
26
|
+
const [file, setFile] = useState<File>();
|
|
27
|
+
const action = useWorkflowAction(async () => {
|
|
28
|
+
if (!file) throw new Error("Choose an evidence file first.");
|
|
29
|
+
await props.onUpload(file);
|
|
30
|
+
return true;
|
|
31
|
+
}, props.scopeKey);
|
|
32
|
+
const state = {
|
|
33
|
+
file,
|
|
34
|
+
setFile: (file: File | undefined) => {
|
|
35
|
+
setFile(file);
|
|
36
|
+
action.reset();
|
|
37
|
+
},
|
|
38
|
+
upload: action.run,
|
|
39
|
+
isPending: action.isPending,
|
|
40
|
+
error: action.error,
|
|
41
|
+
isAttached: action.result === true,
|
|
42
|
+
};
|
|
43
|
+
if (props.children) return <>{props.children(state)}</>;
|
|
44
|
+
return (
|
|
45
|
+
<section data-better-stripe="evidence-upload" aria-busy={state.isPending}>
|
|
46
|
+
<label htmlFor={id}>Evidence file</label>
|
|
47
|
+
<input
|
|
48
|
+
id={id}
|
|
49
|
+
type="file"
|
|
50
|
+
accept="application/pdf,image/jpeg,image/png"
|
|
51
|
+
disabled={state.isPending}
|
|
52
|
+
onChange={(event) => state.setFile(event.target.files?.[0])}
|
|
53
|
+
/>
|
|
54
|
+
<button
|
|
55
|
+
type="button"
|
|
56
|
+
disabled={!file || state.isPending}
|
|
57
|
+
onClick={() => {
|
|
58
|
+
void state.upload();
|
|
59
|
+
}}
|
|
60
|
+
>
|
|
61
|
+
{state.isPending ? "Uploading…" : "Attach to draft"}
|
|
62
|
+
</button>
|
|
63
|
+
{state.error && <p role="alert">{state.error}</p>}
|
|
64
|
+
{state.isAttached && (
|
|
65
|
+
<p role="status">
|
|
66
|
+
File attached to draft. Submit evidence separately when ready.
|
|
67
|
+
</p>
|
|
68
|
+
)}
|
|
69
|
+
</section>
|
|
70
|
+
);
|
|
71
|
+
}
|
|
@@ -73,3 +73,76 @@ it("offers the shared example countries in the native onboarding UI", async () =
|
|
|
73
73
|
);
|
|
74
74
|
expect(screen.getByRole("option", { name: "United Kingdom" })).toBeTruthy();
|
|
75
75
|
});
|
|
76
|
+
|
|
77
|
+
for (const flow of ["create", "continue"] as const) {
|
|
78
|
+
for (const transition of ["unmount", "scope", "current"] as const) {
|
|
79
|
+
it(`${flow} navigates only while current after ${transition}`, async () => {
|
|
80
|
+
let resolve!: (value: { url: string; onboardingUrl: string }) => void;
|
|
81
|
+
const response = new Promise<{ url: string; onboardingUrl: string }>(
|
|
82
|
+
(done) => {
|
|
83
|
+
resolve = done;
|
|
84
|
+
},
|
|
85
|
+
);
|
|
86
|
+
const navigate = vi.fn();
|
|
87
|
+
const { result, rerender, unmount } = renderHook(
|
|
88
|
+
({ scope }) =>
|
|
89
|
+
useOnboarding({
|
|
90
|
+
scopeKey: scope,
|
|
91
|
+
createAccount: () => response,
|
|
92
|
+
continueOnboarding: () => response,
|
|
93
|
+
onNavigate: navigate,
|
|
94
|
+
}),
|
|
95
|
+
{ initialProps: { scope: "seller-a" } },
|
|
96
|
+
);
|
|
97
|
+
let pending!: Promise<unknown>;
|
|
98
|
+
act(() => {
|
|
99
|
+
pending = result.current[flow]();
|
|
100
|
+
});
|
|
101
|
+
if (transition === "unmount") unmount();
|
|
102
|
+
if (transition === "scope") rerender({ scope: "seller-b" });
|
|
103
|
+
await act(async () => {
|
|
104
|
+
resolve({
|
|
105
|
+
url: "https://connect.stripe.com/setup",
|
|
106
|
+
onboardingUrl: "https://connect.stripe.com/setup",
|
|
107
|
+
});
|
|
108
|
+
await pending;
|
|
109
|
+
});
|
|
110
|
+
expect(navigate).toHaveBeenCalledTimes(transition === "current" ? 1 : 0);
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
for (const flow of ["create", "continue"] as const) {
|
|
116
|
+
it(`${flow} suppresses the default location assignment after unmount`, async () => {
|
|
117
|
+
let resolve!: (value: { url: string; onboardingUrl: string }) => void;
|
|
118
|
+
const response = new Promise<{ url: string; onboardingUrl: string }>(
|
|
119
|
+
(done) => {
|
|
120
|
+
resolve = done;
|
|
121
|
+
},
|
|
122
|
+
);
|
|
123
|
+
const assign = vi.fn();
|
|
124
|
+
const { result, unmount } = renderHook(() =>
|
|
125
|
+
useOnboarding({
|
|
126
|
+
scopeKey: "seller",
|
|
127
|
+
createAccount: () => response,
|
|
128
|
+
continueOnboarding: () => response,
|
|
129
|
+
}),
|
|
130
|
+
);
|
|
131
|
+
let pending!: Promise<unknown>;
|
|
132
|
+
act(() => {
|
|
133
|
+
pending = result.current[flow]();
|
|
134
|
+
});
|
|
135
|
+
unmount();
|
|
136
|
+
vi.stubGlobal("window", { location: { assign } });
|
|
137
|
+
try {
|
|
138
|
+
resolve({
|
|
139
|
+
url: "https://connect.stripe.com/setup",
|
|
140
|
+
onboardingUrl: "https://connect.stripe.com/setup",
|
|
141
|
+
});
|
|
142
|
+
await pending;
|
|
143
|
+
expect(assign).not.toHaveBeenCalled();
|
|
144
|
+
} finally {
|
|
145
|
+
vi.unstubAllGlobals();
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
}
|
|
@@ -51,10 +51,13 @@ export function useOnboarding(props: OnboardingActions) {
|
|
|
51
51
|
? result.url
|
|
52
52
|
: undefined;
|
|
53
53
|
if (!url) throw new Error("Stripe did not return an onboarding URL.");
|
|
54
|
-
|
|
55
|
-
return true;
|
|
54
|
+
return url;
|
|
56
55
|
},
|
|
57
56
|
props.scopeKey,
|
|
57
|
+
(url) => {
|
|
58
|
+
if (typeof url === "string")
|
|
59
|
+
(props.onNavigate ?? ((url) => window.location.assign(url)))(url);
|
|
60
|
+
},
|
|
58
61
|
);
|
|
59
62
|
const run = action.run;
|
|
60
63
|
return {
|
|
@@ -69,9 +72,9 @@ export function useOnboarding(props: OnboardingActions) {
|
|
|
69
72
|
createError: operation === "create" ? action.error : null,
|
|
70
73
|
linkError: operation === "link" ? action.error : null,
|
|
71
74
|
restartError: operation === "close" ? action.error : null,
|
|
72
|
-
create: () => run("create"),
|
|
73
|
-
continue: () => run("link"),
|
|
74
|
-
close: () => run("close"),
|
|
75
|
+
create: async () => ((await run("create")) ? true : undefined),
|
|
76
|
+
continue: async () => ((await run("link")) ? true : undefined),
|
|
77
|
+
close: async () => ((await run("close")) ? true : undefined),
|
|
75
78
|
};
|
|
76
79
|
}
|
|
77
80
|
export type OnboardingAccount = {
|