Skip to content
Merged
Show file tree
Hide file tree
Changes from 30 commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
b482b57
(SP: 2)[Wallets] add paymentMethod selection with method-aware checko…
liudmylasovetovs Mar 4, 2026
38a317f
(SP: 1)[Wallets] lock Stripe wallets to card rail and add Apple Pay d…
liudmylasovetovs Mar 4, 2026
2df8b3b
(SP: 2)[Wallets] add Monobank wallet payment foundation (adapter + wa…
liudmylasovetovs Mar 4, 2026
8bc3b77
(SP: 2)[Wallets] add Monobank Google Pay config/submit routes with in…
liudmylasovetovs Mar 4, 2026
00a7fc7
(SP: 2)[Wallets] add Monobank Google Pay checkout UI with pending ret…
liudmylasovetovs Mar 4, 2026
2894a33
(SP: 2)[Wallets] propagate Monobank Google Pay wallet attribution and…
liudmylasovetovs Mar 4, 2026
5c56a3a
(SP: 1)[Wallets] add Stripe wallet attribution and harden well-known …
liudmylasovetovs Mar 4, 2026
3981cb9
(SP: 2)[Wallets] remove Stripe webhook fetch and treat Monobank 429 a…
liudmylasovetovs Mar 5, 2026
6693ece
(SP: 2)[Wallets] restore NP warehouses caching + correct CityRef usag…
liudmylasovetovs Mar 6, 2026
7c4c6e0
Merge branch 'develop' of https://github.com/DevLoversTeam/devlovers.…
liudmylasovetovs Mar 6, 2026
f7dc004
(SP: 1)[Wallets] replace settlement_ref FK with city_ref FK in np_war…
liudmylasovetovs Mar 6, 2026
04e7f5f
(SP: 1)[SHOP] fix ESLint issues (imports, types, minor test cleanup)
liudmylasovetovs Mar 6, 2026
472d76f
(SP: 3) [SHOP][Payments] fix review issues, restore checkout compatib…
liudmylasovetovs Mar 6, 2026
0043209
(SP: 3) [SHOP][Wallets][NP] fix review findings for Monobank, Stripe,…
liudmylasovetovs Mar 6, 2026
1b76792
(SP: 1) [SHOP] polish monobank google pay fallback messaging
liudmylasovetovs Mar 6, 2026
3eaeeb4
Merge branch 'develop' of https://github.com/DevLoversTeam/devlovers.…
liudmylasovetovs Mar 6, 2026
4eecc51
Merge branch 'develop' of https://github.com/DevLoversTeam/devlovers.…
liudmylasovetovs Mar 11, 2026
4c7caaa
(SP:1) [SHOP] fail closed explicit Stripe checkout when unavailable
liudmylasovetovs Mar 11, 2026
8c94e93
(SP:1) [SHOP] gate shipping processing and admin actions by paid orde…
liudmylasovetovs Mar 11, 2026
f06723e
(SP:1) [SHOP] close shipping pipeline on terminal payment failures
liudmylasovetovs Mar 11, 2026
f0fa6e3
(SP:1) [SHOP] add shipping payload validation regression tests
liudmylasovetovs Mar 11, 2026
1519390
(SP:3) [SHOP] harden monobank webhook verification policy and summary…
liudmylasovetovs Mar 11, 2026
d676bd7
(SP:1) [SHOP] gate checkout payment pages by payment-init access
liudmylasovetovs Mar 11, 2026
8630673
(SP:2) [SHOP] align checkout token scopes for payment-init flows
liudmylasovetovs Mar 11, 2026
56f878f
(SP:2) [SHOP] align Stripe capability gating and return-status UX
liudmylasovetovs Mar 11, 2026
e22992b
(SP:3) [SHOP] redesign cart page, extract cart UI class tokens and t…
liudmylasovetovs Mar 12, 2026
66fb418
(SP:1) [SHOP] split monobank invoice and google pay checkout flows
liudmylasovetovs Mar 12, 2026
6ab915a
(SP:2) [SHOP] harden monobank wallet reconciliation for unknown submit
liudmylasovetovs Mar 12, 2026
e6418e6
(SP:2) [SHOP] prevent monobank janitor from canceling reconcilable wa…
liudmylasovetovs Mar 12, 2026
13f8ec0
(SP:3) [SHOP] add stable local Playwright checkout smoke coverage
liudmylasovetovs Mar 12, 2026
1173dfd
(SP:2) [SHOP] harden checkout recovery, token flows, and local e2e gu…
liudmylasovetovs Mar 12, 2026
124c76f
(SP:2) [SHOP] harden checkout idempotent recovery and tocken-failure …
liudmylasovetovs Mar 12, 2026
1896831
(SP:1) [SHOP] fix checkout recovery, cart ally and local e2e stability
liudmylasovetovs Mar 12, 2026
8adb595
(SP:1) [SHOP] harden checkout recovery, fail-closed cart flow, and st…
liudmylasovetovs Mar 12, 2026
e602868
(SP:1) [SHOP] preserve recovery token, allow failed stripe retry, and…
liudmylasovetovs Mar 12, 2026
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
1,758 changes: 1,041 additions & 717 deletions frontend/app/[locale]/shop/cart/CartPageClient.tsx

Large diffs are not rendered by default.

35 changes: 35 additions & 0 deletions frontend/app/[locale]/shop/cart/capabilities.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { isMonobankEnabled } from '@/lib/env/monobank';
import { isPaymentsEnabled as isStripePaymentsEnabled } from '@/lib/env/stripe';

function isFlagEnabled(value: string | undefined): boolean {
return (value ?? '').trim() === 'true';
}

export function resolveStripeCheckoutEnabled(): boolean {
try {
return isStripePaymentsEnabled({
requirePublishableKey: true,
respectStripePaymentsFlag: true,
});
} catch {
return false;
}
}

export function resolveMonobankCheckoutEnabled(): boolean {
const paymentsEnabled = isFlagEnabled(process.env.PAYMENTS_ENABLED);
if (!paymentsEnabled) return false;

try {
return isMonobankEnabled();
} catch {
return false;
}
}

export function resolveMonobankGooglePayEnabled(): boolean {
const raw = (process.env.SHOP_MONOBANK_GPAY_ENABLED ?? '')
.trim()
.toLowerCase();
return raw === 'true' || raw === '1' || raw === 'yes' || raw === 'on';
}
36 changes: 5 additions & 31 deletions frontend/app/[locale]/shop/cart/page.tsx
Original file line number Diff line number Diff line change
@@ -1,43 +1,17 @@
import type { Metadata } from 'next';

import { isMonobankEnabled } from '@/lib/env/monobank';

import {
resolveMonobankCheckoutEnabled,
resolveMonobankGooglePayEnabled,
resolveStripeCheckoutEnabled,
} from './capabilities';
import CartPageClient from './CartPageClient';

export const metadata: Metadata = {
title: 'Cart | DevLovers',
description: 'Review items in your cart and proceed to checkout.',
};

function isFlagEnabled(value: string | undefined): boolean {
return (value ?? '').trim() === 'true';
}

function resolveStripeCheckoutEnabled(): boolean {
const paymentsEnabled = isFlagEnabled(process.env.PAYMENTS_ENABLED);
const stripeFlag = (process.env.STRIPE_PAYMENTS_ENABLED ?? '').trim();

return (
paymentsEnabled && (stripeFlag.length > 0 ? stripeFlag === 'true' : true)
);
}

function resolveMonobankCheckoutEnabled(): boolean {
const paymentsEnabled = isFlagEnabled(process.env.PAYMENTS_ENABLED);
if (!paymentsEnabled) return false;

try {
return isMonobankEnabled();
} catch {
return false;
}
}

function resolveMonobankGooglePayEnabled(): boolean {
const raw = (process.env.SHOP_MONOBANK_GPAY_ENABLED ?? '').trim().toLowerCase();
return raw === 'true' || raw === '1' || raw === 'yes' || raw === 'on';
}

export default function CartPage() {
return (
<CartPageClient
Expand Down
12 changes: 11 additions & 1 deletion frontend/app/[locale]/shop/checkout/error/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,11 @@ function parseOrderId(searchParams?: SearchParams): string | null {
return parsed.success ? parsed.data.id : null;
}

function parseStatusToken(searchParams?: SearchParams): string | null {
const raw = getStringParam(searchParams, 'statusToken').trim();
return raw.length > 0 ? raw : null;
}

const SHOP_HERO_CTA_SM = cn(
SHOP_CTA_BASE,
SHOP_CTA_INTERACTIVE,
Expand Down Expand Up @@ -76,6 +81,7 @@ export default async function CheckoutErrorPage({
: (searchParams as SearchParams | undefined);

const orderId = parseOrderId(resolvedSearchParams);
const statusToken = parseStatusToken(resolvedSearchParams);

if (!orderId) {
return (
Expand Down Expand Up @@ -278,7 +284,11 @@ export default async function CheckoutErrorPage({

{isFailed && order.id ? (
<Link
href={`/shop/checkout/payment/${order.id}`}
href={`/shop/checkout/payment/${order.id}${
statusToken
? `?statusToken=${encodeURIComponent(statusToken)}`
: ''
}`}
className={SHOP_HERO_CTA_SM}
>
<span
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,13 +37,15 @@ import { cn } from '@/lib/utils';
type PaymentFormProps = {
orderId: string;
locale: string;
statusToken: string | null;
};

type StripePaymentClientProps = {
clientSecret?: string | null;
publishableKey: string | null;
paymentsEnabled: boolean;
orderId: string;
statusToken: string | null;
amountMinor: number;
currency: string;
locale: string;
Expand Down Expand Up @@ -81,17 +83,23 @@ function buildStripeReturnUrl(params: {
return new URL(`/${loc}${p}`, window.location.origin).toString();
}

function nextRouteForPaymentResult(params: {
export function nextRouteForPaymentResult(params: {
orderId: string;
statusToken: string | null;
status?: string | null;
}) {
const { orderId, status } = params;
const { orderId, status, statusToken } = params;
const id = encodeURIComponent(orderId);
const tokenSuffix = statusToken
? `&statusToken=${encodeURIComponent(statusToken)}`
: '';

const success = buildInAppPath(`/checkout/success?orderId=${id}`);
const success = buildInAppPath(
`/checkout/success?orderId=${id}${tokenSuffix}`
);
const failure = buildInAppPath(`/checkout/error?orderId=${id}`);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
Comment thread
liudmylasovetovs marked this conversation as resolved.
Outdated

if (!status) return success;
if (!status) return failure;

if (
status === 'succeeded' ||
Expand All @@ -105,7 +113,7 @@ function nextRouteForPaymentResult(params: {
return failure;
}

return success;
return failure;
}

const SHOP_HERO_CTA = cn(
Expand Down Expand Up @@ -152,7 +160,7 @@ function HeroCtaInner({ children }: { children: React.ReactNode }) {
);
}

function StripePaymentForm({ orderId, locale }: PaymentFormProps) {
function StripePaymentForm({ orderId, locale, statusToken }: PaymentFormProps) {
const stripe = useStripe();
const elements = useElements();
const router = useRouter();
Expand All @@ -175,8 +183,13 @@ function StripePaymentForm({ orderId, locale }: PaymentFormProps) {

try {
const id = encodeURIComponent(orderId);
const tokenSuffix = statusToken
? `&statusToken=${encodeURIComponent(statusToken)}`
: '';

const inAppSuccess = buildInAppPath(`/checkout/success?orderId=${id}`);
const inAppSuccess = buildInAppPath(
`/checkout/success?orderId=${id}${tokenSuffix}`
);
const inAppFailure = buildInAppPath(`/checkout/error?orderId=${id}`);

const { error, paymentIntent } = await stripe.confirmPayment({
Expand All @@ -195,6 +208,7 @@ function StripePaymentForm({ orderId, locale }: PaymentFormProps) {

const next = nextRouteForPaymentResult({
orderId,
statusToken,
status: paymentIntent?.status ?? null,
});

Expand Down Expand Up @@ -243,6 +257,7 @@ export default function StripePaymentClient({
publishableKey,
paymentsEnabled,
orderId,
statusToken,
amountMinor,
currency,
locale,
Expand Down Expand Up @@ -279,7 +294,7 @@ export default function StripePaymentClient({
>
<Link
href={buildInAppPath(
`/checkout/success?orderId=${encodeURIComponent(orderId)}`
`/checkout/success?orderId=${encodeURIComponent(orderId)}${statusToken ? `&statusToken=${encodeURIComponent(statusToken)}` : ''}`
)}
className={cn(SHOP_HERO_CTA, 'w-full sm:w-auto')}
>
Expand Down Expand Up @@ -340,7 +355,11 @@ export default function StripePaymentClient({
</p>
</div>

<StripePaymentForm orderId={orderId} locale={locale} />
<StripePaymentForm
orderId={orderId}
locale={locale}
statusToken={statusToken}
/>
</div>
</Elements>
</section>
Expand Down
85 changes: 55 additions & 30 deletions frontend/app/[locale]/shop/checkout/payment/[orderId]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,12 @@ import { getTranslations } from 'next-intl/server';

import { ClearCartOnMount } from '@/components/shop/ClearCartOnMount';
import { Link } from '@/i18n/routing';
import { getStripeEnv } from '@/lib/env/stripe';
import {
getStripeEnv,
isPaymentsEnabled as isStripePaymentsEnabled,
} from '@/lib/env/stripe';
import { logError } from '@/lib/logging';
import { OrderNotFoundError } from '@/lib/services/errors';
import { getOrderSummary } from '@/lib/services/orders';
import { getCheckoutPaymentPageOrderSummary } from '@/lib/services/orders';
import { ensureStripePaymentIntentForOrder } from '@/lib/services/orders/payment-attempts';
import { formatMoney } from '@/lib/shop/currency';
import {
Expand Down Expand Up @@ -48,6 +50,15 @@ function resolveClientSecret(
return raw;
}

function parseStatusToken(
searchParams?: Record<string, string | string[] | undefined>
): string | null {
const raw = searchParams?.statusToken;
const value = Array.isArray(raw) ? raw[0] : raw;
const normalized = (value ?? '').trim();
return normalized.length > 0 ? normalized : null;
}

async function buildStatusMessage(status: string) {
const t = await getTranslations('shop.checkout.payment.statusMessages');

Expand All @@ -56,6 +67,10 @@ async function buildStatusMessage(status: string) {
return t('completePayment');
}

function canInitializeStripeForPaymentStatus(status: string): boolean {
return status === 'pending' || status === 'requires_payment';
}

function shouldClearCart(
searchParams?: Record<string, string | string[] | undefined>
): boolean {
Expand Down Expand Up @@ -160,6 +175,10 @@ export default async function PaymentPage(props: PaymentPageProps) {
const cc = clearCart ? '&clearCart=1' : '';
const { locale } = params;
const shopBase = `/shop`;
const statusToken = parseStatusToken(searchParams);
const statusTokenQuery = statusToken
? `&statusToken=${encodeURIComponent(statusToken)}`
: '';

const t = await getTranslations('shop.checkout');

Expand All @@ -184,51 +203,56 @@ export default async function PaymentPage(props: PaymentPageProps) {
);
}

let order: Awaited<ReturnType<typeof getOrderSummary>>;
const orderAccess = await getCheckoutPaymentPageOrderSummary({
orderId,
statusToken,
});

try {
order = await getOrderSummary(orderId);
} catch (error) {
if (error instanceof OrderNotFoundError) {
if (!orderAccess.ok) {
if (orderAccess.code === 'STATUS_TOKEN_MISCONFIGURED') {
return (
<PageShell
title={t('errors.orderNotFound')}
description={t('notFoundOrder.message')}
>
<nav
className="mt-6 flex justify-center gap-3"
aria-label="Next steps"
>
<Link href={`${shopBase}/cart`} className={SHOP_OUTLINE_BTN}>
{t('actions.goToCart')}
</Link>

<HeroCtaLink href={`${shopBase}/products`}>
{t('actions.continueShopping')}
</HeroCtaLink>
</nav>
</PageShell>
title={t('errors.unableToLoad')}
description={t('errors.tryAgainLater')}
/>
);
}

return (
<PageShell
title={t('errors.unableToLoad')}
description={t('errors.tryAgainLater')}
/>
title={t('errors.orderNotFound')}
description={t('notFoundOrder.message')}
>
<nav className="mt-6 flex justify-center gap-3" aria-label="Next steps">
<Link href={`${shopBase}/cart`} className={SHOP_OUTLINE_BTN}>
{t('actions.goToCart')}
</Link>

<HeroCtaLink href={`${shopBase}/products`}>
{t('actions.continueShopping')}
</HeroCtaLink>
</nav>
</PageShell>
);
}
const order = orderAccess.order;

const paymentsEnabled = isStripePaymentsEnabled({
requirePublishableKey: true,
respectStripePaymentsFlag: true,
});
const stripeEnv = getStripeEnv();
const paymentsEnabled =
stripeEnv.paymentsEnabled && Boolean(stripeEnv.publishableKey);

let clientSecret = resolveClientSecret(searchParams);
const publishableKey = paymentsEnabled ? stripeEnv.publishableKey : null;
const shouldInitStripePaymentIntent = canInitializeStripeForPaymentStatus(
order.paymentStatus
);

if (
paymentsEnabled &&
publishableKey &&
shouldInitStripePaymentIntent &&
(!clientSecret || !clientSecret.trim())
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
) {
const existingPi = order.paymentIntentId?.trim() ?? '';
Expand Down Expand Up @@ -265,7 +289,7 @@ export default async function PaymentPage(props: PaymentPageProps) {
aria-label="Next steps"
>
<HeroCtaLink
href={`${shopBase}/checkout/success?orderId=${order.id}${cc}`}
href={`${shopBase}/checkout/success?orderId=${order.id}${statusTokenQuery}${cc}`}
>
{t('payment.viewConfirmation')}
</HeroCtaLink>
Expand Down Expand Up @@ -338,6 +362,7 @@ export default async function PaymentPage(props: PaymentPageProps) {
<StripePaymentClient
clientSecret={clientSecret}
orderId={order.id}
statusToken={statusToken}
amountMinor={order.totalAmountMinor}
currency={order.currency}
publishableKey={publishableKey}
Expand Down
Loading
Loading