|
|
|
@@ -0,0 +1,515 @@
|
|
|
|
|
import {
|
|
|
|
|
BadRequestException,
|
|
|
|
|
Injectable,
|
|
|
|
|
NotFoundException,
|
|
|
|
|
} from '@nestjs/common';
|
|
|
|
|
import type {
|
|
|
|
|
InventorySummaryDto,
|
|
|
|
|
PartyBalanceDto,
|
|
|
|
|
StockMovementDto,
|
|
|
|
|
StockMovementType,
|
|
|
|
|
} from '@mento/shared';
|
|
|
|
|
import { Prisma } from '@prisma/client';
|
|
|
|
|
import { PrismaService } from '../prisma/prisma.service';
|
|
|
|
|
|
|
|
|
|
const MOVEMENT_TYPES = new Set([
|
|
|
|
|
'opening',
|
|
|
|
|
'purchase',
|
|
|
|
|
'sale',
|
|
|
|
|
'credit',
|
|
|
|
|
'adjustment',
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
@Injectable()
|
|
|
|
|
export class InventoryService {
|
|
|
|
|
constructor(private readonly prisma: PrismaService) {}
|
|
|
|
|
|
|
|
|
|
async isInternalEnabled(organizationId: string): Promise<boolean> {
|
|
|
|
|
const n = await this.prisma.accountingConnection.count({
|
|
|
|
|
where: { organizationId, enabled: true },
|
|
|
|
|
});
|
|
|
|
|
return n === 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async getSummary(organizationId: string): Promise<InventorySummaryDto> {
|
|
|
|
|
const enabled = await this.isInternalEnabled(organizationId);
|
|
|
|
|
const org = await this.prisma.organization.findFirst({
|
|
|
|
|
where: { id: organizationId },
|
|
|
|
|
select: { baseCurrency: true },
|
|
|
|
|
});
|
|
|
|
|
const currency = org?.baseCurrency ?? 'IRR';
|
|
|
|
|
if (!enabled) {
|
|
|
|
|
return {
|
|
|
|
|
enabled: false,
|
|
|
|
|
warehouseName: null,
|
|
|
|
|
products: [],
|
|
|
|
|
cashTotal: 0,
|
|
|
|
|
receivablesTotal: 0,
|
|
|
|
|
currency,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const warehouse = await this.ensureReady(organizationId);
|
|
|
|
|
const products = await this.prisma.product.findMany({
|
|
|
|
|
where: { organizationId, kind: 'product' },
|
|
|
|
|
orderBy: [{ isActive: 'desc' }, { name: 'asc' }],
|
|
|
|
|
});
|
|
|
|
|
const stocks = await this.stockByProduct(
|
|
|
|
|
organizationId,
|
|
|
|
|
products.map((p) => p.id),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const [cashTotal, receivablesTotal] = await Promise.all([
|
|
|
|
|
this.cashTotal(organizationId),
|
|
|
|
|
this.receivablesTotal(organizationId),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
enabled: true,
|
|
|
|
|
warehouseName: warehouse.name,
|
|
|
|
|
products: products.map((p) => ({
|
|
|
|
|
productId: p.id,
|
|
|
|
|
name: p.name,
|
|
|
|
|
sku: p.sku,
|
|
|
|
|
unit: p.unit,
|
|
|
|
|
kind: 'product',
|
|
|
|
|
stockQty: stocks.get(p.id) ?? 0,
|
|
|
|
|
currency: p.currency,
|
|
|
|
|
unitPrice: p.unitPrice == null ? null : Number(p.unitPrice),
|
|
|
|
|
})),
|
|
|
|
|
cashTotal,
|
|
|
|
|
receivablesTotal,
|
|
|
|
|
currency,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async listMovements(
|
|
|
|
|
organizationId: string,
|
|
|
|
|
productId?: string,
|
|
|
|
|
): Promise<StockMovementDto[]> {
|
|
|
|
|
if (!(await this.isInternalEnabled(organizationId))) return [];
|
|
|
|
|
await this.ensureReady(organizationId);
|
|
|
|
|
const rows = await this.prisma.stockMovement.findMany({
|
|
|
|
|
where: {
|
|
|
|
|
organizationId,
|
|
|
|
|
...(productId ? { productId } : {}),
|
|
|
|
|
},
|
|
|
|
|
include: { product: { select: { name: true } } },
|
|
|
|
|
orderBy: [{ occurredAt: 'desc' }, { createdAt: 'desc' }],
|
|
|
|
|
take: 200,
|
|
|
|
|
});
|
|
|
|
|
return rows.map((row) => ({
|
|
|
|
|
id: row.id,
|
|
|
|
|
productId: row.productId,
|
|
|
|
|
productName: row.product.name,
|
|
|
|
|
type: asMovementType(row.type),
|
|
|
|
|
quantity: Number(row.quantity),
|
|
|
|
|
note: row.note,
|
|
|
|
|
invoiceId: row.invoiceId,
|
|
|
|
|
occurredAt: row.occurredAt.toISOString(),
|
|
|
|
|
}));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async addManualMovement(
|
|
|
|
|
organizationId: string,
|
|
|
|
|
dto: { productId: string; type: 'purchase' | 'adjustment'; quantity: number; note?: string },
|
|
|
|
|
): Promise<StockMovementDto> {
|
|
|
|
|
if (!(await this.isInternalEnabled(organizationId))) {
|
|
|
|
|
throw new BadRequestException(
|
|
|
|
|
'با اتصال تهحساب، انبار داخلی غیرفعال است',
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
const warehouse = await this.ensureReady(organizationId);
|
|
|
|
|
const product = await this.prisma.product.findFirst({
|
|
|
|
|
where: { id: dto.productId, organizationId },
|
|
|
|
|
});
|
|
|
|
|
if (!product) throw new NotFoundException('محصول پیدا نشد');
|
|
|
|
|
if (product.kind === 'service') {
|
|
|
|
|
throw new BadRequestException('خدمات موجودی انبار ندارند');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let quantity = Number(dto.quantity);
|
|
|
|
|
if (!Number.isFinite(quantity) || quantity === 0) {
|
|
|
|
|
throw new BadRequestException('مقدار نامعتبر است');
|
|
|
|
|
}
|
|
|
|
|
if (dto.type === 'purchase' && quantity < 0) {
|
|
|
|
|
throw new BadRequestException('ورود خرید باید مثبت باشد');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const row = await this.prisma.stockMovement.create({
|
|
|
|
|
data: {
|
|
|
|
|
organizationId,
|
|
|
|
|
warehouseId: warehouse.id,
|
|
|
|
|
productId: product.id,
|
|
|
|
|
type: dto.type,
|
|
|
|
|
quantity: new Prisma.Decimal(quantity),
|
|
|
|
|
note: dto.note?.trim() || null,
|
|
|
|
|
},
|
|
|
|
|
include: { product: { select: { name: true } } },
|
|
|
|
|
});
|
|
|
|
|
await this.refreshProductStock(organizationId, product.id);
|
|
|
|
|
return {
|
|
|
|
|
id: row.id,
|
|
|
|
|
productId: row.productId,
|
|
|
|
|
productName: row.product.name,
|
|
|
|
|
type: asMovementType(row.type),
|
|
|
|
|
quantity: Number(row.quantity),
|
|
|
|
|
note: row.note,
|
|
|
|
|
invoiceId: row.invoiceId,
|
|
|
|
|
occurredAt: row.occurredAt.toISOString(),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async getPartyBalance(
|
|
|
|
|
organizationId: string,
|
|
|
|
|
personId: string,
|
|
|
|
|
): Promise<PartyBalanceDto> {
|
|
|
|
|
const enabled = await this.isInternalEnabled(organizationId);
|
|
|
|
|
const org = await this.prisma.organization.findFirst({
|
|
|
|
|
where: { id: organizationId },
|
|
|
|
|
select: { baseCurrency: true },
|
|
|
|
|
});
|
|
|
|
|
const currency = org?.baseCurrency ?? 'IRR';
|
|
|
|
|
if (!enabled) {
|
|
|
|
|
return {
|
|
|
|
|
enabled: false,
|
|
|
|
|
personId,
|
|
|
|
|
openingAmount: 0,
|
|
|
|
|
invoicedAmount: 0,
|
|
|
|
|
paidAmount: 0,
|
|
|
|
|
balance: 0,
|
|
|
|
|
currency,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const person = await this.prisma.person.findFirst({
|
|
|
|
|
where: { id: personId, organizationId },
|
|
|
|
|
select: { id: true },
|
|
|
|
|
});
|
|
|
|
|
if (!person) throw new NotFoundException('شخص پیدا نشد');
|
|
|
|
|
|
|
|
|
|
const { openingAmount, invoicedAmount, paidAmount } =
|
|
|
|
|
await this.personLedger(organizationId, personId);
|
|
|
|
|
return {
|
|
|
|
|
enabled: true,
|
|
|
|
|
personId,
|
|
|
|
|
openingAmount,
|
|
|
|
|
invoicedAmount,
|
|
|
|
|
paidAmount,
|
|
|
|
|
balance: roundMoney(openingAmount + invoicedAmount - paidAmount),
|
|
|
|
|
currency,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async setPartyOpening(
|
|
|
|
|
organizationId: string,
|
|
|
|
|
personId: string,
|
|
|
|
|
amount: number,
|
|
|
|
|
note?: string,
|
|
|
|
|
): Promise<PartyBalanceDto> {
|
|
|
|
|
if (!(await this.isInternalEnabled(organizationId))) {
|
|
|
|
|
throw new BadRequestException(
|
|
|
|
|
'با اتصال تهحساب، مانده داخلی غیرفعال است',
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
const person = await this.prisma.person.findFirst({
|
|
|
|
|
where: { id: personId, organizationId },
|
|
|
|
|
select: { id: true },
|
|
|
|
|
});
|
|
|
|
|
if (!person) throw new NotFoundException('شخص پیدا نشد');
|
|
|
|
|
if (!Number.isFinite(amount)) {
|
|
|
|
|
throw new BadRequestException('مبلغ نامعتبر است');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await this.prisma.partyOpeningBalance.upsert({
|
|
|
|
|
where: { organizationId_personId: { organizationId, personId } },
|
|
|
|
|
create: {
|
|
|
|
|
organizationId,
|
|
|
|
|
personId,
|
|
|
|
|
amount: new Prisma.Decimal(amount),
|
|
|
|
|
note: note?.trim() || null,
|
|
|
|
|
},
|
|
|
|
|
update: {
|
|
|
|
|
amount: new Prisma.Decimal(amount),
|
|
|
|
|
note: note?.trim() || null,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
return this.getPartyBalance(organizationId, personId);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async syncInvoiceStock(organizationId: string, invoiceId: string) {
|
|
|
|
|
if (!(await this.isInternalEnabled(organizationId))) {
|
|
|
|
|
await this.prisma.stockMovement.deleteMany({ where: { invoiceId } });
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const warehouse = await this.ensureReady(organizationId);
|
|
|
|
|
const invoice = await this.prisma.invoice.findFirst({
|
|
|
|
|
where: { id: invoiceId, organizationId },
|
|
|
|
|
include: {
|
|
|
|
|
lineItems: { include: { product: { select: { id: true, kind: true } } } },
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
if (!invoice) return;
|
|
|
|
|
|
|
|
|
|
const productIds = [
|
|
|
|
|
...new Set(
|
|
|
|
|
invoice.lineItems
|
|
|
|
|
.map((l) => l.productId)
|
|
|
|
|
.filter((id): id is string => Boolean(id)),
|
|
|
|
|
),
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
await this.prisma.stockMovement.deleteMany({ where: { invoiceId } });
|
|
|
|
|
|
|
|
|
|
const active =
|
|
|
|
|
invoice.status !== 'draft' && invoice.status !== 'canceled';
|
|
|
|
|
if (active) {
|
|
|
|
|
const isCredit = invoice.kind === 'credit';
|
|
|
|
|
const sign = isCredit ? 1 : -1;
|
|
|
|
|
const type = isCredit ? 'credit' : 'sale';
|
|
|
|
|
const rows = invoice.lineItems
|
|
|
|
|
.filter((l) => l.productId && l.product?.kind !== 'service')
|
|
|
|
|
.map((l) => ({
|
|
|
|
|
organizationId,
|
|
|
|
|
warehouseId: warehouse.id,
|
|
|
|
|
productId: l.productId!,
|
|
|
|
|
type,
|
|
|
|
|
quantity: new Prisma.Decimal(sign * Number(l.quantity)),
|
|
|
|
|
note: invoice.number,
|
|
|
|
|
invoiceId,
|
|
|
|
|
occurredAt: invoice.issuedAt ?? new Date(),
|
|
|
|
|
}));
|
|
|
|
|
if (rows.length) {
|
|
|
|
|
await this.prisma.stockMovement.createMany({ data: rows });
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await Promise.all(
|
|
|
|
|
productIds.map((id) => this.refreshProductStock(organizationId, id)),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async onProductCreated(
|
|
|
|
|
organizationId: string,
|
|
|
|
|
productId: string,
|
|
|
|
|
stockQty: number | null,
|
|
|
|
|
kind: string,
|
|
|
|
|
) {
|
|
|
|
|
if (kind === 'service') return;
|
|
|
|
|
if (!(await this.isInternalEnabled(organizationId))) return;
|
|
|
|
|
if (stockQty == null || stockQty === 0) return;
|
|
|
|
|
const warehouse = await this.ensureReady(organizationId);
|
|
|
|
|
const existing = await this.prisma.stockMovement.count({
|
|
|
|
|
where: { productId },
|
|
|
|
|
});
|
|
|
|
|
if (existing) return;
|
|
|
|
|
await this.prisma.stockMovement.create({
|
|
|
|
|
data: {
|
|
|
|
|
organizationId,
|
|
|
|
|
warehouseId: warehouse.id,
|
|
|
|
|
productId,
|
|
|
|
|
type: 'opening',
|
|
|
|
|
quantity: new Prisma.Decimal(stockQty),
|
|
|
|
|
note: 'موجودی اول دوره',
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
await this.refreshProductStock(organizationId, productId);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async onProductStockEdited(
|
|
|
|
|
organizationId: string,
|
|
|
|
|
productId: string,
|
|
|
|
|
nextQty: number | null,
|
|
|
|
|
kind: string,
|
|
|
|
|
) {
|
|
|
|
|
if (kind === 'service') return;
|
|
|
|
|
if (!(await this.isInternalEnabled(organizationId))) return;
|
|
|
|
|
const warehouse = await this.ensureReady(organizationId);
|
|
|
|
|
const current = await this.liveStock(organizationId, productId);
|
|
|
|
|
const target = nextQty ?? 0;
|
|
|
|
|
const delta = target - current;
|
|
|
|
|
if (Math.abs(delta) < 0.0001) {
|
|
|
|
|
await this.refreshProductStock(organizationId, productId);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
await this.prisma.stockMovement.create({
|
|
|
|
|
data: {
|
|
|
|
|
organizationId,
|
|
|
|
|
warehouseId: warehouse.id,
|
|
|
|
|
productId,
|
|
|
|
|
type: 'adjustment',
|
|
|
|
|
quantity: new Prisma.Decimal(delta),
|
|
|
|
|
note: 'اصلاح از فرم محصول',
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
await this.refreshProductStock(organizationId, productId);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async ensureReady(organizationId: string) {
|
|
|
|
|
let warehouse = await this.prisma.warehouse.findFirst({
|
|
|
|
|
where: { organizationId, isDefault: true },
|
|
|
|
|
});
|
|
|
|
|
if (!warehouse) {
|
|
|
|
|
warehouse = await this.prisma.warehouse.create({
|
|
|
|
|
data: {
|
|
|
|
|
organizationId,
|
|
|
|
|
name: 'انبار اصلی',
|
|
|
|
|
isDefault: true,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const products = await this.prisma.product.findMany({
|
|
|
|
|
where: { organizationId, kind: 'product', stockQty: { not: null } },
|
|
|
|
|
select: { id: true, stockQty: true },
|
|
|
|
|
});
|
|
|
|
|
for (const product of products) {
|
|
|
|
|
const count = await this.prisma.stockMovement.count({
|
|
|
|
|
where: { productId: product.id },
|
|
|
|
|
});
|
|
|
|
|
if (count > 0 || product.stockQty == null || product.stockQty === 0) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
await this.prisma.stockMovement.create({
|
|
|
|
|
data: {
|
|
|
|
|
organizationId,
|
|
|
|
|
warehouseId: warehouse.id,
|
|
|
|
|
productId: product.id,
|
|
|
|
|
type: 'opening',
|
|
|
|
|
quantity: new Prisma.Decimal(product.stockQty),
|
|
|
|
|
note: 'موجودی اول دوره',
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
return warehouse;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async stockByProduct(
|
|
|
|
|
organizationId: string,
|
|
|
|
|
productIds: string[],
|
|
|
|
|
): Promise<Map<string, number>> {
|
|
|
|
|
const map = new Map<string, number>();
|
|
|
|
|
if (!productIds.length) return map;
|
|
|
|
|
const groups = await this.prisma.stockMovement.groupBy({
|
|
|
|
|
by: ['productId'],
|
|
|
|
|
where: { organizationId, productId: { in: productIds } },
|
|
|
|
|
_sum: { quantity: true },
|
|
|
|
|
});
|
|
|
|
|
for (const g of groups) {
|
|
|
|
|
map.set(g.productId, Number(g._sum.quantity ?? 0));
|
|
|
|
|
}
|
|
|
|
|
return map;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async liveStock(organizationId: string, productId: string) {
|
|
|
|
|
const agg = await this.prisma.stockMovement.aggregate({
|
|
|
|
|
where: { organizationId, productId },
|
|
|
|
|
_sum: { quantity: true },
|
|
|
|
|
});
|
|
|
|
|
return Number(agg._sum.quantity ?? 0);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async refreshProductStock(organizationId: string, productId: string) {
|
|
|
|
|
const live = await this.liveStock(organizationId, productId);
|
|
|
|
|
await this.prisma.product.update({
|
|
|
|
|
where: { id: productId },
|
|
|
|
|
data: { stockQty: Math.round(live) },
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async cashTotal(organizationId: string): Promise<number> {
|
|
|
|
|
const payments = await this.prisma.payment.findMany({
|
|
|
|
|
where: {
|
|
|
|
|
invoice: {
|
|
|
|
|
organizationId,
|
|
|
|
|
status: { not: 'canceled' },
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
select: { amount: true },
|
|
|
|
|
});
|
|
|
|
|
return roundMoney(payments.reduce((s, p) => s + Number(p.amount), 0));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async receivablesTotal(organizationId: string): Promise<number> {
|
|
|
|
|
const openings = await this.prisma.partyOpeningBalance.findMany({
|
|
|
|
|
where: { organizationId },
|
|
|
|
|
select: { amount: true },
|
|
|
|
|
});
|
|
|
|
|
const openingSum = openings.reduce((s, r) => s + Number(r.amount), 0);
|
|
|
|
|
const invoices = await this.prisma.invoice.findMany({
|
|
|
|
|
where: {
|
|
|
|
|
organizationId,
|
|
|
|
|
status: { in: ['issued', 'partially_paid', 'paid'] },
|
|
|
|
|
},
|
|
|
|
|
include: { lineItems: true, payments: true },
|
|
|
|
|
});
|
|
|
|
|
let remaining = 0;
|
|
|
|
|
for (const inv of invoices) {
|
|
|
|
|
remaining += invoiceRemaining(inv);
|
|
|
|
|
}
|
|
|
|
|
return roundMoney(openingSum + remaining);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async personLedger(organizationId: string, personId: string) {
|
|
|
|
|
const opening = await this.prisma.partyOpeningBalance.findFirst({
|
|
|
|
|
where: { organizationId, personId },
|
|
|
|
|
});
|
|
|
|
|
const invoices = await this.prisma.invoice.findMany({
|
|
|
|
|
where: {
|
|
|
|
|
organizationId,
|
|
|
|
|
personId,
|
|
|
|
|
status: { in: ['issued', 'partially_paid', 'paid'] },
|
|
|
|
|
},
|
|
|
|
|
include: { lineItems: true, payments: true },
|
|
|
|
|
});
|
|
|
|
|
let invoicedAmount = 0;
|
|
|
|
|
let paidAmount = 0;
|
|
|
|
|
for (const inv of invoices) {
|
|
|
|
|
invoicedAmount += invoicePayable(inv);
|
|
|
|
|
paidAmount += inv.payments.reduce((s, p) => s + Number(p.amount), 0);
|
|
|
|
|
}
|
|
|
|
|
return {
|
|
|
|
|
openingAmount: Number(opening?.amount ?? 0),
|
|
|
|
|
invoicedAmount: roundMoney(invoicedAmount),
|
|
|
|
|
paidAmount: roundMoney(paidAmount),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function asMovementType(type: string): StockMovementType {
|
|
|
|
|
if (MOVEMENT_TYPES.has(type)) return type as StockMovementType;
|
|
|
|
|
return 'adjustment';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function invoicePayable(inv: {
|
|
|
|
|
kind: string;
|
|
|
|
|
discountAmount: Prisma.Decimal;
|
|
|
|
|
taxPercent: Prisma.Decimal;
|
|
|
|
|
lineItems: Array<{ quantity: Prisma.Decimal; unitPrice: Prisma.Decimal }>;
|
|
|
|
|
}): number {
|
|
|
|
|
const subtotal = inv.lineItems.reduce(
|
|
|
|
|
(s, i) => s + Number(i.quantity) * Number(i.unitPrice),
|
|
|
|
|
0,
|
|
|
|
|
);
|
|
|
|
|
const discount = Number(inv.discountAmount ?? 0);
|
|
|
|
|
const taxPercent = Number(inv.taxPercent ?? 0);
|
|
|
|
|
const tax = Math.max(0, subtotal - discount) * (taxPercent / 100);
|
|
|
|
|
const signed = roundMoney(subtotal - discount + tax);
|
|
|
|
|
return inv.kind === 'credit' ? -signed : signed;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function invoiceRemaining(inv: {
|
|
|
|
|
kind: string;
|
|
|
|
|
discountAmount: Prisma.Decimal;
|
|
|
|
|
taxPercent: Prisma.Decimal;
|
|
|
|
|
lineItems: Array<{ quantity: Prisma.Decimal; unitPrice: Prisma.Decimal }>;
|
|
|
|
|
payments: Array<{ amount: Prisma.Decimal }>;
|
|
|
|
|
}): number {
|
|
|
|
|
const payable = invoicePayable(inv);
|
|
|
|
|
const paid = inv.payments.reduce((s, p) => s + Number(p.amount), 0);
|
|
|
|
|
return payable - paid;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function roundMoney(n: number) {
|
|
|
|
|
return Math.round(n * 100) / 100;
|
|
|
|
|
}
|