Add SMS templates, prepaid IRT billing, and advertising shortcode 9000590009.

Charge ceil(len/64)*400 IRT per recipient across OTP, customer, and partner SMS; seed new businesses with 500k IRT; default partner sends to the advertising line.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-09-08 16:24:15 +03:30
co-authored by Cursor
parent 4623061a4f
commit 4a1d60dbef
29 changed files with 1170 additions and 61 deletions
+2 -1
View File
@@ -2,9 +2,10 @@ import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { CustomersController } from './customers.controller';
import { CustomersService } from './customers.service';
import { SmsTemplatesModule } from '../sms-templates/sms-templates.module';
@Module({
imports: [AuthModule],
imports: [AuthModule, SmsTemplatesModule],
controllers: [CustomersController],
providers: [CustomersService],
})
+225 -26
View File
@@ -4,6 +4,7 @@ import * as bcrypt from 'bcrypt';
import { randomBytes } from 'crypto';
import { AuthUser } from '../auth/auth.types';
import { PermissionsService } from '../auth/permissions.service';
import { SmsBillingService } from '../auth/sms-billing.service';
import { SmsService } from '../auth/sms.service';
import {
buildDualDailyActivitySeries,
@@ -21,6 +22,11 @@ import { SearchCustomersDto } from './dto/search-customers.dto';
import { SendCustomerSmsDto } from './dto/send-customer-sms.dto';
import { SendCustomersBulkSmsDto } from './dto/send-customers-bulk-sms.dto';
import { UpdateCustomerDto } from './dto/update-customer.dto';
import { SmsTemplatesService } from '../sms-templates/sms-templates.service';
import {
renderSmsTemplate,
smsTemplateUsesVariables,
} from '../sms-templates/sms-template-render';
type CustomerListRow = {
id: bigint;
@@ -43,6 +49,8 @@ export class CustomersService {
private readonly prisma: PrismaService,
private readonly permissions: PermissionsService,
private readonly sms: SmsService,
private readonly smsBilling: SmsBillingService,
private readonly smsTemplates: SmsTemplatesService,
) {}
async list(
@@ -692,7 +700,7 @@ export class CustomersService {
businessId_userId: { businessId, userId },
},
include: {
user: true,
user: { include: { title: true } },
business: { select: { settings: true, isActive: true } },
},
});
@@ -708,23 +716,36 @@ export class CustomersService {
};
}
const settings = normalizeBusinessSettings(membership.business.settings);
const source =
settings.sms.senderNumber?.trim() || this.sms.getAdvertisingSource();
const resolved = await this.resolveOutboundSms(
businessId,
membership.business.settings,
dto.message,
dto.templateId,
{
firstName: membership.user.firstName,
lastName: membership.user.lastName,
titleName: membership.user.title?.nameFa || membership.user.title?.nameEn,
},
);
const charged = await this.smsBilling.charge(businessId, resolved.message);
try {
const result = await this.sms.sendMessage(
membership.user.cellNumber,
dto.message.trim(),
{ source },
resolved.message,
{ source: resolved.source },
);
return {
enabled: true,
message: 'Message sent successfully',
serverId: result.serverId,
source,
source: resolved.source,
templateId: resolved.templateId,
templateVerified: resolved.templateVerified,
};
} catch (err) {
await this.smsBilling.refund(businessId, charged);
if (
err instanceof BadRequestException ||
err instanceof ServiceUnavailableException
@@ -736,8 +757,8 @@ export class CustomersService {
}
/**
* Bulk SMS (Gama SendBulk). Empty/omitted userIds → all enabled business customers.
* Selected userIds may include team members shown on the customers list.
* Bulk SMS. Empty/omitted userIds → all enabled business customers.
* With a personalized template, messages are sent per-recipient (not one bulk payload).
*/
async sendBulkSms(
businessIdRaw: string,
@@ -759,7 +780,14 @@ export class CustomersService {
.map((id) => id.trim())
.filter(Boolean);
let cellNumbers: string[];
type Recipient = {
cellNumber: string;
firstName: string | null;
lastName: string | null;
titleName: string | null;
};
let recipients: Recipient[];
if (selectedIds.length > 0) {
const userIds = selectedIds.map((id) => {
@@ -770,9 +798,21 @@ export class CustomersService {
}
});
const rows = await this.prisma.$queryRaw<{ cellNumber: string }[]>(Prisma.sql`
SELECT DISTINCT u.cell_number AS "cellNumber"
const rows = await this.prisma.$queryRaw<
{
cellNumber: string;
firstName: string | null;
lastName: string | null;
titleName: string | null;
}[]
>(Prisma.sql`
SELECT DISTINCT ON (u.id)
u.cell_number AS "cellNumber",
u.first_name AS "firstName",
u.last_name AS "lastName",
COALESCE(ut.name_fa, ut.name_en) AS "titleName"
FROM users u
LEFT JOIN user_titles ut ON ut.id = u.title_id
LEFT JOIN business_customers bc
ON bc.user_id = u.id AND bc.business_id = ${businessId}
LEFT JOIN business_users bu
@@ -780,16 +820,30 @@ export class CustomersService {
WHERE u.id IN (${Prisma.join(userIds)})
AND (bc.id IS NOT NULL OR bu.id IS NOT NULL)
`);
cellNumbers = rows.map((r) => r.cellNumber);
recipients = rows;
} else {
const rows = await this.prisma.businessCustomer.findMany({
where: { businessId, isEnabled: true },
select: { user: { select: { cellNumber: true } } },
select: {
user: {
select: {
cellNumber: true,
firstName: true,
lastName: true,
title: { select: { nameFa: true, nameEn: true } },
},
},
},
});
cellNumbers = rows.map((r) => r.user.cellNumber);
recipients = rows.map((r) => ({
cellNumber: r.user.cellNumber,
firstName: r.user.firstName,
lastName: r.user.lastName,
titleName: r.user.title?.nameFa || r.user.title?.nameEn || null,
}));
}
if (cellNumbers.length === 0) {
if (recipients.length === 0) {
throw new BadRequestException('No recipients found to send SMS');
}
@@ -797,26 +851,117 @@ export class CustomersService {
return {
enabled: false,
message: 'SMS is disabled. Message was not sent.',
recipientCount: cellNumbers.length,
recipientCount: recipients.length,
};
}
const settings = normalizeBusinessSettings(business.settings);
const source =
settings.sms.senderNumber?.trim() || this.sms.getAdvertisingSource();
const sample = await this.resolveOutboundSms(
businessId,
business.settings,
dto.message,
dto.templateId,
{
firstName: recipients[0].firstName,
lastName: recipients[0].lastName,
titleName: recipients[0].titleName,
},
);
const personalized =
Boolean(dto.templateId) || smsTemplateUsesVariables(dto.message);
let charged = 0;
let personalizedPayloads: Array<{
cellNumber: string;
message: string;
source: string;
cost: number;
}> = [];
if (!personalized) {
charged = await this.smsBilling.charge(
businessId,
sample.message,
recipients.length,
);
} else {
personalizedPayloads = [];
let totalCost = 0;
for (const recipient of recipients) {
const resolved = await this.resolveOutboundSms(
businessId,
business.settings,
dto.message,
dto.templateId,
{
firstName: recipient.firstName,
lastName: recipient.lastName,
titleName: recipient.titleName,
},
);
const cost = this.smsBilling.costForMessage(resolved.message);
personalizedPayloads.push({
cellNumber: recipient.cellNumber,
message: resolved.message,
source: resolved.source,
cost,
});
totalCost += cost;
}
charged = await this.smsBilling.chargeAmount(businessId, totalCost);
}
try {
const result = await this.sms.sendBulkMessage(cellNumbers, dto.message.trim(), {
source,
});
if (!personalized) {
const result = await this.sms.sendBulkMessage(
recipients.map((r) => r.cellNumber),
sample.message,
{ source: sample.source },
);
return {
enabled: true,
message: 'Message sent successfully',
recipientCount: result.recipientCount,
serverIds: result.serverIds,
source: sample.source,
templateId: sample.templateId,
templateVerified: sample.templateVerified,
};
}
const serverIds: string[] = [];
for (let i = 0; i < personalizedPayloads.length; i++) {
const payload = personalizedPayloads[i];
try {
const result = await this.sms.sendMessage(
payload.cellNumber,
payload.message,
{ source: payload.source },
);
serverIds.push(result.serverId);
} catch (err) {
const refundAmount = personalizedPayloads
.slice(i)
.reduce((sum, item) => sum + item.cost, 0);
await this.smsBilling.refund(businessId, refundAmount);
charged = 0;
throw err;
}
}
return {
enabled: true,
message: 'Message sent successfully',
recipientCount: result.recipientCount,
serverIds: result.serverIds,
source,
recipientCount: serverIds.length,
serverIds,
source: sample.source,
templateId: sample.templateId,
templateVerified: sample.templateVerified,
};
} catch (err) {
if (charged > 0) {
await this.smsBilling.refund(businessId, charged);
}
if (
err instanceof BadRequestException ||
err instanceof ServiceUnavailableException
@@ -827,6 +972,60 @@ export class CustomersService {
}
}
/**
* Resolve message text + Gama shortcode for customer SMS.
* Verified template → service line (fixed). Unverified template → advertising line.
* Free text → business senderNumber or advertising (unchanged).
*/
private async resolveOutboundSms(
businessId: bigint,
businessSettings: unknown,
messageRaw: string,
templateIdRaw: string | undefined,
vars: {
firstName?: string | null;
lastName?: string | null;
titleName?: string | null;
},
) {
const settings = normalizeBusinessSettings(businessSettings);
let message = messageRaw.trim();
let templateId: string | null = null;
let templateVerified: boolean | null = null;
let source =
settings.sms.senderNumber?.trim() || this.sms.getAdvertisingSource();
if (templateIdRaw?.trim()) {
const template = await this.smsTemplates.getTemplateForSend(
businessId,
BigInt(templateIdRaw.trim()),
);
templateId = template.id.toString();
templateVerified = template.isVerified;
// Verified templates always use the approved body (sender is also fixed).
const draft = template.isVerified
? template.body
: message || template.body;
message = renderSmsTemplate(draft, vars);
source = template.isVerified
? this.sms.getServiceSource()
: this.sms.getAdvertisingSource();
} else if (smsTemplateUsesVariables(message)) {
message = renderSmsTemplate(message, vars);
}
if (!message.trim()) {
throw new BadRequestException('Message is empty');
}
return {
message: message.trim(),
source,
templateId,
templateVerified,
};
}
private formatLabel(user: {
firstName: string | null;
lastName: string | null;
+6 -1
View File
@@ -1,8 +1,13 @@
import { IsString, MaxLength, MinLength } from 'class-validator';
import { IsOptional, IsString, MaxLength, MinLength } from 'class-validator';
export class SendCustomerSmsDto {
@IsString()
@MinLength(1)
@MaxLength(700)
message!: string;
/** Optional SMS template id — body may still be sent as the rendered text. */
@IsOptional()
@IsString()
templateId?: string;
}
@@ -24,4 +24,8 @@ export class SendCustomersBulkSmsDto {
@IsString({ each: true })
@Type(() => String)
userIds?: string[];
@IsOptional()
@IsString()
templateId?: string;
}