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 { constructor( repository: Repository, private readonly countRepository: Repository, private readonly movementRepository: Repository, private readonly dataSource: DataSource ) { super(repository); } /** * Generate closing number */ private async generateClosingNumber( tenantId: string, branchId: string, type: ClosingType ): Promise { 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> { 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> { 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> { 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> { 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> { 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> { 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 { 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, }; } }