feat(clientes): alta de cliente in one screen, opening balance as a payment
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>
This commit is contained in:
1 parent
ca76f4d57a
commit
6468811955
11 files changed
+568
-8
No files matched your search
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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(
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user