feat(admin): transaction management
Sprint 24. New features/admin/transactions/ module. AdminTransactionsLocalGateway derives one synthetic transaction per Sprint 23's seeded mock order rather than a separate dataset, keeping order numbers/totals consistent across the two mock feature areas. - list: search, status filter, type filter (payment/refund/qr_payment), pagination, CSV export - retry failed transactions (appends an audit entry) - fraud flag toggle - per-transaction audit log (creation/retry/fraud-flag-change), viewed via dialog - intentionally separate from the system-wide audit/security log planned for Sprint 26 (Monitoring) - wired into /:lang/backoffice/transactions, replacing the coming-soon placeholder docs/ADMIN.md + docs/BACKEND.md (new item 13) updated. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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<AdminTransactionListFilters>({ search: '', status: 'all', type: 'all', page: 1, pageSize: 10 });
|
||||
readonly transactions = signal<AdminTransaction[]>([]);
|
||||
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<AdminTransactionListFilters>): 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');
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
<section class="admin-transactions-card">
|
||||
<div class="toolbar">
|
||||
<div class="filters">
|
||||
<app-input type="search" [ngModel]="facade.filters().search" (ngModelChange)="facade.updateFilters({ search: $event })" [placeholder]="'adminTransactions.search' | translate" />
|
||||
<select [ngModel]="facade.filters().status" (ngModelChange)="facade.updateFilters({ status: $event })">
|
||||
@for (status of statuses; track status) {
|
||||
<option [value]="status">{{ ('adminTransactions.status.' + status) | translate }}</option>
|
||||
}
|
||||
</select>
|
||||
<select [ngModel]="facade.filters().type" (ngModelChange)="facade.updateFilters({ type: $event })">
|
||||
@for (type of types; track type) {
|
||||
<option [value]="type">{{ ('adminTransactions.type.' + type) | translate }}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<app-button variant="secondary" (click)="exportCsv()">{{ 'adminOrders.export' | translate }}</app-button>
|
||||
</div>
|
||||
|
||||
@if (!facade.loading() && facade.transactions().length === 0) {
|
||||
<app-empty-state [title]="'adminTransactions.emptyTitle' | translate" [description]="'adminTransactions.emptyDescription' | translate" />
|
||||
} @else {
|
||||
<app-table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ 'adminOrders.orderNumber' | translate }}</th>
|
||||
<th>{{ 'adminTransactions.type' | translate }}</th>
|
||||
<th>{{ 'adminTransactions.method' | translate }}</th>
|
||||
<th>{{ 'backoffice.status' | translate }}</th>
|
||||
<th>{{ 'backoffice.price' | translate }}</th>
|
||||
<th>{{ 'adminTransactions.fraud' | translate }}</th>
|
||||
<th>{{ 'adminProducts.actions' | translate }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (tx of facade.transactions(); track tx.id) {
|
||||
<tr>
|
||||
<td>{{ tx.orderNumber }}</td>
|
||||
<td>{{ ('adminTransactions.type.' + tx.type) | translate }}</td>
|
||||
<td>{{ tx.method }}</td>
|
||||
<td><app-badge [variant]="tx.status === 'success' ? 'success' : tx.status === 'failed' ? 'danger' : 'neutral'">{{ ('adminTransactions.status.' + tx.status) | translate }}</app-badge></td>
|
||||
<td>{{ tx.amount }} {{ tx.currency }}</td>
|
||||
<td>
|
||||
@if (tx.fraudFlag) {
|
||||
<app-badge variant="danger">{{ 'adminTransactions.flagged' | translate }}</app-badge>
|
||||
}
|
||||
</td>
|
||||
<td class="actions">
|
||||
@if (tx.status === 'failed') {
|
||||
<app-button variant="secondary" size="sm" (click)="facade.retryFailed(tx.id)">{{ 'adminTransactions.retry' | translate }}</app-button>
|
||||
}
|
||||
<app-button variant="secondary" size="sm" (click)="facade.toggleFraudFlag(tx.id, !tx.fraudFlag)">{{ (tx.fraudFlag ? 'adminTransactions.clearFlag' : 'adminTransactions.flag') | translate }}</app-button>
|
||||
<app-button variant="secondary" size="sm" (click)="viewAudit(tx)">{{ 'adminTransactions.audit' | translate }}</app-button>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</app-table>
|
||||
|
||||
<div class="pager">
|
||||
<span>{{ facade.total() }} {{ 'adminProducts.items' | translate }}</span>
|
||||
<app-pagination [currentPage]="facade.filters().page" [totalPages]="totalPages()" (pageChange)="facade.updateFilters({ page: $event })" />
|
||||
</div>
|
||||
}
|
||||
|
||||
<app-dialog [open]="!!auditTarget()" [titleText]="'adminTransactions.audit' | translate" size="sm" (closed)="closeAudit()">
|
||||
@if (auditTarget(); as tx) {
|
||||
@for (entry of tx.audit; track $index) {
|
||||
<p>{{ entry.timestamp | date:'short' }} — {{ entry.actor }} — {{ entry.action }}</p>
|
||||
}
|
||||
}
|
||||
</app-dialog>
|
||||
</section>
|
||||
@@ -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; } }
|
||||
@@ -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<AdminTransaction | null>(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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { Observable } from 'rxjs';
|
||||
import { AdminTransaction, AdminTransactionListFilters, AdminTransactionsListResult } from '../models/admin-transaction.model';
|
||||
|
||||
export interface AdminTransactionsGateway {
|
||||
loadTransactions(filters: AdminTransactionListFilters): Observable<AdminTransactionsListResult>;
|
||||
retryFailed(id: string): Observable<AdminTransaction | null>;
|
||||
setFraudFlag(id: string, flagged: boolean): Observable<AdminTransaction | null>;
|
||||
}
|
||||
@@ -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<AdminTransactionsListResult> {
|
||||
return new Observable<AdminTransactionsListResult>(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<AdminTransaction | null> {
|
||||
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<AdminTransaction | null> {
|
||||
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<AdminTransaction | null> {
|
||||
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<void> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user