Compare commits
2 Commits
8a68be797a
...
1bdca917b3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1bdca917b3 | ||
|
|
14467cc6fb |
@@ -1,4 +1,4 @@
|
|||||||
import { expect, test } from '@playwright/test';
|
import { Page, Route, expect, test } from '@playwright/test';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Track Q Q4: currency switch must recalculate by FX quote. Explicitly,
|
* Track Q Q4: currency switch must recalculate by FX quote. Explicitly,
|
||||||
@@ -10,8 +10,23 @@ import { expect, test } from '@playwright/test';
|
|||||||
* checkout to a server-computed total per
|
* checkout to a server-computed total per
|
||||||
* docs/backend/PHASE-1-MONEY-FX-PAYMENTS-CONTRACT.md §5). This test exists so
|
* docs/backend/PHASE-1-MONEY-FX-PAYMENTS-CONTRACT.md §5). This test exists so
|
||||||
* that rewrite has something to break loudly if it silently stops converting.
|
* that rewrite has something to break loudly if it silently stops converting.
|
||||||
|
*
|
||||||
|
* This session has no live backend to run against, so GET
|
||||||
|
* /api/v2/pricing/fx-quote is intercepted with a response shaped exactly per
|
||||||
|
* contract §3.1. That exercises the REAL code path - FxQuoteApiGateway,
|
||||||
|
* CurrencyRatesService, the currencyConvert pipe - rather than switching the
|
||||||
|
* whole app into mock mode, which would test a different (mock) gateway
|
||||||
|
* instead of the one actually shipped.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
/** Rate relative to RUB, only what this test needs. */
|
||||||
|
const MOCK_RATE: Record<string, number> = { USD: 0.0108, EUR: 0.0092, AMD: 4.31 };
|
||||||
|
|
||||||
test.describe('currency switch', () => {
|
test.describe('currency switch', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await mockFxQuoteEndpoint(page);
|
||||||
|
});
|
||||||
|
|
||||||
test('switching currency changes the displayed price value, not just its label', async ({ page }) => {
|
test('switching currency changes the displayed price value, not just its label', async ({ page }) => {
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
await page.waitForLoadState('networkidle');
|
await page.waitForLoadState('networkidle');
|
||||||
@@ -24,12 +39,16 @@ test.describe('currency switch', () => {
|
|||||||
|
|
||||||
await switchCurrency(page, before.currency === 'USD' ? 'RUB' : 'USD');
|
await switchCurrency(page, before.currency === 'USD' ? 'RUB' : 'USD');
|
||||||
|
|
||||||
// The pipe is `pure: false` and re-evaluates on the next change-detection
|
// The currency LABEL flips synchronously (a signal write), but the rate
|
||||||
// cycle; give the DOM a moment to actually repaint rather than reading
|
// itself arrives from the mocked network call asynchronously - polling
|
||||||
// stale text off a signal that hasn't propagated yet.
|
// only the label races ahead of the actual conversion and passes before
|
||||||
|
// the number has caught up. Poll the parsed numeric value instead, since
|
||||||
|
// that is what this test exists to guard.
|
||||||
await expect
|
await expect
|
||||||
.poll(async () => (await priceLocator.textContent()) ?? '')
|
.poll(async () => (await readPrice(priceLocator)).value, {
|
||||||
.not.toContain(before.currency);
|
message: 'price value never diverged from the pre-switch amount',
|
||||||
|
})
|
||||||
|
.not.toBeCloseTo(before.value, 2);
|
||||||
|
|
||||||
const after = await readPrice(priceLocator);
|
const after = await readPrice(priceLocator);
|
||||||
|
|
||||||
@@ -55,7 +74,31 @@ test.describe('currency switch', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
async function switchCurrency(page: import('@playwright/test').Page, targetCode: string): Promise<void> {
|
async function mockFxQuoteEndpoint(page: Page): Promise<void> {
|
||||||
|
await page.route('**/api/v2/pricing/fx-quote**', (route: Route) => {
|
||||||
|
const url = new URL(route.request().url());
|
||||||
|
const base = url.searchParams.get('base') ?? 'RUB';
|
||||||
|
const quote = url.searchParams.get('quote') ?? 'USD';
|
||||||
|
const rate = MOCK_RATE[quote] ?? 1;
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({
|
||||||
|
quoteId: `fxq_e2e_${base}_${quote}_${now.getTime()}`,
|
||||||
|
base,
|
||||||
|
quote,
|
||||||
|
rate,
|
||||||
|
source: 'e2e-fixture',
|
||||||
|
observedAt: now.toISOString(),
|
||||||
|
expiresAt: new Date(now.getTime() + 5 * 60 * 1000).toISOString(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function switchCurrency(page: Page, targetCode: string): Promise<void> {
|
||||||
// The page renders more than one language-selector instance (desktop/mobile
|
// The page renders more than one language-selector instance (desktop/mobile
|
||||||
// variants share the same markup) - scoping to the dropdown that actually
|
// variants share the same markup) - scoping to the dropdown that actually
|
||||||
// carries the "open" class avoids clicking an option in a hidden duplicate,
|
// carries the "open" class avoids clicking an option in a hidden duplicate,
|
||||||
|
|||||||
@@ -1,24 +1,39 @@
|
|||||||
import { Injectable, inject } from '@angular/core';
|
import { Injectable } from '@angular/core';
|
||||||
import { Observable, of } from 'rxjs';
|
import { Observable, of } from 'rxjs';
|
||||||
import { FxQuote } from '../models/fx-quote.model';
|
import { FxQuote } from '../models/fx-quote.model';
|
||||||
import { FxQuoteGateway } from './fx-quote-gateway.interface';
|
import { FxQuoteGateway } from './fx-quote-gateway.interface';
|
||||||
import { CurrencyRatesService } from '../../../services/currency-rates.service';
|
|
||||||
|
|
||||||
const QUOTE_TTL_MS = 5 * 60 * 1000;
|
const QUOTE_TTL_MS = 5 * 60 * 1000;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mock FX source until a real backend rate service exists (Sprint 0.1:
|
* Mock rates, relative to RUB. Dev/demo data only - never presented as a
|
||||||
* FX is computed in-house, "internal" is the normal source value, not just
|
* live market rate. This is the one place in the codebase such a table
|
||||||
* a fallback). Derives a quote from CurrencyRatesService's existing
|
* belongs: explicitly the mock data source, only wired in when
|
||||||
* admin-editable rates so the shape is real even though the source isn't.
|
* environment.useMockData is true (see fx-quote-gateway.token.ts).
|
||||||
|
*
|
||||||
|
* Previously these numbers lived in CurrencyRatesService itself, silently
|
||||||
|
* standing in for a real rate with no expiry and no indication they were
|
||||||
|
* fake. Moved here so the real service (currency-rates.service.ts) has no
|
||||||
|
* fallback data of its own to depend on - it must NOT call back into this
|
||||||
|
* gateway for a rate (that would be circular: this gateway would depend on
|
||||||
|
* the currency service, which is the whole point of the swap point in
|
||||||
|
* fx-quote-gateway.token.ts).
|
||||||
*/
|
*/
|
||||||
|
const MOCK_RATES: Record<string, number> = {
|
||||||
|
RUB: 1,
|
||||||
|
USD: 0.011,
|
||||||
|
EUR: 0.01,
|
||||||
|
AMD: 4.3,
|
||||||
|
};
|
||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class FxQuoteLocalGateway implements FxQuoteGateway {
|
export class FxQuoteLocalGateway implements FxQuoteGateway {
|
||||||
private readonly currencyRates = inject(CurrencyRatesService);
|
|
||||||
|
|
||||||
getQuote(base: string, quote: string): Observable<FxQuote> {
|
getQuote(base: string, quote: string): Observable<FxQuote> {
|
||||||
const rate = this.currencyRates.convert(1, base, quote);
|
const baseRate = MOCK_RATES[base] ?? 1;
|
||||||
|
const quoteRate = MOCK_RATES[quote] ?? 1;
|
||||||
|
const rate = quoteRate / baseRate;
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|
||||||
return of({
|
return of({
|
||||||
quoteId: `fxq_local_${base}_${quote}_${now.getTime()}`,
|
quoteId: `fxq_local_${base}_${quote}_${now.getTime()}`,
|
||||||
base,
|
base,
|
||||||
|
|||||||
@@ -12,27 +12,6 @@
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="settings-card">
|
|
||||||
<h2>{{ 'adminSettings.currencyRates' | translate }}</h2>
|
|
||||||
<p class="settings-explain">{{ 'adminSettings.currencyRatesExplain' | translate }}</p>
|
|
||||||
<div class="rate-row" *ngFor="let currency of languageService.currencies">
|
|
||||||
<span class="rate-code">{{ currency.code }}</span>
|
|
||||||
<input
|
|
||||||
class="rate-input"
|
|
||||||
type="number"
|
|
||||||
min="0"
|
|
||||||
step="0.0001"
|
|
||||||
[disabled]="currency.code === currencyRates.baseCurrency"
|
|
||||||
[ngModel]="rateDrafts()[currency.code]"
|
|
||||||
(ngModelChange)="onRateInput(currency.code, $event)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="rate-actions">
|
|
||||||
<button type="button" class="save-button" (click)="saveRates()">{{ 'adminSettings.currencyRatesSave' | translate }}</button>
|
|
||||||
<span class="saved-message" *ngIf="showSavedMessage()">{{ 'adminSettings.currencyRatesSaved' | translate }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="settings-card">
|
<div class="settings-card">
|
||||||
<h2>{{ 'adminSettings.notificationInterval' | translate }}</h2>
|
<h2>{{ 'adminSettings.notificationInterval' | translate }}</h2>
|
||||||
<p class="settings-explain">{{ 'adminSettings.notificationIntervalExplain' | translate }}</p>
|
<p class="settings-explain">{{ 'adminSettings.notificationIntervalExplain' | translate }}</p>
|
||||||
|
|||||||
@@ -4,8 +4,6 @@ import { FormsModule } from '@angular/forms';
|
|||||||
import { AdminPreferencesService } from '../services/admin-preferences.service';
|
import { AdminPreferencesService } from '../services/admin-preferences.service';
|
||||||
import { TranslatePipe } from '../../../../i18n/translate.pipe';
|
import { TranslatePipe } from '../../../../i18n/translate.pipe';
|
||||||
import { ToggleComponent } from '../../../../shared/ui/toggle/toggle.component';
|
import { ToggleComponent } from '../../../../shared/ui/toggle/toggle.component';
|
||||||
import { CurrencyRatesService } from '../../../../services/currency-rates.service';
|
|
||||||
import { LanguageService } from '../../../../services/language.service';
|
|
||||||
import { AdminOrderWatcherService } from '../../shell/services/admin-order-watcher.service';
|
import { AdminOrderWatcherService } from '../../shell/services/admin-order-watcher.service';
|
||||||
|
|
||||||
const SAVED_MESSAGE_DURATION_MS = 2000;
|
const SAVED_MESSAGE_DURATION_MS = 2000;
|
||||||
@@ -20,11 +18,6 @@ const SAVED_MESSAGE_DURATION_MS = 2000;
|
|||||||
})
|
})
|
||||||
export class AdminSettingsPageComponent {
|
export class AdminSettingsPageComponent {
|
||||||
readonly preferences = inject(AdminPreferencesService);
|
readonly preferences = inject(AdminPreferencesService);
|
||||||
readonly currencyRates = inject(CurrencyRatesService);
|
|
||||||
readonly languageService = inject(LanguageService);
|
|
||||||
|
|
||||||
readonly rateDrafts = signal<Record<string, number>>({ ...this.currencyRates.rates() });
|
|
||||||
readonly showSavedMessage = signal(false);
|
|
||||||
|
|
||||||
readonly orderWatcher = inject(AdminOrderWatcherService);
|
readonly orderWatcher = inject(AdminOrderWatcherService);
|
||||||
readonly notificationIntervalSecondsDraft = signal(Math.round(this.orderWatcher.intervalMs() / 1000));
|
readonly notificationIntervalSecondsDraft = signal(Math.round(this.orderWatcher.intervalMs() / 1000));
|
||||||
@@ -42,20 +35,4 @@ export class AdminSettingsPageComponent {
|
|||||||
onCompactToggle(compact: boolean): void {
|
onCompactToggle(compact: boolean): void {
|
||||||
this.preferences.setDensity(compact ? 'compact' : 'comfortable');
|
this.preferences.setDensity(compact ? 'compact' : 'comfortable');
|
||||||
}
|
}
|
||||||
|
|
||||||
onRateInput(code: string, value: string): void {
|
|
||||||
const parsed = Number(value);
|
|
||||||
this.rateDrafts.set({ ...this.rateDrafts(), [code]: parsed });
|
|
||||||
}
|
|
||||||
|
|
||||||
saveRates(): void {
|
|
||||||
for (const currency of this.languageService.currencies) {
|
|
||||||
const rate = this.rateDrafts()[currency.code];
|
|
||||||
if (Number.isFinite(rate) && rate > 0) {
|
|
||||||
this.currencyRates.setRate(currency.code, rate);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this.showSavedMessage.set(true);
|
|
||||||
setTimeout(() => this.showSavedMessage.set(false), SAVED_MESSAGE_DURATION_MS);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1949,10 +1949,6 @@ export const en: Translations = {
|
|||||||
density: 'Table density',
|
density: 'Table density',
|
||||||
densityExplain: 'Reduce row padding across backoffice list pages for a more compact view.',
|
densityExplain: 'Reduce row padding across backoffice list pages for a more compact view.',
|
||||||
densityCompact: 'Compact rows',
|
densityCompact: 'Compact rows',
|
||||||
currencyRates: 'Currency rates',
|
|
||||||
currencyRatesExplain: 'Rates relative to 1 RUB, used to convert storefront prices while the backend does not return prices per currency.',
|
|
||||||
currencyRatesSave: 'Save rates',
|
|
||||||
currencyRatesSaved: 'Rates saved',
|
|
||||||
notificationInterval: 'New-order check interval (seconds)',
|
notificationInterval: 'New-order check interval (seconds)',
|
||||||
notificationIntervalExplain: 'How often the admin panel polls for new orders to show a notification.',
|
notificationIntervalExplain: 'How often the admin panel polls for new orders to show a notification.',
|
||||||
notificationIntervalSave: 'Save interval',
|
notificationIntervalSave: 'Save interval',
|
||||||
|
|||||||
@@ -1944,10 +1944,6 @@ export const hy: Translations = {
|
|||||||
density: 'Աղյուսակի խտություն',
|
density: 'Աղյուսակի խտություն',
|
||||||
densityExplain: 'Փոքրացնել տողերի հեռավորությունը ադմինիստրատիվ վահանակի ցուցակներում՝ ավելի կոմպակտ տեսքի համար։',
|
densityExplain: 'Փոքրացնել տողերի հեռավորությունը ադմինիստրատիվ վահանակի ցուցակներում՝ ավելի կոմպակտ տեսքի համար։',
|
||||||
densityCompact: 'Կոմպակտ տողեր',
|
densityCompact: 'Կոմպակտ տողեր',
|
||||||
currencyRates: 'Արժույթների փոխարժեքներ',
|
|
||||||
currencyRatesExplain: 'Փոխարժեքներ՝ 1 RUB-ի նկատմամբ, օգտագործվում են կայքի գները փոխարկելու համար, քանի դեռ բեքենդը գներ չի վերադարձնում ըստ արժույթի։',
|
|
||||||
currencyRatesSave: 'Պահպանել փոխարժեքները',
|
|
||||||
currencyRatesSaved: 'Փոխարժեքները պահպանվեցին',
|
|
||||||
notificationInterval: 'Նոր պատվերների ստուգման ինտերվալ (վրկ)',
|
notificationInterval: 'Նոր պատվերների ստուգման ինտերվալ (վրկ)',
|
||||||
notificationIntervalExplain: 'Որքան հաճախ է ադմին վահանակը ստուգում նոր պատվերներ ծանուցման համար։',
|
notificationIntervalExplain: 'Որքան հաճախ է ադմին վահանակը ստուգում նոր պատվերներ ծանուցման համար։',
|
||||||
notificationIntervalSave: 'Պահպանել ինտերվալը',
|
notificationIntervalSave: 'Պահպանել ինտերվալը',
|
||||||
|
|||||||
@@ -1944,10 +1944,6 @@ export const ru: Translations = {
|
|||||||
density: 'Плотность таблиц',
|
density: 'Плотность таблиц',
|
||||||
densityExplain: 'Уменьшить отступы строк в списках панели управления для более компактного вида.',
|
densityExplain: 'Уменьшить отступы строк в списках панели управления для более компактного вида.',
|
||||||
densityCompact: 'Компактные строки',
|
densityCompact: 'Компактные строки',
|
||||||
currencyRates: 'Курсы валют',
|
|
||||||
currencyRatesExplain: 'Курсы относительно 1 RUB, используются для конвертации цен на сайте, пока бэкенд не возвращает цены в разных валютах.',
|
|
||||||
currencyRatesSave: 'Сохранить курсы',
|
|
||||||
currencyRatesSaved: 'Курсы сохранены',
|
|
||||||
notificationInterval: 'Интервал проверки новых заказов (сек)',
|
notificationInterval: 'Интервал проверки новых заказов (сек)',
|
||||||
notificationIntervalExplain: 'Как часто админ-панель проверяет новые заказы для уведомления.',
|
notificationIntervalExplain: 'Как часто админ-панель проверяет новые заказы для уведомления.',
|
||||||
notificationIntervalSave: 'Сохранить интервал',
|
notificationIntervalSave: 'Сохранить интервал',
|
||||||
|
|||||||
@@ -1957,10 +1957,6 @@ export interface Translations {
|
|||||||
density: string;
|
density: string;
|
||||||
densityExplain: string;
|
densityExplain: string;
|
||||||
densityCompact: string;
|
densityCompact: string;
|
||||||
currencyRates: string;
|
|
||||||
currencyRatesExplain: string;
|
|
||||||
currencyRatesSave: string;
|
|
||||||
currencyRatesSaved: string;
|
|
||||||
notificationInterval: string;
|
notificationInterval: string;
|
||||||
notificationIntervalExplain: string;
|
notificationIntervalExplain: string;
|
||||||
notificationIntervalSave: string;
|
notificationIntervalSave: string;
|
||||||
|
|||||||
@@ -234,7 +234,14 @@ export class CartComponent implements OnDestroy {
|
|||||||
this.emailSubmitting.set(false);
|
this.emailSubmitting.set(false);
|
||||||
this.purchaseSubmitted.set(false);
|
this.purchaseSubmitted.set(false);
|
||||||
this.paidItems = [...this.items()];
|
this.paidItems = [...this.items()];
|
||||||
this.createPayment(paymentMethod);
|
|
||||||
|
// Contract §3.2 stale-quote policy: the amount about to be charged must
|
||||||
|
// be computed from a rate fetched now, not one cached from whenever the
|
||||||
|
// shopper last switched currency or opened this page. 'creating' is
|
||||||
|
// already showing, so this adds a wait, not a new state.
|
||||||
|
// ensureFreshQuote never rejects (a fetch failure resolves with the
|
||||||
|
// previously cached rate left in place) - createPayment always runs.
|
||||||
|
void this.currencyRates.ensureFreshQuote(this.currentCurrency).then(() => this.createPayment(paymentMethod));
|
||||||
}
|
}
|
||||||
|
|
||||||
closePaymentPopup(): void {
|
closePaymentPopup(): void {
|
||||||
|
|||||||
@@ -13,20 +13,33 @@ export class CurrencyConvertPipe implements PipeTransform {
|
|||||||
private lastAmount: number | null = null;
|
private lastAmount: number | null = null;
|
||||||
private lastFromCurrency = '';
|
private lastFromCurrency = '';
|
||||||
private lastTargetCurrency = '';
|
private lastTargetCurrency = '';
|
||||||
|
private lastRatesVersion = -1;
|
||||||
private lastResult = 0;
|
private lastResult = 0;
|
||||||
|
|
||||||
transform(amount: number | null | undefined, fromCurrency: string | null | undefined): number {
|
transform(amount: number | null | undefined, fromCurrency: string | null | undefined): number {
|
||||||
const value = amount ?? 0;
|
const value = amount ?? 0;
|
||||||
const from = fromCurrency || this.ratesService.baseCurrency;
|
const from = fromCurrency || this.ratesService.baseCurrency;
|
||||||
const to = this.langService.currentCurrency();
|
const to = this.langService.currentCurrency();
|
||||||
|
// Rates now load asynchronously and can update in place for a pair
|
||||||
|
// already displayed - caching on (amount, from, to) alone means a price
|
||||||
|
// rendered before its quote arrived would stay wrong forever, since
|
||||||
|
// those three inputs never change again on their own. ratesVersion
|
||||||
|
// forces a recompute whenever the underlying rate data changes.
|
||||||
|
const ratesVersion = this.ratesService.ratesVersion();
|
||||||
|
|
||||||
if (value === this.lastAmount && from === this.lastFromCurrency && to === this.lastTargetCurrency) {
|
if (
|
||||||
|
value === this.lastAmount &&
|
||||||
|
from === this.lastFromCurrency &&
|
||||||
|
to === this.lastTargetCurrency &&
|
||||||
|
ratesVersion === this.lastRatesVersion
|
||||||
|
) {
|
||||||
return this.lastResult;
|
return this.lastResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.lastAmount = value;
|
this.lastAmount = value;
|
||||||
this.lastFromCurrency = from;
|
this.lastFromCurrency = from;
|
||||||
this.lastTargetCurrency = to;
|
this.lastTargetCurrency = to;
|
||||||
|
this.lastRatesVersion = ratesVersion;
|
||||||
this.lastResult = this.ratesService.convert(value, from, to);
|
this.lastResult = this.ratesService.convert(value, from, to);
|
||||||
|
|
||||||
return this.lastResult;
|
return this.lastResult;
|
||||||
|
|||||||
@@ -1,38 +1,71 @@
|
|||||||
import { Injectable, Signal, inject, signal } from '@angular/core';
|
import { Injectable, inject, signal } from '@angular/core';
|
||||||
import { LocalStorageService } from '../core/storage/local-storage.service';
|
import { FX_QUOTE_GATEWAY } from '../core/pricing/services/fx-quote-gateway.token';
|
||||||
|
import { FxQuote, isFxQuoteExpired } from '../core/pricing/models/fx-quote.model';
|
||||||
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,
|
|
||||||
};
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class CurrencyRatesService {
|
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';
|
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 {
|
/** True once a non-expired quote exists for this pair. */
|
||||||
if (!Number.isFinite(rate) || rate <= 0) {
|
readonly isRateReady = (code: string): boolean => {
|
||||||
return;
|
if (code === this.baseCurrency) {
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
const next = { ...this.ratesSignal(), [code]: rate };
|
const quote = this.quotesSignal()[this.pairKey(code)];
|
||||||
this.ratesSignal.set(next);
|
return quote !== undefined && !isFxQuoteExpired(quote);
|
||||||
this.localStorage.setJSON(RATES_KEY, next);
|
};
|
||||||
|
|
||||||
|
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. */
|
/** Converts an amount expressed in `fromCurrency` into `toCurrency` via the RUB base rate. */
|
||||||
@@ -46,8 +79,62 @@ export class CurrencyRatesService {
|
|||||||
return amountInBase * toRate;
|
return amountInBase * toRate;
|
||||||
}
|
}
|
||||||
|
|
||||||
private readStoredRates(): Record<string, number> {
|
/**
|
||||||
const stored = this.localStorage.getJSON<Record<string, number>>(RATES_KEY);
|
* Forces a fresh quote for the given currency, ignoring any cached value.
|
||||||
return { ...DEFAULT_RATES, ...stored };
|
* 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}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Injectable, signal } from '@angular/core';
|
import { Injectable, signal } from '@angular/core';
|
||||||
import { Router } from '@angular/router';
|
import { Router } from '@angular/router';
|
||||||
import { LocalStorageService } from '../core/storage/local-storage.service';
|
import { LocalStorageService } from '../core/storage/local-storage.service';
|
||||||
|
import { CurrencyRatesService } from './currency-rates.service';
|
||||||
|
|
||||||
export interface Language {
|
export interface Language {
|
||||||
code: string;
|
code: string;
|
||||||
@@ -39,7 +40,11 @@ export class LanguageService {
|
|||||||
currentLanguage = this.currentLanguageSignal.asReadonly();
|
currentLanguage = this.currentLanguageSignal.asReadonly();
|
||||||
currentCurrency = this.currentCurrencySignal.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
|
// Load saved language from localStorage
|
||||||
const savedLang = this.storage.getItem('selectedLanguage');
|
const savedLang = this.storage.getItem('selectedLanguage');
|
||||||
if (savedLang && this.languages.find(l => l.code === savedLang && l.enabled)) {
|
if (savedLang && this.languages.find(l => l.code === savedLang && l.enabled)) {
|
||||||
@@ -65,6 +70,11 @@ export class LanguageService {
|
|||||||
if (currency) {
|
if (currency) {
|
||||||
this.currentCurrencySignal.set(code);
|
this.currentCurrencySignal.set(code);
|
||||||
this.storage.setItem('selectedCurrency', 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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user