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 =
+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
- Free-text SMS source: `settings.sms.senderNumber` (Super Admin only) or advertising shortcode `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.
- New businesses created in Super Admin start with **500,000 IRT** SMS balance
- 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.
- 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
- Business Settings → **SMS settings**: balance (read-only) + template CRUD
- Super Admin business overflow → **SMS settings** modal: sender number + SMS balance top-up
- Business Settings → **SMS settings**: credit balance (read-only) + template CRUD
- Super Admin business overflow → **SMS settings** modal: sender number + current credits + IRT top-up
- Super Admin → **SMS Templates** (`/sms-templates`): verify / unverify
- Online payment gateways live under **Website → Settings** (not main Settings)
- Template vars: `{{user.name}}`, `{{user.lastname}}`, `{{user.title}}`