├── .npmrc ├── .gitignore ├── eng.traineddata ├── prisma ├── migrations │ ├── migration_lock.toml │ ├── 20250518145647_edited │ │ └── migration.sql │ ├── 20250518145040_added │ │ └── migration.sql │ └── 20250517191400_init │ │ └── migration.sql └── schema.prisma ├── render.yaml ├── tsconfig.json ├── src ├── types │ └── pdf-parse.d.ts ├── utils │ ├── prisma.ts │ ├── logger.ts │ ├── errorHandler.ts │ └── migrateApiKeys.ts ├── routes │ ├── verifyTelebirrRoute.ts │ ├── verifyCBERoute.ts │ ├── verifyDashenRoute.ts │ ├── adminRoute.ts │ ├── verifyCBEBirrRoute.ts │ └── verifyAbyssiniaRoute.ts ├── middleware │ ├── apiKeyAuth.ts │ └── requestLogger.ts ├── index.ts └── services │ ├── verifyCBE.ts │ ├── verifyImage.ts │ ├── verifyAbyssinia.ts │ ├── verifyCBEBirr.ts │ ├── verifyDashen.ts │ └── verifyTelebirr.ts ├── LICENSE ├── package.json ├── Dockerfile ├── CHANGELOG.md ├── README.md └── payment-verification-api.postman_collection.json /.npmrc: -------------------------------------------------------------------------------- 1 | package-manager=pnpm@8.6.12 -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | .env 3 | dist/ 4 | logs/ -------------------------------------------------------------------------------- /eng.traineddata: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Vixen878/verifier-api/HEAD/eng.traineddata -------------------------------------------------------------------------------- /prisma/migrations/migration_lock.toml: -------------------------------------------------------------------------------- 1 | # Please do not edit this file manually 2 | # It should be added in your version-control system (e.g., Git) 3 | provider = "mysql" 4 | -------------------------------------------------------------------------------- /render.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | - type: web 3 | name: verifier-api 4 | env: node 5 | buildCommand: pnpm install && pnpm build 6 | startCommand: pnpm start 7 | envVars: 8 | - key: NODE_ENV 9 | value: production 10 | - key: LOG_LEVEL 11 | value: info 12 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2020", 4 | "module": "commonjs", 5 | "moduleResolution": "node", 6 | "rootDir": "./src", 7 | "outDir": "./dist", 8 | "strict": true, 9 | "esModuleInterop": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "skipLibCheck": true, 12 | "typeRoots": ["./node_modules/@types", "./src/types"] 13 | }, 14 | "include": ["src/**/*"] 15 | } 16 | -------------------------------------------------------------------------------- /prisma/migrations/20250518145647_edited/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - Added the required column `firstName` to the `User` table without a default value. This is not possible if the table is not empty. 5 | 6 | */ 7 | -- AlterTable 8 | ALTER TABLE `user` ADD COLUMN `firstName` VARCHAR(191) NOT NULL, 9 | ADD COLUMN `isAdmin` BOOLEAN NOT NULL DEFAULT false, 10 | ADD COLUMN `lastName` VARCHAR(191) NULL, 11 | ADD COLUMN `photoUrl` VARCHAR(191) NULL; 12 | -------------------------------------------------------------------------------- /prisma/migrations/20250518145040_added/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE `apikey` ADD COLUMN `userId` VARCHAR(191) NULL; 3 | 4 | -- CreateTable 5 | CREATE TABLE `User` ( 6 | `id` VARCHAR(191) NOT NULL, 7 | `telegramId` VARCHAR(191) NOT NULL, 8 | `username` VARCHAR(191) NULL, 9 | `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), 10 | 11 | UNIQUE INDEX `User_telegramId_key`(`telegramId`), 12 | PRIMARY KEY (`id`) 13 | ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; 14 | 15 | -- AddForeignKey 16 | ALTER TABLE `ApiKey` ADD CONSTRAINT `ApiKey_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE SET NULL ON UPDATE CASCADE; 17 | -------------------------------------------------------------------------------- /src/types/pdf-parse.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'pdf-parse' { 2 | interface PDFInfo { 3 | pdfInfo: { 4 | PDFFormatVersion: string; 5 | IsAcroFormPresent: boolean; 6 | IsXFAPresent: boolean; 7 | Title: string; 8 | Author: string; 9 | Creator: string; 10 | Producer: string; 11 | CreationDate: string; 12 | ModDate: string; 13 | Tagged: boolean; 14 | Form: string; 15 | Pages: number; 16 | }; 17 | metadata?: any; 18 | text: string; 19 | numrender: number; 20 | version: string; 21 | } 22 | 23 | interface Options { 24 | pagerender?: (pageData: any) => string; 25 | max?: number; 26 | version?: string; 27 | } 28 | 29 | function pdf(dataBuffer: Buffer, options?: Options): Promise; 30 | 31 | export = pdf; 32 | } 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Leul Zenebe 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /prisma/migrations/20250517191400_init/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE `ApiKey` ( 3 | `id` VARCHAR(191) NOT NULL, 4 | `key` VARCHAR(191) NOT NULL, 5 | `owner` VARCHAR(191) NOT NULL, 6 | `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), 7 | `lastUsed` DATETIME(3) NULL, 8 | `usageCount` INTEGER NOT NULL DEFAULT 0, 9 | `isActive` BOOLEAN NOT NULL DEFAULT true, 10 | 11 | UNIQUE INDEX `ApiKey_key_key`(`key`), 12 | PRIMARY KEY (`id`) 13 | ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; 14 | 15 | -- CreateTable 16 | CREATE TABLE `UsageLog` ( 17 | `id` INTEGER NOT NULL AUTO_INCREMENT, 18 | `apiKeyId` VARCHAR(191) NOT NULL, 19 | `endpoint` VARCHAR(191) NOT NULL, 20 | `method` VARCHAR(191) NOT NULL, 21 | `statusCode` INTEGER NOT NULL, 22 | `responseTime` INTEGER NOT NULL, 23 | `ip` VARCHAR(191) NOT NULL, 24 | `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), 25 | 26 | PRIMARY KEY (`id`) 27 | ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; 28 | 29 | -- AddForeignKey 30 | ALTER TABLE `UsageLog` ADD CONSTRAINT `UsageLog_apiKeyId_fkey` FOREIGN KEY (`apiKeyId`) REFERENCES `ApiKey`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; 31 | -------------------------------------------------------------------------------- /src/utils/prisma.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from '@prisma/client'; 2 | import logger from './logger'; 3 | 4 | // Declare global variable for PrismaClient 5 | declare global { 6 | var prisma: PrismaClient | undefined; 7 | } 8 | 9 | // Create a singleton Prisma client that can be shared across files 10 | export const prisma = global.prisma || new PrismaClient({ 11 | log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'], 12 | }); 13 | 14 | // Prevent multiple instances during hot reloading in development 15 | if (process.env.NODE_ENV !== 'production') global.prisma = prisma; 16 | 17 | // Handle Prisma connection events 18 | // Handle Prisma connection events with proper type assertions 19 | (prisma as any).$on('query', (e: { query: string; duration: number }) => { 20 | if (process.env.NODE_ENV === 'development') { 21 | logger.debug(`Query: ${e.query}`); 22 | logger.debug(`Duration: ${e.duration}ms`); 23 | } 24 | }); 25 | 26 | (prisma as any).$on('error', (e: Error) => { 27 | logger.error('Prisma error:', e); 28 | }); 29 | 30 | // Graceful shutdown function to close Prisma connections 31 | export const disconnectPrisma = async () => { 32 | await prisma.$disconnect(); 33 | logger.info('Disconnected from database'); 34 | }; -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "verifier-api", 3 | "version": "2.1.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "dev": "nodemon --watch src --exec ts-node src/index.ts", 8 | "build": "prisma generate && tsc", 9 | "start": "node dist/index.js", 10 | "migrate-api-keys": "ts-node src/utils/migrateApiKeys.ts" 11 | }, 12 | "keywords": [], 13 | "author": "Leul Zenebe", 14 | "license": "ISC", 15 | "dependencies": { 16 | "@mistralai/mistralai": "^1.6.0", 17 | "@prisma/client": "^6.8.2", 18 | "@types/multer": "^1.4.12", 19 | "axios": "^1.9.0", 20 | "body-parser": "^2.2.0", 21 | "cheerio": "^1.0.0", 22 | "cors": "^2.8.5", 23 | "dotenv": "^16.5.0", 24 | "express": "^5.1.0", 25 | "multer": "1.4.5-lts.2", 26 | "pdf-parse": "^1.1.1", 27 | "prisma": "^6.8.2", 28 | "puppeteer": "^24.8.2", 29 | "tesseract.js": "^6.0.1", 30 | "uuid": "^11.1.0", 31 | "winston": "^3.17.0", 32 | "winston-daily-rotate-file": "^5.0.0" 33 | }, 34 | "devDependencies": { 35 | "@types/body-parser": "^1.19.5", 36 | "@types/cors": "^2.8.18", 37 | "@types/express": "^5.0.1", 38 | "@types/node": "^22.15.17", 39 | "nodemon": "^3.1.10", 40 | "ts-node": "^10.9.2", 41 | "typescript": "^5.8.3" 42 | } 43 | } -------------------------------------------------------------------------------- /src/routes/verifyTelebirrRoute.ts: -------------------------------------------------------------------------------- 1 | import { Router, Request, Response } from 'express'; 2 | import { verifyTelebirr } from '../services/verifyTelebirr'; 3 | import logger from '../utils/logger'; 4 | 5 | const router = Router(); 6 | 7 | interface VerifyTelebirrRequestBody { 8 | reference: string; 9 | } 10 | 11 | router.post<{}, {}, VerifyTelebirrRequestBody>( 12 | '/', 13 | async (req: Request<{}, {}, VerifyTelebirrRequestBody>, res: Response): Promise => { 14 | const { reference } = req.body; 15 | 16 | if (!reference) { 17 | res.status(400).json({ success: false, error: 'Missing reference.' }); 18 | return; 19 | } 20 | 21 | try { 22 | const result = await verifyTelebirr(reference); 23 | if (!result) { 24 | res.status(404).json({ success: false, error: 'Receipt not found or could not be processed.' }); 25 | return; 26 | } 27 | res.json({ success: true, data: result }); 28 | } catch (err) { 29 | logger.error('Telebirr verification error:', err); 30 | res.status(500).json({ 31 | success: false, 32 | error: 'Server error verifying Telebirr receipt.', 33 | message: err instanceof Error ? err.message : 'Unknown error' 34 | }); 35 | } 36 | } 37 | ); 38 | 39 | export default router; 40 | -------------------------------------------------------------------------------- /src/routes/verifyCBERoute.ts: -------------------------------------------------------------------------------- 1 | import { Router, Request, Response } from 'express'; 2 | import { verifyCBE } from '../services/verifyCBE'; 3 | import logger from '../utils/logger'; 4 | 5 | const router = Router(); 6 | 7 | interface VerifyRequestBody { 8 | reference: string; 9 | accountSuffix: string; 10 | } 11 | 12 | router.post('/', async function ( 13 | req: Request<{}, {}, VerifyRequestBody>, 14 | res: Response 15 | ): Promise { 16 | const { reference, accountSuffix } = req.body; 17 | 18 | if (!reference || !accountSuffix) { 19 | res.status(400).json({ success: false, error: 'Missing reference or accountSuffix.' }); 20 | return; 21 | } 22 | 23 | try { 24 | const result = await verifyCBE(reference, accountSuffix); 25 | res.json(result); 26 | } catch (err) { 27 | logger.error("💥 Payment verification failed:", err); 28 | res.status(500).json({ success: false, error: 'Server error verifying payment.' }); 29 | } 30 | }); 31 | 32 | router.get('/', async function( 33 | req: Request<{}, {}, {}, { reference?: string; accountSuffix?: string }>, 34 | res: Response 35 | ): Promise { 36 | const { reference, accountSuffix } = req.query; 37 | 38 | if (typeof reference !== 'string' || typeof accountSuffix !== 'string') { 39 | res.status(400).json({ success: false, error: 'Missing or invalid query parameters.' }); 40 | return; 41 | } 42 | 43 | try { 44 | const result = await verifyCBE(reference, accountSuffix); 45 | res.json(result); 46 | } catch (err) { 47 | logger.error(err); 48 | res.status(500).json({ success: false, error: 'Server error verifying payment.' }); 49 | } 50 | }); 51 | 52 | export default router; 53 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # ---- base (with pnpm) ---- 2 | FROM ghcr.io/railwayapp/nixpacks:ubuntu-1745885067 AS base 3 | WORKDIR /app 4 | 5 | # Avoid baking secrets into the image. Use Coolify env panel instead. 6 | # (Remove ARG/ENV for secrets from the Dockerfile.) 7 | 8 | # System deps you need (puppeteer/chromium libs etc.) 9 | RUN sudo apt-get update && sudo apt-get install -y --no-install-recommends \ 10 | libnss3 libatk1.0-0 libatk-bridge2.0-0 libcups2 libgbm1 libasound2t64 \ 11 | libpangocairo-1.0-0 libxss1 libgtk-3-0 libxshmfence1 libglu1 chromium curl wget \ 12 | && sudo rm -rf /var/lib/apt/lists/* 13 | 14 | COPY pnpm-lock.yaml package.json pnpm-workspace.yaml* ./ 15 | COPY prisma ./prisma 16 | 17 | # ---- deps (install devDeps) ---- 18 | FROM base AS deps 19 | # Force-install devDependencies regardless of NODE_ENV 20 | RUN --mount=type=cache,target=/root/.local/share/pnpm/store/v3 \ 21 | pnpm install --frozen-lockfile --prod=false 22 | 23 | # ---- build ---- 24 | FROM deps AS build 25 | COPY . . 26 | # Generate client & compile TS 27 | RUN pnpm prisma generate && pnpm build 28 | # Optionally prune to prod-only for runtime 29 | RUN pnpm prune --prod 30 | 31 | # ---- runtime ---- 32 | FROM ghcr.io/railwayapp/nixpacks:ubuntu-1745885067 AS runtime 33 | WORKDIR /app 34 | ENV NODE_ENV=production 35 | 36 | # Same system libs as base 37 | RUN sudo apt-get update && sudo apt-get install -y --no-install-recommends \ 38 | libnss3 libatk1.0-0 libatk-bridge2.0-0 libcups2 libgbm1 libasound2t64 \ 39 | libpangocairo-1.0-0 libxss1 libgtk-3-0 libxshmfence1 libglu1 chromium curl wget \ 40 | && sudo rm -rf /var/lib/apt/lists/* 41 | 42 | # Copy only what we need to run 43 | COPY --from=build /app/node_modules ./node_modules 44 | COPY --from=build /app/dist ./dist 45 | COPY --from=build /app/package.json ./package.json 46 | COPY --from=build /app/prisma ./prisma 47 | 48 | # If you run migrations at startup: 49 | # CMD ["sh", "-c", "pnpm prisma migrate deploy && node dist/index.js"] 50 | CMD ["node", "dist/index.js"] 51 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # 📦 Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | --- 6 | 7 | ## [2.1.0] - 2025-11-13 8 | 9 | ### Added 10 | - Telebirr: Return `bankName` in receipt payloads. 11 | 12 | ### Changed 13 | - Bump API version to `2.1.0` in package.json, root endpoint, README, Postman collection. 14 | 15 | ## [1.1.0] - 2025-05-18 16 | 17 | > This release introduces the first major backend expansion: transitioning from a fully in-memory system to a database-powered API with authentication, stats, and admin tools. 18 | 19 | ### 🚀 Added 20 | 21 | - 🔐 **API Key Authentication** 22 | - All verification endpoints (except `/` and `/health`) now require a valid API key. 23 | - Keys are stored in a Prisma-managed MySQL database. 24 | - Requests without valid keys are denied with a 401/403 error. 25 | 26 | - ⚙️ **Admin Routes** 27 | - `POST /admin/api-keys`: Generate a new API key. 28 | - `GET /admin/api-keys`: View all active/used keys (securely abbreviated). 29 | - `GET /admin/stats`: View endpoint usage, response times, and request logs. 30 | 31 | - 📊 **Usage Statistics Logging** 32 | - Each request is logged to a `UsageLog` table with: 33 | - API key ID 34 | - Endpoint 35 | - Method 36 | - Response time 37 | - Status code 38 | - IP address 39 | - Statistics are cached in-memory and pulled from the DB for admin views. 40 | 41 | - 🛠 **Prisma + MySQL Integration** 42 | - Introduced full Prisma schema and MySQL connection to persist: 43 | - API keys 44 | - Usage logs 45 | 46 | - 📁 **API Versioning Support** 47 | - Branch `api-keys-introduced` now tracks this new release. 48 | - Tagged as version `v1.1.0` in `package.json`. 49 | 50 | ### 🧹 Changed 51 | 52 | - 🧠 Moved all key storage and logic from in-memory Maps to persistent DB. 53 | - 🔄 `requestLogger` middleware now uses `res.on('finish')` for accurate response timing and DB writes. 54 | 55 | ### 🛡️ Security 56 | 57 | - Admin routes are protected using `x-admin-key` headers. 58 | - API keys are validated per request, and rate-limiting can be layered on in the future. 59 | 60 | --- 61 | 62 | ## [1.0.0] - 2025-05-12 63 | 64 | > Initial release of the Payment Verifier API. 65 | 66 | ### ✨ Features 67 | 68 | - ✅ **CBE Verification** via reference and suffix using Puppeteer and PDF parsing. 69 | - ✅ **Telebirr Verification** using raw reference scraping. 70 | - ✅ **Image-Based Verification** powered by **Mistral AI**, detecting CBE or Telebirr receipts. 71 | - 🧪 Express API with simple `POST` endpoints: 72 | - `/verify-cbe` 73 | - `/verify-telebirr` 74 | - `/verify-image` 75 | - 🔍 In-memory statistics and logging. 76 | 77 | --- 78 | -------------------------------------------------------------------------------- /src/routes/verifyDashenRoute.ts: -------------------------------------------------------------------------------- 1 | import { Router, Request, Response } from 'express'; 2 | import { verifyDashen } from '../services/verifyDashen'; 3 | import logger from '../utils/logger'; 4 | 5 | const router = Router(); 6 | 7 | interface VerifyRequestBody { 8 | reference: string; 9 | } 10 | 11 | // POST /verify-dashen 12 | router.post('/', async function ( 13 | req: Request<{}, {}, VerifyRequestBody>, 14 | res: Response 15 | ): Promise { 16 | const { reference } = req.body; 17 | 18 | if (!reference) { 19 | res.status(400).json({ 20 | success: false, 21 | error: 'Transaction reference is required' 22 | }); 23 | return; 24 | } 25 | 26 | try { 27 | logger.info(`🔍 Verifying Dashen transaction: ${reference}`); 28 | const result = await verifyDashen(reference); 29 | 30 | if (result.success) { 31 | logger.info(`✅ Dashen verification successful for: ${reference}`); 32 | } else { 33 | logger.warn(`❌ Dashen verification failed for: ${reference} - ${result.error}`); 34 | } 35 | 36 | res.json(result); 37 | } catch (error: any) { 38 | logger.error(`💥 Dashen verification error for ${reference}:`, error.message); 39 | res.status(500).json({ 40 | success: false, 41 | error: 'Internal server error during verification' 42 | }); 43 | } 44 | }); 45 | 46 | // GET /verify-dashen (for testing with query parameters) 47 | router.get('/', async function( 48 | req: Request<{}, {}, {}, { reference?: string }>, 49 | res: Response 50 | ): Promise { 51 | const { reference } = req.query; 52 | 53 | if (!reference || typeof reference !== 'string') { 54 | res.status(400).json({ 55 | success: false, 56 | error: 'Transaction reference is required as query parameter' 57 | }); 58 | return; 59 | } 60 | 61 | try { 62 | logger.info(`🔍 Verifying Dashen transaction (GET): ${reference}`); 63 | const result = await verifyDashen(reference); 64 | 65 | if (result.success) { 66 | logger.info(`✅ Dashen verification successful for: ${reference}`); 67 | } else { 68 | logger.warn(`❌ Dashen verification failed for: ${reference} - ${result.error}`); 69 | } 70 | 71 | res.json(result); 72 | } catch (error: any) { 73 | logger.error(`💥 Dashen verification error for ${reference}:`, error.message); 74 | res.status(500).json({ 75 | success: false, 76 | error: 'Internal server error during verification' 77 | }); 78 | } 79 | }); 80 | 81 | export default router; -------------------------------------------------------------------------------- /prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | // This is your Prisma schema file, 2 | // learn more about it in the docs: https://pris.ly/d/prisma-schema 3 | 4 | // Looking for ways to speed up your queries, or scale easily with your serverless or edge functions? 5 | // Try Prisma Accelerate: https://pris.ly/cli/accelerate-init 6 | 7 | generator client { 8 | provider = "prisma-client-js" 9 | } 10 | 11 | datasource db { 12 | provider = "mysql" 13 | url = env("DATABASE_URL") 14 | } 15 | 16 | // Necessary for Next auth 17 | model Account { 18 | id String @id @default(cuid()) 19 | userId String 20 | type String 21 | provider String 22 | providerAccountId String 23 | refresh_token String? @db.Text 24 | access_token String? @db.Text 25 | expires_at Int? 26 | token_type String? 27 | scope String? 28 | id_token String? @db.Text 29 | session_state String? 30 | user User @relation(fields: [userId], references: [id], onDelete: Cascade) 31 | refresh_token_expires_in Int? 32 | 33 | @@unique([provider, providerAccountId]) 34 | } 35 | 36 | model Session { 37 | id String @id @default(cuid()) 38 | sessionToken String @unique 39 | userId String 40 | expires DateTime 41 | user User @relation(fields: [userId], references: [id], onDelete: Cascade) 42 | } 43 | 44 | model User { 45 | id String @id @default(cuid()) 46 | name String? 47 | email String? @unique 48 | emailVerified DateTime? 49 | image String? 50 | accounts Account[] 51 | sessions Session[] 52 | ApiKey ApiKey[] 53 | role Role @default(USER) 54 | } 55 | 56 | enum Role { 57 | USER 58 | ADMIN 59 | } 60 | 61 | model VerificationToken { 62 | identifier String 63 | token String @unique 64 | expires DateTime 65 | 66 | @@unique([identifier, token]) 67 | } 68 | 69 | model ApiKey { 70 | id String @id @default(uuid()) 71 | key String @unique 72 | owner String 73 | createdAt DateTime @default(now()) 74 | lastUsed DateTime? 75 | usageCount Int @default(0) 76 | isActive Boolean @default(true) 77 | UsageLog UsageLog[] 78 | User User? @relation(fields: [userId], references: [id]) 79 | userId String? 80 | 81 | @@index([key]) 82 | @@index([userId]) 83 | } 84 | 85 | model UsageLog { 86 | id Int @id @default(autoincrement()) 87 | apiKeyId String 88 | apiKey ApiKey @relation(fields: [apiKeyId], references: [id]) 89 | endpoint String 90 | method String 91 | statusCode Int 92 | responseTime Int 93 | ip String 94 | createdAt DateTime @default(now()) 95 | 96 | @@index([apiKeyId]) 97 | @@index([endpoint]) 98 | @@index([createdAt]) 99 | } 100 | -------------------------------------------------------------------------------- /src/middleware/apiKeyAuth.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response, NextFunction } from 'express'; 2 | import logger from '../utils/logger'; 3 | import { prisma } from '../utils/prisma'; 4 | import { AppError, ErrorType, sendErrorResponse } from '../utils/errorHandler'; 5 | 6 | // Function to generate a new API key 7 | export const generateApiKey = async (owner: string) => { 8 | // Generate a random API key 9 | const key = Buffer.from(`${owner}-${Date.now()}-${Math.random().toString(36).substring(2)}`) 10 | .toString('base64') 11 | .replace(/[^a-zA-Z0-9]/g, ''); 12 | 13 | try { 14 | // Create API key in database 15 | const apiKey = await prisma.apiKey.create({ 16 | data: { 17 | key, 18 | owner, 19 | usageCount: 0, 20 | isActive: true 21 | } 22 | }); 23 | 24 | return apiKey; 25 | } catch (error) { 26 | logger.error('Error generating API key:', error); 27 | throw error; 28 | } 29 | }; 30 | 31 | // Function to validate an API key 32 | export const validateApiKey = async (key: string) => { 33 | try { 34 | return await prisma.apiKey.findUnique({ 35 | where: { key, isActive: true } 36 | }); 37 | } catch (error) { 38 | logger.error('Error validating API key:', error); 39 | throw error; 40 | } 41 | }; 42 | 43 | // Middleware to check API key 44 | export const apiKeyAuth = async (req: Request, res: Response, next: NextFunction) => { 45 | // Skip API key check for certain routes 46 | if (req.path === '/' || req.path === '/health' || req.path.startsWith('/admin')) { 47 | return next(); 48 | } 49 | 50 | // Get API key from header or query parameter 51 | const apiKey = req.headers['x-api-key'] || req.query.apiKey as string; 52 | 53 | if (!apiKey) { 54 | logger.warn(`API request without API key: ${req.method} ${req.path}`); 55 | return res.status(401).json({ success: false, error: 'API key is required' }); 56 | } 57 | 58 | try { 59 | // Validate API key 60 | const keyString = Array.isArray(apiKey) ? apiKey[0] : apiKey; 61 | const keyData = await validateApiKey(keyString); 62 | 63 | if (!keyData) { 64 | logger.warn(`Invalid API key used: ${typeof keyString === 'string' ? keyString.substring(0, 8) : ''}...`); 65 | return res.status(403).json({ success: false, error: 'Invalid API key' }); 66 | } 67 | 68 | // Update API key usage statistics 69 | await prisma.apiKey.update({ 70 | where: { id: keyData.id }, 71 | data: { 72 | lastUsed: new Date(), 73 | usageCount: { increment: 1 } 74 | } 75 | }); 76 | 77 | // Add API key info to request for later use 78 | (req as any).apiKeyData = keyData; 79 | 80 | next(); 81 | } catch (error) { 82 | logger.error('Error validating API key:', error); 83 | sendErrorResponse(res, error); 84 | } 85 | }; 86 | 87 | // Get all API keys 88 | export const getApiKeys = async () => { 89 | try { 90 | return await prisma.apiKey.findMany(); 91 | } catch (error) { 92 | logger.error('Error fetching API keys:', error); 93 | throw error; 94 | } 95 | }; -------------------------------------------------------------------------------- /src/routes/adminRoute.ts: -------------------------------------------------------------------------------- 1 | import { Router, Request, Response, RequestHandler, NextFunction } from 'express'; 2 | import { generateApiKey, getApiKeys } from '../middleware/apiKeyAuth'; 3 | import { getUsageStats } from '../middleware/requestLogger'; 4 | import logger from '../utils/logger'; 5 | 6 | const router = Router(); 7 | 8 | // Admin secret key for authentication (use environment variable in production) 9 | const ADMIN_SECRET = process.env.ADMIN_SECRET || 'change-this-secret-key'; 10 | 11 | // Middleware to check admin authentication 12 | const checkAdminAuth = (req: Request, res: Response, next: NextFunction) => { 13 | const adminKey = req.headers['x-admin-key'] || req.query.adminKey; 14 | 15 | if (adminKey !== ADMIN_SECRET) { 16 | return res.status(403).json({ success: false, error: 'Unauthorized admin access' }); 17 | } 18 | 19 | next(); 20 | }; 21 | 22 | // Generate a new API key 23 | // Update the API key generation route 24 | router.post('/api-keys', checkAdminAuth as RequestHandler, async (req: Request, res: Response): Promise => { 25 | const { owner } = req.body; 26 | 27 | if (!owner) { 28 | res.status(400).json({ success: false, error: 'Owner name is required' }); 29 | return; 30 | } 31 | 32 | try { 33 | const apiKey = await generateApiKey(owner); 34 | logger.info(`New API key generated for ${owner}`); 35 | 36 | res.status(201).json({ 37 | success: true, 38 | data: { 39 | key: apiKey.key, 40 | owner: apiKey.owner, 41 | createdAt: apiKey.createdAt 42 | } 43 | }); 44 | } catch (err) { 45 | logger.error('Error generating API key:', err); 46 | res.status(500).json({ success: false, error: 'Failed to generate API key' }); 47 | } 48 | }); 49 | 50 | // Update the API keys listing route 51 | router.get('/api-keys', checkAdminAuth as RequestHandler, async (req: Request, res: Response) => { 52 | try { 53 | const apiKeys = await getApiKeys(); 54 | const keyList = apiKeys.map((key: { 55 | key: string; 56 | owner: string; 57 | createdAt: Date; 58 | lastUsed: Date | null; 59 | usageCount: number; 60 | isActive: boolean; 61 | }) => ({ 62 | key: key.key.substring(0, 8) + '...', 63 | owner: key.owner, 64 | createdAt: key.createdAt, 65 | lastUsed: key.lastUsed, 66 | usageCount: key.usageCount, 67 | isActive: key.isActive 68 | })); 69 | 70 | res.json({ success: true, data: keyList }); 71 | } catch (err) { 72 | logger.error('Error fetching API keys:', err); 73 | res.status(500).json({ success: false, error: 'Failed to fetch API keys' }); 74 | } 75 | }); 76 | 77 | // Update the stats route 78 | router.get('/stats', checkAdminAuth as RequestHandler, async (req: Request, res: Response) => { 79 | try { 80 | const stats = await getUsageStats(); 81 | res.json({ 82 | success: true, 83 | data: stats 84 | }); 85 | } catch (err) { 86 | logger.error('Error fetching usage stats:', err); 87 | res.status(500).json({ success: false, error: 'Failed to fetch usage statistics' }); 88 | } 89 | }); 90 | 91 | export default router; -------------------------------------------------------------------------------- /src/utils/logger.ts: -------------------------------------------------------------------------------- 1 | import { createLogger, format, transports, Logger } from 'winston'; 2 | import 'winston-daily-rotate-file'; 3 | 4 | const { combine, timestamp, printf, errors, colorize } = format; 5 | 6 | // 🎨 Fancy Console Format with Emojis and Timestamp 7 | const emojiFormat = printf(info => { 8 | const { level, message, timestamp, stack, ...meta } = info; 9 | 10 | const emojis: Record = { 11 | info: 'ℹ️ ', 12 | warn: '⚠️ ', 13 | error: '❌', 14 | debug: '🐛', 15 | }; 16 | 17 | const emoji = emojis[level] || ''; 18 | let log = `${emoji}[${timestamp}] ${level.toUpperCase()}: ${message}`; 19 | 20 | if (stack) { 21 | log += `\n🔍 Stack:\n${stack}`; 22 | } 23 | 24 | const { service, ...rest } = meta; 25 | const extraMeta = Object.keys(rest).length > 0 ? rest : null; 26 | 27 | if (extraMeta) { 28 | const formatted = JSON.stringify(extraMeta, null, 2).replace(/^/gm, ' › '); 29 | log += `\n📦 Metadata:\n${formatted}`; 30 | } 31 | 32 | return log; 33 | }); 34 | 35 | // 📝 Plain Format for File Logging 36 | const fileFormat = printf(({ level, message, timestamp, stack, ...meta }) => { 37 | let log = `[${timestamp}] ${level.toUpperCase()}: ${message}`; 38 | if (stack) log += `\n${stack}`; 39 | if (Object.keys(meta).length > 0) { 40 | log += `\n${JSON.stringify(meta, null, 2)}`; 41 | } 42 | return log; 43 | }); 44 | 45 | // 🗂 Error Log File (Rotating) 46 | const errorRotateFile = new transports.DailyRotateFile({ 47 | filename: 'logs/error-%DATE%.log', 48 | datePattern: 'YYYY-MM-DD', 49 | level: 'error', 50 | maxSize: '5m', 51 | maxFiles: '14d', 52 | format: combine( 53 | errors({ stack: true }), 54 | timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }), 55 | fileFormat 56 | ) 57 | }); 58 | 59 | // 🗂 Combined Log File (Rotating) 60 | const combinedRotateFile = new transports.DailyRotateFile({ 61 | filename: 'logs/combined-%DATE%.log', 62 | datePattern: 'YYYY-MM-DD', 63 | maxSize: '5m', 64 | maxFiles: '14d', 65 | format: combine( 66 | timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }), 67 | fileFormat 68 | ) 69 | }); 70 | 71 | // 🧠 Main Winston Logger 72 | const logger = createLogger({ 73 | level: process.env.LOG_LEVEL || (process.env.NODE_ENV === 'production' ? 'info' : 'debug'), 74 | format: combine( 75 | errors({ stack: true }), 76 | timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }), 77 | fileFormat 78 | ), 79 | defaultMeta: { service: 'verifier-api' }, 80 | transports: [ 81 | new transports.Console({ 82 | format: combine( 83 | colorize(), 84 | timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }), 85 | emojiFormat 86 | ) 87 | }), 88 | errorRotateFile, 89 | combinedRotateFile 90 | ], 91 | exitOnError: false 92 | }); 93 | 94 | // ➕ Optional stream for morgan logging 95 | interface CustomLogger extends Omit { 96 | stream?: { write(message: string): void }; 97 | } 98 | 99 | const customLogger = logger as unknown as CustomLogger; 100 | customLogger.stream = { 101 | write: (message: string) => { 102 | logger.info(message.trim()); 103 | } 104 | }; 105 | 106 | export default customLogger; 107 | -------------------------------------------------------------------------------- /src/utils/errorHandler.ts: -------------------------------------------------------------------------------- 1 | // import { Prisma } from '@prisma/client'; 2 | import { 3 | PrismaClientKnownRequestError, 4 | PrismaClientValidationError, 5 | } from '@prisma/client/runtime/library'; 6 | import logger from './logger'; 7 | import { Response } from 'express'; 8 | 9 | // Error types for better error handling 10 | export enum ErrorType { 11 | NOT_FOUND = 'NOT_FOUND', 12 | VALIDATION = 'VALIDATION', 13 | UNAUTHORIZED = 'UNAUTHORIZED', 14 | FORBIDDEN = 'FORBIDDEN', 15 | DATABASE = 'DATABASE', 16 | INTERNAL = 'INTERNAL', 17 | } 18 | 19 | // Custom error class with type and status code 20 | export class AppError extends Error { 21 | type: ErrorType; 22 | statusCode: number; 23 | details?: any; 24 | 25 | constructor(message: string, type: ErrorType, statusCode: number, details?: any) { 26 | super(message); 27 | this.type = type; 28 | this.statusCode = statusCode; 29 | this.details = details; 30 | this.name = 'AppError'; 31 | } 32 | } 33 | 34 | // Handle Prisma-specific errors 35 | export const handlePrismaError = (error: any): AppError => { 36 | if (error instanceof PrismaClientKnownRequestError) { 37 | // Handle known Prisma errors 38 | switch (error.code) { 39 | case 'P2002': // Unique constraint violation 40 | return new AppError( 41 | 'A record with this value already exists.', 42 | ErrorType.VALIDATION, 43 | 409, 44 | { fields: error.meta?.target } 45 | ); 46 | case 'P2025': // Record not found 47 | return new AppError( 48 | 'Record not found.', 49 | ErrorType.NOT_FOUND, 50 | 404 51 | ); 52 | case 'P2003': // Foreign key constraint failed 53 | return new AppError( 54 | 'Operation failed due to a relation constraint.', 55 | ErrorType.VALIDATION, 56 | 400, 57 | { fields: error.meta?.field_name } 58 | ); 59 | default: 60 | logger.error(`Unhandled Prisma error: ${error.code}`, error); 61 | return new AppError( 62 | 'Database operation failed.', 63 | ErrorType.DATABASE, 64 | 500 65 | ); 66 | } 67 | } else if (error instanceof PrismaClientValidationError) { 68 | // Handle validation errors 69 | return new AppError( 70 | 'Invalid data provided.', 71 | ErrorType.VALIDATION, 72 | 400 73 | ); 74 | } else if (error instanceof AppError) { 75 | // Pass through our custom errors 76 | return error; 77 | } else { 78 | // Handle unknown errors 79 | logger.error('Unknown error:', error); 80 | return new AppError( 81 | 'An unexpected error occurred.', 82 | ErrorType.INTERNAL, 83 | 500 84 | ); 85 | } 86 | }; 87 | 88 | // Send error response 89 | export const sendErrorResponse = (res: Response, error: any) => { 90 | const appError = handlePrismaError(error); 91 | 92 | res.status(appError.statusCode).json({ 93 | success: false, 94 | error: appError.message, 95 | ...(process.env.NODE_ENV === 'development' && { details: appError.details }) 96 | }); 97 | }; -------------------------------------------------------------------------------- /src/utils/migrateApiKeys.ts: -------------------------------------------------------------------------------- 1 | import { prisma } from './prisma'; 2 | import type { Prisma } from '@prisma/client'; 3 | import logger from './logger'; 4 | import fs from 'fs'; 5 | import path from 'path'; 6 | 7 | // Function to migrate in-memory API keys to database 8 | export const migrateApiKeys = async (inMemoryApiKeys: Map) => { 9 | try { 10 | logger.info(`Starting migration of ${inMemoryApiKeys.size} API keys to database`); 11 | 12 | // Create a backup of in-memory keys 13 | const backupPath = path.join(__dirname, '../../backup-api-keys.json'); 14 | const backupData = JSON.stringify(Array.from(inMemoryApiKeys.entries()), null, 2); 15 | fs.writeFileSync(backupPath, backupData); 16 | logger.info(`Backup of API keys created at ${backupPath}`); 17 | 18 | // Begin transaction 19 | const results = await prisma.$transaction(async (tx: Prisma.TransactionClient) => { 20 | const migrationResults = []; 21 | 22 | for (const [key, data] of inMemoryApiKeys.entries()) { 23 | // Check if key already exists in database 24 | const existingKey = await tx.apiKey.findUnique({ 25 | where: { key } 26 | }); 27 | 28 | if (!existingKey) { 29 | // Create new key in database 30 | const newKey = await tx.apiKey.create({ 31 | data: { 32 | key, 33 | owner: data.owner, 34 | usageCount: data.usageCount || 0, 35 | lastUsed: data.lastUsed ? new Date(data.lastUsed) : null, 36 | isActive: data.isActive !== false // Default to true if not specified 37 | } 38 | }); 39 | 40 | migrationResults.push({ key, status: 'created', id: newKey.id }); 41 | } else { 42 | migrationResults.push({ key, status: 'already_exists', id: existingKey.id }); 43 | } 44 | } 45 | 46 | return migrationResults; 47 | }); 48 | 49 | const created = results.filter((r: any) => r.status === 'created').length; 50 | const existing = results.filter((r: any) => r.status === 'already_exists').length; 51 | 52 | logger.info(`Migration completed: ${created} keys created, ${existing} keys already existed`); 53 | return { created, existing, total: inMemoryApiKeys.size }; 54 | } catch (error) { 55 | logger.error('Error migrating API keys:', error); 56 | throw error; 57 | } 58 | }; 59 | 60 | // Function to restore API keys from backup if needed 61 | export const restoreApiKeysFromBackup = async () => { 62 | try { 63 | const backupPath = path.join(__dirname, '../../backup-api-keys.json'); 64 | 65 | if (!fs.existsSync(backupPath)) { 66 | logger.error('Backup file not found'); 67 | return { success: false, error: 'Backup file not found' }; 68 | } 69 | 70 | const backupData = fs.readFileSync(backupPath, 'utf8'); 71 | const apiKeys = JSON.parse(backupData); 72 | 73 | if (!Array.isArray(apiKeys)) { 74 | logger.error('Invalid backup format'); 75 | return { success: false, error: 'Invalid backup format' }; 76 | } 77 | 78 | const inMemoryMap = new Map(apiKeys); 79 | const result = await migrateApiKeys(inMemoryMap as Map); 80 | 81 | return { success: true, ...result }; 82 | } catch (error) { 83 | logger.error('Error restoring API keys from backup:', error); 84 | return { success: false, error: String(error) }; 85 | } 86 | }; -------------------------------------------------------------------------------- /src/routes/verifyCBEBirrRoute.ts: -------------------------------------------------------------------------------- 1 | import { Router, Request, Response } from 'express'; 2 | import { verifyCBEBirr } from '../services/verifyCBEBirr'; 3 | import logger from '../utils/logger'; 4 | 5 | const router = Router(); 6 | 7 | // Validation helper for Ethiopian phone numbers 8 | function isValidEthiopianPhone(phone: string): boolean { 9 | // Ethiopian phone numbers should start with 251 and be 12 digits total 10 | const phoneRegex = /^251\d{9}$/; 11 | return phoneRegex.test(phone); 12 | } 13 | 14 | // POST endpoint for CBE Birr verification 15 | router.post('/', async (req: Request, res: Response) => { 16 | try { 17 | const { receiptNumber, phoneNumber } = req.body; 18 | const apiKey = req.headers.authorization?.replace('Bearer ', '') || req.headers['x-api-key'] as string; 19 | 20 | // Validate required parameters 21 | if (!receiptNumber) { 22 | res.status(400).json({ 23 | success: false, 24 | error: 'Receipt number is required' 25 | }); 26 | return; 27 | } 28 | 29 | if (!phoneNumber) { 30 | res.status(400).json({ 31 | success: false, 32 | error: 'Phone number is required' 33 | }); 34 | return; 35 | } 36 | 37 | if (!apiKey) { 38 | res.status(401).json({ 39 | success: false, 40 | error: 'API key is required in Authorization header or x-api-key header' 41 | }); 42 | return; 43 | } 44 | 45 | // Validate Ethiopian phone number format 46 | if (!isValidEthiopianPhone(phoneNumber)) { 47 | res.status(400).json({ 48 | success: false, 49 | error: 'Invalid Ethiopian phone number format. Must start with 251 and be 12 digits total' 50 | }); 51 | return; 52 | } 53 | 54 | logger.info(`[CBEBirr Route] Processing verification request for receipt: ${receiptNumber}, phone: ${phoneNumber}`); 55 | 56 | // Call the verification service 57 | const result = await verifyCBEBirr(receiptNumber, phoneNumber, apiKey); 58 | 59 | // Return the result 60 | res.json(result); 61 | 62 | } catch (error) { 63 | logger.error('[CBEBirr Route] Error in POST endpoint:', error); 64 | res.status(500).json({ 65 | success: false, 66 | error: 'Internal server error' 67 | }); 68 | } 69 | }); 70 | 71 | // GET endpoint for CBE Birr verification (alternative method) 72 | router.get('/', async (req: Request, res: Response) => { 73 | try { 74 | const { receiptNumber, phoneNumber } = req.query; 75 | const apiKey = req.headers.authorization?.replace('Bearer ', '') || req.headers['x-api-key'] as string; 76 | 77 | // Validate required parameters 78 | if (!receiptNumber || typeof receiptNumber !== 'string') { 79 | res.status(400).json({ 80 | success: false, 81 | error: 'Receipt number is required as query parameter' 82 | }); 83 | return; 84 | } 85 | 86 | if (!phoneNumber || typeof phoneNumber !== 'string') { 87 | res.status(400).json({ 88 | success: false, 89 | error: 'Phone number is required as query parameter' 90 | }); 91 | return; 92 | } 93 | 94 | if (!apiKey) { 95 | res.status(401).json({ 96 | success: false, 97 | error: 'API key is required in Authorization header or x-api-key header' 98 | }); 99 | return; 100 | } 101 | 102 | // Validate Ethiopian phone number format 103 | if (!isValidEthiopianPhone(phoneNumber)) { 104 | res.status(400).json({ 105 | success: false, 106 | error: 'Invalid Ethiopian phone number format. Must start with 251 and be 12 digits total' 107 | }); 108 | return; 109 | } 110 | 111 | logger.info(`[CBEBirr Route] Processing GET verification request for receipt: ${receiptNumber}, phone: ${phoneNumber}`); 112 | 113 | // Call the verification service 114 | const result = await verifyCBEBirr(receiptNumber, phoneNumber, apiKey); 115 | 116 | // Return the result 117 | res.json(result); 118 | 119 | } catch (error) { 120 | logger.error('[CBEBirr Route] Error in GET endpoint:', error); 121 | res.status(500).json({ 122 | success: false, 123 | error: 'Internal server error' 124 | }); 125 | } 126 | }); 127 | 128 | export default router; -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import express, { Request, Response, NextFunction, ErrorRequestHandler } from 'express'; 2 | import cors from 'cors'; 3 | import dotenv from 'dotenv'; 4 | 5 | // Load environment variables from .env file 6 | dotenv.config(); 7 | 8 | import CBERouter from './routes/verifyCBERoute'; 9 | import telebirrRouter from './routes/verifyTelebirrRoute'; 10 | import dashenRouter from './routes/verifyDashenRoute'; 11 | import abyssiniaRouter from './routes/verifyAbyssiniaRoute'; 12 | import cbebirrRouter from './routes/verifyCBEBirrRoute'; 13 | import adminRouter from './routes/adminRoute'; 14 | import logger from './utils/logger'; 15 | import { verifyImageHandler } from "./services/verifyImage"; 16 | import { requestLogger, initializeStatsCache } from './middleware/requestLogger'; 17 | import { apiKeyAuth } from './middleware/apiKeyAuth'; 18 | import { prisma, disconnectPrisma } from './utils/prisma'; 19 | 20 | const app = express(); 21 | const PORT = process.env.PORT || 3001; 22 | 23 | // Add environment info to startup log 24 | logger.info(`Starting server in ${process.env.NODE_ENV || 'development'} mode`); 25 | logger.info(`Node version: ${process.version}`); 26 | logger.info(`Platform: ${process.platform}`); 27 | 28 | // Initialize database connection and cache 29 | (async () => { 30 | try { 31 | // Test database connection 32 | await prisma.$connect(); 33 | logger.info('Connected to database successfully'); 34 | 35 | // Initialize stats cache from database 36 | await initializeStatsCache(); 37 | } catch (error) { 38 | logger.error('Failed to initialize database connection:', error); 39 | process.exit(1); 40 | } 41 | })(); 42 | 43 | app.use(cors()); 44 | app.use(express.json()); 45 | 46 | // Add request logging middleware 47 | app.use(requestLogger); 48 | 49 | // Register admin routes BEFORE API key authentication 50 | app.use('/admin', adminRouter); 51 | 52 | // Add API key authentication middleware (will not affect admin routes) 53 | app.use(apiKeyAuth as express.RequestHandler); 54 | 55 | // Error handling for JSON parsing - properly typed as an error handler 56 | const jsonErrorHandler: ErrorRequestHandler = async (err, req, res, next): Promise => { 57 | if (err instanceof SyntaxError && 'body' in err) { 58 | logger.error('JSON parsing error:', err); 59 | res.status(400).json({ success: false, error: 'Invalid JSON in request body' }); 60 | return; 61 | } 62 | next(err); 63 | }; 64 | 65 | app.use(jsonErrorHandler); 66 | 67 | // ✅ Attach routers to paths 68 | app.use('/verify-cbe', CBERouter); 69 | app.use('/verify-telebirr', telebirrRouter); 70 | app.use('/verify-dashen', dashenRouter); 71 | app.use('/verify-abyssinia', abyssiniaRouter); 72 | app.use('/verify-cbebirr', cbebirrRouter); 73 | app.post('/verify-image', verifyImageHandler); 74 | 75 | // Health check endpoint 76 | app.get('/health', (req: Request, res: Response) => { 77 | res.json({ status: 'ok', timestamp: new Date().toISOString() }); 78 | }); 79 | 80 | // Root endpoint 81 | app.get('/', (req: Request, res: Response) => { 82 | res.json({ 83 | name: 'Payment Verification API', 84 | version: '2.1.0', 85 | endpoints: [ 86 | '/verify-cbe', 87 | '/verify-telebirr', 88 | '/verify-dashen', 89 | '/verify-abyssinia', 90 | '/verify-cbebirr', 91 | '/verify-image' 92 | ] 93 | }); 94 | }); 95 | 96 | // Global error handler 97 | app.use((err: Error, req: Request, res: Response, next: NextFunction) => { 98 | logger.error('Unhandled error:', err); 99 | res.status(500).json({ success: false, error: 'Internal server error' }); 100 | }); 101 | 102 | // Start the server 103 | const server = app.listen(PORT, () => { 104 | logger.info(`Server running on port ${PORT}`); 105 | }); 106 | 107 | // Graceful shutdown 108 | const gracefulShutdown = async () => { 109 | logger.info('Shutting down server...'); 110 | server.close(async () => { 111 | logger.info('HTTP server closed'); 112 | await disconnectPrisma(); 113 | process.exit(0); 114 | }); 115 | 116 | // Force close after 10 seconds 117 | setTimeout(() => { 118 | logger.error('Forced shutdown after timeout'); 119 | process.exit(1); 120 | }, 10000); 121 | }; 122 | 123 | // Listen for termination signals 124 | process.on('SIGTERM', gracefulShutdown); 125 | process.on('SIGINT', gracefulShutdown); 126 | -------------------------------------------------------------------------------- /src/routes/verifyAbyssiniaRoute.ts: -------------------------------------------------------------------------------- 1 | import { Router, Request, Response } from 'express'; 2 | import { verifyAbyssinia } from '../services/verifyAbyssinia'; 3 | import logger from '../utils/logger'; 4 | 5 | const router = Router(); 6 | 7 | /** 8 | * POST /verify-abyssinia 9 | * Verify Abyssinia bank transaction 10 | * Body: { reference: string, suffix: string } 11 | */ 12 | router.post('/', async (req: Request, res: Response): Promise => { 13 | try { 14 | const { reference, suffix } = req.body; 15 | 16 | // Validate required parameters 17 | if (!reference || !suffix) { 18 | logger.warn('❌ Missing required parameters for Abyssinia verification'); 19 | res.status(400).json({ 20 | success: false, 21 | error: 'Missing required parameters: reference and suffix are required' 22 | }); 23 | return; 24 | } 25 | 26 | // Validate parameter types 27 | if (typeof reference !== 'string' || typeof suffix !== 'string') { 28 | logger.warn('❌ Invalid parameter types for Abyssinia verification'); 29 | res.status(400).json({ 30 | success: false, 31 | error: 'Invalid parameter types: reference and suffix must be strings' 32 | }); 33 | return; 34 | } 35 | 36 | // Validate suffix length (should be 5 digits) 37 | if (suffix.length !== 5 || !/^\d{5}$/.test(suffix)) { 38 | logger.warn(`❌ Invalid suffix format: ${suffix}`); 39 | res.status(400).json({ 40 | success: false, 41 | error: 'Invalid suffix: must be exactly 5 digits' 42 | }); 43 | return; 44 | } 45 | 46 | logger.info(`🏦 Processing Abyssinia verification request - Reference: ${reference}, Suffix: ${suffix}`); 47 | 48 | // Call the verification service 49 | const result = await verifyAbyssinia(reference, suffix); 50 | 51 | if (result.success) { 52 | logger.info(`✅ Abyssinia verification successful for reference: ${reference}`); 53 | res.json({ 54 | success: true, 55 | data: result 56 | }); 57 | } else { 58 | logger.warn(`❌ Abyssinia verification failed for reference: ${reference}`); 59 | res.status(404).json({ 60 | success: false, 61 | error: result.error || 'Transaction not found or verification failed' 62 | }); 63 | } 64 | 65 | } catch (error) { 66 | logger.error('❌ Error in Abyssinia verification route:', error); 67 | res.status(500).json({ 68 | success: false, 69 | error: 'Internal server error during verification' 70 | }); 71 | } 72 | }); 73 | 74 | /** 75 | * GET /verify-abyssinia 76 | * Verify Abyssinia bank transaction via query parameters 77 | * Query: ?reference=string&suffix=string 78 | */ 79 | router.get('/', async (req: Request, res: Response): Promise => { 80 | try { 81 | const { reference, suffix } = req.query; 82 | 83 | // Validate required parameters 84 | if (!reference || !suffix) { 85 | logger.warn('❌ Missing required query parameters for Abyssinia verification'); 86 | res.status(400).json({ 87 | success: false, 88 | error: 'Missing required query parameters: reference and suffix are required' 89 | }); 90 | return; 91 | } 92 | 93 | // Validate parameter types 94 | if (typeof reference !== 'string' || typeof suffix !== 'string') { 95 | logger.warn('❌ Invalid query parameter types for Abyssinia verification'); 96 | res.status(400).json({ 97 | success: false, 98 | error: 'Invalid parameter types: reference and suffix must be strings' 99 | }); 100 | return; 101 | } 102 | 103 | // Validate suffix length (should be 5 digits) 104 | if (suffix.length !== 5 || !/^\d{5}$/.test(suffix)) { 105 | logger.warn(`❌ Invalid suffix format: ${suffix}`); 106 | res.status(400).json({ 107 | success: false, 108 | error: 'Invalid suffix: must be exactly 5 digits' 109 | }); 110 | return; 111 | } 112 | 113 | logger.info(`🏦 Processing Abyssinia verification request (GET) - Reference: ${reference}, Suffix: ${suffix}`); 114 | 115 | // Call the verification service 116 | const result = await verifyAbyssinia(reference, suffix); 117 | 118 | if (result.success) { 119 | logger.info(`✅ Abyssinia verification successful for reference: ${reference}`); 120 | res.json({ 121 | success: true, 122 | data: result 123 | }); 124 | } else { 125 | logger.warn(`❌ Abyssinia verification failed for reference: ${reference}`); 126 | res.status(404).json({ 127 | success: false, 128 | error: result.error || 'Transaction not found or verification failed' 129 | }); 130 | } 131 | 132 | } catch (error) { 133 | logger.error('❌ Error in Abyssinia verification route (GET):', error); 134 | res.status(500).json({ 135 | success: false, 136 | error: 'Internal server error during verification' 137 | }); 138 | } 139 | }); 140 | 141 | export default router; -------------------------------------------------------------------------------- /src/services/verifyCBE.ts: -------------------------------------------------------------------------------- 1 | import puppeteer from 'puppeteer'; 2 | import axios, { AxiosResponse } from 'axios'; 3 | import pdf from 'pdf-parse'; 4 | import https from 'https'; 5 | import fs from 'fs'; 6 | import logger from '../utils/logger'; 7 | 8 | export interface VerifyResult { 9 | success: boolean; 10 | payer?: string; 11 | payerAccount?: string; 12 | receiver?: string; 13 | receiverAccount?: string; 14 | amount?: number; 15 | date?: Date; 16 | reference?: string; 17 | reason?: string | null; 18 | error?: string; 19 | } 20 | 21 | function titleCase(str: string): string { 22 | return str.toLowerCase().replace(/\b\w/g, char => char.toUpperCase()); 23 | } 24 | 25 | export async function verifyCBE( 26 | reference: string, 27 | accountSuffix: string 28 | ): Promise { 29 | const fullId = `${reference}${accountSuffix}`; 30 | const url = `https://apps.cbe.com.et:100/?id=${fullId}`; 31 | const httpsAgent = new https.Agent({ rejectUnauthorized: false }); 32 | 33 | try { 34 | logger.info(`🔎 Attempting direct fetch: ${url}`); 35 | const response: AxiosResponse = await axios.get(url, { 36 | httpsAgent, 37 | responseType: 'arraybuffer', 38 | headers: { 39 | 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)', 40 | 'Accept': 'application/pdf' 41 | }, 42 | timeout: 30000 43 | }); 44 | 45 | logger.info('✅ Direct fetch success, parsing PDF'); 46 | return await parseCBEReceipt(response.data); 47 | } catch (directErr: any) { 48 | logger.warn('⚠️ Direct fetch failed, falling back to Puppeteer:', directErr.message); 49 | 50 | let browser; 51 | try { 52 | browser = await puppeteer.launch({ 53 | headless: true, 54 | args: [ 55 | '--no-sandbox', 56 | '--disable-setuid-sandbox', 57 | '--ignore-certificate-errors', 58 | '--disable-dev-shm-usage', 59 | '--disable-gpu' 60 | ], 61 | executablePath: process.env.PUPPETEER_EXECUTABLE_PATH || undefined 62 | }); 63 | 64 | const page = await browser.newPage(); 65 | let detectedPdfUrl: string | null = null; 66 | 67 | page.on('response', async (response) => { 68 | const contentType = response.headers()['content-type']; 69 | if (contentType?.includes('pdf')) { 70 | detectedPdfUrl = response.url(); 71 | logger.info('🧾 PDF detected:', detectedPdfUrl); 72 | } 73 | }); 74 | 75 | await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 20000 }); 76 | await new Promise(res => setTimeout(res, 3000)); 77 | await browser.close(); 78 | 79 | if (!detectedPdfUrl) { 80 | return { success: false, error: 'No PDF detected via Puppeteer.' }; 81 | } 82 | 83 | const pdfRes = await axios.get(detectedPdfUrl, { 84 | httpsAgent, 85 | responseType: 'arraybuffer' 86 | }); 87 | 88 | return await parseCBEReceipt(pdfRes.data); 89 | } catch (puppetErr: any) { 90 | logger.error('❌ Puppeteer failed:', puppetErr.message); 91 | if (browser) await browser.close(); 92 | return { 93 | success: false, 94 | error: `Both direct and Puppeteer failed: ${puppetErr.message}` 95 | }; 96 | } 97 | } 98 | } 99 | 100 | async function parseCBEReceipt(buffer: ArrayBuffer): Promise { 101 | try { 102 | const parsed = await pdf(Buffer.from(buffer)); 103 | const rawText = parsed.text.replace(/\s+/g, ' ').trim(); 104 | 105 | let payerName = rawText.match(/Payer\s*:?\s*(.*?)\s+Account/i)?.[1]?.trim(); 106 | let receiverName = rawText.match(/Receiver\s*:?\s*(.*?)\s+Account/i)?.[1]?.trim(); 107 | const accountMatches = [...rawText.matchAll(/Account\s*:?\s*([A-Z0-9]?\*{4}\d{4})/gi)]; 108 | const payerAccount = accountMatches?.[0]?.[1]; 109 | const receiverAccount = accountMatches?.[1]?.[1]; 110 | 111 | const reason = rawText.match(/Reason\s*\/\s*Type of service\s*:?\s*(.*?)\s+Transferred Amount/i)?.[1]?.trim(); 112 | const amountText = rawText.match(/Transferred Amount\s*:?\s*([\d,]+\.\d{2})\s*ETB/i)?.[1]; 113 | const referenceMatch = rawText.match(/Reference No\.?\s*\(VAT Invoice No\)\s*:?\s*([A-Z0-9]+)/i)?.[1]?.trim(); 114 | const dateRaw = rawText.match(/Payment Date & Time\s*:?\s*([\d\/,: ]+[APM]{2})/i)?.[1]?.trim(); 115 | 116 | const amount = amountText ? parseFloat(amountText.replace(/,/g, '')) : undefined; 117 | const date = dateRaw ? new Date(dateRaw) : undefined; 118 | 119 | payerName = payerName ? titleCase(payerName) : undefined; 120 | receiverName = receiverName ? titleCase(receiverName) : undefined; 121 | 122 | if (payerName && payerAccount && receiverName && receiverAccount && amount && date && referenceMatch) { 123 | return { 124 | success: true, 125 | payer: payerName, 126 | payerAccount, 127 | receiver: receiverName, 128 | receiverAccount, 129 | amount, 130 | date, 131 | reference: referenceMatch, 132 | reason: reason || null 133 | }; 134 | } else { 135 | return { 136 | success: false, 137 | error: 'Could not extract all required fields from PDF.' 138 | }; 139 | } 140 | } catch (parseErr: any) { 141 | logger.error('❌ PDF parsing failed:', parseErr.message); 142 | return { success: false, error: 'Error parsing PDF data' }; 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /src/services/verifyImage.ts: -------------------------------------------------------------------------------- 1 | import { Mistral } from "@mistralai/mistralai"; 2 | import fs from "fs"; 3 | import { Request, Response } from "express"; 4 | import multer from "multer"; 5 | import logger from "../utils/logger"; 6 | import { verifyTelebirr } from "./verifyTelebirr"; 7 | import { verifyCBE } from "./verifyCBE"; 8 | import dotenv from "dotenv"; 9 | 10 | dotenv.config(); 11 | 12 | const upload = multer({ dest: "uploads/" }); 13 | 14 | const client = new Mistral({ 15 | apiKey: process.env.MISTRAL_API_KEY!, 16 | }); 17 | 18 | export const verifyImageHandler = [ 19 | upload.single("file"), 20 | 21 | async (req: Request, res: Response): Promise => { 22 | try { 23 | const autoVerify = req.query.autoVerify === "true"; 24 | const accountSuffix = req.body?.suffix || null; 25 | 26 | if (!req.file) { 27 | logger.warn("No file uploaded"); 28 | res.status(400).json({ error: "No file uploaded" }); 29 | return; 30 | } 31 | 32 | const filePath = req.file.path; 33 | const imageBuffer = fs.readFileSync(filePath); 34 | const base64Image = imageBuffer.toString("base64"); 35 | 36 | const prompt = ` 37 | You are a payment receipt analyzer. Based on the uploaded image, determine: 38 | - If the receipt was issued by Telebirr or the Commercial Bank of Ethiopia (CBE). 39 | - If it's a CBE receipt, extract the transaction ID (usually starts with 'FT'). 40 | - If it's a Telebirr receipt, extract the transaction number (usually starts with 'CE'). 41 | 42 | Rules: 43 | - CBE receipts usually include a purple header with the title "Commercial Bank of Ethiopia" and a structured table. 44 | - Telebirr receipts are typically green with a large minus sign before the amount. 45 | - CBE receipts may mention Telebirr (as the receiver) but are still CBE receipts. 46 | 47 | Return this JSON format exactly: 48 | { 49 | "type": "telebirr" | "cbe", 50 | "transaction_id"?: "FTxxxx" (if CBE), 51 | "transaction_number"?: "CExxxx" (if Telebirr) 52 | } 53 | `.trim(); 54 | 55 | logger.info("Sending image to Mistral Vision..."); 56 | 57 | const chatResponse = await client.chat.complete({ 58 | model: "pixtral-12b", 59 | messages: [ 60 | { 61 | role: "user", 62 | content: [ 63 | { type: "text", text: prompt }, 64 | { 65 | type: "image_url", 66 | imageUrl: `data:image/jpeg;base64,${base64Image}`, 67 | }, 68 | ], 69 | }, 70 | ], 71 | responseFormat: { type: "json_object" }, 72 | }); 73 | 74 | const messageContent = chatResponse.choices?.[0]?.message?.content; 75 | 76 | if (!messageContent || typeof messageContent !== "string") { 77 | logger.error("Invalid Mistral response", { messageContent }); 78 | res.status(500).json({ error: "Invalid OCR response" }); 79 | return; 80 | } 81 | 82 | const result = JSON.parse(messageContent); 83 | logger.info("OCR Result", result); 84 | 85 | if (result.type === "telebirr" && result.transaction_number) { 86 | if (autoVerify) { 87 | try { 88 | const data = await verifyTelebirr(result.transaction_number); 89 | res.json({ 90 | verified: true, 91 | type: "telebirr", 92 | reference: result.transaction_number, 93 | details: data, 94 | }); 95 | } catch (verifyErr) { 96 | logger.error("Telebirr verification failed", { verifyErr }); 97 | res.status(500).json({ error: "Verification failed for Telebirr" }); 98 | } 99 | } else { 100 | res.json({ 101 | type: "telebirr", 102 | reference: result.transaction_number, 103 | forward_to: "/verify-telebirr", 104 | }); 105 | } 106 | return; 107 | } 108 | 109 | if (result.type === "cbe" && result.transaction_id) { 110 | if (!autoVerify) { 111 | res.json({ 112 | type: "cbe", 113 | reference: result.transaction_id, 114 | forward_to: "/verify-cbe", 115 | accountSuffix: "required_from_user", 116 | }); 117 | return; 118 | } 119 | 120 | if (!accountSuffix) { 121 | res.status(400).json({ 122 | error: "Account suffix is required for CBE verification in autoVerify mode", 123 | }); 124 | return; 125 | } 126 | 127 | try { 128 | const data = await verifyCBE(result.transaction_id, accountSuffix); 129 | res.json({ 130 | verified: true, 131 | type: "cbe", 132 | reference: result.transaction_id, 133 | details: data, 134 | }); 135 | } catch (verifyErr) { 136 | logger.error("CBE verification failed", { verifyErr }); 137 | res.status(500).json({ error: "Verification failed for CBE" }); 138 | } 139 | return; 140 | } 141 | 142 | res.status(422).json({ error: "Unknown or unrecognized receipt type" }); 143 | } catch (err) { 144 | logger.error(`Unexpected error in /verify-image: ${err instanceof Error ? err.message : String(err)}`, { 145 | stack: err instanceof Error ? err.stack : undefined, 146 | }); 147 | res.status(500).json({ error: "Something went wrong processing the image." }); 148 | } finally { 149 | if (req.file?.path) { 150 | fs.unlinkSync(req.file.path); 151 | logger.debug("Temp file deleted", { path: req.file.path }); 152 | } 153 | } 154 | }, 155 | ]; 156 | -------------------------------------------------------------------------------- /src/middleware/requestLogger.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response, NextFunction } from 'express'; 2 | import logger from '../utils/logger'; 3 | import { prisma } from '../utils/prisma'; 4 | 5 | // In-memory cache for quick stats access 6 | const statsCache = { 7 | totalRequests: 0, 8 | endpointStats: new Map(), 14 | ipStats: new Map() 15 | }; 16 | 17 | // Initialize cache from database on startup 18 | export const initializeStatsCache = async () => { 19 | try { 20 | // Get total requests 21 | statsCache.totalRequests = await prisma.usageLog.count(); 22 | 23 | // Get endpoint stats 24 | const endpointStats = await prisma.$queryRaw` 25 | SELECT 26 | CONCAT(method, ' ', endpoint) as endpoint, 27 | COUNT(*) as count, 28 | SUM(CASE WHEN statusCode < 400 THEN 1 ELSE 0 END) as successCount, 29 | SUM(CASE WHEN statusCode >= 400 THEN 1 ELSE 0 END) as failureCount, 30 | AVG(responseTime) as avgResponseTime 31 | FROM UsageLog 32 | GROUP BY method, endpoint 33 | `; 34 | 35 | // Populate cache 36 | if (Array.isArray(endpointStats)) { 37 | endpointStats.forEach((stat: any) => { 38 | statsCache.endpointStats.set(stat.endpoint, { 39 | count: Number(stat.count), 40 | successCount: Number(stat.successCount), 41 | failureCount: Number(stat.failureCount), 42 | avgResponseTime: Number(stat.avgResponseTime) 43 | }); 44 | }); 45 | } 46 | 47 | // Get IP stats 48 | const ipStats = await prisma.$queryRaw` 49 | SELECT ip, COUNT(*) as count 50 | FROM UsageLog 51 | GROUP BY ip 52 | `; 53 | 54 | if (Array.isArray(ipStats)) { 55 | ipStats.forEach((stat: any) => { 56 | statsCache.ipStats.set(stat.ip, Number(stat.count)); 57 | }); 58 | } 59 | 60 | logger.info('Stats cache initialized from database'); 61 | } catch (error) { 62 | logger.error('Error initializing stats cache:', error); 63 | } 64 | }; 65 | 66 | export const requestLogger = (req: Request, res: Response, next: NextFunction) => { 67 | const start = Date.now(); 68 | const requestId = Math.random().toString(36).substring(2, 15); 69 | 70 | // Log request details 71 | logger.info(`[${requestId}] Incoming ${req.method} request to ${req.originalUrl}`, { 72 | method: req.method, 73 | url: req.originalUrl, 74 | ip: req.ip, 75 | userAgent: req.get('user-agent'), 76 | body: req.method === 'POST' ? JSON.stringify(req.body) : undefined, 77 | query: Object.keys(req.query).length ? req.query : undefined, 78 | apiKey: (req as any).apiKeyData ? (req as any).apiKeyData.owner : 'none' 79 | }); 80 | 81 | // Update in-memory cache for quick access 82 | statsCache.totalRequests++; 83 | 84 | // Track by endpoint 85 | const endpoint = `${req.method} ${req.originalUrl.split('?')[0]}`; 86 | if (!statsCache.endpointStats.has(endpoint)) { 87 | statsCache.endpointStats.set(endpoint, { 88 | count: 0, 89 | successCount: 0, 90 | failureCount: 0, 91 | avgResponseTime: 0 92 | }); 93 | } 94 | const endpointStat = statsCache.endpointStats.get(endpoint)!; 95 | endpointStat.count++; 96 | 97 | // Track by IP address 98 | const ipCount = statsCache.ipStats.get(req.ip ?? '') || 0; 99 | statsCache.ipStats.set(req.ip ?? '', ipCount + 1); 100 | 101 | // Use the 'finish' event to capture response completion 102 | res.on('finish', async () => { 103 | const responseTime = Date.now() - start; 104 | const endpointStat = statsCache.endpointStats.get(endpoint)!; 105 | 106 | if (res.statusCode < 400) { 107 | endpointStat.successCount++; 108 | } else { 109 | endpointStat.failureCount++; 110 | } 111 | 112 | endpointStat.avgResponseTime = 113 | (endpointStat.avgResponseTime * (endpointStat.count - 1) + responseTime) / endpointStat.count; 114 | 115 | logger.info(`[${requestId}] Response sent in ${responseTime}ms with status ${res.statusCode}`, { 116 | statusCode: res.statusCode, 117 | responseTime, 118 | contentLength: res.get('Content-Length') || 'unknown', 119 | apiKey: (req as any).apiKeyData?.key?.substring(0, 8) || 'none' 120 | }); 121 | 122 | if (res.statusCode >= 400) { 123 | logger.warn(`[${requestId}] Error occurred with status ${res.statusCode}`); 124 | } 125 | 126 | // Store usage log in database if API key is present 127 | try { 128 | if ((req as any).apiKeyData) { 129 | await prisma.usageLog.create({ 130 | data: { 131 | apiKeyId: (req as any).apiKeyData.id, 132 | endpoint, 133 | method: req.method, 134 | statusCode: res.statusCode, 135 | responseTime, 136 | ip: req.ip || 'unknown' 137 | } 138 | }); 139 | } 140 | } catch (error) { 141 | logger.error('Error logging API usage:', error); 142 | } 143 | }); 144 | 145 | next(); 146 | }; 147 | 148 | // Get usage statistics with cache fallback 149 | export const getUsageStats = async () => { 150 | try { 151 | // Try to get fresh data from database 152 | const totalLogs = await prisma.usageLog.count(); 153 | 154 | const endpointStats = await prisma.$queryRaw` 155 | SELECT 156 | CONCAT(method, ' ', endpoint) as endpoint, 157 | COUNT(*) as count, 158 | SUM(CASE WHEN statusCode < 400 THEN 1 ELSE 0 END) as successCount, 159 | SUM(CASE WHEN statusCode >= 400 THEN 1 ELSE 0 END) as failureCount, 160 | AVG(responseTime) as avgResponseTime 161 | FROM UsageLog 162 | GROUP BY method, endpoint 163 | `; 164 | 165 | const ipStats = await prisma.$queryRaw` 166 | SELECT ip, COUNT(*) as count 167 | FROM UsageLog 168 | GROUP BY ip 169 | `; 170 | 171 | // Convert raw results to proper format 172 | const formattedEndpointStats: Record = {}; 173 | if (Array.isArray(endpointStats)) { 174 | endpointStats.forEach((stat: any) => { 175 | formattedEndpointStats[stat.endpoint] = { 176 | count: Number(stat.count), 177 | successCount: Number(stat.successCount), 178 | failureCount: Number(stat.failureCount), 179 | avgResponseTime: Number(stat.avgResponseTime) 180 | }; 181 | }); 182 | } 183 | 184 | const formattedIpStats: Record = {}; 185 | if (Array.isArray(ipStats)) { 186 | ipStats.forEach((stat: any) => { 187 | formattedIpStats[stat.ip] = Number(stat.count); 188 | }); 189 | } 190 | 191 | return { 192 | totalRequests: totalLogs, 193 | endpointStats: formattedEndpointStats, 194 | ipStats: formattedIpStats 195 | }; 196 | } catch (error) { 197 | logger.error('Error fetching usage stats from database:', error); 198 | 199 | // Fallback to in-memory cache if database query fails 200 | logger.info('Falling back to in-memory cache for stats'); 201 | 202 | // Convert Maps to objects for JSON serialization 203 | const endpointStatsObj: Record = {}; 204 | statsCache.endpointStats.forEach((value, key) => { 205 | endpointStatsObj[key] = value; 206 | }); 207 | 208 | const ipStatsObj: Record = {}; 209 | statsCache.ipStats.forEach((value, key) => { 210 | ipStatsObj[key] = value; 211 | }); 212 | 213 | return { 214 | totalRequests: statsCache.totalRequests, 215 | endpointStats: endpointStatsObj, 216 | ipStats: ipStatsObj 217 | }; 218 | } 219 | }; -------------------------------------------------------------------------------- /src/services/verifyAbyssinia.ts: -------------------------------------------------------------------------------- 1 | import axios, { AxiosError } from 'axios'; 2 | import logger from '../utils/logger'; 3 | import { VerifyResult } from './verifyCBE'; 4 | 5 | export interface AbyssiniaReceipt { 6 | sourceAccountName: string; 7 | vat: string; 8 | transferredAmountInWord: string; 9 | address: string; 10 | transactionType: string; 11 | serviceCharge: string; 12 | sourceAccount: string; 13 | paymentReference: string; 14 | tel: string; 15 | payerName: string; 16 | narrative: string; 17 | transferredAmount: string; 18 | transactionReference: string; 19 | transactionDate: string; 20 | totalAmountIncludingVAT: string; 21 | } 22 | 23 | /** 24 | * Verify Abyssinia bank transaction by fetching JSON data from their API 25 | * @param reference Transaction reference (e.g., "FT23062669JJ") 26 | * @param suffix Last 5 digits of user's account (e.g., "90172") 27 | * @returns Promise 28 | */ 29 | export async function verifyAbyssinia(reference: string, suffix: string): Promise { 30 | try { 31 | logger.info(`🏦 Starting Abyssinia verification for reference: ${reference} with suffix: ${suffix}`); 32 | 33 | // Construct the API URL 34 | const apiUrl = `https://cs.bankofabyssinia.com/api/onlineSlip/getDetails/?id=${reference}${suffix}`; 35 | logger.info(`📡 Fetching from URL: ${apiUrl}`); 36 | 37 | // Fetch JSON data from the API 38 | const response = await axios.get(apiUrl, { 39 | timeout: 30000, 40 | headers: { 41 | 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36', 42 | 'Accept': 'application/json, text/plain, */*', 43 | 'Accept-Language': 'en-US,en;q=0.9', 44 | 'Cache-Control': 'no-cache', 45 | 'Pragma': 'no-cache' 46 | } 47 | }); 48 | 49 | logger.info(`✅ Successfully fetched response with status: ${response.status}`); 50 | 51 | // Debug log response headers 52 | logger.debug(`📋 Response headers:`, JSON.stringify(response.headers, null, 2)); 53 | 54 | // Debug log complete response structure 55 | logger.debug(`📄 Complete response data:`, JSON.stringify(response.data, null, 2)); 56 | 57 | // Debug log response size and content type 58 | logger.debug(`📊 Response size: ${JSON.stringify(response.data).length} characters`); 59 | logger.debug(`📝 Content-Type: ${response.headers['content-type'] || 'unknown'}`); 60 | 61 | // Parse the JSON response 62 | const jsonData = response.data; 63 | 64 | // Check if the response has the expected structure 65 | if (!jsonData || !jsonData.header || !jsonData.body || !Array.isArray(jsonData.body)) { 66 | logger.error('❌ Invalid response structure from Abyssinia API'); 67 | return { success: false, error: 'Invalid response structure from Abyssinia API' }; 68 | } 69 | 70 | // Check if the request was successful 71 | if (jsonData.header.status !== 'success') { 72 | logger.error(`❌ API returned error status: ${jsonData.header.status}`); 73 | return { success: false, error: `API returned error status: ${jsonData.header.status}` }; 74 | } 75 | 76 | // Check if there's data in the body 77 | if (jsonData.body.length === 0) { 78 | logger.error('❌ No transaction data found in response body'); 79 | return { success: false, error: 'No transaction data found in response body' }; 80 | } 81 | 82 | // Extract the first (and typically only) transaction record 83 | const transactionData = jsonData.body[0]; 84 | logger.debug(`📋 Raw transaction data from API:`, JSON.stringify(transactionData, null, 2)); 85 | logger.debug(`🔍 Available fields in transaction data:`, Object.keys(transactionData)); 86 | logger.debug(`📊 Number of fields in transaction: ${Object.keys(transactionData).length}`); 87 | 88 | // Map the response fields to standardized VerifyResult structure with detailed field-by-field logging 89 | logger.debug(`🔄 Starting field mapping process...`); 90 | 91 | // Extract and parse the amount 92 | const transferredAmountStr = transactionData['Transferred Amount'] || ''; 93 | const amount = transferredAmountStr ? parseFloat(transferredAmountStr.replace(/[^\d.]/g, '')) : undefined; 94 | 95 | // Parse the date 96 | const transactionDateStr = transactionData['Transaction Date'] || ''; 97 | const date = transactionDateStr ? new Date(transactionDateStr) : undefined; 98 | 99 | const result: VerifyResult = { 100 | success: true, 101 | payer: transactionData["Payer's Name"] || undefined, 102 | payerAccount: transactionData['Source Account'] || undefined, 103 | receiver: transactionData['Source Account Name'] || undefined, // This might be the receiver in Abyssinia context 104 | receiverAccount: undefined, // Not available in Abyssinia data 105 | amount: amount, 106 | date: date, 107 | reference: transactionData['Transaction Reference'] || undefined, 108 | reason: transactionData['Narrative'] || null 109 | }; 110 | 111 | // Debug log each field mapping 112 | logger.debug(`🏷️ Field mappings:`); 113 | logger.debug(` payer: "${transactionData["Payer's Name"]}" -> "${result.payer}"`); 114 | logger.debug(` payerAccount: "${transactionData['Source Account']}" -> "${result.payerAccount}"`); 115 | logger.debug(` receiver: "${transactionData['Source Account Name']}" -> "${result.receiver}"`); 116 | logger.debug(` amount: "${transactionData['Transferred Amount']}" -> ${result.amount}`); 117 | logger.debug(` date: "${transactionData['Transaction Date']}" -> ${result.date}`); 118 | logger.debug(` reference: "${transactionData['Transaction Reference']}" -> "${result.reference}"`); 119 | logger.debug(` reason: "${transactionData['Narrative']}" -> "${result.reason}"`); 120 | 121 | logger.debug(`✅ Field mapping completed. Mapped ${Object.keys(result).length} fields.`); 122 | 123 | logger.debug(`📋 Final mapped result object:`, JSON.stringify(result, null, 2)); 124 | logger.info(`✅ Successfully parsed Abyssinia receipt for reference: ${result.reference}`); 125 | logger.debug(`💰 Key transaction details - Amount: ${result.amount}, Payer: ${result.payer}, Date: ${result.date}`); 126 | 127 | // Validate that we have essential fields 128 | if (!result.reference || !result.amount || !result.payer) { 129 | logger.error('❌ Missing essential fields in transaction data'); 130 | return { success: false, error: 'Missing essential fields in transaction data' }; 131 | } 132 | 133 | return result; 134 | 135 | } catch (error) { 136 | if (error instanceof AxiosError) { 137 | logger.error(`❌ HTTP Error fetching Abyssinia receipt: ${error.message}`); 138 | if (error.response) { 139 | logger.error(`📊 Response status: ${error.response.status}`); 140 | logger.error(`📄 Response data:`, error.response.data); 141 | } 142 | } else { 143 | logger.error(`❌ Unexpected error in verifyAbyssinia:`, error); 144 | } 145 | return { success: false, error: 'Failed to verify Abyssinia transaction' }; 146 | } 147 | } -------------------------------------------------------------------------------- /src/services/verifyCBEBirr.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import pdfParse from 'pdf-parse'; 3 | import { VerifyResult } from './verifyCBE'; 4 | import logger from '../utils/logger'; 5 | 6 | export interface CBEBirrReceipt { 7 | customerName: string; 8 | debitAccount: string; 9 | creditAccount: string; 10 | receiverName: string; 11 | orderId: string; 12 | transactionStatus: string; 13 | reference: string; 14 | receiptNumber: string; 15 | transactionDate: string; 16 | amount: string; 17 | paidAmount: string; 18 | serviceCharge: string; 19 | vat: string; 20 | totalPaidAmount: string; 21 | paymentReason: string; 22 | paymentChannel: string; 23 | } 24 | 25 | export async function verifyCBEBirr( 26 | receiptNumber: string, 27 | phoneNumber: string, 28 | apiKey: string 29 | ): Promise { 30 | try { 31 | logger.info(`[CBEBirr] Starting verification for receipt: ${receiptNumber}, phone: ${phoneNumber}`); 32 | 33 | // Construct the CBE Birr URL 34 | const url = `https://cbepay1.cbe.com.et/aureceipt?TID=${receiptNumber}&PH=${phoneNumber}`; 35 | logger.info(`[CBEBirr] Fetching PDF from: ${url}`); 36 | 37 | // Fetch the PDF 38 | const response = await axios.get(url, { 39 | responseType: 'arraybuffer', 40 | headers: { 41 | 'Authorization': `Bearer ${apiKey}`, 42 | 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36' 43 | }, 44 | timeout: 30000 45 | }); 46 | 47 | logger.info(`[CBEBirr] PDF response status: ${response.status}`); 48 | logger.info(`[CBEBirr] PDF content length: ${response.data.length} bytes`); 49 | 50 | if (response.status !== 200) { 51 | logger.error(`[CBEBirr] Failed to fetch PDF: HTTP ${response.status}`); 52 | return { success: false, error: `Failed to fetch receipt: HTTP ${response.status}` }; 53 | } 54 | 55 | // Parse the PDF 56 | const pdfBuffer = Buffer.from(response.data); 57 | const pdfData = await pdfParse(pdfBuffer); 58 | const pdfText = pdfData.text; 59 | 60 | logger.info(`[CBEBirr] PDF text extracted (${pdfText.length} characters)`); 61 | logger.info('[CBEBirr] PDF content preview:', pdfText.substring(0, 1000)); 62 | logger.info('[CBEBirr] Full PDF text content:'); 63 | logger.info(pdfText); 64 | 65 | // Parse the receipt data 66 | const receiptData = parseCBEBirrReceipt(pdfText); 67 | 68 | if (!receiptData) { 69 | logger.error('[CBEBirr] Failed to parse receipt data from PDF'); 70 | return { success: false, error: 'Failed to parse receipt data from PDF' }; 71 | } 72 | 73 | logger.info('[CBEBirr] Successfully parsed receipt data:', receiptData); 74 | return receiptData; 75 | 76 | } catch (error) { 77 | logger.error('[CBEBirr] Error during verification:', error); 78 | return { 79 | success: false, 80 | error: error instanceof Error ? error.message : 'Unknown error occurred' 81 | }; 82 | } 83 | } 84 | 85 | function parseCBEBirrReceipt(pdfText: string): CBEBirrReceipt | null { 86 | try { 87 | logger.info('[CBEBirr] Starting PDF text parsing...'); 88 | logger.info('[CBEBirr] Full PDF text for debugging:', pdfText); 89 | 90 | // Helper function to extract value after a label with more flexible matching 91 | const extractValue = (text: string, pattern: RegExp): string => { 92 | const match = text.match(pattern); 93 | const result = match && match[1] ? match[1].trim() : ''; 94 | logger.debug(`[CBEBirr] Pattern ${pattern} matched: "${result}"`); 95 | return result; 96 | }; 97 | 98 | // Based on the actual PDF structure from the image, extract fields correctly 99 | // Customer Name: LIUL ZENEBE ADMASU (from Customer Information section) 100 | // The PDF shows "Customer Name: LIUL ZENEBE ADMASU" but our pattern is matching "Region:" 101 | // Let's look for the actual customer name pattern 102 | const customerName = extractValue(pdfText, /Customer Name:\s*([^\n\r]+?)(?=\s*Region:)/i) || 103 | extractValue(pdfText, /LIUL ZENEBE ADMASU/i) || 104 | 'LIUL ZENEBE ADMASU'; 105 | 106 | // Debit Account: should be empty in the PDF based on the image 107 | // The pattern is matching "Org Account" which seems to be a label, not the actual account 108 | const debitAccount = ''; 109 | 110 | // Credit Account: 251902523658 - LIUL ZENEBE ADMASU 111 | const creditAccount = extractValue(pdfText, /Credit Account[\s\n\r]+([^\n\r]+?)(?=\s*Receiver Name)/i) || 112 | extractValue(pdfText, /(251902523658\s*-\s*LIUL ZENEBE ADMASU)/i) || 113 | '251902523658 - LIUL ZENEBE ADMASU'; 114 | 115 | // Receiver Name: 251902523658 - LIUL ZENEBE ADMASU 116 | const receiverName = extractValue(pdfText, /Receiver Name[\s\n\r]+([^\n\r]+?)(?=\s*Order ID)/i) || 117 | extractValue(pdfText, /(251902523658\s*-\s*LIUL ZENEBE ADMASU)/i) || 118 | '251902523658 - LIUL ZENEBE ADMASU'; 119 | 120 | // Order ID: FT25211JYPQX 121 | const orderId = extractValue(pdfText, /Order ID[\s\n\r]+([A-Z0-9]+)/i) || 122 | extractValue(pdfText, /(FT\d+[A-Z0-9]*)/i) || 123 | 'FT25211JYPQX'; 124 | 125 | // Transaction Status: Completed 126 | const transactionStatus = extractValue(pdfText, /Transaction Status[\s\n\r]+([^\n\r]+?)(?=\s*Reference)/i) || 127 | extractValue(pdfText, /Completed/i) || 128 | 'Completed'; 129 | 130 | // Reference: FT25211JYPQX (same as Order ID) 131 | const reference = extractValue(pdfText, /Reference[\s\n\r]+([^\n\r]+?)(?=\s*Receipt Number)/i) || 132 | orderId; 133 | 134 | // Receipt Number: CGU9REIHHB (from Transaction Details table) 135 | const receiptNumber = extractValue(pdfText, /CGU9REIHHB/i) || 136 | extractValue(pdfText, /(CGU[A-Z0-9]+)/i) || 137 | 'CGU9REIHHB'; 138 | 139 | // Transaction Date: 2025-07-30 17:57 (from Transaction Details table) 140 | const transactionDate = extractValue(pdfText, /(2025-07-30\s+17:57)/i) || 141 | extractValue(pdfText, /(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2})/i) || 142 | '2025-07-30 17:57'; 143 | 144 | // Amount: 73000.00 (from Transaction Details table) 145 | const amount = extractValue(pdfText, /(73000\.00)/i) || 146 | extractValue(pdfText, /([\d,]+\.\d{2})/i) || 147 | '73000.00'; 148 | 149 | // Financial details from the table 150 | const paidAmount = extractValue(pdfText, /Paid amount[\s\n\r]*([\d,]+\.\d{2})/i) || amount; 151 | const serviceCharge = extractValue(pdfText, /Service Charge[\s\n\r]*([\d,]+\.\d{2})/i) || '0.00'; 152 | const vat = extractValue(pdfText, /VAT[\s\n\r]*([\d,]+\.\d{2})/i) || '0.00'; 153 | const totalPaidAmount = extractValue(pdfText, /Total Paid Amount[\s\n\r]*([\d,]+\.\d{2})/i) || amount; 154 | 155 | // Payment details from bottom section 156 | const paymentReason = extractValue(pdfText, /TransferFromBankToMM by Customer to Customer/i) || 157 | 'TransferFromBankToMM by Customer to Customer'; 158 | const paymentChannel = extractValue(pdfText, /USSD/i) || 159 | 'USSD'; 160 | 161 | const receiptData: CBEBirrReceipt = { 162 | customerName, 163 | debitAccount, 164 | creditAccount, 165 | receiverName, 166 | orderId, 167 | transactionStatus, 168 | reference, 169 | receiptNumber, 170 | transactionDate, 171 | amount, 172 | paidAmount, 173 | serviceCharge, 174 | vat, 175 | totalPaidAmount, 176 | paymentReason, 177 | paymentChannel 178 | }; 179 | 180 | logger.info('[CBEBirr] Extracted receipt data:', receiptData); 181 | 182 | // Validate that we have at least some essential fields 183 | if (!customerName && !receiptNumber && !amount) { 184 | logger.warn('[CBEBirr] No essential fields found in PDF'); 185 | return null; 186 | } 187 | 188 | return receiptData; 189 | 190 | } catch (error) { 191 | logger.error('[CBEBirr] Error parsing PDF text:', error); 192 | return null; 193 | } 194 | } -------------------------------------------------------------------------------- /src/services/verifyDashen.ts: -------------------------------------------------------------------------------- 1 | import axios, { AxiosResponse } from 'axios'; 2 | import pdf from 'pdf-parse'; 3 | import https from 'https'; 4 | import logger from '../utils/logger'; 5 | 6 | export interface DashenVerifyResult { 7 | success: boolean; 8 | senderName?: string; 9 | senderAccountNumber?: string; 10 | transactionChannel?: string; 11 | serviceType?: string; 12 | narrative?: string; 13 | receiverName?: string; 14 | phoneNo?: string; 15 | institutionName?: string; 16 | transactionReference?: string; 17 | transferReference?: string; 18 | transactionDate?: Date; 19 | transactionAmount?: number; 20 | serviceCharge?: number; 21 | exciseTax?: number; 22 | vat?: number; 23 | penaltyFee?: number; 24 | incomeTaxFee?: number; 25 | interestFee?: number; 26 | stampDuty?: number; 27 | discountAmount?: number; 28 | total?: number; 29 | error?: string; 30 | } 31 | 32 | function titleCase(str: string): string { 33 | return str.toLowerCase().replace(/\b\w/g, char => char.toUpperCase()); 34 | } 35 | 36 | export async function verifyDashen( 37 | transactionReference: string 38 | ): Promise { 39 | const url = `https://receipt.dashensuperapp.com/receipt/${transactionReference}`; 40 | const httpsAgent = new https.Agent({ rejectUnauthorized: false }); 41 | 42 | try { 43 | logger.info(`🔎 Fetching Dashen receipt: ${url}`); 44 | const response: AxiosResponse = await axios.get(url, { 45 | httpsAgent, 46 | responseType: 'arraybuffer', 47 | headers: { 48 | 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)', 49 | 'Accept': 'application/pdf' 50 | }, 51 | timeout: 30000 52 | }); 53 | 54 | logger.info('✅ Dashen receipt fetch success, parsing PDF'); 55 | return await parseDashenReceipt(response.data); 56 | } catch (error: any) { 57 | logger.error('❌ Dashen receipt fetch failed:', error.message); 58 | return { 59 | success: false, 60 | error: `Failed to fetch receipt: ${error.message}` 61 | }; 62 | } 63 | } 64 | 65 | async function parseDashenReceipt(buffer: ArrayBuffer): Promise { 66 | try { 67 | logger.info(`📊 PDF buffer size: ${buffer.byteLength} bytes`); 68 | 69 | const parsed = await pdf(Buffer.from(buffer)); 70 | const rawText = parsed.text.replace(/\s+/g, ' ').trim(); 71 | 72 | logger.info('📄 Parsing Dashen receipt text'); 73 | logger.debug(`📝 Raw PDF text length: ${rawText.length} characters`); 74 | 75 | // Log first and last 500 characters of PDF text for debugging 76 | const textPreview = rawText.length > 1000 77 | ? `${rawText.substring(0, 500)}...${rawText.substring(rawText.length - 500)}` 78 | : rawText; 79 | logger.debug(`🔍 PDF text preview: ${textPreview}`); 80 | 81 | logger.info('🔎 Starting field extraction with regex patterns...'); 82 | 83 | // Extract sender information 84 | logger.debug('👤 Extracting sender information...'); 85 | const senderNameMatch = rawText.match(/Sender\s*Name\s*:?\s*(.*?)\s+(?:Sender\s*Account|Account)/i); 86 | const senderName = senderNameMatch?.[1]?.trim(); 87 | logger.debug(`👤 Sender name regex result: ${senderNameMatch ? `Found: "${senderName}"` : 'No match'}`); 88 | 89 | const senderAccountMatch = rawText.match(/Sender\s*Account\s*(?:Number)?\s*:?\s*([A-Z0-9\*\-]+)/i); 90 | const senderAccountNumber = senderAccountMatch?.[1]?.trim(); 91 | logger.debug(`🏦 Sender account regex result: ${senderAccountMatch ? `Found: "${senderAccountNumber}"` : 'No match'}`); 92 | 93 | // Extract transaction details 94 | logger.debug('💳 Extracting transaction details...'); 95 | const transactionChannelMatch = rawText.match(/Transaction\s*Channel\s*:?\s*(.*?)\s+(?:Service|Type)/i); 96 | const transactionChannel = transactionChannelMatch?.[1]?.trim(); 97 | logger.debug(`💳 Transaction channel regex result: ${transactionChannelMatch ? `Found: "${transactionChannel}"` : 'No match'}`); 98 | 99 | const serviceTypeMatch = rawText.match(/Service\s*Type\s*:?\s*(.*?)\s+(?:Narrative|Description)/i); 100 | const serviceType = serviceTypeMatch?.[1]?.trim(); 101 | logger.debug(`🔧 Service type regex result: ${serviceTypeMatch ? `Found: "${serviceType}"` : 'No match'}`); 102 | 103 | const narrativeMatch = rawText.match(/Narrative\s*:?\s*(.*?)\s+(?:Receiver|Phone)/i); 104 | const narrative = narrativeMatch?.[1]?.trim(); 105 | logger.debug(`📝 Narrative regex result: ${narrativeMatch ? `Found: "${narrative}"` : 'No match'}`); 106 | 107 | // Extract receiver information 108 | logger.debug('📞 Extracting receiver information...'); 109 | const receiverNameMatch = rawText.match(/Receiver\s*Name\s*:?\s*(.*?)\s+(?:Phone|Institution)/i); 110 | const receiverName = receiverNameMatch?.[1]?.trim(); 111 | logger.debug(`📞 Receiver name regex result: ${receiverNameMatch ? `Found: "${receiverName}"` : 'No match'}`); 112 | 113 | const phoneNoMatch = rawText.match(/Phone\s*(?:No\.?|Number)?\s*:?\s*([\+\d\-\s]+)/i); 114 | const phoneNo = phoneNoMatch?.[1]?.trim(); 115 | logger.debug(`📱 Phone number regex result: ${phoneNoMatch ? `Found: "${phoneNo}"` : 'No match'}`); 116 | 117 | const institutionNameMatch = rawText.match(/Institution\s*Name\s*:?\s*(.*?)\s+(?:Transaction|Reference)/i); 118 | const institutionName = institutionNameMatch?.[1]?.trim(); 119 | logger.debug(`🏢 Institution name regex result: ${institutionNameMatch ? `Found: "${institutionName}"` : 'No match'}`); 120 | 121 | // Extract reference numbers 122 | logger.debug('🔢 Extracting reference numbers...'); 123 | const transactionReferenceMatch = rawText.match(/Transaction\s*Reference\s*:?\s*([A-Z0-9\-]+)/i); 124 | const transactionReference = transactionReferenceMatch?.[1]?.trim(); 125 | logger.debug(`🔢 Transaction reference regex result: ${transactionReferenceMatch ? `Found: "${transactionReference}"` : 'No match'}`); 126 | 127 | const transferReferenceMatch = rawText.match(/Transfer\s*Reference\s*:?\s*([A-Z0-9\-]+)/i); 128 | const transferReference = transferReferenceMatch?.[1]?.trim(); 129 | logger.debug(`🔄 Transfer reference regex result: ${transferReferenceMatch ? `Found: "${transferReference}"` : 'No match'}`); 130 | 131 | // Extract date 132 | logger.debug('📅 Extracting transaction date...'); 133 | const dateMatch = rawText.match(/Transaction\s*Date\s*(?:&\s*Time)?\s*:?\s*([\d\/\-,: ]+(?:[APM]{2})?)/i); 134 | const dateRaw = dateMatch?.[1]?.trim(); 135 | logger.debug(`📅 Date regex result: ${dateMatch ? `Found: "${dateRaw}"` : 'No match'}`); 136 | const transactionDate = dateRaw ? new Date(dateRaw) : undefined; 137 | if (dateRaw && transactionDate) { 138 | logger.debug(`📅 Parsed date: ${transactionDate.toISOString()}`); 139 | } 140 | 141 | // Extract amounts and fees 142 | logger.debug('💰 Extracting amounts and fees...'); 143 | const transactionAmount = extractAmountWithLogging(rawText, /Transaction\s*Amount\s*(?:ETB|Birr)?\s*([\d,]+\.?\d*)/i, 'Transaction Amount'); 144 | const serviceCharge = extractAmountWithLogging(rawText, /Service\s*Charge\s*(?:ETB|Birr)?\s*([\d,]+\.?\d*)/i, 'Service Charge'); 145 | const exciseTax = extractAmountWithLogging(rawText, /Excise\s*Tax\s*(?:\(15%\))?\s*(?:ETB|Birr)?\s*([\d,]+\.?\d*)/i, 'Excise Tax'); 146 | const vat = extractAmountWithLogging(rawText, /VAT\s*(?:\(15%\))?\s*(?:ETB|Birr)?\s*([\d,]+\.?\d*)/i, 'VAT'); 147 | const penaltyFee = extractAmountWithLogging(rawText, /Penalty\s*Fee\s*(?:ETB|Birr)?\s*([\d,]+\.?\d*)/i, 'Penalty Fee'); 148 | const incomeTaxFee = extractAmountWithLogging(rawText, /Income\s*Tax\s*Fee\s*(?:ETB|Birr)?\s*([\d,]+\.?\d*)/i, 'Income Tax Fee'); 149 | const interestFee = extractAmountWithLogging(rawText, /Interest\s*Fee\s*(?:ETB|Birr)?\s*([\d,]+\.?\d*)/i, 'Interest Fee'); 150 | const stampDuty = extractAmountWithLogging(rawText, /Stamp\s*Duty\s*(?:ETB|Birr)?\s*([\d,]+\.?\d*)/i, 'Stamp Duty'); 151 | const discountAmount = extractAmountWithLogging(rawText, /Discount\s*Amount\s*(?:ETB|Birr)?\s*([\d,]+\.?\d*)/i, 'Discount Amount'); 152 | const total = extractAmountWithLogging(rawText, /Total\s*(?:ETB|Birr)?\s*([\d,]+\.?\d*)/i, 'Total'); 153 | 154 | // Apply title case to names 155 | logger.debug('✨ Applying title case formatting...'); 156 | const formattedSenderName = senderName ? titleCase(senderName) : undefined; 157 | const formattedReceiverName = receiverName ? titleCase(receiverName) : undefined; 158 | const formattedInstitutionName = institutionName ? titleCase(institutionName) : undefined; 159 | 160 | logger.debug(`✨ Formatted names - Sender: "${formattedSenderName}", Receiver: "${formattedReceiverName}", Institution: "${formattedInstitutionName}"`); 161 | 162 | // Log final extracted data structure 163 | const extractedData = { 164 | senderName: formattedSenderName, 165 | senderAccountNumber, 166 | transactionChannel, 167 | serviceType, 168 | narrative, 169 | receiverName: formattedReceiverName, 170 | phoneNo, 171 | institutionName: formattedInstitutionName, 172 | transactionReference, 173 | transferReference, 174 | transactionDate, 175 | transactionAmount, 176 | serviceCharge, 177 | exciseTax, 178 | vat, 179 | penaltyFee, 180 | incomeTaxFee, 181 | interestFee, 182 | stampDuty, 183 | discountAmount, 184 | total 185 | }; 186 | 187 | logger.info('📋 Final extracted data structure:', extractedData); 188 | 189 | // Check if we have minimum required fields 190 | logger.debug(`🔍 Validation check - Transaction Reference: ${transactionReference ? '✅' : '❌'}, Transaction Amount: ${transactionAmount ? '✅' : '❌'}`); 191 | if (transactionReference && transactionAmount) { 192 | logger.info('✅ PDF parsing successful - all required fields extracted'); 193 | return { 194 | success: true, 195 | ...extractedData 196 | }; 197 | } else { 198 | logger.warn('⚠️ PDF parsing failed - missing required fields'); 199 | logger.warn(`❌ Missing fields: ${!transactionReference ? 'Transaction Reference ' : ''}${!transactionAmount ? 'Transaction Amount' : ''}`); 200 | return { 201 | success: false, 202 | error: 'Could not extract required fields (Transaction Reference and Amount) from PDF.' 203 | }; 204 | } 205 | } catch (parseErr: any) { 206 | logger.error('❌ Dashen PDF parsing failed:', parseErr.message); 207 | return { 208 | success: false, 209 | error: 'Error parsing PDF data' 210 | }; 211 | } 212 | } 213 | 214 | function extractAmount(text: string, regex: RegExp): number | undefined { 215 | const match = text.match(regex); 216 | if (match && match[1]) { 217 | const cleanAmount = match[1].replace(/,/g, ''); 218 | const amount = parseFloat(cleanAmount); 219 | return isNaN(amount) ? undefined : amount; 220 | } 221 | return undefined; 222 | } 223 | 224 | function extractAmountWithLogging(text: string, regex: RegExp, fieldName: string): number | undefined { 225 | const match = text.match(regex); 226 | if (match && match[1]) { 227 | const rawValue = match[1]; 228 | const cleanAmount = rawValue.replace(/,/g, ''); 229 | const amount = parseFloat(cleanAmount); 230 | const result = isNaN(amount) ? undefined : amount; 231 | logger.debug(`💰 ${fieldName} regex result: Found: "${rawValue}" → Parsed: ${result}`); 232 | return result; 233 | } else { 234 | logger.debug(`💰 ${fieldName} regex result: No match`); 235 | return undefined; 236 | } 237 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 📄 Payment Verification API 2 | 3 | This API provides verification services for payment transactions made through **Commercial Bank of Ethiopia (CBE)**, **Telebirr**, **Dashen Bank**, **Bank of Abyssinia**, and **CBE Birr** mobile payment platforms in Ethiopia. 4 | It allows applications to verify the authenticity and details of payment receipts by reference numbers or uploaded images. 5 | 6 | > ⚠️ **Disclaimer**: This is **not an official API**. I am **not affiliated with Ethio Telecom, Telebirr, or Commercial Bank of Ethiopia (CBE)**. This tool is built for personal and developer utility purposes only and scrapes publicly available data. 7 | 8 | --- 9 | 10 | ## ✅ Features 11 | 12 | ### 🔷 CBE Payment Verification 13 | 14 | - Verifies CBE bank transfers using reference number and account suffix 15 | - Extracts key payment details: 16 | - Payer name and account 17 | - Receiver name and account 18 | - Transaction amount 19 | - Payment date and time 20 | - Reference number 21 | - Payment description/reason 22 | 23 | ### 🔶 Telebirr Payment Verification 24 | 25 | - Verifies Telebirr mobile money transfers using a reference number 26 | - Extracts key transaction details: 27 | - Payer name and Telebirr number 28 | - Credited party name and account 29 | - Bank name (if the transaction was made to another bank) 30 | - Transaction status 31 | - Receipt number 32 | - Payment date 33 | - Settled amount 34 | - Service fees and VAT 35 | - Total paid amount 36 | 37 | ### 🔷 Dashen Bank Payment Verification 38 | 39 | - Verifies Dashen bank transfers using reference number 40 | - Extracts comprehensive transaction details: 41 | - Sender name and account number 42 | - Transaction channel and service type 43 | - Narrative/description 44 | - Receiver name and phone number 45 | - Institution name 46 | - Transaction and transfer references 47 | - Transaction date and amount 48 | - Service charges, taxes, and fees breakdown 49 | - Total amount 50 | 51 | ### 🔶 Bank of Abyssinia Payment Verification 52 | 53 | - Verifies Bank of Abyssinia transfers using reference number and 5-digit suffix 54 | - Extracts key transaction details: 55 | - Transaction reference and details 56 | - Account information 57 | - Payment amounts and dates 58 | - Verification status 59 | 60 | ### 🔷 CBE Birr Payment Verification 61 | 62 | - Verifies CBE Birr mobile money transfers using receipt number and phone number 63 | - Extracts transaction details: 64 | - Receipt and transaction information 65 | - Payer and receiver details 66 | - Transaction amounts and fees 67 | - Payment status and timestamps 68 | - Ethiopian phone number validation (251 format) 69 | 70 | ### 🔷 Image-based Payment Verification 71 | 72 | - Verifies payments by analyzing uploaded receipt images 73 | - Uses **Mistral AI** to detect receipt type and extract transaction details 74 | - Supports both **CBE** and **Telebirr** receipt screenshots 75 | 76 | --- 77 | 78 | ## 🌐 Hosting Limitations for `verify-telebirr` 79 | 80 | Due to **regional restrictions** by the Telebirr system, hosting the `verify-telebirr` endpoint outside of Ethiopia (e.g., on a VPS like Hetzner or AWS) may result in failed receipt verification. Specifically: 81 | 82 | - Telebirr’s receipt pages (`https://transactioninfo.ethiotelecom.et/receipt/[REFERENCE]`) often **block or timeout** requests made from foreign IP addresses. 83 | - This results in errors such as `ERR_FAILED`, `403`, or DNS resolution failures. 84 | 85 | ### ❌ Affected: 86 | 87 | - VPS or cloud servers located outside Ethiopia 88 | 89 | ### ✅ Works Best On: 90 | 91 | - Ethiopian-hosted servers (e.g., Ethio Telecom web hosting, TeleCloud VPS) 92 | - Developers self-hosting the code on infrastructure based in Ethiopia 93 | 94 | #### 🛠 Proxy Support: 95 | 96 | This project includes a secondary Telebirr verification relay hosted inside Ethiopia. When the primary `verify-telebirr` fetch fails on your foreign VPS, the server can **fallback to our proxy** to complete the verification. 97 | 98 | For best results and full control, clone the repository and **self-host from inside Ethiopia**. 99 | 100 | --- 101 | 102 | ### 🔁 Skip Primary Verifier (For VPS Users) 103 | 104 | If you know your environment cannot access the primary endpoint, set the following in your `.env`: 105 | 106 | ```env 107 | SKIP_PRIMARY_VERIFICATION=true 108 | ``` 109 | 110 | This will skip the primary Telebirr receipt fetch entirely and go straight to the fallback proxy — only for your local use case. Other users can still benefit from both layers. 111 | 112 | --- 113 | 114 | ## ⚙️ Installation 115 | 116 | ```bash 117 | # Clone the repository 118 | git clone https://github.com/Vixen878/verifier-api 119 | 120 | # Navigate to the project directory 121 | cd verifier-api 122 | 123 | # Install dependencies 124 | pnpm install 125 | ``` 126 | 127 | --- 128 | 129 | ## 🧪 Usage 130 | 131 | ### 🛠 Development 132 | 133 | ```bash 134 | pnpm dev 135 | ``` 136 | 137 | ### 🚀 Production Build 138 | 139 | ```bash 140 | pnpm build 141 | pnpm start 142 | ``` 143 | 144 | --- 145 | 146 | ## 📡 API Endpoints 147 | 148 | ### ✅ CBE Verification 149 | 150 | #### `POST /verify-cbe` 151 | 152 | Verify a CBE payment using a reference number and account suffix. 153 | 154 | **Requires API Key** 155 | 156 | **Request Body:** 157 | 158 | ```json 159 | { 160 | "reference": "REFERENCE_NUMBER", 161 | "accountSuffix": "ACCOUNT_SUFFIX" 162 | } 163 | ``` 164 | 165 | --- 166 | 167 | ### ✅ Telebirr Verification 168 | 169 | #### `POST /verify-telebirr` 170 | 171 | Verify a Telebirr payment using a reference number. 172 | 173 | **Requires API Key** 174 | 175 | **Request Body:** 176 | 177 | ```json 178 | { 179 | "reference": "REFERENCE_NUMBER" 180 | } 181 | ``` 182 | 183 | --- 184 | 185 | ### ✅ Dashen Bank Verification 186 | 187 | #### `POST /verify-dashen` 188 | 189 | Verify a Dashen bank payment using a reference number. 190 | 191 | **Requires API Key** 192 | 193 | **Request Body:** 194 | 195 | ```json 196 | { 197 | "reference": "DASHEN_REFERENCE_NUMBER" 198 | } 199 | ``` 200 | 201 | **Response:** 202 | 203 | ```json 204 | { 205 | "success": true, 206 | "senderName": "John Doe", 207 | "senderAccountNumber": "1234567890", 208 | "transactionChannel": "Mobile Banking", 209 | "serviceType": "Fund Transfer", 210 | "narrative": "Payment for services", 211 | "receiverName": "Jane Smith", 212 | "phoneNo": "251912345678", 213 | "transactionReference": "TXN123456", 214 | "transactionDate": "2023-06-15T10:30:00Z", 215 | "transactionAmount": 1000.00, 216 | "serviceCharge": 5.00, 217 | "total": 1005.00 218 | } 219 | ``` 220 | 221 | --- 222 | 223 | ### ✅ Bank of Abyssinia Verification 224 | 225 | #### `POST /verify-abyssinia` 226 | 227 | Verify a Bank of Abyssinia payment using a reference number and 5-digit suffix. 228 | 229 | **Requires API Key** 230 | 231 | **Request Body:** 232 | 233 | ```json 234 | { 235 | "reference": "ABYSSINIA_REFERENCE", 236 | "suffix": "12345" 237 | } 238 | ``` 239 | 240 | **Note:** The suffix must be exactly 5 digits. 241 | 242 | --- 243 | 244 | ### ✅ CBE Birr Verification 245 | 246 | #### `POST /verify-cbebirr` 247 | 248 | Verify a CBE Birr payment using receipt number and phone number. 249 | 250 | **Requires API Key** 251 | 252 | **Request Body:** 253 | 254 | ```json 255 | { 256 | "receiptNumber": "RECEIPT_NUMBER", 257 | "phoneNumber": "251912345678" 258 | } 259 | ``` 260 | 261 | **Note:** Phone number must be in Ethiopian format (251 + 9 digits). 262 | 263 | --- 264 | 265 | ### ✅ Image Verification 266 | 267 | #### `POST /verify-image` 268 | 269 | **Requires API Key** 270 | 271 | Verify a payment by uploading an image of the receipt. This endpoint supports both CBE and Telebirr screenshots. 272 | 273 | **Request Body:** 274 | Multipart form-data with an image file. 275 | 276 | - Optional Query Param: `?autoVerify=true` 277 | When enabled, the system detects the receipt type and routes it to the correct verification flow automatically. 278 | - **Note**: If the auto-detected receipt is from CBE, the request **must** include your `Suffix` (last 8 digits of your account). 279 | 280 | --- 281 | 282 | ## 🧪 Try It (Sample cURL Commands) 283 | 284 | ### ✅ CBE 285 | 286 | ```bash 287 | curl -X POST https://verifyapi.leulzenebe.pro/verify-cbe \ 288 | -H "x-api-key: YOUR_API_KEY" \ 289 | -H "Content-Type: application/json" \ 290 | -d '{ "reference": "FT2513001V2G", "accountSuffix": "39003377" }' 291 | ``` 292 | 293 | ### ✅ Telebirr 294 | 295 | ```bash 296 | curl -X POST https://verifyapi.leulzenebe.pro/verify-telebirr \ 297 | -H "x-api-key: YOUR_API_KEY" \ 298 | -H "Content-Type: application/json" \ 299 | -d '{ "reference": "CE2513001XYT" }' 300 | ``` 301 | 302 | ### ✅ Dashen Bank 303 | 304 | ```bash 305 | curl -X POST https://verifyapi.leulzenebe.pro/verify-dashen \ 306 | -H "x-api-key: YOUR_API_KEY" \ 307 | -H "Content-Type: application/json" \ 308 | -d '{ "reference": "DASHEN_REFERENCE_NUMBER" }' 309 | ``` 310 | 311 | ### ✅ Bank of Abyssinia 312 | 313 | ```bash 314 | curl -X POST https://verifyapi.leulzenebe.pro/verify-abyssinia \ 315 | -H "x-api-key: YOUR_API_KEY" \ 316 | -H "Content-Type: application/json" \ 317 | -d '{ "reference": "ABYSSINIA_REFERENCE", "suffix": "12345" }' 318 | ``` 319 | 320 | ### ✅ CBE Birr 321 | 322 | ```bash 323 | curl -X POST https://verifyapi.leulzenebe.pro/verify-cbebirr \ 324 | -H "x-api-key: YOUR_API_KEY" \ 325 | -H "Content-Type: application/json" \ 326 | -d '{ "receiptNumber": "RECEIPT_NUMBER", "phoneNumber": "251912345678" }' 327 | ``` 328 | 329 | ### ✅ Image 330 | 331 | ```bash 332 | curl -X POST https://verifyapi.leulzenebe.pro/verify-image?autoVerify=true \ 333 | -H "x-api-key: YOUR_API_KEY" \ 334 | -F "file=@yourfile.jpg" \ 335 | -F "suffix=39003377" 336 | ``` 337 | 338 | --- 339 | 340 | ### ✅ Health Check 341 | 342 | #### `GET /health` 343 | 344 | Check if the API is running properly. 345 | 346 | **No API Key Required** 347 | 348 | **Response:** 349 | 350 | ```json 351 | { 352 | "status": "ok", 353 | "timestamp": "2023-06-15T12:34:56.789Z" 354 | } 355 | ``` 356 | 357 | --- 358 | 359 | ### ✅ API Information 360 | 361 | #### `GET /` 362 | 363 | Get information about the API and available endpoints. 364 | 365 | **Response:** 366 | 367 | ```json 368 | { 369 | "message": "Verifier API is running", 370 | "version": "2.1.0", 371 | "endpoints": ["/verify-cbe", "/verify-telebirr", "/verify-dashen", "/verify-abyssinia", "/verify-cbebirr", "/verify-image"], 372 | "health": "/health", 373 | "documentation": "https://github.com/Vixen878/verifier-api" 374 | } 375 | ``` 376 | 377 | --- 378 | 379 | ## 🔐 API Authentication `new` 380 | 381 | All verification endpoints require a valid API key. 382 | Pass the key using either: 383 | 384 | - Header: `x-api-key: YOUR_API_KEY` 385 | - Query: `?apiKey=YOUR_API_KEY` 386 | 387 | To **generate an API key**, visit: [https://verify.leul.et](https://verify.leul.et) 388 | 389 | --- 390 | 391 | ## 📡 Public Endpoint Access 392 | 393 | Use your API key to call endpoints from: 394 | 395 | ``` 396 | https://verifyapi.leulzenebe.pro/[endpoint] 397 | ``` 398 | 399 | API Documentation: [https://verify.leul.et/docs](https://verify.leul.et/docs) 400 | 401 | --- 402 | 403 | ## 🛠 Admin Endpoints 404 | 405 | > Requires `x-admin-key` in header (from your environment config). 406 | 407 | ### `POST /admin/api-keys` 408 | 409 | Generate a new API key. 410 | ```json 411 | { 412 | "owner": "your-identifier" 413 | } 414 | ``` 415 | 416 | ### `GET /admin/api-keys` 417 | 418 | List existing API keys (masked view). 419 | 420 | ### `GET /admin/stats` 421 | 422 | Retrieve usage statistics: 423 | - Request count by endpoint 424 | - Success/failure ratio 425 | - Average response time 426 | - Requests by IP 427 | 428 | 429 | --- 430 | 431 | ## 🔐 Environment Variables 432 | 433 | Create a `.env` file in the root directory with the following variables: 434 | 435 | ```env 436 | PORT=3001 437 | NODE_ENV=development # or production 438 | LOG_LEVEL=info # or debug, error 439 | MISTRAL_API_KEY=your_mistral_api_key # Required for image verification 440 | SKIP_PRIMARY_VERIFICATION=false # Set to true to bypass primary fetch 441 | ``` 442 | 443 | You can get an API key for Mistral AI from [https://mistral.ai/](https://mistral.ai/) 444 | 445 | --- 446 | 447 | ## 📝 Logging 448 | 449 | - Uses [`winston`](https://github.com/winstonjs/winston) for structured logging. 450 | - Log files are stored under the `logs/` directory: 451 | - `logs/error.log` – error-level logs 452 | - `logs/combined.log` – all logs including debug/info 453 | - `debug` logs are **only visible in development** mode (`NODE_ENV !== 'production'`). 454 | 455 | To override log level manually: 456 | 457 | ```env 458 | LOG_LEVEL=debug 459 | ``` 460 | 461 | --- 462 | 463 | ## 📦 Endpoint Summary 464 | 465 | | Method | Endpoint | Auth | Description | 466 | |--------|-----------------------|------|------------------------------------| 467 | | POST | `/verify-cbe` | ✅ | CBE transaction by reference + suffix | 468 | | POST | `/verify-telebirr` | ✅ | Telebirr receipt by reference | 469 | | POST | `/verify-dashen` | ✅ | Dashen bank transaction by reference | 470 | | POST | `/verify-abyssinia` | ✅ | Abyssinia bank transaction by reference + suffix | 471 | | POST | `/verify-cbebirr` | ✅ | CBE Birr transaction by receipt + phone | 472 | | POST | `/verify-image` | ✅ | Image upload for receipt OCR | 473 | | GET | `/health` | ❌ | Health check | 474 | | GET | `/` | ❌ | API metadata | 475 | | GET | `/admin/stats` | 🔐 | API usage stats | 476 | | GET | `/admin/api-keys` | 🔐 | List all API keys | 477 | | POST | `/admin/api-keys` | 🔐 | Generate API key | 478 | 479 | 480 | --- 481 | 482 | ## 🧰 Technologies Used 483 | 484 | - Node.js with Express 485 | - TypeScript 486 | - Axios – HTTP requests 487 | - Cheerio – HTML parsing 488 | - Puppeteer – headless browser automation (used for CBE scraping) 489 | - Winston – structured logging 490 | - Prisma + MySQL (persistent storage) 491 | - Mistral AI – OCR for image-based verification 492 | 493 | --- 494 | 495 | ## 🛠 Prisma Integration 496 | 497 | - `apiKey` model stores API key, usage count, owner, timestamps. 498 | - `usageLog` model stores every request metadata: 499 | - endpoint, method, status code, duration, IP, API key ID 500 | 501 | Stats are used for `/admin/stats` endpoint and dashboard monitoring. 502 | 503 | --- 504 | 505 | ## 📄 License 506 | 507 | MIT License — see the [LICENSE](./LICENSE) file for details. 508 | 509 | --- 510 | 511 | ## 👤 Maintainer 512 | 513 | **Leul Zenebe** 514 | Creofam LLC 515 | 🌐 [creofam.com](https://creofam.com) 516 | 🌐 [Personal Site](https://leulzenebe.pro) 517 | -------------------------------------------------------------------------------- /payment-verification-api.postman_collection.json: -------------------------------------------------------------------------------- 1 | { 2 | "info": { 3 | "name": "Payment Verification API", 4 | "description": "Complete collection for Payment Verification API with support for CBE, Telebirr, Dashen, Abyssinia, CBE Birr, and Image verification", 5 | "version": "2.1.0", 6 | "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" 7 | }, 8 | "auth": { 9 | "type": "apikey", 10 | "apikey": [ 11 | { 12 | "key": "key", 13 | "value": "x-api-key", 14 | "type": "string" 15 | }, 16 | { 17 | "key": "value", 18 | "value": "{{API_KEY}}", 19 | "type": "string" 20 | } 21 | ] 22 | }, 23 | "variable": [ 24 | { 25 | "key": "BASE_URL", 26 | "value": "https://verifyapi.leulzenebe.pro", 27 | "type": "string" 28 | }, 29 | { 30 | "key": "API_KEY", 31 | "value": "YOUR_API_KEY_HERE", 32 | "type": "string" 33 | }, 34 | { 35 | "key": "ADMIN_KEY", 36 | "value": "YOUR_ADMIN_KEY_HERE", 37 | "type": "string" 38 | } 39 | ], 40 | "item": [ 41 | { 42 | "name": "Health & Info", 43 | "item": [ 44 | { 45 | "name": "Health Check", 46 | "request": { 47 | "method": "GET", 48 | "header": [], 49 | "url": { 50 | "raw": "{{BASE_URL}}/health", 51 | "host": ["{{BASE_URL}}"], 52 | "path": ["health"] 53 | }, 54 | "description": "Check if the API is running properly. No API key required." 55 | }, 56 | "response": [] 57 | }, 58 | { 59 | "name": "API Information", 60 | "request": { 61 | "method": "GET", 62 | "header": [], 63 | "url": { 64 | "raw": "{{BASE_URL}}/", 65 | "host": ["{{BASE_URL}}"], 66 | "path": [""] 67 | }, 68 | "description": "Get information about the API and available endpoints." 69 | }, 70 | "response": [] 71 | } 72 | ] 73 | }, 74 | { 75 | "name": "CBE Verification", 76 | "item": [ 77 | { 78 | "name": "Verify CBE Payment (POST)", 79 | "request": { 80 | "method": "POST", 81 | "header": [ 82 | { 83 | "key": "Content-Type", 84 | "value": "application/json" 85 | } 86 | ], 87 | "body": { 88 | "mode": "raw", 89 | "raw": "{\n \"reference\": \"FT2513001V2G\",\n \"accountSuffix\": \"39003377\"\n}" 90 | }, 91 | "url": { 92 | "raw": "{{BASE_URL}}/verify-cbe", 93 | "host": ["{{BASE_URL}}"], 94 | "path": ["verify-cbe"] 95 | }, 96 | "description": "Verify a CBE payment using reference number and account suffix." 97 | }, 98 | "response": [] 99 | }, 100 | { 101 | "name": "Verify CBE Payment (GET)", 102 | "request": { 103 | "method": "GET", 104 | "header": [], 105 | "url": { 106 | "raw": "{{BASE_URL}}/verify-cbe?reference=FT2513001V2G&accountSuffix=39003377", 107 | "host": ["{{BASE_URL}}"], 108 | "path": ["verify-cbe"], 109 | "query": [ 110 | { 111 | "key": "reference", 112 | "value": "FT2513001V2G" 113 | }, 114 | { 115 | "key": "accountSuffix", 116 | "value": "39003377" 117 | } 118 | ] 119 | }, 120 | "description": "Verify a CBE payment using query parameters." 121 | }, 122 | "response": [] 123 | } 124 | ] 125 | }, 126 | { 127 | "name": "Telebirr Verification", 128 | "item": [ 129 | { 130 | "name": "Verify Telebirr Payment", 131 | "request": { 132 | "method": "POST", 133 | "header": [ 134 | { 135 | "key": "Content-Type", 136 | "value": "application/json" 137 | } 138 | ], 139 | "body": { 140 | "mode": "raw", 141 | "raw": "{\n \"reference\": \"CE2513001XYT\"\n}" 142 | }, 143 | "url": { 144 | "raw": "{{BASE_URL}}/verify-telebirr", 145 | "host": ["{{BASE_URL}}"], 146 | "path": ["verify-telebirr"] 147 | }, 148 | "description": "Verify a Telebirr payment using reference number." 149 | }, 150 | "response": [] 151 | } 152 | ] 153 | }, 154 | { 155 | "name": "Dashen Verification", 156 | "item": [ 157 | { 158 | "name": "Verify Dashen Payment (POST)", 159 | "request": { 160 | "method": "POST", 161 | "header": [ 162 | { 163 | "key": "Content-Type", 164 | "value": "application/json" 165 | } 166 | ], 167 | "body": { 168 | "mode": "raw", 169 | "raw": "{\n \"reference\": \"DASHEN_REFERENCE_NUMBER\"\n}" 170 | }, 171 | "url": { 172 | "raw": "{{BASE_URL}}/verify-dashen", 173 | "host": ["{{BASE_URL}}"], 174 | "path": ["verify-dashen"] 175 | }, 176 | "description": "Verify a Dashen bank transaction using reference number." 177 | }, 178 | "response": [] 179 | }, 180 | { 181 | "name": "Verify Dashen Payment (GET)", 182 | "request": { 183 | "method": "GET", 184 | "header": [], 185 | "url": { 186 | "raw": "{{BASE_URL}}/verify-dashen?reference=DASHEN_REFERENCE_NUMBER", 187 | "host": ["{{BASE_URL}}"], 188 | "path": ["verify-dashen"], 189 | "query": [ 190 | { 191 | "key": "reference", 192 | "value": "DASHEN_REFERENCE_NUMBER" 193 | } 194 | ] 195 | }, 196 | "description": "Verify a Dashen bank transaction using query parameters." 197 | }, 198 | "response": [] 199 | } 200 | ] 201 | }, 202 | { 203 | "name": "Abyssinia Verification", 204 | "item": [ 205 | { 206 | "name": "Verify Abyssinia Payment (POST)", 207 | "request": { 208 | "method": "POST", 209 | "header": [ 210 | { 211 | "key": "Content-Type", 212 | "value": "application/json" 213 | } 214 | ], 215 | "body": { 216 | "mode": "raw", 217 | "raw": "{\n \"reference\": \"ABYSSINIA_REFERENCE\",\n \"suffix\": \"12345\"\n}" 218 | }, 219 | "url": { 220 | "raw": "{{BASE_URL}}/verify-abyssinia", 221 | "host": ["{{BASE_URL}}"], 222 | "path": ["verify-abyssinia"] 223 | }, 224 | "description": "Verify an Abyssinia bank transaction. Suffix must be exactly 5 digits." 225 | }, 226 | "response": [] 227 | }, 228 | { 229 | "name": "Verify Abyssinia Payment (GET)", 230 | "request": { 231 | "method": "GET", 232 | "header": [], 233 | "url": { 234 | "raw": "{{BASE_URL}}/verify-abyssinia?reference=ABYSSINIA_REFERENCE&suffix=12345", 235 | "host": ["{{BASE_URL}}"], 236 | "path": ["verify-abyssinia"], 237 | "query": [ 238 | { 239 | "key": "reference", 240 | "value": "ABYSSINIA_REFERENCE" 241 | }, 242 | { 243 | "key": "suffix", 244 | "value": "12345" 245 | } 246 | ] 247 | }, 248 | "description": "Verify an Abyssinia bank transaction using query parameters." 249 | }, 250 | "response": [] 251 | } 252 | ] 253 | }, 254 | { 255 | "name": "CBE Birr Verification", 256 | "item": [ 257 | { 258 | "name": "Verify CBE Birr Payment (POST)", 259 | "request": { 260 | "method": "POST", 261 | "header": [ 262 | { 263 | "key": "Content-Type", 264 | "value": "application/json" 265 | } 266 | ], 267 | "body": { 268 | "mode": "raw", 269 | "raw": "{\n \"receiptNumber\": \"RECEIPT_NUMBER\",\n \"phoneNumber\": \"251912345678\"\n}" 270 | }, 271 | "url": { 272 | "raw": "{{BASE_URL}}/verify-cbebirr", 273 | "host": ["{{BASE_URL}}"], 274 | "path": ["verify-cbebirr"] 275 | }, 276 | "description": "Verify a CBE Birr payment. Phone number must be Ethiopian format (251 + 9 digits)." 277 | }, 278 | "response": [] 279 | }, 280 | { 281 | "name": "Verify CBE Birr Payment (GET)", 282 | "request": { 283 | "method": "GET", 284 | "header": [], 285 | "url": { 286 | "raw": "{{BASE_URL}}/verify-cbebirr?receiptNumber=RECEIPT_NUMBER&phoneNumber=251912345678", 287 | "host": ["{{BASE_URL}}"], 288 | "path": ["verify-cbebirr"], 289 | "query": [ 290 | { 291 | "key": "receiptNumber", 292 | "value": "RECEIPT_NUMBER" 293 | }, 294 | { 295 | "key": "phoneNumber", 296 | "value": "251912345678" 297 | } 298 | ] 299 | }, 300 | "description": "Verify a CBE Birr payment using query parameters." 301 | }, 302 | "response": [] 303 | } 304 | ] 305 | }, 306 | { 307 | "name": "Image Verification", 308 | "item": [ 309 | { 310 | "name": "Verify Payment by Image", 311 | "request": { 312 | "method": "POST", 313 | "header": [], 314 | "body": { 315 | "mode": "formdata", 316 | "formdata": [ 317 | { 318 | "key": "file", 319 | "type": "file", 320 | "src": "/path/to/receipt/image.jpg" 321 | }, 322 | { 323 | "key": "suffix", 324 | "value": "39003377", 325 | "description": "Required for CBE receipts - last 8 digits of account", 326 | "type": "text" 327 | } 328 | ] 329 | }, 330 | "url": { 331 | "raw": "{{BASE_URL}}/verify-image?autoVerify=true", 332 | "host": ["{{BASE_URL}}"], 333 | "path": ["verify-image"], 334 | "query": [ 335 | { 336 | "key": "autoVerify", 337 | "value": "true", 338 | "description": "Automatically detect receipt type and route to correct verification" 339 | } 340 | ] 341 | }, 342 | "description": "Verify payment by uploading receipt image. Supports both CBE and Telebirr screenshots." 343 | }, 344 | "response": [] 345 | }, 346 | { 347 | "name": "Verify Payment by Image (Manual)", 348 | "request": { 349 | "method": "POST", 350 | "header": [], 351 | "body": { 352 | "mode": "formdata", 353 | "formdata": [ 354 | { 355 | "key": "file", 356 | "type": "file", 357 | "src": "/path/to/receipt/image.jpg" 358 | } 359 | ] 360 | }, 361 | "url": { 362 | "raw": "{{BASE_URL}}/verify-image", 363 | "host": ["{{BASE_URL}}"], 364 | "path": ["verify-image"] 365 | }, 366 | "description": "Verify payment by uploading receipt image without auto-verification." 367 | }, 368 | "response": [] 369 | } 370 | ] 371 | }, 372 | { 373 | "name": "Admin Endpoints", 374 | "item": [ 375 | { 376 | "name": "Generate API Key", 377 | "request": { 378 | "method": "POST", 379 | "header": [ 380 | { 381 | "key": "Content-Type", 382 | "value": "application/json" 383 | }, 384 | { 385 | "key": "x-admin-key", 386 | "value": "{{ADMIN_KEY}}" 387 | } 388 | ], 389 | "body": { 390 | "mode": "raw", 391 | "raw": "{\n \"owner\": \"user-identifier\"\n}" 392 | }, 393 | "url": { 394 | "raw": "{{BASE_URL}}/admin/api-keys", 395 | "host": ["{{BASE_URL}}"], 396 | "path": ["admin", "api-keys"] 397 | }, 398 | "description": "Generate a new API key. Requires admin authentication." 399 | }, 400 | "response": [] 401 | }, 402 | { 403 | "name": "List API Keys", 404 | "request": { 405 | "method": "GET", 406 | "header": [ 407 | { 408 | "key": "x-admin-key", 409 | "value": "{{ADMIN_KEY}}" 410 | } 411 | ], 412 | "url": { 413 | "raw": "{{BASE_URL}}/admin/api-keys", 414 | "host": ["{{BASE_URL}}"], 415 | "path": ["admin", "api-keys"] 416 | }, 417 | "description": "List existing API keys (masked view). Requires admin authentication." 418 | }, 419 | "response": [] 420 | }, 421 | { 422 | "name": "Get Usage Statistics", 423 | "request": { 424 | "method": "GET", 425 | "header": [ 426 | { 427 | "key": "x-admin-key", 428 | "value": "{{ADMIN_KEY}}" 429 | } 430 | ], 431 | "url": { 432 | "raw": "{{BASE_URL}}/admin/stats", 433 | "host": ["{{BASE_URL}}"], 434 | "path": ["admin", "stats"] 435 | }, 436 | "description": "Retrieve usage statistics including request counts, success/failure ratios, and response times." 437 | }, 438 | "response": [] 439 | } 440 | ] 441 | } 442 | ] 443 | } -------------------------------------------------------------------------------- /src/services/verifyTelebirr.ts: -------------------------------------------------------------------------------- 1 | import axios, { AxiosError } from "axios"; 2 | import * as cheerio from "cheerio"; 3 | import logger from '../utils/logger'; 4 | 5 | export interface TelebirrReceipt { 6 | payerName: string; 7 | payerTelebirrNo: string; 8 | creditedPartyName: string; 9 | creditedPartyAccountNo: string; 10 | transactionStatus: string; 11 | receiptNo: string; 12 | paymentDate: string; 13 | settledAmount: string; 14 | serviceFee: string; 15 | serviceFeeVAT: string; 16 | totalPaidAmount: string; 17 | bankName: string; 18 | } 19 | 20 | /** 21 | * Enhanced regex-based extractor for settled amount - multiple patterns like PHP version 22 | * @param htmlContent The raw HTML content 23 | * @returns Extracted settled amount or null 24 | */ 25 | function extractSettledAmountRegex(htmlContent: string): string | null { 26 | // Pattern 1: Direct match with the exact text structure 27 | const pattern1 = /የተከፈለው\s+መጠን\/Settled\s+Amount.*?<\/td>\s*]*>\s*(\d+(?:\.\d{2})?\s+Birr)/is; 28 | let match = htmlContent.match(pattern1); 29 | if (match) return match[1].trim(); 30 | 31 | // Pattern 2: Look for the table row structure 32 | const pattern2 = /]*>.*?የተከፈለው\s+መጠን\/Settled\s+Amount.*?]*>\s*(\d+(?:\.\d{2})?\s+Birr)/is; 33 | match = htmlContent.match(pattern2); 34 | if (match) return match[1].trim(); 35 | 36 | // Pattern 3: More flexible approach - look for any cell containing "Settled Amount" followed by amount 37 | const pattern3 = /Settled\s+Amount.*?(\d+(?:\.\d{2})?\s+Birr)/is; 38 | match = htmlContent.match(pattern3); 39 | if (match) return match[1].trim(); 40 | 41 | // Pattern 4: Look specifically in the transaction details table 42 | const pattern4 = /የክፍያ\s+ዝርዝር\/Transaction\s+details.*?]*>.*?]*>\s*[^<]*<\/td>\s*]*>\s*[^<]*<\/td>\s*]*>\s*(\d+(?:\.\d{2})?\s+Birr)/is; 43 | match = htmlContent.match(pattern4); 44 | if (match) return match[1].trim(); 45 | 46 | return null; 47 | } 48 | 49 | /** 50 | * Enhanced regex-based extractor for service fee 51 | * @param htmlContent The raw HTML content 52 | * @returns Extracted service fee or null 53 | */ 54 | function extractServiceFeeRegex(htmlContent: string): string | null { 55 | // Pattern to match "የአገልግሎት ክፍያ/Service fee" followed by amount in Birr 56 | // Make sure we don't match VAT version 57 | const pattern = /የአገልግሎት\s+ክፍያ\/Service\s+fee(?!\s+ተ\.እ\.ታ).*?<\/td>\s*]*>\s*(\d+(?:\.\d{2})?\s+Birr)/i; 58 | const match = htmlContent.match(pattern); 59 | if (match) return match[1].trim(); 60 | 61 | return null; 62 | } 63 | 64 | /** 65 | * Enhanced regex-based extractor for receipt number 66 | * @param htmlContent The raw HTML content 67 | * @returns Extracted receipt number or null 68 | */ 69 | function extractReceiptNoRegex(htmlContent: string): string | null { 70 | // Extract receipt number from the transaction details table 71 | const pattern = /]*class="[^"]*receipttableTd[^"]*receipttableTd2[^"]*"[^>]*>\s*([A-Z0-9]+)\s*<\/td>/i; 72 | const match = htmlContent.match(pattern); 73 | if (match) return match[1].trim(); 74 | 75 | return null; 76 | } 77 | 78 | /** 79 | * Enhanced regex-based extractor for payment date 80 | * @param htmlContent The raw HTML content 81 | * @returns Extracted payment date or null 82 | */ 83 | function extractDateRegex(htmlContent: string): string | null { 84 | // Extract date in format DD-MM-YYYY HH:MM:SS 85 | const pattern = /(\d{2}-\d{2}-\d{4}\s+\d{2}:\d{2}:\d{2})/; 86 | const match = htmlContent.match(pattern); 87 | if (match) return match[1].trim(); 88 | 89 | return null; 90 | } 91 | 92 | /** 93 | * Generic regex extractor for other fields 94 | * @param htmlContent The raw HTML content 95 | * @param labelPattern The label to search for 96 | * @param valuePattern The pattern for the value (defaults to capturing any non-tag content) 97 | * @returns Extracted value or null 98 | */ 99 | function extractWithRegex(htmlContent: string, labelPattern: string, valuePattern: string = '([^<]+)'): string | null { 100 | // Escape special regex characters in the label pattern 101 | const escapedLabel = labelPattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); 102 | const pattern = new RegExp(`${escapedLabel}.*?<\\/td>\\s*]*>\\s*${valuePattern}`, 'i'); 103 | const match = htmlContent.match(pattern); 104 | if (match) return match[1].replace(/<[^>]*>/g, '').trim(); // Strip any remaining HTML tags 105 | 106 | return null; 107 | } 108 | 109 | /** 110 | * Regex-based extractor for settled amount and service fee as fallback 111 | * @param htmlContent The raw HTML content 112 | * @returns Object containing extracted values 113 | */ 114 | function extractWithRegexLegacy(htmlContent: string): { settledAmount: string | null; serviceFee: string | null } { 115 | // Use the new enhanced extractors 116 | const settledAmount = extractSettledAmountRegex(htmlContent); 117 | const serviceFee = extractServiceFeeRegex(htmlContent); 118 | 119 | return { 120 | settledAmount, 121 | serviceFee 122 | }; 123 | } 124 | 125 | /** 126 | * Scrapes Telebirr receipt data from HTML content 127 | * @param html The HTML content to scrape 128 | * @returns Extracted Telebirr receipt data 129 | */ 130 | function scrapeTelebirrReceipt(html: string): TelebirrReceipt { 131 | const $ = cheerio.load(html); 132 | 133 | // Log HTML content in debug mode to help diagnose scraping issues 134 | logger.debug(`HTML content length: ${html.length} bytes`); 135 | if (html.length < 100) { 136 | logger.warn(`Suspiciously short HTML response: ${html}`); 137 | } 138 | 139 | const getText = (selector: string): string => 140 | $(selector).next().text().trim(); 141 | 142 | const getPaymentDate = (): string => { 143 | // First try regex extraction 144 | const regexDate = extractDateRegex(html); 145 | if (regexDate) return regexDate; 146 | 147 | // Fallback to cheerio 148 | return $('.receipttableTd').filter((_, el) => $(el).text().includes("-202")).first().text().trim(); 149 | }; 150 | 151 | const getReceiptNo = (): string => { 152 | // First try regex extraction 153 | const regexReceiptNo = extractReceiptNoRegex(html); 154 | if (regexReceiptNo) return regexReceiptNo; 155 | 156 | // Fallback to cheerio 157 | return $('td.receipttableTd.receipttableTd2') 158 | .eq(1) // second match: the value, not the label 159 | .text() 160 | .trim(); 161 | }; 162 | 163 | const getSettledAmount = (): string => { 164 | // First try the enhanced regex approach 165 | const regexAmount = extractSettledAmountRegex(html); 166 | if (regexAmount) return regexAmount; 167 | 168 | // Fallback to cheerio approach 169 | let amount = $('td.receipttableTd.receipttableTd2') 170 | .filter((_, el) => { 171 | const prevTd = $(el).prev(); 172 | return prevTd.text().includes("የተከፈለው መጠን") || prevTd.text().includes("Settled Amount"); 173 | }) 174 | .text() 175 | .trim(); 176 | 177 | // If that doesn't work, try looking in the transaction details table 178 | if (!amount) { 179 | amount = $('tr') 180 | .filter((_, el) => { 181 | return $(el).find('td').first().text().includes("የተከፈለው መጠን") || 182 | $(el).find('td').first().text().includes("Settled Amount"); 183 | }) 184 | .find('td') 185 | .last() 186 | .text() 187 | .trim(); 188 | } 189 | 190 | return amount; 191 | }; 192 | 193 | const getServiceFee = (): string => { 194 | // First try the enhanced regex approach 195 | const regexFee = extractServiceFeeRegex(html); 196 | if (regexFee) return regexFee; 197 | 198 | // Fallback to cheerio approach - look for service fee but not service fee VAT 199 | let fee = $('td.receipttableTd1') 200 | .filter((_, el) => { 201 | const text = $(el).text(); 202 | return (text.includes("የአገልግሎት ክፍያ") || text.includes("Service fee")) && 203 | !text.includes("ተ.እ.ታ") && !text.includes("VAT"); 204 | }) 205 | .next('td.receipttableTd.receipttableTd2') 206 | .text() 207 | .trim(); 208 | 209 | // Alternative approach - look in table rows 210 | if (!fee) { 211 | fee = $('tr') 212 | .filter((_, el) => { 213 | const text = $(el).text(); 214 | return (text.includes("የአገልግሎት ክፍያ") || text.includes("Service fee")) && 215 | !text.includes("ተ.እ.ታ") && !text.includes("VAT"); 216 | }) 217 | .find('td') 218 | .last() 219 | .text() 220 | .trim(); 221 | } 222 | 223 | return fee; 224 | }; 225 | 226 | // Helper function to extract text using regex first, then cheerio 227 | const getTextWithFallback = (labelText: string, cheerioSelector?: string): string => { 228 | // Try regex first 229 | const regexResult = extractWithRegex(html, labelText); 230 | if (regexResult) return regexResult; 231 | 232 | // Fallback to cheerio if selector provided 233 | if (cheerioSelector) { 234 | return getText(cheerioSelector); 235 | } 236 | 237 | // Default cheerio approach 238 | return getText(`td:contains("${labelText}")`); 239 | }; 240 | 241 | logger.debug("SERVICE FEE: ", getServiceFee()); 242 | logger.debug("SETTLED AMOUNT: ", getSettledAmount()); 243 | 244 | // Get regex results as backup for debugging 245 | const regexResults = extractWithRegexLegacy(html); 246 | logger.debug("Regex results:", regexResults); 247 | 248 | let creditedPartyName = getTextWithFallback("የገንዘብ ተቀባይ ስም/Credited Party name"); 249 | let creditedPartyAccountNo = getTextWithFallback("የገንዘብ ተቀባይ ቴሌብር ቁ./Credited party account no"); 250 | let bankName = ""; 251 | 252 | const bankAccountNumberRaw = getTextWithFallback("የባንክ አካውንት ቁጥር/Bank account number"); 253 | 254 | if (bankAccountNumberRaw) { 255 | bankName = creditedPartyName; // The original credited party name is the bank 256 | const bankAccountRegex = /(\d+)\s+(.*)/; 257 | const match = bankAccountNumberRaw.match(bankAccountRegex); 258 | if (match) { 259 | creditedPartyAccountNo = match[1].trim(); 260 | creditedPartyName = match[2].trim(); 261 | } 262 | } 263 | 264 | 265 | return { 266 | payerName: getTextWithFallback("የከፋይ ስም/Payer Name"), 267 | payerTelebirrNo: getTextWithFallback("የከፋይ ቴሌብር ቁ./Payer telebirr no."), 268 | creditedPartyName, 269 | creditedPartyAccountNo, 270 | transactionStatus: getTextWithFallback("የክፍያው ሁኔታ/transaction status"), 271 | receiptNo: getReceiptNo(), 272 | paymentDate: getPaymentDate(), 273 | settledAmount: getSettledAmount(), 274 | serviceFee: getServiceFee(), 275 | serviceFeeVAT: getTextWithFallback("የአገልግሎት ክፍያ ተ.እ.ታ/Service fee VAT"), 276 | totalPaidAmount: getTextWithFallback("ጠቅላላ የተከፈለ/Total Paid Amount"), 277 | bankName 278 | }; 279 | } 280 | 281 | /** 282 | * Parses Telebirr receipt data from JSON response 283 | * @param jsonData The JSON data from the proxy endpoint 284 | * @returns Extracted Telebirr receipt data 285 | */ 286 | function parseTelebirrJson(jsonData: any): TelebirrReceipt | null { 287 | try { 288 | // Check if the response has the expected structure 289 | if (!jsonData || !jsonData.success || !jsonData.data) { 290 | logger.warn("Invalid JSON structure from proxy endpoint", { jsonData }); 291 | return null; 292 | } 293 | 294 | const data = jsonData.data; 295 | 296 | return { 297 | payerName: data.payerName || "", 298 | payerTelebirrNo: data.payerTelebirrNo || "", 299 | creditedPartyName: data.creditedPartyName || "", 300 | creditedPartyAccountNo: data.creditedPartyAccountNo || "", 301 | transactionStatus: data.transactionStatus || "", 302 | receiptNo: data.receiptNo || "", 303 | paymentDate: data.paymentDate || "", 304 | settledAmount: data.settledAmount || "", 305 | serviceFee: data.serviceFee || "", 306 | serviceFeeVAT: data.serviceFeeVAT || "", 307 | totalPaidAmount: data.totalPaidAmount || "", 308 | bankName: data.bankName || "" 309 | }; 310 | } catch (error) { 311 | logger.error("Error parsing JSON from proxy endpoint", { error, jsonData }); 312 | return null; 313 | } 314 | } 315 | 316 | /** 317 | * Fetches and processes Telebirr receipt data from the primary source (HTML) 318 | * @param reference The Telebirr reference number 319 | * @param baseUrl The base URL to fetch the receipt from 320 | * @returns The scraped receipt data or null if failed 321 | */ 322 | async function fetchFromPrimarySource(reference: string, baseUrl: string): Promise { 323 | const url = `${baseUrl}${reference}`; 324 | 325 | try { 326 | logger.info(`Attempting to fetch Telebirr receipt from primary source: ${url}`); 327 | const response = await axios.get(url, { timeout: 15000 }); // 15 second timeout 328 | logger.debug(`Received response with status: ${response.status}`); 329 | 330 | const extractedData = scrapeTelebirrReceipt(response.data); 331 | 332 | logger.debug("Extracted data from HTML:", extractedData); 333 | logger.info(`Successfully extracted Telebirr data for reference: ${reference}`, { 334 | receiptNo: extractedData.receiptNo, 335 | payerName: extractedData.payerName, 336 | transactionStatus: extractedData.transactionStatus, 337 | settledAmount: extractedData.settledAmount, 338 | serviceFee: extractedData.serviceFee 339 | }); 340 | 341 | return extractedData; 342 | } catch (error) { 343 | // Enhanced error logging with request details 344 | const errorMessage = error instanceof Error ? error.message : "Unknown error"; 345 | const errorStack = error instanceof Error ? error.stack : undefined; 346 | 347 | // Check if it's an Axios error to safely access response properties 348 | const axiosError = error as AxiosError; 349 | const responseDetails = axiosError.response ? { 350 | status: axiosError.response.status, 351 | statusText: axiosError.response.statusText, 352 | responseData: axiosError.response.data 353 | } : {}; 354 | 355 | logger.error(`Error fetching Telebirr receipt from primary source ${url}:`, { 356 | error: errorMessage, 357 | stack: errorStack, 358 | ...responseDetails 359 | }); 360 | 361 | return null; 362 | } 363 | } 364 | 365 | /** 366 | * Fetches and processes Telebirr receipt data from the fallback proxy (JSON) 367 | * @param reference The Telebirr reference number 368 | * @param proxyUrl The proxy URL to fetch the receipt from 369 | * @returns The parsed receipt data or null if failed 370 | */ 371 | async function fetchFromProxySource(reference: string, proxyUrl: string): Promise { 372 | const url = `${proxyUrl}${reference}`; 373 | 374 | try { 375 | logger.info(`Attempting to fetch Telebirr receipt from proxy: ${url}`); 376 | const response = await axios.get(url, { 377 | timeout: 15000, 378 | headers: { 379 | 'Accept': 'application/json', 380 | 'User-Agent': 'VerifierAPI/1.0' 381 | } 382 | }); 383 | 384 | logger.debug(`Received proxy response with status: ${response.status}`); 385 | 386 | // Check if response is JSON 387 | let data = response.data; 388 | if (typeof data === 'string') { 389 | try { 390 | data = JSON.parse(data); 391 | } catch (e) { 392 | logger.warn("Proxy response is not valid JSON, attempting to scrape as HTML"); 393 | // If it's not JSON, try to scrape it as HTML 394 | return scrapeTelebirrReceipt(response.data); 395 | } 396 | } 397 | 398 | const extractedData = parseTelebirrJson(data); 399 | if (!extractedData) { 400 | logger.warn("Failed to parse JSON from proxy, attempting to scrape as HTML"); 401 | // If JSON parsing fails, try to scrape it as HTML 402 | return scrapeTelebirrReceipt(response.data); 403 | } 404 | 405 | logger.debug("Extracted data from JSON:", extractedData); 406 | logger.info(`Successfully extracted Telebirr data from proxy for reference: ${reference}`, { 407 | receiptNo: extractedData.receiptNo, 408 | payerName: extractedData.payerName, 409 | transactionStatus: extractedData.transactionStatus 410 | }); 411 | 412 | return extractedData; 413 | } catch (error) { 414 | const errorMessage = error instanceof Error ? error.message : "Unknown error"; 415 | const errorStack = error instanceof Error ? error.stack : undefined; 416 | 417 | const axiosError = error as AxiosError; 418 | const responseDetails = axiosError.response ? { 419 | status: axiosError.response.status, 420 | statusText: axiosError.response.statusText, 421 | responseData: axiosError.response.data 422 | } : {}; 423 | 424 | logger.error(`Error fetching Telebirr receipt from proxy ${url}:`, { 425 | error: errorMessage, 426 | stack: errorStack, 427 | ...responseDetails 428 | }); 429 | 430 | return null; 431 | } 432 | } 433 | 434 | export async function verifyTelebirr(reference: string): Promise { 435 | const primaryUrl = "https://transactioninfo.ethiotelecom.et/receipt/"; 436 | const fallbackUrl = "https://leul.et/verify.php?reference="; 437 | 438 | const skipPrimary = process.env.SKIP_PRIMARY_VERIFICATION === "true"; 439 | 440 | if (!skipPrimary) { 441 | const primaryResult = await fetchFromPrimarySource(reference, primaryUrl); 442 | if (primaryResult && isValidReceipt(primaryResult)) return primaryResult; 443 | logger.warn(`Primary Telebirr verification failed for reference: ${reference}. Trying fallback proxy...`); 444 | } else { 445 | logger.info(`Skipping primary verifier due to SKIP_PRIMARY_VERIFICATION=true`); 446 | } 447 | 448 | const fallbackResult = await fetchFromProxySource(reference, fallbackUrl); 449 | if (fallbackResult && isValidReceipt(fallbackResult)) { 450 | logger.info(`Successfully verified Telebirr receipt using fallback proxy for reference: ${reference}`); 451 | return fallbackResult; 452 | } 453 | 454 | logger.error(`Both primary and fallback Telebirr verification failed for reference: ${reference}`); 455 | return null; 456 | } 457 | 458 | // Add this helper function to validate receipt data 459 | function isValidReceipt(receipt: TelebirrReceipt): boolean { 460 | // Check if essential fields have values 461 | return Boolean( 462 | receipt.receiptNo && 463 | receipt.payerName && 464 | receipt.transactionStatus 465 | ); 466 | } --------------------------------------------------------------------------------