@fantastic.dev/better-stripe 0.13.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.
Files changed (77) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/README.md +30 -1
  3. package/dist/client/connect/disputes.d.ts +20 -0
  4. package/dist/client/connect/disputes.d.ts.map +1 -1
  5. package/dist/client/connect/disputes.js +55 -0
  6. package/dist/client/connect/disputes.js.map +1 -1
  7. package/dist/client/costRecovery-invoices.d.ts +10 -4
  8. package/dist/client/costRecovery-invoices.d.ts.map +1 -1
  9. package/dist/client/costRecovery-invoices.js +4 -0
  10. package/dist/client/costRecovery-invoices.js.map +1 -1
  11. package/dist/client/costRecovery.d.ts +18 -2
  12. package/dist/client/costRecovery.d.ts.map +1 -1
  13. package/dist/client/costRecovery.js +4 -0
  14. package/dist/client/costRecovery.js.map +1 -1
  15. package/dist/client/errors.d.ts +1 -1
  16. package/dist/client/errors.d.ts.map +1 -1
  17. package/dist/client/errors.js.map +1 -1
  18. package/dist/client/index.d.ts +5 -14
  19. package/dist/client/index.d.ts.map +1 -1
  20. package/dist/client/index.js +3 -7
  21. package/dist/client/index.js.map +1 -1
  22. package/dist/component/_generated/component.d.ts +59 -0
  23. package/dist/component/_generated/component.d.ts.map +1 -1
  24. package/dist/component/costRecovery/claims.d.ts +12 -0
  25. package/dist/component/costRecovery/claims.d.ts.map +1 -1
  26. package/dist/component/costRecovery/imports.d.ts +53 -0
  27. package/dist/component/costRecovery/imports.d.ts.map +1 -0
  28. package/dist/component/costRecovery/imports.js +67 -0
  29. package/dist/component/costRecovery/imports.js.map +1 -0
  30. package/dist/component/costRecovery/mutations.d.ts +47 -0
  31. package/dist/component/costRecovery/mutations.d.ts.map +1 -1
  32. package/dist/component/costRecovery/mutations.js +7 -1
  33. package/dist/component/costRecovery/mutations.js.map +1 -1
  34. package/dist/component/costRecovery/queries.d.ts +12 -0
  35. package/dist/component/costRecovery/queries.d.ts.map +1 -1
  36. package/dist/component/costRecovery/schema.d.ts +72 -2
  37. package/dist/component/costRecovery/schema.d.ts.map +1 -1
  38. package/dist/component/costRecovery/schema.js +13 -0
  39. package/dist/component/costRecovery/schema.js.map +1 -1
  40. package/dist/component/schema.d.ts +19 -1
  41. package/dist/react/workflows/EvidenceUpload.d.ts +18 -0
  42. package/dist/react/workflows/EvidenceUpload.d.ts.map +1 -0
  43. package/dist/react/workflows/EvidenceUpload.js +35 -0
  44. package/dist/react/workflows/EvidenceUpload.js.map +1 -0
  45. package/dist/react/workflows/Onboarding.d.ts +3 -3
  46. package/dist/react/workflows/Onboarding.d.ts.map +1 -1
  47. package/dist/react/workflows/Onboarding.js +8 -6
  48. package/dist/react/workflows/Onboarding.js.map +1 -1
  49. package/dist/react/workflows/index.d.ts +2 -0
  50. package/dist/react/workflows/index.d.ts.map +1 -1
  51. package/dist/react/workflows/index.js +1 -0
  52. package/dist/react/workflows/index.js.map +1 -1
  53. package/dist/react/workflows/useWorkflowAction.d.ts +1 -1
  54. package/dist/react/workflows/useWorkflowAction.d.ts.map +1 -1
  55. package/dist/react/workflows/useWorkflowAction.js +6 -2
  56. package/dist/react/workflows/useWorkflowAction.js.map +1 -1
  57. package/docs/react-workflows.md +42 -0
  58. package/package.json +1 -1
  59. package/src/client/connect/dispute-upload.test.ts +208 -0
  60. package/src/client/connect/disputes.ts +103 -0
  61. package/src/client/costRecovery-import.test.ts +174 -0
  62. package/src/client/costRecovery-invoices.test.ts +69 -0
  63. package/src/client/costRecovery-invoices.ts +32 -0
  64. package/src/client/costRecovery.ts +16 -0
  65. package/src/client/errors.ts +1 -0
  66. package/src/client/index.ts +14 -13
  67. package/src/component/_generated/api.ts +2 -0
  68. package/src/component/_generated/component.ts +65 -0
  69. package/src/component/costRecovery/imports.ts +74 -0
  70. package/src/component/costRecovery/mutations.ts +8 -0
  71. package/src/component/costRecovery/schema.ts +13 -0
  72. package/src/react/workflows/EvidenceUpload.test.tsx +56 -0
  73. package/src/react/workflows/EvidenceUpload.tsx +71 -0
  74. package/src/react/workflows/Onboarding.test.tsx +73 -0
  75. package/src/react/workflows/Onboarding.tsx +8 -5
  76. package/src/react/workflows/index.ts +3 -0
  77. package/src/react/workflows/useWorkflowAction.ts +6 -1
@@ -399,3 +399,72 @@ it("never creates a new provider item for an already completed bookkeeping group
399
399
  await expect(recovery.attachInvoice(ctx, options)).rejects.toThrow(/sealed/i);
400
400
  expect(provider.invoiceItems.create).not.toHaveBeenCalled();
401
401
  });
402
+ it("releases an unbound void invoice after a complete item scan without creating an adjustment", async () => {
403
+ const { ctx, recovery, invoice, items, provider } = await setup();
404
+ items.push(
405
+ ...Array.from(
406
+ { length: 105 },
407
+ (_, i) => ({ id: `ii_other_${i}`, metadata: {} }) as Stripe.InvoiceItem,
408
+ ),
409
+ );
410
+ invoice.status = "void";
411
+ const result = await recovery.reconcileInvoice(ctx, options);
412
+ expect(result).toMatchObject({
413
+ isDone: true,
414
+ invoiceItemId: null,
415
+ invoiceStatus: "void",
416
+ });
417
+ await recovery.reconcileInvoice(ctx, options);
418
+ expect(provider.invoiceItems.create).not.toHaveBeenCalled();
419
+ expect(await recovery.balance(ctx, scope)).toMatchObject({
420
+ reservedAmount: 0,
421
+ collectedAmount: 0,
422
+ outstanding: 320,
423
+ });
424
+ });
425
+ it("keeps missing adjustments reserved for unpaid, paid, uncollectible or previously bound invoices", async () => {
426
+ for (const status of ["draft", "open", "paid", "uncollectible"]) {
427
+ const { ctx, recovery, invoice } = await setup();
428
+ invoice.status = status;
429
+ await expect(
430
+ recovery.reconcileInvoice(ctx, {
431
+ ...options,
432
+ releaseUncollectible: true,
433
+ }),
434
+ ).rejects.toThrow(/missing/i);
435
+ expect(await recovery.balance(ctx, scope)).toMatchObject({
436
+ reservedAmount: 320,
437
+ collectedAmount: 0,
438
+ });
439
+ }
440
+ const { ctx, recovery, invoice, items } = await setup();
441
+ await recovery.attachInvoice(ctx, options);
442
+ items.length = 0;
443
+ invoice.status = "void";
444
+ await expect(recovery.reconcileInvoice(ctx, options)).rejects.toThrow(
445
+ /bound.*missing/i,
446
+ );
447
+ });
448
+ it("does not release an unbound void invoice on an incomplete scan or ownership mismatch", async () => {
449
+ const { ctx, recovery, invoice, provider } = await setup();
450
+ invoice.status = "void";
451
+ const originalList = provider.invoiceItems.list.getMockImplementation()!;
452
+ provider.invoiceItems.list.mockImplementation(() =>
453
+ (async function* () {
454
+ yield { id: "ii_unrelated", metadata: {} } as Stripe.InvoiceItem;
455
+ throw new Error("pagination interrupted");
456
+ })(),
457
+ );
458
+ await expect(recovery.reconcileInvoice(ctx, options)).rejects.toThrow(
459
+ "pagination interrupted",
460
+ );
461
+ provider.invoiceItems.list.mockImplementation(originalList);
462
+ invoice.customer_account = "acct_other";
463
+ await expect(recovery.reconcileInvoice(ctx, options)).rejects.toThrow(
464
+ /ownership/i,
465
+ );
466
+ expect(await recovery.balance(ctx, scope)).toMatchObject({
467
+ reservedAmount: 320,
468
+ collectedAmount: 0,
469
+ });
470
+ });
@@ -6,6 +6,32 @@ const marker = "bsRecoveryGroup";
6
6
  const id = (value: string | { id: string } | null) =>
7
7
  typeof value === "string" ? value : value?.id;
8
8
 
9
+ type InvoiceReceipt = {
10
+ group: RecoveryGroup;
11
+ invoiceItemId: string;
12
+ invoiceStatus: Stripe.Invoice["status"];
13
+ };
14
+ type BindInvoiceItem = (invoiceItemId: string) => Promise<RecoveryGroup>;
15
+
16
+ /** Attachment always produces an item; reconciliation can release an unbound void invoice. */
17
+ export function attachRecoveryInvoice(
18
+ stripe: Stripe,
19
+ group: RecoveryGroup | null,
20
+ opts: RecoveryInvoiceOptions,
21
+ bind: BindInvoiceItem,
22
+ create?: true,
23
+ ): Promise<InvoiceReceipt>;
24
+ export function attachRecoveryInvoice(
25
+ stripe: Stripe,
26
+ group: RecoveryGroup | null,
27
+ opts: RecoveryInvoiceOptions,
28
+ bind: BindInvoiceItem,
29
+ create: false,
30
+ ): Promise<
31
+ | InvoiceReceipt
32
+ | (Omit<InvoiceReceipt, "invoiceItemId"> & { invoiceItemId: null })
33
+ >;
34
+
9
35
  /** One immutable item per sealed group. Host serializes provider operations per group. */
10
36
  export async function attachRecoveryInvoice(
11
37
  stripe: Stripe,
@@ -66,6 +92,12 @@ export async function attachRecoveryInvoice(
66
92
  throw new Error(
67
93
  "Bound recovery invoice item is missing; reconcile before retrying.",
68
94
  );
95
+ if (
96
+ !create &&
97
+ invoice.status === "void" &&
98
+ ["sealed", "releasing", "released"].includes(group.status)
99
+ )
100
+ return { group, invoiceItemId: null, invoiceStatus: invoice.status };
69
101
  if (!create)
70
102
  throw new Error(
71
103
  "Recovery invoice item is missing; no collection can be confirmed.",
@@ -26,6 +26,12 @@ export type RecoveryClaim = RecoveryObservation & {
26
26
  reservedAmount: number;
27
27
  outstanding: number;
28
28
  sign: "positive" | "negative" | "zero";
29
+ openingCollection?: {
30
+ operationKey: string;
31
+ amountDue: number;
32
+ revision: number;
33
+ collectedAmount: number;
34
+ };
29
35
  };
30
36
  export type RecoveryBalance = RecoveryScope & {
31
37
  amountDue: number;
@@ -194,6 +200,16 @@ export class CostRecoveryClient {
194
200
  }),
195
201
  };
196
202
  }
203
+ /** Import a host-verified opening collection. Never overwrites a live claim. */
204
+ importClaim(
205
+ ctx: RunCtx,
206
+ opts: RecoveryObservation & {
207
+ operationKey: string;
208
+ collectedAmount: number;
209
+ },
210
+ ) {
211
+ return this.mutate<RecoveryClaim>(ctx, "mutations/importClaim", opts);
212
+ }
197
213
  recordClaim(ctx: RunCtx, opts: RecoveryObservation) {
198
214
  return this.mutate<RecoveryClaim>(ctx, "mutations/record", opts);
199
215
  }
@@ -9,6 +9,7 @@ export type BetterStripeErrorCode =
9
9
  | "SUBSCRIPTION_UPDATE_FAILED"
10
10
  | "REFUND_CREATE_FAILED"
11
11
  | "REFUND_UNAUTHORIZED"
12
+ | "DISPUTE_UPLOAD_FAILED"
12
13
  | "DISPUTE_UPDATE_FAILED"
13
14
  | "DISPUTE_UNAUTHORIZED"
14
15
  | "CHECKOUT_CREATE_FAILED"
@@ -94,6 +94,8 @@ export { computeFee, computeSplit } from "./core/fees.js";
94
94
  export type { FeeBreakdown, SplitResult, SplitTransfer } from "./core/fees.js";
95
95
  export { groupSubscriptionsByStore } from "./billing/subscriptions.js";
96
96
  export {
97
+ type DisputeEvidenceUploadOptions,
98
+ type DisputeEvidenceFileField,
97
99
  buildDisputeEvidence,
98
100
  disputeEvidenceCountdown,
99
101
  } from "./connect/disputes.js";
@@ -1403,13 +1405,6 @@ export class BetterStripe {
1403
1405
  return disputesImpl.listDisputes(this.component, ctx, opts);
1404
1406
  }
1405
1407
 
1406
- /**
1407
- * Submit or stage dispute evidence. Stripe's `submit` defaults to TRUE, so
1408
- * this method stages by default: EVERY call sends an explicit
1409
- * `submit: false` — evidence-bearing or metadata-only — unless you pass
1410
- * `submit: true` to submit to the bank (one-shot, outcome-affecting;
1411
- * BTS-61, BTS-72). See {@link disputesImpl.updateDispute}.
1412
- */
1413
1408
  async updateDispute(
1414
1409
  ctx: RunCtx,
1415
1410
  opts: {
@@ -1418,23 +1413,29 @@ export class BetterStripe {
1418
1413
  metadata?: Record<string, string>;
1419
1414
  submit?: boolean;
1420
1415
  stripeAccountId?: string;
1421
- /**
1422
- * Who is acting on the dispute (BTS-107). A platform admin may act on any
1423
- * dispute; a seller may act only on a dispute for a sale routed to their
1424
- * own account. Omit for platform-initiated (unrestricted) actions.
1425
- */
1426
1416
  actor?: RefundActor;
1427
1417
  },
1428
1418
  ): Promise<{ success: true }> {
1429
1419
  return disputesImpl.updateDispute(this.stripe(), this.component, ctx, opts);
1430
1420
  }
1431
1421
 
1422
+ async uploadDisputeEvidence(
1423
+ ctx: RunCtx,
1424
+ opts: disputesImpl.DisputeEvidenceUploadOptions,
1425
+ ) {
1426
+ return disputesImpl.uploadDisputeEvidence(
1427
+ this.stripe(),
1428
+ this.component,
1429
+ ctx,
1430
+ opts,
1431
+ );
1432
+ }
1433
+
1432
1434
  async closeDispute(
1433
1435
  ctx: RunCtx,
1434
1436
  opts: {
1435
1437
  stripeDisputeId: string;
1436
1438
  stripeAccountId?: string;
1437
- /** See {@link updateDispute}'s `actor` (BTS-107). Omit for unrestricted. */
1438
1439
  actor?: RefundActor;
1439
1440
  },
1440
1441
  ): Promise<{ success: true }> {
@@ -20,6 +20,7 @@ import type * as core_validators from "../core/validators.js";
20
20
  import type * as costRecovery_claims from "../costRecovery/claims.js";
21
21
  import type * as costRecovery_completion from "../costRecovery/completion.js";
22
22
  import type * as costRecovery_helpers from "../costRecovery/helpers.js";
23
+ import type * as costRecovery_imports from "../costRecovery/imports.js";
23
24
  import type * as costRecovery_mutations from "../costRecovery/mutations.js";
24
25
  import type * as costRecovery_queries from "../costRecovery/queries.js";
25
26
  import type * as costRecovery_reservations from "../costRecovery/reservations.js";
@@ -57,6 +58,7 @@ const fullApi: ApiFromModules<{
57
58
  "costRecovery/claims": typeof costRecovery_claims;
58
59
  "costRecovery/completion": typeof costRecovery_completion;
59
60
  "costRecovery/helpers": typeof costRecovery_helpers;
61
+ "costRecovery/imports": typeof costRecovery_imports;
60
62
  "costRecovery/mutations": typeof costRecovery_mutations;
61
63
  "costRecovery/queries": typeof costRecovery_queries;
62
64
  "costRecovery/reservations": typeof costRecovery_reservations;
@@ -1707,6 +1707,47 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
1707
1707
  },
1708
1708
  Name
1709
1709
  >;
1710
+ importClaim: FunctionReference<
1711
+ "mutation",
1712
+ "internal",
1713
+ {
1714
+ accountId: string;
1715
+ amountDue: number;
1716
+ claimKey: string;
1717
+ collectedAmount: number;
1718
+ currency: string;
1719
+ operationKey: string;
1720
+ revision: number;
1721
+ scopeKey: string;
1722
+ sourceChargeId: string;
1723
+ sourceGross: number;
1724
+ sourceProcessingFee: number;
1725
+ },
1726
+ {
1727
+ _creationTime: number;
1728
+ _id: string;
1729
+ accountId: string;
1730
+ amountDue: number;
1731
+ claimKey: string;
1732
+ collectedAmount: number;
1733
+ currency: string;
1734
+ openingCollection?: {
1735
+ amountDue: number;
1736
+ collectedAmount: number;
1737
+ operationKey: string;
1738
+ revision: number;
1739
+ };
1740
+ outstanding: number;
1741
+ reservedAmount: number;
1742
+ revision: number;
1743
+ scopeKey: string;
1744
+ sign: "positive" | "negative" | "zero";
1745
+ sourceChargeId: string;
1746
+ sourceGross: number;
1747
+ sourceProcessingFee: number;
1748
+ },
1749
+ Name
1750
+ >;
1710
1751
  record: FunctionReference<
1711
1752
  "mutation",
1712
1753
  "internal",
@@ -1729,6 +1770,12 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
1729
1770
  claimKey: string;
1730
1771
  collectedAmount: number;
1731
1772
  currency: string;
1773
+ openingCollection?: {
1774
+ amountDue: number;
1775
+ collectedAmount: number;
1776
+ operationKey: string;
1777
+ revision: number;
1778
+ };
1732
1779
  outstanding: number;
1733
1780
  reservedAmount: number;
1734
1781
  revision: number;
@@ -1816,6 +1863,12 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
1816
1863
  claimKey: string;
1817
1864
  collectedAmount: number;
1818
1865
  currency: string;
1866
+ openingCollection?: {
1867
+ amountDue: number;
1868
+ collectedAmount: number;
1869
+ operationKey: string;
1870
+ revision: number;
1871
+ };
1819
1872
  outstanding: number;
1820
1873
  reservedAmount: number;
1821
1874
  revision: number;
@@ -1892,6 +1945,12 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
1892
1945
  claimKey: string;
1893
1946
  collectedAmount: number;
1894
1947
  currency: string;
1948
+ openingCollection?: {
1949
+ amountDue: number;
1950
+ collectedAmount: number;
1951
+ operationKey: string;
1952
+ revision: number;
1953
+ };
1895
1954
  outstanding: number;
1896
1955
  reservedAmount: number;
1897
1956
  revision: number;
@@ -1965,6 +2024,12 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
1965
2024
  claimKey: string;
1966
2025
  collectedAmount: number;
1967
2026
  currency: string;
2027
+ openingCollection?: {
2028
+ amountDue: number;
2029
+ collectedAmount: number;
2030
+ operationKey: string;
2031
+ revision: number;
2032
+ };
1968
2033
  outstanding: number;
1969
2034
  reservedAmount: number;
1970
2035
  revision: number;
@@ -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,4 @@
1
+ import { importOpeningClaim } from "./imports.js";
1
2
  import { getGroup, integer } from "./helpers.js";
2
3
  import { v } from "convex/values";
3
4
  import { mutation } from "../_generated/server.js";
@@ -6,6 +7,7 @@ import { reserveGroup, sealGroup } from "./reservations.js";
6
7
  import { finishGroup } from "./completion.js";
7
8
  import {
8
9
  observationFields,
10
+ importArgs,
9
11
  claimDoc,
10
12
  groupDoc,
11
13
  reservationDoc,
@@ -113,3 +115,9 @@ export const bindInvoiceFunding = mutation({
113
115
  return (await ctx.db.get("recoveryGroups", group._id))!;
114
116
  },
115
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,
@@ -104,6 +116,7 @@ export const reservationDoc = v.object({
104
116
  });
105
117
  export const claimsTable = defineTable(claimFields)
106
118
  .index("by_key", ["claimKey"])
119
+ .index("by_import", ["openingCollection.operationKey"])
107
120
  .index("by_scope", ["accountId", "scopeKey", "currency"])
108
121
  .index("by_sign", ["accountId", "scopeKey", "currency", "sign"]);
109
122
  export const balancesTable = defineTable(balanceFields).index("by_scope", [
@@ -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
+ }