From e59ad6af005f1aa907c7625691c703c927871bca Mon Sep 17 00:00:00 2001 From: k4rli Date: Thu, 24 Jul 2025 08:02:13 +0300 Subject: [PATCH] feat(MED-100): update montonio redirect --- app/api/montonio/verify-token/route.ts | 6 - .../montonio-callback/[montonioId]/page.tsx | 23 ---- .../montonio-callback/[montonioId]/route.ts | 113 ++++++++++++++++++ .../cart/montonio-callback/error/page.tsx | 47 ++++++++ app/home/(user)/_components/cart/index.tsx | 22 ++-- .../cart/montonio-checkout-callback.tsx | 101 ---------------- app/home/(user)/_components/cart/types.ts | 22 ++++ lib/services/medusaCart.service.ts | 44 ++++--- .../montonio-order-handler.service.ts | 11 +- .../medusa-storefront/src/lib/data/cart.ts | 17 ++- .../src/lib/data/products.ts | 21 ++++ 11 files changed, 261 insertions(+), 166 deletions(-) delete mode 100644 app/home/(user)/(dashboard)/cart/montonio-callback/[montonioId]/page.tsx create mode 100644 app/home/(user)/(dashboard)/cart/montonio-callback/[montonioId]/route.ts create mode 100644 app/home/(user)/(dashboard)/cart/montonio-callback/error/page.tsx delete mode 100644 app/home/(user)/_components/cart/montonio-checkout-callback.tsx create mode 100644 app/home/(user)/_components/cart/types.ts diff --git a/app/api/montonio/verify-token/route.ts b/app/api/montonio/verify-token/route.ts index 36f305f..6898ac6 100644 --- a/app/api/montonio/verify-token/route.ts +++ b/app/api/montonio/verify-token/route.ts @@ -58,12 +58,6 @@ export const POST = enhanceRouteHandler( algorithms: ['HS256'], }) as MontonioOrderToken; - const activeCartId = request.cookies.get('_medusa_cart_id')?.value; - const [, cartId] = decoded.merchantReferenceDisplay.split(':'); - if (cartId !== activeCartId) { - throw new Error('Invalid cart id'); - } - logger.info( { name: namespace, diff --git a/app/home/(user)/(dashboard)/cart/montonio-callback/[montonioId]/page.tsx b/app/home/(user)/(dashboard)/cart/montonio-callback/[montonioId]/page.tsx deleted file mode 100644 index 1c42dcb..0000000 --- a/app/home/(user)/(dashboard)/cart/montonio-callback/[montonioId]/page.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import { PageBody, PageHeader } from '@/packages/ui/src/makerkit/page'; -import { MontonioCheckoutCallback } from '../../../../_components/cart/montonio-checkout-callback'; -import { createI18nServerInstance } from '@/lib/i18n/i18n.server'; -import { Trans } from '@kit/ui/trans'; - -export async function generateMetadata() { - const { t } = await createI18nServerInstance(); - - return { - title: t('cart:montonioCallback.title'), - }; -} - -export default async function MontonioCheckoutCallbackPage() { - return ( -
- } /> - - - -
- ); -} diff --git a/app/home/(user)/(dashboard)/cart/montonio-callback/[montonioId]/route.ts b/app/home/(user)/(dashboard)/cart/montonio-callback/[montonioId]/route.ts new file mode 100644 index 0000000..a295b65 --- /dev/null +++ b/app/home/(user)/(dashboard)/cart/montonio-callback/[montonioId]/route.ts @@ -0,0 +1,113 @@ +import { MontonioOrderToken } from "@/app/home/(user)/_components/cart/types"; +import { loadCurrentUserAccount } from "@/app/home/(user)/_lib/server/load-user-account"; +import { placeOrder } from "@lib/data/cart"; +import jwt from 'jsonwebtoken'; +import { z } from "zod"; +import { createI18nServerInstance } from "~/lib/i18n/i18n.server"; + +const emailSender = process.env.EMAIL_SENDER; +const siteUrl = process.env.NEXT_PUBLIC_SITE_URL!; + +const env = z + .object({ + emailSender: z + .string({ + required_error: 'EMAIL_SENDER is required', + }) + .min(1), + siteUrl: z + .string({ + required_error: 'NEXT_PUBLIC_SITE_URL is required', + }) + .min(1), + }) + .parse({ + emailSender, + siteUrl, + }); + +const sendEmail = async ({ email, analysisPackageName, personName, partnerLocationName, language }: { email: string, analysisPackageName: string, personName: string, partnerLocationName: string, language: string }) => { + try { + const { renderSynlabAnalysisPackageEmail } = await import('@kit/email-templates'); + const { getMailer } = await import('@kit/mailers'); + + const mailer = await getMailer(); + + const { html, subject } = await renderSynlabAnalysisPackageEmail({ + analysisPackageName, + personName, + partnerLocationName, + language, + }); + + await mailer + .sendEmail({ + from: env.emailSender, + to: email, + subject, + html, + }) + .catch((error) => { + throw new Error(`Failed to send email, message=${error}`); + }); + } catch (error) { + throw new Error(`Failed to send email, message=${error}`); + } +} + +const handleOrderToken = async (orderToken: string) => { + const secretKey = process.env.MONTONIO_SECRET_KEY as string; + + const decoded = jwt.verify(orderToken, secretKey, { + algorithms: ['HS256'], + }) as MontonioOrderToken; + if (decoded.paymentStatus !== 'PAID') { + return null; + } + + try { + const [, , cartId] = decoded.merchantReferenceDisplay.split(':'); + if (!cartId) { + throw new Error("Cart ID not found"); + } + const { order } = await placeOrder(cartId, { revalidateCacheTags: true }); + return { + email: order.email, + partnerLocationName: order.metadata?.partner_location_name as string ?? '', + analysisPackageName: order.items?.[0]?.title ?? '', + }; + } catch (error) { + throw new Error(`Failed to place order, message=${error}`); + } +} + +export async function GET(request: Request) { + const { language } = await createI18nServerInstance(); + const baseUrl = new URL(env.siteUrl.replace("localhost", "webhook.site")); + try { + const orderToken = new URL(request.url).searchParams.get('order-token'); + if (!orderToken) { + throw new Error("Order token is missing"); + } + + const account = await loadCurrentUserAccount(); + if (!account) { + throw new Error("Account not found in context"); + } + + const orderResult = await handleOrderToken(orderToken); + if (!orderResult) { + throw new Error("Order result is missing"); + } + + const { email, partnerLocationName, analysisPackageName } = orderResult; + const personName = account.name; + if (email) { + await sendEmail({ email, analysisPackageName, personName, partnerLocationName, language }); + } + return Response.redirect(new URL('/home/order', baseUrl)) + } catch (error) { + console.error("Failed to place order", error); + return Response.redirect(new URL('/home/cart/montonio-callback/error', baseUrl)); + } +} diff --git a/app/home/(user)/(dashboard)/cart/montonio-callback/error/page.tsx b/app/home/(user)/(dashboard)/cart/montonio-callback/error/page.tsx new file mode 100644 index 0000000..cdb64ff --- /dev/null +++ b/app/home/(user)/(dashboard)/cart/montonio-callback/error/page.tsx @@ -0,0 +1,47 @@ +import Link from 'next/link'; + +import { PageBody, PageHeader } from '@/packages/ui/src/makerkit/page'; +import { createI18nServerInstance } from '@/lib/i18n/i18n.server'; +import { Trans } from '@kit/ui/trans'; +import { Alert, AlertDescription } from '@kit/ui/shadcn/alert'; +import { AlertTitle } from '@kit/ui/shadcn/alert'; +import { Button } from '@kit/ui/button'; + +export async function generateMetadata() { + const { t } = await createI18nServerInstance(); + + return { + title: t('cart:montonioCallback.title'), + }; +} + +export default async function MontonioCheckoutCallbackErrorPage() { + return ( +
+ } /> + +
+ + + + + + +

+ +

+
+
+ +
+ +
+
+
+
+ ); +} diff --git a/app/home/(user)/_components/cart/index.tsx b/app/home/(user)/_components/cart/index.tsx index 90f7c7d..f6a4a95 100644 --- a/app/home/(user)/_components/cart/index.tsx +++ b/app/home/(user)/_components/cart/index.tsx @@ -1,5 +1,7 @@ "use client"; +import { useState } from "react"; +import { Loader2 } from "lucide-react"; import { StoreCart, StoreCartLineItem } from "@medusajs/types" import CartItems from "./cart-items" import { Trans } from '@kit/ui/trans'; @@ -10,7 +12,6 @@ import { CardHeader, } from '@kit/ui/card'; import DiscountCode from "./discount-code"; -import { useRouter } from "next/navigation"; import { initiatePaymentSession } from "@lib/data/cart"; import { formatCurrency } from "@/packages/shared/src/utils"; import { useTranslation } from "react-i18next"; @@ -27,9 +28,10 @@ export default function Cart({ analysisPackages: StoreCartLineItem[]; otherItems: StoreCartLineItem[]; }) { - const router = useRouter(); const { i18n: { language } } = useTranslation(); + const [isInitiatingSession, setIsInitiatingSession] = useState(false); + const items = cart?.items ?? []; if (!cart || items.length === 0) { @@ -49,13 +51,18 @@ export default function Cart({ ); } - async function handlePayment() { + async function initiatePayment() { + setIsInitiatingSession(true); const response = await initiatePaymentSession(cart!, { - provider_id: 'pp_system_default', + provider_id: 'pp_montonio_montonio', }); if (response.payment_collection) { - const url = await handleNavigateToPayment({ language }); - router.push(url); + const { payment_sessions } = response.payment_collection; + const paymentSessionId = payment_sessions![0]!.id; + const url = await handleNavigateToPayment({ language, paymentSessionId }); + window.location.href = url; + } else { + setIsInitiatingSession(false); } } @@ -102,7 +109,8 @@ export default function Cart({
-
diff --git a/app/home/(user)/_components/cart/montonio-checkout-callback.tsx b/app/home/(user)/_components/cart/montonio-checkout-callback.tsx deleted file mode 100644 index d6214c7..0000000 --- a/app/home/(user)/_components/cart/montonio-checkout-callback.tsx +++ /dev/null @@ -1,101 +0,0 @@ -'use client'; - -import { useRouter, useSearchParams } from 'next/navigation'; -import { useEffect, useState } from 'react'; - -import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert'; -import { Button } from '@kit/ui/button'; -import { Trans } from '@kit/ui/trans'; -import { placeOrder } from "@lib/data/cart" -import Link from 'next/link'; -import GlobalLoader from '../../loading'; - -enum Status { - LOADING = 'LOADING', - ERROR = 'ERROR', -} - -export function MontonioCheckoutCallback() { - const router = useRouter(); - const [status, setStatus] = useState(Status.LOADING); - const [isFinalized, setIsFinalized] = useState(false); - const searchParams = useSearchParams(); - - useEffect(() => { - if (isFinalized) { - return; - } - - const token = searchParams.get('order-token'); - if (!token) { - router.push('/home/cart'); - return; - } - - async function verifyToken() { - setStatus(Status.LOADING); - - try { - const response = await fetch('/api/montonio/verify-token', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ token }), - }); - setIsFinalized(true); - - if (!response.ok) { - const body = await response.json(); - throw new Error(body.error ?? 'Failed to verify payment status.'); - } - - const body = await response.json(); - const paymentStatus = body.status as string; - if (paymentStatus === 'PAID') { - try { - await placeOrder(); - } catch (e) { - console.error("Error placing order", e); - router.push('/home/cart'); - } - } else { - throw new Error('Payment failed or pending'); - } - } catch (e) { - console.error("Error verifying token", e); - setStatus(Status.ERROR); - } - } - - void verifyToken(); - }, [searchParams, isFinalized]); - - if (status === Status.ERROR) { - return ( -
- - - - - - -

- -

-
-
- -
- -
-
- ); - } - - return ; -} diff --git a/app/home/(user)/_components/cart/types.ts b/app/home/(user)/_components/cart/types.ts new file mode 100644 index 0000000..22385ce --- /dev/null +++ b/app/home/(user)/_components/cart/types.ts @@ -0,0 +1,22 @@ +export interface MontonioOrderToken { + uuid: string; + accessKey: string; + merchantReference: string; + merchantReferenceDisplay: string; + paymentStatus: + | 'PAID' + | 'FAILED' + | 'CANCELLED' + | 'PENDING' + | 'EXPIRED' + | 'REFUNDED'; + paymentMethod: string; + grandTotal: number; + currency: string; + senderIban?: string; + senderName?: string; + paymentProviderName?: string; + paymentLinkUuid: string; + iat: number; + exp: number; +} \ No newline at end of file diff --git a/lib/services/medusaCart.service.ts b/lib/services/medusaCart.service.ts index c3a59a6..821076f 100644 --- a/lib/services/medusaCart.service.ts +++ b/lib/services/medusaCart.service.ts @@ -1,13 +1,34 @@ 'use server'; +import { z } from 'zod'; import { loadCurrentUserAccount } from '@/app/home/(user)/_lib/server/load-user-account'; import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { addToCart, deleteLineItem, retrieveCart } from '@lib/data/cart'; import { StoreCartLineItem, StoreProductVariant } from '@medusajs/types'; import { MontonioOrderHandlerService } from '@/packages/billing/montonio/src'; -import { headers } from 'next/headers'; import { requireUserInServerComponent } from '../server/require-user-in-server-component'; +const medusaBackendUrl = process.env.MEDUSA_BACKEND_URL!; +const siteUrl = process.env.NEXT_PUBLIC_SITE_URL!; + +const env = z + .object({ + medusaBackendUrl: z + .string({ + required_error: 'MEDUSA_BACKEND_URL is required', + }) + .min(1), + siteUrl: z + .string({ + required_error: 'NEXT_PUBLIC_SITE_URL is required', + }) + .min(1), + }) + .parse({ + medusaBackendUrl, + siteUrl, + }); + export async function handleAddToCart({ selectedVariant, countryCode, @@ -46,7 +67,7 @@ export async function handleAddToCart({ return cart; } -export async function handleNavigateToPayment({ language }: { language: string }) { +export async function handleNavigateToPayment({ language, paymentSessionId }: { language: string, paymentSessionId: string }) { const supabase = getSupabaseServerClient(); const user = await requireUserInServerComponent(); const account = await loadCurrentUserAccount() @@ -59,21 +80,14 @@ export async function handleNavigateToPayment({ language }: { language: string } throw new Error("No cart found"); } - const headersList = await headers(); - const host = "webhook.site:3000"; - const proto = "http"; - // const host = headersList.get('host'); - // const proto = headersList.get('x-forwarded-proto') ?? 'http'; - const publicUrl = `${proto}://${host}`; - const paymentLink = await new MontonioOrderHandlerService().getMontonioPaymentLink({ - notificationUrl: `${publicUrl}/api/billing/webhook`, - returnUrl: `${publicUrl}/home/cart/montonio-callback`, + notificationUrl: `${env.medusaBackendUrl}/api/billing/webhook`, + returnUrl: `${env.siteUrl}/home/cart/montonio-callback`, amount: cart.total, currency: cart.currency_code.toUpperCase(), description: `Order from Medreport`, locale: language, - merchantReference: `${account.id}:${cart.id}:${Date.now()}`, + merchantReference: `${account.id}:${paymentSessionId}:${cart.id}`, }); const { error } = await supabase @@ -104,12 +118,6 @@ export async function handleLineItemTimeout({ throw new Error('Account not found'); } - if (lineItem.updated_at) { - const updatedAt = new Date(lineItem.updated_at); - const now = new Date(); - const diff = now.getTime() - updatedAt.getTime(); - } - await deleteLineItem(lineItem.id); const { error } = await supabase diff --git a/packages/billing/montonio/src/services/montonio-order-handler.service.ts b/packages/billing/montonio/src/services/montonio-order-handler.service.ts index 03aff3d..1a613f6 100644 --- a/packages/billing/montonio/src/services/montonio-order-handler.service.ts +++ b/packages/billing/montonio/src/services/montonio-order-handler.service.ts @@ -30,7 +30,7 @@ export class MontonioOrderHandlerService { locale: string; merchantReference: string; }) { - const token = jwt.sign({ + const params = { accessKey, description, currency, @@ -38,16 +38,17 @@ export class MontonioOrderHandlerService { locale, // 15 minutes expiresAt: new Date(Date.now() + 15 * 60 * 1000).toISOString(), - notificationUrl, - returnUrl, + notificationUrl: notificationUrl.replace("localhost", "webhook.site"), + returnUrl: returnUrl.replace("localhost", "webhook.site"), askAdditionalInfo: false, merchantReference, type: "one_time", - }, secretKey, { + }; + const token = jwt.sign(params, secretKey, { algorithm: "HS256", expiresIn: "10m", }); - + try { const { data } = await axios.post(`${apiUrl}/api/payment-links`, { data: token }); return data.url; diff --git a/packages/features/medusa-storefront/src/lib/data/cart.ts b/packages/features/medusa-storefront/src/lib/data/cart.ts index d739abf..c981ebd 100644 --- a/packages/features/medusa-storefront/src/lib/data/cart.ts +++ b/packages/features/medusa-storefront/src/lib/data/cart.ts @@ -392,7 +392,7 @@ export async function setAddresses(currentState: unknown, formData: FormData) { * @param cartId - optional - The ID of the cart to place an order for. * @returns The cart object if the order was successful, or null if not. */ -export async function placeOrder(cartId?: string) { +export async function placeOrder(cartId?: string, options: { revalidateCacheTags: boolean } = { revalidateCacheTags: true }) { const id = cartId || (await getCartId()); if (!id) { @@ -406,21 +406,26 @@ export async function placeOrder(cartId?: string) { const cartRes = await sdk.store.cart .complete(id, {}, headers) .then(async (cartRes) => { - const cartCacheTag = await getCacheTag("carts"); - revalidateTag(cartCacheTag); + if (options?.revalidateCacheTags) { + const cartCacheTag = await getCacheTag("carts"); + revalidateTag(cartCacheTag); + } return cartRes; }) .catch(medusaError); if (cartRes?.type === "order") { - const orderCacheTag = await getCacheTag("orders"); - revalidateTag(orderCacheTag); + if (options?.revalidateCacheTags) { + const orderCacheTag = await getCacheTag("orders"); + revalidateTag(orderCacheTag); + } removeCartId(); - redirect(`/home/order/${cartRes?.order.id}/confirmed`); } else { throw new Error("Cart is not an order"); } + + return cartRes; } /** diff --git a/packages/features/medusa-storefront/src/lib/data/products.ts b/packages/features/medusa-storefront/src/lib/data/products.ts index 2ef33ab..d671d2c 100644 --- a/packages/features/medusa-storefront/src/lib/data/products.ts +++ b/packages/features/medusa-storefront/src/lib/data/products.ts @@ -134,3 +134,24 @@ export const listProductsWithSort = async ({ queryParams, } } + +export const listProductTypes = async (): Promise<{ productTypes: HttpTypes.StoreProductType[]; count: number }> => { + const next = { + ...(await getCacheOptions("productTypes")), + }; + + return sdk.client + .fetch<{ product_types: HttpTypes.StoreProductType[]; count: number }>( + "/store/product-types", + { + next, + cache: "force-cache", + query: { + fields: "id,value,metadata", + }, + } + ) + .then(({ product_types, count }) => { + return { productTypes: product_types, count }; + }); +};