diff --git a/src/app/core/marketplace-registry/models/marketplace-revision.model.ts b/src/app/core/marketplace-registry/models/marketplace-revision.model.ts new file mode 100644 index 0000000..491111f --- /dev/null +++ b/src/app/core/marketplace-registry/models/marketplace-revision.model.ts @@ -0,0 +1,49 @@ +/** + * Publish model for marketplace design/content: draft -> validate -> preview -> publish. + * Contract: docs/backend/PHASE-9-TENANT-REGISTRY-DOMAINS-CONTRACT.md §5. + * + * Hard invariant, stated in the contract and repeated here because it is + * the whole point of this model existing separately from commerce data: + * Order, Payment, InventoryRecord, and every financial ledger row are NOT + * part of a MarketplaceRevision. Rolling back a design revision must never + * touch commerce data. Nothing in this model, and nothing that consumes it, + * should ever reference an order, payment, or inventory record. + */ +/** + * §5 describes the pipeline as draft -> validation -> preview -> publish, + * but only lists 3 write endpoints (validate, publish, rollback) for these + * 4 stages - there is no dedicated "move to preview" call. This model + * assumes POST .../validate moves a revision straight to 'preview' (the + * state publish() actually requires), treating 'validated' as a transient + * value the caller may never observe rather than a distinct stored status. + * Confirm against the real backend response before relying on 'validated' + * ever being read back. + */ +export type RevisionStatus = 'draft' | 'validated' | 'preview' | 'published'; + +export interface MarketplaceRevision { + id: string; + marketplaceId: string; + status: RevisionStatus; + publishedAt?: string; + /** Rollback creates a NEW revision pointing here - the old one is never mutated. */ + supersedesRevisionId?: string; +} + +/** Only forward transitions in the pipeline, plus rollback (which is a new revision, not a transition). */ +export function canValidate(revision: MarketplaceRevision): boolean { + return revision.status === 'draft'; +} + +export function canPreview(revision: MarketplaceRevision): boolean { + return revision.status === 'validated'; +} + +export function canPublish(revision: MarketplaceRevision): boolean { + return revision.status === 'preview'; +} + +/** A published revision is immutable (§5) - rollback is the only way to move past it, and that creates a new revision. */ +export function isImmutable(revision: MarketplaceRevision): boolean { + return revision.status === 'published'; +} diff --git a/src/app/core/marketplace-registry/services/marketplace-revision-api.gateway.ts b/src/app/core/marketplace-registry/services/marketplace-revision-api.gateway.ts new file mode 100644 index 0000000..2d701f6 --- /dev/null +++ b/src/app/core/marketplace-registry/services/marketplace-revision-api.gateway.ts @@ -0,0 +1,39 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable, inject } from '@angular/core'; +import { Observable } from 'rxjs'; +import { MarketplaceRevision } from '../models/marketplace-revision.model'; +import { MarketplaceRevisionGateway } from './marketplace-revision-gateway.interface'; + +/** Contract: docs/backend/PHASE-9-TENANT-REGISTRY-DOMAINS-CONTRACT.md §5. */ +@Injectable({ providedIn: 'root' }) +export class MarketplaceRevisionApiGateway implements MarketplaceRevisionGateway { + private readonly http = inject(HttpClient); + + createDraft(marketplaceId: string): Observable { + return this.http.post( + `/api/admin/v2/marketplaces/${encodeURIComponent(marketplaceId)}/revisions`, + {}, + ); + } + + validate(marketplaceId: string, revisionId: string): Observable { + return this.http.post( + `/api/admin/v2/marketplaces/${encodeURIComponent(marketplaceId)}/revisions/${encodeURIComponent(revisionId)}/validate`, + {}, + ); + } + + publish(marketplaceId: string, revisionId: string): Observable { + return this.http.post( + `/api/admin/v2/marketplaces/${encodeURIComponent(marketplaceId)}/revisions/${encodeURIComponent(revisionId)}/publish`, + {}, + ); + } + + rollback(marketplaceId: string, revisionId: string): Observable { + return this.http.post( + `/api/admin/v2/marketplaces/${encodeURIComponent(marketplaceId)}/revisions/${encodeURIComponent(revisionId)}/rollback`, + {}, + ); + } +} diff --git a/src/app/core/marketplace-registry/services/marketplace-revision-gateway.interface.ts b/src/app/core/marketplace-registry/services/marketplace-revision-gateway.interface.ts new file mode 100644 index 0000000..5c14688 --- /dev/null +++ b/src/app/core/marketplace-registry/services/marketplace-revision-gateway.interface.ts @@ -0,0 +1,11 @@ +import { Observable } from 'rxjs'; +import { MarketplaceRevision } from '../models/marketplace-revision.model'; + +/** Per docs/backend/PHASE-9-TENANT-REGISTRY-DOMAINS-CONTRACT.md §5. */ +export interface MarketplaceRevisionGateway { + createDraft(marketplaceId: string): Observable; + validate(marketplaceId: string, revisionId: string): Observable; + publish(marketplaceId: string, revisionId: string): Observable; + /** Creates a NEW revision pointing at the prior published content - never mutates the old one. */ + rollback(marketplaceId: string, revisionId: string): Observable; +} diff --git a/src/app/core/marketplace-registry/services/marketplace-revision-gateway.token.ts b/src/app/core/marketplace-registry/services/marketplace-revision-gateway.token.ts new file mode 100644 index 0000000..e18cdf9 --- /dev/null +++ b/src/app/core/marketplace-registry/services/marketplace-revision-gateway.token.ts @@ -0,0 +1,11 @@ +import { InjectionToken, inject } from '@angular/core'; +import { environment } from '../../../../environments/environment'; +import { MarketplaceRevisionGateway } from './marketplace-revision-gateway.interface'; +import { MarketplaceRevisionLocalGateway } from './marketplace-revision-local.gateway'; +import { MarketplaceRevisionApiGateway } from './marketplace-revision-api.gateway'; + +/** Swap point for docs/backend/PHASE-9-TENANT-REGISTRY-DOMAINS-CONTRACT.md §5. */ +export const MARKETPLACE_REVISION_GATEWAY = new InjectionToken('MARKETPLACE_REVISION_GATEWAY', { + providedIn: 'root', + factory: () => (environment.useMockData ? inject(MarketplaceRevisionLocalGateway) : inject(MarketplaceRevisionApiGateway)), +}); diff --git a/src/app/core/marketplace-registry/services/marketplace-revision-local.gateway.spec.ts b/src/app/core/marketplace-registry/services/marketplace-revision-local.gateway.spec.ts new file mode 100644 index 0000000..a7154b4 --- /dev/null +++ b/src/app/core/marketplace-registry/services/marketplace-revision-local.gateway.spec.ts @@ -0,0 +1,71 @@ +import { TestBed } from '@angular/core/testing'; +import { firstValueFrom } from 'rxjs'; +import { MarketplaceRevisionLocalGateway } from './marketplace-revision-local.gateway'; + +describe('MarketplaceRevisionLocalGateway (§5 pipeline invariants)', () => { + let gateway: MarketplaceRevisionLocalGateway; + + beforeEach(() => { + TestBed.configureTestingModule({}); + gateway = TestBed.inject(MarketplaceRevisionLocalGateway); + }); + + it('creates a draft', async () => { + const revision = await firstValueFrom(gateway.createDraft('mkt_1')); + + expect(revision.status).toBe('draft'); + expect(revision.marketplaceId).toBe('mkt_1'); + }); + + it('walks the full pipeline in order: draft -> preview -> published', async () => { + const draft = await firstValueFrom(gateway.createDraft('mkt_1')); + const previewed = await firstValueFrom(gateway.validate('mkt_1', draft.id)); + expect(previewed.status).toBe('preview'); + + const published = await firstValueFrom(gateway.publish('mkt_1', previewed.id)); + expect(published.status).toBe('published'); + }); + + it('rejects publishing a draft that skipped validation', async () => { + const draft = await firstValueFrom(gateway.createDraft('mkt_1')); + + await expectAsync( + firstValueFrom(gateway.publish('mkt_1', draft.id)), + ).toBeRejectedWithError(/must be in preview to publish/); + }); + + it('rejects validating a revision that is not a draft', async () => { + const draft = await firstValueFrom(gateway.createDraft('mkt_1')); + await firstValueFrom(gateway.validate('mkt_1', draft.id)); + + await expectAsync( + firstValueFrom(gateway.validate('mkt_1', draft.id)), + ).toBeRejectedWithError(/must be a draft to validate/); + }); + + it('rejects rolling back a revision that was never published', async () => { + const draft = await firstValueFrom(gateway.createDraft('mkt_1')); + + await expectAsync( + firstValueFrom(gateway.rollback('mkt_1', draft.id)), + ).toBeRejectedWithError(/Only a published revision can be rolled back/); + }); + + it('rollback creates a new revision rather than mutating the published one', async () => { + const draft = await firstValueFrom(gateway.createDraft('mkt_1')); + const previewed = await firstValueFrom(gateway.validate('mkt_1', draft.id)); + const published = await firstValueFrom(gateway.publish('mkt_1', previewed.id)); + + const rolledBack = await firstValueFrom(gateway.rollback('mkt_1', published.id)); + + expect(rolledBack.id).not.toBe(published.id); + expect(rolledBack.supersedesRevisionId).toBe(published.id); + expect(rolledBack.status).toBe('published'); + }); + + it('rejects an unknown revision id', async () => { + await expectAsync( + firstValueFrom(gateway.validate('mkt_1', 'nope')), + ).toBeRejectedWithError(/Revision not found/); + }); +}); diff --git a/src/app/core/marketplace-registry/services/marketplace-revision-local.gateway.ts b/src/app/core/marketplace-registry/services/marketplace-revision-local.gateway.ts new file mode 100644 index 0000000..ed3e1b3 --- /dev/null +++ b/src/app/core/marketplace-registry/services/marketplace-revision-local.gateway.ts @@ -0,0 +1,85 @@ +import { Injectable, signal } from '@angular/core'; +import { Observable, of, throwError } from 'rxjs'; +import { + MarketplaceRevision, + canPreview, + canPublish, + canValidate, +} from '../models/marketplace-revision.model'; +import { MarketplaceRevisionGateway } from './marketplace-revision-gateway.interface'; + +/** + * Enforces the §5 pipeline order rather than being a permissive stub - a + * revision cannot skip validate/preview, and a published revision cannot be + * re-published. A mock that allows what the real backend rejects teaches the + * UI a shortcut that will fail against the real API. + */ +@Injectable({ providedIn: 'root' }) +export class MarketplaceRevisionLocalGateway implements MarketplaceRevisionGateway { + private readonly revisions = signal([]); + private sequence = 1; + + createDraft(marketplaceId: string): Observable { + const revision: MarketplaceRevision = { + id: `rev_${this.sequence++}`, + marketplaceId, + status: 'draft', + }; + this.revisions.update(all => [...all, revision]); + return of(revision); + } + + validate(marketplaceId: string, revisionId: string): Observable { + // Moves straight to 'preview', not 'validated' - see the model's doc + // comment: only 3 write endpoints exist for 4 pipeline stages. + return this.transition(revisionId, 'preview', canValidate, 'must be a draft to validate'); + } + + publish(marketplaceId: string, revisionId: string): Observable { + return this.transition(revisionId, 'published', canPublish, 'must be in preview to publish'); + } + + rollback(marketplaceId: string, revisionId: string): Observable { + const target = this.revisions().find(r => r.id === revisionId); + if (!target) { + return throwError(() => new Error(`Revision not found: ${revisionId}`)); + } + if (target.status !== 'published') { + return throwError(() => new Error('Only a published revision can be rolled back from.')); + } + + // §5: rollback creates a NEW revision, never mutates the old one. + const rolledBack: MarketplaceRevision = { + id: `rev_${this.sequence++}`, + marketplaceId, + status: 'published', + publishedAt: new Date().toISOString(), + supersedesRevisionId: target.id, + }; + this.revisions.update(all => [...all, rolledBack]); + return of(rolledBack); + } + + private transition( + revisionId: string, + to: MarketplaceRevision['status'], + guard: (revision: MarketplaceRevision) => boolean, + errorMessage: string, + ): Observable { + const existing = this.revisions().find(r => r.id === revisionId); + if (!existing) { + return throwError(() => new Error(`Revision not found: ${revisionId}`)); + } + if (!guard(existing)) { + return throwError(() => new Error(errorMessage)); + } + + const updated: MarketplaceRevision = { + ...existing, + status: to, + ...(to === 'published' ? { publishedAt: new Date().toISOString() } : {}), + }; + this.revisions.update(all => all.map(r => (r.id === revisionId ? updated : r))); + return of(updated); + } +} diff --git a/src/app/features/admin/dashboard/facade/admin-dashboard.facade.ts b/src/app/features/admin/dashboard/facade/admin-dashboard.facade.ts index a55ceb5..eca8931 100644 --- a/src/app/features/admin/dashboard/facade/admin-dashboard.facade.ts +++ b/src/app/features/admin/dashboard/facade/admin-dashboard.facade.ts @@ -127,6 +127,21 @@ export class AdminDashboardFacade { { code: 'configuration-valid', labelKey: 'dashboard.healthConfigurationValid', status: bootstrapLoading ? 'loading' : issues.size === 0 ? 'healthy' : 'unhealthy' }, { code: 'products-count', labelKey: 'dashboard.healthProductsCount', status: metrics.status === 'loading' ? 'loading' : metrics.status === 'error' ? 'unhealthy' : 'healthy', displayValue: metrics.value ? String(metrics.value.productsCount) : null }, { code: 'categories-count', labelKey: 'dashboard.healthCategoriesCount', status: metrics.status === 'loading' ? 'loading' : metrics.status === 'error' ? 'unhealthy' : 'healthy', displayValue: metrics.value ? String(metrics.value.categoriesCount) : null }, + // Per PHASE-9-TENANT-REGISTRY-DOMAINS-CONTRACT.md §7's target metric list. + // No contract yet defines /api/admin/v2/dashboard/metrics's exact + // response (FRONTEND-API-SURFACE-COMPLETE.md §15), so these fields are + // optional on AdminDashboardMetrics - 'unknown' (not 'healthy') is the + // correct status while the field is absent, same pattern as + // 'images-without-alt' below: a successful fetch with no field present + // is not the same as a metric that is confirmed healthy. + { code: 'gmv', labelKey: 'dashboard.healthGmv', status: metrics.status === 'loading' ? 'loading' : metrics.value?.gmvMinor === undefined ? 'unknown' : 'healthy', displayValue: metrics.value?.gmvMinor !== undefined ? `${(metrics.value.gmvMinor / 100).toFixed(2)} ${metrics.value.currency ?? ''}`.trim() : null }, + { code: 'paid-orders', labelKey: 'dashboard.healthPaidOrders', status: metrics.status === 'loading' ? 'loading' : metrics.value?.paidOrdersCount === undefined ? 'unknown' : 'healthy', displayValue: metrics.value?.paidOrdersCount !== undefined ? String(metrics.value.paidOrdersCount) : null }, + { code: 'conversion-rate', labelKey: 'dashboard.healthConversionRate', status: metrics.status === 'loading' ? 'loading' : metrics.value?.conversionRate === undefined ? 'unknown' : 'healthy', displayValue: metrics.value?.conversionRate !== undefined ? `${(metrics.value.conversionRate * 100).toFixed(1)}%` : null }, + { code: 'payment-failure-rate', labelKey: 'dashboard.healthPaymentFailureRate', status: metrics.status === 'loading' ? 'loading' : metrics.value?.paymentFailureRate === undefined ? 'unknown' : metrics.value.paymentFailureRate > 0.05 ? 'attention' : 'healthy', displayValue: metrics.value?.paymentFailureRate !== undefined ? `${(metrics.value.paymentFailureRate * 100).toFixed(1)}%` : null }, + { code: 'moderation-queue', labelKey: 'dashboard.healthModerationQueue', status: metrics.status === 'loading' ? 'loading' : metrics.value?.moderationQueueCount === undefined ? 'unknown' : metrics.value.moderationQueueCount > 0 ? 'attention' : 'healthy', displayValue: metrics.value?.moderationQueueCount !== undefined ? String(metrics.value.moderationQueueCount) : null }, + { code: 'low-stock', labelKey: 'dashboard.healthLowStock', status: metrics.status === 'loading' ? 'loading' : metrics.value?.lowStockCount === undefined ? 'unknown' : metrics.value.lowStockCount > 0 ? 'attention' : 'healthy', displayValue: metrics.value?.lowStockCount !== undefined ? String(metrics.value.lowStockCount) : null }, + { code: 'unmatched-events', labelKey: 'dashboard.healthUnmatchedEvents', status: metrics.status === 'loading' ? 'loading' : metrics.value?.unmatchedEventsCount === undefined ? 'unknown' : metrics.value.unmatchedEventsCount > 0 ? 'attention' : 'healthy', displayValue: metrics.value?.unmatchedEventsCount !== undefined ? String(metrics.value.unmatchedEventsCount) : null }, + { code: 'integration-health', labelKey: 'dashboard.healthIntegrationHealth', status: metrics.status === 'loading' ? 'loading' : metrics.value?.integrationTotalCount === undefined ? 'unknown' : metrics.value.integrationHealthyCount === metrics.value.integrationTotalCount ? 'healthy' : 'attention', displayValue: metrics.value?.integrationTotalCount !== undefined ? `${metrics.value.integrationHealthyCount ?? 0}/${metrics.value.integrationTotalCount}` : null }, { code: 'missing-translations', labelKey: 'dashboard.healthMissingTranslations', status: bootstrapLoading ? 'loading' : issues.has('missing-translations') ? 'unhealthy' : 'healthy' }, { code: 'draft-exists', labelKey: 'dashboard.healthDraftExists', status: bootstrapLoading ? 'loading' : this.dirty() ? 'attention' : 'healthy' }, { code: 'images-without-alt', labelKey: 'dashboard.healthImagesWithoutAlt', status: 'unknown' }, diff --git a/src/app/features/admin/dashboard/models/admin-dashboard.model.ts b/src/app/features/admin/dashboard/models/admin-dashboard.model.ts index 0de11a5..f50ffd3 100644 --- a/src/app/features/admin/dashboard/models/admin-dashboard.model.ts +++ b/src/app/features/admin/dashboard/models/admin-dashboard.model.ts @@ -7,9 +7,26 @@ export interface AdminDashboardCardState { value: T | null; } +/** + * Target metrics per PHASE-9-TENANT-REGISTRY-DOMAINS-CONTRACT.md §7's + * marketplace dashboard list. All new fields optional - no contract doc + * defines /api/admin/v2/dashboard/metrics's exact response shape yet + * (FRONTEND-API-SURFACE-COMPLETE.md §15), so a field's absence must render + * as "not available", never a fabricated 0. + */ export interface AdminDashboardMetrics { categoriesCount: number; productsCount: number; + gmvMinor?: number; + currency?: string; + paidOrdersCount?: number; + conversionRate?: number; + paymentFailureRate?: number; + moderationQueueCount?: number; + lowStockCount?: number; + unmatchedEventsCount?: number; + integrationHealthyCount?: number; + integrationTotalCount?: number; } export type AdminDashboardQuickActionId = diff --git a/src/app/features/admin/orders/components/order-total-formula/order-total-formula.component.html b/src/app/features/admin/orders/components/order-total-formula/order-total-formula.component.html new file mode 100644 index 0000000..4ba213c --- /dev/null +++ b/src/app/features/admin/orders/components/order-total-formula/order-total-formula.component.html @@ -0,0 +1,69 @@ +
+

Total formula

+ + @if (!hasFullBreakdown()) { +

+ Price breakdown not available for this order. Backend has not populated + per-line unitPriceMinor / lineTotalMinor yet. +

+ } @else { + + + + + + + + + + + + @for (row of lineRows(); track row.name) { + + + + + + + + } + +
LineQtyUnit priceDiscountLine total
{{ row.name }}{{ row.quantity }}{{ toDisplay(row.unitPriceMinor) }} {{ order().currency }}{{ row.discountMinor ? toDisplay(row.discountMinor) + ' ' + order().currency : '—' }}{{ toDisplay(row.lineTotalMinor) }} {{ order().currency }}
+ +
+
+
Subtotal
+
{{ toDisplay(subtotalMinor()) }} {{ order().currency }}
+
+ @if (totalDiscountMinor() > 0) { +
+
Discounts
+
−{{ toDisplay(totalDiscountMinor()) }} {{ order().currency }}
+
+ } + @if (order().deliveryMinor !== undefined) { +
+
Delivery
+
{{ toDisplay(order().deliveryMinor) }} {{ order().currency }}
+
+ } +
+
Charged total
+
{{ order().total }} {{ order().currency }}
+
+ @if (order().fxQuoteId) { +
+
FX quote used
+
{{ order().fxQuoteId }}
+
+ } +
+ } + + @if (order().routing; as routing) { +

+ Routed to payment point {{ routing.leafNodeId }} ({{ routing.environment }}) · + merchant reference {{ routing.merchantReference }} +

+ } +
diff --git a/src/app/features/admin/orders/components/order-total-formula/order-total-formula.component.scss b/src/app/features/admin/orders/components/order-total-formula/order-total-formula.component.scss new file mode 100644 index 0000000..1d63d3c --- /dev/null +++ b/src/app/features/admin/orders/components/order-total-formula/order-total-formula.component.scss @@ -0,0 +1,72 @@ +.order-total-formula { + display: flex; + flex-direction: column; + gap: 12px; + padding: 16px; + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + + h3 { + margin: 0; + } + + &__unavailable { + margin: 0; + color: var(--text-secondary); + } + + &__table { + width: 100%; + border-collapse: collapse; + font-size: 0.85rem; + + th, + td { + text-align: left; + padding: 6px 8px; + border-bottom: 1px solid var(--border-color); + } + } + + &__summary { + margin: 0; + display: flex; + flex-direction: column; + gap: 4px; + + div { + display: flex; + justify-content: space-between; + gap: 12px; + } + + dt { + color: var(--text-secondary); + } + + dd { + margin: 0; + font-weight: 500; + } + } + + &__grand { + padding-top: 6px; + border-top: 1px solid var(--border-color); + + dt, + dd { + font-weight: 700; + } + } + + &__fx dd code { + font-size: 0.8rem; + } + + &__routing { + margin: 0; + font-size: 0.8rem; + color: var(--text-secondary); + } +} diff --git a/src/app/features/admin/orders/components/order-total-formula/order-total-formula.component.spec.ts b/src/app/features/admin/orders/components/order-total-formula/order-total-formula.component.spec.ts new file mode 100644 index 0000000..9ff42d8 --- /dev/null +++ b/src/app/features/admin/orders/components/order-total-formula/order-total-formula.component.spec.ts @@ -0,0 +1,92 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { OrderTotalFormulaComponent } from './order-total-formula.component'; +import { AdminOrder } from '../../models/admin-order.model'; + +function order(overrides: Partial = {}): AdminOrder { + return { + id: 'o1', + orderNumber: '1001', + status: 'pending', + customer: { name: 'Buyer', email: 'b@example.com', phone: '' }, + payment: { method: 'card', status: 'paid', amount: 1000, currency: 'RUB' }, + shipping: { address: '', method: '', trackingNumber: '' }, + items: [], + total: 1000, + currency: 'RUB', + notes: '', + internalNotes: '', + timeline: [], + archived: false, + createdAt: '2026-08-18T00:00:00.000Z', + updatedAt: '2026-08-18T00:00:00.000Z', + ...overrides, + }; +} + +describe('OrderTotalFormulaComponent', () => { + let fixture: ComponentFixture; + let component: OrderTotalFormulaComponent; + + beforeEach(() => { + TestBed.configureTestingModule({ imports: [OrderTotalFormulaComponent] }); + fixture = TestBed.createComponent(OrderTotalFormulaComponent); + component = fixture.componentInstance; + }); + + it('reports no breakdown when the order carries no line pricing data', () => { + fixture.componentRef.setInput('order', order({ + items: [{ productId: 'p1', name: 'Widget', quantity: 1, price: 100 }], + })); + fixture.detectChanges(); + + expect(component.hasFullBreakdown()).toBeFalse(); + expect(component.subtotalMinor()).toBeNull(); + }); + + it('requires every line to carry unitPriceMinor before showing a breakdown', () => { + fixture.componentRef.setInput('order', order({ + items: [ + { productId: 'p1', name: 'A', quantity: 1, price: 100, unitPriceMinor: 10000, lineTotalMinor: 10000 }, + { productId: 'p2', name: 'B', quantity: 1, price: 50 }, + ], + })); + fixture.detectChanges(); + + // One line missing the field must not silently render a partial total. + expect(component.hasFullBreakdown()).toBeFalse(); + }); + + it('sums lineTotalMinor across lines for the subtotal', () => { + fixture.componentRef.setInput('order', order({ + items: [ + { productId: 'p1', name: 'A', quantity: 2, price: 50, unitPriceMinor: 5000, lineTotalMinor: 10000 }, + { productId: 'p2', name: 'B', quantity: 1, price: 30, unitPriceMinor: 3000, lineTotalMinor: 3000 }, + ], + })); + fixture.detectChanges(); + + expect(component.hasFullBreakdown()).toBeTrue(); + expect(component.subtotalMinor()).toBe(13000); + }); + + it('sums discounts across lines even when some lines have none', () => { + fixture.componentRef.setInput('order', order({ + items: [ + { productId: 'p1', name: 'A', quantity: 1, price: 100, unitPriceMinor: 10000, lineTotalMinor: 9000, discountMinor: 1000 }, + { productId: 'p2', name: 'B', quantity: 1, price: 50, unitPriceMinor: 5000, lineTotalMinor: 5000 }, + ], + })); + fixture.detectChanges(); + + expect(component.totalDiscountMinor()).toBe(1000); + }); + + it('formats minor units as a two-decimal major amount', () => { + expect(component.toDisplay(123456)).toBe('1234.56'); + }); + + it('returns null for missing amounts rather than "0.00"', () => { + expect(component.toDisplay(undefined)).toBeNull(); + expect(component.toDisplay(null)).toBeNull(); + }); +}); diff --git a/src/app/features/admin/orders/components/order-total-formula/order-total-formula.component.ts b/src/app/features/admin/orders/components/order-total-formula/order-total-formula.component.ts new file mode 100644 index 0000000..160b8fe --- /dev/null +++ b/src/app/features/admin/orders/components/order-total-formula/order-total-formula.component.ts @@ -0,0 +1,60 @@ +import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { AdminOrder } from '../../models/admin-order.model'; + +/** + * Reconstructs "why was this amount charged" per + * PHASE-1-MONEY-FX-PAYMENTS-CONTRACT.md §5.3: + * total = sum(line.unitPrice * qty) - discounts + delivery + fees + * with the FX quote used, if any. + * + * Pure presentational: renders exactly what AdminOrder carries and nothing + * it doesn't. Most orders today have none of the optional pricing fields - + * this must read as "not available for this order", never guess a number + * the backend never sent. + */ +@Component({ + selector: 'app-order-total-formula', + standalone: true, + imports: [CommonModule], + templateUrl: './order-total-formula.component.html', + styleUrl: './order-total-formula.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class OrderTotalFormulaComponent { + readonly order = input.required(); + + /** True only when every line carries a reconstructable unit price. */ + readonly hasFullBreakdown = computed(() => { + const items = this.order().items; + return items.length > 0 && items.every(item => item.unitPriceMinor !== undefined); + }); + + readonly lineRows = computed(() => + this.order().items.map(item => ({ + name: item.name, + quantity: item.quantity, + unitPriceMinor: item.unitPriceMinor, + lineTotalMinor: item.lineTotalMinor, + discountMinor: item.discountMinor, + })), + ); + + readonly subtotalMinor = computed(() => { + if (!this.hasFullBreakdown()) { + return null; + } + return this.lineRows().reduce((sum, row) => sum + (row.lineTotalMinor ?? 0), 0); + }); + + readonly totalDiscountMinor = computed(() => + this.lineRows().reduce((sum, row) => sum + (row.discountMinor ?? 0), 0), + ); + + toDisplay(minor: number | null | undefined): string | null { + if (minor === null || minor === undefined) { + return null; + } + return (minor / 100).toFixed(2); + } +} diff --git a/src/app/features/admin/orders/models/admin-order.model.ts b/src/app/features/admin/orders/models/admin-order.model.ts index 081c0f0..45c405b 100644 --- a/src/app/features/admin/orders/models/admin-order.model.ts +++ b/src/app/features/admin/orders/models/admin-order.model.ts @@ -35,6 +35,16 @@ export interface AdminOrderItem { name: string; quantity: number; price: number; + /** + * Reconstructs the charged total per PHASE-1-MONEY-FX-PAYMENTS-CONTRACT.md + * §5.3 / §4 - all optional, since orders placed before this field existed + * (or any order the backend hasn't populated it for yet) have none of it. + * Absence must render as "not available", never as a fabricated 0 or 1:1 rate. + */ + unitPriceMinor?: number; + lineTotalMinor?: number; + priceSnapshotId?: string; + discountMinor?: number; } export type AdminOrderTimelineEventKey = 'created' | 'statusChanged' | 'refundRequested'; @@ -68,6 +78,23 @@ export interface AdminOrder { * nothing reads this field yet, no behavior change. */ sellerId?: UUID; + /** FX quote used to compute the total, when the order was priced cross-currency. */ + fxQuoteId?: string; + /** Delivery cost, minor units - see AdminOrder.total = formula in the panel this feeds. */ + deliveryMinor?: number; + /** + * PARTNER-PROVISIONING-API-CONTRACT.md §7 - which payment point the money + * actually landed on. Optional: not every order flows through a partner- + * provisioned hierarchy. + */ + routing?: { + companyId: string; + routingPath: string[]; + leafNodeId: string; + environment: 'TEST' | 'LIVE'; + merchantReference: string; + providerPaymentId: string; + }; } export interface AdminOrderListFilters { diff --git a/src/app/features/admin/orders/pages/admin-order-detail-page.component.html b/src/app/features/admin/orders/pages/admin-order-detail-page.component.html index 31f8b00..342655c 100644 --- a/src/app/features/admin/orders/pages/admin-order-detail-page.component.html +++ b/src/app/features/admin/orders/pages/admin-order-detail-page.component.html @@ -44,6 +44,9 @@

{{ order.shipping.method }}

@if (order.shipping.trackingNumber) {

{{ 'adminOrders.trackingNumber' | translate }}: {{ order.shipping.trackingNumber }}

} +
+ +

{{ 'adminOrders.changeStatus' | translate }}