From 81619f85693aad3a5def54eb1e7f0310ccead95f Mon Sep 17 00:00:00 2001 From: Amritanshu Date: Mon, 20 Jul 2026 09:21:46 +0000 Subject: [PATCH] Created a transaction import voucher. I will use it to import my credit card and bank statements. --- .gemini/settings.json | 13 + brewman/brewman/main.py | 2 + brewman/brewman/routers/transaction_import.py | 273 +++++++++++++++++ overlord/package.json | 2 + overlord/src/app/app.routes.ts | 4 + .../app/core/nav-bar/nav-bar.component.html | 1 + .../transaction-import.component.css | 75 +++++ .../transaction-import.component.html | 275 ++++++++++++++++++ .../transaction-import.component.ts | 273 +++++++++++++++++ .../transaction-import.routes.ts | 11 + .../transaction-import.service.ts | 72 +++++ 11 files changed, 1001 insertions(+) create mode 100644 .gemini/settings.json create mode 100644 brewman/brewman/routers/transaction_import.py create mode 100644 overlord/src/app/transaction-import/transaction-import.component.css create mode 100644 overlord/src/app/transaction-import/transaction-import.component.html create mode 100644 overlord/src/app/transaction-import/transaction-import.component.ts create mode 100644 overlord/src/app/transaction-import/transaction-import.routes.ts create mode 100644 overlord/src/app/transaction-import/transaction-import.service.ts diff --git a/.gemini/settings.json b/.gemini/settings.json new file mode 100644 index 00000000..d4f1f4ca --- /dev/null +++ b/.gemini/settings.json @@ -0,0 +1,13 @@ +{ + "mcpServers": { + "context7": { + "command": "npx", + "args": [ + "-y", + "@upstash/context7-mcp", + "--api-key", + "ctx7sk-200afd5d-1a75-4c28-a3fb-3f3a26a721a0" + ] + } + } +} \ No newline at end of file diff --git a/brewman/brewman/main.py b/brewman/brewman/main.py index 9ceaef89..1e5d1ab3 100644 --- a/brewman/brewman/main.py +++ b/brewman/brewman/main.py @@ -50,6 +50,7 @@ from .routers import ( tag, temporal_product, title, + transaction_import, user, voucher, voucher_types, @@ -163,6 +164,7 @@ app.include_router(employee_benefit.router, prefix="/api/employee-benefit", tags app.include_router(incentive.router, prefix="/api/incentive", tags=["vouchers"]) app.include_router(credit_salary.router, prefix="/api/credit-salary", tags=["vouchers"]) app.include_router(voucher.router, prefix="/api", tags=["vouchers"]) +app.include_router(transaction_import.router, prefix="/api/transaction-import", tags=["vouchers"]) app.include_router(lock_information.router, prefix="/api/lock-information", tags=["settings"]) app.include_router(maintenance.router, prefix="/api/maintenance", tags=["settings"]) diff --git a/brewman/brewman/routers/transaction_import.py b/brewman/brewman/routers/transaction_import.py new file mode 100644 index 00000000..73c497b0 --- /dev/null +++ b/brewman/brewman/routers/transaction_import.py @@ -0,0 +1,273 @@ +import io +import logging +import uuid + +from datetime import date, datetime +from typing import Annotated, Any + +import pandas as pd + +from fastapi import APIRouter, File, Form, HTTPException, Security, UploadFile +from pydantic import BaseModel +from sqlalchemy import select +from sqlalchemy.orm import Session + +from ..core.security import get_current_active_user as get_user +from ..db.session import SessionDep +from ..models.account_base import AccountBase +from ..models.journal import Journal +from ..models.voucher import Voucher +from ..models.voucher_type import VoucherType +from ..schemas.user import UserToken + + +router = APIRouter() +logger = logging.getLogger(__name__) + + +class ColumnMapping(BaseModel): + sheet_name: str + date_col: str + account_col: str | None = None + narration_col: str | None = None + amount_col: str | None = None + debit_col: str | None = None + credit_col: str | None = None + default_account_id: uuid.UUID | None = None + + +class PreviewRow(BaseModel): + index: int + date_: date | None + account_id: uuid.UUID | None + account_name: str | None + narration: str + debit: float + credit: float + is_valid: bool + validation_error: str | None = None + + +class ImportExecutionRequest(BaseModel): + primary_account_id: uuid.UUID + rows: list[PreviewRow] + + +@router.post("/parse-headers") +async def parse_headers( + user: Annotated[UserToken, Security(get_user, scopes=["journal"])], + file: UploadFile = File(...), +): + try: + content = await file.read() + excel = pd.ExcelFile(io.BytesIO(content)) + sheets = excel.sheet_names + headers = {} + for sheet in sheets: + df = pd.read_excel(io.BytesIO(content), sheet_name=sheet, nrows=0) + headers[sheet] = [str(col) if pd.notna(col) else f"Unnamed_{i}" for i, col in enumerate(df.columns)] + return {"sheets": sheets, "headers": headers} + except Exception as e: + logger.error(f"Error parsing headers: {e}") + raise HTTPException(status_code=400, detail="Invalid Excel file format.") + + +def _parse_date(val: Any) -> date | None: + if pd.isna(val): + return None + if isinstance(val, datetime): + return val.date() + if isinstance(val, date): + return val + try: + return pd.to_datetime(val, dayfirst=True).date() + except Exception: + return None + + +def _find_account(val: str, db: Session) -> uuid.UUID | None: + if not val or pd.isna(val): + return None + val_str = str(val).strip() + + # Try exact UUID + try: + uid = uuid.UUID(val_str) + account = db.execute(select(AccountBase).where(AccountBase.id == uid)).scalar_one_or_none() + if account: + return account.id + except ValueError: + pass + + # Try name match + account = db.execute(select(AccountBase).where(AccountBase.name.ilike(val_str))).scalar_one_or_none() + if account: + return account.id + + return None + + +@router.post("/preview") +async def preview_import( + user: Annotated[UserToken, Security(get_user, scopes=["journal"])], + db: SessionDep, + mapping_str: str = Form(...), + file: UploadFile = File(...), +): + try: + mapping = ColumnMapping.model_validate_json(mapping_str) + except Exception as e: + raise HTTPException(status_code=400, detail=f"Invalid mapping configuration: {e}") + + try: + content = await file.read() + df = pd.read_excel(io.BytesIO(content), sheet_name=mapping.sheet_name) + except Exception: + raise HTTPException(status_code=400, detail="Could not read sheet data.") + + preview_rows = [] + + for idx, row in df.iterrows(): + is_valid = True + validation_error = None + + # 1. Date + row_date = _parse_date(row.get(mapping.date_col)) if mapping.date_col else None + if not row_date: + is_valid = False + validation_error = "Invalid or missing date." + + # 2. Account + account_id = None + account_name = None + if mapping.account_col and mapping.account_col in row: + raw_acc = row.get(mapping.account_col) + if not pd.isna(raw_acc): + account_name = str(raw_acc) + account_id = _find_account(raw_acc, db) + + if not account_id and mapping.default_account_id: + account_id = mapping.default_account_id + + if not account_id and is_valid: + is_valid = False + validation_error = f"Account '{account_name}' not found." if account_name else "Account missing." + + # 3. Narration + narration = "" + if mapping.narration_col and mapping.narration_col in row: + val = row.get(mapping.narration_col) + if not pd.isna(val): + narration = str(val) + + # 4. Amount / Debit / Credit + debit = 0.0 + credit = 0.0 + + if mapping.debit_col and mapping.credit_col: + d_val = row.get(mapping.debit_col, 0) + c_val = row.get(mapping.credit_col, 0) + debit = float(d_val) if not pd.isna(d_val) else 0.0 + credit = float(c_val) if not pd.isna(c_val) else 0.0 + elif mapping.amount_col: + amt = row.get(mapping.amount_col, 0) + amt = float(amt) if not pd.isna(amt) else 0.0 + if amt > 0: + debit = amt + elif amt < 0: + credit = abs(amt) + + if debit == 0 and credit == 0 and is_valid: + is_valid = False + validation_error = "Transaction amount is zero." + + preview_rows.append( + PreviewRow( + index=idx, + date_=row_date, + account_id=account_id, + account_name=account_name, + narration=narration, + debit=debit, + credit=credit, + is_valid=is_valid, + validation_error=validation_error, + ) + ) + + return {"rows": preview_rows} + + +@router.post("/execute") +async def execute_import( + request: ImportExecutionRequest, + user: Annotated[UserToken, Security(get_user, scopes=["journal"])], + db: SessionDep, +): + primary_acc = db.execute( + select(AccountBase).where(AccountBase.id == request.primary_account_id) + ).scalar_one_or_none() + if not primary_acc: + raise HTTPException(status_code=400, detail="Primary account not found.") + + created_vouchers = [] + + for row in request.rows: + if not row.is_valid or not row.date_ or not row.account_id: + continue + + other_acc = db.execute(select(AccountBase).where(AccountBase.id == row.account_id)).scalar_one_or_none() + if not other_acc: + continue + + voucher = Voucher( + date_=row.date_, + narration=row.narration, + is_starred=False, + user_id=user.id_, + voucher_type=VoucherType.JOURNAL, + ) + db.add(voucher) + + # Determine signs for journals + if row.debit > 0: + amount = row.debit + # Primary gets Debit (1) + j_primary = Journal( + amount=amount, + debit=1, + account_id=primary_acc.id, + cost_centre_id=primary_acc.cost_centre_id, + ) + # Other gets Credit (-1) + j_other = Journal( + amount=amount, + debit=-1, + account_id=other_acc.id, + cost_centre_id=other_acc.cost_centre_id, + ) + else: + amount = row.credit + # Primary gets Credit (-1) + j_primary = Journal( + amount=amount, + debit=-1, + account_id=primary_acc.id, + cost_centre_id=primary_acc.cost_centre_id, + ) + # Other gets Debit (1) + j_other = Journal( + amount=amount, + debit=1, + account_id=other_acc.id, + cost_centre_id=other_acc.cost_centre_id, + ) + + voucher.journals.append(j_primary) + voucher.journals.append(j_other) + db.add(j_primary) + db.add(j_other) + created_vouchers.append(voucher) + + db.commit() + return {"message": f"Successfully imported {len(created_vouchers)} entries."} diff --git a/overlord/package.json b/overlord/package.json index fb49e09f..d211ec16 100644 --- a/overlord/package.json +++ b/overlord/package.json @@ -25,6 +25,8 @@ "@angular/platform-browser": "^21.1.5", "@angular/platform-browser-dynamic": "^21.1.5", "@angular/router": "^21.1.5", + "html2canvas": "^1.4.1", + "jspdf": "^4.2.0", "mathjs": "^15.1.1", "moment": "^2.30.1", "rxjs": "~7.8.0", diff --git a/overlord/src/app/app.routes.ts b/overlord/src/app/app.routes.ts index a5f029c9..9a5beb12 100644 --- a/overlord/src/app/app.routes.ts +++ b/overlord/src/app/app.routes.ts @@ -177,6 +177,10 @@ export const routes: Routes = [ path: 'entries', loadChildren: () => import('./entries/entries.routes').then((mod) => mod.routes), }, + { + path: 'transaction-import', + loadChildren: () => import('./transaction-import/transaction-import.routes').then((mod) => mod.routes), + }, { path: 'users', loadChildren: () => import('./user/user.routes').then((mod) => mod.routes), diff --git a/overlord/src/app/core/nav-bar/nav-bar.component.html b/overlord/src/app/core/nav-bar/nav-bar.component.html index d27f7b32..5b94db64 100644 --- a/overlord/src/app/core/nav-bar/nav-bar.component.html +++ b/overlord/src/app/core/nav-bar/nav-bar.component.html @@ -9,6 +9,7 @@ Payment - F5 Receipt - F6 Issue + Transaction Import diff --git a/overlord/src/app/transaction-import/transaction-import.component.css b/overlord/src/app/transaction-import/transaction-import.component.css new file mode 100644 index 00000000..e60c9ce7 --- /dev/null +++ b/overlord/src/app/transaction-import/transaction-import.component.css @@ -0,0 +1,75 @@ +.container { + padding: 24px; +} + +.step-container { + padding: 16px 0; +} + +h3 { + margin-top: 16px; + margin-bottom: 8px; + color: #333; +} + +.full-width { + width: 100%; + max-width: 400px; +} + +.file-upload-zone { + border: 2px dashed #ccc; + padding: 32px; + text-align: center; + border-radius: 8px; + margin-bottom: 16px; + max-width: 400px; +} + +.mapping-grid { + display: flex; + flex-wrap: wrap; + gap: 16px; + align-items: center; +} + +.or-separator { + font-weight: bold; + color: #888; + padding: 0 16px; +} + +.actions { + margin-top: 24px; + display: flex; + gap: 16px; +} + +.summary-bar { + display: flex; + align-items: center; + gap: 24px; + padding: 16px; + background-color: #f5f5f5; + border-radius: 8px; + margin-bottom: 16px; +} + +.summary-item { + font-weight: bold; + font-size: 1.1em; +} + +table { + width: 100%; +} + +.invalid-row td { + color: #999; + text-decoration: line-through; +} + +.error-text { + color: #f44336; + font-weight: 500; +} diff --git a/overlord/src/app/transaction-import/transaction-import.component.html b/overlord/src/app/transaction-import/transaction-import.component.html new file mode 100644 index 00000000..6667eb2d --- /dev/null +++ b/overlord/src/app/transaction-import/transaction-import.component.html @@ -0,0 +1,275 @@ +
+ + + +
+

Select Primary Account

+ + Destination Account + + + @for (acc of filteredAccounts(); track acc.id) { + + {{ acc.name }} + + } + + + +

Upload Excel or CSV Statement

+
+ +
+ +
+ +
+
+
+ + + +
+

Select Sheet

+ + Sheet Name + + @for (sheet of availableSheets(); track sheet) { + {{ sheet }} + } + + + +

Column Mappings

+
+ + Date Column + + @for (h of currentHeaders(); track h) { + {{ h }} + } + + + + + Other Account Column + + -- None -- + @for (h of currentHeaders(); track h) { + {{ h }} + } + + + + + Narration / Description + + -- None -- + @for (h of currentHeaders(); track h) { + {{ h }} + } + + +
+ +

Amount Mapping

+
+ + Amount Column (Single) + + -- None -- + @for (h of currentHeaders(); track h) { + {{ h }} + } + + Use this if amount is one column (+/-) + +
OR
+ + Debit Column + + -- None -- + @for (h of currentHeaders(); track h) { + {{ h }} + } + + + + Credit Column + + -- None -- + @for (h of currentHeaders(); track h) { + {{ h }} + } + + +
+ +

Fallback Account

+ + Default Account (if unmapped) + + -- None -- + @for (acc of accounts(); track acc.id) { + {{ acc.name }} + } + + + +
+ + +
+
+
+ + + +
+
+
Selected: {{ totalSelected() }}
+
Total Debit: {{ totalDebit() | number: '1.2-2' }}
+
Total Credit: {{ totalCredit() | number: '1.2-2' }}
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + Date{{ row.date_ | date: 'mediumDate' }}Other Account + + + + @for (acc of filteredAccounts(); track acc.id) { + + {{ acc.name }} + + } + + + @if (!row.account_id) { +
Not Mapped
+ } +
Narration{{ row.narration }}Debit{{ row.debit | number: '1.2-2' }}Credit{{ row.credit | number: '1.2-2' }}Status + @if (row.is_valid) { + check_circle + } @else { + error + } +
+ +
+ +
+
+
+
+
diff --git a/overlord/src/app/transaction-import/transaction-import.component.ts b/overlord/src/app/transaction-import/transaction-import.component.ts new file mode 100644 index 00000000..e5c60d5f --- /dev/null +++ b/overlord/src/app/transaction-import/transaction-import.component.ts @@ -0,0 +1,273 @@ +import { SelectionModel } from '@angular/cdk/collections'; +import { CommonModule } from '@angular/common'; +import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { MatAutocompleteModule } from '@angular/material/autocomplete'; +import { MatButtonModule } from '@angular/material/button'; +import { MatCheckboxModule } from '@angular/material/checkbox'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatIconModule } from '@angular/material/icon'; +import { MatInputModule } from '@angular/material/input'; +import { MatSelectModule } from '@angular/material/select'; +import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; +import { MatStepperModule } from '@angular/material/stepper'; +import { MatTableModule } from '@angular/material/table'; +import { Title } from '@angular/platform-browser'; +import { Router } from '@angular/router'; + +import { Account } from '../core/account'; +import { AccountService } from '../core/account.service'; +import { ColumnMapping, PreviewRow, TransactionImportService } from './transaction-import.service'; + +@Component({ + selector: 'app-transaction-import', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + CommonModule, + FormsModule, + MatStepperModule, + MatFormFieldModule, + MatInputModule, + MatSelectModule, + MatButtonModule, + MatCheckboxModule, + MatTableModule, + MatAutocompleteModule, + MatIconModule, + MatSnackBarModule, + ], + templateUrl: './transaction-import.component.html', + styleUrls: ['./transaction-import.component.css'], +}) +export class TransactionImportComponent { + private titleService = inject(Title); + private accountService = inject(AccountService); + private importService = inject(TransactionImportService); + private snackBar = inject(MatSnackBar); + private router = inject(Router); + + // Step 1 + accounts = signal([]); + primaryAccountId = signal(null); + selectedFile = signal(null); + + // Step 2 + availableSheets = signal([]); + availableHeaders = signal>({}); + selectedSheet = signal(''); + + mapping = signal({ + sheet_name: '', + date_col: '', + account_col: null, + narration_col: null, + amount_col: null, + debit_col: null, + credit_col: null, + default_account_id: null, + }); + + // Step 3 + previewRows = signal([]); + selection = new SelectionModel(true, []); + selectedRows = signal([]); + displayedColumns: string[] = ['select', 'date', 'account', 'narration', 'debit', 'credit', 'status']; + + // Computed state + currentHeaders = computed(() => { + return this.availableHeaders()[this.selectedSheet()] || []; + }); + + totalSelected = computed(() => this.selectedRows().length); + totalDebit = computed(() => this.selectedRows().reduce((sum, row) => sum + row.debit, 0)); + totalCredit = computed(() => this.selectedRows().reduce((sum, row) => sum + row.credit, 0)); + + constructor() { + this.titleService.setTitle('Import Transactions'); + this.loadAccounts(); + this.selection.changed.subscribe(() => { + this.selectedRows.set(this.selection.selected); + }); + } + + loadAccounts() { + this.accountService.list().subscribe((accounts) => { + this.accounts.set(accounts); + }); + } + + filteredAccounts = signal([]); + primaryAccountName = signal(''); + + onPrimaryAccountSearch(query: string) { + if (this.primaryAccountName() === query && this.primaryAccountId() !== null) { + return; + } + this.primaryAccountName.set(query); + this.primaryAccountId.set(null); + if (query && query.length >= 2) { + this.accountService.autocomplete(query).subscribe((res) => this.filteredAccounts.set(res)); + } else { + this.filteredAccounts.set([]); + } + } + + onPrimaryAccountSelected(acc: Account) { + this.primaryAccountId.set(acc.id || null); + this.primaryAccountName.set(acc.name); + } + + onRowAccountSearch(row: PreviewRow, query: string) { + if (row.account_name === query && row.account_id !== null) { + return; + } + row.account_name = query; + row.account_id = null; + row.is_valid = false; + // Notify angular about row update for template binding + this.previewRows.set([...this.previewRows()]); + if (query && query.length >= 2) { + this.accountService.autocomplete(query).subscribe((res) => this.filteredAccounts.set(res)); + } else { + this.filteredAccounts.set([]); + } + } + + onRowAccountSelected(row: PreviewRow, acc: Account) { + row.account_id = acc.id || null; + row.account_name = acc.name; + row.is_valid = true; + row.validation_error = null; + this.previewRows.set([...this.previewRows()]); + } + + onFileSelected(event: Event) { + const file = (event.target as HTMLInputElement).files?.[0]; + if (file) { + this.selectedFile.set(file); + } + } + + onUploadAndParseHeaders() { + const file = this.selectedFile(); + if (!file) return; + + this.importService.parseHeaders(file).subscribe({ + next: (res) => { + this.availableSheets.set(res.sheets); + this.availableHeaders.set(res.headers); + if (res.sheets.length > 0) { + this.selectedSheet.set(res.sheets[0]); + this.autoMapColumns(res.sheets[0]); + } + }, + error: (err) => { + this.snackBar.open('Error parsing file headers: ' + err.message, 'Close', { duration: 3000 }); + }, + }); + } + + onSheetChange() { + this.autoMapColumns(this.selectedSheet()); + } + + autoMapColumns(sheet: string) { + const headers = this.availableHeaders()[sheet] || []; + const hLower = headers.map((h) => h.toLowerCase()); + + const mapping: ColumnMapping = { + sheet_name: sheet, + date_col: '', + account_col: null, + narration_col: null, + amount_col: null, + debit_col: null, + credit_col: null, + default_account_id: null, + }; + + // Auto-detect Date + let idx = hLower.findIndex((h) => h.includes('date')); + if (idx !== -1) mapping.date_col = headers[idx]; + + // Auto-detect Account + idx = hLower.findIndex((h) => h.includes('account')); + if (idx !== -1) mapping.account_col = headers[idx]; + + // Auto-detect Narration + idx = hLower.findIndex((h) => h.includes('narration') || h.includes('description') || h.includes('particulars')); + if (idx !== -1) mapping.narration_col = headers[idx]; + + // Auto-detect Amount / Debit / Credit + const dIdx = hLower.findIndex((h) => h === 'debit' || h === 'dr'); + const cIdx = hLower.findIndex((h) => h === 'credit' || h === 'cr'); + if (dIdx !== -1 && cIdx !== -1) { + mapping.debit_col = headers[dIdx]; + mapping.credit_col = headers[cIdx]; + } else { + idx = hLower.findIndex((h) => h.includes('amount')); + if (idx !== -1) mapping.amount_col = headers[idx]; + } + + this.mapping.set(mapping); + } + + onGeneratePreview() { + const file = this.selectedFile(); + if (!file) return; + + this.mapping.update((m) => ({ ...m, sheet_name: this.selectedSheet() })); + + this.importService.preview(file, this.mapping()).subscribe({ + next: (res) => { + this.previewRows.set(res.rows); + this.selection.clear(); + // Select all valid by default + const validRows = res.rows.filter((r) => r.is_valid); + this.selection.select(...validRows); + }, + error: (err) => { + this.snackBar.open('Error generating preview: ' + err.message, 'Close', { duration: 3000 }); + }, + }); + } + + isAllSelected() { + const numSelected = this.selection.selected.length; + const numValid = this.previewRows().filter((r) => r.is_valid).length; + return numSelected === numValid; + } + + masterToggle() { + if (this.isAllSelected()) { + this.selection.clear(); + return; + } + const validRows = this.previewRows().filter((r) => r.is_valid); + this.selection.select(...validRows); + } + + onExecuteImport() { + const selectedRows = this.selection.selected; + if (selectedRows.length === 0) return; + + const accountId = this.primaryAccountId(); + if (!accountId) return; + + const req = { + primary_account_id: accountId, + rows: selectedRows, + }; + + this.importService.execute(req).subscribe({ + next: (res) => { + this.snackBar.open(res.message, 'Close', { duration: 3000 }); + this.router.navigate(['/daybook']); + }, + error: (err) => { + this.snackBar.open('Error importing transactions: ' + err.message, 'Close', { duration: 3000 }); + }, + }); + } +} diff --git a/overlord/src/app/transaction-import/transaction-import.routes.ts b/overlord/src/app/transaction-import/transaction-import.routes.ts new file mode 100644 index 00000000..8a72ca7a --- /dev/null +++ b/overlord/src/app/transaction-import/transaction-import.routes.ts @@ -0,0 +1,11 @@ +import { Routes } from '@angular/router'; + +import { TransactionImportComponent } from './transaction-import.component'; + +export const routes: Routes = [ + { + path: '', + component: TransactionImportComponent, + title: 'Import Transactions', + }, +]; diff --git a/overlord/src/app/transaction-import/transaction-import.service.ts b/overlord/src/app/transaction-import/transaction-import.service.ts new file mode 100644 index 00000000..e5b0b1d3 --- /dev/null +++ b/overlord/src/app/transaction-import/transaction-import.service.ts @@ -0,0 +1,72 @@ +import { HttpClient } from '@angular/common/http'; +import { inject, Injectable } from '@angular/core'; +import { Observable } from 'rxjs'; +import { catchError } from 'rxjs/operators'; + +import { ErrorLoggerService } from '../core/error-logger.service'; + +const url = '/api/transaction-import'; + +export interface ColumnMapping { + sheet_name: string; + date_col: string; + account_col?: string | null; + narration_col?: string | null; + amount_col?: string | null; + debit_col?: string | null; + credit_col?: string | null; + default_account_id?: string | null; +} + +export interface PreviewRow { + index: number; + date_: string | null; + account_id: string | null; + account_name: string | null; + narration: string; + debit: number; + credit: number; + is_valid: boolean; + validation_error?: string | null; +} + +export interface ImportExecutionRequest { + primary_account_id: string; + rows: PreviewRow[]; +} + +@Injectable({ providedIn: 'root' }) +export class TransactionImportService { + private http = inject(HttpClient); + private log = inject(ErrorLoggerService); + + parseHeaders(file: File): Observable<{ sheets: string[]; headers: Record }> { + const formData = new FormData(); + formData.append('file', file); + return this.http + .post<{ sheets: string[]; headers: Record }>(`${url}/parse-headers`, formData) + .pipe(catchError(this.log.handleError('TransactionImportService', 'parseHeaders'))) as Observable<{ + sheets: string[]; + headers: Record; + }>; + } + + preview(file: File, mapping: ColumnMapping): Observable<{ rows: PreviewRow[] }> { + const formData = new FormData(); + formData.append('file', file); + formData.append('mapping_str', JSON.stringify(mapping)); + return this.http + .post<{ rows: PreviewRow[] }>(`${url}/preview`, formData) + .pipe(catchError(this.log.handleError('TransactionImportService', 'preview'))) as Observable<{ + rows: PreviewRow[]; + }>; + } + + execute(request: ImportExecutionRequest): Observable<{ message: string }> { + return this.http + .post<{ message: string }>(`${url}/execute`, request) + .pipe(catchError(this.log.handleError('TransactionImportService', 'execute'))) as Observable<{ + message: string; + }>; + } +}