@fantastic.dev/better-stripe 0.11.0 → 0.12.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 (139) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +211 -2
  3. package/dist/client/billing/balanceFunding.d.ts +36 -0
  4. package/dist/client/billing/balanceFunding.d.ts.map +1 -0
  5. package/dist/client/billing/balanceFunding.js +107 -0
  6. package/dist/client/billing/balanceFunding.js.map +1 -0
  7. package/dist/client/billing/invoicePreview.d.ts +28 -0
  8. package/dist/client/billing/invoicePreview.d.ts.map +1 -0
  9. package/dist/client/billing/invoicePreview.js +56 -0
  10. package/dist/client/billing/invoicePreview.js.map +1 -0
  11. package/dist/client/charge-currency-format.d.ts +3 -0
  12. package/dist/client/charge-currency-format.d.ts.map +1 -0
  13. package/dist/client/charge-currency-format.js +22 -0
  14. package/dist/client/charge-currency-format.js.map +1 -0
  15. package/dist/client/charge-currency.d.ts +14 -0
  16. package/dist/client/charge-currency.d.ts.map +1 -0
  17. package/dist/client/charge-currency.js +85 -0
  18. package/dist/client/charge-currency.js.map +1 -0
  19. package/dist/client/costRecovery.d.ts +115 -0
  20. package/dist/client/costRecovery.d.ts.map +1 -0
  21. package/dist/client/costRecovery.js +50 -0
  22. package/dist/client/costRecovery.js.map +1 -0
  23. package/dist/client/index.d.ts +28 -66
  24. package/dist/client/index.d.ts.map +1 -1
  25. package/dist/client/index.js +25 -0
  26. package/dist/client/index.js.map +1 -1
  27. package/dist/client/products/prices.d.ts +3 -2
  28. package/dist/client/products/prices.d.ts.map +1 -1
  29. package/dist/client/products/prices.js +0 -3
  30. package/dist/client/products/prices.js.map +1 -1
  31. package/dist/client/products/products.d.ts +3 -2
  32. package/dist/client/products/products.d.ts.map +1 -1
  33. package/dist/client/products/products.js +0 -3
  34. package/dist/client/products/products.js.map +1 -1
  35. package/dist/client/settlement/processingCosts.d.ts +56 -0
  36. package/dist/client/settlement/processingCosts.d.ts.map +1 -0
  37. package/dist/client/settlement/processingCosts.js +173 -0
  38. package/dist/client/settlement/processingCosts.js.map +1 -0
  39. package/dist/client/utils/webhookEndpoints.d.ts +1 -1
  40. package/dist/client/utils/webhookEndpoints.d.ts.map +1 -1
  41. package/dist/client/utils/webhookEndpoints.js +1 -0
  42. package/dist/client/utils/webhookEndpoints.js.map +1 -1
  43. package/dist/client/webhooks/processors.d.ts.map +1 -1
  44. package/dist/client/webhooks/processors.js +1 -0
  45. package/dist/client/webhooks/processors.js.map +1 -1
  46. package/dist/component/_generated/component.d.ts +244 -0
  47. package/dist/component/_generated/component.d.ts.map +1 -1
  48. package/dist/component/costRecovery/claims.d.ts +68 -0
  49. package/dist/component/costRecovery/claims.d.ts.map +1 -0
  50. package/dist/component/costRecovery/claims.js +85 -0
  51. package/dist/component/costRecovery/claims.js.map +1 -0
  52. package/dist/component/costRecovery/completion.d.ts +33 -0
  53. package/dist/component/costRecovery/completion.d.ts.map +1 -0
  54. package/dist/component/costRecovery/completion.js +69 -0
  55. package/dist/component/costRecovery/completion.js.map +1 -0
  56. package/dist/component/costRecovery/helpers.d.ts +62 -0
  57. package/dist/component/costRecovery/helpers.d.ts.map +1 -0
  58. package/dist/component/costRecovery/helpers.js +71 -0
  59. package/dist/component/costRecovery/helpers.js.map +1 -0
  60. package/dist/component/costRecovery/mutations.d.ts +138 -0
  61. package/dist/component/costRecovery/mutations.d.ts.map +1 -0
  62. package/dist/component/costRecovery/mutations.js +36 -0
  63. package/dist/component/costRecovery/mutations.js.map +1 -0
  64. package/dist/component/costRecovery/queries.d.ts +102 -0
  65. package/dist/component/costRecovery/queries.d.ts.map +1 -0
  66. package/dist/component/costRecovery/queries.js +73 -0
  67. package/dist/component/costRecovery/queries.js.map +1 -0
  68. package/dist/component/costRecovery/reservations.d.ts +83 -0
  69. package/dist/component/costRecovery/reservations.d.ts.map +1 -0
  70. package/dist/component/costRecovery/reservations.js +134 -0
  71. package/dist/component/costRecovery/reservations.js.map +1 -0
  72. package/dist/component/costRecovery/schema.d.ts +340 -0
  73. package/dist/component/costRecovery/schema.d.ts.map +1 -0
  74. package/dist/component/costRecovery/schema.js +109 -0
  75. package/dist/component/costRecovery/schema.js.map +1 -0
  76. package/dist/component/schema.d.ts +119 -0
  77. package/dist/component/schema.js +5 -0
  78. package/dist/component/schema.js.map +1 -1
  79. package/dist/react/components/PriceCard.d.ts +3 -1
  80. package/dist/react/components/PriceCard.d.ts.map +1 -1
  81. package/dist/react/components/PriceCard.js +2 -2
  82. package/dist/react/components/PriceCard.js.map +1 -1
  83. package/dist/react/index.d.ts +1 -0
  84. package/dist/react/index.d.ts.map +1 -1
  85. package/dist/react/index.js +1 -0
  86. package/dist/react/index.js.map +1 -1
  87. package/dist/react/lib/price-helpers.d.ts +4 -6
  88. package/dist/react/lib/price-helpers.d.ts.map +1 -1
  89. package/dist/react/lib/price-helpers.js +16 -9
  90. package/dist/react/lib/price-helpers.js.map +1 -1
  91. package/dist/react/workflows/PlanPricingEditor.d.ts +4 -0
  92. package/dist/react/workflows/PlanPricingEditor.d.ts.map +1 -1
  93. package/dist/react/workflows/PlanPricingEditor.js +12 -9
  94. package/dist/react/workflows/PlanPricingEditor.js.map +1 -1
  95. package/docs/react-workflows.md +91 -0
  96. package/package.json +2 -2
  97. package/src/client/billing/balanceFunding.test.ts +257 -0
  98. package/src/client/billing/balanceFunding.ts +183 -0
  99. package/src/client/billing/balanceFundingTransport.test.ts +89 -0
  100. package/src/client/billing/invoicePreview.test.ts +123 -0
  101. package/src/client/billing/invoicePreview.ts +94 -0
  102. package/src/client/billing/invoicePreviewTransport.test.ts +70 -0
  103. package/src/client/charge-currency-format.ts +29 -0
  104. package/src/client/charge-currency.test.ts +113 -0
  105. package/src/client/charge-currency.ts +119 -0
  106. package/src/client/costRecovery.test.ts +76 -0
  107. package/src/client/costRecovery.ts +149 -0
  108. package/src/client/index.ts +76 -75
  109. package/src/client/products/prices.ts +13 -11
  110. package/src/client/products/products.ts +10 -10
  111. package/src/client/settlement/processingCosts.test.ts +349 -0
  112. package/src/client/settlement/processingCosts.ts +315 -0
  113. package/src/client/settlement/processingCostsTransport.test.ts +135 -0
  114. package/src/client/utils/webhookEndpoints.test.ts +5 -0
  115. package/src/client/utils/webhookEndpoints.ts +1 -0
  116. package/src/client/webhooks/hooks.test.ts +9 -0
  117. package/src/client/webhooks/payout-canceled.integration.test.ts +192 -0
  118. package/src/client/webhooks/processors.ts +1 -0
  119. package/src/component/_generated/api.ts +12 -0
  120. package/src/component/_generated/component.ts +308 -0
  121. package/src/component/cost-recovery.test.ts +344 -0
  122. package/src/component/costRecovery/claims.ts +109 -0
  123. package/src/component/costRecovery/completion.ts +92 -0
  124. package/src/component/costRecovery/helpers.ts +83 -0
  125. package/src/component/costRecovery/mutations.ts +43 -0
  126. package/src/component/costRecovery/queries.ts +88 -0
  127. package/src/component/costRecovery/reservations.ts +164 -0
  128. package/src/component/costRecovery/schema.ts +127 -0
  129. package/src/component/schema.ts +10 -0
  130. package/src/react/components/PayoutSchedule.render.test.tsx +15 -0
  131. package/src/react/components/PriceCard.render.test.tsx +37 -0
  132. package/src/react/components/PriceCard.tsx +9 -1
  133. package/src/react/index.test.ts +5 -0
  134. package/src/react/index.ts +7 -0
  135. package/src/react/lib/price-helpers.test.ts +15 -2
  136. package/src/react/lib/price-helpers.ts +25 -8
  137. package/src/react/workflows/Billing.test.tsx +1 -1
  138. package/src/react/workflows/PlanPricingEditor.currency.test.tsx +164 -0
  139. package/src/react/workflows/PlanPricingEditor.tsx +26 -10
@@ -1,3 +1,27 @@
1
+ import { CostRecoveryClient } from "./costRecovery.js";
2
+ export * from "./charge-currency.js";
3
+ export * from "./costRecovery.js";
4
+ import type { SubscriptionStatus } from "../component/billing/validators.js";
5
+ import * as processingCostsImpl from "./settlement/processingCosts.js";
6
+ export type {
7
+ ProcessingCostContext,
8
+ ProcessingCostOptions,
9
+ ProcessingCostTransaction,
10
+ ProcessingCostRecord,
11
+ ProcessingCostNotReadyReason,
12
+ } from "./settlement/processingCosts.js";
13
+ import * as balanceFundingImpl from "./billing/balanceFunding.js";
14
+ export type {
15
+ BalanceFundingCapability,
16
+ BalanceFundingPreparation,
17
+ PrepareBalanceFundingOptions,
18
+ SetBalanceFundingOptions,
19
+ } from "./billing/balanceFunding.js";
20
+ import * as invoicePreviewImpl from "./billing/invoicePreview.js";
21
+ export type {
22
+ UpcomingInvoicePreview,
23
+ UpcomingInvoicePreviewOptions,
24
+ } from "./billing/invoicePreview.js";
1
25
  import type { HttpRouter } from "convex/server";
2
26
  import { registerClientRoutes } from "./previewRoutes.js";
3
27
  import {
@@ -47,10 +71,6 @@ import { v } from "convex/values";
47
71
  import Stripe from "stripe";
48
72
 
49
73
  import type { ComponentApi } from "../component/_generated/component.js";
50
- import type {
51
- InvoiceStatus,
52
- SubscriptionStatus,
53
- } from "../component/billing/validators.js";
54
74
  import type {
55
75
  DisputeStatus,
56
76
  PayoutStatus,
@@ -495,17 +515,7 @@ export class BetterStripe {
495
515
 
496
516
  // PRODUCT METHODS
497
517
 
498
- async createProduct(
499
- ctx: RunCtx,
500
- opts: {
501
- name: string;
502
- description?: string;
503
- active?: boolean;
504
- accountId?: string;
505
- metadata?: Record<string, string>;
506
- idempotencyKey?: string;
507
- },
508
- ) {
518
+ async createProduct(ctx: RunCtx, opts: productsImpl.CreateProductOptions) {
509
519
  return productsImpl.createProduct(this.stripe(), this.component, ctx, {
510
520
  ...opts,
511
521
  // Stamp the per-store descriptor at create so it covers the first invoice
@@ -583,20 +593,7 @@ export class BetterStripe {
583
593
 
584
594
  // PRICE METHODS
585
595
 
586
- async createPrice(
587
- ctx: RunCtx,
588
- opts: {
589
- stripeProductId: string;
590
- unitAmount: number;
591
- currency?: string;
592
- type: "one_time" | "recurring";
593
- interval?: "day" | "week" | "month" | "year";
594
- intervalCount?: number;
595
- nickname?: string;
596
- metadata?: Record<string, string>;
597
- idempotencyKey?: string;
598
- },
599
- ) {
596
+ async createPrice(ctx: RunCtx, opts: pricesImpl.CreatePriceOptions) {
600
597
  return pricesImpl.createPrice(this.stripe(), this.component, ctx, opts);
601
598
  }
602
599
 
@@ -646,19 +643,7 @@ export class BetterStripe {
646
643
 
647
644
  async upsertPrice(
648
645
  ctx: RunCtx,
649
- opts: {
650
- stripePriceId: string;
651
- productId: string;
652
- stripeProductId: string;
653
- nickname?: string;
654
- unitAmount: number;
655
- currency: string;
656
- active: boolean;
657
- type: "one_time" | "recurring";
658
- interval?: "day" | "week" | "month" | "year";
659
- intervalCount?: number;
660
- metadata?: Record<string, unknown>;
661
- },
646
+ opts: Parameters<typeof pricesImpl.upsertPrice>[2],
662
647
  ) {
663
648
  return pricesImpl.upsertPrice(this.component, ctx, opts);
664
649
  }
@@ -956,23 +941,7 @@ export class BetterStripe {
956
941
 
957
942
  async upsertSubscription(
958
943
  ctx: RunCtx,
959
- opts: {
960
- stripeSubscriptionId: string;
961
- accountId?: string;
962
- userId: string;
963
- orgId?: string;
964
- status: SubscriptionStatus;
965
- priceId?: string;
966
- quantity?: number;
967
- currentPeriodStart?: string;
968
- currentPeriodEnd?: string;
969
- cancelAtPeriodEnd: boolean;
970
- canceledAt?: string;
971
- isTrialing: boolean;
972
- trialStart?: string;
973
- trialEnd?: string;
974
- metadata?: Record<string, unknown>;
975
- },
944
+ opts: Parameters<typeof subscriptionsImpl.upsertSubscription>[2],
976
945
  ) {
977
946
  return subscriptionsImpl.upsertSubscription(this.component, ctx, opts);
978
947
  }
@@ -1016,22 +985,7 @@ export class BetterStripe {
1016
985
 
1017
986
  async upsertInvoice(
1018
987
  ctx: RunCtx,
1019
- opts: {
1020
- stripeInvoiceId: string;
1021
- userId: string;
1022
- orgId?: string;
1023
- accountId?: string;
1024
- subscriptionId?: string;
1025
- status: InvoiceStatus;
1026
- currency: string;
1027
- amountDue: number;
1028
- amountPaid: number;
1029
- hostedInvoiceUrl?: string;
1030
- invoicePdf?: string;
1031
- periodStart?: string;
1032
- periodEnd?: string;
1033
- metadata?: Record<string, unknown>;
1034
- },
988
+ opts: Parameters<typeof invoicesImpl.upsertInvoice>[2],
1035
989
  ) {
1036
990
  return invoicesImpl.upsertInvoice(this.component, ctx, opts);
1037
991
  }
@@ -1044,6 +998,42 @@ export class BetterStripe {
1044
998
  return configImpl.getStripeMode(this.component, ctx);
1045
999
  }
1046
1000
 
1001
+ getBalanceFundingCapability() {
1002
+ return balanceFundingImpl.getBalanceFundingCapability(
1003
+ this.stripeApiVersion,
1004
+ );
1005
+ }
1006
+
1007
+ async prepareBalanceFunding(
1008
+ _ctx: RunCtx,
1009
+ opts: balanceFundingImpl.PrepareBalanceFundingOptions,
1010
+ ) {
1011
+ return balanceFundingImpl.prepareBalanceFunding(
1012
+ this.stripe(),
1013
+ this.stripeApiVersion,
1014
+ opts,
1015
+ );
1016
+ }
1017
+
1018
+ async setBalanceFunding(
1019
+ _ctx: RunCtx,
1020
+ opts: balanceFundingImpl.SetBalanceFundingOptions,
1021
+ ) {
1022
+ return balanceFundingImpl.setBalanceFunding(
1023
+ this.stripe(),
1024
+ this.stripeApiVersion,
1025
+ opts,
1026
+ );
1027
+ }
1028
+
1029
+ /** Authorize the customer in the host; validates subscription ownership. */
1030
+ async getUpcomingInvoicePreview(
1031
+ _ctx: RunCtx,
1032
+ opts: invoicePreviewImpl.UpcomingInvoicePreviewOptions,
1033
+ ) {
1034
+ return invoicePreviewImpl.getUpcomingInvoicePreview(this.stripe(), opts);
1035
+ }
1036
+
1047
1037
  async getInvoiceFromStripe(ctx: RunCtx, opts: { stripeInvoiceId: string }) {
1048
1038
  return invoicesImpl.getInvoiceFromStripe(this.stripe(), ctx, opts);
1049
1039
  }
@@ -1274,6 +1264,17 @@ export class BetterStripe {
1274
1264
  ) {
1275
1265
  return providerReads.getCouponFromStripe(this.stripe(), opts);
1276
1266
  }
1267
+ get costRecovery() {
1268
+ return new CostRecoveryClient(this.component);
1269
+ }
1270
+
1271
+ async getProcessingCostContext(
1272
+ _ctx: RunCtx,
1273
+ opts: processingCostsImpl.ProcessingCostOptions,
1274
+ ) {
1275
+ return processingCostsImpl.getProcessingCostContext(this.stripe(), opts);
1276
+ }
1277
+
1277
1278
  async getVerifiedSale(
1278
1279
  _ctx: RunCtx,
1279
1280
  opts: saleContextImpl.VerifiedSaleOptions,
@@ -11,21 +11,23 @@ import { componentRef } from "../webhooks/helpers.js";
11
11
  // Price methods
12
12
  // =============================================================================
13
13
 
14
+ export type CreatePriceOptions = {
15
+ stripeProductId: string;
16
+ unitAmount: number;
17
+ currency?: string;
18
+ type: "one_time" | "recurring";
19
+ interval?: "day" | "week" | "month" | "year";
20
+ intervalCount?: number;
21
+ nickname?: string;
22
+ metadata?: Record<string, string>;
23
+ idempotencyKey?: string;
24
+ };
25
+
14
26
  export async function createPrice(
15
27
  stripe: Stripe,
16
28
  component: Component,
17
29
  ctx: RunCtx,
18
- opts: {
19
- stripeProductId: string;
20
- unitAmount: number;
21
- currency?: string;
22
- type: "one_time" | "recurring";
23
- interval?: "day" | "week" | "month" | "year";
24
- intervalCount?: number;
25
- nickname?: string;
26
- metadata?: Record<string, string>;
27
- idempotencyKey?: string;
28
- },
30
+ opts: CreatePriceOptions,
29
31
  ) {
30
32
  // Validate the product exists in the component DB *before* writing to Stripe.
31
33
  // Creating the price first and throwing afterwards would orphan a price in
@@ -54,20 +54,20 @@ async function resolveProductStatementDescriptor(
54
54
  // Product methods
55
55
  // =============================================================================
56
56
 
57
+ export type CreateProductOptions = {
58
+ name: string;
59
+ description?: string;
60
+ active?: boolean;
61
+ accountId?: string;
62
+ metadata?: Record<string, string>;
63
+ idempotencyKey?: string;
64
+ };
65
+
57
66
  export async function createProduct(
58
67
  stripe: Stripe,
59
68
  component: Component,
60
69
  ctx: RunCtx,
61
- opts: {
62
- name: string;
63
- description?: string;
64
- active?: boolean;
65
- accountId?: string;
66
- metadata?: Record<string, string>;
67
- idempotencyKey?: string;
68
- /** Platform default statement-descriptor suffix (BetterStripe option). */
69
- defaultStatementDescriptorSuffix?: string;
70
- },
70
+ opts: CreateProductOptions & { defaultStatementDescriptorSuffix?: string },
71
71
  ) {
72
72
  const statementDescriptor = await resolveProductStatementDescriptor(
73
73
  component,
@@ -0,0 +1,349 @@
1
+ import type Stripe from "stripe";
2
+ import { beforeEach, describe, expect, it, vi } from "vitest";
3
+ import { getProcessingCostContext } from "./processingCosts.js";
4
+
5
+ const transaction = (
6
+ id: string,
7
+ amount: number,
8
+ fee: number,
9
+ currency = "usd",
10
+ ) => ({
11
+ id,
12
+ amount,
13
+ fee,
14
+ net: amount - fee,
15
+ currency,
16
+ created: 1800000000,
17
+ });
18
+ const charge = {
19
+ id: "ch_1",
20
+ payment_intent: "pi_1",
21
+ amount: 10000,
22
+ amount_captured: 10000,
23
+ currency: "usd",
24
+ disputed: false,
25
+ amount_refunded: 0,
26
+ balance_transaction: transaction("txn_charge", 10000, 320),
27
+ };
28
+ const payment = {
29
+ id: "pi_1",
30
+ status: "succeeded",
31
+ currency: "usd",
32
+ amount_received: 10000,
33
+ metadata: { sellerId: "store" },
34
+ latest_charge: charge,
35
+ };
36
+ const raw = {
37
+ paymentIntents: { retrieve: vi.fn() },
38
+ balanceTransactions: { retrieve: vi.fn() },
39
+ refunds: { list: vi.fn() },
40
+ disputes: { list: vi.fn() },
41
+ };
42
+ const stripe = raw as unknown as Stripe;
43
+ function page<T>(items: T[]) {
44
+ return (async function* () {
45
+ for (const item of items) yield item;
46
+ })();
47
+ }
48
+ const refund = (id: string, amount: number, fee = 0) => ({
49
+ id,
50
+ amount,
51
+ currency: "usd",
52
+ charge: "ch_1",
53
+ payment_intent: "pi_1",
54
+ status: "succeeded",
55
+ balance_transaction: transaction(`txn_${id}`, -amount, fee),
56
+ failure_balance_transaction: null,
57
+ });
58
+ const dispute = (
59
+ status: string,
60
+ transactions: ReturnType<typeof transaction>[],
61
+ ) => ({
62
+ id: "dp_1",
63
+ charge: "ch_1",
64
+ payment_intent: "pi_1",
65
+ amount: 10000,
66
+ currency: "usd",
67
+ status,
68
+ balance_transactions: transactions,
69
+ });
70
+ beforeEach(() => {
71
+ vi.resetAllMocks();
72
+ raw.paymentIntents.retrieve.mockResolvedValue(payment);
73
+ raw.refunds.list.mockImplementation(() => page([]));
74
+ raw.disputes.list.mockImplementation(() => page([]));
75
+ });
76
+ describe("authoritative processing-cost context", () => {
77
+ it("returns signed source-linked provider transactions and integer fees without initiating settlement", async () => {
78
+ const result = await getProcessingCostContext(stripe, {
79
+ paymentIntentId: "pi_1",
80
+ });
81
+ expect(result).toMatchObject({
82
+ status: "ready",
83
+ paymentIntentId: "pi_1",
84
+ chargeId: "ch_1",
85
+ currency: "usd",
86
+ gross: 10000,
87
+ processingFee: 320,
88
+ refundFeeAdjustment: 0,
89
+ disputeFeeAdjustment: 0,
90
+ netFees: 320,
91
+ refundedPrincipal: 0,
92
+ disputedPrincipal: 0,
93
+ principalReturned: 0,
94
+ transactions: [
95
+ {
96
+ id: "txn_charge",
97
+ sourceId: "ch_1",
98
+ sourceType: "charge",
99
+ amount: 10000,
100
+ fee: 320,
101
+ net: 9680,
102
+ created: 1800000000000,
103
+ },
104
+ ],
105
+ });
106
+ });
107
+ it("includes cumulative partial refunds and signed returned processing fees", async () => {
108
+ raw.refunds.list.mockImplementation(() =>
109
+ page([refund("re_first", 2000, -64), refund("re_second", 3000, -96)]),
110
+ );
111
+ raw.paymentIntents.retrieve.mockResolvedValue({
112
+ ...payment,
113
+ latest_charge: { ...charge, amount_refunded: 5000 },
114
+ });
115
+ expect(
116
+ await getProcessingCostContext(stripe, { paymentIntentId: "pi_1" }),
117
+ ).toMatchObject({
118
+ status: "ready",
119
+ refundedPrincipal: 5000,
120
+ principalReturned: 5000,
121
+ processingFee: 320,
122
+ refundFeeAdjustment: -160,
123
+ netFees: 160,
124
+ });
125
+ });
126
+ it.each(["lost", "needs_response"])(
127
+ "accounts for %s dispute principal and fees once",
128
+ async (status) => {
129
+ raw.disputes.list.mockImplementation(() =>
130
+ page([dispute(status, [transaction("txn_dispute", -10000, 1500)])]),
131
+ );
132
+ raw.paymentIntents.retrieve.mockResolvedValue({
133
+ ...payment,
134
+ latest_charge: { ...charge, disputed: true },
135
+ });
136
+ expect(
137
+ await getProcessingCostContext(stripe, { paymentIntentId: "pi_1" }),
138
+ ).toMatchObject({
139
+ status: "ready",
140
+ disputedPrincipal: 10000,
141
+ principalReturned: 10000,
142
+ disputeFeeAdjustment: 1500,
143
+ netFees: 1820,
144
+ });
145
+ },
146
+ );
147
+ it("handles won disputes and fee credits without retaining principal or assuming all fees return", async () => {
148
+ raw.disputes.list.mockImplementation(() =>
149
+ page([
150
+ dispute("won", [
151
+ transaction("txn_withdraw", -10000, 1500),
152
+ transaction("txn_reinstate", 10000, -1000),
153
+ ]),
154
+ ]),
155
+ );
156
+ expect(
157
+ await getProcessingCostContext(stripe, { paymentIntentId: "pi_1" }),
158
+ ).toMatchObject({
159
+ status: "ready",
160
+ disputedPrincipal: 0,
161
+ principalReturned: 0,
162
+ disputeFeeAdjustment: 500,
163
+ netFees: 820,
164
+ });
165
+ });
166
+ it("does not double-count principal when refund and dispute observations overlap", async () => {
167
+ raw.refunds.list.mockImplementation(() =>
168
+ page([refund("re_partial", 2000)]),
169
+ );
170
+ raw.disputes.list.mockImplementation(() =>
171
+ page([dispute("lost", [transaction("txn_dispute", -10000, 1500)])]),
172
+ );
173
+ const result = await getProcessingCostContext(stripe, {
174
+ paymentIntentId: "pi_1",
175
+ });
176
+ expect(result).toMatchObject({
177
+ status: "ready",
178
+ refundedPrincipal: 2000,
179
+ disputedPrincipal: 10000,
180
+ principalReturned: 10000,
181
+ });
182
+ if (result.status === "ready")
183
+ expect(
184
+ result.transactions
185
+ .filter((t) => t.sourceType !== "charge")
186
+ .reduce((n, t) => n + t.amount, 0),
187
+ ).toBe(-12000);
188
+ });
189
+ it("walks every refund and dispute page rather than using a capped preview", async () => {
190
+ raw.refunds.list.mockImplementation(() =>
191
+ page(Array.from({ length: 101 }, (_, i) => refund(`re_${i}`, 10))),
192
+ );
193
+ raw.disputes.list.mockImplementation(() =>
194
+ page(
195
+ Array.from({ length: 101 }, (_, i) => ({
196
+ ...dispute("warning_closed", []),
197
+ id: `dp_${i}`,
198
+ })),
199
+ ),
200
+ );
201
+ const result = await getProcessingCostContext(stripe, {
202
+ paymentIntentId: "pi_1",
203
+ });
204
+ expect(result).toMatchObject({ status: "ready", refundedPrincipal: 1010 });
205
+ if (result.status === "ready") {
206
+ expect(result.refunds).toHaveLength(101);
207
+ expect(result.disputes).toHaveLength(101);
208
+ expect(result.transactions).toHaveLength(102);
209
+ }
210
+ });
211
+ it("returns explicit not-ready states for absent fees and pending refunds", async () => {
212
+ raw.paymentIntents.retrieve.mockResolvedValueOnce({
213
+ ...payment,
214
+ latest_charge: { ...charge, balance_transaction: null },
215
+ });
216
+ expect(
217
+ await getProcessingCostContext(stripe, { paymentIntentId: "pi_1" }),
218
+ ).toMatchObject({
219
+ status: "not_ready",
220
+ reason: "charge_transaction_unavailable",
221
+ });
222
+ raw.refunds.list.mockImplementation(() =>
223
+ page([
224
+ {
225
+ ...refund("re_pending", 1000),
226
+ status: "pending",
227
+ balance_transaction: null,
228
+ },
229
+ ]),
230
+ );
231
+ expect(
232
+ await getProcessingCostContext(stripe, { paymentIntentId: "pi_1" }),
233
+ ).toMatchObject({ status: "not_ready", reason: "refund_pending" });
234
+ });
235
+ it("includes a failed refund's reversal rather than counting a returned principal twice", async () => {
236
+ raw.refunds.list.mockImplementation(() =>
237
+ page([
238
+ {
239
+ ...refund("re_failed", 1000, 10),
240
+ status: "failed",
241
+ failure_balance_transaction: transaction("txn_failure", 1000, -10),
242
+ },
243
+ ]),
244
+ );
245
+ expect(
246
+ await getProcessingCostContext(stripe, { paymentIntentId: "pi_1" }),
247
+ ).toMatchObject({
248
+ status: "ready",
249
+ refundedPrincipal: 0,
250
+ refundFeeAdjustment: 0,
251
+ principalReturned: 0,
252
+ netFees: 320,
253
+ });
254
+ });
255
+ it("rejects inconsistent source linkage and fractional provider amounts", async () => {
256
+ raw.refunds.list.mockImplementation(() =>
257
+ page([{ ...refund("re_other", 1000), charge: "ch_other" }]),
258
+ );
259
+ await expect(
260
+ getProcessingCostContext(stripe, { paymentIntentId: "pi_1" }),
261
+ ).rejects.toThrow(/belong/);
262
+ raw.refunds.list.mockImplementation(() =>
263
+ page([refund("re_bad", 1000, 0.5)]),
264
+ );
265
+ await expect(
266
+ getProcessingCostContext(stripe, { paymentIntentId: "pi_1" }),
267
+ ).rejects.toThrow(/integer/);
268
+ });
269
+ it("never combines amounts in different currencies", async () => {
270
+ raw.refunds.list.mockImplementation(() =>
271
+ page([
272
+ {
273
+ ...refund("re_fx", 1000),
274
+ balance_transaction: transaction("txn_fx", -1000, 0, "eur"),
275
+ },
276
+ ]),
277
+ );
278
+ expect(
279
+ await getProcessingCostContext(stripe, { paymentIntentId: "pi_1" }),
280
+ ).toMatchObject({ status: "not_ready", reason: "currency_mismatch" });
281
+ });
282
+ it("preserves provider errors instead of returning incomplete cost data", async () => {
283
+ const error = Object.assign(new Error("Provider unavailable"), {
284
+ statusCode: 503,
285
+ });
286
+ raw.disputes.list.mockImplementation(() =>
287
+ (async function* () {
288
+ yield dispute("warning_closed", []);
289
+ throw error;
290
+ })(),
291
+ );
292
+ await expect(
293
+ getProcessingCostContext(stripe, { paymentIntentId: "pi_1" }),
294
+ ).rejects.toBe(error);
295
+ });
296
+ });
297
+ it("waits for a won dispute's principal reinstatement instead of reporting the old withdrawal as owed", async () => {
298
+ raw.disputes.list.mockImplementation(() =>
299
+ page([dispute("won", [transaction("txn_out", -10000, 1500)])]),
300
+ );
301
+ expect(
302
+ await getProcessingCostContext(stripe, { paymentIntentId: "pi_1" }),
303
+ ).toMatchObject({
304
+ status: "not_ready",
305
+ reason: "dispute_reinstatement_unavailable",
306
+ });
307
+ });
308
+ it("exposes provider fee breakdowns so application fees are not mistaken for processor fees", async () => {
309
+ raw.paymentIntents.retrieve.mockResolvedValue({
310
+ ...payment,
311
+ latest_charge: {
312
+ ...charge,
313
+ balance_transaction: {
314
+ ...transaction("txn_charge", 10000, 820),
315
+ fee_details: [
316
+ {
317
+ type: "stripe_fee",
318
+ amount: 320,
319
+ currency: "usd",
320
+ application: null,
321
+ description: "Processing",
322
+ },
323
+ {
324
+ type: "application_fee",
325
+ amount: 500,
326
+ currency: "usd",
327
+ application: "ca_platform",
328
+ description: "Platform",
329
+ },
330
+ ],
331
+ },
332
+ },
333
+ });
334
+ const result = await getProcessingCostContext(stripe, {
335
+ paymentIntentId: "pi_1",
336
+ });
337
+ expect(result).toMatchObject({
338
+ status: "ready",
339
+ processingFee: 820,
340
+ transactions: [
341
+ {
342
+ feeDetails: [
343
+ { type: "stripe_fee", amount: 320 },
344
+ { type: "application_fee", amount: 500 },
345
+ ],
346
+ },
347
+ ],
348
+ });
349
+ });