Compare commits
2 Commits
1bdca917b3
...
7fe5ac7cd4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7fe5ac7cd4 | ||
|
|
fc53a3b7f5 |
186
e2e/checkout-request-shape.spec.ts
Normal file
186
e2e/checkout-request-shape.spec.ts
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
import { Page, Route, expect, test } from '@playwright/test';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Guards the specific contract this rewrite exists to enforce
|
||||||
|
* (PHASE-1-MONEY-FX-PAYMENTS-CONTRACT.md §5.2): the amount actually charged
|
||||||
|
* must be computed server-side, never sent by the client. Before this
|
||||||
|
* rewrite, POST /cart carried a client-computed `amount` the backend was
|
||||||
|
* asked to trust.
|
||||||
|
*
|
||||||
|
* cart.component.ts has no unit spec (no src/app/pages/cart/*.spec.ts
|
||||||
|
* exists), so this E2E test is the only coverage the checkout request shape
|
||||||
|
* has. Scoped narrowly on purpose: cart state is seeded directly into
|
||||||
|
* localStorage and the customer session is faked via cookie + intercepted
|
||||||
|
* session-check, rather than driving a full add-to-cart-then-login UI
|
||||||
|
* journey - that journey is real product surface worth its own test, but
|
||||||
|
* would make this test about navigation, not about what it exists to prove.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const FAKE_SESSION_ID = 'e2e-fake-session';
|
||||||
|
const FAKE_ITEM = {
|
||||||
|
categoryID: 1,
|
||||||
|
itemID: 4242,
|
||||||
|
name: 'E2E Test Item',
|
||||||
|
photos: null,
|
||||||
|
description: '',
|
||||||
|
currency: 'RUB',
|
||||||
|
price: 1000,
|
||||||
|
discount: 0,
|
||||||
|
rating: 0,
|
||||||
|
callbacks: null,
|
||||||
|
questions: null,
|
||||||
|
quantity: 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
test.describe('checkout request shape', () => {
|
||||||
|
test.beforeEach(async ({ page, context }) => {
|
||||||
|
await seedCart(page);
|
||||||
|
await fakeCustomerSession(page, context);
|
||||||
|
await mockFxQuoteEndpoint(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('checkout session request carries offers and qty, never amount or price', async ({ page }) => {
|
||||||
|
const checkoutRequest = interceptCheckoutSession(page);
|
||||||
|
|
||||||
|
await page.goto('/cart');
|
||||||
|
await acceptTermsAndCheckout(page);
|
||||||
|
|
||||||
|
const body = await checkoutRequest;
|
||||||
|
|
||||||
|
expect(body, 'must never send a client-computed amount').not.toHaveProperty('amount');
|
||||||
|
expect(body, 'must never send a client-computed price').not.toHaveProperty('price');
|
||||||
|
expect(Array.isArray(body.offers), 'must send an offers array').toBe(true);
|
||||||
|
expect(body.offers[0]).toMatchObject({ offerId: String(FAKE_ITEM.itemID), qty: FAKE_ITEM.quantity });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('payment intent request references the checkout session id, not a raw amount', async ({ page }) => {
|
||||||
|
interceptCheckoutSession(page); // must resolve for the intent call to fire at all
|
||||||
|
const intentRequest = interceptPaymentIntent(page);
|
||||||
|
|
||||||
|
await page.goto('/cart');
|
||||||
|
await acceptTermsAndCheckout(page);
|
||||||
|
|
||||||
|
const body = await intentRequest;
|
||||||
|
|
||||||
|
expect(body.checkoutSessionId, 'must reference the session created in step 1').toBe('chk_e2e_fixture');
|
||||||
|
expect(body).not.toHaveProperty('amount');
|
||||||
|
expect(typeof body.merchantReference).toBe('string');
|
||||||
|
expect(body.merchantReference.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
async function seedCart(page: Page): Promise<void> {
|
||||||
|
await page.addInitScript(item => {
|
||||||
|
window.localStorage.setItem('marketplace_cart', JSON.stringify([item]));
|
||||||
|
}, FAKE_ITEM);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fakeCustomerSession(page: Page, context: import('@playwright/test').BrowserContext): Promise<void> {
|
||||||
|
await context.addCookies([
|
||||||
|
{
|
||||||
|
name: 'webSessionID',
|
||||||
|
value: FAKE_SESSION_ID,
|
||||||
|
domain: 'localhost',
|
||||||
|
path: '/',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Matches TelegramSessionApiService.normalizeWebSession's expected shape.
|
||||||
|
await page.route('**/users/sessions/**', route => {
|
||||||
|
route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({
|
||||||
|
sessionId: FAKE_SESSION_ID,
|
||||||
|
status: 'active',
|
||||||
|
username: 'e2e_user',
|
||||||
|
userId: 1,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mockFxQuoteEndpoint(page: Page): Promise<void> {
|
||||||
|
await page.route('**/api/v2/pricing/fx-quote**', route => {
|
||||||
|
route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({
|
||||||
|
quoteId: 'fxq_e2e',
|
||||||
|
base: 'RUB',
|
||||||
|
quote: 'RUB',
|
||||||
|
rate: 1,
|
||||||
|
source: 'e2e-fixture',
|
||||||
|
observedAt: new Date().toISOString(),
|
||||||
|
expiresAt: new Date(Date.now() + 300_000).toISOString(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function interceptCheckoutSession(page: Page): Promise<Record<string, unknown>> {
|
||||||
|
return new Promise(resolve => {
|
||||||
|
page.route('**/api/v2/storefront/checkout', (route: Route) => {
|
||||||
|
const body = route.request().postDataJSON();
|
||||||
|
resolve(body);
|
||||||
|
route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({
|
||||||
|
checkoutSessionId: 'chk_e2e_fixture',
|
||||||
|
lines: [{
|
||||||
|
offerId: String(FAKE_ITEM.itemID),
|
||||||
|
qty: FAKE_ITEM.quantity,
|
||||||
|
unitPrice: { amountMinor: FAKE_ITEM.price * 100, currency: 'RUB' },
|
||||||
|
lineTotal: { amountMinor: FAKE_ITEM.price * FAKE_ITEM.quantity * 100, currency: 'RUB' },
|
||||||
|
priceSnapshotId: 'snap_e2e',
|
||||||
|
}],
|
||||||
|
subtotal: { amountMinor: FAKE_ITEM.price * FAKE_ITEM.quantity * 100, currency: 'RUB' },
|
||||||
|
discount: { amountMinor: 0, currency: 'RUB' },
|
||||||
|
delivery: { amountMinor: 0, currency: 'RUB' },
|
||||||
|
total: { amountMinor: FAKE_ITEM.price * FAKE_ITEM.quantity * 100, currency: 'RUB' },
|
||||||
|
fxQuoteId: 'fxq_e2e',
|
||||||
|
expiresAt: new Date(Date.now() + 300_000).toISOString(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function interceptPaymentIntent(page: Page): Promise<Record<string, unknown>> {
|
||||||
|
return new Promise(resolve => {
|
||||||
|
page.route('**/api/v2/storefront/payments/intents', (route: Route) => {
|
||||||
|
const body = route.request().postDataJSON();
|
||||||
|
resolve(body);
|
||||||
|
route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({
|
||||||
|
qrId: 'qr_e2e_fixture',
|
||||||
|
nspkurl: 'https://example.com/pay/e2e',
|
||||||
|
qrTTL: 5,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function acceptTermsAndCheckout(page: Page): Promise<void> {
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
|
||||||
|
// #terms-checkbox is a custom-styled input (zero-size native element, a
|
||||||
|
// <label> renders the visible box) - .check() refuses on geometry even
|
||||||
|
// with force:true, so toggle it via its label the way a real user would.
|
||||||
|
const termsCheckbox = page.locator('#terms-checkbox');
|
||||||
|
if (await termsCheckbox.count() > 0) {
|
||||||
|
const label = page.locator('label[for="terms-checkbox"]');
|
||||||
|
if (await label.count() > 0) {
|
||||||
|
await label.click();
|
||||||
|
} else {
|
||||||
|
await termsCheckbox.dispatchEvent('click');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const qrButton = page.getByRole('button', { name: /qr/i }).first();
|
||||||
|
await qrButton.click();
|
||||||
|
}
|
||||||
@@ -124,7 +124,6 @@ export const en: Translations = {
|
|||||||
emailNeedsDomain: 'Email must contain a domain (.com, .ru, etc.)',
|
emailNeedsDomain: 'Email must contain a domain (.com, .ru, etc.)',
|
||||||
emailInvalid: 'Invalid email format',
|
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.',
|
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',
|
emailPlaceholder: 'you@example.com',
|
||||||
phonePlaceholder: '+7 (___) ___-__-__',
|
phonePlaceholder: '+7 (___) ___-__-__',
|
||||||
loginRequired: 'Log in to checkout',
|
loginRequired: 'Log in to checkout',
|
||||||
|
|||||||
@@ -124,7 +124,6 @@ export const hy: Translations = {
|
|||||||
emailNeedsDomain: 'Email-ը պետք է պարունակի դոմեյն (.com, .ru և այլն)',
|
emailNeedsDomain: 'Email-ը պետք է պարունակի դոմեյն (.com, .ru և այլն)',
|
||||||
emailInvalid: 'Սխալ email ձևաչափ',
|
emailInvalid: 'Սխալ email ձևաչափ',
|
||||||
telegramIdMissing: 'Չհաջողվեց հաստատել ձեր Telegram հաշիվը, ուստի կոնտակտային տվյալները չեն պահպանվել։ Վճարումը հաջողությամբ կատարվել է։',
|
telegramIdMissing: 'Չհաջողվեց հաստատել ձեր Telegram հաշիվը, ուստի կոնտակտային տվյալները չեն պահպանվել։ Վճարումը հաջողությամբ կատարվել է։',
|
||||||
paymentDescriptionFallback: 'Գնում Մարկետփլեյսում',
|
|
||||||
emailPlaceholder: 'you@example.com',
|
emailPlaceholder: 'you@example.com',
|
||||||
phonePlaceholder: '+7 (___) ___-__-__',
|
phonePlaceholder: '+7 (___) ___-__-__',
|
||||||
loginRequired: 'Մուտք գործեք ձևակերպելու համար',
|
loginRequired: 'Մուտք գործեք ձևակերպելու համար',
|
||||||
|
|||||||
@@ -124,7 +124,6 @@ export const ru: Translations = {
|
|||||||
emailNeedsDomain: 'Email должен содержать домен (.com, .ru и т.д.)',
|
emailNeedsDomain: 'Email должен содержать домен (.com, .ru и т.д.)',
|
||||||
emailInvalid: 'Некорректный формат email',
|
emailInvalid: 'Некорректный формат email',
|
||||||
telegramIdMissing: 'Не удалось определить ваш Telegram-аккаунт, поэтому контактные данные не сохранены. Оплата прошла успешно.',
|
telegramIdMissing: 'Не удалось определить ваш Telegram-аккаунт, поэтому контактные данные не сохранены. Оплата прошла успешно.',
|
||||||
paymentDescriptionFallback: 'Покупка на Маркетплейсе',
|
|
||||||
emailPlaceholder: 'you@example.com',
|
emailPlaceholder: 'you@example.com',
|
||||||
phonePlaceholder: '+7 (___) ___-__-__',
|
phonePlaceholder: '+7 (___) ___-__-__',
|
||||||
loginRequired: 'Войдите для оформления',
|
loginRequired: 'Войдите для оформления',
|
||||||
|
|||||||
@@ -122,7 +122,6 @@ export interface Translations {
|
|||||||
emailNeedsDomain: string;
|
emailNeedsDomain: string;
|
||||||
emailInvalid: string;
|
emailInvalid: string;
|
||||||
telegramIdMissing: string;
|
telegramIdMissing: string;
|
||||||
paymentDescriptionFallback: string;
|
|
||||||
emailPlaceholder: string;
|
emailPlaceholder: string;
|
||||||
phonePlaceholder: string;
|
phonePlaceholder: string;
|
||||||
loginRequired: 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 { IconComponent } from '../../shared/ui/icon/icon.component';
|
||||||
import { EmptyStateComponent } from '../../shared/ui/empty-state/empty-state.component';
|
import { EmptyStateComponent } from '../../shared/ui/empty-state/empty-state.component';
|
||||||
import { ButtonComponent } from '../../shared/ui/button/button.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 { 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 { UserNotificationService } from '../../features/website/user-experience/services/user-notification.service';
|
||||||
import { ConfirmDialogComponent } from '../../shared/ui/confirm-dialog/confirm-dialog.component';
|
import { ConfirmDialogComponent } from '../../shared/ui/confirm-dialog/confirm-dialog.component';
|
||||||
import { DialogComponent } from '../../shared/ui/dialog/dialog.component';
|
import { DialogComponent } from '../../shared/ui/dialog/dialog.component';
|
||||||
@@ -82,8 +80,6 @@ export class CartComponent implements OnDestroy {
|
|||||||
private pollingSubscription?: Subscription;
|
private pollingSubscription?: Subscription;
|
||||||
private closeTimeout?: ReturnType<typeof setTimeout>;
|
private closeTimeout?: ReturnType<typeof setTimeout>;
|
||||||
|
|
||||||
private configService = inject(ConfigService);
|
|
||||||
private tenantResolver = inject(TenantResolverService);
|
|
||||||
private currencyRates = inject(CurrencyRatesService);
|
private currencyRates = inject(CurrencyRatesService);
|
||||||
private readonly analytics = inject(AnalyticsService);
|
private readonly analytics = inject(AnalyticsService);
|
||||||
|
|
||||||
@@ -271,51 +267,72 @@ export class CartComponent implements OnDestroy {
|
|||||||
this.createPayment(this.selectedPaymentMethod());
|
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 {
|
createPayment(paymentMethod: PaymentMethod): void {
|
||||||
const orderId = this.generateOrderId();
|
const merchantReference = this.generateOrderId();
|
||||||
const paymentPayload = {
|
const checkoutPayload = {
|
||||||
amount: Number(this.convertTotal(this.totalWithDelivery())),
|
offers: this.items().map(item => ({ offerId: String(item.itemID), qty: item.quantity })),
|
||||||
currency: this.langService.currentCurrency(),
|
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)
|
this.apiService.createCheckoutSession(checkoutPayload).subscribe({
|
||||||
.subscribe({
|
next: session => this.createPaymentIntent(session, paymentMethod, merchantReference),
|
||||||
next: (response) => {
|
error: err => {
|
||||||
const qrId = this.apiService.resolvePaymentQrId(response);
|
console.error('Error creating checkout session:', err);
|
||||||
const qrUrl = this.apiService.resolvePaymentQrUrl(response);
|
this.setPaymentError();
|
||||||
const paymentLink = this.apiService.resolvePaymentLink(response);
|
},
|
||||||
const bankUrl = this.apiService.resolveBankPaymentUrl(response);
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (!qrId || (paymentMethod === 'qr' && !qrUrl) || (paymentMethod === 'card' && !bankUrl)) {
|
private createPaymentIntent(
|
||||||
console.error('Payment response missing payment fields:', response);
|
session: import('../../services/api.service').CheckoutSessionResponse,
|
||||||
this.setPaymentError();
|
paymentMethod: PaymentMethod,
|
||||||
return;
|
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);
|
if (!qrId || (paymentMethod === 'qr' && !qrUrl) || (paymentMethod === 'card' && !bankUrl)) {
|
||||||
this.qrCodeUrl.set(qrUrl);
|
console.error('Payment intent response missing payment fields:', response);
|
||||||
this.paymentUrl.set(paymentLink);
|
this.setPaymentError();
|
||||||
this.bankPaymentUrl.set(bankUrl);
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
this.paymentStatus.set('waiting');
|
this.paymentId.set(qrId);
|
||||||
this.startPolling(response.qrTTL);
|
this.qrCodeUrl.set(qrUrl);
|
||||||
if (paymentMethod === 'card') {
|
this.paymentUrl.set(paymentLink);
|
||||||
this.openBankPaymentPopup();
|
this.bankPaymentUrl.set(bankUrl);
|
||||||
}
|
|
||||||
},
|
this.paymentStatus.set('waiting');
|
||||||
error: (err) => {
|
this.startPolling(response.qrTTL);
|
||||||
console.error('Error creating payment:', err);
|
if (paymentMethod === 'card') {
|
||||||
this.setPaymentError();
|
this.openBankPaymentPopup();
|
||||||
}
|
}
|
||||||
});
|
},
|
||||||
|
error: (err) => {
|
||||||
|
console.error('Error creating payment intent:', err);
|
||||||
|
this.setPaymentError();
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
startPolling(qrTTL?: number): void {
|
startPolling(qrTTL?: number): void {
|
||||||
@@ -618,49 +635,12 @@ export class CartComponent implements OnDestroy {
|
|||||||
return 'nontelegram';
|
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 {
|
private generateOrderId(): string {
|
||||||
const timestamp = Date.now();
|
const timestamp = Date.now();
|
||||||
const random = Math.random().toString(36).substring(2, 8);
|
const random = Math.random().toString(36).substring(2, 8);
|
||||||
return `order_${timestamp}_${random}`;
|
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 {
|
onPhoneInput(event: Event): void {
|
||||||
const input = event.target as HTMLInputElement;
|
const input = event.target as HTMLInputElement;
|
||||||
let value = input.value.replace(/\D/g, ''); // Remove all non-digits
|
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[] }>;
|
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 {
|
export interface CreateOrderRequest {
|
||||||
/**
|
/**
|
||||||
* No `price` field: the backend must price each line item from its own
|
* 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);
|
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
|
* 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
|
* from the caller's perspective - a failure here must never block the existing
|
||||||
|
|||||||
Reference in New Issue
Block a user