@fantastic.dev/better-stripe 0.8.1 → 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 (175) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/MIGRATION.md +12 -0
  3. package/README.md +46 -2
  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 +115 -0
  63. package/dist/component/_generated/component.d.ts.map +1 -1
  64. package/dist/component/connect/queries.d.ts +30 -1
  65. package/dist/component/connect/queries.d.ts.map +1 -1
  66. package/dist/component/connect/queries.js +19 -2
  67. package/dist/component/connect/queries.js.map +1 -1
  68. package/dist/component/schema.d.ts +67 -0
  69. package/dist/component/schema.js +4 -0
  70. package/dist/component/schema.js.map +1 -1
  71. package/dist/component/settlement/mutations.d.ts +47 -0
  72. package/dist/component/settlement/mutations.d.ts.map +1 -0
  73. package/dist/component/settlement/mutations.js +104 -0
  74. package/dist/component/settlement/mutations.js.map +1 -0
  75. package/dist/component/settlement/queries.d.ts +29 -0
  76. package/dist/component/settlement/queries.d.ts.map +1 -0
  77. package/dist/component/settlement/queries.js +35 -0
  78. package/dist/component/settlement/queries.js.map +1 -0
  79. package/dist/component/settlement/schema.d.ts +99 -0
  80. package/dist/component/settlement/schema.d.ts.map +1 -0
  81. package/dist/component/settlement/schema.js +35 -0
  82. package/dist/component/settlement/schema.js.map +1 -0
  83. package/dist/react/workflows/AffiliatePayoutEditor.d.ts +41 -0
  84. package/dist/react/workflows/AffiliatePayoutEditor.d.ts.map +1 -0
  85. package/dist/react/workflows/AffiliatePayoutEditor.js +80 -0
  86. package/dist/react/workflows/AffiliatePayoutEditor.js.map +1 -0
  87. package/dist/react/workflows/AffiliateRateEditor.d.ts +26 -0
  88. package/dist/react/workflows/AffiliateRateEditor.d.ts.map +1 -0
  89. package/dist/react/workflows/AffiliateRateEditor.js +49 -0
  90. package/dist/react/workflows/AffiliateRateEditor.js.map +1 -0
  91. package/dist/react/workflows/Checkout.d.ts +3 -1
  92. package/dist/react/workflows/Checkout.d.ts.map +1 -1
  93. package/dist/react/workflows/Checkout.js +3 -2
  94. package/dist/react/workflows/Checkout.js.map +1 -1
  95. package/dist/react/workflows/CheckoutForms.d.ts +2 -0
  96. package/dist/react/workflows/CheckoutForms.d.ts.map +1 -1
  97. package/dist/react/workflows/CheckoutForms.js +22 -16
  98. package/dist/react/workflows/CheckoutForms.js.map +1 -1
  99. package/dist/react/workflows/IntegrationSwitch.js +2 -2
  100. package/dist/react/workflows/IntegrationSwitch.js.map +1 -1
  101. package/dist/react/workflows/Pagination.d.ts +32 -0
  102. package/dist/react/workflows/Pagination.d.ts.map +1 -0
  103. package/dist/react/workflows/Pagination.js +49 -0
  104. package/dist/react/workflows/Pagination.js.map +1 -0
  105. package/dist/react/workflows/Payouts.d.ts +3 -18
  106. package/dist/react/workflows/Payouts.d.ts.map +1 -1
  107. package/dist/react/workflows/Payouts.js +2 -2
  108. package/dist/react/workflows/Payouts.js.map +1 -1
  109. package/dist/react/workflows/PlanPricingEditor.d.ts +35 -0
  110. package/dist/react/workflows/PlanPricingEditor.d.ts.map +1 -0
  111. package/dist/react/workflows/PlanPricingEditor.js +64 -0
  112. package/dist/react/workflows/PlanPricingEditor.js.map +1 -0
  113. package/dist/react/workflows/index.d.ts +8 -0
  114. package/dist/react/workflows/index.d.ts.map +1 -1
  115. package/dist/react/workflows/index.js +4 -0
  116. package/dist/react/workflows/index.js.map +1 -1
  117. package/docs/react-workflows-audit.md +19 -0
  118. package/docs/react-workflows.md +94 -3
  119. package/docs/settlement.md +158 -0
  120. package/package.json +6 -4
  121. package/src/client/billing/checkout-idempotency.test.ts +82 -0
  122. package/src/client/billing/checkout.ts +6 -1
  123. package/src/client/billing/providerReads.test.ts +60 -0
  124. package/src/client/billing/providerReads.ts +30 -0
  125. package/src/client/billing/subscriptions.ts +1 -8
  126. package/src/client/connect/payoutSnapshot.test.ts +91 -0
  127. package/src/client/connect/payoutSnapshot.ts +130 -0
  128. package/src/client/connect/transfers.test.ts +15 -4
  129. package/src/client/connect/transfers.ts +13 -42
  130. package/src/client/core/account-idempotency.test.ts +78 -0
  131. package/src/client/core/accounts.ts +18 -25
  132. package/src/client/core/idempotency.test.ts +10 -0
  133. package/src/client/core/idempotency.ts +11 -0
  134. package/src/client/index.ts +84 -103
  135. package/src/client/settlement/allocation.test.ts +98 -0
  136. package/src/client/settlement/allocation.ts +110 -0
  137. package/src/client/settlement/commissions.test.ts +103 -0
  138. package/src/client/settlement/commissions.ts +125 -0
  139. package/src/client/settlement/execution.test.ts +186 -0
  140. package/src/client/settlement/execution.ts +134 -0
  141. package/src/client/settlement/reconciliation.ts +119 -0
  142. package/src/client/settlement/settleSale.ts +59 -0
  143. package/src/client/settlement/verifiedSale.test.ts +139 -0
  144. package/src/client/settlement/verifiedSale.ts +159 -0
  145. package/src/client/webhooks/asyncHooks.ts +38 -0
  146. package/src/client/webhooks/processors.test.ts +9 -9
  147. package/src/component/_generated/api.ts +4 -0
  148. package/src/component/_generated/component.ts +141 -0
  149. package/src/component/connect/queries.ts +26 -2
  150. package/src/component/earnings-totals.test.ts +13 -0
  151. package/src/component/schema.ts +8 -0
  152. package/src/component/settlement/mutations.ts +119 -0
  153. package/src/component/settlement/queries.ts +38 -0
  154. package/src/component/settlement/schema.ts +45 -0
  155. package/src/component/settlement-claims.test.ts +77 -0
  156. package/src/component/transfers.test.ts +48 -0
  157. package/src/react/components/EmbeddedDisputes.render.test.tsx +2 -2
  158. package/src/react/index.payment-element.test.ts +22 -37
  159. package/src/react/workflows/AffiliatePayoutEditor.test.tsx +90 -0
  160. package/src/react/workflows/AffiliatePayoutEditor.tsx +201 -0
  161. package/src/react/workflows/AffiliateRateEditor.test.tsx +36 -0
  162. package/src/react/workflows/AffiliateRateEditor.tsx +92 -0
  163. package/src/react/workflows/Checkout.test.tsx +30 -0
  164. package/src/react/workflows/Checkout.tsx +10 -3
  165. package/src/react/workflows/CheckoutForms.test.tsx +40 -0
  166. package/src/react/workflows/CheckoutForms.tsx +37 -16
  167. package/src/react/workflows/IntegrationSwitch.test.tsx +25 -0
  168. package/src/react/workflows/IntegrationSwitch.tsx +2 -2
  169. package/src/react/workflows/Pagination.test.tsx +53 -0
  170. package/src/react/workflows/Pagination.tsx +93 -0
  171. package/src/react/workflows/Payouts.test.tsx +33 -0
  172. package/src/react/workflows/Payouts.tsx +21 -15
  173. package/src/react/workflows/PlanPricingEditor.test.tsx +79 -0
  174. package/src/react/workflows/PlanPricingEditor.tsx +156 -0
  175. package/src/react/workflows/index.ts +22 -0
@@ -0,0 +1,139 @@
1
+ import type Stripe from "stripe";
2
+ import { expect, it, vi } from "vitest";
3
+ import { getVerifiedSale } from "./verifiedSale.js";
4
+ function fixture() {
5
+ const payment = {
6
+ id: "pi_1",
7
+ status: "succeeded",
8
+ currency: "usd",
9
+ amount_received: 10000,
10
+ metadata: { seller: "store" },
11
+ latest_charge: {
12
+ id: "ch_1",
13
+ amount: 10000,
14
+ currency: "usd",
15
+ amount_refunded: 0,
16
+ disputed: false,
17
+ balance_transaction: { currency: "usd", fee: 320 },
18
+ },
19
+ };
20
+ const raw = {
21
+ paymentIntents: { retrieve: vi.fn().mockResolvedValue(payment) },
22
+ subscriptions: {
23
+ retrieve: vi.fn().mockResolvedValue({
24
+ id: "sub_1",
25
+ metadata: { ref: "a" },
26
+ items: {
27
+ data: [{ price: { id: "price_current", product: "prod_current" } }],
28
+ },
29
+ }),
30
+ },
31
+ invoices: {
32
+ retrieve: vi.fn().mockResolvedValue({
33
+ id: "in_1",
34
+ status: "paid",
35
+ amount_paid: 10000,
36
+ currency: "usd",
37
+ parent: { subscription_details: { subscription: "sub_1" } },
38
+ }),
39
+ },
40
+ invoicePayments: {
41
+ list: vi.fn().mockResolvedValue({
42
+ data: [{ status: "paid", payment: { payment_intent: "pi_1" } }],
43
+ has_more: false,
44
+ }),
45
+ },
46
+ };
47
+ return { payment, raw, stripe: raw as unknown as Stripe };
48
+ }
49
+ it("returns actual fees, verified invoice linkage and current price instead of stale metadata", async () => {
50
+ const { stripe, raw } = fixture();
51
+ expect(
52
+ await getVerifiedSale(stripe, {
53
+ paymentIntentId: "pi_1",
54
+ subscriptionId: "sub_1",
55
+ invoiceId: "in_1",
56
+ }),
57
+ ).toMatchObject({
58
+ status: "ready",
59
+ chargeId: "ch_1",
60
+ processingFee: 320,
61
+ currency: "usd",
62
+ stripePriceId: "price_current",
63
+ stripeProductId: "prod_current",
64
+ invoiceId: "in_1",
65
+ });
66
+ expect(raw.paymentIntents.retrieve).toHaveBeenCalledWith("pi_1", {
67
+ expand: ["latest_charge.balance_transaction"],
68
+ });
69
+ });
70
+ it("reports missing fees and unsuccessful payments as non-ready, never zero fees", async () => {
71
+ const { stripe, raw, payment } = fixture();
72
+ raw.paymentIntents.retrieve.mockResolvedValue({
73
+ ...payment,
74
+ latest_charge: { ...payment.latest_charge, balance_transaction: null },
75
+ });
76
+ expect(await getVerifiedSale(stripe, { paymentIntentId: "pi_1" })).toEqual({
77
+ status: "pending",
78
+ reason: "processing_fee_unavailable",
79
+ retryable: true,
80
+ });
81
+ raw.paymentIntents.retrieve.mockResolvedValue({
82
+ ...payment,
83
+ status: "requires_payment_method",
84
+ });
85
+ expect(
86
+ await getVerifiedSale(stripe, { paymentIntentId: "pi_1" }),
87
+ ).toMatchObject({ status: "not_payable", retryable: false });
88
+ });
89
+ it("rejects mismatched currencies and unrelated invoice payments", async () => {
90
+ const { stripe, raw, payment } = fixture();
91
+ raw.paymentIntents.retrieve.mockResolvedValue({
92
+ ...payment,
93
+ latest_charge: {
94
+ ...payment.latest_charge,
95
+ balance_transaction: { currency: "eur", fee: 320 },
96
+ },
97
+ });
98
+ await expect(
99
+ getVerifiedSale(stripe, { paymentIntentId: "pi_1" }),
100
+ ).rejects.toThrow();
101
+ raw.paymentIntents.retrieve.mockResolvedValue(payment);
102
+ raw.invoicePayments.list.mockResolvedValue({
103
+ data: [{ status: "paid", payment: { payment_intent: "pi_other" } }],
104
+ has_more: false,
105
+ });
106
+ await expect(
107
+ getVerifiedSale(stripe, {
108
+ paymentIntentId: "pi_1",
109
+ invoiceId: "in_1",
110
+ subscriptionId: "sub_1",
111
+ }),
112
+ ).rejects.toThrow();
113
+ });
114
+ it("marks processing payments as retryable without paying recipients", async () => {
115
+ const { stripe, raw, payment } = fixture();
116
+ raw.paymentIntents.retrieve.mockResolvedValue({
117
+ ...payment,
118
+ status: "processing",
119
+ });
120
+ expect(await getVerifiedSale(stripe, { paymentIntentId: "pi_1" })).toEqual({
121
+ status: "pending",
122
+ reason: "payment_processing",
123
+ retryable: true,
124
+ });
125
+ });
126
+
127
+ it("refuses partial invoice payments rather than awarding a fixed commission more than once", async () => {
128
+ const { stripe, raw } = fixture();
129
+ raw.invoices.retrieve.mockResolvedValue({
130
+ id: "in_1",
131
+ status: "paid",
132
+ amount_paid: 20000,
133
+ currency: "usd",
134
+ parent: { subscription_details: { subscription: "sub_1" } },
135
+ });
136
+ await expect(
137
+ getVerifiedSale(stripe, { paymentIntentId: "pi_1", invoiceId: "in_1" }),
138
+ ).rejects.toThrow("Partial invoice payments");
139
+ });
@@ -0,0 +1,159 @@
1
+ import type Stripe from "stripe";
2
+ import { isBetterStripeError, throwStripeError } from "../errors.js";
3
+ export type VerifiedSaleOptions = {
4
+ paymentIntentId: string;
5
+ subscriptionId?: string;
6
+ invoiceId?: string;
7
+ };
8
+ export type VerifiedSale =
9
+ | { status: "pending" | "not_payable"; reason: string; retryable: boolean }
10
+ | {
11
+ status: "ready";
12
+ paymentIntentId: string;
13
+ chargeId: string;
14
+ gross: number;
15
+ processingFee: number;
16
+ currency: string;
17
+ metadata: Record<string, string>;
18
+ invoiceId?: string;
19
+ subscriptionId?: string;
20
+ stripePriceId?: string;
21
+ stripeProductId?: string;
22
+ };
23
+ const id = (value: string | { id: string } | null | undefined) =>
24
+ typeof value === "string" ? value : value?.id;
25
+ export async function getVerifiedSale(
26
+ stripe: Stripe,
27
+ opts: VerifiedSaleOptions,
28
+ ): Promise<VerifiedSale> {
29
+ try {
30
+ const payment = await stripe.paymentIntents.retrieve(opts.paymentIntentId, {
31
+ expand: ["latest_charge.balance_transaction"],
32
+ });
33
+ if (payment.status === "processing")
34
+ return {
35
+ status: "pending",
36
+ reason: "payment_processing",
37
+ retryable: true,
38
+ };
39
+ if (payment.status !== "succeeded")
40
+ return {
41
+ status: "not_payable",
42
+ reason: "payment_not_succeeded",
43
+ retryable: false,
44
+ };
45
+ const charge = payment.latest_charge;
46
+ if (!charge || typeof charge === "string")
47
+ return {
48
+ status: "pending",
49
+ reason: "charge_unavailable",
50
+ retryable: true,
51
+ };
52
+ if (charge.amount_refunded > 0 || charge.disputed)
53
+ return {
54
+ status: "not_payable",
55
+ reason: "charge_refunded_or_disputed",
56
+ retryable: false,
57
+ };
58
+ const balance = charge.balance_transaction;
59
+ if (!balance || typeof balance === "string")
60
+ return {
61
+ status: "pending",
62
+ reason: "processing_fee_unavailable",
63
+ retryable: true,
64
+ };
65
+ if (
66
+ balance.currency !== payment.currency ||
67
+ charge.currency !== payment.currency
68
+ )
69
+ throwStripeError(
70
+ "INVALID_CONFIGURATION",
71
+ "Settlement currency must match the payment and processing currency.",
72
+ );
73
+ if (
74
+ ![payment.amount_received, balance.fee].every(Number.isSafeInteger) ||
75
+ balance.fee < 0 ||
76
+ balance.fee > payment.amount_received ||
77
+ charge.amount !== payment.amount_received
78
+ )
79
+ throwStripeError(
80
+ "INVALID_CONFIGURATION",
81
+ "Unsupported payment or processing amounts.",
82
+ );
83
+ if (opts.subscriptionId && !opts.invoiceId)
84
+ throwStripeError(
85
+ "INVALID_CONFIGURATION",
86
+ "Subscription settlement requires a paid invoice.",
87
+ );
88
+ const invoice = opts.invoiceId
89
+ ? await stripe.invoices.retrieve(opts.invoiceId)
90
+ : null;
91
+ if (invoice && invoice.status !== "paid")
92
+ return { status: "pending", reason: "invoice_not_paid", retryable: true };
93
+ const subscriptionId = id(
94
+ invoice?.parent?.subscription_details?.subscription,
95
+ );
96
+ if (
97
+ invoice &&
98
+ (invoice.currency !== payment.currency ||
99
+ (opts.subscriptionId && subscriptionId !== opts.subscriptionId))
100
+ )
101
+ throwStripeError(
102
+ "INVALID_CONFIGURATION",
103
+ "Invoice currency or subscription does not match settlement.",
104
+ );
105
+ if (invoice) {
106
+ if (invoice.amount_paid !== payment.amount_received)
107
+ throwStripeError(
108
+ "INVALID_CONFIGURATION",
109
+ "Partial invoice payments require an explicitly resolved invoice allocation.",
110
+ );
111
+ const linked = await stripe.invoicePayments.list({
112
+ invoice: invoice.id,
113
+ limit: 100,
114
+ status: "paid",
115
+ payment: { type: "payment_intent", payment_intent: payment.id },
116
+ });
117
+ if (
118
+ !linked.data.some(
119
+ (row) =>
120
+ row.status === "paid" &&
121
+ id(row.payment.payment_intent) === payment.id,
122
+ )
123
+ )
124
+ throwStripeError(
125
+ "INVALID_CONFIGURATION",
126
+ "Payment does not belong to this paid invoice.",
127
+ );
128
+ }
129
+ const subscription = subscriptionId
130
+ ? await stripe.subscriptions.retrieve(subscriptionId)
131
+ : null;
132
+ if (subscription && subscription.items.data.length !== 1)
133
+ throwStripeError(
134
+ "INVALID_CONFIGURATION",
135
+ "Resolve multi-price subscription allocations explicitly.",
136
+ );
137
+ const price = subscription?.items.data[0]?.price;
138
+ const metadata = subscription?.metadata ?? payment.metadata;
139
+ return {
140
+ status: "ready",
141
+ paymentIntentId: payment.id,
142
+ chargeId: charge.id,
143
+ gross: payment.amount_received,
144
+ processingFee: balance.fee,
145
+ currency: payment.currency,
146
+ metadata,
147
+ invoiceId: invoice?.id,
148
+ subscriptionId,
149
+ stripePriceId: price?.id ?? metadata.stripePriceId,
150
+ stripeProductId: id(price?.product) ?? metadata.stripeProductId,
151
+ };
152
+ } catch (error) {
153
+ if (isBetterStripeError(error)) throw error;
154
+ throwStripeError(
155
+ "STRIPE_API_ERROR",
156
+ "Could not retrieve verified sale context.",
157
+ );
158
+ }
159
+ }
@@ -0,0 +1,38 @@
1
+ import type { AsyncHooks, AsyncHookCtx } from "../types.js";
2
+ import type { AsyncHookName } from "../types/triggers.js";
3
+ /** Route async webhook hooks through the shared public callback contract. */
4
+ export function buildAsyncHookRoutes(hooks: AsyncHooks | undefined) {
5
+ const routes: Record<
6
+ AsyncHookName,
7
+ ((ctx: AsyncHookCtx, doc: unknown) => Promise<void>) | undefined
8
+ > = {
9
+ afterAccountUpdated: hooks?.onAccountUpdated as
10
+ ((ctx: AsyncHookCtx, doc: unknown) => Promise<void>) | undefined,
11
+ afterCheckoutCompleted: hooks?.onCheckoutCompleted as
12
+ ((ctx: AsyncHookCtx, doc: unknown) => Promise<void>) | undefined,
13
+ afterSubscriptionUpdated: hooks?.onSubscriptionUpdated as
14
+ ((ctx: AsyncHookCtx, doc: unknown) => Promise<void>) | undefined,
15
+ afterSubscriptionCanceled: hooks?.onSubscriptionCanceled as
16
+ ((ctx: AsyncHookCtx, doc: unknown) => Promise<void>) | undefined,
17
+ afterTrialEnding: hooks?.onTrialEnding as
18
+ ((ctx: AsyncHookCtx, doc: unknown) => Promise<void>) | undefined,
19
+ afterInvoicePaid: hooks?.onInvoicePaid as
20
+ ((ctx: AsyncHookCtx, doc: unknown) => Promise<void>) | undefined,
21
+ afterInvoicePaymentFailed: hooks?.onInvoicePaymentFailed as
22
+ ((ctx: AsyncHookCtx, doc: unknown) => Promise<void>) | undefined,
23
+ afterPaymentSucceeded: hooks?.onPaymentSucceeded as
24
+ ((ctx: AsyncHookCtx, doc: unknown) => Promise<void>) | undefined,
25
+ afterPaymentFailed: hooks?.onPaymentFailed as
26
+ ((ctx: AsyncHookCtx, doc: unknown) => Promise<void>) | undefined,
27
+ afterPayoutCompleted: hooks?.onPayoutCompleted as
28
+ ((ctx: AsyncHookCtx, doc: unknown) => Promise<void>) | undefined,
29
+ afterRefundCreated: hooks?.onRefundCreated as
30
+ ((ctx: AsyncHookCtx, doc: unknown) => Promise<void>) | undefined,
31
+ afterDisputeCreated: hooks?.onDisputeCreated as
32
+ ((ctx: AsyncHookCtx, doc: unknown) => Promise<void>) | undefined,
33
+ afterDisputeClosed: hooks?.onDisputeClosed as
34
+ ((ctx: AsyncHookCtx, doc: unknown) => Promise<void>) | undefined,
35
+ };
36
+
37
+ return routes;
38
+ }
@@ -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;
@@ -611,6 +611,7 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
611
611
  destinationAccountId?: string;
612
612
  feeCollectedAmount?: number;
613
613
  feeRefundedAmount?: number;
614
+ isCollectionPaused?: boolean;
614
615
  isTrialing: boolean;
615
616
  metadata?: any;
616
617
  orgId?: string;
@@ -1339,6 +1340,46 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
1339
1340
  }>,
1340
1341
  Name
1341
1342
  >;
1343
+ paginateTransfersByAccount: FunctionReference<
1344
+ "query",
1345
+ "internal",
1346
+ {
1347
+ destinationAccountId: string;
1348
+ paginationOpts: {
1349
+ cursor: string | null;
1350
+ endCursor?: string | null;
1351
+ id?: number;
1352
+ maximumBytesRead?: number;
1353
+ maximumRowsRead?: number;
1354
+ numItems: number;
1355
+ };
1356
+ },
1357
+ {
1358
+ continueCursor: string;
1359
+ isDone: boolean;
1360
+ page: Array<{
1361
+ _creationTime: number;
1362
+ _id: string;
1363
+ amount: number;
1364
+ currency: string;
1365
+ destinationAccountId: string;
1366
+ metadata?: any;
1367
+ paymentId?: string;
1368
+ reinstatement?: boolean;
1369
+ reversalClaimedAmount?: number;
1370
+ reversalStatus?: "partially_reversed" | "fully_reversed";
1371
+ reversedAmount?: number;
1372
+ role?: "store" | "affiliate" | "other";
1373
+ sourceChargeId?: string;
1374
+ sourceInvoiceId?: string;
1375
+ status: "pending" | "paid" | "failed" | "reversed";
1376
+ stripeTransferId: string;
1377
+ }>;
1378
+ pageStatus?: "SplitRecommended" | "SplitRequired" | null;
1379
+ splitCursor?: string | null;
1380
+ },
1381
+ Name
1382
+ >;
1342
1383
  };
1343
1384
  };
1344
1385
  core: {
@@ -1722,6 +1763,106 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
1722
1763
  >;
1723
1764
  };
1724
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
+ };
1725
1866
  webhooks: {
1726
1867
  mutations: {
1727
1868
  insertWebhookEvent: FunctionReference<
@@ -1,4 +1,10 @@
1
1
  import { v } from "convex/values";
2
+ import { paginator } from "convex-helpers/server/pagination";
3
+ import schema from "../schema.js";
4
+ import {
5
+ paginationOptsValidator,
6
+ paginationResultValidator,
7
+ } from "convex/server";
2
8
 
3
9
  import type { Doc } from "../_generated/dataModel";
4
10
  import { query } from "../_generated/server";
@@ -343,6 +349,23 @@ export const listTransfersByAccount = query({
343
349
  },
344
350
  });
345
351
 
352
+ /** Newest transfers for a recipient, with bounded cursor pagination. */
353
+ export const paginateTransfersByAccount = query({
354
+ args: {
355
+ destinationAccountId: v.string(),
356
+ paginationOpts: paginationOptsValidator,
357
+ },
358
+ returns: paginationResultValidator(transferDocValidator),
359
+ handler: (ctx, args) =>
360
+ paginator(ctx.db, schema)
361
+ .query("transfers")
362
+ .withIndex("by_destination_account_id", (q) =>
363
+ q.eq("destinationAccountId", args.destinationAccountId),
364
+ )
365
+ .order("desc")
366
+ .paginate(args.paginationOpts),
367
+ });
368
+
346
369
  // =============================================================================
347
370
  // REVERSAL-CLAIM RECLAIM (BTS-74) — ops diagnostics for wedged legs
348
371
  // =============================================================================
@@ -442,7 +465,7 @@ export const listWedgedReversalClaims = query({
442
465
  * Exact earnings totals for one connected account (BTS-64): `gross` (sum of all
443
466
  * transfer amounts, incl. reinstatements — the earnings view) and `reversed`
444
467
  * (sum of all `reversedAmount`), iterated to completion so totals are never
445
- * capped. `transfers` is a bounded preview (`previewLimit ?? 50` rows) for
468
+ * capped. `transfers` is a newest-first bounded preview (`previewLimit ?? 50` rows) for
446
469
  * drill-down; use `gross`/`reversed`/`transferCount` for the money figures.
447
470
  */
448
471
  export const getAccountEarnings = query({
@@ -467,7 +490,8 @@ export const getAccountEarnings = query({
467
490
  .query("transfers")
468
491
  .withIndex("by_destination_account_id", (q) =>
469
492
  q.eq("destinationAccountId", args.destinationAccountId),
470
- )) {
493
+ )
494
+ .order("desc")) {
471
495
  gross += tr.amount;
472
496
  reversed += tr.reversedAmount ?? 0;
473
497
  transferCount += 1;
@@ -50,6 +50,18 @@ describe("getAccountEarnings — exact totals past the 50-row cap (BTS-64)", ()
50
50
  expect(result.transferCount).toBe(120);
51
51
  // The preview is still bounded (drill-down rows), but the totals are exact.
52
52
  expect(result.transfers.length).toBe(50);
53
+ expect(result.transfers[0].stripeTransferId).toBe("tr_big_119");
54
+ expect(
55
+ result.transfers.some((row) => row.stripeTransferId === "tr_big_0"),
56
+ ).toBe(false);
57
+ const newest = await t.query(api.connect.queries.getAccountEarnings, {
58
+ destinationAccountId: "acct_big",
59
+ previewLimit: 1,
60
+ });
61
+ expect(newest.transfers.map((row) => row.stripeTransferId)).toEqual([
62
+ "tr_big_119",
63
+ ]);
64
+ expect(newest.gross).toBe(12000);
53
65
  });
54
66
 
55
67
  it("returns exact totals and all rows for a small account (regression)", async () => {
@@ -208,6 +220,7 @@ describe("aggregate read-limit failures (BTS-116)", () => {
208
220
  "Too many documents read in a single function execution",
209
221
  );
210
222
  const rows = {
223
+ order: () => rows,
211
224
  async *[Symbol.asyncIterator]() {
212
225
  yield { amount: 100, reversedAmount: 10, status: "paid" };
213
226
  throw readLimit;
@@ -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