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")
|
oldBusinessId BigInt? @map("old_business_id")
|
||||||
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")
|
||||||
addresses Address[]
|
addresses Address[]
|
||||||
blogs blogs[]
|
blogs blogs[]
|
||||||
brands Brand[]
|
brands Brand[]
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
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 { calculateSmsCostIrt } from './sms-billing';
|
import { calculateSmsCredits, irtToSmsCredits } from './sms-billing';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SmsBillingService {
|
export class SmsBillingService {
|
||||||
@@ -10,8 +10,13 @@ export class SmsBillingService {
|
|||||||
private readonly tenant: TenantService,
|
private readonly tenant: TenantService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
/** Credits required for a message (1 credit = 64 chars × recipient). */
|
||||||
costForMessage(message: string, recipientCount = 1): number {
|
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;
|
return id;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Atomically deduct IRT for a message. Returns charged amount. */
|
/** Atomically deduct SMS credits for a message. Returns credits charged. */
|
||||||
async charge(
|
async charge(
|
||||||
businessId: bigint,
|
businessId: bigint,
|
||||||
message: string,
|
message: string,
|
||||||
@@ -48,17 +53,27 @@ export class SmsBillingService {
|
|||||||
return this.chargeAmount(
|
return this.chargeAmount(
|
||||||
businessId,
|
businessId,
|
||||||
this.costForMessage(message, recipientCount),
|
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 cost = Math.floor(amount);
|
||||||
|
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 updated = await this.prisma.$executeRaw`
|
||||||
UPDATE businesses
|
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}
|
WHERE id = ${businessId} AND sms_balance >= ${cost}
|
||||||
`;
|
`;
|
||||||
if (updated === 0) {
|
if (updated === 0) {
|
||||||
@@ -69,13 +84,35 @@ export class SmsBillingService {
|
|||||||
return cost;
|
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);
|
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`
|
await this.prisma.$executeRaw`
|
||||||
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})
|
||||||
WHERE id = ${businessId}
|
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_SEGMENT_CHARS = 64;
|
||||||
export const SMS_COST_PER_SEGMENT_IRT = 400;
|
/** IRT (toman) price to purchase one SMS credit. */
|
||||||
export const DEFAULT_NEW_BUSINESS_SMS_BALANCE_IRT = 500_000;
|
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.
|
* Credits charged for one or more recipients.
|
||||||
* 1–64 chars → 400, 65–128 → 800, etc.
|
* 1–64 chars → 1, 65–128 → 2, etc.
|
||||||
*/
|
*/
|
||||||
export function calculateSmsCostIrt(
|
export function calculateSmsCredits(
|
||||||
messageLength: number,
|
messageLength: number,
|
||||||
recipientCount = 1,
|
recipientCount = 1,
|
||||||
): number {
|
): number {
|
||||||
@@ -16,5 +22,26 @@ export function calculateSmsCostIrt(
|
|||||||
const recipients = Math.max(0, Math.floor(recipientCount));
|
const recipients = Math.max(0, Math.floor(recipientCount));
|
||||||
if (recipients === 0 || length === 0) return 0;
|
if (recipients === 0 || length === 0) return 0;
|
||||||
const segments = Math.max(1, Math.ceil(length / SMS_SEGMENT_CHARS));
|
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 { Prisma } from '@prisma/client';
|
||||||
import * as bcrypt from 'bcrypt';
|
import * as bcrypt from 'bcrypt';
|
||||||
import { PermissionsService } from '../auth/permissions.service';
|
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 { PrismaService } from '../prisma/prisma.service';
|
||||||
import { AuthUser } from '../auth/auth.types';
|
import { AuthUser } from '../auth/auth.types';
|
||||||
import { AddDomainDto } from './dto/add-domain.dto';
|
import { AddDomainDto } from './dto/add-domain.dto';
|
||||||
@@ -83,6 +86,7 @@ type BusinessRow = {
|
|||||||
homeCharts: unknown;
|
homeCharts: unknown;
|
||||||
smsSenderNumber: string | null;
|
smsSenderNumber: string | null;
|
||||||
smsBalance: number;
|
smsBalance: number;
|
||||||
|
smsSentCount: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
function slugify(value: string) {
|
function slugify(value: string) {
|
||||||
@@ -169,7 +173,8 @@ export class BusinessAdminService {
|
|||||||
b.settings->'modules'->'enabled' AS "enabledModules",
|
b.settings->'modules'->'enabled' AS "enabledModules",
|
||||||
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"
|
||||||
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
|
||||||
@@ -232,6 +237,7 @@ export class BusinessAdminService {
|
|||||||
homeCharts,
|
homeCharts,
|
||||||
smsSenderNumber: normalizeSmsSenderNumber(item.smsSenderNumber),
|
smsSenderNumber: normalizeSmsSenderNumber(item.smsSenderNumber),
|
||||||
smsBalance: Number(item.smsBalance ?? 0),
|
smsBalance: Number(item.smsBalance ?? 0),
|
||||||
|
smsSentCount: Number(item.smsSentCount ?? 0),
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
total: totalRow[0]?.total ?? 0,
|
total: totalRow[0]?.total ?? 0,
|
||||||
@@ -391,7 +397,7 @@ export class BusinessAdminService {
|
|||||||
: new Date(),
|
: new Date(),
|
||||||
oldBusinessId:
|
oldBusinessId:
|
||||||
dto.oldBusinessId !== undefined ? BigInt(dto.oldBusinessId) : undefined,
|
dto.oldBusinessId !== undefined ? BigInt(dto.oldBusinessId) : undefined,
|
||||||
smsBalance: DEFAULT_NEW_BUSINESS_SMS_BALANCE_IRT,
|
smsBalance: DEFAULT_NEW_BUSINESS_SMS_CREDITS,
|
||||||
settings: toPrismaJson({
|
settings: toPrismaJson({
|
||||||
...DEFAULT_BUSINESS_SETTINGS,
|
...DEFAULT_BUSINESS_SETTINGS,
|
||||||
modules: {
|
modules: {
|
||||||
@@ -471,8 +477,18 @@ export class BusinessAdminService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (dto.smsBalance !== undefined && dto.smsBalance < 0) {
|
if (dto.smsTopUpIrt !== undefined && dto.smsTopUpIrt < 1) {
|
||||||
throw new BadRequestException('smsBalance cannot be negative');
|
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) => {
|
await this.prisma.$transaction(async (tx) => {
|
||||||
@@ -512,7 +528,9 @@ export class BusinessAdminService {
|
|||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
...(nextSettings !== undefined ? { settings: nextSettings } : {}),
|
...(nextSettings !== undefined ? { settings: nextSettings } : {}),
|
||||||
...(dto.smsBalance !== undefined ? { smsBalance: dto.smsBalance } : {}),
|
...(topUpCredits > 0
|
||||||
|
? { smsBalance: { increment: topUpCredits } }
|
||||||
|
: {}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1283,6 +1301,7 @@ export class BusinessAdminService {
|
|||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
settings: unknown;
|
settings: unknown;
|
||||||
smsBalance?: number;
|
smsBalance?: number;
|
||||||
|
smsSentCount?: number;
|
||||||
categoryAssignments: {
|
categoryAssignments: {
|
||||||
category: {
|
category: {
|
||||||
id: bigint;
|
id: bigint;
|
||||||
@@ -1328,6 +1347,7 @@ export class BusinessAdminService {
|
|||||||
updatedAt: business.updatedAt,
|
updatedAt: business.updatedAt,
|
||||||
smsSenderNumber: settings.sms.senderNumber,
|
smsSenderNumber: settings.sms.senderNumber,
|
||||||
smsBalance: business.smsBalance ?? 0,
|
smsBalance: business.smsBalance ?? 0,
|
||||||
|
smsSentCount: business.smsSentCount ?? 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,
|
||||||
|
|||||||
@@ -73,10 +73,13 @@ export class UpdateBusinessDto {
|
|||||||
})
|
})
|
||||||
smsSenderNumber?: string | null;
|
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()
|
@IsOptional()
|
||||||
@Type(() => Number)
|
@Type(() => Number)
|
||||||
@IsInt()
|
@IsInt()
|
||||||
@Min(0)
|
@Min(1)
|
||||||
smsBalance?: number;
|
smsTopUpIrt?: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -908,7 +908,11 @@ export class CustomersService {
|
|||||||
});
|
});
|
||||||
totalCost += cost;
|
totalCost += cost;
|
||||||
}
|
}
|
||||||
charged = await this.smsBilling.chargeAmount(businessId, totalCost);
|
charged = await this.smsBilling.chargeAmount(
|
||||||
|
businessId,
|
||||||
|
totalCost,
|
||||||
|
personalizedPayloads.length,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -940,10 +944,13 @@ export class CustomersService {
|
|||||||
);
|
);
|
||||||
serverIds.push(result.serverId);
|
serverIds.push(result.serverId);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const refundAmount = personalizedPayloads
|
const remaining = personalizedPayloads.slice(i);
|
||||||
.slice(i)
|
const refundAmount = remaining.reduce((sum, item) => sum + item.cost, 0);
|
||||||
.reduce((sum, item) => sum + item.cost, 0);
|
await this.smsBilling.refund(
|
||||||
await this.smsBilling.refund(businessId, refundAmount);
|
businessId,
|
||||||
|
refundAmount,
|
||||||
|
remaining.length,
|
||||||
|
);
|
||||||
charged = 0;
|
charged = 0;
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
@@ -960,7 +967,11 @@ export class CustomersService {
|
|||||||
};
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (charged > 0) {
|
if (charged > 0) {
|
||||||
await this.smsBilling.refund(businessId, charged);
|
await this.smsBilling.refund(
|
||||||
|
businessId,
|
||||||
|
charged,
|
||||||
|
personalized ? personalizedPayloads.length : recipients.length,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
err instanceof BadRequestException ||
|
err instanceof BadRequestException ||
|
||||||
|
|||||||
@@ -159,7 +159,7 @@ 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 },
|
select: { smsBalance: true, smsSentCount: true },
|
||||||
});
|
});
|
||||||
if (!business) {
|
if (!business) {
|
||||||
throw new NotFoundException('Business not found');
|
throw new NotFoundException('Business not found');
|
||||||
@@ -168,6 +168,8 @@ export class SmsTemplatesService {
|
|||||||
return {
|
return {
|
||||||
balance: business.smsBalance,
|
balance: business.smsBalance,
|
||||||
credit: business.smsBalance,
|
credit: business.smsBalance,
|
||||||
|
sentCount: business.smsSentCount,
|
||||||
|
unit: 'credits',
|
||||||
currency: null,
|
currency: null,
|
||||||
message: null,
|
message: null,
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user