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(), + }); + } +}