Switch SMS billing to credit units with IRT top-ups.

Balance is SMS credits (1 per 64 chars); top-ups add floor(IRT/500) credits, track sms_sent_count, and convert existing IRT balances.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-09-09 11:10:22 +03:30
co-authored by Cursor
parent 07f3304c07
commit 584c01fcee
9 changed files with 152 additions and 32 deletions
@@ -0,0 +1,8 @@
-- Lifetime count of SMS messages successfully billed to each business
-- (incremented on charge, decremented on refund). One unit = one recipient.
ALTER TABLE businesses
ADD COLUMN IF NOT EXISTS sms_sent_count INTEGER NOT NULL DEFAULT 0;
COMMENT ON COLUMN businesses.sms_sent_count IS
'Lifetime SMS messages billed to this business (one per recipient). Incremented on charge, decremented on refund.';
@@ -0,0 +1,11 @@
-- Convert prepaid SMS balance from IRT to credit units.
-- Previous billing: 400 IRT per 64-char segment.
-- New model: balance is SMS credits (1 credit = 1 segment); top-up at 500 IRT/credit.
-- Preserve existing prepaid capacity: IRT / 400 → credits.
UPDATE businesses
SET sms_balance = sms_balance / 400
WHERE sms_balance > 0;
COMMENT ON COLUMN businesses.sms_balance IS
'Prepaid SMS credit units. 1 unit = one 64-character segment per recipient. Top-up adds floor(IRT / 500) credits.';
+1
View File
@@ -113,6 +113,7 @@ model Business {
oldBusinessId BigInt? @map("old_business_id")
annualRenewalAt DateTime? @map("annual_renewal_at") @db.Timestamptz(6)
smsBalance Int @default(0) @map("sms_balance")
smsSentCount Int @default(0) @map("sms_sent_count")
addresses Address[]
blogs blogs[]
brands Brand[]
+46 -9
View File
@@ -1,7 +1,7 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { TenantService } from '../tenant/tenant.service';
import { calculateSmsCostIrt } from './sms-billing';
import { calculateSmsCredits, irtToSmsCredits } from './sms-billing';
@Injectable()
export class SmsBillingService {
@@ -10,8 +10,13 @@ export class SmsBillingService {
private readonly tenant: TenantService,
) {}
/** Credits required for a message (1 credit = 64 chars × recipient). */
costForMessage(message: string, recipientCount = 1): number {
return calculateSmsCostIrt(message.trim().length, recipientCount);
return calculateSmsCredits(message.trim().length, recipientCount);
}
creditsFromTopUpIrt(irtAmount: number): number {
return irtToSmsCredits(irtAmount);
}
/**
@@ -39,7 +44,7 @@ export class SmsBillingService {
return id;
}
/** Atomically deduct IRT for a message. Returns charged amount. */
/** Atomically deduct SMS credits for a message. Returns credits charged. */
async charge(
businessId: bigint,
message: string,
@@ -48,17 +53,27 @@ export class SmsBillingService {
return this.chargeAmount(
businessId,
this.costForMessage(message, recipientCount),
recipientCount,
);
}
/** Atomically deduct an explicit IRT amount. Returns charged amount. */
async chargeAmount(businessId: bigint, amount: number): Promise<number> {
/**
* Atomically deduct an explicit credit amount and bump sms_sent_count.
* Returns credits charged.
*/
async chargeAmount(
businessId: bigint,
amount: number,
recipientCount = 1,
): 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}
SET sms_balance = sms_balance - ${cost},
sms_sent_count = sms_sent_count + ${count}
WHERE id = ${businessId} AND sms_balance >= ${cost}
`;
if (updated === 0) {
@@ -69,13 +84,35 @@ export class SmsBillingService {
return cost;
}
async refund(businessId: bigint, amount: number): Promise<void> {
async refund(
businessId: bigint,
amount: number,
recipientCount = 1,
): Promise<void> {
const credit = Math.floor(amount);
if (credit <= 0) return;
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}
SET sms_balance = sms_balance + ${credit},
sms_sent_count = GREATEST(0, sms_sent_count - ${count})
WHERE id = ${businessId}
`;
}
/** Add credits from an IRT top-up. Returns credits added. */
async addTopUpIrt(businessId: bigint, irtAmount: number): Promise<number> {
const credits = irtToSmsCredits(irtAmount);
if (credits <= 0) {
throw new BadRequestException(
`Top-up amount is too small to purchase any SMS credits (${SMS_PURCHASE_PRICE_PER_CREDIT_IRT} IRT each).`,
);
}
await this.prisma.$executeRaw`
UPDATE businesses
SET sms_balance = sms_balance + ${credits}
WHERE id = ${businessId}
`;
return credits;
}
}
+34 -7
View File
@@ -1,14 +1,20 @@
/** Prepaid SMS billing: each 64-character segment costs 400 IRT per recipient. */
/**
* Prepaid SMS billing in credit units.
* - 1 credit = one 64-character segment for one recipient
* - Top-up: floor(IRT / 500) credits (e.g. 1,000,000 IRT → 2,000 credits)
*/
export const SMS_SEGMENT_CHARS = 64;
export const SMS_COST_PER_SEGMENT_IRT = 400;
export const DEFAULT_NEW_BUSINESS_SMS_BALANCE_IRT = 500_000;
/** IRT (toman) price to purchase one SMS credit. */
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;
/**
* Cost in IRT for one or more recipients.
* 1–64 chars → 400, 65–128 → 800, etc.
* Credits charged for one or more recipients.
* 1–64 chars → 1, 65–128 → 2, etc.
*/
export function calculateSmsCostIrt(
export function calculateSmsCredits(
messageLength: number,
recipientCount = 1,
): number {
@@ -16,5 +22,26 @@ export function calculateSmsCostIrt(
const recipients = Math.max(0, Math.floor(recipientCount));
if (recipients === 0 || length === 0) return 0;
const segments = Math.max(1, Math.ceil(length / SMS_SEGMENT_CHARS));
return segments * SMS_COST_PER_SEGMENT_IRT * recipients;
return segments * recipients;
}
/** Convert a top-up amount in IRT into SMS credits. */
export function irtToSmsCredits(irtAmount: number): number {
const irt = Math.max(0, Math.floor(irtAmount));
if (irt <= 0) return 0;
return Math.floor(irt / SMS_PURCHASE_PRICE_PER_CREDIT_IRT);
}
/** @deprecated Use calculateSmsCredits — balance is no longer stored in IRT. */
export function calculateSmsCostIrt(
messageLength: number,
recipientCount = 1,
): number {
return calculateSmsCredits(messageLength, recipientCount);
}
/** @deprecated Use DEFAULT_NEW_BUSINESS_SMS_CREDITS */
export const DEFAULT_NEW_BUSINESS_SMS_BALANCE_IRT = DEFAULT_NEW_BUSINESS_SMS_CREDITS;
/** @deprecated Purchase price constant — not a per-send IRT charge. */
export const SMS_COST_PER_SEGMENT_IRT = SMS_PURCHASE_PRICE_PER_CREDIT_IRT;
+26 -6
View File
@@ -9,7 +9,10 @@ import {
import { Prisma } from '@prisma/client';
import * as bcrypt from 'bcrypt';
import { PermissionsService } from '../auth/permissions.service';
import { DEFAULT_NEW_BUSINESS_SMS_BALANCE_IRT } from '../auth/sms-billing';
import {
DEFAULT_NEW_BUSINESS_SMS_CREDITS,
SMS_PURCHASE_PRICE_PER_CREDIT_IRT,
} from '../auth/sms-billing';
import { PrismaService } from '../prisma/prisma.service';
import { AuthUser } from '../auth/auth.types';
import { AddDomainDto } from './dto/add-domain.dto';
@@ -83,6 +86,7 @@ type BusinessRow = {
homeCharts: unknown;
smsSenderNumber: string | null;
smsBalance: number;
smsSentCount: number;
};
function slugify(value: string) {
@@ -169,7 +173,8 @@ export class BusinessAdminService {
b.settings->'modules'->'enabled' AS "enabledModules",
b.settings->'modules'->'charts' AS "homeCharts",
b.settings->'sms'->>'senderNumber' AS "smsSenderNumber",
b.sms_balance AS "smsBalance"
b.sms_balance AS "smsBalance",
b.sms_sent_count AS "smsSentCount"
FROM businesses b
LEFT JOIN LATERAL (
SELECT d.id, d.host, d.ssl_enabled, d.git_repo_url, d.deploy_slug
@@ -232,6 +237,7 @@ export class BusinessAdminService {
homeCharts,
smsSenderNumber: normalizeSmsSenderNumber(item.smsSenderNumber),
smsBalance: Number(item.smsBalance ?? 0),
smsSentCount: Number(item.smsSentCount ?? 0),
};
}),
total: totalRow[0]?.total ?? 0,
@@ -391,7 +397,7 @@ export class BusinessAdminService {
: new Date(),
oldBusinessId:
dto.oldBusinessId !== undefined ? BigInt(dto.oldBusinessId) : undefined,
smsBalance: DEFAULT_NEW_BUSINESS_SMS_BALANCE_IRT,
smsBalance: DEFAULT_NEW_BUSINESS_SMS_CREDITS,
settings: toPrismaJson({
...DEFAULT_BUSINESS_SETTINGS,
modules: {
@@ -471,8 +477,18 @@ export class BusinessAdminService {
}
}
if (dto.smsBalance !== undefined && dto.smsBalance < 0) {
throw new BadRequestException('smsBalance cannot be negative');
if (dto.smsTopUpIrt !== undefined && dto.smsTopUpIrt < 1) {
throw new BadRequestException('smsTopUpIrt must be a positive integer');
}
const topUpCredits =
dto.smsTopUpIrt !== undefined
? Math.floor(dto.smsTopUpIrt / SMS_PURCHASE_PRICE_PER_CREDIT_IRT)
: 0;
if (dto.smsTopUpIrt !== undefined && topUpCredits < 1) {
throw new BadRequestException(
`Top-up amount is too small to purchase any SMS credits (${SMS_PURCHASE_PRICE_PER_CREDIT_IRT} IRT each).`,
);
}
await this.prisma.$transaction(async (tx) => {
@@ -512,7 +528,9 @@ export class BusinessAdminService {
}
: {}),
...(nextSettings !== undefined ? { settings: nextSettings } : {}),
...(dto.smsBalance !== undefined ? { smsBalance: dto.smsBalance } : {}),
...(topUpCredits > 0
? { smsBalance: { increment: topUpCredits } }
: {}),
},
});
@@ -1283,6 +1301,7 @@ export class BusinessAdminService {
updatedAt: Date;
settings: unknown;
smsBalance?: number;
smsSentCount?: number;
categoryAssignments: {
category: {
id: bigint;
@@ -1328,6 +1347,7 @@ export class BusinessAdminService {
updatedAt: business.updatedAt,
smsSenderNumber: settings.sms.senderNumber,
smsBalance: business.smsBalance ?? 0,
smsSentCount: business.smsSentCount ?? 0,
categories: business.categoryAssignments.map((a) => ({
id: a.category.id,
name: a.category.name,
@@ -73,10 +73,13 @@ export class UpdateBusinessDto {
})
smsSenderNumber?: string | null;
/** Prepaid SMS message credits for this business (absolute set). */
/**
* IRT (toman) amount to add as SMS credits.
* Credits added = floor(smsTopUpIrt / 500). Does not replace existing balance.
*/
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(0)
smsBalance?: number;
@Min(1)
smsTopUpIrt?: number;
}
+17 -6
View File
@@ -908,7 +908,11 @@ export class CustomersService {
});
totalCost += cost;
}
charged = await this.smsBilling.chargeAmount(businessId, totalCost);
charged = await this.smsBilling.chargeAmount(
businessId,
totalCost,
personalizedPayloads.length,
);
}
try {
@@ -940,10 +944,13 @@ export class CustomersService {
);
serverIds.push(result.serverId);
} catch (err) {
const refundAmount = personalizedPayloads
.slice(i)
.reduce((sum, item) => sum + item.cost, 0);
await this.smsBilling.refund(businessId, refundAmount);
const remaining = personalizedPayloads.slice(i);
const refundAmount = remaining.reduce((sum, item) => sum + item.cost, 0);
await this.smsBilling.refund(
businessId,
refundAmount,
remaining.length,
);
charged = 0;
throw err;
}
@@ -960,7 +967,11 @@ export class CustomersService {
};
} catch (err) {
if (charged > 0) {
await this.smsBilling.refund(businessId, charged);
await this.smsBilling.refund(
businessId,
charged,
personalized ? personalizedPayloads.length : recipients.length,
);
}
if (
err instanceof BadRequestException ||
+3 -1
View File
@@ -159,7 +159,7 @@ export class SmsTemplatesService {
const business = await this.prisma.business.findUnique({
where: { id: businessId },
select: { smsBalance: true },
select: { smsBalance: true, smsSentCount: true },
});
if (!business) {
throw new NotFoundException('Business not found');
@@ -168,6 +168,8 @@ export class SmsTemplatesService {
return {
balance: business.smsBalance,
credit: business.smsBalance,
sentCount: business.smsSentCount,
unit: 'credits',
currency: null,
message: null,
};