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:
co-authored by
Cursor
parent
717a203d48
commit
a2dacfdc97
@@ -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.';
|
||||
@@ -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[]
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user