feat: Phase 7 frontend - Refund/Reconciliation core + Payments & Finance section
New core/finance (Refund/ReconciliationRecord/Settlement models + gateway/ token, mock backed, seeded empty) and features/admin/finance (facade + page: reconciliation queue with resolve action, settlements placeholder). New /backoffice/finance route + nav entry, nav i18n key in all 3 languages. Against docs/backend/PHASE-7-PAYMENTS-RECONCILIATION-CONTRACT.md. Note: does not wire AdminOrdersLocalGateway's existing mock requestRefund(id) method into this new Refund flow yet - that's a small follow-up once Phase 2's real Order backend exists to refund against. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -209,6 +209,15 @@ const coreRoutes: Routes = [
|
|||||||
breadcrumb: [{ labelKey: 'adminShell.nav.integrations' }]
|
breadcrumb: [{ labelKey: 'adminShell.nav.integrations' }]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'finance',
|
||||||
|
loadComponent: () => import('./features/admin/finance/pages/admin-finance-page.component').then(m => m.AdminFinancePageComponent),
|
||||||
|
data: {
|
||||||
|
titleKey: 'adminShell.nav.finance',
|
||||||
|
descriptionKey: 'adminShell.nav.finance',
|
||||||
|
breadcrumb: [{ labelKey: 'adminShell.nav.finance' }]
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'moderation',
|
path: 'moderation',
|
||||||
loadComponent: () => import('./features/admin/moderation/pages/admin-reviews-list-page.component').then(m => m.AdminReviewsListPageComponent),
|
loadComponent: () => import('./features/admin/moderation/pages/admin-reviews-list-page.component').then(m => m.AdminReviewsListPageComponent),
|
||||||
|
|||||||
38
src/app/core/finance/models/reconciliation.model.ts
Normal file
38
src/app/core/finance/models/reconciliation.model.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { Money } from '../../pricing/models/money.model';
|
||||||
|
|
||||||
|
/** Per docs/backend/PHASE-7-PAYMENTS-RECONCILIATION-CONTRACT.md §1-3. */
|
||||||
|
export interface Refund {
|
||||||
|
id: string;
|
||||||
|
orderId: string;
|
||||||
|
orderLineIds: string[];
|
||||||
|
amount: Money;
|
||||||
|
reason: string;
|
||||||
|
actor: string;
|
||||||
|
status: 'requested' | 'approved' | 'processing' | 'completed' | 'failed';
|
||||||
|
requestedAt: string;
|
||||||
|
completedAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReconciliationRecord {
|
||||||
|
id: string;
|
||||||
|
orderId: string;
|
||||||
|
providerPaymentId?: string;
|
||||||
|
internalAmount: Money;
|
||||||
|
providerAmount?: Money;
|
||||||
|
matchStrategy: 'provider_payment_id' | 'merchant_reference' | 'amount_currency_fallback';
|
||||||
|
result: 'matched' | 'unmatched' | 'duplicate' | 'amount_mismatch' | 'status_mismatch';
|
||||||
|
resolvedBy?: string;
|
||||||
|
resolvedAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Settlement {
|
||||||
|
id: string;
|
||||||
|
sellerId: string;
|
||||||
|
periodStart: string;
|
||||||
|
periodEnd: string;
|
||||||
|
grossAmount: Money;
|
||||||
|
commission: Money;
|
||||||
|
refunds: Money;
|
||||||
|
netPayout: Money;
|
||||||
|
status: 'pending' | 'paid';
|
||||||
|
}
|
||||||
10
src/app/core/finance/services/finance-gateway.interface.ts
Normal file
10
src/app/core/finance/services/finance-gateway.interface.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { Observable } from 'rxjs';
|
||||||
|
import { ReconciliationRecord, Refund, Settlement } from '../models/reconciliation.model';
|
||||||
|
|
||||||
|
/** Per docs/backend/PHASE-7-PAYMENTS-RECONCILIATION-CONTRACT.md §1-3. */
|
||||||
|
export interface FinanceGateway {
|
||||||
|
loadRefunds(orderId?: string): Observable<Refund[]>;
|
||||||
|
loadReconciliationQueue(): Observable<ReconciliationRecord[]>;
|
||||||
|
resolveReconciliation(id: string, note: string): Observable<void>;
|
||||||
|
loadSettlements(sellerId?: string): Observable<Settlement[]>;
|
||||||
|
}
|
||||||
9
src/app/core/finance/services/finance-gateway.token.ts
Normal file
9
src/app/core/finance/services/finance-gateway.token.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { InjectionToken, inject } from '@angular/core';
|
||||||
|
import { FinanceGateway } from './finance-gateway.interface';
|
||||||
|
import { FinanceLocalGateway } from './finance-local.gateway';
|
||||||
|
|
||||||
|
/** Swap point for docs/backend/PHASE-7-PAYMENTS-RECONCILIATION-CONTRACT.md. */
|
||||||
|
export const FINANCE_GATEWAY = new InjectionToken<FinanceGateway>('FINANCE_GATEWAY', {
|
||||||
|
providedIn: 'root',
|
||||||
|
factory: () => inject(FinanceLocalGateway),
|
||||||
|
});
|
||||||
32
src/app/core/finance/services/finance-local.gateway.ts
Normal file
32
src/app/core/finance/services/finance-local.gateway.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import { Observable, of } from 'rxjs';
|
||||||
|
import { ReconciliationRecord, Refund, Settlement } from '../models/reconciliation.model';
|
||||||
|
import { FinanceGateway } from './finance-gateway.interface';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seeded empty. AdminOrdersLocalGateway's requestRefund(id) exists as a mock
|
||||||
|
* method but nothing reads it into a real Refund/reconciliation flow yet -
|
||||||
|
* this gateway is the real target once docs/backend/
|
||||||
|
* PHASE-7-PAYMENTS-RECONCILIATION-CONTRACT.md ships.
|
||||||
|
*/
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class FinanceLocalGateway implements FinanceGateway {
|
||||||
|
private reconciliation: ReconciliationRecord[] = [];
|
||||||
|
|
||||||
|
loadRefunds(_orderId?: string): Observable<Refund[]> {
|
||||||
|
return of([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
loadReconciliationQueue(): Observable<ReconciliationRecord[]> {
|
||||||
|
return of(this.reconciliation);
|
||||||
|
}
|
||||||
|
|
||||||
|
resolveReconciliation(id: string, _note: string): Observable<void> {
|
||||||
|
this.reconciliation = this.reconciliation.filter(r => r.id !== id);
|
||||||
|
return of(void 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
loadSettlements(_sellerId?: string): Observable<Settlement[]> {
|
||||||
|
return of([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { Injectable, inject, signal } from '@angular/core';
|
||||||
|
import { take } from 'rxjs/operators';
|
||||||
|
import { ReconciliationRecord, Settlement } from '../../../../core/finance/models/reconciliation.model';
|
||||||
|
import { FINANCE_GATEWAY } from '../../../../core/finance/services/finance-gateway.token';
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class AdminFinanceFacade {
|
||||||
|
private readonly gateway = inject(FINANCE_GATEWAY);
|
||||||
|
|
||||||
|
readonly reconciliation = signal<ReconciliationRecord[]>([]);
|
||||||
|
readonly settlements = signal<Settlement[]>([]);
|
||||||
|
readonly loading = signal(false);
|
||||||
|
|
||||||
|
load(): void {
|
||||||
|
this.loading.set(true);
|
||||||
|
this.gateway.loadReconciliationQueue().pipe(take(1)).subscribe(items => {
|
||||||
|
this.reconciliation.set(items);
|
||||||
|
this.loading.set(false);
|
||||||
|
});
|
||||||
|
this.gateway.loadSettlements().pipe(take(1)).subscribe(items => this.settlements.set(items));
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve(id: string, note: string): void {
|
||||||
|
this.gateway.resolveReconciliation(id, note).pipe(take(1)).subscribe(() => this.load());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<div class="finance-page">
|
||||||
|
<header>
|
||||||
|
<h1>Payments & Finance</h1>
|
||||||
|
<p>Reconciliation queue and settlements. Mock-gateway backed until docs/backend/PHASE-7-PAYMENTS-RECONCILIATION-CONTRACT.md ships.</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Reconciliation queue</h2>
|
||||||
|
@if (facade.reconciliation().length === 0 && !facade.loading()) {
|
||||||
|
<app-empty-state title="Nothing to reconcile" description="Unmatched payments will appear here." />
|
||||||
|
} @else {
|
||||||
|
<ul>
|
||||||
|
@for (r of facade.reconciliation(); track r.id) {
|
||||||
|
<li>
|
||||||
|
{{ r.orderId }} - {{ r.result }}
|
||||||
|
<app-button variant="secondary" (click)="facade.resolve(r.id, 'resolved from backoffice')">Resolve</app-button>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Settlements</h2>
|
||||||
|
@if (facade.settlements().length === 0) {
|
||||||
|
<app-empty-state title="No settlements yet" description="Seller payout periods will appear here." />
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
.finance-page {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 24px;
|
||||||
|
|
||||||
|
section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
li {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { AdminFinanceFacade } from '../facade/admin-finance.facade';
|
||||||
|
import { EmptyStateComponent } from '../../../../shared/ui/empty-state/empty-state.component';
|
||||||
|
import { ButtonComponent } from '../../../../shared/ui/button/button.component';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-admin-finance-page',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, EmptyStateComponent, ButtonComponent],
|
||||||
|
templateUrl: './admin-finance-page.component.html',
|
||||||
|
styleUrls: ['./admin-finance-page.component.scss'],
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush
|
||||||
|
})
|
||||||
|
export class AdminFinancePageComponent {
|
||||||
|
readonly facade = inject(AdminFinanceFacade);
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.facade.load();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -40,6 +40,7 @@ export const ADMIN_NAV_PRIMARY: AdminNavEntry[] = [
|
|||||||
{ type: 'link', id: 'moderation', icon: 'star', labelKey: 'adminShell.nav.moderation', path: ['moderation'] },
|
{ type: 'link', id: 'moderation', icon: 'star', labelKey: 'adminShell.nav.moderation', path: ['moderation'] },
|
||||||
{ type: 'link', id: 'notifications', icon: 'bell', labelKey: 'adminShell.nav.notifications', path: ['notifications'] },
|
{ type: 'link', id: 'notifications', icon: 'bell', labelKey: 'adminShell.nav.notifications', path: ['notifications'] },
|
||||||
{ type: 'link', id: 'integrations', icon: 'network', labelKey: 'adminShell.nav.integrations', path: ['integrations'] },
|
{ type: 'link', id: 'integrations', icon: 'network', labelKey: 'adminShell.nav.integrations', path: ['integrations'] },
|
||||||
|
{ type: 'link', id: 'finance', icon: 'creditCard', labelKey: 'adminShell.nav.finance', path: ['finance'] },
|
||||||
{ type: 'link', id: 'reports', icon: 'chartBar', labelKey: 'adminShell.nav.reports', path: ['reports'] },
|
{ type: 'link', id: 'reports', icon: 'chartBar', labelKey: 'adminShell.nav.reports', path: ['reports'] },
|
||||||
{ type: 'group', labelKey: 'adminShell.nav.partnersGroup' },
|
{ type: 'group', labelKey: 'adminShell.nav.partnersGroup' },
|
||||||
{ type: 'link', id: 'seller-management', icon: 'store', labelKey: 'adminShell.nav.sellerManagement', path: ['partners', 'seller-management'] },
|
{ type: 'link', id: 'seller-management', icon: 'store', labelKey: 'adminShell.nav.sellerManagement', path: ['partners', 'seller-management'] },
|
||||||
|
|||||||
@@ -2061,6 +2061,7 @@ export const en: Translations = {
|
|||||||
moderation: 'Reviews & Moderation',
|
moderation: 'Reviews & Moderation',
|
||||||
notifications: 'Notifications',
|
notifications: 'Notifications',
|
||||||
integrations: 'Integrations',
|
integrations: 'Integrations',
|
||||||
|
finance: 'Payments & Finance',
|
||||||
reviews: 'Reviews',
|
reviews: 'Reviews',
|
||||||
reports: 'Reports',
|
reports: 'Reports',
|
||||||
partnersGroup: 'Partners',
|
partnersGroup: 'Partners',
|
||||||
|
|||||||
@@ -2055,6 +2055,7 @@ export const hy: Translations = {
|
|||||||
moderation: 'Կարծիքներ և մոդերացիա',
|
moderation: 'Կարծիքներ և մոդերացիա',
|
||||||
notifications: 'Ծանուցումներ',
|
notifications: 'Ծանուցումներ',
|
||||||
integrations: 'Ինտեգրումներ',
|
integrations: 'Ինտեգրումներ',
|
||||||
|
finance: 'Վճարումներ և ֆինանսներ',
|
||||||
transactions: 'Գործարքներ',
|
transactions: 'Գործարքներ',
|
||||||
reviews: 'Կարծիքներ',
|
reviews: 'Կարծիքներ',
|
||||||
reports: 'Հաշվետվություններ',
|
reports: 'Հաշվետվություններ',
|
||||||
|
|||||||
@@ -2055,6 +2055,7 @@ export const ru: Translations = {
|
|||||||
moderation: 'Отзывы и модерация',
|
moderation: 'Отзывы и модерация',
|
||||||
notifications: 'Уведомления',
|
notifications: 'Уведомления',
|
||||||
integrations: 'Интеграции',
|
integrations: 'Интеграции',
|
||||||
|
finance: 'Платежи и финансы',
|
||||||
transactions: 'Транзакции',
|
transactions: 'Транзакции',
|
||||||
reviews: 'Отзывы',
|
reviews: 'Отзывы',
|
||||||
reports: 'Отчёты',
|
reports: 'Отчёты',
|
||||||
|
|||||||
@@ -2070,6 +2070,7 @@ export interface Translations {
|
|||||||
moderation: string;
|
moderation: string;
|
||||||
notifications: string;
|
notifications: string;
|
||||||
integrations: string;
|
integrations: string;
|
||||||
|
finance: string;
|
||||||
reports: string;
|
reports: string;
|
||||||
partnersGroup: string;
|
partnersGroup: string;
|
||||||
sellerManagement: string;
|
sellerManagement: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user