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:
14
src/app/core/pricing/models/fx-quote.model.ts
Normal file
14
src/app/core/pricing/models/fx-quote.model.ts
Normal 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();
|
||||||
|
}
|
||||||
47
src/app/core/pricing/models/money.model.ts
Normal file
47
src/app/core/pricing/models/money.model.ts
Normal 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 };
|
||||||
|
}
|
||||||
30
src/app/core/pricing/models/price-snapshot.model.ts
Normal file
30
src/app/core/pricing/models/price-snapshot.model.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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>;
|
||||||
|
}
|
||||||
9
src/app/core/pricing/services/fx-quote-gateway.token.ts
Normal file
9
src/app/core/pricing/services/fx-quote-gateway.token.ts
Normal 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),
|
||||||
|
});
|
||||||
32
src/app/core/pricing/services/fx-quote-local.gateway.ts
Normal file
32
src/app/core/pricing/services/fx-quote-local.gateway.ts
Normal 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(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user