Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion messages/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -267,7 +267,9 @@
"policyConsentRequired": "Sie müssen den Geschäftsrichtlinien zustimmen, bevor Sie Ihre Bestellung aufgeben",
"iAgreeToThe": "Ich stimme zu",
"policySeparatorComma": ", ",
"policySeparatorAnd": " und "
"policySeparatorAnd": " und ",
"failedToApplyStoreCredit": "Guthaben konnte nicht angewendet werden. Bitte versuchen Sie es erneut.",
"storeCreditPartiallyCovers": "Ihr Guthaben deckt einen Teil dieser Bestellung. {amount} sind noch offen — wählen Sie eine andere Zahlungsart, um abzuschließen."
},
"address": {
"firstName": "Vorname",
Expand Down
4 changes: 3 additions & 1 deletion messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -267,7 +267,9 @@
"policyConsentRequired": "You must agree to the store policies before placing your order",
"iAgreeToThe": "I agree to the",
"policySeparatorComma": ", ",
"policySeparatorAnd": " and "
"policySeparatorAnd": " and ",
"failedToApplyStoreCredit": "Failed to apply store credit. Please try again.",
"storeCreditPartiallyCovers": "Your store credit covers part of this order. {amount} is still due — choose another payment method to finish."
},
"address": {
"firstName": "First name",
Expand Down
4 changes: 3 additions & 1 deletion messages/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -267,7 +267,9 @@
"policyConsentRequired": "Debes aceptar las politicas de la tienda antes de realizar tu pedido",
"iAgreeToThe": "Acepto las",
"policySeparatorComma": ", ",
"policySeparatorAnd": " y "
"policySeparatorAnd": " y ",
"failedToApplyStoreCredit": "No se pudo aplicar el crédito de la tienda. Inténtalo de nuevo.",
"storeCreditPartiallyCovers": "Tu crédito de la tienda cubre parte de este pedido. Aún quedan {amount} — elige otro método de pago para finalizar."
},
"address": {
"firstName": "Nombre",
Expand Down
4 changes: 3 additions & 1 deletion messages/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -267,7 +267,9 @@
"policyConsentRequired": "Vous devez accepter les politiques du magasin avant de passer votre commande",
"iAgreeToThe": "J'accepte les",
"policySeparatorComma": ", ",
"policySeparatorAnd": " et "
"policySeparatorAnd": " et ",
"failedToApplyStoreCredit": "Impossible d'appliquer l'avoir. Veuillez réessayer.",
"storeCreditPartiallyCovers": "Votre avoir couvre une partie de cette commande. Il reste {amount} à payer — choisissez un autre moyen de paiement pour finaliser."
},
"address": {
"firstName": "Prenom",
Expand Down
4 changes: 3 additions & 1 deletion messages/pl.json
Original file line number Diff line number Diff line change
Expand Up @@ -267,7 +267,9 @@
"policyConsentRequired": "Musisz zaakceptować regulamin sklepu przed złożeniem zamówienia",
"iAgreeToThe": "Zgadzam się z",
"policySeparatorComma": ", ",
"policySeparatorAnd": " i "
"policySeparatorAnd": " i ",
"failedToApplyStoreCredit": "Nie udało się użyć środków na koncie. Spróbuj ponownie.",
"storeCreditPartiallyCovers": "Środki na koncie pokrywają część tego zamówienia. Do zapłaty pozostaje {amount} — wybierz inną metodę płatności, aby zakończyć."
},
"address": {
"firstName": "Imię",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -740,6 +740,7 @@ function CheckoutPageContentInner({
fetchStates={fetchStates}
onUpdateBillingAddress={handleUpdateBillingAddress}
onPaymentComplete={handlePaymentComplete}
onCartUpdate={setCart}
processing={processing}
setProcessing={setProcessing}
onSessionMethodChange={setIsSessionPayment}
Expand Down
58 changes: 46 additions & 12 deletions src/components/checkout/PaymentSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { useCountryStates } from "@/hooks/useCountryStates";
import { getCreditCards } from "@/lib/data/credit-cards";
import {
applyStoreCredit,
createCheckoutPaymentSession,
createDirectPayment,
updateCheckoutPaymentSession,
Expand Down Expand Up @@ -72,6 +73,7 @@ interface PaymentSectionProps {
use_shipping?: boolean;
}) => Promise<boolean>;
onPaymentComplete: (result: PaymentCompleteResult) => Promise<void>;
onCartUpdate?: (cart: Cart) => void;
processing: boolean;
setProcessing: (processing: boolean) => void;
onSessionMethodChange?: (isSessionBased: boolean) => void;
Expand All @@ -86,6 +88,7 @@ export function PaymentSection({
fetchStates,
onUpdateBillingAddress,
onPaymentComplete,
onCartUpdate,
processing,
setProcessing,
onSessionMethodChange,
Expand Down Expand Up @@ -616,6 +619,37 @@ export function PaymentSection({
return {};
}

// Store credit is drawn through its own endpoint, which returns the
// cart: a balance spread over several credits takes more than one
// payment, so there is no single payment to create.
if (selectedMethod.type === "store_credit") {
const creditResult = await applyStoreCredit(cart.id);
if (!creditResult.success) {
const msg = creditResult.error || t("failedToApplyStoreCredit");
setGatewayError(msg);
setProcessing(false);
return { error: msg };
}

// The credit is applied either way, so the parent's cart is now
// stale — hand it the new totals before deciding what to do.
onCartUpdate?.(creditResult.cart);

// Credit covering only part of the order leaves a balance for
// another method. What was applied stands.
if (!creditResult.cart.covered_by_store_credit) {
const msg = t("storeCreditPartiallyCovers", {
amount: creditResult.cart.display_amount_due ?? "",
});
setGatewayError(msg);
setProcessing(false);
return { error: msg };
}

await onPaymentComplete({ type: "direct" });
return {};
}

// Direct payment flow (Check, Cash on Delivery, etc.)
const paymentResult = await createDirectPayment(
cart.id,
Expand Down Expand Up @@ -651,6 +685,7 @@ export function PaymentSection({
billAddress,
onUpdateBillingAddress,
onPaymentComplete,
onCartUpdate,
cart.id,
setProcessing,
t,
Expand Down Expand Up @@ -881,18 +916,6 @@ export function PaymentSection({
</div>
)}

{/* Shared: gateway error */}
{gatewayError && !loading && (
<div className="px-4 py-3">
<div className="rounded-sm border border-red-300 bg-red-50 px-4 py-3">
<p className="text-sm text-red-700 flex items-center gap-2">
<CircleAlert className="h-4 w-4 flex-shrink-0" />
{gatewayError}
</p>
</div>
</div>
)}

{/* Gateway-specific payment form */}
{!loading &&
sessionExternalData &&
Expand Down Expand Up @@ -984,6 +1007,17 @@ export function PaymentSection({
})}
</RadioGroup>

{/* Payment error — outside the method list so it reaches every method,
not only the session-based ones that mount a gateway form. */}
{gatewayError && !loading && (
<div className="mt-3 rounded-sm border border-red-300 bg-red-50 px-4 py-3">
<p className="text-sm text-red-700 flex items-center gap-2">
<CircleAlert className="h-4 w-4 flex-shrink-0" />
{gatewayError}
</p>
</div>
)}
Comment on lines +1010 to +1019

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Image


{/* Billing address — below payment box */}
<div className="mt-4">
<label className="flex items-center gap-2.5 cursor-pointer">
Expand Down
54 changes: 54 additions & 0 deletions src/lib/data/__tests__/payment.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ const mockClient = {
create: vi.fn(),
complete: vi.fn(),
},
storeCredits: {
apply: vi.fn(),
},
},
};

Expand All @@ -35,6 +38,7 @@ vi.mock("next/cache", () => ({
}));

import {
applyStoreCredit,
completeCheckoutOrder,
completeCheckoutPaymentSession,
confirmPaymentAndCompleteCart,
Expand Down Expand Up @@ -103,6 +107,56 @@ describe("payment server actions", () => {
});
});

describe("applyStoreCredit", () => {
it("returns the updated cart", async () => {
const coveredCart = {
id: "cart-1",
amount_due: "0.0",
covered_by_store_credit: true,
};
mockClient.carts.storeCredits.apply.mockResolvedValue(coveredCart);

const result = await applyStoreCredit("cart-1");

// No amount — draws the whole outstanding balance, across as many
// credits as it takes.
expect(mockClient.carts.storeCredits.apply).toHaveBeenCalledWith(
"cart-1",
undefined,
{ spreeToken: "order-token-123", token: undefined },
);
expect(result).toEqual({ success: true, cart: coveredCart });
});

it("reports a balance the credit did not cover", async () => {
mockClient.carts.storeCredits.apply.mockResolvedValue({
id: "cart-1",
amount_due: "25.0",
display_amount_due: "$25.00",
covered_by_store_credit: false,
});

const result = await applyStoreCredit("cart-1");

expect(result.success).toBe(true);
expect(result.success && result.cart.covered_by_store_credit).toBe(false);
expect(result.success && result.cart.display_amount_due).toBe("$25.00");
});

it("returns error on failure", async () => {
mockClient.carts.storeCredits.apply.mockRejectedValue(
new Error("User does not have any Store Credits available"),
);

const result = await applyStoreCredit("cart-1");

expect(result).toEqual({
success: false,
error: "User does not have any Store Credits available",
});
});
});

describe("completeCheckoutPaymentSession", () => {
it("returns success with session", async () => {
const completedSession = { ...mockSession, status: "completed" };
Expand Down
31 changes: 30 additions & 1 deletion src/lib/data/payment.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use server";

import type { Order } from "@spree/sdk";
import type { Cart, Order } from "@spree/sdk";
import { updateTag } from "next/cache";
import {
cacheTagSuffix,
Expand Down Expand Up @@ -74,6 +74,9 @@ export async function updateCheckoutPaymentSession(
/**
* Creates a direct payment for non-session payment methods
* (e.g. Check, Cash on Delivery, Bank Transfer).
*
* Store credit is not one of them — use {@link applyStoreCredit} instead.
* Passing it here is refused with a `store_credits_endpoint_required` error.
*/
export async function createDirectPayment(
cartId: string,
Expand All @@ -93,6 +96,32 @@ export async function createDirectPayment(
}, "Failed to create payment");
}

/**
* Applies the customer's store credit to the cart.
*
* Store credit is a non-session method but is not created through the payments
* endpoint: a balance spread over several credits takes more than one payment
* to draw, so the API applies it here and answers with the updated cart. Read
* `amount_due` on the result to see whether anything is still to collect.
*/
export async function applyStoreCredit(
cartId: string,
): Promise<{ success: true; cart: Cart } | { success: false; error: string }> {
return actionResult(async () => {
const surface = await resolveSurfaceForCart(cartId);
const options = await getCartOptions(surface);
const id = await requireCartId(surface);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
const cart = await getClientForSurface(surface).carts.storeCredits.apply(
id,
undefined,
options,
);
updateTag(checkoutTag(surface));
updateTag(cartTag(surface));
return { cart };
}, "Failed to apply store credit");
}

export async function completeCheckoutPaymentSession(
cartId: string,
sessionId: string,
Expand Down
Loading