feat: add per-org SMS wallet with adm panel and header credit

Bill all platform SMS (including OTP), allow negative balance with manual/auto cutoffs, and support own-panel bypass without wallet debit.
This commit is contained in:
2026-10-19 15:55:48 +03:30
parent f15ebb72ea
commit f5d68d83fc
42 changed files with 2073 additions and 121 deletions
@@ -0,0 +1,90 @@
-- CreateTable
CREATE TABLE "SmsAccount" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"balanceRials" INTEGER NOT NULL DEFAULT 0,
"pricePerPartRials" INTEGER NOT NULL DEFAULT 2500,
"isBlocked" BOOLEAN NOT NULL DEFAULT false,
"autoBlockBelowRials" INTEGER,
"alertCriticalRials" INTEGER NOT NULL DEFAULT 10000,
"alertWarningRials" INTEGER NOT NULL DEFAULT 100000,
"panelMode" TEXT NOT NULL DEFAULT 'platform',
"ownPanelType" TEXT NOT NULL DEFAULT '0098',
"ownPanelUsername" TEXT,
"ownPanelPassword" TEXT,
"ownPanelFrom" TEXT,
"chargeCard" TEXT,
"chargeIban" TEXT,
"chargeHolder" TEXT,
"chargeInstruction" TEXT,
"alertDaysSent" INTEGER NOT NULL DEFAULT 0,
"lastAlertDate" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "SmsAccount_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "SmsLedger" (
"id" TEXT NOT NULL,
"smsAccountId" TEXT NOT NULL,
"type" TEXT NOT NULL,
"amountRials" INTEGER NOT NULL,
"balanceAfterRials" INTEGER NOT NULL,
"parts" INTEGER,
"pricePerPartRials" INTEGER,
"note" TEXT,
"smsSendLogId" TEXT,
"createdById" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "SmsLedger_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "SmsBillingSendLog" (
"id" TEXT NOT NULL,
"smsAccountId" TEXT NOT NULL,
"receiver" TEXT NOT NULL,
"body" TEXT NOT NULL,
"parts" INTEGER NOT NULL,
"pricePerPartRials" INTEGER NOT NULL,
"totalAmountRials" INTEGER NOT NULL,
"provider" TEXT,
"source" TEXT,
"isAlert" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "SmsBillingSendLog_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "SmsAccount_organizationId_key" ON "SmsAccount"("organizationId");
-- CreateIndex
CREATE INDEX "SmsAccount_isBlocked_idx" ON "SmsAccount"("isBlocked");
-- CreateIndex
CREATE INDEX "SmsAccount_balanceRials_idx" ON "SmsAccount"("balanceRials");
-- CreateIndex
CREATE INDEX "SmsLedger_smsAccountId_createdAt_idx" ON "SmsLedger"("smsAccountId", "createdAt");
-- CreateIndex
CREATE INDEX "SmsLedger_type_idx" ON "SmsLedger"("type");
-- CreateIndex
CREATE INDEX "SmsBillingSendLog_smsAccountId_createdAt_idx" ON "SmsBillingSendLog"("smsAccountId", "createdAt");
-- CreateIndex
CREATE INDEX "SmsBillingSendLog_source_idx" ON "SmsBillingSendLog"("source");
-- AddForeignKey
ALTER TABLE "SmsAccount" ADD CONSTRAINT "SmsAccount_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "SmsLedger" ADD CONSTRAINT "SmsLedger_smsAccountId_fkey" FOREIGN KEY ("smsAccountId") REFERENCES "SmsAccount"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "SmsBillingSendLog" ADD CONSTRAINT "SmsBillingSendLog_smsAccountId_fkey" FOREIGN KEY ("smsAccountId") REFERENCES "SmsAccount"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+73
View File
@@ -206,6 +206,7 @@ model Organization {
webhookEndpoints WebhookEndpoint[]
smsCampaigns SmsCampaign[]
outboundSmsLogs OutboundSmsLog[]
smsAccount SmsAccount?
financialAccounts FinancialAccount[]
financialCategories FinancialCategory[]
financialDocuments FinancialDocument[]
@@ -2290,6 +2291,78 @@ model FinancialInstallment {
@@index([dueDate])
}
/// کیف‌پول و تنظیمات پیامک هر محیط (الگوی همکار / سوپرادمین آبشُده)
model SmsAccount {
id String @id @default(cuid())
organizationId String @unique
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
balanceRials Int @default(0)
pricePerPartRials Int @default(2500)
isBlocked Boolean @default(false)
/// اگر ست باشد: وقتی موجودی < این مقدار، ارسال قطع می‌شود (می‌تواند منفی باشد). null = فقط کلید دستی
autoBlockBelowRials Int?
alertCriticalRials Int @default(10000)
alertWarningRials Int @default(100000)
/// platform = سامانهٔ ما | own = پنل اختصاصی مشتری (بدون کسر/لاگ کیف)
panelMode String @default("platform")
/// وقتی panelMode=own: 0098 | asanak | trez
ownPanelType String @default("0098")
ownPanelUsername String?
ownPanelPassword String?
ownPanelFrom String?
chargeCard String?
chargeIban String?
chargeHolder String?
chargeInstruction String?
alertDaysSent Int @default(0)
lastAlertDate DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
ledgers SmsLedger[]
sendLogs SmsBillingSendLog[]
@@index([isBlocked])
@@index([balanceRials])
}
model SmsLedger {
id String @id @default(cuid())
smsAccountId String
smsAccount SmsAccount @relation(fields: [smsAccountId], references: [id], onDelete: Cascade)
/// charge | debit | alert_debit
type String
amountRials Int
balanceAfterRials Int
parts Int?
pricePerPartRials Int?
note String?
smsSendLogId String?
createdById String?
createdAt DateTime @default(now())
@@index([smsAccountId, createdAt])
@@index([type])
}
/// لاگ ارسال صورتحساب‌شده (همهٔ مسیرها وقتی panelMode=platform)
model SmsBillingSendLog {
id String @id @default(cuid())
smsAccountId String
smsAccount SmsAccount @relation(fields: [smsAccountId], references: [id], onDelete: Cascade)
receiver String
body String
parts Int
pricePerPartRials Int
totalAmountRials Int
provider String?
source String?
isAlert Boolean @default(false)
createdAt DateTime @default(now())
@@index([smsAccountId, createdAt])
@@index([source])
}
/// لاگ یکپارچهٔ پیامک خروجی (دستی / کمپین / اتوماسیون)
model OutboundSmsLog {
id String @id @default(cuid())