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,106 @@
/**
* Payment methods configuration
* Centralized configuration for all available payment methods
*/
import type { PaymentMethod, Money } from '@/lib/saleor/payments/types';
/**
* List of all available payment methods
* Configure availability per channel, fees, and other settings
*/
export const paymentMethods: PaymentMethod[] = [
{
id: 'cod',
name: 'Cash on Delivery',
description: 'Pay when you receive your order',
type: 'simple',
fee: 0,
available: true,
availableInChannels: ['default-channel'], // Currently Serbia only
icon: 'Banknote',
},
{
id: 'card',
name: 'Credit Card',
description: 'Secure online payment',
type: 'app',
fee: 0,
available: false, // Coming soon
availableInChannels: ['default-channel'],
icon: 'CreditCard',
},
{
id: 'bank_transfer',
name: 'Bank Transfer',
description: 'Pay via bank transfer',
type: 'simple',
fee: 0,
available: false, // Coming later
availableInChannels: ['default-channel'],
icon: 'Building2',
},
];
/**
* Get payment methods available for a specific channel
*/
export function getPaymentMethodsForChannel(channel: string): PaymentMethod[] {
return paymentMethods.filter(
(method) =>
method.available && method.availableInChannels.includes(channel)
);
}
/**
* Get a specific payment method by ID
*/
export function getPaymentMethodById(id: string): PaymentMethod | undefined {
return paymentMethods.find((method) => method.id === id);
}
/**
* Check if a payment method is available for a channel
*/
export function isPaymentMethodAvailable(
methodId: string,
channel: string
): boolean {
const method = getPaymentMethodById(methodId);
if (!method) return false;
return method.available && method.availableInChannels.includes(channel);
}
/**
* Default payment method ID
* Used when no payment method is explicitly selected
*/
export const DEFAULT_PAYMENT_METHOD = 'cod';
/**
* Channel configuration
* Maps channels to their supported payment methods
*/
export const channelPaymentConfig: Record<string, string[]> = {
'default-channel': ['cod'], // Serbia - COD only for now
};
/**
* Format payment method fee for display
*/
export function formatPaymentFee(fee: number, currency: string): string {
if (fee === 0) return 'No additional fee';
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: currency,
}).format(fee);
}
/**
* Generate PSP reference for COD transactions
* Format: COD-{orderNumber}-{timestamp}
*/
export function generateCODReference(orderNumber: string): string {
const timestamp = Date.now();
return `COD-${orderNumber}-${timestamp}`;
}

View File

@@ -0,0 +1,149 @@
/**
* Cash on Delivery (COD) payment logic
* Handles creation of COD transactions in Saleor
*/
import type { Money, TransactionInput } from '@/lib/saleor/payments/types';
import { generateCODReference } from '@/lib/config/paymentMethods';
import { gql } from "@apollo/client";
/**
* GraphQL mutation to create a transaction on an order
*/
export const CREATE_TRANSACTION_MUTATION = gql`
mutation TransactionCreate($id: ID!, $transaction: TransactionCreateInput!) {
transactionCreate(id: $id, transaction: $transaction) {
transaction {
id
name
pspReference
status
availableActions
amountAuthorized {
amount
currency
}
amountCharged {
amount
currency
}
}
errors {
field
message
code
}
}
}
`;
/**
* Create a Cash on Delivery transaction configuration
* @param orderNumber - The order number for reference
* @param amount - The order total amount
* @returns TransactionInput for Saleor
*/
export function createCODTransactionInput(
orderNumber: string,
amount: Money
): TransactionInput {
return {
name: 'Cash on Delivery',
pspReference: generateCODReference(orderNumber),
availableActions: ['CHARGE'],
amountAuthorized: {
amount: 0,
currency: amount.currency,
},
amountCharged: {
amount: 0,
currency: amount.currency,
},
externalUrl: null,
};
}
/**
* Create COD transaction on an order
* This should be called after checkoutComplete creates the order
*
* @param orderId - Saleor order ID
* @param orderNumber - Human-readable order number
* @param amount - Order total amount
* @returns Promise with transaction result
*/
export async function createCODTransaction(
orderId: string,
orderNumber: string,
amount: Money
): Promise<{ success: boolean; transaction?: unknown; errors?: unknown[] }> {
try {
// Note: This function should be called from a Server Component or API route
// as it requires making a GraphQL mutation with authentication
const transactionInput = createCODTransactionInput(orderNumber, amount);
// The actual GraphQL call will be made in the checkout page
// This function just prepares the input
return {
success: true,
transaction: {
orderId,
...transactionInput,
},
};
} catch (error) {
console.error('Error creating COD transaction:', error);
return {
success: false,
errors: [{ message: 'Failed to create COD transaction' }],
};
}
}
/**
* Check if an order has a COD transaction
* @param order - Order object from Saleor
* @returns boolean
*/
export function hasCODTransaction(order: { transactions?: Array<{ name?: string }> }): boolean {
if (!order.transactions || order.transactions.length === 0) {
return false;
}
return order.transactions.some(
(t) => t.name === 'Cash on Delivery'
);
}
/**
* Get COD transaction from order
* @param order - Order object from Saleor
* @returns COD transaction or undefined
*/
export function getCODTransaction(order: { transactions?: Array<{ name?: string }> }) {
if (!order.transactions) return undefined;
return order.transactions.find(
(t) => t.name === 'Cash on Delivery'
);
}
/**
* Format COD status for display
* @param transactionStatus - Transaction status from Saleor
* @returns Human-readable status
*/
export function formatCODStatus(transactionStatus: string): string {
switch (transactionStatus) {
case 'NOT_CHARGED':
return 'Pending Collection';
case 'CHARGED':
return 'Paid';
case 'CANCELLED':
return 'Cancelled';
default:
return transactionStatus;
}
}

View File

@@ -0,0 +1,62 @@
/**
* Payment method type definitions
* Supports both simple payments (COD, Bank Transfer) and Payment Apps (Stripe, etc.)
*/
export type PaymentType = 'simple' | 'async' | 'app';
export interface Money {
amount: number;
currency: string;
}
export interface PaymentMethod {
id: string;
name: string;
description: string;
type: PaymentType;
fee: number;
available: boolean;
availableInChannels: string[];
icon?: string;
}
export interface TransactionInput {
name: string;
pspReference: string;
availableActions: string[];
amountAuthorized?: Money;
amountCharged?: Money;
externalUrl?: string | null;
}
export interface AsyncSession {
id: string;
status: 'pending' | 'completed' | 'failed';
paymentUrl?: string;
qrCode?: string;
timeout?: number;
}
export interface PaymentResult {
type: 'order_created' | 'session_created' | 'error';
order?: {
id: string;
number: string;
};
session?: AsyncSession;
error?: string;
}
export interface PaymentStatus {
status: 'pending' | 'completed' | 'failed';
message?: string;
}
export interface CODTransactionConfig {
name: string;
pspReference: string;
availableActions: ['CHARGE'];
amountAuthorized: Money;
amountCharged: Money;
}