diff --git a/app/home/(user)/(dashboard)/cart/montonio-callback/actions.ts b/app/home/(user)/(dashboard)/cart/montonio-callback/actions.ts index fd3c894..a23660c 100644 --- a/app/home/(user)/(dashboard)/cart/montonio-callback/actions.ts +++ b/app/home/(user)/(dashboard)/cart/montonio-callback/actions.ts @@ -2,100 +2,13 @@ import { MontonioOrderToken } from '@/app/home/(user)/_components/cart/types'; import { loadCurrentUserAccount } from '@/app/home/(user)/_lib/server/load-user-account'; -import { placeOrder, retrieveCart } from '@lib/data/cart'; -import { listProductTypes } from '@lib/data/products'; -import type { StoreOrder } from '@medusajs/types'; +import { retrieveCart } from '@lib/data/cart'; import jwt from 'jsonwebtoken'; -import { z } from 'zod'; -import type { AccountWithParams } from '@kit/accounts/types/accounts'; -import { createNotificationsApi } from '@kit/notifications/api'; -import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client'; +import { handlePlaceOrder } from '../../../_lib/server/cart-actions'; -import { createI18nServerInstance } from '~/lib/i18n/i18n.server'; -import { sendOrderToMedipost } from '~/lib/services/medipost/medipostPrivateMessage.service'; -import { getOrderedAnalysisIds } from '~/lib/services/medusaOrder.service'; -import { - createAnalysisOrder, - getAnalysisOrder, -} from '~/lib/services/order.service'; - -const ANALYSIS_PACKAGES_TYPE_HANDLE = 'analysis-packages'; -const ANALYSIS_TYPE_HANDLE = 'synlab-analysis'; const MONTONIO_PAID_STATUS = 'PAID'; -const env = () => - z - .object({ - emailSender: z - .string({ - error: 'EMAIL_SENDER is required', - }) - .min(1), - siteUrl: z - .string({ - error: 'NEXT_PUBLIC_SITE_URL is required', - }) - .min(1), - isEnabledDispatchOnMontonioCallback: z.boolean({ - error: 'MEDIPOST_ENABLE_DISPATCH_ON_MONTONIO_CALLBACK is required', - }), - }) - .parse({ - emailSender: process.env.EMAIL_SENDER, - siteUrl: process.env.NEXT_PUBLIC_SITE_URL!, - isEnabledDispatchOnMontonioCallback: - process.env.MEDIPOST_ENABLE_DISPATCH_ON_MONTONIO_CALLBACK === 'true', - }); - -const sendEmail = async ({ - account, - email, - analysisPackageName, - partnerLocationName, - language, -}: { - account: Pick; - email: string; - analysisPackageName: string; - partnerLocationName: string; - language: string; -}) => { - const client = getSupabaseServerAdminClient(); - 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: account.name, - partnerLocationName, - language, - }); - - await mailer - .sendEmail({ - from: env().emailSender, - to: email, - subject, - html, - }) - .catch((error) => { - throw new Error(`Failed to send email, message=${error}`); - }); - await createNotificationsApi(client).createNotification({ - account_id: account.id, - body: html, - }); - } catch (error) { - throw new Error(`Failed to send email, message=${error}`); - } -}; - async function decodeOrderToken(orderToken: string) { const secretKey = process.env.MONTONIO_SECRET_KEY as string; @@ -122,74 +35,6 @@ async function getCartByOrderToken(decoded: MontonioOrderToken) { return cart; } -async function getOrderResultParameters(medusaOrder: StoreOrder) { - const { productTypes } = await listProductTypes(); - const analysisPackagesType = productTypes.find( - ({ metadata }) => metadata?.handle === ANALYSIS_PACKAGES_TYPE_HANDLE, - ); - const analysisType = productTypes.find( - ({ metadata }) => metadata?.handle === ANALYSIS_TYPE_HANDLE, - ); - - const analysisPackageOrderItem = medusaOrder.items?.find( - ({ product_type_id }) => product_type_id === analysisPackagesType?.id, - ); - const analysisItems = medusaOrder.items?.filter( - ({ product_type_id }) => product_type_id === analysisType?.id, - ); - - return { - medusaOrderId: medusaOrder.id, - email: medusaOrder.email, - analysisPackageOrder: analysisPackageOrderItem - ? { - partnerLocationName: - (analysisPackageOrderItem?.metadata - ?.partner_location_name as string) ?? '', - analysisPackageName: analysisPackageOrderItem?.title ?? '', - } - : null, - analysisItemsOrder: - Array.isArray(analysisItems) && analysisItems.length > 0 - ? analysisItems.map(({ product }) => ({ - analysisName: product?.title ?? '', - analysisId: (product?.metadata?.analysisIdOriginal as string) ?? '', - })) - : null, - }; -} - -async function sendAnalysisPackageOrderEmail({ - account, - email, - analysisPackageOrder, -}: { - account: AccountWithParams; - email: string; - analysisPackageOrder: { - partnerLocationName: string; - analysisPackageName: string; - }; -}) { - const { language } = await createI18nServerInstance(); - const { analysisPackageName, partnerLocationName } = analysisPackageOrder; - try { - await sendEmail({ - account: { id: account.id, name: account.name }, - email, - analysisPackageName, - partnerLocationName, - language, - }); - console.info(`Successfully sent analysis package order email to ${email}`); - } catch (error) { - console.error( - `Failed to send analysis package order email to ${email}`, - error, - ); - } -} - export async function processMontonioCallback(orderToken: string) { const { account } = await loadCurrentUserAccount(); if (!account) { @@ -199,63 +44,8 @@ export async function processMontonioCallback(orderToken: string) { try { const decoded = await decodeOrderToken(orderToken); const cart = await getCartByOrderToken(decoded); - - const medusaOrder = await placeOrder(cart.id, { - revalidateCacheTags: false, - }); - const orderedAnalysisElements = await getOrderedAnalysisIds({ - medusaOrder, - }); - - try { - const existingAnalysisOrder = await getAnalysisOrder({ - medusaOrderId: medusaOrder.id, - }); - console.info( - `Analysis order already exists for medusaOrderId=${medusaOrder.id}, orderId=${existingAnalysisOrder.id}`, - ); - return { success: true, orderId: existingAnalysisOrder.id }; - } catch { - // ignored - } - - const orderId = await createAnalysisOrder({ - medusaOrder, - orderedAnalysisElements, - }); - const orderResult = await getOrderResultParameters(medusaOrder); - - const { medusaOrderId, email, analysisPackageOrder, analysisItemsOrder } = - orderResult; - - if (email) { - if (analysisPackageOrder) { - await sendAnalysisPackageOrderEmail({ - account, - email, - analysisPackageOrder, - }); - } else { - console.info(`Order has no analysis package, skipping email.`); - } - - if (analysisItemsOrder) { - // @TODO send email for separate analyses - console.warn( - `Order has analysis items, but no email template exists yet`, - ); - } else { - console.info(`Order has no analysis items, skipping email.`); - } - } else { - console.error('Missing email to send order result email', orderResult); - } - - if (env().isEnabledDispatchOnMontonioCallback) { - await sendOrderToMedipost({ medusaOrderId, orderedAnalysisElements }); - } - - return { success: true, orderId }; + const result = await handlePlaceOrder({ cart }); + return result; } catch (error) { console.error('Failed to place order', error); throw new Error(`Failed to place order, message=${error}`); diff --git a/app/home/(user)/_lib/server/cart-actions.ts b/app/home/(user)/_lib/server/cart-actions.ts new file mode 100644 index 0000000..fb95d49 --- /dev/null +++ b/app/home/(user)/_lib/server/cart-actions.ts @@ -0,0 +1,308 @@ +'use server'; + +import { z } from 'zod'; +import jwt from 'jsonwebtoken'; + +import type { StoreCart, StoreOrder } from "@medusajs/types"; + +import { initiateMultiPaymentSession, placeOrder } from "@lib/data/cart"; +import type { AccountBalanceSummary } from "~/lib/services/accountBalance.service"; +import { handleNavigateToPayment } from "~/lib/services/medusaCart.service"; +import { loadCurrentUserAccount } from "./load-user-account"; +import { getOrderedAnalysisIds } from "~/lib/services/medusaOrder.service"; +import { createAnalysisOrder, getAnalysisOrder } from "~/lib/services/order.service"; +import { listProductTypes } from "@lib/data"; +import { sendOrderToMedipost } from "~/lib/services/medipost/medipostPrivateMessage.service"; +import { AccountWithParams } from "@/packages/features/accounts/src/types/accounts"; +import { createI18nServerInstance } from "~/lib/i18n/i18n.server"; +import { getSupabaseServerAdminClient } from "@/packages/supabase/src/clients/server-admin-client"; +import { createNotificationsApi } from "@/packages/features/notifications/src/server/api"; + +const ANALYSIS_PACKAGES_TYPE_HANDLE = 'analysis-packages'; +const ANALYSIS_TYPE_HANDLE = 'synlab-analysis'; + +const env = () => + z + .object({ + emailSender: z + .string({ + error: 'EMAIL_SENDER is required', + }) + .min(1), + siteUrl: z + .string({ + error: 'NEXT_PUBLIC_SITE_URL is required', + }) + .min(1), + isEnabledDispatchOnMontonioCallback: z.boolean({ + error: 'MEDIPOST_ENABLE_DISPATCH_ON_MONTONIO_CALLBACK is required', + }), + medusaBackendPublicUrl: z.string({ + error: 'MEDUSA_BACKEND_PUBLIC_URL is required', + }).min(1), + companyBenefitsPaymentSecretKey: z.string({ + error: 'COMPANY_BENEFITS_PAYMENT_SECRET_KEY is required', + }).min(1), + }) + .parse({ + emailSender: process.env.EMAIL_SENDER, + siteUrl: process.env.NEXT_PUBLIC_SITE_URL!, + isEnabledDispatchOnMontonioCallback: + process.env.MEDIPOST_ENABLE_DISPATCH_ON_MONTONIO_CALLBACK === 'true', + medusaBackendPublicUrl: process.env.MEDUSA_BACKEND_PUBLIC_URL!, + companyBenefitsPaymentSecretKey: process.env.COMPANY_BENEFITS_PAYMENT_SECRET_KEY!, + }); + +export const initiatePayment = async ({ + accountId, + balanceSummary, + cart, + language, +}: { + accountId: string; + balanceSummary: AccountBalanceSummary; + cart: StoreCart; + language: string; +}) => { + try { + const { + montonioPaymentSessionId, + companyBenefitsPaymentSessionId, + totalByMontonio, + totalByBenefits, + isFullyPaidByBenefits, + } = await initiateMultiPaymentSession(cart, balanceSummary.totalBalance); + + if (!isFullyPaidByBenefits) { + if (!montonioPaymentSessionId) { + throw new Error('Montonio payment session ID is missing'); + } + const url = await handleNavigateToPayment({ + language, + paymentSessionId: montonioPaymentSessionId, + amount: totalByMontonio, + currencyCode: cart.currency_code, + cartId: cart.id, + }); + return { url }; + } else { + // place order if all paid already + const { orderId } = await handlePlaceOrder({ cart }); + + const companyBenefitsOrderToken = jwt.sign({ + accountId, + companyBenefitsPaymentSessionId, + orderId, + totalByBenefits, + }, env().companyBenefitsPaymentSecretKey, { + algorithm: 'HS256', + }); + const webhookResponse = await fetch(`${env().medusaBackendPublicUrl}/hooks/payment/company-benefits_company-benefits`, { + method: 'POST', + body: JSON.stringify({ + orderToken: companyBenefitsOrderToken, + }), + headers: { + 'Content-Type': 'application/json', + }, + }); + if (!webhookResponse.ok) { + throw new Error('Failed to send company benefits webhook'); + } + return { isFullyPaidByBenefits, orderId }; + } + } catch (error) { + console.error('Error initiating payment', error); + } + + return { url: null } +} + +export async function handlePlaceOrder({ + cart, +}: { + cart: StoreCart; +}) { + const { account } = await loadCurrentUserAccount(); + if (!account) { + throw new Error('Account not found in context'); + } + + try { + const medusaOrder = await placeOrder(cart.id, { + revalidateCacheTags: false, + }); + const orderedAnalysisElements = await getOrderedAnalysisIds({ + medusaOrder, + }); + + try { + const existingAnalysisOrder = await getAnalysisOrder({ + medusaOrderId: medusaOrder.id, + }); + console.info( + `Analysis order already exists for medusaOrderId=${medusaOrder.id}, orderId=${existingAnalysisOrder.id}`, + ); + return { success: true, orderId: existingAnalysisOrder.id }; + } catch { + // ignored + } + + const orderId = await createAnalysisOrder({ + medusaOrder, + orderedAnalysisElements, + }); + const orderResult = await getOrderResultParameters(medusaOrder); + + const { medusaOrderId, email, analysisPackageOrder, analysisItemsOrder } = + orderResult; + + if (email) { + if (analysisPackageOrder) { + await sendAnalysisPackageOrderEmail({ + account, + email, + analysisPackageOrder, + }); + } else { + console.info(`Order has no analysis package, skipping email.`); + } + + if (analysisItemsOrder) { + // @TODO send email for separate analyses + console.warn( + `Order has analysis items, but no email template exists yet`, + ); + } else { + console.info(`Order has no analysis items, skipping email.`); + } + } else { + console.error('Missing email to send order result email', orderResult); + } + + if (env().isEnabledDispatchOnMontonioCallback) { + await sendOrderToMedipost({ medusaOrderId, orderedAnalysisElements }); + } + + return { success: true, orderId }; + } catch (error) { + console.error('Failed to place order', error); + throw new Error(`Failed to place order, message=${error}`); + } +} + +async function sendAnalysisPackageOrderEmail({ + account, + email, + analysisPackageOrder, +}: { + account: AccountWithParams; + email: string; + analysisPackageOrder: { + partnerLocationName: string; + analysisPackageName: string; + }; +}) { + const { language } = await createI18nServerInstance(); + const { analysisPackageName, partnerLocationName } = analysisPackageOrder; + try { + await sendEmail({ + account: { id: account.id, name: account.name }, + email, + analysisPackageName, + partnerLocationName, + language, + }); + console.info(`Successfully sent analysis package order email to ${email}`); + } catch (error) { + console.error( + `Failed to send analysis package order email to ${email}`, + error, + ); + } +} + +async function getOrderResultParameters(medusaOrder: StoreOrder) { + const { productTypes } = await listProductTypes(); + const analysisPackagesType = productTypes.find( + ({ metadata }) => metadata?.handle === ANALYSIS_PACKAGES_TYPE_HANDLE, + ); + const analysisType = productTypes.find( + ({ metadata }) => metadata?.handle === ANALYSIS_TYPE_HANDLE, + ); + + const analysisPackageOrderItem = medusaOrder.items?.find( + ({ product_type_id }) => product_type_id === analysisPackagesType?.id, + ); + const analysisItems = medusaOrder.items?.filter( + ({ product_type_id }) => product_type_id === analysisType?.id, + ); + + return { + medusaOrderId: medusaOrder.id, + email: medusaOrder.email, + analysisPackageOrder: analysisPackageOrderItem + ? { + partnerLocationName: + (analysisPackageOrderItem?.metadata + ?.partner_location_name as string) ?? '', + analysisPackageName: analysisPackageOrderItem?.title ?? '', + } + : null, + analysisItemsOrder: + Array.isArray(analysisItems) && analysisItems.length > 0 + ? analysisItems.map(({ product }) => ({ + analysisName: product?.title ?? '', + analysisId: (product?.metadata?.analysisIdOriginal as string) ?? '', + })) + : null, + }; +} + +const sendEmail = async ({ + account, + email, + analysisPackageName, + partnerLocationName, + language, +}: { + account: Pick; + email: string; + analysisPackageName: string; + partnerLocationName: string; + language: string; +}) => { + const client = getSupabaseServerAdminClient(); + 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: account.name, + partnerLocationName, + language, + }); + + await mailer + .sendEmail({ + from: env().emailSender, + to: email, + subject, + html, + }) + .catch((error) => { + throw new Error(`Failed to send email, message=${error}`); + }); + await createNotificationsApi(client).createNotification({ + account_id: account.id, + body: html, + }); + } catch (error) { + throw new Error(`Failed to send email, message=${error}`); + } +};