feat: Phase 1 pricing core - Money/FxQuote/PriceSnapshot, mock-gateway backed

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 <noreply@anthropic.com>
This commit is contained in:
sdarbinyan
2026-08-17 23:34:00 +04:00
parent 2e09369345
commit b19fd77a60
6 changed files with 139 additions and 0 deletions

View File

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

View File

@@ -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<string, number> = {
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 };
}

View File

@@ -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;
}

View File

@@ -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<FxQuote>;
}

View File

@@ -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<FxQuoteGateway>('FX_QUOTE_GATEWAY', {
providedIn: 'root',
factory: () => inject(FxQuoteLocalGateway),
});

View File

@@ -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<FxQuote> {
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(),
});
}
}