🏦 Automate your expenses

Complete Firebase Cloud Functions Tutorial

🚀

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

Terminal
# Install TypeScript globally
npm install -g typescript

# Install Firebase CLI globally
npm install -g firebase-tools

# Login to Firebase
firebase login
Important: Make sure you're logged in to Firebase and have the necessary permissions for your project.
1

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

  1. Navigate to Cloud Firestore in the Firebase Console
  2. Click Create database
  3. Choose a location (recommend me-central1 for Saudi Arabia)
  4. Start in Test mode for development
✅ Success: Your Firestore database is now ready to store transaction data!
2

Set Up Cloud Functions with Express

Initialize Firebase Functions

Terminal
firebase init functions
Configuration Choices:
  • ✅ Use Existing project (select your project)
  • ✅ TypeScript (this is important!)
  • ❌ Use ESLint (skip for now)
  • ✅ Install dependencies now

Install Required Dependencies

Terminal (in functions folder)
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:

functions/package.json (scripts section)
{
  "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"
  }
}
💡 Development Tip: Use npm run build:watch in a separate terminal while developing. It will automatically recompile TypeScript files when you save changes!
3

Project File Structure

Create the following file structure in your functions/ directory:

functions/
├── package.json
├── tsconfig.json
└── src/
    ├── index.ts
    └── routes/
        ├── index.ts
        └── transactions/
            ├── const.ts
            ├── types.ts
            ├── utils.ts
            └── index.ts
💡 Structure Benefits:
  • Modular: Each component has its own file
  • Scalable: Easy to add new features
  • Maintainable: Clear separation of concerns
4

Create the Middleware Function

Create the main Express application in src/index.ts:

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
);
⚠️ Memory Setting: Use "1GiB" format, not "1GB" for Firebase Functions v2 API.
5

Add TypeScript Types

Create type definitions in src/routes/transactions/types.ts:

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 Benefits:
  • Type Safety: Catch errors at compile time
  • IntelliSense: Better IDE support
  • Documentation: Self-documenting code
6

Add Constants & Bank Patterns

Create bank parsing patterns and rules in src/routes/transactions/const.ts:

🏦 Supported Banks: Al Rajhi, NCB/SNB, Riyad Bank, SAMBA, SAIB, BSF, ANB, SABB, AlJazira, AlBilad, FAB, Alinma, and digital wallets (STC Pay, Mobily Pay, Zain Pay, Tamara, Tabby).
src/routes/transactions/const.ts (COMPLETE FILE)
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,
 },
];
💡 Bonus Challenge: Consider implementing a dynamic merchant pattern learning system to reduce categorization errors over time by tracking merchant patterns and keeping them in Firestore.
7

Add Utility Functions

Create parsing logic in src/routes/transactions/utils.ts:

src/routes/transactions/utils.ts (key functions)
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;
}
🎯 Parser Features:
  • Multi-bank pattern matching
  • Intelligent merchant normalization
  • Automatic category classification
  • Date normalization to ISO format
  • Recurrence detection
8

Add Transaction Routes

Create API endpoints in src/routes/transactions/index.ts:

src/routes/transactions/index.ts (main endpoints)
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;
🛣️ Available Endpoints:
  • 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
  • GET /users/:userId/transactions - Get user history
9

Export Your Routes

Create the main routes index in src/routes/index.ts:

src/routes/index.ts
import * as express from "express";
import transactions from "./transactions";

const router = express.Router();

router.use("/transactions", transactions);

export default router;
✅ Route Structure Complete! Your API now has a clean, modular structure that's easy to extend with new features.
10

Run & Test Your API

Start Development Server

Terminal (in functions folder)
npm run build:watch
npm run serve
🌐 Your API will be available at:
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:

Sample Request Body
{
  "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"
  ]
}
Click "Test API" to see the expected response...

Deploy to Production

Terminal
firebase deploy --only functions
⚠️ Before Production:
  • Update Firestore security rules
  • Set up proper authentication
  • Configure rate limiting
  • Add monitoring and logging