feat: initial commit

This commit is contained in:
s.esmaeili
2026-09-09 06:46:27 +03:30
commit 0ed6bed028
214 changed files with 20177 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/gold_bank?schema=public"
PORT=3000
+8
View File
@@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}
+37
View File
@@ -0,0 +1,37 @@
{
"name": "api",
"version": "0.0.1",
"private": true,
"scripts": {
"dev": "nest start --watch",
"build": "nest build",
"lint": "eslint \"src/**/*.ts\"",
"test": "jest",
"prisma:push": "prisma db push",
"prisma:migrate": "prisma migrate dev",
"prisma:seed": "prisma db seed",
"prisma:studio": "prisma studio"
},
"prisma": {
"seed": "ts-node --transpile-only prisma/seed.ts"
},
"dependencies": {
"@gold-bank/shared-types": "workspace:*",
"@nestjs/common": "^10.4.0",
"@nestjs/core": "^10.4.0",
"@nestjs/platform-express": "^10.4.0",
"@prisma/client": "^5.19.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1"
},
"devDependencies": {
"@nestjs/cli": "^10.4.5",
"@types/express": "^4.17.21",
"@types/node": "^20.14.0",
"prisma": "^5.19.0",
"ts-node": "^10.9.2",
"typescript": "^5.5.0"
}
}
+423
View File
@@ -0,0 +1,423 @@
// 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"
}
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])
}
+441
View File
@@ -0,0 +1,441 @@
import { AdminRole, PrismaClient, VerificationStep } from '@prisma/client';
const prisma = new PrismaClient();
// Placeholder — replace with a real bcrypt hash once the auth module lands.
const PLACEHOLDER_PASSWORD_HASH = 'placeholder-hash-not-for-production';
// Served from the user-app's own public/ folder. This is the same placeholder
// image the legacy app used for missing/failed product imagery (no-items.png).
// Replace these rows with real photo URLs when product photography exists.
const PLACEHOLDER_PRODUCT_IMAGE = '/img/no-items.png';
async function main() {
// ── Cities ────────────────────────────────────────────────────────────
const [tehran, mashhad, isfahan, tabriz] = await Promise.all([
prisma.city.upsert({ where: { id: 'city-tehran' }, update: {}, create: { id: 'city-tehran', name: 'تهران', province: 'تهران' } }),
prisma.city.upsert({ where: { id: 'city-mashhad' }, update: {}, create: { id: 'city-mashhad', name: 'مشهد', province: 'خراسان رضوی' } }),
prisma.city.upsert({ where: { id: 'city-isfahan' }, update: {}, create: { id: 'city-isfahan', name: 'اصفهان', province: 'اصفهان' } }),
prisma.city.upsert({ where: { id: 'city-tabriz' }, update: {}, create: { id: 'city-tabriz', name: 'تبریز', province: 'آذربایجان شرقی' } }),
]);
// ── Branches & workshop ──────────────────────────────────────────────
const branchTehran = await prisma.branch.upsert({
where: { id: 'branch-tehran-bazaar' },
update: {},
create: { id: 'branch-tehran-bazaar', name: 'شعبه بازار بزرگ تهران', cityId: tehran.id },
});
const branchMashhad = await prisma.branch.upsert({
where: { id: 'branch-mashhad-main' },
update: {},
create: { id: 'branch-mashhad-main', name: 'شعبه مرکزی مشهد', cityId: mashhad.id },
});
await prisma.workshop.upsert({
where: { id: 'workshop-central' },
update: {},
create: { id: 'workshop-central', name: 'کارگاه مرکزی ساخت طلا' },
});
// ── Admin users (sales agents + one super admin) ────────────────────
await prisma.adminUser.upsert({
where: { mobile: '09120000001' },
update: {},
create: {
mobile: '09120000001',
fullName: 'مدیر ارشد سیستم',
passwordHash: PLACEHOLDER_PASSWORD_HASH,
role: AdminRole.SUPER_ADMIN,
branchId: branchTehran.id,
},
});
const agentReza = await prisma.adminUser.upsert({
where: { mobile: '09121234567' },
update: {},
create: {
mobile: '09121234567',
fullName: 'رضا احمدی',
passwordHash: PLACEHOLDER_PASSWORD_HASH,
role: AdminRole.SALES_EXPERT,
branchId: branchTehran.id,
wagePercent: 1.5,
},
});
const agentSara = await prisma.adminUser.upsert({
where: { mobile: '09129876543' },
update: {},
create: {
mobile: '09129876543',
fullName: 'سارا کریمی',
passwordHash: PLACEHOLDER_PASSWORD_HASH,
role: AdminRole.SALES_EXPERT,
branchId: branchMashhad.id,
wagePercent: 1.2,
},
});
await prisma.adminUser.upsert({
where: { mobile: '09123334444' },
update: {},
create: {
mobile: '09123334444',
fullName: 'هومن یزدانی',
passwordHash: PLACEHOLDER_PASSWORD_HASH,
role: AdminRole.WORKSHOP_SUPERVISOR,
},
});
await prisma.adminUser.upsert({
where: { mobile: '09125556666' },
update: {},
create: {
mobile: '09125556666',
fullName: 'فرزانه محمودی',
passwordHash: PLACEHOLDER_PASSWORD_HASH,
role: AdminRole.ACCOUNTANT,
branchId: branchTehran.id,
},
});
// ── Customers ────────────────────────────────────────────────────────
const customerData = [
{
id: 'customer-1',
fullName: 'علی محمدی',
mobile: '09151112233',
nationalCode: '0012345678',
cityId: tehran.id,
referredByAgentId: agentReza.id,
goldCreditLimitGrams: '200',
verificationStep: VerificationStep.VERIFIED,
},
{
id: 'customer-2',
fullName: 'مریم حسینی',
mobile: '09151112234',
nationalCode: '0012345679',
cityId: tehran.id,
referredByAgentId: agentReza.id,
goldCreditLimitGrams: '100',
verificationStep: VerificationStep.VERIFIED,
},
{
id: 'customer-3',
fullName: 'جواد نوروزی',
mobile: '09151112235',
nationalCode: '0012345680',
cityId: mashhad.id,
referredByAgentId: agentSara.id,
goldCreditLimitGrams: '50',
verificationStep: VerificationStep.STEP_TWO_SUBMITTED,
},
{
id: 'customer-4',
fullName: 'نرگس ابراهیمی',
mobile: '09151112236',
nationalCode: '0012345681',
cityId: isfahan.id,
referredByAgentId: null,
goldCreditLimitGrams: '30',
verificationStep: VerificationStep.UNVERIFIED,
},
{
id: 'customer-5',
fullName: 'کامران صادقی',
mobile: '09151112237',
nationalCode: '0012345682',
cityId: tehran.id,
referredByAgentId: agentReza.id,
goldCreditLimitGrams: '500',
verificationStep: VerificationStep.VERIFIED,
},
{
id: 'customer-6',
fullName: 'زهرا قاسمی',
mobile: '09151112238',
nationalCode: '0012345683',
cityId: tabriz.id,
referredByAgentId: agentSara.id,
goldCreditLimitGrams: '80',
verificationStep: VerificationStep.STEP_ONE_SUBMITTED,
},
];
const customers = [];
for (const c of customerData) {
customers.push(
await prisma.customer.upsert({
where: { id: c.id },
update: {},
create: {
id: c.id,
fullName: c.fullName,
mobile: c.mobile,
nationalCode: c.nationalCode,
passwordHash: PLACEHOLDER_PASSWORD_HASH,
cityId: c.cityId,
referredByAgentId: c.referredByAgentId,
goldCreditLimitGrams: c.goldCreditLimitGrams,
verificationStep: c.verificationStep,
},
}),
);
}
const [customer1, customer2, , , customer5] = customers;
// ── Catalog: categories, brands, products, variants ────────────────
const [ringsCategory, braceletsCategory, necklacesCategory, earringsCategory, coinsCategory] = await Promise.all([
prisma.category.upsert({ where: { id: 'cat-rings' }, update: {}, create: { id: 'cat-rings', title: 'انگشتر' } }),
prisma.category.upsert({ where: { id: 'cat-bracelets' }, update: {}, create: { id: 'cat-bracelets', title: 'دستبند' } }),
prisma.category.upsert({ where: { id: 'cat-necklaces' }, update: {}, create: { id: 'cat-necklaces', title: 'گردنبند' } }),
prisma.category.upsert({ where: { id: 'cat-earrings' }, update: {}, create: { id: 'cat-earrings', title: 'گوشواره' } }),
prisma.category.upsert({ where: { id: 'cat-coins' }, update: {}, create: { id: 'cat-coins', title: 'سکه و شمش' } }),
]);
const brandHenza = await prisma.brand.upsert({
where: { id: 'brand-henza' },
update: {},
create: { id: 'brand-henza', title: 'هنزا طلا' },
});
const brandArtisan = await prisma.brand.upsert({
where: { id: 'brand-artisan' },
update: {},
create: { id: 'brand-artisan', title: 'مجموعه صنعتگر' },
});
const productDefs = [
{
id: 'product-classic-ring',
title: 'انگشتر تک نگین کلاسیک',
description: 'انگشتر طلای ۱۸ عیار با طراحی ساده و مینیمال.',
categoryId: ringsCategory.id,
brandId: brandHenza.id,
variants: [
{ id: 'variant-ring-size-6', title: 'سایز ۶', sku: 'RING-CLASSIC-6', rashWeightGrams: '2.850', defaultWagePercent: '9.0' },
{ id: 'variant-ring-size-7', title: 'سایز ۷', sku: 'RING-CLASSIC-7', rashWeightGrams: '3.100', defaultWagePercent: '9.0' },
],
},
{
id: 'product-woven-bracelet',
title: 'دستبند بافت زنجیری',
description: 'دستبند بافت‌دار دست‌ساز از طلای ۱۸ عیار.',
categoryId: braceletsCategory.id,
brandId: brandArtisan.id,
variants: [
{ id: 'variant-bracelet-standard', title: 'استاندارد ۱۸ سانتی', sku: 'BRC-WOVEN-18', rashWeightGrams: '14.200', defaultWagePercent: '11.5' },
],
},
{
id: 'product-pendant-necklace',
title: 'گردنبند آویز اشکی',
description: 'گردنبند ظریف با آویز اشکی و زنجیر نازک.',
categoryId: necklacesCategory.id,
brandId: brandHenza.id,
variants: [
{ id: 'variant-necklace-40cm', title: 'زنجیر ۴۰ سانتی', sku: 'NCK-TEARDROP-40', rashWeightGrams: '5.600', defaultWagePercent: '10.0' },
{ id: 'variant-necklace-45cm', title: 'زنجیر ۴۵ سانتی', sku: 'NCK-TEARDROP-45', rashWeightGrams: '6.100', defaultWagePercent: '10.0' },
],
},
{
id: 'product-hoop-earrings',
title: 'گوشواره حلقه‌ای کلاسیک',
description: 'گوشواره حلقه‌ای طلای ۱۸ عیار مناسب استفاده روزمره.',
categoryId: earringsCategory.id,
brandId: brandArtisan.id,
variants: [
{ id: 'variant-hoop-small', title: 'کوچک', sku: 'ERG-HOOP-S', rashWeightGrams: '1.900', defaultWagePercent: '8.5' },
{ id: 'variant-hoop-large', title: 'بزرگ', sku: 'ERG-HOOP-L', rashWeightGrams: '3.400', defaultWagePercent: '8.5' },
],
},
{
id: 'product-full-coin',
title: 'سکه تمام بهار آزادی',
description: 'سکه طلای تمام بهار آزادی، عیار استاندارد.',
categoryId: coinsCategory.id,
brandId: brandHenza.id,
variants: [
{ id: 'variant-coin-full', title: 'تمام سکه', sku: 'COIN-FULL', rashWeightGrams: '8.133', defaultWagePercent: '2.0' },
{ id: 'variant-coin-half', title: 'نیم سکه', sku: 'COIN-HALF', rashWeightGrams: '4.066', defaultWagePercent: '2.5' },
],
},
];
const createdVariantIds: Record<string, string> = {};
let imageCount = 0;
for (const p of productDefs) {
const product = await prisma.product.upsert({
where: { id: p.id },
update: {},
create: {
id: p.id,
title: p.title,
description: p.description,
categoryId: p.categoryId,
brandId: p.brandId,
},
});
for (const v of p.variants) {
await prisma.productVariant.upsert({
where: { id: v.id },
update: {},
create: {
id: v.id,
productId: product.id,
title: v.title,
sku: v.sku,
rashWeightGrams: v.rashWeightGrams,
defaultWagePercent: v.defaultWagePercent,
},
});
createdVariantIds[v.id] = v.id;
}
// Product photography does not exist in this project: legacy served it from
// the old Laravel host (Product.thumb.full), so there is nothing to migrate.
// Seed the placeholder the legacy app itself used for missing imagery so
// every product card renders an image instead of collapsing to nothing.
await prisma.productImage.upsert({
where: { id: `${p.id}-img-1` },
update: { url: PLACEHOLDER_PRODUCT_IMAGE },
create: {
id: `${p.id}-img-1`,
productId: product.id,
url: PLACEHOLDER_PRODUCT_IMAGE,
sortOrder: 0,
},
});
imageCount += 1;
}
// ── Featured collection ──────────────────────────────────────────────
const featured = await prisma.collection.upsert({
where: { id: 'collection-featured' },
update: {},
create: { id: 'collection-featured', title: 'پرفروش‌های این هفته', description: 'منتخبی از محبوب‌ترین محصولات فروشگاه.' },
});
await prisma.collectionProduct.upsert({
where: { collectionId_productId: { collectionId: featured.id, productId: 'product-classic-ring' } },
update: {},
create: { collectionId: featured.id, productId: 'product-classic-ring' },
});
await prisma.collectionProduct.upsert({
where: { collectionId_productId: { collectionId: featured.id, productId: 'product-pendant-necklace' } },
update: {},
create: { collectionId: featured.id, productId: 'product-pendant-necklace' },
});
await prisma.collectionProduct.upsert({
where: { collectionId_productId: { collectionId: featured.id, productId: 'product-full-coin' } },
update: {},
create: { collectionId: featured.id, productId: 'product-full-coin' },
});
// ── Gold price quote (illustrative, not live-market-accurate) ───────
await prisma.goldPriceQuote.create({
data: { karatPerMille: 750, pricePerGramRial: '61500000', source: 'seed-illustrative' },
});
// ── A delivered order + posted debt, so the dashboard shows real numbers ──
const existingDemoOrder = await prisma.order.findUnique({ where: { orderNumber: 'ORD-SEED-0001' } });
if (!existingDemoOrder) {
const rashWeight = '3.100'; // variant-ring-size-7
const wagePercent = 9.0;
const discountPercent = 0;
const computedWeight = (parseFloat(rashWeight) * (1 + (wagePercent - discountPercent) / 100)).toFixed(4);
const quantity = 1;
const flatRialChargePerUnit = '500000'; // e.g. a small stone-setting fee
const order = await prisma.order.create({
data: {
orderNumber: 'ORD-SEED-0001',
customerId: customer1.id,
branchId: branchTehran.id,
status: 'DELIVERED_TO_CUSTOMER',
totalGoldWeightGrams: computedWeight,
wageWeightGrams: (parseFloat(computedWeight) - parseFloat(rashWeight)).toFixed(4),
flatRialCharge: flatRialChargePerUnit,
deliveredAt: new Date(),
items: {
create: [
{
productVariantId: 'variant-ring-size-7',
quantity,
rashWeightGramsSnapshot: rashWeight,
wagePercentSnapshot: wagePercent.toString(),
discountPercentSnapshot: discountPercent.toString(),
computedWeightGrams: computedWeight,
flatRialCharge: flatRialChargePerUnit,
},
],
},
statusEvents: {
create: [{ toStatus: 'PLACED' }, { toStatus: 'ADMIN_APPROVED' }, { toStatus: 'DELIVERED_TO_CUSTOMER' }],
},
},
});
await prisma.ledgerEntry.create({
data: {
customerId: customer1.id,
currency: 'GOLD_GRAM',
direction: 'DEBIT',
sourceType: 'ORDER_STOCK',
amount: computedWeight,
orderId: order.id,
confirmed: true,
confirmedAt: new Date(),
note: 'بدهی وزنی طلا هنگام تحویل (داده آزمایشی)',
},
});
await prisma.ledgerEntry.create({
data: {
customerId: customer1.id,
currency: 'RIAL',
direction: 'DEBIT',
sourceType: 'ORDER_STOCK',
amount: flatRialChargePerUnit,
orderId: order.id,
confirmed: true,
confirmedAt: new Date(),
note: 'هزینه ثابت ریالی هنگام تحویل (داده آزمایشی)',
},
});
await prisma.customer.update({
where: { id: customer1.id },
data: { goldDebtGrams: { increment: computedWeight }, fiatDebtRial: { increment: flatRialChargePerUnit } },
});
// A pending repayment awaiting admin confirmation, to demo the confirm flow.
await prisma.ledgerEntry.create({
data: {
customerId: customer1.id,
currency: 'GOLD_GRAM',
direction: 'CREDIT',
sourceType: 'REPAYMENT_MELTED_GOLD',
amount: '1.5000',
karatPerMille: 750,
confirmed: false,
note: 'تسویه با طلای آب‌شده، در انتظار تایید (داده آزمایشی)',
},
});
}
// customer2 and customer5 get existing debt/credit variety for the dashboard.
await prisma.customer.update({ where: { id: customer2.id }, data: { goldDebtGrams: '8.2500', fiatDebtRial: '2000000' } });
await prisma.customer.update({ where: { id: customer5.id }, data: { goldDebtGrams: '0', fiatDebtRial: '0' } });
console.log('Seed complete:', {
cities: 4,
branches: 2,
adminUsers: 5,
customers: customers.length,
products: productDefs.length,
variants: Object.keys(createdVariantIds).length,
productImages: imageCount,
});
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});
+12
View File
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { PrismaModule } from './prisma/prisma.module';
import { CatalogModule } from './modules/catalog/catalog.module';
import { CustomersModule } from './modules/customers/customers.module';
import { OrdersModule } from './modules/orders/orders.module';
import { AccountingModule } from './modules/accounting/accounting.module';
import { PricingModule } from './modules/pricing/pricing.module';
@Module({
imports: [PrismaModule, CatalogModule, CustomersModule, OrdersModule, AccountingModule, PricingModule],
})
export class AppModule {}
+15
View File
@@ -0,0 +1,15 @@
import 'reflect-metadata';
import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import { AppModule } from './app.module';
import { PrismaExceptionFilter } from './prisma/prisma-exception.filter';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.setGlobalPrefix('api');
app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
app.useGlobalFilters(new PrismaExceptionFilter());
app.enableCors();
await app.listen(process.env.PORT ?? 3000);
}
bootstrap();
@@ -0,0 +1,34 @@
import { Body, Controller, Get, Param, Post } from '@nestjs/common';
import { LedgerService } from './ledger.service';
import { PostGoldRepaymentDto } from './dto/post-gold-repayment.dto';
import { PostRialRepaymentDto } from './dto/post-rial-repayment.dto';
@Controller('accounting')
export class AccountingController {
constructor(private readonly ledger: LedgerService) {}
@Get('customers/:customerId/balance')
getBalance(@Param('customerId') customerId: string) {
return this.ledger.getBalance(customerId);
}
@Post('customers/:customerId/reconcile')
reconcile(@Param('customerId') customerId: string) {
return this.ledger.reconcileBalance(customerId);
}
@Post('repayments/gold')
postGoldRepayment(@Body() dto: PostGoldRepaymentDto) {
return this.ledger.postGoldRepayment(dto);
}
@Post('repayments/rial')
postRialRepayment(@Body() dto: PostRialRepaymentDto) {
return this.ledger.postRialRepayment(dto);
}
@Post('entries/:entryId/confirm')
confirmEntry(@Param('entryId') entryId: string) {
return this.ledger.confirmEntry(entryId);
}
}
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { LedgerService } from './ledger.service';
import { AccountingController } from './accounting.controller';
@Module({
controllers: [AccountingController],
providers: [LedgerService],
exports: [LedgerService],
})
export class AccountingModule {}
@@ -0,0 +1,26 @@
import { IsIn, IsNumber, IsOptional, IsPositive, IsString } from 'class-validator';
export class PostGoldRepaymentDto {
@IsString()
customerId!: string;
@IsIn(['MELTED_GOLD', 'TRANSFER', 'CHEQUE', 'CASH'])
instrument!: 'MELTED_GOLD' | 'TRANSFER' | 'CHEQUE' | 'CASH';
@IsNumber()
@IsPositive()
rawValue!: number;
@IsOptional()
@IsNumber()
karatPerMille?: number;
@IsOptional()
@IsNumber()
@IsPositive()
pricePerGramRial?: number;
@IsOptional()
@IsString()
note?: string;
}
@@ -0,0 +1,14 @@
import { IsNumber, IsOptional, IsPositive, IsString } from 'class-validator';
export class PostRialRepaymentDto {
@IsString()
customerId!: string;
@IsNumber()
@IsPositive()
amount!: number;
@IsOptional()
@IsString()
note?: string;
}
@@ -0,0 +1,313 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { LedgerCurrency, LedgerDirection, LedgerSourceType, Prisma, PrismaClient } from '@prisma/client';
import { GRAMS_PER_MESGHAL, STANDARD_KARAT_PER_MILLE } from '@gold-bank/shared-types';
import { PrismaService } from '../../prisma/prisma.service';
type Tx = Prisma.TransactionClient | PrismaClient;
export interface PostEntryInput {
customerId: string;
currency: LedgerCurrency;
direction: LedgerDirection;
sourceType: LedgerSourceType;
amount: Prisma.Decimal.Value;
orderId?: string;
karatPerMille?: number;
pricePerGramRialAtEntry?: Prisma.Decimal.Value;
note?: string;
/** Order-delivery debt entries are self-confirming; repayments start pending. */
confirmed?: boolean;
postedByAdminUserId?: string;
}
/**
* The dual-currency accounting engine. LedgerEntry is append-only and is the
* sole source of truth for customer debt; Customer.goldDebtGrams/fiatDebtRial
* are denormalized caches kept in sync here and self-healed by reconcileBalance.
*/
@Injectable()
export class LedgerService {
constructor(private readonly prisma: PrismaService) {}
/** Posts a ledger entry and, if confirmed, applies it to the cached customer balance. */
async postEntry(input: PostEntryInput, tx: Tx = this.prisma) {
const amount = new Prisma.Decimal(input.amount);
if (amount.lte(0)) {
throw new BadRequestException('Ledger entry amount must be positive');
}
const confirmed = input.confirmed ?? false;
const entry = await tx.ledgerEntry.create({
data: {
customerId: input.customerId,
currency: input.currency,
direction: input.direction,
sourceType: input.sourceType,
amount,
orderId: input.orderId,
karatPerMille: input.karatPerMille,
pricePerGramRialAtEntry:
input.pricePerGramRialAtEntry !== undefined ? new Prisma.Decimal(input.pricePerGramRialAtEntry) : undefined,
note: input.note,
confirmed,
confirmedAt: confirmed ? new Date() : null,
postedByAdminUserId: input.postedByAdminUserId,
},
});
if (confirmed) {
await this.applyToBalance(input.customerId, input.currency, input.direction, amount, tx);
}
return entry;
}
/**
* Marks a pending repayment entry confirmed. Legacy's confirmed toggle
* (Adm\CashController::toggleConfiremCash) is bidirectional and used for
* post-hoc correction/rejection — legacy Cash rows are auto-confirmed
* (confirmed=1) at creation, not created pending. This system intentionally
* requires an explicit confirmation step before a repayment counts (see
* postGoldRepayment for the full rationale) — that is a deliberate design
* change from legacy, not a faithful mirror of it.
*/
async confirmEntry(entryId: string, adminUserId?: string, tx: Tx = this.prisma) {
const entry = await tx.ledgerEntry.findUnique({ where: { id: entryId } });
if (!entry) throw new NotFoundException('Ledger entry not found');
if (entry.confirmed) return entry;
const updated = await tx.ledgerEntry.update({
where: { id: entryId },
data: { confirmed: true, confirmedAt: new Date(), postedByAdminUserId: adminUserId ?? entry.postedByAdminUserId },
});
await this.applyToBalance(entry.customerId, entry.currency, entry.direction, entry.amount, tx);
return updated;
}
private async applyToBalance(
customerId: string,
currency: LedgerCurrency,
direction: LedgerDirection,
amount: Prisma.Decimal,
tx: Tx,
) {
const signedDelta = direction === LedgerDirection.DEBIT ? amount : amount.neg();
await tx.customer.update({
where: { id: customerId },
data:
currency === LedgerCurrency.GOLD_GRAM
? { goldDebtGrams: { increment: signedDelta } }
: { fiatDebtRial: { increment: signedDelta } },
});
}
/**
* Posts the dual-currency debt for a stock order once it reaches
* DELIVERED_TO_AGENT: GOLD_GRAM debt = order.totalGoldWeightGrams, RIAL debt
* = order.flatRialCharge. Both entries are self-confirming — the status
* transition itself is the confirming event (mirrors legacy sts>4, which
* first becomes true at sts=5 "تحویل مسئول فروش" / delivered to agent, not
* sts=6 delivered to customer).
*/
async postOrderDebt(orderId: string, tx: Tx = this.prisma) {
const order = await tx.order.findUnique({ where: { id: orderId } });
if (!order) throw new NotFoundException('Order not found');
await this.postEntry(
{
customerId: order.customerId,
currency: LedgerCurrency.GOLD_GRAM,
direction: LedgerDirection.DEBIT,
sourceType: LedgerSourceType.ORDER_STOCK,
amount: order.totalGoldWeightGrams,
orderId: order.id,
confirmed: true,
note: 'Gold-weight debt on delivery',
},
tx,
);
if (new Prisma.Decimal(order.flatRialCharge).gt(0)) {
await this.postEntry(
{
customerId: order.customerId,
currency: LedgerCurrency.RIAL,
direction: LedgerDirection.DEBIT,
sourceType: LedgerSourceType.ORDER_STOCK,
amount: order.flatRialCharge,
orderId: order.id,
confirmed: true,
note: 'Flat Rial charge (e.g. stones) on delivery',
},
tx,
);
}
}
/**
* Normalizes a physical repayment instrument into gold grams and posts a
* pending (unconfirmed) CREDIT entry against the customer's gold debt.
* Formulas preserved verbatim from legacy CashController::store. Note:
* legacy auto-confirms (confirmed=1) every Cash row at creation — posting
* this as pending instead is an intentional design change, not a mirror of
* legacy's default.
*/
async postGoldRepayment(
params: {
customerId: string;
instrument: 'MELTED_GOLD' | 'TRANSFER' | 'CHEQUE' | 'CASH';
rawValue: Prisma.Decimal.Value;
karatPerMille?: number;
pricePerGramRial?: Prisma.Decimal.Value;
note?: string;
postedByAdminUserId?: string;
},
tx: Tx = this.prisma,
) {
const raw = new Prisma.Decimal(params.rawValue);
let goldGramsValue: Prisma.Decimal;
let sourceType: LedgerSourceType;
switch (params.instrument) {
case 'MELTED_GOLD': {
if (!params.karatPerMille) {
throw new BadRequestException('karatPerMille is required for a melted-gold repayment');
}
// legacy: value = original_value * ayar / 750
goldGramsValue = raw.mul(params.karatPerMille).div(STANDARD_KARAT_PER_MILLE);
sourceType = LedgerSourceType.REPAYMENT_MELTED_GOLD;
break;
}
case 'CASH': {
if (!params.pricePerGramRial) {
throw new BadRequestException('pricePerGramRial is required for a cash repayment');
}
// legacy: value = price / (price_per_gram / 4.331802) — preserved verbatim;
// flagged in migration analysis as a formula worth stakeholder review.
const pricePerGram = new Prisma.Decimal(params.pricePerGramRial);
goldGramsValue = raw.div(pricePerGram.div(GRAMS_PER_MESGHAL));
sourceType = LedgerSourceType.REPAYMENT_CASH;
break;
}
case 'TRANSFER':
goldGramsValue = raw;
sourceType = LedgerSourceType.REPAYMENT_TRANSFER;
break;
case 'CHEQUE':
goldGramsValue = raw;
sourceType = LedgerSourceType.REPAYMENT_CHEQUE;
break;
}
return this.postEntry(
{
customerId: params.customerId,
currency: LedgerCurrency.GOLD_GRAM,
direction: LedgerDirection.CREDIT,
sourceType,
amount: goldGramsValue,
karatPerMille: params.karatPerMille,
pricePerGramRialAtEntry: params.pricePerGramRial,
note: params.note,
confirmed: false, // pending until an admin confirms — intentional deviation from legacy, which auto-confirms on creation
postedByAdminUserId: params.postedByAdminUserId,
},
tx,
);
}
/**
* Direct Rial-debt repayment. Not present in the legacy system (its
* rial_remain had no repayment offset at all — a gap flagged during
* migration analysis) but supported here since the new ledger is symmetric.
*/
async postRialRepayment(
params: { customerId: string; amount: Prisma.Decimal.Value; note?: string; postedByAdminUserId?: string },
tx: Tx = this.prisma,
) {
return this.postEntry(
{
customerId: params.customerId,
currency: LedgerCurrency.RIAL,
direction: LedgerDirection.CREDIT,
sourceType: LedgerSourceType.REPAYMENT_CASH,
amount: params.amount,
note: params.note,
confirmed: false,
postedByAdminUserId: params.postedByAdminUserId,
},
tx,
);
}
async postAdjustment(
params: {
customerId: string;
currency: LedgerCurrency;
direction: LedgerDirection;
amount: Prisma.Decimal.Value;
note: string;
postedByAdminUserId: string;
},
tx: Tx = this.prisma,
) {
return this.postEntry(
{
customerId: params.customerId,
currency: params.currency,
direction: params.direction,
sourceType: LedgerSourceType.ADJUSTMENT,
amount: params.amount,
note: params.note,
confirmed: true,
postedByAdminUserId: params.postedByAdminUserId,
},
tx,
);
}
async getBalance(customerId: string) {
const customer = await this.prisma.customer.findUnique({
where: { id: customerId },
select: { id: true, goldDebtGrams: true, fiatDebtRial: true, goldCreditLimitGrams: true },
});
if (!customer) throw new NotFoundException('Customer not found');
return {
customerId: customer.id,
goldDebtGrams: customer.goldDebtGrams,
fiatDebtRial: customer.fiatDebtRial,
goldCreditLimitGrams: customer.goldCreditLimitGrams,
};
}
/**
* Recomputes cached balances from confirmed LedgerEntry rows and overwrites
* the cache — a self-healing reconciliation, run periodically or on demand,
* unlike legacy's recompute-on-every-write (Customer::updateRemain).
*/
async reconcileBalance(customerId: string) {
const [goldDebits, goldCredits, rialDebits, rialCredits] = await Promise.all([
this.sumConfirmed(customerId, LedgerCurrency.GOLD_GRAM, LedgerDirection.DEBIT),
this.sumConfirmed(customerId, LedgerCurrency.GOLD_GRAM, LedgerDirection.CREDIT),
this.sumConfirmed(customerId, LedgerCurrency.RIAL, LedgerDirection.DEBIT),
this.sumConfirmed(customerId, LedgerCurrency.RIAL, LedgerDirection.CREDIT),
]);
return this.prisma.customer.update({
where: { id: customerId },
data: {
goldDebtGrams: goldDebits.sub(goldCredits),
fiatDebtRial: rialDebits.sub(rialCredits),
},
});
}
private async sumConfirmed(customerId: string, currency: LedgerCurrency, direction: LedgerDirection) {
const result = await this.prisma.ledgerEntry.aggregate({
where: { customerId, currency, direction, confirmed: true },
_sum: { amount: true },
});
return result._sum.amount ?? new Prisma.Decimal(0);
}
}
@@ -0,0 +1,12 @@
import { Controller, Get } from '@nestjs/common';
import { CatalogService } from './catalog.service';
@Controller('catalog/products')
export class CatalogController {
constructor(private readonly catalog: CatalogService) {}
@Get()
list() {
return this.catalog.listProducts();
}
}
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { CatalogService } from './catalog.service';
import { CatalogController } from './catalog.controller';
@Module({
controllers: [CatalogController],
providers: [CatalogService],
exports: [CatalogService],
})
export class CatalogModule {}
@@ -0,0 +1,28 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
@Injectable()
export class CatalogService {
constructor(private readonly prisma: PrismaService) {}
listProducts() {
return this.prisma.product.findMany({
where: { isActive: true },
include: {
variants: true,
images: true,
category: true,
brand: true,
collections: { include: { collection: true } },
},
});
}
async getVariantOrThrow(variantId: string) {
const variant = await this.prisma.productVariant.findUnique({ where: { id: variantId } });
if (!variant || !variant.isActive) {
throw new NotFoundException(`Product variant ${variantId} not found or inactive`);
}
return variant;
}
}
@@ -0,0 +1,27 @@
import { Controller, Get, Param } from '@nestjs/common';
import { CustomersService } from './customers.service';
@Controller('customers')
export class CustomersController {
constructor(private readonly customers: CustomersService) {}
@Get()
list() {
return this.customers.list();
}
@Get(':id')
get(@Param('id') id: string) {
return this.customers.getOrThrow(id);
}
@Get(':id/orders')
getOrders(@Param('id') id: string) {
return this.customers.getOrders(id);
}
@Get(':id/ledger-entries')
getLedgerEntries(@Param('id') id: string) {
return this.customers.getLedgerEntries(id);
}
}
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { CustomersService } from './customers.service';
import { CustomersController } from './customers.controller';
@Module({
controllers: [CustomersController],
providers: [CustomersService],
exports: [CustomersService],
})
export class CustomersModule {}
@@ -0,0 +1,59 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../../prisma/prisma.service';
// passwordHash must never leave the API — every customer read goes through this.
const PUBLIC_CUSTOMER_SELECT = {
id: true,
fullName: true,
mobile: true,
nationalCode: true,
cityId: true,
referredByAgentId: true,
goldCreditLimitGrams: true,
goldDebtGrams: true,
fiatDebtRial: true,
verificationStep: true,
nationalIdImageUrl: true,
businessLicenseUrl: true,
shopImageUrl: true,
isActive: true,
createdAt: true,
updatedAt: true,
} satisfies Prisma.CustomerSelect;
@Injectable()
export class CustomersService {
constructor(private readonly prisma: PrismaService) {}
async getOrThrow(customerId: string) {
const customer = await this.prisma.customer.findUnique({
where: { id: customerId },
select: PUBLIC_CUSTOMER_SELECT,
});
if (!customer) throw new NotFoundException(`Customer ${customerId} not found`);
return customer;
}
list() {
return this.prisma.customer.findMany({
select: PUBLIC_CUSTOMER_SELECT,
orderBy: { createdAt: 'desc' },
});
}
getOrders(customerId: string) {
return this.prisma.order.findMany({
where: { customerId },
include: { items: true },
orderBy: { createdAt: 'desc' },
});
}
getLedgerEntries(customerId: string) {
return this.prisma.ledgerEntry.findMany({
where: { customerId },
orderBy: { createdAt: 'desc' },
});
}
}
@@ -0,0 +1,39 @@
import { Type } from 'class-transformer';
import { ArrayMinSize, IsArray, IsInt, IsNumber, IsOptional, IsPositive, IsString, Min, ValidateNested } from 'class-validator';
export class CreateOrderItemInputDto {
@IsString()
productVariantId!: string;
@IsInt()
@Min(1)
quantity!: number;
/** Making-charge percentage negotiated for this order (may differ from the variant default). */
@IsNumber()
wagePercent!: number;
@IsOptional()
@IsNumber()
discountPercent?: number;
/** Flat Rial charge per unit, independent of gold weight (e.g. stones). */
@IsOptional()
@IsNumber()
flatRialCharge?: number;
}
export class CreateOrderDto {
@IsString()
customerId!: string;
@IsOptional()
@IsString()
branchId?: string;
@IsArray()
@ArrayMinSize(1)
@ValidateNested({ each: true })
@Type(() => CreateOrderItemInputDto)
items!: CreateOrderItemInputDto[];
}
@@ -0,0 +1,15 @@
import { IsEnum, IsOptional, IsString } from 'class-validator';
import { OrderStatus } from '@prisma/client';
export class UpdateOrderStatusDto {
@IsEnum(OrderStatus)
status!: OrderStatus;
@IsOptional()
@IsString()
note?: string;
@IsOptional()
@IsString()
actorAdminUserId?: string;
}
@@ -0,0 +1,24 @@
import { Body, Controller, Get, Param, Patch, Post } from '@nestjs/common';
import { OrdersService } from './orders.service';
import { CreateOrderDto } from './dto/create-order.dto';
import { UpdateOrderStatusDto } from './dto/update-order-status.dto';
@Controller('orders')
export class OrdersController {
constructor(private readonly orders: OrdersService) {}
@Post()
create(@Body() dto: CreateOrderDto) {
return this.orders.createOrder(dto);
}
@Get(':id')
get(@Param('id') id: string) {
return this.orders.getById(id);
}
@Patch(':id/status')
updateStatus(@Param('id') id: string, @Body() dto: UpdateOrderStatusDto) {
return this.orders.updateStatus(id, dto.status, dto.actorAdminUserId, dto.note);
}
}
@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { OrdersService } from './orders.service';
import { OrdersController } from './orders.controller';
import { CatalogModule } from '../catalog/catalog.module';
import { AccountingModule } from '../accounting/accounting.module';
@Module({
imports: [CatalogModule, AccountingModule],
controllers: [OrdersController],
providers: [OrdersService],
exports: [OrdersService],
})
export class OrdersModule {}
@@ -0,0 +1,151 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { OrderStatus, Prisma } from '@prisma/client';
import { PrismaService } from '../../prisma/prisma.service';
import { CatalogService } from '../catalog/catalog.service';
import { LedgerService } from '../accounting/ledger.service';
import { CreateOrderDto } from './dto/create-order.dto';
interface ComputedItem {
productVariantId: string;
quantity: number;
rashWeightGramsSnapshot: Prisma.Decimal;
wagePercentSnapshot: Prisma.Decimal;
discountPercentSnapshot: Prisma.Decimal;
computedWeightGrams: Prisma.Decimal;
flatRialCharge: Prisma.Decimal;
}
@Injectable()
export class OrdersService {
constructor(
private readonly prisma: PrismaService,
private readonly catalog: CatalogService,
private readonly ledger: LedgerService,
) {}
/**
* Places a stock order. Per line item:
* w = rashWeightGrams * (1 + (wagePercent - discountPercent) / 100)
* mirrors legacy ApiMobileController::setOrder. Debt is NOT posted here —
* only once the order reaches DELIVERED_TO_AGENT (see updateStatus).
*/
async createOrder(dto: CreateOrderDto) {
const customer = await this.prisma.customer.findUnique({ where: { id: dto.customerId } });
if (!customer) throw new NotFoundException(`Customer ${dto.customerId} not found`);
const computedItems: ComputedItem[] = await Promise.all(
dto.items.map(async (item) => {
const variant = await this.catalog.getVariantOrThrow(item.productVariantId);
const rashWeight = new Prisma.Decimal(variant.rashWeightGrams);
const wagePercent = new Prisma.Decimal(item.wagePercent);
const discountPercent = new Prisma.Decimal(item.discountPercent ?? 0);
const netPercent = wagePercent.sub(discountPercent);
const computedWeightGrams = rashWeight.mul(netPercent.div(100).add(1));
return {
productVariantId: variant.id,
quantity: item.quantity,
rashWeightGramsSnapshot: rashWeight,
wagePercentSnapshot: wagePercent,
discountPercentSnapshot: discountPercent,
computedWeightGrams,
flatRialCharge: new Prisma.Decimal(item.flatRialCharge ?? 0),
};
}),
);
const totalGoldWeightGrams = computedItems.reduce(
(sum, item) => sum.add(item.computedWeightGrams.mul(item.quantity)),
new Prisma.Decimal(0),
);
const totalRashWeightGrams = computedItems.reduce(
(sum, item) => sum.add(item.rashWeightGramsSnapshot.mul(item.quantity)),
new Prisma.Decimal(0),
);
const wageWeightGrams = totalGoldWeightGrams.sub(totalRashWeightGrams);
const flatRialCharge = computedItems.reduce(
(sum, item) => sum.add(item.flatRialCharge.mul(item.quantity)),
new Prisma.Decimal(0),
);
// legacy: inValidOrder = (allGram + customer.remain) > customer.gram_level
const isSuspendedOverCreditLimit = totalGoldWeightGrams
.add(customer.goldDebtGrams)
.gt(customer.goldCreditLimitGrams);
const initialStatus = isSuspendedOverCreditLimit ? OrderStatus.SUSPENDED : OrderStatus.PLACED;
return this.prisma.$transaction(async (tx) => {
const order = await tx.order.create({
data: {
orderNumber: this.generateOrderNumber(),
customerId: dto.customerId,
branchId: dto.branchId,
status: initialStatus,
totalGoldWeightGrams,
wageWeightGrams,
flatRialCharge,
isSuspendedOverCreditLimit,
items: {
create: computedItems.map((item) => ({
productVariantId: item.productVariantId,
quantity: item.quantity,
rashWeightGramsSnapshot: item.rashWeightGramsSnapshot,
wagePercentSnapshot: item.wagePercentSnapshot,
discountPercentSnapshot: item.discountPercentSnapshot,
computedWeightGrams: item.computedWeightGrams,
flatRialCharge: item.flatRialCharge,
})),
},
statusEvents: {
create: { toStatus: initialStatus, note: isSuspendedOverCreditLimit ? 'Credit limit exceeded' : undefined },
},
},
include: { items: true, statusEvents: true },
});
return order;
});
}
async updateStatus(orderId: string, newStatus: OrderStatus, actorAdminUserId?: string, note?: string) {
return this.prisma.$transaction(async (tx) => {
const order = await tx.order.findUnique({ where: { id: orderId } });
if (!order) throw new NotFoundException(`Order ${orderId} not found`);
const updated = await tx.order.update({
where: { id: orderId },
data: {
status: newStatus,
deliveredAt: newStatus === OrderStatus.DELIVERED_TO_CUSTOMER ? new Date() : order.deliveredAt,
},
});
await tx.orderStatusEvent.create({
data: { orderId, fromStatus: order.status, toStatus: newStatus, note, actorAdminUserId },
});
// Debt recognition point — mirrors legacy sts>4, which first becomes true
// at sts=5 ("تحویل مسئول فروش" / delivered to agent), not sts=6 (delivered
// to customer). Only fires once: a second transition into this status
// (e.g. a data-fix re-save) would double-post debt, so guard on the order
// not already having deliveredAt.
if (newStatus === OrderStatus.DELIVERED_TO_AGENT && !order.deliveredAt) {
await this.ledger.postOrderDebt(orderId, tx);
}
return updated;
});
}
getById(orderId: string) {
return this.prisma.order.findUnique({
where: { id: orderId },
include: { items: true, statusEvents: true, ledgerEntries: true },
});
}
private generateOrderNumber(): string {
const random = Math.random().toString(36).slice(2, 8).toUpperCase();
return `ORD-${Date.now()}-${random}`;
}
}
@@ -0,0 +1,12 @@
import { Controller, Get, Query } from '@nestjs/common';
import { PricingService } from './pricing.service';
@Controller('pricing')
export class PricingController {
constructor(private readonly pricing: PricingService) {}
@Get('latest')
getLatest(@Query('karatPerMille') karatPerMille: string) {
return this.pricing.getLatestQuote(Number(karatPerMille) || 750);
}
}
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { PricingService } from './pricing.service';
import { PricingController } from './pricing.controller';
@Module({
controllers: [PricingController],
providers: [PricingService],
exports: [PricingService],
})
export class PricingModule {}
@@ -0,0 +1,23 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../../prisma/prisma.service';
@Injectable()
export class PricingService {
constructor(private readonly prisma: PrismaService) {}
async recordQuote(karatPerMille: number, pricePerGramRial: Prisma.Decimal.Value, source: string) {
return this.prisma.goldPriceQuote.create({
data: { karatPerMille, pricePerGramRial, source },
});
}
async getLatestQuote(karatPerMille: number) {
const quote = await this.prisma.goldPriceQuote.findFirst({
where: { karatPerMille },
orderBy: { quotedAt: 'desc' },
});
if (!quote) throw new NotFoundException(`No gold price quote recorded for ${karatPerMille}‰`);
return quote;
}
}
@@ -0,0 +1,63 @@
import { ArgumentsHost, Catch, ExceptionFilter, HttpStatus, Logger } from '@nestjs/common';
import { Response } from 'express';
import { Prisma } from '@prisma/client';
/**
* Without this, an unmigrated table (P2021), a bad foreign key (P2003), etc.
* all surfaced as an opaque "Internal Server Error" with no indication of
* the actual cause — this maps the common Prisma error codes to sensible
* HTTP statuses and echoes the real reason back (safe in dev; consider
* stripping detail in production).
*/
@Catch(Prisma.PrismaClientKnownRequestError, Prisma.PrismaClientValidationError)
export class PrismaExceptionFilter implements ExceptionFilter {
private readonly logger = new Logger('PrismaExceptionFilter');
catch(exception: Prisma.PrismaClientKnownRequestError | Prisma.PrismaClientValidationError, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
this.logger.error(exception.message);
if (exception instanceof Prisma.PrismaClientValidationError) {
return response.status(HttpStatus.BAD_REQUEST).json({
statusCode: HttpStatus.BAD_REQUEST,
error: 'Invalid request shape for the database query',
message: exception.message.split('\n').pop()?.trim(),
});
}
switch (exception.code) {
case 'P2021': // table does not exist — schema not pushed/migrated
return response.status(HttpStatus.SERVICE_UNAVAILABLE).json({
statusCode: HttpStatus.SERVICE_UNAVAILABLE,
error: 'Database schema not initialized',
message: `${exception.meta?.table ?? 'A required table'} does not exist. Run "pnpm prisma:push" (or "pnpm prisma:migrate") against this DATABASE_URL.`,
});
case 'P2025': // record not found for update/delete
return response.status(HttpStatus.NOT_FOUND).json({
statusCode: HttpStatus.NOT_FOUND,
error: 'Not found',
message: exception.meta?.cause ?? 'Record not found',
});
case 'P2002': // unique constraint violation
return response.status(HttpStatus.CONFLICT).json({
statusCode: HttpStatus.CONFLICT,
error: 'Conflict',
message: `Unique constraint failed on: ${(exception.meta?.target as string[] | undefined)?.join(', ')}`,
});
case 'P2003': // foreign key constraint violation
return response.status(HttpStatus.BAD_REQUEST).json({
statusCode: HttpStatus.BAD_REQUEST,
error: 'Invalid reference',
message: `Foreign key constraint failed on: ${exception.meta?.field_name}`,
});
default:
return response.status(HttpStatus.INTERNAL_SERVER_ERROR).json({
statusCode: HttpStatus.INTERNAL_SERVER_ERROR,
error: 'Database error',
message: `[${exception.code}] ${exception.message.split('\n').pop()?.trim()}`,
});
}
}
}
+9
View File
@@ -0,0 +1,9 @@
import { Global, Module } from '@nestjs/common';
import { PrismaService } from './prisma.service';
@Global()
@Module({
providers: [PrismaService],
exports: [PrismaService],
})
export class PrismaModule {}
+13
View File
@@ -0,0 +1,13 @@
import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
async onModuleInit() {
await this.$connect();
}
async onModuleDestroy() {
await this.$disconnect();
}
}
+13
View File
@@ -0,0 +1,13 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"module": "commonjs",
"outDir": "./dist",
"baseUrl": "./",
"incremental": true,
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"strictNullChecks": true,
"sourceMap": true
}
}