import { Prisma } from '@prisma/client'; import { normalizeBusinessPrimaryColorId, } from './business-primary-colors'; import { normalizeWebsiteSitemapConfig, } from '../sitemap/sitemap-config.util'; import { BUSINESS_MODULE_IDS, BusinessModuleId, BusinessSettings, DEFAULT_BUSINESS_DASHBOARD_LOCALE, DEFAULT_BUSINESS_SETTINGS, DEFAULT_BUSINESS_THEME_MODE, DEFAULT_ENABLED_BUSINESS_MODULES, DEFAULT_EPAYMENT_SETTINGS, DEFAULT_HOME_CHARTS, DEFAULT_MELLAT_GATEWAY_SETTINGS, DEFAULT_ORDER_PROCESS_STEPS, DEFAULT_STUB_GATEWAY_SETTINGS, DEFAULT_ZARINPAL_GATEWAY_SETTINGS, EPaymentSettings, HOME_CHART_IDS, MellatGatewaySettings, PAYMENT_GATEWAY_IDS, SPECIAL_PRODUCTS_SOURCE_IDS, type DashboardLocale, type DashboardThemeMode, type HomeChartId, type PaymentGatewayId, type SpecialProductsSource, OrderProcessStep, StubGatewaySettings, WebsiteSitemapConfig, ZarinpalGatewaySettings, DEFAULT_WEBSITE_SCHEMA_SETTINGS, DEFAULT_WEBSITE_TAGS_AND_BADGES, } from './business-settings.types'; import { defaultOrderStepColor, normalizeOrderStepColor, } from './order-step-colors'; import { normalizeWebsiteSchemaSettings } from './website-schema.util'; import { normalizeWebsiteTagsAndBadges } from './website-tags-badges.util'; function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } const BUSINESS_MODULE_ID_SET = new Set(BUSINESS_MODULE_IDS); const HOME_CHART_ID_SET = new Set(HOME_CHART_IDS); export function isBusinessModuleId(value: unknown): value is BusinessModuleId { return typeof value === 'string' && BUSINESS_MODULE_ID_SET.has(value); } export function isHomeChartId(value: unknown): value is HomeChartId { return typeof value === 'string' && HOME_CHART_ID_SET.has(value); } /** Dedupes and orders enabled modules by canonical BUSINESS_MODULE_IDS order. */ export function normalizeEnabledBusinessModules( value: unknown, ): BusinessModuleId[] { if (!Array.isArray(value)) { return [...DEFAULT_ENABLED_BUSINESS_MODULES]; } const selected = new Set(); for (const item of value) { if (isBusinessModuleId(item)) { selected.add(item); } } return BUSINESS_MODULE_IDS.filter((id) => selected.has(id)); } export function isSpecialProductsSource( value: unknown, ): value is SpecialProductsSource { return ( typeof value === 'string' && (SPECIAL_PRODUCTS_SOURCE_IDS as readonly string[]).includes(value) ); } /** * Explicit saved source wins. Otherwise store businesses default to store items, * catalog-only businesses default to products. */ export function resolveSpecialProductsSource( value: unknown, enabledModules: readonly BusinessModuleId[], ): SpecialProductsSource { if (isSpecialProductsSource(value)) return value; return enabledModules.includes('store') ? 'store_item' : 'product'; } export function normalizeHomeCharts(value: unknown): [HomeChartId, HomeChartId] { if (!Array.isArray(value)) { return [...DEFAULT_HOME_CHARTS]; } const first = isHomeChartId(value[0]) ? value[0] : DEFAULT_HOME_CHARTS[0]; const second = isHomeChartId(value[1]) ? value[1] : DEFAULT_HOME_CHARTS[1]; return [first, second]; } export function normalizeDashboardLocale(value: unknown): DashboardLocale { return value === 'en' || value === 'fa' ? value : DEFAULT_BUSINESS_DASHBOARD_LOCALE; } export function normalizeDashboardThemeMode(value: unknown): DashboardThemeMode { return value === 'dark' || value === 'light' ? value : DEFAULT_BUSINESS_THEME_MODE; } function readBoolean(value: unknown, fallback: boolean) { return typeof value === 'boolean' ? value : fallback; } function readNonNegativeNumber(value: unknown, fallback: number) { if (typeof value === 'number' && Number.isFinite(value) && value >= 0) { return value; } if (typeof value === 'string' && value.trim() !== '') { const parsed = Number(value); if (Number.isFinite(parsed) && parsed >= 0) return parsed; } return fallback; } function readStaticShippingProvinceExceptions( value: unknown, ): BusinessSettings['store']['staticShippingProvinceExceptions'] { if (!Array.isArray(value)) return []; const seen = new Set(); const items: BusinessSettings['store']['staticShippingProvinceExceptions'] = []; for (const entry of value) { if (!isRecord(entry)) continue; const provinceId = typeof entry.provinceId === 'string' ? entry.provinceId.trim() : typeof entry.provinceId === 'number' ? String(entry.provinceId) : ''; if (!provinceId || seen.has(provinceId)) continue; const amount = readNonNegativeNumber(entry.amount, Number.NaN); if (!Number.isFinite(amount)) continue; seen.add(provinceId); items.push({ provinceId, amount }); } return items; } function readString(value: unknown, fallback = '') { return typeof value === 'string' ? value.trim() : fallback; } /** Gama / Iranian service shortcodes are numeric (e.g. 5000123456). */ export function normalizeSmsSenderNumber(value: unknown): string | null { if (value == null) return null; if (typeof value !== 'string' && typeof value !== 'number') return null; const digits = String(value).replace(/\D/g, ''); if (!digits) return null; if (digits.length < 8 || digits.length > 16) return null; return digits; } function isPaymentGatewayId(value: unknown): value is PaymentGatewayId { return ( typeof value === 'string' && (PAYMENT_GATEWAY_IDS as readonly string[]).includes(value) ); } function readMellatGateway(value: unknown): MellatGatewaySettings { const source = isRecord(value) ? value : {}; return { enabled: readBoolean( source.enabled, DEFAULT_MELLAT_GATEWAY_SETTINGS.enabled, ), terminalId: readString( source.terminalId, DEFAULT_MELLAT_GATEWAY_SETTINGS.terminalId, ), username: readString( source.username, DEFAULT_MELLAT_GATEWAY_SETTINGS.username, ), password: typeof source.password === 'string' ? source.password : DEFAULT_MELLAT_GATEWAY_SETTINGS.password, }; } function readStubGateway(value: unknown): StubGatewaySettings { const source = isRecord(value) ? value : {}; return { enabled: readBoolean( source.enabled, DEFAULT_STUB_GATEWAY_SETTINGS.enabled, ), }; } function readZarinpalGateway(value: unknown): ZarinpalGatewaySettings { const source = isRecord(value) ? value : {}; return { enabled: readBoolean( source.enabled, DEFAULT_ZARINPAL_GATEWAY_SETTINGS.enabled, ), merchantId: readString( source.merchantId, DEFAULT_ZARINPAL_GATEWAY_SETTINGS.merchantId, ), sandbox: readBoolean( source.sandbox, DEFAULT_ZARINPAL_GATEWAY_SETTINGS.sandbox, ), }; } export function normalizeEPaymentSettings(value: unknown): EPaymentSettings { const source = isRecord(value) ? value : {}; const gateways = isRecord(source.gateways) ? source.gateways : {}; const defaultGateway = isPaymentGatewayId(source.defaultGateway) ? source.defaultGateway : null; return { enabled: readBoolean(source.enabled, DEFAULT_EPAYMENT_SETTINGS.enabled), defaultGateway, gateways: { mellat: readMellatGateway(gateways.mellat), sep: readStubGateway(gateways.sep), snappay: readStubGateway(gateways.snappay), digipay: readStubGateway(gateways.digipay), zarinpal: readZarinpalGateway(gateways.zarinpal), }, }; } /** Merge e-payment patch; empty mellat password keeps the current secret. */ export function mergeEPaymentSettings( current: EPaymentSettings, patch: EPaymentSettings | undefined, ): EPaymentSettings { if (!patch) return current; const nextPassword = patch.gateways.mellat.password; const password = typeof nextPassword === 'string' && nextPassword.length > 0 ? nextPassword : current.gateways.mellat.password; return { enabled: patch.enabled, defaultGateway: patch.defaultGateway, gateways: { mellat: { enabled: patch.gateways.mellat.enabled, terminalId: patch.gateways.mellat.terminalId.trim(), username: patch.gateways.mellat.username.trim(), password, }, sep: { enabled: patch.gateways.sep.enabled }, snappay: { enabled: patch.gateways.snappay.enabled }, digipay: { enabled: patch.gateways.digipay.enabled }, zarinpal: { enabled: patch.gateways.zarinpal.enabled, merchantId: patch.gateways.zarinpal.merchantId.trim(), sandbox: patch.gateways.zarinpal.sandbox, }, }, }; } /** Redact gateway secrets for dashboard API responses. */ export function sanitizeBusinessSettingsForClient( settings: BusinessSettings, ): BusinessSettings { const mellat = settings.store.ePayment.gateways.mellat; return { ...settings, store: { ...settings.store, ePayment: { ...settings.store.ePayment, gateways: { ...settings.store.ePayment.gateways, mellat: { ...mellat, password: '', }, }, }, }, }; } /** Whether a gateway is enabled and has enough credentials to initiate. */ export function isGatewayReady( settings: EPaymentSettings, gatewayId: PaymentGatewayId, ): boolean { if (!settings.enabled) return false; switch (gatewayId) { case 'mellat': { const g = settings.gateways.mellat; return ( g.enabled && g.terminalId.length > 0 && g.username.length > 0 && g.password.length > 0 ); } case 'zarinpal': { const g = settings.gateways.zarinpal; return g.enabled && g.merchantId.length > 0; } case 'sep': case 'snappay': case 'digipay': return false; default: return false; } } export function listPublicPaymentGateways(settings: EPaymentSettings) { const labels: Record< PaymentGatewayId, { label: string; labelFa: string } > = { mellat: { label: 'Bank Mellat', labelFa: 'بانک ملت' }, sep: { label: 'SEP (Saman)', labelFa: 'سپ (سامان)' }, snappay: { label: 'Snapp Pay', labelFa: 'اسنپ‌پی' }, digipay: { label: 'DigiPay', labelFa: 'دیجی‌پی' }, zarinpal: { label: 'Zarinpal', labelFa: 'زرین‌پال' }, }; const gateways = PAYMENT_GATEWAY_IDS.filter((id) => isGatewayReady(settings, id), ).map((id) => ({ id, ...labels[id], })); let defaultGateway = settings.defaultGateway; if (defaultGateway && !gateways.some((g) => g.id === defaultGateway)) { defaultGateway = null; } if (!defaultGateway && gateways.length) { defaultGateway = gateways[0].id; } return { enabled: settings.enabled && gateways.length > 0, defaultGateway, gateways, }; } /** System-managed payment steps — set by gateway callbacks, not admin UI. */ export const PAYMENT_PROCESS_STEP_IDS = [ 'awaiting-payment', 'payment-successful', ] as const; export type PaymentProcessStepId = (typeof PAYMENT_PROCESS_STEP_IDS)[number]; export function isSystemPaymentProcessStep(stepId: string): boolean { return (PAYMENT_PROCESS_STEP_IDS as readonly string[]).includes(stepId); } /** Prepend default payment steps when an older store settings JSON omits them. */ function ensurePaymentProcessSteps(steps: OrderProcessStep[]): OrderProcessStep[] { const missing = DEFAULT_ORDER_PROCESS_STEPS.filter( (defaults) => PAYMENT_PROCESS_STEP_IDS.includes( defaults.id as (typeof PAYMENT_PROCESS_STEP_IDS)[number], ) && !steps.some((step) => step.id === defaults.id), ); return missing.length ? [...missing, ...steps] : steps; } function readOrderProcessSteps(value: unknown): OrderProcessStep[] { if (!Array.isArray(value)) { return DEFAULT_ORDER_PROCESS_STEPS; } const steps = value .map((item, index) => { if (!isRecord(item)) return null; const id = typeof item.id === 'string' ? item.id.trim() : ''; const label = typeof item.label === 'string' ? item.label.trim() : ''; if (!id || !label) return null; const defaults = DEFAULT_ORDER_PROCESS_STEPS.find((step) => step.id === id); const byEnglishLabel = DEFAULT_ORDER_PROCESS_STEPS.find( (step) => step.label.toLowerCase() === label.toLowerCase(), ); const labelFaRaw = typeof item.labelFa === 'string' ? item.labelFa.trim() : ''; return { id, label, // Never fall back to the English label — that leaks EN into FA UIs. labelFa: labelFaRaw || defaults?.labelFa || byEnglishLabel?.labelFa || '', color: normalizeOrderStepColor( item.color, defaultOrderStepColor(id, index), ), }; }) .filter((step): step is OrderProcessStep => step !== null); return steps.length ? ensurePaymentProcessSteps(steps) : DEFAULT_ORDER_PROCESS_STEPS; } export function normalizeBusinessSettings(raw: unknown): BusinessSettings { const source = isRecord(raw) ? raw : {}; const branding = isRecord(source.branding) ? source.branding : {}; const dashboard = isRecord(source.dashboard) ? source.dashboard : {}; const comments = isRecord(dashboard.comments) ? dashboard.comments : {}; const expertReviews = isRecord(dashboard.expertReviews) ? dashboard.expertReviews : {}; const store = isRecord(source.store) ? source.store : {}; const modules = isRecord(source.modules) ? source.modules : {}; const website = isRecord(source.website) ? source.website : {}; const sms = isRecord(source.sms) ? source.sms : {}; const hasModulesKey = Object.prototype.hasOwnProperty.call(source, 'modules'); const enabledModules = hasModulesKey ? Array.isArray(modules.enabled) ? normalizeEnabledBusinessModules(modules.enabled) : [] : [...DEFAULT_ENABLED_BUSINESS_MODULES]; return { branding: { primaryColor: normalizeBusinessPrimaryColorId(branding.primaryColor), defaultLocale: normalizeDashboardLocale(branding.defaultLocale), themeMode: normalizeDashboardThemeMode(branding.themeMode), }, dashboard: { comments: { autoApprove: readBoolean( comments.autoApprove, DEFAULT_BUSINESS_SETTINGS.dashboard.comments.autoApprove, ), }, expertReviews: { autoApprove: readBoolean( expertReviews.autoApprove, DEFAULT_BUSINESS_SETTINGS.dashboard.expertReviews.autoApprove, ), }, }, store: { onlineSellEnabled: readBoolean( store.onlineSellEnabled, DEFAULT_BUSINESS_SETTINGS.store.onlineSellEnabled, ), torobEnabled: readBoolean( store.torobEnabled, DEFAULT_BUSINESS_SETTINGS.store.torobEnabled, ), orderProcessSteps: readOrderProcessSteps(store.orderProcessSteps), ePayment: normalizeEPaymentSettings(store.ePayment), staticShippingEnabled: readBoolean( store.staticShippingEnabled, DEFAULT_BUSINESS_SETTINGS.store.staticShippingEnabled, ), staticShippingAmount: readNonNegativeNumber( store.staticShippingAmount, DEFAULT_BUSINESS_SETTINGS.store.staticShippingAmount, ), staticShippingProvinceExceptions: readStaticShippingProvinceExceptions( store.staticShippingProvinceExceptions, ), }, modules: { // Missing `modules` → all enabled (legacy). Explicit `{ enabled: [] }` stays empty. enabled: enabledModules, charts: normalizeHomeCharts(modules.charts), }, website: { specialProductsSource: resolveSpecialProductsSource( website.specialProductsSource, enabledModules, ), sitemapConfig: normalizeWebsiteSitemapConfig(website.sitemapConfig), schema: normalizeWebsiteSchemaSettings( isRecord(website) ? website.schema : DEFAULT_WEBSITE_SCHEMA_SETTINGS, ), tagsAndBadges: normalizeWebsiteTagsAndBadges( isRecord(website) ? website.tagsAndBadges : DEFAULT_WEBSITE_TAGS_AND_BADGES, ), }, sms: { senderNumber: normalizeSmsSenderNumber(sms.senderNumber), }, }; } export function mergeBusinessSettings( current: BusinessSettings, patch: Partial, ): BusinessSettings { return { branding: { primaryColor: patch.branding?.primaryColor ?? current.branding.primaryColor, defaultLocale: patch.branding?.defaultLocale ?? current.branding.defaultLocale, themeMode: patch.branding?.themeMode ?? current.branding.themeMode, }, dashboard: { comments: { autoApprove: patch.dashboard?.comments?.autoApprove ?? current.dashboard.comments.autoApprove, }, expertReviews: { autoApprove: patch.dashboard?.expertReviews?.autoApprove ?? current.dashboard.expertReviews.autoApprove, }, }, store: { onlineSellEnabled: patch.store?.onlineSellEnabled ?? current.store.onlineSellEnabled, torobEnabled: patch.store?.torobEnabled ?? current.store.torobEnabled, orderProcessSteps: patch.store?.orderProcessSteps ?? current.store.orderProcessSteps, ePayment: mergeEPaymentSettings( current.store.ePayment, patch.store?.ePayment, ), staticShippingEnabled: patch.store?.staticShippingEnabled ?? current.store.staticShippingEnabled, staticShippingAmount: patch.store?.staticShippingAmount ?? current.store.staticShippingAmount, staticShippingProvinceExceptions: patch.store?.staticShippingProvinceExceptions ?? current.store.staticShippingProvinceExceptions, }, modules: { enabled: patch.modules?.enabled ?? current.modules.enabled, charts: patch.modules?.charts ?? current.modules.charts, }, website: { specialProductsSource: patch.website?.specialProductsSource ?? current.website.specialProductsSource, sitemapConfig: patch.website?.sitemapConfig !== undefined ? patch.website.sitemapConfig : current.website.sitemapConfig, schema: patch.website?.schema !== undefined ? patch.website.schema : current.website.schema, tagsAndBadges: patch.website?.tagsAndBadges !== undefined ? patch.website.tagsAndBadges : current.website.tagsAndBadges, }, sms: { senderNumber: patch.sms?.senderNumber !== undefined ? patch.sms.senderNumber : current.sms.senderNumber, }, }; } export function toPrismaJson(settings: BusinessSettings): Prisma.InputJsonValue { return settings as Prisma.InputJsonValue; }