Track SMS sent counts by type (mass, singular, auth, auto, admin).

Charge/refund bump the matching counter plus total; expose smsSentByType on business APIs.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-09-09 11:31:17 +03:30
co-authored by Cursor
parent 717a203d48
commit a2dacfdc97
9 changed files with 156 additions and 22 deletions
+2 -2
View File
@@ -438,13 +438,13 @@ export class AuthService {
? `کد تایید شما: ${code}\n${brand}`
: `کد تایید شما: ${code}`;
const charged = await this.smsBilling.charge(business.id, message);
const charged = await this.smsBilling.charge(business.id, message, 1, 'auth');
await this.redis.setOtp(cellNumber, code, OTP_TTL_SECONDS);
try {
await this.sms.sendMessage(cellNumber, message);
} catch (err) {
await this.smsBilling.refund(business.id, charged);
await this.smsBilling.refund(business.id, charged, 1, 'auth');
if (err instanceof HttpException) {
throw err;
}
+34 -14
View File
@@ -1,7 +1,14 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { TenantService } from '../tenant/tenant.service';
import { calculateSmsCredits, irtToSmsCredits, SMS_PURCHASE_PRICE_PER_CREDIT_IRT } from './sms-billing';
import {
calculateSmsCredits,
irtToSmsCredits,
smsSentColumnForType,
SMS_PURCHASE_PRICE_PER_CREDIT_IRT,
type SmsSendType,
} from './sms-billing';
@Injectable()
export class SmsBillingService {
@@ -49,33 +56,40 @@ export class SmsBillingService {
businessId: bigint,
message: string,
recipientCount = 1,
type: SmsSendType = 'singular',
): Promise<number> {
return this.chargeAmount(
businessId,
this.costForMessage(message, recipientCount),
recipientCount,
type,
);
}
/**
* Atomically deduct an explicit credit amount and bump sms_sent_count.
* Atomically deduct credits and bump sms_sent_count + typed counter.
* Returns credits charged.
*/
async chargeAmount(
businessId: bigint,
amount: number,
recipientCount = 1,
type: SmsSendType = 'singular',
): Promise<number> {
const cost = Math.floor(amount);
const count = Math.max(0, Math.floor(recipientCount));
if (cost <= 0) return 0;
const updated = await this.prisma.$executeRaw`
UPDATE businesses
SET sms_balance = sms_balance - ${cost},
sms_sent_count = sms_sent_count + ${count}
WHERE id = ${businessId} AND sms_balance >= ${cost}
`;
const typedColumn = smsSentColumnForType(type);
const updated = await this.prisma.$executeRaw(
Prisma.sql`
UPDATE businesses
SET sms_balance = sms_balance - ${cost},
sms_sent_count = sms_sent_count + ${count},
${Prisma.raw(typedColumn)} = ${Prisma.raw(typedColumn)} + ${count}
WHERE id = ${businessId} AND sms_balance >= ${cost}
`,
);
if (updated === 0) {
throw new BadRequestException(
'Insufficient SMS balance. Contact Meshkee to top up.',
@@ -88,16 +102,22 @@ export class SmsBillingService {
businessId: bigint,
amount: number,
recipientCount = 1,
type: SmsSendType = 'singular',
): Promise<void> {
const credit = Math.floor(amount);
const count = Math.max(0, Math.floor(recipientCount));
if (credit <= 0 && count <= 0) return;
await this.prisma.$executeRaw`
UPDATE businesses
SET sms_balance = sms_balance + ${credit},
sms_sent_count = GREATEST(0, sms_sent_count - ${count})
WHERE id = ${businessId}
`;
const typedColumn = smsSentColumnForType(type);
await this.prisma.$executeRaw(
Prisma.sql`
UPDATE businesses
SET sms_balance = sms_balance + ${credit},
sms_sent_count = GREATEST(0, sms_sent_count - ${count}),
${Prisma.raw(typedColumn)} = GREATEST(0, ${Prisma.raw(typedColumn)} - ${count})
WHERE id = ${businessId}
`,
);
}
/** Add credits from an IRT top-up. Returns credits added. */
+36
View File
@@ -10,6 +10,42 @@ export const SMS_PURCHASE_PRICE_PER_CREDIT_IRT = 500;
/** Starting credits for newly created businesses (≈ 500,000 IRT at purchase price). */
export const DEFAULT_NEW_BUSINESS_SMS_CREDITS = 1_000;
/** Lifetime send counter categories (one unit = one recipient). */
export type SmsSendType =
| 'mass'
| 'singular'
| 'auth'
| 'auto_customer'
| 'admin';
export const SMS_SEND_TYPES: readonly SmsSendType[] = [
'mass',
'singular',
'auth',
'auto_customer',
'admin',
] as const;
/** Whitelisted DB columns for typed send counters. */
export function smsSentColumnForType(type: SmsSendType): string {
switch (type) {
case 'mass':
return 'sms_sent_mass';
case 'singular':
return 'sms_sent_singular';
case 'auth':
return 'sms_sent_auth';
case 'auto_customer':
return 'sms_sent_auto_customer';
case 'admin':
return 'sms_sent_admin';
default: {
const _exhaustive: never = type;
return _exhaustive;
}
}
}
/**
* Credits charged for one or more recipients.
* 1–64 chars → 1, 65–128 → 2, etc.
+30 -1
View File
@@ -87,6 +87,11 @@ type BusinessRow = {
smsSenderNumber: string | null;
smsBalance: number;
smsSentCount: number;
smsSentMass: number;
smsSentSingular: number;
smsSentAuth: number;
smsSentAutoCustomer: number;
smsSentAdmin: number;
};
function slugify(value: string) {
@@ -174,7 +179,12 @@ export class BusinessAdminService {
b.settings->'modules'->'charts' AS "homeCharts",
b.settings->'sms'->>'senderNumber' AS "smsSenderNumber",
b.sms_balance AS "smsBalance",
b.sms_sent_count AS "smsSentCount"
b.sms_sent_count AS "smsSentCount",
b.sms_sent_mass AS "smsSentMass",
b.sms_sent_singular AS "smsSentSingular",
b.sms_sent_auth AS "smsSentAuth",
b.sms_sent_auto_customer AS "smsSentAutoCustomer",
b.sms_sent_admin AS "smsSentAdmin"
FROM businesses b
LEFT JOIN LATERAL (
SELECT d.id, d.host, d.ssl_enabled, d.git_repo_url, d.deploy_slug
@@ -238,6 +248,13 @@ export class BusinessAdminService {
smsSenderNumber: normalizeSmsSenderNumber(item.smsSenderNumber),
smsBalance: Number(item.smsBalance ?? 0),
smsSentCount: Number(item.smsSentCount ?? 0),
smsSentByType: {
mass: Number(item.smsSentMass ?? 0),
singular: Number(item.smsSentSingular ?? 0),
auth: Number(item.smsSentAuth ?? 0),
autoCustomer: Number(item.smsSentAutoCustomer ?? 0),
admin: Number(item.smsSentAdmin ?? 0),
},
};
}),
total: totalRow[0]?.total ?? 0,
@@ -1302,6 +1319,11 @@ export class BusinessAdminService {
settings: unknown;
smsBalance?: number;
smsSentCount?: number;
smsSentMass?: number;
smsSentSingular?: number;
smsSentAuth?: number;
smsSentAutoCustomer?: number;
smsSentAdmin?: number;
categoryAssignments: {
category: {
id: bigint;
@@ -1348,6 +1370,13 @@ export class BusinessAdminService {
smsSenderNumber: settings.sms.senderNumber,
smsBalance: business.smsBalance ?? 0,
smsSentCount: business.smsSentCount ?? 0,
smsSentByType: {
mass: business.smsSentMass ?? 0,
singular: business.smsSentSingular ?? 0,
auth: business.smsSentAuth ?? 0,
autoCustomer: business.smsSentAutoCustomer ?? 0,
admin: business.smsSentAdmin ?? 0,
},
categories: business.categoryAssignments.map((a) => ({
id: a.category.id,
name: a.category.name,
+11 -2
View File
@@ -728,7 +728,12 @@ export class CustomersService {
},
);
const charged = await this.smsBilling.charge(businessId, resolved.message);
const charged = await this.smsBilling.charge(
businessId,
resolved.message,
1,
'singular',
);
try {
const result = await this.sms.sendMessage(
@@ -745,7 +750,7 @@ export class CustomersService {
templateVerified: resolved.templateVerified,
};
} catch (err) {
await this.smsBilling.refund(businessId, charged);
await this.smsBilling.refund(businessId, charged, 1, 'singular');
if (
err instanceof BadRequestException ||
err instanceof ServiceUnavailableException
@@ -883,6 +888,7 @@ export class CustomersService {
businessId,
sample.message,
recipients.length,
'mass',
);
} else {
personalizedPayloads = [];
@@ -912,6 +918,7 @@ export class CustomersService {
businessId,
totalCost,
personalizedPayloads.length,
'mass',
);
}
@@ -950,6 +957,7 @@ export class CustomersService {
businessId,
refundAmount,
remaining.length,
'mass',
);
charged = 0;
throw err;
@@ -971,6 +979,7 @@ export class CustomersService {
businessId,
charged,
personalized ? personalizedPayloads.length : recipients.length,
'mass',
);
}
if (
+2 -2
View File
@@ -38,7 +38,7 @@ export class PublicSmsService {
const businessId =
await this.smsBilling.requireBusinessIdByDomain(partnerDomain);
const charged = await this.smsBilling.charge(businessId, dto.message);
const charged = await this.smsBilling.charge(businessId, dto.message, 1, 'singular');
// Partners / free-text: advertising shortcode unless an explicit source is provided.
// Verified Meshkee templates use the service line via the business customers SMS API.
@@ -58,7 +58,7 @@ export class PublicSmsService {
serverId: result.serverId,
};
} catch (err) {
await this.smsBilling.refund(businessId, charged);
await this.smsBilling.refund(businessId, charged, 1, 'singular');
throw err;
}
}
+16 -1
View File
@@ -159,7 +159,15 @@ export class SmsTemplatesService {
const business = await this.prisma.business.findUnique({
where: { id: businessId },
select: { smsBalance: true, smsSentCount: true },
select: {
smsBalance: true,
smsSentCount: true,
smsSentMass: true,
smsSentSingular: true,
smsSentAuth: true,
smsSentAutoCustomer: true,
smsSentAdmin: true,
},
});
if (!business) {
throw new NotFoundException('Business not found');
@@ -169,6 +177,13 @@ export class SmsTemplatesService {
balance: business.smsBalance,
credit: business.smsBalance,
sentCount: business.smsSentCount,
sentByType: {
mass: business.smsSentMass,
singular: business.smsSentSingular,
auth: business.smsSentAuth,
autoCustomer: business.smsSentAutoCustomer,
admin: business.smsSentAdmin,
},
unit: 'credits',
currency: null,
message: null,