From a0f5cae9f286a8e79ea28039cbeeec9008256d49 Mon Sep 17 00:00:00 2001 From: Filip Cichorek Date: Thu, 23 Apr 2026 10:20:33 +0200 Subject: [PATCH 1/3] Fix gift card amount not deducted from Stripe payment When a gift card was applied on the payment step, the Spree backend registered a store-credit payment but the storefront kept the original Stripe PaymentIntent (sized to `cart.total`). Customer ended up charged the full order total while the gift card was also processed, resulting in overcharge / "Credit Owed" orders. PaymentSection now watches `cart.amount_due ?? cart.total` and uses `paymentSessions.update` (with explicit `amount`) to resize the same Stripe PaymentIntent on cart changes, instead of creating a new session every time and leaving stale `Incomplete` PaymentIntents behind. --- src/components/checkout/PaymentSection.tsx | 81 ++++++++++++++-------- src/lib/data/__tests__/payment.test.ts | 64 +++++++++++++++++ src/lib/data/payment.ts | 26 +++++++ 3 files changed, 144 insertions(+), 27 deletions(-) diff --git a/src/components/checkout/PaymentSection.tsx b/src/components/checkout/PaymentSection.tsx index 3606ca85..8e67a502 100644 --- a/src/components/checkout/PaymentSection.tsx +++ b/src/components/checkout/PaymentSection.tsx @@ -29,7 +29,10 @@ import { Checkbox } from "@/components/ui/checkbox"; import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; import { useCountryStates } from "@/hooks/useCountryStates"; import { getCreditCards } from "@/lib/data/credit-cards"; -import { createCheckoutPaymentSession } from "@/lib/data/payment"; +import { + createCheckoutPaymentSession, + updateCheckoutPaymentSession, +} from "@/lib/data/payment"; import { type AddressFormData, addressToFormData, @@ -106,8 +109,11 @@ export const PaymentSection = forwardRef< const [loading, setLoading] = useState(false); const gatewayHandleRef = useRef(null); const initRef = useRef(false); - // Monotonic counter to discard stale createSession responses + // Monotonic counter to discard stale syncSession responses const sessionRequestIdRef = useRef(0); + // Mirrors paymentSessionId so syncSession can branch create vs update + // without being re-created every time the session changes. + const paymentSessionIdRef = useRef(null); const handleGatewayReady = useCallback((handle: StripePaymentFormHandle) => { gatewayHandleRef.current = handle; @@ -118,25 +124,40 @@ export const PaymentSection = forwardRef< (pm) => pm.session_required, ); - // Helper: create a payment session - const createSession = useCallback( + // Helper: sync the Spree PaymentSession with current cart/card state. + // First call creates the session (and Stripe PaymentIntent). Subsequent + // calls PATCH with an explicit `amount` so the Spree backend resizes the + // same Stripe PaymentIntent instead of spawning a new one on every gift + // card / shipping / card change. + const syncSession = useCallback( async (cardId: string | null) => { if (!sessionPaymentMethod) return; const requestId = ++sessionRequestIdRef.current; + const existingSessionId = paymentSessionIdRef.current; + const targetAmount = cart.amount_due ?? cart.total; setLoading(true); setGatewayError(null); - setClientSecret(null); - setPaymentSessionId(null); - gatewayHandleRef.current = null; + // Only clear on create — update keeps the same Stripe PaymentIntent + // and therefore the same client_secret. + if (!existingSessionId) { + setClientSecret(null); + setPaymentSessionId(null); + gatewayHandleRef.current = null; + } try { - const result = await createCheckoutPaymentSession( - cart.id, - sessionPaymentMethod.id, - cardId ?? undefined, - ); + const result = existingSessionId + ? await updateCheckoutPaymentSession(cart.id, existingSessionId, { + amount: targetAmount, + stripePaymentMethodId: cardId ?? undefined, + }) + : await createCheckoutPaymentSession( + cart.id, + sessionPaymentMethod.id, + cardId ?? undefined, + ); // Discard if a newer request was started while this one was in flight if (requestId !== sessionRequestIdRef.current) return; @@ -146,9 +167,12 @@ export const PaymentSection = forwardRef< | string | undefined; if (secret) { - setClientSecret(secret); + // Keep client_secret stable across PATCH so the Stripe form + // does not remount on every amount change. + setClientSecret((prev) => (prev === secret ? prev : secret)); setPaymentSessionId(result.session.id); - } else { + paymentSessionIdRef.current = result.session.id; + } else if (!existingSessionId) { setGatewayError(t("failedToInitPayment")); } } else if (!result.success) { @@ -163,11 +187,14 @@ export const PaymentSection = forwardRef< } } }, - [sessionPaymentMethod, cart.id, t], + [sessionPaymentMethod, cart.id, cart.amount_due, cart.total, t], ); - // Track the cart total so we can recreate the session when it changes - const lastTotalRef = useRef(null); + // Target amount the gateway must charge. Gift cards reduce amount_due but + // not total, so watching total alone misses gift card changes and the + // gateway keeps the original (pre-gift-card) PaymentIntent. + const paymentTarget = cart.amount_due ?? cart.total; + const lastPaymentTargetRef = useRef(null); const selectedCardRef = useRef(null); // On mount: load saved cards (if authenticated), then create initial session — once. @@ -201,24 +228,24 @@ export const PaymentSection = forwardRef< } selectedCardRef.current = initialCardId; - lastTotalRef.current = cart.total; + lastPaymentTargetRef.current = paymentTarget; // Create the initial payment session - await createSession(initialCardId); + await syncSession(initialCardId); }; init(); - }, [sessionPaymentMethod, isAuthenticated, createSession, cart.total]); + }, [sessionPaymentMethod, isAuthenticated, syncSession, paymentTarget]); - // When cart total changes (shipping rate, coupon, etc.), recreate the - // payment session so the amount matches the new order total. + // When the payment target changes (shipping rate, coupon, gift card, etc.), + // recreate the payment session so the amount matches the new order total. useEffect(() => { if (!initRef.current) return; - if (lastTotalRef.current === cart.total) return; + if (lastPaymentTargetRef.current === paymentTarget) return; - lastTotalRef.current = cart.total; - createSession(selectedCardRef.current); - }, [cart.total, createSession]); + lastPaymentTargetRef.current = paymentTarget; + syncSession(selectedCardRef.current); + }, [paymentTarget, syncSession]); const [billStates, isPendingBill] = useCountryStates( billAddress.country_iso, @@ -237,7 +264,7 @@ export const PaymentSection = forwardRef< if (cardId === selectedCardId) return; setSelectedCardId(cardId); selectedCardRef.current = cardId; - createSession(cardId); + syncSession(cardId); }; const updateBillAddress = (field: keyof AddressFormData, value: string) => { diff --git a/src/lib/data/__tests__/payment.test.ts b/src/lib/data/__tests__/payment.test.ts index 4b67dbd7..3cfabbd5 100644 --- a/src/lib/data/__tests__/payment.test.ts +++ b/src/lib/data/__tests__/payment.test.ts @@ -7,6 +7,7 @@ const mockClient = { complete: vi.fn(), paymentSessions: { create: vi.fn(), + update: vi.fn(), complete: vi.fn(), }, }, @@ -35,6 +36,7 @@ import { completeCheckoutPaymentSession, confirmPaymentAndCompleteCart, createCheckoutPaymentSession, + updateCheckoutPaymentSession, } from "@/lib/data/payment"; const mockSession = { @@ -97,6 +99,68 @@ describe("payment server actions", () => { }); }); + describe("updateCheckoutPaymentSession", () => { + it("sends empty params when no amount or card id", async () => { + mockClient.carts.paymentSessions.update.mockResolvedValue(mockSession); + + const result = await updateCheckoutPaymentSession("cart-1", "session-1"); + + expect(mockClient.carts.paymentSessions.update).toHaveBeenCalledWith( + "cart-1", + "session-1", + {}, + { spreeToken: "order-token-123", token: undefined }, + ); + expect(result).toEqual({ success: true, session: mockSession }); + }); + + it("passes amount when provided", async () => { + mockClient.carts.paymentSessions.update.mockResolvedValue(mockSession); + + await updateCheckoutPaymentSession("cart-1", "session-1", { + amount: "48.99", + }); + + expect(mockClient.carts.paymentSessions.update).toHaveBeenCalledWith( + "cart-1", + "session-1", + { amount: "48.99" }, + { spreeToken: "order-token-123", token: undefined }, + ); + }); + + it("passes both amount and stripe payment method id", async () => { + mockClient.carts.paymentSessions.update.mockResolvedValue(mockSession); + + await updateCheckoutPaymentSession("cart-1", "session-1", { + amount: "48.99", + stripePaymentMethodId: "spm_123", + }); + + expect(mockClient.carts.paymentSessions.update).toHaveBeenCalledWith( + "cart-1", + "session-1", + { + amount: "48.99", + external_data: { stripe_payment_method_id: "spm_123" }, + }, + { spreeToken: "order-token-123", token: undefined }, + ); + }); + + it("returns error on failure", async () => { + mockClient.carts.paymentSessions.update.mockRejectedValue( + new Error("Session locked"), + ); + + const result = await updateCheckoutPaymentSession("cart-1", "session-1", { + amount: "48.99", + }); + + expect(result).toEqual({ success: false, error: "Session locked" }); + }); + }); + describe("completeCheckoutPaymentSession", () => { it("returns success with session", async () => { const completedSession = { ...mockSession, status: "completed" }; diff --git a/src/lib/data/payment.ts b/src/lib/data/payment.ts index e08b238d..65277c5d 100644 --- a/src/lib/data/payment.ts +++ b/src/lib/data/payment.ts @@ -30,6 +30,32 @@ export async function createCheckoutPaymentSession( }, "Failed to create payment session"); } +export async function updateCheckoutPaymentSession( + cartId: string, + sessionId: string, + params: { amount?: string; stripePaymentMethodId?: string } = {}, +) { + return actionResult(async () => { + const options = await getCartOptions(); + const id = await requireCartId(); + const session = await getClient().carts.paymentSessions.update( + id, + sessionId, + { + ...(params.amount && { amount: params.amount }), + ...(params.stripePaymentMethodId && { + external_data: { + stripe_payment_method_id: params.stripePaymentMethodId, + }, + }), + }, + options, + ); + updateTag("checkout"); + return { session }; + }, "Failed to update payment session"); +} + export async function completeCheckoutPaymentSession( cartId: string, sessionId: string, From e79617d8646bed28e424dff9ed7daeade971fb8f Mon Sep 17 00:00:00 2001 From: Filip Cichorek Date: Thu, 23 Apr 2026 10:40:56 +0200 Subject: [PATCH 2/3] Clear stale stripe_payment_method_id when switching cards Previously, switching from a saved card back to "add new payment method" left the prior stripe_payment_method_id attached to the Spree payment session. Stripe Elements overrides it at confirm time so no mischarge occurred, but session metadata went stale. Now updateCheckoutPaymentSession accepts `stripePaymentMethodId: string | null`, and PaymentSection always syncs the current cardId (including null) on PATCH, explicitly clearing the attached payment method when needed. --- src/components/checkout/PaymentSection.tsx | 5 ++++- src/lib/data/__tests__/payment.test.ts | 19 +++++++++++++++++++ src/lib/data/payment.ts | 9 +++++++-- 3 files changed, 30 insertions(+), 3 deletions(-) diff --git a/src/components/checkout/PaymentSection.tsx b/src/components/checkout/PaymentSection.tsx index 8e67a502..3309c7bf 100644 --- a/src/components/checkout/PaymentSection.tsx +++ b/src/components/checkout/PaymentSection.tsx @@ -151,7 +151,10 @@ export const PaymentSection = forwardRef< const result = existingSessionId ? await updateCheckoutPaymentSession(cart.id, existingSessionId, { amount: targetAmount, - stripePaymentMethodId: cardId ?? undefined, + // Always sync — passing null explicitly clears a stale + // stripe_payment_method_id when the user switches back to + // "add new payment method". + stripePaymentMethodId: cardId, }) : await createCheckoutPaymentSession( cart.id, diff --git a/src/lib/data/__tests__/payment.test.ts b/src/lib/data/__tests__/payment.test.ts index 3cfabbd5..65895bc4 100644 --- a/src/lib/data/__tests__/payment.test.ts +++ b/src/lib/data/__tests__/payment.test.ts @@ -148,6 +148,25 @@ describe("payment server actions", () => { ); }); + it("clears external_data when stripePaymentMethodId is null", async () => { + mockClient.carts.paymentSessions.update.mockResolvedValue(mockSession); + + await updateCheckoutPaymentSession("cart-1", "session-1", { + amount: "48.99", + stripePaymentMethodId: null, + }); + + expect(mockClient.carts.paymentSessions.update).toHaveBeenCalledWith( + "cart-1", + "session-1", + { + amount: "48.99", + external_data: { stripe_payment_method_id: null }, + }, + { spreeToken: "order-token-123", token: undefined }, + ); + }); + it("returns error on failure", async () => { mockClient.carts.paymentSessions.update.mockRejectedValue( new Error("Session locked"), diff --git a/src/lib/data/payment.ts b/src/lib/data/payment.ts index 65277c5d..dfc2cc42 100644 --- a/src/lib/data/payment.ts +++ b/src/lib/data/payment.ts @@ -33,7 +33,12 @@ export async function createCheckoutPaymentSession( export async function updateCheckoutPaymentSession( cartId: string, sessionId: string, - params: { amount?: string; stripePaymentMethodId?: string } = {}, + params: { + amount?: string; + // `null` explicitly clears the previously attached payment method + // (e.g. user switches from a saved card back to "add new"). + stripePaymentMethodId?: string | null; + } = {}, ) { return actionResult(async () => { const options = await getCartOptions(); @@ -43,7 +48,7 @@ export async function updateCheckoutPaymentSession( sessionId, { ...(params.amount && { amount: params.amount }), - ...(params.stripePaymentMethodId && { + ...(params.stripePaymentMethodId !== undefined && { external_data: { stripe_payment_method_id: params.stripePaymentMethodId, }, From 74e306ec0df6fdf016ee9a1b3d6a947cf3b035ae Mon Sep 17 00:00:00 2001 From: Filip Cichorek Date: Tue, 28 Apr 2026 10:51:30 +0200 Subject: [PATCH 3/3] Always recreate payment session on cart changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drops the Stripe-only PATCH path (and the helper updateCheckoutPaymentSession) introduced earlier in this branch. PaymentSection now always calls createCheckoutPaymentSession on every relevant change — gift card / shipping / saved-card switch / payment-method switch — so the gateway charges exactly `cart.amount_due ?? cart.total`. Reason: per project guideline, mutating an existing PaymentSession in place risks the user being charged the wrong amount (and gateway-specific shortcuts drift from each other). Uniform recreate is safer than the pi_… reuse it replaces. Kept: `paymentTarget = cart.amount_due ?? cart.total` watcher — the actual gift-card fix, applied uniformly to Stripe / Adyen / PayPal. --- src/components/checkout/PaymentSection.tsx | 76 +++++--------------- src/lib/data/__tests__/payment.test.ts | 83 ---------------------- src/lib/data/payment.ts | 34 --------- 3 files changed, 17 insertions(+), 176 deletions(-) diff --git a/src/components/checkout/PaymentSection.tsx b/src/components/checkout/PaymentSection.tsx index 3c4befb7..0f55d85f 100644 --- a/src/components/checkout/PaymentSection.tsx +++ b/src/components/checkout/PaymentSection.tsx @@ -41,7 +41,6 @@ import { getCreditCards } from "@/lib/data/credit-cards"; import { createCheckoutPaymentSession, createDirectPayment, - updateCheckoutPaymentSession, } from "@/lib/data/payment"; import { type AddressFormData, @@ -157,9 +156,6 @@ export function PaymentSection({ unknown > | null>(null); const [paymentSessionId, setPaymentSessionId] = useState(null); - // Mirrors paymentSessionId so createSession can branch create-vs-PATCH - // without being re-created on every session id change. - const paymentSessionIdRef = useRef(null); const [gatewayError, setGatewayError] = useState(null); const [loading, setLoading] = useState(false); const gatewayHandleRef = useRef< @@ -190,28 +186,19 @@ export function PaymentSection({ const paymentTarget = cart.amount_due ?? cart.total; // ── Session management ────────────────────────────────────────────── - // First call creates the Spree PaymentSession (and Stripe PaymentIntent). - // For Stripe, subsequent calls PATCH amount / stripe_payment_method_id on - // the same session — keeps the same PaymentIntent and `client_secret`, - // so the Stripe form does not remount on every gift-card / shipping / - // saved-card change. Adyen / PayPal recreate the session because their - // drop-in / order is bound to the initial amount. + // Always create a fresh PaymentSession on every cart / card / method change + // so the gateway charges exactly `paymentTarget`. Updating in place is not + // safe — the user could end up charged the wrong amount. const createSession = useCallback( async (cardId: string | null, method: PaymentMethod) => { const currentGatewayId = resolveGatewayId(method.type); const requestId = ++sessionRequestIdRef.current; - const existingSessionId = paymentSessionIdRef.current; - const canPatch = currentGatewayId === "stripe" && !!existingSessionId; setLoading(true); setGatewayError(null); - // PATCH keeps the same client_secret — don't tear the form down. - if (!canPatch) { - setSessionExternalData(null); - setPaymentSessionId(null); - paymentSessionIdRef.current = null; - gatewayHandleRef.current = null; - } + setSessionExternalData(null); + setPaymentSessionId(null); + gatewayHandleRef.current = null; try { // Build gateway-specific external_data @@ -222,25 +209,15 @@ export function PaymentSection({ return_url: returnUrl, }; - if (currentGatewayId === "stripe") { - // Always sync — passing `null` explicitly clears a stale - // stripe_payment_method_id when the user toggles back to - // "add new payment method". + if (currentGatewayId === "stripe" && cardId) { externalData.stripe_payment_method_id = cardId; } - const result = canPatch - ? await updateCheckoutPaymentSession(cart.id, existingSessionId, { - amount: paymentTarget, - externalData: { - stripe_payment_method_id: cardId, - }, - }) - : await createCheckoutPaymentSession( - cart.id, - method.id, - externalData, - ); + const result = await createCheckoutPaymentSession( + cart.id, + method.id, + externalData, + ); if (requestId !== sessionRequestIdRef.current) return; @@ -249,29 +226,12 @@ export function PaymentSection({ if (extData && Object.keys(extData).length > 0) { // Include external_id so gateway forms can access the // provider-side session/order ID (e.g. Adyen session ID). - const next: Record = { + setSessionExternalData({ ...extData, _external_id: result.session.external_id, - }; - // Avoid spurious remounts of the Stripe form when only `amount` - // changed — keep the previous object if client_secret + external_id - // are unchanged. - setSessionExternalData((prev) => { - if ( - prev && - prev.client_secret === next.client_secret && - prev._external_id === next._external_id - ) { - return prev; - } - return next; }); setPaymentSessionId(result.session.id); - paymentSessionIdRef.current = result.session.id; - } else if (!canPatch) { - // Only treat empty external_data as a hard failure on create; - // a PATCH that returns no external_data still leaves the prior - // session valid, no need to surface an error. + } else { setGatewayError(t("failedToInitPayment")); } } else if (!result.success) { @@ -286,10 +246,10 @@ export function PaymentSection({ } } }, - [cart.id, paymentTarget, t], + [cart.id, t], ); - // Track the payment target so we can recreate / PATCH the session when it changes + // Track the payment target so we can recreate the session when it changes. const lastPaymentTargetRef = useRef(null); const selectedCardRef = useRef(null); @@ -343,8 +303,7 @@ export function PaymentSection({ ]); // When the payment target changes (shipping rate, coupon, gift card, etc.), - // re-sync the payment session so the amount matches. For Stripe this PATCHes - // the existing PaymentIntent in place; for other gateways it recreates. + // recreate the payment session so the amount matches the new total. useEffect(() => { if (!initRef.current) return; if (!isSessionBased || !selectedMethod) return; @@ -423,7 +382,6 @@ export function PaymentSection({ sessionRequestIdRef.current += 1; setSessionExternalData(null); setPaymentSessionId(null); - paymentSessionIdRef.current = null; setGatewayError(null); gatewayHandleRef.current = null; setLoading(false); diff --git a/src/lib/data/__tests__/payment.test.ts b/src/lib/data/__tests__/payment.test.ts index e572ca88..5f179058 100644 --- a/src/lib/data/__tests__/payment.test.ts +++ b/src/lib/data/__tests__/payment.test.ts @@ -7,7 +7,6 @@ const mockClient = { complete: vi.fn(), paymentSessions: { create: vi.fn(), - update: vi.fn(), complete: vi.fn(), }, }, @@ -36,7 +35,6 @@ import { completeCheckoutPaymentSession, confirmPaymentAndCompleteCart, createCheckoutPaymentSession, - updateCheckoutPaymentSession, } from "@/lib/data/payment"; const mockSession = { @@ -101,87 +99,6 @@ describe("payment server actions", () => { }); }); - describe("updateCheckoutPaymentSession", () => { - it("sends empty params when no amount or card id", async () => { - mockClient.carts.paymentSessions.update.mockResolvedValue(mockSession); - - const result = await updateCheckoutPaymentSession("cart-1", "session-1"); - - expect(mockClient.carts.paymentSessions.update).toHaveBeenCalledWith( - "cart-1", - "session-1", - {}, - { spreeToken: "order-token-123", token: undefined }, - ); - expect(result).toEqual({ success: true, session: mockSession }); - }); - - it("passes amount when provided", async () => { - mockClient.carts.paymentSessions.update.mockResolvedValue(mockSession); - - await updateCheckoutPaymentSession("cart-1", "session-1", { - amount: "48.99", - }); - - expect(mockClient.carts.paymentSessions.update).toHaveBeenCalledWith( - "cart-1", - "session-1", - { amount: "48.99" }, - { spreeToken: "order-token-123", token: undefined }, - ); - }); - - it("passes amount and external_data together", async () => { - mockClient.carts.paymentSessions.update.mockResolvedValue(mockSession); - - await updateCheckoutPaymentSession("cart-1", "session-1", { - amount: "48.99", - externalData: { stripe_payment_method_id: "spm_123" }, - }); - - expect(mockClient.carts.paymentSessions.update).toHaveBeenCalledWith( - "cart-1", - "session-1", - { - amount: "48.99", - external_data: { stripe_payment_method_id: "spm_123" }, - }, - { spreeToken: "order-token-123", token: undefined }, - ); - }); - - it("preserves null values in external_data (clears stale stripe_payment_method_id)", async () => { - mockClient.carts.paymentSessions.update.mockResolvedValue(mockSession); - - await updateCheckoutPaymentSession("cart-1", "session-1", { - amount: "48.99", - externalData: { stripe_payment_method_id: null }, - }); - - expect(mockClient.carts.paymentSessions.update).toHaveBeenCalledWith( - "cart-1", - "session-1", - { - amount: "48.99", - external_data: { stripe_payment_method_id: null }, - }, - { spreeToken: "order-token-123", token: undefined }, - ); - }); - - it("returns error on failure", async () => { - mockClient.carts.paymentSessions.update.mockRejectedValue( - new Error("Session locked"), - ); - - const result = await updateCheckoutPaymentSession("cart-1", "session-1", { - amount: "48.99", - }); - - expect(result).toEqual({ success: false, error: "Session locked" }); - }); - }); - describe("completeCheckoutPaymentSession", () => { it("returns success with session", async () => { const completedSession = { ...mockSession, status: "completed" }; diff --git a/src/lib/data/payment.ts b/src/lib/data/payment.ts index 92fd672a..08182699 100644 --- a/src/lib/data/payment.ts +++ b/src/lib/data/payment.ts @@ -28,40 +28,6 @@ export async function createCheckoutPaymentSession( }, "Failed to create payment session"); } -/** - * PATCHes an existing PaymentSession. For Stripe this updates the underlying - * PaymentIntent in place — same `client_secret`, no new pi_… — so the gateway - * form does not remount on every cart change (e.g. gift card / shipping). - * - * Pass `externalData` to overwrite session metadata (e.g. clear a stale - * `stripe_payment_method_id` by sending `{ stripe_payment_method_id: null }` - * when the user switches back to "add new payment method"). - */ -export async function updateCheckoutPaymentSession( - cartId: string, - sessionId: string, - params: { - amount?: string; - externalData?: Record; - } = {}, -) { - return actionResult(async () => { - const options = await getCartOptions(); - const id = await requireCartId(); - const session = await getClient().carts.paymentSessions.update( - id, - sessionId, - { - ...(params.amount && { amount: params.amount }), - ...(params.externalData && { external_data: params.externalData }), - }, - options, - ); - updateTag("checkout"); - return { session }; - }, "Failed to update payment session"); -} - /** * Creates a direct payment for non-session payment methods * (e.g. Check, Cash on Delivery, Bank Transfer).