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>
158 lines
4.9 KiB
TypeScript
158 lines
4.9 KiB
TypeScript
import {
|
|
Body,
|
|
Controller,
|
|
Delete,
|
|
Get,
|
|
Param,
|
|
Patch,
|
|
Post,
|
|
Query,
|
|
Req,
|
|
UseGuards,
|
|
} from "@nestjs/common";
|
|
import { Request } from "express";
|
|
import { AuthenticatedGuard } from "../auth/authenticated.guard";
|
|
import { AbilityGuard } from "../auth/ability.guard";
|
|
import { RequireAbility } from "../auth/require-ability.decorator";
|
|
import { AuditService } from "../common/audit.service";
|
|
import { CustomersService } from "./customers.service";
|
|
import { NumidService } from "./numid.service";
|
|
import { CreateCustomerDto } from "./create-customer.dto";
|
|
import { UpdateCustomerDto } from "./update-customer.dto";
|
|
import { OnboardCustomerDto } from "./onboard-customer.dto";
|
|
|
|
@UseGuards(AuthenticatedGuard, AbilityGuard)
|
|
@Controller("customers")
|
|
export class CustomersController {
|
|
constructor(
|
|
private readonly customers: CustomersService,
|
|
private readonly numids: NumidService,
|
|
private readonly audit: AuditService,
|
|
) {}
|
|
|
|
private actingId(req: Request): string {
|
|
return (req.user as { id: string }).id;
|
|
}
|
|
|
|
@Get("stats")
|
|
stats() {
|
|
return this.customers.stats();
|
|
}
|
|
|
|
/** Reusable portal ids, lowest first. Declared above `:id` so the literal
|
|
* path is not swallowed by the wildcard route. */
|
|
@Get("numid/candidates")
|
|
async numidCandidates() {
|
|
return { candidates: await this.numids.emptyCandidates() };
|
|
}
|
|
|
|
/** Suggested folder number for a new customer. Above `:id` like the rest. */
|
|
@Get("folder/next")
|
|
async nextFolder() {
|
|
return { folderNumber: await this.customers.nextFolderNumber() };
|
|
}
|
|
|
|
@Get()
|
|
list(
|
|
@Query("query") query?: string,
|
|
@Query("page") page?: string,
|
|
@Query("pageSize") pageSize?: string,
|
|
@Query("line") line?: "utility" | "insurance" | "both",
|
|
@Query("includeArchived") includeArchived?: string,
|
|
) {
|
|
const p = Math.max(1, Number(page) || 1);
|
|
const ps = Math.min(100, Math.max(1, Number(pageSize) || 25));
|
|
return this.customers.list({
|
|
query,
|
|
page: p,
|
|
pageSize: ps,
|
|
line,
|
|
includeArchived: includeArchived === "true",
|
|
});
|
|
}
|
|
|
|
@Get(":id")
|
|
detail(@Param("id") id: string) {
|
|
return this.customers.detail(id);
|
|
}
|
|
|
|
@Post()
|
|
@RequireAbility("customer:create")
|
|
async create(@Body() dto: CreateCustomerDto, @Req() req: Request) {
|
|
const c = await this.customers.create(dto);
|
|
void this.audit.log(this.actingId(req), "customer.create", { customerId: c.id, name: c.name });
|
|
return c;
|
|
}
|
|
|
|
/**
|
|
* Alta en una pantalla. Gated on customer:create; the property, service and
|
|
* ledger abilities it also exercises sit at the same STAFF tier.
|
|
*/
|
|
@Post("onboard")
|
|
@RequireAbility("customer:create")
|
|
async onboard(@Body() dto: OnboardCustomerDto, @Req() req: Request) {
|
|
const r = await this.customers.onboard(dto, this.actingId(req));
|
|
void this.audit.log(this.actingId(req), "customer.onboard", {
|
|
customerId: r.customer.id,
|
|
name: r.customer.name,
|
|
folderNumber: r.customer.folderNumber,
|
|
propertyId: r.property?.id,
|
|
services: dto.services?.length ?? 0,
|
|
folio: r.receipt?.folio,
|
|
openingTransactionId: r.receipt?.transactionId ?? r.payment?.id,
|
|
});
|
|
return r;
|
|
}
|
|
|
|
@Patch(":id")
|
|
@RequireAbility("customer:update")
|
|
async update(
|
|
@Param("id") id: string,
|
|
@Body() dto: UpdateCustomerDto,
|
|
@Req() req: Request,
|
|
) {
|
|
const c = await this.customers.update(id, dto);
|
|
void this.audit.log(this.actingId(req), "customer.update", { customerId: id });
|
|
return c;
|
|
}
|
|
|
|
@Delete(":id")
|
|
@RequireAbility("customer:delete")
|
|
async archive(@Param("id") id: string, @Req() req: Request) {
|
|
const c = await this.customers.archive(id);
|
|
void this.audit.log(this.actingId(req), "customer.archive", { customerId: id });
|
|
return c;
|
|
}
|
|
|
|
@Post(":id/restore")
|
|
@RequireAbility("customer:delete")
|
|
async restore(@Param("id") id: string, @Req() req: Request) {
|
|
const c = await this.customers.restore(id);
|
|
void this.audit.log(this.actingId(req), "customer.restore", { customerId: id });
|
|
return c;
|
|
}
|
|
|
|
/**
|
|
* Give this customer a portal NUMid so they can log in to
|
|
* my.jorgecuadros.com. Idempotent — a customer who already has one gets it
|
|
* back rather than a second identity.
|
|
*/
|
|
@Post(":id/portal-access")
|
|
@RequireAbility("customer:portal-access")
|
|
async portalAccess(@Param("id") id: string, @Req() req: Request) {
|
|
const allocation = await this.numids.allocate(id);
|
|
if (allocation.origin !== "existing") {
|
|
// Logged with the origin and the previous holder: a recycled id is the one
|
|
// case where reading this record later has to answer "whose number was
|
|
// this before, and was it taken or minted".
|
|
void this.audit.log(this.actingId(req), "customer.portal-access", {
|
|
customerId: id,
|
|
numid: allocation.numid,
|
|
origin: allocation.origin,
|
|
previousCustomerId: allocation.previousCustomerId,
|
|
});
|
|
}
|
|
return allocation;
|
|
}
|
|
}
|