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
@@ -0,0 +1,20 @@
-- Per-type lifetime SMS send counters (one unit = one recipient).
-- sms_sent_count remains the total across all types.
ALTER TABLE businesses
ADD COLUMN IF NOT EXISTS sms_sent_mass INTEGER NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS sms_sent_singular INTEGER NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS sms_sent_auth INTEGER NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS sms_sent_auto_customer INTEGER NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS sms_sent_admin INTEGER NOT NULL DEFAULT 0;
COMMENT ON COLUMN businesses.sms_sent_mass IS
'Lifetime bulk/mass customer SMS messages billed (one per recipient).';
COMMENT ON COLUMN businesses.sms_sent_singular IS
'Lifetime single-recipient SMS messages billed (customer one-to-one, partner public).';
COMMENT ON COLUMN businesses.sms_sent_auth IS
'Lifetime auth/OTP SMS messages billed.';
COMMENT ON COLUMN businesses.sms_sent_auto_customer IS
'Lifetime automatic customer SMS (birthdays, rewards, …) — reserved.';
COMMENT ON COLUMN businesses.sms_sent_admin IS
'Lifetime admin notification SMS (new order, visits, …) — reserved.';
+5
View File
@@ -114,6 +114,11 @@ model Business {
annualRenewalAt DateTime? @map("annual_renewal_at") @db.Timestamptz(6) annualRenewalAt DateTime? @map("annual_renewal_at") @db.Timestamptz(6)
smsBalance Int @default(0) @map("sms_balance") smsBalance Int @default(0) @map("sms_balance")
smsSentCount Int @default(0) @map("sms_sent_count") smsSentCount Int @default(0) @map("sms_sent_count")
smsSentMass Int @default(0) @map("sms_sent_mass")
smsSentSingular Int @default(0) @map("sms_sent_singular")
smsSentAuth Int @default(0) @map("sms_sent_auth")
smsSentAutoCustomer Int @default(0) @map("sms_sent_auto_customer")
smsSentAdmin Int @default(0) @map("sms_sent_admin")
addresses Address[] addresses Address[]
blogs blogs[] blogs blogs[]
brands Brand[] brands Brand[]
+2 -2
View File
@@ -438,13 +438,13 @@ export class AuthService {
? `کد تایید شما: ${code}\n${brand}` ? `کد تایید شما: ${code}\n${brand}`
: `کد تایید شما: ${code}`; : `کد تایید شما: ${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); await this.redis.setOtp(cellNumber, code, OTP_TTL_SECONDS);
try { try {
await this.sms.sendMessage(cellNumber, message); await this.sms.sendMessage(cellNumber, message);
} catch (err) { } catch (err) {
await this.smsBilling.refund(business.id, charged); await this.smsBilling.refund(business.id, charged, 1, 'auth');
if (err instanceof HttpException) { if (err instanceof HttpException) {
throw err; throw err;
} }
+28 -8
View File
@@ -1,7 +1,14 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { TenantService } from '../tenant/tenant.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() @Injectable()
export class SmsBillingService { export class SmsBillingService {
@@ -49,33 +56,40 @@ export class SmsBillingService {
businessId: bigint, businessId: bigint,
message: string, message: string,
recipientCount = 1, recipientCount = 1,
type: SmsSendType = 'singular',
): Promise<number> { ): Promise<number> {
return this.chargeAmount( return this.chargeAmount(
businessId, businessId,
this.costForMessage(message, recipientCount), this.costForMessage(message, recipientCount),
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. * Returns credits charged.
*/ */
async chargeAmount( async chargeAmount(
businessId: bigint, businessId: bigint,
amount: number, amount: number,
recipientCount = 1, recipientCount = 1,
type: SmsSendType = 'singular',
): Promise<number> { ): Promise<number> {
const cost = Math.floor(amount); const cost = Math.floor(amount);
const count = Math.max(0, Math.floor(recipientCount)); const count = Math.max(0, Math.floor(recipientCount));
if (cost <= 0) return 0; if (cost <= 0) return 0;
const updated = await this.prisma.$executeRaw` const typedColumn = smsSentColumnForType(type);
const updated = await this.prisma.$executeRaw(
Prisma.sql`
UPDATE businesses UPDATE businesses
SET sms_balance = sms_balance - ${cost}, SET sms_balance = sms_balance - ${cost},
sms_sent_count = sms_sent_count + ${count} sms_sent_count = sms_sent_count + ${count},
${Prisma.raw(typedColumn)} = ${Prisma.raw(typedColumn)} + ${count}
WHERE id = ${businessId} AND sms_balance >= ${cost} WHERE id = ${businessId} AND sms_balance >= ${cost}
`; `,
);
if (updated === 0) { if (updated === 0) {
throw new BadRequestException( throw new BadRequestException(
'Insufficient SMS balance. Contact Meshkee to top up.', 'Insufficient SMS balance. Contact Meshkee to top up.',
@@ -88,16 +102,22 @@ export class SmsBillingService {
businessId: bigint, businessId: bigint,
amount: number, amount: number,
recipientCount = 1, recipientCount = 1,
type: SmsSendType = 'singular',
): Promise<void> { ): Promise<void> {
const credit = Math.floor(amount); const credit = Math.floor(amount);
const count = Math.max(0, Math.floor(recipientCount)); const count = Math.max(0, Math.floor(recipientCount));
if (credit <= 0 && count <= 0) return; if (credit <= 0 && count <= 0) return;
await this.prisma.$executeRaw`
const typedColumn = smsSentColumnForType(type);
await this.prisma.$executeRaw(
Prisma.sql`
UPDATE businesses UPDATE businesses
SET sms_balance = sms_balance + ${credit}, SET sms_balance = sms_balance + ${credit},
sms_sent_count = GREATEST(0, sms_sent_count - ${count}) sms_sent_count = GREATEST(0, sms_sent_count - ${count}),
${Prisma.raw(typedColumn)} = GREATEST(0, ${Prisma.raw(typedColumn)} - ${count})
WHERE id = ${businessId} WHERE id = ${businessId}
`; `,
);
} }
/** Add credits from an IRT top-up. Returns credits added. */ /** 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). */ /** Starting credits for newly created businesses (≈ 500,000 IRT at purchase price). */
export const DEFAULT_NEW_BUSINESS_SMS_CREDITS = 1_000; 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. * Credits charged for one or more recipients.
* 1–64 chars → 1, 65–128 → 2, etc. * 1–64 chars → 1, 65–128 → 2, etc.
+30 -1
View File
@@ -87,6 +87,11 @@ type BusinessRow = {
smsSenderNumber: string | null; smsSenderNumber: string | null;
smsBalance: number; smsBalance: number;
smsSentCount: number; smsSentCount: number;
smsSentMass: number;
smsSentSingular: number;
smsSentAuth: number;
smsSentAutoCustomer: number;
smsSentAdmin: number;
}; };
function slugify(value: string) { function slugify(value: string) {
@@ -174,7 +179,12 @@ export class BusinessAdminService {
b.settings->'modules'->'charts' AS "homeCharts", b.settings->'modules'->'charts' AS "homeCharts",
b.settings->'sms'->>'senderNumber' AS "smsSenderNumber", b.settings->'sms'->>'senderNumber' AS "smsSenderNumber",
b.sms_balance AS "smsBalance", 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 FROM businesses b
LEFT JOIN LATERAL ( LEFT JOIN LATERAL (
SELECT d.id, d.host, d.ssl_enabled, d.git_repo_url, d.deploy_slug 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), smsSenderNumber: normalizeSmsSenderNumber(item.smsSenderNumber),
smsBalance: Number(item.smsBalance ?? 0), smsBalance: Number(item.smsBalance ?? 0),
smsSentCount: Number(item.smsSentCount ?? 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, total: totalRow[0]?.total ?? 0,
@@ -1302,6 +1319,11 @@ export class BusinessAdminService {
settings: unknown; settings: unknown;
smsBalance?: number; smsBalance?: number;
smsSentCount?: number; smsSentCount?: number;
smsSentMass?: number;
smsSentSingular?: number;
smsSentAuth?: number;
smsSentAutoCustomer?: number;
smsSentAdmin?: number;
categoryAssignments: { categoryAssignments: {
category: { category: {
id: bigint; id: bigint;
@@ -1348,6 +1370,13 @@ export class BusinessAdminService {
smsSenderNumber: settings.sms.senderNumber, smsSenderNumber: settings.sms.senderNumber,
smsBalance: business.smsBalance ?? 0, smsBalance: business.smsBalance ?? 0,
smsSentCount: business.smsSentCount ?? 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) => ({ categories: business.categoryAssignments.map((a) => ({
id: a.category.id, id: a.category.id,
name: a.category.name, 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 { try {
const result = await this.sms.sendMessage( const result = await this.sms.sendMessage(
@@ -745,7 +750,7 @@ export class CustomersService {
templateVerified: resolved.templateVerified, templateVerified: resolved.templateVerified,
}; };
} catch (err) { } catch (err) {
await this.smsBilling.refund(businessId, charged); await this.smsBilling.refund(businessId, charged, 1, 'singular');
if ( if (
err instanceof BadRequestException || err instanceof BadRequestException ||
err instanceof ServiceUnavailableException err instanceof ServiceUnavailableException
@@ -883,6 +888,7 @@ export class CustomersService {
businessId, businessId,
sample.message, sample.message,
recipients.length, recipients.length,
'mass',
); );
} else { } else {
personalizedPayloads = []; personalizedPayloads = [];
@@ -912,6 +918,7 @@ export class CustomersService {
businessId, businessId,
totalCost, totalCost,
personalizedPayloads.length, personalizedPayloads.length,
'mass',
); );
} }
@@ -950,6 +957,7 @@ export class CustomersService {
businessId, businessId,
refundAmount, refundAmount,
remaining.length, remaining.length,
'mass',
); );
charged = 0; charged = 0;
throw err; throw err;
@@ -971,6 +979,7 @@ export class CustomersService {
businessId, businessId,
charged, charged,
personalized ? personalizedPayloads.length : recipients.length, personalized ? personalizedPayloads.length : recipients.length,
'mass',
); );
} }
if ( if (
+2 -2
View File
@@ -38,7 +38,7 @@ export class PublicSmsService {
const businessId = const businessId =
await this.smsBilling.requireBusinessIdByDomain(partnerDomain); 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. // Partners / free-text: advertising shortcode unless an explicit source is provided.
// Verified Meshkee templates use the service line via the business customers SMS API. // Verified Meshkee templates use the service line via the business customers SMS API.
@@ -58,7 +58,7 @@ export class PublicSmsService {
serverId: result.serverId, serverId: result.serverId,
}; };
} catch (err) { } catch (err) {
await this.smsBilling.refund(businessId, charged); await this.smsBilling.refund(businessId, charged, 1, 'singular');
throw err; throw err;
} }
} }
+16 -1
View File
@@ -159,7 +159,15 @@ export class SmsTemplatesService {
const business = await this.prisma.business.findUnique({ const business = await this.prisma.business.findUnique({
where: { id: businessId }, 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) { if (!business) {
throw new NotFoundException('Business not found'); throw new NotFoundException('Business not found');
@@ -169,6 +177,13 @@ export class SmsTemplatesService {
balance: business.smsBalance, balance: business.smsBalance,
credit: business.smsBalance, credit: business.smsBalance,
sentCount: business.smsSentCount, sentCount: business.smsSentCount,
sentByType: {
mass: business.smsSentMass,
singular: business.smsSentSingular,
auth: business.smsSentAuth,
autoCustomer: business.smsSentAutoCustomer,
admin: business.smsSentAdmin,
},
unit: 'credits', unit: 'credits',
currency: null, currency: null,
message: null, message: null,