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,320 @@
# Cash on Delivery (COD) Implementation Plan
**Branch:** `feature/cash-on-delivery`
**Status:** In Development
**Created:** March 29, 2026
---
## 1. ARCHITECTURE DECISIONS
### Payment Method Type: Simple Transaction
- Uses Saleor's native `Transaction` objects
- No Payment App required (COD is manual payment)
- Creates transaction with status `NOT_CHARGED`
- Staff marks as paid via Dashboard when cash collected
### Why This Approach:
- ✅ Native Saleor data structures
- ✅ Appears in Dashboard automatically
- ✅ No metadata hacks
- ✅ Extensible to other simple payments (Bank Transfer)
- ✅ Compatible with Payment Apps later (Stripe, etc.)
---
## 2. FILE STRUCTURE
```
src/
├── lib/
│ ├── config/
│ │ └── paymentMethods.ts # Payment methods configuration
│ └── saleor/
│ └── payments/
│ ├── types.ts # Payment type definitions
│ ├── cod.ts # COD-specific logic
│ └── createTransaction.ts # Generic transaction creator
├── components/
│ └── payment/
│ ├── PaymentMethodSelector.tsx # Payment method selection UI
│ ├── PaymentMethodCard.tsx # Individual payment card
│ └── CODInstructions.tsx # COD-specific instructions
├── app/[locale]/checkout/
│ ├── page.tsx # Updated checkout page
│ └── components/
│ └── PaymentSection.tsx # Checkout payment section wrapper
└── i18n/messages/
├── en.json # Payment translations
├── sr.json # Payment translations
├── de.json # Payment translations
└── fr.json # Payment translations
```
---
## 3. DATA MODELS
### PaymentMethod Interface
```typescript
interface PaymentMethod {
id: string;
name: string;
description: string;
type: 'simple' | 'app';
fee: number;
available: boolean;
availableInChannels: string[];
icon?: string;
}
```
### COD Transaction Structure
```typescript
const codTransaction = {
name: "Cash on Delivery",
pspReference: `COD-${orderNumber}-${timestamp}`,
availableActions: ["CHARGE"],
amountAuthorized: { amount: 0, currency: "RSD" },
amountCharged: { amount: 0, currency: "RSD" }
};
```
---
## 4. IMPLEMENTATION PHASES
### Phase 1: Configuration & Types (Files 1-3)
**Files:**
1. `lib/config/paymentMethods.ts` - Payment methods config
2. `lib/saleor/payments/types.ts` - Type definitions
3. `lib/saleor/payments/cod.ts` - COD transaction logic
**Deliverables:**
- [ ] Payment methods configuration
- [ ] TypeScript interfaces
- [ ] COD transaction creation function
### Phase 2: UI Components (Files 4-6)
**Files:**
4. `components/payment/PaymentMethodCard.tsx`
5. `components/payment/PaymentMethodSelector.tsx`
6. `components/payment/CODInstructions.tsx`
**Deliverables:**
- [ ] Payment method selection UI
- [ ] COD instructions component
- [ ] Responsive design
### Phase 3: Checkout Integration (Files 7-8)
**Files:**
7. `app/[locale]/checkout/components/PaymentSection.tsx`
8. `app/[locale]/checkout/page.tsx` (updated)
**Deliverables:**
- [ ] Payment section in checkout
- [ ] Integration with checkout flow
- [ ] Transaction creation on complete
### Phase 4: Translations (Files 9-12)
**Files:**
9-12. Update `i18n/messages/{en,sr,de,fr}.json`
**Deliverables:**
- [ ] All translation keys
- [ ] Serbian, English, German, French
### Phase 5: Testing
**Tasks:**
- [ ] Test COD flow end-to-end
- [ ] Verify transaction created in Saleor
- [ ] Test mobile responsiveness
- [ ] Test locale switching
---
## 5. CHECKOUT FLOW
```
1. User adds items to cart
2. User proceeds to checkout
3. Checkout page loads with:
- Contact form (email, phone)
- Shipping address form
- Billing address form (same as shipping default)
- Shipping method selector
- PAYMENT METHOD SELECTOR (NEW)
└─ COD selected by default
- Order summary
- Complete Order button
4. User fills all required fields
5. User clicks "Complete Order"
6. System:
a. Validates all fields
b. Creates order via checkoutComplete
c. Creates COD Transaction on order
d. Redirects to order confirmation
7. Order Confirmation page shows:
- Order number
- Total amount
- Payment method: "Cash on Delivery"
- Instructions: "Please prepare cash for delivery"
8. Staff sees order in Dashboard:
- Status: UNFULFILLED
- Payment Status: NOT_CHARGED
- Transaction: "Cash on Delivery (COD-123)"
9. On delivery:
- Delivery person collects cash
- Staff marks order as FULFILLED in Dashboard
- (Optional: Create CHARGE_SUCCESS transaction event)
```
---
## 6. SALESOR DASHBOARD VIEW
### Order Details:
```
Order #1234
├─ Status: UNFULFILLED
├─ Payment Status: NOT_CHARGED
├─ Transactions:
│ └─ Cash on Delivery (COD-1234-1743214567890)
│ ├─ Status: NOT_CHARGED
│ ├─ Amount: 3,200 RSD
│ └─ Available Actions: [CHARGE]
└─ Actions: [Fulfill] [Cancel]
```
### When Cash Collected:
```
Staff clicks [Fulfill]
Order Status: FULFILLED
Payment Status: (still NOT_CHARGED, but order is complete)
```
---
## 7. TRANSLATION KEYS
### English (en.json):
```json
{
"Payment": {
"title": "Payment Method",
"cod": {
"name": "Cash on Delivery",
"description": "Pay when you receive your order",
"instructions": {
"title": "Payment Instructions",
"prepareCash": "Please prepare the exact amount in cash",
"inspectOrder": "You can inspect your order before paying",
"noFee": "No additional fee for cash on delivery"
}
},
"card": {
"name": "Credit Card",
"description": "Secure online payment",
"comingSoon": "Coming soon"
},
"selectMethod": "Select payment method",
"securePayment": "Secure payment processing"
}
}
```
### Serbian (sr.json):
```json
{
"Payment": {
"title": "Način Plaćanja",
"cod": {
"name": "Plaćanje Pouzećem",
"description": "Platite kada primite porudžbinu",
"instructions": {
"title": "Uputstva za Plaćanje",
"prepareCash": "Pripremite tačan iznos u gotovini",
"inspectOrder": "Možete pregledati porudžbinu pre plaćanja",
"noFee": "Bez dodatne naknade za plaćanje pouzećem"
}
}
}
}
```
---
## 8. TESTING CHECKLIST
### Functional Tests:
- [ ] COD radio button selected by default
- [ ] Payment section visible in checkout
- [ ] Order completes with COD selected
- [ ] Transaction created with correct details
- [ ] Transaction visible in Saleor Dashboard
- [ ] Order confirmation shows COD
- [ ] Translations work in all locales
### Edge Cases:
- [ ] Checkout validation fails - payment method preserved
- [ ] Network error during transaction creation
- [ ] User switches payment methods (when multiple available)
- [ ] Mobile viewport - payment section responsive
### Integration Tests:
- [ ] End-to-end COD flow
- [ ] Order appears in Dashboard
- [ ] Staff can fulfill COD order
- [ ] Multiple payment methods display correctly
---
## 9. FUTURE ENHANCEMENTS
### Phase 2 (Post-MVP):
- [ ] Add Bank Transfer payment method
- [ ] Payment method icons
- [ ] Save payment preference for logged-in users
### Phase 3 (Advanced):
- [ ] Bitcoin (manual) payment method
- [ ] Bitcoin (automated) via custom handler
- [ ] Payment Apps integration (Stripe, etc.)
---
## 10. NOTES
### Why No Metadata:
- Saleor has native Transaction objects
- Transactions are typed and validated
- Appear in Dashboard automatically
- Support proper lifecycle (NOT_CHARGED → CHARGED)
### Why Simple Type (Not App):
- COD doesn't need async processing
- No external API to integrate
- No PCI compliance requirements
- Manual verification by staff
### Compatibility:
- Current architecture supports Payment Apps later
- Can add Stripe/PayPal as `type: 'app'` without breaking COD
- Bitcoin can be added as `type: 'async'` when ready
---
**Last Updated:** March 29, 2026
**Next Review:** After Phase 1 completion

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}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -0,0 +1,4 @@
// Payment components exports
export { PaymentMethodSelector } from "./PaymentMethodSelector";
export { PaymentMethodCard } from "./PaymentMethodCard";
export { CODInstructions } from "./CODInstructions";

View File

@@ -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"
}
}
}

View File

@@ -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"
}
}
}

View File

@@ -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"
}
}
}

View File

@@ -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"
}
}
}

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;
}