Bill SMS in credits and add IRT top-up in Super Admin.

Show credit balance in business settings; Super Admin tops up by IRT (adds credits) and tracks SMS sent per business.

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 a8676854fb
commit 6bc57f26ed
8 changed files with 139 additions and 34 deletions
@@ -8,7 +8,6 @@ import { Tooltip } from './Tooltip'
import { useToast } from '../context/ToastContext' import { useToast } from '../context/ToastContext'
import { useT } from '../i18n/useT' import { useT } from '../i18n/useT'
import { ApiError, isAbortError } from '../lib/api' import { ApiError, isAbortError } from '../lib/api'
import { formatIrtPrice } from '../utils/irtPrice'
import { import {
createSmsTemplate, createSmsTemplate,
deleteSmsTemplate, deleteSmsTemplate,
@@ -26,7 +25,7 @@ import settingsStyles from '../pages/SettingsPage.module.css'
import modalStyles from './VariationsModal.module.css' import modalStyles from './VariationsModal.module.css'
const SMS_TEMPLATE_VARS = ['{{user.name}}', '{{user.lastname}}', '{{user.title}}'] as const const SMS_TEMPLATE_VARS = ['{{user.name}}', '{{user.lastname}}', '{{user.title}}'] as const
const LOW_BALANCE_IRT = 200_000 const LOW_BALANCE_CREDITS = 400
const ANIMATION_MS = 220 const ANIMATION_MS = 220
const MAX_BODY_LEN = 700 const MAX_BODY_LEN = 700
@@ -131,7 +130,7 @@ export function SmsSettingsSection() {
}, [listMounted, editorMounted, editorOpen, listOpen, submitting, removeTarget]) }, [listMounted, editorMounted, editorOpen, listOpen, submitting, removeTarget])
const verifiedCount = templates.filter((item) => item.isVerified).length const verifiedCount = templates.filter((item) => item.isVerified).length
const isLowBalance = balance < LOW_BALANCE_IRT const isLowBalance = balance < LOW_BALANCE_CREDITS
function openCreate() { function openCreate() {
setEditing(null) setEditing(null)
@@ -265,7 +264,11 @@ export function SmsSettingsSection() {
> >
<span className={settingsStyles.smsCreditLabel}>{t('settings.sms.balance')}</span> <span className={settingsStyles.smsCreditLabel}>{t('settings.sms.balance')}</span>
<strong className={settingsStyles.smsCreditValue}> <strong className={settingsStyles.smsCreditValue}>
{loading ? '…' : formatIrtPrice(balance)} {loading
? '…'
: t('settings.sms.balanceValue', {
count: balance.toLocaleString(isFa ? 'fa-IR' : 'en-US'),
})}
</strong> </strong>
</div> </div>
+7 -3
View File
@@ -1694,9 +1694,11 @@ const en = {
'settings.sms.title': 'SMS settings', 'settings.sms.title': 'SMS settings',
'settings.sms.desc': 'settings.sms.desc':
'View your prepaid SMS balance and manage message templates. Sender numbers are managed by Meshkee.', 'View your prepaid SMS balance and manage message templates. Sender numbers are managed by Meshkee.',
'settings.sms.balance': 'SMS balance', 'settings.sms.balance': 'SMS credits',
'settings.sms.balanceValue': '{count} SMS',
'settings.sms.balanceHint': 'settings.sms.balanceHint':
'Balance is in IRT. Each 64 characters costs 400 IRT per recipient (login, orders, customer SMS, etc.). Contact Meshkee to top up.', 'Balance is SMS credits. Each 64 characters costs 1 credit per recipient (login, orders, customer SMS, etc.). Contact Meshkee to top up.',
'settings.sms.sentCount': 'SMS sent',
'settings.sms.credit': 'Account credit', 'settings.sms.credit': 'Account credit',
'settings.sms.creditUnavailable': 'Unavailable', 'settings.sms.creditUnavailable': 'Unavailable',
'settings.sms.templates': 'Templates', 'settings.sms.templates': 'Templates',
@@ -4157,8 +4159,10 @@ const fa: Record<MessageKey, string> = {
'settings.sms.desc': 'settings.sms.desc':
'اعتبار پیش‌پرداخت پیامک و قالب‌های پیام را ببینید. شماره فرستنده توسط مشکی مدیریت می‌شود.', 'اعتبار پیش‌پرداخت پیامک و قالب‌های پیام را ببینید. شماره فرستنده توسط مشکی مدیریت می‌شود.',
'settings.sms.balance': 'اعتبار پیامک', 'settings.sms.balance': 'اعتبار پیامک',
'settings.sms.balanceValue': '{count} پیامک',
'settings.sms.balanceHint': 'settings.sms.balanceHint':
'اعتبار به تومان (IRT) است. هر ۶۴ کاراکتر برای هر گیرنده ۴۰۰ تومان هزینه دارد (ورود، سفارش، پیامک مشتری و غیره). برای افزایش با مشکی تماس بگیرید.', 'اعتبار به واحد پیامک است. هر ۶۴ کاراکتر برای هر گیرنده ۱ اعتبار مصرف می‌کند (ورود، سفارش، پیامک مشتری و غیره). برای افزایش با مشکی تماس بگیرید.',
'settings.sms.sentCount': 'پیامک ارسال‌شده',
'settings.sms.credit': 'اعتبار حساب', 'settings.sms.credit': 'اعتبار حساب',
'settings.sms.creditUnavailable': 'در دسترس نیست', 'settings.sms.creditUnavailable': 'در دسترس نیست',
'settings.sms.templates': 'قالب‌ها', 'settings.sms.templates': 'قالب‌ها',
@@ -69,6 +69,9 @@ export async function getSmsCredit(signal?: AbortSignal) {
balance: number balance: number
/** @deprecated use balance */ /** @deprecated use balance */
credit: number | null credit: number | null
/** Lifetime SMS messages billed (one per recipient). */
sentCount: number
unit: 'credits'
currency: string | null currency: string | null
message: string | null message: string | null
}>(smsPath('/credit'), { auth: true, signal }) }>(smsPath('/credit'), { auth: true, signal })
@@ -30,9 +30,11 @@
min-width: 0; min-width: 0;
} }
/* Equal width fields; same 12px gap as field→action buttons */ /* Equal width fields; same 12px gap as field→action buttons.
Cap growth so a single filter (e.g. Websites Name) stays compact. */
.filtersInputs > .field { .filtersInputs > .field {
flex: 1 1 160px; flex: 1 1 160px;
max-width: 280px;
min-width: 0; min-width: 0;
} }
@@ -78,6 +80,14 @@
color: var(--text-muted); color: var(--text-muted);
} }
.smsTopUpCredits {
margin: 8px 0 0;
font-size: 13px;
font-weight: 600;
font-variant-numeric: tabular-nums;
color: var(--text-primary);
}
.entityGroupLabel { .entityGroupLabel {
font-size: 12px; font-size: 12px;
font-weight: 500; font-weight: 500;
@@ -440,6 +450,21 @@
padding-right: 8px; padding-right: 8px;
} }
.thSms,
.tdSms {
width: 1%;
white-space: nowrap;
text-align: center;
font-variant-numeric: tabular-nums;
}
.tdSms {
padding-left: 8px;
padding-right: 8px;
color: var(--text-secondary);
font-size: 13px;
}
.thModules, .thModules,
.tdModules { .tdModules {
width: 1%; width: 1%;
@@ -1051,6 +1076,7 @@
.filtersInputs > .field { .filtersInputs > .field {
flex-basis: 100%; flex-basis: 100%;
max-width: none;
} }
.filterActions { .filterActions {
+84 -20
View File
@@ -90,6 +90,7 @@ import {
type HomeChartId, type HomeChartId,
} from '../utils/businessModules' } from '../utils/businessModules'
import { flattenBusinessCategories } from '../utils/categories' import { flattenBusinessCategories } from '../utils/categories'
import { formatIrtInput, parseIrtInput } from '../utils/irtPrice'
import { Pagination } from '@meshkee/dashboard-ui' import { Pagination } from '@meshkee/dashboard-ui'
import pageStyles from '../components/PageContent.module.css' import pageStyles from '../components/PageContent.module.css'
import styles from './BusinessesPage.module.css' import styles from './BusinessesPage.module.css'
@@ -345,7 +346,7 @@ export function BusinessesPage() {
const [smsSettingsOpen, setSmsSettingsOpen] = useState(false) const [smsSettingsOpen, setSmsSettingsOpen] = useState(false)
const [smsSettingsBusiness, setSmsSettingsBusiness] = useState<BusinessListItem | null>(null) const [smsSettingsBusiness, setSmsSettingsBusiness] = useState<BusinessListItem | null>(null)
const [smsSenderNumber, setSmsSenderNumber] = useState('') const [smsSenderNumber, setSmsSenderNumber] = useState('')
const [smsBalance, setSmsBalance] = useState('0') const [smsTopUpIrt, setSmsTopUpIrt] = useState('')
const [smsSettingsSubmitting, setSmsSettingsSubmitting] = useState(false) const [smsSettingsSubmitting, setSmsSettingsSubmitting] = useState(false)
const [smsSettingsError, setSmsSettingsError] = useState('') const [smsSettingsError, setSmsSettingsError] = useState('')
@@ -534,7 +535,7 @@ export function BusinessesPage() {
function openSmsSettings(b: BusinessListItem) { function openSmsSettings(b: BusinessListItem) {
setSmsSettingsBusiness(b) setSmsSettingsBusiness(b)
setSmsSenderNumber(b.smsSenderNumber ?? '') setSmsSenderNumber(b.smsSenderNumber ?? '')
setSmsBalance(String(b.smsBalance ?? 0)) setSmsTopUpIrt('')
setSmsSettingsError('') setSmsSettingsError('')
setSmsSettingsOpen(true) setSmsSettingsOpen(true)
} }
@@ -917,18 +918,28 @@ export function BusinessesPage() {
if (!smsSettingsBusiness) return if (!smsSettingsBusiness) return
const sender = smsSenderNumber.replace(/\D/g, '') const sender = smsSenderNumber.replace(/\D/g, '')
const balanceParsed = Number.parseInt(smsBalance.replace(/\D/g, '') || '0', 10) const topUpParsed = parseIrtInput(smsTopUpIrt) ?? undefined
if (!Number.isFinite(balanceParsed) || balanceParsed < 0) { if (
setSmsSettingsError('SMS balance must be a non-negative integer.') topUpParsed !== undefined &&
(!Number.isFinite(topUpParsed) || topUpParsed < 1)
) {
setSmsSettingsError('Top-up amount must be a positive integer (IRT).')
return return
} }
if (topUpParsed !== undefined && topUpParsed < 500) {
setSmsSettingsError('Minimum top-up is 500 IRT (1 SMS credit).')
return
}
const creditsAdded =
topUpParsed !== undefined ? Math.floor(topUpParsed / 500) : 0
setSmsSettingsSubmitting(true) setSmsSettingsSubmitting(true)
setSmsSettingsError('') setSmsSettingsError('')
try { try {
await updateBusiness(smsSettingsBusiness.id, { await updateBusiness(smsSettingsBusiness.id, {
smsSenderNumber: sender || null, smsSenderNumber: sender || null,
smsBalance: balanceParsed, ...(topUpParsed !== undefined ? { smsTopUpIrt: topUpParsed } : {}),
}) })
setData((prev) => { setData((prev) => {
if (!prev) return prev if (!prev) return prev
@@ -939,7 +950,7 @@ export function BusinessesPage() {
? { ? {
...item, ...item,
smsSenderNumber: sender || null, smsSenderNumber: sender || null,
smsBalance: balanceParsed, smsBalance: (item.smsBalance ?? 0) + creditsAdded,
} }
: item, : item,
), ),
@@ -947,7 +958,12 @@ export function BusinessesPage() {
}) })
setSmsSettingsOpen(false) setSmsSettingsOpen(false)
setSmsSettingsBusiness(null) setSmsSettingsBusiness(null)
showToast('SMS settings saved.', 'success') showToast(
creditsAdded > 0
? `SMS settings saved. Added ${creditsAdded.toLocaleString('en-US')} credits.`
: 'SMS settings saved.',
'success',
)
} catch (err) { } catch (err) {
setSmsSettingsError( setSmsSettingsError(
err instanceof ApiError ? err.message : 'Unable to save SMS settings.', err instanceof ApiError ? err.message : 'Unable to save SMS settings.',
@@ -1546,6 +1562,7 @@ export function BusinessesPage() {
<th className={styles.th}>Contract Data / Renewal</th> <th className={styles.th}>Contract Data / Renewal</th>
<th className={styles.th}>Domain</th> <th className={styles.th}>Domain</th>
<th className={styles.th}>Owner</th> <th className={styles.th}>Owner</th>
<th className={`${styles.th} ${styles.thSms}`}>SMS sent</th>
<th className={`${styles.th} ${styles.thTheme}`}>Theme</th> <th className={`${styles.th} ${styles.thTheme}`}>Theme</th>
<th className={`${styles.th} ${styles.thLocale}`}>Lang</th> <th className={`${styles.th} ${styles.thLocale}`}>Lang</th>
<th className={`${styles.th} ${styles.thModules}`}>Modules</th> <th className={`${styles.th} ${styles.thModules}`}>Modules</th>
@@ -1555,7 +1572,7 @@ export function BusinessesPage() {
<tbody> <tbody>
{loading && ( {loading && (
<tr> <tr>
<td className={styles.td} colSpan={8}> <td className={styles.td} colSpan={9}>
Loading... Loading...
</td> </td>
</tr> </tr>
@@ -1563,7 +1580,7 @@ export function BusinessesPage() {
{!loading && data?.items?.length === 0 && ( {!loading && data?.items?.length === 0 && (
<tr> <tr>
<td className={styles.td} colSpan={8}> <td className={styles.td} colSpan={9}>
No results found. No results found.
</td> </td>
</tr> </tr>
@@ -1654,6 +1671,9 @@ export function BusinessesPage() {
</div> </div>
</div> </div>
</td> </td>
<td className={`${styles.td} ${styles.tdSms}`}>
{(b.smsSentCount ?? 0).toLocaleString('en-US')}
</td>
<td className={`${styles.td} ${styles.tdTheme}`}> <td className={`${styles.td} ${styles.tdTheme}`}>
<PrimaryColorSwatchControl <PrimaryColorSwatchControl
value={normalizeBusinessPrimaryColorId(b.primaryColor)} value={normalizeBusinessPrimaryColorId(b.primaryColor)}
@@ -2088,20 +2108,64 @@ export function BusinessesPage() {
</p> </p>
</div> </div>
<div className={styles.field}> <div className={styles.field}>
<label htmlFor="sms-settings-balance">SMS balance (IRT)</label> <label>Current SMS balance</label>
<input <input
id="sms-settings-balance" value={`${(smsSettingsBusiness?.smsBalance ?? 0).toLocaleString('en-US')} credits`}
readOnly
disabled
aria-label="Current SMS credit balance"
/>
<p className={styles.helperText}>
Prepaid SMS credits. 1 credit = one 64-character segment per recipient (OTP,
customer SMS, partner SMS). New businesses start with 1,000 credits. Values below
400 credits show as low balance in the business dashboard.
</p>
</div>
<div className={styles.field}>
<label htmlFor="sms-settings-topup">Add top-up (IRT)</label>
<input
id="sms-settings-topup"
type="text"
inputMode="numeric" inputMode="numeric"
value={smsBalance} value={smsTopUpIrt}
onChange={(e) => setSmsBalance(e.target.value.replace(/\D/g, '').slice(0, 12))} onChange={(e) => {
placeholder="0" const digits = e.target.value.replace(/\D/g, '').slice(0, 12)
setSmsTopUpIrt(formatIrtInput(digits))
}}
placeholder="e.g. 1,000,000"
disabled={smsSettingsSubmitting} disabled={smsSettingsSubmitting}
autoComplete="off" autoComplete="off"
/> />
{(() => {
const irt = parseIrtInput(smsTopUpIrt) ?? 0
const credits = Math.floor(irt / 500)
const current = smsSettingsBusiness?.smsBalance ?? 0
const next = current + credits
return (
<p className={styles.smsTopUpCredits} aria-live="polite">
{irt > 0
? `SMS credit charge: +${credits.toLocaleString('en-US')} → new balance ${next.toLocaleString('en-US')} (was ${current.toLocaleString('en-US')})`
: 'SMS credit charge: —'}
</p>
)
})()}
<p className={styles.helperText}> <p className={styles.helperText}>
Prepaid SMS balance in IRT. Billing: 400 IRT per 64 characters per recipient Each SMS credit costs 500 IRT (1,000,000 → +2,000 credits). Top-ups are added to
(OTP, customer SMS, partner SMS). New businesses start with 500,000 IRT. Values the current balance — they never reset it. Leave empty to only update the sender
below 200,000 IRT show as low balance in the business dashboard. number.
</p>
</div>
<div className={styles.field}>
<label>SMS sent</label>
<input
value={(smsSettingsBusiness?.smsSentCount ?? 0).toLocaleString('en-US')}
readOnly
disabled
aria-label="SMS messages sent"
/>
<p className={styles.helperText}>
Lifetime messages billed for this business (one per recipient). Updated automatically
when SMS is charged or refunded.
</p> </p>
</div> </div>
<div style={{ height: 12 }} /> <div style={{ height: 12 }} />
@@ -2607,8 +2671,8 @@ export function BusinessesPage() {
</p> </p>
) : null} ) : null}
<p className={styles.helperText} style={{ marginTop: 0, marginBottom: 12 }}> <p className={styles.helperText} style={{ marginTop: 0, marginBottom: 12 }}>
New businesses receive an initial SMS balance of 500,000 IRT (400 IRT per 64 characters New businesses receive an initial SMS balance of 1,000 credits (1 credit = 64 characters
per recipient). per recipient; top-ups cost 500 IRT per credit).
</p> </p>
<div className={styles.formGrid}> <div className={styles.formGrid}>
<div className={styles.field}> <div className={styles.field}>
@@ -25,7 +25,8 @@ export interface UpdateBusinessPayload {
oldBusinessId?: number | null oldBusinessId?: number | null
annualRenewalAt?: string | null annualRenewalAt?: string | null
smsSenderNumber?: string | null smsSenderNumber?: string | null
smsBalance?: number /** IRT amount to convert into SMS credits and add (floor(irt / 500)). */
smsTopUpIrt?: number
} }
export interface AddDomainPayload { export interface AddDomainPayload {
+3 -1
View File
@@ -40,8 +40,10 @@ export interface BusinessListItem {
oldBusinessId: string | null oldBusinessId: string | null
/** Gama shortcode for customer SMS; null → Meshkee advertising number. */ /** Gama shortcode for customer SMS; null → Meshkee advertising number. */
smsSenderNumber: string | null smsSenderNumber: string | null
/** Prepaid SMS balance in IRT (400 IRT per 64 chars / recipient). */ /** Prepaid SMS credit units (1 = 64 chars × recipient). Top-up at 500 IRT/credit. */
smsBalance: number smsBalance: number
/** Lifetime SMS messages billed (one per recipient; decremented on refund). */
smsSentCount: number
} }
export type MigrateFromOldEntity = export type MigrateFromOldEntity =
+6 -4
View File
@@ -319,11 +319,13 @@ Products accept optional `brandId` on create/update.
- `POST /businesses/:businessId/customers/sms` — bulk SMS via Gama **SendBulk** (or per-recipient when template/vars); body `{ message, userIds?, templateId? }` — empty/omitted `userIds` → all enabled customers - `POST /businesses/:businessId/customers/sms` — bulk SMS via Gama **SendBulk** (or per-recipient when template/vars); body `{ message, userIds?, templateId? }` — empty/omitted `userIds` → all enabled customers
- Free-text SMS source: `settings.sms.senderNumber` (Super Admin only) or advertising shortcode `5000990009` - Free-text SMS source: `settings.sms.senderNumber` (Super Admin only) or advertising shortcode `5000990009`
- Verified SMS template → service shortcode `5000110005` (fixed); unverified template → `5000990009` - Verified SMS template → service shortcode `5000110005` (fixed); unverified template → `5000990009`
- Per-business prepaid `businesses.sms_balance` (IRT) — all Meshkee SMS (OTP login/forgot-password, customer SMS, partner/public SMS for linked domains) costs **400 IRT per 64 characters** per recipient (`ceil(len/64)*400`). Insufficient balance blocks send. - Per-business prepaid `businesses.sms_balance` (**SMS credits**) — all Meshkee SMS (OTP login/forgot-password, customer SMS, partner/public SMS for linked domains) costs **1 credit per 64 characters** per recipient (`ceil(len/64)`). Insufficient balance blocks send.
- New businesses created in Super Admin start with **500,000 IRT** SMS balance - Top-up (Super Admin): enter IRT amount → adds `floor(IRT / 500)` credits (e.g. 1,000,000 IRT → 2,000 credits)
- New businesses created in Super Admin start with **1,000** SMS credits
- `businesses.sms_sent_count` — lifetime messages billed (one per recipient)
- External domains (no Meshkee website) still bill via their business row once the domain is linked - External domains (no Meshkee website) still bill via their business row once the domain is linked
- Business Settings → **SMS settings**: balance (read-only) + template CRUD - Business Settings → **SMS settings**: credit balance (read-only) + template CRUD
- Super Admin business overflow → **SMS settings** modal: sender number + SMS balance top-up - Super Admin business overflow → **SMS settings** modal: sender number + current credits + IRT top-up
- Super Admin → **SMS Templates** (`/sms-templates`): verify / unverify - Super Admin → **SMS Templates** (`/sms-templates`): verify / unverify
- Online payment gateways live under **Website → Settings** (not main Settings) - Online payment gateways live under **Website → Settings** (not main Settings)
- Template vars: `{{user.name}}`, `{{user.lastname}}`, `{{user.title}}` - Template vars: `{{user.name}}`, `{{user.lastname}}`, `{{user.title}}`