@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.
- package/CHANGELOG.md +17 -0
- package/README.md +211 -2
- package/dist/client/billing/balanceFunding.d.ts +36 -0
- package/dist/client/billing/balanceFunding.d.ts.map +1 -0
- package/dist/client/billing/balanceFunding.js +107 -0
- package/dist/client/billing/balanceFunding.js.map +1 -0
- package/dist/client/billing/invoicePreview.d.ts +28 -0
- package/dist/client/billing/invoicePreview.d.ts.map +1 -0
- package/dist/client/billing/invoicePreview.js +56 -0
- package/dist/client/billing/invoicePreview.js.map +1 -0
- package/dist/client/charge-currency-format.d.ts +3 -0
- package/dist/client/charge-currency-format.d.ts.map +1 -0
- package/dist/client/charge-currency-format.js +22 -0
- package/dist/client/charge-currency-format.js.map +1 -0
- package/dist/client/charge-currency.d.ts +14 -0
- package/dist/client/charge-currency.d.ts.map +1 -0
- package/dist/client/charge-currency.js +85 -0
- package/dist/client/charge-currency.js.map +1 -0
- package/dist/client/costRecovery.d.ts +115 -0
- package/dist/client/costRecovery.d.ts.map +1 -0
- package/dist/client/costRecovery.js +50 -0
- package/dist/client/costRecovery.js.map +1 -0
- package/dist/client/index.d.ts +28 -66
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +25 -0
- package/dist/client/index.js.map +1 -1
- package/dist/client/products/prices.d.ts +3 -2
- package/dist/client/products/prices.d.ts.map +1 -1
- package/dist/client/products/prices.js +0 -3
- package/dist/client/products/prices.js.map +1 -1
- package/dist/client/products/products.d.ts +3 -2
- package/dist/client/products/products.d.ts.map +1 -1
- package/dist/client/products/products.js +0 -3
- package/dist/client/products/products.js.map +1 -1
- package/dist/client/settlement/processingCosts.d.ts +56 -0
- package/dist/client/settlement/processingCosts.d.ts.map +1 -0
- package/dist/client/settlement/processingCosts.js +173 -0
- package/dist/client/settlement/processingCosts.js.map +1 -0
- package/dist/client/utils/webhookEndpoints.d.ts +1 -1
- package/dist/client/utils/webhookEndpoints.d.ts.map +1 -1
- package/dist/client/utils/webhookEndpoints.js +1 -0
- package/dist/client/utils/webhookEndpoints.js.map +1 -1
- package/dist/client/webhooks/processors.d.ts.map +1 -1
- package/dist/client/webhooks/processors.js +1 -0
- package/dist/client/webhooks/processors.js.map +1 -1
- package/dist/component/_generated/component.d.ts +244 -0
- package/dist/component/_generated/component.d.ts.map +1 -1
- package/dist/component/costRecovery/claims.d.ts +68 -0
- package/dist/component/costRecovery/claims.d.ts.map +1 -0
- package/dist/component/costRecovery/claims.js +85 -0
- package/dist/component/costRecovery/claims.js.map +1 -0
- package/dist/component/costRecovery/completion.d.ts +33 -0
- package/dist/component/costRecovery/completion.d.ts.map +1 -0
- package/dist/component/costRecovery/completion.js +69 -0
- package/dist/component/costRecovery/completion.js.map +1 -0
- package/dist/component/costRecovery/helpers.d.ts +62 -0
- package/dist/component/costRecovery/helpers.d.ts.map +1 -0
- package/dist/component/costRecovery/helpers.js +71 -0
- package/dist/component/costRecovery/helpers.js.map +1 -0
- package/dist/component/costRecovery/mutations.d.ts +138 -0
- package/dist/component/costRecovery/mutations.d.ts.map +1 -0
- package/dist/component/costRecovery/mutations.js +36 -0
- package/dist/component/costRecovery/mutations.js.map +1 -0
- package/dist/component/costRecovery/queries.d.ts +102 -0
- package/dist/component/costRecovery/queries.d.ts.map +1 -0
- package/dist/component/costRecovery/queries.js +73 -0
- package/dist/component/costRecovery/queries.js.map +1 -0
- package/dist/component/costRecovery/reservations.d.ts +83 -0
- package/dist/component/costRecovery/reservations.d.ts.map +1 -0
- package/dist/component/costRecovery/reservations.js +134 -0
- package/dist/component/costRecovery/reservations.js.map +1 -0
- package/dist/component/costRecovery/schema.d.ts +340 -0
- package/dist/component/costRecovery/schema.d.ts.map +1 -0
- package/dist/component/costRecovery/schema.js +109 -0
- package/dist/component/costRecovery/schema.js.map +1 -0
- package/dist/component/schema.d.ts +119 -0
- package/dist/component/schema.js +5 -0
- package/dist/component/schema.js.map +1 -1
- package/dist/react/components/PriceCard.d.ts +3 -1
- package/dist/react/components/PriceCard.d.ts.map +1 -1
- package/dist/react/components/PriceCard.js +2 -2
- package/dist/react/components/PriceCard.js.map +1 -1
- package/dist/react/index.d.ts +1 -0
- package/dist/react/index.d.ts.map +1 -1
- package/dist/react/index.js +1 -0
- package/dist/react/index.js.map +1 -1
- package/dist/react/lib/price-helpers.d.ts +4 -6
- package/dist/react/lib/price-helpers.d.ts.map +1 -1
- package/dist/react/lib/price-helpers.js +16 -9
- package/dist/react/lib/price-helpers.js.map +1 -1
- package/dist/react/workflows/PlanPricingEditor.d.ts +4 -0
- package/dist/react/workflows/PlanPricingEditor.d.ts.map +1 -1
- package/dist/react/workflows/PlanPricingEditor.js +12 -9
- package/dist/react/workflows/PlanPricingEditor.js.map +1 -1
- package/docs/react-workflows.md +91 -0
- package/package.json +2 -2
- package/src/client/billing/balanceFunding.test.ts +257 -0
- package/src/client/billing/balanceFunding.ts +183 -0
- package/src/client/billing/balanceFundingTransport.test.ts +89 -0
- package/src/client/billing/invoicePreview.test.ts +123 -0
- package/src/client/billing/invoicePreview.ts +94 -0
- package/src/client/billing/invoicePreviewTransport.test.ts +70 -0
- package/src/client/charge-currency-format.ts +29 -0
- package/src/client/charge-currency.test.ts +113 -0
- package/src/client/charge-currency.ts +119 -0
- package/src/client/costRecovery.test.ts +76 -0
- package/src/client/costRecovery.ts +149 -0
- package/src/client/index.ts +76 -75
- package/src/client/products/prices.ts +13 -11
- package/src/client/products/products.ts +10 -10
- package/src/client/settlement/processingCosts.test.ts +349 -0
- package/src/client/settlement/processingCosts.ts +315 -0
- package/src/client/settlement/processingCostsTransport.test.ts +135 -0
- package/src/client/utils/webhookEndpoints.test.ts +5 -0
- package/src/client/utils/webhookEndpoints.ts +1 -0
- package/src/client/webhooks/hooks.test.ts +9 -0
- package/src/client/webhooks/payout-canceled.integration.test.ts +192 -0
- package/src/client/webhooks/processors.ts +1 -0
- package/src/component/_generated/api.ts +12 -0
- package/src/component/_generated/component.ts +308 -0
- package/src/component/cost-recovery.test.ts +344 -0
- package/src/component/costRecovery/claims.ts +109 -0
- package/src/component/costRecovery/completion.ts +92 -0
- package/src/component/costRecovery/helpers.ts +83 -0
- package/src/component/costRecovery/mutations.ts +43 -0
- package/src/component/costRecovery/queries.ts +88 -0
- package/src/component/costRecovery/reservations.ts +164 -0
- package/src/component/costRecovery/schema.ts +127 -0
- package/src/component/schema.ts +10 -0
- package/src/react/components/PayoutSchedule.render.test.tsx +15 -0
- package/src/react/components/PriceCard.render.test.tsx +37 -0
- package/src/react/components/PriceCard.tsx +9 -1
- package/src/react/index.test.ts +5 -0
- package/src/react/index.ts +7 -0
- package/src/react/lib/price-helpers.test.ts +15 -2
- package/src/react/lib/price-helpers.ts +25 -8
- package/src/react/workflows/Billing.test.tsx +1 -1
- package/src/react/workflows/PlanPricingEditor.currency.test.tsx +164 -0
- package/src/react/workflows/PlanPricingEditor.tsx +26 -10
package/src/client/index.ts
CHANGED
|
@@ -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
|
+
});
|