feat: accounting integration (D27), SMS relay driver (D26), rebuild user apps

- Add Tahesab/Zarrin accounting sync module with admin panel UI
- Add SMS relay driver as default provider, refactor SMS into driver pattern
- Update customer services, orders, products, and catalog queries
- Rebuild user-app and user-app-v2 dist assets
- Add Prisma migration for accounting tables
- Update docker-compose and env examples
- Update parity docs (DECISIONS, ISSUES, PROGRESS)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
s.esmaeili
2026-10-06 16:48:36 +03:30
co-authored by Claude Opus 4.6
parent a72fa607a5
commit 19d490e790
179 changed files with 12538 additions and 370 deletions
@@ -0,0 +1,110 @@
-- Accounting integration (D27): Tahesab / Zarrin, chosen in the admin panel.
-- Plan: docs/ACCOUNTING-INTEGRATION-PLAN.md.
--
-- Adds the link table (app row <-> accounting record), the outbox the push
-- jobs run from, the issue list pulls report into, and the cached balance and
-- stock columns read back from the accounting software. Nothing existing
-- changes meaning: goldDebtGrams/fiatDebtRial stay the internal ledger caches.
-- CreateEnum
CREATE TYPE "AccountingEntityType" AS ENUM ('CUSTOMER', 'PRODUCT');
-- CreateEnum
CREATE TYPE "AccountingOutboxKind" AS ENUM ('CUSTOMER_PUSH', 'PRODUCT_PUSH', 'ORDER_DOCUMENT', 'ORDER_DOCUMENT_DELETE');
-- CreateEnum
CREATE TYPE "AccountingOutboxStatus" AS ENUM ('PENDING', 'SENDING', 'SENT', 'FAILED', 'NEEDS_REVIEW', 'RESOLVED');
-- AlterTable
ALTER TABLE "Customer" ADD COLUMN "accountingBalanceAt" TIMESTAMP(3),
ADD COLUMN "accountingGoldBalance" DECIMAL(14,4),
ADD COLUMN "accountingRialBalance" DECIMAL(20,2);
-- AlterTable
ALTER TABLE "Product" ADD COLUMN "accountingStockAt" TIMESTAMP(3),
ADD COLUMN "accountingStockCount" DECIMAL(14,4),
ADD COLUMN "accountingStockWeight" DECIMAL(14,4);
-- CreateTable
CREATE TABLE "AccountingLink" (
"id" TEXT NOT NULL,
"provider" TEXT NOT NULL,
"entityType" "AccountingEntityType" NOT NULL,
"entityId" TEXT NOT NULL,
"externalCode" TEXT NOT NULL,
"externalName" TEXT,
"origin" TEXT NOT NULL,
"snapshot" JSONB,
"syncedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "AccountingLink_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "AccountingOutbox" (
"id" TEXT NOT NULL,
"provider" TEXT NOT NULL,
"kind" "AccountingOutboxKind" NOT NULL,
"entityId" TEXT NOT NULL,
"status" "AccountingOutboxStatus" NOT NULL DEFAULT 'PENDING',
"attempts" INTEGER NOT NULL DEFAULT 0,
"nextAttemptAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"lastError" TEXT,
"externalRef" TEXT,
"progress" JSONB,
"lastRequest" JSONB,
"lastResponse" JSONB,
"sentAt" TIMESTAMP(3),
"resolvedById" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "AccountingOutbox_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "AccountingIssue" (
"id" TEXT NOT NULL,
"provider" TEXT NOT NULL,
"kind" TEXT NOT NULL,
"externalCode" TEXT NOT NULL,
"entityId" TEXT,
"message" TEXT NOT NULL,
"data" JSONB,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"resolvedAt" TIMESTAMP(3),
CONSTRAINT "AccountingIssue_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "AccountingLink_entityType_entityId_idx" ON "AccountingLink"("entityType", "entityId");
-- CreateIndex
CREATE UNIQUE INDEX "AccountingLink_provider_entityType_entityId_key" ON "AccountingLink"("provider", "entityType", "entityId");
-- CreateIndex
CREATE UNIQUE INDEX "AccountingLink_provider_entityType_externalCode_key" ON "AccountingLink"("provider", "entityType", "externalCode");
-- CreateIndex
CREATE INDEX "AccountingOutbox_status_nextAttemptAt_idx" ON "AccountingOutbox"("status", "nextAttemptAt");
-- CreateIndex
CREATE INDEX "AccountingOutbox_kind_entityId_idx" ON "AccountingOutbox"("kind", "entityId");
-- CreateIndex
CREATE INDEX "AccountingIssue_resolvedAt_idx" ON "AccountingIssue"("resolvedAt");
-- CreateIndex
CREATE UNIQUE INDEX "AccountingIssue_provider_kind_externalCode_key" ON "AccountingIssue"("provider", "kind", "externalCode");
-- The new admin screen's permission. The seeded super-admin user type (85,
-- prisma/seed.ts) gets every rid in ALL_RIDS on a fresh seed; an existing
-- database has to be granted the new one here.
INSERT INTO "permission" ("rid", "userTypeId")
SELECT 'config.accounting', 85
WHERE EXISTS (SELECT 1 FROM "user_type" WHERE "id" = 85)
ON CONFLICT DO NOTHING;
+125
View File
@@ -400,6 +400,14 @@ model Customer {
goldDebtGrams Decimal @default(0) @db.Decimal(14, 4) // legacy remain
fiatDebtRial Decimal @default(0) @db.Decimal(20, 2) // legacy rial_remain
/// Balance as last read from the accounting software (D27), in the app's own
/// sign convention (positive = owes). Shown to the customer when the
/// integration is on; the credit-limit check still uses goldDebtGrams above.
/// Null until the first successful sync.
accountingGoldBalance Decimal? @db.Decimal(14, 4)
accountingRialBalance Decimal? @db.Decimal(20, 2)
accountingBalanceAt DateTime?
/// Legacy `customers.percent` — the per-customer making-charge offset. It is
/// ADDED to the variant's making charge when pricing an order line
/// (henza_pwa src/Mixins/product/index.ts:69-75:
@@ -557,6 +565,14 @@ model Product {
isActive Boolean @default(true)
createdAt DateTime @default(now())
/// Stock of the linked کارساخته as last read from the accounting software
/// (D27). Weight for gram-type items, count for count-type ones — whichever
/// the software reports. At zero or below the product is unavailable in the
/// app (pre-order variants excepted). Null = not linked or never synced.
accountingStockWeight Decimal? @db.Decimal(14, 4)
accountingStockCount Decimal? @db.Decimal(14, 4)
accountingStockAt DateTime?
variants ProductVariant[]
images ProductImage[]
collections CollectionProduct[]
@@ -911,6 +927,115 @@ model Setting {
updatedAt DateTime @updatedAt
}
// ─────────────────────────────────────────────────────────────────────────
// Accounting integration (D27) — the shop's accounting software, Tahesab or
// Zarrin, chosen in the admin panel. Configuration lives in the Setting row
// `accounting.config` (never served by the generic `const` routes). Plan:
// docs/ACCOUNTING-INTEGRATION-PLAN.md.
//
// `provider` is the driver name ('tahesab' | 'zarrin'), a plain string like
// SMS_PROVIDER so a new driver needs no migration. Links are per provider, so
// switching provider and back loses nothing.
// ─────────────────────────────────────────────────────────────────────────
enum AccountingEntityType {
CUSTOMER
PRODUCT
}
/// One app row ↔ one record in the accounting software (customer account or
/// کارساخته).
model AccountingLink {
id String @id @default(cuid())
provider String
entityType AccountingEntityType
entityId String
/// The record's code in the accounting software (Tahesab Moshtari_Code /
/// Zarrin idHesab; for products the Zarrin idKar or the Tahesab Name_Kar).
externalCode String
externalName String?
/// How the link came to be: LINKED (matched by mobile/name), IMPORTED (app
/// row created from the accounting record), PUSHED (accounting record created
/// from the app row) or MANUAL (an admin typed the code).
origin String
/// The record as last read, for display only.
snapshot Json?
syncedAt DateTime @default(now())
createdAt DateTime @default(now())
@@unique([provider, entityType, entityId])
@@unique([provider, entityType, externalCode])
@@index([entityType, entityId])
}
enum AccountingOutboxKind {
CUSTOMER_PUSH
PRODUCT_PUSH
ORDER_DOCUMENT
ORDER_DOCUMENT_DELETE
}
enum AccountingOutboxStatus {
PENDING
/// A call was in flight. Found at startup, it means the process died mid-call
/// and the outcome is unknown — see AccountingOutboxWorker.
SENDING
SENT
FAILED
/// Unsafe to retry automatically (e.g. a Zarrin call timed out: Zarrin has no
/// idempotency key, so a blind retry could post the document twice).
NEEDS_REVIEW
/// Closed by an admin from the log screen.
RESOLVED
}
/// Work to push to the accounting software. Written in the SAME transaction as
/// the change that causes it, so an outage of the accounting software never
/// loses an event and never blocks the app.
model AccountingOutbox {
id String @id @default(cuid())
provider String
kind AccountingOutboxKind
entityId String
status AccountingOutboxStatus @default(PENDING)
attempts Int @default(0)
nextAttemptAt DateTime @default(now())
lastError String?
/// What the accounting software returned as the document/record id.
externalRef String?
/// Per-step progress of multi-call jobs (a Zarrin document is one call per
/// line), so a failure resumes where it stopped.
progress Json?
/// Last request/response, for the log screen. Never contains the token.
lastRequest Json?
lastResponse Json?
sentAt DateTime?
resolvedById String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([status, nextAttemptAt])
@@index([kind, entityId])
}
/// Something a pull found that needs a human: an account with no mobile, two
/// accounts sharing one mobile, a کارساخته group with no category mapping.
model AccountingIssue {
id String @id @default(cuid())
provider String
kind String
externalCode String
entityId String?
message String
data Json?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
resolvedAt DateTime?
@@unique([provider, kind, externalCode])
@@index([resolvedAt])
}
// ─────────────────────────────────────────────────────────────────────────
// Promotions / discounts — legacy views/promotion/*, served by
// PromotionController (`product/variant/promotion`). The redesigned schema had