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:
sdarbinyan
2026-07-15 10:57:29 +04:00
parent 2d8d6b6dc4
commit 7d65913245
11 changed files with 356 additions and 4 deletions

View File

@@ -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');
}
}

View File

@@ -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;
}

View File

@@ -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>

View File

@@ -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; } }

View File

@@ -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);
}
}

View File

@@ -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>;
}

View File

@@ -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,
};
}
}