feat: implement Cash on Delivery (COD) payment method

This commit adds comprehensive COD support using Saleor's native Transaction system:

**Architecture:**
- Uses Saleor's native Transaction objects (not metadata)
- Modular payment method configuration
- Extensible design for future payment types (cards, bank transfer, etc.)

**New Components:**
- PaymentMethodSelector: Reusable payment method selection UI
- PaymentMethodCard: Individual payment method card
- CODInstructions: COD-specific instructions and guidance
- PaymentSection: Checkout integration wrapper

**Core Features:**
- COD selected by default for Serbia (default-channel)
- Transaction created automatically on order completion
- Transaction visible in Saleor Dashboard
- Multi-language support (EN, SR, DE, FR)
- No additional fees
- Instructions shown to customer (prepare cash, inspect order, no fee)

**Files Added:**
- docs/COD-IMPLEMENTATION-PLAN.md
- src/lib/config/paymentMethods.ts
- src/lib/saleor/payments/types.ts
- src/lib/saleor/payments/cod.ts
- src/components/payment/PaymentMethodSelector.tsx
- src/components/payment/PaymentMethodCard.tsx
- src/components/payment/CODInstructions.tsx
- src/components/payment/index.ts
- src/app/[locale]/checkout/components/PaymentSection.tsx

**Files Modified:**
- src/app/[locale]/checkout/page.tsx (added payment section, transaction creation)
- src/i18n/messages/{en,sr,de,fr}.json (payment translations)

**Technical Details:**
- Transaction status: NOT_CHARGED
- Available actions: [CHARGE]
- PSP Reference format: COD-{orderNumber}-{timestamp}
- Staff collects cash and fulfills order via Dashboard

Closes: Cash on Delivery payment implementation
This commit is contained in:
Unchained
2026-03-29 06:02:51 +02:00
parent 6f9081cb52
commit ff481f18c3
14 changed files with 1067 additions and 5 deletions

View File

@@ -0,0 +1,47 @@
"use client";
import { PaymentMethodSelector, CODInstructions } from "@/components/payment";
import { getPaymentMethodsForChannel } from "@/lib/config/paymentMethods";
import type { PaymentMethod } from "@/lib/saleor/payments/types";
import { useTranslations } from "next-intl";
interface PaymentSectionProps {
selectedMethodId: string;
onSelectMethod: (methodId: string) => void;
locale: string;
channel?: string;
disabled?: boolean;
}
export function PaymentSection({
selectedMethodId,
onSelectMethod,
locale,
channel = "default-channel",
disabled = false,
}: PaymentSectionProps) {
const t = useTranslations("Payment");
// Get available payment methods for this channel
const paymentMethods: PaymentMethod[] = getPaymentMethodsForChannel(channel);
// Get the selected method details
const selectedMethod = paymentMethods.find((m) => m.id === selectedMethodId);
return (
<section className="border-t border-gray-200 pt-6">
<PaymentMethodSelector
methods={paymentMethods}
selectedMethodId={selectedMethodId}
onSelectMethod={onSelectMethod}
locale={locale}
disabled={disabled}
/>
{/* Show COD instructions when COD is selected */}
{selectedMethod?.id === "cod" && (
<CODInstructions locale={locale} />
)}
</section>
);
}

View File

@@ -19,6 +19,9 @@ import {
CHECKOUT_METADATA_UPDATE,
CHECKOUT_SHIPPING_METHOD_UPDATE,
} from "@/lib/saleor/mutations/Checkout";
import { CREATE_TRANSACTION_MUTATION } from "@/lib/saleor/payments/cod";
import { PaymentSection } from "./components/PaymentSection";
import { DEFAULT_PAYMENT_METHOD } from "@/lib/config/paymentMethods";
import { GET_CHECKOUT_BY_ID } from "@/lib/saleor/queries/Checkout";
import type { Checkout } from "@/types/saleor";
@@ -38,7 +41,7 @@ interface BillingAddressUpdateResponse {
interface CheckoutCompleteResponse {
checkoutComplete?: {
order?: { number: string };
order?: { id: string; number: string };
errors?: Array<{ message: string }>;
};
}
@@ -104,6 +107,7 @@ export default function CheckoutPage() {
const [orderNumber, setOrderNumber] = useState<string | null>(null);
const [sameAsShipping, setSameAsShipping] = useState(true);
const [selectedPaymentMethod, setSelectedPaymentMethod] = useState<string>(DEFAULT_PAYMENT_METHOD);
const [shippingAddress, setShippingAddress] = useState<AddressForm>({
firstName: "",
lastName: "",
@@ -277,6 +281,11 @@ export default function CheckoutPage() {
return;
}
if (!selectedPaymentMethod) {
setError(t("errorSelectPayment"));
return;
}
setIsLoading(true);
setError(null);
@@ -377,6 +386,37 @@ export default function CheckoutPage() {
const order = completeResult.data?.checkoutComplete?.order;
if (order) {
setOrderNumber(order.number);
// Create COD transaction for the order
if (selectedPaymentMethod === 'cod') {
console.log("Step 6: Creating COD transaction...");
try {
await saleorClient.mutate({
mutation: CREATE_TRANSACTION_MUTATION,
variables: {
id: order.id,
transaction: {
name: "Cash on Delivery",
pspReference: `COD-${order.number}-${Date.now()}`,
availableActions: ["CHARGE"],
amountAuthorized: {
amount: 0,
currency: "RSD"
},
amountCharged: {
amount: 0,
currency: "RSD"
}
}
}
});
console.log("Step 6: COD transaction created successfully");
} catch (txError) {
console.error("Failed to create COD transaction:", txError);
// Don't fail the order if transaction creation fails
}
}
setOrderComplete(true);
// Track order completion
@@ -660,6 +700,15 @@ export default function CheckoutPage() {
)}
</div>
{/* Payment Method Section */}
<PaymentSection
selectedMethodId={selectedPaymentMethod}
onSelectMethod={setSelectedPaymentMethod}
locale={locale}
channel="default-channel"
disabled={isLoading}
/>
<button
type="submit"
disabled={isLoading || lines.length === 0 || !selectedShippingMethod}