Deposit
This commit is contained in:
@@ -33,6 +33,10 @@ NODE_ENV=development
|
|||||||
# CORS (comma-separated origins, e.g. https://app.example.com)
|
# CORS (comma-separated origins, e.g. https://app.example.com)
|
||||||
# CORS_ORIGIN=
|
# 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 Payment Gateway
|
||||||
ZARINPAL_MERCHANT_ID=8bf4bd49-df53-44b3-a58d-b068d4c1ec98
|
ZARINPAL_MERCHANT_ID=8bf4bd49-df53-44b3-a58d-b068d4c1ec98
|
||||||
ZARINPAL_SANDBOX=true
|
ZARINPAL_SANDBOX=true
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import { ProvincesModule } from './provinces/provinces.module';
|
|||||||
import { UploaderModule } from './uploader/uploader.module';
|
import { UploaderModule } from './uploader/uploader.module';
|
||||||
import { DashboardModule } from './dashboard/dashboard.module';
|
import { DashboardModule } from './dashboard/dashboard.module';
|
||||||
import { NotificationsModule } from './notifications/notifications.module';
|
import { NotificationsModule } from './notifications/notifications.module';
|
||||||
|
import { ShortLinksModule } from './short-links/short-links.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -51,6 +52,7 @@ import { NotificationsModule } from './notifications/notifications.module';
|
|||||||
UploaderModule,
|
UploaderModule,
|
||||||
DashboardModule,
|
DashboardModule,
|
||||||
NotificationsModule,
|
NotificationsModule,
|
||||||
|
ShortLinksModule,
|
||||||
],
|
],
|
||||||
controllers: [],
|
controllers: [],
|
||||||
providers: [],
|
providers: [],
|
||||||
|
|||||||
@@ -39,5 +39,7 @@ export default () => {
|
|||||||
process.env.S3_PUBLIC_BASE_URL ??
|
process.env.S3_PUBLIC_BASE_URL ??
|
||||||
process.env.S3_ENDPOINT ??
|
process.env.S3_ENDPOINT ??
|
||||||
'https://c274938.parspack.net',
|
'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 { NestFactory } from '@nestjs/core';
|
||||||
import { AppModule } from './app.module';
|
import { AppModule } from './app.module';
|
||||||
import { ApiExceptionFilter } from './common/filters/api-exception.filter';
|
import { ApiExceptionFilter } from './common/filters/api-exception.filter';
|
||||||
@@ -20,8 +20,10 @@ async function bootstrap() {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Global prefix
|
// Global prefix (short links stay outside "api" so shared URLs are shorter)
|
||||||
app.setGlobalPrefix('api');
|
app.setGlobalPrefix('api', {
|
||||||
|
exclude: [{ path: 's/:code', method: RequestMethod.GET }],
|
||||||
|
});
|
||||||
|
|
||||||
setupSwagger(app);
|
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(
|
export const NOTIFICATION_SCENARIO_MAP = new Map(
|
||||||
|
|||||||
@@ -5,4 +5,5 @@ export enum NotificationScenarioKey {
|
|||||||
RESERVE_REMINDER_SENT = 'reserve_reminder_sent',
|
RESERVE_REMINDER_SENT = 'reserve_reminder_sent',
|
||||||
CAMPAIGN_SENT = 'campaign_sent',
|
CAMPAIGN_SENT = 'campaign_sent',
|
||||||
CAMPAIGN_FAILED = 'campaign_failed',
|
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: {
|
async notifyCampaignSent(params: {
|
||||||
salon: Salon;
|
salon: Salon;
|
||||||
type: CampaignType;
|
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 { Reserve } from '../../reserves/entities/reserve.entity';
|
||||||
import { Salon } from '../../salons/entities/salon.entity';
|
import { Salon } from '../../salons/entities/salon.entity';
|
||||||
import { SubscriptionPlan } from '../../subscriptions/entities/subscription-plan.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 { PaymentStatus } from '../enums/payment-status.enum';
|
||||||
import { PaymentType } from '../enums/payment-type.enum';
|
import { PaymentType } from '../enums/payment-type.enum';
|
||||||
|
|
||||||
@@ -18,6 +20,7 @@ import { PaymentType } from '../enums/payment-type.enum';
|
|||||||
@Index(['reserveId'])
|
@Index(['reserveId'])
|
||||||
@Index(['salonId'])
|
@Index(['salonId'])
|
||||||
@Index(['authority'], { unique: true, where: '"authority" IS NOT NULL' })
|
@Index(['authority'], { unique: true, where: '"authority" IS NOT NULL' })
|
||||||
|
@Index(['publicToken'], { unique: true, where: '"publicToken" IS NOT NULL' })
|
||||||
export class Payment {
|
export class Payment {
|
||||||
@PrimaryGeneratedColumn('uuid')
|
@PrimaryGeneratedColumn('uuid')
|
||||||
id: string;
|
id: string;
|
||||||
@@ -73,6 +76,36 @@ export class Payment {
|
|||||||
@Column({ type: 'text' })
|
@Column({ type: 'text' })
|
||||||
description: string;
|
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()
|
@CreateDateColumn()
|
||||||
createdAt: Date;
|
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',
|
PAID = 'paid',
|
||||||
FAILED = 'failed',
|
FAILED = 'failed',
|
||||||
CANCELLED = 'cancelled',
|
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 { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
import { AuthGuardsModule } from '../auth/auth-guards.module';
|
import { AuthGuardsModule } from '../auth/auth-guards.module';
|
||||||
import { AuthorizationModule } from '../authorization/authorization.module';
|
import { AuthorizationModule } from '../authorization/authorization.module';
|
||||||
|
import { NotificationsModule } from '../notifications/notifications.module';
|
||||||
import { ReservesModule } from '../reserves/reserves.module';
|
import { ReservesModule } from '../reserves/reserves.module';
|
||||||
import { SalonsModule } from '../salons/salons.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 { 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 { Payment } from './entities/payment.entity';
|
||||||
import { PaymentsController } from './payments.controller';
|
import { PaymentsController } from './payments.controller';
|
||||||
import { PaymentsService } from './payments.service';
|
import { PaymentsService } from './payments.service';
|
||||||
@@ -20,9 +27,14 @@ import { ZarinpalService } from './zarinpal.service';
|
|||||||
forwardRef(() => SubscriptionsModule),
|
forwardRef(() => SubscriptionsModule),
|
||||||
AuthorizationModule,
|
AuthorizationModule,
|
||||||
AuthGuardsModule,
|
AuthGuardsModule,
|
||||||
|
SmsSettingsModule,
|
||||||
|
SmsModule,
|
||||||
|
ShortLinksModule,
|
||||||
|
UploaderModule,
|
||||||
|
NotificationsModule,
|
||||||
],
|
],
|
||||||
controllers: [PaymentsController],
|
controllers: [PaymentsController, DepositsController],
|
||||||
providers: [PaymentsService, ZarinpalService],
|
providers: [PaymentsService, ZarinpalService, CardToCardDepositService],
|
||||||
exports: [PaymentsService],
|
exports: [PaymentsService],
|
||||||
})
|
})
|
||||||
export class PaymentsModule {}
|
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 {
|
export class UpdateSalonDto {
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@@ -26,4 +35,10 @@ export class UpdateSalonDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsUUID()
|
@IsUUID()
|
||||||
cityId?: string;
|
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' })
|
@JoinColumn({ name: 'cityId' })
|
||||||
city: City | null;
|
city: City | null;
|
||||||
|
|
||||||
|
@Column({ type: 'jsonb', default: () => "'[]'" })
|
||||||
|
images: string[];
|
||||||
|
|
||||||
@CreateDateColumn()
|
@CreateDateColumn()
|
||||||
createdAt: Date;
|
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 { AuthorizationModule } from '../authorization/authorization.module';
|
||||||
import { ProvincesModule } from '../provinces/provinces.module';
|
import { ProvincesModule } from '../provinces/provinces.module';
|
||||||
import { UsersModule } from '../users/users.module';
|
import { UsersModule } from '../users/users.module';
|
||||||
|
import { SalonFinancialSetting } from './entities/salon-financial-setting.entity';
|
||||||
import { Salon } from './entities/salon.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 { SalonsController } from './salons.controller';
|
||||||
import { SalonsService } from './salons.service';
|
import { SalonsService } from './salons.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
TypeOrmModule.forFeature([Salon]),
|
TypeOrmModule.forFeature([Salon, SalonFinancialSetting]),
|
||||||
UsersModule,
|
UsersModule,
|
||||||
ProvincesModule,
|
ProvincesModule,
|
||||||
AuthorizationModule,
|
AuthorizationModule,
|
||||||
AuthGuardsModule,
|
AuthGuardsModule,
|
||||||
],
|
],
|
||||||
controllers: [SalonsController],
|
controllers: [SalonsController, SalonFinancialSettingsController],
|
||||||
providers: [SalonsService],
|
providers: [SalonsService, SalonFinancialSettingsService],
|
||||||
exports: [SalonsService],
|
exports: [SalonsService, SalonFinancialSettingsService],
|
||||||
})
|
})
|
||||||
export class SalonsModule {}
|
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(
|
export const SMS_SCENARIO_MAP = new Map(
|
||||||
|
|||||||
@@ -10,4 +10,8 @@ export enum SmsScenarioKey {
|
|||||||
CAMPAIGN_VIP = 'campaign_vip',
|
CAMPAIGN_VIP = 'campaign_vip',
|
||||||
CAMPAIGN_INACTIVE = 'campaign_inactive',
|
CAMPAIGN_INACTIVE = 'campaign_inactive',
|
||||||
CAMPAIGN_COMPLEMENTARY = 'campaign_complementary',
|
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);
|
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 {
|
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 {
|
function formatDate(date: string): string {
|
||||||
|
|||||||
Reference in New Issue
Block a user