import { Injectable } from '@nestjs/common';
import { randomUUID } from 'node:crypto';
import { DatabaseService } from '../../../infrastructure/database/database.service';
import { DomainConflictError } from '../../../common/errors/domain-conflict.error';
import { OutboxService } from '../../../infrastructure/outbox/outbox.service';
import {
assertCashCloseInput,
assertCashCountInput,
assertCashMovementInput,
assertCashOpenInput,
assertCashSaleInput,
} from './cash-input-validation';
@Injectable()
export class CashService {
constructor(
private readonly database:DatabaseService,
private readonly outbox:OutboxService,
) {}
async openShift(input:{
organizationId:string;locationId:string;tillId:string;businessDate:string;
openingFloatMinor:number;currencyCode:string;userId?:string;
}) {
assertCashOpenInput(input);
return this.database.transaction(async(tx)=>{
const till=await tx.query<{id:string}>(
`select id from tills
where id=$1 and organization_id=$2 and location_id=$3 and status='active'
for update`,
[input.tillId,input.organizationId,input.locationId],
);
if(!till[0]) throw new DomainConflictError('cash.till_invalid');
const existing=await tx.query<{id:string}>(
`select id from cash_shifts
where organization_id=$1 and location_id=$2 and till_id=$3
and status in ('open','counting')
for update`,
[input.organizationId,input.locationId,input.tillId],
);
if(existing[0]) throw new DomainConflictError('cash.till_shift_already_open');
const id=randomUUID();
await tx.query(
`insert into cash_shifts(
id,organization_id,location_id,till_id,business_date,status,opened_by,
opening_float_minor,expected_cash_minor,currency_code
) values($1,$2,$3,$4,$5::date,'open',$6,$7,$7,$8)`,
[
id,input.organizationId,input.locationId,input.tillId,input.businessDate,
input.userId??null,input.openingFloatMinor,input.currencyCode,
],
);
if(input.openingFloatMinor>0) {
await tx.query(
`insert into cash_movements(
id,organization_id,location_id,cash_shift_id,movement_type,
amount_minor,currency_code,reason,created_by
) values($1,$2,$3,$4,'opening_float',$5,$6,'Opening float',$7)`,
[
randomUUID(),input.organizationId,input.locationId,id,
input.openingFloatMinor,input.currencyCode,input.userId??null,
],
);
}
await this.outbox.append({
organizationId:input.organizationId,aggregateType:'CashShift',aggregateId:id,
eventType:'CashShiftOpened',
payload:{cashShiftId:id,tillId:input.tillId,businessDate:input.businessDate,locationId:input.locationId},
},tx);
return {id,status:'open'};
},{lockTimeoutMs:3000,statementTimeoutMs:10000});
}
async addMovement(input:{
organizationId:string;locationId:string;cashShiftId:string;
movementType:'pay_in'|'pay_out'|'safe_drop'|'correction';
amountMinor:number;currencyCode:string;reason:string;userId?:string;
}) {
assertCashMovementInput(input);
return this.database.transaction(async(tx)=>{
const shift=await tx.query<{id:string;currency_code:string}>(
`select id,currency_code from cash_shifts
where id=$1 and organization_id=$2 and location_id=$3 and status='open'
for update`,
[input.cashShiftId,input.organizationId,input.locationId],
);
if(!shift[0]) throw new DomainConflictError('cash.shift_not_open');
if(shift[0].currency_code!==input.currencyCode) throw new DomainConflictError('cash.currency_mismatch');
const id=randomUUID();
const sign=input.movementType==='pay_in'||input.movementType==='correction'?1:-1;
const signed=BigInt(input.amountMinor)*BigInt(sign);
await tx.query(
`insert into cash_movements(
id,organization_id,location_id,cash_shift_id,movement_type,
amount_minor,currency_code,reason,created_by
) values($1,$2,$3,$4,$5,$6,$7,$8,$9)`,
[
id,input.organizationId,input.locationId,input.cashShiftId,input.movementType,
input.amountMinor,input.currencyCode,input.reason,input.userId??null,
],
);
await tx.query(
`update cash_shifts
set expected_cash_minor=expected_cash_minor+$1::bigint,version=version+1
where id=$2 and organization_id=$3 and location_id=$4`,
[signed.toString(),input.cashShiftId,input.organizationId,input.locationId],
);
await this.outbox.append({
organizationId:input.organizationId,aggregateType:'CashShift',aggregateId:input.cashShiftId,
eventType:'CashMovementRecorded',
payload:{cashShiftId:input.cashShiftId,cashMovementId:id,movementType:input.movementType,locationId:input.locationId},
},tx);
return {id};
},{lockTimeoutMs:3000,statementTimeoutMs:10000});
}
async recordCashSale(input:{
organizationId:string;locationId:string;cashShiftId:string;paymentId:string;
amountMinor:number;currencyCode:string;userId?:string;
}) {
assertCashSaleInput(input);
return this.database.transaction(async(tx)=>{
const shift=await tx.query<{id:string;currency_code:string}>(
`select id,currency_code from cash_shifts
where id=$1 and organization_id=$2 and location_id=$3 and status='open'
for update`,
[input.cashShiftId,input.organizationId,input.locationId],
);
if(!shift[0]) throw new DomainConflictError('cash.shift_not_open');
if(shift[0].currency_code!==input.currencyCode) throw new DomainConflictError('cash.currency_mismatch');
const exists=await tx.query<{id:string}>(
`select id from cash_movements
where organization_id=$1 and location_id=$2 and cash_shift_id=$3
and source_type='payment' and source_id=$4
limit 1`,
[input.organizationId,input.locationId,input.cashShiftId,input.paymentId],
);
if(exists[0]) return {id:exists[0].id,replay:true};
const id=randomUUID();
await tx.query(
`insert into cash_movements(
id,organization_id,location_id,cash_shift_id,movement_type,amount_minor,
currency_code,source_type,source_id,created_by
) values($1,$2,$3,$4,'cash_sale',$5,$6,'payment',$7,$8)`,
[
id,input.organizationId,input.locationId,input.cashShiftId,
input.amountMinor,input.currencyCode,input.paymentId,input.userId??null,
],
);
await tx.query(
`update cash_shifts
set expected_cash_minor=expected_cash_minor+$1::bigint,version=version+1
where id=$2 and organization_id=$3 and location_id=$4`,
[String(input.amountMinor),input.cashShiftId,input.organizationId,input.locationId],
);
return {id,replay:false};
},{lockTimeoutMs:3000,statementTimeoutMs:10000});
}
async count(input:{
organizationId:string;locationId:string;cashShiftId:string;totalMinor:number;currencyCode:string;
denominationCounts:Record<string,number>;userId?:string;
}) {
assertCashCountInput(input);
return this.database.transaction(async(tx)=>{
const shifts=await tx.query<any>(
`select * from cash_shifts
where id=$1 and organization_id=$2 and location_id=$3 and status in ('open','counting')
for update`,
[input.cashShiftId,input.organizationId,input.locationId],
);
if(!shifts[0]) throw new DomainConflictError('cash.shift_not_countable');
if(shifts[0].currency_code!==input.currencyCode) throw new DomainConflictError('cash.currency_mismatch');
const expected=BigInt(String(shifts[0].expected_cash_minor));
const total=BigInt(input.totalMinor);
const variance=total-expected;
const countId=randomUUID();
await tx.query(
`insert into cash_counts(
id,cash_shift_id,counted_by,denomination_counts,total_minor,currency_code
) values($1,$2,$3,$4::jsonb,$5,$6)`,
[
countId,input.cashShiftId,input.userId??null,
JSON.stringify(input.denominationCounts),total.toString(),input.currencyCode,
],
);
await tx.query(
`update cash_shifts
set status='counting',counted_cash_minor=$1::bigint,variance_minor=$2::bigint,version=version+1
where id=$3 and organization_id=$4 and location_id=$5`,
[total.toString(),variance.toString(),input.cashShiftId,input.organizationId,input.locationId],
);
return {countId,varianceMinor:variance.toString()};
},{lockTimeoutMs:3000,statementTimeoutMs:10000});
}
async close(input:{
organizationId:string;locationId:string;cashShiftId:string;notes?:string;userId?:string;
}) {
assertCashCloseInput(input);
return this.database.transaction(async(tx)=>{
const rows=await tx.query<any>(
`update cash_shifts
set status='closed',closed_by=$1,closed_at=now(),notes=$2,version=version+1
where id=$3 and organization_id=$4 and location_id=$5
and status='counting' and counted_cash_minor is not null
returning id,location_id,expected_cash_minor,counted_cash_minor,variance_minor`,
[input.userId??null,input.notes??null,input.cashShiftId,input.organizationId,input.locationId],
);
if(!rows[0]) throw new DomainConflictError('cash.close_requires_count');
await this.outbox.append({
organizationId:input.organizationId,aggregateType:'CashShift',aggregateId:input.cashShiftId,
eventType:'CashShiftClosed',
payload:{
cashShiftId:input.cashShiftId,
locationId:rows[0].location_id,
expectedCashMinor:String(rows[0].expected_cash_minor),
countedCashMinor:String(rows[0].counted_cash_minor),
varianceMinor:String(rows[0].variance_minor),
},
},tx);
return {
id:input.cashShiftId,status:'closed',
varianceMinor:String(rows[0].variance_minor),
};
},{lockTimeoutMs:3000,statementTimeoutMs:10000});
}
}
