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 };
+ });
+};