diff --git a/apps/business/src/components/SmsSettingsSection.tsx b/apps/business/src/components/SmsSettingsSection.tsx index ddbcb8c..3873dcb 100644 --- a/apps/business/src/components/SmsSettingsSection.tsx +++ b/apps/business/src/components/SmsSettingsSection.tsx @@ -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() { > {t('settings.sms.balance')} - {loading ? '…' : formatIrtPrice(balance)} + {loading + ? '…' + : t('settings.sms.balanceValue', { + count: balance.toLocaleString(isFa ? 'fa-IR' : 'en-US'), + })} diff --git a/apps/business/src/i18n/messages.ts b/apps/business/src/i18n/messages.ts index ba56cce..02c418f 100644 --- a/apps/business/src/i18n/messages.ts +++ b/apps/business/src/i18n/messages.ts @@ -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 = { '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': 'قالب‌ها', diff --git a/apps/business/src/services/smsTemplateService.ts b/apps/business/src/services/smsTemplateService.ts index 3d89b16..1e5ada6 100644 --- a/apps/business/src/services/smsTemplateService.ts +++ b/apps/business/src/services/smsTemplateService.ts @@ -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 }) diff --git a/apps/super-admin/src/pages/BusinessesPage.module.css b/apps/super-admin/src/pages/BusinessesPage.module.css index 97d2ef3..8235243 100644 --- a/apps/super-admin/src/pages/BusinessesPage.module.css +++ b/apps/super-admin/src/pages/BusinessesPage.module.css @@ -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 { diff --git a/apps/super-admin/src/pages/BusinessesPage.tsx b/apps/super-admin/src/pages/BusinessesPage.tsx index 1d2527f..e3b4fe8 100644 --- a/apps/super-admin/src/pages/BusinessesPage.tsx +++ b/apps/super-admin/src/pages/BusinessesPage.tsx @@ -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(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() { Contract Data / Renewal Domain Owner + SMS sent Theme Lang Modules @@ -1555,7 +1572,7 @@ export function BusinessesPage() { {loading && ( - + Loading... @@ -1563,7 +1580,7 @@ export function BusinessesPage() { {!loading && data?.items?.length === 0 && ( - + No results found. @@ -1654,6 +1671,9 @@ export function BusinessesPage() { + + {(b.smsSentCount ?? 0).toLocaleString('en-US')} +
- + +

+ 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. +

+
+
+ + 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 ( +

+ {irt > 0 + ? `SMS credit charge: +${credits.toLocaleString('en-US')} → new balance ${next.toLocaleString('en-US')} (was ${current.toLocaleString('en-US')})` + : 'SMS credit charge: —'} +

+ ) + })()}

- 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. +

+
+
+ + +

+ Lifetime messages billed for this business (one per recipient). Updated automatically + when SMS is charged or refunded.

@@ -2607,8 +2671,8 @@ export function BusinessesPage() {

) : null}

- 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).

diff --git a/apps/super-admin/src/services/businessService.ts b/apps/super-admin/src/services/businessService.ts index 6fa96c9..831a820 100644 --- a/apps/super-admin/src/services/businessService.ts +++ b/apps/super-admin/src/services/businessService.ts @@ -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 { diff --git a/apps/super-admin/src/types/business.ts b/apps/super-admin/src/types/business.ts index 7d85be4..e90f3b3 100644 --- a/apps/super-admin/src/types/business.ts +++ b/apps/super-admin/src/types/business.ts @@ -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 = diff --git a/docs/PROJECT_CONTEXT.md b/docs/PROJECT_CONTEXT.md index b13f6c8..52e525b 100644 --- a/docs/PROJECT_CONTEXT.md +++ b/docs/PROJECT_CONTEXT.md @@ -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}}`