feat(MED-97): update cart flow for using benefits

This commit is contained in:
2025-09-26 13:24:09 +03:00
parent 56f84a003c
commit db38e602aa
15 changed files with 419 additions and 81 deletions

View File

@@ -1,5 +1,3 @@
import { notFound } from 'next/navigation';
import { createI18nServerInstance } from '@/lib/i18n/i18n.server';
import { PageBody, PageHeader } from '@/packages/ui/src/makerkit/page';
import { retrieveCart } from '@lib/data/cart';
@@ -11,6 +9,8 @@ import { withI18n } from '~/lib/i18n/with-i18n';
import Cart from '../../_components/cart';
import CartTimer from '../../_components/cart/cart-timer';
import { loadCurrentUserAccount } from '../../_lib/server/load-user-account';
import { AccountBalanceService } from '~/lib/services/accountBalance.service';
export async function generateMetadata() {
const { t } = await createI18nServerInstance();
@@ -21,12 +21,22 @@ export async function generateMetadata() {
}
async function CartPage() {
const cart = await retrieveCart().catch((error) => {
console.error('Failed to retrieve cart', error);
return notFound();
});
const [
cart,
{ productTypes },
{ account },
] = await Promise.all([
retrieveCart(),
listProductTypes(),
loadCurrentUserAccount(),
]);
if (!account) {
return null;
}
const balanceSummary = await new AccountBalanceService().getBalanceSummary(account.id);
const { productTypes } = await listProductTypes();
const analysisPackagesType = productTypes.find(
({ metadata }) => metadata?.handle === 'analysis-packages',
);
@@ -63,9 +73,11 @@ async function CartPage() {
{isTimerShown && <CartTimer cartItem={item} />}
</PageHeader>
<Cart
accountId={account.id}
cart={cart}
synlabAnalyses={synlabAnalyses}
ttoServiceItems={ttoServiceItems}
balanceSummary={balanceSummary}
/>
</PageBody>
);

View File

@@ -2,9 +2,7 @@
import { useState } from 'react';
import { handleNavigateToPayment } from '@/lib/services/medusaCart.service';
import { formatCurrency } from '@/packages/shared/src/utils';
import { initiatePaymentSession } from '@lib/data/cart';
import { StoreCart, StoreCartLineItem } from '@medusajs/types';
import { Loader2 } from 'lucide-react';
import { useTranslation } from 'react-i18next';
@@ -16,27 +14,35 @@ import { Trans } from '@kit/ui/trans';
import AnalysisLocation from './analysis-location';
import CartItems from './cart-items';
import DiscountCode from './discount-code';
import { initiatePayment } from '../../_lib/server/cart-actions';
import { useRouter } from 'next/navigation';
import { AccountBalanceSummary } from '@kit/accounts/services/account-balance.service';
const IS_DISCOUNT_SHOWN = true as boolean;
export default function Cart({
accountId,
cart,
synlabAnalyses,
ttoServiceItems,
balanceSummary,
}: {
accountId: string;
cart: StoreCart | null;
synlabAnalyses: StoreCartLineItem[];
ttoServiceItems: StoreCartLineItem[];
balanceSummary: AccountBalanceSummary | null;
}) {
const {
i18n: { language },
} = useTranslation();
const [isInitiatingSession, setIsInitiatingSession] = useState(false);
const router = useRouter();
const items = cart?.items ?? [];
const hasCartItems = cart && Array.isArray(items) && items.length > 0;
if (!cart || items.length === 0) {
if (!hasCartItems) {
return (
<div className="content-container py-5 lg:px-4">
<div>
@@ -56,24 +62,35 @@ export default function Cart({
);
}
async function initiatePayment() {
async function initiateSession() {
setIsInitiatingSession(true);
const response = await initiatePaymentSession(cart!, {
provider_id: 'pp_montonio_montonio',
});
if (response.payment_collection) {
const { payment_sessions } = response.payment_collection;
const paymentSessionId = payment_sessions![0]!.id;
const url = await handleNavigateToPayment({ language, paymentSessionId });
window.location.href = url;
} else {
try {
const { url, isFullyPaidByBenefits, orderId } = await initiatePayment({
accountId,
balanceSummary: balanceSummary!,
cart: cart!,
language,
});
if (url) {
window.location.href = url;
} else if (isFullyPaidByBenefits) {
if (typeof orderId !== 'number') {
throw new Error('Order ID is missing');
}
router.push(`/home/order/${orderId}/confirmed`);
}
} catch (error) {
console.error('Failed to initiate payment', error);
setIsInitiatingSession(false);
}
}
const hasCartItems = Array.isArray(cart.items) && cart.items.length > 0;
const isLocationsShown = synlabAnalyses.length > 0;
const companyBenefitsTotal = balanceSummary?.totalBalance ?? 0;
const montonioTotal = cart && companyBenefitsTotal > 0 ? cart.total - companyBenefitsTotal : cart.total;
return (
<div className="small:grid-cols-[1fr_360px] grid grid-cols-1 gap-x-40 lg:px-4">
<div className="flex flex-col gap-y-6 bg-white">
@@ -106,7 +123,7 @@ export default function Cart({
</p>
</div>
</div>
<div className="flex gap-x-4 px-4 py-2 sm:justify-end sm:px-6 sm:py-4">
<div className="flex gap-x-4 px-4 pt-2 sm:justify-end sm:px-6 sm:pt-4">
<div className="w-full sm:mr-[42px] sm:w-auto">
<p className="text-muted-foreground ml-0 text-sm font-bold">
<Trans i18nKey="cart:order.promotionsTotal" />
@@ -122,6 +139,24 @@ export default function Cart({
</p>
</div>
</div>
{companyBenefitsTotal > 0 && (
<div className="flex gap-x-4 px-4 py-2 sm:justify-end sm:px-6 sm:py-4">
<div className="w-full sm:mr-[42px] sm:w-auto">
<p className="text-muted-foreground ml-0 text-sm font-bold">
<Trans i18nKey="cart:order.companyBenefitsTotal" />
</p>
</div>
<div className={`sm:mr-[112px] sm:w-[50px]`}>
<p className="text-right text-sm">
{formatCurrency({
value: (companyBenefitsTotal > cart.total ? cart.total : companyBenefitsTotal),
currencyCode: cart.currency_code,
locale: language,
})}
</p>
</div>
</div>
)}
<div className="flex gap-x-4 px-4 sm:justify-end sm:px-6">
<div className="w-full sm:mr-[42px] sm:w-auto">
<p className="ml-0 text-sm font-bold">
@@ -131,7 +166,7 @@ export default function Cart({
<div className={`sm:mr-[112px] sm:w-[50px]`}>
<p className="text-right text-sm">
{formatCurrency({
value: cart.total,
value: montonioTotal < 0 ? 0 : montonioTotal,
currencyCode: cart.currency_code,
locale: language,
})}
@@ -175,7 +210,7 @@ export default function Cart({
<div>
<Button
className="h-10"
onClick={initiatePayment}
onClick={initiateSession}
disabled={isInitiatingSession}
>
{isInitiatingSession && (

View File

@@ -8,6 +8,11 @@ import { useTranslation } from 'react-i18next';
import { Trans } from '@kit/ui/trans';
const PaymentProviderIds = {
COMPANY_BENEFITS: "pp_company-benefits_company-benefits",
MONTONIO: "pp_montonio_montonio",
};
export default function CartTotals({
medusaOrder,
}: {
@@ -20,11 +25,16 @@ export default function CartTotals({
currency_code,
total,
subtotal,
tax_total,
discount_total,
gift_card_total,
payment_collections,
} = medusaOrder;
const montonioPayment = payment_collections?.[0]?.payments
?.find(({ provider_id }) => provider_id === PaymentProviderIds.MONTONIO);
const companyBenefitsPayment = payment_collections?.[0]?.payments
?.find(({ provider_id }) => provider_id === PaymentProviderIds.COMPANY_BENEFITS);
return (
<div>
<div className="txt-medium text-ui-fg-subtle flex flex-col gap-y-2">
@@ -86,8 +96,11 @@ export default function CartTotals({
</span>
</div>
)}
</div>
<div className="my-4 h-px w-full border-b border-gray-200" />
<div className="text-ui-fg-base txt-medium mb-2 flex items-center justify-between">
<span className="font-bold">
<Trans i18nKey="cart:order.total" />
@@ -104,7 +117,42 @@ export default function CartTotals({
})}
</span>
</div>
<div className="mt-4 h-px w-full border-b border-gray-200" />
<div className="my-4 h-px w-full border-b border-gray-200" />
<div className="txt-medium text-ui-fg-subtle flex flex-col gap-y-2">
{companyBenefitsPayment && (
<div className="flex items-center justify-between">
<span className="flex items-center gap-x-1">
<Trans i18nKey="cart:order.benefitsTotal" />
</span>
<span data-testid="cart-subtotal" data-value={companyBenefitsPayment.amount || 0}>
-{' '}
{formatCurrency({
value: companyBenefitsPayment.amount ?? 0,
currencyCode: currency_code,
locale: language,
})}
</span>
</div>
)}
{montonioPayment && (
<div className="flex items-center justify-between">
<span className="flex items-center gap-x-1">
<Trans i18nKey="cart:order.montonioTotal" />
</span>
<span data-testid="cart-subtotal" data-value={montonioPayment.amount || 0}>
-{' '}
{formatCurrency({
value: montonioPayment.amount ?? 0,
currencyCode: currency_code,
locale: language,
})}
</span>
</div>
)}
</div>
</div>
);
}

View File

@@ -11,7 +11,7 @@ export default function OrderDetails({ order }: { order: AnalysisOrder }) {
<span className="font-bold">
<Trans i18nKey="cart:orderConfirmed.orderNumber" />:{' '}
</span>
<span>{order.medusa_order_id}</span>
<span className="break-all">{order.medusa_order_id}</span>
</div>
<div>

View File

@@ -0,0 +1,13 @@
'use server';
import { AccountBalanceService, AccountBalanceSummary } from '@kit/accounts/services/account-balance.service';
export async function getAccountBalanceSummary(accountId: string): Promise<AccountBalanceSummary | null> {
try {
const service = new AccountBalanceService();
return await service.getBalanceSummary(accountId);
} catch (error) {
console.error('Error getting account balance summary:', error);
return null;
}
}

View File

@@ -6,7 +6,7 @@ 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 type { AccountBalanceSummary } from "@kit/accounts/services/account-balance.service";
import { handleNavigateToPayment } from "~/lib/services/medusaCart.service";
import { loadCurrentUserAccount } from "./load-user-account";
import { getOrderedAnalysisIds } from "~/lib/services/medusaOrder.service";

View File

@@ -2,7 +2,7 @@
import { loadCurrentUserAccount } from '@/app/home/(user)/_lib/server/load-user-account';
import { MontonioOrderHandlerService } from '@/packages/billing/montonio/src';
import { addToCart, deleteLineItem, retrieveCart } from '@lib/data/cart';
import { addToCart, deleteLineItem } from '@lib/data/cart';
import { getCartId } from '@lib/data/cookies';
import { StoreCartLineItem, StoreProductVariant } from '@medusajs/types';
import { z } from 'zod';
@@ -87,9 +87,15 @@ export async function handleDeleteCartItem({ lineId }: { lineId: string }) {
export async function handleNavigateToPayment({
language,
paymentSessionId,
amount,
currencyCode,
cartId,
}: {
language: string;
paymentSessionId: string;
amount: number;
currencyCode: string;
cartId: string;
}) {
const supabase = getSupabaseServerClient();
const { account, user } = await loadCurrentUserAccount();
@@ -97,26 +103,21 @@ export async function handleNavigateToPayment({
throw new Error('Account not found');
}
const cart = await retrieveCart();
if (!cart) {
throw new Error('No cart found');
}
const paymentLink =
await new MontonioOrderHandlerService().getMontonioPaymentLink({
notificationUrl: `${env().medusaBackendPublicUrl}/hooks/payment/montonio_montonio`,
returnUrl: `${env().siteUrl}/home/cart/montonio-callback`,
amount: cart.total,
currency: cart.currency_code.toUpperCase(),
amount,
currency: currencyCode.toUpperCase(),
description: `Order from Medreport`,
locale: language,
merchantReference: `${account.id}:${paymentSessionId}:${cart.id}`,
merchantReference: `${account.id}:${paymentSessionId}:${cartId}`,
});
const { error } = await supabase.schema('audit').from('cart_entries').insert({
operation: 'NAVIGATE_TO_PAYMENT',
account_id: account.id,
cart_id: cart.id,
cart_id: cartId,
changed_by: user.id,
});
if (error) {

View File

@@ -51,48 +51,6 @@ export async function createAnalysisOrder({
return orderResult.data.id;
}
export async function updateAnalysisOrder({
orderId,
orderStatus,
}: {
orderId: number;
orderStatus: Tables<{ schema: 'medreport' }, 'analysis_orders'>['status'];
}) {
console.info(`Updating order id=${orderId} status=${orderStatus}`);
await getSupabaseServerAdminClient()
.schema('medreport')
.from('analysis_orders')
.update({
status: orderStatus,
})
.eq('id', orderId)
.throwOnError();
}
export async function updateAnalysisOrderStatus({
orderId,
medusaOrderId,
orderStatus,
}: {
orderId?: number;
medusaOrderId?: string;
orderStatus: Tables<{ schema: 'medreport' }, 'analysis_orders'>['status'];
}) {
const orderIdParam = orderId;
const medusaOrderIdParam = medusaOrderId;
if (!orderIdParam && !medusaOrderIdParam) {
throw new Error('Either orderId or medusaOrderId must be provided');
}
await getSupabaseServerAdminClient()
.schema('medreport')
.rpc('update_analysis_order_status', {
order_id: orderIdParam ?? -1,
status_param: orderStatus,
medusa_order_id_param: medusaOrderIdParam ?? '',
})
.throwOnError();
}
export async function getAnalysisOrder({
medusaOrderId,
analysisOrderId,

View File

@@ -0,0 +1,3 @@
import type { Database } from "@/packages/supabase/src/database.types";
export type AccountBalanceEntry = Database['medreport']['Tables']['account_balance_entries']['Row'];

View File

@@ -12,6 +12,7 @@
"./personal-account-settings": "./src/components/personal-account-settings/index.ts",
"./components": "./src/components/index.ts",
"./hooks/*": "./src/hooks/*.ts",
"./services/*": "./src/server/services/*.ts",
"./api": "./src/server/api.ts",
"./types/*": "./src/types/*.ts"
},

View File

@@ -0,0 +1,125 @@
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import type { AccountBalanceEntry } from '../../types/account-balance-entry';
export type AccountBalanceSummary = {
totalBalance: number;
expiringSoon: number;
recentEntries: AccountBalanceEntry[];
};
export class AccountBalanceService {
private supabase: ReturnType<typeof getSupabaseServerClient>;
constructor() {
this.supabase = getSupabaseServerClient();
}
/**
* Get the current balance for a specific account
*/
async getAccountBalance(accountId: string): Promise<number> {
const { data, error } = await this.supabase
.schema('medreport')
.rpc('get_account_balance', {
p_account_id: accountId,
});
if (error) {
console.error('Error getting account balance:', error);
throw new Error('Failed to get account balance');
}
return data || 0;
}
/**
* Get balance entries for an account with pagination
*/
async getAccountBalanceEntries(
accountId: string,
options: {
limit?: number;
offset?: number;
entryType?: string;
includeInactive?: boolean;
} = {}
): Promise<{
entries: AccountBalanceEntry[];
total: number;
}> {
const { limit = 50, offset = 0, entryType, includeInactive = false } = options;
let query = this.supabase
.schema('medreport')
.from('account_balance_entries')
.select('*', { count: 'exact' })
.eq('account_id', accountId)
.order('created_at', { ascending: false })
.range(offset, offset + limit - 1);
if (!includeInactive) {
query = query.eq('is_active', true);
}
if (entryType) {
query = query.eq('entry_type', entryType);
}
const { data, error, count } = await query;
if (error) {
console.error('Error getting account balance entries:', error);
throw new Error('Failed to get account balance entries');
}
return {
entries: data || [],
total: count || 0,
};
}
/**
* Get balance summary for dashboard display
*/
async getBalanceSummary(accountId: string): Promise<AccountBalanceSummary> {
const [balance, entries] = await Promise.all([
this.getAccountBalance(accountId),
this.getAccountBalanceEntries(accountId, { limit: 5 }),
]);
// Calculate expiring balance (next 30 days)
const thirtyDaysFromNow = new Date();
thirtyDaysFromNow.setDate(thirtyDaysFromNow.getDate() + 30);
const { data: expiringData, error: expiringError } = await this.supabase
.schema('medreport')
.from('account_balance_entries')
.select('amount')
.eq('account_id', accountId)
.eq('is_active', true)
.not('expires_at', 'is', null)
.lte('expires_at', thirtyDaysFromNow.toISOString());
if (expiringError) {
console.error('Error getting expiring balance:', expiringError);
}
const expiringSoon = expiringData?.reduce((sum, entry) => sum + (entry.amount || 0), 0) || 0;
return {
totalBalance: balance,
expiringSoon,
recentEntries: entries.entries,
};
}
async processPeriodicBenefitDistributions(): Promise<void> {
const { error } = await this.supabase.schema('medreport').rpc('process_periodic_benefit_distributions')
if (error) {
console.error('Error processing periodic benefit distributions:', error);
throw new Error('Failed to process periodic benefit distributions');
}
}
}

View File

@@ -0,0 +1,3 @@
import type { Database } from '@kit/supabase/database';
export type AccountBalanceEntry = Database['medreport']['Tables']['account_balance_entries']['Row'];

View File

@@ -253,6 +253,37 @@ export async function setShippingMethod({
.catch(medusaError);
}
export async function initiateMultiPaymentSession(
cart: HttpTypes.StoreCart,
benefitsAmount: number,
) {
const headers = {
...(await getAuthHeaders()),
};
return sdk.client.fetch<unknown>(`/store/multi-payment`, {
method: 'POST',
body: { cartId: cart.id, benefitsAmount },
headers,
})
.then(async (response) => {
console.info('Payment session initiated:', response);
const cartCacheTag = await getCacheTag('carts');
revalidateTag(cartCacheTag);
return response as {
montonioPaymentSessionId: string | null;
companyBenefitsPaymentSessionId: string | null;
totalByBenefits: number;
totalByMontonio: number;
isFullyPaidByBenefits: boolean;
};
})
.catch((e) => {
console.error('Error initiating payment session:', e, JSON.stringify(Object.keys(e)));
return medusaError(e);
});
}
export async function initiatePaymentSession(
cart: HttpTypes.StoreCart,
data: HttpTypes.StoreInitializePaymentSession,

View File

@@ -337,6 +337,51 @@ export type Database = {
}
medreport: {
Tables: {
account_balance_entries: {
Row: {
id: string
account_id: string
amount: number
entry_type: string
description: string
source_company_id: string
reference_id: string
created_at: string
created_by: string
expires_at: string
is_active: boolean
is_analysis_order: boolean
is_analysis_package_order: boolean
}
Insert: {
account_id: string
amount: number
entry_type: string
description: string
source_company_id: string
reference_id: string
created_at: string
created_by: string
expires_at: string
is_active: boolean
is_analysis_order?: boolean
is_analysis_package_order?: boolean
}
Update: {
account_id?: string
amount?: number
entry_type?: string
description?: string
source_company_id?: string
reference_id?: string
created_at?: string
created_by?: string
expires_at?: string
is_active?: boolean
is_analysis_order?: boolean
is_analysis_package_order?: boolean
}
}
account_params: {
Row: {
account_id: string
@@ -2148,6 +2193,10 @@ export type Database = {
Args: { medusa_order_id: string }
Returns: boolean
}
process_periodic_benefit_distributions: {
Args: {}
Returns: void
}
revoke_nonce: {
Args: { p_id: string; p_reason?: string }
Returns: boolean

View File

@@ -0,0 +1,59 @@
alter table medreport.account_balance_entries add column "is_analysis_order" boolean;
alter table medreport.account_balance_entries add column "is_analysis_package_order" boolean;
drop function if exists medreport.consume_account_balance(uuid, numeric, text, text);
-- Create function to consume balance (for purchases)
create or replace function medreport.consume_account_balance(
p_account_id uuid,
p_amount numeric,
p_description text,
p_reference_id text default null,
p_is_analysis_order boolean default false,
p_is_analysis_package_order boolean default false
)
returns boolean
language plpgsql
security definer
as $$
declare
current_balance numeric;
remaining_amount numeric := p_amount;
entry_record record;
consumed_amount numeric;
begin
-- Get current balance
current_balance := medreport.get_account_balance(p_account_id);
-- Check if sufficient balance
if current_balance < p_amount then
return false;
end if;
-- Record the consumption
insert into medreport.account_balance_entries (
account_id,
amount,
entry_type,
description,
reference_id,
created_by,
is_analysis_order,
is_analysis_package_order
) values (
p_account_id,
-p_amount,
'purchase',
p_description,
p_reference_id,
auth.uid(),
p_is_analysis_order,
p_is_analysis_package_order
);
return true;
end;
$$;
-- Grant execute permission
grant execute on function medreport.consume_account_balance(uuid, numeric, text, text, boolean, boolean) to authenticated, service_role;