diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 000000000..a2595e30c --- /dev/null +++ b/PLAN.md @@ -0,0 +1,131 @@ +# Credit Card Credits Flow Implementation Plan + +## Overview + +Route starter-pack credit-card purchases through the existing USD credits +deposit flow instead of creating a direct Coinflow starter-pack checkout. After +credits settle, resume the original bundle purchase automatically. + +## Goals + +- Always open the credits deposit flow when "Credit Card" is selected. +- Default to the Credits rail whenever the existing balance covers the purchase. +- Display the card-backed purchase amount in USD, not USDC. +- Preserve automatic purchase completion after the deposited credits become + available. + +## Non-Goals + +- Change Apple Pay, wallet, or direct credits behavior. +- Remove Coinflow support from the standalone credits deposit flow. + +## Assumptions and Constraints + +- The existing credits quote remains the authoritative bundle price. +- Existing minimum and maximum credits purchase limits remain unchanged. +- Coinflow sandbox deposits do not produce spendable credits and retain the + current warning/error behavior. + +## Requirements + +### Functional + +- Credit-card checkout starts a Coinflow-backed credits deposit. +- The deposit amount covers the credits shortfall and respects the minimum + deposit. +- Successful settlement purchases the requested bundle with credits. +- The review total uses the USD pseudo-token and credits quote. + +### Non-Functional + +- Keep analytics method attribution as `coinflow` for card selection. +- Reuse the existing stale-purchase and duplicate-completion guards. + +## Technical Design + +### Data Model + +No schema changes. + +### API Design + +No API changes. Reuse bundle credits quote, credits deposit, balance refresh, +and credits purchase operations. + +### Architecture + +`Credit Card selection -> credits deposit drawer -> credits settlement -> bundle credits purchase -> success` + +### UX Flow + +The review screen shows USD. Continue opens the credits amount/deposit flow with +Coinflow preferred; completion resumes the original purchase. + +--- + +## Implementation Plan + +### Serial Dependencies (Must Complete First) + +#### Phase 0: Shared Calculation + +**Prerequisite for:** Checkout routing + +| Task | Description | Output | +| ---- | ------------------------------------------------------------ | ---------------------------- | +| 0.1 | Centralize shortfall-to-USD rounding and minimum enforcement | Tested credits top-up helper | + +--- + +### Parallel Workstreams + +#### Workstream A: Checkout Routing + +**Dependencies:** Phase 0 **Can parallelize with:** Workstream B + +| Task | Description | Output | +| ---- | ------------------------------------------------------------- | ------------------------ | +| A.1 | Route card purchases into credits deposit and resume purchase | Updated checkout handler | + +#### Workstream B: USD Presentation + +**Dependencies:** Phase 0 **Can parallelize with:** Workstream A + +| Task | Description | Output | +| ---- | ----------------------------------------------------------- | ---------------------- | +| B.1 | Render card-backed review pricing with the USD pseudo-token | Updated cost breakdown | + +--- + +### Merge Phase + +#### Phase 2: Integration + +**Dependencies:** Workstreams A, B + +| Task | Description | Output | +| ---- | -------------------------------------------------------------------------------------- | -------------------- | +| 2.1 | Remove direct starter-pack Coinflow drawer restrictions and validate the combined flow | Focused, linted diff | + +--- + +## Testing and Validation + +- Unit-test top-up rounding, existing-balance subtraction, and minimum + enforcement. +- Run keychain unit tests, lint/format checks, and TypeScript build. + +## Rollout and Migration + +No migration. Ship with the next controller/keychain release; rollback is a +normal code revert. + +## Risks and Mitigations + +- Credits settlement delay: retain polling and stale-purchase guards. +- Sandbox cannot mint spendable credits: retain the explicit sandbox failure + message. + +## Open Questions + +None; the Slack thread defines the required routing and currency behavior. diff --git a/examples/next/src/components/providers/StarknetProvider.tsx b/examples/next/src/components/providers/StarknetProvider.tsx index c26eb908c..551bea9f0 100644 --- a/examples/next/src/components/providers/StarknetProvider.tsx +++ b/examples/next/src/components/providers/StarknetProvider.tsx @@ -169,29 +169,33 @@ const provider = jsonRpcProvider({ }); const getKeychainUrl = () => { + const configuredUrl = process.env.NEXT_PUBLIC_KEYCHAIN_FRAME_URL; + if ( process.env.NEXT_PUBLIC_VERCEL_ENV === "preview" && process.env.NEXT_PUBLIC_VERCEL_GIT_COMMIT_REF ) { - let branchName: string; + let branchName = process.env.NEXT_PUBLIC_VERCEL_GIT_COMMIT_REF.replace( + /[^a-zA-Z0-9-]/g, + "-", + ); - const url = window.location.href; - const match = url.match(/git-([a-zA-Z0-9-]+)\.preview/); + // Some Vercel builds report the fallback branch name "update-ui". In + // that case, recover the branch name from the current URL. + if (branchName === "update-ui") { + const match = window.location.href.match(/git-([a-zA-Z0-9-]+)\.preview/); - if (match && match[1]) { - branchName = match[1]; - } else { - branchName = process.env.NEXT_PUBLIC_VERCEL_GIT_COMMIT_REF.replace( - /[^a-zA-Z0-9-]/g, - "-", - ); + if (match && match[1]) { + branchName = match[1]; + } } - const keychainUrl = `https://keychain-git-${branchName}.preview.cartridge.gg/`; + const keychainPreviewLabel = `keychain-git-${branchName}`; + const keychainUrl = `https://${keychainPreviewLabel}.preview.cartridge.gg/`; return keychainUrl; } else { - return process.env.NEXT_PUBLIC_KEYCHAIN_FRAME_URL; + return configuredUrl; } }; diff --git a/packages/keychain/src/components/credits/CoinbaseCreditsCheckout.tsx b/packages/keychain/src/components/credits/CoinbaseCreditsCheckout.tsx index 2cb2c7f3c..56f2e70c7 100644 --- a/packages/keychain/src/components/credits/CoinbaseCreditsCheckout.tsx +++ b/packages/keychain/src/components/credits/CoinbaseCreditsCheckout.tsx @@ -8,7 +8,7 @@ import { import { CoinbaseDrawer } from "@/components/purchase/checkout/coinbase/drawer"; import { useCoinbase } from "@/hooks/payments/coinbase"; import { waitForCryptoPaymentConfirmation } from "@/hooks/payments/crypto"; -import { useUsdcToken } from "@/hooks/payments/usdc"; +import { CREDITS_TOKEN } from "@/components/purchase/review/cost"; import { MIN_CREDITS_PURCHASE_USD } from "@/utils/credits"; import { ErrorCard } from "@/components/purchase/checkout/onchain/error"; import { CoinbaseOnrampStatus } from "@/utils/api"; @@ -51,7 +51,6 @@ export function CoinbaseCreditsCheckout({ onChangeAmount, }: CoinbaseCreditsCheckoutProps) { const { isMainnet } = useConnection(); - const usdcToken = useUsdcToken(); const [orderError, setOrderError] = useState(null); const coinbase = useCoinbase({ onError: setOrderError }); @@ -217,7 +216,7 @@ export function CoinbaseCreditsCheckout({ amount={amount} onChangeMethod={onChangeMethod} onChangeAmount={onChangeAmount} - costToken={usdcToken} + costToken={CREDITS_TOKEN} costValue={ coinbaseQuote ? ( diff --git a/packages/keychain/src/components/credits/CoinflowCreditsCheckout.tsx b/packages/keychain/src/components/credits/CoinflowCreditsCheckout.tsx index ae8d6f1e5..db5cf9262 100644 --- a/packages/keychain/src/components/credits/CoinflowCreditsCheckout.tsx +++ b/packages/keychain/src/components/credits/CoinflowCreditsCheckout.tsx @@ -7,8 +7,10 @@ import { type CoinflowRailContextValue, } from "@/components/purchase/checkout/rails"; import { CoinflowDrawer } from "@/components/purchase/checkout/coinflow/drawer"; -import { convertCentsToDollars } from "@/components/purchase/review/cost"; -import { useUsdcToken } from "@/hooks/payments/usdc"; +import { + CREDITS_TOKEN, + convertCentsToDollars, +} from "@/components/purchase/review/cost"; import { useCoinflowCreditsPayment, useCoinflowIsMainnet, @@ -47,7 +49,6 @@ export function CoinflowCreditsCheckout({ onChangeAmount, }: CoinflowCreditsCheckoutProps) { const { createIntent, env, isLoading, error } = useCoinflowCreditsPayment(); - const usdcToken = useUsdcToken(); const { isCoinflowSandbox } = useCoinflowIsMainnet(); const [intent, setIntent] = useState(); const { phase, verifying, handleContinue, backToReview } = @@ -112,7 +113,7 @@ export function CoinflowCreditsCheckout({ amount={amount} onChangeMethod={onChangeMethod} onChangeAmount={onChangeAmount} - costToken={usdcToken} + costToken={CREDITS_TOKEN} costValue={ intent ? ( diff --git a/packages/keychain/src/components/purchase/checkout/onchain/index.tsx b/packages/keychain/src/components/purchase/checkout/onchain/index.tsx index 6514bd169..094d59e1c 100644 --- a/packages/keychain/src/components/purchase/checkout/onchain/index.tsx +++ b/packages/keychain/src/components/purchase/checkout/onchain/index.tsx @@ -41,14 +41,15 @@ import { type PaymentMethodSelection, } from "./wallet-drawer"; import { SocialClaimCheckout } from "./social-claim"; -import { CoinflowDrawer } from "../coinflow/drawer"; import { CoinbaseDrawer } from "../coinbase/drawer"; import { VerificationDrawer } from "../../verification/drawer"; -import { USDC_ADDRESSES } from "@/utils/ekubo"; import { useGeoLocation } from "@/hooks/geo"; -import { num } from "starknet"; import { useIdentityContext } from "@/components/identity/provider"; import { useCreditsContext } from "@/components/credits/provider"; +import { + creditsTopupAmountUsd, + shouldOpenCreditsDeposit, +} from "@/utils/credits-topup"; import { MAX_CREDITS_PURCHASE_USD, MIN_CREDITS_PURCHASE_USD, @@ -121,8 +122,6 @@ export function OnchainCheckout() { onCreditsSelect, } = useOnchainPurchaseContext(); const { - onCreditCardPurchase, - isCoinflowLoading, creditsQuote, isCreditsQuoteLoading, creditsQuoteError, @@ -148,7 +147,6 @@ export function OnchainCheckout() { const [isLoading, setIsLoading] = useState(false); const [isDrawerOpen, setIsDrawerOpen] = useState(false); - const [isCoinflowDrawerOpen, setIsCoinflowDrawerOpen] = useState(false); const [isCoinbaseDrawerOpen, setIsCoinbaseDrawerOpen] = useState(false); const [verificationMethod, setVerificationMethod] = useState< "apple-pay" | "identity" | null @@ -249,17 +247,6 @@ export function OnchainCheckout() { return quote ? quote.totalCost === BigInt(0) : undefined; }, [quote]); - const isCoinflowStarterpackSupported = useMemo(() => { - if (!controller || !quote) { - return true; - } - - const usdcAddress = USDC_ADDRESSES[controller.chainId()]; - return ( - !!usdcAddress && num.toHex(quote.paymentToken) === num.toHex(usdcAddress) - ); - }, [controller, quote]); - const { balanceError, bridgeFrom, @@ -374,8 +361,7 @@ export function OnchainCheckout() { credits: creditsResolution, hasSufficientCredits, cardTopupAvailable: configuredCoinflowAvailable, - directCardAvailable: - configuredCoinflowAvailable && isCoinflowStarterpackSupported, + directCardAvailable: configuredCoinflowAvailable, }); if (resolution.status === "pending") return; @@ -414,7 +400,6 @@ export function OnchainCheckout() { tokenFundingStatus, hasSufficientCredits, configuredCoinflowAvailable, - isCoinflowStarterpackSupported, onCreditsSelect, onCoinflowSelect, clearSelectedWallet, @@ -433,7 +418,7 @@ export function OnchainCheckout() { const showConfiguredCreditsTopup = configuredCard && showInsufficientCredits; const globalDisabled = useMemo(() => { - if (isCreditsSelected) { + if (isCreditsSelected || isCoinflowSelected) { return ( isCreditsQuoteLoading || !!creditsQuoteError || @@ -444,10 +429,6 @@ export function OnchainCheckout() { ); } - if (isCoinflowSelected) { - return !isCoinflowStarterpackSupported || isCoinflowLoading; - } - // Disable if there's a fee estimation error (e.g., bridge amount too low) if (feeEstimationError) return true; @@ -479,8 +460,6 @@ export function OnchainCheckout() { isApplePaySelected, isApplePayAmountTooLow, isCoinflowSelected, - isCoinflowStarterpackSupported, - isCoinflowLoading, isCreditsSelected, isCreditsQuoteLoading, isCreditsBalanceLoading, @@ -615,23 +594,17 @@ export function OnchainCheckout() { ? "credits" : "onchain"; - if ( - method === "coinflow" && - (!isUS || !isCoinflowEnabled || !isCoinflowStarterpackSupported) - ) { + if (method === "coinflow" && (!isUS || !isCoinflowEnabled)) { return; } - if (method === "credits" && !hasSufficientCredits) { + if (shouldOpenCreditsDeposit(method, hasSufficientCredits)) { if (!creditsQuote || !purchaseKey) return; const requiredCredits = BigInt(creditsQuote.requiredCredits); - const shortfall = - requiredCredits > creditsBalance - ? requiredCredits - creditsBalance - : 0n; - // $1 = 1e8 raw credit units. Round upward to a cent so the top-up can - // never land a fraction below the authoritative bundle quote. - const shortfallUsd = Number((shortfall + 999_999n) / 1_000_000n) / 100; - const topupAmount = Math.max(MIN_CREDITS_PURCHASE_USD, shortfallUsd); + const topupAmount = creditsTopupAmountUsd({ + requiredCredits, + creditsBalance, + minimumAmount: MIN_CREDITS_PURCHASE_USD, + }); if (topupAmount > MAX_CREDITS_PURCHASE_USD) { setDisplayError( new Error( @@ -644,9 +617,10 @@ export function OnchainCheckout() { const originatingPurchaseKey = purchaseKey; initiateCreditsDeposit({ - preferredMethod: configuredCoinflowAvailable - ? { type: "coinflow" } - : undefined, + preferredMethod: + method === "coinflow" || configuredCoinflowAvailable + ? { type: "coinflow" } + : undefined, minimumAmount: topupAmount, purchaseKey: originatingPurchaseKey, onSuccess: async () => { @@ -723,9 +697,6 @@ export function OnchainCheckout() { if (method === "credits") { await onCreditsPurchase(); navigate("/purchase/success", { reset: true }); - } else if (method === "coinflow") { - await onCreditCardPurchase(); - setIsCoinflowDrawerOpen(true); } else if (method === "apple-pay") { resetCoinbasePurchase(); @@ -778,13 +749,11 @@ export function OnchainCheckout() { isFree, isCoinflowSelected, isCoinflowEnabled, - isCoinflowStarterpackSupported, isUS, isApplePaySelected, applePayLimitExceeded, fetchCoinbaseLimits, resetCoinbasePurchase, - onCreditCardPurchase, isPhoneNumberVerified, isEmailVerified, onCreateCoinbaseOrder, @@ -951,25 +920,18 @@ export function OnchainCheckout() { /> )} - {isCoinflowSelected && !isCoinflowStarterpackSupported && ( - - )} - - {isCreditsSelected && creditsQuoteError && ( - - )} + {(isCreditsSelected || isCoinflowSelected) && + creditsQuoteError && ( + + )} {showInsufficientCredits && ( - setIsCoinflowDrawerOpen(false)} - /> - setIsCoinbaseDrawerOpen(false)} diff --git a/packages/keychain/src/components/purchase/review/cost.tsx b/packages/keychain/src/components/purchase/review/cost.tsx index 83d3c5342..02f749ec1 100644 --- a/packages/keychain/src/components/purchase/review/cost.tsx +++ b/packages/keychain/src/components/purchase/review/cost.tsx @@ -167,21 +167,8 @@ export function OnchainCostBreakdown({ coinbaseQuote, isFetchingCoinbaseQuote, } = useOnchainPurchaseContext(); - const { - coinflowQuote, - isCoinflowQuoteLoading, - creditsQuote, - isCreditsQuoteLoading, - } = useCreditPurchaseContext(); + const { creditsQuote, isCreditsQuoteLoading } = useCreditPurchaseContext(); const { decimals } = quote.paymentTokenMetadata; - // When credit card is selected, use the Coinflow backend quote so that - // pricing is correct even for non-USDC starterpacks (handles Ekubo swap). - const coinflowCostDetails = useMemo(() => { - if (!isCoinflowSelected) { - return undefined; - } - return coinflowQuote?.pricing; - }, [isCoinflowSelected, coinflowQuote]); // Get default token (matching quote if available) or fallback to the first available token const defaultToken = @@ -190,7 +177,9 @@ export function OnchainCostBreakdown({ ) || availableTokens[0]; // Use selectedToken or fallback to defaultToken for display - const displayToken = selectedToken || defaultToken; + const displayToken = isCoinflowSelected + ? CREDITS_TOKEN + : selectedToken || defaultToken; // Auto-select defaultToken if none is selected (for initial load) useEffect(() => { @@ -286,65 +275,58 @@ export function OnchainCostBreakdown({ {`($${totalUsd.toFixed(2)})`} ); - const value = isCreditsSelected ? ( - isCreditsQuoteLoading ? ( - - ) : creditsQuote ? ( - - {formatCredits(creditsQuote.requiredCredits).formatted} - - ) : ( - — - ) - ) : isCoinflowSelected ? ( - isCoinflowQuoteLoading ? ( - - ) : coinflowCostDetails ? ( - - {formatAmount(coinflowCostDetails.totalInCents / 100)} - - ) : ( - — - ) - ) : isApplePaySelected ? ( - coinbaseQuote ? ( - - {`$${Number(coinbaseQuote.paymentTotal.amount).toFixed(2)}`} - - ) : ( - — - ) - ) : isUsingLayerswap ? ( - feeEstimationError ? ( - — - ) : layerswapTotal !== null && displayToken ? ( - - {formatAmount(layerswapTotal)} - - ) : ( - - ) - ) : isPaymentTokenSameAsSelected ? ( - <> - {usdEquivalent} - {formatAmount(paymentAmount)} - - ) : ( - convertedEquivalent !== null && - displayToken && ( + const value = + isCreditsSelected || isCoinflowSelected ? ( + isCreditsQuoteLoading ? ( + + ) : creditsQuote ? ( + + {formatCredits(creditsQuote.requiredCredits).formatted} + + ) : ( + — + ) + ) : isApplePaySelected ? ( + coinbaseQuote ? ( + + {`$${Number(coinbaseQuote.paymentTotal.amount).toFixed(2)}`} + + ) : ( + — + ) + ) : isUsingLayerswap ? ( + feeEstimationError ? ( + — + ) : layerswapTotal !== null && displayToken ? ( + + {formatAmount(layerswapTotal)} + + ) : ( + + ) + ) : isPaymentTokenSameAsSelected ? ( <> {usdEquivalent} - - {formatAmount(convertedEquivalent)} + + {formatAmount(paymentAmount)} - ) - ); + ) : ( + convertedEquivalent !== null && + displayToken && ( + <> + {usdEquivalent} + + {formatAmount(convertedEquivalent)} + + + ) + ); return ( } /> diff --git a/packages/keychain/src/components/purchase/review/onchain-cost.stories.tsx b/packages/keychain/src/components/purchase/review/onchain-cost.stories.tsx index d385c01d9..258f9ed33 100644 --- a/packages/keychain/src/components/purchase/review/onchain-cost.stories.tsx +++ b/packages/keychain/src/components/purchase/review/onchain-cost.stories.tsx @@ -30,9 +30,11 @@ const mockUsdcToken = { const MockOnchainPurchaseProvider = ({ children, overrides, + creditOverrides, }: { children: ReactNode; overrides?: Partial; + creditOverrides?: Partial; }) => { const mockValue: OnchainPurchaseContextType = { purchaseItems: [], @@ -122,7 +124,9 @@ const MockOnchainPurchaseProvider = ({ return ( - + {children} @@ -133,7 +137,10 @@ const meta = { component: OnchainCostBreakdown, decorators: [ (Story, { parameters }) => ( - + ), @@ -189,6 +196,37 @@ export const USDCPaymentWithUsdPrefix: Story = { }, }; +export const CreditCardPurchase: Story = { + parameters: { + mockOverrides: { + isCoinflowSelected: true, + } satisfies Partial, + mockCreditOverrides: { + creditsQuote: { + requiredCredits: "200000000", + costInUsdc: "2000000", + paymentToken: USDC_ADDRESS, + paymentTokenAmount: "2000000", + needsSwap: false, + }, + } satisfies Partial, + }, + args: { + quote: { + basePrice: 2000000n, + protocolFee: 0n, + referralFee: 0n, + totalCost: 2000000n, + paymentToken: USDC_ADDRESS, + paymentTokenMetadata: { + symbol: "USDC", + decimals: 6, + }, + }, + platform: "starknet", + }, +}; + // ETH payment example (18 decimals) export const ETHPayment: Story = { args: { diff --git a/packages/keychain/src/utils/credits-topup.ts b/packages/keychain/src/utils/credits-topup.ts new file mode 100644 index 000000000..81c638700 --- /dev/null +++ b/packages/keychain/src/utils/credits-topup.ts @@ -0,0 +1,32 @@ +export type CreditsBackedPaymentMethod = + | "coinflow" + | "credits" + | "apple-pay" + | "onchain"; + +/** Card purchases always deposit credits; credits purchases only top up a shortfall. */ +export function shouldOpenCreditsDeposit( + method: CreditsBackedPaymentMethod, + hasSufficientCredits: boolean, +): boolean { + return ( + method === "coinflow" || (method === "credits" && !hasSufficientCredits) + ); +} + +/** Calculate the USD deposit needed to cover a raw credit-unit shortfall. */ +export function creditsTopupAmountUsd({ + requiredCredits, + creditsBalance, + minimumAmount, +}: { + requiredCredits: bigint; + creditsBalance: bigint; + minimumAmount: number; +}): number { + const shortfall = + requiredCredits > creditsBalance ? requiredCredits - creditsBalance : 0n; + // Round upward to a cent so the deposit cannot land below the quote. + const shortfallUsd = Number((shortfall + 999_999n) / 1_000_000n) / 100; + return Math.max(minimumAmount, shortfallUsd); +} diff --git a/packages/keychain/src/utils/credits.test.ts b/packages/keychain/src/utils/credits.test.ts new file mode 100644 index 000000000..ffbd16bb3 --- /dev/null +++ b/packages/keychain/src/utils/credits.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; +import { + creditsTopupAmountUsd, + shouldOpenCreditsDeposit, +} from "./credits-topup"; + +describe("shouldOpenCreditsDeposit", () => { + it("always routes card purchases through credits deposit", () => { + expect(shouldOpenCreditsDeposit("coinflow", false)).toBe(true); + expect(shouldOpenCreditsDeposit("coinflow", true)).toBe(true); + }); + + it("only tops up direct credits purchases when their balance is short", () => { + expect(shouldOpenCreditsDeposit("credits", false)).toBe(true); + expect(shouldOpenCreditsDeposit("credits", true)).toBe(false); + expect(shouldOpenCreditsDeposit("onchain", false)).toBe(false); + }); +}); + +describe("creditsTopupAmountUsd", () => { + it("subtracts the existing balance and rounds the shortfall up to a cent", () => { + expect( + creditsTopupAmountUsd({ + requiredCredits: 1_000_000_001n, + creditsBalance: 450_000_000n, + minimumAmount: 5, + }), + ).toBe(5.51); + }); + + it("enforces the deposit minimum when the shortfall is smaller", () => { + expect( + creditsTopupAmountUsd({ + requiredCredits: 200_000_000n, + creditsBalance: 0n, + minimumAmount: 5, + }), + ).toBe(5); + }); + + it("still returns the minimum when the account already has enough credits", () => { + expect( + creditsTopupAmountUsd({ + requiredCredits: 200_000_000n, + creditsBalance: 300_000_000n, + minimumAmount: 5, + }), + ).toBe(5); + }); +}); diff --git a/packages/keychain/src/utils/payment-preference.test.ts b/packages/keychain/src/utils/payment-preference.test.ts index 8f7682882..e5c20529b 100644 --- a/packages/keychain/src/utils/payment-preference.test.ts +++ b/packages/keychain/src/utils/payment-preference.test.ts @@ -60,7 +60,7 @@ describe("payment preference", () => { ).toBeUndefined(); }); - it("keeps funded Controller ahead of the configured default", () => { + it("defaults to credits when the balance covers the purchase", () => { expect( resolveInitialPaymentMethod({ configuredDefault: true, @@ -70,7 +70,7 @@ describe("payment preference", () => { cardTopupAvailable: true, directCardAvailable: true, }), - ).toEqual({ status: "resolved", method: "controller" }); + ).toEqual({ status: "resolved", method: "credits" }); }); it("preserves legacy Controller behavior when no default is configured", () => { @@ -78,7 +78,7 @@ describe("payment preference", () => { resolveInitialPaymentMethod({ configuredDefault: false, funding: "pending", - credits: "pending", + credits: "unavailable", hasSufficientCredits: false, cardTopupAvailable: false, directCardAvailable: false, @@ -86,6 +86,34 @@ describe("payment preference", () => { ).toEqual({ status: "resolved", method: "controller" }); }); + it("waits for credits before applying another default", () => { + expect( + resolveInitialPaymentMethod({ + remembered: "coinflow", + configuredDefault: true, + funding: "funded", + credits: "pending", + hasSufficientCredits: false, + cardTopupAvailable: true, + directCardAvailable: true, + }), + ).toEqual({ status: "pending" }); + }); + + it("prefers sufficient credits over a remembered card preference", () => { + expect( + resolveInitialPaymentMethod({ + remembered: "coinflow", + configuredDefault: true, + funding: "funded", + credits: "available", + hasSufficientCredits: true, + cardTopupAvailable: true, + directCardAvailable: true, + }), + ).toEqual({ status: "resolved", method: "credits" }); + }); + it("selects credits only after Controller funding is exhausted", () => { expect( resolveInitialPaymentMethod({ diff --git a/packages/keychain/src/utils/payment-preference.ts b/packages/keychain/src/utils/payment-preference.ts index 1205e0b47..0949ea680 100644 --- a/packages/keychain/src/utils/payment-preference.ts +++ b/packages/keychain/src/utils/payment-preference.ts @@ -107,6 +107,14 @@ export function resolveInitialPaymentMethod({ cardTopupAvailable: boolean; directCardAvailable: boolean; }): InitialPaymentResolution { + // Spend an existing credits balance before applying remembered or configured + // funding preferences. Wait for the quote and balance so a faster funding + // check cannot incorrectly select Controller or card checkout first. + if (credits === "pending") return { status: "pending" }; + if (credits === "available" && hasSufficientCredits) { + return { status: "resolved", method: "credits" }; + } + if (remembered === "controller") { return { status: "resolved", method: "controller" }; } @@ -122,11 +130,7 @@ export function resolveInitialPaymentMethod({ } if (remembered === "credits") { - if (credits === "pending") return { status: "pending" }; - if ( - credits === "available" && - (hasSufficientCredits || cardTopupAvailable) - ) { + if (credits === "available" && cardTopupAvailable) { return { status: "resolved", method: "credits" }; } return { @@ -144,8 +148,7 @@ export function resolveInitialPaymentMethod({ if (funding === "funded") { return { status: "resolved", method: "controller" }; } - if (credits === "pending") return { status: "pending" }; - if (credits === "available" && (hasSufficientCredits || cardTopupAvailable)) { + if (credits === "available" && cardTopupAvailable) { return { status: "resolved", method: "credits" }; } return {