import {
  BadRequestException,
  ForbiddenException,
  Injectable,
  NotFoundException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { ClsService } from 'nestjs-cls';
import { BookingCashCollectionRepository, type CashCollectionLine } from './repositories/booking-cash-collection.repository';
import { BookingCashCollectionBatchRepository } from './repositories/booking-cash-collection-batch.repository';
import { MdCashCollectionRepository } from './repositories/md-cash-collection.repository';
import { MdCashCollectionEntity } from '../../entities/md-cash-collection.entity';
import { BookingCashCollectionEntity } from '../../entities/booking-cash-collection.entity';
import { BookingEntity } from '../../entities/booking.entity';
import { QueryEntity } from '../../entities/query.entity';
import { UpsertBookingCashCollectionDto } from './dto/upsert-booking-cash-collection.dto';
import { ConfirmCollectionDto } from './dto/confirm-collection.dto';
import { VoidConfirmationDto } from './dto/void-confirmation.dto';
import { DriverService } from '../driver/driver.service';
import { AuditService } from '../audit/audit.service';
import { PermissionCheckService } from '../../common/permissions/permission-check.service';
import { AccessLevel, ModuleName } from '../../entities/role-permission.entity';
import { CLS_TENANT_ID, CLS_USER_ID } from '../../common/cls/cls.constants';
import { CollectForMdDto } from '../payment-settlement/dto/collect-for-md.dto';
import { UpdatePaymentHistoryDto } from '../payment-settlement/dto/update-payment-history.dto';
import {
  PaymentSettlementService,
  type PaymentHistoryDetail,
  type PaymentHistoryListMeta,
  type PaymentHistoryListRow,
} from '../payment-settlement/payment-settlement.service';
import { CollectionPartyType } from '../../entities/payment-collection.entity';
import { SettlementPaymentType } from '../../common/constants/settlement-payment-type';
import { OpStatus, OP_STATUS_TERMINAL } from '../../common/constants/op-status';

export interface BookingCashCollectionParty {
  source_kind: 'TRANSPORT' | 'ACTIVITY' | 'SPECIAL';
  supplier_id: string;
  supplier_name: string;
  activity_id: string | null;
  activity_name: string | null;
  label: string;
}

export interface BookingCashCollectionView {
  id: string;
  booking_id: string;
  transport_item_id: string | null;
  activity_item_id: string | null;
  special_item_id: string | null;
  currency_id: string;
  currency_code: string;
  destination_id: string | null;
  collection_date: string;
  has_expense: boolean;
  has_cash: boolean;
  /** Expense (former 💰 Cash) — off-invoice. */
  collected_by_type: 'OWN' | 'SUPPLIER' | null;
  collector_name: string | null;
  supplier_id: string | null;
  supplier_name: string | null;
  source_kind: 'TRANSPORT' | 'ACTIVITY' | 'SPECIAL' | null;
  activity_id: string | null;
  activity_name: string | null;
  driver_id: string | null;
  driver_name: string | null;
  expected_amount: number;
  notes: string | null;
  status: 'PENDING' | 'COLLECTED' | 'CANCELLED' | null;
  actual_amount: number | null;
  collected_at: string | null;
  difference_reason: string | null;
  collected_by: string | null;
  collected_by_name: string | null;
  /** Collect-for-MD cash on this route. */
  cash_expected_amount: number;
  cash_status: 'PENDING' | 'COLLECTED' | null;
  cash_actual_amount: number | null;
  cash_collected_by_type: 'OWN' | 'SUPPLIER' | null;
  cash_collector_name: string | null;
}

interface PartyRow {
  source_kind: 'TRANSPORT' | 'ACTIVITY' | 'SPECIAL';
  supplier_id: string;
  supplier_name: string | null;
  activity_id: string | null;
  activity_name: string | null;
}

@Injectable()
export class BookingCashCollectionService {
  constructor(
    private readonly repo: BookingCashCollectionRepository,
    private readonly batches: BookingCashCollectionBatchRepository,
    private readonly mdCash: MdCashCollectionRepository,
    @InjectRepository(BookingEntity)
    private readonly bookingRepo: Repository<BookingEntity>,
    @InjectRepository(QueryEntity)
    private readonly queryRepo: Repository<QueryEntity>,
    private readonly drivers: DriverService,
    private readonly audit: AuditService,
    private readonly perms: PermissionCheckService,
    private readonly cls: ClsService,
    private readonly settlementsService: PaymentSettlementService,
  ) {}

  async findByLine(
    bookingId: string,
    collectionDate: string,
    line: CashCollectionLine,
  ): Promise<BookingCashCollectionView | null> {
    this.requireDate(collectionDate);
    const booking = await this.requireBooking(bookingId);
    const row = await this.repo.findLiveByLine(line);
    if (!row || row.booking_id !== booking.id) return null;
    return this.toView(row, booking.destination_id);
  }

  async listParties(bookingId: string): Promise<{
    destination_id: string | null;
    default_currency_id: string | null;
    parties: BookingCashCollectionParty[];
  }> {
    const booking = await this.requireBooking(bookingId);
    const tenantId = this.tenantId();
    const rows: PartyRow[] = await this.repo.manager.query(
      `
      SELECT DISTINCT
        'TRANSPORT'::varchar AS source_kind,
        COALESCE(j.supplier_id, ti.supplier_id) AS supplier_id,
        COALESCE(s.name, s2.name, j.supplier_name_text, ti.supplier_name_text) AS supplier_name,
        NULL::uuid AS activity_id,
        NULL::varchar AS activity_name
      FROM booking_transport_items ti
      LEFT JOIN booking_transport_item_suppliers j
        ON j.transport_item_id = ti.id AND j.is_deleted = false AND j.tenant_id = $2
      LEFT JOIN suppliers s ON s.id = j.supplier_id
      LEFT JOIN suppliers s2 ON s2.id = ti.supplier_id
      WHERE ti.booking_id = $1
        AND ti.tenant_id = $2
        AND ti.is_deleted = false
        AND COALESCE(j.supplier_id, ti.supplier_id) IS NOT NULL

      UNION ALL

      SELECT DISTINCT
        'ACTIVITY'::varchar AS source_kind,
        COALESCE(j.supplier_id, ai.supplier_id) AS supplier_id,
        COALESCE(s.name, s2.name, j.supplier_name_text, ai.supplier_name_text) AS supplier_name,
        ai.activity_id,
        a.name AS activity_name
      FROM booking_activity_items ai
      LEFT JOIN booking_activity_item_suppliers j
        ON j.activity_item_id = ai.id AND j.is_deleted = false AND j.tenant_id = $2
      LEFT JOIN suppliers s ON s.id = j.supplier_id
      LEFT JOIN suppliers s2 ON s2.id = ai.supplier_id
      LEFT JOIN activities a ON a.id = ai.activity_id
      WHERE ai.booking_id = $1
        AND ai.tenant_id = $2
        AND ai.is_deleted = false
        AND COALESCE(j.supplier_id, ai.supplier_id) IS NOT NULL

      UNION ALL

      SELECT DISTINCT
        'SPECIAL'::varchar AS source_kind,
        COALESCE(j.supplier_id, si.supplier_id) AS supplier_id,
        COALESCE(s.name, s2.name, j.supplier_name_text, si.supplier_name_text) AS supplier_name,
        NULL::uuid AS activity_id,
        si.service_name AS activity_name
      FROM booking_special_items si
      LEFT JOIN booking_special_item_suppliers j
        ON j.special_item_id = si.id AND j.is_deleted = false AND j.tenant_id = $2
      LEFT JOIN suppliers s ON s.id = j.supplier_id
      LEFT JOIN suppliers s2 ON s2.id = si.supplier_id
      WHERE si.booking_id = $1
        AND si.tenant_id = $2
        AND si.is_deleted = false
        AND COALESCE(j.supplier_id, si.supplier_id) IS NOT NULL
      `,
      [bookingId, tenantId],
    );

    const seen = new Set<string>();
    const parties: BookingCashCollectionParty[] = [];
    for (const r of rows) {
      if (!r.supplier_id) continue;
      const key = `${r.source_kind}:${r.supplier_id}:${r.activity_id ?? ''}`;
      if (seen.has(key)) continue;
      seen.add(key);
      const supplierName = (r.supplier_name || '').trim() || 'Supplier';
      const activityName = (r.activity_name || '').trim();
      const label =
        r.source_kind === 'ACTIVITY' && activityName
          ? `${activityName} — ${supplierName}`
          : r.source_kind === 'SPECIAL' && activityName
            ? `${activityName} — ${supplierName}`
            : supplierName;
      parties.push({
        source_kind: r.source_kind,
        supplier_id: r.supplier_id,
        supplier_name: supplierName,
        activity_id: r.activity_id,
        activity_name: activityName || null,
        label,
      });
    }
    parties.sort((a, b) => a.label.localeCompare(b.label));

    return {
      destination_id: booking.destination_id,
      default_currency_id: booking.default_currency_id,
      parties,
    };
  }

  async upsert(dto: UpsertBookingCashCollectionDto): Promise<BookingCashCollectionView> {
    const collectionDate = this.requireDate(dto.collection_date);
    const booking = await this.requireBooking(dto.booking_id);
    const line = this.requireLine(dto);
    await this.assertItemOnBooking(booking.id, line);
    let existing = await this.repo.findLiveByLine(line);
    if (!existing) {
      const unscoped = await this.repo.findLiveUnscoped(booking.id, collectionDate);
      if (unscoped) {
        await this.repo.update(unscoped.id, this.lineFks(line));
        existing = await this.repo.findLiveByLine(line);
      }
    }
    const minLevel =
      existing?.expense_status === 'COLLECTED' ? AccessLevel.DELETE : AccessLevel.EDIT;
    await this.requireAtLeast(minLevel);

    if (dto.expected_amount <= 0) {
      throw new BadRequestException('Expected amount must be greater than 0');
    }

    const collector = await this.resolveCollector(dto, booking.destination_id);
    return this.saveRow(booking, existing, collectionDate, line, {
      currency_id: dto.currency_id,
      expected_amount: dto.expected_amount,
      notes: dto.notes?.trim() ? dto.notes.trim() : null,
      ...collector,
    });
  }

  async confirm(id: string, dto: ConfirmCollectionDto): Promise<BookingCashCollectionView> {
    const current = await this.repo.findById(id);
    if (!current) throw new NotFoundException('Cash collection not found');
    if (!this.hasExpense(current) || current.expense_status === 'CANCELLED') {
      throw new BadRequestException('No expense requirement to confirm');
    }

    const isAmend = current.expense_status === 'COLLECTED';
    await this.requireAtLeast(isAmend ? AccessLevel.DELETE : AccessLevel.EDIT);

    if (dto.actual_amount < 0) {
      throw new BadRequestException('actual_amount cannot be negative');
    }
    const expected = Number(current.expense_expected_amount);
    const actual = Number(dto.actual_amount);
    const diff = +(actual - expected).toFixed(2);
    if (Math.abs(diff) > 0.005 && !dto.difference_reason) {
      throw new BadRequestException(
        `actual_amount (${actual.toFixed(2)}) differs from expected (${expected.toFixed(2)}) — difference_reason is required`,
      );
    }

    const userId = this.cls.get<string>(CLS_USER_ID);
    if (!userId) {
      throw new ForbiddenException('No user context — cannot record collection');
    }

    await this.repo.update(id, {
      expense_status: 'COLLECTED',
      expense_actual_amount: actual,
      expense_collected_at: new Date(dto.collected_at),
      expense_difference_reason: dto.difference_reason ?? null,
      expense_notes: dto.notes !== undefined ? dto.notes : current.expense_notes,
      expense_collected_by: userId,
      settlement_payment_id: null,
    } as never);

    await this.audit.log('booking_cash_collection', id, isAmend ? 'expense_amended' : 'expense_confirmed', [
      { field: 'expense_actual_amount', old_value: current.expense_actual_amount, new_value: actual },
    ]);

    const booking = await this.requireBooking(current.booking_id);
    const reloaded = await this.repo.findWithRelations(id);
    if (!reloaded) throw new NotFoundException('Cash collection not found');
    return this.toView(reloaded, booking.destination_id);
  }

  async voidLatestConfirmation(
    id: string,
    dto: VoidConfirmationDto,
  ): Promise<BookingCashCollectionView> {
    await this.requireAtLeast(AccessLevel.DELETE);
    const current = await this.repo.findById(id);
    if (!current) throw new NotFoundException('Cash collection not found');
    if (current.expense_status !== 'COLLECTED') {
      throw new BadRequestException('No live expense confirmation to void');
    }

    await this.repo.update(id, {
      expense_status: 'PENDING',
      expense_actual_amount: null,
      expense_collected_at: null,
      expense_difference_reason: null,
      expense_collected_by: null,
      settlement_payment_id: null,
    } as never);

    await this.audit.log('booking_cash_collection', id, 'confirmation_voided', [
      { field: 'reason', old_value: null, new_value: dto.reason ?? null },
    ]);

    const booking = await this.requireBooking(current.booking_id);
    const reloaded = await this.repo.findWithRelations(id);
    if (!reloaded) throw new NotFoundException('Cash collection not found');
    return this.toView(reloaded, booking.destination_id);
  }

  async cancel(id: string): Promise<BookingCashCollectionView> {
    await this.requireAtLeast(AccessLevel.EDIT);
    const current = await this.repo.findById(id);
    if (!current) throw new NotFoundException('Cash collection not found');
    if (!this.hasExpense(current) || current.expense_status === 'CANCELLED') {
      throw new BadRequestException('Already cancelled');
    }
    if (current.expense_status === 'COLLECTED') {
      throw new BadRequestException('Void the current confirmation before cancelling the requirement');
    }

    await this.repo.update(id, {
      expense_status: 'CANCELLED',
      expense_collected_by_type: null,
      expense_collector_name: null,
      expense_supplier_id: null,
      expense_source_kind: null,
      expense_activity_id: null,
      expense_driver_id: null,
      expense_expected_amount: 0,
      expense_notes: null,
    } as never);
    await this.audit.log('booking_cash_collection', id, 'cancelled', null);

    const booking = await this.requireBooking(current.booking_id);
    const reloaded = await this.repo.findById(id);
    if (!reloaded) throw new NotFoundException('Cash collection not found');
    return this.toView(reloaded, booking.destination_id);
  }

  private async resolveCollector(
    dto: UpsertBookingCashCollectionDto,
    destinationId: string | null,
    opts?: { requireLinkedToBooking?: boolean },
  ): Promise<{
    collected_by_type: 'OWN' | 'SUPPLIER';
    collector_name: string | null;
    supplier_id: string | null;
    source_kind: 'TRANSPORT' | 'ACTIVITY' | 'SPECIAL' | null;
    activity_id: string | null;
    driver_id: string | null;
  }> {
    if (dto.collected_by_type === 'OWN') {
      const name = (dto.collector_name || '').trim();
      if (!name) {
        throw new BadRequestException('Name of Collector is required when Cash Collected By is Own');
      }
      return {
        collected_by_type: 'OWN',
        collector_name: name,
        supplier_id: null,
        source_kind: null,
        activity_id: null,
        driver_id: null,
      };
    }

    if (!dto.supplier_id || !dto.source_kind) {
      throw new BadRequestException('Supplier / Activity is required when Cash Collected By is Supplier');
    }
    if (opts?.requireLinkedToBooking !== false) {
      await this.assertPartyOnBooking(
        dto.booking_id,
        dto.supplier_id,
        dto.source_kind,
        dto.activity_id ?? null,
      );
    } else {
      await this.assertSupplierInTenant(dto.supplier_id);
    }

    let driverId = dto.driver_id || null;
    if (!driverId) {
      const typed = (dto.driver_name || '').trim();
      if (!typed) {
        throw new BadRequestException('Driver Name is required when Cash Collected By is Supplier');
      }
      if (!destinationId) {
        throw new BadRequestException('Booking destination is required to save a driver');
      }
      const created = await this.drivers.findOrCreateByName({
        name: typed,
        supplier_id: dto.supplier_id,
        destination_id: destinationId,
      });
      driverId = created.id;
    } else {
      await this.assertDriverBelongsToSupplier(driverId, dto.supplier_id);
    }

    return {
      collected_by_type: 'SUPPLIER',
      collector_name: null,
      supplier_id: dto.supplier_id,
      source_kind: dto.source_kind,
      activity_id: dto.source_kind === 'ACTIVITY' ? dto.activity_id ?? null : null,
      driver_id: driverId,
    };
  }

  private requireLine(dto: UpsertBookingCashCollectionDto): CashCollectionLine {
    const ids = [
      dto.transport_item_id ? 1 : 0,
      dto.activity_item_id ? 1 : 0,
      dto.special_item_id ? 1 : 0,
    ].reduce((a, b) => a + b, 0);
    if (ids !== 1) {
      throw new BadRequestException(
        'Exactly one of transport_item_id, activity_item_id, special_item_id is required',
      );
    }
    if (dto.transport_item_id) {
      return { kind: 'TRANSPORT', item_id: dto.transport_item_id };
    }
    if (dto.activity_item_id) {
      return { kind: 'ACTIVITY', item_id: dto.activity_item_id };
    }
    return { kind: 'SPECIAL', item_id: dto.special_item_id! };
  }

  private lineFks(line: CashCollectionLine): {
    transport_item_id: string | null;
    activity_item_id: string | null;
    special_item_id: string | null;
  } {
    return {
      transport_item_id: line.kind === 'TRANSPORT' ? line.item_id : null,
      activity_item_id: line.kind === 'ACTIVITY' ? line.item_id : null,
      special_item_id: line.kind === 'SPECIAL' ? line.item_id : null,
    };
  }

  /**
   * Collect-for-MD stamps cash onto a live transport leg. Dropped /
   * No-show / Cancelled rows are final on Movement Dashboard — they
   * must not appear in the Transport route picker and cannot be
   * stamped via the API either.
   */
  private async assertTransportOpenForCollect(
    bookingId: string,
    transportItemId: string,
    collectionDate: string,
  ): Promise<void> {
    const tenantId = this.tenantId();
    const rows: Array<{ id: string; op_status: string | null; date: string | Date | null }> =
      await this.repo.manager.query(
        `SELECT id, op_status, date::text AS date FROM booking_transport_items
          WHERE id = $1 AND booking_id = $2 AND tenant_id = $3 AND is_deleted = false
          LIMIT 1`,
        [transportItemId, bookingId, tenantId],
      );
    if (!rows.length) {
      throw new BadRequestException('Route item is not on this booking');
    }
    const status = (rows[0].op_status || OpStatus.UNPLANNED) as OpStatus;
    if ((OP_STATUS_TERMINAL as readonly string[]).includes(status)) {
      throw new BadRequestException(
        'Cannot stamp cash on a Dropped, No-show, or Cancelled transport route',
      );
    }
    const routeDate = rows[0].date ? this.dateKey(rows[0].date) : '';
    if (routeDate && routeDate !== collectionDate) {
      throw new BadRequestException('Transport route is not scheduled on this collection date');
    }
  }

  private async assertItemOnBooking(
    bookingId: string,
    line: CashCollectionLine,
  ): Promise<void> {
    const tenantId = this.tenantId();
    const sql =
      line.kind === 'TRANSPORT'
        ? `SELECT id FROM booking_transport_items
            WHERE id = $1 AND booking_id = $2 AND tenant_id = $3 AND is_deleted = false LIMIT 1`
        : line.kind === 'ACTIVITY'
          ? `SELECT id FROM booking_activity_items
              WHERE id = $1 AND booking_id = $2 AND tenant_id = $3 AND is_deleted = false LIMIT 1`
          : `SELECT id FROM booking_special_items
              WHERE id = $1 AND booking_id = $2 AND tenant_id = $3 AND is_deleted = false LIMIT 1`;
    const rows: Array<{ id: string }> = await this.repo.manager.query(sql, [
      line.item_id,
      bookingId,
      tenantId,
    ]);
    if (!rows.length) {
      throw new BadRequestException('Route item is not on this booking');
    }
  }

  private async saveRow(
    booking: { id: string; destination_id: string | null },
    existing: BookingCashCollectionEntity | null,
    collectionDate: string,
    line: CashCollectionLine,
    fields: {
      currency_id: string;
      expected_amount: number;
      notes: string | null;
      collected_by_type: 'OWN' | 'SUPPLIER';
      collector_name: string | null;
      supplier_id: string | null;
      source_kind: 'TRANSPORT' | 'ACTIVITY' | 'SPECIAL' | null;
      activity_id: string | null;
      driver_id: string | null;
    },
  ): Promise<BookingCashCollectionView> {
    const itemFks = this.lineFks(line);
    if (
      existing &&
      Number(existing.cash_expected_amount ?? 0) > 0.005 &&
      existing.currency_id !== fields.currency_id
    ) {
      throw new BadRequestException(
        'Expense currency must match the cash collection currency on this route',
      );
    }
    const expenseFields = {
      expense_collected_by_type: fields.collected_by_type,
      expense_collector_name: fields.collector_name,
      expense_supplier_id: fields.supplier_id,
      expense_source_kind: fields.source_kind,
      expense_activity_id: fields.activity_id,
      expense_driver_id: fields.driver_id,
      expense_expected_amount: fields.expected_amount,
      expense_notes: fields.notes,
      expense_status: 'PENDING' as const,
      currency_id: fields.currency_id,
    };
    let saved: BookingCashCollectionEntity;
    if (existing) {
      const patch: Record<string, unknown> = { ...expenseFields };
      if (existing.expense_status === 'COLLECTED') {
        patch.expense_status = 'PENDING';
        patch.expense_actual_amount = null;
        patch.expense_collected_at = null;
        patch.expense_difference_reason = null;
        patch.expense_collected_by = null;
        patch.settlement_payment_id = null;
        await this.audit.log('booking_cash_collection', existing.id, 'expense_reopened', [
          { field: 'reason', old_value: null, new_value: 'Requirement edited after collection' },
        ]);
      } else if (!existing.expense_status || existing.expense_status === 'CANCELLED') {
        patch.expense_status = 'PENDING';
      }
      await this.repo.update(existing.id, patch as never);
      const reloaded = await this.repo.findLiveByLine(line);
      if (!reloaded) throw new NotFoundException('Cash collection not found');
      saved = reloaded;
      await this.audit.diffAndLog(
        'booking_cash_collection',
        existing.id,
        existing as unknown as Record<string, unknown>,
        saved as unknown as Record<string, unknown>,
      );
    } else {
      saved = await this.repo.save({
        booking_id: booking.id,
        collection_date: collectionDate,
        cash_expected_amount: 0,
        ...itemFks,
        ...expenseFields,
      });
      const reloaded = await this.repo.findLiveByLine(line);
      if (reloaded) saved = reloaded;
      await this.audit.log('booking_cash_collection', saved.id, 'created', [
        { field: 'booking_id', old_value: null, new_value: booking.id },
        { field: 'collection_date', old_value: null, new_value: collectionDate },
        { field: 'collected_by_type', old_value: null, new_value: fields.collected_by_type },
        { field: 'expected_amount', old_value: null, new_value: fields.expected_amount },
      ]);
    }
    return this.toView(saved, booking.destination_id);
  }

  private async requireBooking(bookingId: string): Promise<{
    id: string;
    destination_id: string | null;
    default_currency_id: string | null;
  }> {
    const tenantId = this.tenantId();
    const booking = await this.bookingRepo.findOne({
      where: { id: bookingId, tenant_id: tenantId, is_deleted: false },
    });
    if (!booking) throw new NotFoundException('Booking not found');
    const query = await this.queryRepo.findOne({
      where: { id: booking.query_id, tenant_id: tenantId, is_deleted: false },
    });
    const destinationId = query?.destination_id ?? null;
    let defaultCurrencyId: string | null = null;
    if (destinationId) {
      const destRows: Array<{ currency_id: string | null }> = await this.repo.manager.query(
        `SELECT currency_id FROM destinations
          WHERE id = $1 AND tenant_id = $2 AND is_deleted = false`,
        [destinationId, tenantId],
      );
      defaultCurrencyId = destRows[0]?.currency_id ?? null;
    }
    return {
      id: booking.id,
      destination_id: destinationId,
      default_currency_id: defaultCurrencyId,
    };
  }

  private async assertPartyOnBooking(
    bookingId: string,
    supplierId: string,
    sourceKind: 'TRANSPORT' | 'ACTIVITY' | 'SPECIAL',
    activityId: string | null,
  ): Promise<void> {
    const tenantId = this.tenantId();
    const rows: Array<{ ok: number }> =
      sourceKind === 'TRANSPORT'
        ? await this.repo.manager.query(
            `
            SELECT 1 AS ok
            FROM booking_transport_items ti
            LEFT JOIN booking_transport_item_suppliers j
              ON j.transport_item_id = ti.id AND j.is_deleted = false AND j.tenant_id = $2
            WHERE ti.booking_id = $1 AND ti.tenant_id = $2 AND ti.is_deleted = false
              AND (j.supplier_id = $3 OR ti.supplier_id = $3)
            LIMIT 1
            `,
            [bookingId, tenantId, supplierId],
          )
        : sourceKind === 'ACTIVITY'
          ? await this.repo.manager.query(
              `
              SELECT 1 AS ok
              FROM booking_activity_items ai
              LEFT JOIN booking_activity_item_suppliers j
                ON j.activity_item_id = ai.id AND j.is_deleted = false AND j.tenant_id = $2
              WHERE ai.booking_id = $1 AND ai.tenant_id = $2 AND ai.is_deleted = false
                AND (j.supplier_id = $3 OR ai.supplier_id = $3)
                AND ($4::uuid IS NULL OR ai.activity_id = $4)
              LIMIT 1
              `,
              [bookingId, tenantId, supplierId, activityId],
            )
          : await this.repo.manager.query(
              `
              SELECT 1 AS ok
              FROM booking_special_items si
              LEFT JOIN booking_special_item_suppliers j
                ON j.special_item_id = si.id AND j.is_deleted = false AND j.tenant_id = $2
              WHERE si.booking_id = $1 AND si.tenant_id = $2 AND si.is_deleted = false
                AND (j.supplier_id = $3 OR si.supplier_id = $3)
              LIMIT 1
              `,
              [bookingId, tenantId, supplierId],
            );
    if (!rows.length) {
      throw new BadRequestException(
        'Supplier / Activity is not linked to this booking',
      );
    }
  }

  private async assertSupplierInTenant(supplierId: string): Promise<void> {
    const tenantId = this.tenantId();
    const rows: Array<{ id: string }> = await this.repo.manager.query(
      `SELECT id FROM suppliers
        WHERE id = $1 AND tenant_id = $2 AND is_deleted = false
        LIMIT 1`,
      [supplierId, tenantId],
    );
    if (!rows.length) {
      throw new BadRequestException('Supplier not found');
    }
  }

  private async assertDriverBelongsToSupplier(
    driverId: string,
    supplierId: string,
  ): Promise<void> {
    const tenantId = this.tenantId();
    const rows: Array<{ id: string }> = await this.repo.manager.query(
      `
      SELECT id FROM drivers
      WHERE id = $1 AND tenant_id = $2 AND is_deleted = false AND supplier_id = $3
      LIMIT 1
      `,
      [driverId, tenantId, supplierId],
    );
    if (!rows.length) {
      throw new BadRequestException('Driver does not belong to the selected supplier');
    }
  }

  private toView(
    row: BookingCashCollectionEntity,
    destinationId: string | null,
  ): BookingCashCollectionView {
    const expenseAmt = Number(row.expense_expected_amount ?? 0);
    const cashAmt = Number(row.cash_expected_amount ?? 0);
    const hasExpense =
      !!row.expense_status &&
      row.expense_status !== 'CANCELLED' &&
      expenseAmt > 0.005;
    const hasCash = cashAmt > 0.005;
    return {
      id: row.id,
      booking_id: row.booking_id,
      transport_item_id: row.transport_item_id ?? null,
      activity_item_id: row.activity_item_id ?? null,
      special_item_id: row.special_item_id ?? null,
      currency_id: row.currency_id,
      currency_code: row.currency?.code ?? '',
      destination_id: destinationId,
      collection_date: this.dateKey(row.collection_date),
      has_expense: hasExpense,
      has_cash: hasCash,
      collected_by_type: row.expense_collected_by_type,
      collector_name: row.expense_collector_name,
      supplier_id: row.expense_supplier_id,
      supplier_name: row.expense_supplier?.name ?? null,
      source_kind: row.expense_source_kind,
      activity_id: row.expense_activity_id,
      activity_name: row.expense_activity?.name ?? null,
      driver_id: row.expense_driver_id,
      driver_name: row.expense_driver?.name ?? null,
      expected_amount: expenseAmt,
      notes: row.expense_notes,
      status: hasExpense ? row.expense_status : null,
      actual_amount:
        row.expense_actual_amount == null ? null : Number(row.expense_actual_amount),
      collected_at: row.expense_collected_at
        ? new Date(row.expense_collected_at).toISOString()
        : null,
      difference_reason: row.expense_difference_reason,
      collected_by: row.expense_collected_by,
      collected_by_name: row.expense_collected_by_user?.name ?? null,
      cash_expected_amount: cashAmt,
      cash_status: hasCash ? row.cash_status : null,
      cash_actual_amount:
        row.cash_actual_amount == null ? null : Number(row.cash_actual_amount),
      cash_collected_by_type: row.cash_collected_by_type,
      cash_collector_name: row.cash_collector_name,
    };
  }

  private hasExpense(row: BookingCashCollectionEntity): boolean {
    return (
      !!row.expense_status &&
      row.expense_status !== 'CANCELLED' &&
      Number(row.expense_expected_amount ?? 0) > 0.005
    );
  }

  async stampFromCollect(dto: CollectForMdDto): Promise<BookingCashCollectionView> {
    const received = +Number(dto.received_amount).toFixed(2);
    const booking = await this.requireBooking(dto.booking_id);
    const collector = await this.resolveCashCollector(dto, booking.destination_id);
    const saved = await this.mdCash.save({
      booking_id: booking.id,
      currency_id: dto.currency_id,
      amount: received,
      status: 'PENDING',
      collected_by_type: collector.collected_by_type,
      collector_name: collector.collector_name,
      supplier_id: collector.supplier_id,
      driver_id: collector.driver_id,
      notes: dto.notes?.trim() ? dto.notes.trim() : null,
    });
    await this.audit.log('md_cash_collection', saved.id, 'cash_stamped_from_collect', [
      { field: 'amount', old_value: null, new_value: received },
    ]);
    return this.mdToView(saved, booking.destination_id);
  }

  private mdToView(
    row: MdCashCollectionEntity,
    destinationId: string | null,
  ): BookingCashCollectionView {
    const amount = Number(row.amount);
    const collected = row.status === 'COLLECTED';
    return {
      id: row.id,
      booking_id: row.booking_id,
      transport_item_id: null,
      activity_item_id: null,
      special_item_id: null,
      currency_id: row.currency_id,
      currency_code: '',
      destination_id: destinationId,
      collection_date: '',
      has_expense: false,
      has_cash: amount > 0.005,
      collected_by_type: null,
      collector_name: null,
      supplier_id: null,
      supplier_name: null,
      source_kind: null,
      activity_id: null,
      activity_name: null,
      driver_id: null,
      driver_name: null,
      expected_amount: 0,
      notes: null,
      status: null,
      actual_amount: null,
      collected_at: null,
      difference_reason: null,
      collected_by: null,
      collected_by_name: null,
      cash_expected_amount: amount,
      cash_status: row.status,
      cash_actual_amount: collected ? amount : null,
      cash_collected_by_type: row.collected_by_type,
      cash_collector_name: row.collector_name,
    };
  }

  async confirmCash(
    id: string,
    dto?: { route_kind?: 'TRANSPORT' | 'ACTIVITY' | 'SPECIAL'; route_item_id?: string },
  ): Promise<{ row: BookingCashCollectionView; collection_id: string | null }> {
    await this.requireAtLeast(AccessLevel.EDIT);
    const md = await this.mdCash.findById(id);
    if (md) {
      if (md.status !== 'PENDING') {
        throw new BadRequestException('No pending cash collection to confirm');
      }
      const userId = this.cls.get<string>(CLS_USER_ID);
      if (!userId) {
        throw new ForbiddenException('No user context — cannot record collection');
      }
      const now = new Date();
      await this.mdCash.update(md.id, { status: 'COLLECTED', collected_at: now } as never);
      await this.audit.log('md_cash_collection', md.id, 'cash_confirmed', [
        { field: 'status', old_value: 'PENDING', new_value: 'COLLECTED' },
      ]);
      const booking = await this.requireBooking(md.booking_id);
      return {
        row: this.mdToView(
          { ...md, status: 'COLLECTED', collected_at: now },
          booking.destination_id,
        ),
        collection_id: null,
      };
    }
    const current = await this.repo.findById(id);
    if (!current) throw new NotFoundException('Cash collection not found');
    if (current.cash_status !== 'PENDING' || Number(current.cash_expected_amount ?? 0) <= 0.005) {
      throw new BadRequestException('No pending cash collection to confirm');
    }
    const userId = this.cls.get<string>(CLS_USER_ID);
    if (!userId) {
      throw new ForbiddenException('No user context — cannot record collection');
    }

    const pending = await this.batches.findPendingByCollection(id);
    for (const batch of pending) {
      await this.batches.update(batch.id, {
        status: 'POSTED',
        payment_collection_id: null,
        allocations: [],
      } as never);
    }

    await this.repo.update(id, {
      cash_status: 'COLLECTED',
      cash_actual_amount: Number(current.cash_expected_amount),
      cash_collected_at: new Date(),
      cash_collected_by: userId,
      driver_received_at: new Date(),
      driver_received_route_kind:
        dto?.route_kind ??
        current.cash_source_kind ??
        (current.transport_item_id
          ? 'TRANSPORT'
          : current.activity_item_id
            ? 'ACTIVITY'
            : current.special_item_id
              ? 'SPECIAL'
              : null),
      driver_received_route_item_id:
        dto?.route_item_id ??
        current.transport_item_id ??
        current.activity_item_id ??
        current.special_item_id ??
        null,
    } as never);

    await this.audit.log('booking_cash_collection', id, 'cash_confirmed', [
      { field: 'cash_status', old_value: 'PENDING', new_value: 'COLLECTED' },
    ]);

    const booking = await this.requireBooking(current.booking_id);
    const reloaded = await this.repo.findWithRelations(id);
    if (!reloaded) throw new NotFoundException('Cash collection not found');
    return { row: this.toView(reloaded, booking.destination_id), collection_id: null };
  }

  async listPaymentHistory(query: {
    search?: string;
    customer_mobile?: string;
    source_id?: string;
    page?: number;
    limit?: number;
    date_from?: string;
    date_to?: string;
    currency_id?: string;
  }): Promise<{
    items: PaymentHistoryListRow[];
    meta: PaymentHistoryListMeta;
  }> {
    const tenantId = this.tenantId();
    const page = query.page && query.page > 0 ? query.page : 1;
    const limit = query.limit && query.limit > 0 ? Math.min(query.limit, 100) : 10;
    const offset = (page - 1) * limit;
    const params: unknown[] = [tenantId];
    const partyFilters: string[] = [];
    const filters: string[] = [
      'b.tenant_id = $1',
      'b.is_deleted = false',
      `EXISTS (
         SELECT 1
           FROM booking_cash_collections bcc
           INNER JOIN bookings bk
             ON bk.id = bcc.booking_id
            AND bk.tenant_id = $1
            AND bk.is_deleted = false
          WHERE bcc.id = b.booking_cash_collection_id
            AND bcc.tenant_id = $1
            AND bcc.is_deleted = false
       )`,
    ];
    if (query.customer_mobile) {
      params.push(query.customer_mobile);
      partyFilters.push(`(b.party_type = 'CUSTOMER' AND b.customer_mobile = $${params.length})`);
    }
    if (query.source_id) {
      params.push(query.source_id);
      partyFilters.push(`(b.party_type = 'SOURCE' AND b.source_id = $${params.length})`);
    }
    if (partyFilters.length) filters.push(`(${partyFilters.join(' OR ')})`);
    if (query.search?.trim()) {
      params.push(`%${query.search.trim()}%`);
      const n = params.length;
      filters.push(
        `(b.customer_name ILIKE $${n} OR b.customer_mobile ILIKE $${n} OR ts.name ILIKE $${n})`,
      );
    }
    if (query.date_from) {
      params.push(query.date_from);
      filters.push(`b.payment_date >= $${params.length}::date`);
    }
    if (query.date_to) {
      params.push(query.date_to);
      filters.push(`b.payment_date <= $${params.length}::date`);
    }
    if (query.currency_id) {
      params.push(query.currency_id);
      filters.push(`COALESCE(b.receive_currency_id, b.currency_id) = $${params.length}`);
    }
    const where = filters.join(' AND ');
    const countRows = (await this.batches.manager.query(
      `SELECT COUNT(*)::int AS total
         FROM booking_cash_collection_batches b
         LEFT JOIN trip_sources ts ON ts.id = b.source_id AND ts.is_deleted = false
        WHERE ${where}`,
      params,
    )) as Array<{ total: number }>;
    const total = Number(countRows[0]?.total ?? 0);
    params.push(limit, offset);
    const rows = (await this.batches.manager.query(
      `SELECT b.id,
              b.status,
              bcc.cash_status,
              b.payment_date::text AS payment_date,
              b.party_type,
              COALESCE(NULLIF(b.customer_name, ''), ts.name, b.customer_mobile, '') AS party_name,
              COALESCE(rc.code, c.code, '') AS receive_currency_code,
              COALESCE(b.received_amount_receive_currency, b.received_amount) AS received_amount
         FROM booking_cash_collection_batches b
         INNER JOIN booking_cash_collections bcc
            ON bcc.id = b.booking_cash_collection_id
           AND bcc.tenant_id = $1
           AND bcc.is_deleted = false
         LEFT JOIN trip_sources ts ON ts.id = b.source_id AND ts.is_deleted = false
         LEFT JOIN currencies c ON c.id = b.currency_id
         LEFT JOIN currencies rc ON rc.id = b.receive_currency_id
        WHERE ${where}
        ORDER BY b.payment_date DESC, b.created_at DESC
        LIMIT $${params.length - 1} OFFSET $${params.length}`,
      params,
    )) as Array<Record<string, unknown>>;
    return {
      items: rows.map((r) => ({
        id: String(r.id),
        payment_date: String(r.payment_date).slice(0, 10),
        party_type: r.party_type as CollectionPartyType,
        party_name: String(r.party_name ?? ''),
        payment_type: SettlementPaymentType.BILL_TO_BILL,
        receive_currency_code: String(r.receive_currency_code ?? ''),
        received_amount: Number(r.received_amount),
        posted: String(r.status) === 'POSTED' || String(r.cash_status) === 'COLLECTED',
      })),
      meta: { total, page, limit, totalPages: Math.ceil(total / limit) || 1 },
    };
  }

  async findPaymentHistoryDetail(id: string): Promise<PaymentHistoryDetail | null> {
    const batch = await this.batches.findById(id);
    if (!batch) return null;
    const parent = await this.repo.findById(batch.booking_cash_collection_id);
    if (!parent) return null;
    const booking = await this.bookingRepo.findOne({
      where: { id: parent.booking_id, tenant_id: this.tenantId(), is_deleted: false },
      select: { id: true } as never,
    });
    if (!booking) return null;
    let sourceName: string | null = null;
    if (batch.party_type === CollectionPartyType.SOURCE && batch.source_id) {
      const src = (await this.batches.manager.query(
        `SELECT name FROM trip_sources WHERE id = $1 AND is_deleted = false LIMIT 1`,
        [batch.source_id],
      )) as Array<{ name: string }>;
      sourceName = src[0]?.name ?? null;
    }
    const payments = await this.enrichMdAllocations(batch.allocations);
    return {
      kind: 'MD',
      id: batch.id,
      posted: batch.status === 'POSTED' || parent.cash_status === 'COLLECTED',
      payment_collection_id: batch.payment_collection_id,
      receipt_no: null,
      party_type: batch.party_type,
      customer_mobile: batch.customer_mobile,
      source_id: batch.source_id,
      source_name: sourceName,
      customer_name: batch.customer_name,
      payment_date: this.dateKey(batch.payment_date),
      // MD cash always settles the transport line it was stamped on —
      // there is no On Account path here.
      payment_type: SettlementPaymentType.BILL_TO_BILL,
      mode: batch.mode,
      ref_no: null,
      debit_bank_account_id: batch.debit_bank_account_id,
      credit_bank_account_id: batch.credit_bank_account_id,
      currency_id: batch.currency_id,
      receive_currency_id: batch.receive_currency_id,
      exchange_rate: batch.exchange_rate != null ? Number(batch.exchange_rate) : null,
      exchange_rate_quote:
        batch.exchange_rate_quote != null ? Number(batch.exchange_rate_quote) : null,
      received_amount: Number(batch.received_amount),
      received_amount_receive_currency:
        batch.received_amount_receive_currency != null
          ? Number(batch.received_amount_receive_currency)
          : null,
      roe: batch.roe != null ? Number(batch.roe) : null,
      credit_applied: 0,
      excess_treatment: null,
      notes: batch.notes,
      collected_by_type: parent.cash_collected_by_type,
      collector_name: parent.cash_collector_name,
      collector_supplier_id: parent.cash_supplier_id,
      collector_driver_id: parent.cash_driver_id,
      md_booking_id: parent.booking_id,
      md_transport_item_id: parent.transport_item_id,
      payments,
    };
  }

  async updateFromCollect(id: string, dto: UpdatePaymentHistoryDto): Promise<PaymentHistoryDetail> {
    const batch = await this.batches.findById(id);
    if (!batch) throw new NotFoundException('Payment history not found');
    const parent = await this.repo.findById(batch.booking_cash_collection_id);
    if (!parent) throw new NotFoundException('Cash collection not found');
    if (batch.status === 'POSTED' || parent.cash_status === 'COLLECTED') {
      throw new BadRequestException(
        'This payment is already collected on Movement Dashboard and cannot be edited',
      );
    }
    const received = +Number(dto.received_amount).toFixed(2);
    const allocs: Array<{ settlement_id: string; amount: number }> = [];

    const bankSides = await this.settlementsService.resolveCollectBankAccountIds({
      mode: dto.mode,
      debit_bank_account_id: dto.debit_bank_account_id ?? null,
      credit_bank_account_id: dto.credit_bank_account_id ?? null,
    });
    const oldReceived = Number(batch.received_amount);
    const delta = +(received - oldReceived).toFixed(2);
    const nextExpected = +(Number(parent.cash_expected_amount ?? 0) + delta).toFixed(2);
    if (nextExpected < -0.005) {
      throw new BadRequestException('Received amount cannot reduce the route total below zero');
    }

    if (dto.collected_by_type) {
      const collector = await this.resolveCashCollector(
        {
          booking_id: parent.booking_id,
          transport_item_id: parent.transport_item_id!,
          party_type: batch.party_type,
          customer_mobile: batch.customer_mobile ?? undefined,
          source_id: batch.source_id ?? undefined,
          payment_date: dto.payment_date,
          mode: dto.mode,
          currency_id: dto.currency_id,
          received_amount: received,
          allocations: allocs,
          collected_by_type: dto.collected_by_type,
          collector_name: dto.collector_name,
          supplier_id: dto.collector_supplier_id,
          driver_id: dto.collector_driver_id,
          driver_name: dto.driver_name,
        } as CollectForMdDto,
        (await this.requireBooking(parent.booking_id)).destination_id,
      );
      await this.repo.update(parent.id, {
        cash_expected_amount: nextExpected,
        cash_actual_amount:
          parent.cash_actual_amount != null
            ? +(Number(parent.cash_actual_amount) + delta).toFixed(2)
            : parent.cash_actual_amount,
        ...this.cashCollectorPatch(collector),
      } as never);
    } else {
      await this.repo.update(parent.id, {
        cash_expected_amount: nextExpected,
        cash_actual_amount:
          parent.cash_actual_amount != null
            ? +(Number(parent.cash_actual_amount) + delta).toFixed(2)
            : parent.cash_actual_amount,
      } as never);
    }

    await this.batches.update(id, {
      received_amount: received,
      allocations: allocs,
      notes: dto.notes?.trim() ? dto.notes.trim() : null,
      payment_date: dto.payment_date,
      mode: dto.mode,
      currency_id: dto.currency_id,
      roe: dto.roe ?? null,
      receive_currency_id: dto.receive_currency_id ?? null,
      exchange_rate: dto.exchange_rate ?? null,
      exchange_rate_quote: dto.exchange_rate_quote ?? null,
      received_amount_receive_currency: dto.received_amount_receive_currency ?? null,
      debit_bank_account_id: bankSides.debit_bank_account_id,
      credit_bank_account_id: bankSides.credit_bank_account_id,
    } as never);

    const updated = await this.findPaymentHistoryDetail(id);
    if (!updated) throw new NotFoundException('Payment history not found');
    return updated;
  }

  private async enrichMdAllocations(
    allocations: Array<{ settlement_id: string; amount: number }>,
  ): Promise<PaymentHistoryDetail['payments']> {
    if (!allocations.length) return [];
    const ids = allocations.map((a) => a.settlement_id);
    const tenantId = this.tenantId();
    const rows = (await this.batches.manager.query(
      `SELECT ps.id,
              ps.booking_id,
              b.booking_number,
              q.guest_name,
              q.guest_mobile,
              q.start_date::text AS travel_date
         FROM payment_settlements ps
         INNER JOIN bookings b ON b.id = ps.booking_id
         LEFT JOIN queries q ON q.id = b.query_id
        WHERE ps.tenant_id = $1
          AND ps.id = ANY($2::uuid[])
          AND ps.is_deleted = false`,
      [tenantId, ids],
    )) as Array<Record<string, unknown>>;
    const byId = new Map(rows.map((r) => [String(r.id), r]));
    return allocations.map((a) => {
      const r = byId.get(a.settlement_id);
      return {
        settlement_id: a.settlement_id,
        paid_amount: Number(a.amount),
        booking_id: r ? String(r.booking_id) : null,
        booking_number: (r?.booking_number as string) ?? null,
        guest_name: (r?.guest_name as string) ?? null,
        guest_mobile: (r?.guest_mobile as string) ?? null,
        travel_date: (r?.travel_date as string) ?? null,
      };
    });
  }

  async listBookingTransports(bookingId: string): Promise<
    Array<{ id: string; date: string | null; label: string }>
  > {
    const booking = await this.requireBooking(bookingId);
    const tenantId = this.tenantId();
    const rows: Array<{
      id: string;
      date: string | null;
      from_city: string | null;
      to_city: string | null;
    }> = await this.repo.manager.query(
      `
      SELECT ti.id, ti.date::text AS date, ts.from_city, ts.to_city
      FROM booking_transport_items ti
      LEFT JOIN transport_services ts
        ON ts.id = ti.transport_service_id AND ts.is_deleted = false
      WHERE ti.booking_id = $1 AND ti.tenant_id = $2 AND ti.is_deleted = false
        AND COALESCE(ti.op_status, 'PLANNED') NOT IN ('PICKED_UP', 'NO_SHOW', 'CANCELLED')
      ORDER BY ti.date ASC NULLS LAST, ti.sort_order ASC, ti.created_at ASC
      `,
      [booking.id, tenantId],
    );
    return rows.map((r) => ({
      id: r.id,
      date: r.date,
      label: [r.date, `${r.from_city || '?'} → ${r.to_city || '?'}`].filter(Boolean).join(' · '),
    }));
  }

  private cashCollectorPatch(collector: {
    collected_by_type: 'OWN' | 'SUPPLIER';
    collector_name: string | null;
    supplier_id: string | null;
    source_kind: 'TRANSPORT' | 'ACTIVITY' | 'SPECIAL' | null;
    activity_id: string | null;
    driver_id: string | null;
  }) {
    return {
      cash_collected_by_type: collector.collected_by_type,
      cash_collector_name: collector.collector_name,
      cash_supplier_id: collector.supplier_id,
      cash_source_kind: collector.source_kind,
      cash_activity_id: collector.activity_id,
      cash_driver_id: collector.driver_id,
    };
  }

  /**
   * Collect-for-MD collector is who physically took the cash — not the
   * supplier assigned on the Movement Dashboard itinerary. Skip the
   * booking-link check used by Expense.
   */
  private async resolveCashCollector(
    dto: CollectForMdDto,
    destinationId: string | null,
  ) {
    return this.resolveCollector(
      {
        booking_id: dto.booking_id,
        collection_date: dto.payment_date,
        currency_id: dto.currency_id,
        expected_amount: dto.received_amount,
        collected_by_type: dto.collected_by_type,
        collector_name: dto.collector_name,
        supplier_id: dto.supplier_id,
        source_kind: dto.collected_by_type === 'SUPPLIER' ? 'TRANSPORT' : undefined,
        driver_id: dto.driver_id,
        driver_name: dto.driver_name,
      },
      destinationId,
      { requireLinkedToBooking: false },
    );
  }

  private requireDate(value: string | Date | null | undefined): string {
    const key = value ? this.dateKey(value) : '';
    if (!/^\d{4}-\d{2}-\d{2}$/.test(key)) {
      throw new BadRequestException('collection_date must be YYYY-MM-DD');
    }
    return key;
  }

  private dateKey(value: string | Date): string {
    if (typeof value === 'string') return value.slice(0, 10);
    const y = value.getUTCFullYear();
    const m = String(value.getUTCMonth() + 1).padStart(2, '0');
    const d = String(value.getUTCDate()).padStart(2, '0');
    return `${y}-${m}-${d}`;
  }

  private tenantId(): string {
    const id = this.cls.get<string>(CLS_TENANT_ID);
    if (!id) throw new BadRequestException('Tenant context missing');
    return id;
  }

  private async requireAtLeast(level: AccessLevel): Promise<void> {
    const ok = await this.perms.hasAtLeast(ModuleName.PAYMENT_COLLECTION, level);
    if (!ok) {
      throw new ForbiddenException(
        `Requires PAYMENT_COLLECTION:${level} — contact your Admin`,
      );
    }
  }
}
