Skip to content

[Phase 1.5] Pathao Courier Integration #32

@syed-reza98

Description

@syed-reza98

Priority: P1

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

Overview

Integrate Pathao Courier API for automated shipping label generation, real-time order tracking, and delivery status webhooks. Pathao is Bangladesh's leading logistics provider with 99% coverage in Dhaka and 95% nationwide, offering same-day delivery in metro areas and 2-5 day delivery elsewhere.

Context

Pathao Courier integration is essential for Bangladesh e-commerce:

  • Market Share: 40% of e-commerce logistics (2024)
  • Coverage: 64 districts, 490+ upazilas (sub-districts)
  • Delivery Speed: Same-day (Dhaka), 1-2 days (metro), 2-5 days (nationwide)
  • Rate Structure: Zone-based (Dhaka city, metro, outside metro)
  • Tracking: Real-time GPS tracking via app/SMS
  • COD Support: Cash collection with 2-day settlement
  • API Reliability: 99.5% uptime, webhook delivery 98%

Acceptance Criteria

  1. Pathao API Authentication

    • ✅ OAuth 2.0 token generation (client_id, client_secret, refresh_token)
    • ✅ Token caching with 1-hour expiry
    • ✅ Automatic token refresh before expiry
    • ✅ Multi-tenant: Store separate credentials per organization
  2. Rate Calculator

    • ✅ Calculate shipping cost by zone (Dhaka/metro/outside)
    • ✅ Weight-based pricing (0-1kg, 1-2kg, 2-5kg, 5-10kg, 10+kg)
    • ✅ Item type (document, parcel, fragile)
    • ✅ Real-time rate API call on checkout
    • ✅ Display estimated delivery time (1-5 days)
  3. Order Creation

    • ✅ Auto-create Pathao consignment on order fulfillment
    • ✅ Generate shipping label PDF (A4 printable)
    • ✅ Store consignment_id in Order.trackingNumber
    • ✅ Send tracking link to customer via SMS/email
  4. Tracking Integration

    • ✅ Real-time tracking page (/track/[consignmentId])
    • ✅ Display delivery status (picked_up, in_transit, out_for_delivery, delivered)
    • ✅ Show GPS location on map (if available)
    • ✅ Delivery person name and phone number
  5. Webhook Handler

    • ✅ Receive status updates from Pathao (webhook endpoint)
    • ✅ Update Order.shippingStatus automatically
    • ✅ Send customer notifications on status change
    • ✅ Mark order as DELIVERED on delivery confirmation
  6. Bulk Order Upload

    • ✅ CSV import for bulk consignment creation
    • ✅ Validate addresses against Pathao zones
    • ✅ Batch create up to 100 orders in single API call
    • ✅ Download printable shipping labels (PDF)
  7. Merchant Dashboard

    • ✅ View all Pathao shipments (pending, in_transit, delivered)
    • ✅ Print shipping labels
    • ✅ Request pickup from Pathao
    • ✅ Track delivery performance (on-time rate, failed deliveries)
  8. Address Validation

    • ✅ Validate customer address against Pathao coverage zones
    • ✅ Auto-suggest city/area from Pathao zone list
    • ✅ Warn if address is outside coverage (offer alternative courier)
  9. COD Collection

    • ✅ Pathao collects COD amount on delivery
    • ✅ Reconciliation report (daily/weekly)
    • ✅ Automatic settlement to merchant bank account (2 business days)
    • ✅ Track pending collections dashboard
  10. Error Handling

    • ✅ Handle Pathao API errors gracefully (rate limit, downtime)
    • ✅ Fallback to manual fulfillment if API fails
    • ✅ Retry failed webhook deliveries (exponential backoff)
    • ✅ Admin notifications for critical errors

Technical Implementation

1. Pathao Service Class

// src/lib/services/pathao.service.ts
import { prisma } from '@/lib/prisma';

interface PathaoConfig {
  clientId: string;
  clientSecret: string;
  refreshToken: string;
  baseUrl: string; // https://hermes-api.p-stageenv.xyz (sandbox) or https://api-hermes.pathao.com (production)
}

interface PathaoAddress {
  name: string;
  phone: string;
  address: string;
  city_id: number;
  zone_id: number;
  area_id: number;
}

interface CreateConsignmentParams {
  merchant_order_id: string;
  recipient: PathaoAddress;
  item: {
    item_type: 1 | 2 | 3; // 1=Document, 2=Parcel, 3=Fragile
    item_quantity: number;
    item_weight: number; // in kg
    amount_to_collect: number; // COD amount (0 for prepaid)
    item_description: string;
  };
  pickup_store_id: number;
}

interface ConsignmentResponse {
  consignment_id: string;
  merchant_order_id: string;
  order_status: string;
  tracking_url: string;
}

export class PathaoService {
  private config: PathaoConfig;
  private accessToken: string | null = null;
  private tokenExpiry: Date | null = null;

  constructor(config: PathaoConfig) {
    this.config = config;
  }

  /**
   * Generate OAuth 2.0 access token
   */
  async authenticate(): Promise<string> {
    // Check cached token
    if (this.accessToken && this.tokenExpiry && new Date() < this.tokenExpiry) {
      return this.accessToken;
    }

    const response = await fetch(`${this.config.baseUrl}/api/v1/issue-token`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        client_id: this.config.clientId,
        client_secret: this.config.clientSecret,
        grant_type: 'refresh_token',
        refresh_token: this.config.refreshToken,
      }),
    });

    if (!response.ok) {
      throw new Error(`Pathao authentication failed: ${response.statusText}`);
    }

    const data = await response.json();
    this.accessToken = data.access_token;
    this.tokenExpiry = new Date(Date.now() + 3600 * 1000); // 1 hour

    return this.accessToken;
  }

  /**
   * Get list of cities
   */
  async getCities(): Promise<Array<{ city_id: number; city_name: string }>> {
    const token = await this.authenticate();

    const response = await fetch(`${this.config.baseUrl}/api/v1/cities`, {
      headers: { Authorization: `Bearer ${token}` },
    });

    if (!response.ok) {
      throw new Error(`Failed to fetch cities: ${response.statusText}`);
    }

    const data = await response.json();
    return data.data.cities;
  }

  /**
   * Get zones for a city
   */
  async getZones(cityId: number): Promise<Array<{ zone_id: number; zone_name: string }>> {
    const token = await this.authenticate();

    const response = await fetch(`${this.config.baseUrl}/api/v1/cities/${cityId}/zones`, {
      headers: { Authorization: `Bearer ${token}` },
    });

    if (!response.ok) {
      throw new Error(`Failed to fetch zones: ${response.statusText}`);
    }

    const data = await response.json();
    return data.data.zones;
  }

  /**
   * Get areas for a zone
   */
  async getAreas(zoneId: number): Promise<Array<{ area_id: number; area_name: string }>> {
    const token = await this.authenticate();

    const response = await fetch(`${this.config.baseUrl}/api/v1/zones/${zoneId}/areas`, {
      headers: { Authorization: `Bearer ${token}` },
    });

    if (!response.ok) {
      throw new Error(`Failed to fetch areas: ${response.statusText}`);
    }

    const data = await response.json();
    return data.data.areas;
  }

  /**
   * Calculate delivery price
   */
  async calculatePrice(params: {
    storeId: number;
    itemType: 1 | 2 | 3;
    deliveryType: 48 | 12; // 48=Normal, 12=On-demand
    itemWeight: number;
    recipientCity: number;
    recipientZone: number;
  }): Promise<{ price: number; estimatedDays: number }> {
    const token = await this.authenticate();

    const response = await fetch(`${this.config.baseUrl}/api/v1/merchant/price-plan`, {
      method: 'POST',
      headers: {
        Authorization: `Bearer ${token}`,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        store_id: params.storeId,
        item_type: params.itemType,
        delivery_type: params.deliveryType,
        item_weight: params.itemWeight,
        recipient_city: params.recipientCity,
        recipient_zone: params.recipientZone,
      }),
    });

    if (!response.ok) {
      throw new Error(`Failed to calculate price: ${response.statusText}`);
    }

    const data = await response.json();
    
    return {
      price: data.data.price, // in BDT
      estimatedDays: data.data.estimated_delivery_days || 3,
    };
  }

  /**
   * Create consignment (parcel)
   */
  async createConsignment(params: CreateConsignmentParams): Promise<ConsignmentResponse> {
    const token = await this.authenticate();

    const response = await fetch(`${this.config.baseUrl}/api/v1/orders`, {
      method: 'POST',
      headers: {
        Authorization: `Bearer ${token}`,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        store_id: params.pickup_store_id,
        merchant_order_id: params.merchant_order_id,
        recipient_name: params.recipient.name,
        recipient_phone: params.recipient.phone,
        recipient_address: params.recipient.address,
        recipient_city: params.recipient.city_id,
        recipient_zone: params.recipient.zone_id,
        recipient_area: params.recipient.area_id,
        delivery_type: 48, // Normal delivery
        item_type: params.item.item_type,
        item_quantity: params.item.item_quantity,
        item_weight: params.item.item_weight,
        amount_to_collect: params.item.amount_to_collect,
        item_description: params.item.item_description,
      }),
    });

    if (!response.ok) {
      const error = await response.json();
      throw new Error(`Failed to create consignment: ${error.message}`);
    }

    const data = await response.json();
    
    return {
      consignment_id: data.data.consignment_id,
      merchant_order_id: data.data.merchant_order_id,
      order_status: data.data.order_status,
      tracking_url: `https://pathao.com/track/${data.data.consignment_id}`,
    };
  }

  /**
   * Track consignment
   */
  async trackConsignment(consignmentId: string): Promise<{
    status: string;
    statusMessage: string;
    pickupTime: Date | null;
    deliveryTime: Date | null;
    deliveryPerson: { name: string; phone: string } | null;
  }> {
    const token = await this.authenticate();

    const response = await fetch(
      `${this.config.baseUrl}/api/v1/orders/${consignmentId}`,
      { headers: { Authorization: `Bearer ${token}` } }
    );

    if (!response.ok) {
      throw new Error(`Failed to track consignment: ${response.statusText}`);
    }

    const data = await response.json();
    const order = data.data;

    return {
      status: order.order_status,
      statusMessage: order.order_status_message,
      pickupTime: order.pickup_time ? new Date(order.pickup_time) : null,
      deliveryTime: order.delivery_time ? new Date(order.delivery_time) : null,
      deliveryPerson: order.rider
        ? { name: order.rider.name, phone: order.rider.phone }
        : null,
    };
  }

  /**
   * Generate shipping label PDF
   */
  async getShippingLabel(consignmentId: string): Promise<Buffer> {
    const token = await this.authenticate();

    const response = await fetch(
      `${this.config.baseUrl}/api/v1/orders/${consignmentId}/label`,
      { headers: { Authorization: `Bearer ${token}` } }
    );

    if (!response.ok) {
      throw new Error(`Failed to get shipping label: ${response.statusText}`);
    }

    return Buffer.from(await response.arrayBuffer());
  }
}

// Singleton instance per organization
const pathaoInstances = new Map<string, PathaoService>();

export function getPathaoService(organizationId: string): PathaoService {
  if (!pathaoInstances.has(organizationId)) {
    // Fetch credentials from database
    const config: PathaoConfig = {
      clientId: process.env.PATHAO_CLIENT_ID!,
      clientSecret: process.env.PATHAO_CLIENT_SECRET!,
      refreshToken: process.env.PATHAO_REFRESH_TOKEN!,
      baseUrl:
        process.env.PATHAO_MODE === 'production'
          ? 'https://api-hermes.pathao.com'
          : 'https://hermes-api.p-stageenv.xyz',
    };

    pathaoInstances.set(organizationId, new PathaoService(config));
  }

  return pathaoInstances.get(organizationId)!;
}

2. Create Consignment API

// src/app/api/shipping/pathao/create/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { prisma } from '@/lib/prisma';
import { getPathaoService } from '@/lib/services/pathao.service';

export async function POST(req: NextRequest) {
  const session = await getServerSession(authOptions);
  if (!session) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
  }

  const { orderId, pickupStoreId } = await req.json();

  // Fetch order
  const order = await prisma.order.findUnique({
    where: { id: orderId },
    include: { items: true },
  });

  if (!order) {
    return NextResponse.json({ error: 'Order not found' }, { status: 404 });
  }

  if (order.shippingStatus === 'SHIPPED') {
    return NextResponse.json({ error: 'Order already shipped' }, { status: 400 });
  }

  // Parse shipping address
  const address = order.shippingAddress as any;

  try {
    const pathao = getPathaoService(order.storeId);

    // Create consignment
    const consignment = await pathao.createConsignment({
      merchant_order_id: order.id,
      recipient: {
        name: address.name,
        phone: address.phone,
        address: `${address.line1}, ${address.line2 || ''}, ${address.city}`,
        city_id: address.pathao_city_id,
        zone_id: address.pathao_zone_id,
        area_id: address.pathao_area_id,
      },
      item: {
        item_type: 2, // Parcel
        item_quantity: order.items.length,
        item_weight: calculateTotalWeight(order.items),
        amount_to_collect: order.paymentMethod === 'COD' ? order.totalAmount : 0,
        item_description: order.items.map((item) => item.productName).join(', '),
      },
      pickup_store_id: pickupStoreId,
    });

    // Update order
    await prisma.order.update({
      where: { id: orderId },
      data: {
        trackingNumber: consignment.consignment_id,
        shippingStatus: 'SHIPPED',
        shippedAt: new Date(),
      },
    });

    return NextResponse.json({
      success: true,
      consignment_id: consignment.consignment_id,
      tracking_url: consignment.tracking_url,
    });
  } catch (error: any) {
    console.error('Pathao consignment creation error:', error);
    return NextResponse.json(
      { error: error.message || 'Failed to create consignment' },
      { status: 500 }
    );
  }
}

function calculateTotalWeight(items: any[]): number {
  // Assume each item is 0.5kg for now
  return items.reduce((total, item) => total + item.quantity * 0.5, 0);
}

3. Webhook Handler

// src/app/api/webhooks/pathao/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
import { sendOrderStatusEmail } from '@/lib/email/order-status';

export async function POST(req: NextRequest) {
  const payload = await req.json();

  const { consignment_id, order_status, delivery_time } = payload;

  // Find order by tracking number
  const order = await prisma.order.findFirst({
    where: { trackingNumber: consignment_id },
  });

  if (!order) {
    return NextResponse.json({ error: 'Order not found' }, { status: 404 });
  }

  // Map Pathao status to our status
  let newStatus: string = order.shippingStatus;
  let orderStatus: string = order.status;

  switch (order_status) {
    case 'Pickup_Requested':
      newStatus = 'PROCESSING';
      break;
    case 'Pickup_Successful':
      newStatus = 'SHIPPED';
      break;
    case 'On_The_Way':
      newStatus = 'IN_TRANSIT';
      break;
    case 'Delivered':
      newStatus = 'DELIVERED';
      orderStatus = order.paymentMethod === 'COD' ? 'COMPLETED' : order.status;
      break;
    case 'Delivery_Failed':
      newStatus = 'FAILED';
      break;
  }

  // Update order
  await prisma.order.update({
    where: { id: order.id },
    data: {
      shippingStatus: newStatus,
      status: orderStatus,
      deliveredAt: order_status === 'Delivered' ? new Date(delivery_time) : undefined,
    },
  });

  // Send email notification
  await sendOrderStatusEmail({
    to: order.customerEmail,
    orderId: order.id,
    status: newStatus,
    trackingUrl: `https://pathao.com/track/${consignment_id}`,
  });

  return NextResponse.json({ success: true });
}

4. Tracking Page Component

// src/app/track/[consignmentId]/page.tsx
import { notFound } from 'next/navigation';
import { getPathaoService } from '@/lib/services/pathao.service';
import { prisma } from '@/lib/prisma';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Package, MapPin, Phone, Clock } from 'lucide-react';

interface TrackingPageProps {
  params: { consignmentId: string };
}

export default async function TrackingPage({ params }: TrackingPageProps) {
  // Find order
  const order = await prisma.order.findFirst({
    where: { trackingNumber: params.consignmentId },
  });

  if (!order) {
    notFound();
  }

  // Track consignment
  const pathao = getPathaoService(order.storeId);
  const tracking = await pathao.trackConsignment(params.consignmentId);

  return (
    <div className="container max-w-2xl py-8">
      <h1 className="text-3xl font-bold mb-6">Track Your Order</h1>

      <Card>
        <CardHeader>
          <CardTitle className="flex items-center gap-2">
            <Package className="h-5 w-5" />
            Order #{order.id.slice(0, 8)}
          </CardTitle>
        </CardHeader>
        <CardContent className="space-y-4">
          <div className="flex justify-between items-center">
            <span className="text-muted-foreground">Status</span>
            <Badge variant={tracking.status === 'Delivered' ? 'success' : 'default'}>
              {tracking.statusMessage}
            </Badge>
          </div>

          {tracking.deliveryPerson && (
            <div className="flex items-start gap-2">
              <Phone className="h-5 w-5 mt-0.5 text-muted-foreground" />
              <div>
                <p className="font-medium">Delivery Person</p>
                <p className="text-sm text-muted-foreground">
                  {tracking.deliveryPerson.name} - {tracking.deliveryPerson.phone}
                </p>
              </div>
            </div>
          )}

          {tracking.pickupTime && (
            <div className="flex items-start gap-2">
              <Clock className="h-5 w-5 mt-0.5 text-muted-foreground" />
              <div>
                <p className="font-medium">Picked Up</p>
                <p className="text-sm text-muted-foreground">
                  {tracking.pickupTime.toLocaleString()}
                </p>
              </div>
            </div>
          )}

          {tracking.deliveryTime && (
            <div className="flex items-start gap-2">
              <MapPin className="h-5 w-5 mt-0.5 text-muted-foreground" />
              <div>
                <p className="font-medium">Delivered</p>
                <p className="text-sm text-muted-foreground">
                  {tracking.deliveryTime.toLocaleString()}
                </p>
              </div>
            </div>
          )}

          <div className="pt-4 border-t">
            <a
              href={`https://pathao.com/track/${params.consignmentId}`}
              target="_blank"
              rel="noopener noreferrer"
              className="text-primary hover:underline"
            >
              View on Pathao Website 
            </a>
          </div>
        </CardContent>
      </Card>
    </div>
  );
}

Dependencies

Blocks:

  • None (can be deployed independently)

Blocked By:

Enhances:

  • All order fulfillment workflows
  • Customer tracking experience

References

  • Pathao API Docs: https://pathao.com/courier-api-docs
  • Gap Analysis: docs/research/codebase_feature_gap_analysis.md (Order.trackingNumber, shippingStatus)
  • Implementation Plan: docs/research/implementation_plan.md (Shipping integration, webhook handlers)
  • Marketing Automation V2: docs/research/MARKETING_AUTOMATION_V2.md (Pathao 40% market share, same-day delivery Dhaka)

Testing Checklist

Authentication

  • OAuth token generation succeeds
  • Token caching works (no repeated auth calls)
  • Token refresh before expiry
  • Multi-tenant credentials isolation

Rate Calculator

  • Dhaka city zone returns correct rate
  • Metro zone rate calculation
  • Outside metro zone rate calculation
  • Weight-based pricing (0-1kg, 1-2kg, 2-5kg)

Order Creation

  • Create consignment for prepaid order
  • Create consignment for COD order (amount_to_collect set)
  • Tracking number stored in Order.trackingNumber
  • Shipping label PDF generated

Tracking

  • Tracking page displays status correctly
  • Delivery person info shown when available
  • Pickup/delivery timestamps displayed
  • Link to Pathao website works

Webhook Integration

  • Webhook updates order status (Pickup_Successful → SHIPPED)
  • Delivered webhook marks order as COMPLETED (COD)
  • Failed delivery restores inventory
  • Customer notification emails sent

Bulk Upload

  • CSV import validates addresses
  • Batch create 100 orders successfully
  • Download all shipping labels as PDF
  • Error handling for invalid addresses

Current Status

Not Started - Awaiting Phase 1 completion

Success Metrics

Metric Target Measurement
Pathao Adoption >80% of orders Track shipping provider distribution
On-Time Delivery >90% Orders delivered within estimated time
Failed Delivery Rate <5% (Failed deliveries / Total shipments) × 100
Webhook Delivery Rate >95% Track webhook success rate
Customer Satisfaction >4.5/5 Post-delivery survey ratings

Implementation Notes: Pathao integration is critical for Bangladesh market. 40% market share, 99% Dhaka coverage, and reliable same-day delivery make it the preferred choice. OAuth token caching is essential (avoid rate limits). Webhook reliability is 98%, but implement retry logic for the 2% failures. COD cash collection settlement takes 2 business days - factor into cash flow planning. Consider multi-courier support (Steadfast, RedX) as fallback if Pathao API is down.

Metadata

Metadata

Assignees

Labels

enhancementNew feature or request

Type

No type

Projects

Status

Backlog

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions