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:
co-authored by
Cursor
parent
a8676854fb
commit
6bc57f26ed
@@ -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>
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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}}`
|
||||
|
||||
Reference in New Issue
Block a user