diff --git a/docs/ADMIN.md b/docs/ADMIN.md index 4fa923d..e33adbe 100644 --- a/docs/ADMIN.md +++ b/docs/ADMIN.md @@ -314,6 +314,27 @@ container/facade/service split as the rest of `admin/*`: - Wired into `/​:lang/backoffice/orders` and `/​:lang/backoffice/orders/:id`, replacing the coming-soon placeholder. +## Sprint 24 - Transactions (mock/local) + +`features/admin/transactions/`. `AdminTransactionsLocalGateway` derives its +mock data from `AdminOrdersLocalGateway`'s 24 seeded orders (one +transaction per order, deterministic type/status/method assignment) rather +than a separate synthetic dataset - keeps order numbers/totals consistent +between the two mock feature areas. + +- List: search, status filter, type filter (payment/refund/qr_payment), + pagination, CSV export. +- Retry failed transactions (`status: 'failed' -> 'retried'`, appends an + audit entry). +- Fraud flag toggle per transaction. +- Audit log: each transaction carries its own `audit: AdminTransactionAuditEntry[]` + (creation, retries, fraud-flag changes), viewed via a dialog - this is a + per-transaction audit trail, not the system-wide audit/security log + planned for Sprint 26 (Monitoring); the two are intentionally separate + scopes. +- Wired into `/​:lang/backoffice/transactions`, replacing the coming-soon + placeholder. + ## Known gaps / backend needs - **Dashboard metrics endpoint.** Categories/Products counts are computed diff --git a/docs/BACKEND.md b/docs/BACKEND.md index 8781d64..b922d8a 100644 --- a/docs/BACKEND.md +++ b/docs/BACKEND.md @@ -144,6 +144,14 @@ Plus, if authenticated history/wishlist/compare/saved-searches sync is wanted: ` --- +## 13. Transactions (Sprint 24, mock/local) + +**Current behavior:** `features/admin/transactions/` exists (list, retry-failed, fraud flag, per-transaction audit log, CSV export) against `AdminTransactionsLocalGateway`, which derives one synthetic transaction per seeded mock order from item 7's `AdminOrdersLocalGateway` — no real payment/transaction data exists. + +**Needed:** a real payments/transactions domain (card, QR, cash-on-delivery), linked to orders, with retry semantics matching whatever the actual payment provider supports, and fraud-flag persistence. + +**Frontend files:** implement `AdminTransactionsApiGateway` against `AdminTransactionsGateway` (`services/admin-transactions-gateway.interface.ts`) and rebind via an injection token. + ## Known reliability issues ### Production 502/504 Bad Gateway on refresh / back-navigation diff --git a/docs/SPRINT-PLAN.md b/docs/SPRINT-PLAN.md index d979b32..2d271c6 100644 --- a/docs/SPRINT-PLAN.md +++ b/docs/SPRINT-PLAN.md @@ -47,8 +47,9 @@ Notify user: **from Sprint 20 (Categories) once product↔category link + admin - [x] `docs/ADMIN.md` (new Sprint 23 section), `docs/BACKEND.md` item 7 rewritten - Commit: `feat(admin): order management` -## Sprint 24 — Transactions (mock/local) -- [ ] Payments/refunds/QR list, status, history, export, filters, search, retry, fraud flags, audit log view +## Sprint 24 — Transactions (mock/local) ✅ done +- [x] Payments/refunds/QR list, status, history (derived from Sprint 23's seeded orders), export, filters, search, retry failed, fraud flags, per-transaction audit log dialog +- [x] `docs/ADMIN.md` (new Sprint 24 section), `docs/BACKEND.md` item 13 added - Commit: `feat(admin): transaction management` ## Sprint 25 — Users & Roles diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index dace8b1..6737af8 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -93,8 +93,7 @@ const coreRoutes: Routes = [ }, { path: 'transactions', - loadComponent: () => import('./features/backoffice/shared/backoffice-coming-soon-page.component').then(m => m.BackofficeComingSoonPageComponent), - data: { titleKey: 'dashboard.actionTransactions' } + loadComponent: () => import('./features/admin/transactions/pages/admin-transactions-list-page.component').then(m => m.AdminTransactionsListPageComponent) }, { path: 'orders', diff --git a/src/app/features/admin/transactions/facade/admin-transactions.facade.ts b/src/app/features/admin/transactions/facade/admin-transactions.facade.ts new file mode 100644 index 0000000..038689f --- /dev/null +++ b/src/app/features/admin/transactions/facade/admin-transactions.facade.ts @@ -0,0 +1,51 @@ +import { Injectable, inject, signal } from '@angular/core'; +import { take } from 'rxjs/operators'; +import { AdminTransaction, AdminTransactionListFilters } from '../models/admin-transaction.model'; +import { AdminTransactionsLocalGateway } from '../services/admin-transactions-local.gateway'; + +@Injectable({ providedIn: 'root' }) +export class AdminTransactionsFacade { + private readonly gateway = inject(AdminTransactionsLocalGateway); + + readonly filters = signal({ search: '', status: 'all', type: 'all', page: 1, pageSize: 10 }); + readonly transactions = signal([]); + readonly total = signal(0); + readonly loading = signal(false); + + loadList(): void { + this.loading.set(true); + this.gateway.loadTransactions(this.filters()).pipe(take(1)).subscribe({ + next: result => { + this.transactions.set(result.items); + this.total.set(result.total); + this.loading.set(false); + }, + error: () => { + this.transactions.set([]); + this.total.set(0); + this.loading.set(false); + } + }); + } + + updateFilters(patch: Partial): void { + this.filters.update(current => ({ ...current, ...patch, page: patch.page ?? 1 })); + this.loadList(); + } + + retryFailed(id: string): void { + this.gateway.retryFailed(id).pipe(take(1)).subscribe({ next: () => this.loadList() }); + } + + toggleFraudFlag(id: string, flagged: boolean): void { + this.gateway.setFraudFlag(id, flagged).pipe(take(1)).subscribe({ next: () => this.loadList() }); + } + + exportCsv(): string { + const header = 'Order Number,Type,Method,Status,Amount,Currency,Fraud Flag,Created At'; + const rows = this.transactions().map(tx => + [tx.orderNumber, tx.type, tx.method, tx.status, tx.amount, tx.currency, tx.fraudFlag, tx.createdAt].join(',') + ); + return [header, ...rows].join('\n'); + } +} diff --git a/src/app/features/admin/transactions/models/admin-transaction.model.ts b/src/app/features/admin/transactions/models/admin-transaction.model.ts new file mode 100644 index 0000000..1031b35 --- /dev/null +++ b/src/app/features/admin/transactions/models/admin-transaction.model.ts @@ -0,0 +1,38 @@ +export type AdminTransactionType = 'payment' | 'refund' | 'qr_payment'; +export type AdminTransactionStatus = 'pending' | 'success' | 'failed' | 'retried'; + +export interface AdminTransactionAuditEntry { + action: string; + actor: string; + timestamp: string; +} + +export interface AdminTransaction { + id: string; + orderId: string; + orderNumber: string; + type: AdminTransactionType; + method: string; + status: AdminTransactionStatus; + amount: number; + currency: string; + fraudFlag: boolean; + audit: AdminTransactionAuditEntry[]; + createdAt: string; + updatedAt: string; +} + +export interface AdminTransactionListFilters { + search: string; + status: 'all' | AdminTransactionStatus; + type: 'all' | AdminTransactionType; + page: number; + pageSize: number; +} + +export interface AdminTransactionsListResult { + items: AdminTransaction[]; + total: number; + page: number; + pageSize: number; +} diff --git a/src/app/features/admin/transactions/pages/admin-transactions-list-page.component.html b/src/app/features/admin/transactions/pages/admin-transactions-list-page.component.html new file mode 100644 index 0000000..31f0481 --- /dev/null +++ b/src/app/features/admin/transactions/pages/admin-transactions-list-page.component.html @@ -0,0 +1,72 @@ +
+
+
+ + + +
+ {{ 'adminOrders.export' | translate }} +
+ + @if (!facade.loading() && facade.transactions().length === 0) { + + } @else { + + + + {{ 'adminOrders.orderNumber' | translate }} + {{ 'adminTransactions.type' | translate }} + {{ 'adminTransactions.method' | translate }} + {{ 'backoffice.status' | translate }} + {{ 'backoffice.price' | translate }} + {{ 'adminTransactions.fraud' | translate }} + {{ 'adminProducts.actions' | translate }} + + + + @for (tx of facade.transactions(); track tx.id) { + + {{ tx.orderNumber }} + {{ ('adminTransactions.type.' + tx.type) | translate }} + {{ tx.method }} + {{ ('adminTransactions.status.' + tx.status) | translate }} + {{ tx.amount }} {{ tx.currency }} + + @if (tx.fraudFlag) { + {{ 'adminTransactions.flagged' | translate }} + } + + + @if (tx.status === 'failed') { + {{ 'adminTransactions.retry' | translate }} + } + {{ (tx.fraudFlag ? 'adminTransactions.clearFlag' : 'adminTransactions.flag') | translate }} + {{ 'adminTransactions.audit' | translate }} + + + } + + + +
+ {{ facade.total() }} {{ 'adminProducts.items' | translate }} + +
+ } + + + @if (auditTarget(); as tx) { + @for (entry of tx.audit; track $index) { +

{{ entry.timestamp | date:'short' }} — {{ entry.actor }} — {{ entry.action }}

+ } + } +
+
diff --git a/src/app/features/admin/transactions/pages/admin-transactions-list-page.component.scss b/src/app/features/admin/transactions/pages/admin-transactions-list-page.component.scss new file mode 100644 index 0000000..418b67f --- /dev/null +++ b/src/app/features/admin/transactions/pages/admin-transactions-list-page.component.scss @@ -0,0 +1,7 @@ +.admin-transactions-card { display: grid; gap: 16px; padding: 16px; border: 1px solid var(--border-color, #d3dad9); border-radius: 16px; background: #fff; } +.toolbar { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 12px; align-items: flex-start; } +.filters { display: flex; flex-wrap: wrap; gap: 10px; flex: 1; } +select { min-height: 40px; padding: 0 10px; border: 1px solid var(--border-color, #d3dad9); border-radius: 10px; } +.pager { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 10px; } +.actions { display: flex; gap: 8px; flex-wrap: wrap; } +@media (max-width: 640px) { .filters { flex-direction: column; align-items: stretch; } } diff --git a/src/app/features/admin/transactions/pages/admin-transactions-list-page.component.ts b/src/app/features/admin/transactions/pages/admin-transactions-list-page.component.ts new file mode 100644 index 0000000..16696a5 --- /dev/null +++ b/src/app/features/admin/transactions/pages/admin-transactions-list-page.component.ts @@ -0,0 +1,56 @@ +import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { AdminTransactionsFacade } from '../facade/admin-transactions.facade'; +import { AdminTransaction } from '../models/admin-transaction.model'; +import { TranslatePipe } from '../../../../i18n/translate.pipe'; +import { ButtonComponent } from '../../../../shared/ui/button/button.component'; +import { InputComponent } from '../../../../shared/ui/input/input.component'; +import { BadgeComponent } from '../../../../shared/ui/badge/badge.component'; +import { TableComponent } from '../../../../shared/ui/table/table.component'; +import { PaginationComponent } from '../../../../shared/ui/pagination/pagination.component'; +import { EmptyStateComponent } from '../../../../shared/ui/empty-state/empty-state.component'; +import { DialogComponent } from '../../../../shared/ui/dialog/dialog.component'; + +@Component({ + selector: 'app-admin-transactions-list-page', + standalone: true, + imports: [CommonModule, FormsModule, TranslatePipe, ButtonComponent, InputComponent, BadgeComponent, TableComponent, PaginationComponent, EmptyStateComponent, DialogComponent], + templateUrl: './admin-transactions-list-page.component.html', + styleUrls: ['./admin-transactions-list-page.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class AdminTransactionsListPageComponent { + readonly facade = inject(AdminTransactionsFacade); + + readonly statuses = ['all', 'pending', 'success', 'failed', 'retried'] as const; + readonly types = ['all', 'payment', 'refund', 'qr_payment'] as const; + readonly auditTarget = signal(null); + + constructor() { + this.facade.loadList(); + } + + totalPages(): number { + return Math.max(1, Math.ceil(this.facade.total() / this.facade.filters().pageSize)); + } + + viewAudit(tx: AdminTransaction): void { + this.auditTarget.set(tx); + } + + closeAudit(): void { + this.auditTarget.set(null); + } + + exportCsv(): void { + const csv = this.facade.exportCsv(); + const blob = new Blob([csv], { type: 'text/csv' }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = 'transactions.csv'; + link.click(); + URL.revokeObjectURL(url); + } +} diff --git a/src/app/features/admin/transactions/services/admin-transactions-gateway.interface.ts b/src/app/features/admin/transactions/services/admin-transactions-gateway.interface.ts new file mode 100644 index 0000000..86bbef3 --- /dev/null +++ b/src/app/features/admin/transactions/services/admin-transactions-gateway.interface.ts @@ -0,0 +1,8 @@ +import { Observable } from 'rxjs'; +import { AdminTransaction, AdminTransactionListFilters, AdminTransactionsListResult } from '../models/admin-transaction.model'; + +export interface AdminTransactionsGateway { + loadTransactions(filters: AdminTransactionListFilters): Observable; + retryFailed(id: string): Observable; + setFraudFlag(id: string, flagged: boolean): Observable; +} diff --git a/src/app/features/admin/transactions/services/admin-transactions-local.gateway.ts b/src/app/features/admin/transactions/services/admin-transactions-local.gateway.ts new file mode 100644 index 0000000..ba00a3f --- /dev/null +++ b/src/app/features/admin/transactions/services/admin-transactions-local.gateway.ts @@ -0,0 +1,91 @@ +import { Injectable } from '@angular/core'; +import { Observable, of } from 'rxjs'; +import { delay } from 'rxjs/operators'; +import { AdminTransaction, AdminTransactionListFilters, AdminTransactionsListResult } from '../models/admin-transaction.model'; +import { AdminTransactionsGateway } from './admin-transactions-gateway.interface'; +import { AdminOrdersLocalGateway } from '../../orders/services/admin-orders-local.gateway'; + +const METHODS = ['card', 'qr', 'cash_on_delivery']; + +@Injectable({ providedIn: 'root' }) +export class AdminTransactionsLocalGateway implements AdminTransactionsGateway { + private cache: AdminTransaction[] | null = null; + + constructor(private readonly ordersGateway: AdminOrdersLocalGateway) {} + + loadTransactions(filters: AdminTransactionListFilters): Observable { + return new Observable(subscriber => { + this.ensureData().then(() => { + const filtered = (this.cache ?? []) + .filter(tx => !filters.search || `${tx.orderNumber} ${tx.method}`.toLowerCase().includes(filters.search.toLowerCase())) + .filter(tx => filters.status === 'all' || tx.status === filters.status) + .filter(tx => filters.type === 'all' || tx.type === filters.type) + .sort((left, right) => right.createdAt.localeCompare(left.createdAt)); + const start = (filters.page - 1) * filters.pageSize; + subscriber.next({ + items: filtered.slice(start, start + filters.pageSize), + total: filtered.length, + page: filters.page, + pageSize: filters.pageSize, + }); + subscriber.complete(); + }); + }).pipe(delay(50)); + } + + retryFailed(id: string): Observable { + return this.mutate(id, tx => ({ + ...tx, + status: 'retried', + updatedAt: new Date().toISOString(), + audit: [...tx.audit, { action: 'Retried failed transaction', actor: 'admin', timestamp: new Date().toISOString() }], + })); + } + + setFraudFlag(id: string, flagged: boolean): Observable { + return this.mutate(id, tx => ({ + ...tx, + fraudFlag: flagged, + updatedAt: new Date().toISOString(), + audit: [...tx.audit, { action: flagged ? 'Flagged as fraud' : 'Fraud flag cleared', actor: 'admin', timestamp: new Date().toISOString() }], + })); + } + + private mutate(id: string, update: (tx: AdminTransaction) => AdminTransaction): Observable { + const existing = (this.cache ?? []).find(tx => tx.id === id); + if (!existing) { + return of(null); + } + const updated = update(existing); + this.cache = (this.cache ?? []).map(tx => tx.id === id ? updated : tx); + return of(updated).pipe(delay(50)); + } + + private async ensureData(): Promise { + if (this.cache) { + return; + } + const orders = await new Promise<{ id: string; orderNumber: string; total: number; currency: string; status: string; createdAt: string }[]>(resolve => + this.ordersGateway.loadOrders({ search: '', status: 'all', page: 1, pageSize: 1000 }).subscribe(result => resolve(result.items)) + ); + this.cache = orders.map((order, index) => this.seedTransaction(order, index)); + } + + private seedTransaction(order: { id: string; orderNumber: string; total: number; currency: string; status: string; createdAt: string }, index: number): AdminTransaction { + const status = order.status === 'cancelled' ? 'failed' : order.status === 'refunded' ? 'success' : index % 7 === 0 ? 'pending' : 'success'; + return { + id: `tx-${index + 1}`, + orderId: order.id, + orderNumber: order.orderNumber, + type: order.status === 'refunded' ? 'refund' : index % 3 === 0 ? 'qr_payment' : 'payment', + method: METHODS[index % METHODS.length], + status, + amount: order.total, + currency: order.currency, + fraudFlag: index % 11 === 0, + audit: [{ action: 'Transaction created', actor: 'system', timestamp: order.createdAt }], + createdAt: order.createdAt, + updatedAt: order.createdAt, + }; + } +}