// Gold Banking System — core schema // Canonical accounting model: legacy "System A" gram ledger (see migration notes). // The external "Tahesab" service is retired — LedgerEntry is the sole source of truth. // All money/weight fields use Decimal, never Float, to avoid rounding drift in debt math. generator client { provider = "prisma-client-js" // native = local Windows/macOS build; linux-musl = Arvan/Alpine Docker runtime binaryTargets = ["native", "linux-musl-openssl-3.0.x"] } datasource db { provider = "postgresql" url = env("DATABASE_URL") } // ───────────────────────────────────────────────────────────────────────── // Enums // ───────────────────────────────────────────────────────────────────────── enum AdminRole { SUPER_ADMIN SYSTEM_APPROVER // legacy: تایید مدیر سیستم SALES_EXPERT // legacy Customer.type=1, also acts as a referral "reagent" WORKSHOP_SUPERVISOR ACCOUNTANT } enum VerificationStep { UNVERIFIED STEP_ONE_SUBMITTED STEP_TWO_SUBMITTED VERIFIED } enum OrderStatus { SUSPENDED // credit limit (goldCreditLimitGrams) exceeded at placement PLACED ADMIN_APPROVED SOURCING PREPARING DELIVERED_TO_AGENT DELIVERED_TO_CUSTOMER // debt recognition point — mirrors legacy sts>4 CANCELLED } enum PreOrderStatus { PLACED ADMIN_APPROVED WORKSHOP_APPROVED READY_TO_SHIP DELIVERED CANCELLED } enum LedgerCurrency { GOLD_GRAM RIAL } enum LedgerDirection { DEBIT // increases what the customer owes CREDIT // decreases what the customer owes } enum LedgerSourceType { ORDER_STOCK ORDER_PRE_ORDER REPAYMENT_MELTED_GOLD // karat-normalized: amount * karatPerMille / 750 REPAYMENT_TRANSFER REPAYMENT_CHEQUE REPAYMENT_CASH // price-per-gram-normalized via pricePerGramRialAtEntry WALLET_BONUS ADJUSTMENT } // ───────────────────────────────────────────────────────────────────────── // Identity // ───────────────────────────────────────────────────────────────────────── model City { id String @id @default(cuid()) name String province String? customers Customer[] addresses CustomerAddress[] branches Branch[] preOrders PreOrder[] } model AdminUser { id String @id @default(cuid()) fullName String mobile String @unique passwordHash String role AdminRole branchId String? branch Branch? @relation(fields: [branchId], references: [id]) wagePercent Decimal? @db.Decimal(6, 3) // legacy Customer.percent, commission on referred orders isActive Boolean @default(true) createdAt DateTime @default(now()) referredCustomers Customer[] @relation("ReferralAgent") postedLedgerEntries LedgerEntry[] orderStatusEvents OrderStatusEvent[] preOrderStatusEvents PreOrderStatusEvent[] @@index([role]) } model Customer { id String @id @default(cuid()) fullName String mobile String @unique nationalCode String @unique passwordHash String cityId String? city City? @relation(fields: [cityId], references: [id]) referredByAgentId String? referredByAgent AdminUser? @relation("ReferralAgent", fields: [referredByAgentId], references: [id]) // Credit limit + cached ledger balances (source of truth is LedgerEntry; these // are denormalized for fast reads and reconciled by a scheduled job). goldCreditLimitGrams Decimal @default(0) @db.Decimal(14, 4) // legacy gram_level goldDebtGrams Decimal @default(0) @db.Decimal(14, 4) // legacy remain fiatDebtRial Decimal @default(0) @db.Decimal(20, 2) // legacy rial_remain verificationStep VerificationStep @default(UNVERIFIED) nationalIdImageUrl String? businessLicenseUrl String? shopImageUrl String? isActive Boolean @default(true) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt addresses CustomerAddress[] orders Order[] preOrders PreOrder[] ledgerEntries LedgerEntry[] walletBonuses WalletBonus[] @@index([referredByAgentId]) } model CustomerAddress { id String @id @default(cuid()) customerId String customer Customer @relation(fields: [customerId], references: [id]) cityId String? city City? @relation(fields: [cityId], references: [id]) addressLine String isDefault Boolean @default(false) } model Branch { id String @id @default(cuid()) name String cityId String? city City? @relation(fields: [cityId], references: [id]) adminUsers AdminUser[] orders Order[] } model Workshop { id String @id @default(cuid()) name String isActive Boolean @default(true) preOrders PreOrder[] } // ───────────────────────────────────────────────────────────────────────── // Catalog — item reference catalog // ───────────────────────────────────────────────────────────────────────── model Category { id String @id @default(cuid()) title String parentId String? parent Category? @relation("CategoryTree", fields: [parentId], references: [id]) children Category[] @relation("CategoryTree") products Product[] } model Brand { id String @id @default(cuid()) title String products Product[] } /// Curated product groupings — replaces legacy "Ciny/CinyProduct" (a tagging /// feature, NOT a gold-price snapshot mechanism, despite the misleading legacy name). model Collection { id String @id @default(cuid()) title String description String? isActive Boolean @default(true) products CollectionProduct[] } model CollectionProduct { collectionId String collection Collection @relation(fields: [collectionId], references: [id]) productId String product Product @relation(fields: [productId], references: [id]) @@id([collectionId, productId]) } model Product { id String @id @default(cuid()) title String description String? categoryId String? category Category? @relation(fields: [categoryId], references: [id]) brandId String? brand Brand? @relation(fields: [brandId], references: [id]) isActive Boolean @default(true) createdAt DateTime @default(now()) variants ProductVariant[] images ProductImage[] collections CollectionProduct[] } model ProductImage { id String @id @default(cuid()) productId String product Product @relation(fields: [productId], references: [id]) url String sortOrder Int @default(0) } model ProductVariant { id String @id @default(cuid()) productId String product Product @relation(fields: [productId], references: [id]) title String sku String? @unique /// Base/raw weight before making charge ("rash weight"). rashWeightGrams Decimal @db.Decimal(14, 4) /// Default making-charge percentage applied on top of rash weight. defaultWagePercent Decimal @db.Decimal(6, 3) isActive Boolean @default(true) orderItems OrderItem[] } // ───────────────────────────────────────────────────────────────────────── // Orders — stock orders (debt-bearing) // ───────────────────────────────────────────────────────────────────────── model Order { id String @id @default(cuid()) orderNumber String @unique customerId String customer Customer @relation(fields: [customerId], references: [id]) branchId String? branch Branch? @relation(fields: [branchId], references: [id]) status OrderStatus @default(PLACED) /// Total gold weight incl. wage across all items ("allGram" / order.w). totalGoldWeightGrams Decimal @db.Decimal(14, 4) /// Pure making-charge weight component (totalGoldWeightGrams minus raw weight sum). wageWeightGrams Decimal @db.Decimal(14, 4) /// Flat Rial charge independent of gold price (e.g. stones) — legacy rial_input. flatRialCharge Decimal @default(0) @db.Decimal(20, 2) isSuspendedOverCreditLimit Boolean @default(false) deliveredAt DateTime? // setting this is what triggers debt recognition createdAt DateTime @default(now()) updatedAt DateTime @updatedAt items OrderItem[] statusEvents OrderStatusEvent[] ledgerEntries LedgerEntry[] convertedFromPreOrder PreOrder? @@index([customerId]) @@index([status]) } model OrderItem { id String @id @default(cuid()) orderId String order Order @relation(fields: [orderId], references: [id]) productVariantId String productVariant ProductVariant @relation(fields: [productVariantId], references: [id]) quantity Int // Snapshots at order time — never re-read from ProductVariant after placement. rashWeightGramsSnapshot Decimal @db.Decimal(14, 4) wagePercentSnapshot Decimal @db.Decimal(6, 3) discountPercentSnapshot Decimal @default(0) @db.Decimal(6, 3) /// computed: rash*(1 + (wagePercent-discount)/100) computedWeightGrams Decimal @db.Decimal(14, 4) flatRialCharge Decimal @default(0) @db.Decimal(20, 2) } model OrderStatusEvent { id String @id @default(cuid()) orderId String order Order @relation(fields: [orderId], references: [id]) fromStatus OrderStatus? toStatus OrderStatus note String? actorAdminUserId String? actorAdminUser AdminUser? @relation(fields: [actorAdminUserId], references: [id]) createdAt DateTime @default(now()) @@index([orderId]) } // ───────────────────────────────────────────────────────────────────────── // Pre-orders — custom manufacturing requests (not debt-bearing until converted) // ───────────────────────────────────────────────────────────────────────── model PreOrder { id String @id @default(cuid()) customerId String customer Customer @relation(fields: [customerId], references: [id]) cityId String? city City? @relation(fields: [cityId], references: [id]) workshopId String? workshop Workshop? @relation(fields: [workshopId], references: [id]) status PreOrderStatus @default(PLACED) designImageUrl String? colorName String? sizeSpec String? stoneColor String? minaColor String? estimatedWeightGrams Decimal? @db.Decimal(14, 4) estimatedPriceRial Decimal? @db.Decimal(20, 2) description String? /// Once priced, an admin converts this into a real (debt-bearing) Order. convertedOrderId String? @unique convertedOrder Order? @relation(fields: [convertedOrderId], references: [id]) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt statusEvents PreOrderStatusEvent[] @@index([customerId]) } model PreOrderStatusEvent { id String @id @default(cuid()) preOrderId String preOrder PreOrder @relation(fields: [preOrderId], references: [id]) /// Field-level diff, e.g. {"status": {"old": "PLACED", "new": "ADMIN_APPROVED"}} changes Json actorAdminUserId String? actorAdminUser AdminUser? @relation(fields: [actorAdminUserId], references: [id]) ipAddress String? createdAt DateTime @default(now()) @@index([preOrderId]) } // ───────────────────────────────────────────────────────────────────────── // Accounting — the dual-currency ledger (core of the system) // ───────────────────────────────────────────────────────────────────────── model LedgerEntry { id String @id @default(cuid()) customerId String customer Customer @relation(fields: [customerId], references: [id]) currency LedgerCurrency direction LedgerDirection sourceType LedgerSourceType /// Always positive; direction determines the sign applied to the customer balance. amount Decimal @db.Decimal(20, 4) /// Purity of the physical instrument, for REPAYMENT_MELTED_GOLD (e.g. 750, 916). karatPerMille Int? /// Gold price/gram at conversion time, for REPAYMENT_CASH. pricePerGramRialAtEntry Decimal? @db.Decimal(20, 2) orderId String? order Order? @relation(fields: [orderId], references: [id]) /// Pending entries don't count toward the cached customer balance until confirmed. confirmed Boolean @default(false) confirmedAt DateTime? postedByAdminUserId String? postedByAdminUser AdminUser? @relation(fields: [postedByAdminUserId], references: [id]) note String? createdAt DateTime @default(now()) @@index([customerId, currency]) @@index([orderId]) } /// Replaces legacy WebServiceGold / ConfigStatic.gold_price — timestamped price feed. model GoldPriceQuote { id String @id @default(cuid()) karatPerMille Int pricePerGramRial Decimal @db.Decimal(20, 2) source String quotedAt DateTime @default(now()) @@index([karatPerMille, quotedAt]) } /// Time-limited gold cashback bonus — replaces the legacy Rooyesh\Wallet gold credit. model WalletBonus { id String @id @default(cuid()) customerId String customer Customer @relation(fields: [customerId], references: [id]) orderId String? goldGrams Decimal @db.Decimal(14, 4) grantedAt DateTime @default(now()) expiresAt DateTime consumedAt DateTime? isActive Boolean @default(true) @@index([customerId, isActive]) }