diff --git a/apps/api/src/customers/customer-folder.spec.ts b/apps/api/src/customers/customer-folder.spec.ts index 0c62b68..862aa6d 100644 --- a/apps/api/src/customers/customer-folder.spec.ts +++ b/apps/api/src/customers/customer-folder.spec.ts @@ -20,7 +20,7 @@ function build(opts: { holder?: { name: string } | null; archived?: boolean; fol update: jest.fn().mockImplementation(({ data }) => Promise.resolve(data)), }, }; - return { service: new CustomersService(prisma as never), prisma }; + return { service: new CustomersService(prisma as never, {} as never), prisma }; } describe("customer folder numbers", () => { diff --git a/apps/api/src/customers/customer-ledger-card.spec.ts b/apps/api/src/customers/customer-ledger-card.spec.ts index 1e5579e..fae328e 100644 --- a/apps/api/src/customers/customer-ledger-card.spec.ts +++ b/apps/api/src/customers/customer-ledger-card.spec.ts @@ -26,7 +26,7 @@ describe("customer file ledger card", () => { }, }; return { - service: new CustomersService(prisma as never), + service: new CustomersService(prisma as never, {} as never), prisma, groupBy, }; diff --git a/apps/api/src/customers/customer-onboard.spec.ts b/apps/api/src/customers/customer-onboard.spec.ts new file mode 100644 index 0000000..8fca180 --- /dev/null +++ b/apps/api/src/customers/customer-onboard.spec.ts @@ -0,0 +1,68 @@ +import { BadRequestException } from "@nestjs/common"; +import { CustomersService } from "./customers.service"; + +/** + * Onboarding writes four tables in one transaction; what is checked here is + * that a request it can't complete is refused before anything is written, and + * that a cash opening balance goes through the receipt book inside that same + * transaction. The rollback itself was exercised against the dev database. + */ + +function build() { + const tx = { + customer: { create: jest.fn().mockResolvedValue({ id: "c1" }) }, + property: { create: jest.fn().mockResolvedValue({ id: "p1" }) }, + propertyService: { createMany: jest.fn() }, + typeTransaction: { findFirst: jest.fn() }, + transaction: { create: jest.fn() }, + }; + const prisma = { + customer: { findFirst: jest.fn().mockResolvedValue(null) }, + $transaction: jest.fn((fn) => fn(tx)), + }; + const cash = { create: jest.fn().mockResolvedValue({ folio: 13935 }) }; + return { service: new CustomersService(prisma as never, cash as never), prisma, tx, cash }; +} + +const pay = { amount: 500, currency: "MXN" as const, receivedAt: "2026-10-12" }; + +describe("customer onboarding", () => { + it("refuses a check opening balance with no check number, writing nothing", async () => { + const { service, prisma } = build(); + await expect( + service.onboard({ customer: { name: "X" }, openingPayment: { ...pay, method: "CHECK" } }, "u1"), + ).rejects.toBeInstanceOf(BadRequestException); + expect(prisma.$transaction).not.toHaveBeenCalled(); + }); + + it("refuses services with no property to hang them on", async () => { + const { service, prisma } = build(); + await expect( + service.onboard({ customer: { name: "X" }, services: [{ kind: "WATER" }] }, "u1"), + ).rejects.toBeInstanceOf(BadRequestException); + expect(prisma.$transaction).not.toHaveBeenCalled(); + }); + + it("issues the cash receipt inside the onboarding transaction", async () => { + const { service, tx, cash } = build(); + const r = await service.onboard( + { + customer: { name: "X" }, + property: { addressLine1: "Calle 1" }, + services: [{ kind: "WATER", accountNumber: "3014057" }], + openingPayment: { ...pay, method: "CASH" }, + }, + "u1", + ); + + expect(cash.create).toHaveBeenCalledWith( + expect.objectContaining({ customerId: "c1", amount: 500, concept: "SALDO INICIAL" }), + "u1", + tx, + ); + expect(tx.propertyService.createMany).toHaveBeenCalledWith({ + data: [{ kind: "WATER", accountNumber: "3014057", propertyId: "p1" }], + }); + expect(r.receipt).toEqual({ folio: 13935 }); + }); +}); diff --git a/apps/api/src/customers/customers.controller.ts b/apps/api/src/customers/customers.controller.ts index 38b8670..0caabec 100644 --- a/apps/api/src/customers/customers.controller.ts +++ b/apps/api/src/customers/customers.controller.ts @@ -19,6 +19,7 @@ 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") @@ -83,6 +84,26 @@ export class CustomersController { 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( diff --git a/apps/api/src/customers/customers.module.ts b/apps/api/src/customers/customers.module.ts index 3729e47..e829217 100644 --- a/apps/api/src/customers/customers.module.ts +++ b/apps/api/src/customers/customers.module.ts @@ -1,11 +1,12 @@ import { Module } from "@nestjs/common"; import { SettingsModule } from "../settings/settings.module"; +import { CashReceiptsModule } from "../cash-receipts/cash-receipts.module"; import { CustomersController } from "./customers.controller"; import { CustomersService } from "./customers.service"; import { NumidService } from "./numid.service"; @Module({ - imports: [SettingsModule], + imports: [SettingsModule, CashReceiptsModule], controllers: [CustomersController], providers: [CustomersService, NumidService], exports: [NumidService], diff --git a/apps/api/src/customers/customers.service.ts b/apps/api/src/customers/customers.service.ts index 4880ec4..dfaa952 100644 --- a/apps/api/src/customers/customers.service.ts +++ b/apps/api/src/customers/customers.service.ts @@ -1,8 +1,15 @@ -import { ConflictException, Injectable, NotFoundException } from "@nestjs/common"; +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, @@ -51,7 +58,10 @@ function toDate(v?: string): Date | null | undefined { @Injectable() export class CustomersService { - constructor(private readonly prisma: PrismaService) {} + 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) { @@ -259,6 +269,81 @@ export class CustomersService { }); } + /** + * 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. diff --git a/apps/api/src/customers/onboard-customer.dto.ts b/apps/api/src/customers/onboard-customer.dto.ts new file mode 100644 index 0000000..535ca9b --- /dev/null +++ b/apps/api/src/customers/onboard-customer.dto.ts @@ -0,0 +1,56 @@ +import { + ArrayMaxSize, + IsArray, + IsEnum, + IsIn, + IsNumber, + IsOptional, + IsString, + MinLength, + ValidateNested, +} from "class-validator"; +import { Type } from "class-transformer"; +import { Currency } from "@jorgecuadros/database"; +import { CreateCustomerDto } from "./create-customer.dto"; +import { ServiceDto } from "../properties/property.dto"; + +/** The property, minus the customer it is being created for. */ +export class OnboardPropertyDto { + @IsOptional() @IsString() addressLine1?: string; + @IsOptional() @IsString() addressLine2?: string; + @IsOptional() @IsString() phone1?: string; +} + +/** + * The opening balance the office takes when an account is opened, captured as + * the customer's first payment. Cash issues an EFE receipt; a check posts a + * CHECK DEPOSIT against that check number. + */ +export class OpeningPaymentDto { + @IsIn(["CASH", "CHECK"]) method!: "CASH" | "CHECK"; + @IsNumber() amount!: number; + @IsEnum(Currency) currency!: Currency; + @IsString() @MinLength(1) receivedAt!: string; + @IsOptional() @IsString() checkNumber?: string; + /** Cash only: dollars credited to a pesos ledger. */ + @IsOptional() @IsNumber() exchangeRate?: number; + @IsOptional() @IsString() concept?: string; +} + +/** One screen: cliente + propiedad + servicios + saldo inicial. */ +export class OnboardCustomerDto { + @ValidateNested() @Type(() => CreateCustomerDto) customer!: CreateCustomerDto; + + @IsOptional() @ValidateNested() @Type(() => OnboardPropertyDto) + property?: OnboardPropertyDto; + + @IsOptional() + @IsArray() + @ArrayMaxSize(12) + @ValidateNested({ each: true }) + @Type(() => ServiceDto) + services?: ServiceDto[]; + + @IsOptional() @ValidateNested() @Type(() => OpeningPaymentDto) + openingPayment?: OpeningPaymentDto; +} diff --git a/apps/web/src/app/clientes/alta/page.tsx b/apps/web/src/app/clientes/alta/page.tsx new file mode 100644 index 0000000..34388eb --- /dev/null +++ b/apps/web/src/app/clientes/alta/page.tsx @@ -0,0 +1,291 @@ +"use client"; + +import Link from "next/link"; +import { useEffect, useState } from "react"; +import { AppShell } from "@/components/AppShell"; +import { getNextFolderNumber, onboardCustomer } from "@/lib/api"; +import { useCan } from "@/lib/abilities"; +import { formatMoney, SERVICE_KIND_LABELS } from "@/lib/labels"; +import type { Currency, OnboardInput, OnboardResult, ServiceKind } from "@/lib/types"; + +/** + * Alta de cliente in one screen (2026-10-08 meeting): the customer, their + * property, its services and the opening balance taken as the first payment, + * saved together or not at all. Cash issues an EFE receipt with its folio. + */ +export default function AltaClientePage() { + return ( + + + + ); +} + +type ServiceRow = { kind: ServiceKind; accountNumber: string; meterNumber: string }; + +const today = () => new Date().toISOString().slice(0, 10); +const t = (v: string) => v.trim() || undefined; + +function Alta() { + const allowed = useCan("customer:create"); + + const [folder, setFolder] = useState(""); + const [name, setName] = useState(""); + const [email, setEmail] = useState(""); + const [phone, setPhone] = useState(""); + const [mobile, setMobile] = useState(""); + const [preferredCurrency, setPreferredCurrency] = useState("USD"); + + const [address1, setAddress1] = useState(""); + const [address2, setAddress2] = useState(""); + const [propertyPhone, setPropertyPhone] = useState(""); + const [services, setServices] = useState([]); + + const [payAmount, setPayAmount] = useState(""); + const [payMethod, setPayMethod] = useState<"CASH" | "CHECK">("CASH"); + const [payCurrency, setPayCurrency] = useState("MXN"); + const [payDate, setPayDate] = useState(today); + const [checkNumber, setCheckNumber] = useState(""); + const [exchangeRate, setExchangeRate] = useState(""); + const [concept, setConcept] = useState(""); + + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [done, setDone] = useState(null); + + useEffect(() => { + getNextFolderNumber() + .then((r) => setFolder((prev) => prev || String(r.folderNumber))) + .catch(() => {}); + }, []); + + const hasProperty = !!(address1.trim() || address2.trim() || propertyPhone.trim() || services.length); + const paying = Number(payAmount) > 0; + + function setService(i: number, patch: Partial) { + setServices((prev) => prev.map((s, j) => (j === i ? { ...s, ...patch } : s))); + } + + async function submit(e: React.FormEvent) { + e.preventDefault(); + setBusy(true); + setError(null); + const input: OnboardInput = { + customer: { + name: name.trim(), + folderNumber: folder ? Number(folder) : undefined, + email: t(email), + phone: t(phone), + mobile: t(mobile), + preferredCurrency, + }, + ...(hasProperty && { + property: { addressLine1: t(address1), addressLine2: t(address2), phone1: t(propertyPhone) }, + services: services.map((s) => ({ + kind: s.kind, + accountNumber: t(s.accountNumber), + meterNumber: t(s.meterNumber), + })), + }), + ...(paying && { + openingPayment: { + method: payMethod, + amount: Number(payAmount), + currency: payCurrency, + receivedAt: payDate, + checkNumber: payMethod === "CHECK" ? t(checkNumber) : undefined, + exchangeRate: + payMethod === "CASH" && payCurrency === "USD" && exchangeRate + ? Number(exchangeRate) + : undefined, + concept: t(concept), + }, + }), + }; + try { + setDone(await onboardCustomer(input)); + } catch (e2) { + setError((e2 as Error)?.message ?? "No se pudo dar de alta al cliente."); + } finally { + setBusy(false); + } + } + + if (!allowed) { + return
No tiene permisos para crear clientes.
; + } + + if (done) { + return ( +
+

Cliente dado de alta

+
+

+ {done.customer.name} + {done.customer.folderNumber != null && ` · Carpeta ${done.customer.folderNumber}`} +

+ {done.receipt && ( +

+ Saldo inicial recibido en efectivo, folio {done.receipt.folio}.{" "} + + Imprimir recibo + +

+ )} + {done.payment &&

Saldo inicial registrado con el cheque {checkNumber}.

} +
+ + Ver cliente + + +
+
+
+ ); + } + + return ( +
+
+ ← Clientes +

Alta de cliente

+
+ {error &&
{error}
} + +
+

Cliente

+
+ + setFolder(e.target.value)} /> + + + setName(e.target.value)} /> + + + setEmail(e.target.value)} /> + + + setPhone(e.target.value)} /> + + + setMobile(e.target.value)} /> + + + + +
+
+ +
+

Propiedad y servicios

+
+ + setAddress1(e.target.value)} /> + + + setAddress2(e.target.value)} /> + + + setPropertyPhone(e.target.value)} /> + +
+ {services.map((s, i) => ( +
+ + setService(i, { accountNumber: e.target.value })} /> + setService(i, { meterNumber: e.target.value })} /> + +
+ ))} + +
+ +
+

Saldo inicial (pago)

+
+ + setPayAmount(e.target.value)} /> + + + + + + + + + setPayDate(e.target.value)} /> + + {payMethod === "CHECK" && ( + + setCheckNumber(e.target.value)} /> + + )} + {payMethod === "CASH" && payCurrency === "USD" && ( + + setExchangeRate(e.target.value)} /> + + )} + + setConcept(e.target.value)} /> + +
+ {paying && ( +

+ Se abonará {formatMoney(payAmount, payCurrency)} a la cuenta del cliente + {payMethod === "CASH" ? " y se emitirá el recibo de efectivo con el siguiente folio." : "."} +

+ )} +
+ +
+ + Cancelar +
+
+ ); +} + +function Field({ label, children }: { label: string; children: React.ReactNode }) { + return ( + + ); +} diff --git a/apps/web/src/app/clientes/page.tsx b/apps/web/src/app/clientes/page.tsx index eacda3c..ea72387 100644 --- a/apps/web/src/app/clientes/page.tsx +++ b/apps/web/src/app/clientes/page.tsx @@ -107,9 +107,14 @@ function ClientesBrowser() { ]} /> {canCreate && ( - - + Nuevo cliente - + <> + + + Solo cliente + + + + Alta de cliente + + )} diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index ba1e6b4..b7cf3af 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -84,6 +84,8 @@ import type { CashReceipt, CashReceiptInput, CompanyHeader, + OnboardInput, + OnboardResult, } from "./types"; // Resolve the API origin at runtime, not build time — so one built image serves @@ -228,6 +230,14 @@ export function getCustomer(id: string): Promise { return apiFetch(`/customers/${id}`); } +/** Open an account in one step; see OnboardInput. */ +export function onboardCustomer(input: OnboardInput): Promise { + return apiFetch("/customers/onboard", { + method: "POST", + body: JSON.stringify(input), + }); +} + export function createCustomer(input: CustomerInput): Promise { return apiFetch("/customers", { method: "POST", diff --git a/apps/web/src/lib/types.ts b/apps/web/src/lib/types.ts index 1aa3bc0..e6e75a6 100644 --- a/apps/web/src/lib/types.ts +++ b/apps/web/src/lib/types.ts @@ -1723,3 +1723,26 @@ export interface CashReceiptInput { concept: string; depositedAt?: string; } + +/** One-screen account opening: cliente + propiedad + servicios + saldo inicial. */ +export interface OnboardInput { + customer: CustomerInput; + property?: { addressLine1?: string; addressLine2?: string; phone1?: string }; + services?: { kind: ServiceKind; accountNumber?: string; meterNumber?: string }[]; + openingPayment?: { + method: "CASH" | "CHECK"; + amount: number; + currency: Currency; + receivedAt: string; + checkNumber?: string; + exchangeRate?: number; + concept?: string; + }; +} + +export interface OnboardResult { + customer: { id: string; name: string; folderNumber: number | null }; + property: { id: string } | null; + receipt: { folio: number } | null; + payment: { id: string } | null; +}