Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion dist/checkout-f6479839.css

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion dist/vendor-async-b61cddce.css
Original file line number Diff line number Diff line change
Expand Up @@ -977,7 +977,7 @@
}
}
}
.iti__country-container:not(:has(+ input[disabled])):not(:has(+ input[readonly])) {
.iti__country-container:not(:has(+ input[disabled]), :has(+ input[readonly])) {
.iti__selected-country-primary:hover,
.iti__selected-country:has(+ .iti__dropdown-content:hover) .iti__selected-country-primary {
background-color: var(--iti-hover-color);
Expand Down
71 changes: 71 additions & 0 deletions packages/core/src/app/payment/OrderPlacementTiming.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { type CheckoutSettings } from '@bigcommerce/checkout-sdk';

import { isExperimentEnabled } from '../common/utility';

// STRIPE-1525 order-placement-timing POC flags. Mirrored here to avoid string drift.
export const ORDER_PLACEMENT_START_CLIENT_EVENT = 'PROJECT-8686.order_placement_start_client_event';
export const ORDER_PLACEMENT_START_SERVER_EVENT =
'PROJECT-8686.order_placement_start_server_event';

interface ReportOrderPlacementStartOptions {
checkoutSettings?: CheckoutSettings;
checkoutId?: string;
provider: string;
methodId: string;
}

const readCookie = (name: string): string => {
const match = document.cookie.match(new RegExp(`(?:^|; )${name}=([^;]*)`));

return match ? decodeURIComponent(match[1]) : '';
};

// Order-placement-start signal(s). Guarded so it can never throw into the caller.
// The server beacon is AWAITED (blocking) so its session write commits — and the session lock is
// released — before the order-submission request runs. Otherwise the two requests race and the
// later one writes back a session snapshot that clobbers the placement-start key.
export default async function reportOrderPlacementStart({
checkoutSettings,
checkoutId,
provider,
methodId,
}: ReportOrderPlacementStartOptions): Promise<void> {
try {
if (isExperimentEnabled(checkoutSettings, ORDER_PLACEMENT_START_CLIENT_EVENT, false)) {
// Float seconds (ms resolution) so the server can compute tenths. Do not floor.
const value = `${provider}:${methodId}:${Date.now() / 1000}`;

document.cookie = `place_order=${encodeURIComponent(value)}; path=/; SameSite=Lax; Secure`;
}

if (
checkoutId &&
isExperimentEnabled(checkoutSettings, ORDER_PLACEMENT_START_SERVER_EVENT, false)
) {
// Bound the wait so a slow/hung beacon can never freeze order placement.
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 3000);

try {
await fetch(`/api/storefront/checkout/${checkoutId}/event`, {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
'X-XSRF-TOKEN': readCookie('XSRF-TOKEN'),
},
body: JSON.stringify({
event: 'placement_started',
payment_provider_id: provider,
payment_method_id: methodId,
}),
signal: controller.signal,
});
} finally {
clearTimeout(timeout);
}
}
} catch {
// Never block or affect order submission.
}
}
29 changes: 29 additions & 0 deletions packages/core/src/app/payment/Payment.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
type CartStockPositionsChangedError,
type CheckoutSelectors,
type CheckoutService,
type CheckoutSettings,
type Consignment,
type FormField,
type OrderFinalizeOptions,
Expand Down Expand Up @@ -62,6 +63,7 @@ import { buildB2BMetadataOptions, clearB2BMetadataStorage } from './b2bMetadata'
import CartStockPositionsChangedModal from './CartStockPositionsChangedModal';
import mapSubmitOrderErrorMessage, { mapSubmitOrderErrorTitle } from './mapSubmitOrderErrorMessage';
import mapToOrderRequestBody from './mapToOrderRequestBody';
import reportOrderPlacementStart from './OrderPlacementTiming';
import PaymentContext from './PaymentContext';
import PaymentForm from './PaymentForm';
import { getUniquePaymentMethodId, PaymentMethodProviderType } from './paymentMethod';
Expand All @@ -87,6 +89,8 @@ interface WithCheckoutPaymentProps {
availableStoreCredit: number;
b2bToken?: string;
cart?: Cart;
checkoutId?: string;
checkoutSettings?: CheckoutSettings;
consignments?: Consignment[];
cartUrl: string;
defaultMethod?: PaymentMethod;
Expand Down Expand Up @@ -408,6 +412,8 @@ const Payment = (
const handleSubmit = useCallback(
async (values: PaymentFormValues) => {
const {
checkoutId,
checkoutSettings,
defaultMethod,
loadPaymentMethods,
isPaymentDataRequired,
Expand All @@ -423,6 +429,27 @@ const Payment = (

analyticsTracker.clickPayButton({ shouldCreateAccount: values.shouldCreateAccount });

if (selectedMethod) {
// STRIPE-1525: prefer the shopper-selected Stripe sub-method (e.g.
// `card`, `us_bank_account`) over the generic method id (e.g.
// `optimized_checkout`) when available. `selectedSubMethodId` is a
// READ-ONLY channel set by the Stripe OCS/CS widget and is NOT part of
// the submitted order body. Falls back to the generic id when absent.
const reportedMethodId =
(typeof values.selectedSubMethodId === 'string' &&
values.selectedSubMethodId) ||
selectedMethod.id;

// Awaited so the server beacon's session write commits before submitOrder runs
// (avoids the concurrent session-write race). Self-guarded; never throws.
await reportOrderPlacementStart({
checkoutSettings,
checkoutId,
provider: selectedMethod.gateway ?? selectedMethod.id,
methodId: reportedMethodId,
});
}

const customSubmit =
selectedMethod &&
submitFunctions[
Expand Down Expand Up @@ -803,6 +830,8 @@ export function mapToPaymentProps(
addressExtraFields,
b2bToken: checkoutState.data.getB2BToken(),
cart: getCart(),
checkoutId: checkout.id,
checkoutSettings,
consignments,
cartUrl: config.links.cartLink,
clearError: checkoutService.clearError,
Expand Down
5 changes: 4 additions & 1 deletion packages/core/src/app/payment/mapToOrderRequestBody.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,10 @@ export default function mapToOrderRequestBody(
return {};
}

const { paymentProviderRadio, methodIdOverride, ...rest } = values;
// `selectedSubMethodId` is a READ-ONLY channel used only for the STRIPE-1525
// timing signal (consumed by `Payment.tsx handleSubmit`). It must never be
// included in the submitted order body, so it is destructured out here.
const { paymentProviderRadio, methodIdOverride, selectedSubMethodId, ...rest } = values;
const { methodId: baseMethodId, gatewayId } = parseUniquePaymentMethodId(paymentProviderRadio);
const methodId =
typeof methodIdOverride === 'string' ? methodIdOverride || baseMethodId : baseMethodId;
Expand Down
5 changes: 5 additions & 0 deletions packages/payment-integration-api/src/PaymentFormValues.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
export default interface PaymentFormValues {
[key: string]: unknown;
paymentProviderRadio: string; // TODO: Give this property a better name. We need to keep it for now because of legacy reasons.
// READ-ONLY channel for the STRIPE-1525 timing signal. Set by the Stripe
// OCS/CS widget's `paymentMethodSelect` callback and consumed by
// `Payment.tsx handleSubmit`. It is intentionally NOT mapped into the
// submitted order body (see `mapToOrderRequestBody`).
selectedSubMethodId?: string;
shouldSaveInstrument?: boolean;
terms?: boolean;
}
Original file line number Diff line number Diff line change
Expand Up @@ -78,8 +78,10 @@ const StripeOCSPaymentMethod: FunctionComponent<PaymentMethodProps> = ({
}

setSelectedPaymentMethodId(selectedItemId);
// STRIPE-1525: clear the lifted sub-method when this Stripe item is deselected.
paymentForm.setFieldValue('selectedSubMethodId', undefined);
collapseStripeElement.current?.();
}, [selectedItemId, methodSelector]);
}, [selectedItemId, methodSelector, paymentForm]);

useEffect(() => {
if (selectedPaymentMethodId !== methodSelector) {
Expand All @@ -101,6 +103,30 @@ const StripeOCSPaymentMethod: FunctionComponent<PaymentMethodProps> = ({
setSubmit,
setValidationSchema,
} = paymentForm;

// STRIPE-1525: lift the shopper-selected Stripe sub-method (e.g. `card`,
// `us_bank_account`) into a READ-ONLY Formik field so `Payment.tsx
// handleSubmit` can use it for the timing signal. This is NOT submitted with
// the order (see `mapToOrderRequestBody`, which destructures it out).
// NOTE: `selectedSubMethod` is the NEW optional 2nd arg of the SDK's
// `paymentMethodSelect` callback. The installed SDK type does not declare it
// yet, so it is typed inline and will be `undefined` at runtime until the SDK
// is republished — falling back to the generic method id in that case.
const handlePaymentMethodSelect = useCallback(
(methodId: string, selectedSubMethod?: string) => {
setSelectedPaymentMethodId(methodId);
setFieldValue('selectedSubMethodId', selectedSubMethod);
},
[setFieldValue],
);

useEffect(() => {
return () => {
setFieldValue('selectedSubMethodId', undefined);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);

const instruments = checkoutState.data.getInstruments(method) || [];
const {
data: { getCheckout, isPaymentDataRequired },
Expand Down Expand Up @@ -135,7 +161,9 @@ const StripeOCSPaymentMethod: FunctionComponent<PaymentMethodProps> = ({
fonts: getFonts(),
onError: onUnhandledError,
render: renderSubmitButton,
paymentMethodSelect: setSelectedPaymentMethodId,
// STRIPE-1525: typed inline because the installed SDK does not yet
// declare the optional 2nd `selectedSubMethod` arg (ships separately).
paymentMethodSelect: handlePaymentMethodSelect,
handleClosePaymentMethod: (collapseElement: () => void) => {
collapseStripeElement.current = collapseElement;
},
Expand All @@ -153,7 +181,7 @@ const StripeOCSPaymentMethod: FunctionComponent<PaymentMethodProps> = ({
checkoutService,
onUnhandledError,
renderSubmitButton,
setSelectedPaymentMethodId,
handlePaymentMethodSelect,
setIsOCSLoading,
],
);
Expand Down