import axios from "axios"; export class PathaoAdapter { courierName = "pathao"; clientId; clientSecret; username; password; storeId; baseUrl; accessToken = null; tokenExpiresAt = 0; // In-memory caches for fast sub-second routing cityCache = null; zoneCache = new Map(); areaCache = new Map(); constructor(clientId, clientSecret, username, password, storeId = "", baseUrl = "https://api-hermes.pathao.com") { this.clientId = clientId; this.clientSecret = clientSecret; this.username = username; this.password = password; this.storeId = storeId || process.env.PATHAO_STORE_ID || ""; this.baseUrl = baseUrl || "https://api-hermes.pathao.com"; } isConfigured() { return Boolean(this.clientId && this.clientSecret); } async getAuthToken() { const now = Date.now(); if (this.accessToken && this.tokenExpiresAt > now + 60000) { return this.accessToken; } const response = await axios.post(`${this.baseUrl}/aladdin/api/v1/issue-token`, { client_id: this.clientId, client_secret: this.clientSecret, username: this.username, password: this.password, grant_type: "password", }); this.accessToken = response.data.access_token; this.tokenExpiresAt = now + (response.data.expires_in || 3600) * 1000; return this.accessToken; } async getClient() { const token = await this.getAuthToken(); return axios.create({ baseURL: this.baseUrl, headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", }, timeout: 12000, }); } /** * Dynamically resolve merchant store_id from Pathao portal if missing or default */ async resolveStoreId() { if (this.storeId && Number(this.storeId) > 1) { return Number(this.storeId); } try { const client = await this.getClient(); const res = await client.get("/aladdin/api/v1/stores"); const stores = res.data?.data?.data || []; const activeStore = stores.find((s) => s.is_active) || stores[0]; if (activeStore && activeStore.store_id) { this.storeId = String(activeStore.store_id); return Number(activeStore.store_id); } } catch (err) { console.warn("Pathao store resolution notice:", err.message); } return Number(this.storeId) || 0; // Aura Merchant registered store id } /** * DeepMind Intelligent Spatial Hierarchy Resolver for Pathao * Eliminates 422 Unprocessable Entity by matching address tokens against live Pathao geo catalog */ async resolveLocation(address, req) { const client = await this.getClient(); if (req?.delivery_area_id && typeof req.delivery_area_id === "number") { return { cityId: 1, zoneId: Number(req.delivery_area_id) }; } const addrLower = (address || "").toLowerCase(); // 1. Resolve City let cityId = 1; // Default Dhaka city ID try { if (!this.cityCache) { const cRes = await client.get("/aladdin/api/v1/countries/1/city-list"); this.cityCache = cRes.data?.data?.data || []; } for (const c of this.cityCache || []) { const cName = String(c.city_name || "").toLowerCase().trim(); if (cName && cName !== "dhaka" && addrLower.includes(cName)) { cityId = c.city_id; break; } } } catch (err) { cityId = 1; } // 2. Resolve Zone for City let zoneId = cityId === 1 ? 62 : 1; // Default 62 (Dhanmondi, Dhaka) try { if (!this.zoneCache.has(cityId)) { const zRes = await client.get(`/aladdin/api/v1/cities/${cityId}/zone-list`); const zones = zRes.data?.data?.data || []; this.zoneCache.set(cityId, zones); } const zones = this.zoneCache.get(cityId) || []; const sortedZones = [...zones].sort((a, b) => b.zone_name.length - a.zone_name.length); const cleanAddrNoCity = addrLower .replace(/bangladesh|dhaka/gi, " ") .replace(/[,.-]/g, " ") .split(/\s+/) .filter(Boolean) .join(" "); let matchedZone = null; for (const z of sortedZones) { const zName = z.zone_name.toLowerCase().trim(); if (zName === "dhaka" || zName === "dhaka city") continue; if (cleanAddrNoCity.includes(zName)) { matchedZone = z; break; } } if (!matchedZone) { for (const z of sortedZones) { const words = z.zone_name .toLowerCase() .split(/\s+/) .filter((w) => w.length >= 4 && !["road", "block", "sector", "area"].includes(w)); if (words.some((w) => cleanAddrNoCity.includes(w))) { matchedZone = z; break; } } } if (matchedZone) { zoneId = matchedZone.zone_id; } else if (zones.length > 0) { // Safe fallback zone for that city zoneId = zones[0].zone_id; } } catch (err) { console.warn("Pathao zone resolution notice:", err.message); } // 3. Resolve Area for Zone let areaId = undefined; try { if (!this.areaCache.has(zoneId)) { const aRes = await client.get(`/aladdin/api/v1/zones/${zoneId}/area-list`); const areas = aRes.data?.data?.data || []; this.areaCache.set(zoneId, areas); } const areas = this.areaCache.get(zoneId) || []; if (areas.length > 0) { for (const a of areas) { const aName = a.area_name.toLowerCase().trim(); if (aName.length >= 4 && addrLower.includes(aName)) { areaId = a.area_id; break; } } if (!areaId) { areaId = areas[0].area_id; } } } catch (err) { // Area is optional in Pathao } return { cityId, zoneId, areaId }; } async createParcel(req) { if (!this.isConfigured()) { throw new Error("Pathao credentials are not configured."); } const client = await this.getClient(); const resolvedStoreId = await this.resolveStoreId(); const { cityId, zoneId, areaId } = await this.resolveLocation(req.recipient_address, req); const cleanPhone = req.recipient_phone.replace(/\D/g, ""); const formattedPhone = cleanPhone.length > 11 ? cleanPhone.slice(-11) : cleanPhone; const payload = { store_id: resolvedStoreId, merchant_order_id: req.invoice, recipient_name: (req.recipient_name || "Customer").trim().slice(0, 100), recipient_phone: formattedPhone, recipient_address: (req.recipient_address || "").trim().slice(0, 220), delivery_type: 48, // 48 for Normal Delivery, 12 for On Demand Delivery item_type: 2, // 1 for Document, 2 for Parcel special_instruction: (req.note || "Aura AI automated dispatch").slice(0, 250), item_quantity: 1, item_weight: Math.max(0.5, Math.min(10, Number(req.item_weight || 0.5))), amount_to_collect: Math.round(Number(req.cod_amount || 0)), item_description: (req.item_type || "Standard parcel").slice(0, 250), }; // Pathao docs: "do not send a null value. If not included in the request payload, // then our system will populate it automatically based on recipient_address" if (cityId && zoneId && zoneId > 1) { payload.recipient_city = Number(cityId); payload.recipient_zone = Number(zoneId); if (areaId && areaId > 1) { payload.recipient_area = Number(areaId); } } const response = await client.post("/aladdin/api/v1/orders", payload); const data = response.data.data; return { success: true, courier: "pathao", tracking_code: data.consignment_id, consignment_id: data.consignment_id, invoice: req.invoice, status: data.order_status || "Pending", cod_amount: req.cod_amount, delivery_fee: data.delivery_fee, created_at: new Date().toISOString(), raw_response: response.data, }; } async trackParcel(trackingCode) { if (!this.isConfigured()) { throw new Error("Pathao credentials are not configured."); } const client = await this.getClient(); const response = await client.get(`/aladdin/api/v1/orders/${encodeURIComponent(trackingCode)}/info`); const data = response.data.data; return { success: true, courier: "pathao", tracking_code: trackingCode, status: data.order_status || "unknown", updated_at: data.updated_at || new Date().toISOString(), raw_response: response.data, }; } async getBalance() { return { success: true, courier: "pathao", current_balance: 0, raw_response: { message: "Pathao payout balance managed via merchant portal" }, }; } async getLocations(cityName) { const client = await this.getClient(); const response = await client.get("/aladdin/api/v1/cities"); const cities = response.data.data.data || []; return { success: true, courier: "pathao", locations: cities.map((c) => ({ city_id: c.city_id, city_name: c.city_name, })), }; } }