Deposit
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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',
|
||||
}
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user