feat: server-authoritative checkout, no client-computed amount
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:
sdarbinyan
2026-08-18 14:14:19 +04:00
parent 14467cc6fb
commit fc53a3b7f5
7 changed files with 312 additions and 83 deletions

View File

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