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:
co-authored by
Cursor
parent
07f3304c07
commit
584c01fcee
@@ -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.';
|
||||
@@ -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[]
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user