569 lines
16 KiB
TypeScript
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,
|
|
};
|
|
}
|
|
}
|