459 lines
15 KiB
TypeScript
459 lines
15 KiB
TypeScript
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
|
import { PaymentGatewayEnum, PaymentMethodEnum, PaymentStatusEnum } from '../interface/payment';
|
|
import { Payment } from '../entities/payment.entity';
|
|
import { EntityManager } from '@mikro-orm/postgresql';
|
|
import { Order } from '../../orders/entities/order.entity';
|
|
import { Logger } from '@nestjs/common';
|
|
import { GatewayManager } from '../gateways/gateway.manager';
|
|
import { WalletTransaction } from 'src/modules/users/entities/wallet-transaction.entity';
|
|
import { OrderPaymentContext } from '../interface/payment';
|
|
import { OrderStatus } from 'src/modules/orders/interface/order.interface';
|
|
import { ChartPeriodEnum, PaymentChartDto } from '../dto/payment-chart.dto';
|
|
import { PaymentMessage, OrderMessage } from 'src/common/enums/message.enum';
|
|
import { EventEmitter2 } from '@nestjs/event-emitter';
|
|
import { onlinePaymentSucceedEvent, paymentSucceedEvent } from '../events/payment.events';
|
|
import { WalletTransactionReason, WalletTransactionType } from 'src/modules/users/interface/wallet';
|
|
|
|
@Injectable()
|
|
export class PaymentsService {
|
|
private readonly logger = new Logger(PaymentsService.name);
|
|
|
|
constructor(
|
|
private readonly em: EntityManager,
|
|
private readonly gatewayManager: GatewayManager,
|
|
private readonly eventEmitter: EventEmitter2,
|
|
) { }
|
|
|
|
async payOrder(orderId: string): Promise<{ paymentUrl: string | null }> {
|
|
const ctx = await this.loadAndValidateOrder(orderId);
|
|
|
|
// Idempotency: avoid creating/charging again for already-paid orders
|
|
if (ctx.order.payments.find(p => p.status === PaymentStatusEnum.Paid)) {
|
|
return { paymentUrl: null };
|
|
}
|
|
|
|
switch (ctx.method) {
|
|
case PaymentMethodEnum.Cash:
|
|
await this.handleCashPayment(ctx);
|
|
return { paymentUrl: null };
|
|
|
|
case PaymentMethodEnum.Wallet:
|
|
await this.handleWalletPayment(ctx);
|
|
return { paymentUrl: null };
|
|
|
|
case PaymentMethodEnum.Online:
|
|
return this.handleOnlinePayment(ctx);
|
|
|
|
case PaymentMethodEnum.CreditCard:
|
|
await this.handleCreditCardPayment(ctx);
|
|
return { paymentUrl: null };
|
|
|
|
default:
|
|
throw new BadRequestException(PaymentMessage.UNSUPPORTED_PAYMENT_METHOD);
|
|
}
|
|
}
|
|
|
|
private async loadAndValidateOrder(orderId: string): Promise<OrderPaymentContext> {
|
|
const order = await this.em.findOne(
|
|
Order,
|
|
{ id: orderId },
|
|
{ populate: ['user', 'restaurant', 'paymentMethod', 'paymentMethod.restaurant', 'paymentMethod.cards'] },
|
|
);
|
|
|
|
if (!order) {
|
|
throw new NotFoundException(OrderMessage.NOT_FOUND);
|
|
}
|
|
|
|
if (order.total <= 0) {
|
|
throw new BadRequestException(PaymentMessage.AMOUNT_MUST_BE_GREATER_THAN_ZERO);
|
|
}
|
|
|
|
const pm = order.paymentMethod;
|
|
if (!pm) {
|
|
throw new NotFoundException(PaymentMessage.PAYMENT_METHOD_NOT_FOUND);
|
|
}
|
|
|
|
if (pm.method === PaymentMethodEnum.Online) {
|
|
if (!pm.gateway) throw new BadRequestException(PaymentMessage.GATEWAY_REQUIRED);
|
|
if (!pm.merchantId) throw new BadRequestException(PaymentMessage.MERCHANT_ID_REQUIRED);
|
|
if (!pm.restaurant?.domain) throw new BadRequestException(PaymentMessage.RESTAURANT_DOMAIN_REQUIRED);
|
|
}
|
|
|
|
|
|
return {
|
|
order,
|
|
amount: order.total,
|
|
method: pm.method,
|
|
gateway: pm.gateway ?? null,
|
|
merchantId: pm.merchantId ?? null,
|
|
restaurantDomain: pm.restaurant.domain ?? null,
|
|
};
|
|
}
|
|
|
|
private async handleCashPayment(ctx: OrderPaymentContext): Promise<void> {
|
|
await this.getOrCreateLatestPendingPayment(ctx.order.id, {
|
|
amount: ctx.amount,
|
|
method: PaymentMethodEnum.Cash,
|
|
gateway: null,
|
|
});
|
|
}
|
|
|
|
private async handleCreditCardPayment(ctx: OrderPaymentContext): Promise<void> {
|
|
await this.getOrCreateLatestPendingPayment(ctx.order.id, {
|
|
amount: ctx.amount,
|
|
method: PaymentMethodEnum.CreditCard,
|
|
gateway: null,
|
|
});
|
|
}
|
|
|
|
// TODO clean this code and refactor it
|
|
private async handleWalletPayment(ctx: OrderPaymentContext): Promise<void> {
|
|
await this.em.transactional(async em => {
|
|
const order = await em.findOne(Order, { id: ctx.order.id }, { populate: ['user', 'restaurant'] });
|
|
if (!order) throw new NotFoundException(OrderMessage.NOT_FOUND);
|
|
if (!order.user) throw new NotFoundException(OrderMessage.USER_NOT_FOUND);
|
|
if (order.payments.find(p => p.status === PaymentStatusEnum.Paid)) {
|
|
return;
|
|
}
|
|
|
|
const walletTransaction = await em.findOne(WalletTransaction, {
|
|
user: { id: order.user.id },
|
|
restaurant: { id: order.restaurant.id },
|
|
}, {
|
|
orderBy: { createdAt: 'DESC' }
|
|
});
|
|
|
|
if (!walletTransaction) {
|
|
throw new NotFoundException('User wallet not found');
|
|
}
|
|
|
|
if (walletTransaction.balance < ctx.amount) {
|
|
throw new BadRequestException('Insufficient wallet balance');
|
|
}
|
|
|
|
const newBalance = walletTransaction.balance - ctx.amount;
|
|
|
|
|
|
const payment = await this.getOrCreateLatestPendingPayment(order.id, {
|
|
em,
|
|
amount: ctx.amount,
|
|
method: PaymentMethodEnum.Wallet,
|
|
gateway: null,
|
|
});
|
|
|
|
const newWalletTransaction = em.create(WalletTransaction, {
|
|
user: order.user,
|
|
restaurant: order.restaurant,
|
|
amount: ctx.amount,
|
|
type: WalletTransactionType.DEBIT,
|
|
reason: WalletTransactionReason.ORDER_PAYMENT,
|
|
balance: newBalance,
|
|
});
|
|
|
|
payment.status = PaymentStatusEnum.Paid;
|
|
payment.paidAt = new Date();
|
|
|
|
em.persist([ payment, order, newWalletTransaction]);
|
|
await em.flush();
|
|
});
|
|
this.eventEmitter.emit(
|
|
paymentSucceedEvent.name,
|
|
new paymentSucceedEvent(ctx.order.id, ctx.order.restaurant.id, String(ctx.order?.orderNumber) || '', ctx.method, ctx.amount),
|
|
);
|
|
}
|
|
|
|
private async handleOnlinePayment(ctx: OrderPaymentContext): Promise<{ paymentUrl: string }> {
|
|
const gateway = this.gatewayManager.get(ctx.gateway!);
|
|
|
|
const payment = await this.getOrCreateLatestPendingPayment(ctx.order.id, {
|
|
amount: ctx.amount,
|
|
method: PaymentMethodEnum.Online,
|
|
gateway: ctx.gateway!,
|
|
});
|
|
|
|
// If we already requested a gateway transaction, just return the same URL (idempotent)
|
|
if (payment.transactionId) {
|
|
return { paymentUrl: gateway.getPaymentUrl(payment.transactionId) };
|
|
}
|
|
|
|
const { transactionId } = await gateway.requestPayment({
|
|
amount: ctx.amount,
|
|
orderId: ctx.order.id,
|
|
merchantId: ctx.merchantId!,
|
|
domain: ctx.restaurantDomain!,
|
|
});
|
|
|
|
payment.gateway = ctx.gateway;
|
|
payment.transactionId = transactionId;
|
|
payment.status = PaymentStatusEnum.Pending;
|
|
|
|
await this.em.persistAndFlush(payment);
|
|
|
|
return {
|
|
paymentUrl: gateway.getPaymentUrl(transactionId),
|
|
};
|
|
}
|
|
|
|
async verifyOnlinePayment(transactionId: string, orderId: string): Promise<Payment> {
|
|
const payment = await this.em.transactional(async em => {
|
|
const payment = await em.findOne(
|
|
Payment,
|
|
{ transactionId, order: { id: orderId } },
|
|
{ populate: ['order', 'order.paymentMethod'] },
|
|
);
|
|
|
|
if (!payment) {
|
|
throw new NotFoundException(PaymentMessage.PAYMENT_NOT_FOUND);
|
|
}
|
|
|
|
if (payment.status === PaymentStatusEnum.Paid) {
|
|
return payment;
|
|
}
|
|
|
|
const pm = payment.order.paymentMethod;
|
|
if (!pm?.merchantId || !payment.gateway) {
|
|
throw new BadRequestException(PaymentMessage.INVALID_PAYMENT_CONFIGURATION);
|
|
}
|
|
|
|
const gateway = this.gatewayManager.get(payment.gateway);
|
|
|
|
const result = await gateway.verifyPayment({
|
|
merchantId: pm.merchantId,
|
|
amount: payment.amount,
|
|
transactionId: payment.transactionId!,
|
|
});
|
|
|
|
payment.verifyResponse = result.raw;
|
|
|
|
if (!result.success) {
|
|
this.failPayment(payment);
|
|
return payment;
|
|
}
|
|
|
|
this.markPaid(payment, result.referenceId);
|
|
|
|
await em.flush();
|
|
return payment;
|
|
});
|
|
this.eventEmitter.emit(
|
|
onlinePaymentSucceedEvent.name,
|
|
new onlinePaymentSucceedEvent(payment.id, payment.order.id, payment.order.restaurant.id, String(payment.order?.orderNumber) || '', payment.amount),
|
|
);
|
|
return payment;
|
|
}
|
|
|
|
findAllPaymentsByRestaurantId(restId: string, userId: string) {
|
|
return this.em.find(
|
|
Payment,
|
|
{ order: { restaurant: { id: restId }, user: { id: userId } } },
|
|
{ populate: ['order', 'order.paymentMethod'] },
|
|
);
|
|
}
|
|
|
|
async verifyCashAnCreditPayment(id: string): Promise<Payment> {
|
|
return this.verifyManualPayment(id, [PaymentMethodEnum.Cash, PaymentMethodEnum.CreditCard], PaymentMessage.PAYMENT_METHOD_NOT_CASH);
|
|
}
|
|
|
|
|
|
private async verifyManualPayment(
|
|
id: string,
|
|
expectedMethods: PaymentMethodEnum[],
|
|
wrongMethodMessage: PaymentMessage,
|
|
): Promise<Payment> {
|
|
const payment = await this.em.transactional(async em => {
|
|
const payment = await em.findOne(Payment, id, { populate: ['order'] });
|
|
if (!payment) {
|
|
throw new NotFoundException(PaymentMessage.PAYMENT_NOT_FOUND);
|
|
}
|
|
if (!expectedMethods.includes(payment.method)) {
|
|
throw new BadRequestException(wrongMethodMessage);
|
|
}
|
|
if (payment.status === PaymentStatusEnum.Paid) {
|
|
throw new BadRequestException(PaymentMessage.PAYMENT_ALREADY_PAID);
|
|
}
|
|
|
|
payment.status = PaymentStatusEnum.Paid;
|
|
payment.paidAt = new Date();
|
|
em.persist(payment);
|
|
em.persist(payment.order);
|
|
await em.flush();
|
|
return payment;
|
|
});
|
|
return payment;
|
|
}
|
|
|
|
private markPaid(payment: Payment, referenceId: string) {
|
|
payment.status = PaymentStatusEnum.Paid;
|
|
payment.paidAt = new Date();
|
|
payment.referenceId = referenceId;
|
|
}
|
|
|
|
private failPayment(payment: Payment) {
|
|
payment.status = PaymentStatusEnum.Failed;
|
|
payment.failedAt = new Date();
|
|
payment.order.status = OrderStatus.CANCELED;
|
|
}
|
|
|
|
|
|
private async getOrCreateLatestPendingPayment(
|
|
orderId: string,
|
|
params: {
|
|
amount: number;
|
|
method: PaymentMethodEnum;
|
|
gateway: PaymentGatewayEnum | null;
|
|
em?: EntityManager;
|
|
},
|
|
): Promise<Payment> {
|
|
const em = params.em ?? this.em;
|
|
|
|
const existing = await em.findOne(
|
|
Payment,
|
|
{ order: { id: orderId }, status: PaymentStatusEnum.Pending },
|
|
{ orderBy: { createdAt: 'DESC' } },
|
|
);
|
|
|
|
if (existing) {
|
|
if (existing.method !== params.method) {
|
|
throw new BadRequestException(PaymentMessage.EXISTING_PENDING_PAYMENT_MISMATCH);
|
|
}
|
|
|
|
return existing;
|
|
}
|
|
|
|
const orderRef = em.getReference(Order, orderId);
|
|
const payment = em.create(Payment, {
|
|
amount: params.amount,
|
|
order: orderRef,
|
|
method: params.method,
|
|
gateway: params.gateway,
|
|
transactionId: null,
|
|
status: PaymentStatusEnum.Pending,
|
|
});
|
|
|
|
em.persist(payment);
|
|
await em.flush();
|
|
return payment;
|
|
}
|
|
|
|
async getChartData(dto: PaymentChartDto, restaurantId: string): Promise<Array<{ date: string; cash: number; online: number }>> {
|
|
const { startDate, endDate, type = ChartPeriodEnum.Daily } = dto;
|
|
|
|
const start = startDate ? new Date(startDate) : new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
|
|
const end = endDate ? new Date(endDate) : new Date();
|
|
|
|
const startOfDay = new Date(start);
|
|
startOfDay.setHours(0, 0, 0, 0);
|
|
|
|
const endOfDay = new Date(end);
|
|
endOfDay.setHours(23, 59, 59, 999);
|
|
|
|
// 1. Map your Enum to valid Postgres date_trunc units
|
|
// 'Daily' is not valid in PG, it must be 'day'
|
|
const pgPeriod = {
|
|
[ChartPeriodEnum.Daily]: 'day',
|
|
[ChartPeriodEnum.Weekly]: 'week',
|
|
[ChartPeriodEnum.Monthly]: 'month',
|
|
}[type] || 'day';
|
|
|
|
const params: any[] = [startOfDay, endOfDay];
|
|
let restaurantFilter = '';
|
|
|
|
if (restaurantId) {
|
|
params.push(restaurantId);
|
|
restaurantFilter = `AND o.restaurant_id = ?`;
|
|
}
|
|
|
|
// MikroORM uses ? placeholders for parameter binding
|
|
// Ensure paid_at is not NULL and handle timestamp casting properly
|
|
const query = `
|
|
SELECT
|
|
TO_CHAR(DATE_TRUNC('${pgPeriod}', CAST(p.paid_at AS timestamp)), 'YYYY-MM-DD') as "date",
|
|
COALESCE(SUM(CASE WHEN p.method = '${PaymentMethodEnum.Cash}' THEN p.amount ELSE 0 END), 0)::numeric as cash,
|
|
COALESCE(SUM(CASE WHEN p.method = '${PaymentMethodEnum.Online}' THEN p.amount ELSE 0 END), 0)::numeric as online
|
|
FROM payments p
|
|
INNER JOIN orders o ON p.order_id = o.id
|
|
WHERE p.status = '${PaymentStatusEnum.Paid}'
|
|
AND p.paid_at IS NOT NULL
|
|
AND p.paid_at >= ?
|
|
AND p.paid_at <= ?
|
|
${restaurantFilter}
|
|
GROUP BY DATE_TRUNC('${pgPeriod}', CAST(p.paid_at AS timestamp))
|
|
ORDER BY DATE_TRUNC('${pgPeriod}', CAST(p.paid_at AS timestamp)) ASC
|
|
`;
|
|
|
|
this.logger.debug(`Chart query params: startOfDay=${startOfDay.toISOString()}, endOfDay=${endOfDay.toISOString()}, restaurantId=${restaurantId}`);
|
|
const result = await this.em.execute(query, params);
|
|
this.logger.debug(`Chart query returned ${result.length} rows`);
|
|
|
|
const dataMap = new Map<string, { cash: number; online: number }>();
|
|
result.forEach((row: any) => {
|
|
dataMap.set(row.date, {
|
|
cash: Number(row.cash),
|
|
online: Number(row.online),
|
|
});
|
|
});
|
|
|
|
const allDates = this.generateDateRange(startOfDay, endOfDay, type);
|
|
|
|
return allDates.map(date => ({
|
|
date,
|
|
cash: dataMap.get(date)?.cash ?? 0,
|
|
online: dataMap.get(date)?.online ?? 0,
|
|
}));
|
|
}
|
|
|
|
private generateDateRange(start: Date, end: Date, type: ChartPeriodEnum): string[] {
|
|
const dates: string[] = [];
|
|
const current = new Date(start);
|
|
const endDate = new Date(end);
|
|
|
|
// Normalize start date based on period type
|
|
switch (type) {
|
|
case ChartPeriodEnum.Weekly:
|
|
// Start of week (Monday) - PostgreSQL DATE_TRUNC('week') uses ISO week (Monday as first day)
|
|
const dayOfWeek = current.getDay();
|
|
const diff = current.getDate() - dayOfWeek + (dayOfWeek === 0 ? -6 : 1);
|
|
current.setDate(diff);
|
|
current.setHours(0, 0, 0, 0);
|
|
// Also normalize end date to include the week containing it
|
|
const endDayOfWeek = endDate.getDay();
|
|
const endDiff = endDate.getDate() - endDayOfWeek + (endDayOfWeek === 0 ? -6 : 1);
|
|
endDate.setDate(endDiff);
|
|
endDate.setHours(23, 59, 59, 999);
|
|
break;
|
|
case ChartPeriodEnum.Monthly:
|
|
// Start of month
|
|
current.setDate(1);
|
|
current.setHours(0, 0, 0, 0);
|
|
// Also normalize end date to include the month containing it
|
|
endDate.setDate(1);
|
|
endDate.setHours(23, 59, 59, 999);
|
|
break;
|
|
case ChartPeriodEnum.Daily:
|
|
default:
|
|
current.setHours(0, 0, 0, 0);
|
|
endDate.setHours(23, 59, 59, 999);
|
|
}
|
|
|
|
while (current <= endDate) {
|
|
const dateStr = current.toISOString().split('T')[0];
|
|
dates.push(dateStr);
|
|
|
|
// Increment based on period type
|
|
switch (type) {
|
|
case ChartPeriodEnum.Weekly:
|
|
current.setDate(current.getDate() + 7);
|
|
break;
|
|
case ChartPeriodEnum.Monthly:
|
|
current.setMonth(current.getMonth() + 1);
|
|
break;
|
|
case ChartPeriodEnum.Daily:
|
|
default:
|
|
current.setDate(current.getDate() + 1);
|
|
}
|
|
}
|
|
|
|
return dates;
|
|
}
|
|
}
|