Ctrl K
ComunidadGuías…

test/shared/accounting/manual-entries.test.ts

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

Archivo original ↗Preguntar a KiBot
import { expect } from "@std/expect";
import { describe, it as test } from "@std/testing/bdd";
import { stub } from "@std/testing/mock";
import {
  deleteManualLedgerEntry,
  MANUAL_ATTENDEE_CHARGE,
  MANUAL_ATTENDEE_PAYMENT,
  MANUAL_ATTENDEE_WRITEOFF,
  MANUAL_LISTING_COST,
  MANUAL_LISTING_INCOME,
  MANUAL_MODIFIER_INCOME,
  MANUAL_MODIFIER_REDUCTION,
  type ManualLedgerEntryType,
  ManualLedgerEntryTypeSchema,
  manualEntrySpecByType,
  manualLedgerEntryOptionsFor,
  postManualLedgerEntry,
  updateManualLedgerEntry,
} from "#accounting/manual-entries.ts";
import { allTransfers } from "#accounting/queries.ts";
import { eventGroup, legReference } from "#accounting/refs.ts";
import { postTransfers } from "#accounting/store.ts";
import { t } from "#i18n";
import { account } from "#shared/ledger/account.ts";
import type { AccountRef, Transfer } from "#shared/ledger/types.ts";
import { humanAmount } from "#templates/admin/ledger/formatting.tsx";
import { tx, useTransactionalDb } from "#test-utils/ledger.ts";

const world = account("external", "world");
const writeoff = account("writeoff", "default");

type ManualEntryCase = {
  type: ManualLedgerEntryType;
  account: AccountRef;
  amount: number;
  source: AccountRef;
  destination: AccountRef;
};

const attendee = account("attendee", 1);
const revenue = account("revenue", 2);
const modifier = account("modifier", 3);

/** Every entry type once, each with the legs it must post and its own amount,
 *  so a row read back by kind cannot be another row wearing the same figure. */
const ENTRY_CASES: ManualEntryCase[] = [
  {
    account: attendee,
    amount: 100,
    destination: attendee,
    source: world,
    type: MANUAL_ATTENDEE_PAYMENT,
  },
  {
    account: attendee,
    amount: 101,
    destination: writeoff,
    source: attendee,
    type: MANUAL_ATTENDEE_CHARGE,
  },
  {
    account: attendee,
    amount: 102,
    destination: attendee,
    source: writeoff,
    type: MANUAL_ATTENDEE_WRITEOFF,
  },
  {
    account: revenue,
    amount: 103,
    destination: revenue,
    source: world,
    type: MANUAL_LISTING_INCOME,
  },
  {
    account: revenue,
    amount: 104,
    destination: world,
    source: revenue,
    type: MANUAL_LISTING_COST,
  },
  {
    account: modifier,
    amount: 105,
    destination: modifier,
    source: writeoff,
    type: MANUAL_MODIFIER_INCOME,
  },
  {
    account: modifier,
    amount: 106,
    destination: writeoff,
    source: modifier,
    type: MANUAL_MODIFIER_REDUCTION,
  },
];

/** The entry types an operator reads as money given back, so the ledger shows
 *  the amount as a reduction. Nothing in the legs says which these are: an
 *  attendee writeoff and an option income both put money into their account. */
const GIVES_MONEY_BACK = new Set<ManualLedgerEntryType>([
  MANUAL_ATTENDEE_WRITEOFF,
  MANUAL_LISTING_COST,
  MANUAL_MODIFIER_REDUCTION,
]);

/** Post one entry of every type and read back what each one stored. */
const postEveryEntryType = async (): Promise<Map<string, Transfer>> => {
  for (const entry of ENTRY_CASES) {
    await postManualLedgerEntry({
      account: entry.account,
      amount: entry.amount,
      occurredAt: "2026-06-22T09:30:00.000Z",
      postedBy: "1",
      type: entry.type,
    });
  }
  const stored = await allTransfers();
  return new Map(stored.map((transfer) => [transfer.kind ?? "", transfer]));
};

describe("db > accounting > manual ledger entries", () => {
  useTransactionalDb();

  test("posts each owner-entered entry type to the expected ledger legs", async () => {
    const rows = await postEveryEntryType();

    for (const entry of ENTRY_CASES) {
      expect(rows.get(entry.type)?.amount).toBe(entry.amount);
      expect(rows.get(entry.type)?.source).toEqual(entry.source);
      expect(rows.get(entry.type)?.destination).toEqual(entry.destination);
    }
  });

  test("shows an entry that gives money back as a reduction on the ledger", async () => {
    const rows = await postEveryEntryType();

    // humanAmount is the figure the operator reads on the row, so a writeoff,
    // a cost, and an option reduction must all come back below zero.
    for (const entry of ENTRY_CASES) {
      const stored = rows.get(entry.type);
      expect(humanAmount(stored!)).toBe(
        GIVES_MONEY_BACK.has(entry.type) ? -entry.amount : entry.amount,
      );
    }
  });

  test("derives the reference and event group from the manual-entry parts", async () => {
    // Pin the nonce so the HMAC parts are fully known: the test recomputes both
    // digests from the documented tuple — the "manual-ledger-entry" prefix, the
    // entry type, the account, the nonce, and the reference's "transfer" leg
    // part — so a drifted prefix or leg part changes the stored digests.
    const nonce = "00000000-0000-4000-8000-000000000000" as const;
    using _uuid = stub(crypto, "randomUUID", () => nonce);

    await postManualLedgerEntry({
      account: account("attendee", 1),
      amount: 100,
      occurredAt: "2026-06-22T09:30:00.000Z",
      postedBy: "1",
      type: MANUAL_ATTENDEE_PAYMENT,
    });

    const [stored] = await allTransfers();
    const parts = [
      "manual-ledger-entry",
      MANUAL_ATTENDEE_PAYMENT,
      "attendee",
      "1",
      nonce,
    ];
    expect(stored!.eventGroup).toBe(await eventGroup(parts));
    expect(stored!.reference).toBe(await legReference([...parts, "transfer"]));
  });

  test("keeps the stored word for every entry type, in the order shown", () => {
    // Each word is written into transfers.kind and finds that row again, so a
    // changed word orphans every entry a site already stored. The literals are
    // the point: the constants would move with the change and hide it. The
    // order is what the owner sees in the add-entry menu.
    expect(ManualLedgerEntryTypeSchema.options).toEqual([
      "manual_attendee_payment",
      "manual_attendee_charge",
      "manual_attendee_writeoff",
      "manual_listing_income",
      "manual_listing_cost",
      "manual_modifier_income",
      "manual_modifier_reduction",
    ]);
  });

  test("every entry type's user-facing keys have translations", () => {
    // t() throws on a key missing from the locale files, so resolving every key
    // proves the spec table and the en catalogue stay in sync.
    for (const spec of Object.values(manualEntrySpecByType)) {
      for (const key of [
        spec.labelKey,
        spec.hintKey,
        spec.descriptionKey,
        spec.eventKey,
      ]) {
        expect(t(key).length).toBeGreaterThan(0);
      }
    }
    // One spec spelled out in full: the copy the admin actually reads.
    const payment = manualEntrySpecByType[MANUAL_ATTENDEE_PAYMENT];
    expect(t(payment.labelKey)).toBe("Payment received another way");
    expect(t(payment.hintKey)).toBe(
      "Use this for cash, a bank transfer, a separate card machine, a voucher, or another payment system.",
    );
    expect(t(payment.descriptionKey)).toBe("Payment received another way for");
    expect(t(payment.eventKey)).toBe("Payment received another way");
  });

  test("rejects an entry type that does not belong to the account", async () => {
    await expect(
      postManualLedgerEntry({
        account: account("attendee", 1),
        amount: 100,
        occurredAt: "2026-06-22T09:30:00.000Z",
        postedBy: "1",
        type: MANUAL_LISTING_COST,
      }),
    ).rejects.toThrow("is not valid for attendee");
    expect(await allTransfers()).toEqual([]);
  });

  test("rejects an edit that would make the transfer invalid", async () => {
    await postManualLedgerEntry({
      account: account("attendee", 1),
      amount: 100,
      occurredAt: "2026-06-22T09:30:00.000Z",
      postedBy: "1",
      type: MANUAL_ATTENDEE_PAYMENT,
    });
    const [transfer] = await allTransfers();

    await expect(
      updateManualLedgerEntry(transfer!, -1, transfer!.occurredAt),
    ).rejects.toThrow("invalid transfer update");
  });

  test("offers each account type ONLY its own entry options", () => {
    // The option filter keys on the spec table's accountType; an inverted
    // match would offer listing entries on an attendee account.
    const offered = manualLedgerEntryOptionsFor(account("attendee", 1)).map(
      (option) => option.type,
    );
    expect(offered).toEqual([
      MANUAL_ATTENDEE_PAYMENT,
      MANUAL_ATTENDEE_CHARGE,
      MANUAL_ATTENDEE_WRITEOFF,
    ]);
    expect(
      manualLedgerEntryOptionsFor(account("revenue", 1)).map((o) => o.type),
    ).toEqual([MANUAL_LISTING_INCOME, MANUAL_LISTING_COST]);
  });

  test("updates an owner-entered entry's amount and business time", async () => {
    await postManualLedgerEntry({
      account: account("attendee", 1),
      amount: 100,
      occurredAt: "2026-06-22T09:30:00.000Z",
      postedBy: "1",
      type: MANUAL_ATTENDEE_PAYMENT,
    });
    const [before] = await allTransfers();

    await updateManualLedgerEntry(before!, 250, "2026-06-23T10:00:00.000Z");

    const [after] = await allTransfers();
    expect(after!.amount).toBe(250);
    expect(after!.occurredAt).toBe("2026-06-23T10:00:00.000Z");
    // Identity and provenance are untouched by an amount/time edit.
    expect(after!.id).toBe(before!.id);
    expect(after!.reference).toBe(before!.reference);
  });

  test("edits only the targeted entry, leaving sibling manual entries alone", async () => {
    // Two owner-entered rows on the same account: the update must be keyed on
    // the transfer id, never bleed onto the account's other entries.
    for (const type of [
      MANUAL_ATTENDEE_PAYMENT,
      MANUAL_ATTENDEE_CHARGE,
    ] as const) {
      await postManualLedgerEntry({
        account: account("attendee", 1),
        amount: 100,
        occurredAt: "2026-06-22T09:30:00.000Z",
        postedBy: "1",
        type,
      });
    }
    const findByKind = async (kind: string): Promise<Transfer> =>
      (await allTransfers()).find((transfer) => transfer.kind === kind)!;

    await updateManualLedgerEntry(
      await findByKind(MANUAL_ATTENDEE_PAYMENT),
      777,
      "2026-06-25T12:00:00.000Z",
    );

    const untouched = await findByKind(MANUAL_ATTENDEE_CHARGE);
    expect(untouched.amount).toBe(100);
    expect(untouched.occurredAt).toBe("2026-06-22T09:30:00.000Z");
    const edited = await findByKind(MANUAL_ATTENDEE_PAYMENT);
    expect(edited.amount).toBe(777);
    expect(edited.occurredAt).toBe("2026-06-25T12:00:00.000Z");
  });

  test("deletes an owner-entered entry", async () => {
    await postManualLedgerEntry({
      account: account("attendee", 1),
      amount: 100,
      occurredAt: "2026-06-22T09:30:00.000Z",
      postedBy: "1",
      type: MANUAL_ATTENDEE_PAYMENT,
    });
    const [entry] = await allTransfers();
    await deleteManualLedgerEntry(entry!);
    expect(await allTransfers()).toEqual([]);
  });

  /** Post one checkout-history leg (a `sale`) and read back its stored row. */
  const storedSaleLeg = async (): Promise<Transfer> => {
    await postTransfers([tx({ kind: "sale", reference: "sale-guard" })]);
    return (await allTransfers())[0]!;
  };

  test("refuses to edit a checkout-event transfer, leaving it untouched", async () => {
    const sale = await storedSaleLeg();
    await expect(
      updateManualLedgerEntry(sale, 999, sale.occurredAt),
    ).rejects.toThrow("not an owner-entered ledger entry");
    expect((await allTransfers())[0]!.amount).toBe(sale.amount);
  });

  test("refuses to touch a kindless transfer, naming its empty kind", async () => {
    // A kindless stored leg reads back with kind omitted; the guard must refuse
    // it too and render the missing kind as "" in the error.
    await postTransfers([tx({ reference: "kindless-guard" })]);
    const [kindless] = await allTransfers();
    await expect(
      updateManualLedgerEntry(kindless!, 999, kindless!.occurredAt),
    ).rejects.toThrow('not an owner-entered ledger entry (kind "")');
  });

  test("refuses to delete a checkout-event transfer, leaving it stored", async () => {
    const sale = await storedSaleLeg();
    await expect(deleteManualLedgerEntry(sale)).rejects.toThrow(
      "not an owner-entered ledger entry",
    );
    expect(await allTransfers()).toHaveLength(1);
  });
});