@fantastic.dev/better-stripe 0.6.0 → 0.7.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 (39) hide show
  1. package/CHANGELOG.md +73 -45
  2. package/README.md +22 -14
  3. package/dist/client/webhooks/processors.js +9 -5
  4. package/dist/client/webhooks/processors.js.map +1 -1
  5. package/dist/component/_generated/component.d.ts +34 -0
  6. package/dist/component/_generated/component.d.ts.map +1 -1
  7. package/dist/component/billing/queries.d.ts +36 -0
  8. package/dist/component/billing/queries.d.ts.map +1 -1
  9. package/dist/component/billing/queries.js +10 -0
  10. package/dist/component/billing/queries.js.map +1 -1
  11. package/dist/component/billing/schema.d.ts +1 -0
  12. package/dist/component/billing/schema.d.ts.map +1 -1
  13. package/dist/component/billing/schema.js +1 -0
  14. package/dist/component/billing/schema.js.map +1 -1
  15. package/dist/component/connect/mutations.d.ts.map +1 -1
  16. package/dist/component/connect/mutations.js +46 -8
  17. package/dist/component/connect/mutations.js.map +1 -1
  18. package/dist/component/connect/queries.d.ts +3 -4
  19. package/dist/component/connect/queries.d.ts.map +1 -1
  20. package/dist/component/connect/queries.js +19 -10
  21. package/dist/component/connect/queries.js.map +1 -1
  22. package/dist/component/schema.d.ts +1 -0
  23. package/dist/react/hooks/useSplitBreakdown.d.ts +4 -6
  24. package/dist/react/hooks/useSplitBreakdown.d.ts.map +1 -1
  25. package/dist/react/hooks/useSplitBreakdown.js +4 -6
  26. package/dist/react/hooks/useSplitBreakdown.js.map +1 -1
  27. package/package.json +1 -1
  28. package/src/client/webhooks/processors.test.ts +16 -0
  29. package/src/client/webhooks/processors.ts +8 -3
  30. package/src/component/_generated/component.ts +46 -0
  31. package/src/component/billing/queries.ts +12 -0
  32. package/src/component/billing/schema.ts +1 -0
  33. package/src/component/connect/mutations.ts +68 -17
  34. package/src/component/connect/queries.ts +18 -10
  35. package/src/component/reversal-races.test.ts +41 -1
  36. package/src/component/seller-case-ownership.test.ts +61 -0
  37. package/src/component/store-subscriptions.test.ts +32 -0
  38. package/src/component/transfers.test.ts +69 -0
  39. package/src/react/hooks/useSplitBreakdown.ts +4 -6
@@ -1,7 +1,7 @@
1
1
  import { v } from "convex/values";
2
2
 
3
- import type { Id } from "../_generated/dataModel";
4
- import { mutation } from "../_generated/server";
3
+ import type { Doc, Id } from "../_generated/dataModel";
4
+ import { mutation, type MutationCtx } from "../_generated/server";
5
5
  import { feeRoutingFields } from "../lib/fees";
6
6
  import { computeReversalSlices } from "../lib/reversals";
7
7
  import {
@@ -12,6 +12,20 @@ import {
12
12
  transferFields,
13
13
  } from "./validators";
14
14
 
15
+ async function paymentSeller(
16
+ ctx: MutationCtx,
17
+ stripePaymentIntentId?: string,
18
+ ): Promise<string | undefined> {
19
+ if (!stripePaymentIntentId) return undefined;
20
+ const payment = await ctx.db
21
+ .query("payments")
22
+ .withIndex("by_stripe_payment_intent_id", (q) =>
23
+ q.eq("stripePaymentIntentId", stripePaymentIntentId),
24
+ )
25
+ .first();
26
+ return payment?.destinationAccountId;
27
+ }
28
+
15
29
  // =============================================================================
16
30
  // PAYMENT MUTATIONS
17
31
  // =============================================================================
@@ -87,6 +101,26 @@ export const upsertPayment = mutation({
87
101
  }
88
102
  }
89
103
 
104
+ // A refund/dispute can arrive before the platform charge has its seller
105
+ // routing metadata. Reconcile those case owners once routing is known.
106
+ const sellerAccountId =
107
+ args.destinationAccountId ?? existing?.destinationAccountId;
108
+ if (sellerAccountId) {
109
+ for (const table of ["refunds", "disputes"] as const) {
110
+ const cases = await ctx.db
111
+ .query(table)
112
+ .withIndex("by_stripe_payment_intent_id", (q) =>
113
+ q.eq("stripePaymentIntentId", args.stripePaymentIntentId),
114
+ )
115
+ .take(100);
116
+ for (const record of cases)
117
+ if (!record.accountId)
118
+ await ctx.db.patch(table, record._id, {
119
+ accountId: sellerAccountId,
120
+ });
121
+ }
122
+ }
123
+
90
124
  return null;
91
125
  },
92
126
  });
@@ -140,10 +174,13 @@ export const upsertRefund = mutation({
140
174
  )
141
175
  .first();
142
176
 
177
+ const accountId =
178
+ args.accountId ?? (await paymentSeller(ctx, args.stripePaymentIntentId));
179
+ const record = { ...args, ...(accountId ? { accountId } : {}) };
143
180
  if (existing) {
144
- await ctx.db.patch("refunds", existing._id, args);
181
+ await ctx.db.patch("refunds", existing._id, record);
145
182
  } else {
146
- await ctx.db.insert("refunds", args);
183
+ await ctx.db.insert("refunds", record);
147
184
  }
148
185
 
149
186
  // Denormalize cumulative refund state onto the linked `payments` row so
@@ -278,10 +315,13 @@ export const upsertDispute = mutation({
278
315
  )
279
316
  .first();
280
317
 
318
+ const accountId =
319
+ args.accountId ?? (await paymentSeller(ctx, args.stripePaymentIntentId));
320
+ const record = { ...args, ...(accountId ? { accountId } : {}) };
281
321
  if (existing) {
282
- await ctx.db.patch("disputes", existing._id, args);
322
+ await ctx.db.patch("disputes", existing._id, record);
283
323
  } else {
284
- await ctx.db.insert("disputes", args);
324
+ await ctx.db.insert("disputes", record);
285
325
  }
286
326
 
287
327
  return null;
@@ -311,7 +351,8 @@ function deriveReversalState(
311
351
  return {
312
352
  reversedAmount: reversedAmount > 0 ? reversedAmount : undefined,
313
353
  reversalStatus,
314
- status: reversalStatus === "fully_reversed" ? ("reversed" as const) : baseStatus,
354
+ status:
355
+ reversalStatus === "fully_reversed" ? ("reversed" as const) : baseStatus,
315
356
  };
316
357
  }
317
358
 
@@ -333,7 +374,11 @@ export const upsertTransfer = mutation({
333
374
  existing.reversedAmount ?? 0,
334
375
  args.reversedAmount ?? 0,
335
376
  );
336
- const derived = deriveReversalState(args.amount, reversedAmount, args.status);
377
+ const derived = deriveReversalState(
378
+ args.amount,
379
+ reversedAmount,
380
+ args.status,
381
+ );
337
382
  await ctx.db.patch("transfers", existing._id, { ...args, ...derived });
338
383
  } else {
339
384
  const derived = deriveReversalState(
@@ -459,21 +504,27 @@ export const claimReversalSlices = mutation({
459
504
  if (existingOp) {
460
505
  const slices = [];
461
506
  for (const s of existingOp.slices) {
462
- slices.push({ ...s, confirmed: await confirmedFor(s.stripeTransferId) });
507
+ slices.push({
508
+ ...s,
509
+ confirmed: await confirmedFor(s.stripeTransferId),
510
+ });
463
511
  }
464
512
  return { replay: true, slices };
465
513
  }
466
514
 
467
515
  // Original split legs only — reinstatement payouts are audit rows and
468
516
  // must never be re-reversed (mirrors listTransfersByCharge).
469
- const rows = (
470
- await ctx.db
471
- .query("transfers")
472
- .withIndex("by_source_charge_id", (q) =>
473
- q.eq("sourceChargeId", args.sourceChargeId),
474
- )
475
- .take(50)
476
- ).filter((t) => !t.reinstatement);
517
+ // Claim every original split leg, including those beyond the former
518
+ // 50-row preview. Read/write limits abort the transaction before a partial
519
+ // claim can commit; no Stripe call happens until this mutation succeeds.
520
+ const rows: Doc<"transfers">[] = [];
521
+ for await (const transfer of ctx.db
522
+ .query("transfers")
523
+ .withIndex("by_source_charge_id", (q) =>
524
+ q.eq("sourceChargeId", args.sourceChargeId),
525
+ )) {
526
+ if (!transfer.reinstatement) rows.push(transfer);
527
+ }
477
528
 
478
529
  const byId = new Map(rows.map((r) => [r.stripeTransferId, r]));
479
530
  const slices = computeReversalSlices(
@@ -293,24 +293,32 @@ export const getTransferByStripeId = query({
293
293
  /**
294
294
  * All transfers funded from one charge (the legs of a split).
295
295
  *
296
- * ⚠️ CAPPED at `limit ?? 50` rows. Unlike the per-account earnings view this is
297
- * safe in practice — a single sale has a handful of split legs, far below the
298
- * cap (BTS-64) — but pass a higher `limit` if you ever split across many
299
- * recipients.
296
+ * Returns every original split leg unless an explicit limit is provided.
297
+ * Reinstatement audit rows do not consume that limit. Database read/response
298
+ * limits fail the query rather than silently returning incomplete money data.
300
299
  */
301
300
  export const listTransfersByCharge = query({
302
301
  args: { sourceChargeId: v.string(), limit: v.optional(v.number()) },
303
302
  returns: v.array(transferDocValidator),
304
303
  handler: async (ctx, args) => {
305
- const rows = await ctx.db
304
+ if (
305
+ args.limit !== undefined &&
306
+ (!Number.isInteger(args.limit) || args.limit < 0)
307
+ ) {
308
+ throw new Error("limit must be a non-negative integer");
309
+ }
310
+ const rows: Doc<"transfers">[] = [];
311
+ if (args.limit === 0) return rows;
312
+ for await (const transfer of ctx.db
306
313
  .query("transfers")
307
314
  .withIndex("by_source_charge_id", (q) =>
308
315
  q.eq("sourceChargeId", args.sourceChargeId),
309
- )
310
- .take(args.limit ?? 50);
311
- // Exclude reinstatement payouts — this returns the original split legs the
312
- // reverse/split engines act on (reinstatement rows are audit-only here).
313
- return rows.filter((t) => !t.reinstatement);
316
+ )) {
317
+ if (transfer.reinstatement) continue;
318
+ rows.push(transfer);
319
+ if (args.limit !== undefined && rows.length >= args.limit) break;
320
+ }
321
+ return rows;
314
322
  },
315
323
  });
316
324
 
@@ -296,7 +296,47 @@ describe("BTS-63 race 1 — refund path: concurrent DISTINCT refunds must not do
296
296
  });
297
297
 
298
298
  describe("BTS-63 race 2 — dispute path: retry after partial success must converge", () => {
299
- const DISPUTE = { sourceChargeId: "ch_dp", amount: 4500, operationId: "dp_1" };
299
+ it("claws back all 120 legs and converges after a failure beyond the former cap", async () => {
300
+ const t = convexTest(schema, modules);
301
+ const ctx = makeCtx(t);
302
+ const component = proxyComponent();
303
+ const stripe = makeRacingStripe();
304
+ for (let i = 0; i < 120; i++) {
305
+ await seedLeg(t, {
306
+ stripeTransferId: `tr_large_${i}`,
307
+ sourceChargeId: "ch_large",
308
+ amount: 100,
309
+ });
310
+ }
311
+ const opts = {
312
+ sourceChargeId: "ch_large",
313
+ amount: 6000,
314
+ operationId: "dispute_large",
315
+ };
316
+ stripe.failNextCallFor("tr_large_75");
317
+ await expect(
318
+ reverseTransfers(asStripe(stripe), component, ctx, opts),
319
+ ).rejects.toThrow(/network error/);
320
+ await reverseTransfers(asStripe(stripe), component, ctx, opts);
321
+ // A further redelivery must not move money or conflict with earlier keys.
322
+ await reverseTransfers(asStripe(stripe), component, ctx, opts);
323
+ const rows = await t.query(api.connect.queries.listTransfersByCharge, {
324
+ sourceChargeId: "ch_large",
325
+ });
326
+ expect(rows).toHaveLength(120);
327
+ expect(rows.reduce((sum, row) => sum + (row.reversedAmount ?? 0), 0)).toBe(
328
+ 6000,
329
+ );
330
+ for (const row of rows) {
331
+ expect(row.reversedAmount).toBe(50);
332
+ expect(stripe.total(row.stripeTransferId)).toBe(50);
333
+ }
334
+ });
335
+ const DISPUTE = {
336
+ sourceChargeId: "ch_dp",
337
+ amount: 4500,
338
+ operationId: "dp_1",
339
+ };
300
340
 
301
341
  async function seedDisputeLegs(t: ReturnType<typeof convexTest>) {
302
342
  await seedLeg(t, {
@@ -0,0 +1,61 @@
1
+ import { convexTest } from "convex-test";
2
+ import { expect, it } from "vitest";
3
+ import { api } from "./_generated/api";
4
+ import schema from "./schema";
5
+ const modules = import.meta.glob("./**/*.*s");
6
+ const payment = {
7
+ stripePaymentIntentId: "pi_sale",
8
+ userId: "customer",
9
+ amount: 10000,
10
+ currency: "usd",
11
+ status: "succeeded" as const,
12
+ destinationAccountId: "acct_store",
13
+ chargeType: "separate" as const,
14
+ };
15
+ const refund = {
16
+ stripeRefundId: "re_sale",
17
+ stripePaymentIntentId: "pi_sale",
18
+ amount: 1000,
19
+ currency: "usd",
20
+ status: "succeeded" as const,
21
+ };
22
+ const dispute = {
23
+ stripeDisputeId: "dp_sale",
24
+ stripePaymentIntentId: "pi_sale",
25
+ amount: 10000,
26
+ currency: "usd",
27
+ status: "needs_response" as const,
28
+ reason: "product_not_received",
29
+ isChargeRefundable: false,
30
+ };
31
+ it("attributes refunds and disputes to the seller instead of the purchasing customer", async () => {
32
+ const t = convexTest(schema, modules);
33
+ await t.mutation(api.connect.mutations.upsertPayment, payment);
34
+ await t.mutation(api.connect.mutations.upsertRefund, refund);
35
+ await t.mutation(api.connect.mutations.upsertDispute, dispute);
36
+ expect(
37
+ await t.query(api.connect.queries.listRefunds, { accountId: "acct_store" }),
38
+ ).toHaveLength(1);
39
+ expect(
40
+ await t.query(api.connect.queries.listDisputes, {
41
+ accountId: "acct_store",
42
+ }),
43
+ ).toHaveLength(1);
44
+ expect(
45
+ await t.query(api.connect.queries.listRefunds, { accountId: "acct_other" }),
46
+ ).toHaveLength(0);
47
+ });
48
+ it("reconciles cases delivered before the seller's settlement metadata", async () => {
49
+ const t = convexTest(schema, modules);
50
+ await t.mutation(api.connect.mutations.upsertRefund, refund);
51
+ await t.mutation(api.connect.mutations.upsertDispute, dispute);
52
+ await t.mutation(api.connect.mutations.upsertPayment, payment);
53
+ expect(
54
+ await t.query(api.connect.queries.listRefunds, { accountId: "acct_store" }),
55
+ ).toHaveLength(1);
56
+ expect(
57
+ await t.query(api.connect.queries.listDisputes, {
58
+ accountId: "acct_store",
59
+ }),
60
+ ).toHaveLength(1);
61
+ });
@@ -0,0 +1,32 @@
1
+ import { convexTest } from "convex-test";
2
+ import { expect, it } from "vitest";
3
+ import { api } from "./_generated/api";
4
+ import schema from "./schema";
5
+ const modules = import.meta.glob("./**/*.*s");
6
+ it("scopes seller subscriptions by catalog price for both destination and split sales", async () => {
7
+ const t = convexTest(schema, modules);
8
+ await t.run(async (ctx) => {
9
+ for (const [id, priceId] of [
10
+ ["sub_direct", "price_seller"],
11
+ ["sub_referred", "price_seller"],
12
+ ["sub_other", "price_other"],
13
+ ]) {
14
+ await ctx.db.insert("subscriptions", {
15
+ stripeSubscriptionId: id,
16
+ priceId,
17
+ userId: "customer",
18
+ status: "active",
19
+ cancelAtPeriodEnd: false,
20
+ isTrialing: false,
21
+ isCollectionPaused: false,
22
+ });
23
+ }
24
+ });
25
+ const rows = await t.query(api.billing.queries.listSubscriptionsByPrice, {
26
+ priceId: "price_seller",
27
+ });
28
+ expect(rows.map((s) => s.stripeSubscriptionId).sort()).toEqual([
29
+ "sub_direct",
30
+ "sub_referred",
31
+ ]);
32
+ });
@@ -2,11 +2,80 @@
2
2
  import { convexTest } from "convex-test";
3
3
  import { describe, expect, it } from "vitest";
4
4
 
5
+ import { createUseSplitBreakdown } from "../react/hooks/useSplitBreakdown.js";
6
+
5
7
  import { api } from "./_generated/api.js";
6
8
  import schema from "./schema.js";
7
9
 
8
10
  const modules = import.meta.glob("./**/*.*s");
9
11
 
12
+ describe("complete split-leg reads", () => {
13
+ it("returns every original leg beyond the former 50-row cap and filters audit rows before limiting", async () => {
14
+ const t = convexTest(schema, modules);
15
+ await t.mutation(api.connect.mutations.upsertTransfer, {
16
+ stripeTransferId: "tr_audit_first",
17
+ sourceChargeId: "ch_many",
18
+ destinationAccountId: "acct_store",
19
+ amount: 100,
20
+ currency: "usd",
21
+ status: "paid",
22
+ reinstatement: true,
23
+ });
24
+ for (let i = 0; i < 120; i++) {
25
+ await t.mutation(api.connect.mutations.upsertTransfer, {
26
+ stripeTransferId: `tr_many_${i}`,
27
+ sourceChargeId: "ch_many",
28
+ destinationAccountId: `acct_${i}`,
29
+ amount: 100,
30
+ currency: "usd",
31
+ status: "paid",
32
+ role: i % 2 === 0 ? "store" : "affiliate",
33
+ });
34
+ }
35
+ const rows = await t.query(api.connect.queries.listTransfersByCharge, {
36
+ sourceChargeId: "ch_many",
37
+ });
38
+ expect(rows).toHaveLength(120);
39
+ expect(rows.reduce((sum, row) => sum + row.amount, 0)).toBe(12000);
40
+ expect(rows.some((row) => row.reinstatement)).toBe(false);
41
+ const useSplitBreakdown = createUseSplitBreakdown(
42
+ () => rows,
43
+ "chargeQuery",
44
+ );
45
+ expect(
46
+ useSplitBreakdown({ sourceChargeId: "ch_many", saleAmount: 13000 })
47
+ .breakdown,
48
+ ).toMatchObject({
49
+ store: 6000,
50
+ affiliate: 6000,
51
+ recipientsTotal: 12000,
52
+ platform: 1000,
53
+ });
54
+ const preview = await t.query(api.connect.queries.listTransfersByCharge, {
55
+ sourceChargeId: "ch_many",
56
+ limit: 50,
57
+ });
58
+ expect(preview).toHaveLength(50);
59
+ expect(preview[0].stripeTransferId).toBe("tr_many_0");
60
+ expect(
61
+ await t.query(api.connect.queries.listTransfersByCharge, {
62
+ sourceChargeId: "ch_many",
63
+ limit: 0,
64
+ }),
65
+ ).toEqual([]);
66
+ });
67
+
68
+ it.each([-1, 1.5])("rejects invalid explicit limit %s", async (limit) => {
69
+ const t = convexTest(schema, modules);
70
+ await expect(
71
+ t.query(api.connect.queries.listTransfersByCharge, {
72
+ sourceChargeId: "ch_many",
73
+ limit,
74
+ }),
75
+ ).rejects.toThrow("non-negative integer");
76
+ });
77
+ });
78
+
10
79
  describe("transfers ledger (BTS-12)", () => {
11
80
  it("upserts a transfer and reads it back by stripe id", async () => {
12
81
  const t = convexTest(schema, modules);
@@ -42,7 +42,7 @@ export type UseSplitBreakdownResult = {
42
42
  };
43
43
 
44
44
  /**
45
- * Factory for a per-sale split-breakdown hook (BTS-36): reads the sale's
45
+ * Factory for a per-sale split-breakdown hook: reads the sale's
46
46
  * original split legs (`listTransfersByCharge` — reinstatement rows are
47
47
  * excluded server-side) and sums them by role, with the platform's share
48
48
  * derived from the sale amount when provided. Presentation math only — the
@@ -51,11 +51,9 @@ export type UseSplitBreakdownResult = {
51
51
  * Pass `sourceChargeId: undefined` to skip the query. The `useQuery` binding
52
52
  * must support Convex's `"skip"` sentinel.
53
53
  *
54
- * Note (BTS-64): the underlying `listTransfersByCharge` query is capped at 50
55
- * rows. Unlike the per-account earnings totals this is safe in practice — a
56
- * single sale has only a handful of split legs — but a sale split across more
57
- * than 50 recipients would undercount here; pass a higher `limit` on that query
58
- * if you ever do that.
54
+ * Bind this to a query that returns every original leg for the charge. The
55
+ * component's `listTransfersByCharge` returns all legs by default; applying an
56
+ * explicit preview limit in the app's wrapper would understate these totals.
59
57
  */
60
58
  export function createUseSplitBreakdown(
61
59
  useQuery: (queryRef: any, args: Record<string, unknown> | "skip") => any,