This commit is contained in:
2026-07-10 13:26:55 +03:30
parent 9dc1728edc
commit cefd42de17
35 changed files with 1467 additions and 11 deletions
+4
View File
@@ -33,6 +33,10 @@ NODE_ENV=development
# CORS (comma-separated origins, e.g. https://app.example.com)
# CORS_ORIGIN=
# Public URLs used to build short links sent by SMS (e.g. deposit payment/review links)
FRONTEND_URL=http://localhost:3000
API_PUBLIC_URL=http://localhost:4000
# Zarinpal Payment Gateway
ZARINPAL_MERCHANT_ID=8bf4bd49-df53-44b3-a58d-b068d4c1ec98
ZARINPAL_SANDBOX=true
+2
View File
@@ -23,6 +23,7 @@ import { ProvincesModule } from './provinces/provinces.module';
import { UploaderModule } from './uploader/uploader.module';
import { DashboardModule } from './dashboard/dashboard.module';
import { NotificationsModule } from './notifications/notifications.module';
import { ShortLinksModule } from './short-links/short-links.module';
@Module({
imports: [
@@ -51,6 +52,7 @@ import { NotificationsModule } from './notifications/notifications.module';
UploaderModule,
DashboardModule,
NotificationsModule,
ShortLinksModule,
],
controllers: [],
providers: [],
+2
View File
@@ -39,5 +39,7 @@ export default () => {
process.env.S3_PUBLIC_BASE_URL ??
process.env.S3_ENDPOINT ??
'https://c274938.parspack.net',
FRONTEND_URL: process.env.FRONTEND_URL ?? 'http://localhost:3000',
API_PUBLIC_URL: process.env.API_PUBLIC_URL ?? 'http://localhost:4000',
};
};
@@ -0,0 +1,20 @@
import { MigrationInterface, QueryRunner, TableColumn } from 'typeorm';
export class AddSalonImages1721600000000 implements MigrationInterface {
name = 'AddSalonImages1721600000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.addColumn(
'salons',
new TableColumn({
name: 'images',
type: 'jsonb',
default: `'[]'`,
}),
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropColumn('salons', 'images');
}
}
@@ -0,0 +1,60 @@
import {
MigrationInterface,
QueryRunner,
Table,
TableForeignKey,
TableIndex,
} from 'typeorm';
export class AddSalonFinancialSettings1721700000000
implements MigrationInterface
{
name = 'AddSalonFinancialSettings1721700000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.createTable(
new Table({
name: 'salon_financial_settings',
columns: [
{
name: 'id',
type: 'uuid',
isPrimary: true,
generationStrategy: 'uuid',
default: 'gen_random_uuid()',
},
{ name: 'salonId', type: 'uuid' },
{ name: 'cardNumber', type: 'varchar', length: '19' },
{ name: 'cardHolderName', type: 'varchar', length: '100' },
{ name: 'createdAt', type: 'timestamp', default: 'now()' },
{ name: 'updatedAt', type: 'timestamp', default: 'now()' },
],
}),
true,
);
await queryRunner.createIndex(
'salon_financial_settings',
new TableIndex({
name: 'IDX_salon_financial_settings_salonId',
columnNames: ['salonId'],
isUnique: true,
}),
);
await queryRunner.createForeignKey(
'salon_financial_settings',
new TableForeignKey({
name: 'FK_salon_financial_settings_salonId',
columnNames: ['salonId'],
referencedTableName: 'salons',
referencedColumnNames: ['id'],
onDelete: 'CASCADE',
}),
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropTable('salon_financial_settings', true);
}
}
@@ -0,0 +1,41 @@
import { MigrationInterface, QueryRunner, Table, TableIndex } from 'typeorm';
export class AddShortLinks1721800000000 implements MigrationInterface {
name = 'AddShortLinks1721800000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.createTable(
new Table({
name: 'short_links',
columns: [
{
name: 'id',
type: 'uuid',
isPrimary: true,
generationStrategy: 'uuid',
default: 'gen_random_uuid()',
},
{ name: 'code', type: 'varchar', length: '12' },
{ name: 'targetUrl', type: 'text' },
{ name: 'clickCount', type: 'int', default: 0 },
{ name: 'expiresAt', type: 'timestamp', isNullable: true },
{ name: 'createdAt', type: 'timestamp', default: 'now()' },
],
}),
true,
);
await queryRunner.createIndex(
'short_links',
new TableIndex({
name: 'IDX_short_links_code',
columnNames: ['code'],
isUnique: true,
}),
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropTable('short_links', true);
}
}
@@ -0,0 +1,91 @@
import {
MigrationInterface,
QueryRunner,
TableColumn,
TableForeignKey,
TableIndex,
} from 'typeorm';
export class AddCardToCardDeposit1721900000000 implements MigrationInterface {
name = 'AddCardToCardDeposit1721900000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TYPE "payments_status_enum" ADD VALUE IF NOT EXISTS 'in_review'
`);
await queryRunner.query(`
ALTER TYPE "payments_status_enum" ADD VALUE IF NOT EXISTS 'rejected'
`);
await queryRunner.query(`
CREATE TYPE "payments_method_enum" AS ENUM('zarinpal', 'card_to_card')
`);
await queryRunner.addColumns('payments', [
new TableColumn({
name: 'method',
type: 'payments_method_enum',
default: `'zarinpal'`,
}),
new TableColumn({
name: 'publicToken',
type: 'varchar',
length: '64',
isNullable: true,
}),
new TableColumn({
name: 'receiptUrl',
type: 'text',
isNullable: true,
}),
new TableColumn({
name: 'rejectionReason',
type: 'text',
isNullable: true,
}),
new TableColumn({
name: 'reviewedAt',
type: 'timestamp',
isNullable: true,
}),
new TableColumn({
name: 'reviewedByUserId',
type: 'uuid',
isNullable: true,
}),
]);
await queryRunner.query(`
CREATE UNIQUE INDEX "IDX_payments_publicToken"
ON "payments" ("publicToken")
WHERE "publicToken" IS NOT NULL
`);
await queryRunner.createForeignKey(
'payments',
new TableForeignKey({
name: 'FK_payments_reviewedByUserId',
columnNames: ['reviewedByUserId'],
referencedTableName: 'users',
referencedColumnNames: ['id'],
onDelete: 'SET NULL',
}),
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropForeignKey('payments', 'FK_payments_reviewedByUserId');
await queryRunner.dropIndex('payments', 'IDX_payments_publicToken');
await queryRunner.dropColumns('payments', [
'reviewedByUserId',
'reviewedAt',
'rejectionReason',
'receiptUrl',
'publicToken',
'method',
]);
await queryRunner.query(`DROP TYPE "payments_method_enum"`);
// Note: Postgres does not support removing enum values; 'in_review' and
// 'rejected' remain in "payments_status_enum" after rollback.
}
}
@@ -0,0 +1,40 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
import { SmsScenarioKey } from '../../sms-settings/enums/sms-scenario-key.enum';
import { NotificationScenarioKey } from '../../notifications/enums/notification-scenario-key.enum';
const NEW_SMS_KEYS = [
SmsScenarioKey.DEPOSIT_PAYMENT_REQUEST,
SmsScenarioKey.DEPOSIT_RECEIPT_SUBMITTED,
SmsScenarioKey.DEPOSIT_APPROVED,
SmsScenarioKey.DEPOSIT_REJECTED,
];
const NEW_NOTIFICATION_KEYS = [NotificationScenarioKey.DEPOSIT_RECEIPT_SUBMITTED];
/**
* Only adds the new enum values. Postgres forbids using a newly added enum
* value in the same transaction it was created in, so seeding the actual
* scenario rows is handled by the follow-up `SeedDepositScenarios` migration.
*/
export class AddDepositScenarios1722000000000 implements MigrationInterface {
name = 'AddDepositScenarios1722000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
for (const key of NEW_SMS_KEYS) {
await queryRunner.query(
`ALTER TYPE "sms_scenario_key_enum" ADD VALUE IF NOT EXISTS '${key}'`,
);
}
for (const key of NEW_NOTIFICATION_KEYS) {
await queryRunner.query(
`ALTER TYPE "notification_scenario_key_enum" ADD VALUE IF NOT EXISTS '${key}'`,
);
}
}
public async down(): Promise<void> {
// Enum values cannot be dropped without recreating the type; left as-is on downgrade.
// Seeded rows (if any) are removed by SeedDepositScenarios's down().
}
}
@@ -0,0 +1,56 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
import { SMS_SCENARIOS } from '../../sms-settings/constants/sms-scenarios.constants';
import { SmsScenarioKey } from '../../sms-settings/enums/sms-scenario-key.enum';
import { NOTIFICATION_SCENARIOS } from '../../notifications/constants/notification-scenarios.constants';
import { NotificationScenarioKey } from '../../notifications/enums/notification-scenario-key.enum';
const NEW_SMS_KEYS = [
SmsScenarioKey.DEPOSIT_PAYMENT_REQUEST,
SmsScenarioKey.DEPOSIT_RECEIPT_SUBMITTED,
SmsScenarioKey.DEPOSIT_APPROVED,
SmsScenarioKey.DEPOSIT_REJECTED,
];
const NEW_NOTIFICATION_KEYS = [NotificationScenarioKey.DEPOSIT_RECEIPT_SUBMITTED];
/** Seeds the default scenario rows for the enum values added in AddDepositScenarios. */
export class SeedDepositScenarios1722100000000 implements MigrationInterface {
name = 'SeedDepositScenarios1722100000000';
public async up(queryRunner: QueryRunner): Promise<void> {
for (const key of NEW_SMS_KEYS) {
const scenario = SMS_SCENARIOS.find((s) => s.key === key);
if (!scenario) continue;
await queryRunner.query(
`
INSERT INTO sms_scenario_settings (key, body, "isActive")
VALUES ($1, $2, true)
ON CONFLICT (key) DO NOTHING
`,
[scenario.key, scenario.defaultBody],
);
}
for (const key of NEW_NOTIFICATION_KEYS) {
const scenario = NOTIFICATION_SCENARIOS.find((s) => s.key === key);
if (!scenario) continue;
await queryRunner.query(
`
INSERT INTO notification_scenario_settings (key, title, body, "isActive")
VALUES ($1, $2, $3, true)
ON CONFLICT (key) DO NOTHING
`,
[scenario.key, scenario.defaultTitle, scenario.defaultBody],
);
}
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`DELETE FROM "sms_scenario_settings" WHERE "key" IN (${NEW_SMS_KEYS.map((key) => `'${key}'`).join(', ')})`,
);
await queryRunner.query(
`DELETE FROM "notification_scenario_settings" WHERE "key" IN (${NEW_NOTIFICATION_KEYS.map((key) => `'${key}'`).join(', ')})`,
);
}
}
+5 -3
View File
@@ -1,4 +1,4 @@
import { ValidationPipe } from '@nestjs/common';
import { RequestMethod, ValidationPipe } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { ApiExceptionFilter } from './common/filters/api-exception.filter';
@@ -20,8 +20,10 @@ async function bootstrap() {
}),
);
// Global prefix
app.setGlobalPrefix('api');
// Global prefix (short links stay outside "api" so shared URLs are shorter)
app.setGlobalPrefix('api', {
exclude: [{ path: 's/:code', method: RequestMethod.GET }],
});
setupSwagger(app);
@@ -271,6 +271,34 @@ export const NOTIFICATION_SCENARIOS: NotificationScenarioDefinition[] = [
},
],
},
{
key: NotificationScenarioKey.DEPOSIT_RECEIPT_SUBMITTED,
label: 'ثبت رسید بیعانه',
description: 'اعلان برای سالن پس از بارگذاری رسید بیعانه توسط مشتری',
defaultTitle: 'رسید بیعانه ثبت شد',
defaultBody:
'مشتری {customerName} رسید پرداخت بیعانه {depositAmount} تومان را ثبت کرد. برای بررسی وارد شوید.',
variables: [
{
key: 'customerName',
label: 'نام مشتری',
description: 'نام و نام خانوادگی مشتری',
sampleValue: 'مریم احمدی',
},
{
key: 'salonName',
label: 'نام سالن',
description: 'نام سالن زیبایی',
sampleValue: 'سالن زیبایی رز',
},
{
key: 'depositAmount',
label: 'مبلغ بیعانه',
description: 'مبلغ بیعانه به تومان',
sampleValue: '۵۰۰,۰۰۰',
},
],
},
];
export const NOTIFICATION_SCENARIO_MAP = new Map(
@@ -5,4 +5,5 @@ export enum NotificationScenarioKey {
RESERVE_REMINDER_SENT = 'reserve_reminder_sent',
CAMPAIGN_SENT = 'campaign_sent',
CAMPAIGN_FAILED = 'campaign_failed',
DEPOSIT_RECEIPT_SUBMITTED = 'deposit_receipt_submitted',
}
@@ -78,6 +78,29 @@ export class SalonNotificationsService {
});
}
async notifyDepositReceiptSubmitted(params: {
salonId: string;
salonOwnerId: string;
salonName: string;
customerName: string;
depositAmount: number;
paymentId: string;
}): Promise<void> {
await this.safeCreate({
salonId: params.salonId,
userId: params.salonOwnerId,
key: NotificationScenarioKey.DEPOSIT_RECEIPT_SUBMITTED,
variables: {
customerName: params.customerName,
salonName: params.salonName,
depositAmount: params.depositAmount.toLocaleString('fa-IR'),
},
link: `/deposits/${params.paymentId}/review`,
relatedEntityType: 'payment',
relatedEntityId: params.paymentId,
});
}
async notifyCampaignSent(params: {
salon: Salon;
type: CampaignType;
@@ -0,0 +1,386 @@
import {
BadRequestException,
ForbiddenException,
forwardRef,
Inject,
Injectable,
Logger,
NotFoundException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { InjectRepository } from '@nestjs/typeorm';
import { randomUUID } from 'crypto';
import { Repository } from 'typeorm';
import { AuthUser } from '../auth/interfaces/auth-user.interface';
import { AuthorizationService } from '../authorization/authorization.service';
import { ReservePolicy } from '../authorization/policies/reserve.policy';
import { SalonNotificationsService } from '../notifications/salon-notifications.service';
import { ReserveStatus } from '../reserves/enums/reserve-status.enum';
import { ReservesService } from '../reserves/reserves.service';
import { calculateSmsParts } from '../sms/helpers/sms-segment.helper';
import { SmsService } from '../sms/sms.service';
import { SmsDispatchService } from '../sms-settings/sms-dispatch.service';
import { SmsScenarioKey } from '../sms-settings/enums/sms-scenario-key.enum';
import { buildReserveSmsVariables } from '../sms-settings/utils/build-reserve-sms-variables.util';
import { SalonFinancialSettingsService } from '../salons/salon-financial-settings.service';
import { SalonSubscriptionsService } from '../subscriptions/salon-subscriptions.service';
import { UploaderService } from '../uploader/uploader.service';
import { UserRole } from '../users/enums/user-role.enum';
import { ShortLinksService } from '../short-links/short-links.service';
import { PublicDepositPaymentDto } from './dto/public-deposit-payment.dto';
import { ReviewDepositDto } from './dto/review-deposit.dto';
import { Payment } from './entities/payment.entity';
import { PaymentMethod } from './enums/payment-method.enum';
import { PaymentStatus } from './enums/payment-status.enum';
import { PaymentType } from './enums/payment-type.enum';
const MIN_AMOUNT_TOMAN = 100;
@Injectable()
export class CardToCardDepositService {
private readonly logger = new Logger(CardToCardDepositService.name);
private readonly frontendUrl: string;
constructor(
@InjectRepository(Payment)
private readonly paymentsRepository: Repository<Payment>,
private readonly reservesService: ReservesService,
private readonly reservePolicy: ReservePolicy,
private readonly authorizationService: AuthorizationService,
private readonly salonFinancialSettingsService: SalonFinancialSettingsService,
private readonly uploaderService: UploaderService,
private readonly smsDispatchService: SmsDispatchService,
private readonly smsService: SmsService,
private readonly shortLinksService: ShortLinksService,
private readonly salonNotificationsService: SalonNotificationsService,
@Inject(forwardRef(() => SalonSubscriptionsService))
private readonly salonSubscriptionsService: SalonSubscriptionsService,
private readonly configService: ConfigService,
) {
this.frontendUrl = (
this.configService.get<string>('FRONTEND_URL') ?? 'http://localhost:3000'
).replace(/\/$/, '');
}
async requestDeposit(reserveId: string, user: AuthUser): Promise<Payment> {
if (user.role !== UserRole.SALON && user.role !== UserRole.ADMIN) {
throw new ForbiddenException();
}
const reserve = await this.reservesService.findOne(reserveId, user);
if (!this.reservePolicy.canRead(user, reserve)) {
throw new ForbiddenException();
}
if (reserve.status !== ReserveStatus.PENDING) {
throw new BadRequestException(
'Only pending reserves can be paid for deposit',
);
}
if (reserve.depositAmount <= 0) {
throw new BadRequestException('This reserve has no deposit amount');
}
if (reserve.depositAmount < MIN_AMOUNT_TOMAN) {
throw new BadRequestException(
`Minimum deposit amount is ${MIN_AMOUNT_TOMAN} Toman`,
);
}
const existingPaid = await this.paymentsRepository.existsBy({
reserveId,
type: PaymentType.DEPOSIT,
status: PaymentStatus.PAID,
});
if (existingPaid) {
throw new BadRequestException('Deposit has already been paid');
}
// Ensures the salon has configured a card before a request is sent to the customer.
await this.salonFinancialSettingsService.findBySalonId(reserve.salonId);
const existingPending = await this.paymentsRepository.findOne({
where: {
reserveId,
type: PaymentType.DEPOSIT,
method: PaymentMethod.CARD_TO_CARD,
},
order: { createdAt: 'DESC' },
});
if (existingPending?.status === PaymentStatus.IN_REVIEW) {
throw new BadRequestException(
'رسید پرداخت قبلی در انتظار بررسی است',
);
}
const payment =
existingPending &&
[PaymentStatus.PENDING, PaymentStatus.REJECTED].includes(
existingPending.status,
)
? existingPending
: this.paymentsRepository.create({
type: PaymentType.DEPOSIT,
method: PaymentMethod.CARD_TO_CARD,
amountRial: this.tomanToRial(reserve.depositAmount),
status: PaymentStatus.PENDING,
description: `پرداخت کارت به کارت بیعانه رزرو ${reserve.id}`,
reserveId: reserve.id,
publicToken: randomUUID(),
});
if (payment.status === PaymentStatus.REJECTED) {
payment.status = PaymentStatus.PENDING;
payment.rejectionReason = null;
payment.receiptUrl = null;
}
const saved = await this.paymentsRepository.save(payment);
const paymentLink = await this.shortLinksService.createShortUrl(
`${this.frontendUrl}/pay/${saved.publicToken}`,
);
const variables = buildReserveSmsVariables(reserve);
const mobile = reserve.customer?.user?.mobile;
if (mobile) {
await this.sendDepositSms(
SmsScenarioKey.DEPOSIT_PAYMENT_REQUEST,
mobile,
{ ...variables, paymentLink },
reserve.salonId,
);
}
return saved;
}
async getPublicPaymentByToken(token: string): Promise<PublicDepositPaymentDto> {
const payment = await this.paymentsRepository.findOne({
where: { publicToken: token },
relations: { reserve: { salon: true } },
});
if (!payment || !payment.reserve) {
throw new NotFoundException('لینک پرداخت یافت نشد');
}
const financial = await this.salonFinancialSettingsService.findBySalonId(
payment.reserve.salonId,
);
return {
paymentId: payment.id,
status: payment.status,
amountToman: this.rialToToman(payment.amountRial),
salon: {
name: payment.reserve.salon.name,
address: payment.reserve.salon.address,
images: payment.reserve.salon.images ?? [],
},
card: {
cardNumber: financial.cardNumber,
cardHolderName: financial.cardHolderName,
},
appointmentDate: payment.reserve.appointmentDate ?? null,
startTime: payment.reserve.startTime ?? null,
rejectionReason: payment.rejectionReason,
};
}
async submitReceipt(
token: string,
file: Express.Multer.File,
): Promise<PublicDepositPaymentDto> {
const payment = await this.paymentsRepository.findOne({
where: { publicToken: token },
relations: {
reserve: { salon: { owner: true }, customer: { user: true } },
},
});
if (!payment || !payment.reserve) {
throw new NotFoundException('لینک پرداخت یافت نشد');
}
if (
payment.status !== PaymentStatus.PENDING &&
payment.status !== PaymentStatus.REJECTED
) {
throw new BadRequestException(
'این پرداخت قبلاً ثبت شده و در انتظار بررسی یا تأیید است',
);
}
const uploaded = await this.uploaderService.uploadSingle(file);
payment.receiptUrl = uploaded.url;
payment.status = PaymentStatus.IN_REVIEW;
payment.rejectionReason = null;
const saved = await this.paymentsRepository.save(payment);
const reserve = payment.reserve;
const ownerMobile = reserve.salon?.owner?.mobile;
const variables = buildReserveSmsVariables(reserve);
if (ownerMobile) {
const reviewLink = await this.shortLinksService.createShortUrl(
`${this.frontendUrl}/deposits/${saved.id}/review`,
);
await this.sendDepositSms(
SmsScenarioKey.DEPOSIT_RECEIPT_SUBMITTED,
ownerMobile,
{ ...variables, reviewLink },
reserve.salonId,
);
}
await this.salonNotificationsService.notifyDepositReceiptSubmitted({
salonId: reserve.salonId,
salonOwnerId: reserve.salon.ownerId,
salonName: reserve.salon.name,
customerName: variables.customerName,
depositAmount: reserve.depositAmount,
paymentId: saved.id,
});
return this.getPublicPaymentByToken(token);
}
async findForReview(paymentId: string, user: AuthUser): Promise<Payment> {
const payment = await this.loadPaymentWithRelations(paymentId);
this.assertCanReview(user, payment);
return payment;
}
async reviewDeposit(
paymentId: string,
dto: ReviewDepositDto,
user: AuthUser,
): Promise<Payment> {
const payment = await this.loadPaymentWithRelations(paymentId);
this.assertCanReview(user, payment);
if (payment.status !== PaymentStatus.IN_REVIEW) {
throw new BadRequestException(
'این پرداخت در وضعیت قابل بررسی نیست',
);
}
// Guaranteed non-null by loadPaymentWithRelations().
const reserve = payment.reserve!;
const variables = buildReserveSmsVariables(reserve);
const customerMobile = reserve.customer?.user?.mobile;
if (dto.decision === 'approve') {
payment.status = PaymentStatus.PAID;
payment.reviewedAt = new Date();
payment.reviewedByUserId = user.id;
const saved = await this.paymentsRepository.save(payment);
await this.reservesService.confirmDepositPayment(reserve.id, user.id);
if (customerMobile) {
await this.sendDepositSms(
SmsScenarioKey.DEPOSIT_APPROVED,
customerMobile,
variables,
reserve.salonId,
);
}
return saved;
}
payment.status = PaymentStatus.REJECTED;
payment.rejectionReason = dto.rejectionReason ?? null;
payment.reviewedAt = new Date();
payment.reviewedByUserId = user.id;
const saved = await this.paymentsRepository.save(payment);
if (customerMobile) {
const paymentLink = await this.shortLinksService.createShortUrl(
`${this.frontendUrl}/pay/${saved.publicToken}`,
);
await this.sendDepositSms(
SmsScenarioKey.DEPOSIT_REJECTED,
customerMobile,
{
...variables,
rejectionReason: dto.rejectionReason ?? '—',
paymentLink,
},
reserve.salonId,
);
}
return saved;
}
private async loadPaymentWithRelations(paymentId: string): Promise<Payment> {
const payment = await this.paymentsRepository.findOne({
where: { id: paymentId },
relations: {
reserve: { salon: true, customer: { user: true } },
},
});
if (!payment || !payment.reserve) {
throw new NotFoundException(`Payment #${paymentId} not found`);
}
return payment;
}
private assertCanReview(user: AuthUser, payment: Payment): void {
if (this.authorizationService.isAdmin(user)) {
return;
}
if (
user.role === UserRole.SALON &&
payment.reserve?.salon.ownerId === user.id
) {
return;
}
throw new ForbiddenException();
}
/** Resolves the scenario message, deducts the salon's SMS balance, then sends it. */
private async sendDepositSms(
key: SmsScenarioKey,
mobile: string,
variables: Record<string, string>,
salonId: string,
): Promise<boolean> {
const message = await this.smsDispatchService.resolveMessage(key, variables);
if (!message) {
return false;
}
try {
await this.salonSubscriptionsService.consumeSms(
salonId,
calculateSmsParts(message),
);
} catch (error) {
this.logger.warn(
`Skipping deposit SMS "${key}" for salon ${salonId}: ${(error as Error).message}`,
);
return false;
}
return this.smsService.sendSms(mobile, message);
}
private tomanToRial(amountToman: number): number {
return Math.round(amountToman * 10);
}
private rialToToman(amountRial: number): number {
return Math.round(amountRial / 10);
}
}
+111
View File
@@ -0,0 +1,111 @@
import {
Body,
Controller,
Get,
Param,
ParseUUIDPipe,
Post,
UploadedFile,
UseGuards,
UseInterceptors,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { ApiBearerAuth, ApiBody, ApiConsumes, ApiTags } from '@nestjs/swagger';
import { memoryStorage } from 'multer';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { Public } from '../auth/decorators/public.decorator';
import { Roles } from '../auth/decorators/roles.decorator';
import { RolesGuard } from '../auth/guards/roles.guard';
import { AuthUser } from '../auth/interfaces/auth-user.interface';
import {
ApiStandardController,
ApiStandardOkResponse,
} from '../swagger/decorators/swagger-response.decorator';
import { ApiPublicEndpoint } from '../swagger/decorators/swagger-auth.decorator';
import { SWAGGER_JWT_AUTH } from '../swagger/swagger.setup';
import { UserRole } from '../users/enums/user-role.enum';
import { CardToCardDepositService } from './card-to-card-deposit.service';
import { PublicDepositPaymentDto } from './dto/public-deposit-payment.dto';
import { ReviewDepositDto } from './dto/review-deposit.dto';
import { Payment } from './entities/payment.entity';
const MAX_RECEIPT_SIZE_BYTES = 10 * 1024 * 1024;
@ApiTags('Deposits (Card to Card)')
@ApiStandardController()
@Controller('deposits')
export class DepositsController {
constructor(
private readonly cardToCardDepositService: CardToCardDepositService,
) {}
@ApiBearerAuth(SWAGGER_JWT_AUTH)
@UseGuards(RolesGuard)
@Roles(UserRole.ADMIN, UserRole.SALON)
@ApiStandardOkResponse(Payment)
@Post('reserves/:reserveId')
requestDeposit(
@Param('reserveId', ParseUUIDPipe) reserveId: string,
@CurrentUser() user: AuthUser,
) {
return this.cardToCardDepositService.requestDeposit(reserveId, user);
}
@Public()
@ApiPublicEndpoint('Get card-to-card deposit payment details by public token')
@ApiStandardOkResponse(PublicDepositPaymentDto)
@Get('public/:token')
getPublicPayment(@Param('token') token: string) {
return this.cardToCardDepositService.getPublicPaymentByToken(token);
}
@Public()
@ApiPublicEndpoint('Upload the payment receipt for a card-to-card deposit')
@ApiConsumes('multipart/form-data')
@ApiBody({
schema: {
type: 'object',
required: ['file'],
properties: { file: { type: 'string', format: 'binary' } },
},
})
@ApiStandardOkResponse(PublicDepositPaymentDto)
@UseInterceptors(
FileInterceptor('file', {
storage: memoryStorage(),
limits: { fileSize: MAX_RECEIPT_SIZE_BYTES },
}),
)
@Post('public/:token/receipt')
submitReceipt(
@Param('token') token: string,
@UploadedFile() file: Express.Multer.File,
) {
return this.cardToCardDepositService.submitReceipt(token, file);
}
@ApiBearerAuth(SWAGGER_JWT_AUTH)
@UseGuards(RolesGuard)
@Roles(UserRole.ADMIN, UserRole.SALON)
@ApiStandardOkResponse(Payment)
@Get(':id')
findForReview(
@Param('id', ParseUUIDPipe) id: string,
@CurrentUser() user: AuthUser,
) {
return this.cardToCardDepositService.findForReview(id, user);
}
@ApiBearerAuth(SWAGGER_JWT_AUTH)
@UseGuards(RolesGuard)
@Roles(UserRole.ADMIN, UserRole.SALON)
@ApiStandardOkResponse(Payment)
@Post(':id/review')
review(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: ReviewDepositDto,
@CurrentUser() user: AuthUser,
) {
return this.cardToCardDepositService.reviewDeposit(id, dto, user);
}
}
@@ -0,0 +1,47 @@
import { ApiProperty } from '@nestjs/swagger';
import { PaymentStatus } from '../enums/payment-status.enum';
class PublicDepositSalonDto {
@ApiProperty({ example: 'سالن زیبایی رز' })
name: string;
@ApiProperty({ example: 'تهران، خیابان ولیعصر' })
address: string;
@ApiProperty({ type: [String] })
images: string[];
}
class PublicDepositCardDto {
@ApiProperty({ example: '6037997012345678' })
cardNumber: string;
@ApiProperty({ example: 'مریم احمدی' })
cardHolderName: string;
}
export class PublicDepositPaymentDto {
@ApiProperty({ example: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890' })
paymentId: string;
@ApiProperty({ enum: PaymentStatus })
status: PaymentStatus;
@ApiProperty({ example: 500000 })
amountToman: number;
@ApiProperty({ type: PublicDepositSalonDto })
salon: PublicDepositSalonDto;
@ApiProperty({ type: PublicDepositCardDto })
card: PublicDepositCardDto;
@ApiProperty({ example: '۱۴۰۴/۰۴/۲۰', required: false, nullable: true })
appointmentDate: string | null;
@ApiProperty({ example: '۱۰:۰۰', required: false, nullable: true })
startTime: string | null;
@ApiProperty({ required: false, nullable: true })
rejectionReason: string | null;
}
+11
View File
@@ -0,0 +1,11 @@
import { IsIn, IsString, Length, ValidateIf } from 'class-validator';
export class ReviewDepositDto {
@IsIn(['approve', 'reject'])
decision: 'approve' | 'reject';
@ValidateIf((dto: ReviewDepositDto) => dto.decision === 'reject')
@IsString()
@Length(1, 500)
rejectionReason?: string;
}
+33
View File
@@ -11,6 +11,8 @@ import {
import { Reserve } from '../../reserves/entities/reserve.entity';
import { Salon } from '../../salons/entities/salon.entity';
import { SubscriptionPlan } from '../../subscriptions/entities/subscription-plan.entity';
import { User } from '../../users/entities/user.entity';
import { PaymentMethod } from '../enums/payment-method.enum';
import { PaymentStatus } from '../enums/payment-status.enum';
import { PaymentType } from '../enums/payment-type.enum';
@@ -18,6 +20,7 @@ import { PaymentType } from '../enums/payment-type.enum';
@Index(['reserveId'])
@Index(['salonId'])
@Index(['authority'], { unique: true, where: '"authority" IS NOT NULL' })
@Index(['publicToken'], { unique: true, where: '"publicToken" IS NOT NULL' })
export class Payment {
@PrimaryGeneratedColumn('uuid')
id: string;
@@ -73,6 +76,36 @@ export class Payment {
@Column({ type: 'text' })
description: string;
@Column({
type: 'enum',
enum: PaymentMethod,
enumName: 'payments_method_enum',
default: PaymentMethod.ZARINPAL,
})
method: PaymentMethod;
/** Card-to-card only: opaque token used by the public customer payment page. */
@Column({ type: 'varchar', length: 64, nullable: true })
publicToken: string | null;
/** Card-to-card only: URL of the receipt image/PDF uploaded by the customer. */
@Column({ type: 'text', nullable: true })
receiptUrl: string | null;
/** Card-to-card only: reason provided by the salon when rejecting the receipt. */
@Column({ type: 'text', nullable: true })
rejectionReason: string | null;
@Column({ type: 'timestamp', nullable: true })
reviewedAt: Date | null;
@Column({ type: 'uuid', nullable: true })
reviewedByUserId: string | null;
@ManyToOne(() => User, { onDelete: 'SET NULL', nullable: true })
@JoinColumn({ name: 'reviewedByUserId' })
reviewedByUser: User | null;
@CreateDateColumn()
createdAt: Date;
@@ -0,0 +1,4 @@
export enum PaymentMethod {
ZARINPAL = 'zarinpal',
CARD_TO_CARD = 'card_to_card',
}
@@ -3,4 +3,8 @@ export enum PaymentStatus {
PAID = 'paid',
FAILED = 'failed',
CANCELLED = 'cancelled',
/** Card-to-card only: customer uploaded a receipt, awaiting salon review. */
IN_REVIEW = 'in_review',
/** Card-to-card only: salon rejected the uploaded receipt. */
REJECTED = 'rejected',
}
+14 -2
View File
@@ -3,9 +3,16 @@ import { Module, forwardRef } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AuthGuardsModule } from '../auth/auth-guards.module';
import { AuthorizationModule } from '../authorization/authorization.module';
import { NotificationsModule } from '../notifications/notifications.module';
import { ReservesModule } from '../reserves/reserves.module';
import { SalonsModule } from '../salons/salons.module';
import { ShortLinksModule } from '../short-links/short-links.module';
import { SmsModule } from '../sms/sms.module';
import { SmsSettingsModule } from '../sms-settings/sms-settings.module';
import { SubscriptionsModule } from '../subscriptions/subscriptions.module';
import { UploaderModule } from '../uploader/uploader.module';
import { CardToCardDepositService } from './card-to-card-deposit.service';
import { DepositsController } from './deposits.controller';
import { Payment } from './entities/payment.entity';
import { PaymentsController } from './payments.controller';
import { PaymentsService } from './payments.service';
@@ -20,9 +27,14 @@ import { ZarinpalService } from './zarinpal.service';
forwardRef(() => SubscriptionsModule),
AuthorizationModule,
AuthGuardsModule,
SmsSettingsModule,
SmsModule,
ShortLinksModule,
UploaderModule,
NotificationsModule,
],
controllers: [PaymentsController],
providers: [PaymentsService, ZarinpalService],
controllers: [PaymentsController, DepositsController],
providers: [PaymentsService, ZarinpalService, CardToCardDepositService],
exports: [PaymentsService],
})
export class PaymentsModule {}
+16 -1
View File
@@ -1,4 +1,13 @@
import { IsOptional, IsString, IsUUID, Length, Matches } from 'class-validator';
import {
ArrayMaxSize,
IsArray,
IsOptional,
IsString,
IsUUID,
IsUrl,
Length,
Matches,
} from 'class-validator';
export class UpdateSalonDto {
@IsOptional()
@@ -26,4 +35,10 @@ export class UpdateSalonDto {
@IsOptional()
@IsUUID()
cityId?: string;
@IsOptional()
@IsArray()
@ArrayMaxSize(8)
@IsUrl({}, { each: true })
images?: string[];
}
@@ -0,0 +1,15 @@
import { IsNotEmpty, IsString, Length, Matches } from 'class-validator';
export class UpsertSalonFinancialSettingDto {
@IsString()
@IsNotEmpty()
@Matches(/^\d{16,19}$/, {
message: 'cardNumber must contain 16 to 19 digits only',
})
cardNumber: string;
@IsString()
@IsNotEmpty()
@Length(1, 100)
cardHolderName: string;
}
@@ -0,0 +1,37 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
JoinColumn,
OneToOne,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import { Salon } from './salon.entity';
@Entity('salon_financial_settings')
@Index(['salonId'], { unique: true })
export class SalonFinancialSetting {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid' })
salonId: string;
@OneToOne(() => Salon, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'salonId' })
salon: Salon;
@Column({ length: 19 })
cardNumber: string;
@Column({ length: 100 })
cardHolderName: string;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}
+3
View File
@@ -49,6 +49,9 @@ export class Salon {
@JoinColumn({ name: 'cityId' })
city: City | null;
@Column({ type: 'jsonb', default: () => "'[]'" })
images: string[];
@CreateDateColumn()
createdAt: Date;
@@ -0,0 +1,52 @@
import { Body, Controller, Get, Param, ParseUUIDPipe, Put, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { Permissions } from '../auth/decorators/permissions.decorator';
import { Roles } from '../auth/decorators/roles.decorator';
import { PermissionsGuard } from '../auth/guards/permissions.guard';
import { RolesGuard } from '../auth/guards/roles.guard';
import { AuthUser } from '../auth/interfaces/auth-user.interface';
import { PermissionCode } from '../authorization/enums/permission-code.enum';
import {
ApiStandardController,
ApiStandardOkResponse,
} from '../swagger/decorators/swagger-response.decorator';
import { SWAGGER_JWT_AUTH } from '../swagger/swagger.setup';
import { UserRole } from '../users/enums/user-role.enum';
import { UpsertSalonFinancialSettingDto } from './dto/upsert-salon-financial-setting.dto';
import { SalonFinancialSetting } from './entities/salon-financial-setting.entity';
import { SalonFinancialSettingsService } from './salon-financial-settings.service';
@ApiTags('Salon Financial Settings')
@ApiBearerAuth(SWAGGER_JWT_AUTH)
@ApiStandardController()
@Controller('salons/:salonId/financial-settings')
@UseGuards(RolesGuard, PermissionsGuard)
export class SalonFinancialSettingsController {
constructor(
private readonly financialSettingsService: SalonFinancialSettingsService,
) {}
@Roles(UserRole.ADMIN, UserRole.SALON)
@Permissions(PermissionCode.SALONS_READ)
@ApiStandardOkResponse(SalonFinancialSetting, { nullable: true })
@Get()
findOne(
@Param('salonId', ParseUUIDPipe) salonId: string,
@CurrentUser() user: AuthUser,
) {
return this.financialSettingsService.findForOwner(salonId, user);
}
@Roles(UserRole.ADMIN, UserRole.SALON)
@Permissions(PermissionCode.SALONS_WRITE)
@ApiStandardOkResponse(SalonFinancialSetting)
@Put()
upsert(
@Param('salonId', ParseUUIDPipe) salonId: string,
@Body() dto: UpsertSalonFinancialSettingDto,
@CurrentUser() user: AuthUser,
) {
return this.financialSettingsService.upsert(salonId, dto, user);
}
}
@@ -0,0 +1,66 @@
import {
BadRequestException,
ForbiddenException,
Injectable,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { AuthUser } from '../auth/interfaces/auth-user.interface';
import { SalonPolicy } from '../authorization/policies/salon.policy';
import { UpsertSalonFinancialSettingDto } from './dto/upsert-salon-financial-setting.dto';
import { SalonFinancialSetting } from './entities/salon-financial-setting.entity';
import { SalonsService } from './salons.service';
@Injectable()
export class SalonFinancialSettingsService {
constructor(
@InjectRepository(SalonFinancialSetting)
private readonly repository: Repository<SalonFinancialSetting>,
private readonly salonsService: SalonsService,
private readonly salonPolicy: SalonPolicy,
) {}
async findForOwner(
salonId: string,
user: AuthUser,
): Promise<SalonFinancialSetting | null> {
const salon = await this.salonsService.findOne(salonId);
if (!this.salonPolicy.canRead(user, salon)) {
throw new ForbiddenException();
}
return this.repository.findOne({ where: { salonId } });
}
async upsert(
salonId: string,
dto: UpsertSalonFinancialSettingDto,
user: AuthUser,
): Promise<SalonFinancialSetting> {
const salon = await this.salonsService.findOne(salonId);
if (!this.salonPolicy.canUpdate(user, salon)) {
throw new ForbiddenException();
}
const existing = await this.repository.findOne({ where: { salonId } });
const entity = this.repository.create({
...existing,
salonId,
cardNumber: dto.cardNumber,
cardHolderName: dto.cardHolderName,
});
return this.repository.save(entity);
}
/** Used internally by the deposit flow; not policy-checked (system access). */
async findBySalonId(salonId: string): Promise<SalonFinancialSetting> {
const setting = await this.repository.findOne({ where: { salonId } });
if (!setting) {
throw new BadRequestException(
'اطلاعات مالی (شماره کارت) سالن ثبت نشده است',
);
}
return setting;
}
}
+7 -4
View File
@@ -4,20 +4,23 @@ import { AuthGuardsModule } from '../auth/auth-guards.module';
import { AuthorizationModule } from '../authorization/authorization.module';
import { ProvincesModule } from '../provinces/provinces.module';
import { UsersModule } from '../users/users.module';
import { SalonFinancialSetting } from './entities/salon-financial-setting.entity';
import { Salon } from './entities/salon.entity';
import { SalonFinancialSettingsController } from './salon-financial-settings.controller';
import { SalonFinancialSettingsService } from './salon-financial-settings.service';
import { SalonsController } from './salons.controller';
import { SalonsService } from './salons.service';
@Module({
imports: [
TypeOrmModule.forFeature([Salon]),
TypeOrmModule.forFeature([Salon, SalonFinancialSetting]),
UsersModule,
ProvincesModule,
AuthorizationModule,
AuthGuardsModule,
],
controllers: [SalonsController],
providers: [SalonsService],
exports: [SalonsService],
controllers: [SalonsController, SalonFinancialSettingsController],
providers: [SalonsService, SalonFinancialSettingsService],
exports: [SalonsService, SalonFinancialSettingsService],
})
export class SalonsModule {}
@@ -0,0 +1,29 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
PrimaryGeneratedColumn,
} from 'typeorm';
@Entity('short_links')
export class ShortLink {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ length: 12 })
@Index({ unique: true })
code: string;
@Column({ type: 'text' })
targetUrl: string;
@Column({ type: 'int', default: 0 })
clickCount: number;
@Column({ type: 'timestamp', nullable: true })
expiresAt: Date | null;
@CreateDateColumn()
createdAt: Date;
}
+22
View File
@@ -0,0 +1,22 @@
import { Controller, Get, Param, Res } from '@nestjs/common';
import { ApiExcludeController } from '@nestjs/swagger';
import type { Response } from 'express';
import { Public } from '../auth/decorators/public.decorator';
import { ShortLinksService } from './short-links.service';
/**
* Registered outside the global `api` prefix (see main.ts) so links stay short,
* e.g. `https://api.example.com/s/aB3xQ9k`.
*/
@ApiExcludeController()
@Controller('s')
export class ShortLinksController {
constructor(private readonly shortLinksService: ShortLinksService) {}
@Public()
@Get(':code')
async redirect(@Param('code') code: string, @Res() res: Response) {
const targetUrl = await this.shortLinksService.resolveAndTrack(code);
return res.redirect(targetUrl);
}
}
+13
View File
@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ShortLink } from './entities/short-link.entity';
import { ShortLinksController } from './short-links.controller';
import { ShortLinksService } from './short-links.service';
@Module({
imports: [TypeOrmModule.forFeature([ShortLink])],
controllers: [ShortLinksController],
providers: [ShortLinksService],
exports: [ShortLinksService],
})
export class ShortLinksModule {}
+77
View File
@@ -0,0 +1,77 @@
import { GoneException, Injectable, NotFoundException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { InjectRepository } from '@nestjs/typeorm';
import { randomBytes } from 'crypto';
import { Repository } from 'typeorm';
import { ShortLink } from './entities/short-link.entity';
const CODE_ALPHABET =
'abcdefghijkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789';
const CODE_LENGTH = 7;
const MAX_GENERATION_ATTEMPTS = 5;
@Injectable()
export class ShortLinksService {
private readonly apiBaseUrl: string;
constructor(
@InjectRepository(ShortLink)
private readonly repository: Repository<ShortLink>,
private readonly configService: ConfigService,
) {
this.apiBaseUrl = (
this.configService.get<string>('API_PUBLIC_URL') ??
'http://localhost:4000'
).replace(/\/$/, '');
}
/** Creates a short link and returns its public, shareable URL. */
async createShortUrl(
targetUrl: string,
expiresAt: Date | null = null,
): Promise<string> {
const code = await this.generateUniqueCode();
await this.repository.save(
this.repository.create({ code, targetUrl, expiresAt }),
);
return `${this.apiBaseUrl}/s/${code}`;
}
async resolveAndTrack(code: string): Promise<string> {
const shortLink = await this.repository.findOne({ where: { code } });
if (!shortLink) {
throw new NotFoundException('لینک یافت نشد');
}
if (shortLink.expiresAt && shortLink.expiresAt.getTime() < Date.now()) {
throw new GoneException('این لینک منقضی شده است');
}
shortLink.clickCount += 1;
await this.repository.save(shortLink);
return shortLink.targetUrl;
}
private async generateUniqueCode(): Promise<string> {
for (let attempt = 0; attempt < MAX_GENERATION_ATTEMPTS; attempt += 1) {
const code = this.generateCode();
const exists = await this.repository.existsBy({ code });
if (!exists) {
return code;
}
}
throw new Error('Failed to generate a unique short link code');
}
private generateCode(): string {
const bytes = randomBytes(CODE_LENGTH);
let code = '';
for (let i = 0; i < CODE_LENGTH; i += 1) {
code += CODE_ALPHABET[bytes[i] % CODE_ALPHABET.length];
}
return code;
}
}
@@ -391,6 +391,135 @@ export const SMS_SCENARIOS: SmsScenarioDefinition[] = [
},
],
},
{
key: SmsScenarioKey.DEPOSIT_PAYMENT_REQUEST,
label: 'درخواست پرداخت بیعانه (کارت به کارت)',
description:
'پیامک ارسالی به مشتری برای پرداخت بیعانه به‌صورت کارت به کارت، همراه با لینک صفحه پرداخت',
defaultBody:
'{customerName} عزیز، لطفاً بیعانه {depositAmount} تومان رزرو خود در {salonName} را از طریق لینک زیر پرداخت کنید:\n{paymentLink}',
variables: [
{
key: 'customerName',
label: 'نام مشتری',
description: 'نام و نام خانوادگی مشتری',
sampleValue: 'مریم احمدی',
},
{
key: 'salonName',
label: 'نام سالن',
description: 'نام سالن زیبایی',
sampleValue: 'سالن زیبایی رز',
},
{
key: 'depositAmount',
label: 'مبلغ بیعانه',
description: 'مبلغ بیعانه به تومان',
sampleValue: '۵۰۰,۰۰۰',
},
{
key: 'paymentLink',
label: 'لینک پرداخت',
description: 'لینک کوتاه صفحه پرداخت بیعانه',
sampleValue: 'https://byb.ir/s/aB3xQ9k',
},
],
},
{
key: SmsScenarioKey.DEPOSIT_RECEIPT_SUBMITTED,
label: 'ثبت رسید بیعانه توسط مشتری',
description:
'پیامک ارسالی به سالن پس از بارگذاری رسید کارت به کارت توسط مشتری، همراه با لینک بررسی',
defaultBody:
'مشتری {customerName} رسید پرداخت بیعانه {depositAmount} تومان را ثبت کرد. برای بررسی و تأیید:\n{reviewLink}',
variables: [
{
key: 'customerName',
label: 'نام مشتری',
description: 'نام و نام خانوادگی مشتری',
sampleValue: 'مریم احمدی',
},
{
key: 'salonName',
label: 'نام سالن',
description: 'نام سالن زیبایی',
sampleValue: 'سالن زیبایی رز',
},
{
key: 'depositAmount',
label: 'مبلغ بیعانه',
description: 'مبلغ بیعانه به تومان',
sampleValue: '۵۰۰,۰۰۰',
},
{
key: 'reviewLink',
label: 'لینک بررسی',
description: 'لینک کوتاه صفحه بررسی رسید در پنل سالن',
sampleValue: 'https://byb.ir/s/kT7mN2p',
},
],
},
{
key: SmsScenarioKey.DEPOSIT_APPROVED,
label: 'تأیید رسید بیعانه',
description: 'پیامک ارسالی به مشتری پس از تأیید رسید کارت به کارت توسط سالن',
defaultBody:
'{customerName} عزیز، رسید پرداخت بیعانه شما تأیید شد و رزرو شما در {salonName} قطعی است.',
variables: [
{
key: 'customerName',
label: 'نام مشتری',
description: 'نام و نام خانوادگی مشتری',
sampleValue: 'مریم احمدی',
},
{
key: 'salonName',
label: 'نام سالن',
description: 'نام سالن زیبایی',
sampleValue: 'سالن زیبایی رز',
},
{
key: 'depositAmount',
label: 'مبلغ بیعانه',
description: 'مبلغ بیعانه به تومان',
sampleValue: '۵۰۰,۰۰۰',
},
],
},
{
key: SmsScenarioKey.DEPOSIT_REJECTED,
label: 'رد رسید بیعانه',
description:
'پیامک ارسالی به مشتری پس از رد رسید کارت به کارت توسط سالن، همراه با دلیل و لینک پرداخت مجدد',
defaultBody:
'{customerName} عزیز، رسید پرداخت بیعانه شما در {salonName} رد شد.\nدلیل: {rejectionReason}\nبرای پرداخت مجدد:\n{paymentLink}',
variables: [
{
key: 'customerName',
label: 'نام مشتری',
description: 'نام و نام خانوادگی مشتری',
sampleValue: 'مریم احمدی',
},
{
key: 'salonName',
label: 'نام سالن',
description: 'نام سالن زیبایی',
sampleValue: 'سالن زیبایی رز',
},
{
key: 'rejectionReason',
label: 'دلیل رد',
description: 'دلیل رد رسید توسط سالن',
sampleValue: 'کیفیت تصویر رسید نامشخص بود',
},
{
key: 'paymentLink',
label: 'لینک پرداخت',
description: 'لینک کوتاه صفحه پرداخت بیعانه',
sampleValue: 'https://byb.ir/s/aB3xQ9k',
},
],
},
];
export const SMS_SCENARIO_MAP = new Map(
@@ -10,4 +10,8 @@ export enum SmsScenarioKey {
CAMPAIGN_VIP = 'campaign_vip',
CAMPAIGN_INACTIVE = 'campaign_inactive',
CAMPAIGN_COMPLEMENTARY = 'campaign_complementary',
DEPOSIT_PAYMENT_REQUEST = 'deposit_payment_request',
DEPOSIT_RECEIPT_SUBMITTED = 'deposit_receipt_submitted',
DEPOSIT_APPROVED = 'deposit_approved',
DEPOSIT_REJECTED = 'deposit_rejected',
}
@@ -8,8 +8,21 @@ function formatTime(time: string): string {
return time.slice(0, 5);
}
const PERSIAN_DIGITS = ['۰', '۱', '۲', '۳', '۴', '۵', '۶', '۷', '۸', '۹'];
function toPersianDigits(value: string): string {
return value.replace(/[0-9]/g, (digit) => PERSIAN_DIGITS[Number(digit)]);
}
/**
* Formats amounts for SMS bodies using Persian digits grouped with a plain
* ASCII comma. `toLocaleString('fa-IR')` uses the Arabic thousands separator
* (U+066C) which several Iranian SMS gateways can't encode, turning it into
* a "?" on delivery — so we group manually instead.
*/
function formatAmount(amount: number): string {
return amount.toLocaleString('fa-IR');
const grouped = Math.round(amount).toLocaleString('en-US');
return toPersianDigits(grouped);
}
function formatDate(date: string): string {