Ctrl K
ComunidadGuías…

test/shared/accounting/mappers.test.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
import { assertStringIncludes } from "@std/assert";
import { expect } from "@std/expect";
import { it as test } from "@std/testing/bdd";
import {
  attendeeAccount,
  BOOKING_FEE_INCOME,
  modifierAccount,
  revenueAccount,
  TAX_VAT,
  WORLD,
} from "#accounting/accounts.ts";
import { mapBooking } from "#accounting/mappers.ts";
import { balanceOf } from "#shared/ledger/project.ts";
import type { Transfer, TransferInput } from "#shared/ledger/types.ts";
import {
  asTransfer,
  facts,
  paidBookingNettingToZero,
  soleLegOf,
} from "#test/shared/accounting/mappers/fixtures.ts";
import { rejectionMessage } from "#test-utils/assertions.ts";
import { describeWithEnv } from "#test-utils/db.ts";

describeWithEnv("accounting > mapBooking", { encryptionKey: true }, () => {
  const theModifierLeg = soleLegOf("modifier");

  /** Asserts a single-listing VAT booking's common shape: the ex-VAT sale, the
   * vat leg amount, and the attendee's outstanding balance including the tax. */
  const expectVatBooking = (
    legs: Transfer[],
    vatAmount: number,
    attendeeOwed: number,
  ): void => {
    expect(soleLegOf("sale")(legs).amount).toBe(10000);
    expect(soleLegOf("vat")(legs).amount).toBe(vatAmount);
    expect(balanceOf(attendeeAccount(3))(legs)).toBe(attendeeOwed);
  };

  test("books gross, modifiers, fee and payment; a paid booking nets to zero", async () => {
    const legs = (await mapBooking(facts(paidBookingNettingToZero))).map(
      asTransfer,
    );

    // 8000 gross + 200 surcharge + 150 fee − 500 discount − 7850 paid = 0
    expect(balanceOf(attendeeAccount(3))(legs)).toBe(0);
    expect(balanceOf(revenueAccount(1))(legs)).toBe(5000);
    expect(balanceOf(revenueAccount(2))(legs)).toBe(3000);
    expect(balanceOf(modifierAccount(10))(legs)).toBe(-500); // contra (discount)
    expect(balanceOf(modifierAccount(11))(legs)).toBe(200); // surcharge revenue
    expect(balanceOf(BOOKING_FEE_INCOME)(legs)).toBe(150);
    expect(balanceOf(WORLD)(legs)).toBe(-7850);
  });

  test("posts a surcharge as a positive attendee→modifier leg, a discount the other way", async () => {
    // balanceOf can't tell the two branches apart — flipping the ends and
    // negating the amount nets the same — so assert the leg's direction and its
    // positive amount directly.
    const surcharge = theModifierLeg(
      await mapBooking(facts({ modifiers: [{ delta: 200, modifierId: 11 }] })),
    );
    expect(surcharge.amount).toBe(200);
    expect(surcharge.source).toEqual(attendeeAccount(3));
    expect(surcharge.destination).toEqual(modifierAccount(11));

    const discount = theModifierLeg(
      await mapBooking(
        facts({
          lines: [{ gross: 1000, listingId: 1 }],
          modifiers: [{ delta: -500, modifierId: 10 }],
        }),
      ),
    );
    expect(discount.amount).toBe(500);
    expect(discount.source).toEqual(modifierAccount(10));
    expect(discount.destination).toEqual(attendeeAccount(3));
  });

  test("leaves a deposit booking owing the remainder", async () => {
    const legs = (
      await mapBooking(
        facts({ amountPaid: 2000, lines: [{ gross: 10000, listingId: 1 }] }),
      )
    ).map(asTransfer);
    expect(balanceOf(attendeeAccount(3))(legs)).toBe(-8000); // owes £80
  });

  test("shares one event group and emits a distinct reference per leg", async () => {
    const legs = await mapBooking(
      facts({ amountPaid: 5000, lines: [{ gross: 5000, listingId: 1 }] }),
    );
    expect(new Set(legs.map((l) => l.eventGroup)).size).toBe(1);
    expect(new Set(legs.map((l) => l.reference)).size).toBe(legs.length);
  });

  test("is deterministic across calls (idempotent references)", async () => {
    const input = facts({
      amountPaid: 5000,
      lines: [{ gross: 5000, listingId: 1 }],
    });
    const first = await mapBooking(input);
    const second = await mapBooking(input);
    expect(first.map((l) => l.reference)).toEqual(
      second.map((l) => l.reference),
    );
  });

  test("aggregates multiple lines for one listing into a single sale leg", async () => {
    const legs = await mapBooking(
      facts({
        amountPaid: 5000,
        lines: [
          { gross: 3000, listingId: 1 },
          { gross: 2000, listingId: 1 }, // discount split — same listing
        ],
      }),
    );
    const sales = legs.filter((l) => l.kind === "sale");
    expect(sales.length).toBe(1);
    expect(sales[0]!.amount).toBe(5000);
    expect(new Set(legs.map((l) => l.reference)).size).toBe(legs.length);
  });

  test("rejects a negative line gross", async () => {
    assertStringIncludes(
      await rejectionMessage(
        mapBooking(facts({ lines: [{ gross: -100, listingId: 1 }] })),
      ),
      "negative listing 1 gross",
    );
  });

  test("rejects a negative booking fee", async () => {
    assertStringIncludes(
      await rejectionMessage(mapBooking(facts({ bookingFee: -10 }))),
      "negative bookingFee",
    );
  });

  test("rejects a negative amount paid", async () => {
    assertStringIncludes(
      await rejectionMessage(mapBooking(facts({ amountPaid: -50 }))),
      "negative amountPaid",
    );
  });

  test("rejects a non-finite (NaN) gross", async () => {
    assertStringIncludes(
      await rejectionMessage(
        mapBooking(facts({ lines: [{ gross: Number.NaN, listingId: 1 }] })),
      ),
      "non-finite listing 1 gross",
    );
  });

  test("rejects a fractional line gross (minor units must be integers)", async () => {
    assertStringIncludes(
      await rejectionMessage(
        mapBooking(facts({ lines: [{ gross: 10.5, listingId: 1 }] })),
      ),
      "non-integer listing 1 gross",
    );
  });

  test("rejects a fractional modifier delta", async () => {
    assertStringIncludes(
      await rejectionMessage(
        mapBooking(facts({ modifiers: [{ delta: -2.5, modifierId: 7 }] })),
      ),
      "non-integer modifier 7 delta",
    );
  });

  test("rejects a non-finite modifier delta", async () => {
    assertStringIncludes(
      await rejectionMessage(
        mapBooking(
          facts({ modifiers: [{ delta: Number.NaN, modifierId: 7 }] }),
        ),
      ),
      "non-finite modifier 7 delta",
    );
  });

  test("rejects an empty event id", async () => {
    assertStringIncludes(
      await rejectionMessage(mapBooking(facts({ eventId: "" }))),
      "empty eventId",
    );
  });

  test("rejects a whitespace-only event id", async () => {
    assertStringIncludes(
      await rejectionMessage(mapBooking(facts({ eventId: "   " }))),
      "empty eventId",
    );
  });

  test("drops zero-amount legs (a free booking posts nothing)", async () => {
    const legs = await mapBooking(
      facts({
        lines: [{ gross: 0, listingId: 1 }],
        modifiers: [{ delta: 0, modifierId: 10 }],
      }),
    );
    expect(legs).toEqual([]);
  });

  test("posts every one-unit leg — the smallest money there is", async () => {
    // A gross, fee and payment of exactly 1 minor unit must survive the
    // zero-drop guard, not fall through it.
    const legs: readonly TransferInput[] = await mapBooking(
      facts({
        amountPaid: 1,
        bookingFee: 1,
        lines: [{ gross: 1, listingId: 1 }],
      }),
    );
    expect(soleLegOf("sale")(legs).amount).toBe(1);
    expect(soleLegOf("fee")(legs).amount).toBe(1);
    expect(soleLegOf("payment")(legs).amount).toBe(1);
  });

  test("posts a one-unit surcharge and discount at their smallest size", async () => {
    const surcharge = theModifierLeg(
      await mapBooking(facts({ modifiers: [{ delta: 1, modifierId: 11 }] })),
    );
    expect(surcharge.amount).toBe(1);
    expect(surcharge.source).toEqual(attendeeAccount(3));

    const discount = theModifierLeg(
      await mapBooking(
        facts({
          lines: [{ gross: 2, listingId: 1 }],
          modifiers: [{ delta: -1, modifierId: 10 }],
        }),
      ),
    );
    expect(discount.amount).toBe(1);
    expect(discount.destination).toEqual(attendeeAccount(3));
  });

  test("joins every invalid fact into one report, comma-separated", async () => {
    expect(
      await rejectionMessage(
        mapBooking(facts({ amountPaid: -50, bookingFee: -10, eventId: "" })),
      ),
    ).toBe(
      "mapBooking: invalid facts (empty eventId, negative bookingFee, negative amountPaid)",
    );
  });

  test("adds a vat leg on top in add-to-total mode: sale stays ex-VAT, total includes the tax", async () => {
    const legs = (
      await mapBooking(
        facts({
          amountPaid: 0,
          lines: [{ gross: 10000, listingId: 1 }],
          vat: { mode: "add_to_total", rateBasisPoints: 2000 },
        }),
      )
    ).map(asTransfer);

    expectVatBooking(legs, 2000, -12000);
    const vat = soleLegOf("vat")(legs);
    expect(vat.source).toEqual(attendeeAccount(3));
    expect(vat.destination).toEqual(TAX_VAT);
    expect(balanceOf(revenueAccount(1))(legs)).toBe(10000);
    expect(balanceOf(TAX_VAT)(legs)).toBe(2000);
  });

  test("splits VAT out of an inclusive total so income stays ex-VAT", async () => {
    const legs = (
      await mapBooking(
        facts({
          amountPaid: 0,
          lines: [{ gross: 12000, listingId: 1 }],
          vat: { mode: "calculate_from_total", rateBasisPoints: 2000 },
        }),
      )
    ).map(asTransfer);

    // 12000 includes 20% VAT: 10000 ex-VAT sale + 2000 tax.
    expectVatBooking(legs, 2000, -12000);
    expect(balanceOf(revenueAccount(1))(legs)).toBe(10000);
    expect(balanceOf(TAX_VAT)(legs)).toBe(2000);
  });

  test("splits an inclusive VAT total across several sale legs by largest remainder", async () => {
    const legs = (
      await mapBooking(
        facts({
          amountPaid: 0,
          lines: [
            { gross: 6000, listingId: 1 },
            { gross: 6000, listingId: 2 },
          ],
          vat: { mode: "calculate_from_total", rateBasisPoints: 2000 },
        }),
      )
    ).map(asTransfer);

    expect(soleLegOf("vat")(legs).amount).toBe(2000);
    const saleAmounts = legs
      .filter((leg) => leg.kind === "sale")
      .map((leg) => leg.amount)
      .sort((a, b) => a - b);
    expect(saleAmounts).toEqual([5000, 5000]);
    expect(balanceOf(attendeeAccount(3))(legs)).toBe(-12000);
  });

  test("keeps discount legs whole when splitting an inclusive total", async () => {
    const legs = (
      await mapBooking(
        facts({
          amountPaid: 0,
          lines: [{ gross: 12000, listingId: 1 }],
          modifiers: [
            { delta: -1000, modifierId: 10 }, // discount
            { delta: 200, modifierId: 11 }, // surcharge
          ],
          vat: { mode: "calculate_from_total", rateBasisPoints: 2000 },
        }),
      )
    ).map(asTransfer);

    // Taxable base = 12000 − 1000 + 200 = 11200 → 1867 VAT at 20% inclusive.
    expect(soleLegOf("vat")(legs).amount).toBe(1867);
    expect(soleLegOf("sale")(legs).amount).toBe(10164);
    // The discount leg stays whole; the surcharge leg is reduced by its share.
    expect(balanceOf(modifierAccount(10))(legs)).toBe(-1000);
    expect(balanceOf(modifierAccount(11))(legs)).toBe(169);
    expect(balanceOf(attendeeAccount(3))(legs)).toBe(-11200);
  });

  test("charges VAT on the net modifier base, not the booking fee", async () => {
    const legs = (
      await mapBooking(
        facts({
          amountPaid: 12640,
          bookingFee: 1000,
          lines: [{ gross: 10000, listingId: 1 }],
          modifiers: [
            { delta: -500, modifierId: 10 },
            { delta: 200, modifierId: 11 },
          ],
          vat: { mode: "add_to_total", rateBasisPoints: 2000 },
        }),
      )
    ).map(asTransfer);

    // Taxable base = 10000 + 200 − 500 = 9700 → 1940 VAT at 20%.
    expect(soleLegOf("vat")(legs).amount).toBe(1940);
    expect(balanceOf(revenueAccount(1))(legs)).toBe(10000);
    expect(balanceOf(TAX_VAT)(legs)).toBe(1940);
    // A fully-paid VAT booking nets the attendee to zero: tickets 9700
    // + 1000 fee + 1940 VAT, all paid.
    expect(balanceOf(attendeeAccount(3))(legs)).toBe(0);
  });

  test("posts no vat leg when a discount wipes out the taxable base", async () => {
    const legs = await mapBooking(
      facts({
        amountPaid: 0,
        lines: [{ gross: 10000, listingId: 1 }],
        modifiers: [{ delta: -10000, modifierId: 10 }],
        vat: { mode: "add_to_total", rateBasisPoints: 2000 },
      }),
    );
    expect(legs.some((leg) => leg.kind === "vat")).toBe(false);
  });

  test("rejects a negative VAT rate", async () => {
    assertStringIncludes(
      await rejectionMessage(
        mapBooking(
          facts({
            lines: [{ gross: 10000, listingId: 1 }],
            vat: { mode: "add_to_total", rateBasisPoints: -1 },
          }),
        ),
      ),
      "invalid tax policy",
    );
  });
});