Files
jorgecuadros-platform/apps/api/src/customers/customers.service.ts
T
rmancinasandClaude Opus 4.8 548eeb5798 feat(ledger,bank): append + void write API, voided excluded from totals (plan phase 5 API)
Transactions and the bank register become append-only with a void
(reversal) action — never edited or hard-deleted. This is the API half of
phase 5; the capture/void web UI is the remaining piece.

Schema:
- Transaction and BankTransaction gain voidedAt + voidedById. A non-null
  voidedAt reverses the row. Pushed to dev.

Correctness (the high-stakes part):
- Every aggregate excludes voided rows: billing movements totals, the raw
  balances SQL, stats (groupBy + the sides/crossLine raw subqueries +
  first/last), facets (types/sources/years); the statement's running
  balance freezes on a voided row and its per-currency/per-domain/per-type
  summaries skip them; customers.detail and property owner-ledger groupBy;
  and every bank total (totalsFor, stats counts/bounds, facets + summary
  raw SQL). List views still return voided rows with a `voided` flag so
  the UI can strike them through.
- Bank's legacy zero-amount "void" cheques are unchanged and distinct from
  app voids (voidedAt).

API:
- POST /billing + POST /billing/:id/void (ledger:create / ledger:void);
  POST /bank + POST /bank/:id/void (bank:create / bank:void). Create needs
  STAFF+, void needs MANAGER+. Double-void -> 400, unknown id -> 404,
  bad date -> 400. Mutations audited. DTOs added.

Verified against dev end-to-end: a -500 MXN charge moved a customer
balance 31082.08 -> 30582.08, and voiding it returned it to 31082.08 to
the cent; a +1234.56 bank ingreso moved net 899375.77 -> 900610.33 and
voiding returned it to 899375.77. VIEWER create/void both 403,
double-void 400. API compiles clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 12:34:47 -07:00

221 lines
7.0 KiB
TypeScript

import { 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";
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) {}
/** 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 } } } },
];
}
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,
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,
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) {
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: {
orderBy: { transactionDate: "desc" },
take: 100,
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.
const summary = await this.prisma.transaction.groupBy({
by: ["domain", "currency"],
// Exclude voided rows so the per-domain balance matches the statement.
where: { customerId: id, voidedAt: null },
_sum: { amount: true },
_count: { _all: true },
});
return {
...customer,
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) {
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 },
});
}
async update(id: string, dto: UpdateCustomerDto) {
await this.ensureExists(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) {
await this.ensureExists(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 },
});
if (!found) throw new NotFoundException(`Customer ${id} not found`);
}
/** 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 };
}
}