Ctrl K
ComunidadGuías…

test/shared/checkout-pricing.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 {
  allocateDiscount,
  applyModifiers,
  lineListPrice,
  type PricedLine,
  priceCheckout,
  ticketPaymentBreakdown,
} from "#shared/checkout-pricing.ts";
import type {
  CheckoutIntent,
  CheckoutItem,
  ModifierSpec,
} from "#shared/payments.ts";
import { testWithSetting } from "#test-utils/settings.ts";

const modifier = (overrides: Partial<ModifierSpec> = {}): ModifierSpec => ({
  id: 1,
  kind: "fixed",
  listingIds: null,
  name: "Add-on",
  quantity: 1,
  trigger: "automatic",
  value: 500,
  ...overrides,
});

/** Build a CheckoutIntent around the given items (and optional overrides). */
const intentWith = (
  items: CheckoutItem[],
  overrides: Partial<CheckoutIntent> = {},
): CheckoutIntent => ({
  address: "",
  date: null,
  email: "buyer@example.com",
  items,
  name: "Buyer",
  phone: "",
  special_instructions: "",
  ...overrides,
});

const item = (overrides: Partial<CheckoutItem> = {}): CheckoutItem => ({
  listingId: 1,
  name: "General",
  quantity: 1,
  slug: "general",
  unitPrice: 1000,
  ...overrides,
});

const line = (overrides: Partial<PricedLine> = {}): PricedLine => ({
  chargedUnitAmount: 1000,
  item: item(),
  quantity: 1,
  ...overrides,
});

describe("priceCheckout", () => {
  testWithSetting(
    "prices ticket lines at the full unit price",
    { booking_fee: "0" },
    () => {
      const order = priceCheckout(intentWith([item({ quantity: 2 })]));
      expect(order.lines).toHaveLength(1);
      expect(order.lines[0]!.chargedUnitAmount).toBe(1000);
      expect(order.extras).toEqual([]);
      expect(order.total).toBe(2000);
    },
  );

  testWithSetting(
    "charges the per-unit deposit for a reservation",
    { booking_fee: "0" },
    () => {
      // 10% of a £10 ticket = £1 charged per unit; total is the deposit only.
      const order = priceCheckout(
        intentWith([item({ quantity: 2 })], { reservationAmount: "10%" }),
      );
      expect(order.lines[0]!.chargedUnitAmount).toBe(100);
      expect(order.total).toBe(200);
    },
  );

  testWithSetting(
    "splits flat reservation lines so 10 across 3 tickets charges exactly 1000",
    { booking_fee: "0", currency: "GBP" },
    () => {
      const order = priceCheckout(
        intentWith([item({ quantity: 3 })], { reservationAmount: "10" }),
      );
      expect(order.lines).toEqual([
        { chargedUnitAmount: 334, item: item({ quantity: 3 }), quantity: 1 },
        { chargedUnitAmount: 333, item: item({ quantity: 3 }), quantity: 2 },
      ]);
      expect(order.total).toBe(1000);
    },
  );

  testWithSetting(
    "splits flat reservation lines so 10.01 across 3 tickets charges exactly 1001",
    { booking_fee: "0", currency: "GBP" },
    () => {
      const order = priceCheckout(
        intentWith([item({ quantity: 3 })], { reservationAmount: "10.01" }),
      );
      expect(order.lines).toEqual([
        { chargedUnitAmount: 334, item: item({ quantity: 3 }), quantity: 2 },
        { chargedUnitAmount: 333, item: item({ quantity: 3 }), quantity: 1 },
      ]);
      expect(order.total).toBe(1001);
    },
  );

  testWithSetting(
    "clamps an oversized flat reservation deposit to the full order total",
    { booking_fee: "0", currency: "GBP" },
    () => {
      const order = priceCheckout(
        intentWith([item({ quantity: 2, unitPrice: 300 })], {
          reservationAmount: "100",
        }),
      );
      expect(order.lines).toEqual([
        {
          chargedUnitAmount: 300,
          item: item({ quantity: 2, unitPrice: 300 }),
          quantity: 2,
        },
      ]);
      expect(order.total).toBe(600);
    },
  );

  testWithSetting(
    "allocates mixed flat reservation items exactly without exceeding line prices",
    { booking_fee: "0", currency: "GBP" },
    () => {
      const general = item({ listingId: 1, quantity: 2, unitPrice: 1000 });
      const vip = item({
        listingId: 2,
        name: "VIP",
        quantity: 1,
        slug: "vip",
        unitPrice: 2500,
      });
      const cheap = item({
        listingId: 3,
        name: "Cheap",
        quantity: 3,
        slug: "cheap",
        unitPrice: 500,
      });
      const order = priceCheckout(
        intentWith([general, vip, cheap], { reservationAmount: "10" }),
      );
      expect(order.lines).toEqual([
        { chargedUnitAmount: 167, item: general, quantity: 2 },
        { chargedUnitAmount: 417, item: vip, quantity: 1 },
        { chargedUnitAmount: 83, item: cheap, quantity: 3 },
      ]);
      expect(order.total).toBe(1000);
      for (const line of order.lines) {
        expect(line.chargedUnitAmount).toBeLessThanOrEqual(line.item.unitPrice);
      }
    },
  );

  testWithSetting(
    "adds a booking-fee extra line on top of the ticket total",
    { booking_fee: "5" },
    () => {
      const order = priceCheckout(intentWith([item({ quantity: 2 })]));
      // 5% of the £20 subtotal = £1 fee, as a single extra line.
      expect(order.extras).toEqual([
        { amount: 100, key: "fee", name: "Booking fee", quantity: 1 },
      ]);
      expect(order.total).toBe(2100);
    },
  );

  testWithSetting(
    "records the full applied amount for a quantity-based add-on",
    { booking_fee: "0" },
    () => {
      const order = priceCheckout(
        intentWith([item()], {
          modifiers: [modifier({ kind: "fixed", quantity: 3, value: 500 })],
        }),
      );
      expect(order.extras).toEqual([
        { amount: 500, key: "mod:1", name: "Add-on", quantity: 3 },
      ]);
      expect(order.modifierApplications).toEqual([
        {
          amountApplied: 1500,
          delta: 1500,
          modifierId: 1,
          name: "Add-on",
          quantity: 3,
          scopedSubtotal: 1000,
        },
      ]);
      expect(order.total).toBe(2500);
    },
  );

  testWithSetting(
    "omits the fee extra when the booking fee is zero",
    { booking_fee: "0" },
    () => {
      const order = priceCheckout(intentWith([item()]));
      expect(order.extras).toEqual([]);
      expect(order.total).toBe(1000);
    },
  );

  testWithSetting(
    "charges the fee on the feeSubtotal override, not the deposit",
    { booking_fee: "5" },
    () => {
      // A reservation pays a deposit but the fee is charged on the full order
      // (feeSubtotal override), so the fee line stays at 5% of £20 = £1.
      const order = priceCheckout(
        intentWith([item({ quantity: 2 })], {
          feeSubtotal: 2000,
          reservationAmount: "10%",
        }),
      );
      expect(order.lines[0]!.chargedUnitAmount).toBe(100);
      expect(order.extras[0]!.amount).toBe(100);
      // Deposit (2 × £1) + fee (£1) = £3.
      expect(order.total).toBe(300);
    },
  );

  testWithSetting(
    "takes the percentage deposit from the feeSubtotal override, not the items",
    { booking_fee: "5" },
    () => {
      // feeSubtotal override (£50) differs from the item subtotal (2 × £10 =
      // £20), so a 10% deposit is 10% of £50 = £5 (£2.50/unit), not 10% of £20.
      // The ?:-branch must keep the override path; collapsing it to the
      // "unmodified" path would deposit 10% of the items instead.
      const order = priceCheckout(
        intentWith([item({ quantity: 2 })], {
          feeSubtotal: 5000,
          reservationAmount: "10%",
        }),
      );
      expect(order.lines[0]!.chargedUnitAmount).toBe(250);
      // Deposit (2 × £2.50 = £5) + fee (5% of £50 = £2.50) = £7.50.
      expect(order.total).toBe(750);
    },
  );

  testWithSetting(
    "charges the fee on the full reservation order when no override is present",
    { booking_fee: "5" },
    () => {
      const order = priceCheckout(
        intentWith([item({ quantity: 3 })], { reservationAmount: "10" }),
      );
      expect(order.extras).toEqual([
        { amount: 150, key: "fee", name: "Booking fee", quantity: 1 },
      ]);
      expect(order.total).toBe(1150);
    },
  );

  testWithSetting(
    "sums charges across multiple ticket lines",
    { booking_fee: "0" },
    () => {
      const order = priceCheckout(
        intentWith([
          item({ listingId: 1, quantity: 2, unitPrice: 1000 }),
          item({ listingId: 2, quantity: 1, slug: "vip", unitPrice: 2500 }),
        ]),
      );
      expect(order.total).toBe(4500);
    },
  );

  testWithSetting(
    "adds an additive modifier as an extra line before the fee",
    { booking_fee: "10" },
    () => {
      const order = priceCheckout(
        intentWith([item({ quantity: 2 })], {
          modifiers: [modifier({ kind: "fixed", name: "Parking", value: 500 })],
        }),
      );
      // Tickets 2 × £10 = £20; +£5 parking; fee is 10% of (£20 + £5) = £2.50.
      expect(order.extras).toEqual([
        { amount: 500, key: "mod:1", name: "Parking", quantity: 1 },
        { amount: 250, key: "fee", name: "Booking fee", quantity: 1 },
      ]);
      expect(order.total).toBe(2750);
    },
  );

  testWithSetting(
    "applies a discount and charges the fee on the reduced subtotal",
    { booking_fee: "10" },
    () => {
      // £20 order, 10% discount → £18; fee is 10% of £18 = £1.80.
      const order = priceCheckout(
        intentWith([item({ quantity: 2 })], {
          modifiers: [modifier({ kind: "percent", value: -10 })],
        }),
      );
      expect(order.lines).toEqual([
        { chargedUnitAmount: 900, item: item({ quantity: 2 }), quantity: 2 },
      ]);
      expect(order.extras).toEqual([
        { amount: 180, key: "fee", name: "Booking fee", quantity: 1 },
      ]);
      expect(order.total).toBe(1980);
    },
  );

  testWithSetting(
    "splits a line when a discount lands unevenly across its units",
    { booking_fee: "0" },
    () => {
      // £5 off £30 (3 × £10): 100 minor units over 3 units → 34/33/33.
      const order = priceCheckout(
        intentWith([item({ quantity: 3 })], {
          modifiers: [modifier({ kind: "fixed", value: -100 })],
        }),
      );
      expect(order.lines).toEqual([
        { chargedUnitAmount: 967, item: item({ quantity: 3 }), quantity: 2 },
        { chargedUnitAmount: 966, item: item({ quantity: 3 }), quantity: 1 },
      ]);
      // No pennies lost: 967×2 + 966 = 2900 = 3000 − 100.
      expect(order.total).toBe(2900);
    },
  );

  testWithSetting(
    "computes reservation deposits and balances from modifier-adjusted ticket totals",
    { booking_fee: "10" },
    () => {
      const intent = intentWith([item()], {
        modifiers: [modifier({ kind: "fixed", value: -500 })],
        reservationAmount: "10%",
      });
      const order = priceCheckout(intent);

      // £10 ticket - £5 discount = £5 final ticket total. The reservation
      // deposit is 10% of that adjusted total, and the booking fee is charged on
      // the same adjusted subtotal.
      expect(order.lines).toEqual([
        { chargedUnitAmount: 50, item: item(), quantity: 1 },
      ]);
      expect(order.extras).toEqual([
        { amount: 50, key: "fee", name: "Booking fee", quantity: 1 },
      ]);
      expect(order.total).toBe(100);

      const breakdown = ticketPaymentBreakdown(intent);
      expect(breakdown.paidByItem.size).toBe(1);
      expect(breakdown.paidByItem.get(intent.items[0]!)).toBe(50);
      expect(breakdown.remainingBalance).toBe(450);
    },
  );

  testWithSetting(
    "charges a reservation deposit against the modified full subtotal",
    { booking_fee: "10" },
    () => {
      const order = priceCheckout(
        intentWith([item()], {
          modifiers: [
            modifier({ kind: "fixed", name: "Programme", value: 500 }),
          ],
          reservationAmount: "10%",
        }),
      );
      // Full modified subtotal is £15.00; checkout charges a £1.50 deposit
      // plus a £1.50 booking fee, not the full £5.00 add-on.
      expect(order.lines).toEqual([
        { chargedUnitAmount: 150, item: item(), quantity: 1 },
      ]);
      expect(order.extras).toEqual([
        { amount: 150, key: "fee", name: "Booking fee", quantity: 1 },
      ]);
      expect(order.total).toBe(300);

      const intent = intentWith([item()], {
        modifiers: [modifier({ kind: "fixed", name: "Programme", value: 500 })],
        reservationAmount: "10%",
      });
      const breakdown = ticketPaymentBreakdown(intent);
      expect(breakdown.paidByItem.size).toBe(1);
      expect(breakdown.paidByItem.get(intent.items[0]!)).toBe(150);
      expect(breakdown.remainingBalance).toBe(1350);
    },
  );

  testWithSetting(
    "reduces a reservation deposit when a modifier discounts the full subtotal",
    { booking_fee: "0" },
    () => {
      const order = priceCheckout(
        intentWith([item()], {
          modifiers: [modifier({ kind: "percent", value: -10 })],
          reservationAmount: "10%",
        }),
      );
      expect(order.lines).toEqual([
        { chargedUnitAmount: 90, item: item(), quantity: 1 },
      ]);
      expect(order.total).toBe(90);
    },
  );

  testWithSetting(
    "allocates a modifier-funded reservation deposit for zero-price listings",
    { booking_fee: "0" },
    () => {
      const freeItem = item({ unitPrice: 0 });
      const order = priceCheckout(
        intentWith([freeItem], {
          modifiers: [
            modifier({ kind: "fixed", name: "Donation", value: 500 }),
          ],
          reservationAmount: "10%",
        }),
      );
      expect(order.lines).toEqual([
        { chargedUnitAmount: 50, item: freeItem, quantity: 1 },
      ]);
      expect(order.total).toBe(50);
    },
  );
});

describe("applyModifiers", () => {
  const lines: PricedLine[] = [
    line({
      chargedUnitAmount: 1000,
      item: item({ listingId: 1 }),
      quantity: 2,
    }),
    line({
      chargedUnitAmount: 2500,
      item: item({ listingId: 2, slug: "vip" }),
      quantity: 1,
    }),
  ];

  test("charges a percentage on the whole-order subtotal", () => {
    // 10% of (£20 + £25) = £4.50.
    const result = applyModifiers(lines, [
      modifier({ kind: "percent", listingIds: null, value: 10 }),
    ]);
    expect(result.extras).toEqual([
      { amount: 450, key: "mod:1", name: "Add-on", quantity: 1 },
    ]);
    expect(result.applications).toEqual([
      {
        amountApplied: 450,
        delta: 450,
        modifierId: 1,
        name: "Add-on",
        quantity: 1,
        scopedSubtotal: 4500,
      },
    ]);
    expect(result.modifierTotal).toBe(450);
  });

  test("scopes a percentage to only the listed items", () => {
    // 10% of listing 1's £20 only = £2.
    const result = applyModifiers(lines, [
      modifier({ kind: "percent", listingIds: [1], value: 10 }),
    ]);
    expect(result.extras[0]!.amount).toBe(200);
    expect(result.applications[0]).toEqual({
      amountApplied: 200,
      delta: 200,
      modifierId: 1,
      name: "Add-on",
      quantity: 1,
      scopedSubtotal: 2000,
    });
    expect(result.modifierTotal).toBe(200);
  });

  test("scopes a percentage across a group-sized listing set", () => {
    // 10% of listings 1 + 2 only = £4.50.
    const result = applyModifiers(lines, [
      modifier({ kind: "percent", listingIds: [1, 2], value: 10 }),
    ]);
    expect(result.applications[0]).toEqual({
      amountApplied: 450,
      delta: 450,
      modifierId: 1,
      name: "Add-on",
      quantity: 1,
      scopedSubtotal: 4500,
    });
    expect(result.modifierTotal).toBe(450);
  });

  test("multiplies a fixed add-on by its quantity in the total", () => {
    const result = applyModifiers(lines, [
      modifier({ kind: "fixed", quantity: 3, value: 500 }),
    ]);
    // Line shows £5 × 3; the contributed total is £15.
    expect(result.extras[0]).toEqual({
      amount: 500,
      key: "mod:1",
      name: "Add-on",
      quantity: 3,
    });
    expect(result.applications).toEqual([
      {
        amountApplied: 1500,
        delta: 1500,
        modifierId: 1,
        name: "Add-on",
        quantity: 3,
        scopedSubtotal: 4500,
      },
    ]);
    expect(result.modifierTotal).toBe(1500);
  });

  test("treats a zero-delta modifier as a no-op", () => {
    const result = applyModifiers(lines, [
      modifier({ kind: "percent", value: 0 }),
    ]);
    expect(result.extras).toEqual([]);
    expect(result.lines).toEqual(lines);
    expect(result.applications).toEqual([
      {
        amountApplied: 0,
        delta: 0,
        modifierId: 1,
        name: "Add-on",
        quantity: 1,
        scopedSubtotal: 4500,
      },
    ]);
    expect(result.modifierTotal).toBe(0);
  });

  test("reduces only the in-scope lines for a scoped discount", () => {
    const result = applyModifiers(lines, [
      modifier({ kind: "percent", listingIds: [1], value: -10 }),
    ]);
    // £2 off listing 1's two units (£20) → £9 each; listing 2 untouched.
    expect(result.lines).toEqual([
      { chargedUnitAmount: 900, item: item({ listingId: 1 }), quantity: 2 },
      {
        chargedUnitAmount: 2500,
        item: item({ listingId: 2, slug: "vip" }),
        quantity: 1,
      },
    ]);
    expect(result.applications).toEqual([
      {
        amountApplied: 200,
        delta: -200,
        modifierId: 1,
        name: "Add-on",
        quantity: 1,
        scopedSubtotal: 2000,
      },
    ]);
    expect(result.modifierTotal).toBe(-200);
  });

  test("records only the clamped amount for an oversized discount", () => {
    const result = applyModifiers(
      [line({ quantity: 2 })],
      [modifier({ kind: "fixed", value: -5000 })],
    );
    expect(result.lines).toEqual([
      { chargedUnitAmount: 0, item: item(), quantity: 2 },
    ]);
    expect(result.applications).toEqual([
      {
        amountApplied: 2000,
        delta: -2000,
        modifierId: 1,
        name: "Add-on",
        quantity: 1,
        scopedSubtotal: 2000,
      },
    ]);
    expect(result.modifierTotal).toBe(-2000);
  });
});

describe("allocateDiscount", () => {
  test("is a no-op for a zero amount", () => {
    expect(allocateDiscount([100, 200], 0)).toEqual([100, 200]);
  });

  test("is a no-op when there is nothing to discount", () => {
    expect(allocateDiscount([0, 0], 50)).toEqual([0, 0]);
  });

  test("removes exactly the discount, proportionally", () => {
    expect(allocateDiscount([1000, 1000, 2500], 450)).toEqual([900, 900, 2250]);
  });

  test("hands leftover minor units to the largest remainders", () => {
    // 100 over three equal units: 34/33/33, the extra penny to the first.
    expect(allocateDiscount([1000, 1000, 1000], 100)).toEqual([966, 967, 967]);
  });

  test("clamps a discount larger than the total to zero", () => {
    expect(allocateDiscount([100, 100], 500)).toEqual([0, 0]);
  });
});

describe("lineListPrice", () => {
  test("is unit price multiplied by quantity (gross list price)", () => {
    expect(
      lineListPrice(line({ item: item({ unitPrice: 500 }), quantity: 4 })),
    ).toBe(2000);
  });
});