Project Setup & Prerequisites
🎯 What You'll Build
A robust Saudi bank transaction parser API using Firebase Cloud Functions that can:
- Parse transaction messages from all major Saudi banks
- Automatically categorize transactions
- Detect recurring payments
- Store transaction history in Firestore
- Handle batch processing
Prerequisites
- Node.js 22 installed
- Firebase CLI installed globally
- Google Cloud account with Firebase project
- Basic knowledge of TypeScript and Express.js
Install Firebase CLI
# Install TypeScript globally
npm install -g typescript
# Install Firebase CLI globally
npm install -g firebase-tools
# Login to Firebase
firebase login
Create Firebase Project & Enable Firestore
Step 1.1: Create Firebase Project
Go to the Firebase Console and create or select your project.
Step 1.2: Enable Firestore
- Navigate to Cloud Firestore in the Firebase Console
- Click Create database
- Choose a location (recommend
me-central1for Saudi Arabia) - Start in Test mode for development
Set Up Cloud Functions with Express
Initialize Firebase Functions
firebase init functions
- ✅ Use Existing project (select your project)
- ✅ TypeScript (this is important!)
- ❌ Use ESLint (skip for now)
- ✅ Install dependencies now
Install Required Dependencies
cd functions
npm install express cors helmet express-rate-limit firebase-admin firebase-functions
Update package.json Scripts
Add the watch script to your functions/package.json for better development:
{
"scripts": {
"build": "tsc",
"build:watch": "tsc --watch",
"serve": "npm run build && firebase emulators:start --only functions",
"shell": "npm run build && firebase functions:shell",
"start": "npm run shell",
"deploy": "firebase deploy --only functions",
"logs": "firebase functions:log"
}
}
npm run build:watch in a separate terminal while developing. It will automatically recompile TypeScript files when you save changes!
Project File Structure
Create the following file structure in your functions/ directory:
├── package.json
├── tsconfig.json
└── src/
├── index.ts
└── routes/
├── index.ts
└── transactions/
├── const.ts
├── types.ts
├── utils.ts
└── index.ts
- Modular: Each component has its own file
- Scalable: Easy to add new features
- Maintainable: Clear separation of concerns
Create the Middleware Function
Create the main Express application in src/index.ts:
import express from "express";
import cors from "cors";
import routes from "./routes";
import {onRequest} from "firebase-functions/v2/https";
const app = express();
app.use(express.json());
app.use(express.urlencoded({extended: true}));
app.use(
cors({
origin: true,
}),
);
// Your route handlers
app.use(routes);
// Handle invalid routes
app.use((req, res) => {
res.status(404).json({
error: {
name: "Error",
status: 404,
message: "Invalid Request",
statusCode: 404,
},
message: "Invalid Request",
});
});
// Export the Express app as an onRequest function
export const api = onRequest(
{
timeoutSeconds: 300,
region: "me-central1",
memory: "1GiB", // Note: Must be "1GiB", not "1GB" per v2 API
// Optional additional settings:
// minInstances: 1,
// concurrency: 80
},
app
);
Add TypeScript Types
Create type definitions in src/routes/transactions/types.ts:
export interface ParsedTransaction {
description: string;
amount: number;
currency: string;
merchant: string;
accountMasked: string;
date: string; // YYYY-MM-DD format
category: string;
recurrence: {
isRecurring: boolean;
period?: "daily" | "weekly" | "monthly" | "yearly";
confidence?: number; // 0-1 score for recurrence detection
};
rawText: string;
bankFormat?: string;
}
export interface CategoryRule {
keywords: string[];
category: string;
priority: number; // Higher priority rules are checked first
}
export interface MerchantPattern {
pattern: RegExp;
normalizedName: string;
category?: string;
}
export type ApiResponse = {
success: boolean;
data?: T;
error?: string;
message?: string;
metadata?: {
version: string;
timestamp: string;
processingTimeMs?: number;
region?: string;
};
}
- Type Safety: Catch errors at compile time
- IntelliSense: Better IDE support
- Documentation: Self-documenting code
Add Constants & Bank Patterns
Create bank parsing patterns and rules in src/routes/transactions/const.ts:
import {MerchantPattern, CategoryRule} from "./types";
/**
* Bank-specific parsing patterns
* Add new bank formats here
*/
export const BANK_PATTERNS = {
// Generic pattern that works for most Saudi banks
generic: {
description: /^([^\n]+)/,
amount: /بـ\s*([\d,\.]+)\s*([A-Z]{3})/,
merchant: /من\s+([^\n]+)/,
card: /مدى\s*(\d+\*)/,
account: /حساب\s*(\d+\*)/,
date: /في\s*(\d{2}-\d{2}-\d{1,2})/,
},
// Al Rajhi Bank (الراجحي) - Most popular bank in Saudi
alrajhi: {
description: /^([^\n]+)/,
amount: /(?:قيمة|مبلغ|بقيمة)\s*([\d,\.]+)\s*([A-Z]{3})/,
merchant: /(?:من|لدى|عند)\s+([^\n]+)/,
card: /(?:بطاقة|كارت)\s*(\d+\*+)/,
account: /(?:حساب|رقم الحساب)\s*(\d+\*+)/,
date: /(?:بتاريخ|في|تاريخ)\s*(\d{1,2}[\/\-]\d{1,2}[\/\-]\d{2,4})/,
reference: /(?:مرجع|رقم مرجع)\s*([A-Z0-9]+)/,
terminal: /(?:طرفية|جهاز)\s*([A-Z0-9]+)/,
},
// National Commercial Bank (الأهلي) - NCB/AlAhli
ncb: {
description: /^([^\n]+)/,
amount: /(?:المبلغ|القيمة|بمبلغ)\s*([\d,\.]+)\s*([A-Z]{3})/,
merchant: /(?:التاجر|من|لدى)\s+([^\n]+)/,
card: /(?:البطاقة|كارت)\s*(\d+\*+)/,
account: /(?:الحساب|حساب رقم)\s*(\d+\*+)/,
date: /(?:التاريخ|في)\s*(\d{1,2}[\/\-]\d{1,2}[\/\-]\d{2,4})/,
branch: /(?:الفرع|فرع)\s*(\d+)/,
reference: /(?:الرقم المرجعي|مرجع)\s*([A-Z0-9]+)/,
},
// Riyad Bank (بنك الرياض)
riyad: {
description: /^([^\n]+)/,
amount: /(?:بمبلغ|المبلغ|قدره)\s*([\d,\.]+)\s*([A-Z]{3})/,
merchant: /(?:من|لصالح|إلى)\s+([^\n]+)/,
card: /(?:بطاقة رقم|البطاقة)\s*(\d+\*+)/,
account: /(?:من الحساب|الحساب)\s*(\d+\*+)/,
date: /(?:بتاريخ|في يوم)\s*(\d{1,2}[\/\-]\d{1,2}[\/\-]\d{2,4})/,
time: /(?:الساعة|وقت)\s*(\d{1,2}:\d{2})/,
location: /(?:في|بـ)\s+([^0-9\n]+)/,
},
// SAMBA Bank (سامبا) - Now part of SNB
samba: {
description: /^([^\n]+)/,
amount: /(?:بقيمة|مقدار|بمبلغ)\s*([\d,\.]+)\s*([A-Z]{3})/,
merchant: /(?:من|عند|لدى)\s+([^\n]+)/,
card: /(?:بالبطاقة|البطاقة)\s*(\d+\*+)/,
account: /(?:الحساب|من حساب)\s*(\d+\*+)/,
date: /(?:في|بتاريخ)\s*(\d{1,2}[\/\-]\d{1,2}[\/\-]\d{2,4})/,
approval: /(?:رقم الموافقة|الموافقة)\s*([A-Z0-9]+)/,
},
// Saudi National Bank (البنك الأهلي السعودي) - SNB (merged SAMBA + NCB)
snb: {
description: /^([^\n]+)/,
amount: /(?:بمبلغ|القيمة|المبلغ)\s*([\d,\.]+)\s*([A-Z]{3})/,
merchant: /(?:من|التاجر|عند)\s+([^\n]+)/,
card: /(?:البطاقة|بطاقة رقم)\s*(\d+\*+)/,
account: /(?:الحساب|حساب)\s*(\d+\*+)/,
date: /(?:في|التاريخ|بتاريخ)\s*(\d{1,2}[\/\-]\d{1,2}[\/\-]\d{2,4})/,
channel: /(?:القناة|عبر)\s+([^\n]+)/,
reference: /(?:المرجع|رقم مرجعي)\s*([A-Z0-9]+)/,
},
// Saudi Investment Bank (البنك السعودي للاستثمار) - SAIB
saib: {
description: /^([^\n]+)/,
amount: /(?:بمبلغ|قيمة|مقدار)\s*([\d,\.]+)\s*([A-Z]{3})/,
merchant: /(?:من|لدى|عند)\s+([^\n]+)/,
card: /(?:بطاقة|كرت)\s*(\d+\*+)/,
account: /(?:حساب|الحساب رقم)\s*(\d+\*+)/,
date: /(?:بتاريخ|في)\s*(\d{1,2}[\/\-]\d{1,2}[\/\-]\d{2,4})/,
type: /(?:نوع العملية|العملية)\s+([^\n]+)/,
},
// Banque Saudi Fransi (البنك السعودي الفرنسي) - BSF
bsf: {
description: /^([^\n]+)/,
amount: /(?:مبلغ|بقيمة|القيمة)\s*([\d,\.]+)\s*([A-Z]{3})/,
merchant: /(?:من|عند|التاجر)\s+([^\n]+)/,
card: /(?:البطاقة|بطاقة)\s*(\d+\*+)/,
account: /(?:الحساب|حساب رقم)\s*(\d+\*+)/,
date: /(?:في|التاريخ)\s*(\d{1,2}[\/\-]\d{1,2}[\/\-]\d{2,4})/,
location: /(?:المكان|في)\s+([^0-9\n]+)/,
},
// Arab National Bank (البنك العربي الوطني) - ANB
anb: {
description: /^([^\n]+)/,
amount: /(?:بمبلغ|المبلغ|قدره)\s*([\d,\.]+)\s*([A-Z]{3})/,
merchant: /(?:من|لصالح|عند)\s+([^\n]+)/,
card: /(?:بطاقة رقم|البطاقة)\s*(\d+\*+)/,
account: /(?:من حساب|الحساب)\s*(\d+\*+)/,
date: /(?:بتاريخ|في)\s*(\d{1,2}[\/\-]\d{1,2}[\/\-]\d{2,4})/,
balance: /(?:الرصيد|الرصيد المتاح)\s*([\d,\.]+)/,
},
// Saudi British Bank (البنك السعودي البريطاني) - SABB
sabb: {
description: /^([^\n]+)/,
amount: /(?:Amount|مبلغ|القيمة)\s*([\d,\.]+)\s*([A-Z]{3})/,
merchant: /(?:من|From|Merchant)\s+([^\n]+)/,
card: /(?:Card|البطاقة|بطاقة)\s*(\d+\*+)/,
account: /(?:Account|الحساب|حساب)\s*(\d+\*+)/,
date: /(?:Date|في|التاريخ)\s*(\d{1,2}[\/\-]\d{1,2}[\/\-]\d{2,4})/,
reference: /(?:Ref|مرجع|Reference)\s*([A-Z0-9]+)/,
},
// Bank AlJazira (بنك الجزيرة)
aljazira: {
description: /^([^\n]+)/,
amount: /(?:بمبلغ|قيمة|المبلغ)\s*([\d,\.]+)\s*([A-Z]{3})/,
merchant: /(?:من|عند|لدى)\s+([^\n]+)/,
card: /(?:بطاقة|البطاقة)\s*(\d+\*+)/,
account: /(?:حساب|الحساب)\s*(\d+\*+)/,
date: /(?:في|بتاريخ)\s*(\d{1,2}[\/\-]\d{1,2}[\/\-]\d{2,4})/,
terminal: /(?:الجهاز|طرفية)\s*([A-Z0-9]+)/,
},
// Bank Albilad (بنك البلاد)
albilad: {
description: /^([^\n]+)/,
amount: /(?:بمبلغ|القيمة|مقدار)\s*([\d,\.]+)\s*([A-Z]{3})/,
merchant: /(?:من|التاجر|عند)\s+([^\n]+)/,
card: /(?:بطاقة|البطاقة رقم)\s*(\d+\*+)/,
account: /(?:الحساب|حساب)\s*(\d+\*+)/,
date: /(?:بتاريخ|في|التاريخ)\s*(\d{1,2}[\/\-]\d{1,2}[\/\-]\d{2,4})/,
branch: /(?:الفرع|فرع رقم)\s*(\d+)/,
},
// First Abu Dhabi Bank (بنك أبوظبي الأول) - FAB
fab: {
description: /^([^\n]+)/,
amount: /(?:Amount|مبلغ|بقيمة)\s*([\d,\.]+)\s*([A-Z]{3})/,
merchant: /(?:من|From|at)\s+([^\n]+)/,
card: /(?:Card|بطاقة)\s*(\d+\*+)/,
account: /(?:Account|حساب)\s*(\d+\*+)/,
date: /(?:Date|في|on)\s*(\d{1,2}[\/\-]\d{1,2}[\/\-]\d{2,4})/,
reference: /(?:Ref|مرجع)\s*([A-Z0-9]+)/,
},
// STC Pay (الدفع الرقمي - stc pay)
stcpay: {
description: /^([^\n]+)/,
amount: /(?:مبلغ|بقيمة|القيمة)\s*([\d,\.]+)\s*([A-Z]{3})/,
merchant: /(?:إلى|من|للتاجر)\s+([^\n]+)/,
account: /(?:محفظة|الرقم)\s*(\d+\*+)/,
date: /(?:في|بتاريخ)\s*(\d{1,2}[\/\-]\d{1,2}[\/\-]\d{2,4})/,
time: /(?:الساعة|وقت)\s*(\d{1,2}:\d{2})/,
type: /(?:نوع العملية|العملية)\s+([^\n]+)/,
reference: /(?:رقم العملية|مرجع)\s*([A-Z0-9]+)/,
},
// Mobily Pay (موبايلي باي)
mobilypay: {
description: /^([^\n]+)/,
amount: /(?:مبلغ|بقيمة|القيمة)\s*([\d,\.]+)\s*([A-Z]{3})/,
merchant: /(?:إلى|من|للتاجر)\s+([^\n]+)/,
account: /(?:محفظة|رقم المحفظة)\s*(\d+\*+)/,
date: /(?:في|التاريخ)\s*(\d{1,2}[\/\-]\d{1,2}[\/\-]\d{2,4})/,
reference: /(?:رقم المرجع|مرجع)\s*([A-Z0-9]+)/,
},
// Zain Pay (زين باي)
zainpay: {
description: /^([^\n]+)/,
amount: /(?:مبلغ|القيمة|بقيمة)\s*([\d,\.]+)\s*([A-Z]{3})/,
merchant: /(?:إلى|من|التاجر)\s+([^\n]+)/,
account: /(?:محفظة|الرقم)\s*(\d+\*+)/,
date: /(?:في|بتاريخ)\s*(\d{1,2}[\/\-]\d{1,2}[\/\-]\d{2,4})/,
reference: /(?:رقم العملية|مرجع)\s*([A-Z0-9]+)/,
},
// Alinma Bank (بنك الإنماء)
alinma: {
description: /^([^\n]+)/,
amount: /(?:بمبلغ|المبلغ|قيمة)\s*([\d,\.]+)\s*([A-Z]{3})/,
merchant: /(?:من|عند|لدى)\s+([^\n]+)/,
card: /(?:بطاقة|البطاقة)\s*(\d+\*+)/,
account: /(?:الحساب|حساب رقم)\s*(\d+\*+)/,
date: /(?:بتاريخ|في|التاريخ)\s*(\d{1,2}[\/\-]\d{1,2}[\/\-]\d{2,4})/,
islamic: /(?:وفقاً للشريعة|شريعة|إسلامي)/,
},
// Bank AlBilad (مصرف الراجحي الإسلامي)
rajhiislamic: {
description: /^([^\n]+)/,
amount: /(?:بمبلغ|القيمة|مقدار)\s*([\d,\.]+)\s*([A-Z]{3})/,
merchant: /(?:من|لدى|عند)\s+([^\n]+)/,
card: /(?:بطاقة|البطاقة)\s*(\d+\*+)/,
account: /(?:الحساب|حساب)\s*(\d+\*+)/,
date: /(?:في|بتاريخ)\s*(\d{1,2}[\/\-]\d{1,2}[\/\-]\d{2,4})/,
islamic: /(?:حلال|شرعي|إسلامي)/,
},
// Generic Apple Pay format
applepay: {
description: /^([^\n]+)/,
amount: /(?:بـ|بمبلغ|Amount)\s*([\d,\.]+)\s*([A-Z]{3})/,
merchant: /(?:من|From|at)\s+([^\n]+)/,
card: /(?:Apple Pay|آبل باي).*(\d+\*+)/,
date: /(?:في|on|Date)\s*(\d{1,2}[\/\-]\d{1,2}[\/\-]\d{2,4})/,
device: /(?:iPhone|iPad|Apple Watch|آيفون|آيباد)/,
},
// Generic Samsung Pay format
samsungpay: {
description: /^([^\n]+)/,
amount: /(?:بـ|بمبلغ|Amount)\s*([\d,\.]+)\s*([A-Z]{3})/,
merchant: /(?:من|From|at)\s+([^\n]+)/,
card: /(?:Samsung Pay|سامسونج باي).*(\d+\*+)/,
date: /(?:في|on|Date)\s*(\d{1,2}[\/\-]\d{1,2}[\/\-]\d{2,4})/,
device: /(?:Galaxy|سامسونج)/,
},
// MADA (نظام مدى) - Generic MADA card format
mada: {
description: /^([^\n]+)/,
amount: /(?:بـ|بمبلغ|مبلغ)\s*([\d,\.]+)\s*([A-Z]{3})/,
merchant: /(?:من|عند|لدى)\s+([^\n]+)/,
card: /(?:مدى|MADA)\s*(\d+\*+)/,
account: /(?:حساب|الحساب)\s*(\d+\*+)/,
date: /(?:في|بتاريخ)\s*(\d{1,2}[\/\-]\d{1,2}[\/\-]\d{2,4})/,
terminal: /(?:طرفية|جهاز)\s*([A-Z0-9]+)/,
},
// VISA format (international cards)
visa: {
description: /^([^\n]+)/,
amount: /(?:Amount|مبلغ|بـ)\s*([\d,\.]+)\s*([A-Z]{3})/,
merchant: /(?:من|From|at)\s+([^\n]+)/,
card: /(?:VISA|فيزا).*(\d+\*+)/,
date: /(?:في|on|Date)\s*(\d{1,2}[\/\-]\d{1,2}[\/\-]\d{2,4})/,
reference: /(?:Ref|مرجع)\s*([A-Z0-9]+)/,
},
// Mastercard format (international cards)
mastercard: {
description: /^([^\n]+)/,
amount: /(?:Amount|مبلغ|بـ)\s*([\d,\.]+)\s*([A-Z]{3})/,
merchant: /(?:من|From|at)\s+([^\n]+)/,
card: /(?:MasterCard|Mastercard|ماستركارد).*(\d+\*+)/,
date: /(?:في|on|Date)\s*(\d{1,2}[\/\-]\d{1,2}[\/\-]\d{2,4})/,
reference: /(?:Ref|مرجع)\s*([A-Z0-9]+)/,
},
// Tamara (Buy Now Pay Later)
tamara: {
description: /^([^\n]+)/,
amount: /(?:بمبلغ|القيمة|مبلغ)\s*([\d,\.]+)\s*([A-Z]{3})/,
merchant: /(?:من|عند|لدى)\s+([^\n]+)/,
installment: /(?:قسط|دفعة)\s*(\d+)\s*من\s*(\d+)/,
date: /(?:في|بتاريخ)\s*(\d{1,2}[\/\-]\d{1,2}[\/\-]\d{2,4})/,
reference: /(?:رقم الطلب|رقم المرجع)\s*([A-Z0-9]+)/,
},
// Tabby (Buy Now Pay Later)
tabby: {
description: /^([^\n]+)/,
amount: /(?:بمبلغ|القيمة|مبلغ)\s*([\d,\.]+)\s*([A-Z]{3})/,
merchant: /(?:من|عند|التاجر)\s+([^\n]+)/,
installment: /(?:قسط|دفعة)\s*(\d+)/,
date: /(?:في|بتاريخ)\s*(\d{1,2}[\/\-]\d{1,2}[\/\-]\d{2,4})/,
reference: /(?:رقم الطلب|Order)\s*([A-Z0-9]+)/,
},
};
/**
* Known merchant patterns for normalization
* Add new merchant patterns here
*/
export const MERCHANT_PATTERNS: MerchantPattern[] = [
{pattern: /spotify\s*ab/i, normalizedName: "Spotify", category: "Subscriptions"},
{pattern: /netflix/i, normalizedName: "Netflix", category: "Subscriptions"},
{pattern: /amazon.*prime/i, normalizedName: "Amazon Prime", category: "Subscriptions"},
{pattern: /starbucks/i, normalizedName: "Starbucks", category: "Food & Dining"},
{pattern: /carrefour|كارفور/i, normalizedName: "Carrefour", category: "Groceries"},
{pattern: /uber/i, normalizedName: "Uber", category: "Transportation"},
{pattern: /careem|كريم/i, normalizedName: "Careem", category: "Transportation"},
];
/**
* Category classification rules - easily extensible
* Add new keywords and categories here
*/
export const CATEGORY_RULES: CategoryRule[] = [
// Subscriptions & Digital Services
{
keywords: ["spotify", "netflix", "amazon prime", "youtube", "apple music", "shahid", "stc tv", "موسيقى", "اشتراك"],
category: "Subscriptions",
priority: 90,
},
// Food & Dining
{
keywords: ["مطعم", "كافيه", "مقهى", "بيتزا", "برجر", "كنتاكي", "ماكدونالدز", "pizza", "burger", "restaurant", "cafe", "kfc", "mcdonalds", "starbucks", "dunkin"],
category: "Food & Dining",
priority: 85,
},
// Groceries & Supermarkets
{
keywords: ["كارفور", "هايبر", "سوبر ماركت", "بقالة", "تموينات", "carrefour", "lulu", "panda", "danube", "extra"],
category: "Groceries",
priority: 85,
},
// Transport & Fuel
{
keywords: ["بنزين", "وقود", "تاكسي", "أوبر", "كريم", "مواقف", "رسوم طريق", "uber", "careem", "taxi", "fuel", "gas", "petrol", "aramco"],
category: "Transportation",
priority: 80,
},
// Shopping & Retail
{
keywords: ["تسوق", "متجر", "مول", "ملابس", "أزياء", "shopping", "mall", "store", "fashion", "zara", "h&m", "adidas", "nike"],
category: "Shopping",
priority: 75,
},
// Healthcare
{
keywords: ["صيدلية", "مستشفى", "عيادة", "طبيب", "دواء", "pharmacy", "hospital", "clinic", "medical", "nahdi", "aldawaa"],
category: "Healthcare",
priority: 80,
},
// Utilities & Bills
{
keywords: ["كهرباء", "مياه", "إنترنت", "جوال", "اتصالات", "موبايلي", "زين", "electricity", "water", "internet", "mobile", "stc", "mobily", "zain"],
category: "Utilities",
priority: 85,
},
// Entertainment
{
keywords: ["سينما", "ألعاب", "ملاهي", "ترفيه", "cinema", "games", "entertainment", "vox", "muvi"],
category: "Entertainment",
priority: 70,
},
// ATM & Banking
{
keywords: ["صراف", "سحب نقدي", "atm", "cash withdrawal", "رسوم مصرفية", "bank fee"],
category: "Banking & ATM",
priority: 95,
},
];
Add Utility Functions
Create parsing logic in src/routes/transactions/utils.ts:
import {ParsedTransaction} from "./types";
import {BANK_PATTERNS, CATEGORY_RULES, MERCHANT_PATTERNS} from "./const";
// ==================== CORE PARSING FUNCTIONS ====================
/**
* Main transaction parser function
*/
export function parseTransaction(rawText: string): ParsedTransaction {
const lines = rawText.trim().split("\n").map((line) => line.trim());
// Try different bank patterns
let parsedData: any = {};
let detectedBank = "generic";
for (const [bankName, patterns] of Object.entries(BANK_PATTERNS)) {
const result = tryParseWithPattern(rawText, patterns);
if (result.confidence > (parsedData.confidence || 0)) {
parsedData = result;
detectedBank = bankName;
}
}
// Extract basic fields
const description = parsedData.description || lines[0] || "";
const amount = parseFloat(parsedData.amount?.replace(/,/g, "") || "0");
const currency = parsedData.currency || "SAR";
const merchant = normalizeMerchant(parsedData.merchant || extractMerchantFallback(rawText));
const accountMasked = formatAccountMasked(parsedData.card, parsedData.account);
const date = normalizeDate(parsedData.date);
// Classify category
const category = classifyCategory(description, merchant);
// Basic recurrence detection
const recurrence = detectRecurrence(merchant, amount, description);
return {
description,
amount,
currency,
merchant,
accountMasked,
date,
category,
recurrence,
rawText,
bankFormat: detectedBank,
};
}
/**
* Try parsing with a specific bank pattern
*/
function tryParseWithPattern(text: string, patterns: any): any {
const result: any = {confidence: 0};
let matchCount = 0;
// Description
const descMatch = text.match(patterns.description);
if (descMatch) {
result.description = descMatch[1].trim();
matchCount++;
}
// Amount and currency
const amountMatch = text.match(patterns.amount);
if (amountMatch) {
result.amount = amountMatch[1];
result.currency = amountMatch[2];
matchCount += 2;
}
// Merchant
const merchantMatch = text.match(patterns.merchant);
if (merchantMatch) {
result.merchant = merchantMatch[1].trim();
matchCount++;
}
// Card
const cardMatch = text.match(patterns.card);
if (cardMatch) {
result.card = cardMatch[1];
matchCount++;
}
// Account
const accountMatch = text.match(patterns.account);
if (accountMatch) {
result.account = accountMatch[1];
matchCount++;
}
// Date
const dateMatch = text.match(patterns.date);
if (dateMatch) {
result.date = dateMatch[1];
matchCount++;
}
result.confidence = matchCount / Object.keys(patterns).length;
return result;
}
/**
* Normalize merchant name using patterns
*/
function normalizeMerchant(rawMerchant: string): string {
if (!rawMerchant) return "Unknown Merchant";
// Clean the merchant string
const normalized = rawMerchant
.replace(/[A-Z0-9]{8,}/g, "") // Remove long alphanumeric codes
.replace(/\s+/g, " ")
.trim();
// Apply merchant patterns
for (const pattern of MERCHANT_PATTERNS) {
if (pattern.pattern.test(normalized)) {
return pattern.normalizedName;
}
}
return normalized || rawMerchant;
}
/**
* Extract merchant as fallback when pattern fails
*/
function extractMerchantFallback(text: string): string {
const merchantIndicators = ["من", "إلى", "لدى", "عند"];
for (const indicator of merchantIndicators) {
const regex = new RegExp(`${indicator}\\s+([^\\n]+)`, "i");
const match = text.match(regex);
if (match) {
return match[1].trim();
}
}
const words = text.split(/\s+/);
const possibleMerchants = words.filter((word) =>
/[A-Z]/.test(word) && word.length > 2
);
return possibleMerchants.join(" ") || "Unknown Merchant";
}
/**
* Format account/card information
*/
function formatAccountMasked(card?: string, account?: string): string {
const parts = [];
if (card) parts.push(card);
if (account) parts.push(account);
return parts.join(" / ") || "N/A";
}
/**
* Normalize date to YYYY-MM-DD format
*/
function normalizeDate(dateStr?: string): string {
if (!dateStr) return new Date().toISOString().split("T")[0];
let normalized = dateStr;
// Convert DD-MM-YY to YYYY-MM-DD
const ddmmyyMatch = dateStr.match(/(\d{2})-(\d{2})-(\d{1,2})/);
if (ddmmyyMatch) {
const [, day, month, year] = ddmmyyMatch;
const fullYear = year.length === 2 ? `20${year}` : year;
normalized = `${fullYear}-${month}-${day}`;
}
// Handle DD/MM/YYYY format
const ddmmyyyyMatch = dateStr.match(/(\d{2})\/(\d{2})\/(\d{4})/);
if (ddmmyyyyMatch) {
const [, day, month, year] = ddmmyyyyMatch;
normalized = `${year}-${month}-${day}`;
}
return normalized;
}
// ==================== CATEGORY CLASSIFICATION ====================
/**
* Classify transaction category using rules-based approach
*/
function classifyCategory(description: string, merchant: string): string {
const text = `${description} ${merchant}`.toLowerCase();
const sortedRules = [...CATEGORY_RULES].sort((a, b) => b.priority - a.priority);
for (const rule of sortedRules) {
for (const keyword of rule.keywords) {
if (text.includes(keyword.toLowerCase())) {
return rule.category;
}
}
}
for (const pattern of MERCHANT_PATTERNS) {
if (pattern.category && pattern.pattern.test(text)) {
return pattern.category;
}
}
return "Other";
}
// ==================== RECURRENCE DETECTION ====================
/**
* Basic recurrence detection
*/
function detectRecurrence(merchant: string, amount: number, description: string): {
isRecurring: boolean;
period?: "daily" | "weekly" | "monthly" | "yearly";
confidence?: number;
} {
const subscriptionKeywords = ["spotify", "netflix", "prime", "subscription", "اشتراك"];
const isLikelySubscription = subscriptionKeywords.some((keyword) =>
merchant.toLowerCase().includes(keyword) ||
description.toLowerCase().includes(keyword)
);
if (isLikelySubscription) {
return {
isRecurring: true,
period: "monthly",
confidence: 0.8,
};
}
const utilityKeywords = ["كهرباء", "مياه", "إنترنت", "جوال", "electricity", "water", "internet", "mobile"];
const isUtility = utilityKeywords.some((keyword) =>
merchant.toLowerCase().includes(keyword) ||
description.toLowerCase().includes(keyword)
);
if (isUtility) {
return {
isRecurring: true,
period: "monthly",
confidence: 0.7,
};
}
return {
isRecurring: false,
};
}
/**
* Advanced recurrence detection with historical data
*/
export function detectRecurrenceWithHistory(
currentTransaction: ParsedTransaction,
historicalTransactions: ParsedTransaction[]
): {
isRecurring: boolean;
period?: "daily" | "weekly" | "monthly" | "yearly";
confidence: number;
} {
const similarTransactions = historicalTransactions.filter((tx) =>
tx.merchant === currentTransaction.merchant &&
Math.abs(tx.amount - currentTransaction.amount) <= currentTransaction.amount * 0.1
);
if (similarTransactions.length < 2) {
return {isRecurring: false, confidence: 0};
}
const dates = similarTransactions.map((tx) => new Date(tx.date)).sort();
const intervals = [];
for (let i = 1; i < dates.length; i++) {
const diff = dates[i].getTime() - dates[i-1].getTime();
const days = diff / (1000 * 60 * 60 * 24);
intervals.push(days);
}
const avgInterval = intervals.reduce((a, b) => a + b, 0) / intervals.length;
const variance = intervals.reduce((sum, interval) => sum + Math.pow(interval - avgInterval, 2), 0) / intervals.length;
const standardDeviation = Math.sqrt(variance);
const isRegular = standardDeviation < avgInterval * 0.2;
if (!isRegular) {
return {isRecurring: false, confidence: 0};
}
let period: "daily" | "weekly" | "monthly" | "yearly";
let confidence = 0;
if (avgInterval >= 25 && avgInterval <= 35) {
period = "monthly";
confidence = 0.9;
} else if (avgInterval >= 6 && avgInterval <= 8) {
period = "weekly";
confidence = 0.8;
} else if (avgInterval >= 360 && avgInterval <= 370) {
period = "yearly";
confidence = 0.8;
} else if (avgInterval >= 0.8 && avgInterval <= 1.2) {
period = "daily";
confidence = 0.7;
} else {
return {isRecurring: false, confidence: 0};
}
return {
isRecurring: true,
period,
confidence,
};
}
/**
* Add new category rule
*/
export function addCategoryRule(keywords: string[], category: string, priority = 50): void {
CATEGORY_RULES.push({keywords, category, priority});
}
/**
* Add new merchant pattern
*/
export function addMerchantPattern(pattern: RegExp, normalizedName: string, category?: string): void {
MERCHANT_PATTERNS.push({pattern, normalizedName, category});
}
/**
* Add new bank parsing pattern
*/
export function addBankPattern(bankName: string, patterns: any): void {
(BANK_PATTERNS as any)[bankName] = patterns;
}
- Multi-bank pattern matching
- Intelligent merchant normalization
- Automatic category classification
- Date normalization to ISO format
- Recurrence detection
Add Transaction Routes
Create API endpoints in src/routes/transactions/index.ts:
import * as functions from "firebase-functions";
import * as functions from "firebase-functions";
import * as admin from "firebase-admin";
import express from "express";
import { ApiResponse, ParsedTransaction } from "./types";
import { parseTransaction } from "./utils";
import { CATEGORY_RULES, MERCHANT_PATTERNS } from "./const";
// Import Firestore helpers from the modular admin API (v11+)
import { getFirestore, FieldValue } from "firebase-admin/firestore";
const app = express.Router();
// Ensure JSON bodies are parsed
app.use(express.json());
// Initialize Firebase Admin (only once)
if (!admin.apps.length) {
admin.initializeApp();
}
// Use the modular Firestore handle
const db = getFirestore();
// ==================== API ROUTES ====================
/**
* Health check endpoint
*/
app.get("/health", (req, res) => {
res.json({
success: true,
message: "Saudi Bank Transaction Parser API is running",
version: "1.0.0",
timestamp: new Date().toISOString(),
});
});
/**
* API documentation endpoint
*/
app.get("/docs", (req, res) => {
res.json({
name: "Saudi Bank Transaction Parser API",
version: "1.0.0",
endpoints: {
"GET /health": "Health check",
"GET /docs": "API documentation",
"POST /parse": "Parse single transaction",
"POST /parse/batch": "Parse multiple transactions",
"GET /categories": "Get available categories",
"POST /categories": "Add new category rule",
"GET /merchants": "Get merchant patterns",
"POST /merchants": "Add new merchant pattern",
},
example: {
endpoint: "POST /parse",
body: {
transaction:
"شراء إنترنت\nبـ 21.99 SAR\nمن Spotify AB P3781C3C72\nمدى 3180*\nحساب 0165*\nفي08-06-2",
userId: "optional-user-id",
historicalTransactions: [],
},
},
});
});
/**
* Parse single transaction
*/
app.post("/parse", async (req, res) => {
const startTime = Date.now();
try {
const { transaction, historicalTransactions, userId } = req.body;
if (!transaction) {
return res.status(400).json({
success: false,
error: "Missing transaction data",
message: "Please provide transaction text in the request body",
} as ApiResponse);
}
// Parse the transaction
const parsed = parseTransaction(transaction);
// Optional advanced recurrence detection (placeholder)
if (historicalTransactions && Array.isArray(historicalTransactions)) {
// e.g., parsed.recurrence = detectRecurrenceWithHistory(parsed, historicalTransactions);
}
// Optional Firestore storage
if (userId) {
try {
await db
.collection("users")
.doc(userId)
.collection("transactions")
.add({
...parsed,
createdAt: FieldValue.serverTimestamp(),
updatedAt: FieldValue.serverTimestamp(),
});
} catch (firestoreError) {
functions.logger.warn(
"Failed to store transaction in Firestore:",
firestoreError
);
}
}
const processingTime = Date.now() - startTime;
return res.json({
success: true,
data: parsed,
metadata: {
version: "1.0.0",
timestamp: new Date().toISOString(),
processingTimeMs: processingTime,
},
} as ApiResponse);
} catch (error) {
functions.logger.error("Transaction parsing error:", error);
return res.status(500).json({
success: false,
error: "Internal server error",
message: "Failed to parse transaction",
} as ApiResponse);
}
});
/**
* Parse multiple transactions (batch processing)
*/
app.post("/parse/batch", async (req, res) => {
const startTime = Date.now();
try {
const { transactions, userId } = req.body;
if (!transactions || !Array.isArray(transactions)) {
return res.status(400).json({
success: false,
error: "Invalid input",
message: "Please provide an array of transactions",
} as ApiResponse);
}
if (transactions.length > 50) {
return res.status(400).json({
success: false,
error: "Batch too large",
message: "Maximum 50 transactions per batch",
} as ApiResponse);
}
const results: Array<{
success: boolean;
data?: ParsedTransaction;
error?: string;
original: string;
}> = [];
for (const transaction of transactions) {
try {
const parsed = parseTransaction(transaction);
results.push({
success: true,
data: parsed,
original: transaction,
});
} catch (error) {
results.push({
success: false,
error: (error as Error).message,
original: transaction,
});
}
}
// Firestore batch write if needed
if (userId) {
const batch = db.batch();
const userTransactionsRef = db
.collection("users")
.doc(userId)
.collection("transactions");
results
.filter((result) => result.success && result.data)
.forEach((result) => {
const docRef = userTransactionsRef.doc();
batch.set(docRef, {
...(result as any).data,
createdAt: FieldValue.serverTimestamp(),
updatedAt: FieldValue.serverTimestamp(),
});
});
try {
await batch.commit();
} catch (firestoreError) {
functions.logger.warn(
"Failed to store batch transactions:",
firestoreError
);
}
}
const processingTime = Date.now() - startTime;
const successCount = results.filter((r) => r.success).length;
return res.json({
success: true,
data: {
results,
summary: {
total: transactions.length,
successful: successCount,
failed: transactions.length - successCount,
},
},
metadata: {
version: "1.0.0",
timestamp: new Date().toISOString(),
processingTimeMs: processingTime,
},
} as ApiResponse);
} catch (error) {
functions.logger.error("Batch parsing error:", error);
return res.status(500).json({
success: false,
error: "Internal server error",
message: "Failed to process batch",
} as ApiResponse);
}
});
/**
* Get available categories
*/
app.get("/categories", (req, res) => {
const categories = [...new Set(CATEGORY_RULES.map((rule) => rule.category))];
res.json({
success: true,
data: {
categories: categories.sort(),
rules: CATEGORY_RULES.map((rule) => ({
category: rule.category,
priority: rule.priority,
keywordCount: rule.keywords.length,
})),
},
} as ApiResponse);
});
/**
* Add new category rule
*/
app.post("/categories", (req, res) => {
try {
const { keywords, category, priority = 50 } = req.body;
if (!keywords || !Array.isArray(keywords) || !category) {
return res.status(400).json({
success: false,
error: "Invalid input",
message: "Please provide keywords array and category name",
} as ApiResponse);
}
CATEGORY_RULES.push({ keywords, category, priority });
return res.json({
success: true,
message: `Category rule for '${category}' added successfully`,
data: { keywords, category, priority },
} as ApiResponse);
} catch (error) {
return res.status(500).json({
success: false,
error: "Failed to add category rule",
} as ApiResponse);
}
});
/**
* Get merchant patterns
*/
app.get("/merchants", (req, res) => {
const merchants = MERCHANT_PATTERNS.map((pattern) => ({
normalizedName: pattern.normalizedName,
category: pattern.category,
pattern: pattern.pattern.source,
}));
res.json({
success: true,
data: merchants,
} as ApiResponse);
});
/**
* Add new merchant pattern
*/
app.post("/merchants", (req, res) => {
try {
const { pattern, normalizedName, category } = req.body;
if (!pattern || !normalizedName) {
return res.status(400).json({
success: false,
error: "Invalid input",
message: "Please provide pattern and normalizedName",
} as ApiResponse);
}
const regex = new RegExp(pattern, "i");
MERCHANT_PATTERNS.push({ pattern: regex, normalizedName, category });
return res.json({
success: true,
message: `Merchant pattern for '${normalizedName}' added successfully`,
data: { pattern, normalizedName, category },
} as ApiResponse);
} catch (error) {
return res.status(500).json({
success: false,
error: "Failed to add merchant pattern",
} as ApiResponse);
}
});
/**
* Get user's transaction history
*/
app.get("/users/:userId/transactions", async (req, res) => {
try {
const { userId } = req.params;
const { limit = 50, offset = 0, category } = req.query as {
limit?: string | number;
offset?: string | number;
category?: string;
};
let query = db
.collection("users")
.doc(userId)
.collection("transactions")
.orderBy("createdAt", "desc");
if (category) {
query = query.where("category", "==", category);
}
const snapshot = await query
.limit(Number(limit))
.offset(Number(offset))
.get();
const transactions = snapshot.docs.map((doc) => ({
id: doc.id,
...doc.data(),
}));
res.json({
success: true,
data: {
transactions,
pagination: {
limit: Number(limit),
offset: Number(offset),
count: transactions.length,
},
},
} as ApiResponse);
} catch (error) {
functions.logger.error("Failed to fetch transactions:", error);
res.status(500).json({
success: false,
error: "Failed to fetch transactions",
} as ApiResponse);
}
});
// 404 handler
app.use("*", (req, res) => {
res.status(404).json({
success: false,
error: "Not found",
message: "The requested endpoint does not exist",
} as ApiResponse);
});
// Error handling middleware
app.use(
(
error: any,
req: express.Request,
res: express.Response,
next: express.NextFunction
) => {
functions.logger.error("Express error:", error);
res.status(500).json({
success: false,
error: "Internal server error",
message: "Something went wrong",
} as ApiResponse);
}
);
// function detectRecurrenceWithHistory(parsed: ParsedTransaction, historicalTransactions: any[]) {
// // Placeholder for advanced recurrence detection logic
// throw new Error("Function not implemented.");
// }
export default app;
POST /parse- Parse single transactionPOST /parse/batch- Parse multiple transactionsGET /categories- Get available categoriesPOST /categories- Add new category ruleGET /merchants- Get merchant patternsGET /users/:userId/transactions- Get user history
Export Your Routes
Create the main routes index in src/routes/index.ts:
import * as express from "express";
import transactions from "./transactions";
const router = express.Router();
router.use("/transactions", transactions);
export default router;
Run & Test Your API
Start Development Server
npm run build:watch
npm run serve
http://localhost:5001/YOUR_PROJECT/me-central1/api/
Test the API
Test Parse Endpoint
Click the button below to test the API with sample Saudi bank transaction data:
{
"userId": "testUser123",
"transaction": "شراء إنترنت\nبـ 21.99 SAR\nمن Spotify AB P3781C3C72\nمدى 3180*\nحساب 0165*\nفي08-06-25",
"historicalTransactions": [
"شراء إنترنت\nبـ 21.99 SAR\nمن Spotify AB P3781C3C72\nمدى 3180*\nحساب 0165*\nفي08-05-25"
]
}
Deploy to Production
firebase deploy --only functions
- Update Firestore security rules
- Set up proper authentication
- Configure rate limiting
- Add monitoring and logging