erp-retail-backend/src/modules/cash/services/cash-closing.service.ts

569 lines
16 KiB
TypeScript

import { Repository, DataSource, Between } from 'typeorm';
import { BaseService } from '../../../shared/services/base.service';
import { ServiceResult, QueryOptions } from '../../../shared/types';
import {
CashClosing,
ClosingStatus,
ClosingType,
} from '../entities/cash-closing.entity';
import { CashCount, DenominationType } from '../entities/cash-count.entity';
import { CashMovement, MovementStatus } from '../entities/cash-movement.entity';
export interface CreateClosingInput {
branchId: string;
registerId?: string;
sessionId?: string;
type: ClosingType;
periodStart: Date;
periodEnd: Date;
openingBalance: number;
closingNotes?: string;
}
export interface ClosingQueryOptions extends QueryOptions {
branchId?: string;
sessionId?: string;
status?: ClosingStatus;
type?: ClosingType;
startDate?: Date;
endDate?: Date;
}
export interface DenominationCount {
type: DenominationType;
denomination: number;
quantity: number;
}
export interface PaymentTotals {
cash: number;
card: number;
transfer: number;
other: number;
}
export class CashClosingService extends BaseService<CashClosing> {
constructor(
repository: Repository<CashClosing>,
private readonly countRepository: Repository<CashCount>,
private readonly movementRepository: Repository<CashMovement>,
private readonly dataSource: DataSource
) {
super(repository);
}
/**
* Generate closing number
*/
private async generateClosingNumber(
tenantId: string,
branchId: string,
type: ClosingType
): Promise<string> {
const today = new Date();
const datePrefix = today.toISOString().slice(0, 10).replace(/-/g, '');
const typePrefix = type === ClosingType.SHIFT ? 'CLS' : type.toUpperCase().slice(0, 3);
const count = await this.repository.count({
where: {
tenantId,
branchId,
type,
createdAt: Between(
new Date(today.setHours(0, 0, 0, 0)),
new Date(today.setHours(23, 59, 59, 999))
),
},
});
return `${typePrefix}-${datePrefix}-${String(count + 1).padStart(3, '0')}`;
}
/**
* Calculate expected amounts from POS orders and movements
*/
private async calculateExpectedAmounts(
tenantId: string,
sessionId: string | null,
branchId: string,
periodStart: Date,
periodEnd: Date
): Promise<{
expected: PaymentTotals;
movements: { in: number; out: number };
transactions: { sales: number; refunds: number; discounts: number; orders: number; refundsCount: number; voidedCount: number };
}> {
// Get movements in period
const movements = await this.movementRepository.find({
where: {
tenantId,
branchId,
status: MovementStatus.APPROVED,
...(sessionId ? { sessionId } : {}),
},
});
let cashIn = 0;
let cashOut = 0;
for (const m of movements) {
if (['cash_in', 'opening'].includes(m.type)) {
cashIn += Number(m.amount);
} else if (['cash_out', 'closing', 'deposit', 'withdrawal'].includes(m.type)) {
cashOut += Number(m.amount);
}
}
// TODO: Query POS orders to calculate payment totals
// This would require the POSOrder and POSPayment repositories
// For now, return placeholder values that can be populated
return {
expected: {
cash: cashIn - cashOut,
card: 0,
transfer: 0,
other: 0,
},
movements: {
in: cashIn,
out: cashOut,
},
transactions: {
sales: 0,
refunds: 0,
discounts: 0,
orders: 0,
refundsCount: 0,
voidedCount: 0,
},
};
}
/**
* Create a new cash closing
*/
async createClosing(
tenantId: string,
input: CreateClosingInput,
userId: string
): Promise<ServiceResult<CashClosing>> {
const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
try {
const number = await this.generateClosingNumber(tenantId, input.branchId, input.type);
// Calculate expected amounts
const { expected, movements, transactions } = await this.calculateExpectedAmounts(
tenantId,
input.sessionId ?? null,
input.branchId,
input.periodStart,
input.periodEnd
);
const closing = queryRunner.manager.create(CashClosing, {
tenantId,
branchId: input.branchId,
registerId: input.registerId,
sessionId: input.sessionId,
number,
type: input.type,
status: ClosingStatus.IN_PROGRESS,
closingDate: new Date(),
periodStart: input.periodStart,
periodEnd: input.periodEnd,
openingBalance: input.openingBalance,
expectedCash: expected.cash,
expectedCard: expected.card,
expectedTransfer: expected.transfer,
expectedOther: expected.other,
expectedTotal: expected.cash + expected.card + expected.transfer + expected.other,
totalSales: transactions.sales,
totalRefunds: transactions.refunds,
totalDiscounts: transactions.discounts,
netSales: transactions.sales - transactions.refunds - transactions.discounts,
ordersCount: transactions.orders,
refundsCount: transactions.refundsCount,
voidedCount: transactions.voidedCount,
cashInTotal: movements.in,
cashOutTotal: movements.out,
closedBy: userId,
closingNotes: input.closingNotes,
});
const savedClosing = await queryRunner.manager.save(closing);
await queryRunner.commitTransaction();
return { success: true, data: savedClosing };
} catch (error) {
await queryRunner.rollbackTransaction();
return {
success: false,
error: {
code: 'CREATE_CLOSING_ERROR',
message: error instanceof Error ? error.message : 'Failed to create closing',
},
};
} finally {
await queryRunner.release();
}
}
/**
* Submit cash count for a closing
*/
async submitCashCount(
tenantId: string,
closingId: string,
denominations: DenominationCount[],
userId: string
): Promise<ServiceResult<CashClosing>> {
const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
try {
const closing = await queryRunner.manager.findOne(CashClosing, {
where: { id: closingId, tenantId },
});
if (!closing) {
return {
success: false,
error: { code: 'NOT_FOUND', message: 'Closing not found' },
};
}
if (closing.status !== ClosingStatus.IN_PROGRESS) {
return {
success: false,
error: { code: 'INVALID_STATUS', message: 'Closing is not in progress' },
};
}
// Delete existing counts
await queryRunner.manager.delete(CashCount, { closingId, tenantId });
// Create new counts
let totalCounted = 0;
for (const d of denominations) {
const total = d.denomination * d.quantity;
totalCounted += total;
const count = queryRunner.manager.create(CashCount, {
tenantId,
closingId,
sessionId: closing.sessionId,
registerId: closing.registerId,
type: d.type,
denomination: d.denomination,
quantity: d.quantity,
total,
countType: 'closing',
countedBy: userId,
});
await queryRunner.manager.save(count);
}
// Update closing with counted values
closing.countedCash = totalCounted;
closing.countedTotal = totalCounted + (closing.countedCard ?? 0) +
(closing.countedTransfer ?? 0) + (closing.countedOther ?? 0);
closing.cashDifference = totalCounted - Number(closing.expectedCash);
closing.totalDifference = Number(closing.countedTotal) - Number(closing.expectedTotal);
closing.status = ClosingStatus.PENDING_REVIEW;
await queryRunner.manager.save(closing);
await queryRunner.commitTransaction();
return { success: true, data: closing };
} catch (error) {
await queryRunner.rollbackTransaction();
return {
success: false,
error: {
code: 'SUBMIT_COUNT_ERROR',
message: error instanceof Error ? error.message : 'Failed to submit cash count',
},
};
} finally {
await queryRunner.release();
}
}
/**
* Submit other payment method counts (card, transfer, etc.)
*/
async submitPaymentCounts(
tenantId: string,
closingId: string,
counts: PaymentTotals,
userId: string
): Promise<ServiceResult<CashClosing>> {
const closing = await this.repository.findOne({
where: { id: closingId, tenantId },
});
if (!closing) {
return {
success: false,
error: { code: 'NOT_FOUND', message: 'Closing not found' },
};
}
closing.countedCard = counts.card;
closing.countedTransfer = counts.transfer;
closing.countedOther = counts.other;
closing.cardDifference = counts.card - Number(closing.expectedCard);
closing.countedTotal = (closing.countedCash ?? 0) + counts.card + counts.transfer + counts.other;
closing.totalDifference = Number(closing.countedTotal) - Number(closing.expectedTotal);
// Build payment breakdown
closing.paymentBreakdown = [
{
method: 'cash',
expected: Number(closing.expectedCash),
counted: Number(closing.countedCash ?? 0),
difference: Number(closing.cashDifference ?? 0),
transactionCount: 0,
},
{
method: 'card',
expected: Number(closing.expectedCard),
counted: counts.card,
difference: counts.card - Number(closing.expectedCard),
transactionCount: 0,
},
{
method: 'transfer',
expected: Number(closing.expectedTransfer),
counted: counts.transfer,
difference: counts.transfer - Number(closing.expectedTransfer),
transactionCount: 0,
},
{
method: 'other',
expected: Number(closing.expectedOther),
counted: counts.other,
difference: counts.other - Number(closing.expectedOther),
transactionCount: 0,
},
];
const saved = await this.repository.save(closing);
return { success: true, data: saved };
}
/**
* Approve a closing
*/
async approveClosing(
tenantId: string,
closingId: string,
approverId: string,
notes?: string
): Promise<ServiceResult<CashClosing>> {
const closing = await this.repository.findOne({
where: { id: closingId, tenantId },
});
if (!closing) {
return {
success: false,
error: { code: 'NOT_FOUND', message: 'Closing not found' },
};
}
if (closing.status !== ClosingStatus.PENDING_REVIEW) {
return {
success: false,
error: { code: 'INVALID_STATUS', message: 'Closing is not pending review' },
};
}
closing.status = ClosingStatus.APPROVED;
closing.approvedBy = approverId;
closing.approvedAt = new Date();
if (notes) {
closing.reviewNotes = notes;
}
const saved = await this.repository.save(closing);
return { success: true, data: saved };
}
/**
* Reject a closing
*/
async rejectClosing(
tenantId: string,
closingId: string,
reviewerId: string,
notes: string
): Promise<ServiceResult<CashClosing>> {
const closing = await this.repository.findOne({
where: { id: closingId, tenantId },
});
if (!closing) {
return {
success: false,
error: { code: 'NOT_FOUND', message: 'Closing not found' },
};
}
if (closing.status !== ClosingStatus.PENDING_REVIEW) {
return {
success: false,
error: { code: 'INVALID_STATUS', message: 'Closing is not pending review' },
};
}
closing.status = ClosingStatus.REJECTED;
closing.reviewedBy = reviewerId;
closing.reviewedAt = new Date();
closing.reviewNotes = notes;
const saved = await this.repository.save(closing);
return { success: true, data: saved };
}
/**
* Mark closing as reconciled
*/
async reconcileClosing(
tenantId: string,
closingId: string,
depositInfo: { amount: number; reference: string; date: Date },
userId: string
): Promise<ServiceResult<CashClosing>> {
const closing = await this.repository.findOne({
where: { id: closingId, tenantId },
});
if (!closing) {
return {
success: false,
error: { code: 'NOT_FOUND', message: 'Closing not found' },
};
}
if (closing.status !== ClosingStatus.APPROVED) {
return {
success: false,
error: { code: 'INVALID_STATUS', message: 'Closing must be approved before reconciliation' },
};
}
closing.status = ClosingStatus.RECONCILED;
closing.depositAmount = depositInfo.amount;
closing.depositReference = depositInfo.reference;
closing.depositDate = depositInfo.date;
const saved = await this.repository.save(closing);
return { success: true, data: saved };
}
/**
* Find closings with filters
*/
async findClosings(
tenantId: string,
options: ClosingQueryOptions
): Promise<{ data: CashClosing[]; total: number }> {
const qb = this.repository.createQueryBuilder('closing')
.where('closing.tenantId = :tenantId', { tenantId });
if (options.branchId) {
qb.andWhere('closing.branchId = :branchId', { branchId: options.branchId });
}
if (options.sessionId) {
qb.andWhere('closing.sessionId = :sessionId', { sessionId: options.sessionId });
}
if (options.status) {
qb.andWhere('closing.status = :status', { status: options.status });
}
if (options.type) {
qb.andWhere('closing.type = :type', { type: options.type });
}
if (options.startDate && options.endDate) {
qb.andWhere('closing.closingDate BETWEEN :startDate AND :endDate', {
startDate: options.startDate,
endDate: options.endDate,
});
}
const page = options.page ?? 1;
const limit = options.limit ?? 20;
qb.skip((page - 1) * limit).take(limit);
qb.orderBy('closing.closingDate', 'DESC');
const [data, total] = await qb.getManyAndCount();
return { data, total };
}
/**
* Get closing with cash counts
*/
async getClosingWithCounts(
tenantId: string,
closingId: string
): Promise<CashClosing | null> {
return this.repository.findOne({
where: { id: closingId, tenantId },
relations: ['cashCounts'],
});
}
/**
* Get daily summary for branch
*/
async getDailySummary(
tenantId: string,
branchId: string,
date: Date
): Promise<{
closings: CashClosing[];
totalSales: number;
totalRefunds: number;
netSales: number;
totalCashIn: number;
totalCashOut: number;
expectedDeposit: number;
}> {
const startOfDay = new Date(date);
startOfDay.setHours(0, 0, 0, 0);
const endOfDay = new Date(date);
endOfDay.setHours(23, 59, 59, 999);
const closings = await this.repository.find({
where: {
tenantId,
branchId,
closingDate: Between(startOfDay, endOfDay),
status: ClosingStatus.APPROVED,
},
});
const summary = closings.reduce(
(acc, c) => ({
totalSales: acc.totalSales + Number(c.totalSales),
totalRefunds: acc.totalRefunds + Number(c.totalRefunds),
netSales: acc.netSales + Number(c.netSales),
totalCashIn: acc.totalCashIn + Number(c.cashInTotal),
totalCashOut: acc.totalCashOut + Number(c.cashOutTotal),
}),
{ totalSales: 0, totalRefunds: 0, netSales: 0, totalCashIn: 0, totalCashOut: 0 }
);
return {
closings,
...summary,
expectedDeposit: summary.totalCashIn - summary.totalCashOut,
};
}
}