diff --git a/database/migrations/085_business_sms_sent_count.sql b/database/migrations/085_business_sms_sent_count.sql new file mode 100644 index 0000000..6233a9e --- /dev/null +++ b/database/migrations/085_business_sms_sent_count.sql @@ -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.'; diff --git a/database/migrations/086_sms_balance_credit_units.sql b/database/migrations/086_sms_balance_credit_units.sql new file mode 100644 index 0000000..0a3a9b9 --- /dev/null +++ b/database/migrations/086_sms_balance_credit_units.sql @@ -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.'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index b645c15..8fb05d9 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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[] diff --git a/src/auth/sms-billing.service.ts b/src/auth/sms-billing.service.ts index 85e3726..3d1725f 100644 --- a/src/auth/sms-billing.service.ts +++ b/src/auth/sms-billing.service.ts @@ -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 { + /** + * Atomically deduct an explicit credit amount and bump sms_sent_count. + * Returns credits charged. + */ + async chargeAmount( + businessId: bigint, + amount: number, + recipientCount = 1, + ): 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} + 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 { + async refund( + businessId: bigint, + amount: number, + recipientCount = 1, + ): Promise { 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 { + 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; + } } diff --git a/src/auth/sms-billing.ts b/src/auth/sms-billing.ts index 00ba074..ae5f460 100644 --- a/src/auth/sms-billing.ts +++ b/src/auth/sms-billing.ts @@ -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; diff --git a/src/business-admin/business-admin.service.ts b/src/business-admin/business-admin.service.ts index 1e0810e..98bd59d 100644 --- a/src/business-admin/business-admin.service.ts +++ b/src/business-admin/business-admin.service.ts @@ -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, diff --git a/src/business-admin/dto/update-business.dto.ts b/src/business-admin/dto/update-business.dto.ts index 315313b..8661a1d 100644 --- a/src/business-admin/dto/update-business.dto.ts +++ b/src/business-admin/dto/update-business.dto.ts @@ -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; } diff --git a/src/customers/customers.service.ts b/src/customers/customers.service.ts index 212555c..f549e13 100644 --- a/src/customers/customers.service.ts +++ b/src/customers/customers.service.ts @@ -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 || diff --git a/src/sms-templates/sms-templates.service.ts b/src/sms-templates/sms-templates.service.ts index 624fc0b..30efbdd 100644 --- a/src/sms-templates/sms-templates.service.ts +++ b/src/sms-templates/sms-templates.service.ts @@ -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, };