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 { useT } from '../i18n/useT'
import { ApiError, isAbortError } from '../lib/api'
import { formatIrtPrice } from '../utils/irtPrice'
import {
createSmsTemplate,
deleteSmsTemplate,
@@ -26,7 +25,7 @@ import settingsStyles from '../pages/SettingsPage.module.css'
import modalStyles from './VariationsModal.module.css'
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 MAX_BODY_LEN = 700
@@ -131,7 +130,7 @@ export function SmsSettingsSection() {
}, [listMounted, editorMounted, editorOpen, listOpen, submitting, removeTarget])
const verifiedCount = templates.filter((item) => item.isVerified).length
const isLowBalance = balance < LOW_BALANCE_IRT
const isLowBalance = balance < LOW_BALANCE_CREDITS
function openCreate() {
setEditing(null)
@@ -265,7 +264,11 @@ export function SmsSettingsSection() {
>
<span className={settingsStyles.smsCreditLabel}>{t('settings.sms.balance')}</span>
<strong className={settingsStyles.smsCreditValue}>
{loading ? '…' : formatIrtPrice(balance)}
{loading
? '…'
: t('settings.sms.balanceValue', {
count: balance.toLocaleString(isFa ? 'fa-IR' : 'en-US'),
})}
</strong>
</div>
+7 -3
View File
@@ -1694,9 +1694,11 @@ const en = {
'settings.sms.title': 'SMS settings',
'settings.sms.desc':
'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':
'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.creditUnavailable': 'Unavailable',
'settings.sms.templates': 'Templates',
@@ -4157,8 +4159,10 @@ const fa: Record<MessageKey, string> = {
'settings.sms.desc':
'اعتبار پیش‌پرداخت پیامک و قالب‌های پیام را ببینید. شماره فرستنده توسط مشکی مدیریت می‌شود.',
'settings.sms.balance': 'اعتبار پیامک',
'settings.sms.balanceValue': '{count} پیامک',
'settings.sms.balanceHint':
'اعتبار به تومان (IRT) است. هر ۶۴ کاراکتر برای هر گیرنده ۴۰۰ تومان هزینه دارد (ورود، سفارش، پیامک مشتری و غیره). برای افزایش با مشکی تماس بگیرید.',
'اعتبار به واحد پیامک است. هر ۶۴ کاراکتر برای هر گیرنده ۱ اعتبار مصرف می‌کند (ورود، سفارش، پیامک مشتری و غیره). برای افزایش با مشکی تماس بگیرید.',
'settings.sms.sentCount': 'پیامک ارسال‌شده',
'settings.sms.credit': 'اعتبار حساب',
'settings.sms.creditUnavailable': 'در دسترس نیست',
'settings.sms.templates': 'قالب‌ها',
@@ -69,6 +69,9 @@ export async function getSmsCredit(signal?: AbortSignal) {
balance: number
/** @deprecated use balance */
credit: number | null
/** Lifetime SMS messages billed (one per recipient). */
sentCount: number
unit: 'credits'
currency: string | null
message: string | null
}>(smsPath('/credit'), { auth: true, signal })
@@ -30,9 +30,11 @@
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 {
flex: 1 1 160px;
max-width: 280px;
min-width: 0;
}
@@ -78,6 +80,14 @@
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 {
font-size: 12px;
font-weight: 500;
@@ -440,6 +450,21 @@
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,
.tdModules {
width: 1%;
@@ -1051,6 +1076,7 @@
.filtersInputs > .field {
flex-basis: 100%;
max-width: none;
}
.filterActions {
+84 -20
View File
@@ -90,6 +90,7 @@ import {
type HomeChartId,
} from '../utils/businessModules'
import { flattenBusinessCategories } from '../utils/categories'
import { formatIrtInput, parseIrtInput } from '../utils/irtPrice'
import { Pagination } from '@meshkee/dashboard-ui'
import pageStyles from '../components/PageContent.module.css'
import styles from './BusinessesPage.module.css'
@@ -345,7 +346,7 @@ export function BusinessesPage() {
const [smsSettingsOpen, setSmsSettingsOpen] = useState(false)
const [smsSettingsBusiness, setSmsSettingsBusiness] = useState<BusinessListItem | null>(null)
const [smsSenderNumber, setSmsSenderNumber] = useState('')
const [smsBalance, setSmsBalance] = useState('0')
const [smsTopUpIrt, setSmsTopUpIrt] = useState('')
const [smsSettingsSubmitting, setSmsSettingsSubmitting] = useState(false)
const [smsSettingsError, setSmsSettingsError] = useState('')
@@ -534,7 +535,7 @@ export function BusinessesPage() {
function openSmsSettings(b: BusinessListItem) {
setSmsSettingsBusiness(b)
setSmsSenderNumber(b.smsSenderNumber ?? '')
setSmsBalance(String(b.smsBalance ?? 0))
setSmsTopUpIrt('')
setSmsSettingsError('')
setSmsSettingsOpen(true)
}
@@ -917,18 +918,28 @@ export function BusinessesPage() {
if (!smsSettingsBusiness) return
const sender = smsSenderNumber.replace(/\D/g, '')
const balanceParsed = Number.parseInt(smsBalance.replace(/\D/g, '') || '0', 10)
if (!Number.isFinite(balanceParsed) || balanceParsed < 0) {
setSmsSettingsError('SMS balance must be a non-negative integer.')
const topUpParsed = parseIrtInput(smsTopUpIrt) ?? undefined
if (
topUpParsed !== undefined &&
(!Number.isFinite(topUpParsed) || topUpParsed < 1)
) {
setSmsSettingsError('Top-up amount must be a positive integer (IRT).')
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)
setSmsSettingsError('')
try {
await updateBusiness(smsSettingsBusiness.id, {
smsSenderNumber: sender || null,
smsBalance: balanceParsed,
...(topUpParsed !== undefined ? { smsTopUpIrt: topUpParsed } : {}),
})
setData((prev) => {
if (!prev) return prev
@@ -939,7 +950,7 @@ export function BusinessesPage() {
? {
...item,
smsSenderNumber: sender || null,
smsBalance: balanceParsed,
smsBalance: (item.smsBalance ?? 0) + creditsAdded,
}
: item,
),
@@ -947,7 +958,12 @@ export function BusinessesPage() {
})
setSmsSettingsOpen(false)
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) {
setSmsSettingsError(
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}>Domain</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.thLocale}`}>Lang</th>
<th className={`${styles.th} ${styles.thModules}`}>Modules</th>
@@ -1555,7 +1572,7 @@ export function BusinessesPage() {
<tbody>
{loading && (
<tr>
<td className={styles.td} colSpan={8}>
<td className={styles.td} colSpan={9}>
Loading...
</td>
</tr>
@@ -1563,7 +1580,7 @@ export function BusinessesPage() {
{!loading && data?.items?.length === 0 && (
<tr>
<td className={styles.td} colSpan={8}>
<td className={styles.td} colSpan={9}>
No results found.
</td>
</tr>
@@ -1654,6 +1671,9 @@ export function BusinessesPage() {
</div>
</div>
</td>
<td className={`${styles.td} ${styles.tdSms}`}>
{(b.smsSentCount ?? 0).toLocaleString('en-US')}
</td>
<td className={`${styles.td} ${styles.tdTheme}`}>
<PrimaryColorSwatchControl
value={normalizeBusinessPrimaryColorId(b.primaryColor)}
@@ -2088,20 +2108,64 @@ export function BusinessesPage() {
</p>
</div>
<div className={styles.field}>
<label htmlFor="sms-settings-balance">SMS balance (IRT)</label>
<label>Current SMS balance</label>
<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"
value={smsBalance}
onChange={(e) => setSmsBalance(e.target.value.replace(/\D/g, '').slice(0, 12))}
placeholder="0"
value={smsTopUpIrt}
onChange={(e) => {
const digits = e.target.value.replace(/\D/g, '').slice(0, 12)
setSmsTopUpIrt(formatIrtInput(digits))
}}
placeholder="e.g. 1,000,000"
disabled={smsSettingsSubmitting}
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}>
Prepaid SMS balance in IRT. Billing: 400 IRT per 64 characters per recipient
(OTP, customer SMS, partner SMS). New businesses start with 500,000 IRT. Values
below 200,000 IRT show as low balance in the business dashboard.
Each SMS credit costs 500 IRT (1,000,000 → +2,000 credits). Top-ups are added to
the current balance — they never reset it. Leave empty to only update the sender
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>
</div>
<div style={{ height: 12 }} />
@@ -2607,8 +2671,8 @@ export function BusinessesPage() {
</p>
) : null}
<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
per recipient).
New businesses receive an initial SMS balance of 1,000 credits (1 credit = 64 characters
per recipient; top-ups cost 500 IRT per credit).
</p>
<div className={styles.formGrid}>
<div className={styles.field}>
@@ -25,7 +25,8 @@ export interface UpdateBusinessPayload {
oldBusinessId?: number | null
annualRenewalAt?: string | null
smsSenderNumber?: string | null
smsBalance?: number
/** IRT amount to convert into SMS credits and add (floor(irt / 500)). */
smsTopUpIrt?: number
}
export interface AddDomainPayload {
+3 -1
View File
@@ -40,8 +40,10 @@ export interface BusinessListItem {
oldBusinessId: string | null
/** Gama shortcode for customer SMS; null → Meshkee advertising number. */
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
/** Lifetime SMS messages billed (one per recipient; decremented on refund). */
smsSentCount: number
}
export type MigrateFromOldEntity =