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:
47
src/app/[locale]/checkout/components/PaymentSection.tsx
Normal file
47
src/app/[locale]/checkout/components/PaymentSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
55
src/components/payment/CODInstructions.tsx
Normal file
55
src/components/payment/CODInstructions.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Banknote, Package, CheckCircle } from "lucide-react";
|
||||
|
||||
interface CODInstructionsProps {
|
||||
locale: string;
|
||||
}
|
||||
|
||||
export function CODInstructions({ locale }: CODInstructionsProps) {
|
||||
const t = useTranslations("Payment.COD");
|
||||
|
||||
const instructions = [
|
||||
{
|
||||
icon: Banknote,
|
||||
title: t("instructions.prepareCash"),
|
||||
description: t("instructions.prepareCashDesc"),
|
||||
},
|
||||
{
|
||||
icon: Package,
|
||||
title: t("instructions.inspectOrder"),
|
||||
description: t("instructions.inspectOrderDesc"),
|
||||
},
|
||||
{
|
||||
icon: CheckCircle,
|
||||
title: t("instructions.noFee"),
|
||||
description: t("instructions.noFeeDesc"),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="mt-4 rounded-lg bg-blue-50 p-4">
|
||||
<h4 className="mb-3 font-medium text-blue-900">
|
||||
{t("instructions.title")}
|
||||
</h4>
|
||||
|
||||
<div className="space-y-3">
|
||||
{instructions.map((instruction, index) => {
|
||||
const Icon = instruction.icon;
|
||||
return (
|
||||
<div key={index} className="flex items-start gap-3">
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-blue-100">
|
||||
<Icon className="h-4 w-4 text-blue-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-blue-900">{instruction.title}</p>
|
||||
<p className="text-sm text-blue-700">{instruction.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
84
src/components/payment/PaymentMethodCard.tsx
Normal file
84
src/components/payment/PaymentMethodCard.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { PaymentMethod } from "@/lib/saleor/payments/types";
|
||||
import { Banknote, CreditCard, Building2, LucideIcon } from "lucide-react";
|
||||
|
||||
// Icon mapping for payment methods
|
||||
const iconMap: Record<string, LucideIcon> = {
|
||||
Banknote,
|
||||
CreditCard,
|
||||
Building2,
|
||||
};
|
||||
|
||||
interface PaymentMethodCardProps {
|
||||
method: PaymentMethod;
|
||||
isSelected: boolean;
|
||||
onSelect: () => void;
|
||||
disabled?: boolean;
|
||||
locale: string;
|
||||
}
|
||||
|
||||
export function PaymentMethodCard({
|
||||
method,
|
||||
isSelected,
|
||||
onSelect,
|
||||
disabled = false,
|
||||
locale,
|
||||
}: PaymentMethodCardProps) {
|
||||
const Icon = method.icon ? iconMap[method.icon] : Banknote;
|
||||
|
||||
return (
|
||||
<label
|
||||
className={cn(
|
||||
"relative flex cursor-pointer items-start gap-3 rounded-lg border p-4 transition-all",
|
||||
"hover:border-gray-400",
|
||||
isSelected && "border-black bg-gray-50",
|
||||
!isSelected && "border-gray-200 bg-white",
|
||||
(disabled || !method.available) && "cursor-not-allowed opacity-50"
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="payment-method"
|
||||
value={method.id}
|
||||
checked={isSelected}
|
||||
onChange={onSelect}
|
||||
disabled={disabled || !method.available}
|
||||
className="sr-only"
|
||||
/>
|
||||
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-gray-100">
|
||||
{Icon && <Icon className="h-5 w-5 text-gray-600" />}
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium text-gray-900">{method.name}</span>
|
||||
{method.fee > 0 && (
|
||||
<span className="text-sm text-amber-600">
|
||||
+{new Intl.NumberFormat(locale === 'sr' ? 'sr-RS' : 'en-US', {
|
||||
style: 'currency',
|
||||
currency: 'RSD',
|
||||
}).format(method.fee)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="mt-1 text-sm text-gray-600">{method.description}</p>
|
||||
|
||||
{!method.available && (
|
||||
<span className="mt-2 inline-block text-xs text-gray-500">
|
||||
{locale === 'sr' ? 'Uskoro dostupno' : 'Coming soon'}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{isSelected && (
|
||||
<div className="absolute right-4 top-4">
|
||||
<div className="h-2 w-2 rounded-full bg-black" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
62
src/components/payment/PaymentMethodSelector.tsx
Normal file
62
src/components/payment/PaymentMethodSelector.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
"use client";
|
||||
|
||||
import type { PaymentMethod } from "@/lib/saleor/payments/types";
|
||||
import { PaymentMethodCard } from "./PaymentMethodCard";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
interface PaymentMethodSelectorProps {
|
||||
methods: PaymentMethod[];
|
||||
selectedMethodId: string;
|
||||
onSelectMethod: (methodId: string) => void;
|
||||
locale: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function PaymentMethodSelector({
|
||||
methods,
|
||||
selectedMethodId,
|
||||
onSelectMethod,
|
||||
locale,
|
||||
disabled = false,
|
||||
}: PaymentMethodSelectorProps) {
|
||||
const t = useTranslations("Payment");
|
||||
|
||||
// Filter to only available methods
|
||||
const availableMethods = methods.filter((m) => m.available);
|
||||
|
||||
if (availableMethods.length === 0) {
|
||||
return (
|
||||
<div className="rounded-lg border border-gray-200 p-4 text-center text-gray-500">
|
||||
{t("noMethodsAvailable")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// If only one method, show it as selected but don't allow changing
|
||||
const isSingleMethod = availableMethods.length === 1;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-medium">{t("title")}</h3>
|
||||
|
||||
<div className="space-y-3">
|
||||
{availableMethods.map((method) => (
|
||||
<PaymentMethodCard
|
||||
key={method.id}
|
||||
method={method}
|
||||
isSelected={selectedMethodId === method.id}
|
||||
onSelect={() => onSelectMethod(method.id)}
|
||||
disabled={disabled || isSingleMethod}
|
||||
locale={locale}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{isSingleMethod && (
|
||||
<p className="text-sm text-gray-500">
|
||||
{t("singleMethodNotice")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
4
src/components/payment/index.ts
Normal file
4
src/components/payment/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
// Payment components exports
|
||||
export { PaymentMethodSelector } from "./PaymentMethodSelector";
|
||||
export { PaymentMethodCard } from "./PaymentMethodCard";
|
||||
export { CODInstructions } from "./CODInstructions";
|
||||
@@ -383,6 +383,37 @@
|
||||
"thankYou": "Vielen Dank für Ihren Einkauf.",
|
||||
"orderNumber": "Bestellnummer",
|
||||
"confirmationEmail": "Sie erhalten in Kürze eine Bestätigungs-E-Mail. Wir werden Sie kontaktieren, um Nachnahme zu arrangieren.",
|
||||
"continueShoppingBtn": "Weiter einkaufen"
|
||||
"continueShoppingBtn": "Weiter einkaufen",
|
||||
"errorSelectPayment": "Bitte wählen Sie eine Zahlungsmethode."
|
||||
},
|
||||
"Payment": {
|
||||
"title": "Zahlungsmethode",
|
||||
"selectMethod": "Zahlungsmethode wählen",
|
||||
"securePayment": "Sichere Zahlungsabwicklung",
|
||||
"noMethodsAvailable": "Keine Zahlungsmethoden verfügbar",
|
||||
"singleMethodNotice": "Nachnahme ist die einzige verfügbare Zahlungsmethode für Ihren Standort",
|
||||
"COD": {
|
||||
"name": "Nachnahme",
|
||||
"description": "Bezahlen Sie bei Erhalt Ihrer Bestellung",
|
||||
"instructions": {
|
||||
"title": "Zahlungsanweisungen",
|
||||
"prepareCash": "Bargeld vorbereiten",
|
||||
"prepareCashDesc": "Bitte haben Sie den genauen Betrag in bar bereit",
|
||||
"inspectOrder": "Vor Zahlung prüfen",
|
||||
"inspectOrderDesc": "Sie können Ihre Bestellung vor der Zahlung überprüfen",
|
||||
"noFee": "Keine zusätzliche Gebühr",
|
||||
"noFeeDesc": "Nachnahme ist völlig kostenlos"
|
||||
}
|
||||
},
|
||||
"card": {
|
||||
"name": "Kreditkarte",
|
||||
"description": "Sichere Online-Zahlung",
|
||||
"comingSoon": "Demnächst verfügbar"
|
||||
},
|
||||
"bank_transfer": {
|
||||
"name": "Banküberweisung",
|
||||
"description": "Bezahlen Sie per Banküberweisung",
|
||||
"comingSoon": "Demnächst verfügbar"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -430,6 +430,37 @@
|
||||
"thankYou": "Thank you for your purchase.",
|
||||
"orderNumber": "Order Number",
|
||||
"confirmationEmail": "You will receive a confirmation email shortly. We will contact you to arrange Cash on Delivery.",
|
||||
"continueShoppingBtn": "Continue Shopping"
|
||||
"continueShoppingBtn": "Continue Shopping",
|
||||
"errorSelectPayment": "Please select a payment method."
|
||||
},
|
||||
"Payment": {
|
||||
"title": "Payment Method",
|
||||
"selectMethod": "Select payment method",
|
||||
"securePayment": "Secure payment processing",
|
||||
"noMethodsAvailable": "No payment methods available",
|
||||
"singleMethodNotice": "Cash on Delivery is the only available payment method for your location",
|
||||
"COD": {
|
||||
"name": "Cash on Delivery",
|
||||
"description": "Pay when you receive your order",
|
||||
"instructions": {
|
||||
"title": "Payment Instructions",
|
||||
"prepareCash": "Prepare Cash",
|
||||
"prepareCashDesc": "Please have the exact amount ready in cash",
|
||||
"inspectOrder": "Inspect Before Paying",
|
||||
"inspectOrderDesc": "You can check your order before making payment",
|
||||
"noFee": "No Extra Fee",
|
||||
"noFeeDesc": "Cash on Delivery is completely free"
|
||||
}
|
||||
},
|
||||
"card": {
|
||||
"name": "Credit Card",
|
||||
"description": "Secure online payment",
|
||||
"comingSoon": "Coming soon"
|
||||
},
|
||||
"bank_transfer": {
|
||||
"name": "Bank Transfer",
|
||||
"description": "Pay via bank transfer",
|
||||
"comingSoon": "Coming soon"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -383,6 +383,37 @@
|
||||
"thankYou": "Merci pour votre achat.",
|
||||
"orderNumber": "Numéro de Commande",
|
||||
"confirmationEmail": "Vous recevrez bientôt un email de confirmation. Nous vous contacterons pour organiser le paiement contre-remboursement.",
|
||||
"continueShoppingBtn": "Continuer les Achats"
|
||||
"continueShoppingBtn": "Continuer les Achats",
|
||||
"errorSelectPayment": "Veuillez sélectionner un mode de paiement."
|
||||
},
|
||||
"Payment": {
|
||||
"title": "Mode de Paiement",
|
||||
"selectMethod": "Sélectionner le mode de paiement",
|
||||
"securePayment": "Paiement sécurisé",
|
||||
"noMethodsAvailable": "Aucun mode de paiement disponible",
|
||||
"singleMethodNotice": "Le paiement à la livraison est le seul mode de paiement disponible pour votre région",
|
||||
"COD": {
|
||||
"name": "Paiement à la Livraison",
|
||||
"description": "Payez lors de la réception de votre commande",
|
||||
"instructions": {
|
||||
"title": "Instructions de Paiement",
|
||||
"prepareCash": "Préparer l'Argent",
|
||||
"prepareCashDesc": "Veuillez préparer le montant exact en espèces",
|
||||
"inspectOrder": "Inspecter Avant de Payer",
|
||||
"inspectOrderDesc": "Vous pouvez vérifier votre commande avant de payer",
|
||||
"noFee": "Pas de Frais Supplémentaires",
|
||||
"noFeeDesc": "Le paiement à la livraison est entièrement gratuit"
|
||||
}
|
||||
},
|
||||
"card": {
|
||||
"name": "Carte de Crédit",
|
||||
"description": "Paiement en ligne sécurisé",
|
||||
"comingSoon": "Bientôt disponible"
|
||||
},
|
||||
"bank_transfer": {
|
||||
"name": "Virement Bancaire",
|
||||
"description": "Payez par virement bancaire",
|
||||
"comingSoon": "Bientôt disponible"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -429,6 +429,37 @@
|
||||
"thankYou": "Hvala vam na kupovini!",
|
||||
"orderNumber": "Broj narudžbine",
|
||||
"confirmationEmail": "Uскoro ćete primiti email potvrde. Kontaktiraćemo vas da dogovorimo pouzećem plaćanje.",
|
||||
"continueShoppingBtn": "Nastavi kupovinu"
|
||||
"continueShoppingBtn": "Nastavi kupovinu",
|
||||
"errorSelectPayment": "Molimo izaberite način plaćanja."
|
||||
},
|
||||
"Payment": {
|
||||
"title": "Način Plaćanja",
|
||||
"selectMethod": "Izaberite način plaćanja",
|
||||
"securePayment": "Bezbedno plaćanje",
|
||||
"noMethodsAvailable": "Nema dostupnih načina plaćanja",
|
||||
"singleMethodNotice": "Plaćanje pouzećem je jedini dostupan način plaćanja za vašu lokaciju",
|
||||
"COD": {
|
||||
"name": "Plaćanje Pouzećem",
|
||||
"description": "Platite kada primite porudžbinu",
|
||||
"instructions": {
|
||||
"title": "Uputstva za Plaćanje",
|
||||
"prepareCash": "Pripremite Gotovinu",
|
||||
"prepareCashDesc": "Molimo pripremite tačan iznos u gotovini",
|
||||
"inspectOrder": "Pregledajte Pre Plaćanja",
|
||||
"inspectOrderDesc": "Možete pregledati porudžbinu pre nego što platite",
|
||||
"noFee": "Bez Dodatne Naknade",
|
||||
"noFeeDesc": "Plaćanje pouzećem je potpuno besplatno"
|
||||
}
|
||||
},
|
||||
"card": {
|
||||
"name": "Kreditna Kartica",
|
||||
"description": "Bezbedno online plaćanje",
|
||||
"comingSoon": "Uskoro dostupno"
|
||||
},
|
||||
"bank_transfer": {
|
||||
"name": "Bankovni Transfer",
|
||||
"description": "Platite putem bankovnog transfera",
|
||||
"comingSoon": "Uskoro dostupno"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
106
src/lib/config/paymentMethods.ts
Normal file
106
src/lib/config/paymentMethods.ts
Normal 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}`;
|
||||
}
|
||||
149
src/lib/saleor/payments/cod.ts
Normal file
149
src/lib/saleor/payments/cod.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
62
src/lib/saleor/payments/types.ts
Normal file
62
src/lib/saleor/payments/types.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user