feat: server-authoritative checkout, no client-computed amount
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
F14-F16 of the frontend backlog. Contract: PHASE-1-MONEY-FX-PAYMENTS-CONTRACT.md §5.2. The highest-priority change in Phase 1: `POST /cart` sent `amount` computed client-side (this.convertTotal(this.totalWithDelivery())) and the backend was asked to trust it. Replaced with two calls: 1. POST /api/v2/storefront/checkout - offer ids + qty only. Returns checkoutSessionId and the server-computed total. 2. POST /api/v2/storefront/payments/intents - references checkoutSessionId only. Same response shape as before (qrId/qrUrl/bankUrl/qrTTL via the existing resolvePaymentQrId/resolvePaymentLink/resolveBankPaymentUrl helpers) - this replaces how the charged amount is determined, not the QR/card provider polling flow, which Phase 1 does not redesign. merchantReference (PARTNER-PROVISIONING-API-CONTRACT.md's RoutingContext field) is sent on the payment intent, generated the same way the old orderId was - our own correlation id, now with a name that matches what it is. api.service.ts: CheckoutSessionRequest/Response and PaymentIntentRequest types added, old CartPaymentRequest/createCartPayment left in place (Phase 7 reconciliation and any other caller may still reference the shape) but no longer called from checkout. offerId uses item.itemID: this codebase has no distinct Offer entity yet (Phase 3, Product/Offer split, not shipped in this model) - itemID is the same catalog identifier every other endpoint already keys off. Flagged in a code comment for whoever ships Phase 3 to revisit. Dead code removed as a consequence, not a separate pass: buildPaymentItems, getPaymentUserId, getPaymentDescription (no other caller once the old payload was gone), the ConfigService/TenantResolverService injects that existed only for getPaymentDescription, and the now-orphaned cart.paymentDescriptionFallback i18n key in all three locales. Verification: cart.component.ts has no unit spec (no src/app/pages/cart/ *.spec.ts exists) - this session's E2E suite is the only coverage the checkout request shape has. Added checkout-request-shape.spec.ts, scoped narrowly to the request/response contract rather than a full add-to-cart UI journey: seeds cart state directly into localStorage, fakes the customer session via cookie + intercepted session-check, intercepts both new endpoints and asserts on the captured request bodies. Confirms concretely: no `amount` or `price` field ever leaves the client, offers carry the right offerId/qty, and the payment intent correctly threads checkoutSessionId through. Verified: 5/5 E2E green, 115/115 unit tests green, arch:check clean, production build succeeds. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -124,7 +124,6 @@ export const en: Translations = {
|
||||
emailNeedsDomain: 'Email must contain a domain (.com, .ru, etc.)',
|
||||
emailInvalid: 'Invalid email format',
|
||||
telegramIdMissing: 'We could not identify your Telegram account, so we could not save your contact details. Your payment was still successful.',
|
||||
paymentDescriptionFallback: 'Purchase on Marketplace',
|
||||
emailPlaceholder: 'you@example.com',
|
||||
phonePlaceholder: '+7 (___) ___-__-__',
|
||||
loginRequired: 'Log in to checkout',
|
||||
|
||||
@@ -124,7 +124,6 @@ export const hy: Translations = {
|
||||
emailNeedsDomain: 'Email-ը պետք է պարունակի դոմեյն (.com, .ru և այլն)',
|
||||
emailInvalid: 'Սխալ email ձևաչափ',
|
||||
telegramIdMissing: 'Չհաջողվեց հաստատել ձեր Telegram հաշիվը, ուստի կոնտակտային տվյալները չեն պահպանվել։ Վճարումը հաջողությամբ կատարվել է։',
|
||||
paymentDescriptionFallback: 'Գնում Մարկետփլեյսում',
|
||||
emailPlaceholder: 'you@example.com',
|
||||
phonePlaceholder: '+7 (___) ___-__-__',
|
||||
loginRequired: 'Մուտք գործեք ձևակերպելու համար',
|
||||
|
||||
@@ -124,7 +124,6 @@ export const ru: Translations = {
|
||||
emailNeedsDomain: 'Email должен содержать домен (.com, .ru и т.д.)',
|
||||
emailInvalid: 'Некорректный формат email',
|
||||
telegramIdMissing: 'Не удалось определить ваш Telegram-аккаунт, поэтому контактные данные не сохранены. Оплата прошла успешно.',
|
||||
paymentDescriptionFallback: 'Покупка на Маркетплейсе',
|
||||
emailPlaceholder: 'you@example.com',
|
||||
phonePlaceholder: '+7 (___) ___-__-__',
|
||||
loginRequired: 'Войдите для оформления',
|
||||
|
||||
@@ -122,7 +122,6 @@ export interface Translations {
|
||||
emailNeedsDomain: string;
|
||||
emailInvalid: string;
|
||||
telegramIdMissing: string;
|
||||
paymentDescriptionFallback: string;
|
||||
emailPlaceholder: string;
|
||||
phonePlaceholder: string;
|
||||
loginRequired: string;
|
||||
|
||||
@@ -18,9 +18,7 @@ import { PAYMENT_POLL_INTERVAL_MS, PAYMENT_MIN_POLL_SECONDS, PAYMENT_TIMEOUT_CLO
|
||||
import { IconComponent } from '../../shared/ui/icon/icon.component';
|
||||
import { EmptyStateComponent } from '../../shared/ui/empty-state/empty-state.component';
|
||||
import { ButtonComponent } from '../../shared/ui/button/button.component';
|
||||
import { ConfigService } from '../../core/config/config.service';
|
||||
import { AnalyticsService } from '../../core/analytics/services/analytics.service';
|
||||
import { TenantResolverService } from '../../core/config/tenant-resolver.service';
|
||||
import { UserNotificationService } from '../../features/website/user-experience/services/user-notification.service';
|
||||
import { ConfirmDialogComponent } from '../../shared/ui/confirm-dialog/confirm-dialog.component';
|
||||
import { DialogComponent } from '../../shared/ui/dialog/dialog.component';
|
||||
@@ -82,8 +80,6 @@ export class CartComponent implements OnDestroy {
|
||||
private pollingSubscription?: Subscription;
|
||||
private closeTimeout?: ReturnType<typeof setTimeout>;
|
||||
|
||||
private configService = inject(ConfigService);
|
||||
private tenantResolver = inject(TenantResolverService);
|
||||
private currencyRates = inject(CurrencyRatesService);
|
||||
private readonly analytics = inject(AnalyticsService);
|
||||
|
||||
@@ -271,51 +267,72 @@ export class CartComponent implements OnDestroy {
|
||||
this.createPayment(this.selectedPaymentMethod());
|
||||
}
|
||||
|
||||
/**
|
||||
* Server-authoritative checkout (PHASE-1-MONEY-FX-PAYMENTS-CONTRACT.md
|
||||
* §5.2): the client sends offer ids and quantities, never a computed
|
||||
* amount. Two calls, not one - createCartPayment's single-request shape
|
||||
* doesn't exist in the new contract because the backend must price the
|
||||
* session before a payment intent can reference it.
|
||||
*
|
||||
* offerId uses item.itemID: this codebase has no distinct Offer entity yet
|
||||
* (Phase 3, Product/Offer split, not shipped in this model) - itemID is
|
||||
* the same catalog identifier every other endpoint already keys off.
|
||||
* Revisit once Offer exists as its own id.
|
||||
*/
|
||||
createPayment(paymentMethod: PaymentMethod): void {
|
||||
const orderId = this.generateOrderId();
|
||||
const paymentPayload = {
|
||||
amount: Number(this.convertTotal(this.totalWithDelivery())),
|
||||
const merchantReference = this.generateOrderId();
|
||||
const checkoutPayload = {
|
||||
offers: this.items().map(item => ({ offerId: String(item.itemID), qty: item.quantity })),
|
||||
currency: this.langService.currentCurrency(),
|
||||
siteuserID: this.getPaymentUserId(),
|
||||
siteorderID: orderId,
|
||||
redirectUrl: '',
|
||||
telegramUsername: this.getTelegramUsername(),
|
||||
paymentMethod,
|
||||
qrDescription: this.getPaymentDescription(),
|
||||
customerID: this.getTelegramUserId() ?? undefined,
|
||||
items: this.buildPaymentItems(),
|
||||
};
|
||||
|
||||
this.apiService.createCartPayment(paymentPayload)
|
||||
.subscribe({
|
||||
next: (response) => {
|
||||
const qrId = this.apiService.resolvePaymentQrId(response);
|
||||
const qrUrl = this.apiService.resolvePaymentQrUrl(response);
|
||||
const paymentLink = this.apiService.resolvePaymentLink(response);
|
||||
const bankUrl = this.apiService.resolveBankPaymentUrl(response);
|
||||
this.apiService.createCheckoutSession(checkoutPayload).subscribe({
|
||||
next: session => this.createPaymentIntent(session, paymentMethod, merchantReference),
|
||||
error: err => {
|
||||
console.error('Error creating checkout session:', err);
|
||||
this.setPaymentError();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (!qrId || (paymentMethod === 'qr' && !qrUrl) || (paymentMethod === 'card' && !bankUrl)) {
|
||||
console.error('Payment response missing payment fields:', response);
|
||||
this.setPaymentError();
|
||||
return;
|
||||
}
|
||||
private createPaymentIntent(
|
||||
session: import('../../services/api.service').CheckoutSessionResponse,
|
||||
paymentMethod: PaymentMethod,
|
||||
merchantReference: string,
|
||||
): void {
|
||||
this.apiService.createPaymentIntent({
|
||||
checkoutSessionId: session.checkoutSessionId,
|
||||
paymentMethod,
|
||||
merchantReference,
|
||||
}).subscribe({
|
||||
next: (response) => {
|
||||
const qrId = this.apiService.resolvePaymentQrId(response);
|
||||
const qrUrl = this.apiService.resolvePaymentQrUrl(response);
|
||||
const paymentLink = this.apiService.resolvePaymentLink(response);
|
||||
const bankUrl = this.apiService.resolveBankPaymentUrl(response);
|
||||
|
||||
this.paymentId.set(qrId);
|
||||
this.qrCodeUrl.set(qrUrl);
|
||||
this.paymentUrl.set(paymentLink);
|
||||
this.bankPaymentUrl.set(bankUrl);
|
||||
if (!qrId || (paymentMethod === 'qr' && !qrUrl) || (paymentMethod === 'card' && !bankUrl)) {
|
||||
console.error('Payment intent response missing payment fields:', response);
|
||||
this.setPaymentError();
|
||||
return;
|
||||
}
|
||||
|
||||
this.paymentStatus.set('waiting');
|
||||
this.startPolling(response.qrTTL);
|
||||
if (paymentMethod === 'card') {
|
||||
this.openBankPaymentPopup();
|
||||
}
|
||||
},
|
||||
error: (err) => {
|
||||
console.error('Error creating payment:', err);
|
||||
this.setPaymentError();
|
||||
}
|
||||
});
|
||||
this.paymentId.set(qrId);
|
||||
this.qrCodeUrl.set(qrUrl);
|
||||
this.paymentUrl.set(paymentLink);
|
||||
this.bankPaymentUrl.set(bankUrl);
|
||||
|
||||
this.paymentStatus.set('waiting');
|
||||
this.startPolling(response.qrTTL);
|
||||
if (paymentMethod === 'card') {
|
||||
this.openBankPaymentPopup();
|
||||
}
|
||||
},
|
||||
error: (err) => {
|
||||
console.error('Error creating payment intent:', err);
|
||||
this.setPaymentError();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
startPolling(qrTTL?: number): void {
|
||||
@@ -618,49 +635,12 @@ export class CartComponent implements OnDestroy {
|
||||
return 'nontelegram';
|
||||
}
|
||||
|
||||
private getPaymentUserId(): string {
|
||||
return this.getTelegramUserId() ?? `web_${Date.now()}`;
|
||||
}
|
||||
|
||||
private getPaymentDescription(): string {
|
||||
const brandName = this.configService.getBootstrapSnapshot()?.branding?.brandName?.trim();
|
||||
if (brandName) {
|
||||
return brandName;
|
||||
}
|
||||
|
||||
const hostname = this.tenantResolver.getHostname();
|
||||
if (hostname && !this.tenantResolver.isLocalhost()) {
|
||||
return hostname;
|
||||
}
|
||||
|
||||
return this.i18n.t('cart.paymentDescriptionFallback');
|
||||
}
|
||||
|
||||
private generateOrderId(): string {
|
||||
const timestamp = Date.now();
|
||||
const random = Math.random().toString(36).substring(2, 8);
|
||||
return `order_${timestamp}_${random}`;
|
||||
}
|
||||
|
||||
private buildPaymentItems(): Array<{ itemID: number; price: number; name: string; quantity: number; delivery?: DeliveryOption[] }> {
|
||||
return this.items().map((item: CartItem) => {
|
||||
const unitPrice = item.discount > 0
|
||||
? item.price * (1 - item.discount / 100)
|
||||
: item.price;
|
||||
const details = [item.colour, item.size].filter(Boolean).join(', ');
|
||||
const translatedName = this.itemName(item).trim() || `Item ${item.itemID}`;
|
||||
const name = details ? `${item.quantity} x ${translatedName} (${details})` : `${item.quantity} x ${translatedName}`;
|
||||
|
||||
return {
|
||||
itemID: item.itemID,
|
||||
price: unitPrice * item.quantity,
|
||||
name,
|
||||
quantity: item.quantity,
|
||||
...(item.selectedDelivery ? { delivery: [item.selectedDelivery] } : {}),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
onPhoneInput(event: Event): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
let value = input.value.replace(/\D/g, ''); // Remove all non-digits
|
||||
|
||||
@@ -53,6 +53,53 @@ export interface CartPaymentRequest {
|
||||
items: Array<{ itemID: number; price: number; name: string; quantity?: number; delivery?: DeliveryOption[] }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Server-authoritative checkout. Contract: PHASE-1-MONEY-FX-PAYMENTS-CONTRACT.md §5.2.
|
||||
* No `amount` or `price` field anywhere in this pair - the backend prices
|
||||
* every offer itself from its own catalog and the current FX quote.
|
||||
*/
|
||||
export interface CheckoutSessionRequest {
|
||||
offers: Array<{ offerId: string; qty: number }>;
|
||||
currency: string;
|
||||
deliveryOptionId?: string;
|
||||
}
|
||||
|
||||
interface MoneyAmount {
|
||||
amountMinor: number;
|
||||
currency: string;
|
||||
}
|
||||
|
||||
export interface CheckoutSessionLine {
|
||||
offerId: string;
|
||||
qty: number;
|
||||
unitPrice: MoneyAmount;
|
||||
lineTotal: MoneyAmount;
|
||||
priceSnapshotId: string;
|
||||
}
|
||||
|
||||
export interface CheckoutSessionResponse {
|
||||
checkoutSessionId: string;
|
||||
lines: CheckoutSessionLine[];
|
||||
subtotal: MoneyAmount;
|
||||
discount: MoneyAmount;
|
||||
delivery: MoneyAmount;
|
||||
total: MoneyAmount;
|
||||
fxQuoteId: string;
|
||||
expiresAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* References checkoutSessionId only - the amount charged is read
|
||||
* server-side from the session, never re-sent by the client (contract §5.2).
|
||||
* merchantReference is PARTNER-PROVISIONING-API-CONTRACT.md's RoutingContext
|
||||
* field: our own correlation id, echoed back on every related event.
|
||||
*/
|
||||
export interface PaymentIntentRequest {
|
||||
checkoutSessionId: string;
|
||||
paymentMethod: 'qr' | 'card';
|
||||
merchantReference: string;
|
||||
}
|
||||
|
||||
export interface CreateOrderRequest {
|
||||
/**
|
||||
* No `price` field: the backend must price each line item from its own
|
||||
@@ -637,6 +684,26 @@ export class ApiService {
|
||||
return this.http.post<QrCreateResponse>(`${this.baseUrl}/cart`, payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a server-priced checkout session. Contract §5.2 - the frontend
|
||||
* sends offer ids and quantities only; the response carries the total that
|
||||
* actually gets charged, computed server-side from the live offer price
|
||||
* and current FX quote.
|
||||
*/
|
||||
createCheckoutSession(payload: CheckoutSessionRequest): Observable<CheckoutSessionResponse> {
|
||||
return this.http.post<CheckoutSessionResponse>('/api/v2/storefront/checkout', payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a payment intent against an existing checkout session. Same
|
||||
* response shape as createCartPayment (QrCreateResponse) - this replaces
|
||||
* how the amount is determined, not the QR/card provider integration
|
||||
* itself, which Phase 1 does not redesign.
|
||||
*/
|
||||
createPaymentIntent(payload: PaymentIntentRequest): Observable<QrCreateResponse> {
|
||||
return this.http.post<QrCreateResponse>('/api/v2/storefront/payments/intents', payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* Records the just-paid cart as a backoffice order (POST /orders). Fire-and-forget
|
||||
* from the caller's perspective - a failure here must never block the existing
|
||||
|
||||
Reference in New Issue
Block a user