import { Test, type TestingModule } from '@nestjs/testing';
import { BadRequestException, ForbiddenException, NotFoundException } from '@nestjs/common';
import { ClsService } from 'nestjs-cls';
import { BookingCashCollectionService } from './booking-cash-collection.service';
import { BookingCashCollectionRepository } from './repositories/booking-cash-collection.repository';
import { BookingCashCollectionBatchRepository } from './repositories/booking-cash-collection-batch.repository';
import { MdCashCollectionRepository } from './repositories/md-cash-collection.repository';
import { BookingEntity } from '../../entities/booking.entity';
import { QueryEntity } from '../../entities/query.entity';
import { DriverService } from '../driver/driver.service';
import { AuditService } from '../audit/audit.service';
import { PermissionCheckService } from '../../common/permissions/permission-check.service';
import { PaymentSettlementService } from '../payment-settlement/payment-settlement.service';
import { CLS_USER_ID } from '../../common/cls/cls.constants';
import { CollectionPartyType } from '../../entities/payment-collection.entity';
import { SettlementPaymentMode } from '../../entities/settlement-payment.entity';
import { getRepositoryToken } from '@nestjs/typeorm';

describe('BookingCashCollectionService', () => {
  let service: BookingCashCollectionService;
  let repo: {
    findLiveByLine: jest.Mock;
    findLiveUnscoped: jest.Mock;
    findLiveUnscopedByBooking: jest.Mock;
    findWithRelations: jest.Mock;
    findById: jest.Mock;
    save: jest.Mock;
    update: jest.Mock;
    manager: { query: jest.Mock };
  };
  let mdCash: {
    findById: jest.Mock;
    findPendingByBooking: jest.Mock;
    save: jest.Mock;
    update: jest.Mock;
  };
  let batches: {
    findPendingByCollection: jest.Mock;
    findById: jest.Mock;
    save: jest.Mock;
    update: jest.Mock;
    manager: { query: jest.Mock };
  };
  let bookingRepo: { findOne: jest.Mock };
  let queryRepo: { findOne: jest.Mock };
  let drivers: { findOrCreateByName: jest.Mock };
  let audit: { log: jest.Mock; diffAndLog: jest.Mock };
  let perms: { hasAtLeast: jest.Mock };
  let cls: { get: jest.Mock };
  let settlementsService: {
    collectPayment: jest.Mock;
    voidPayment: jest.Mock;
    assertExactCollectAllocations: jest.Mock;
    resolveCollectBankAccountIds: jest.Mock;
    updateCollection: jest.Mock;
  };

  const TENANT = 'tenant-1';
  const USER = 'user-1';
  const BOOKING = 'booking-1';
  const QUERY = 'query-1';
  const DEST = 'dest-1';
  const CURRENCY = 'currency-1';
  const SUPPLIER = 'supplier-1';
  const DRIVER = 'driver-1';
  const DATE = '2026-12-31';
  const TRANSPORT_ITEM = 'transport-item-1';
  const TRANSPORT_ITEM_B = 'transport-item-2';
  const SETTLEMENT = 'settlement-1';

  const expenseRow = (over: Record<string, unknown> = {}) => ({
    id: 'bcc-1',
    booking_id: BOOKING,
    collection_date: DATE,
    transport_item_id: TRANSPORT_ITEM,
    activity_item_id: null,
    special_item_id: null,
    currency_id: CURRENCY,
    expense_collected_by_type: 'OWN' as const,
    expense_collector_name: 'Ravi',
    expense_supplier_id: null,
    expense_source_kind: null,
    expense_activity_id: null,
    expense_driver_id: null,
    expense_expected_amount: 500,
    expense_notes: 'Entry ticket',
    expense_status: 'PENDING' as const,
    expense_actual_amount: null,
    expense_collected_at: null,
    expense_difference_reason: null,
    expense_collected_by: null,
    cash_expected_amount: 0,
    cash_status: null,
    cash_actual_amount: null,
    cash_collected_by_type: null,
    cash_collector_name: null,
    settlement_payment_id: null,
    currency: { code: 'INR' },
    ...over,
  });

  beforeEach(async () => {
    repo = {
      findLiveByLine: jest.fn().mockResolvedValue(null),
      findLiveUnscoped: jest.fn().mockResolvedValue(null),
      findLiveUnscopedByBooking: jest.fn().mockResolvedValue(null),
      findWithRelations: jest.fn().mockResolvedValue(null),
      findById: jest.fn().mockResolvedValue(null),
      save: jest.fn().mockImplementation(async (row) => ({ id: 'bcc-1', ...row })),
      update: jest.fn().mockResolvedValue(undefined),
      manager: { query: jest.fn().mockResolvedValue([]) },
    };
    batches = {
      findPendingByCollection: jest.fn().mockResolvedValue([]),
      findById: jest.fn().mockResolvedValue(null),
      save: jest.fn().mockResolvedValue({ id: 'batch-1' }),
      update: jest.fn().mockResolvedValue(undefined),
      manager: { query: jest.fn().mockResolvedValue([]) },
    };
    mdCash = {
      findById: jest.fn().mockResolvedValue(null),
      findPendingByBooking: jest.fn().mockResolvedValue([]),
      save: jest.fn().mockImplementation(async (row) => ({ id: 'md-1', ...row })),
      update: jest.fn().mockResolvedValue(undefined),
    };
    bookingRepo = {
      findOne: jest.fn().mockResolvedValue({ id: BOOKING, query_id: QUERY, tenant_id: TENANT }),
    };
    queryRepo = {
      findOne: jest.fn().mockResolvedValue({
        id: QUERY,
        destination_id: DEST,
        guest_mobile: '999',
        guest_name: 'Guest',
      }),
    };
    drivers = {
      findOrCreateByName: jest.fn().mockResolvedValue({ id: DRIVER, name: 'Ali', created: true }),
    };
    audit = { log: jest.fn().mockResolvedValue(null), diffAndLog: jest.fn().mockResolvedValue(null) };
    perms = { hasAtLeast: jest.fn().mockResolvedValue(true) };
    cls = {
      get: jest.fn((key: string) => (key === CLS_USER_ID ? USER : TENANT)),
    };
    settlementsService = {
      collectPayment: jest.fn().mockResolvedValue({
        collection: { id: 'pc-1', receipt_no: 'RCP-1' },
        payments: [],
      }),
      voidPayment: jest.fn().mockResolvedValue(undefined),
      assertExactCollectAllocations: jest.fn().mockResolvedValue(undefined),
      resolveCollectBankAccountIds: jest.fn().mockResolvedValue({
        debit_bank_account_id: null,
        credit_bank_account_id: null,
      }),
      updateCollection: jest.fn(),
    };

    const module: TestingModule = await Test.createTestingModule({
      providers: [
        BookingCashCollectionService,
        { provide: BookingCashCollectionRepository, useValue: repo },
        { provide: BookingCashCollectionBatchRepository, useValue: batches },
        { provide: MdCashCollectionRepository, useValue: mdCash },
        { provide: getRepositoryToken(BookingEntity), useValue: bookingRepo },
        { provide: getRepositoryToken(QueryEntity), useValue: queryRepo },
        { provide: DriverService, useValue: drivers },
        { provide: AuditService, useValue: audit },
        { provide: PermissionCheckService, useValue: perms },
        { provide: ClsService, useValue: cls },
        { provide: PaymentSettlementService, useValue: settlementsService },
      ],
    }).compile();

    service = module.get(BookingCashCollectionService);
  });

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

  describe('tenant isolation', () => {
    it('findByLine throws NotFound when the booking is not in this tenant', async () => {
      bookingRepo.findOne.mockResolvedValue(null);
      await expect(
        service.findByLine('other-tenant-booking', DATE, {
          kind: 'TRANSPORT',
          item_id: TRANSPORT_ITEM,
        }),
      ).rejects.toThrow(NotFoundException);
      expect(bookingRepo.findOne).toHaveBeenCalledWith({
        where: { id: 'other-tenant-booking', tenant_id: TENANT, is_deleted: false },
      });
    });
  });

  describe('upsert OWN (expense)', () => {
    it('saves expense collector_name, expected amount, and clears supplier/driver', async () => {
      repo.manager.query
        .mockResolvedValueOnce([{ currency_id: CURRENCY }])
        .mockResolvedValueOnce([{ id: TRANSPORT_ITEM }]);
      const saved = expenseRow();
      repo.save.mockResolvedValue(saved);
      repo.findLiveByLine.mockResolvedValueOnce(null).mockResolvedValueOnce(saved);

      const result = await service.upsert({
        booking_id: BOOKING,
        collection_date: DATE,
        transport_item_id: TRANSPORT_ITEM,
        currency_id: CURRENCY,
        expected_amount: 500,
        notes: 'Entry ticket',
        collected_by_type: 'OWN',
        collector_name: '  Ravi  ',
      });

      expect(repo.save).toHaveBeenCalledWith(
        expect.objectContaining({
          booking_id: BOOKING,
          collection_date: DATE,
          transport_item_id: TRANSPORT_ITEM,
          expense_collected_by_type: 'OWN',
          expense_collector_name: 'Ravi',
          expense_supplier_id: null,
          expense_driver_id: null,
          expense_expected_amount: 500,
          expense_status: 'PENDING',
        }),
      );
      expect(result.collector_name).toBe('Ravi');
      expect(result.expected_amount).toBe(500);
      expect(result.status).toBe('PENDING');
      expect(result.has_expense).toBe(true);
      expect(drivers.findOrCreateByName).not.toHaveBeenCalled();
    });

    it('reopens COLLECTED expense to PENDING without touching the invoice ledger', async () => {
      const collected = expenseRow({
        expense_status: 'COLLECTED',
        expense_expected_amount: 50,
        expense_actual_amount: 12,
        expense_collected_at: new Date('2026-08-24T06:58:00.000Z'),
        expense_difference_reason: 'sdfsd',
        expense_collected_by: USER,
        settlement_payment_id: 'sp-1',
        currency: { code: 'AED' },
      });
      const reopened = expenseRow({
        expense_status: 'PENDING',
        expense_expected_amount: 80,
        expense_actual_amount: null,
        expense_collected_at: null,
        expense_difference_reason: null,
        expense_collected_by: null,
        settlement_payment_id: null,
        currency: { code: 'AED' },
      });
      repo.manager.query
        .mockResolvedValueOnce([{ currency_id: CURRENCY }])
        .mockResolvedValueOnce([{ id: TRANSPORT_ITEM }]);
      repo.findLiveByLine.mockResolvedValueOnce(collected).mockResolvedValueOnce(reopened);

      const result = await service.upsert({
        booking_id: BOOKING,
        collection_date: DATE,
        transport_item_id: TRANSPORT_ITEM,
        currency_id: CURRENCY,
        expected_amount: 80,
        collected_by_type: 'OWN',
        collector_name: 'Ravi',
      });

      expect(settlementsService.voidPayment).not.toHaveBeenCalled();
      expect(settlementsService.collectPayment).not.toHaveBeenCalled();
      expect(repo.update).toHaveBeenCalledWith(
        'bcc-1',
        expect.objectContaining({
          expense_status: 'PENDING',
          expense_expected_amount: 80,
          expense_actual_amount: null,
          expense_collected_at: null,
          expense_difference_reason: null,
          expense_collected_by: null,
          settlement_payment_id: null,
        }),
      );
      expect(result.status).toBe('PENDING');
      expect(result.actual_amount).toBeNull();
    });

    it('does not reopen a PENDING expense on edit', async () => {
      const pending = expenseRow({
        expense_expected_amount: 50,
        currency: { code: 'AED' },
      });
      const updated = expenseRow({
        expense_expected_amount: 90,
        currency: { code: 'AED' },
      });
      repo.manager.query
        .mockResolvedValueOnce([{ currency_id: CURRENCY }])
        .mockResolvedValueOnce([{ id: TRANSPORT_ITEM }]);
      repo.findLiveByLine.mockResolvedValueOnce(pending).mockResolvedValueOnce(updated);

      await service.upsert({
        booking_id: BOOKING,
        collection_date: DATE,
        transport_item_id: TRANSPORT_ITEM,
        currency_id: CURRENCY,
        expected_amount: 90,
        collected_by_type: 'OWN',
        collector_name: 'Ravi',
      });

      expect(settlementsService.voidPayment).not.toHaveBeenCalled();
      expect(repo.update).toHaveBeenCalledWith(
        'bcc-1',
        expect.objectContaining({ expense_expected_amount: 90 }),
      );
      expect(repo.update).toHaveBeenCalledWith(
        'bcc-1',
        expect.not.objectContaining({ expense_actual_amount: null }),
      );
    });

    it('rejects a blank collector name', async () => {
      repo.manager.query
        .mockResolvedValueOnce([{ currency_id: CURRENCY }])
        .mockResolvedValueOnce([{ id: TRANSPORT_ITEM }]);
      await expect(
        service.upsert({
          booking_id: BOOKING,
          collection_date: DATE,
          transport_item_id: TRANSPORT_ITEM,
          currency_id: CURRENCY,
          expected_amount: 100,
          collected_by_type: 'OWN',
          collector_name: '   ',
        }),
      ).rejects.toThrow(BadRequestException);
    });

    it('rejects upsert without a route item', async () => {
      repo.manager.query.mockResolvedValueOnce([{ currency_id: CURRENCY }]);
      await expect(
        service.upsert({
          booking_id: BOOKING,
          collection_date: DATE,
          currency_id: CURRENCY,
          expected_amount: 100,
          collected_by_type: 'OWN',
          collector_name: 'Ravi',
        }),
      ).rejects.toThrow(BadRequestException);
      expect(repo.save).not.toHaveBeenCalled();
    });

    it('looks up each transport route independently', async () => {
      repo.manager.query
        .mockResolvedValueOnce([{ currency_id: CURRENCY }])
        .mockResolvedValueOnce([{ id: TRANSPORT_ITEM_B }]);
      repo.findLiveByLine.mockResolvedValue(null);
      repo.save.mockResolvedValue(
        expenseRow({
          id: 'bcc-2',
          transport_item_id: TRANSPORT_ITEM_B,
          expense_expected_amount: 10,
          expense_notes: null,
          currency: { code: 'AED' },
        }),
      );

      await service.upsert({
        booking_id: BOOKING,
        collection_date: DATE,
        transport_item_id: TRANSPORT_ITEM_B,
        currency_id: CURRENCY,
        expected_amount: 10,
        collected_by_type: 'OWN',
        collector_name: 'Ravi',
      });

      expect(repo.findLiveByLine).toHaveBeenCalledWith({
        kind: 'TRANSPORT',
        item_id: TRANSPORT_ITEM_B,
      });
    });
  });

  describe('upsert SUPPLIER (expense)', () => {
    it('find-or-creates a typed driver name for the supplier', async () => {
      repo.manager.query
        .mockResolvedValueOnce([{ currency_id: CURRENCY }])
        .mockResolvedValueOnce([{ id: TRANSPORT_ITEM }])
        .mockResolvedValueOnce([{ ok: 1 }]);
      const saved = expenseRow({
        expense_collected_by_type: 'SUPPLIER',
        expense_collector_name: null,
        expense_supplier_id: SUPPLIER,
        expense_source_kind: 'TRANSPORT',
        expense_driver_id: DRIVER,
        expense_expected_amount: 250,
        expense_notes: null,
        currency: { code: 'AED' },
        expense_supplier: { name: 'Desert Cars' },
        expense_driver: { name: 'Ali' },
      });
      repo.save.mockResolvedValue(saved);
      repo.findLiveByLine.mockResolvedValueOnce(null).mockResolvedValueOnce(saved);

      const result = await service.upsert({
        booking_id: BOOKING,
        collection_date: DATE,
        transport_item_id: TRANSPORT_ITEM,
        currency_id: CURRENCY,
        expected_amount: 250,
        collected_by_type: 'SUPPLIER',
        supplier_id: SUPPLIER,
        source_kind: 'TRANSPORT',
        driver_name: 'Ali',
      });

      expect(drivers.findOrCreateByName).toHaveBeenCalledWith({
        name: 'Ali',
        supplier_id: SUPPLIER,
        destination_id: DEST,
      });
      expect(result.driver_id).toBe(DRIVER);
      expect(result.collected_by_type).toBe('SUPPLIER');
    });

    it('rejects a supplier that is not on the booking', async () => {
      repo.manager.query
        .mockResolvedValueOnce([{ currency_id: CURRENCY }])
        .mockResolvedValueOnce([{ id: TRANSPORT_ITEM }])
        .mockResolvedValueOnce([]);
      await expect(
        service.upsert({
          booking_id: BOOKING,
          collection_date: DATE,
          transport_item_id: TRANSPORT_ITEM,
          currency_id: CURRENCY,
          expected_amount: 250,
          collected_by_type: 'SUPPLIER',
          supplier_id: SUPPLIER,
          source_kind: 'TRANSPORT',
          driver_name: 'Ali',
        }),
      ).rejects.toThrow(BadRequestException);
      expect(drivers.findOrCreateByName).not.toHaveBeenCalled();
    });
  });

  describe('expense confirm / void / cancel — no ledger write', () => {
    const pending = expenseRow({
      expense_expected_amount: 100,
      expense_notes: null,
    });

    it('confirms a pending expense without calling collectPayment', async () => {
      repo.findById.mockResolvedValue(pending);
      const collected = expenseRow({
        expense_status: 'COLLECTED',
        expense_expected_amount: 100,
        expense_actual_amount: 80,
        expense_collected_at: new Date('2026-08-21T10:00:00.000Z'),
        expense_difference_reason: 'Guest paid less',
        expense_collected_by: USER,
        expense_notes: null,
      });
      repo.findWithRelations.mockResolvedValue(collected);
      repo.manager.query.mockResolvedValueOnce([{ currency_id: CURRENCY }]);

      const result = await service.confirm('bcc-1', {
        actual_amount: 80,
        collected_at: '2026-08-21T10:00:00.000Z',
        difference_reason: 'Guest paid less',
      });

      expect(settlementsService.collectPayment).not.toHaveBeenCalled();
      expect(repo.update).toHaveBeenCalledWith(
        'bcc-1',
        expect.objectContaining({
          expense_status: 'COLLECTED',
          expense_actual_amount: 80,
          expense_difference_reason: 'Guest paid less',
          expense_collected_by: USER,
          settlement_payment_id: null,
        }),
      );
      expect(result.status).toBe('COLLECTED');
      expect(result.actual_amount).toBe(80);
    });

    it('rejects confirm when actual differs from expected without a reason', async () => {
      repo.findById.mockResolvedValue(pending);
      await expect(
        service.confirm('bcc-1', {
          actual_amount: 80,
          collected_at: '2026-08-21T10:00:00.000Z',
        }),
      ).rejects.toThrow(BadRequestException);
    });

    it('voids a collected expense back to PENDING without voidPayment', async () => {
      const collected = expenseRow({
        expense_status: 'COLLECTED',
        expense_expected_amount: 100,
        expense_actual_amount: 100,
        expense_collected_at: new Date(),
        settlement_payment_id: 'sp-1',
        expense_notes: null,
      });
      repo.findById.mockResolvedValue(collected);
      repo.findWithRelations.mockResolvedValue(
        expenseRow({
          expense_status: 'PENDING',
          expense_expected_amount: 100,
          expense_actual_amount: null,
          expense_notes: null,
        }),
      );
      repo.manager.query.mockResolvedValueOnce([{ currency_id: CURRENCY }]);

      const result = await service.voidLatestConfirmation('bcc-1', { reason: 'Mistake' });

      expect(settlementsService.voidPayment).not.toHaveBeenCalled();
      expect(repo.update).toHaveBeenCalledWith(
        'bcc-1',
        expect.objectContaining({
          expense_status: 'PENDING',
          expense_actual_amount: null,
          settlement_payment_id: null,
        }),
      );
      expect(result.status).toBe('PENDING');
    });

    it('cancels a pending expense without wiping cash columns', async () => {
      repo.findById.mockResolvedValueOnce(pending).mockResolvedValueOnce(
        expenseRow({
          expense_status: 'CANCELLED',
          expense_expected_amount: 0,
          expense_notes: null,
          cash_expected_amount: 1000,
          cash_status: 'PENDING',
        }),
      );
      repo.manager.query.mockResolvedValueOnce([{ currency_id: CURRENCY }]);

      const result = await service.cancel('bcc-1');

      expect(repo.update).toHaveBeenCalledWith(
        'bcc-1',
        expect.objectContaining({
          expense_status: 'CANCELLED',
          expense_expected_amount: 0,
        }),
      );
      expect(repo.update).toHaveBeenCalledWith(
        'bcc-1',
        expect.not.objectContaining({ cash_expected_amount: 0 }),
      );
      expect(result.status).toBeNull();
    });

    it('rejects cancel while collected', async () => {
      repo.findById.mockResolvedValue(expenseRow({ expense_status: 'COLLECTED' }));
      await expect(service.cancel('bcc-1')).rejects.toThrow(BadRequestException);
    });
  });

  describe('stampFromCollect / confirmCash', () => {
    const mdDto = {
      booking_id: BOOKING,
      party_type: CollectionPartyType.CUSTOMER,
      customer_mobile: '999',
      customer_name: 'Guest',
      payment_date: DATE,
      mode: SettlementPaymentMode.CASH,
      currency_id: CURRENCY,
      received_amount: 1000,
      collected_by_type: 'OWN' as const,
      collector_name: 'Priya',
    };

    it('saves a pending md_cash_collections row and does not touch expense rows', async () => {
      repo.manager.query.mockResolvedValueOnce([{ currency_id: CURRENCY }]);

      const result = await service.stampFromCollect(mdDto);

      expect(mdCash.save).toHaveBeenCalledWith(
        expect.objectContaining({
          booking_id: BOOKING,
          currency_id: CURRENCY,
          amount: 1000,
          status: 'PENDING',
          collected_by_type: 'OWN',
          collector_name: 'Priya',
          supplier_id: null,
          driver_id: null,
        }),
      );
      expect(repo.save).not.toHaveBeenCalled();
      expect(batches.save).not.toHaveBeenCalled();
      expect(settlementsService.collectPayment).not.toHaveBeenCalled();
      expect(result.has_cash).toBe(true);
      expect(result.cash_status).toBe('PENDING');
      expect(result.id).toBe('md-1');
    });

    it('stores a created driver id when the supplier driver is typed', async () => {
      repo.manager.query
        .mockResolvedValueOnce([{ currency_id: CURRENCY }])
        .mockResolvedValueOnce([{ id: SUPPLIER }]);

      await service.stampFromCollect({
        ...mdDto,
        collected_by_type: 'SUPPLIER',
        collector_name: undefined,
        supplier_id: SUPPLIER,
        driver_name: 'Ali',
      });

      expect(drivers.findOrCreateByName).toHaveBeenCalledWith({
        name: 'Ali',
        supplier_id: SUPPLIER,
        destination_id: DEST,
      });
      expect(mdCash.save).toHaveBeenCalledWith(
        expect.objectContaining({
          collected_by_type: 'SUPPLIER',
          supplier_id: SUPPLIER,
          driver_id: DRIVER,
          collector_name: null,
        }),
      );
    });

    it('confirmCash marks pending md rows collected and does not post a receipt', async () => {
      mdCash.findById.mockResolvedValue({
        id: 'md-1',
        booking_id: BOOKING,
        currency_id: CURRENCY,
        amount: 1000,
        status: 'PENDING',
        collected_by_type: 'OWN',
        collector_name: 'Priya',
        supplier_id: null,
        driver_id: null,
      });
      repo.manager.query.mockResolvedValueOnce([{ currency_id: CURRENCY }]);

      const result = await service.confirmCash('md-1');

      expect(mdCash.update).toHaveBeenCalledTimes(1);
      expect(mdCash.update).toHaveBeenCalledWith(
        'md-1',
        expect.objectContaining({ status: 'COLLECTED' }),
      );
      expect(settlementsService.collectPayment).not.toHaveBeenCalled();
      expect(repo.update).not.toHaveBeenCalled();
      expect(result.collection_id).toBeNull();
      expect(result.row.cash_status).toBe('COLLECTED');
    });

    it('confirmCash still collects a legacy booking_cash_collections row', async () => {
      mdCash.findById.mockResolvedValue(null);
      const row = expenseRow({
        expense_expected_amount: 0,
        expense_status: null,
        cash_expected_amount: 15000,
        cash_status: 'PENDING',
        cash_collected_by_type: 'OWN',
        cash_collector_name: 'Priya',
      });
      repo.findById.mockResolvedValue(row);
      batches.findPendingByCollection.mockResolvedValue([]);
      repo.findWithRelations.mockResolvedValue({
        ...row,
        cash_status: 'COLLECTED',
        cash_actual_amount: 15000,
      });
      repo.manager.query.mockResolvedValueOnce([{ currency_id: CURRENCY }]);

      const result = await service.confirmCash('bcc-1');

      expect(repo.update).toHaveBeenCalledWith(
        'bcc-1',
        expect.objectContaining({ cash_status: 'COLLECTED' }),
      );
      expect(result.row.cash_status).toBe('COLLECTED');
    });
  });

  describe('permissions', () => {
    it('rejects upsert without PAYMENT_COLLECTION:edit', async () => {
      perms.hasAtLeast.mockResolvedValue(false);
      repo.manager.query
        .mockResolvedValueOnce([{ currency_id: CURRENCY }])
        .mockResolvedValueOnce([{ id: TRANSPORT_ITEM }]);
      await expect(
        service.upsert({
          booking_id: BOOKING,
          collection_date: DATE,
          transport_item_id: TRANSPORT_ITEM,
          currency_id: CURRENCY,
          expected_amount: 100,
          collected_by_type: 'OWN',
          collector_name: 'Ravi',
        }),
      ).rejects.toThrow(ForbiddenException);
    });
  });

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

    it('updateFromCollect rejects a missing batch', async () => {
      batches.findById.mockResolvedValueOnce(null);
      await expect(
        service.updateFromCollect('missing', {
          kind: 'MD',
          payment_date: DATE,
          mode: SettlementPaymentMode.CASH,
          currency_id: CURRENCY,
          received_amount: 100,
          allocations: [{ settlement_id: SETTLEMENT, amount: 100 }],
        }),
      ).rejects.toThrow(NotFoundException);
    });

    it('updateFromCollect rejects a posted MD batch', async () => {
      batches.findById.mockResolvedValueOnce({
        id: 'batch-1',
        status: 'POSTED',
        booking_cash_collection_id: 'bcc-1',
      });
      repo.findById.mockResolvedValueOnce({ id: 'bcc-1', cash_status: 'COLLECTED' });
      await expect(
        service.updateFromCollect('batch-1', {
          kind: 'MD',
          payment_date: DATE,
          mode: SettlementPaymentMode.CASH,
          currency_id: CURRENCY,
          received_amount: 100,
          allocations: [{ settlement_id: SETTLEMENT, amount: 100 }],
        }),
      ).rejects.toThrow(BadRequestException);
      expect(settlementsService.assertExactCollectAllocations).not.toHaveBeenCalled();
      expect(settlementsService.updateCollection).not.toHaveBeenCalled();
    });
  });
});
