import { Test, type TestingModule } from '@nestjs/testing';
import { BadRequestException, NotFoundException } from '@nestjs/common';
import { ClsService } from 'nestjs-cls';
import { PaymentSettlementService } from './payment-settlement.service';
import { PaymentSettlementRepository } from './repositories/payment-settlement.repository';
import { SettlementPaymentRepository } from './repositories/settlement-payment.repository';
import { BookingSettlementMasterRepository } from './repositories/booking-settlement-master.repository';
import { SettlementAnalyticsRepository } from './repositories/settlement-analytics.repository';
import { PaymentCollectionRepository } from './repositories/payment-collection.repository';
import { CustomerCreditLedgerRepository } from './repositories/customer-credit-ledger.repository';
import { SupplierPayoutRepository } from './repositories/supplier-payout.repository';
import { BankAccountService } from '../bank-account/bank-account.service';
import {
  SettlementParty,
  SettlementStatus,
  SettlementType,
} from '../../entities/payment-settlement.entity';
import { SettlementPaymentMode } from '../../entities/settlement-payment.entity';
import { SettlementPaymentType } from '../../common/constants/settlement-payment-type';
import { PayoutExcessTreatment } from '../../entities/supplier-payout.entity';
import { CreditLedgerPartyType } from '../../entities/customer-credit-ledger.entity';
import { BookingEntity, BookingPaymentStatus } from '../../entities/booking.entity';
import { QueryEntity } from '../../entities/query.entity';
import { TaxSlabResolverService } from '../../common/services/tax-slab-resolver.service';
import { getRepositoryToken } from '@nestjs/typeorm';
import {
  CustomerSettlementStatus,
  SupplierSettlementStatus,
  OverallSettlementStatus,
} from '../../entities/booking-settlement-master.entity';

describe('PaymentSettlementService — Phase B', () => {
  let service: PaymentSettlementService;
  let managerQuery: jest.Mock;
  let settlements: {
    findActiveForBooking: jest.Mock;
    findById: jest.Mock;
    save: jest.Mock;
    update: jest.Mock;
    find: jest.Mock;
    currentTenantId: jest.Mock;
    manager: { query: jest.Mock };
  };
  let payments: {
    findBySettlement: jest.Mock;
    findById: jest.Mock;
    save: jest.Mock;
    update: jest.Mock;
    findByCollection: jest.Mock;
    softDelete: jest.Mock;
  };
  let masters: {
    findActiveByBooking: jest.Mock;
    findById: jest.Mock;
    findDetailById: jest.Mock;
    findPaginatedList: jest.Mock;
    deriveRollup: jest.Mock;
    save: jest.Mock;
    update: jest.Mock;
  };
  let cls: { get: jest.Mock };
  let creditLedger: {
    findByParty: jest.Mock;
    save: jest.Mock;
    update: jest.Mock;
  };
  let payouts: {
    save: jest.Mock;
    currentTenantId: jest.Mock;
    manager: { transaction: jest.Mock; query: jest.Mock };
  };

  const TENANT = 'tenant-1';
  const USER = 'user-1';
  const BOOKING = 'booking-1';

  beforeEach(async () => {
    managerQuery = jest.fn().mockImplementation((sql: string) => {
      // Phase F receipt allocator hits this. Default to seq=1 so the
      // happy-path payment tests don't have to mock receipt math
      // themselves. Tests that care assert on the returned receipt_no.
      if (/INSERT INTO settlement_receipt_sequences/.test(sql)) {
        return Promise.resolve([{ last_number: 1 }]);
      }
      return Promise.resolve(undefined);
    });
    settlements = {
      findActiveForBooking: jest.fn(),
      findById: jest.fn(),
      save: jest.fn(),
      update: jest.fn(),
      find: jest.fn().mockResolvedValue([]),
      currentTenantId: jest.fn().mockReturnValue(TENANT),
      manager: { query: managerQuery },
    };
    payments = {
      findBySettlement: jest.fn(),
      findById: jest.fn(),
      save: jest.fn(),
      update: jest.fn(),
      findByCollection: jest.fn().mockResolvedValue([]),
      softDelete: jest.fn(),
    };
    // Per-booking master mock. Defaults to "no existing master, create
    // returns a fresh PENDING one"; tests that care about HOLD or
    // rollups override these on a per-test basis.
    masters = {
      findActiveByBooking: jest.fn().mockResolvedValue(null),
      findById: jest.fn().mockResolvedValue({
        id: 'master-1',
        overall_status: OverallSettlementStatus.PENDING,
      }),
      findDetailById: jest.fn(),
      findPaginatedList: jest.fn(),
      deriveRollup: jest.fn().mockResolvedValue({
        customer_expected: 0,
        customer_received: 0,
        customer_refund_due: 0,
        customer_status: CustomerSettlementStatus.PENDING,
        supplier_expected: 0,
        supplier_paid: 0,
        supplier_status: SupplierSettlementStatus.PENDING,
        planned_margin: 0,
        realised_margin: 0,
        cost_expected: 0,
        cost_margin: 0,
        overall_status: OverallSettlementStatus.PENDING,
      }),
      save: jest.fn().mockResolvedValue({ id: 'master-1' }),
      update: jest.fn(),
    };
    cls = { get: jest.fn().mockReturnValue(USER) };
    creditLedger = {
      findByParty: jest.fn().mockResolvedValue(null),
      save: jest.fn().mockResolvedValue({ id: 'ledger-1' }),
      update: jest.fn(),
    };
    payouts = {
      save: jest.fn().mockResolvedValue({ id: 'payout-1', voucher_no: 'PAY-2026-0001' }),
      currentTenantId: jest.fn().mockReturnValue(TENANT),
      manager: {
        transaction: jest.fn((cb: (em?: unknown) => unknown) => cb()),
        query: managerQuery,
      },
    };

    const module: TestingModule = await Test.createTestingModule({
      providers: [
        PaymentSettlementService,
        { provide: PaymentSettlementRepository, useValue: settlements },
        { provide: SettlementPaymentRepository, useValue: payments },
        { provide: BookingSettlementMasterRepository, useValue: masters },
        // Analytical aggregations — not exercised by the writer/reader
        // specs in this file; stubbed to satisfy DI.
        {
          provide: SettlementAnalyticsRepository,
          useValue: {
            bySupplier: jest.fn(),
            byAgent: jest.fn(),
            byService: jest.fn(),
          },
        },
        { provide: ClsService, useValue: cls },
        // Step 3h dependencies — stubbed because these spec cases focus
        // on the ledger math, not the slab-resolver path. resolveFor*
        // returns null so the payment is recorded with tax_snapshot=null
        // (matches the legacy behaviour the existing assertions expect).
        {
          provide: TaxSlabResolverService,
          useValue: {
            resolveForHotelOrSpecial: jest.fn().mockResolvedValue(null),
            resolveForTransport: jest.fn().mockResolvedValue(null),
            resolveForActivity: jest.fn().mockResolvedValue(null),
            resolveForSupplier: jest.fn().mockResolvedValue(null),
            loadSnapshot: jest.fn().mockResolvedValue(null),
          },
        },
        {
          provide: getRepositoryToken(BookingEntity),
          useValue: { findOne: jest.fn().mockResolvedValue(null) },
        },
        {
          provide: getRepositoryToken(QueryEntity),
          useValue: { findOne: jest.fn().mockResolvedValue(null) },
        },
        // Phase A — collection writer dep; stubbed because these specs
        // focus on the per-settlement ledger, not the customer-collection
        // path. Phase A's own coverage lives in dedicated cases.
        {
          provide: PaymentCollectionRepository,
          useValue: {
            save: jest.fn(),
            findById: jest.fn(),
            findOne: jest.fn(),
            manager: { transaction: jest.fn((cb: (em?: unknown) => unknown) => cb()), query: managerQuery },
            currentTenantId: jest.fn().mockReturnValue(TENANT),
          },
        },
        {
          provide: CustomerCreditLedgerRepository,
          useValue: creditLedger,
        },
        {
          provide: SupplierPayoutRepository,
          useValue: payouts,
        },
        {
          provide: BankAccountService,
          useValue: {
            requireActive: jest.fn().mockImplementation(async (id: string) => ({ id })),
            findByIdWithBankOrNull: jest.fn().mockResolvedValue(null),
          },
        },
      ],
    }).compile();
    service = module.get(PaymentSettlementService);
  });

  afterEach(() => jest.clearAllMocks());

  // ────────────────────────────────────────────────────────────────────
  // Reads + tenant-isolation contract (carried from Phase A).
  // ────────────────────────────────────────────────────────────────────
  describe('reads forward to tenant-aware repos without tenant hints', () => {
    it('findForBooking forwards bookingId only', async () => {
      settlements.findActiveForBooking.mockResolvedValue([]);
      await service.findForBooking(BOOKING);
      expect(settlements.findActiveForBooking).toHaveBeenCalledWith(BOOKING);
      expect(settlements.findActiveForBooking.mock.calls[0]).toHaveLength(1);
    });

    it('findById forwards id only', async () => {
      settlements.findById.mockResolvedValue(null);
      await service.findById('s1');
      expect(settlements.findById).toHaveBeenCalledWith('s1');
    });

    it('findPayments forwards settlementId only', async () => {
      payments.findBySettlement.mockResolvedValue([]);
      await service.findPayments('s1');
      expect(payments.findBySettlement).toHaveBeenCalledWith('s1');
    });
  });

  // ────────────────────────────────────────────────────────────────────
  // recordFromBooking — initial COLLECTION
  // ────────────────────────────────────────────────────────────────────
  describe('recordFromBooking', () => {
    it('creates a PENDING COLLECTION with source=INITIAL on the happy path', async () => {
      settlements.findActiveForBooking.mockResolvedValueOnce([]); // pre-check
      settlements.save.mockResolvedValueOnce({ id: 's1' });
      settlements.findActiveForBooking.mockResolvedValueOnce([]); // recompute

      const result = await service.recordFromBooking({
        booking_id: BOOKING,
        amount: 1000,
        currency_id: 'cur-1',
        due_date: '2026-07-01',
      });

      expect(result).toEqual({ id: 's1' });
      const payload = settlements.save.mock.calls[0][0];
      expect(payload).toMatchObject({
        booking_id: BOOKING,
        type: SettlementType.COLLECTION,
        amount: 1000,
        amount_settled: 0,
        status: SettlementStatus.PENDING,
        due_date: '2026-07-01',
        source: 'INITIAL',
      });
    });

    it('is idempotent when a settlement already exists on the booking', async () => {
      settlements.findActiveForBooking.mockResolvedValueOnce([{ id: 'existing' }]);
      const result = await service.recordFromBooking({
        booking_id: BOOKING,
        amount: 1000,
        currency_id: 'cur-1',
        due_date: '2026-07-01',
      });
      expect(result).toBeNull();
      expect(settlements.save).not.toHaveBeenCalled();
    });

    it('skips when amount is zero or negative (no row would survive the CHECK)', async () => {
      await service.recordFromBooking({
        booking_id: BOOKING,
        amount: 0,
        currency_id: 'cur-1',
        due_date: '2026-07-01',
      });
      expect(settlements.save).not.toHaveBeenCalled();
    });
  });

  // ────────────────────────────────────────────────────────────────────
  // recordItineraryDelta — Collection on positive, Refund on negative
  // ────────────────────────────────────────────────────────────────────
  describe('recordItineraryDelta', () => {
    it('positive delta → COLLECTION with abs(delta) as amount', async () => {
      settlements.save.mockResolvedValueOnce({ id: 's1' });
      settlements.findActiveForBooking.mockResolvedValue([]);
      await service.recordItineraryDelta({
        booking_id: BOOKING,
        delta: 250,
        currency_id: 'cur-1',
        due_date: '2026-07-01',
      });
      const payload = settlements.save.mock.calls[0][0];
      expect(payload.type).toBe(SettlementType.COLLECTION);
      expect(payload.amount).toBe(250);
      expect(payload.source).toBe('ITINERARY_EDIT');
    });

    it('negative delta → REFUND with abs(delta) as amount', async () => {
      settlements.save.mockResolvedValueOnce({ id: 's2' });
      settlements.findActiveForBooking.mockResolvedValue([]);
      await service.recordItineraryDelta({
        booking_id: BOOKING,
        delta: -100,
        currency_id: 'cur-1',
        due_date: '2026-07-01',
      });
      const payload = settlements.save.mock.calls[0][0];
      expect(payload.type).toBe(SettlementType.REFUND);
      expect(payload.amount).toBe(100);
    });

    it('zero delta → no row, no recompute', async () => {
      await service.recordItineraryDelta({
        booking_id: BOOKING,
        delta: 0,
        currency_id: 'cur-1',
        due_date: '2026-07-01',
      });
      expect(settlements.save).not.toHaveBeenCalled();
      expect(managerQuery).not.toHaveBeenCalled();
    });

    it('carries breakdown + source override + CR id onto the row', async () => {
      settlements.save.mockResolvedValueOnce({ id: 's3' });
      settlements.findActiveForBooking.mockResolvedValue([]);
      await service.recordItineraryDelta({
        booking_id: BOOKING,
        delta: 75,
        currency_id: 'cur-1',
        due_date: '2026-07-01',
        breakdown: { added: [{ name: 'Extra activity' }] },
        source: 'MOVEMENT_CHART',
        triggered_by_change_request_id: 'cr-1',
      });
      const payload = settlements.save.mock.calls[0][0];
      expect(payload.change_breakdown).toEqual({ added: [{ name: 'Extra activity' }] });
      expect(payload.source).toBe('MOVEMENT_CHART');
      expect(payload.triggered_by_change_request_id).toBe('cr-1');
    });
  });

  // ────────────────────────────────────────────────────────────────────
  // recordPayment — partial + full flips + guards
  // ────────────────────────────────────────────────────────────────────
  describe('recordPayment', () => {
    const baseSettlement = {
      id: 's1',
      booking_id: BOOKING,
      amount: 1000,
      amount_settled: 0,
      status: SettlementStatus.PENDING,
      type: SettlementType.COLLECTION,
    };
    const baseInput = {
      paid_amount: 400,
      paid_date: '2026-04-25',
      mode: SettlementPaymentMode.BANK_TRANSFER,
    };

    it('throws when the settlement is missing', async () => {
      settlements.findById.mockResolvedValueOnce(null);
      await expect(service.recordPayment('missing', baseInput)).rejects.toThrow(NotFoundException);
    });

    it('throws when the settlement is already SETTLED', async () => {
      settlements.findById.mockResolvedValueOnce({
        ...baseSettlement,
        status: SettlementStatus.SETTLED,
      });
      await expect(service.recordPayment('s1', baseInput)).rejects.toThrow(BadRequestException);
    });

    it('rejects non-positive paid_amount', async () => {
      settlements.findById.mockResolvedValueOnce(baseSettlement);
      await expect(service.recordPayment('s1', { ...baseInput, paid_amount: 0 })).rejects.toThrow(
        /greater than 0/,
      );
    });

    it('rejects overpayment (paid_amount pushes amount_settled past amount)', async () => {
      settlements.findById.mockResolvedValueOnce({ ...baseSettlement, amount_settled: 700 });
      await expect(service.recordPayment('s1', { ...baseInput, paid_amount: 400 })).rejects.toThrow(
        /exceeds outstanding/,
      );
    });

    it('first partial → status PARTIAL, amount_settled = paid_amount, no settled_date', async () => {
      settlements.findById.mockResolvedValueOnce(baseSettlement);
      settlements.findById.mockResolvedValueOnce({
        ...baseSettlement,
        amount_settled: 400,
        status: SettlementStatus.PARTIAL,
      });
      payments.save.mockResolvedValueOnce({ id: 'p1' });
      settlements.findActiveForBooking.mockResolvedValue([]);

      const result = await service.recordPayment('s1', baseInput);

      // ledger row written
      expect(payments.save.mock.calls[0][0]).toMatchObject({
        settlement_id: 's1',
        paid_amount: 400,
        paid_date: '2026-04-25',
        mode: SettlementPaymentMode.BANK_TRANSFER,
      });
      // parent flipped to PARTIAL
      expect(settlements.update).toHaveBeenCalledWith('s1', {
        amount_settled: 400,
        status: SettlementStatus.PARTIAL,
        settled_date: null,
      });
      expect(result.payment).toEqual({ id: 'p1' });
    });

    it('final payment that reaches the total → status SETTLED + stamps settled_date', async () => {
      settlements.findById.mockResolvedValueOnce({ ...baseSettlement, amount_settled: 600 });
      settlements.findById.mockResolvedValueOnce({
        ...baseSettlement,
        amount_settled: 1000,
        status: SettlementStatus.SETTLED,
        settled_date: '2026-04-25',
      });
      payments.save.mockResolvedValueOnce({ id: 'p2' });
      settlements.findActiveForBooking.mockResolvedValue([]);

      await service.recordPayment('s1', { ...baseInput, paid_amount: 400 });

      expect(settlements.update).toHaveBeenCalledWith('s1', {
        amount_settled: 1000,
        status: SettlementStatus.SETTLED,
        settled_date: '2026-04-25',
      });
    });
  });

  // ────────────────────────────────────────────────────────────────────
  // recomputeBookingPaymentStatus — pill derivation
  // ────────────────────────────────────────────────────────────────────
  describe('recomputeBookingPaymentStatus', () => {
    it('returns UNPAID when no settlements exist', async () => {
      settlements.findActiveForBooking.mockResolvedValue([]);
      const result = await service.recomputeBookingPaymentStatus(BOOKING);
      expect(result).toBe(BookingPaymentStatus.UNPAID);
    });

    it('returns UNPAID when collection exists with no payment yet', async () => {
      settlements.findActiveForBooking.mockResolvedValue([
        {
          type: SettlementType.COLLECTION,
          party_type: SettlementParty.CUSTOMER,
          amount: 1000,
          amount_settled: 0,
        },
      ]);
      const result = await service.recomputeBookingPaymentStatus(BOOKING);
      expect(result).toBe(BookingPaymentStatus.UNPAID);
    });

    it('returns PARTIAL when running total is below expected', async () => {
      settlements.findActiveForBooking.mockResolvedValue([
        {
          type: SettlementType.COLLECTION,
          party_type: SettlementParty.CUSTOMER,
          amount: 1000,
          amount_settled: 400,
        },
      ]);
      const result = await service.recomputeBookingPaymentStatus(BOOKING);
      expect(result).toBe(BookingPaymentStatus.PARTIAL);
    });

    it('returns PAID when running total reaches expected', async () => {
      settlements.findActiveForBooking.mockResolvedValue([
        {
          type: SettlementType.COLLECTION,
          party_type: SettlementParty.CUSTOMER,
          amount: 1000,
          amount_settled: 1000,
        },
      ]);
      const result = await service.recomputeBookingPaymentStatus(BOOKING);
      expect(result).toBe(BookingPaymentStatus.PAID);
    });

    it('REFUND_DUE wins over PAID/PARTIAL when an unpaid CUSTOMER refund exists', async () => {
      settlements.findActiveForBooking.mockResolvedValue([
        {
          type: SettlementType.COLLECTION,
          party_type: SettlementParty.CUSTOMER,
          amount: 1000,
          amount_settled: 1000,
        },
        {
          type: SettlementType.REFUND,
          party_type: SettlementParty.CUSTOMER,
          amount: 200,
          amount_settled: 0,
        },
      ]);
      const result = await service.recomputeBookingPaymentStatus(BOOKING);
      expect(result).toBe(BookingPaymentStatus.REFUND_DUE);
    });

    it('ignores SUPPLIER settlements — a supplier refund does NOT flip the customer pill', async () => {
      // Regression: the booking pill is a CUSTOMER rollup. A supplier
      // payout/refund must not make it REFUND_DUE. Here the customer has
      // an unpaid collection, so the pill stays UNPAID.
      settlements.findActiveForBooking.mockResolvedValue([
        {
          type: SettlementType.COLLECTION,
          party_type: SettlementParty.CUSTOMER,
          amount: 1000,
          amount_settled: 0,
        },
        {
          type: SettlementType.REFUND,
          party_type: SettlementParty.SUPPLIER,
          amount: 617.58,
          amount_settled: 0,
        },
      ]);
      const result = await service.recomputeBookingPaymentStatus(BOOKING);
      expect(result).toBe(BookingPaymentStatus.UNPAID);
    });

    it('persists the rollup via a tenant-scoped UPDATE on bookings', async () => {
      settlements.findActiveForBooking.mockResolvedValue([
        {
          type: SettlementType.COLLECTION,
          party_type: SettlementParty.CUSTOMER,
          amount: 1000,
          amount_settled: 1000,
        },
      ]);
      await service.recomputeBookingPaymentStatus(BOOKING);
      expect(managerQuery).toHaveBeenCalledTimes(1);
      const [, params] = managerQuery.mock.calls[0];
      expect(params).toEqual([BookingPaymentStatus.PAID, USER, BOOKING, TENANT]);
    });
  });

  // ────────────────────────────────────────────────────────────────────
  // Phase F — receipts on every payment
  // ────────────────────────────────────────────────────────────────────
  describe('recordPayment — receipt allocation', () => {
    it('stamps receipt_no = RCP-{year}-{NNNN} on a COLLECTION payment', async () => {
      settlements.findById.mockResolvedValueOnce({
        id: 's1',
        booking_id: BOOKING,
        amount: 1000,
        amount_settled: 0,
        status: SettlementStatus.PENDING,
        type: SettlementType.COLLECTION,
        party_type: 'CUSTOMER',
      });
      settlements.findById.mockResolvedValueOnce({});
      payments.save.mockResolvedValueOnce({ id: 'p1' });
      managerQuery.mockImplementation((sql: string) => {
        if (/INSERT INTO settlement_receipt_sequences/.test(sql)) {
          return Promise.resolve([{ last_number: 42 }]);
        }
        return Promise.resolve(undefined);
      });
      settlements.findActiveForBooking.mockResolvedValue([]);

      await service.recordPayment('s1', {
        paid_amount: 500,
        paid_date: '2026-04-15',
        mode: SettlementPaymentMode.CASH,
      });

      const payload = payments.save.mock.calls[0][0];
      expect(payload.receipt_no).toBe('RCP-2026-0042');
      expect(payload.receipt_year).toBe(2026);
    });

    it('uses RFD prefix for REFUND-type parent', async () => {
      settlements.findById.mockResolvedValueOnce({
        id: 's2',
        booking_id: BOOKING,
        amount: 200,
        amount_settled: 0,
        status: SettlementStatus.PENDING,
        type: SettlementType.REFUND,
        party_type: 'CUSTOMER',
      });
      settlements.findById.mockResolvedValueOnce({});
      payments.save.mockResolvedValueOnce({ id: 'p2' });
      managerQuery.mockImplementation((sql: string) => {
        if (/INSERT INTO settlement_receipt_sequences/.test(sql)) {
          return Promise.resolve([{ last_number: 7 }]);
        }
        return Promise.resolve(undefined);
      });
      settlements.findActiveForBooking.mockResolvedValue([]);

      await service.recordPayment('s2', {
        paid_amount: 200,
        paid_date: '2026-06-01',
        mode: SettlementPaymentMode.BANK_TRANSFER,
      });

      const payload = payments.save.mock.calls[0][0];
      expect(payload.receipt_no).toBe('RFD-2026-0007');
    });

    it('decimal-caps amount_settled at settlement.amount (no CHECK violation)', async () => {
      settlements.findById.mockResolvedValueOnce({
        id: 's3',
        booking_id: BOOKING,
        amount: 1000,
        amount_settled: 666.66,
        status: SettlementStatus.PARTIAL,
        type: SettlementType.COLLECTION,
        party_type: 'CUSTOMER',
      });
      settlements.findById.mockResolvedValueOnce({});
      payments.save.mockResolvedValueOnce({ id: 'p3' });
      settlements.findActiveForBooking.mockResolvedValue([]);

      // Outstanding is 333.34 — paying 333.34 lands raw sum at
      // 1000.00 but with float drift may compute as 1000.0000000001.
      // The Math.min cap clamps it to exactly settlement.amount.
      await service.recordPayment('s3', {
        paid_amount: 333.34,
        paid_date: '2026-06-04',
        mode: SettlementPaymentMode.UPI,
      });

      const updatePayload = settlements.update.mock.calls[0][1];
      expect(updatePayload.amount_settled).toBe(1000);
      expect(updatePayload.status).toBe(SettlementStatus.SETTLED);
    });
  });

  // ────────────────────────────────────────────────────────────────────
  // Phase F — voidPayment (admin only at controller, no gate here)
  // ────────────────────────────────────────────────────────────────────
  describe('voidPayment', () => {
    const PAYMENT_ID = 'p-vd';
    const SETTLEMENT_ID = 's-vd';

    it('throws when payment is missing', async () => {
      payments.findById.mockResolvedValueOnce(null);
      await expect(service.voidPayment(PAYMENT_ID, 'mistake')).rejects.toThrow(NotFoundException);
    });

    it('throws when payment is already voided', async () => {
      payments.findById.mockResolvedValueOnce({ id: PAYMENT_ID, is_void: true });
      await expect(service.voidPayment(PAYMENT_ID, 'mistake')).rejects.toThrow(/already voided/);
    });

    it('requires a reason', async () => {
      payments.findById.mockResolvedValueOnce({ id: PAYMENT_ID, is_void: false });
      await expect(service.voidPayment(PAYMENT_ID, '   ')).rejects.toThrow(
        /void_reason is required/,
      );
    });

    it('stamps is_void + voided_at + voided_by + void_reason, recomputes parent', async () => {
      payments.findById
        .mockResolvedValueOnce({
          id: PAYMENT_ID,
          settlement_id: SETTLEMENT_ID,
          is_void: false,
          paid_amount: 400,
        })
        .mockResolvedValueOnce({ id: PAYMENT_ID, is_void: true });
      settlements.findById
        .mockResolvedValueOnce({
          id: SETTLEMENT_ID,
          booking_id: BOOKING,
          amount: 1000,
          amount_settled: 1000,
          status: SettlementStatus.SETTLED,
          type: SettlementType.COLLECTION,
          party_type: 'CUSTOMER',
        })
        .mockResolvedValueOnce({});
      payments.findBySettlement.mockResolvedValueOnce([
        { id: PAYMENT_ID, paid_amount: 400, is_void: false },
        { id: 'p-other', paid_amount: 600, is_void: false },
      ]);
      settlements.findActiveForBooking.mockResolvedValue([]);

      await service.voidPayment(PAYMENT_ID, 'duplicate entry');

      const args = payments.update.mock.calls[0];
      expect(args[0]).toBe(PAYMENT_ID);
      expect(args[1]).toMatchObject({
        is_void: true,
        voided_by: USER,
        void_reason: 'duplicate entry',
      });
      expect(args[1].voided_at).toBeInstanceOf(Date);

      // Parent rewind: 600 remaining (only the non-voided payment counts)
      const parentArgs = settlements.update.mock.calls[0];
      expect(parentArgs[1]).toMatchObject({
        amount_settled: 600,
        status: SettlementStatus.PARTIAL,
        settled_date: null,
      });
    });

    it('rewinds parent to PENDING when the voided payment was the only one', async () => {
      payments.findById
        .mockResolvedValueOnce({
          id: PAYMENT_ID,
          settlement_id: SETTLEMENT_ID,
          is_void: false,
          paid_amount: 400,
        })
        .mockResolvedValueOnce({ id: PAYMENT_ID, is_void: true });
      settlements.findById
        .mockResolvedValueOnce({
          id: SETTLEMENT_ID,
          booking_id: BOOKING,
          amount: 400,
          amount_settled: 400,
          status: SettlementStatus.SETTLED,
          type: SettlementType.COLLECTION,
          party_type: 'CUSTOMER',
        })
        .mockResolvedValueOnce({});
      payments.findBySettlement.mockResolvedValueOnce([
        { id: PAYMENT_ID, paid_amount: 400, is_void: false },
      ]);
      settlements.findActiveForBooking.mockResolvedValue([]);

      await service.voidPayment(PAYMENT_ID, 'mistake');

      expect(settlements.update.mock.calls[0][1]).toMatchObject({
        amount_settled: 0,
        status: SettlementStatus.PENDING,
        settled_date: null,
      });
    });
  });

  // ────────────────────────────────────────────────────────────────────
  // Phase F — recordSupplierPayable (supplier-side)
  // ────────────────────────────────────────────────────────────────────
  describe('recordSupplierPayable', () => {
    const base = {
      booking_id: BOOKING,
      supplier_id: 'sup-1',
      amount: 1200,
      currency_id: 'cur-1',
      due_date: '2026-07-15',
      source_item_type: 'booking_transport_item',
      source_item_id: 'bti-1',
    };

    it('creates a SUPPLIER + REFUND row tagged with source_item_*', async () => {
      settlements.find.mockResolvedValueOnce([]);
      settlements.save.mockResolvedValueOnce({ id: 'sp1' });

      await service.recordSupplierPayable(base);

      const payload = settlements.save.mock.calls[0][0];
      expect(payload).toMatchObject({
        booking_id: BOOKING,
        type: SettlementType.REFUND, // money out of us
        amount: 1200,
        party_type: 'SUPPLIER',
        supplier_id: 'sup-1',
        source_item_type: 'booking_transport_item',
        source_item_id: 'bti-1',
        source: 'SUPPLIER_ASSIGNMENT',
        status: SettlementStatus.PENDING,
      });
    });

    it('is idempotent — returns the existing row when one is already present for the line', async () => {
      settlements.find.mockResolvedValueOnce([{ id: 'sp-existing' }]);
      const result = await service.recordSupplierPayable(base);
      expect(result).toEqual({ id: 'sp-existing' });
      expect(settlements.save).not.toHaveBeenCalled();
    });

    it('skips when amount is 0 (no negative or zero payable)', async () => {
      const result = await service.recordSupplierPayable({ ...base, amount: 0 });
      expect(result).toBeNull();
      expect(settlements.save).not.toHaveBeenCalled();
    });

    it('does NOT trigger the customer-side booking payment_status recompute', async () => {
      settlements.find.mockResolvedValueOnce([]);
      settlements.save.mockResolvedValueOnce({ id: 'sp2' });
      await service.recordSupplierPayable(base);
      // recompute is only called from the customer-side write paths;
      // managerQuery only logs the receipt sequence reads
      const recomputeUpdates = managerQuery.mock.calls.filter((c) =>
        /UPDATE bookings/.test(c[0] as string),
      );
      expect(recomputeUpdates).toHaveLength(0);
    });
  });

  // ────────────────────────────────────────────────────────────────────
  // §12e — supplier prepayment ledger + explicit payment type
  // ────────────────────────────────────────────────────────────────────
  describe('paySupplier — prepayment ledger', () => {
    const base = {
      supplier_id: 'sup-1',
      supplier_name: 'Desert Tours',
      payment_date: '2026-07-01',
      mode: SettlementPaymentMode.BANK_TRANSFER,
      currency_id: 'cur-1',
      paid_amount: 5000,
      allocations: [],
    };

    it('parks unallocated money on the supplier ledger when treatment is PREPAYMENT', async () => {
      await service.paySupplier({
        ...base,
        payment_type: SettlementPaymentType.ON_ACCOUNT,
        excess_treatment: PayoutExcessTreatment.PREPAYMENT,
      });

      expect(creditLedger.save).toHaveBeenCalledWith(
        expect.objectContaining({
          party_type: CreditLedgerPartyType.SUPPLIER,
          party_key: 'sup-1',
          party_label: 'Desert Tours',
          currency_id: 'cur-1',
          balance: 5000,
        }),
      );
      expect(payouts.save).toHaveBeenCalledWith(
        expect.objectContaining({
          payment_type: SettlementPaymentType.ON_ACCOUNT,
          excess_amount: 5000,
          excess_treatment: PayoutExcessTreatment.PREPAYMENT,
        }),
      );
    });

    it('leaves the ledger alone when the excess is marked ADVANCE', async () => {
      await service.paySupplier({
        ...base,
        excess_treatment: PayoutExcessTreatment.ADVANCE,
      });
      expect(creditLedger.save).not.toHaveBeenCalled();
    });

    it('rejects an excess with no stated treatment', async () => {
      await expect(service.paySupplier(base)).rejects.toBeInstanceOf(BadRequestException);
    });

    it('rejects allocations on an On Account payout', async () => {
      await expect(
        service.paySupplier({
          ...base,
          payment_type: SettlementPaymentType.ON_ACCOUNT,
          allocations: [{ settlement_id: 'ps-1', amount: 100 }],
          excess_treatment: PayoutExcessTreatment.PREPAYMENT,
        }),
      ).rejects.toBeInstanceOf(BadRequestException);
    });

    it('rejects a drawdown larger than the prepayment on file', async () => {
      creditLedger.findByParty.mockResolvedValueOnce({ id: 'ledger-1', balance: 200 });
      await expect(
        service.paySupplier({
          ...base,
          credit_applied: 500,
          excess_treatment: PayoutExcessTreatment.PREPAYMENT,
        }),
      ).rejects.toBeInstanceOf(BadRequestException);
    });

    it('rejects draining and re-parking prepayment on the same voucher', async () => {
      creditLedger.findByParty.mockResolvedValueOnce({ id: 'ledger-1', balance: 900 });
      await expect(
        service.paySupplier({
          ...base,
          credit_applied: 500,
          excess_treatment: PayoutExcessTreatment.PREPAYMENT,
        }),
      ).rejects.toBeInstanceOf(BadRequestException);
    });
  });

  // ────────────────────────────────────────────────────────────────────
  // Phase H — editAmount + appendNote
  // ────────────────────────────────────────────────────────────────────
  describe('editAmount', () => {
    const baseSettlement = {
      id: 's1',
      booking_id: BOOKING,
      amount: 1000,
      amount_settled: 400,
      status: SettlementStatus.PARTIAL,
      type: SettlementType.COLLECTION,
      party_type: 'CUSTOMER',
      notes: null,
      settled_date: null,
    };

    it('throws when the settlement is missing', async () => {
      settlements.findById.mockResolvedValueOnce(null);
      await expect(service.editAmount('missing', 500, 'fix')).rejects.toThrow(NotFoundException);
    });

    it('throws when new amount is below amount_settled', async () => {
      settlements.findById.mockResolvedValueOnce(baseSettlement);
      await expect(service.editAmount('s1', 300, 'reduce')).rejects.toThrow(
        /cannot be less than amount_settled/,
      );
    });

    it('throws when reason is empty', async () => {
      settlements.findById.mockResolvedValueOnce(baseSettlement);
      await expect(service.editAmount('s1', 1500, '   ')).rejects.toThrow(/reason is required/);
    });

    it('reduces to current settled exactly → flips to SETTLED', async () => {
      settlements.findById.mockResolvedValueOnce({ ...baseSettlement, amount_settled: 400 });
      settlements.findById.mockResolvedValueOnce({});
      settlements.findActiveForBooking.mockResolvedValue([]);

      await service.editAmount('s1', 400, 'customer downgraded');

      const upd = settlements.update.mock.calls[0][1];
      expect(upd.amount).toBe(400);
      expect(upd.status).toBe(SettlementStatus.SETTLED);
      expect(upd.notes).toMatch(/Amount edited 1000.00 → 400.00\. Reason: customer downgraded/);
    });

    it('increases amount above settled → flips back to PARTIAL', async () => {
      settlements.findById.mockResolvedValueOnce(baseSettlement);
      settlements.findById.mockResolvedValueOnce({});
      settlements.findActiveForBooking.mockResolvedValue([]);

      await service.editAmount('s1', 1500, 'customer added services');

      const upd = settlements.update.mock.calls[0][1];
      expect(upd.amount).toBe(1500);
      expect(upd.status).toBe(SettlementStatus.PARTIAL);
    });

    it('skips booking recompute on SUPPLIER party rows', async () => {
      settlements.findById.mockResolvedValueOnce({ ...baseSettlement, party_type: 'SUPPLIER' });
      settlements.findById.mockResolvedValueOnce({});

      await service.editAmount('s1', 800, 'supplier renegotiated');

      const recomputeUpdates = managerQuery.mock.calls.filter((c) =>
        /UPDATE bookings/.test(c[0] as string),
      );
      expect(recomputeUpdates).toHaveLength(0);
    });
  });

  describe('appendNote', () => {
    it('appends timestamped + user-tagged entry to settlement.notes', async () => {
      settlements.findById.mockResolvedValueOnce({
        id: 's1',
        notes: 'prior note',
        booking_id: BOOKING,
      });
      settlements.findById.mockResolvedValueOnce({});

      await service.appendNote('s1', 'called customer');

      const upd = settlements.update.mock.calls[0][1];
      expect(upd.notes).toMatch(/prior note\n\[.* · user-1\] called customer/);
    });

    it('throws when text is empty / whitespace', async () => {
      settlements.findById.mockResolvedValueOnce({ id: 's1', notes: null });
      await expect(service.appendNote('s1', '   ')).rejects.toThrow(/note text is required/);
    });
  });

  describe('resolveCollectBankAccountIds', () => {
    const debit = '11111111-1111-4111-8111-111111111111';
    const credit = '22222222-2222-4222-8222-222222222222';

    it('returns nulls when no bank accounts are sent, including CASH', async () => {
      const result = await service.resolveCollectBankAccountIds({
        mode: SettlementPaymentMode.CASH,
      });
      expect(result).toEqual({
        debit_bank_account_id: null,
        credit_bank_account_id: null,
      });
    });

    it('accepts debit only on CASH', async () => {
      const result = await service.resolveCollectBankAccountIds({
        mode: SettlementPaymentMode.CASH,
        debit_bank_account_id: debit,
      });
      expect(result).toEqual({
        debit_bank_account_id: debit,
        credit_bank_account_id: null,
      });
    });

    it('accepts credit only for BANK_TRANSFER', async () => {
      const result = await service.resolveCollectBankAccountIds({
        mode: SettlementPaymentMode.BANK_TRANSFER,
        credit_bank_account_id: credit,
      });
      expect(result).toEqual({
        debit_bank_account_id: null,
        credit_bank_account_id: credit,
      });
    });

    it('accepts neither bank for BANK_TRANSFER', async () => {
      const result = await service.resolveCollectBankAccountIds({
        mode: SettlementPaymentMode.BANK_TRANSFER,
      });
      expect(result).toEqual({
        debit_bank_account_id: null,
        credit_bank_account_id: null,
      });
    });

    it('rejects the same account on both sides', async () => {
      await expect(
        service.resolveCollectBankAccountIds({
          mode: SettlementPaymentMode.BANK_TRANSFER,
          debit_bank_account_id: debit,
          credit_bank_account_id: debit,
        }),
      ).rejects.toThrow(/different bank accounts/);
    });

    it('returns both ids when they are distinct and active', async () => {
      const result = await service.resolveCollectBankAccountIds({
        mode: SettlementPaymentMode.BANK_TRANSFER,
        debit_bank_account_id: debit,
        credit_bank_account_id: credit,
      });
      expect(result).toEqual({
        debit_bank_account_id: debit,
        credit_bank_account_id: credit,
      });
    });
  });

  describe('payment history', () => {
    it('listOfficePaymentHistory scopes SQL by tenant_id', async () => {
      managerQuery.mockResolvedValueOnce([{ total: 0 }]).mockResolvedValueOnce([]);
      await service.listOfficePaymentHistory({ page: 1, limit: 10 });
      expect(String(managerQuery.mock.calls[0][0])).toContain('pc.tenant_id = $1');
      expect(String(managerQuery.mock.calls[0][0])).toContain('bk.is_deleted = true');
      expect(managerQuery.mock.calls[0][1][0]).toBe(TENANT);
    });

    it('updateCollection rejects a missing collection', async () => {
      const collections = (
        service as unknown as { collections: { findById: jest.Mock } }
      ).collections;
      collections.findById.mockResolvedValueOnce(null);
      await expect(
        service.updateCollection('missing', {
          payment_date: '2026-09-01',
          mode: SettlementPaymentMode.CASH,
          currency_id: 'cur-1',
          received_amount: 100,
          allocations: [],
          excess_treatment: 'CREDIT' as never,
        }),
      ).rejects.toThrow(NotFoundException);
    });

    it('updateCollection rejects a voided collection', async () => {
      const collections = (
        service as unknown as { collections: { findById: jest.Mock } }
      ).collections;
      collections.findById.mockResolvedValueOnce({ id: 'pc-1', is_void: true });
      await expect(
        service.updateCollection('pc-1', {
          payment_date: '2026-09-01',
          mode: SettlementPaymentMode.CASH,
          currency_id: 'cur-1',
          received_amount: 100,
          allocations: [{ settlement_id: 's1', amount: 50 }],
        }),
      ).rejects.toThrow(/Voided/);
    });

    it('updateCollection rejects allocations above received + credit', async () => {
      const collections = (
        service as unknown as { collections: { findById: jest.Mock } }
      ).collections;
      collections.findById.mockResolvedValueOnce({
        id: 'pc-1',
        is_void: false,
        party_type: 'CUSTOMER',
        customer_mobile: '999',
      });
      await expect(
        service.updateCollection('pc-1', {
          payment_date: '2026-09-01',
          mode: SettlementPaymentMode.CASH,
          currency_id: 'cur-1',
          received_amount: 10,
          allocations: [{ settlement_id: 's1', amount: 50 }],
        }),
      ).rejects.toThrow(/exceeds received/);
    });
  });
});
