@fantastic.dev/better-stripe 0.9.0 → 0.10.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 (128) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/MIGRATION.md +12 -0
  3. package/README.md +29 -1
  4. package/dist/client/billing/checkout.d.ts +1 -0
  5. package/dist/client/billing/checkout.d.ts.map +1 -1
  6. package/dist/client/billing/checkout.js +5 -1
  7. package/dist/client/billing/checkout.js.map +1 -1
  8. package/dist/client/billing/providerReads.d.ts +18 -0
  9. package/dist/client/billing/providerReads.d.ts.map +1 -0
  10. package/dist/client/billing/providerReads.js +15 -0
  11. package/dist/client/billing/providerReads.js.map +1 -0
  12. package/dist/client/billing/subscriptions.d.ts +1 -1
  13. package/dist/client/billing/subscriptions.d.ts.map +1 -1
  14. package/dist/client/billing/subscriptions.js.map +1 -1
  15. package/dist/client/connect/payoutSnapshot.d.ts +28 -0
  16. package/dist/client/connect/payoutSnapshot.d.ts.map +1 -0
  17. package/dist/client/connect/payoutSnapshot.js +81 -0
  18. package/dist/client/connect/payoutSnapshot.js.map +1 -0
  19. package/dist/client/connect/transfers.d.ts.map +1 -1
  20. package/dist/client/connect/transfers.js +13 -32
  21. package/dist/client/connect/transfers.js.map +1 -1
  22. package/dist/client/core/accounts.d.ts +12 -23
  23. package/dist/client/core/accounts.d.ts.map +1 -1
  24. package/dist/client/core/accounts.js +5 -1
  25. package/dist/client/core/accounts.js.map +1 -1
  26. package/dist/client/core/idempotency.d.ts +5 -0
  27. package/dist/client/core/idempotency.d.ts.map +1 -0
  28. package/dist/client/core/idempotency.js +10 -0
  29. package/dist/client/core/idempotency.js.map +1 -0
  30. package/dist/client/index.d.ts +31 -25
  31. package/dist/client/index.d.ts.map +1 -1
  32. package/dist/client/index.js +29 -52
  33. package/dist/client/index.js.map +1 -1
  34. package/dist/client/settlement/allocation.d.ts +30 -0
  35. package/dist/client/settlement/allocation.d.ts.map +1 -0
  36. package/dist/client/settlement/allocation.js +71 -0
  37. package/dist/client/settlement/allocation.js.map +1 -0
  38. package/dist/client/settlement/commissions.d.ts +27 -0
  39. package/dist/client/settlement/commissions.d.ts.map +1 -0
  40. package/dist/client/settlement/commissions.js +69 -0
  41. package/dist/client/settlement/commissions.js.map +1 -0
  42. package/dist/client/settlement/execution.d.ts +27 -0
  43. package/dist/client/settlement/execution.d.ts.map +1 -0
  44. package/dist/client/settlement/execution.js +70 -0
  45. package/dist/client/settlement/execution.js.map +1 -0
  46. package/dist/client/settlement/reconciliation.d.ts +10 -0
  47. package/dist/client/settlement/reconciliation.d.ts.map +1 -0
  48. package/dist/client/settlement/reconciliation.js +73 -0
  49. package/dist/client/settlement/reconciliation.js.map +1 -0
  50. package/dist/client/settlement/settleSale.d.ts +21 -0
  51. package/dist/client/settlement/settleSale.d.ts.map +1 -0
  52. package/dist/client/settlement/settleSale.js +27 -0
  53. package/dist/client/settlement/settleSale.js.map +1 -0
  54. package/dist/client/settlement/verifiedSale.d.ts +25 -0
  55. package/dist/client/settlement/verifiedSale.d.ts.map +1 -0
  56. package/dist/client/settlement/verifiedSale.js +100 -0
  57. package/dist/client/settlement/verifiedSale.js.map +1 -0
  58. package/dist/client/webhooks/asyncHooks.d.ts +5 -0
  59. package/dist/client/webhooks/asyncHooks.d.ts.map +1 -0
  60. package/dist/client/webhooks/asyncHooks.js +20 -0
  61. package/dist/client/webhooks/asyncHooks.js.map +1 -0
  62. package/dist/component/_generated/component.d.ts +80 -0
  63. package/dist/component/_generated/component.d.ts.map +1 -1
  64. package/dist/component/schema.d.ts +67 -0
  65. package/dist/component/schema.js +4 -0
  66. package/dist/component/schema.js.map +1 -1
  67. package/dist/component/settlement/mutations.d.ts +47 -0
  68. package/dist/component/settlement/mutations.d.ts.map +1 -0
  69. package/dist/component/settlement/mutations.js +104 -0
  70. package/dist/component/settlement/mutations.js.map +1 -0
  71. package/dist/component/settlement/queries.d.ts +29 -0
  72. package/dist/component/settlement/queries.d.ts.map +1 -0
  73. package/dist/component/settlement/queries.js +35 -0
  74. package/dist/component/settlement/queries.js.map +1 -0
  75. package/dist/component/settlement/schema.d.ts +99 -0
  76. package/dist/component/settlement/schema.d.ts.map +1 -0
  77. package/dist/component/settlement/schema.js +35 -0
  78. package/dist/component/settlement/schema.js.map +1 -0
  79. package/dist/react/workflows/Checkout.d.ts +3 -1
  80. package/dist/react/workflows/Checkout.d.ts.map +1 -1
  81. package/dist/react/workflows/Checkout.js +3 -2
  82. package/dist/react/workflows/Checkout.js.map +1 -1
  83. package/dist/react/workflows/Payouts.d.ts +3 -18
  84. package/dist/react/workflows/Payouts.d.ts.map +1 -1
  85. package/dist/react/workflows/Payouts.js +2 -2
  86. package/dist/react/workflows/Payouts.js.map +1 -1
  87. package/docs/react-workflows.md +5 -2
  88. package/docs/settlement.md +158 -0
  89. package/package.json +2 -1
  90. package/src/client/billing/checkout-idempotency.test.ts +82 -0
  91. package/src/client/billing/checkout.ts +6 -1
  92. package/src/client/billing/providerReads.test.ts +60 -0
  93. package/src/client/billing/providerReads.ts +30 -0
  94. package/src/client/billing/subscriptions.ts +1 -8
  95. package/src/client/connect/payoutSnapshot.test.ts +91 -0
  96. package/src/client/connect/payoutSnapshot.ts +130 -0
  97. package/src/client/connect/transfers.test.ts +15 -4
  98. package/src/client/connect/transfers.ts +13 -42
  99. package/src/client/core/account-idempotency.test.ts +78 -0
  100. package/src/client/core/accounts.ts +18 -25
  101. package/src/client/core/idempotency.test.ts +10 -0
  102. package/src/client/core/idempotency.ts +11 -0
  103. package/src/client/index.ts +84 -103
  104. package/src/client/settlement/allocation.test.ts +98 -0
  105. package/src/client/settlement/allocation.ts +110 -0
  106. package/src/client/settlement/commissions.test.ts +103 -0
  107. package/src/client/settlement/commissions.ts +125 -0
  108. package/src/client/settlement/execution.test.ts +186 -0
  109. package/src/client/settlement/execution.ts +134 -0
  110. package/src/client/settlement/reconciliation.ts +119 -0
  111. package/src/client/settlement/settleSale.ts +59 -0
  112. package/src/client/settlement/verifiedSale.test.ts +139 -0
  113. package/src/client/settlement/verifiedSale.ts +159 -0
  114. package/src/client/webhooks/asyncHooks.ts +38 -0
  115. package/src/client/webhooks/processors.test.ts +9 -9
  116. package/src/component/_generated/api.ts +4 -0
  117. package/src/component/_generated/component.ts +100 -0
  118. package/src/component/schema.ts +8 -0
  119. package/src/component/settlement/mutations.ts +119 -0
  120. package/src/component/settlement/queries.ts +38 -0
  121. package/src/component/settlement/schema.ts +45 -0
  122. package/src/component/settlement-claims.test.ts +77 -0
  123. package/src/react/components/EmbeddedDisputes.render.test.tsx +2 -2
  124. package/src/react/index.payment-element.test.ts +22 -37
  125. package/src/react/workflows/Checkout.test.tsx +30 -0
  126. package/src/react/workflows/Checkout.tsx +10 -3
  127. package/src/react/workflows/Payouts.test.tsx +33 -0
  128. package/src/react/workflows/Payouts.tsx +21 -15
@@ -1,8 +1,4 @@
1
1
  // @vitest-environment edge-runtime
2
- /**
3
- * Drives Stripe event normalization through processEvent and checks the payloads
4
- * passed to component mutations, including owner, fee, and trial fields.
5
- */
6
2
  import type Stripe from "stripe";
7
3
  import { beforeEach, describe, expect, it, vi } from "vitest";
8
4
 
@@ -12,7 +8,6 @@ import { processEvent } from "./processors.js";
12
8
 
13
9
  const TO_REF = Symbol.for("toReferencePath");
14
10
 
15
- /** Fake component exposing every query/mutation ref processors.ts resolves. */
16
11
  function makeComponent() {
17
12
  const refs = (namespace: string, names: string[]) =>
18
13
  Object.fromEntries(
@@ -44,12 +39,16 @@ function makeCtx(opts?: { query?: unknown; queryThrows?: boolean }) {
44
39
  const runQuery = vi.fn();
45
40
  if (opts?.queryThrows) runQuery.mockRejectedValue(new Error("not found"));
46
41
  else runQuery.mockResolvedValue(opts?.query ?? null);
47
- // `claimReversalSlices` returns the slices the reversal engines execute
48
- // (BTS-63). Fake only its storage: the slice math is the REAL shared
49
- // computeReversalSlices, applied to this ctx's ledger legs.
42
+ // Fake claim storage; use the shared reversal sizing math.
50
43
  const runMutation = vi.fn(
51
44
  async (ref: Record<symbol, string>, args: Record<string, unknown>) => {
52
45
  const path = ref?.[TO_REF] ?? "";
46
+ if (
47
+ String(path).endsWith(
48
+ "settlement/mutations/reserveSettlementAllocation",
49
+ )
50
+ )
51
+ return args;
53
52
  if (!String(path).endsWith("connect/mutations/claimReversalSlices")) {
54
53
  return undefined;
55
54
  }
@@ -107,7 +106,9 @@ function makeStripe() {
107
106
  update: vi.fn().mockResolvedValue({}),
108
107
  retrieve: vi.fn(),
109
108
  },
109
+ charges: { retrieve: vi.fn().mockResolvedValue({ created: 1 }) },
110
110
  transfers: {
111
+ list: vi.fn().mockResolvedValue({ data: [], has_more: false }),
111
112
  create: vi.fn(async (_params: unknown) => ({
112
113
  id: `tr_${++trCreateSeq}`,
113
114
  })),
@@ -146,7 +147,6 @@ function makeStripe() {
146
147
  retrieve: vi.fn(),
147
148
  update: vi.fn().mockResolvedValue({}),
148
149
  },
149
- charges: { retrieve: vi.fn() },
150
150
  };
151
151
  }
152
152
 
@@ -25,6 +25,8 @@ import type * as lib_stripeFactory from "../lib/stripeFactory.js";
25
25
  import type * as products_mutations from "../products/mutations.js";
26
26
  import type * as products_queries from "../products/queries.js";
27
27
  import type * as products_validators from "../products/validators.js";
28
+ import type * as settlement_mutations from "../settlement/mutations.js";
29
+ import type * as settlement_queries from "../settlement/queries.js";
28
30
  import type * as webhooks_mutations from "../webhooks/mutations.js";
29
31
  import type * as webhooks_queries from "../webhooks/queries.js";
30
32
  import type * as webhooks_validators from "../webhooks/validators.js";
@@ -54,6 +56,8 @@ const fullApi: ApiFromModules<{
54
56
  "products/mutations": typeof products_mutations;
55
57
  "products/queries": typeof products_queries;
56
58
  "products/validators": typeof products_validators;
59
+ "settlement/mutations": typeof settlement_mutations;
60
+ "settlement/queries": typeof settlement_queries;
57
61
  "webhooks/mutations": typeof webhooks_mutations;
58
62
  "webhooks/queries": typeof webhooks_queries;
59
63
  "webhooks/validators": typeof webhooks_validators;
@@ -1763,6 +1763,106 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
1763
1763
  >;
1764
1764
  };
1765
1765
  };
1766
+ settlement: {
1767
+ mutations: {
1768
+ claimCommissionInvoice: FunctionReference<
1769
+ "mutation",
1770
+ "internal",
1771
+ {
1772
+ affiliateAccountId?: string;
1773
+ firstPaidInvoiceId: string;
1774
+ invoiceId: string;
1775
+ subscriptionId: string;
1776
+ term: "forever" | "one_time";
1777
+ },
1778
+ {
1779
+ claimKey: string;
1780
+ eligible: boolean;
1781
+ firstPaidInvoiceId: string;
1782
+ replay: boolean;
1783
+ term: "forever" | "one_time";
1784
+ },
1785
+ Name
1786
+ >;
1787
+ reserveSettlementAllocation: FunctionReference<
1788
+ "mutation",
1789
+ "internal",
1790
+ {
1791
+ adjustmentAmount?: number;
1792
+ currency: string;
1793
+ gross: number;
1794
+ paymentId?: string;
1795
+ paymentIntentId?: string;
1796
+ platformNet: number;
1797
+ processingFee: number;
1798
+ sourceChargeId: string;
1799
+ sourceInvoiceId?: string;
1800
+ transfers: Array<{
1801
+ amount: number;
1802
+ destinationAccountId: string;
1803
+ role: "store" | "affiliate" | "other";
1804
+ }>;
1805
+ },
1806
+ {
1807
+ _creationTime: number;
1808
+ _id: string;
1809
+ adjustmentAmount?: number;
1810
+ currency: string;
1811
+ gross: number;
1812
+ paymentId?: string;
1813
+ paymentIntentId?: string;
1814
+ platformNet: number;
1815
+ processingFee: number;
1816
+ sourceChargeId: string;
1817
+ sourceInvoiceId?: string;
1818
+ transfers: Array<{
1819
+ amount: number;
1820
+ destinationAccountId: string;
1821
+ role: "store" | "affiliate" | "other";
1822
+ }>;
1823
+ },
1824
+ Name
1825
+ >;
1826
+ };
1827
+ queries: {
1828
+ getCommissionTerm: FunctionReference<
1829
+ "query",
1830
+ "internal",
1831
+ { subscriptionId: string },
1832
+ {
1833
+ affiliateAccountId?: string;
1834
+ firstPaidInvoiceId: string;
1835
+ subscriptionId: string;
1836
+ term: "forever" | "one_time";
1837
+ } | null,
1838
+ Name
1839
+ >;
1840
+ getSettlementAllocation: FunctionReference<
1841
+ "query",
1842
+ "internal",
1843
+ { sourceChargeId: string },
1844
+ {
1845
+ _creationTime: number;
1846
+ _id: string;
1847
+ adjustmentAmount?: number;
1848
+ currency: string;
1849
+ gross: number;
1850
+ paymentId?: string;
1851
+ paymentIntentId?: string;
1852
+ platformNet: number;
1853
+ processingFee: number;
1854
+ sourceChargeId: string;
1855
+ sourceInvoiceId?: string;
1856
+ transfers: Array<{
1857
+ amount: number;
1858
+ destinationAccountId: string;
1859
+ role: "store" | "affiliate" | "other";
1860
+ }>;
1861
+ } | null,
1862
+ Name
1863
+ >;
1864
+ };
1865
+ };
1766
1866
  webhooks: {
1767
1867
  mutations: {
1768
1868
  insertWebhookEvent: FunctionReference<
@@ -1,3 +1,8 @@
1
+ import {
2
+ allocationsTable,
3
+ commissionTermsTable,
4
+ commissionDecisionsTable,
5
+ } from "./settlement/schema";
1
6
  import { defineSchema } from "convex/server";
2
7
 
3
8
  import {
@@ -19,6 +24,9 @@ import { pricesTable, productsTable } from "./products/schema";
19
24
  import { webhookEventsTable } from "./webhooks/schema";
20
25
 
21
26
  export default defineSchema({
27
+ settlementAllocations: allocationsTable,
28
+ commissionTerms: commissionTermsTable,
29
+ commissionDecisions: commissionDecisionsTable,
22
30
  // Core
23
31
  accounts: accountsTable,
24
32
 
@@ -0,0 +1,119 @@
1
+ import { v } from "convex/values";
2
+ import { mutation } from "../_generated/server.js";
3
+ import { allocationFields, commissionFields, termValidator } from "./schema.js";
4
+ const doc = v.object({
5
+ ...allocationFields,
6
+ _id: v.id("settlementAllocations"),
7
+ _creationTime: v.number(),
8
+ });
9
+ export const reserveSettlementAllocation = mutation({
10
+ args: allocationFields,
11
+ returns: doc,
12
+ handler: async (ctx, args) => {
13
+ const existing = await ctx.db
14
+ .query("settlementAllocations")
15
+ .withIndex("by_source_charge", (q) =>
16
+ q.eq("sourceChargeId", args.sourceChargeId),
17
+ )
18
+ .first();
19
+ if (existing) {
20
+ if (
21
+ existing.currency !== args.currency ||
22
+ existing.paymentIntentId !== args.paymentIntentId ||
23
+ existing.sourceInvoiceId !== args.sourceInvoiceId
24
+ )
25
+ throw new Error("Settlement source identity cannot change.");
26
+ return existing;
27
+ }
28
+ const adjustment = args.adjustmentAmount ?? 0;
29
+ const amounts = [
30
+ args.gross,
31
+ args.processingFee,
32
+ args.platformNet,
33
+ ...args.transfers.map((t) => t.amount),
34
+ ];
35
+ const keys = args.transfers.map(
36
+ (t) => `${t.destinationAccountId}:${t.role}`,
37
+ );
38
+ if (
39
+ !Number.isSafeInteger(adjustment) ||
40
+ args.processingFee + args.platformNet + adjustment < 0 ||
41
+ amounts.some((n) => !Number.isSafeInteger(n) || n < 0) ||
42
+ args.gross <= 0 ||
43
+ !args.currency ||
44
+ !args.sourceChargeId ||
45
+ args.transfers.some((t) => !t.destinationAccountId) ||
46
+ new Set(keys).size !== keys.length ||
47
+ args.gross !==
48
+ args.processingFee +
49
+ args.platformNet +
50
+ adjustment +
51
+ args.transfers.reduce((sum, t) => sum + t.amount, 0)
52
+ )
53
+ throw new Error(
54
+ "Settlement allocation must reconcile in integer currency units with unique recipient legs.",
55
+ );
56
+ const id = await ctx.db.insert("settlementAllocations", args);
57
+ return (await ctx.db.get("settlementAllocations", id))!;
58
+ },
59
+ });
60
+ export const claimCommissionInvoice = mutation({
61
+ args: { ...commissionFields, invoiceId: v.string() },
62
+ returns: v.object({
63
+ eligible: v.boolean(),
64
+ term: termValidator,
65
+ firstPaidInvoiceId: v.string(),
66
+ claimKey: v.string(),
67
+ replay: v.boolean(),
68
+ }),
69
+ handler: async (ctx, args) => {
70
+ let stored = await ctx.db
71
+ .query("commissionTerms")
72
+ .withIndex("by_subscription", (q) =>
73
+ q.eq("subscriptionId", args.subscriptionId),
74
+ )
75
+ .first();
76
+ if (stored && stored.affiliateAccountId !== args.affiliateAccountId)
77
+ throw new Error("Subscription affiliate identity cannot change.");
78
+ if (!stored) {
79
+ const fields = {
80
+ subscriptionId: args.subscriptionId,
81
+ term: args.term,
82
+ affiliateAccountId: args.affiliateAccountId,
83
+ firstPaidInvoiceId: args.firstPaidInvoiceId,
84
+ };
85
+ const id = await ctx.db.insert("commissionTerms", fields);
86
+ stored = (await ctx.db.get("commissionTerms", id))!;
87
+ }
88
+ const existing = await ctx.db
89
+ .query("commissionDecisions")
90
+ .withIndex("by_subscription_invoice", (q) =>
91
+ q
92
+ .eq("subscriptionId", args.subscriptionId)
93
+ .eq("invoiceId", args.invoiceId),
94
+ )
95
+ .first();
96
+ const claimKey =
97
+ existing?.claimKey ??
98
+ `commission:${args.subscriptionId}:${args.invoiceId}`;
99
+ const eligible =
100
+ existing?.eligible ??
101
+ (!!stored.affiliateAccountId &&
102
+ (stored.term === "forever" ||
103
+ args.invoiceId === stored.firstPaidInvoiceId));
104
+ if (!existing)
105
+ await ctx.db.insert("commissionDecisions", {
106
+ subscriptionId: args.subscriptionId,
107
+ invoiceId: args.invoiceId,
108
+ eligible,
109
+ claimKey,
110
+ });
111
+ return {
112
+ eligible,
113
+ term: stored.term,
114
+ firstPaidInvoiceId: stored.firstPaidInvoiceId,
115
+ claimKey,
116
+ replay: !!existing,
117
+ };
118
+ },
119
+ });
@@ -0,0 +1,38 @@
1
+ import { v } from "convex/values";
2
+ import { query } from "../_generated/server.js";
3
+ import { allocationFields, commissionFields } from "./schema.js";
4
+ const doc = v.object({
5
+ ...allocationFields,
6
+ _id: v.id("settlementAllocations"),
7
+ _creationTime: v.number(),
8
+ });
9
+ export const getSettlementAllocation = query({
10
+ args: { sourceChargeId: v.string() },
11
+ returns: v.union(doc, v.null()),
12
+ handler: (ctx, args) =>
13
+ ctx.db
14
+ .query("settlementAllocations")
15
+ .withIndex("by_source_charge", (q) =>
16
+ q.eq("sourceChargeId", args.sourceChargeId),
17
+ )
18
+ .first(),
19
+ });
20
+ export const getCommissionTerm = query({
21
+ args: { subscriptionId: v.string() },
22
+ returns: v.union(v.object(commissionFields), v.null()),
23
+ handler: async (ctx, args) => {
24
+ const row = await ctx.db
25
+ .query("commissionTerms")
26
+ .withIndex("by_subscription", (q) =>
27
+ q.eq("subscriptionId", args.subscriptionId),
28
+ )
29
+ .first();
30
+ if (!row) return null;
31
+ return {
32
+ subscriptionId: row.subscriptionId,
33
+ term: row.term,
34
+ affiliateAccountId: row.affiliateAccountId,
35
+ firstPaidInvoiceId: row.firstPaidInvoiceId,
36
+ };
37
+ },
38
+ });
@@ -0,0 +1,45 @@
1
+ import { defineTable } from "convex/server";
2
+ import { v } from "convex/values";
3
+ import { splitRecipientRoleValidator } from "../lib/fees.js";
4
+ export const termValidator = v.union(
5
+ v.literal("forever"),
6
+ v.literal("one_time"),
7
+ );
8
+ export const allocationFields = {
9
+ sourceChargeId: v.string(),
10
+ paymentIntentId: v.optional(v.string()),
11
+ sourceInvoiceId: v.optional(v.string()),
12
+ gross: v.number(),
13
+ currency: v.string(),
14
+ processingFee: v.number(),
15
+ platformNet: v.number(),
16
+ adjustmentAmount: v.optional(v.number()),
17
+ paymentId: v.optional(v.string()),
18
+ transfers: v.array(
19
+ v.object({
20
+ destinationAccountId: v.string(),
21
+ role: splitRecipientRoleValidator,
22
+ amount: v.number(),
23
+ }),
24
+ ),
25
+ };
26
+ export const allocationsTable = defineTable(allocationFields).index(
27
+ "by_source_charge",
28
+ ["sourceChargeId"],
29
+ );
30
+ export const commissionFields = {
31
+ subscriptionId: v.string(),
32
+ term: termValidator,
33
+ affiliateAccountId: v.optional(v.string()),
34
+ firstPaidInvoiceId: v.string(),
35
+ };
36
+ export const commissionTermsTable = defineTable(commissionFields).index(
37
+ "by_subscription",
38
+ ["subscriptionId"],
39
+ );
40
+ export const commissionDecisionsTable = defineTable({
41
+ subscriptionId: v.string(),
42
+ invoiceId: v.string(),
43
+ eligible: v.boolean(),
44
+ claimKey: v.string(),
45
+ }).index("by_subscription_invoice", ["subscriptionId", "invoiceId"]);
@@ -0,0 +1,77 @@
1
+ // @vitest-environment edge-runtime
2
+ import { convexTest } from "convex-test";
3
+ import { expect, it } from "vitest";
4
+ import { api } from "./_generated/api.js";
5
+ import schema from "./schema.js";
6
+ const modules = import.meta.glob("./**/*.*s");
7
+ it("keeps a claimed allocation immutable and isolated per source charge", async () => {
8
+ const t = convexTest(schema, modules);
9
+ const opts = {
10
+ sourceChargeId: "ch_1",
11
+ paymentIntentId: "pi_1",
12
+ gross: 100,
13
+ currency: "usd",
14
+ processingFee: 3,
15
+ platformNet: 7,
16
+ transfers: [
17
+ { destinationAccountId: "acct_c", amount: 90, role: "store" as const },
18
+ ],
19
+ };
20
+ const first = await t.mutation(
21
+ api.settlement.mutations.reserveSettlementAllocation,
22
+ opts,
23
+ );
24
+ const replay = await t.mutation(
25
+ api.settlement.mutations.reserveSettlementAllocation,
26
+ {
27
+ ...opts,
28
+ transfers: [{ ...opts.transfers[0], amount: 80 }],
29
+ platformNet: 17,
30
+ },
31
+ );
32
+ expect(replay).toEqual(first);
33
+ await expect(
34
+ t.mutation(api.settlement.mutations.reserveSettlementAllocation, {
35
+ ...opts,
36
+ sourceChargeId: "ch_invalid",
37
+ gross: 1,
38
+ }),
39
+ ).rejects.toThrow();
40
+ });
41
+ it("persists the term, identity and earliest invoice despite replay and term changes", async () => {
42
+ const t = convexTest(schema, modules);
43
+ const opts = {
44
+ subscriptionId: "sub_1",
45
+ invoiceId: "in_renewal",
46
+ firstPaidInvoiceId: "in_first",
47
+ term: "one_time" as const,
48
+ affiliateAccountId: "acct_a",
49
+ };
50
+ expect(
51
+ await t.mutation(api.settlement.mutations.claimCommissionInvoice, opts),
52
+ ).toMatchObject({
53
+ eligible: false,
54
+ firstPaidInvoiceId: "in_first",
55
+ term: "one_time",
56
+ replay: false,
57
+ });
58
+ expect(
59
+ await t.mutation(api.settlement.mutations.claimCommissionInvoice, {
60
+ ...opts,
61
+ invoiceId: "in_first",
62
+ term: "forever",
63
+ }),
64
+ ).toMatchObject({ eligible: true, term: "one_time" });
65
+ expect(
66
+ await t.mutation(api.settlement.mutations.claimCommissionInvoice, {
67
+ ...opts,
68
+ invoiceId: "in_first",
69
+ }),
70
+ ).toMatchObject({ eligible: true, replay: true });
71
+ await expect(
72
+ t.mutation(api.settlement.mutations.claimCommissionInvoice, {
73
+ ...opts,
74
+ affiliateAccountId: "acct_other",
75
+ }),
76
+ ).rejects.toThrow();
77
+ });
@@ -10,6 +10,7 @@
10
10
  * tree rendering without throwing via react-dom/server, the disputes_list vs
11
11
  * payment_disputes mount switch, and the headless overrides.
12
12
  */
13
+ import * as barrel from "../index.js";
13
14
  import { createElement } from "react";
14
15
  import { renderToStaticMarkup } from "react-dom/server";
15
16
  import { beforeEach, describe, expect, it, vi } from "vitest";
@@ -136,8 +137,7 @@ describe("EmbeddedDisputes", () => {
136
137
  });
137
138
 
138
139
  describe("react barrel exports", () => {
139
- it("exports ConnectProvider and EmbeddedDisputes", async () => {
140
- const barrel = await import("../index.js");
140
+ it("exports ConnectProvider and EmbeddedDisputes", () => {
141
141
  expect(barrel.ConnectProvider).toBe(ConnectProvider);
142
142
  expect(barrel.EmbeddedDisputes).toBe(EmbeddedDisputes);
143
143
  });
@@ -1,41 +1,26 @@
1
- import { afterEach, describe, expect, it, vi } from "vitest";
1
+ import { describe, expect, it, vi } from "vitest";
2
+
3
+ const elements = vi.hoisted(() => ({
4
+ checkout: Symbol("checkout-payment-element"),
5
+ paymentIntent: Symbol("elements-payment-element"),
6
+ }));
7
+ vi.mock("@stripe/react-stripe-js", async () => ({
8
+ ...(await vi.importActual<typeof import("@stripe/react-stripe-js")>(
9
+ "@stripe/react-stripe-js",
10
+ )),
11
+ PaymentElement: elements.paymentIntent,
12
+ }));
13
+ vi.mock("@stripe/react-stripe-js/checkout", async () => ({
14
+ ...(await vi.importActual<typeof import("@stripe/react-stripe-js/checkout")>(
15
+ "@stripe/react-stripe-js/checkout",
16
+ )),
17
+ PaymentElement: elements.checkout,
18
+ }));
19
+ import * as reactExports from "./index.js";
2
20
 
3
21
  describe("better-stripe/react PaymentElement exports", () => {
4
- afterEach(() => {
5
- vi.resetModules();
6
- vi.doUnmock("@stripe/react-stripe-js");
7
- vi.doUnmock("@stripe/react-stripe-js/checkout");
8
- });
9
-
10
- it("uses checkout PaymentElement as the default export and keeps the Elements variant aliased", async () => {
11
- const checkoutPaymentElement = Symbol("checkout-payment-element");
12
- const elementsPaymentElement = Symbol("elements-payment-element");
13
-
14
- vi.doMock("@stripe/react-stripe-js", async () => {
15
- const actual = await vi.importActual<
16
- typeof import("@stripe/react-stripe-js")
17
- >("@stripe/react-stripe-js");
18
-
19
- return {
20
- ...actual,
21
- PaymentElement: elementsPaymentElement,
22
- };
23
- });
24
-
25
- vi.doMock("@stripe/react-stripe-js/checkout", async () => {
26
- const actual = await vi.importActual<
27
- typeof import("@stripe/react-stripe-js/checkout")
28
- >("@stripe/react-stripe-js/checkout");
29
-
30
- return {
31
- ...actual,
32
- PaymentElement: checkoutPaymentElement,
33
- };
34
- });
35
-
36
- const reactExports = await import("./index.js");
37
-
38
- expect(reactExports.PaymentElement).toBe(checkoutPaymentElement);
39
- expect(reactExports.ElementsPaymentElement).toBe(elementsPaymentElement);
22
+ it("uses checkout PaymentElement as the default export and keeps the Elements variant aliased", () => {
23
+ expect(reactExports.PaymentElement).toBe(elements.checkout);
24
+ expect(reactExports.ElementsPaymentElement).toBe(elements.paymentIntent);
40
25
  });
41
26
  });
@@ -83,6 +83,9 @@ it("exposes creation errors and retries without losing the selected integration"
83
83
  await userEvent.click(screen.getByRole("button", { name: "Retry" }));
84
84
  await screen.findByText("Stripe iframe");
85
85
  await waitFor(() => expect(createSession).toHaveBeenCalledTimes(2));
86
+ expect(createSession.mock.calls[1][0].requestId).toBe(
87
+ createSession.mock.calls[0][0].requestId,
88
+ );
86
89
  });
87
90
  it("rejects missing session secrets and initializes Payment Intents through their own callback", async () => {
88
91
  const { unmount } = render(
@@ -116,3 +119,30 @@ it("rejects missing session secrets and initializes Payment Intents through thei
116
119
  requestId: expect.any(String),
117
120
  });
118
121
  });
122
+
123
+ it("keeps an explicit request identity through rerenders and replaces generated identity for a new order", async () => {
124
+ const createSession = vi
125
+ .fn()
126
+ .mockResolvedValue({ stripeSessionId: "cs_test", clientSecret: "secret" });
127
+ const props = {
128
+ mode: "embedded" as const,
129
+ orderKey: "order_one",
130
+ publishableKey: "pk_test",
131
+ returnUrl: "https://app.test/receipt",
132
+ createSession,
133
+ };
134
+ const { rerender } = render(
135
+ <Checkout {...props} requestId="persisted-order" />,
136
+ );
137
+ await screen.findByText("Stripe iframe");
138
+ expect(createSession.mock.calls[0][0].requestId).toBe("persisted-order");
139
+ rerender(<Checkout {...props} requestId="persisted-order" />);
140
+ expect(createSession).toHaveBeenCalledTimes(1);
141
+ rerender(<Checkout {...props} orderKey="order_two" />);
142
+ await waitFor(() => expect(createSession).toHaveBeenCalledTimes(2));
143
+ const second = createSession.mock.calls[1][0].requestId;
144
+ expect(second).not.toBe("persisted-order");
145
+ rerender(<Checkout {...props} orderKey="order_three" />);
146
+ await waitFor(() => expect(createSession).toHaveBeenCalledTimes(3));
147
+ expect(createSession.mock.calls[2][0].requestId).not.toBe(second);
148
+ });
@@ -1,5 +1,5 @@
1
1
  "use client";
2
- import type { ReactNode } from "react";
2
+ import { useState, type ReactNode } from "react";
3
3
  import { EmbeddedCheckout } from "../components/EmbeddedCheckout.js";
4
4
  import { useWorkflowResource } from "./useWorkflowResource.js";
5
5
  import {
@@ -36,6 +36,8 @@ type Common = CheckoutFormOptions & {
36
36
  publishableKey: string;
37
37
  /** Changes when the price, signed-in customer, or referral attribution changes. */
38
38
  orderKey: string;
39
+ /** Optional durable logical-order identity, persisted by the host app. */
40
+ requestId?: string;
39
41
  returnUrl: string;
40
42
  onNavigate?: (url: string) => void;
41
43
  children?: (state: CheckoutRenderProps) => ReactNode;
@@ -57,11 +59,16 @@ export type CheckoutProps = Common &
57
59
  );
58
60
  /** All checkout integrations share one unstyled lifecycle. Callbacks must authenticate and validate prices on the server. */
59
61
  export function Checkout(props: CheckoutProps) {
60
- return <CheckoutBody key={`${props.orderKey}:${props.mode}`} {...props} />;
62
+ return (
63
+ <CheckoutBody
64
+ key={`${props.orderKey}:${props.mode}:${props.requestId ?? ""}`}
65
+ {...props}
66
+ />
67
+ );
61
68
  }
62
69
  function CheckoutBody(props: CheckoutProps) {
70
+ const [requestId] = useState(() => props.requestId ?? crypto.randomUUID());
63
71
  const state = useWorkflowResource(async () => {
64
- const requestId = crypto.randomUUID();
65
72
  const result =
66
73
  props.mode === "payment-intent"
67
74
  ? await props.createPaymentIntent({ requestId })
@@ -110,3 +110,36 @@ it("keeps completed payouts out of upcoming and sorts the remaining arrivals", a
110
110
  );
111
111
  expect(screen.getAllByText("po_paid")).toHaveLength(1);
112
112
  });
113
+ it("distinguishes an unavailable account from an empty balance and labels upcoming previews", async () => {
114
+ const load = vi.fn().mockResolvedValue({
115
+ status: "not_ready",
116
+ reason: "account_unavailable",
117
+ balances: [],
118
+ upcoming: [],
119
+ payouts: [],
120
+ nextCursor: null,
121
+ upcomingComplete: true,
122
+ });
123
+ const { unmount } = render(<Payouts scopeKey="unavailable" load={load} />);
124
+ expect((await screen.findByRole("alert")).textContent).toContain(
125
+ "not available",
126
+ );
127
+ expect(screen.queryByText("Not scheduled")).toBeNull();
128
+ unmount();
129
+ render(
130
+ <Payouts
131
+ scopeKey="preview"
132
+ load={async () => ({
133
+ status: "ready",
134
+ balances: [],
135
+ upcoming: [],
136
+ payouts: [],
137
+ nextCursor: null,
138
+ upcomingComplete: false,
139
+ })}
140
+ />,
141
+ );
142
+ expect(
143
+ await screen.findByText(/Upcoming payouts are a bounded preview/),
144
+ ).toBeTruthy();
145
+ });