El bug de dinero más caro que he visto costó cuatro días de conciliación contable, y el código que lo produjo tenía este aspecto:
const total = precioProducto + costeEnvio; // uno en PEN, otro en USD
Compila. No lanza. Devuelve un número perfectamente creíble. Y está mal.
Un importe sin moneda no es dinero
La moneda no es un adorno del importe: forma parte de lo que el valor es. Modelarlos juntos convierte el error de arriba en algo que no se puede escribir:
interface IMoneyProps {
amount: number;
currency: string;
}
export class Money extends DddValueObject<IMoneyProps> {
static create(amount: number, currency = \'PEN\'): Money {
const money = new Money({ amount, currency });
if (!money.isValid) {
throw new BrokenRulesException(\'Money\', money.brokenRules.getBrokenRules());
}
return money;
}
add(other: Money): Money {
if (other.props.currency !== this.props.currency) {
throw new InvalidOperationException(
`No se puede sumar ${other.props.currency} a ${this.props.currency}`,
);
}
return Money.create(this.props.amount + other.props.amount, this.props.currency);
}
}
Ahora precio.add(envio) lanza en el momento en que las monedas difieren, en lugar de producir un número sin sentido que aparece en un informe tres semanas después.
Devuelve uno nuevo, no mutes
add() devuelve un Money nuevo. Los value objects son inmutables, y aquí importa más que en otros sitios: si mutaras el importe de un objeto compartido, cambiarías el precio bajo los pies de quien lo tuviera guardado en una variable.
Los decimales
0.1 + 0.2 !== 0.3 también en tu aplicación de facturación. Hay dos caminos y conviene elegir a propósito:
| Enfoque | Cuándo | Coste |
|---|---|---|
| Enteros en la unidad mínima (céntimos) | Contabilidad, pagos, cualquier cosa que se concilie | Hay que convertir al entrar y al salir |
| Decimal con validación de escala | Catálogos, precios de escaparate | Sigue habiendo error de coma flotante en operaciones largas |
Si tu dinero pasa por una conciliación, usa enteros. Si sólo se muestra, un decimal con la escala validada basta — pero valida la escala:
if (Math.round(amount * 100) !== amount * 100) {
this.addBrokenRule(\'amount\', \'Como máximo dos decimales\');
}
Formatear no es responsabilidad del valor
Money guarda el importe y la moneda. Cómo se ve —con símbolo, con código, en formato contable, en palabras— es una decisión de presentación, y cambia según dónde se muestre. Sepáralo:
const f = new MoneyFormatter();
f.format(precio); // \'S/ 1,999.00\'
f.formatWithCode(precio); // \'1,999.00 PEN\'
f.formatAccounting(deuda); // \'(1,999.00)\'
f.formatCompact(precio); // \'S/ 2.0K\'
Un value object que sabe formatearse a sí mismo acaba con un método por cada sitio donde se muestra, y todos viven en la capa de dominio sin pertenecer a ella.
Ya está hecho
Money con sus seis formateadores y validación ISO 4217 está en @nestjslatam/ddd-valueobjects, junto con otros once. Si el tuyo va a acabar pareciéndose, empieza por ahí.
