Skip to content
ExponaBLOG
PAYMENT ENGINEERING & STRIPE INTEGRATION · DAY 072 / 100

Cash on delivery and offline payments: a practical guide

Payments outside the gateway.

Expona Admin14 min read

Introduction

Not every payment travels through Stripe. When you're operating in markets with limited card penetration, high fraud risk, or cultural preference for paying after delivery, cash on delivery and offline payments become a core part of your revenue engine. This guide walks you through the operational and technical patterns we've used across e-commerce, ride-hailing, and SaaS platforms to handle payments outside the gateway reliably.

The biggest mistake teams make is treating offline payments as an afterthought—bolting reconciliation onto an existing payment flow rather than designing the order and payment lifecycle separately from the start. We'll show you how to structure this so your finance team can close books daily, your support team can handle edge cases without manual database surgery, and your customers see accurate payment status in real time.

Separate order and payment status

The foundation of reliable offline payments is accepting that order creation and payment collection are decoupled events. In card-based flows, we often create the order and attempt payment in one transaction. With cash on delivery and bank transfers, that's impossible. Your order exists in a "pending payment" state until the customer actually pays.

Start by defining clear payment states that are independent of order fulfillment states. An order can be "awaiting payment" while being "ready to ship." A customer might have paid via bank transfer yesterday, but the payment hasn't been recognized by your system yet because it arrives in a daily batch file from the bank.

Here's a typical data model for this separation:

typescript

enum OrderStatus {
  CREATED = 'created',
  AWAITING_PAYMENT = 'awaiting_payment',
  PAID = 'paid',
  PROCESSING = 'processing',
  SHIPPED = 'shipped',
  DELIVERED = 'delivered',
  CANCELLED = 'cancelled'
}

enum PaymentMethod {
  CASH_ON_DELIVERY = 'cod',
  BANK_TRANSFER = 'bank_transfer',
  CHECK = 'check',
  CORPORATE_ACCOUNT = 'corporate_account'
}

enum PaymentStatus {
  PENDING = 'pending',
  COLLECTED = 'collected',
  RECONCILED = 'reconciled',
  FAILED = 'failed',
  REFUNDED = 'refunded'
}

interface Order {
  id: string;
  customerId: string;
  status: OrderStatus;
  totalAmount: number;
  currency: string;
  createdAt: Date;
  paymentMethod: PaymentMethod;
  paymentStatus: PaymentStatus;
}

interface Payment {
  id: string;
  orderId: string;
  amount: number;
  currency: string;
  method: PaymentMethod;
  status: PaymentStatus;
  collectedAt?: Date;
  reconciledAt?: Date;
  reference?: string;
  notes?: string;
}

Notice the three separate statuses: order status (what's happening with fulfillment), payment method (how we're getting paid), and payment status (where are we in the payment lifecycle). This gives you flexibility. A cash on delivery order can move to "processing" and ship before the driver collects payment. A bank transfer order waits in "awaiting payment" until the funds appear in your account.

In your order creation endpoint, set paymentStatus to PENDING when the order is placed. Don't create any payment transaction yet. Create the Payment record only when something concrete happens: the driver collects cash, a bank transfer arrives, or a check clears.

Record payments when collected

Payment collection happens outside your system. A delivery driver collects cash. A customer transfers funds. A check arrives in the mail. Your job is to record these events reliably and make them queryable by your finance team.

For cash on delivery, the driver (or the customer, if they collect from a store) is your payment capture device. They need a simple way to mark an order as paid. This should be a separate action from marking the order as delivered:

typescript

interface RecordCashPaymentRequest {
  orderId: string;
  amount: number;
  collectedAt: Date;
  collectedBy: string; // driver ID or user ID
  reference?: string; // receipt number, etc.
  notes?: string;
}

async function recordCashPayment(req: RecordCashPaymentRequest) {
  const order = await db.orders.findOne({ id: req.orderId });
  
  if (!order) {
    throw new NotFoundError(`Order ${req.orderId} not found`);
  }
  
  if (order.paymentStatus !== 'pending') {
    throw new ValidationError(`Order ${req.orderId} is already paid`);
  }
  
  if (req.amount !== order.totalAmount) {
    throw new ValidationError(
      `Amount mismatch: expected ${order.totalAmount}, got ${req.amount}`
    );
  }
  
  const payment = await db.payments.create({
    id: generateId(),
    orderId: req.orderId,
    amount: req.amount,
    currency: order.currency,
    method: 'cash_on_delivery',
    status: 'collected',
    collectedAt: req.collectedAt,
    collectedBy: req.collectedBy,
    reference: req.reference,
    notes: req.notes
  });
  
  await db.orders.updateOne(
    { id: req.orderId },
    { paymentStatus: 'collected' }
  );
  
  // Emit event for downstream systems (accounting, reporting, etc.)
  await eventBus.emit('payment.collected', {
    orderId: req.orderId,
    paymentId: payment.id,
    amount: req.amount,
    method: 'cash_on_delivery',
    collectedAt: req.collectedAt
  });
  
  return payment;
}

For bank transfers and checks, you'll receive a batch file (SWIFT, ACH, bank CSV export) with transactions. Parse this file daily and match transactions to orders. The key is idempotency: running the same batch file twice should not create duplicate payments.

Here's a pattern that works across most banks:

typescript

interface BankTransaction {
  transactionId: string; // Bank's unique ID
  amount: number;
  currency: string;
  senderName: string;
  senderAccount: string;
  description: string;
  valueDate: Date;
  processedDate: Date;
}

async function processBankBatch(transactions: BankTransaction[]) {
  const results = {
    matched: 0,
    unmatched: 0,
    errors: []
  };
  
  for (const txn of transactions) {
    try {
      // Idempotency check: has this bank transaction been processed?
      const existing = await db.payments.findOne({
        reference: txn.transactionId,
        method: 'bank_transfer'
      });
      
      if (existing) {
        results.matched++;
        continue;
      }
      
      // Try to match to an order using description or sender info
      const orderId = extractOrderIdFromDescription(txn.description);
      
      if (!orderId) {
        // Could not identify order; create unmatched payment for manual review
        await db.unmatched_payments.create({
          id: generateId(),
          transactionId: txn.transactionId,
          amount: txn.amount,
          currency: txn.currency,
          senderName: txn.senderName,
          description: txn.description,
          valueDate: txn.valueDate,
          status: 'pending_review',
          createdAt: new Date()
        });
        results.unmatched++;
        continue;
      }
      
      const order = await db.orders.findOne({ id: orderId });
      
      if (!order) {
        results.errors.push({
          txn: txn.transactionId,
          reason: `Order ${orderId} not found`
        });
        continue;
      }
      
      if (txn.amount !== order.totalAmount) {
        results.errors.push({
          txn: txn.transactionId,
          reason: `Amount mismatch: order ${order.totalAmount}, got ${txn.amount}`
        });
        continue;
      }
      
      // Record the payment
      await db.payments.create({
        id: generateId(),
        orderId: orderId,
        amount: txn.amount,
        currency: txn.currency,
        method: 'bank_transfer',
        status: 'collected',
        collectedAt: txn.valueDate,
        reference: txn.transactionId,
        notes: `From ${txn.senderName} (${txn.senderAccount})`
      });
      
      await db.orders.updateOne(
        { id: orderId },
        { paymentStatus: 'collected' }
      );
      
      await eventBus.emit('payment.collected', {
        orderId: orderId,
        amount: txn.amount,
        method: 'bank_transfer',
        collectedAt: txn.valueDate
      });
      
      results.matched++;
      
    } catch (err) {
      results.errors.push({
        txn: txn.transactionId,
        reason: err.message
      });
    }
  }
  
  return results;
}

The pattern here is: collect payment data, attempt to match it to orders using available context, record the payment with a reference to the source transaction, and emit events for other systems. Any transaction that can't be matched automatically goes to an "unmatched payments" table for your finance team to review.

Reconcile daily

Reconciliation is where offline payments get tricky. Unlike card transactions that settle in predictable ways, cash and bank transfers can drift: you might have collected payment but not yet recognized it, or recognized it but not yet received it.

Set up a daily reconciliation process that compares your COLLECTED payments against your actual bank balance and your collected cash. This is often manual or semi-automated, depending on your scale.

Create a reconciliation ledger that tracks state over time:

typescript

interface ReconciliationRecord {
  id: string;
  date: Date; // The day being reconciled
  source: 'bank' | 'cash_collection'; // Source of funds
  totalExpected: number; // Sum of COLLECTED payments for this source
  totalReceived: number; // What we actually have
  variance: number; // totalExpected - totalReceived
  reconciledBy: string; // User ID who approved
  reconciledAt: Date;
  notes?: string;
  status: 'pending' | 'approved' | 'exception';
}

async function createDailyReconciliation(date: Date, source: string) {
  // Sum all payments marked as COLLECTED but not yet RECONCILED
  const payments = await db.payments.find({
    status: 'collected',
    reconciledAt: { $exists: false },
    collectedAt: {
      $gte: startOfDay(date),
      $lt: endOfDay(date)
    },
    method: source === 'bank' ? 'bank_transfer' : 'cash_on_delivery'
  });
  
  const totalExpected = payments.reduce((sum, p) => sum + p.amount, 0);
  
  const reconciliation = await db.reconciliations.create({
    id: generateId(),
    date: date,
    source: source,
    totalExpected: totalExpected,
    totalReceived: 0, // Set by finance team
    variance: 0,
    status: 'pending',
    createdAt: new Date()
  });
  
  return reconciliation;
}

async function approveReconciliation(
  reconciliationId: string,
  totalReceived: number,
  reconciledBy: string,
  notes?: string
) {
  const reconciliation = await db.reconciliations.findOne({
    id: reconciliationId
  });
  
  if (!reconciliation) throw new NotFoundError();
  
  const variance = reconciliation.totalExpected - totalReceived;
  
  // If variance exists, flag as exception; don't auto-reconcile
  const status = variance !== 0 ? 'exception' : 'approved';
  
  await db.reconciliations.updateOne(
    { id: reconciliationId },
    {
      totalReceived: totalReceived,
      variance: variance,
      status: status,
      reconciledBy: reconciledBy,
      reconciledAt: new Date(),
      notes: notes
    }
  );
  
  // If approved (no variance), mark payments as RECONCILED
  if (status === 'approved') {
    await db.payments.updateMany(
      {
        status: 'collected',
        reconciledAt: { $exists: false },
        collectedAt: {
          $gte: startOfDay(reconciliation.date),
          $lt: endOfDay(reconciliation.date)
        },
        method: reconciliation.source === 'bank' ? 'bank_transfer' : 'cash_on_delivery'
      },
      {
        status: 'reconciled',
        reconciledAt: new Date()
      }
    );
  }
  
  return reconciliation;
}

When a variance is detected, don't silently mark payments as reconciled. Flag it as an exception. Your finance team investigates: maybe a driver hasn't deposited cash yet, maybe a bank transaction is still in transit, maybe there's genuine fraud. Reconciliation is your safety valve.

Emit events when reconciliation completes. Other systems (revenue recognition, tax reporting) depend on knowing when money is truly confirmed:

typescript

await eventBus.emit('reconciliation.approved', {
  reconciliationId: reconciliation.id,
  date: reconciliation.date,
  source: reconciliation.source,
  totalReceived: totalReceived
});

Track failed deliveries

The messiest part of cash on delivery is when the driver can't collect. The customer isn't home. The order is refused. The customer promises to pay later but doesn't. These edge cases can cause cascading problems if you don't handle them explicitly.

Create a "failed delivery" or "payment attempt" record that captures why payment wasn't collected:

typescript

enum FailureReason {
  CUSTOMER_ABSENT = 'customer_absent',
  ORDER_REFUSED = 'order_refused',
  PARTIAL_PAYMENT = 'partial_payment',
  DAMAGED_GOODS = 'damaged_goods',
  ADDRESS_INVALID = 'address_invalid',
  OTHER = 'other'
}

interface PaymentAttempt {
  id: string;
  orderId: string;
  attemptNumber: number;
  attemptedAt: Date;
  attemptedBy: string; // driver ID
  status: 'success' | 'failed';
  failureReason?: FailureReason;
  amountCollected?: number;
  notes?: string;
}

async function recordDeliveryAttempt(
  orderId: string,
  attemptedBy: string,
  success: boolean,
  failureReason?: FailureReason,
  amountCollected?: number
) {
  const order = await db.orders.findOne({ id: orderId });
  
  const attemptNumber = (await db.payment_attempts.countDocuments({
    orderId: orderId
  })) + 1;
  
  const attempt = await db.payment_attempts.create({
    id: generateId(),
    orderId: orderId,
    attemptNumber: attemptNumber,
    attemptedAt: new Date(),
    attemptedBy: attemptedBy,
    status: success ? 'success' : 'failed',
    failureReason: failureReason,
    amountCollected: amountCollected || 0,
    notes: ''
  });
  
  if (success && amountCollected) {
    // Record the partial or full payment
    if (amountCollected < order.totalAmount) {
      // Partial payment; create a payment record and flag for follow-up
      await db.payments.create({
        id: generateId(),
        orderId: orderId,
        amount: amountCollected,
        currency: order.currency,
        method: 'cash_on_delivery',
        status: 'collected',
        collectedAt: new Date(),
        collectedBy: attemptedBy,
        notes: `Partial payment collected. Outstanding: ${order.totalAmount - amountCollected}`
      });
      
      await db.orders.updateOne(
        { id: orderId },
        { paymentStatus: 'partial' }
      );
      
      await eventBus.emit('payment.partial', {
        orderId: orderId,
        amountCollected: amountCollected,
        amountOutstanding: order.totalAmount - amountCollected
      });
    } else {
      // Full payment
      await recordCashPayment({
        orderId: orderId,
        amount: amountCollected,
        collectedAt: new Date(),
        collectedBy: attemptedBy
      });
    }
  } else if (!success && attemptNumber < 3) {
    // Reattempt: update order status to signal another delivery try
    await db.orders.updateOne(
      { id: orderId },
      { status: 'awaiting_payment' }
    );
    
    await eventBus.emit('delivery.reattempt_scheduled', {
      orderId: orderId,
      reason: failureReason,
      attemptNumber: attemptNumber
    });
  } else if (!success && attemptNumber >= 3) {
    // Mark order for cancellation or return
    await db.orders.updateOne(
      { id: orderId },
      { status: 'cancelled', paymentStatus: 'failed' }
    );
    
    await eventBus.emit('order.payment_failed_final', {
      orderId: orderId,
      reason: failureReason,
      attempts: attemptNumber
    });
  }
  
  return attempt;
}

The key insight: each delivery attempt is its own record, separate from the payment record. This gives you a complete audit trail. Your support team can see exactly what happened on each visit. Your logistics team knows whether to reattempt. Your finance team can explain why an order is still "awaiting payment" after weeks.

Common mistakes

  • Treating offline payments as a fallback. If offline payments are 30% of your revenue, they're not a fallback; they're core. Design your data model, reconciliation, and UX around them from the start, not bolted on later.
  • Conflating order status and payment status. Changing orderStatus to "paid" when you haven't received payment yet creates invisible bugs. The order ships before payment arrives. The payment fails but you've already fulfilled. Keep them separate.
  • Skipping idempotency on batch processing. Your bank will send you the same transactions twice (duplicate files, retransmissions). If you don't check reference or transaction ID before creating payment records, you'll have double-counted revenue.
  • Not reconciling exceptions. When your expected payment total doesn't match your bank balance, don't force them to match. Flag it. Investigate. A missing $500 today might be a theft, a pending wire, or a bank error. Find out before you close the books.
  • Losing delivery attempt history. Store every failed delivery attempt. Don't just update paymentStatus to "failed" and move on. Your customer service team needs to know the driver visited three times. Your fraud team needs to know the customer claimed the address was wrong.

Key takeaways

  • Separate order status, payment method, and payment status into distinct fields so your system can represent reality: an order can be shipped but not paid.
  • Record payments only when they actually happen (cash collected, bank transfer received, check cleared), not when you hope they will.
  • Batch-process bank transactions idempotently, matching them to orders by reference, and send unmatched transactions to your finance team for manual review.
  • Reconcile daily, explicitly, with variance tracking. Don't auto-reconcile when money doesn't match. Investigate first.
  • Track every delivery or payment attempt separately, including failures and reasons, so your support and logistics teams have full visibility and your fraud detection has audit data.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles