feat: add per-org SMS wallet with adm panel and header credit

Bill all platform SMS (including OTP), allow negative balance with manual/auto cutoffs, and support own-panel bypass without wallet debit.
This commit is contained in:
2026-10-19 15:55:48 +03:30
parent f15ebb72ea
commit f5d68d83fc
42 changed files with 2073 additions and 121 deletions
+1 -2
View File
@@ -1,13 +1,12 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { SmsService } from '../common/sms.service';
import { LoyaltyController } from './loyalty.controller';
import { LoyaltyService } from './loyalty.service';
@Module({
imports: [AuthModule],
controllers: [LoyaltyController],
providers: [LoyaltyService, SmsService],
providers: [LoyaltyService],
exports: [LoyaltyService],
})
export class LoyaltyModule {}
+14 -7
View File
@@ -22,7 +22,7 @@ import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { buildSearchWhere, ci } from '../common/search';
import { type Actor, ownerWhere } from '../common/record-scope';
import { SmsService } from '../common/sms.service';
import { SmsGatewayService } from '../sms-billing/sms-gateway.service';
const DEFAULT_TIERS = [
{ name: 'برنزی', minPoints: 0, color: '#c9843a' },
@@ -57,7 +57,7 @@ const DAY_MS = 86_400_000;
export class LoyaltyService {
constructor(
private readonly prisma: PrismaService,
private readonly sms: SmsService,
private readonly sms: SmsGatewayService,
) {}
async ensureDefaults(organizationId: string): Promise<void> {
@@ -447,10 +447,12 @@ export class LoyaltyService {
const settings = await this.ensureSettings(actor.organizationId);
if (settings.notifyOnAdjust && updated.mobile) {
const sign = delta > 0 ? '+' : '';
void this.sms.sendSmsDetailed(
updated.mobile,
`باشگاه مشتریان: ${sign}${Math.trunc(delta)} امتیاز. موجودی: ${updated.loyaltyPoints}`,
);
void this.sms.send({
organizationId: actor.organizationId,
phone: updated.mobile,
text: `باشگاه مشتریان: ${sign}${Math.trunc(delta)} امتیاز. موجودی: ${updated.loyaltyPoints}`,
source: 'loyalty',
});
}
return updated;
}
@@ -504,7 +506,12 @@ export class LoyaltyService {
skipped += 1;
continue;
}
const result = await this.sms.sendSmsDetailed(p.mobile, body);
const result = await this.sms.send({
organizationId: actor.organizationId,
phone: p.mobile,
text: body,
source: 'loyalty',
});
if (result.ok) sent += 1;
else failed += 1;
}