/** * Import real SMS wallet data from one Hamkar/Khanegi MySQL into SuperAdmin. * * Each sold version has its own MySQL (sms_wallets is single-row per server). * Run once per version (SSH tunnel / on that DB host), matching SuperAdmin tenant by domain. * * Env: * SMS_IMPORT_DOMAIN=montpar.ir # required (or SMS_IMPORT_TENANT_ID) * SMS_IMPORT_PRODUCT=hamkar|khanegi # default: hamkar (charge-info key mapping) * SMS_IMPORT_HISTORY=1 # also copy ledger + send logs (default 1) * SMS_IMPORT_DRY_RUN=1 # print only, no writes * SMS_IMPORT_REPLACE_HISTORY=1 # delete existing SA ledger/logs before copy (default 1 when HISTORY=1) * * DB (hamkar defaults from HAMKAR_DB_*; override with SMS_IMPORT_DB_*): * SMS_IMPORT_DB_HOST / PORT / USER / PASSWORD / DATABASE * * Usage: * cd apps/api * npm run prisma:import-sms-from-product */ import * as mysql from 'mysql2/promise'; import { PrismaClient, SmsLedgerType } from '@prisma/client'; const prisma = new PrismaClient(); type WalletRow = { balance: number | string; price_per_part: number | string; is_blocked: number | boolean; alert_days_sent?: number; last_alert_date?: Date | string | null; }; type SettingRow = { key: string; value: string | null }; type LedgerRow = { type: string; amount: number | string; balance_after: number | string; price_per_part: number | string | null; parts: number | string | null; note: string | null; created_by: number | string | null; created_at: Date | string; }; type SendLogRow = { receiver: string; body: string; parts: number | string; price_per_part: number | string; total_amount: number | string; provider: string | null; source: string | null; is_alert: number | boolean; created_at: Date | string; }; function env(name: string, fallback = '') { return String(process.env[name] ?? fallback).trim(); } function asInt(value: unknown, fallback = 0) { const n = Number(value); return Number.isFinite(n) ? Math.trunc(n) : fallback; } function asBool(value: unknown) { return value === true || value === 1 || value === '1'; } function asDate(value: Date | string) { const d = value instanceof Date ? value : new Date(value); return Number.isNaN(d.getTime()) ? new Date() : d; } function normalizeHost(value?: string | null) { if (!value) return ''; return String(value) .trim() .toLowerCase() .replace(/^https?:\/\//, '') .split('/')[0] .split(':')[0]; } function mapLedgerType(raw: string): SmsLedgerType { const t = String(raw || '').toLowerCase(); if (t === 'charge') return 'charge'; if (t === 'alert_debit' || t === 'debit') return 'debit'; if (t === 'adjust') return 'adjust'; return 'debit'; } async function tableExists(conn: mysql.Connection, database: string, table: string) { const [rows] = await conn.query( `SELECT 1 AS ok FROM information_schema.tables WHERE table_schema = ? AND table_name = ? LIMIT 1`, [database, table], ); return Array.isArray(rows) && rows.length > 0; } async function findTenant(domainOrId: string) { if (env('SMS_IMPORT_TENANT_ID')) { const byId = await prisma.tenant.findUnique({ where: { id: env('SMS_IMPORT_TENANT_ID') }, }); if (!byId) throw new Error(`Tenant id not found: ${env('SMS_IMPORT_TENANT_ID')}`); return byId; } const host = normalizeHost(domainOrId); if (!host) throw new Error('SMS_IMPORT_DOMAIN or SMS_IMPORT_TENANT_ID is required'); const tenants = await prisma.tenant.findMany({ where: { isActive: true }, }); const hit = tenants.find((t) => { const candidates = new Set(); const primary = normalizeHost(t.domain); if (primary) candidates.add(primary); const extraRaw = t.domains as unknown; const extra = Array.isArray(extraRaw) ? extraRaw : typeof extraRaw === 'string' ? (() => { try { const parsed = JSON.parse(extraRaw); return Array.isArray(parsed) ? parsed : []; } catch { return []; } })() : []; for (const d of extra) { const n = normalizeHost(String(d)); if (n) candidates.add(n); } return candidates.has(host); }); if (!hit) throw new Error(`No SuperAdmin tenant matched domain: ${host}`); return hit; } function chargeKeys(product: string) { if (product === 'khanegi') { return { card: 'sms_charge_card_number', iban: 'sms_charge_sheba', holder: 'sms_charge_account_holder', instruction: 'sms_charge_note', }; } return { card: 'sms_charge_card', iban: 'sms_charge_iban', holder: 'sms_charge_account_holder', instruction: 'sms_charge_instruction', }; } async function main() { const domain = env('SMS_IMPORT_DOMAIN'); const product = (env('SMS_IMPORT_PRODUCT', 'hamkar') || 'hamkar').toLowerCase(); const dryRun = env('SMS_IMPORT_DRY_RUN') === '1'; const withHistory = env('SMS_IMPORT_HISTORY', '1') !== '0'; const replaceHistory = env('SMS_IMPORT_REPLACE_HISTORY', withHistory ? '1' : '0') === '1'; const host = env('SMS_IMPORT_DB_HOST') || (product === 'khanegi' ? env('KHANEGI_DB_HOST') : env('HAMKAR_DB_HOST')) || '127.0.0.1'; const port = Number( env('SMS_IMPORT_DB_PORT') || (product === 'khanegi' ? env('KHANEGI_DB_PORT') : env('HAMKAR_DB_PORT')) || '3306', ); const user = env('SMS_IMPORT_DB_USER') || (product === 'khanegi' ? env('KHANEGI_DB_USER') : env('HAMKAR_DB_USER')) || 'root'; const password = env('SMS_IMPORT_DB_PASSWORD') || (product === 'khanegi' ? env('KHANEGI_DB_PASSWORD') : env('HAMKAR_DB_PASSWORD')) || ''; const database = env('SMS_IMPORT_DB_DATABASE') || (product === 'khanegi' ? env('KHANEGI_DB_DATABASE') : env('HAMKAR_DB_DATABASE')); if (!database) { throw new Error('MySQL database name is required (SMS_IMPORT_DB_DATABASE / HAMKAR_DB_DATABASE)'); } const tenant = await findTenant(domain); console.log( JSON.stringify( { dryRun, product, withHistory, replaceHistory, tenant: { id: tenant.id, nameFa: tenant.nameFa, domain: tenant.domain, product: tenant.product, }, mysql: { host, port, user, database }, }, null, 2, ), ); const conn = await mysql.createConnection({ host, port, user, password, database, dateStrings: false, }); try { if (!(await tableExists(conn, database, 'sms_wallets'))) { console.log('SKIP: sms_wallets table missing — billing not installed on this version.'); return; } let smsPanel = ''; if (await tableExists(conn, database, 'settings')) { const [panelRows] = await conn.query( `SELECT \`value\` AS v FROM settings WHERE \`key\` = 'sms_panel' LIMIT 1`, ); const first = Array.isArray(panelRows) ? (panelRows[0] as { v?: string }) : null; smsPanel = String(first?.v ?? '').trim(); } if (smsPanel === 'me') { console.log('SKIP: sms_panel=me (customer own 0098 panel) — SuperAdmin billing not used.'); return; } const [walletRowsRaw] = await conn.query( `SELECT balance, price_per_part, is_blocked, alert_days_sent, last_alert_date FROM sms_wallets ORDER BY id ASC LIMIT 1`, ); const walletRows = walletRowsRaw as WalletRow[]; if (!walletRows.length) { console.log('SKIP: sms_wallets is empty — no real wallet yet.'); return; } const wallet = walletRows[0]; const keys = chargeKeys(product); const settings = new Map(); if (await tableExists(conn, database, 'settings')) { const [settingRowsRaw] = await conn.query( `SELECT \`key\`, \`value\` FROM settings WHERE \`key\` IN (?, ?, ?, ?)`, [keys.card, keys.iban, keys.holder, keys.instruction], ); for (const r of settingRowsRaw as SettingRow[]) { settings.set(r.key, r.value == null ? '' : String(r.value)); } } const payload = { balanceRials: asInt(wallet.balance), pricePerPartRials: asInt(wallet.price_per_part), isBlocked: asBool(wallet.is_blocked), chargeCard: settings.get(keys.card) || null, chargeIban: settings.get(keys.iban) || null, chargeHolder: settings.get(keys.holder) || null, chargeInstruction: settings.get(keys.instruction) || null, }; console.log('Wallet source →', payload); let ledgers: LedgerRow[] = []; let sendLogs: SendLogRow[] = []; if (withHistory) { if (await tableExists(conn, database, 'sms_ledger')) { const [rows] = await conn.query( `SELECT type, amount, balance_after, price_per_part, parts, note, created_by, created_at FROM sms_ledger ORDER BY id ASC`, ); ledgers = rows as LedgerRow[]; } if (await tableExists(conn, database, 'sms_send_logs')) { const [rows] = await conn.query( `SELECT receiver, body, parts, price_per_part, total_amount, provider, source, is_alert, created_at FROM sms_send_logs ORDER BY id ASC`, ); sendLogs = rows as SendLogRow[]; } console.log(`History source → ledger=${ledgers.length}, send_logs=${sendLogs.length}`); } if (dryRun) { console.log('DRY RUN: no writes to SuperAdmin.'); return; } const account = await prisma.smsAccount.upsert({ where: { tenantId: tenant.id }, create: { tenantId: tenant.id, ...payload, alertCriticalRials: 10000, alertWarningRials: 100000, }, update: { ...payload, }, }); if (withHistory) { if (replaceHistory) { await prisma.smsSendLog.deleteMany({ where: { smsAccountId: account.id } }); await prisma.smsLedger.deleteMany({ where: { smsAccountId: account.id } }); } const ledgerChunk = 200; for (let i = 0; i < ledgers.length; i += ledgerChunk) { const slice = ledgers.slice(i, i + ledgerChunk); await prisma.smsLedger.createMany({ data: slice.map((row) => ({ smsAccountId: account.id, type: mapLedgerType(row.type), amountRials: asInt(row.amount), balanceAfterRials: asInt(row.balance_after), parts: row.parts == null ? null : asInt(row.parts), note: row.note, createdById: row.created_by == null ? null : String(row.created_by), createdAt: asDate(row.created_at), })), }); } const logChunk = 200; for (let i = 0; i < sendLogs.length; i += logChunk) { const slice = sendLogs.slice(i, i + logChunk); await prisma.smsSendLog.createMany({ data: slice.map((row) => ({ smsAccountId: account.id, receiver: String(row.receiver || '').slice(0, 32) || '—', body: String(row.body || '—').slice(0, 2000), parts: Math.max(1, asInt(row.parts, 1)), pricePerPartRials: asInt(row.price_per_part), totalAmountRials: asInt(row.total_amount), provider: row.provider ? String(row.provider).slice(0, 64) : null, source: row.source ? String(row.source).slice(0, 64) : null, isAlert: asBool(row.is_alert), createdAt: asDate(row.created_at), })), }); } } await prisma.tenant.update({ where: { id: tenant.id }, data: { smsSyncedAt: new Date() }, }); await prisma.auditLog.create({ data: { action: 'sms.import.from_product', entityType: 'sms_account', entityId: account.id, tenantId: tenant.id, after: { product, domain: tenant.domain, balanceRials: payload.balanceRials, pricePerPartRials: payload.pricePerPartRials, isBlocked: payload.isBlocked, ledgerCount: ledgers.length, sendLogCount: sendLogs.length, mysql: { host, database }, smsSyncedAt: true, }, }, }); console.log('OK: SuperAdmin SMS account updated from product MySQL.', { smsAccountId: account.id, balanceRials: payload.balanceRials, ledger: ledgers.length, sendLogs: sendLogs.length, smsSyncedAt: true, }); } finally { await conn.end(); } } main() .catch((err) => { console.error(err); process.exitCode = 1; }) .finally(async () => { await prisma.$disconnect(); });