From a2dacfdc978602308b9c30994cd2cce9a199b886 Mon Sep 17 00:00:00 2001 From: Alireza Hassani Date: Wed, 9 Sep 2026 11:31:17 +0330 Subject: [PATCH] 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 --- database/migrations/087_sms_sent_by_type.sql | 20 ++++++++ prisma/schema.prisma | 5 ++ src/auth/auth.service.ts | 4 +- src/auth/sms-billing.service.ts | 48 ++++++++++++++------ src/auth/sms-billing.ts | 36 +++++++++++++++ src/business-admin/business-admin.service.ts | 31 ++++++++++++- src/customers/customers.service.ts | 13 +++++- src/public-sms/public-sms.service.ts | 4 +- src/sms-templates/sms-templates.service.ts | 17 ++++++- 9 files changed, 156 insertions(+), 22 deletions(-) create mode 100644 database/migrations/087_sms_sent_by_type.sql diff --git a/database/migrations/087_sms_sent_by_type.sql b/database/migrations/087_sms_sent_by_type.sql new file mode 100644 index 0000000..350691d --- /dev/null +++ b/database/migrations/087_sms_sent_by_type.sql @@ -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.'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 8fb05d9..dfe3911 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -114,6 +114,11 @@ model Business { annualRenewalAt DateTime? @map("annual_renewal_at") @db.Timestamptz(6) smsBalance Int @default(0) @map("sms_balance") 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[] blogs blogs[] brands Brand[] diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index b73dee1..c2f7f6d 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -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; } diff --git a/src/auth/sms-billing.service.ts b/src/auth/sms-billing.service.ts index 914c99c..147fff5 100644 --- a/src/auth/sms-billing.service.ts +++ b/src/auth/sms-billing.service.ts @@ -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 { 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 { 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 { 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. */ diff --git a/src/auth/sms-billing.ts b/src/auth/sms-billing.ts index ae5f460..70fbe8d 100644 --- a/src/auth/sms-billing.ts +++ b/src/auth/sms-billing.ts @@ -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. diff --git a/src/business-admin/business-admin.service.ts b/src/business-admin/business-admin.service.ts index 98bd59d..b5eb327 100644 --- a/src/business-admin/business-admin.service.ts +++ b/src/business-admin/business-admin.service.ts @@ -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, diff --git a/src/customers/customers.service.ts b/src/customers/customers.service.ts index f549e13..cf0da2f 100644 --- a/src/customers/customers.service.ts +++ b/src/customers/customers.service.ts @@ -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 ( diff --git a/src/public-sms/public-sms.service.ts b/src/public-sms/public-sms.service.ts index 168b726..7b8cccd 100644 --- a/src/public-sms/public-sms.service.ts +++ b/src/public-sms/public-sms.service.ts @@ -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; } } diff --git a/src/sms-templates/sms-templates.service.ts b/src/sms-templates/sms-templates.service.ts index 30efbdd..343003a 100644 --- a/src/sms-templates/sms-templates.service.ts +++ b/src/sms-templates/sms-templates.service.ts @@ -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,