feat: FX-quote-backed currency conversion, delete admin rate editor
Some checks failed
Architecture Governance / architecture (push) Has been cancelled

F10-F12 of the frontend backlog. Contract: PHASE-1-MONEY-FX-PAYMENTS-CONTRACT.md §3.

Removed the failure mode §5 of that contract exists to close: rates were
typed once by an admin into Settings, persisted to localStorage, seeded from
a hardcoded DEFAULT_RATES table (USD: 0.011, AMD: 4.3) that never updated and
drifted from market. Nothing recorded which rate produced a displayed price
or when.

- currency-rates.service.ts   now fetches through FX_QUOTE_GATEWAY instead of
                              reading admin-typed/localStorage numbers. Stays
                              synchronous at the call site (getRate/convert) -
                              rewriting every consuming template to `| async`
                              is a separate, larger change (F13, not this
                              commit). Before a quote has loaded for a pair,
                              getRate returns 1 rather than a fabricated
                              market rate; isRateReady() lets a caller that
                              cares distinguish the two. ensureFreshQuote()
                              added for checkout to await before charging,
                              per contract §3.2's stale-quote policy.
- language.service.ts        setCurrency() now triggers a quote fetch instead
                              of just flipping the display signal.
- cart.component.ts           openPaymentPopup() awaits ensureFreshQuote()
                              before computing the charged amount.
- admin-settings-page.*        currency-rate editor deleted (F11) - card,
                              component state, and the three orphaned i18n
                              keys it was the only consumer of.

Two real bugs surfaced fixing this, neither cosmetic:

1. fx-quote-local.gateway.ts had CurrencyRatesService.convert() as its rate
   source. That is now circular - CurrencyRatesService depends on
   FX_QUOTE_GATEWAY, and under useMockData:true this gateway IS
   FX_QUOTE_GATEWAY. Would have recursed the moment mock FX data was
   exercised. Fixed by giving the local gateway its own static mock table -
   the correct home for those numbers now: explicitly labelled dev/mock data,
   only wired in behind useMockData, never presented as a live rate.

2. currency-convert.pipe.ts memoized its result on (amount, from, to) alone.
   That was already latently wrong - rates could change via the old
   setRate() without the pipe re-evaluating for an already-rendered price -
   but never surfaced because rates never changed mid-session in practice.
   Async quote loading made it concrete and reproducible: a price rendered
   before its quote arrived stayed wrong forever, because none of the three
   cached inputs ever changed again on their own. Fixed with a ratesVersion
   counter on the service, bumped on every quote arrival, included in the
   pipe's cache key.

Both found and fixed via the E2E suite (docs from the prior commit) actually
exercising the real code path: GET /api/v2/pricing/fx-quote intercepted with
a contract-shaped response rather than flipping the whole app into mock mode,
so the test runs the real FxQuoteApiGateway, not a stand-in for it.

Verified: 3/3 E2E green, 115/115 unit tests green, arch:check clean,
production build succeeds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
sdarbinyan
2026-08-18 14:03:02 +04:00
parent 21443d34a0
commit 14467cc6fb
12 changed files with 223 additions and 108 deletions

View File

@@ -1,38 +1,71 @@
import { Injectable, Signal, inject, signal } from '@angular/core';
import { LocalStorageService } from '../core/storage/local-storage.service';
const RATES_KEY = 'currencyRates.v1';
/** Fallback rates relative to RUB (1 RUB = rate[code] units of code), used until admin overrides them. */
const DEFAULT_RATES: Record<string, number> = {
RUB: 1,
USD: 0.011,
EUR: 0.01,
AMD: 4.3,
};
import { Injectable, inject, signal } from '@angular/core';
import { FX_QUOTE_GATEWAY } from '../core/pricing/services/fx-quote-gateway.token';
import { FxQuote, isFxQuoteExpired } from '../core/pricing/models/fx-quote.model';
/**
* Server-sourced currency conversion. Contract:
* docs/backend/PHASE-1-MONEY-FX-PAYMENTS-CONTRACT.md §3.
*
* Previously: admin-typed rates persisted to localStorage, seeded from a
* hardcoded DEFAULT_RATES table that never updated and drifted from market
* (USD: 0.011, AMD: 4.3, fixed at whatever date someone last typed them in).
* That is exactly the class of bug §5 of the contract exists to close - the
* amount actually charged must be reconstructable from real data, not from
* a number an admin guessed once.
*
* getRate/convert stay synchronous because their call sites (a `pure: false`
* pipe, cart's computed signals) are synchronous today and rewriting every
* template into `| async` is a separate, much larger change. The honest
* tradeoff that follows from that: before a quote has loaded for a pair,
* getRate returns 1 (same-currency, i.e. "not yet converted") rather than a
* fabricated market rate - a caller that needs to know whether a rate is
* real can check isRateReady().
*/
@Injectable({ providedIn: 'root' })
export class CurrencyRatesService {
private readonly localStorage = inject(LocalStorageService);
private readonly fxQuoteGateway = inject(FX_QUOTE_GATEWAY);
private readonly ratesSignal = signal<Record<string, number>>(this.readStoredRates());
private readonly quotesSignal = signal<Record<string, FxQuote>>({});
private readonly inFlight = new Set<string>();
private readonly versionSignal = signal(0);
readonly rates: Signal<Record<string, number>> = this.ratesSignal.asReadonly();
/** Base currency all rates are relative to. */
readonly baseCurrency = 'RUB';
getRate(code: string): number {
return this.ratesSignal()[code] ?? DEFAULT_RATES[code] ?? 1;
}
/**
* Bumped every time a quote is added or refreshed. Quotes now arrive
* asynchronously and can update a rate for a pair that was already
* displayed, so anything that memoizes convert()'s output (the
* `pure: false` currencyConvert pipe caches on (amount, from, to)) needs a
* signal that changes even when those three inputs are unchanged, or a
* price rendered before the quote loaded is stuck at rate=1 forever.
*/
readonly ratesVersion = this.versionSignal.asReadonly();
setRate(code: string, rate: number): void {
if (!Number.isFinite(rate) || rate <= 0) {
return;
/** True once a non-expired quote exists for this pair. */
readonly isRateReady = (code: string): boolean => {
if (code === this.baseCurrency) {
return true;
}
const next = { ...this.ratesSignal(), [code]: rate };
this.ratesSignal.set(next);
this.localStorage.setJSON(RATES_KEY, next);
const quote = this.quotesSignal()[this.pairKey(code)];
return quote !== undefined && !isFxQuoteExpired(quote);
};
getRate(code: string): number {
if (code === this.baseCurrency) {
return 1;
}
const key = this.pairKey(code);
const quote = this.quotesSignal()[key];
if (!quote || isFxQuoteExpired(quote)) {
this.fetchQuote(code);
}
// A stale-but-present quote is still a real market rate from a moment
// ago, which is a better estimate than 1 while the refetch is in
// flight - only "never fetched" falls back to 1.
return quote?.rate ?? 1;
}
/** Converts an amount expressed in `fromCurrency` into `toCurrency` via the RUB base rate. */
@@ -46,8 +79,62 @@ export class CurrencyRatesService {
return amountInBase * toRate;
}
private readStoredRates(): Record<string, number> {
const stored = this.localStorage.getJSON<Record<string, number>>(RATES_KEY);
return { ...DEFAULT_RATES, ...stored };
/**
* Forces a fresh quote for the given currency, ignoring any cached value.
* Callers that are about to charge money (checkout) should await this
* before reading a rate, per contract §3.2's stale-quote policy - a rate
* cached moments ago is fine for display, not for computing a charge.
*/
async ensureFreshQuote(code: string): Promise<void> {
if (code === this.baseCurrency) {
return;
}
await this.fetchQuotePromise(code);
}
private fetchQuote(code: string): void {
const key = this.pairKey(code);
if (this.inFlight.has(key)) {
return;
}
this.inFlight.add(key);
this.fxQuoteGateway.getQuote(this.baseCurrency, code).subscribe({
next: quote => {
this.quotesSignal.update(current => ({ ...current, [key]: quote }));
this.versionSignal.update(v => v + 1);
this.inFlight.delete(key);
},
error: () => {
// Leave any previously cached quote in place rather than clearing it -
// a stale rate that is still roughly right beats no rate at all.
this.inFlight.delete(key);
},
});
}
private fetchQuotePromise(code: string): Promise<void> {
const key = this.pairKey(code);
// Shares inFlight with fetchQuote() so a concurrent getRate() call for
// the same pair doesn't fire a second, redundant request.
this.inFlight.add(key);
return new Promise(resolve => {
this.fxQuoteGateway.getQuote(this.baseCurrency, code).subscribe({
next: quote => {
this.quotesSignal.update(current => ({ ...current, [key]: quote }));
this.versionSignal.update(v => v + 1);
this.inFlight.delete(key);
resolve();
},
error: () => {
this.inFlight.delete(key);
resolve();
},
});
});
}
private pairKey(quoteCode: string): string {
return `${this.baseCurrency}_${quoteCode}`;
}
}

View File

@@ -1,6 +1,7 @@
import { Injectable, signal } from '@angular/core';
import { Router } from '@angular/router';
import { LocalStorageService } from '../core/storage/local-storage.service';
import { CurrencyRatesService } from './currency-rates.service';
export interface Language {
code: string;
@@ -39,7 +40,11 @@ export class LanguageService {
currentLanguage = this.currentLanguageSignal.asReadonly();
currentCurrency = this.currentCurrencySignal.asReadonly();
constructor(private router: Router, private readonly storage: LocalStorageService) {
constructor(
private router: Router,
private readonly storage: LocalStorageService,
private readonly currencyRates: CurrencyRatesService,
) {
// Load saved language from localStorage
const savedLang = this.storage.getItem('selectedLanguage');
if (savedLang && this.languages.find(l => l.code === savedLang && l.enabled)) {
@@ -65,6 +70,11 @@ export class LanguageService {
if (currency) {
this.currentCurrencySignal.set(code);
this.storage.setItem('selectedCurrency', code);
// PHASE-1-MONEY-FX-PAYMENTS-CONTRACT.md §3: fetch a fresh quote on
// switch rather than waiting for the next getRate() call to notice the
// cached one is stale - the displayed price should update as soon as
// the switch happens, not one render behind it.
void this.currencyRates.ensureFreshQuote(code);
}
}