feat: add partial payment to match the warehouse invoice before migration

This commit is contained in:
Nathnael
2026-06-30 10:50:43 +00:00
parent 31a2e93c49
commit 21cf24950d
5 changed files with 309 additions and 5 deletions

View File

@@ -0,0 +1,71 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Extend `freight.invoices` into the billing record of record for every source
* (booking, demurrage, warehouse fees, …) so warehouse fee invoices can be
* centralized onto it instead of the parallel `warehouse_fee_invoices` table.
*
* Adds money tracking that supports partial payment (`subtotal/tax/paid/balance`),
* a `paid_at` stamp, a `payments` jsonb ledger, and the `ISSUED` / `PARTIALLY_PAID`
* statuses the warehouse flow uses.
*
* Matches billing/entities/invoice.entity.ts. All columns are additive with
* defaults, so existing booking/demurrage rows are unaffected.
*/
export class ExtendInvoicesForPartialPayment1828000000000
implements MigrationInterface
{
name = "ExtendInvoicesForPartialPayment1828000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
// New statuses. ADD VALUE is non-transactional-value-safe on PG 12+ as long
// as the value is not referenced in the same transaction (it is not here).
await queryRunner.query(
`ALTER TYPE freight.invoices_status_enum ADD VALUE IF NOT EXISTS 'ISSUED' BEFORE 'PENDING';`,
);
await queryRunner.query(
`ALTER TYPE freight.invoices_status_enum ADD VALUE IF NOT EXISTS 'PARTIALLY_PAID' BEFORE 'PAID';`,
);
await queryRunner.query(`
ALTER TABLE freight.invoices
ADD COLUMN IF NOT EXISTS subtotal_amount numeric(14, 2) NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS tax_amount numeric(14, 2) NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS paid_amount numeric(14, 2) NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS balance_amount numeric(14, 2) NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS paid_at timestamptz,
ADD COLUMN IF NOT EXISTS payments jsonb NOT NULL DEFAULT '[]';
`);
// Backfill existing rows: subtotal mirrors the total (no tax was modeled),
// the outstanding balance is the full total for unpaid invoices.
await queryRunner.query(`
UPDATE freight.invoices
SET subtotal_amount = total_amount,
balance_amount = total_amount;
`);
// Already-settled invoices: fully paid, zero balance, stamped from updated_at.
await queryRunner.query(`
UPDATE freight.invoices
SET paid_amount = total_amount,
balance_amount = 0,
paid_at = updated_at
WHERE status = 'PAID';
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.invoices
DROP COLUMN IF EXISTS payments,
DROP COLUMN IF EXISTS paid_at,
DROP COLUMN IF EXISTS balance_amount,
DROP COLUMN IF EXISTS paid_amount,
DROP COLUMN IF EXISTS tax_amount,
DROP COLUMN IF EXISTS subtotal_amount;
`);
// Postgres cannot drop individual enum values; ISSUED / PARTIALLY_PAID are
// left on freight.invoices_status_enum (harmless, unused after down).
}
}

View File

@@ -180,6 +180,88 @@ describe("BillingService.markInvoiceAsPaid", () => {
});
});
describe("BillingService.recordPayment", () => {
function serviceFor(invoice: Record<string, unknown> | null) {
const mg = {
findOne: jest.fn().mockResolvedValue(invoice),
update: jest.fn().mockResolvedValue(undefined),
};
const events = makeEvents();
const service = new BillingService(
{ manager: mg } as never,
{} as never,
{} as never,
events as never,
{} as never, // payment
{} as never, // companies
);
return { service, mg, events };
}
const openInvoice = (overrides: Record<string, unknown> = {}) => ({
id: "inv-1",
status: Freight.InvoiceStatus.Issued,
source: "warehouse",
sourceId: "inv-item-1",
totalAmount: 1000,
paidAmount: 0,
balanceAmount: 1000,
payments: [],
paidAt: null,
...overrides,
});
it("moves to PARTIALLY_PAID and emits no event on a partial payment", async () => {
const { service, mg, events } = serviceFor(openInvoice());
const updated = await service.recordPayment("inv-1", { amount: 400, method: "CASH" });
expect(updated.status).toBe(Freight.InvoiceStatus.PartiallyPaid);
expect(updated.paidAmount).toBe(400);
expect(updated.balanceAmount).toBe(600);
expect(updated.payments).toHaveLength(1);
expect(mg.update).toHaveBeenCalledWith(
expect.anything(),
{ id: "inv-1" },
expect.objectContaining({
status: Freight.InvoiceStatus.PartiallyPaid,
paidAmount: 400,
balanceAmount: 600,
}),
);
expect(events.emit).not.toHaveBeenCalled();
});
it("settles to PAID, stamps paidAt, and emits ${source}.invoice.paid when the balance clears", async () => {
const { service, mg, events } = serviceFor(openInvoice({ paidAmount: 400, balanceAmount: 600 }));
const updated = await service.recordPayment("inv-1", { amount: 600 });
expect(updated.status).toBe(Freight.InvoiceStatus.Paid);
expect(updated.balanceAmount).toBe(0);
expect(updated.paidAt).toBeInstanceOf(Date);
expect(mg.update).toHaveBeenCalled();
expect(events.emit).toHaveBeenCalledWith(
"warehouse.invoice.paid",
expect.objectContaining({ invoiceId: "inv-1", status: Freight.InvoiceStatus.Paid }),
);
});
it("rejects a non-positive amount", async () => {
const { service, mg } = serviceFor(openInvoice());
await expect(service.recordPayment("inv-1", { amount: 0 })).rejects.toThrow();
expect(mg.update).not.toHaveBeenCalled();
});
it("rejects payment against a cancelled invoice", async () => {
const { service, mg } = serviceFor(
openInvoice({ status: Freight.InvoiceStatus.Cancelled }),
);
await expect(service.recordPayment("inv-1", { amount: 100 })).rejects.toThrow();
expect(mg.update).not.toHaveBeenCalled();
});
});
describe("BillingService.settlePayable", () => {
it("settles the source's open invoice PAID and emits ${source}.invoice.paid", async () => {
const open = {

View File

@@ -1,9 +1,16 @@
import { forwardRef, Inject, Injectable, Logger, NotFoundException } from "@nestjs/common";
import {
BadRequestException,
forwardRef,
Inject,
Injectable,
Logger,
NotFoundException,
} from "@nestjs/common";
import { EventEmitter2 } from "@nestjs/event-emitter";
import { Freight, PaymentReferenceType } from "@edr/types";
import { DataSource, EntityManager, In } from "typeorm";
import { Invoice } from "./entities/invoice.entity";
import { Invoice, InvoicePayment } from "./entities/invoice.entity";
import { InvoiceLine } from "./entities/invoice-line.entity";
import { InvoiceRepository } from "./invoice.repository";
import { InvoiceLineRepository } from "./invoice-line.repository";
@@ -20,16 +27,32 @@ export interface PayInvoiceOptions {
failureUrl?: string;
}
/** A single manual/offline settlement to record against an invoice. */
export interface RecordPaymentInput {
/** Amount settled by this payment; must be > 0. */
amount: number;
method?: string | null;
reference?: string | null;
/** When the settlement occurred; defaults to now. */
paidAt?: Date;
metadata?: Record<string, unknown> | null;
}
/** Default invoice payment-term window, in days, used to compute `dueAt`. */
const DEFAULT_DUE_DAYS = 14;
/** Statuses an invoice can still be settled (paid/refunded/cancelled) from. */
const OPEN_STATUSES: Freight.InvoiceStatus[] = [
Freight.InvoiceStatus.Draft,
Freight.InvoiceStatus.Issued,
Freight.InvoiceStatus.Pending,
Freight.InvoiceStatus.PartiallyPaid,
Freight.InvoiceStatus.Overdue,
];
/** Round to 2 decimals, avoiding binary float drift. */
const round2 = (n: number): number => Math.round(n * 100) / 100;
/** A single line to bill on a generated invoice. */
export interface InvoiceLineInput {
chargeType: string;
@@ -56,7 +79,11 @@ export interface GenerateInvoiceInput {
companyProfileId: string;
lines: InvoiceLineInput[];
currency?: string;
/** Explicit total; defaults to the sum of line amounts. */
/** Explicit pre-tax subtotal; defaults to the sum of line amounts. */
subtotalAmount?: number;
/** Tax applied on top of the subtotal; defaults to 0. */
taxAmount?: number;
/** Explicit total; defaults to `subtotalAmount + taxAmount`. */
totalAmount?: number;
/** Issue date window; defaults to `DEFAULT_DUE_DAYS` from now. */
dueAt?: Date;
@@ -230,8 +257,12 @@ export class BillingService {
};
});
const subtotalAmount =
input.subtotalAmount ??
lines.reduce((sum, l) => sum + Number(l.amount), 0);
const taxAmount = input.taxAmount ?? 0;
const totalAmount =
input.totalAmount ?? lines.reduce((sum, l) => sum + Number(l.amount), 0);
input.totalAmount ?? round2(subtotalAmount + taxAmount);
const dueAt =
input.dueAt ??
@@ -250,7 +281,12 @@ export class BillingService {
type: input.type,
companyId: input.companyId,
companyProfileId: input.companyProfileId,
totalAmount,
subtotalAmount: round2(subtotalAmount),
taxAmount: round2(taxAmount),
totalAmount: round2(totalAmount),
paidAmount: 0,
balanceAmount: round2(totalAmount),
payments: [],
currency,
status,
issuedAt: issued ? new Date() : null,
@@ -293,6 +329,84 @@ export class BillingService {
);
}
/**
* Record a (possibly partial) settlement against an invoice and sync its
* status. Appends to the `payments` ledger, recomputes `paidAmount` /
* `balanceAmount`, and moves the invoice to PARTIALLY_PAID or — once the
* balance reaches zero — PAID, stamping `paidAt` and emitting
* `${source}.invoice.paid`. Use this for manual/offline settlement (e.g. cash
* at the warehouse counter); gateway settlement goes through
* {@link markInvoiceAsPaid}.
*
* Throws when the invoice is missing, cancelled, refunded, already fully paid,
* or when `amount` is not positive. Pass `manager` to enlist in a caller's
* transaction.
*/
async recordPayment(
invoiceId: string,
input: RecordPaymentInput,
manager?: EntityManager,
): Promise<Invoice> {
if (!(input.amount > 0)) {
throw new BadRequestException("Payment amount must be greater than zero.");
}
const mg = manager ?? this.dataSource.manager;
const invoice = await mg.findOne(Invoice, { where: { id: invoiceId } });
if (!invoice) throw new NotFoundException(`Invoice ${invoiceId} not found`);
if (invoice.status === Freight.InvoiceStatus.Cancelled) {
throw new BadRequestException("Cannot pay a cancelled invoice.");
}
if (invoice.status === Freight.InvoiceStatus.Refunded) {
throw new BadRequestException("Cannot pay a refunded invoice.");
}
if (invoice.status === Freight.InvoiceStatus.Paid) {
throw new BadRequestException("Invoice is already fully paid.");
}
const at = input.paidAt ?? new Date();
const total = Number(invoice.totalAmount);
const paidAmount = round2(Number(invoice.paidAmount) + input.amount);
const balanceAmount = Math.max(0, round2(total - paidAmount));
const fullyPaid = paidAmount >= total;
const status = fullyPaid
? Freight.InvoiceStatus.Paid
: Freight.InvoiceStatus.PartiallyPaid;
const entry: InvoicePayment = {
amount: round2(input.amount),
method: input.method ?? null,
reference: input.reference ?? null,
paidAt: at.toISOString(),
metadata: input.metadata ?? null,
};
const payments = [...(invoice.payments ?? []), entry];
await mg.update(
Invoice,
{ id: invoice.id },
{
paidAmount,
balanceAmount,
status,
payments,
paidAt: fullyPaid ? at : invoice.paidAt ?? null,
} as never,
);
const updated = {
...invoice,
paidAmount,
balanceAmount,
status,
payments,
paidAt: fullyPaid ? at : invoice.paidAt ?? null,
} as Invoice;
if (fullyPaid) this.emitInvoiceEvent("paid", updated);
return updated;
}
/**
* Mark an invoice refunded and emit `${source}.invoice.refunded`.
* No-op when already refunded.

View File

@@ -5,6 +5,16 @@ import { PaymentEntity } from "../../payment/entities/payment.entity";
import { Company } from "../../companies/entities/company.entity";
import { CompanyProfile } from "../../companies/entities/company-profile.entity";
/** A single recorded settlement against an invoice (payment ledger entry). */
export interface InvoicePayment {
amount: number;
method?: string | null;
reference?: string | null;
/** ISO timestamp of when the settlement was recorded. */
paidAt: string;
metadata?: Record<string, unknown> | null;
}
@Entity({ schema: "freight", name: "invoices" })
@Index(["companyId"])
@Index(["companyProfileId"])
@@ -28,9 +38,24 @@ export class Invoice extends BaseEntity {
@JoinColumn({ name: "company_profile_id" })
companyProfile?: CompanyProfile;
/** Sum of line amounts before tax; defaults to `totalAmount` for tax-free invoices. */
@Column({ name: "subtotal_amount", type: "numeric", precision: 14, scale: 2, default: 0 })
subtotalAmount!: number;
@Column({ name: "tax_amount", type: "numeric", precision: 14, scale: 2, default: 0 })
taxAmount!: number;
@Column({ name: "total_amount", type: "numeric", precision: 14, scale: 2 })
totalAmount!: number;
/** Cumulative amount settled so far (supports partial payment). */
@Column({ name: "paid_amount", type: "numeric", precision: 14, scale: 2, default: 0 })
paidAmount!: number;
/** Outstanding balance = `totalAmount - paidAmount` (0 once fully paid). */
@Column({ name: "balance_amount", type: "numeric", precision: 14, scale: 2, default: 0 })
balanceAmount!: number;
@Column({ name: "currency", type: "varchar", length: 8, default: "ETB" })
currency!: string;
@@ -62,6 +87,14 @@ export class Invoice extends BaseEntity {
@Column({ name: "issued_at", type: "timestamptz", nullable: true })
issuedAt?: Date | null;
/** Set when the invoice is fully settled. */
@Column({ name: "paid_at", type: "timestamptz", nullable: true })
paidAt?: Date | null;
/** Ledger of individual settlements (manual or gateway), newest last. */
@Column({ name: "payments", type: "jsonb", default: () => "'[]'" })
payments!: InvoicePayment[];
/** The ID of the payment that generated this invoice. */
@Column({ name: "payment_id", type: "uuid", nullable: true })
paymentId?: string | null;

View File

@@ -131,7 +131,11 @@ export enum PaymentStatus {
export enum InvoiceStatus {
Draft = "DRAFT",
/** Issued and awaiting payment (alias of PENDING for fee invoices). */
Issued = "ISSUED",
Pending = "PENDING",
/** Some, but not all, of the balance has been settled. */
PartiallyPaid = "PARTIALLY_PAID",
Paid = "PAID",
Overdue = "OVERDUE",
Cancelled = "CANCELLED",