-
Notifications
You must be signed in to change notification settings - Fork 0
Description
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
-
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
-
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)
-
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
-
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
- ✅ Real-time tracking page (
-
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
-
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)
-
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)
-
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)
-
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
-
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:
- [Phase 1] Order Processing API #24 (Order Processing API) - Requires Order model with shippingStatus
- [Phase 1.5] Cash on Delivery (COD) Option #30 (COD Option) - COD orders use Pathao for cash collection
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
Type
Projects
Status