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
@@ -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;
}
}