The 2026-10-08 ask: open an account without four screens — cliente, propiedad, servicios and the saldo inicial, taken as the first payment. POST /customers/onboard writes it all in one Prisma transaction; a bad service or a taken folder number leaves nothing behind (rolled back on dev). A cash opening balance goes through the EFE receipt book inside that same transaction, so it gets its folio and its C<folio> credit; a check posts a CHECK DEPOSIT against the number given. /clientes/alta pre-fills the next free folder number and links the receipt for printing. The old customer-only form stays as "Solo cliente". Built without the screen sketch from the meeting (it never arrived); the fields are the legacy ones. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
435 lines
16 KiB
TypeScript
435 lines
16 KiB
TypeScript
import {
|
|
BadRequestException,
|
|
ConflictException,
|
|
Injectable,
|
|
NotFoundException,
|
|
} from "@nestjs/common";
|
|
import { Prisma } from "@jorgecuadros/database";
|
|
import { PrismaService } from "../prisma/prisma.service";
|
|
import { CreateCustomerDto } from "./create-customer.dto";
|
|
import { UpdateCustomerDto } from "./update-customer.dto";
|
|
import type { OnboardCustomerDto } from "./onboard-customer.dto";
|
|
import { CashReceiptsService } from "../cash-receipts/cash-receipts.service";
|
|
import {
|
|
BALANCE_FORWARD_TYPE,
|
|
notCashJournal,
|
|
PERIOD_TABLE_PREFIX,
|
|
} from "../billing/billing.service";
|
|
|
|
/**
|
|
* Keeps an imported prior period out of the *current* period, NULL-safely.
|
|
*
|
|
* A closed year is imported as its own tagged copy (`datos2@2025`). Below the
|
|
* year start it is history and counts — for the one customer whose newest
|
|
* BALANCE FORWARD lives inside an archive it is the only carry there is, and
|
|
* dropping it understated NUMid 295 by his entire 2025 closing balance. At or
|
|
* above the year start it must go: the archives spill a couple of rows into the
|
|
* following January, and those already sit inside the next year's BALANCE
|
|
* FORWARD, which is the sum of the whole archive.
|
|
*
|
|
* Spelled as a positive OR because `NOT (col LIKE ... AND ...)` evaluates to
|
|
* NULL for an app-captured row (no legacySourceTable), dropping every one.
|
|
*/
|
|
const archiveIsHistory = (
|
|
yearStart: Date,
|
|
): Prisma.TransactionWhereInput => ({
|
|
OR: [
|
|
{ legacySourceTable: null },
|
|
{ legacySourceTable: { not: { startsWith: PERIOD_TABLE_PREFIX } } },
|
|
{ transactionDate: { lt: yearStart } },
|
|
],
|
|
});
|
|
|
|
export interface ListParams {
|
|
query?: string;
|
|
page: number;
|
|
pageSize: number;
|
|
line?: "utility" | "insurance" | "both";
|
|
includeArchived?: boolean;
|
|
}
|
|
|
|
/** Parse an optional ISO date string to a Date (or null to clear it). */
|
|
function toDate(v?: string): Date | null | undefined {
|
|
if (v === undefined) return undefined;
|
|
if (v === "" || v === null) return null;
|
|
const d = new Date(v);
|
|
return isNaN(d.getTime()) ? undefined : d;
|
|
}
|
|
|
|
@Injectable()
|
|
export class CustomersService {
|
|
constructor(
|
|
private readonly prisma: PrismaService,
|
|
private readonly cashReceipts: CashReceiptsService,
|
|
) {}
|
|
|
|
/** Unified customer list with search + business-line filter, paginated. */
|
|
async list({ query, page, pageSize, line, includeArchived }: ListParams) {
|
|
const where: Prisma.CustomerWhereInput = {};
|
|
|
|
if (!includeArchived) where.archivedAt = null;
|
|
|
|
if (query && query.trim()) {
|
|
const q = query.trim();
|
|
where.OR = [
|
|
{ name: { contains: q } },
|
|
{ email: { contains: q } },
|
|
{ phone: { contains: q } },
|
|
{ mobile: { contains: q } },
|
|
{ city: { contains: q } },
|
|
{ legacyRefs: { some: { legacyId: { contains: q } } } },
|
|
...(/^\d+$/.test(q) ? [{ folderNumber: Number(q) }] : []),
|
|
];
|
|
}
|
|
if (line === "utility") where.properties = { some: {} };
|
|
if (line === "insurance") where.policies = { some: {} };
|
|
if (line === "both") {
|
|
where.properties = { some: {} };
|
|
where.policies = { some: {} };
|
|
}
|
|
|
|
const [total, rows] = await this.prisma.$transaction([
|
|
this.prisma.customer.count({ where }),
|
|
this.prisma.customer.findMany({
|
|
where,
|
|
skip: (page - 1) * pageSize,
|
|
take: pageSize,
|
|
// Nameless records last: ordering by name alone floats every
|
|
// "(SIN NOMBRE)" to the top, since "(" sorts before every letter.
|
|
orderBy: [{ nameMissing: "asc" }, { name: "asc" }],
|
|
select: {
|
|
id: true,
|
|
name: true,
|
|
nameSource: true,
|
|
folderNumber: true,
|
|
city: true,
|
|
state: true,
|
|
email: true,
|
|
phone: true,
|
|
mobile: true,
|
|
status: true,
|
|
archivedAt: true,
|
|
_count: { select: { properties: true, policies: true, transactions: true } },
|
|
},
|
|
}),
|
|
]);
|
|
|
|
const items = rows.map((r) => ({
|
|
id: r.id,
|
|
name: r.name,
|
|
nameSource: r.nameSource,
|
|
folderNumber: r.folderNumber,
|
|
city: r.city,
|
|
state: r.state,
|
|
email: r.email,
|
|
phone: r.phone,
|
|
mobile: r.mobile,
|
|
status: r.status,
|
|
archived: r.archivedAt != null,
|
|
propertyCount: r._count.properties,
|
|
policyCount: r._count.policies,
|
|
transactionCount: r._count.transactions,
|
|
hasUtilities: r._count.properties > 0,
|
|
hasInsurance: r._count.policies > 0,
|
|
}));
|
|
|
|
return { items, total, page, pageSize, pageCount: Math.ceil(total / pageSize) };
|
|
}
|
|
|
|
/** Full unified customer view: identity + both business lines + ledger. */
|
|
async detail(id: string) {
|
|
// The movement list on the customer file is the same statement the office
|
|
// prints, so it follows the same rule as BillingService.statement: this
|
|
// calendar year, oldest-first. No `take` any more — the cap used to hide
|
|
// the end of a busy customer's year once the order flipped, and a single
|
|
// year is small (365 rows for the heaviest customer in the book).
|
|
const yearStart = new Date(Date.UTC(new Date().getUTCFullYear(), 0, 1));
|
|
|
|
const customer = await this.prisma.customer.findUnique({
|
|
where: { id },
|
|
include: {
|
|
legacyRefs: true,
|
|
properties: {
|
|
include: { services: true, trustAccount: true, documents: true },
|
|
},
|
|
policies: {
|
|
orderBy: { policyFrom: "desc" },
|
|
include: {
|
|
policyType: true,
|
|
insuranceProvider: true,
|
|
installments: { orderBy: { sequence: "asc" } },
|
|
vehicles: true,
|
|
insuredDrivers: true,
|
|
beneficiaries: true,
|
|
claims: true,
|
|
documents: true,
|
|
},
|
|
},
|
|
transactions: {
|
|
// Archives are excluded by tag, not by date. They are not cleanly
|
|
// bounded — datos2@2024 carries rows dated 2022, 2023, 2025 and one
|
|
// in 2026, datos2@2025 two more — so a date test alone would surface
|
|
// a closed year's rows in the current year's list, duplicating the
|
|
// live ledger's own copy of them for three customers.
|
|
// Archives are kept out by tag, not by date. They are not cleanly
|
|
// bounded — datos2@2025 carries rows dated into 2026 — so a date test
|
|
// alone would surface a closed year's rows in the current year's
|
|
// list. Nothing below yearStart reaches this list anyway, so the
|
|
// window rule reduces to a plain exclusion here.
|
|
where: {
|
|
transactionDate: { gte: yearStart },
|
|
...archiveIsHistory(yearStart),
|
|
},
|
|
orderBy: [{ transactionDate: "asc" }, { id: "asc" }],
|
|
include: { type: true },
|
|
},
|
|
},
|
|
});
|
|
|
|
if (!customer) {
|
|
throw new NotFoundException(`Customer ${id} not found`);
|
|
}
|
|
|
|
// Ledger totals per domain + currency (the "one statement across both
|
|
// business lines" payoff), computed in the DB rather than in JS.
|
|
//
|
|
// These have to answer the same question BillingService.statement answers,
|
|
// because this card is titled "Estado de cuenta" and links straight to it —
|
|
// two screens quoting one customer two different balances is worse than
|
|
// either number alone. So it takes the same three rules the statement uses:
|
|
// the balance floor, the cash-source exclusion, and dropping outstanding
|
|
// rows the office has not paid yet.
|
|
//
|
|
// Without the floor these were a raw lifetime sum, double-counting the
|
|
// pre-cutover history each BALANCE FORWARD row already absorbs. Importing
|
|
// prior periods made that visibly worse: for NUMid 501 the tiles read
|
|
// -7,119.29 before the archives landed and -15,270.59 after, against a true
|
|
// -10,715.29 — the difference being exactly the 2024 and 2025 closing
|
|
// balances, added a second time on top of the opening row that contains
|
|
// them.
|
|
const floor = await this.prisma.transaction.findFirst({
|
|
where: {
|
|
customerId: id,
|
|
voidedAt: null,
|
|
type: { nameEn: BALANCE_FORWARD_TYPE },
|
|
},
|
|
orderBy: { transactionDate: "desc" },
|
|
select: { transactionDate: true },
|
|
});
|
|
|
|
const summary = await this.prisma.transaction.groupBy({
|
|
by: ["domain", "currency"],
|
|
where: {
|
|
customerId: id,
|
|
voidedAt: null,
|
|
outstanding: false,
|
|
...(floor ? { transactionDate: { gte: floor.transactionDate } } : {}),
|
|
// The floor alone does not settle the archives: a customer floored by
|
|
// an archive clears it with every row of that archive, and the rows
|
|
// archives spill into the following January clear any floor.
|
|
AND: [archiveIsHistory(yearStart), notCashJournal()],
|
|
},
|
|
_sum: { amount: true },
|
|
_count: { _all: true },
|
|
});
|
|
|
|
return {
|
|
...customer,
|
|
/** Calendar year the movement list covers. */
|
|
transactionYear: yearStart.getUTCFullYear(),
|
|
transactionSummary: summary.map((s) => ({
|
|
domain: s.domain,
|
|
currency: s.currency,
|
|
total: s._sum.amount,
|
|
count: s._count._all,
|
|
})),
|
|
};
|
|
}
|
|
|
|
// --- writes ---------------------------------------------------------------
|
|
|
|
private toData(dto: CreateCustomerDto | UpdateCustomerDto) {
|
|
// Whitelisted by the DTO already; map the date strings to Date objects.
|
|
const { identificationExpiration, customerSince, ...rest } = dto;
|
|
return {
|
|
...rest,
|
|
...(identificationExpiration !== undefined && {
|
|
identificationExpiration: toDate(identificationExpiration),
|
|
}),
|
|
...(customerSince !== undefined && { customerSince: toDate(customerSince) }),
|
|
};
|
|
}
|
|
|
|
async create(dto: CreateCustomerDto) {
|
|
await this.assertFolderFree(dto.folderNumber);
|
|
return this.prisma.customer.create({
|
|
// App-created rows: nameMissing false (name is required), no legacy
|
|
// provenance — those columns stay null, marking a native record.
|
|
data: { ...this.toData(dto), name: dto.name, nameMissing: false },
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Open an account from one screen: customer, property, services and the
|
|
* opening balance taken as the first payment. One database transaction — a
|
|
* half-created account (a customer with no property, a payment with no
|
|
* customer) is worse than none, because the office would key it again.
|
|
*/
|
|
async onboard(dto: OnboardCustomerDto, userId: string) {
|
|
const pay = dto.openingPayment;
|
|
if (pay) {
|
|
if (!(pay.amount > 0)) throw new BadRequestException("El saldo inicial debe ser mayor a cero.");
|
|
if (pay.method === "CHECK" && !pay.checkNumber?.trim()) {
|
|
throw new BadRequestException("Falta el número de cheque del saldo inicial.");
|
|
}
|
|
}
|
|
if (dto.services?.length && !dto.property) {
|
|
throw new BadRequestException("Los servicios necesitan una propiedad.");
|
|
}
|
|
await this.assertFolderFree(dto.customer.folderNumber);
|
|
|
|
return this.prisma.$transaction(async (tx) => {
|
|
const customer = await tx.customer.create({
|
|
data: { ...this.toData(dto.customer), name: dto.customer.name, nameMissing: false },
|
|
});
|
|
|
|
const property = dto.property
|
|
? await tx.property.create({ data: { ...dto.property, customerId: customer.id } })
|
|
: null;
|
|
if (property && dto.services?.length) {
|
|
await tx.propertyService.createMany({
|
|
data: dto.services.map((s) => ({ ...s, propertyId: property.id })),
|
|
});
|
|
}
|
|
|
|
const concept = pay?.concept?.trim() || "SALDO INICIAL";
|
|
let receipt = null;
|
|
let payment = null;
|
|
if (pay?.method === "CASH") {
|
|
receipt = await this.cashReceipts.create(
|
|
{
|
|
customerId: customer.id,
|
|
receivedAt: pay.receivedAt,
|
|
amount: pay.amount,
|
|
currency: pay.currency,
|
|
exchangeRate: pay.exchangeRate,
|
|
concept,
|
|
},
|
|
userId,
|
|
tx,
|
|
);
|
|
} else if (pay?.method === "CHECK") {
|
|
const date = new Date(pay.receivedAt);
|
|
if (isNaN(date.getTime())) throw new BadRequestException("Fecha del saldo inicial inválida.");
|
|
const type = await tx.typeTransaction.findFirst({
|
|
where: { nameEn: "CHECK DEPOSIT" },
|
|
select: { id: true },
|
|
});
|
|
payment = await tx.transaction.create({
|
|
data: {
|
|
customerId: customer.id,
|
|
domain: "UTILITY",
|
|
typeId: type?.id,
|
|
transactionDate: date,
|
|
amount: new Prisma.Decimal(pay.amount.toFixed(2)),
|
|
currency: pay.currency,
|
|
checkNumber: pay.checkNumber!.trim(),
|
|
message: concept,
|
|
captureSource: "MANUAL",
|
|
},
|
|
});
|
|
}
|
|
|
|
return { customer, property, receipt, payment };
|
|
});
|
|
}
|
|
|
|
async update(id: string, dto: UpdateCustomerDto) {
|
|
const current = await this.ensureExists(id);
|
|
// An archived customer's number is history, not a claim on the shelf.
|
|
if (!current.archivedAt) await this.assertFolderFree(dto.folderNumber, id);
|
|
return this.prisma.customer.update({ where: { id }, data: this.toData(dto) });
|
|
}
|
|
|
|
/** Soft-delete: hide from default lists, keep the row + provenance. */
|
|
async archive(id: string) {
|
|
await this.ensureExists(id);
|
|
return this.prisma.customer.update({
|
|
where: { id },
|
|
data: { archivedAt: new Date() },
|
|
});
|
|
}
|
|
|
|
async restore(id: string) {
|
|
const current = await this.ensureExists(id);
|
|
// Its folder may have been handed to someone else while it was closed.
|
|
await this.assertFolderFree(current.folderNumber, id);
|
|
return this.prisma.customer.update({
|
|
where: { id },
|
|
data: { archivedAt: null },
|
|
});
|
|
}
|
|
|
|
private async ensureExists(id: string) {
|
|
const found = await this.prisma.customer.findUnique({
|
|
where: { id },
|
|
select: { id: true, archivedAt: true, folderNumber: true },
|
|
});
|
|
if (!found) throw new NotFoundException(`Customer ${id} not found`);
|
|
return found;
|
|
}
|
|
|
|
// --- folder numbers ---------------------------------------------------------
|
|
|
|
/**
|
|
* A folder number belongs to one open account at a time. Archived customers
|
|
* keep theirs as a record of which folder they used, without holding it.
|
|
*
|
|
* ponytail: check-then-write, not a constraint — two staff saving the same
|
|
* number in the same instant both win. A functional unique index on
|
|
* IF(archivedAt IS NULL, folderNumber, NULL) closes it, if Prisma ever models one.
|
|
*/
|
|
private async assertFolderFree(folderNumber: number | null | undefined, exceptId?: string) {
|
|
if (folderNumber == null) return;
|
|
const holder = await this.prisma.customer.findFirst({
|
|
where: { folderNumber, archivedAt: null, ...(exceptId ? { id: { not: exceptId } } : {}) },
|
|
select: { name: true },
|
|
});
|
|
if (holder) {
|
|
throw new ConflictException(`La carpeta ${folderNumber} ya la tiene ${holder.name}`);
|
|
}
|
|
}
|
|
|
|
/** Lowest folder number no open account holds — how the office reuses them. */
|
|
async nextFolderNumber(): Promise<number> {
|
|
const [row] = await this.prisma.$queryRaw<{ n: bigint | number }[]>`
|
|
SELECT MIN(c.n) AS n FROM (
|
|
SELECT 1 AS n
|
|
UNION SELECT folderNumber + 1 FROM customers
|
|
WHERE archivedAt IS NULL AND folderNumber IS NOT NULL
|
|
) c
|
|
WHERE c.n NOT IN (
|
|
SELECT folderNumber FROM customers
|
|
WHERE archivedAt IS NULL AND folderNumber IS NOT NULL
|
|
)`;
|
|
return Number(row.n);
|
|
}
|
|
|
|
/** Top-line counts for a dashboard header. */
|
|
async stats() {
|
|
const [customers, withUtilities, withInsurance, policies, properties, transactions] =
|
|
await this.prisma.$transaction([
|
|
this.prisma.customer.count(),
|
|
this.prisma.customer.count({ where: { properties: { some: {} } } }),
|
|
this.prisma.customer.count({ where: { policies: { some: {} } } }),
|
|
this.prisma.policy.count(),
|
|
this.prisma.property.count(),
|
|
this.prisma.transaction.count(),
|
|
]);
|
|
const bothLines = await this.prisma.customer.count({
|
|
where: { properties: { some: {} }, policies: { some: {} } },
|
|
});
|
|
return { customers, withUtilities, withInsurance, bothLines, policies, properties, transactions };
|
|
}
|
|
}
|