From b19fd77a60a07b88eb3092563e18b142a06850e4 Mon Sep 17 00:00:00 2001 From: sdarbinyan Date: Mon, 17 Aug 2026 23:34:00 +0400 Subject: [PATCH] feat: Phase 1 pricing core - Money/FxQuote/PriceSnapshot, mock-gateway backed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First frontend build against docs/backend/PHASE-1-MONEY-FX-PAYMENTS-CONTRACT.md: - Money type (amountMinor + currency, no float math) with add/subtract/ multiply helpers respecting per-currency minor-unit decimals. - FxQuote model + FX_QUOTE_GATEWAY token, mirroring the DI-seam pattern already used for the 9 admin domains. FxQuoteLocalGateway derives a quote from the existing CurrencyRatesService so the shape is real even before a backend rate source exists (Sprint 0.1: FX is ours in-house). source: 'local-mock' is explicit and distinct from the eventual real backend's 'internal' - swapping the token when the real endpoint ships requires zero caller changes. - PriceSnapshot/CheckoutLine/CheckoutResult models per contract §4-5. Scope note: this does NOT yet rewire the live cart/checkout payment flow (pages/cart/cart.component.ts) onto this module - that flow handles real money against a live payment provider, and rewiring it deserves its own focused pass with explicit verification, not a bundled mega-change. The core module is ready for that pass. Co-Authored-By: Claude Sonnet 5 --- src/app/core/pricing/models/fx-quote.model.ts | 14 ++++++ src/app/core/pricing/models/money.model.ts | 47 +++++++++++++++++++ .../pricing/models/price-snapshot.model.ts | 30 ++++++++++++ .../services/fx-quote-gateway.interface.ts | 7 +++ .../services/fx-quote-gateway.token.ts | 9 ++++ .../services/fx-quote-local.gateway.ts | 32 +++++++++++++ 6 files changed, 139 insertions(+) create mode 100644 src/app/core/pricing/models/fx-quote.model.ts create mode 100644 src/app/core/pricing/models/money.model.ts create mode 100644 src/app/core/pricing/models/price-snapshot.model.ts create mode 100644 src/app/core/pricing/services/fx-quote-gateway.interface.ts create mode 100644 src/app/core/pricing/services/fx-quote-gateway.token.ts create mode 100644 src/app/core/pricing/services/fx-quote-local.gateway.ts diff --git a/src/app/core/pricing/models/fx-quote.model.ts b/src/app/core/pricing/models/fx-quote.model.ts new file mode 100644 index 0000000..7e3445d --- /dev/null +++ b/src/app/core/pricing/models/fx-quote.model.ts @@ -0,0 +1,14 @@ +/** Per docs/backend/PHASE-1-MONEY-FX-PAYMENTS-CONTRACT.md §3. */ +export interface FxQuote { + quoteId: string; + base: string; + quote: string; + rate: number; + source: string; + observedAt: string; + expiresAt: string; +} + +export function isFxQuoteExpired(fxQuote: FxQuote, now: Date = new Date()): boolean { + return now.getTime() >= new Date(fxQuote.expiresAt).getTime(); +} diff --git a/src/app/core/pricing/models/money.model.ts b/src/app/core/pricing/models/money.model.ts new file mode 100644 index 0000000..a384076 --- /dev/null +++ b/src/app/core/pricing/models/money.model.ts @@ -0,0 +1,47 @@ +/** Minor-unit money, per docs/backend/PHASE-1-MONEY-FX-PAYMENTS-CONTRACT.md §2. No float for money math. */ +export interface Money { + amountMinor: number; + currency: string; +} + +const MINOR_UNIT_DECIMALS: Record = { + RUB: 2, + USD: 2, + EUR: 2, + AMD: 2, +}; + +export function decimalsFor(currency: string): number { + return MINOR_UNIT_DECIMALS[currency] ?? 2; +} + +export function toMajor(money: Money): number { + return money.amountMinor / Math.pow(10, decimalsFor(money.currency)); +} + +export function fromMajor(amount: number, currency: string): Money { + const decimals = decimalsFor(currency); + return { amountMinor: Math.round(amount * Math.pow(10, decimals)), currency }; +} + +export function addMoney(a: Money, b: Money): Money { + if (a.currency !== b.currency) { + throw new Error(`Cannot add Money of different currencies: ${a.currency} vs ${b.currency}`); + } + return { amountMinor: a.amountMinor + b.amountMinor, currency: a.currency }; +} + +export function subtractMoney(a: Money, b: Money): Money { + if (a.currency !== b.currency) { + throw new Error(`Cannot subtract Money of different currencies: ${a.currency} vs ${b.currency}`); + } + return { amountMinor: a.amountMinor - b.amountMinor, currency: a.currency }; +} + +export function multiplyMoney(money: Money, factor: number): Money { + return { amountMinor: Math.round(money.amountMinor * factor), currency: money.currency }; +} + +export function zeroMoney(currency: string): Money { + return { amountMinor: 0, currency }; +} diff --git a/src/app/core/pricing/models/price-snapshot.model.ts b/src/app/core/pricing/models/price-snapshot.model.ts new file mode 100644 index 0000000..8e16a72 --- /dev/null +++ b/src/app/core/pricing/models/price-snapshot.model.ts @@ -0,0 +1,30 @@ +import { Money } from './money.model'; + +/** Per docs/backend/PHASE-1-MONEY-FX-PAYMENTS-CONTRACT.md §4. Immutable once created. */ +export interface PriceSnapshot { + id: string; + offerId: string; + amount: Money; + displayAmount: Money; + fxQuoteId: string | null; + capturedAt: string; +} + +export interface CheckoutLine { + offerId: string; + qty: number; + unitPrice: Money; + lineTotal: Money; + priceSnapshotId: string; +} + +export interface CheckoutResult { + checkoutSessionId: string; + lines: CheckoutLine[]; + subtotal: Money; + discount: Money; + delivery: Money; + total: Money; + fxQuoteId: string | null; + expiresAt: string; +} diff --git a/src/app/core/pricing/services/fx-quote-gateway.interface.ts b/src/app/core/pricing/services/fx-quote-gateway.interface.ts new file mode 100644 index 0000000..e36eda1 --- /dev/null +++ b/src/app/core/pricing/services/fx-quote-gateway.interface.ts @@ -0,0 +1,7 @@ +import { Observable } from 'rxjs'; +import { FxQuote } from '../models/fx-quote.model'; + +/** Per docs/backend/PHASE-1-MONEY-FX-PAYMENTS-CONTRACT.md §3.1. */ +export interface FxQuoteGateway { + getQuote(base: string, quote: string): Observable; +} diff --git a/src/app/core/pricing/services/fx-quote-gateway.token.ts b/src/app/core/pricing/services/fx-quote-gateway.token.ts new file mode 100644 index 0000000..0fc131b --- /dev/null +++ b/src/app/core/pricing/services/fx-quote-gateway.token.ts @@ -0,0 +1,9 @@ +import { InjectionToken, inject } from '@angular/core'; +import { FxQuoteGateway } from './fx-quote-gateway.interface'; +import { FxQuoteLocalGateway } from './fx-quote-local.gateway'; + +/** Swap point for the real FX backend from docs/backend/PHASE-1-MONEY-FX-PAYMENTS-CONTRACT.md §3.1. */ +export const FX_QUOTE_GATEWAY = new InjectionToken('FX_QUOTE_GATEWAY', { + providedIn: 'root', + factory: () => inject(FxQuoteLocalGateway), +}); diff --git a/src/app/core/pricing/services/fx-quote-local.gateway.ts b/src/app/core/pricing/services/fx-quote-local.gateway.ts new file mode 100644 index 0000000..8d9eeb3 --- /dev/null +++ b/src/app/core/pricing/services/fx-quote-local.gateway.ts @@ -0,0 +1,32 @@ +import { Injectable, inject } from '@angular/core'; +import { Observable, of } from 'rxjs'; +import { FxQuote } from '../models/fx-quote.model'; +import { FxQuoteGateway } from './fx-quote-gateway.interface'; +import { CurrencyRatesService } from '../../../services/currency-rates.service'; + +const QUOTE_TTL_MS = 5 * 60 * 1000; + +/** + * Mock FX source until a real backend rate service exists (Sprint 0.1: + * FX is computed in-house, "internal" is the normal source value, not just + * a fallback). Derives a quote from CurrencyRatesService's existing + * admin-editable rates so the shape is real even though the source isn't. + */ +@Injectable({ providedIn: 'root' }) +export class FxQuoteLocalGateway implements FxQuoteGateway { + private readonly currencyRates = inject(CurrencyRatesService); + + getQuote(base: string, quote: string): Observable { + const rate = this.currencyRates.convert(1, base, quote); + const now = new Date(); + return of({ + quoteId: `fxq_local_${base}_${quote}_${now.getTime()}`, + base, + quote, + rate, + source: 'local-mock', + observedAt: now.toISOString(), + expiresAt: new Date(now.getTime() + QUOTE_TTL_MS).toISOString(), + }); + } +}