@fantastic.dev/better-stripe 0.13.0 → 0.15.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 +63 -1
- package/dist/client/billing/checkoutOptions.d.ts +27 -0
- package/dist/client/billing/checkoutOptions.d.ts.map +1 -0
- package/dist/client/billing/checkoutOptions.js +2 -0
- package/dist/client/billing/checkoutOptions.js.map +1 -0
- package/dist/client/billing/intents.d.ts +32 -0
- package/dist/client/billing/intents.d.ts.map +1 -0
- package/dist/client/billing/intents.js +71 -0
- package/dist/client/billing/intents.js.map +1 -0
- package/dist/client/connect/disputes.d.ts +20 -0
- package/dist/client/connect/disputes.d.ts.map +1 -1
- package/dist/client/connect/disputes.js +55 -0
- package/dist/client/connect/disputes.js.map +1 -1
- package/dist/client/core/fees.d.ts +2 -0
- package/dist/client/core/fees.d.ts.map +1 -1
- package/dist/client/core/fees.js +7 -0
- package/dist/client/core/fees.js.map +1 -1
- package/dist/client/costRecovery-invoices.d.ts +10 -4
- package/dist/client/costRecovery-invoices.d.ts.map +1 -1
- package/dist/client/costRecovery-invoices.js +4 -0
- package/dist/client/costRecovery-invoices.js.map +1 -1
- package/dist/client/costRecovery.d.ts +18 -2
- package/dist/client/costRecovery.d.ts.map +1 -1
- package/dist/client/costRecovery.js +4 -0
- package/dist/client/costRecovery.js.map +1 -1
- package/dist/client/errors.d.ts +1 -1
- package/dist/client/errors.d.ts.map +1 -1
- package/dist/client/errors.js.map +1 -1
- package/dist/client/index.d.ts +20 -37
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +18 -20
- package/dist/client/index.js.map +1 -1
- package/dist/component/_generated/component.d.ts +59 -0
- package/dist/component/_generated/component.d.ts.map +1 -1
- package/dist/component/costRecovery/claims.d.ts +12 -0
- package/dist/component/costRecovery/claims.d.ts.map +1 -1
- package/dist/component/costRecovery/imports.d.ts +53 -0
- package/dist/component/costRecovery/imports.d.ts.map +1 -0
- package/dist/component/costRecovery/imports.js +67 -0
- package/dist/component/costRecovery/imports.js.map +1 -0
- package/dist/component/costRecovery/mutations.d.ts +47 -0
- package/dist/component/costRecovery/mutations.d.ts.map +1 -1
- package/dist/component/costRecovery/mutations.js +7 -1
- package/dist/component/costRecovery/mutations.js.map +1 -1
- package/dist/component/costRecovery/queries.d.ts +12 -0
- package/dist/component/costRecovery/queries.d.ts.map +1 -1
- package/dist/component/costRecovery/schema.d.ts +72 -2
- package/dist/component/costRecovery/schema.d.ts.map +1 -1
- package/dist/component/costRecovery/schema.js +13 -0
- package/dist/component/costRecovery/schema.js.map +1 -1
- package/dist/component/schema.d.ts +19 -1
- package/dist/react/hooks/useConfirmPayment.d.ts +1 -1
- package/dist/react/hooks/useConfirmPayment.d.ts.map +1 -1
- package/dist/react/hooks/useConfirmPayment.js +13 -2
- package/dist/react/hooks/useConfirmPayment.js.map +1 -1
- package/dist/react/index.d.ts +2 -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/workflows/EvidenceUpload.d.ts +18 -0
- package/dist/react/workflows/EvidenceUpload.d.ts.map +1 -0
- package/dist/react/workflows/EvidenceUpload.js +35 -0
- package/dist/react/workflows/EvidenceUpload.js.map +1 -0
- package/dist/react/workflows/Onboarding.d.ts +3 -3
- package/dist/react/workflows/Onboarding.d.ts.map +1 -1
- package/dist/react/workflows/Onboarding.js +8 -6
- package/dist/react/workflows/Onboarding.js.map +1 -1
- package/dist/react/workflows/SaveCard.d.ts +27 -0
- package/dist/react/workflows/SaveCard.d.ts.map +1 -0
- package/dist/react/workflows/SaveCard.js +56 -0
- package/dist/react/workflows/SaveCard.js.map +1 -0
- package/dist/react/workflows/index.d.ts +2 -0
- package/dist/react/workflows/index.d.ts.map +1 -1
- package/dist/react/workflows/index.js +1 -0
- package/dist/react/workflows/index.js.map +1 -1
- package/dist/react/workflows/useWorkflowAction.d.ts +1 -1
- package/dist/react/workflows/useWorkflowAction.d.ts.map +1 -1
- package/dist/react/workflows/useWorkflowAction.js +6 -2
- package/dist/react/workflows/useWorkflowAction.js.map +1 -1
- package/docs/react-workflows.md +83 -0
- package/package.json +1 -1
- package/src/client/billing/checkoutOptions.ts +27 -0
- package/src/client/billing/intents.test.ts +216 -0
- package/src/client/billing/intents.ts +120 -0
- package/src/client/billing/intentsTransport.test.ts +94 -0
- package/src/client/connect/dispute-upload.test.ts +208 -0
- package/src/client/connect/disputes.ts +103 -0
- package/src/client/core/fees.ts +8 -0
- package/src/client/costRecovery-import.test.ts +174 -0
- package/src/client/costRecovery-invoices.test.ts +69 -0
- package/src/client/costRecovery-invoices.ts +32 -0
- package/src/client/costRecovery.ts +16 -0
- package/src/client/errors.ts +1 -0
- package/src/client/index.ts +50 -52
- package/src/component/_generated/api.ts +2 -0
- package/src/component/_generated/component.ts +65 -0
- package/src/component/costRecovery/imports.ts +74 -0
- package/src/component/costRecovery/mutations.ts +8 -0
- package/src/component/costRecovery/schema.ts +13 -0
- package/src/react/hooks/useConfirmPayment.test.tsx +47 -0
- package/src/react/hooks/useConfirmPayment.ts +16 -2
- package/src/react/index.ts +3 -0
- package/src/react/workflows/EvidenceUpload.test.tsx +56 -0
- package/src/react/workflows/EvidenceUpload.tsx +71 -0
- package/src/react/workflows/Onboarding.test.tsx +73 -0
- package/src/react/workflows/Onboarding.tsx +8 -5
- package/src/react/workflows/SaveCard.test.tsx +127 -0
- package/src/react/workflows/SaveCard.tsx +117 -0
- package/src/react/workflows/index.ts +3 -0
- package/src/react/workflows/useWorkflowAction.ts +6 -1
package/src/client/index.ts
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
import type { CreateCheckoutSessionOptions } from "./billing/checkoutOptions.js";
|
|
2
|
+
export type { CreateCheckoutSessionOptions } from "./billing/checkoutOptions.js";
|
|
3
|
+
import * as intentsImpl from "./billing/intents.js";
|
|
4
|
+
export type * from "./billing/intents.js";
|
|
1
5
|
import { CostRecoveryClient } from "./costRecovery.js";
|
|
2
6
|
export * from "./charge-currency.js";
|
|
3
7
|
export * from "./costRecovery.js";
|
|
@@ -88,12 +92,14 @@ import * as transfersImpl from "./connect/transfers.js";
|
|
|
88
92
|
import * as accountLinksImpl from "./core/accountLinks.js";
|
|
89
93
|
import * as accountsImpl from "./core/accounts.js";
|
|
90
94
|
import * as configImpl from "./core/config.js";
|
|
91
|
-
import
|
|
95
|
+
import * as fees from "./core/fees.js";
|
|
92
96
|
import { validateStatementDescriptorSuffix } from "./core/descriptors.js";
|
|
93
97
|
export { computeFee, computeSplit } from "./core/fees.js";
|
|
94
98
|
export type { FeeBreakdown, SplitResult, SplitTransfer } from "./core/fees.js";
|
|
95
99
|
export { groupSubscriptionsByStore } from "./billing/subscriptions.js";
|
|
96
100
|
export {
|
|
101
|
+
type DisputeEvidenceUploadOptions,
|
|
102
|
+
type DisputeEvidenceFileField,
|
|
97
103
|
buildDisputeEvidence,
|
|
98
104
|
disputeEvidenceCountdown,
|
|
99
105
|
} from "./connect/disputes.js";
|
|
@@ -202,14 +208,6 @@ export type {
|
|
|
202
208
|
|
|
203
209
|
export type BetterStripeComponent = ComponentApi;
|
|
204
210
|
|
|
205
|
-
/** Deep-copy a platform fee config so stored/returned values can't be mutated. */
|
|
206
|
-
function clonePlatformFee(config: PlatformFeeConfig): PlatformFeeConfig {
|
|
207
|
-
return {
|
|
208
|
-
...config,
|
|
209
|
-
tiers: config.tiers?.map((tier) => ({ ...tier })),
|
|
210
|
-
};
|
|
211
|
-
}
|
|
212
|
-
|
|
213
211
|
// BETTER STRIPE CLIENT
|
|
214
212
|
|
|
215
213
|
export class BetterStripe {
|
|
@@ -228,10 +226,10 @@ export class BetterStripe {
|
|
|
228
226
|
this._triggers = options?.triggers;
|
|
229
227
|
this._hooks = options?.hooks;
|
|
230
228
|
if (options?.platformFee !== undefined) {
|
|
231
|
-
validatePlatformFee(options.platformFee);
|
|
229
|
+
fees.validatePlatformFee(options.platformFee);
|
|
232
230
|
// Store a defensive copy so later mutation of the caller's object can't
|
|
233
231
|
// silently bypass validation or change runtime behavior.
|
|
234
|
-
this._platformFee = clonePlatformFee(options.platformFee);
|
|
232
|
+
this._platformFee = fees.clonePlatformFee(options.platformFee);
|
|
235
233
|
}
|
|
236
234
|
if (options?.statementDescriptorSuffix !== undefined) {
|
|
237
235
|
this._statementDescriptorSuffix = validateStatementDescriptorSuffix(
|
|
@@ -250,7 +248,7 @@ export class BetterStripe {
|
|
|
250
248
|
|
|
251
249
|
/** The configured default platform fee (the platform's take), if any. */
|
|
252
250
|
get platformFee(): PlatformFeeConfig | undefined {
|
|
253
|
-
return this._platformFee && clonePlatformFee(this._platformFee);
|
|
251
|
+
return this._platformFee && fees.clonePlatformFee(this._platformFee);
|
|
254
252
|
}
|
|
255
253
|
|
|
256
254
|
/**
|
|
@@ -258,8 +256,8 @@ export class BetterStripe {
|
|
|
258
256
|
* default. The fee math itself is applied at charge time (M2).
|
|
259
257
|
*/
|
|
260
258
|
resolveFee(override?: FeeOverride): PlatformFeeConfig | undefined {
|
|
261
|
-
const resolved = resolveFeeConfig(this._platformFee, override);
|
|
262
|
-
return resolved && clonePlatformFee(resolved);
|
|
259
|
+
const resolved = fees.resolveFeeConfig(this._platformFee, override);
|
|
260
|
+
return resolved && fees.clonePlatformFee(resolved);
|
|
263
261
|
}
|
|
264
262
|
|
|
265
263
|
get apiKey(): string {
|
|
@@ -655,34 +653,35 @@ export class BetterStripe {
|
|
|
655
653
|
return pricesImpl.listStripePrices(this.stripe(), ctx, opts);
|
|
656
654
|
}
|
|
657
655
|
|
|
658
|
-
|
|
656
|
+
/** Card-only one-time checkout. Host authorizes price and authenticated customer. */
|
|
657
|
+
async createPaymentIntent(
|
|
658
|
+
ctx: RunCtx,
|
|
659
|
+
opts: intentsImpl.CreatePaymentIntentOptions,
|
|
660
|
+
) {
|
|
661
|
+
return intentsImpl.createPaymentIntent(
|
|
662
|
+
this.stripe(),
|
|
663
|
+
this.component,
|
|
664
|
+
ctx,
|
|
665
|
+
opts,
|
|
666
|
+
);
|
|
667
|
+
}
|
|
659
668
|
|
|
660
|
-
|
|
669
|
+
/** Provision consented card setup; confirmation and authentication happen in Stripe.js. */
|
|
670
|
+
async createSetupIntent(
|
|
661
671
|
ctx: RunCtx,
|
|
662
|
-
opts:
|
|
663
|
-
idempotencyKey?: string;
|
|
664
|
-
userId: string;
|
|
665
|
-
orgId?: string;
|
|
666
|
-
stripePriceId: string;
|
|
667
|
-
mode: "payment" | "subscription" | "setup";
|
|
668
|
-
uiMode?: "embedded" | "redirect" | "custom" | "form";
|
|
669
|
-
quantity?: number;
|
|
670
|
-
returnUrl: string;
|
|
671
|
-
trialDays?: number;
|
|
672
|
-
accountId?: string;
|
|
673
|
-
customerEmail?: string;
|
|
674
|
-
/** Seller/recipient connected account to route funds to (destination charge). */
|
|
675
|
-
destinationAccountId?: string;
|
|
676
|
-
/** Multiple recipients (store + affiliate[s]); >1 routes via separate charges & transfers. */
|
|
677
|
-
split?: SplitRecipient[];
|
|
678
|
-
/** Per-call platform fee override; falls back to the configured default. */
|
|
679
|
-
fee?: FeeOverride;
|
|
680
|
-
/** Charge total (minor units) for one-time payment fee computation. */
|
|
681
|
-
amount?: number;
|
|
682
|
-
metadata?: Record<string, string>;
|
|
683
|
-
sessionOverrides?: Record<string, unknown>;
|
|
684
|
-
},
|
|
672
|
+
opts: intentsImpl.CreateSetupIntentOptions,
|
|
685
673
|
) {
|
|
674
|
+
return intentsImpl.createSetupIntent(
|
|
675
|
+
this.stripe(),
|
|
676
|
+
this.component,
|
|
677
|
+
ctx,
|
|
678
|
+
opts,
|
|
679
|
+
);
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
// CHECKOUT METHODS
|
|
683
|
+
|
|
684
|
+
async createCheckoutSession(ctx: RunCtx, opts: CreateCheckoutSessionOptions) {
|
|
686
685
|
const { fee, ...rest } = opts;
|
|
687
686
|
return checkoutImpl.createCheckoutSession(
|
|
688
687
|
this.stripe(),
|
|
@@ -1403,13 +1402,6 @@ export class BetterStripe {
|
|
|
1403
1402
|
return disputesImpl.listDisputes(this.component, ctx, opts);
|
|
1404
1403
|
}
|
|
1405
1404
|
|
|
1406
|
-
/**
|
|
1407
|
-
* Submit or stage dispute evidence. Stripe's `submit` defaults to TRUE, so
|
|
1408
|
-
* this method stages by default: EVERY call sends an explicit
|
|
1409
|
-
* `submit: false` — evidence-bearing or metadata-only — unless you pass
|
|
1410
|
-
* `submit: true` to submit to the bank (one-shot, outcome-affecting;
|
|
1411
|
-
* BTS-61, BTS-72). See {@link disputesImpl.updateDispute}.
|
|
1412
|
-
*/
|
|
1413
1405
|
async updateDispute(
|
|
1414
1406
|
ctx: RunCtx,
|
|
1415
1407
|
opts: {
|
|
@@ -1418,23 +1410,29 @@ export class BetterStripe {
|
|
|
1418
1410
|
metadata?: Record<string, string>;
|
|
1419
1411
|
submit?: boolean;
|
|
1420
1412
|
stripeAccountId?: string;
|
|
1421
|
-
/**
|
|
1422
|
-
* Who is acting on the dispute (BTS-107). A platform admin may act on any
|
|
1423
|
-
* dispute; a seller may act only on a dispute for a sale routed to their
|
|
1424
|
-
* own account. Omit for platform-initiated (unrestricted) actions.
|
|
1425
|
-
*/
|
|
1426
1413
|
actor?: RefundActor;
|
|
1427
1414
|
},
|
|
1428
1415
|
): Promise<{ success: true }> {
|
|
1429
1416
|
return disputesImpl.updateDispute(this.stripe(), this.component, ctx, opts);
|
|
1430
1417
|
}
|
|
1431
1418
|
|
|
1419
|
+
async uploadDisputeEvidence(
|
|
1420
|
+
ctx: RunCtx,
|
|
1421
|
+
opts: disputesImpl.DisputeEvidenceUploadOptions,
|
|
1422
|
+
) {
|
|
1423
|
+
return disputesImpl.uploadDisputeEvidence(
|
|
1424
|
+
this.stripe(),
|
|
1425
|
+
this.component,
|
|
1426
|
+
ctx,
|
|
1427
|
+
opts,
|
|
1428
|
+
);
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1432
1431
|
async closeDispute(
|
|
1433
1432
|
ctx: RunCtx,
|
|
1434
1433
|
opts: {
|
|
1435
1434
|
stripeDisputeId: string;
|
|
1436
1435
|
stripeAccountId?: string;
|
|
1437
|
-
/** See {@link updateDispute}'s `actor` (BTS-107). Omit for unrestricted. */
|
|
1438
1436
|
actor?: RefundActor;
|
|
1439
1437
|
},
|
|
1440
1438
|
): Promise<{ success: true }> {
|
|
@@ -20,6 +20,7 @@ import type * as core_validators from "../core/validators.js";
|
|
|
20
20
|
import type * as costRecovery_claims from "../costRecovery/claims.js";
|
|
21
21
|
import type * as costRecovery_completion from "../costRecovery/completion.js";
|
|
22
22
|
import type * as costRecovery_helpers from "../costRecovery/helpers.js";
|
|
23
|
+
import type * as costRecovery_imports from "../costRecovery/imports.js";
|
|
23
24
|
import type * as costRecovery_mutations from "../costRecovery/mutations.js";
|
|
24
25
|
import type * as costRecovery_queries from "../costRecovery/queries.js";
|
|
25
26
|
import type * as costRecovery_reservations from "../costRecovery/reservations.js";
|
|
@@ -57,6 +58,7 @@ const fullApi: ApiFromModules<{
|
|
|
57
58
|
"costRecovery/claims": typeof costRecovery_claims;
|
|
58
59
|
"costRecovery/completion": typeof costRecovery_completion;
|
|
59
60
|
"costRecovery/helpers": typeof costRecovery_helpers;
|
|
61
|
+
"costRecovery/imports": typeof costRecovery_imports;
|
|
60
62
|
"costRecovery/mutations": typeof costRecovery_mutations;
|
|
61
63
|
"costRecovery/queries": typeof costRecovery_queries;
|
|
62
64
|
"costRecovery/reservations": typeof costRecovery_reservations;
|
|
@@ -1707,6 +1707,47 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
|
|
|
1707
1707
|
},
|
|
1708
1708
|
Name
|
|
1709
1709
|
>;
|
|
1710
|
+
importClaim: FunctionReference<
|
|
1711
|
+
"mutation",
|
|
1712
|
+
"internal",
|
|
1713
|
+
{
|
|
1714
|
+
accountId: string;
|
|
1715
|
+
amountDue: number;
|
|
1716
|
+
claimKey: string;
|
|
1717
|
+
collectedAmount: number;
|
|
1718
|
+
currency: string;
|
|
1719
|
+
operationKey: string;
|
|
1720
|
+
revision: number;
|
|
1721
|
+
scopeKey: string;
|
|
1722
|
+
sourceChargeId: string;
|
|
1723
|
+
sourceGross: number;
|
|
1724
|
+
sourceProcessingFee: number;
|
|
1725
|
+
},
|
|
1726
|
+
{
|
|
1727
|
+
_creationTime: number;
|
|
1728
|
+
_id: string;
|
|
1729
|
+
accountId: string;
|
|
1730
|
+
amountDue: number;
|
|
1731
|
+
claimKey: string;
|
|
1732
|
+
collectedAmount: number;
|
|
1733
|
+
currency: string;
|
|
1734
|
+
openingCollection?: {
|
|
1735
|
+
amountDue: number;
|
|
1736
|
+
collectedAmount: number;
|
|
1737
|
+
operationKey: string;
|
|
1738
|
+
revision: number;
|
|
1739
|
+
};
|
|
1740
|
+
outstanding: number;
|
|
1741
|
+
reservedAmount: number;
|
|
1742
|
+
revision: number;
|
|
1743
|
+
scopeKey: string;
|
|
1744
|
+
sign: "positive" | "negative" | "zero";
|
|
1745
|
+
sourceChargeId: string;
|
|
1746
|
+
sourceGross: number;
|
|
1747
|
+
sourceProcessingFee: number;
|
|
1748
|
+
},
|
|
1749
|
+
Name
|
|
1750
|
+
>;
|
|
1710
1751
|
record: FunctionReference<
|
|
1711
1752
|
"mutation",
|
|
1712
1753
|
"internal",
|
|
@@ -1729,6 +1770,12 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
|
|
|
1729
1770
|
claimKey: string;
|
|
1730
1771
|
collectedAmount: number;
|
|
1731
1772
|
currency: string;
|
|
1773
|
+
openingCollection?: {
|
|
1774
|
+
amountDue: number;
|
|
1775
|
+
collectedAmount: number;
|
|
1776
|
+
operationKey: string;
|
|
1777
|
+
revision: number;
|
|
1778
|
+
};
|
|
1732
1779
|
outstanding: number;
|
|
1733
1780
|
reservedAmount: number;
|
|
1734
1781
|
revision: number;
|
|
@@ -1816,6 +1863,12 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
|
|
|
1816
1863
|
claimKey: string;
|
|
1817
1864
|
collectedAmount: number;
|
|
1818
1865
|
currency: string;
|
|
1866
|
+
openingCollection?: {
|
|
1867
|
+
amountDue: number;
|
|
1868
|
+
collectedAmount: number;
|
|
1869
|
+
operationKey: string;
|
|
1870
|
+
revision: number;
|
|
1871
|
+
};
|
|
1819
1872
|
outstanding: number;
|
|
1820
1873
|
reservedAmount: number;
|
|
1821
1874
|
revision: number;
|
|
@@ -1892,6 +1945,12 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
|
|
|
1892
1945
|
claimKey: string;
|
|
1893
1946
|
collectedAmount: number;
|
|
1894
1947
|
currency: string;
|
|
1948
|
+
openingCollection?: {
|
|
1949
|
+
amountDue: number;
|
|
1950
|
+
collectedAmount: number;
|
|
1951
|
+
operationKey: string;
|
|
1952
|
+
revision: number;
|
|
1953
|
+
};
|
|
1895
1954
|
outstanding: number;
|
|
1896
1955
|
reservedAmount: number;
|
|
1897
1956
|
revision: number;
|
|
@@ -1965,6 +2024,12 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
|
|
|
1965
2024
|
claimKey: string;
|
|
1966
2025
|
collectedAmount: number;
|
|
1967
2026
|
currency: string;
|
|
2027
|
+
openingCollection?: {
|
|
2028
|
+
amountDue: number;
|
|
2029
|
+
collectedAmount: number;
|
|
2030
|
+
operationKey: string;
|
|
2031
|
+
revision: number;
|
|
2032
|
+
};
|
|
1968
2033
|
outstanding: number;
|
|
1969
2034
|
reservedAmount: number;
|
|
1970
2035
|
revision: number;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { v, type Infer } from "convex/values";
|
|
2
|
+
import type { MutationCtx } from "../_generated/server.js";
|
|
3
|
+
import { importArgs } from "./schema.js";
|
|
4
|
+
import { recordClaim } from "./claims.js";
|
|
5
|
+
import { adjustTotals, balance, integer, sign } from "./helpers.js";
|
|
6
|
+
const _argsValidator = v.object(importArgs);
|
|
7
|
+
/** The host verifies historical collection; this operation performs no provider IO. */
|
|
8
|
+
export async function importOpeningClaim(
|
|
9
|
+
ctx: MutationCtx,
|
|
10
|
+
args: Infer<typeof _argsValidator>,
|
|
11
|
+
) {
|
|
12
|
+
integer(args.collectedAmount);
|
|
13
|
+
if (!args.operationKey.trim() || args.claimKey.startsWith("rollback:"))
|
|
14
|
+
throw new Error("Invalid recovery import identity.");
|
|
15
|
+
const imported = await ctx.db
|
|
16
|
+
.query("recoveryClaims")
|
|
17
|
+
.withIndex("by_import", (q) =>
|
|
18
|
+
q.eq("openingCollection.operationKey", args.operationKey),
|
|
19
|
+
)
|
|
20
|
+
.unique();
|
|
21
|
+
if (imported && imported.claimKey !== args.claimKey)
|
|
22
|
+
throw new Error("Recovery import identity cannot change.");
|
|
23
|
+
const existing = await ctx.db
|
|
24
|
+
.query("recoveryClaims")
|
|
25
|
+
.withIndex("by_key", (q) => q.eq("claimKey", args.claimKey))
|
|
26
|
+
.unique();
|
|
27
|
+
const openingCollection = {
|
|
28
|
+
operationKey: args.operationKey,
|
|
29
|
+
amountDue: args.amountDue,
|
|
30
|
+
revision: args.revision,
|
|
31
|
+
collectedAmount: args.collectedAmount,
|
|
32
|
+
};
|
|
33
|
+
if (existing) {
|
|
34
|
+
for (const key of [
|
|
35
|
+
"accountId",
|
|
36
|
+
"scopeKey",
|
|
37
|
+
"currency",
|
|
38
|
+
"sourceChargeId",
|
|
39
|
+
"sourceGross",
|
|
40
|
+
"sourceProcessingFee",
|
|
41
|
+
] as const)
|
|
42
|
+
if (existing[key] !== args[key])
|
|
43
|
+
throw new Error("Recovery import source identity cannot change.");
|
|
44
|
+
if (!existing.openingCollection)
|
|
45
|
+
throw new Error("Cannot import over an existing live recovery claim.");
|
|
46
|
+
for (const key of [
|
|
47
|
+
"operationKey",
|
|
48
|
+
"amountDue",
|
|
49
|
+
"revision",
|
|
50
|
+
"collectedAmount",
|
|
51
|
+
] as const)
|
|
52
|
+
if (existing.openingCollection[key] !== openingCollection[key])
|
|
53
|
+
throw new Error("Recovery opening snapshot cannot change.");
|
|
54
|
+
return existing;
|
|
55
|
+
}
|
|
56
|
+
if ((await balance(ctx, args)).activeGroupKey)
|
|
57
|
+
throw new Error(
|
|
58
|
+
"Cannot import while another recovery boundary is pending.",
|
|
59
|
+
);
|
|
60
|
+
const { operationKey: _operationKey, collectedAmount, ...observation } = args;
|
|
61
|
+
const claim = await recordClaim(ctx, observation);
|
|
62
|
+
const outstanding = integer(claim.amountDue - collectedAmount);
|
|
63
|
+
await ctx.db.patch("recoveryClaims", claim._id, {
|
|
64
|
+
collectedAmount,
|
|
65
|
+
outstanding,
|
|
66
|
+
sign: sign(outstanding),
|
|
67
|
+
openingCollection,
|
|
68
|
+
});
|
|
69
|
+
await adjustTotals(ctx, args, {
|
|
70
|
+
collectedAmount,
|
|
71
|
+
outstanding: -collectedAmount,
|
|
72
|
+
});
|
|
73
|
+
return (await ctx.db.get("recoveryClaims", claim._id))!;
|
|
74
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { importOpeningClaim } from "./imports.js";
|
|
1
2
|
import { getGroup, integer } from "./helpers.js";
|
|
2
3
|
import { v } from "convex/values";
|
|
3
4
|
import { mutation } from "../_generated/server.js";
|
|
@@ -6,6 +7,7 @@ import { reserveGroup, sealGroup } from "./reservations.js";
|
|
|
6
7
|
import { finishGroup } from "./completion.js";
|
|
7
8
|
import {
|
|
8
9
|
observationFields,
|
|
10
|
+
importArgs,
|
|
9
11
|
claimDoc,
|
|
10
12
|
groupDoc,
|
|
11
13
|
reservationDoc,
|
|
@@ -113,3 +115,9 @@ export const bindInvoiceFunding = mutation({
|
|
|
113
115
|
return (await ctx.db.get("recoveryGroups", group._id))!;
|
|
114
116
|
},
|
|
115
117
|
});
|
|
118
|
+
|
|
119
|
+
export const importClaim = mutation({
|
|
120
|
+
args: importArgs,
|
|
121
|
+
returns: claimDoc,
|
|
122
|
+
handler: importOpeningClaim,
|
|
123
|
+
});
|
|
@@ -25,12 +25,24 @@ export const observationFields = {
|
|
|
25
25
|
amountDue: v.number(),
|
|
26
26
|
revision: v.number(),
|
|
27
27
|
};
|
|
28
|
+
export const openingCollection = v.object({
|
|
29
|
+
operationKey: v.string(),
|
|
30
|
+
amountDue: v.number(),
|
|
31
|
+
revision: v.number(),
|
|
32
|
+
collectedAmount: v.number(),
|
|
33
|
+
});
|
|
34
|
+
export const importArgs = {
|
|
35
|
+
...observationFields,
|
|
36
|
+
operationKey: v.string(),
|
|
37
|
+
collectedAmount: v.number(),
|
|
38
|
+
};
|
|
28
39
|
export const claimFields = {
|
|
29
40
|
...observationFields,
|
|
30
41
|
collectedAmount: v.number(),
|
|
31
42
|
reservedAmount: v.number(),
|
|
32
43
|
outstanding: v.number(),
|
|
33
44
|
sign: signValidator,
|
|
45
|
+
openingCollection: v.optional(openingCollection),
|
|
34
46
|
};
|
|
35
47
|
export const claimDoc = v.object({
|
|
36
48
|
...claimFields,
|
|
@@ -104,6 +116,7 @@ export const reservationDoc = v.object({
|
|
|
104
116
|
});
|
|
105
117
|
export const claimsTable = defineTable(claimFields)
|
|
106
118
|
.index("by_key", ["claimKey"])
|
|
119
|
+
.index("by_import", ["openingCollection.operationKey"])
|
|
107
120
|
.index("by_scope", ["accountId", "scopeKey", "currency"])
|
|
108
121
|
.index("by_sign", ["accountId", "scopeKey", "currency", "sign"]);
|
|
109
122
|
export const balancesTable = defineTable(balanceFields).index("by_scope", [
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { renderHook } from "@testing-library/react";
|
|
3
|
+
import { expect, it, vi } from "vitest";
|
|
4
|
+
const f = vi.hoisted(() => ({ confirmSetup: vi.fn(), submit: vi.fn() }));
|
|
5
|
+
vi.mock("@stripe/react-stripe-js", () => ({
|
|
6
|
+
useStripe: () => ({ confirmSetup: f.confirmSetup }),
|
|
7
|
+
useElements: () => ({ submit: f.submit }),
|
|
8
|
+
}));
|
|
9
|
+
import { useConfirmPayment } from "./useConfirmPayment.js";
|
|
10
|
+
it("submits Elements and confirms explicit redisplay permission only on succeeded setup", async () => {
|
|
11
|
+
f.submit.mockResolvedValue({});
|
|
12
|
+
f.confirmSetup.mockResolvedValue({ setupIntent: { status: "succeeded" } });
|
|
13
|
+
const { result } = renderHook(useConfirmPayment);
|
|
14
|
+
expect(
|
|
15
|
+
await result.current.confirmSetup("https://app.test", "always"),
|
|
16
|
+
).toEqual({ success: true });
|
|
17
|
+
expect(f.submit).toHaveBeenCalled();
|
|
18
|
+
expect(f.confirmSetup).toHaveBeenCalledWith({
|
|
19
|
+
elements: expect.anything(),
|
|
20
|
+
confirmParams: {
|
|
21
|
+
return_url: "https://app.test",
|
|
22
|
+
payment_method_data: { allow_redisplay: "always" },
|
|
23
|
+
},
|
|
24
|
+
redirect: "if_required",
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
it("does not turn processing or incomplete authentication into saved-card success", async () => {
|
|
28
|
+
f.submit.mockResolvedValue({});
|
|
29
|
+
f.confirmSetup.mockResolvedValue({
|
|
30
|
+
setupIntent: { status: "requires_action" },
|
|
31
|
+
});
|
|
32
|
+
const { result } = renderHook(useConfirmPayment);
|
|
33
|
+
expect(await result.current.confirmSetup()).toEqual({
|
|
34
|
+
success: false,
|
|
35
|
+
error: "Card setup is not complete.",
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
it("reports validation errors before confirmation", async () => {
|
|
39
|
+
f.confirmSetup.mockClear();
|
|
40
|
+
f.submit.mockResolvedValue({ error: { message: "Invalid card" } });
|
|
41
|
+
const { result } = renderHook(useConfirmPayment);
|
|
42
|
+
expect(await result.current.confirmSetup()).toEqual({
|
|
43
|
+
success: false,
|
|
44
|
+
error: "Invalid card",
|
|
45
|
+
});
|
|
46
|
+
expect(f.confirmSetup).not.toHaveBeenCalled();
|
|
47
|
+
});
|
|
@@ -77,15 +77,27 @@ export function useConfirmPayment() {
|
|
|
77
77
|
* @param returnUrl - URL to redirect to after 3D Secure. Defaults to current page.
|
|
78
78
|
*/
|
|
79
79
|
const confirmSetup = useCallback(
|
|
80
|
-
async (
|
|
80
|
+
async (
|
|
81
|
+
returnUrl?: string,
|
|
82
|
+
allowRedisplay?: "always" | "limited" | "unspecified",
|
|
83
|
+
): Promise<StripeConfirmResult> => {
|
|
81
84
|
if (!stripe || !elements) {
|
|
82
85
|
return { success: false, error: "Stripe not initialized" };
|
|
83
86
|
}
|
|
84
87
|
|
|
85
|
-
const
|
|
88
|
+
const validation = await elements.submit();
|
|
89
|
+
if (validation.error)
|
|
90
|
+
return {
|
|
91
|
+
success: false,
|
|
92
|
+
error: validation.error.message ?? "Check your card details.",
|
|
93
|
+
};
|
|
94
|
+
const { error, setupIntent } = await stripe.confirmSetup({
|
|
86
95
|
elements,
|
|
87
96
|
confirmParams: {
|
|
88
97
|
return_url: returnUrl ?? window.location.href,
|
|
98
|
+
...(allowRedisplay
|
|
99
|
+
? { payment_method_data: { allow_redisplay: allowRedisplay } }
|
|
100
|
+
: {}),
|
|
89
101
|
},
|
|
90
102
|
redirect: "if_required",
|
|
91
103
|
});
|
|
@@ -96,6 +108,8 @@ export function useConfirmPayment() {
|
|
|
96
108
|
error: error.message ?? "Failed to confirm setup",
|
|
97
109
|
};
|
|
98
110
|
}
|
|
111
|
+
if (setupIntent?.status !== "succeeded")
|
|
112
|
+
return { success: false, error: "Card setup is not complete." };
|
|
99
113
|
return { success: true };
|
|
100
114
|
},
|
|
101
115
|
[stripe, elements],
|
package/src/react/index.ts
CHANGED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import {
|
|
3
|
+
act,
|
|
4
|
+
cleanup,
|
|
5
|
+
fireEvent,
|
|
6
|
+
render,
|
|
7
|
+
screen,
|
|
8
|
+
} from "@testing-library/react";
|
|
9
|
+
import { afterEach, expect, it, vi } from "vitest";
|
|
10
|
+
import { EvidenceUpload } from "./EvidenceUpload.js";
|
|
11
|
+
afterEach(cleanup);
|
|
12
|
+
it("uploads the selected file once, retains it on failure and retries as a draft", async () => {
|
|
13
|
+
let reject!: (error: Error) => void;
|
|
14
|
+
const upload = vi.fn(
|
|
15
|
+
() =>
|
|
16
|
+
new Promise<unknown>((_, fail) => {
|
|
17
|
+
reject = fail;
|
|
18
|
+
}),
|
|
19
|
+
);
|
|
20
|
+
render(<EvidenceUpload scopeKey="seller:dp_1" onUpload={upload} />);
|
|
21
|
+
const file = new File(["%PDF-1.7"], "proof.pdf", { type: "application/pdf" });
|
|
22
|
+
fireEvent.change(screen.getByLabelText("Evidence file"), {
|
|
23
|
+
target: { files: [file] },
|
|
24
|
+
});
|
|
25
|
+
fireEvent.click(screen.getByRole("button", { name: "Attach to draft" }));
|
|
26
|
+
fireEvent.click(screen.getByRole("button", { name: "Uploading…" }));
|
|
27
|
+
expect(upload).toHaveBeenCalledExactlyOnceWith(file);
|
|
28
|
+
await act(async () => {
|
|
29
|
+
reject(new Error("Upload unavailable"));
|
|
30
|
+
});
|
|
31
|
+
expect(screen.getByRole("alert").textContent).toBe("Upload unavailable");
|
|
32
|
+
upload.mockResolvedValue({});
|
|
33
|
+
fireEvent.click(screen.getByRole("button", { name: "Attach to draft" }));
|
|
34
|
+
await act(async () => {});
|
|
35
|
+
expect(upload).toHaveBeenCalledTimes(2);
|
|
36
|
+
expect(screen.getByRole("status").textContent).toBe(
|
|
37
|
+
"File attached to draft. Submit evidence separately when ready.",
|
|
38
|
+
);
|
|
39
|
+
});
|
|
40
|
+
it("forgets another dispute's selected file", () => {
|
|
41
|
+
const upload = vi.fn();
|
|
42
|
+
const { rerender } = render(
|
|
43
|
+
<EvidenceUpload scopeKey="seller:dp_1" onUpload={upload} />,
|
|
44
|
+
);
|
|
45
|
+
fireEvent.change(screen.getByLabelText("Evidence file"), {
|
|
46
|
+
target: { files: [new File(["a"], "a.pdf")] },
|
|
47
|
+
});
|
|
48
|
+
rerender(<EvidenceUpload scopeKey="seller:dp_2" onUpload={upload} />);
|
|
49
|
+
expect(
|
|
50
|
+
(
|
|
51
|
+
screen.getByRole("button", {
|
|
52
|
+
name: "Attach to draft",
|
|
53
|
+
}) as HTMLButtonElement
|
|
54
|
+
).disabled,
|
|
55
|
+
).toBe(true);
|
|
56
|
+
});
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useId, useState, type ReactNode } from "react";
|
|
3
|
+
import { useWorkflowAction } from "./useWorkflowAction.js";
|
|
4
|
+
|
|
5
|
+
export type EvidenceUploadProps = {
|
|
6
|
+
/** Include the authenticated scope and selected dispute. */
|
|
7
|
+
scopeKey: string;
|
|
8
|
+
/** Host authorizes the dispute and uploads bytes through BetterStripe server-side. */
|
|
9
|
+
onUpload: (file: File) => Promise<unknown>;
|
|
10
|
+
children?: (state: {
|
|
11
|
+
file: File | undefined;
|
|
12
|
+
setFile: (file: File | undefined) => void;
|
|
13
|
+
upload: () => Promise<unknown>;
|
|
14
|
+
isPending: boolean;
|
|
15
|
+
error: string | null;
|
|
16
|
+
isAttached: boolean;
|
|
17
|
+
}) => ReactNode;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/** Uploading only attaches to the draft. EvidenceForm owns explicit submission. */
|
|
21
|
+
export function EvidenceUpload(props: EvidenceUploadProps) {
|
|
22
|
+
return <EvidenceUploadBody key={props.scopeKey} {...props} />;
|
|
23
|
+
}
|
|
24
|
+
function EvidenceUploadBody(props: EvidenceUploadProps) {
|
|
25
|
+
const id = useId();
|
|
26
|
+
const [file, setFile] = useState<File>();
|
|
27
|
+
const action = useWorkflowAction(async () => {
|
|
28
|
+
if (!file) throw new Error("Choose an evidence file first.");
|
|
29
|
+
await props.onUpload(file);
|
|
30
|
+
return true;
|
|
31
|
+
}, props.scopeKey);
|
|
32
|
+
const state = {
|
|
33
|
+
file,
|
|
34
|
+
setFile: (file: File | undefined) => {
|
|
35
|
+
setFile(file);
|
|
36
|
+
action.reset();
|
|
37
|
+
},
|
|
38
|
+
upload: action.run,
|
|
39
|
+
isPending: action.isPending,
|
|
40
|
+
error: action.error,
|
|
41
|
+
isAttached: action.result === true,
|
|
42
|
+
};
|
|
43
|
+
if (props.children) return <>{props.children(state)}</>;
|
|
44
|
+
return (
|
|
45
|
+
<section data-better-stripe="evidence-upload" aria-busy={state.isPending}>
|
|
46
|
+
<label htmlFor={id}>Evidence file</label>
|
|
47
|
+
<input
|
|
48
|
+
id={id}
|
|
49
|
+
type="file"
|
|
50
|
+
accept="application/pdf,image/jpeg,image/png"
|
|
51
|
+
disabled={state.isPending}
|
|
52
|
+
onChange={(event) => state.setFile(event.target.files?.[0])}
|
|
53
|
+
/>
|
|
54
|
+
<button
|
|
55
|
+
type="button"
|
|
56
|
+
disabled={!file || state.isPending}
|
|
57
|
+
onClick={() => {
|
|
58
|
+
void state.upload();
|
|
59
|
+
}}
|
|
60
|
+
>
|
|
61
|
+
{state.isPending ? "Uploading…" : "Attach to draft"}
|
|
62
|
+
</button>
|
|
63
|
+
{state.error && <p role="alert">{state.error}</p>}
|
|
64
|
+
{state.isAttached && (
|
|
65
|
+
<p role="status">
|
|
66
|
+
File attached to draft. Submit evidence separately when ready.
|
|
67
|
+
</p>
|
|
68
|
+
)}
|
|
69
|
+
</section>
|
|
70
|
+
);
|
|
71
|
+
}
|