refactor: the whole paymanet service

This commit is contained in:
mahyargdz
2025-05-03 16:30:22 +03:30
parent 4f54774400
commit 2e2154d745
30 changed files with 1231 additions and 396 deletions
+1 -2
View File
@@ -48,7 +48,6 @@
"@nestjs/swagger": "^11.1.5", "@nestjs/swagger": "^11.1.5",
"@nestjs/throttler": "^6.4.0", "@nestjs/throttler": "^6.4.0",
"@nestjs/typeorm": "^11.0.0", "@nestjs/typeorm": "^11.0.0",
"@types/bcrypt": "^5.0.2",
"@willsoto/nestjs-prometheus": "^6.0.2", "@willsoto/nestjs-prometheus": "^6.0.2",
"aws-sdk": "^2.1692.0", "aws-sdk": "^2.1692.0",
"axios": "^1.8.4", "axios": "^1.8.4",
@@ -74,12 +73,12 @@
"typeorm": "^0.3.22" "typeorm": "^0.3.22"
}, },
"devDependencies": { "devDependencies": {
"@types/bcrypt": "^5.0.2",
"@commitlint/cli": "^19.8.0", "@commitlint/cli": "^19.8.0",
"@commitlint/config-conventional": "^19.8.0", "@commitlint/config-conventional": "^19.8.0",
"@nestjs/cli": "^11.0.6", "@nestjs/cli": "^11.0.6",
"@nestjs/schematics": "^11.0.5", "@nestjs/schematics": "^11.0.5",
"@nestjs/testing": "^11.0.20", "@nestjs/testing": "^11.0.20",
"@types/bcrypt": "^5.0.2",
"@types/jest": "^29.5.14", "@types/jest": "^29.5.14",
"@types/node": "^20.17.30", "@types/node": "^20.17.30",
"@types/nodemailer": "^6.4.17", "@types/nodemailer": "^6.4.17",
+3 -3
View File
@@ -62,9 +62,6 @@ importers:
'@nestjs/typeorm': '@nestjs/typeorm':
specifier: ^11.0.0 specifier: ^11.0.0
version: 11.0.0(@nestjs/common@11.0.20(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.0.20(@nestjs/common@11.0.20(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.22(ioredis@5.6.1)(pg@8.14.1)(redis@4.7.0)(reflect-metadata@0.2.2)(ts-node@10.9.2(@types/node@20.17.30)(typescript@5.8.3))) version: 11.0.0(@nestjs/common@11.0.20(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.0.20(@nestjs/common@11.0.20(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.22(ioredis@5.6.1)(pg@8.14.1)(redis@4.7.0)(reflect-metadata@0.2.2)(ts-node@10.9.2(@types/node@20.17.30)(typescript@5.8.3)))
'@types/bcrypt':
specifier: ^5.0.2
version: 5.0.2
'@willsoto/nestjs-prometheus': '@willsoto/nestjs-prometheus':
specifier: ^6.0.2 specifier: ^6.0.2
version: 6.0.2(@nestjs/common@11.0.20(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(prom-client@15.1.3) version: 6.0.2(@nestjs/common@11.0.20(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(prom-client@15.1.3)
@@ -150,6 +147,9 @@ importers:
'@nestjs/testing': '@nestjs/testing':
specifier: ^11.0.20 specifier: ^11.0.20
version: 11.0.20(@nestjs/common@11.0.20(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.0.20(@nestjs/common@11.0.20(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2)) version: 11.0.20(@nestjs/common@11.0.20(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.0.20(@nestjs/common@11.0.20(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2))
'@types/bcrypt':
specifier: ^5.0.2
version: 5.0.2
'@types/jest': '@types/jest':
specifier: ^29.5.14 specifier: ^29.5.14
version: 29.5.14 version: 29.5.14
@@ -2,4 +2,4 @@ import { InjectQueue } from "@nestjs/bullmq";
import { PAYMENT } from "../../modules/payments/constants"; import { PAYMENT } from "../../modules/payments/constants";
export const InjectPaymentQueue = () => InjectQueue(PAYMENT.PAYMENT_QUEUE_NAME); export const InjectPaymentQueue = () => InjectQueue(PAYMENT.QUEUE_NAME);
+13 -1
View File
@@ -119,6 +119,7 @@ export const enum CommonMessage {
ID_SHOULD_BE_UUID = "شناسه باید یک یو یو آی دی باشد", ID_SHOULD_BE_UUID = "شناسه باید یک یو یو آی دی باشد",
PaymentTypeQueryNotEmpty = "نوع پرداخت نمی‌تواند خالی باشد", PaymentTypeQueryNotEmpty = "نوع پرداخت نمی‌تواند خالی باشد",
SEARCH_QUERY_STRING = "رشته جستجو باید یک رشته باشد", SEARCH_QUERY_STRING = "رشته جستجو باید یک رشته باشد",
UPDATED = "با موفقیت آپدیت شد",
} }
export const enum CategoryMessage { export const enum CategoryMessage {
@@ -383,7 +384,7 @@ export const enum NotificationMessage {
WALLET_CHARGE_MESSAGE = "کیف پول شما به مبلغ [amount] تومان شارژ شد", WALLET_CHARGE_MESSAGE = "کیف پول شما به مبلغ [amount] تومان شارژ شد",
WALLET_DEDUCTION = "کسر از کیف پول", WALLET_DEDUCTION = "کسر از کیف پول",
WALLET_DEDUCTION_MESSAGE = "مبلغ [amount] تومان از کیف پول شما کسر شد", WALLET_DEDUCTION_MESSAGE = "مبلغ [amount] تومان از کیف پول شما کسر شد",
BILL_INVOICE_REMINDER = "یادآوری پرداخت", BILL_INVOICE_REMINDER = "یادآوری پرداخت صورت حساب",
BILL_INVOICE_REMINDER_MESSAGE = "صورت حساب شماره [invoiceNumber] هنوز پرداخت نشده است", BILL_INVOICE_REMINDER_MESSAGE = "صورت حساب شماره [invoiceNumber] هنوز پرداخت نشده است",
BILL_INVOICE = "صورت حساب پرداختی", BILL_INVOICE = "صورت حساب پرداختی",
BILL_INVOICE_MESSAGE = "صورت حساب شماره [invoiceNumber] با موفقیت پرداخت شد", BILL_INVOICE_MESSAGE = "صورت حساب شماره [invoiceNumber] با موفقیت پرداخت شد",
@@ -405,6 +406,10 @@ export const enum NotificationMessage {
ASSIGN_TICKET = "اختصاص تیکت", ASSIGN_TICKET = "اختصاص تیکت",
RECURRING_INVOICE_MESSAGE = "صورت حساب دوره‌ای شماره [invoiceNumber] صادر شده است، لطفا نسبت به تایید یا ویرایش آن اقدام کنید", RECURRING_INVOICE_MESSAGE = "صورت حساب دوره‌ای شماره [invoiceNumber] صادر شده است، لطفا نسبت به تایید یا ویرایش آن اقدام کنید",
RECURRING_INVOICE = "صورت حساب دوره‌ای", RECURRING_INVOICE = "صورت حساب دوره‌ای",
PAYMENT_REMINDER = "یادآوری پرداخت",
PAYMENT_REMINDER_MESSAGE = "لطفا مبلغ [amount] تومان را پرداخت کنید",
PAYMENT_CANCELLATION = "لغو پرداخت",
PAYMENT_CANCELLATION_MESSAGE = "پرداخت مبلغ [amount] تومان لغو شد",
} }
export const enum SubscriptionMessage { export const enum SubscriptionMessage {
@@ -555,6 +560,13 @@ export const enum DiscountMessage {
export const enum EmailMessage { export const enum EmailMessage {
EMAIL_VERIFICATION = "تایید ایمیل", EMAIL_VERIFICATION = "تایید ایمیل",
EMAIL_SENDING_FAILED = "ارسال ایمیل با خطا مواجه شد", EMAIL_SENDING_FAILED = "ارسال ایمیل با خطا مواجه شد",
LOGIN = "ورود به سیستم",
INVOICE = "فاکتور",
WALLET = "اعلان کیف پول",
TICKET = "تیکت پشتیبانی",
ANNOUNCEMENT = "اعلان",
PAYMENT_REMINDER = "یادآوری پرداخت",
PAYMENT_CANCELLATION = "لغو پرداخت",
} }
export const enum FinancialMessage { export const enum FinancialMessage {
+1 -1
View File
@@ -20,7 +20,7 @@ export function mailerConfig(): MailerAsyncOptions {
}, },
template: { template: {
dir: process.cwd() + "/src/templates", dir: process.cwd() + "/src/templates/email",
adapter: new HandlebarsAdapter(), adapter: new HandlebarsAdapter(),
options: { options: {
strict: true, strict: true,
+8 -6
View File
@@ -19,11 +19,12 @@ export function smsConfigs() {
SMS_PATTERN_TICKET_ASSIGNED_ADMIN: configService.getOrThrow<string>("SMS_PATTERN_TICKET_ASSIGNED_ADMIN"), SMS_PATTERN_TICKET_ASSIGNED_ADMIN: configService.getOrThrow<string>("SMS_PATTERN_TICKET_ASSIGNED_ADMIN"),
SMS_PATTERN_INVOICE_APPROVED: configService.getOrThrow<string>("SMS_PATTERN_INVOICE_APPROVED"), SMS_PATTERN_INVOICE_APPROVED: configService.getOrThrow<string>("SMS_PATTERN_INVOICE_APPROVED"),
SMS_PATTERN_INVOICE_PAID: configService.getOrThrow<string>("SMS_PATTERN_INVOICE_PAID"), SMS_PATTERN_INVOICE_PAID: configService.getOrThrow<string>("SMS_PATTERN_INVOICE_PAID"),
SMS_PATTERN_INVOICE_PAYMENT_REMINDER: configService.getOrThrow<string>("SMS_PATTERN_INVOICE_PAYMENT_REMINDER"),
SMS_PATTERN_INVOICE_REMINDER: configService.getOrThrow<string>("SMS_PATTERN_INVOICE_REMINDER"),
SMS_PATTERN_INVOICE_OVERDUE: configService.getOrThrow<string>("SMS_PATTERN_INVOICE_OVERDUE"),
SMS_PATTERN_RECURRING_INVOICE_DRAFT: configService.getOrThrow<string>("SMS_PATTERN_RECURRING_INVOICE_DRAFT"), SMS_PATTERN_RECURRING_INVOICE_DRAFT: configService.getOrThrow<string>("SMS_PATTERN_RECURRING_INVOICE_DRAFT"),
SMS_PATTERN_SUBSCRIPTION_CANCELLED: configService.getOrThrow<string>("SMS_PATTERN_SUBSCRIPTION_CANCELLED"), SMS_PATTERN_SUBSCRIPTION_CANCELLED: configService.getOrThrow<string>("SMS_PATTERN_SUBSCRIPTION_CANCELLED"),
SMS_PATTERN_INVOICE_REMINDER: configService.getOrThrow<string>("SMS_PATTERN_INVOICE_REMINDER"),
SMS_PATTERN_INVOICE_OVERDUE: configService.getOrThrow<string>("SMS_PATTERN_INVOICE_OVERDUE"),
SMS_PATTERN_PAYMENT_REMINDER: configService.getOrThrow<string>("SMS_PATTERN_PAYMENT_REMINDER"),
SMS_PATTERN_PAYMENT_CANCELLATION: configService.getOrThrow<string>("SMS_PATTERN_PAYMENT_CANCELLATION"),
}; };
}, },
}; };
@@ -44,9 +45,10 @@ export interface ISmsConfigs {
SMS_PATTERN_TICKET_ASSIGNED_ADMIN: string; SMS_PATTERN_TICKET_ASSIGNED_ADMIN: string;
SMS_PATTERN_INVOICE_APPROVED: string; SMS_PATTERN_INVOICE_APPROVED: string;
SMS_PATTERN_INVOICE_PAID: string; SMS_PATTERN_INVOICE_PAID: string;
SMS_PATTERN_INVOICE_PAYMENT_REMINDER: string;
SMS_PATTERN_INVOICE_REMINDER: string;
SMS_PATTERN_INVOICE_OVERDUE: string;
SMS_PATTERN_RECURRING_INVOICE_DRAFT: string; SMS_PATTERN_RECURRING_INVOICE_DRAFT: string;
SMS_PATTERN_SUBSCRIPTION_CANCELLED: string; SMS_PATTERN_SUBSCRIPTION_CANCELLED: string;
SMS_PATTERN_INVOICE_REMINDER: string;
SMS_PATTERN_INVOICE_OVERDUE: string;
SMS_PATTERN_PAYMENT_REMINDER: string;
SMS_PATTERN_PAYMENT_CANCELLATION: string;
} }
+5
View File
@@ -13,12 +13,17 @@ async function bootstrap() {
const fastifyAdapter = new FastifyAdapter({ const fastifyAdapter = new FastifyAdapter({
bodyLimit: 10 * 1024 * 1024, bodyLimit: 10 * 1024 * 1024,
trustProxy: true,
}); });
fastifyAdapter.getInstance().addContentTypeParser("multipart/form-data", (_request, _payload, done) => { fastifyAdapter.getInstance().addContentTypeParser("multipart/form-data", (_request, _payload, done) => {
done(null); done(null);
}); });
fastifyAdapter.getInstance().addContentTypeParser("text/plain", (_request, _payload, done) => {
done(null);
});
const app = await NestFactory.create<NestFastifyApplication>(AppModule, fastifyAdapter); const app = await NestFactory.create<NestFastifyApplication>(AppModule, fastifyAdapter);
app.useGlobalPipes(new ValidationPipe({ transform: true, whitelist: true })); app.useGlobalPipes(new ValidationPipe({ transform: true, whitelist: true }));
@@ -45,3 +45,8 @@ export interface ISubscriptionNotificationData extends IBaseNotificationData {
planName: string; planName: string;
invoiceId: string; invoiceId: string;
} }
export interface IPaymentNotificationData extends IBaseNotificationData {
amount: number;
paymentId: string;
}
@@ -1,10 +1,10 @@
import { BadRequestException, Injectable, Logger, NotFoundException } from "@nestjs/common"; import { BadRequestException, Injectable, Logger, NotFoundException } from "@nestjs/common";
import { InjectDataSource } from "@nestjs/typeorm";
import { DataSource, QueryRunner } from "typeorm"; import { DataSource, QueryRunner } from "typeorm";
import { CommonMessage, NotificationMessage } from "../../../common/enums/message.enum"; import { CommonMessage, NotificationMessage } from "../../../common/enums/message.enum";
import { NotifType } from "../../settings/enums/notif-settings.enum"; import { NotifType } from "../../settings/enums/notif-settings.enum";
import { UserSettingsService } from "../../settings/providers/user-settings.service"; import { UserSettingsService } from "../../settings/providers/user-settings.service";
import { EmailService } from "../../utils/providers/email.service";
import { dateFormat, numberFormat } from "../../utils/providers/international.utils"; import { dateFormat, numberFormat } from "../../utils/providers/international.utils";
import { SmsService } from "../../utils/providers/sms.service"; import { SmsService } from "../../utils/providers/sms.service";
import { CreateNotificationDto } from "../DTO/create-notification.dto"; import { CreateNotificationDto } from "../DTO/create-notification.dto";
@@ -14,6 +14,7 @@ import {
IAnnouncementNotificationData, IAnnouncementNotificationData,
IBaseNotificationData, IBaseNotificationData,
IInvoiceNotificationData, IInvoiceNotificationData,
IPaymentNotificationData,
ISubscriptionNotificationData, ISubscriptionNotificationData,
ITicketNotificationData, ITicketNotificationData,
IWalletNotificationData, IWalletNotificationData,
@@ -24,10 +25,11 @@ import { NotificationRepository } from "../repositories/notifications.repository
export class NotificationsService { export class NotificationsService {
private readonly logger = new Logger(NotificationsService.name); private readonly logger = new Logger(NotificationsService.name);
constructor( constructor(
private readonly dataSource: DataSource,
private readonly notificationRepository: NotificationRepository, private readonly notificationRepository: NotificationRepository,
private readonly smsService: SmsService, private readonly smsService: SmsService,
private readonly userSettingsService: UserSettingsService, private readonly userSettingsService: UserSettingsService,
@InjectDataSource() private readonly dataSource: DataSource, private readonly emailService: EmailService,
) {} ) {}
//************************ */ //************************ */
@@ -116,14 +118,14 @@ export class NotificationsService {
//************************ */ //************************ */
async createLoginNotification(recipientId: string, sendNotifData: IBaseNotificationData) { async createLoginNotification(recipientId: string, data: IBaseNotificationData) {
const loginDate = new Date().toLocaleString("fa-IR"); const loginDate = new Date().toLocaleString("fa-IR");
const message = NotificationMessage.LOGIN_MESSAGE.replace("[loginDate]", loginDate); const message = NotificationMessage.LOGIN_MESSAGE.replace("[loginDate]", loginDate);
//sent notification based on the userSettings //sent notification based on the userSettings
const { isActive } = await this.userSettingsService.getUserNotificationSettingWithType(NotifType.USER_LOGIN, recipientId); const { isActive } = await this.userSettingsService.getUserNotificationSettingWithType(NotifType.USER_LOGIN, recipientId);
if (isActive) { if (isActive) {
await this.smsService.sendLoginSms(sendNotifData.userPhone, loginDate); await this.smsService.sendLoginSms(data.userPhone, loginDate);
//send email too if (data.userEmail) await this.emailService.sendLoginEmail(data);
} }
return this.createNotification({ title: NotificationMessage.LOGIN, type: NotifType.USER_LOGIN, message, recipientId }); return this.createNotification({ title: NotificationMessage.LOGIN, type: NotifType.USER_LOGIN, message, recipientId });
} }
@@ -146,7 +148,7 @@ export class NotificationsService {
dateFormat(data.createDate), dateFormat(data.createDate),
dateFormat(data.dueDate), dateFormat(data.dueDate),
); );
//send email too if (data.userEmail) await this.emailService.sendInvoiceEmail(data);
} }
return this.createNotification( return this.createNotification(
{ title: NotificationMessage.INVOICE_CREATION, type: NotifType.CREATE_INVOICE, message, recipientId }, { title: NotificationMessage.INVOICE_CREATION, type: NotifType.CREATE_INVOICE, message, recipientId },
@@ -165,7 +167,7 @@ export class NotificationsService {
); );
if (isActive) { if (isActive) {
await this.smsService.sendAnnouncementSms(data.userPhone, data.title, data.description, dateFormat(data.date)); await this.smsService.sendAnnouncementSms(data.userPhone, data.title, data.description, dateFormat(data.date));
//send email too if (data.userEmail) await this.emailService.sendAnnouncementEmail(data);
} }
return this.createNotification({ title: NotificationMessage.ANNOUNCEMENT, type: NotifType.ANNOUNCEMENT, message, recipientId }); return this.createNotification({ title: NotificationMessage.ANNOUNCEMENT, type: NotifType.ANNOUNCEMENT, message, recipientId });
} }
@@ -187,7 +189,7 @@ export class NotificationsService {
numberFormat(data.balance), numberFormat(data.balance),
dateFormat(data.date), dateFormat(data.date),
); );
//send email too if (data.userEmail) await this.emailService.sendWalletEmail(data);
} }
return this.createNotification({ title: NotificationMessage.WALLET_CHARGE, type: NotifType.WALLET_CHARGE, message, recipientId }); return this.createNotification({ title: NotificationMessage.WALLET_CHARGE, type: NotifType.WALLET_CHARGE, message, recipientId });
} }
@@ -210,7 +212,7 @@ export class NotificationsService {
data.reason, data.reason,
dateFormat(data.date), dateFormat(data.date),
); );
//send email too if (data.userEmail) await this.emailService.sendWalletEmail(data);
} }
return this.createNotification({ title: NotificationMessage.WALLET_DEDUCTION, type: NotifType.WALLET_DEDUCTION, message, recipientId }); return this.createNotification({ title: NotificationMessage.WALLET_DEDUCTION, type: NotifType.WALLET_DEDUCTION, message, recipientId });
} }
@@ -227,7 +229,7 @@ export class NotificationsService {
); );
if (isActive) { if (isActive) {
await this.smsService.sendAnswerTicketSms(data.userPhone, data.ticketId, data.subject); await this.smsService.sendAnswerTicketSms(data.userPhone, data.ticketId, data.subject);
//send email too if (data.userEmail) await this.emailService.sendTicketEmail(data);
} }
return this.createNotification({ title: NotificationMessage.ANSWER_TICKET, type: NotifType.ANSWER_TICKET, message, recipientId }); return this.createNotification({ title: NotificationMessage.ANSWER_TICKET, type: NotifType.ANSWER_TICKET, message, recipientId });
} }
@@ -244,24 +246,23 @@ export class NotificationsService {
); );
if (isActive) { if (isActive) {
await this.smsService.sendCreateTicketSms(data.userPhone, data.ticketId, data.subject, dateFormat(data.date)); await this.smsService.sendCreateTicketSms(data.userPhone, data.ticketId, data.subject, dateFormat(data.date));
//send email too if (data.userEmail) await this.emailService.sendTicketEmail(data);
} }
return this.createNotification({ title: NotificationMessage.CREATE_TICKET, type: NotifType.CREATE_TICKET, message, recipientId }); return this.createNotification({ title: NotificationMessage.CREATE_TICKET, type: NotifType.CREATE_TICKET, message, recipientId });
} }
//************************ */ //************************ */
//TODO: fix this cause this for admin
async createAssignTicketNotificationForAdmin(recipientId: string, data: ITicketNotificationData, queryRunner: QueryRunner) { async createAssignTicketNotificationForAdmin(recipientId: string, data: ITicketNotificationData, queryRunner: QueryRunner) {
const message = NotificationMessage.ASSIGN_TICKET_MESSAGE.replace("[ticketId]", data.ticketId); const message = NotificationMessage.ASSIGN_TICKET_MESSAGE.replace("[ticketId]", data.ticketId);
//sent notification based on the userSettings //sent notification based on the userSettings
// const { isActive } = await this.userSettingsService.getUserNotificationSettingWithType( const { isActive } = await this.userSettingsService.getUserNotificationSettingWithType(
// NotifType.ANSWER_TICKET, NotifType.ASSIGN_TICKET,
// recipientId, recipientId,
// queryRunner, queryRunner,
// ); );
// if (isActive) { if (isActive) {
await this.smsService.sendTicketAssignedToAdminSms(data.userPhone, data.ticketId, data.subject, data.user!); await this.smsService.sendTicketAssignedToAdminSms(data.userPhone, data.ticketId, data.subject, data.user!);
//send email too if (data.userEmail) await this.emailService.sendTicketEmail(data);
// } }
return this.createNotification( return this.createNotification(
{ title: NotificationMessage.ASSIGN_TICKET, type: NotifType.ASSIGN_TICKET, message, recipientId }, { title: NotificationMessage.ASSIGN_TICKET, type: NotifType.ASSIGN_TICKET, message, recipientId },
queryRunner, queryRunner,
@@ -276,7 +277,7 @@ export class NotificationsService {
const { isActive } = await this.userSettingsService.getUserNotificationSettingWithType(NotifType.BILL_INVOICE_REMINDER, recipientId); const { isActive } = await this.userSettingsService.getUserNotificationSettingWithType(NotifType.BILL_INVOICE_REMINDER, recipientId);
if (isActive) { if (isActive) {
await this.smsService.sendInvoicePaymentReminderSms(data.userPhone, data.invoiceId, data.price, dateFormat(data.dueDate)); await this.smsService.sendInvoicePaymentReminderSms(data.userPhone, data.invoiceId, data.price, dateFormat(data.dueDate));
//send email too if (data.userEmail) await this.emailService.sendInvoiceEmail(data);
} }
return this.createNotification( return this.createNotification(
{ title: NotificationMessage.BILL_INVOICE_REMINDER, type: NotifType.BILL_INVOICE_REMINDER, message, recipientId }, { title: NotificationMessage.BILL_INVOICE_REMINDER, type: NotifType.BILL_INVOICE_REMINDER, message, recipientId },
@@ -292,7 +293,7 @@ export class NotificationsService {
const { isActive } = await this.userSettingsService.getUserNotificationSettingWithType(NotifType.BILL_INVOICE, recipientId); const { isActive } = await this.userSettingsService.getUserNotificationSettingWithType(NotifType.BILL_INVOICE, recipientId);
if (isActive) { if (isActive) {
await this.smsService.sendInvoicePaidSms(data.userPhone, data.invoiceId, data.price, dateFormat(data.paidAt!)); await this.smsService.sendInvoicePaidSms(data.userPhone, data.invoiceId, data.price, dateFormat(data.paidAt!));
//send email too if (data.userEmail) await this.emailService.sendInvoiceEmail(data);
} }
return this.createNotification( return this.createNotification(
{ title: NotificationMessage.BILL_INVOICE, type: NotifType.BILL_INVOICE, message, recipientId }, { title: NotificationMessage.BILL_INVOICE, type: NotifType.BILL_INVOICE, message, recipientId },
@@ -307,7 +308,7 @@ export class NotificationsService {
const { isActive } = await this.userSettingsService.getUserNotificationSettingWithType(NotifType.APPROVE_INVOICE, recipientId); const { isActive } = await this.userSettingsService.getUserNotificationSettingWithType(NotifType.APPROVE_INVOICE, recipientId);
if (isActive) { if (isActive) {
await this.smsService.sendInvoiceApprovedSms(data.userPhone, data.invoiceId, data.price, dateFormat(data.dueDate)); await this.smsService.sendInvoiceApprovedSms(data.userPhone, data.invoiceId, data.price, dateFormat(data.dueDate));
//send email too if (data.userEmail) await this.emailService.sendInvoiceEmail(data);
} }
return this.createNotification( return this.createNotification(
{ title: NotificationMessage.APPROVED_INVOICE, type: NotifType.BILL_INVOICE, message, recipientId }, { title: NotificationMessage.APPROVED_INVOICE, type: NotifType.BILL_INVOICE, message, recipientId },
@@ -327,7 +328,7 @@ export class NotificationsService {
const { isActive } = await this.userSettingsService.getUserNotificationSettingWithType(NotifType.INVOICE_OVERDUE, recipientId); const { isActive } = await this.userSettingsService.getUserNotificationSettingWithType(NotifType.INVOICE_OVERDUE, recipientId);
if (isActive) { if (isActive) {
await this.smsService.sendInvoiceOverdueSms(data.userPhone, data.invoiceId, data.price, data.lateFee || 0, dateFormat(data.dueDate)); await this.smsService.sendInvoiceOverdueSms(data.userPhone, data.invoiceId, data.price, data.lateFee || 0, dateFormat(data.dueDate));
//send email too if (data.userEmail) await this.emailService.sendInvoiceEmail(data);
} }
return this.createNotification( return this.createNotification(
{ title: NotificationMessage.INVOICE_OVERDUE, type: NotifType.INVOICE_OVERDUE, message, recipientId }, { title: NotificationMessage.INVOICE_OVERDUE, type: NotifType.INVOICE_OVERDUE, message, recipientId },
@@ -343,13 +344,14 @@ export class NotificationsService {
const { isActive } = await this.userSettingsService.getUserNotificationSettingWithType(NotifType.RECURRING_INVOICE, recipientId); const { isActive } = await this.userSettingsService.getUserNotificationSettingWithType(NotifType.RECURRING_INVOICE, recipientId);
if (isActive) { if (isActive) {
await this.smsService.sendRecurringInvoiceDraftSms(data.userPhone, data.invoiceId, data.price); await this.smsService.sendRecurringInvoiceDraftSms(data.userPhone, data.invoiceId, data.price);
//send email too if (data.userEmail) await this.emailService.sendInvoiceEmail(data);
} }
return this.createNotification( return this.createNotification(
{ title: NotificationMessage.RECURRING_INVOICE, type: NotifType.RECURRING_INVOICE, message, recipientId }, { title: NotificationMessage.RECURRING_INVOICE, type: NotifType.RECURRING_INVOICE, message, recipientId },
queryRunner, queryRunner,
); );
} }
// //************************ */
async createBlockServiceNotification(recipientId: string, data: ISubscriptionNotificationData, queryRunner: QueryRunner) { async createBlockServiceNotification(recipientId: string, data: ISubscriptionNotificationData, queryRunner: QueryRunner) {
const message = NotificationMessage.BLOCK_SERVICE_MESSAGE.replace("[serviceName]", data.planName); const message = NotificationMessage.BLOCK_SERVICE_MESSAGE.replace("[serviceName]", data.planName);
@@ -357,7 +359,14 @@ export class NotificationsService {
const { isActive } = await this.userSettingsService.getUserNotificationSettingWithType(NotifType.BLOCK_SERVICE, recipientId); const { isActive } = await this.userSettingsService.getUserNotificationSettingWithType(NotifType.BLOCK_SERVICE, recipientId);
if (isActive) { if (isActive) {
await this.smsService.sendBlockServiceSms(data.userPhone, data.invoiceId, data.planName); await this.smsService.sendBlockServiceSms(data.userPhone, data.invoiceId, data.planName);
//send email too if (data.userEmail)
await this.emailService.sendAnnouncementEmail({
userEmail: data.userEmail,
userPhone: data.userPhone,
title: NotificationMessage.BLOCK_SERVICE,
description: message,
date: new Date(),
});
} }
return this.createNotification( return this.createNotification(
{ title: NotificationMessage.BLOCK_SERVICE, type: NotifType.BLOCK_SERVICE, message, recipientId }, { title: NotificationMessage.BLOCK_SERVICE, type: NotifType.BLOCK_SERVICE, message, recipientId },
@@ -365,29 +374,56 @@ export class NotificationsService {
); );
} }
// async createServiceNotification(recipientId: string, sendNotifData: ISendNotificationData) {
// const message = NotificationMessage.CREATE_SERVICE_MESSAGE.replace("[serviceName]", sendNotifData.serviceName);
// //sent notification based on the userSettings
// const { isActive } = await this.userSettingsService.getUserNotificationSettingWithType(NotifType.CREATE_SERVICE, recipientId);
// if (isActive) {
// await this.smsService.sendCreateServiceSms(sendNotifData.userPhone, sendNotifData.serviceName);
// //send email too
// }
// return this.createNotification({ title: NotificationMessage.CREATE_SERVICE, message, recipientId });
// }
// //************************ */ // //************************ */
// async createUnblockServiceNotification(recipientId: string, sendNotifData: ISendNotificationData) { async createPaymentReminderNotification(recipientId: string, data: IPaymentNotificationData, queryRunner: QueryRunner) {
// const message = NotificationMessage.UNBLOCK_SERVICE_MESSAGE.replace("[serviceName]", sendNotifData.serviceName); const message = NotificationMessage.PAYMENT_REMINDER_MESSAGE.replace("[amount]", numberFormat(data.amount));
// //sent notification based on the userSettings
// const { isActive } = await this.userSettingsService.getUserNotificationSettingWithType(NotifType.UNBLOCK_SERVICE, recipientId);
// if (isActive) {
// await this.smsService.sendUnblockServiceSms(sendNotifData.userPhone, sendNotifData.serviceName);
// //send email too
// }
// return this.createNotification({ title: NotificationMessage.UNBLOCK_SERVICE, message, recipientId });
// }
const { isActive } = await this.userSettingsService.getUserNotificationSettingWithType(
NotifType.PAYMENT_REMINDER,
recipientId,
queryRunner,
);
if (isActive) {
await this.smsService.sendPaymentReminderSms(data.userPhone, numberFormat(data.amount));
if (data.userEmail) await this.emailService.sendPaymentReminderEmail(data);
}
return this.createNotification(
{
title: NotificationMessage.PAYMENT_REMINDER,
type: NotifType.PAYMENT_REMINDER,
message,
recipientId,
},
queryRunner,
);
}
// //************************ */ // //************************ */
async createPaymentCancellationNotification(recipientId: string, data: IPaymentNotificationData, queryRunner: QueryRunner) {
const message = NotificationMessage.PAYMENT_CANCELLATION_MESSAGE.replace("[amount]", numberFormat(data.amount));
const { isActive } = await this.userSettingsService.getUserNotificationSettingWithType(
NotifType.PAYMENT_CANCELLATION,
recipientId,
queryRunner,
);
if (isActive) {
await this.smsService.sendPaymentCancellationSms(data.userPhone, numberFormat(data.amount));
if (data.userEmail) await this.emailService.sendPaymentCancellationEmail(data);
}
return this.createNotification(
{
title: NotificationMessage.PAYMENT_CANCELLATION,
type: NotifType.PAYMENT_CANCELLATION,
message,
recipientId,
},
queryRunner,
);
}
} }
+15 -2
View File
@@ -1,6 +1,19 @@
export const ZARINPAL_CONFIG = "ZARINPAL_CONFIG"; export const ZARINPAL_CONFIG = "ZARINPAL_CONFIG";
//===============================================
export const PAYMENT = Object.freeze({ export const PAYMENT = Object.freeze({
PAYMENT_QUEUE_NAME: "payment", QUEUE_NAME: "payment",
PAYMENT_START: "payment_start", START: "payment_start",
FIRST_REMINDER: "payment_first_reminder",
SECOND_REMINDER: "payment_second_reminder",
CANCELLATION: "payment_cancellation",
JOB_PRIORITY: 1, // high priority
JOB_ATTEMPTS: 3, // 3 times
JOB_BACKOFF: 5 * 1000, // ms
JOB_TIMEOUT: 10000, // timeout after 10 seconds
START_DELAY: 1 * 60 * 1000, // 1 minute delay in milliseconds
FIRST_REMINDER_DELAY: 5 * 60 * 1000, // 5 minutes
SECOND_REMINDER_DELAY: 10 * 60 * 1000, // 10 minutes
CANCELLATION_DELAY: 15 * 60 * 1000, // 15 minutes
}); });
@@ -3,26 +3,32 @@ import { BadGatewayException, Injectable } from "@nestjs/common";
import { PaymentMessage } from "../../../common/enums/message.enum"; import { PaymentMessage } from "../../../common/enums/message.enum";
import { GatewayEnum } from "../enums/gateway.enum"; import { GatewayEnum } from "../enums/gateway.enum";
import { ZarinpalGateway } from "../gateways/zarinpal.gateway"; import { ZarinpalGateway } from "../gateways/zarinpal.gateway";
import { IPaymentGateway, IPaymentGatewayFactory } from "../interfaces/IPayment";
import { GatewayType } from "../types/gateway.type"; import { GatewayType } from "../types/gateway.type";
@Injectable() @Injectable()
export class PaymentGatewayFactory { export class PaymentGatewayFactory implements IPaymentGatewayFactory {
constructor(private readonly ZarinpalGateway: ZarinpalGateway) {} private readonly gateways: Map<GatewayType, IPaymentGateway>;
//************************ * constructor(private readonly zarinpalGateway: ZarinpalGateway) {
getPaymentGateway(provider: GatewayType) { this.gateways = new Map<GatewayType, IPaymentGateway>();
switch (provider) { this.initializeGateways();
case "zarinpal": }
return this.ZarinpalGateway;
private initializeGateways(): void {
default: this.gateways.set(GatewayEnum.ZARINPAL, this.zarinpalGateway);
throw new BadGatewayException(PaymentMessage.PAYMENT_GATEWAY_NOT_FOUND); }
}
getPaymentGateway(provider: GatewayType): IPaymentGateway {
const gateway = this.gateways.get(provider);
if (!gateway) {
throw new BadGatewayException(PaymentMessage.PAYMENT_GATEWAY_NOT_FOUND);
}
return gateway;
} }
//************************ */
getAvailablePaymentGateways() { getAvailablePaymentGateways() {
const gateways = [{ name: GatewayEnum.ZARINPAL }]; const gateways = Array.from(this.gateways.keys()).map((name) => ({ name: name as GatewayEnum }));
return { gateways }; return { gateways };
} }
} }
+21 -4
View File
@@ -2,6 +2,11 @@
import Decimal from "decimal.js"; import Decimal from "decimal.js";
import { Payment } from "../entities/payment.entity";
import { GatewayEnum } from "../enums/gateway.enum";
import { PaymentStatus } from "../enums/payment-status.enum";
import { GatewayType } from "../types/gateway.type";
export interface IProcessPaymentParams { export interface IProcessPaymentParams {
amount: number; amount: number;
description: string; description: string;
@@ -18,9 +23,9 @@ export interface IProcessPaymentData {
//------------------ Verify payment ------------------------- //------------------ Verify payment -------------------------
//TODO: Define the IPaymentVerifyData interface export interface IVerifyPayment {
export interface IPaymentVerifyData { code: number;
[x: string]: unknown; ref_id: number;
} }
export interface IPaymentVerifyParams { export interface IPaymentVerifyParams {
@@ -73,15 +78,27 @@ export interface ZarinPalPGVerifyData {
error: string[]; error: string[];
} }
export interface PaymentVerificationResult {
status: PaymentStatus;
payment: Payment;
redirectUrl?: URL;
additionalParams?: Record<string, string>;
}
//********************************************** */ //********************************************** */
//------------------abstract class---------------- //------------------abstract class----------------
export interface IPaymentGateway { export interface IPaymentGateway {
processPayment(processPaymentParam: IProcessPaymentParams): Promise<IProcessPaymentData>; processPayment(processPaymentParam: IProcessPaymentParams): Promise<IProcessPaymentData>;
verifyPayment(verifyPaymentParam: IPaymentVerifyParams): Promise<IPaymentVerifyData>; verifyPayment(verifyPaymentParam: IPaymentVerifyParams): Promise<IVerifyPayment>;
} }
// export abstract class PaymentGateway { // export abstract class PaymentGateway {
// abstract processPayment(processPaymentParam: IProcessPaymentParams): Promise<IProcessPaymentData>; // abstract processPayment(processPaymentParam: IProcessPaymentParams): Promise<IProcessPaymentData>;
// abstract verifyPayment(verifyPaymentParam: IPaymentVerifyParams): Promise<IPaymentVerifyData>; // abstract verifyPayment(verifyPaymentParam: IPaymentVerifyParams): Promise<IPaymentVerifyData>;
// } // }
export interface IPaymentGatewayFactory {
getPaymentGateway(provider: GatewayType): IPaymentGateway;
getAvailablePaymentGateways(): { gateways: Array<{ name: GatewayEnum }> };
}
+1 -1
View File
@@ -24,7 +24,7 @@ import { ReferralsModule } from "../referrals/referrals.module";
@Module({ @Module({
imports: [ imports: [
TypeOrmModule.forFeature([Payment, PaymentGateway, BankAccount, DepositRequest]), TypeOrmModule.forFeature([Payment, PaymentGateway, BankAccount, DepositRequest]),
BullModule.registerQueue({ name: PAYMENT.PAYMENT_QUEUE_NAME }), BullModule.registerQueue({ name: PAYMENT.QUEUE_NAME }),
WalletsModule, WalletsModule,
NotificationModule, NotificationModule,
ReferralsModule, ReferralsModule,
+405 -296
View File
@@ -1,10 +1,10 @@
import { InjectQueue } from "@nestjs/bullmq"; import { InjectQueue } from "@nestjs/bullmq";
import { BadRequestException, HttpException, HttpStatus, Injectable, InternalServerErrorException } from "@nestjs/common"; import { BadRequestException, HttpException, HttpStatus, Injectable, InternalServerErrorException, Logger } from "@nestjs/common";
import { ConfigService } from "@nestjs/config"; import { ConfigService } from "@nestjs/config";
import { Queue } from "bullmq"; import { Queue } from "bullmq";
import dayjs from "dayjs"; import dayjs from "dayjs";
import Decimal from "decimal.js"; import Decimal from "decimal.js";
import { FastifyReply } from "fastify"; import { FastifyReply as FRply } from "fastify";
import { DataSource, Not, QueryRunner } from "typeorm"; import { DataSource, Not, QueryRunner } from "typeorm";
import { PaginationDto } from "../../../common/DTO/pagination.dto"; import { PaginationDto } from "../../../common/DTO/pagination.dto";
@@ -30,17 +30,19 @@ import { PaymentGateway } from "../entities/payment-gateway.entity";
import { Payment } from "../entities/payment.entity"; import { Payment } from "../entities/payment.entity";
import { DepositRequestStatus } from "../enums/deposit-request-status.enum"; import { DepositRequestStatus } from "../enums/deposit-request-status.enum";
import { PaymentStatus } from "../enums/payment-status.enum"; import { PaymentStatus } from "../enums/payment-status.enum";
import { TransferType } from "../enums/payment-type.enum";
import { PaymentGatewayFactory } from "../factories/payment.factory"; import { PaymentGatewayFactory } from "../factories/payment.factory";
import { IVerifyPayment } from "../interfaces/IPayment";
import { BankAccountsRepository } from "../repositories/bank-accounts.repository"; import { BankAccountsRepository } from "../repositories/bank-accounts.repository";
import { DepositRequestsRepository } from "../repositories/deposit-requests.repository"; import { DepositRequestsRepository } from "../repositories/deposit-requests.repository";
import { PaymentGatewaysRepository } from "../repositories/payment-gateway.repository"; import { PaymentGatewaysRepository } from "../repositories/payment-gateway.repository";
import { PaymentsRepository } from "../repositories/payments.repository"; import { PaymentsRepository } from "../repositories/payments.repository";
import { GatewayType } from "../types/gateway.type"; import { GatewayType } from "../types/gateway.type";
@Injectable() @Injectable()
export class PaymentsService { export class PaymentsService {
private readonly logger = new Logger(PaymentsService.name);
constructor( constructor(
@InjectQueue(PAYMENT.PAYMENT_QUEUE_NAME) private readonly paymentQueue: Queue, @InjectQueue(PAYMENT.QUEUE_NAME) private readonly paymentQueue: Queue,
private readonly notificationsService: NotificationsService, private readonly notificationsService: NotificationsService,
private readonly configService: ConfigService, private readonly configService: ConfigService,
private readonly gatewayFactory: PaymentGatewayFactory, private readonly gatewayFactory: PaymentGatewayFactory,
@@ -53,19 +55,17 @@ export class PaymentsService {
private dataSource: DataSource, private dataSource: DataSource,
) {} ) {}
//*********************************** */ //===============================================
async chargeWalletWithGateway(chargeDto: GatewayDepositDto, userId: string) { async chargeWalletWithGateway(chargeDto: GatewayDepositDto, userId: string) {
const queryRunner = this.dataSource.createQueryRunner(); const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect(); await queryRunner.connect();
await queryRunner.startTransaction(); await queryRunner.startTransaction();
try { try {
const { amount, gatewayId } = chargeDto; const { amount, gatewayId } = chargeDto;
const user = await queryRunner.manager.findOneBy(User, { id: userId }); const user = await this.getUserById(userId, queryRunner);
if (!user) throw new BadRequestException(UserMessage.USER_NOT_FOUND); const paymentGateway = await this.getPaymentGatewayById(gatewayId, queryRunner);
const paymentGateway = await queryRunner.manager.findOneBy(PaymentGateway, { id: gatewayId });
if (!paymentGateway) throw new BadRequestException(PaymentMessage.PAYMENT_GATEWAY_NOT_FOUND);
const gatewayData = await this.processPayment(paymentGateway.name, amount, WalletMessage.DEPOSIT_WALLET_IPG, user.email, user.phone); const gatewayData = await this.processPayment(paymentGateway.name, amount, WalletMessage.DEPOSIT_WALLET_IPG, user.email, user.phone);
@@ -84,32 +84,20 @@ export class PaymentsService {
await queryRunner.release(); await queryRunner.release();
} }
} }
//===============================================
//*********************************** */
async chargeWalletWithTransfer(depositDto: TransferDepositDto, userId: string) { async chargeWalletWithTransfer(depositDto: TransferDepositDto, userId: string) {
const queryRunner = this.dataSource.createQueryRunner(); const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect(); await queryRunner.connect();
await queryRunner.startTransaction(); await queryRunner.startTransaction();
const { amount, bankAccountId, method, transferReceiptUrl } = depositDto;
try { try {
const bankAccount = await queryRunner.manager.findOneBy(BankAccount, { id: bankAccountId }); const { amount, bankAccountId, method, transferReceiptUrl } = depositDto;
if (!bankAccount) throw new BadRequestException(PaymentMessage.BANK_ACCOUNT_NOT_FOUND);
const user = await queryRunner.manager.findOneBy(User, { id: userId }); const { bankAccount } = await this.getBankAccountById(bankAccountId);
if (!user) throw new BadRequestException(UserMessage.USER_NOT_FOUND); const user = await this.getUserById(userId, queryRunner);
// if (method === TransferType.GATEWAY) throw new BadRequestException(PaymentMessage.TRANSFER_METHOD_NOT_ALLOWED); const depositRequest = this.createDepositRequest(bankAccount, user, transferReceiptUrl, amount, method, queryRunner);
//
const depositRequest = queryRunner.manager.create(DepositRequest, {
bankAccount,
user,
receiptUrl: transferReceiptUrl,
amount,
type: method,
});
await queryRunner.manager.save(DepositRequest, depositRequest);
await queryRunner.commitTransaction(); await queryRunner.commitTransaction();
return { return {
@@ -118,101 +106,267 @@ export class PaymentsService {
}; };
} catch (error) { } catch (error) {
await queryRunner.rollbackTransaction(); await queryRunner.rollbackTransaction();
throw error; throw error;
} finally { } finally {
await queryRunner.release(); await queryRunner.release();
} }
} }
//*********************************** */ //===============================================
//*********************************** */
async processPayment(provider: GatewayType, amount: number, description: string, email?: string, mobile?: string) { async processPayment(provider: GatewayType, amount: number, description: string, email?: string, mobile?: string) {
const paymentGateway = this.gatewayFactory.getPaymentGateway(provider); const paymentGateway = this.gatewayFactory.getPaymentGateway(provider);
return paymentGateway.processPayment({ amount, description, email, mobile }); return paymentGateway.processPayment({ amount, description, email, mobile });
} }
//===============================================
//*********************************** */ async verifyPayment(gateway: GatewayType, queryDto: VerifyQueryDto, rep: FRply) {
//*********************************** */ const frontUrl = this.buildFrontendRedirectUrl();
async verifyPayment(gateway: GatewayType, queryDto: VerifyQueryDto, rep: FastifyReply) { const queryRunner = this.dataSource.createQueryRunner();
const frontUrl = new URL(this.configService.getOrThrow<string>("SITE_URL"));
frontUrl.pathname = "/payment";
try {
await queryRunner.connect();
await queryRunner.startTransaction();
const paymentGateway = this.gatewayFactory.getPaymentGateway(gateway);
const payment = await this.getPaymentByReference(queryDto.Authority, queryRunner);
if (payment.status === PaymentStatus.COMPLETED) throw new BadRequestException(PaymentMessage.VALIDATED_BEFORE);
if (queryDto.Status !== "OK") await this.handleFailedPaymentRedirect(payment, queryRunner, rep, frontUrl);
const verifyData = await paymentGateway.verifyPayment({
reference: queryDto.Authority,
amount: payment.amount,
});
return await this.handlePaymentVerificationWithResponse(verifyData, payment, rep, frontUrl, queryRunner);
} catch (error) {
await queryRunner.rollbackTransaction();
throw error;
} finally {
await queryRunner.release();
}
}
//===============================================
async handlePaymentVerificationWithResponse(verifyData: IVerifyPayment, payment: Payment, rp: FRply, frontUrl: URL, qryRnr: QueryRunner) {
const result = await this.processVerificationResult(verifyData, payment, qryRnr);
this.addPaymentParamsToUrl(frontUrl, result.payment, result.status);
if ("additionalParams" in result) {
Object.entries(result.additionalParams).forEach(([key, value]) => {
frontUrl.searchParams.append(key, String(value));
});
}
await qryRnr.commitTransaction();
return rp.status(HttpStatus.FOUND).redirect(frontUrl.toString());
}
//===============================================
async processVerificationResult(verifyData: IVerifyPayment, payment: Payment, qryRnr: QueryRunner) {
if (verifyData.code === 100) {
return this.processSuccessfulVerification(payment, verifyData.ref_id, qryRnr);
} else if (verifyData.code === 101) {
return { status: PaymentStatus.PENDING, payment };
} else {
await this.handleFailedPayment(payment.id, qryRnr);
return { status: PaymentStatus.FAILED, payment };
}
}
//===============================================
private async processSuccessfulVerification(payment: Payment, refId: number, queryRunner: QueryRunner) {
// Regular wallet charge
const { wallet } = await this.handleSuccessfulPayment(payment, payment.user.id, payment.amount, refId, queryRunner);
//for future use
const additionalParams: Record<string, string> = {};
//
await this.notificationsService.createWalletChargeNotification(
payment.user.id,
{
amount: new Decimal(payment.amount).toNumber(),
balance: new Decimal(wallet.balance).toNumber(),
date: payment.createdAt,
reason: WalletMessage.DEPOSIT_WALLET_IPG,
userPhone: payment.user.phone,
userEmail: payment.user.email,
},
queryRunner,
);
return {
status: PaymentStatus.COMPLETED,
payment,
additionalParams,
};
}
//===============================================
async createGatewayPaymentForUser(usr: User, amt: number, ref_Id: string, gatewayId: string, qryRnr: QueryRunner) {
const payment = qryRnr.manager.create(Payment, {
amount: amt,
reference: ref_Id,
user: usr,
paymentGateway: { id: gatewayId },
});
await qryRnr.manager.save(Payment, payment);
this.logger.log(`Created payment ${payment.id} with reference ${ref_Id}`);
await this.addPaymentReminderToQueue(payment);
return payment;
}
//===============================================
async getAvailableGateways() {
const paymentGateways = await this.paymentGatewaysRepository.find({ where: { isActive: true } });
return { paymentGateways };
}
//===============================================
async getPaymentWithReference(reference: string) {
const payment = await this.paymentsRepository.findOneWithReference(reference);
if (!payment) throw new BadRequestException(PaymentMessage.PAYMENT_NOT_FOUND_WITH_REF);
return { payment };
}
//===============================================
async handleFailedPayment(paymentId: string, queryRunner: QueryRunner) {
await queryRunner.manager.update(Payment, { id: paymentId }, { status: PaymentStatus.CANCELLED });
}
//===============================================
async handleSuccessfulPayment(payment: Payment, userId: string, amount: Decimal, transactionId: number, queryRunner: QueryRunner) {
await queryRunner.manager.update(
Payment,
{ id: payment.id },
{ status: PaymentStatus.COMPLETED, transactionId: transactionId.toString() },
);
await this.handleReferralReward(payment, queryRunner);
return this.walletsService.createPaymentTransaction(userId, amount, payment, queryRunner);
}
//===============================================
async addBankAccount(createDto: CreateBankAccountDto) {
await this.validateBankAccountDetails(createDto);
const bankAccount = this.bankAccountsRepository.create({ ...createDto });
await this.bankAccountsRepository.save(bankAccount);
return {
message: CommonMessage.CREATED,
bankAccount,
};
}
//===============================================
async updateBankAccount(updateDto: UpdateBankAccountDto, bankAccountId: string) {
const bankAccount = await this.bankAccountsRepository.findOneBy({ id: bankAccountId });
if (!bankAccount) throw new BadRequestException(PaymentMessage.BANK_ACCOUNT_NOT_FOUND);
await this.validateBankAccountUpdate(updateDto, bankAccountId);
await this.bankAccountsRepository.save({ ...bankAccount, ...updateDto });
return {
message: CommonMessage.UPDATED,
};
}
//===============================================
async getBankAccounts(isAdmin: boolean) {
const bankAccounts = isAdmin
? await this.bankAccountsRepository.find({ where: { isActive: true } })
: await this.bankAccountsRepository.find();
return { bankAccounts };
}
//===============================================
async getBankAccountById(bankAccountId: string) {
const bankAccount = await this.bankAccountsRepository.findOneBy({ id: bankAccountId });
if (!bankAccount) throw new BadRequestException(PaymentMessage.BANK_ACCOUNT_NOT_FOUND);
return {
bankAccount,
};
}
//===============================================
async getTransactions(queryDto: SearchTransactionQueryDto) {
return this.walletsService.getTransactionsForAdmin(queryDto);
}
//===============================================
async getDepositRequests(queryDto: PaymentTransactionQueryDto) {
const { limit, skip } = PaginationUtils(queryDto);
const { type } = queryDto;
const queryBuilder = this.buildDepositRequestsQuery(skip, limit, type);
const [depositRequests, count] = await queryBuilder.getManyAndCount();
return {
depositRequests,
count,
paginate: true,
};
}
//===============================================
async getDepositGatewayPayment(queryDto: PaginationDto) {
const { limit, skip } = PaginationUtils(queryDto);
const queryBuilder = this.buildDepositGatewayPaymentsQuery(skip, limit);
const [payments, count] = await queryBuilder.getManyAndCount();
return {
payments,
count,
paginate: true,
};
}
//===============================================
async getDepositRequestById(depositId: string) {
const depositRequest = await this.depositRequestsRepository.findOneBy({ id: depositId });
if (!depositRequest) throw new BadRequestException(PaymentMessage.DEPOSIT_NOT_FOUND);
return {
depositRequest,
};
}
//===============================================
async approverDepositRequest(depositId: string) {
const queryRunner = this.dataSource.createQueryRunner(); const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect(); await queryRunner.connect();
await queryRunner.startTransaction(); await queryRunner.startTransaction();
try { try {
const paymentGateway = this.gatewayFactory.getPaymentGateway(gateway); const depositRequest = await this.getDepositRequestWithLock(depositId, queryRunner);
// const payment = await queryRunner.manager.findOne(Payment, {
// where: { reference: queryDto.Authority },
// relations: ["user"],
// });
const payment = await queryRunner.manager if (depositRequest.status === DepositRequestStatus.APPROVED) {
.createQueryBuilder(Payment, "payment") throw new BadRequestException(PaymentMessage.DEPOSIT_ALREADY_APPROVED);
.innerJoinAndSelect("payment.user", "user")
.where("payment.reference = :reference", { reference: queryDto.Authority })
.setLock("pessimistic_write")
.getOne();
if (!payment) throw new BadRequestException(PaymentMessage.PAYMENT_NOT_FOUND_WITH_REF);
if (payment.status === PaymentStatus.COMPLETED) throw new BadRequestException(PaymentMessage.VALIDATED_BEFORE);
if (queryDto.Status !== "OK") {
await this.handleFailedPayment(payment.id, queryRunner);
await queryRunner.commitTransaction();
frontUrl.searchParams.append("status", PaymentStatus.FAILED);
frontUrl.searchParams.append("id", payment.id);
frontUrl.searchParams.append("date", payment.createdAt.toISOString());
frontUrl.searchParams.append("amount", payment.amount.toString());
return rep.status(HttpStatus.FOUND).redirect(frontUrl.toString());
} }
const verifyData = await paymentGateway.verifyPayment({ reference: queryDto.Authority, amount: payment.amount }); await this.updateDepositRequestStatus(depositRequest.id, DepositRequestStatus.APPROVED, null, queryRunner);
if (verifyData.code === 100) { await this.walletsService.createDepositTransaction(depositRequest.user.id, depositRequest.amount, depositRequest, queryRunner);
const { wallet } = await this.handleSuccessfulPayment(
payment,
payment.user.id,
payment.amount,
`${verifyData.ref_id}`,
queryRunner,
);
await queryRunner.commitTransaction();
frontUrl.searchParams.append("status", PaymentStatus.COMPLETED);
frontUrl.searchParams.append("id", payment.id);
frontUrl.searchParams.append("date", payment.createdAt.toISOString());
frontUrl.searchParams.append("amount", payment.amount.toString());
//
await this.notificationsService.createWalletChargeNotification(
payment.user.id,
{
amount: new Decimal(payment.amount).toNumber(),
balance: new Decimal(wallet.balance).toNumber(),
date: payment.createdAt,
reason: WalletMessage.DEPOSIT_WALLET_IPG,
userPhone: payment.user.phone,
userEmail: payment.user.email,
},
queryRunner,
);
} else if (verifyData.code === 101) {
await queryRunner.commitTransaction();
frontUrl.searchParams.append("status", PaymentStatus.PENDING);
frontUrl.searchParams.append("id", payment.id);
frontUrl.searchParams.append("date", payment.createdAt.toISOString());
frontUrl.searchParams.append("amount", payment.amount.toString());
} else {
await this.handleFailedPayment(payment.id, queryRunner);
await queryRunner.commitTransaction();
frontUrl.searchParams.append("status", PaymentStatus.FAILED);
frontUrl.searchParams.append("id", payment.id);
frontUrl.searchParams.append("date", payment.createdAt.toISOString());
frontUrl.searchParams.append("amount", payment.amount.toString());
}
return rep.status(HttpStatus.FOUND).redirect(frontUrl.toString()); await queryRunner.commitTransaction();
return {
message: PaymentMessage.DEPOSIT_APPROVED,
depositRequest: depositRequest.id,
};
} catch (error) { } catch (error) {
await queryRunner.rollbackTransaction(); await queryRunner.rollbackTransaction();
throw error; throw error;
@@ -221,43 +375,49 @@ export class PaymentsService {
} }
} }
//*********************************** */ //===============================================
//*********************************** */ async rejectDepositRequest(depositId: string, rejectDto: RejectDepositRequestDto) {
async createGatewayPaymentForUser(user: User, amount: number, reference: string, gatewayId: string, queryRunner: QueryRunner) { const queryRunner = this.dataSource.createQueryRunner();
// await queryRunner.connect();
const payment = queryRunner.manager.create(Payment, { await queryRunner.startTransaction();
amount,
reference,
user,
paymentGateway: { id: gatewayId },
});
//
await queryRunner.manager.save(Payment, payment);
this.paymentQueue.add(PAYMENT.PAYMENT_START, { payment }, { delay: 1000 });
return payment; try {
} const depositRequest = await queryRunner.manager.findOne(DepositRequest, {
//*********************************** */ where: { id: depositId },
//*********************************** */ lock: { mode: "pessimistic_write" },
async getAvailableGateways() { });
const paymentGateways = await this.paymentGatewaysRepository.find({ where: { isActive: true } });
return { paymentGateways };
}
//*********************************** */
//*********************************** */
async getPaymentWithReference(reference: string) {
const payment = await this.paymentsRepository.findOneWithReference(reference);
if (!payment) throw new BadRequestException(PaymentMessage.PAYMENT_NOT_FOUND_WITH_REF);
return { payment }; if (!depositRequest) {
} throw new BadRequestException(PaymentMessage.DEPOSIT_NOT_FOUND);
}
//*********************************** */ if (depositRequest.status === DepositRequestStatus.REJECTED) {
//*********************************** */ throw new BadRequestException(PaymentMessage.DEPOSIT_ALREADY_REJECTED);
async handleFailedPayment(paymentId: string, queryRunner: QueryRunner) { }
await queryRunner.manager.update(Payment, { id: paymentId }, { status: PaymentStatus.CANCELLED });
await this.updateDepositRequestStatus(depositRequest.id, DepositRequestStatus.REJECTED, rejectDto.comment, queryRunner);
await queryRunner.commitTransaction();
return {
message: PaymentMessage.DEPOSIT_REJECTED,
depositRequest,
};
} catch (error) {
await queryRunner.rollbackTransaction();
throw error;
} finally {
await queryRunner.release();
}
}
//----------------------------------------
//-private methods
//----------------------------------------
private buildFrontendRedirectUrl(): URL {
const frontUrl = new URL(this.configService.getOrThrow<string>("SITE_URL"));
frontUrl.pathname = "/payment";
return frontUrl;
} }
//*********************************** */
private async isFirstPurchase(userId: string, queryRunner: QueryRunner) { private async isFirstPurchase(userId: string, queryRunner: QueryRunner) {
const paymentCount = await queryRunner.manager.count(Payment, { const paymentCount = await queryRunner.manager.count(Payment, {
@@ -266,7 +426,7 @@ export class PaymentsService {
return paymentCount === 0; return paymentCount === 0;
} }
//*********************************** */ //===============================================
private async handleReferralReward(payment: Payment, queryRunner: QueryRunner) { private async handleReferralReward(payment: Payment, queryRunner: QueryRunner) {
const isFirstPurchase = await this.isFirstPurchase(payment.user.id, queryRunner); const isFirstPurchase = await this.isFirstPurchase(payment.user.id, queryRunner);
@@ -293,87 +453,100 @@ export class PaymentsService {
referral.completedAt = dayjs().toDate(); referral.completedAt = dayjs().toDate();
await queryRunner.manager.save(referral); await queryRunner.manager.save(referral);
} }
//*********************************** */ //===============================================
async handleSuccessfulPayment(payment: Payment, userId: string, amount: Decimal, transactionId: string, queryRunner: QueryRunner) { private async handleFailedPaymentRedirect(payment: Payment, queryRunner: QueryRunner, rep: FRply, frontUrl: URL) {
await queryRunner.manager.update(Payment, { id: payment.id }, { status: PaymentStatus.COMPLETED, transactionId }); await this.handleFailedPayment(payment.id, queryRunner);
const result = await this.walletsService.createPaymentTransaction(userId, amount, payment, queryRunner); await queryRunner.commitTransaction();
// Handle referral reward if applicable this.addPaymentParamsToUrl(frontUrl, payment, PaymentStatus.FAILED);
await this.handleReferralReward(payment, queryRunner);
return result; return rep.status(HttpStatus.FOUND).redirect(frontUrl.toString());
} }
//*********************************** */ //===============================================
async addBankAccount(createDto: CreateBankAccountDto) { private addPaymentParamsToUrl(url: URL, payment: Payment, status: PaymentStatus) {
const existCardNumber = await this.bankAccountsRepository.findOneBy({ cardNumber: createDto.cardNumber }); url.searchParams.append("status", status);
if (existCardNumber) throw new BadRequestException(PaymentMessage.CARD_NUMBER_EXIST); url.searchParams.append("id", payment.id);
// url.searchParams.append("date", payment.createdAt.toISOString());
const existIBan = await this.bankAccountsRepository.findOneBy({ IBan: createDto.IBan }); url.searchParams.append("amount", payment.amount.toString());
if (existIBan) throw new BadRequestException(PaymentMessage.IBAN_EXIST);
//
const bankAccount = this.bankAccountsRepository.create({ ...createDto });
await this.bankAccountsRepository.save(bankAccount);
return {
message: CommonMessage.CREATED,
bankAccount,
};
} }
//*********************************** */ //===============================================
async updateBankAccount(updateDto: UpdateBankAccountDto, bankAccountId: string) {
const bankAccount = await this.bankAccountsRepository.findOneBy({ id: bankAccountId });
if (!bankAccount) throw new BadRequestException(PaymentMessage.BANK_ACCOUNT_NOT_FOUND);
if (updateDto.cardNumber) { private async getUserById(userId: string, queryRunner: QueryRunner): Promise<User> {
const existCardNumber = await this.bankAccountsRepository.findOneBy({ cardNumber: updateDto.cardNumber, id: Not(bankAccountId) }); const user = await queryRunner.manager.findOneBy(User, { id: userId });
if (!user) throw new BadRequestException(UserMessage.USER_NOT_FOUND);
return user;
}
//===============================================
private async getPaymentGatewayById(gatewayId: string, queryRunner: QueryRunner): Promise<PaymentGateway> {
const paymentGateway = await queryRunner.manager.findOneBy(PaymentGateway, { id: gatewayId });
if (!paymentGateway) throw new BadRequestException(PaymentMessage.PAYMENT_GATEWAY_NOT_FOUND);
return paymentGateway;
}
//===============================================
private createDepositRequest(bnkAcc: BankAccount, usr: User, recUrl: string, amt: number, typ: TransferType, qryRnr: QueryRunner) {
const depositRequest = qryRnr.manager.create(DepositRequest, {
bankAccount: bnkAcc,
user: usr,
receiptUrl: recUrl,
amount: amt,
type: typ,
});
qryRnr.manager.save(DepositRequest, depositRequest);
return depositRequest;
}
//===============================================
private async getPaymentByReference(reference: string, queryRunner: QueryRunner): Promise<Payment> {
const payment = await queryRunner.manager
.createQueryBuilder(Payment, "payment")
.innerJoinAndSelect("payment.user", "user")
.where("payment.reference = :reference", { reference })
.setLock("pessimistic_write")
.getOne();
if (!payment) throw new BadRequestException(PaymentMessage.PAYMENT_NOT_FOUND_WITH_REF);
return payment;
}
//===============================================
private async validateBankAccountDetails(accountDetails: { cardNumber?: string; IBan?: string }) {
if (accountDetails.cardNumber) {
const existCardNumber = await this.bankAccountsRepository.findOneBy({ cardNumber: accountDetails.cardNumber });
if (existCardNumber) throw new BadRequestException(PaymentMessage.CARD_NUMBER_EXIST); if (existCardNumber) throw new BadRequestException(PaymentMessage.CARD_NUMBER_EXIST);
} }
//
if (updateDto.IBan) { if (accountDetails.IBan) {
const existIBan = await this.bankAccountsRepository.findOneBy({ IBan: updateDto.IBan, id: Not(bankAccountId) }); const existIBan = await this.bankAccountsRepository.findOneBy({ IBan: accountDetails.IBan });
if (existIBan) throw new BadRequestException(PaymentMessage.IBAN_EXIST); if (existIBan) throw new BadRequestException(PaymentMessage.IBAN_EXIST);
} }
await this.bankAccountsRepository.save({ ...bankAccount, ...updateDto });
} }
//*********************************** */
async getBankAccounts(isAdmin: boolean) {
let bankAccounts: BankAccount[];
if (isAdmin) { //===============================================
bankAccounts = await this.bankAccountsRepository.find({ where: { isActive: true } }); private async validateBankAccountUpdate(updateDto: UpdateBankAccountDto, bankAccountId: string) {
} else { if (updateDto.cardNumber) {
bankAccounts = await this.bankAccountsRepository.find(); const existCardNumber = await this.bankAccountsRepository.findOneBy({
cardNumber: updateDto.cardNumber,
id: Not(bankAccountId),
});
if (existCardNumber) throw new BadRequestException(PaymentMessage.CARD_NUMBER_EXIST);
} }
return { if (updateDto.IBan) {
bankAccounts, const existIBan = await this.bankAccountsRepository.findOneBy({
}; IBan: updateDto.IBan,
id: Not(bankAccountId),
});
if (existIBan) throw new BadRequestException(PaymentMessage.IBAN_EXIST);
}
} }
//*********************************** */ //===============================================
async getBankAccountById(bankAccountId: string) { private buildDepositRequestsQuery(skip: number, limit: number, type?: TransferType) {
const bankAccount = await this.bankAccountsRepository.findOneBy({ id: bankAccountId });
if (!bankAccount) throw new BadRequestException(PaymentMessage.BANK_ACCOUNT_NOT_FOUND);
return {
bankAccount,
};
}
// async deleteBankAccount()
//*********************************** */
async getTransactions(queryDto: SearchTransactionQueryDto) {
return this.walletsService.getTransactionsForAdmin(queryDto);
}
//*********************************** */
async getDepositRequests(queryDto: PaymentTransactionQueryDto) {
const { limit, skip } = PaginationUtils(queryDto);
const { type } = queryDto;
const queryBuilder = this.depositRequestsRepository const queryBuilder = this.depositRequestsRepository
.createQueryBuilder("depositRequest") .createQueryBuilder("depositRequest")
.leftJoinAndSelect("depositRequest.user", "user") .leftJoinAndSelect("depositRequest.user", "user")
@@ -386,117 +559,53 @@ export class PaymentsService {
queryBuilder.andWhere("depositRequest.type = :type", { type }); queryBuilder.andWhere("depositRequest.type = :type", { type });
} }
const [depositRequests, count] = await queryBuilder.getManyAndCount(); return queryBuilder;
return {
depositRequests,
count,
paginate: true,
};
} }
//*********************************** */
async getDepositGatewayPayment(queryDto: PaginationDto) { //===============================================
const { limit, skip } = PaginationUtils(queryDto); private buildDepositGatewayPaymentsQuery(skip: number, limit: number) {
return this.paymentsRepository
const queryBuilder = this.paymentsRepository
.createQueryBuilder("payment") .createQueryBuilder("payment")
.leftJoinAndSelect("payment.user", "user") .leftJoinAndSelect("payment.user", "user")
.leftJoinAndSelect("payment.paymentGateway", "paymentGateway") .leftJoinAndSelect("payment.paymentGateway", "paymentGateway")
.orderBy("payment.createdAt", "DESC") .orderBy("payment.createdAt", "DESC")
.skip(skip) .skip(skip)
.take(limit); .take(limit);
const [payments, count] = await queryBuilder.getManyAndCount();
return {
payments,
count,
paginate: true,
};
} }
//*********************************** */ //===============================================
private async getDepositRequestWithLock(depositId: string, queryRunner: QueryRunner) {
const depositRequest = await queryRunner.manager
.createQueryBuilder(DepositRequest, "deposit")
.innerJoinAndSelect("deposit.user", "user")
.where("deposit.id = :id", { id: depositId })
.setLock("pessimistic_write")
.getOne();
async getDepositRequestById(depositId: string) {
const depositRequest = await this.depositRequestsRepository.findOneBy({ id: depositId });
if (!depositRequest) throw new BadRequestException(PaymentMessage.DEPOSIT_NOT_FOUND); if (!depositRequest) throw new BadRequestException(PaymentMessage.DEPOSIT_NOT_FOUND);
return depositRequest;
return {
depositRequest,
};
} }
//*********************************** */ //===============================================
async approverDepositRequest(depositId: string) { private async updateDepositRequestStatus(depositId: string, status: DepositRequestStatus, comment: string | null, qryRnr: QueryRunner) {
const queryRunner = this.dataSource.createQueryRunner(); const updateData: Partial<DepositRequest> = { status };
await queryRunner.connect(); if (comment !== null) {
await queryRunner.startTransaction(); updateData.comment = comment;
try {
// const depositRequest = await queryRunner.manager.findOne(DepositRequest, {
// where: { id: depositId },
// lock: { mode: "pessimistic_write" },
// });
const depositRequest = await queryRunner.manager
.createQueryBuilder(DepositRequest, "deposit")
.innerJoinAndSelect("deposit.user", "user")
.where("deposit.id = :id", { id: depositId })
.setLock("pessimistic_write")
.getOne();
if (!depositRequest) throw new BadRequestException(PaymentMessage.DEPOSIT_NOT_FOUND);
if (depositRequest.status === DepositRequestStatus.APPROVED) throw new BadRequestException(PaymentMessage.DEPOSIT_ALREADY_APPROVED);
await queryRunner.manager.update(DepositRequest, { id: depositRequest.id }, { status: DepositRequestStatus.APPROVED });
await this.walletsService.createDepositTransaction(depositRequest.user.id, depositRequest.amount, depositRequest, queryRunner);
await queryRunner.commitTransaction();
return {
message: PaymentMessage.DEPOSIT_APPROVED,
depositRequest: depositRequest.id,
};
} catch (error) {
await queryRunner.rollbackTransaction();
throw error;
} finally {
await queryRunner.release();
} }
await qryRnr.manager.update(DepositRequest, { id: depositId }, updateData);
} }
//===============================================
//*********************************** */ private async addPaymentReminderToQueue(payment: Payment) {
async rejectDepositRequest(depositId: string, rejectDto: RejectDepositRequestDto) { this.paymentQueue.add(
const queryRunner = this.dataSource.createQueryRunner(); PAYMENT.START,
await queryRunner.connect(); { payment },
await queryRunner.startTransaction(); {
try { delay: PAYMENT.START_DELAY,
const depositRequest = await queryRunner.manager.findOne(DepositRequest, { priority: PAYMENT.JOB_PRIORITY,
where: { id: depositId }, attempts: PAYMENT.JOB_ATTEMPTS,
lock: { mode: "pessimistic_write" }, backoff: { type: "exponential", delay: PAYMENT.JOB_BACKOFF },
}); },
if (!depositRequest) throw new BadRequestException(PaymentMessage.DEPOSIT_NOT_FOUND); );
if (depositRequest.status === DepositRequestStatus.REJECTED) throw new BadRequestException(PaymentMessage.DEPOSIT_ALREADY_REJECTED);
await queryRunner.manager.update(
DepositRequest,
{ id: depositRequest.id },
{ status: DepositRequestStatus.REJECTED, comment: rejectDto.comment },
);
await queryRunner.commitTransaction();
return {
message: PaymentMessage.DEPOSIT_REJECTED,
depositRequest,
};
} catch (error) {
await queryRunner.rollbackTransaction();
throw error;
} finally {
await queryRunner.release();
}
} }
} }
+229 -6
View File
@@ -1,16 +1,239 @@
import { Processor } from "@nestjs/bullmq"; import { InjectQueue, Processor } from "@nestjs/bullmq";
import { Logger } from "@nestjs/common"; import { Logger } from "@nestjs/common";
import { Job } from "bullmq"; import { Job, Queue } from "bullmq";
import Decimal from "decimal.js";
import { DataSource, QueryRunner } from "typeorm";
import { WorkerProcessor } from "../../../common/queues/worker.processor"; import { WorkerProcessor } from "../../../common/queues/worker.processor";
import { NotificationsService } from "../../notifications/providers/notifications.service";
import { PAYMENT } from "../constants"; import { PAYMENT } from "../constants";
import { Payment } from "../entities/payment.entity";
import { PaymentStatus } from "../enums/payment-status.enum";
import { PaymentGatewayFactory } from "../factories/payment.factory";
import { PaymentsService } from "../providers/payments.service";
import { PaymentJobData } from "../types/payment-job.type";
@Processor(PAYMENT.PAYMENT_QUEUE_NAME) @Processor(PAYMENT.QUEUE_NAME)
export class PaymentProcessor extends WorkerProcessor { export class PaymentProcessor extends WorkerProcessor {
protected readonly logger = new Logger(PaymentProcessor.name); protected readonly logger = new Logger(PaymentProcessor.name);
process(job: Job, token?: string) { constructor(
this.logger.log(job, token); @InjectQueue(PAYMENT.QUEUE_NAME) private readonly paymentQueue: Queue,
return Promise.resolve("test"); private readonly gatewayFactory: PaymentGatewayFactory,
private readonly dataSource: DataSource,
private readonly notificationsService: NotificationsService,
private readonly paymentsService: PaymentsService,
) {
super();
}
//===============================================
async process(job: Job<PaymentJobData>) {
this.logger.log(`Processing payment job: ${job.name}`);
const queryRunner = this.dataSource.createQueryRunner();
try {
await queryRunner.connect();
await queryRunner.startTransaction();
const result = await this.processJobWithTransaction(job, queryRunner);
await queryRunner.commitTransaction();
return result;
} catch (error) {
this.logger.error(`Error processing payment job: ${error}`);
await queryRunner.rollbackTransaction();
throw error;
} finally {
await queryRunner.release();
}
}
//===============================================
private async processJobWithTransaction(job: Job<PaymentJobData>, queryRunner: QueryRunner) {
switch (job.name) {
case PAYMENT.START:
return this.handlePaymentStart(job as Job<{ payment: Payment }>, queryRunner);
case PAYMENT.FIRST_REMINDER:
return this.handleFirstReminder(job as Job<{ paymentId: string }>, queryRunner);
case PAYMENT.SECOND_REMINDER:
return this.handleSecondReminder(job as Job<{ paymentId: string }>, queryRunner);
case PAYMENT.CANCELLATION:
return this.handlePaymentCancellation(job as Job<{ paymentId: string }>, queryRunner);
default:
this.logger.error(`Unknown job name: ${job.name}`);
return;
}
}
//===============================================
private async handlePaymentStart(job: Job<{ payment: Payment }>, _queryRunner: QueryRunner) {
const { payment } = job.data;
await this.scheduleFirstReminder(payment.id);
return payment;
}
//===============================================
private async handleFirstReminder(job: Job<{ paymentId: string }>, queryRunner: QueryRunner) {
const payment = await this.fetchPayment(job.data.paymentId, queryRunner);
if (!this.isPaymentPending(payment, queryRunner)) {
this.logger.log(`Payment ${payment.id} is no longer pending, skipping reminder`);
return payment;
}
await this.sendPaymentReminderNotification(payment, "first", queryRunner);
await this.scheduleSecondReminder(payment.id);
return payment;
}
//===============================================
private async handleSecondReminder(job: Job<{ paymentId: string }>, queryRunner: QueryRunner) {
const payment = await this.fetchPayment(job.data.paymentId, queryRunner);
if (!this.isPaymentPending(payment, queryRunner)) {
this.logger.log(`Payment ${payment.id} is no longer pending, skipping reminder`);
return payment;
}
await this.sendPaymentReminderNotification(payment, "second", queryRunner);
await this.schedulePaymentCancellation(payment.id);
return payment;
}
//===============================================
private async handlePaymentCancellation(job: Job<{ paymentId: string }>, queryRunner: QueryRunner) {
const payment = await this.fetchPayment(job.data.paymentId, queryRunner);
if (!this.isPaymentPending(payment, queryRunner)) {
this.logger.log(`Payment ${payment.id} is no longer pending, skipping cancellation`);
return payment;
}
await this.cancelPayment(payment, queryRunner);
return payment;
}
//===============================================
private async scheduleFirstReminder(paymentId: string) {
await this.paymentQueue.add(
PAYMENT.FIRST_REMINDER,
{ paymentId },
{
delay: PAYMENT.FIRST_REMINDER_DELAY,
priority: PAYMENT.JOB_PRIORITY,
attempts: PAYMENT.JOB_ATTEMPTS,
backoff: { type: "exponential", delay: PAYMENT.JOB_BACKOFF },
},
);
}
//===============================================
private async scheduleSecondReminder(paymentId: string) {
await this.paymentQueue.add(
PAYMENT.SECOND_REMINDER,
{ paymentId },
{
delay: PAYMENT.SECOND_REMINDER_DELAY,
priority: PAYMENT.JOB_PRIORITY,
attempts: PAYMENT.JOB_ATTEMPTS,
backoff: { type: "exponential", delay: PAYMENT.JOB_BACKOFF },
},
);
}
//===============================================
private async schedulePaymentCancellation(paymentId: string) {
await this.paymentQueue.add(
PAYMENT.CANCELLATION,
{ paymentId },
{
delay: PAYMENT.CANCELLATION_DELAY,
priority: PAYMENT.JOB_PRIORITY,
attempts: PAYMENT.JOB_ATTEMPTS,
backoff: { type: "exponential", delay: PAYMENT.JOB_BACKOFF },
},
);
}
//===============================================
private async cancelPayment(payment: Payment, queryRunner: QueryRunner) {
await queryRunner.manager.update(Payment, { id: payment.id }, { status: PaymentStatus.CANCELLED });
await this.sendPaymentCancellationNotification(payment, queryRunner);
}
//===============================================
private async fetchPayment(paymentId: string, queryRunner: QueryRunner): Promise<Payment> {
const payment = await queryRunner.manager.findOne(Payment, {
where: { id: paymentId },
relations: { user: true },
});
if (!payment) throw new Error(`Payment not found: ${paymentId}`);
return payment;
}
//===============================================
private async isPaymentPending(payment: Payment, queryRunner: QueryRunner): Promise<boolean> {
if (payment.status !== PaymentStatus.PENDING) {
this.logger.log(`Payment ${payment.id} is not in pending status`);
return false;
}
this.logger.log(`Verifying pending status for payment ${payment.id}`);
try {
const gateway = this.gatewayFactory.getPaymentGateway(payment.paymentGateway.name);
const verifyData = await gateway.verifyPayment({ reference: payment.reference, amount: payment.amount });
const result = await this.paymentsService.processVerificationResult(verifyData, payment, queryRunner);
if (result.status === PaymentStatus.FAILED || result.status === PaymentStatus.PENDING) {
this.logger.log(`Payment ${payment.id} verification returned code ${verifyData.code} (failed or pending)`);
return true;
}
this.logger.log(`Payment ${payment.id} verification returned code ${verifyData.code} (completed)`);
return false;
} catch (error) {
this.logger.error(`Error verifying payment ${payment.id}: ${error instanceof Error ? error.message : "Unknown error"}`);
return true;
}
}
//===============================================
private async sendPaymentReminderNotification(payment: Payment, reminderType: "first" | "second", queryRunner: QueryRunner) {
this.logger.log(`Sending ${reminderType} payment reminder for payment ${payment.id}`);
await this.notificationsService.createPaymentReminderNotification(
payment.user.id,
{
paymentId: payment.id,
amount: new Decimal(payment.amount).toNumber(),
userPhone: payment.user.phone,
userEmail: payment.user.email,
},
queryRunner,
);
}
//===============================================
private async sendPaymentCancellationNotification(payment: Payment, queryRunner: QueryRunner) {
this.logger.log(`Sending payment cancellation notification for payment ${payment.id}`);
await this.notificationsService.createPaymentCancellationNotification(
payment.user.id,
{
paymentId: payment.id,
amount: new Decimal(payment.amount).toNumber(),
userPhone: payment.user.phone,
userEmail: payment.user.email,
},
queryRunner,
);
} }
} }
@@ -0,0 +1,3 @@
import { Payment } from "../entities/payment.entity";
export type PaymentJobData = { payment: Payment } | { paymentId: string };
@@ -34,6 +34,8 @@ export enum NotifType {
// //
ASSIGN_TICKET = "ASSIGN_TICKET", ASSIGN_TICKET = "ASSIGN_TICKET",
RECURRING_INVOICE = "RECURRING_INVOICE", RECURRING_INVOICE = "RECURRING_INVOICE",
PAYMENT_REMINDER = "PAYMENT_REMINDER",
PAYMENT_CANCELLATION = "PAYMENT_CANCELLATION",
} }
export const NotifDescriptions: Record<NotifType, { fa: string; category: NotifCategory }> = { export const NotifDescriptions: Record<NotifType, { fa: string; category: NotifCategory }> = {
@@ -42,6 +44,8 @@ export const NotifDescriptions: Record<NotifType, { fa: string; category: NotifC
[NotifType.ANNOUNCEMENT]: { fa: "اعلانات و اطلاعیه ها", category: NotifCategory.ACCOUNT }, [NotifType.ANNOUNCEMENT]: { fa: "اعلانات و اطلاعیه ها", category: NotifCategory.ACCOUNT },
// Finance category // Finance category
[NotifType.PAYMENT_REMINDER]: { fa: "یادآوری پرداخت", category: NotifCategory.FINANCE },
[NotifType.PAYMENT_CANCELLATION]: { fa: "لغو پرداخت", category: NotifCategory.FINANCE },
[NotifType.WALLET_CHARGE]: { fa: "شارژ کیف پول", category: NotifCategory.FINANCE }, [NotifType.WALLET_CHARGE]: { fa: "شارژ کیف پول", category: NotifCategory.FINANCE },
[NotifType.WALLET_DEDUCTION]: { fa: "کسر از کیف پول", category: NotifCategory.FINANCE }, [NotifType.WALLET_DEDUCTION]: { fa: "کسر از کیف پول", category: NotifCategory.FINANCE },
@@ -2,7 +2,16 @@ import { Injectable, InternalServerErrorException, Logger } from "@nestjs/common
import { MailerService } from "@nestjs-modules/mailer"; import { MailerService } from "@nestjs-modules/mailer";
import { SentMessageInfo } from "nodemailer"; import { SentMessageInfo } from "nodemailer";
import { dateFormat, numberFormat } from "./international.utils";
import { EmailMessage } from "../../../common/enums/message.enum"; import { EmailMessage } from "../../../common/enums/message.enum";
import {
IAnnouncementNotificationData,
IInvoiceNotificationData,
IPaymentNotificationData,
ITicketNotificationData,
IWalletNotificationData,
} from "../../notifications/interfaces/ISendNotificationData";
import { IBaseNotificationData } from "../../notifications/interfaces/ISendNotificationData";
@Injectable() @Injectable()
export class EmailService { export class EmailService {
@@ -28,4 +37,120 @@ export class EmailService {
throw new InternalServerErrorException(EmailMessage.EMAIL_SENDING_FAILED); throw new InternalServerErrorException(EmailMessage.EMAIL_SENDING_FAILED);
} }
} }
async sendLoginEmail(data: IBaseNotificationData) {
try {
await this.mailerService.sendMail({
to: data.userEmail,
subject: EmailMessage.LOGIN,
template: "login",
context: {
date: dateFormat(new Date()),
},
});
} catch (error) {
this.logger.error("error in sending login email", error);
}
}
async sendInvoiceEmail(data: IInvoiceNotificationData) {
try {
await this.mailerService.sendMail({
to: data.userEmail,
subject: EmailMessage.INVOICE,
template: "invoice",
context: {
price: numberFormat(data.price),
createDate: dateFormat(data.createDate),
dueDate: dateFormat(data.dueDate),
invoiceId: data.invoiceId,
},
});
} catch (error) {
this.logger.error("error in sending invoice email", error);
}
}
async sendWalletEmail(data: IWalletNotificationData) {
try {
await this.mailerService.sendMail({
to: data.userEmail,
subject: EmailMessage.WALLET,
template: "wallet",
context: {
amount: numberFormat(data.amount),
balance: numberFormat(data.balance),
date: dateFormat(data.date),
reason: data.reason,
},
});
} catch (error) {
this.logger.error("error in sending wallet email", error);
}
}
async sendTicketEmail(data: ITicketNotificationData) {
try {
await this.mailerService.sendMail({
to: data.userEmail,
subject: EmailMessage.TICKET,
template: "ticket",
context: {
ticketId: data.ticketId,
subject: data.subject,
date: data.date ? dateFormat(data.date) : dateFormat(new Date()),
user: data.user,
},
});
} catch (error) {
this.logger.error("error in sending ticket email", error);
}
}
async sendAnnouncementEmail(data: IAnnouncementNotificationData) {
try {
await this.mailerService.sendMail({
to: data.userEmail,
subject: EmailMessage.ANNOUNCEMENT,
template: "announcement",
context: {
title: data.title,
description: data.description,
date: dateFormat(data.date),
},
});
} catch (error) {
this.logger.error("error in sending announcement email", error);
}
}
async sendPaymentReminderEmail(data: IPaymentNotificationData) {
try {
await this.mailerService.sendMail({
to: data.userEmail,
subject: EmailMessage.PAYMENT_REMINDER,
template: "payment-reminder",
context: {
amount: numberFormat(data.amount),
},
});
} catch (error) {
this.logger.error("error in sending payment reminder email", error);
}
}
async sendPaymentCancellationEmail(data: IPaymentNotificationData) {
try {
await this.mailerService.sendMail({
to: data.userEmail,
subject: EmailMessage.PAYMENT_CANCELLATION,
template: "payment-cancellation",
context: {
amount: numberFormat(data.amount),
},
});
} catch (error) {
this.logger.error("error in sending payment cancellation email", error);
}
}
} }
+50 -11
View File
@@ -527,21 +527,60 @@ export class SmsService {
} }
//************************************************* */ //************************************************* */
// sendCreateServiceSms(userPhone: string, serviceName: any) {
// throw new Error("Method not implemented.");
// }
//************************************************* */ //************************************************* */
// sendUnblockServiceSms(userPhone: string, serviceName: any) { async sendPaymentReminderSms(mobile: string, amount: string) {
// throw new Error("Method not implemented."); const smsData: ISmsVerifyBody = {
// } Parameters: [{ name: "amount", value: amount }],
Mobile: mobile,
TemplateId: this.smsConfigs.SMS_PATTERN_PAYMENT_REMINDER,
};
try {
const { data } = await firstValueFrom(
this.httpService
.post<ISmsVerifyResponse>(`${this.smsConfigs.API_URL}/send/verify`, smsData, {
headers: { "X-API-KEY": this.smsConfigs.API_KEY },
})
.pipe(
catchError((err: AxiosError) => {
this.logger.error("error in sending payment reminder sms", err);
throw new InternalServerErrorException("error in sending payment reminder sms");
}),
),
);
return data;
} catch (error) {
this.logger.error("error in sending sms", error);
}
}
//************************************************* */ //************************************************* */
// sendBlockServiceSms(userPhone: string, serviceName: any) { async sendPaymentCancellationSms(mobile: string, amount: string) {
// throw new Error("Method not implemented."); const smsData: ISmsVerifyBody = {
// } Parameters: [{ name: "amount", value: amount }],
//************************************************* */ Mobile: mobile,
TemplateId: this.smsConfigs.SMS_PATTERN_PAYMENT_CANCELLATION,
};
try {
const { data } = await firstValueFrom(
this.httpService
.post<ISmsVerifyResponse>(`${this.smsConfigs.API_URL}/send/verify`, smsData, {
headers: { "X-API-KEY": this.smsConfigs.API_KEY },
})
.pipe(
catchError((err: AxiosError) => {
this.logger.error("error in sending payment cancellation sms", err);
throw new InternalServerErrorException("error in sending payment cancellation sms");
}),
),
);
return data;
} catch (error) {
this.logger.error("error in sending sms", error);
}
}
async getSmsLines() { async getSmsLines() {
try { try {
-1
View File
@@ -7,7 +7,6 @@ import { PrometheusModule } from "@willsoto/nestjs-prometheus";
defaultMetrics: { defaultMetrics: {
enabled: true, enabled: true,
}, },
path: "/metrics",
defaultLabels: { defaultLabels: {
application: "danak_dsc_api", application: "danak_dsc_api",
}, },
View File
+24
View File
@@ -0,0 +1,24 @@
<html dir="rtl" lang="fa">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>اعلان</title>
</head>
<body
style="font-family: 'IRANSans', Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px; text-align: center;"
>
<div style="text-align: center; margin-bottom: 30px;">
<h1>اعلان</h1>
</div>
<div style="background-color: #f9f9f9; padding: 20px; border-radius: 5px;">
<p>سلام،</p>
<div style="font-size: 24px; color: #2c3e50; text-align: center; margin: 20px 0;">{{title}}</div>
<div style="margin: 20px 0; text-align: center;">{{description}}</div>
<div style="text-align: center; color: #666; font-size: 14px;">تاریخ: {{date}}</div>
<p>با تشکر</p>
</div>
<div style="text-align: center; margin-top: 30px; font-size: 12px; color: #666;">
<p>این ایمیل به صورت خودکار ارسال شده است. لطفاً به آن پاسخ ندهید.</p>
</div>
</body>
</html>
+23
View File
@@ -0,0 +1,23 @@
<html dir="rtl" lang="fa">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>لغو آزمون</title>
</head>
<body
style="font-family: 'IRANSans', Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px; text-align: center;"
>
<div style="text-align: center; margin-bottom: 30px;">
<h1>لغو آزمون</h1>
</div>
<div style="background-color: #f9f9f9; padding: 20px; border-radius: 5px;">
<p>سلام،</p>
<p>آزمون زیر با موفقیت لغو شد:</p>
<div style="font-size: 20px; color: #2c3e50; text-align: center; margin: 20px 0;">{{examTitle}}</div>
<p>با تشکر</p>
</div>
<div style="text-align: center; margin-top: 30px; font-size: 12px; color: #666;">
<p>این ایمیل به صورت خودکار ارسال شده است. لطفاً به آن پاسخ ندهید.</p>
</div>
</body>
</html>
+40
View File
@@ -0,0 +1,40 @@
<html dir="rtl" lang="fa">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>فاکتور</title>
</head>
<body
style="font-family: 'IRANSans', Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px; text-align: center;"
>
<div style="text-align: center; margin-bottom: 30px;">
<h1>فاکتور</h1>
</div>
<div style="background-color: #f9f9f9; padding: 20px; border-radius: 5px;">
<p>سلام،</p>
<p>جزئیات فاکتور شما به شرح زیر است:</p>
<div style="margin: 20px 0;">
<div style="display: flex; justify-content: space-between; margin: 10px 0;">
<span>مبلغ:</span>
<span>{{price}} تومان</span>
</div>
<div style="display: flex; justify-content: space-between; margin: 10px 0;">
<span>تاریخ ایجاد:</span>
<span>{{createDate}}</span>
</div>
<div style="display: flex; justify-content: space-between; margin: 10px 0;">
<span>تاریخ سررسید:</span>
<span>{{dueDate}}</span>
</div>
<div style="display: flex; justify-content: space-between; margin: 10px 0;">
<span>شناسه فاکتور:</span>
<span>{{invoiceId}}</span>
</div>
</div>
<p>با تشکر</p>
</div>
<div style="text-align: center; margin-top: 30px; font-size: 12px; color: #666;">
<p>این ایمیل به صورت خودکار ارسال شده است. لطفاً به آن پاسخ ندهید.</p>
</div>
</body>
</html>
+23
View File
@@ -0,0 +1,23 @@
<html dir="rtl" lang="fa">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ورود به سیستم</title>
</head>
<body
style="font-family: 'IRANSans', Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px; text-align: center;"
>
<div style="text-align: center; margin-bottom: 30px;">
<h1>ورود به سیستم</h1>
</div>
<div style="background-color: #f9f9f9; padding: 20px; border-radius: 5px;">
<p>سلام،</p>
<p>ورود به سیستم در تاریخ و ساعت زیر انجام شد:</p>
<div style="font-size: 20px; color: #2c3e50; text-align: center; margin: 20px 0;">{{date}}</div>
<p>با تشکر</p>
</div>
<div style="text-align: center; margin-top: 30px; font-size: 12px; color: #666;">
<p>این ایمیل به صورت خودکار ارسال شده است. لطفاً به آن پاسخ ندهید.</p>
</div>
</body>
</html>
@@ -0,0 +1,23 @@
<html dir="rtl" lang="fa">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>لغو پرداخت</title>
</head>
<body
style="font-family: 'IRANSans', Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px; text-align: center;"
>
<div style="text-align: center; margin-bottom: 30px;">
<h1>لغو پرداخت</h1>
</div>
<div style="background-color: #f9f9f9; padding: 20px; border-radius: 5px;">
<p>سلام،</p>
<p>پرداخت مبلغ زیر به دلیل عدم پرداخت در زمان مقرر لغو شد:</p>
<div style="font-size: 24px; color: #e74c3c; text-align: center; margin: 20px 0;">{{amount}} تومان</div>
<p>با تشکر</p>
</div>
<div style="text-align: center; margin-top: 30px; font-size: 12px; color: #666;">
<p>این ایمیل به صورت خودکار ارسال شده است. لطفاً به آن پاسخ ندهید.</p>
</div>
</body>
</html>
+23
View File
@@ -0,0 +1,23 @@
<html dir="rtl" lang="fa">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>یادآوری پرداخت</title>
</head>
<body
style="font-family: 'IRANSans', Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px; text-align: center;"
>
<div style="text-align: center; margin-bottom: 30px;">
<h1>یادآوری پرداخت</h1>
</div>
<div style="background-color: #f9f9f9; padding: 20px; border-radius: 5px;">
<p>سلام،</p>
<p>لطفاً نسبت به پرداخت مبلغ زیر اقدام فرمایید:</p>
<div style="font-size: 24px; color: #e74c3c; text-align: center; margin: 20px 0;">{{amount}} تومان</div>
<p>با تشکر</p>
</div>
<div style="text-align: center; margin-top: 30px; font-size: 12px; color: #666;">
<p>این ایمیل به صورت خودکار ارسال شده است. لطفاً به آن پاسخ ندهید.</p>
</div>
</body>
</html>
+42
View File
@@ -0,0 +1,42 @@
<html dir="rtl" lang="fa">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>تیکت پشتیبانی</title>
</head>
<body
style="font-family: 'IRANSans', Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px; text-align: center;"
>
<div style="text-align: center; margin-bottom: 30px;">
<h1>تیکت پشتیبانی</h1>
</div>
<div style="background-color: #f9f9f9; padding: 20px; border-radius: 5px;">
<p>سلام،</p>
<p>جزئیات تیکت پشتیبانی شما به شرح زیر است:</p>
<div style="margin: 20px 0;">
<div style="display: flex; justify-content: space-between; margin: 10px 0;">
<span>موضوع:</span>
<span>{{subject}}</span>
</div>
<div style="display: flex; justify-content: space-between; margin: 10px 0;">
<span>تاریخ:</span>
<span>{{date}}</span>
</div>
<div style="display: flex; justify-content: space-between; margin: 10px 0;">
<span>شناسه تیکت:</span>
<span>{{ticketId}}</span>
</div>
{{#if user}}
<div style="display: flex; justify-content: space-between; margin: 10px 0;">
<span>کارشناس:</span>
<span>{{user}}</span>
</div>
{{/if}}
</div>
<p>با تشکر</p>
</div>
<div style="text-align: center; margin-top: 30px; font-size: 12px; color: #666;">
<p>این ایمیل به صورت خودکار ارسال شده است. لطفاً به آن پاسخ ندهید.</p>
</div>
</body>
</html>
+40
View File
@@ -0,0 +1,40 @@
<html dir="rtl" lang="fa">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>اعلان کیف پول</title>
</head>
<body
style="font-family: 'IRANSans', Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px; text-align: center;"
>
<div style="text-align: center; margin-bottom: 30px;">
<h1>اعلان کیف پول</h1>
</div>
<div style="background-color: #f9f9f9; padding: 20px; border-radius: 5px;">
<p>سلام،</p>
<p>جزئیات تراکنش کیف پول شما به شرح زیر است:</p>
<div style="margin: 20px 0;">
<div style="display: flex; justify-content: space-between; margin: 10px 0;">
<span>مبلغ تراکنش:</span>
<div style="font-size: 24px; color: #2ecc71; text-align: center; margin: 20px 0;">{{amount}} تومان</div>
</div>
<div style="display: flex; justify-content: space-between; margin: 10px 0;">
<span>موجودی فعلی:</span>
<div style="font-size: 20px; color: #3498db; text-align: center; margin: 20px 0;">{{balance}} تومان</div>
</div>
<div style="display: flex; justify-content: space-between; margin: 10px 0;">
<span>تاریخ:</span>
<span>{{date}}</span>
</div>
<div style="display: flex; justify-content: space-between; margin: 10px 0;">
<span>دلیل:</span>
<span>{{reason}}</span>
</div>
</div>
<p>با تشکر</p>
</div>
<div style="text-align: center; margin-top: 30px; font-size: 12px; color: #666;">
<p>این ایمیل به صورت خودکار ارسال شده است. لطفاً به آن پاسخ ندهید.</p>
</div>
</body>
</html>