Skip to content

[Phase 1.5] Cash on Delivery (COD) Option #30

@syed-reza98

Description

@syed-reza98

Priority: P1

Phase: 1.5
Parent Epic: #28 (Bangladesh Payment Methods)
Estimate: 2 days
Type: Story

Overview

Implement Cash on Delivery (COD) payment option for Bangladesh market where 60-70% of e-commerce transactions are settled via cash at the time of delivery. This feature enables customers to order without upfront payment, with collection tracking and reconciliation workflows for vendors.

Context

COD is the dominant payment method in Bangladesh e-commerce:

  • Adoption Rate: 60-70% of all online orders (2024 statistics)
  • Customer Trust: Preferred due to credit card penetration <5%
  • Risk Factor: Higher fraud rate (5-8% failed deliveries vs 1-2% prepaid)
  • Cash Flow Impact: 3-7 days payment settlement vs instant digital
  • Courier Integration: Required for collection tracking (Pathao, Steadfast, RedX)
  • Rural Penetration: Essential for areas without banking infrastructure

Acceptance Criteria

  1. Payment Method Selection

    • ✅ COD option visible on checkout page (radio button)
    • ✅ Display COD fee if applicable (e.g., BDT 50 for orders <500)
    • ✅ Show estimated delivery time (2-5 days metro, 5-7 days outside)
    • ✅ Terms & conditions checkbox ("আমি প্রোডাক্ট রিসিভ করার সময় টাকা প্রদান করব")
  2. Order Creation Workflow

    • ✅ Order created with status=PENDING, paymentMethod=COD
    • ✅ PaymentAttempt record with provider=COD, status=PENDING
    • ✅ No payment redirect (skip Stripe/bKash flow)
    • ✅ Inventory reserved immediately (prevent overselling)
  3. Email Notifications

    • ✅ Order confirmation email to customer (Bengali + English)
    • ✅ Include payment instructions: "আপনার পণ্য ডেলিভারি নেওয়ার সময় টাকা পরিশোধ করুন"
    • ✅ Order amount breakdown (subtotal + shipping + COD fee)
    • ✅ Estimated delivery date
    • ✅ Customer support contact (phone + WhatsApp)
  4. Vendor Dashboard

    • ✅ COD orders flagged with badge ("Cash on Delivery")
    • ✅ Collection status field (Pending, Collected, Failed, Partial)
    • ✅ Mark as collected button (updates order status to COMPLETED)
    • ✅ Failed delivery workflow (restore inventory, mark as CANCELLED)
  5. Collection Tracking

    • ✅ Track delivery partner collection status
    • ✅ Reconciliation report (daily/weekly/monthly)
    • ✅ Pending collections dashboard widget
    • ✅ SMS reminder to customer 1 day before delivery
  6. Failed Delivery Handling

    • ✅ 3 delivery attempts before marking failed
    • ✅ Customer confirmation call before each attempt
    • ✅ Automatic inventory restoration on final failure
    • ✅ Admin notification for high failure rate (>10% in 7 days)
  7. Security & Fraud Prevention

    • ✅ Phone verification required for COD orders
    • ✅ OTP sent to customer mobile (6-digit code)
    • ✅ Block repeat offenders (2+ failed deliveries in 30 days)
    • ✅ Order value limit (e.g., max BDT 10,000 for first-time COD)
  8. Reporting & Analytics

    • ✅ COD conversion rate (vs prepaid)
    • ✅ Failed delivery rate by area (identify risky zones)
    • ✅ Average collection time
    • ✅ Revenue by payment method comparison
  9. Multi-Language Support

    • ✅ Bengali labels: "ক্যাশ অন ডেলিভারি", "হোম ডেলিভারি পেমেন্ট"
    • ✅ English fallback for non-Bengali browsers
    • ✅ SMS templates in Bengali (delivery confirmation, OTP)
  10. Courier Integration

    • ✅ Automatically create COD parcel in Pathao/Steadfast
    • ✅ Track delivery status via webhook
    • ✅ Update payment status when courier confirms collection
    • ✅ Handle partial collections (customer refuses some items)

Technical Implementation

1. COD Order Creation

// src/lib/services/order.service.ts (add COD support)
import { prisma } from '@/lib/prisma';
import { sendCODConfirmationEmail } from '@/lib/email/cod-confirmation';

interface CreateCODOrderParams {
  storeId: string;
  userId: string | null;
  customerEmail: string;
  customerPhone: string;
  shippingAddress: Address;
  items: CartItem[];
  subtotalAmount: number;
  shippingAmount: number;
  codFee: number;
  locale: 'en' | 'bn';
}

export async function createCODOrder(params: CreateCODOrderParams) {
  const totalAmount = params.subtotalAmount + params.shippingAmount + params.codFee;

  // Phone verification
  const isPhoneVerified = await verifyPhoneNumber(params.customerPhone);
  if (!isPhoneVerified) {
    throw new Error('Phone verification required for COD orders');
  }

  // Check COD eligibility
  const customerOrders = await prisma.order.count({
    where: {
      customerEmail: params.customerEmail,
      status: 'CANCELLED',
      paymentMethod: 'COD',
    },
  });

  if (customerOrders >= 2) {
    throw new Error('COD not available due to previous failed deliveries');
  }

  // First-time customer limit
  const isFirstTime = await prisma.order.count({
    where: { customerEmail: params.customerEmail },
  }) === 0;

  if (isFirstTime && totalAmount > 10000 * 100) { // 10,000 BDT in paisa
    throw new Error('COD limit exceeded for first-time customers (max BDT 10,000)');
  }

  // Create order
  const order = await prisma.$transaction(async (tx) => {
    // Create order
    const newOrder = await tx.order.create({
      data: {
        storeId: params.storeId,
        userId: params.userId,
        customerEmail: params.customerEmail,
        customerPhone: params.customerPhone,
        shippingAddress: params.shippingAddress,
        subtotalAmount: params.subtotalAmount,
        shippingAmount: params.shippingAmount,
        totalAmount: totalAmount,
        paymentMethod: 'COD',
        status: 'PENDING',
        items: {
          create: params.items.map((item) => ({
            productId: item.productId,
            variantId: item.variantId,
            quantity: item.quantity,
            unitPrice: item.unitPrice,
            totalPrice: item.unitPrice * item.quantity,
            productName: item.productName,
            variantName: item.variantName,
          })),
        },
      },
      include: { items: true },
    });

    // Create COD payment attempt
    await tx.paymentAttempt.create({
      data: {
        orderId: newOrder.id,
        provider: 'COD',
        amount: totalAmount,
        status: 'PENDING',
        metadata: {
          codFee: params.codFee,
          phoneVerified: true,
          deliveryAttempts: 0,
        },
      },
    });

    // Reserve inventory
    for (const item of params.items) {
      await tx.product.update({
        where: { id: item.productId },
        data: {
          inventory: { decrement: item.quantity },
        },
      });

      // Create inventory log
      await tx.inventoryLog.create({
        data: {
          productId: item.productId,
          storeId: params.storeId,
          reason: 'ORDER_CREATED',
          quantityChange: -item.quantity,
          userId: params.userId,
          metadata: { orderId: newOrder.id },
        },
      });
    }

    return newOrder;
  });

  // Send confirmation email
  await sendCODConfirmationEmail({
    to: params.customerEmail,
    orderId: order.id,
    totalAmount,
    codFee: params.codFee,
    estimatedDelivery: new Date(Date.now() + 5 * 24 * 60 * 60 * 1000), // 5 days
    locale: params.locale,
  });

  return order;
}

// Phone verification via OTP
async function verifyPhoneNumber(phone: string): Promise<boolean> {
  // TODO: Integrate with SMS gateway (SSL Wireless)
  // For now, assume verified if valid Bangladesh number
  const bdPhoneRegex = /^(\+880|880|0)?1[3-9]\d{8}$/;
  return bdPhoneRegex.test(phone);
}

2. COD Email Template

// src/lib/email/cod-confirmation.ts
import { Resend } from 'resend';

const resend = new Resend(process.env.RESEND_API_KEY);

interface CODConfirmationParams {
  to: string;
  orderId: string;
  totalAmount: number; // in paisa
  codFee: number; // in paisa
  estimatedDelivery: Date;
  locale: 'en' | 'bn';
}

export async function sendCODConfirmationEmail(params: CODConfirmationParams) {
  const totalAmountBDT = (params.totalAmount / 100).toFixed(2);
  const codFeeBDT = (params.codFee / 100).toFixed(2);

  const subject = params.locale === 'bn'
    ? `অর্ডার কনফার্মেশন #${params.orderId.slice(0, 8)}`
    : `Order Confirmation #${params.orderId.slice(0, 8)}`;

  const htmlBody = params.locale === 'bn' ? `
    <h2>আপনার অর্ডার সফলভাবে সম্পন্ন হয়েছে!</h2>
    <p>অর্ডার নম্বর: <strong>#${params.orderId.slice(0, 8)}</strong></p>
    
    <h3>পেমেন্ট বিবরণ</h3>
    <ul>
      <li><strong>পেমেন্ট পদ্ধতি:</strong> ক্যাশ অন ডেলিভারি (হোম ডেলিভারি পেমেন্ট)</li>
      <li><strong>মোট টাকা:</strong> ৳${totalAmountBDT}</li>
      <li><strong>ডেলিভারি চার্জ:</strong> ৳${codFeeBDT}</li>
    </ul>

    <h3>গুরুত্বপূর্ণ নির্দেশনা</h3>
    <p>✅ আপনার পণ্য ডেলিভারি নেওয়ার সময় নগদ টাকা পরিশোধ করুন</p>
    <p>✅ পণ্য চেক করার পর টাকা দিন</p>
    <p>✅ ডেলিভারি ম্যান থেকে রসিদ নিন</p>
    
    <p><strong>আনুমানিক ডেলিভারি:</strong> ${params.estimatedDelivery.toLocaleDateString('bn-BD')}</p>
    
    <p>কোন সমস্যা হলে যোগাযোগ করুন: 01XXX-XXXXXX (হোয়াটসঅ্যাপ সাপোর্ট)</p>
  ` : `
    <h2>Your Order Has Been Confirmed!</h2>
    <p>Order Number: <strong>#${params.orderId.slice(0, 8)}</strong></p>
    
    <h3>Payment Details</h3>
    <ul>
      <li><strong>Payment Method:</strong> Cash on Delivery</li>
      <li><strong>Total Amount:</strong> Tk ${totalAmountBDT}</li>
      <li><strong>COD Fee:</strong> Tk ${codFeeBDT}</li>
    </ul>

    <h3>Important Instructions</h3>
    <p>✅ Pay cash when you receive your products</p>
    <p>✅ Inspect products before payment</p>
    <p>✅ Get receipt from delivery person</p>
    
    <p><strong>Estimated Delivery:</strong> ${params.estimatedDelivery.toLocaleDateString('en-US')}</p>
    
    <p>For support, contact: 01XXX-XXXXXX (WhatsApp available)</p>
  `;

  await resend.emails.send({
    from: process.env.EMAIL_FROM!,
    to: params.to,
    subject,
    html: htmlBody,
  });
}

3. COD Payment Button Component

// src/components/cod-payment-button.tsx
'use client';

import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Phone, AlertCircle } from 'lucide-react';
import { useToast } from '@/hooks/use-toast';

interface CODPaymentButtonProps {
  orderId: string;
  amount: number;
  codFee: number;
  disabled?: boolean;
  locale?: 'en' | 'bn';
}

export function CODPaymentButton({ 
  orderId, 
  amount, 
  codFee, 
  disabled,
  locale = 'en' 
}: CODPaymentButtonProps) {
  const [agreed, setAgreed] = useState(false);
  const [loading, setLoading] = useState(false);
  const { toast } = useToast();

  const totalAmount = amount + codFee;

  const labels = {
    en: {
      title: 'Cash on Delivery',
      terms: 'I agree to pay cash upon receiving the products',
      button: `Place Order - Tk ${totalAmount.toFixed(2)}`,
      codFee: `COD Fee: Tk ${codFee.toFixed(2)}`,
      info: 'You will pay when you receive your order. Please keep exact cash ready.',
    },
    bn: {
      title: 'ক্যাশ অন ডেলিভারি',
      terms: 'আমি প্রোডাক্ট রিসিভ করার সময় টাকা প্রদান করতে সম্মত',
      button: `অর্ডার করুন - ৳${totalAmount.toFixed(2)}`,
      codFee: `ডেলিভারি চার্জ: ৳${codFee.toFixed(2)}`,
      info: 'আপনার অর্ডার পাওয়ার সময় টাকা দিতে হবে। দয়া করে সঠিক টাকা রাখুন।',
    },
  };

  const t = labels[locale];

  const handleConfirmOrder = async () => {
    if (!agreed) {
      toast({
        title: locale === 'bn' ? 'শর্ত স্বীকার করুন' : 'Accept Terms',
        description: locale === 'bn' 
          ? 'অর্ডার নিশ্চিত করতে শর্ত স্বীকার করুন'
          : 'Please accept the terms to confirm your order',
        variant: 'destructive',
      });
      return;
    }

    setLoading(true);

    try {
      const response = await fetch('/api/orders/create', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          paymentMethod: 'COD',
          orderId,
        }),
      });

      if (!response.ok) {
        const error = await response.json();
        throw new Error(error.error || 'Failed to create order');
      }

      const { order } = await response.json();

      // Redirect to success page
      window.location.href = `/order/${order.id}/success`;
    } catch (error: any) {
      console.error('COD order error:', error);
      toast({
        title: locale === 'bn' ? 'অর্ডার ব্যর্থ' : 'Order Failed',
        description: error.message,
        variant: 'destructive',
      });
      setLoading(false);
    }
  };

  return (
    <div className="space-y-4">
      <Alert>
        <Phone className="h-4 w-4" />
        <AlertDescription>{t.info}</AlertDescription>
      </Alert>

      <div className="rounded-lg border p-4 space-y-2">
        <div className="flex justify-between">
          <span>{locale === 'bn' ? 'পণ্যের মূল্য' : 'Subtotal'}</span>
          <span>{amount.toFixed(2)}</span>
        </div>
        <div className="flex justify-between text-sm text-muted-foreground">
          <span>{t.codFee}</span>
          <span>{codFee.toFixed(2)}</span>
        </div>
        <div className="flex justify-between font-bold pt-2 border-t">
          <span>{locale === 'bn' ? 'মোট' : 'Total'}</span>
          <span>{totalAmount.toFixed(2)}</span>
        </div>
      </div>

      <div className="flex items-center space-x-2">
        <Checkbox 
          id="cod-terms" 
          checked={agreed}
          onCheckedChange={(checked) => setAgreed(checked === true)}
        />
        <label 
          htmlFor="cod-terms" 
          className="text-sm cursor-pointer"
        >
          {t.terms}
        </label>
      </div>

      <Button
        onClick={handleConfirmOrder}
        disabled={disabled || loading || !agreed}
        className="w-full"
      >
        {loading ? (
          locale === 'bn' ? 'প্রসেস করা হচ্ছে...' : 'Processing...'
        ) : (
          t.button
        )}
      </Button>
    </div>
  );
}

4. Vendor Dashboard - Collection Tracking

// src/app/dashboard/orders/[id]/page.tsx (add COD collection UI)
'use client';

import { useState } from 'react';
import { Button } from '@/components/ui/button';
import {
  Dialog,
  DialogContent,
  DialogDescription,
  DialogFooter,
  DialogHeader,
  DialogTitle,
} from '@/components/ui/dialog';
import { Badge } from '@/components/ui/badge';
import { CheckCircle2, XCircle } from 'lucide-react';

interface CODCollectionActionsProps {
  orderId: string;
  amount: number;
  currentStatus: 'PENDING' | 'COLLECTED' | 'FAILED';
  onStatusUpdate: () => void;
}

export function CODCollectionActions({ 
  orderId, 
  amount, 
  currentStatus,
  onStatusUpdate 
}: CODCollectionActionsProps) {
  const [showDialog, setShowDialog] = useState(false);
  const [action, setAction] = useState<'collected' | 'failed' | null>(null);
  const [loading, setLoading] = useState(false);

  const handleAction = async () => {
    setLoading(true);

    try {
      const response = await fetch(`/api/orders/${orderId}/cod-status`, {
        method: 'PUT',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ status: action }),
      });

      if (!response.ok) {
        throw new Error('Failed to update collection status');
      }

      setShowDialog(false);
      onStatusUpdate();
    } catch (error) {
      console.error('COD status update error:', error);
    } finally {
      setLoading(false);
    }
  };

  if (currentStatus === 'COLLECTED') {
    return (
      <Badge variant="success" className="gap-1">
        <CheckCircle2 className="h-3 w-3" />
        Cash Collected
      </Badge>
    );
  }

  if (currentStatus === 'FAILED') {
    return (
      <Badge variant="destructive" className="gap-1">
        <XCircle className="h-3 w-3" />
        Delivery Failed
      </Badge>
    );
  }

  return (
    <>
      <div className="flex gap-2">
        <Button
          variant="default"
          onClick={() => {
            setAction('collected');
            setShowDialog(true);
          }}
        >
          <CheckCircle2 className="mr-2 h-4 w-4" />
          Mark as Collected
        </Button>
        <Button
          variant="destructive"
          onClick={() => {
            setAction('failed');
            setShowDialog(true);
          }}
        >
          <XCircle className="mr-2 h-4 w-4" />
          Delivery Failed
        </Button>
      </div>

      <Dialog open={showDialog} onOpenChange={setShowDialog}>
        <DialogContent>
          <DialogHeader>
            <DialogTitle>
              {action === 'collected' ? 'Confirm Cash Collection' : 'Confirm Delivery Failure'}
            </DialogTitle>
            <DialogDescription>
              {action === 'collected' 
                ? `Confirm that you have collected Tk ${(amount / 100).toFixed(2)} cash from the customer. This action will mark the order as completed.`
                : `Mark this delivery as failed. Inventory will be restored and the order will be cancelled.`
              }
            </DialogDescription>
          </DialogHeader>
          <DialogFooter>
            <Button variant="outline" onClick={() => setShowDialog(false)}>
              Cancel
            </Button>
            <Button onClick={handleAction} disabled={loading}>
              {loading ? 'Processing...' : 'Confirm'}
            </Button>
          </DialogFooter>
        </DialogContent>
      </Dialog>
    </>
  );
}

Dependencies

Blocks:

  • None (can be deployed independently)

Blocked By:

Enhances:

References

  • Gap Analysis: docs/research/codebase_feature_gap_analysis.md (PaymentAttempt model with COD provider)
  • Implementation Plan: docs/research/implementation_plan.md (Order lifecycle, inventory restoration)
  • Marketing Automation V2: docs/research/MARKETING_AUTOMATION_V2.md (COD adoption rate 60-70%, fraud prevention strategies)
  • Feature Roadmap: docs/research/feature_roadmap_user_stories.md (As a customer, I can pay cash on delivery)

Testing Checklist

Order Creation Testing

  • Create COD order with valid phone number
  • Verify inventory reserved immediately
  • Check PaymentAttempt record created with provider=COD
  • Confirm email sent with Bengali + English instructions
  • Test COD fee calculation (<500 = ৳50, >=500 = free)

Phone Verification Testing

  • Valid Bangladesh number accepted (+8801XXXXXXXXX)
  • Invalid format rejected (show error message)
  • OTP sent via SMS (6-digit code)
  • OTP verification within 5 minutes
  • Resend OTP option (max 3 attempts)

Fraud Prevention Testing

  • First-time customer limited to BDT 10,000
  • Block customer with 2+ failed deliveries
  • High-value orders (>BDT 20,000) require admin approval
  • Suspicious address detection (incomplete/fake addresses)

Collection Tracking Testing

  • Vendor marks order as collected
  • Order status updates to COMPLETED
  • PaymentAttempt status updates to SUCCEEDED
  • Customer receives receipt email

Failed Delivery Testing

  • Vendor marks delivery as failed
  • Inventory restored automatically
  • Order status updates to CANCELLED
  • PaymentAttempt status updates to FAILED
  • Customer notified via email/SMS

Reporting Testing

  • COD vs prepaid conversion rate
  • Failed delivery rate by area (Dhaka vs outside)
  • Average delivery time
  • Pending collections dashboard widget

Current Status

Not Started - Awaiting Phase 1 completion

Success Metrics

Metric Target Measurement
COD Adoption Rate >60% of orders Track payment method distribution
Successful Delivery Rate >92% (Collected orders / Total COD orders) × 100
Failed Delivery Rate <8% (Failed deliveries / Total COD orders) × 100
Average Collection Time <5 days Time from order creation to collection
Fraud Rate <2% (Fraudulent orders / Total COD orders) × 100

Implementation Notes: COD is non-negotiable for Bangladesh market success. Despite higher operational costs (failed deliveries, cash handling), it unlocks 60-70% of customer base. Phone verification and fraud detection are critical to minimize losses. Consider partnering with courier services (Pathao, Steadfast) for cash collection handling to reduce operational burden.

Metadata

Metadata

Assignees

Type

No type

Projects

Status

Backlog

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions