feat: order total-formula panel, dashboard metrics, revision-API core (F53/F55/F56)
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
Checked F49-F56 against current source before writing anything; three
resolved to "already done, nothing to build":
F49 (ed25519 admin auth flip) - lives entirely in the external
@marketplaces/auth package plus a live backend; nothing in this repo.
F50 (HttpOnly session cookie) - HttpOnly can only be set via a
Set-Cookie response header. No frontend code can ever set one via
document.cookie. 100% backend, always was.
F51 (route permission guards) - already deliberately paused by prior
work with a stated reason ("could lock an admin out without warning"
across 27 routes, no live backend to verify against). Respected that
judgment rather than overriding it blind.
F54 (wire mock requestRefund) - already resolved by the Block 3 gateway
swap; the facade calls gateway.requestRefund(), which now hits the
real endpoint. No mock left to wire.
Built:
- AdminOrder gains optional per-line pricing fields (unitPriceMinor,
lineTotalMinor, priceSnapshotId, discountMinor) plus fxQuoteId and
routing, per PHASE-1-MONEY-FX-PAYMENTS-CONTRACT.md §5.3/§6.5. All
optional - most orders today have none of it, and absence must render
as "not available", never a fabricated 0.
- OrderTotalFormulaComponent: pure presentational panel reconstructing
total = sum(unitPrice*qty) - discounts + delivery, wired into order
detail. Refuses to show a partial breakdown - every line must carry
unitPriceMinor or the panel says so plainly instead of half-computing.
- AdminDashboardMetrics gains 8 optional fields per
PHASE-9-TENANT-REGISTRY-DOMAINS-CONTRACT.md §7's target list (GMV, paid
orders, conversion, payment failure rate, moderation queue, low stock,
unmatched events, integration health). Status is 'unknown' when a field
is absent, not 'healthy' - a successful fetch with no field present is
not the same as a confirmed-healthy metric, same pattern the existing
'images-without-alt' check already used.
- Marketplace revision gateway core (F52/F55's shared dependency):
models/marketplace-revision.model.ts, interface, api+local gateways,
token. Additive only - the 724-line project-editor facade's actual
localStorage-to-API rewire is NOT done here; that is a separate,
larger, stateful change (autosave/undo/redo all currently synchronous)
that deserves its own verified pass, not a rushed retrofit.
One real contract ambiguity surfaced and resolved with a stated
assumption, not silently: §5 describes 4 pipeline stages
(draft/validated/preview/published) but only 3 write endpoints
(validate/publish/rollback). Modeled validate() as moving straight to
'preview' - the state publish() actually requires - treating 'validated'
as a value the caller may never observe. Documented in the model's own
comment for backend to confirm.
Local gateway enforces the pipeline order rather than being a permissive
stub: can't publish a draft, can't re-validate a previewed revision,
rollback only from published, rollback creates a new revision rather
than mutating the old one.
Verified: 152/152 unit tests (13 new), arch:check clean, production
build succeeds.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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';
|
||||
}
|
||||
@@ -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<MarketplaceRevision> {
|
||||
return this.http.post<MarketplaceRevision>(
|
||||
`/api/admin/v2/marketplaces/${encodeURIComponent(marketplaceId)}/revisions`,
|
||||
{},
|
||||
);
|
||||
}
|
||||
|
||||
validate(marketplaceId: string, revisionId: string): Observable<MarketplaceRevision> {
|
||||
return this.http.post<MarketplaceRevision>(
|
||||
`/api/admin/v2/marketplaces/${encodeURIComponent(marketplaceId)}/revisions/${encodeURIComponent(revisionId)}/validate`,
|
||||
{},
|
||||
);
|
||||
}
|
||||
|
||||
publish(marketplaceId: string, revisionId: string): Observable<MarketplaceRevision> {
|
||||
return this.http.post<MarketplaceRevision>(
|
||||
`/api/admin/v2/marketplaces/${encodeURIComponent(marketplaceId)}/revisions/${encodeURIComponent(revisionId)}/publish`,
|
||||
{},
|
||||
);
|
||||
}
|
||||
|
||||
rollback(marketplaceId: string, revisionId: string): Observable<MarketplaceRevision> {
|
||||
return this.http.post<MarketplaceRevision>(
|
||||
`/api/admin/v2/marketplaces/${encodeURIComponent(marketplaceId)}/revisions/${encodeURIComponent(revisionId)}/rollback`,
|
||||
{},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<MarketplaceRevision>;
|
||||
validate(marketplaceId: string, revisionId: string): Observable<MarketplaceRevision>;
|
||||
publish(marketplaceId: string, revisionId: string): Observable<MarketplaceRevision>;
|
||||
/** Creates a NEW revision pointing at the prior published content - never mutates the old one. */
|
||||
rollback(marketplaceId: string, revisionId: string): Observable<MarketplaceRevision>;
|
||||
}
|
||||
@@ -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<MarketplaceRevisionGateway>('MARKETPLACE_REVISION_GATEWAY', {
|
||||
providedIn: 'root',
|
||||
factory: () => (environment.useMockData ? inject(MarketplaceRevisionLocalGateway) : inject(MarketplaceRevisionApiGateway)),
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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<MarketplaceRevision[]>([]);
|
||||
private sequence = 1;
|
||||
|
||||
createDraft(marketplaceId: string): Observable<MarketplaceRevision> {
|
||||
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<MarketplaceRevision> {
|
||||
// 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<MarketplaceRevision> {
|
||||
return this.transition(revisionId, 'published', canPublish, 'must be in preview to publish');
|
||||
}
|
||||
|
||||
rollback(marketplaceId: string, revisionId: string): Observable<MarketplaceRevision> {
|
||||
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<MarketplaceRevision> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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' },
|
||||
|
||||
@@ -7,9 +7,26 @@ export interface AdminDashboardCardState<T> {
|
||||
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 =
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
<section class="order-total-formula">
|
||||
<h3>Total formula</h3>
|
||||
|
||||
@if (!hasFullBreakdown()) {
|
||||
<p class="order-total-formula__unavailable">
|
||||
Price breakdown not available for this order. Backend has not populated
|
||||
per-line unitPriceMinor / lineTotalMinor yet.
|
||||
</p>
|
||||
} @else {
|
||||
<table class="order-total-formula__table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Line</th>
|
||||
<th>Qty</th>
|
||||
<th>Unit price</th>
|
||||
<th>Discount</th>
|
||||
<th>Line total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (row of lineRows(); track row.name) {
|
||||
<tr>
|
||||
<td>{{ row.name }}</td>
|
||||
<td>{{ row.quantity }}</td>
|
||||
<td>{{ toDisplay(row.unitPriceMinor) }} {{ order().currency }}</td>
|
||||
<td>{{ row.discountMinor ? toDisplay(row.discountMinor) + ' ' + order().currency : '—' }}</td>
|
||||
<td>{{ toDisplay(row.lineTotalMinor) }} {{ order().currency }}</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<dl class="order-total-formula__summary">
|
||||
<div>
|
||||
<dt>Subtotal</dt>
|
||||
<dd>{{ toDisplay(subtotalMinor()) }} {{ order().currency }}</dd>
|
||||
</div>
|
||||
@if (totalDiscountMinor() > 0) {
|
||||
<div>
|
||||
<dt>Discounts</dt>
|
||||
<dd>−{{ toDisplay(totalDiscountMinor()) }} {{ order().currency }}</dd>
|
||||
</div>
|
||||
}
|
||||
@if (order().deliveryMinor !== undefined) {
|
||||
<div>
|
||||
<dt>Delivery</dt>
|
||||
<dd>{{ toDisplay(order().deliveryMinor) }} {{ order().currency }}</dd>
|
||||
</div>
|
||||
}
|
||||
<div class="order-total-formula__grand">
|
||||
<dt>Charged total</dt>
|
||||
<dd>{{ order().total }} {{ order().currency }}</dd>
|
||||
</div>
|
||||
@if (order().fxQuoteId) {
|
||||
<div class="order-total-formula__fx">
|
||||
<dt>FX quote used</dt>
|
||||
<dd><code>{{ order().fxQuoteId }}</code></dd>
|
||||
</div>
|
||||
}
|
||||
</dl>
|
||||
}
|
||||
|
||||
@if (order().routing; as routing) {
|
||||
<p class="order-total-formula__routing">
|
||||
Routed to payment point <code>{{ routing.leafNodeId }}</code> ({{ routing.environment }}) ·
|
||||
merchant reference <code>{{ routing.merchantReference }}</code>
|
||||
</p>
|
||||
}
|
||||
</section>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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> = {}): 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<OrderTotalFormulaComponent>;
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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<AdminOrder>();
|
||||
|
||||
/** 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);
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -44,6 +44,9 @@
|
||||
<p>{{ order.shipping.method }}</p>
|
||||
@if (order.shipping.trackingNumber) { <p>{{ 'adminOrders.trackingNumber' | translate }}: {{ order.shipping.trackingNumber }}</p> }
|
||||
</div>
|
||||
<div class="card">
|
||||
<app-order-total-formula [order]="order" />
|
||||
</div>
|
||||
<div class="card no-print">
|
||||
<h3>{{ 'adminOrders.changeStatus' | translate }}</h3>
|
||||
<select [attr.aria-label]="'adminOrders.changeStatus' | translate" [ngModel]="order.status" (ngModelChange)="setStatus(order.id, $event)" [disabled]="isTerminal()">
|
||||
|
||||
@@ -11,13 +11,14 @@ import { ButtonComponent } from '../../../../shared/ui/button/button.component';
|
||||
import { BadgeComponent } from '../../../../shared/ui/badge/badge.component';
|
||||
import { OrderTimelineComponent, OrderTimelineEntry } from '../components/order-timeline/order-timeline.component';
|
||||
import { ConfirmDialogComponent } from '../../../../shared/ui/confirm-dialog/confirm-dialog.component';
|
||||
import { OrderTotalFormulaComponent } from '../components/order-total-formula/order-total-formula.component';
|
||||
|
||||
const WORKFLOW_STEPS: AdminOrderStatus[] = ['pending', 'processing', 'shipped', 'delivered'];
|
||||
|
||||
@Component({
|
||||
selector: 'app-admin-order-detail-page',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, TranslatePipe, ButtonComponent, BadgeComponent, OrderTimelineComponent, ConfirmDialogComponent],
|
||||
imports: [CommonModule, FormsModule, TranslatePipe, ButtonComponent, BadgeComponent, OrderTimelineComponent, ConfirmDialogComponent, OrderTotalFormulaComponent],
|
||||
templateUrl: './admin-order-detail-page.component.html',
|
||||
styleUrls: ['./admin-order-detail-page.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
|
||||
@@ -995,6 +995,14 @@ export const en: Translations = {
|
||||
draftDiscard: 'Discard draft',
|
||||
healthProductsCount: 'Products',
|
||||
healthCategoriesCount: 'Categories',
|
||||
healthGmv: 'GMV',
|
||||
healthPaidOrders: 'Paid orders',
|
||||
healthConversionRate: 'Conversion rate',
|
||||
healthPaymentFailureRate: 'Payment failure rate',
|
||||
healthModerationQueue: 'Moderation queue',
|
||||
healthLowStock: 'Low stock',
|
||||
healthUnmatchedEvents: 'Unmatched events',
|
||||
healthIntegrationHealth: 'Integration health',
|
||||
healthDraftExists: 'Draft',
|
||||
healthImagesWithoutAlt: 'Images missing alt text',
|
||||
healthStaticPagesUnpublished: 'Unpublished static pages',
|
||||
|
||||
@@ -995,6 +995,14 @@ export const hy: Translations = {
|
||||
draftDiscard: 'Չեղարկել սևագիրը',
|
||||
healthProductsCount: 'Ապրանքներ',
|
||||
healthCategoriesCount: 'Կատեգորիաներ',
|
||||
healthGmv: 'GMV',
|
||||
healthPaidOrders: 'Վճարված պատվերներ',
|
||||
healthConversionRate: 'Փոխարկման տոկոս',
|
||||
healthPaymentFailureRate: 'Անհաջող վճարումների տոկոս',
|
||||
healthModerationQueue: 'Մոդերացիայի հերթ',
|
||||
healthLowStock: 'Ցածր պահուստ',
|
||||
healthUnmatchedEvents: 'Չհամապատասխանեցված իրադարձություններ',
|
||||
healthIntegrationHealth: 'Ինտեգրումների վիճակ',
|
||||
healthDraftExists: 'Սևագիր',
|
||||
healthImagesWithoutAlt: 'Alt տեքստ չունեցող պատկերներ',
|
||||
healthStaticPagesUnpublished: 'Չհրապարակված ստատիկ էջեր',
|
||||
|
||||
@@ -995,6 +995,14 @@ export const ru: Translations = {
|
||||
draftDiscard: 'Отменить черновик',
|
||||
healthProductsCount: 'Товары',
|
||||
healthCategoriesCount: 'Категории',
|
||||
healthGmv: 'GMV',
|
||||
healthPaidOrders: 'Оплаченные заказы',
|
||||
healthConversionRate: 'Конверсия',
|
||||
healthPaymentFailureRate: 'Доля неудачных платежей',
|
||||
healthModerationQueue: 'Очередь модерации',
|
||||
healthLowStock: 'Низкий остаток',
|
||||
healthUnmatchedEvents: 'Несопоставленные события',
|
||||
healthIntegrationHealth: 'Состояние интеграций',
|
||||
healthDraftExists: 'Черновик',
|
||||
healthImagesWithoutAlt: 'Изображения без alt-текста',
|
||||
healthStaticPagesUnpublished: 'Неопубликованные статические страницы',
|
||||
|
||||
@@ -994,6 +994,14 @@ export interface Translations {
|
||||
draftDiscard: string;
|
||||
healthProductsCount: string;
|
||||
healthCategoriesCount: string;
|
||||
healthGmv: string;
|
||||
healthPaidOrders: string;
|
||||
healthConversionRate: string;
|
||||
healthPaymentFailureRate: string;
|
||||
healthModerationQueue: string;
|
||||
healthLowStock: string;
|
||||
healthUnmatchedEvents: string;
|
||||
healthIntegrationHealth: string;
|
||||
healthDraftExists: string;
|
||||
healthImagesWithoutAlt: string;
|
||||
healthStaticPagesUnpublished: string;
|
||||
|
||||
Reference in New Issue
Block a user