<?php

// Plugin Check / PHPCS: category-level exclusions with reasons.
// Line-level findings in this file are handled individually below.
// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- PaintOps keeps its records in its own custom tables (built by paintops_table()); $wpdb is the supported API for plugin-owned tables, there is no core CRUD alternative, and results are request-scoped.
// phpcs:disable WordPress.DateTime.RestrictedFunctions.date_date -- date() here is deliberately paired with current_time() timestamps: crews are scheduled on the site's local calendar day, and gmdate() would shift jobs across midnight UTC.
// phpcs:disable WordPress.Security.NonceVerification.Recommended -- read-only GET routing and filter parameters (which screen/record to show); every state-changing handler verifies a nonce before writing.
/**
 * PaintOps - Job Financials Add-on
 *
 * Drop this file beside manage-jobs.php and include it once:
 * require_once __DIR__ . '/job-financials-addon.php';
 *
 * Run paintops_job_financials_install(); once from an admin-only route or plugin activation.
 *
 * Designed for:
 * - Gross / payout per job
 * - Multiple expense lines per job
 * - Worker/owner hours with no hourly rate
 * - Profit, margin, profit per job, monthly totals, hours per worker
 */
if ( !defined( 'ABSPATH' ) ) {
    exit;
}
function paintops_job_financials_install() : void {
    global $wpdb;
    require_once ABSPATH . 'wp-admin/includes/upgrade.php';
    $charset = $wpdb->get_charset_collate();
    dbDelta( "CREATE TABLE " . paintops_table( 'job_financials' ) . " (\n        job_id BIGINT UNSIGNED NOT NULL,\n        gross_amount DECIMAL(12,2) NOT NULL DEFAULT 0.00,\n        updated_by BIGINT UNSIGNED NULL,\n        updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,\n        PRIMARY KEY (job_id)\n    ) {$charset};" );
    dbDelta( "CREATE TABLE " . paintops_table( 'job_expenses' ) . " (\n        id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,\n        job_id BIGINT UNSIGNED NOT NULL,\n        expense_date DATE NULL,\n        vendor VARCHAR(150) NOT NULL DEFAULT '',\n        category VARCHAR(80) NOT NULL DEFAULT 'Materials',\n        amount DECIMAL(12,2) NOT NULL DEFAULT 0.00,\n        created_by BIGINT UNSIGNED NULL,\n        created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n        PRIMARY KEY (id),\n        KEY job_id (job_id),\n        KEY expense_date (expense_date)\n    ) {$charset};" );
    dbDelta( "CREATE TABLE " . paintops_table( 'job_worker_hours' ) . " (\n        id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,\n        job_id BIGINT UNSIGNED NOT NULL,\n        user_id BIGINT UNSIGNED NOT NULL,\n        work_date DATE NOT NULL,\n        hours DECIMAL(8,2) NOT NULL DEFAULT 0.00,\n        created_by BIGINT UNSIGNED NULL,\n        created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n        PRIMARY KEY (id),\n        KEY job_id (job_id),\n        KEY user_id (user_id),\n        KEY work_date (work_date)\n    ) {$charset};" );
}

// cs_job_financials_cleanup_old_columns() used to live here, a
// first-draft migration helper that was never called from anywhere and,
// worse, would have DROPped job_worker_hours' hourly_rate and notes
// columns, which the schema in paintops.php creates on purpose and the
// Time Clock (includes/job-checkin.php) now writes to. Removed
// 2026-07-13 so nobody ever wires it up by accident.
function paintops_money_to_float(  $value  ) : float {
    return round( (float) preg_replace( '/[^0-9.\\-]/', '', (string) $value ), 2 );
}

/**
 * Shared green/red/neutral CSS class for any profit-ish figure (profit,
 * margin, profit-per-hour, YoY growth) so Job Financials, Profits, the
 * per-hour rankings, and profit-by-classification all color the same way
 * instead of each page inventing its own threshold check.
 */
function paintops_profit_css_class(  float $value  ) : string {
    if ( $value > 0 ) {
        return 'cs-fin-positive';
    }
    if ( $value < 0 ) {
        return 'cs-fin-negative';
    }
    return '';
}

/**
 * Total labor cost for a single job: each worker's logged hours on that
 * job times their hourly rate (paintops_get_worker_hourly_rate() returns
 * 0 for anyone without a rate set, e.g. an owner paid by the job rather
 * than the hour, so their time is tracked but never counted as a cost
 * to themselves). This is what makes profit, and profit-per-hour,
 * reflect what was actually paid out, not just gross revenue minus
 * material costs.
 */
function paintops_job_labor_cost(  int $job_id  ) : float {
    global $wpdb;
    if ( $job_id <= 0 || !function_exists( 'paintops_get_worker_hourly_rate' ) ) {
        return 0.0;
    }
    $rows = $wpdb->get_results( $wpdb->prepare( "SELECT user_id, SUM(hours) AS total_hours FROM %i WHERE job_id = %d GROUP BY user_id", paintops_table( 'job_worker_hours' ), $job_id ) );
    $labor_cost = 0.0;
    foreach ( $rows as $row ) {
        $rate = paintops_get_worker_hourly_rate( (int) $row->user_id );
        if ( $rate > 0 ) {
            $labor_cost += (float) $row->total_hours * $rate;
        }
    }
    return $labor_cost;
}

/**
 * Non-billable follow-up visits whose labor really belongs to this job's
 * REPORTS (Mike 2026-07-23): a Callback or Punch List visit usually has
 * no price of its own, the crew is finishing or warrantying the parent
 * job, so hours logged on those visits are a real cost OF THE PARENT JOB
 * that used to be invisible to its profit numbers. Returns the
 * qualifying visit ids plus their total hours and labor cost.
 *
 * Deliberately EXCLUDED:
 *  - Add-On visits: an add-on is priced work, effectively its own small
 *    job, it keeps its own books (Mike's call, same conversation).
 *  - Billable callbacks/punch lists (a stored gross_amount of their
 *    own): they charge for the visit, so their costs stay their own.
 *    Same billable test as the Add Expense routing hint (1.0.189).
 *  - Aggregate report paths (yearly totals, business averages, crew
 *    profit, per-hour rankings, customer totals): those iterate every
 *    record INCLUDING the visit rows themselves, folding the same hours
 *    into the parent there would double-count. That's why
 *    paintops_job_financial_summary() only rolls up when a caller
 *    explicitly passes $with_followups = true.
 *
 * Hours stay logged (and editable) on the visit record itself; only the
 * parent's report math folds them in. Walks nested follow-ups with the
 * same guarded BFS as paintops_delete_job_deep(), expanding only through
 * qualifying visits, so a punch list hanging off a billable Add-On rolls
 * into the add-on's books, never into the original job's.
 */
function paintops_job_followup_rollup(  int $job_id  ) : array {
    global $wpdb;
    static $cache = [];
    if ( isset( $cache[$job_id] ) ) {
        return $cache[$job_id];
    }
    $result = [
        'job_ids'    => [],
        'hours'      => 0.0,
        'labor_cost' => 0.0,
    ];
    if ( $job_id <= 0 ) {
        return $result;
    }
    $jobs_t = paintops_table( 'jobs' );
    $fin_t = paintops_table( 'job_financials' );
    $seen = [
        $job_id => true,
    ];
    $front = [$job_id];
    $guard = 0;
    while ( $front && $guard++ < 10 ) {
        // Ids are intval-cast before interpolation (same pattern as
        // paintops_delete_job_deep), no injection surface.
        $in = implode( ',', array_fill( 0, count( $front ), '%d' ) );
        // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- literal %d placeholder list sized to the id array; the args travel as one merged array
        $children = array_map( 'intval', (array) $wpdb->get_col( $wpdb->prepare( "SELECT j.id FROM %i j\n              LEFT JOIN %i f ON f.job_id = j.id\n             WHERE j.parent_job_id IN ({$in})\n               AND COALESCE(j.visit_type, 'original') IN ('callback', 'punch_list')\n               AND COALESCE(f.gross_amount, 0) <= 0", array_merge( [$jobs_t, $fin_t], array_map( 'intval', $front ) ) ) ) );
        $front = [];
        foreach ( $children as $cid ) {
            if ( $cid > 0 && empty( $seen[$cid] ) ) {
                $seen[$cid] = true;
                $result['job_ids'][] = $cid;
                $front[] = $cid;
            }
        }
    }
    foreach ( $result['job_ids'] as $cid ) {
        $result['hours'] += (float) $wpdb->get_var( $wpdb->prepare( "SELECT COALESCE(SUM(hours),0) FROM %i WHERE job_id = %d", paintops_table( 'job_worker_hours' ), $cid ) );
        $result['labor_cost'] += paintops_job_labor_cost( $cid );
    }
    return $cache[$job_id] = $result;
}

/**
 * Total of every logged payment against a job, the Payments log under
 * Gross / Payout (see paintops_render_job_financial_box()), which replaces what
 * used to be a single editable "Amount Paid" figure. Each row is one
 * payment (a deposit, a milestone, cash/check, the final payment, etc.);
 * Stripe deposits are logged here automatically by
 * paintops_mark_deposit_paid() in customer-payments.php. Summing the log
 * is always the source of truth, there's no separate stored total to
 * drift out of sync with it.
 */
function paintops_job_amount_paid(  int $job_id  ) : float {
    global $wpdb;
    if ( $job_id <= 0 ) {
        return 0.0;
    }
    return (float) $wpdb->get_var( $wpdb->prepare( "SELECT COALESCE(SUM(amount),0) FROM %i WHERE job_id = %d", paintops_table( 'job_payments' ), $job_id ) );
}

function paintops_job_financial_summary(  int $job_id, bool $with_followups = false  ) : array {
    global $wpdb;
    $gross = (float) $wpdb->get_var( $wpdb->prepare( "SELECT gross_amount FROM %i WHERE job_id = %d", paintops_table( 'job_financials' ), $job_id ) );
    // If no Payout has been set yet, default the gross to the job's quote total,
    // so a job awarded from a quote already carries the agreed amount as its
    // balance (to invoice / collect) without the owner re-typing it. A saved
    // Payout always wins (once gross > 0). Centralized here so every consumer,
    // the Financials page, the invoice balance, and the Profits report, agrees
    // instead of only the Financials render doing this fallback for display.
    if ( $gross <= 0 && function_exists( 'paintops_get_job_quote_for_financials' ) ) {
        $quote_for_gross = paintops_get_job_quote_for_financials( $job_id, 0 );
        if ( !empty( $quote_for_gross['found'] ) ) {
            $gross = (float) $quote_for_gross['total'];
        }
    }
    $amount_paid = paintops_job_amount_paid( $job_id );
    $material_expenses = (float) $wpdb->get_var( $wpdb->prepare( "SELECT COALESCE(SUM(amount),0) FROM %i WHERE job_id = %d", paintops_table( 'job_expenses' ), $job_id ) );
    $hours = (float) $wpdb->get_var( $wpdb->prepare( "SELECT COALESCE(SUM(hours),0) FROM %i WHERE job_id = %d", paintops_table( 'job_worker_hours' ), $job_id ) );
    // Labor cost (hourly workers' pay) counts as a real expense alongside
    // materials, so profit, and profit-per-hour below, reflects what
    // was actually paid out.
    $labor_cost = paintops_job_labor_cost( $job_id );
    // Parent-job REPORT surfaces (profit summary, the Financials page
    // dashboard, Estimate vs Actual) pass $with_followups = true to fold
    // in labor from non-billable callback/punch-list visits; see
    // paintops_job_followup_rollup() above for what qualifies and why
    // aggregate paths must keep the default false. Gross, payments, and
    // the balance are untouched by this, a $0 visit has none of those.
    $followup_hours = 0.0;
    $followup_labor = 0.0;
    $followup_visits = 0;
    if ( $with_followups && function_exists( 'paintops_job_followup_rollup' ) ) {
        $fu_rollup = paintops_job_followup_rollup( $job_id );
        $followup_hours = (float) $fu_rollup['hours'];
        $followup_labor = (float) $fu_rollup['labor_cost'];
        $followup_visits = count( $fu_rollup['job_ids'] );
        $hours += $followup_hours;
        $labor_cost += $followup_labor;
    }
    $expenses = $material_expenses + $labor_cost;
    $profit = $gross - $expenses;
    $margin = ( $gross > 0 ? $profit / $gross * 100 : 0 );
    $profit_per_hour = ( $hours > 0 ? $profit / $hours : 0 );
    // Remaining Balance = what's left of the Gross/Payout amount that
    // hasn't been collected yet, per the Payments log above. Works no
    // matter which payment_plan the job used, online Stripe deposits,
    // offline cash/check milestones, and a final payment all just add
    // another row to the same log.
    $remaining_balance = $gross - $amount_paid;
    $paid_in_full = $gross > 0 && $remaining_balance <= 0.005;
    return [
        'gross'               => $gross,
        'expenses'            => $expenses,
        'material_expenses'   => $material_expenses,
        'labor_cost'          => $labor_cost,
        'profit'              => $profit,
        'margin'              => $margin,
        'hours'               => $hours,
        'profit_per_hour'     => $profit_per_hour,
        'amount_paid'         => $amount_paid,
        'remaining_balance'   => $remaining_balance,
        'paid_in_full'        => $paid_in_full,
        'followup_hours'      => $followup_hours,
        'followup_labor_cost' => $followup_labor,
        'followup_visits'     => $followup_visits,
    ];
}

// end paintops_fs()->is__premium_only()
/**
 * Saves the Gross/Payout, Payments log, and Hours form, and returns an
 * array of extra notice strings (HTML) for the caller to display.
 * Expenses/receipts no longer come through this form at all, they're
 * logged via the "Manage or Add Expenses" page (?jobAddReceipt=X, see
 * paintops_save_worker_expense_receipt()) instead, so this only ever
 * touches gross_amount, the job_payments log, and worker hours.
 */
/**
 * Concurrency fingerprint for the two tables the Financials save REPLACES
 * (delete-and-reinsert from the form snapshot): payments and hours. The
 * form carries the fingerprint from render time; if it no longer matches
 * at save time, something changed underneath the open page, a crew
 * member's check-out logging hours, a customer's online payment landing
 * via the Stripe webhook, or another admin saving, and a blind save
 * would WIPE those new rows (Mike 2026-07-23: "is there a risk anything
 * gets overwritten?"). Yes there was; this closes it. Expenses/receipts
 * were never at risk, they're insert-only and not round-tripped by this
 * form. COUNT + MAX(id) per table is enough: inserts and deletes both
 * move it, and rows are never updated in place by anything but this save.
 */
function paintops_job_financials_fingerprint(  int $job_id  ) : string {
    global $wpdb;
    $pay = $wpdb->get_row( $wpdb->prepare( "SELECT COUNT(*) AS c, COALESCE(MAX(id), 0) AS m FROM %i WHERE job_id = %d", paintops_table( 'job_payments' ), $job_id ) );
    $hrs = $wpdb->get_row( $wpdb->prepare( "SELECT COUNT(*) AS c, COALESCE(MAX(id), 0) AS m FROM %i WHERE job_id = %d", paintops_table( 'job_worker_hours' ), $job_id ) );
    return md5( 'p' . (int) ($pay->c ?? 0) . '-' . (int) ($pay->m ?? 0) . 'h' . (int) ($hrs->c ?? 0) . '-' . (int) ($hrs->m ?? 0) );
}

function paintops_save_job_financials_from_post(  int $job_id  ) : array {
    // Returns ['saved' => bool, 'notices' => string[]]. 'saved' is only
    // true when the write path actually ran, so the route can stop
    // printing "Financials saved." after an expired-nonce/no-op submit
    // (2026-07-22 audit: a stale tab's save was silently discarded while
    // still showing the success notice).
    if ( $job_id <= 0 || empty( $_POST['cs_financials_nonce'] ) ) {
        return [
            'saved'   => false,
            'notices' => [],
        ];
    }
    if ( !wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['cs_financials_nonce'] ) ), 'cs_save_financials_' . $job_id ) ) {
        return [
            'saved'   => false,
            'notices' => [],
        ];
    }
    // Defense in depth: this is currently only ever reached via
    // manage-jobs.php's ?jobFinancials= route, which already gates on
    // paintops_user_can_view_financials() (Administrator-only) before
    // calling this function, but checking again here means a future
    // new entry point can't accidentally let a lower-privileged account
    // save Gross/Profit data.
    if ( !function_exists( 'paintops_user_can_view_financials' ) || !paintops_user_can_view_financials() ) {
        return [
            'saved'   => false,
            'notices' => [],
        ];
    }
    $notices = [];
    $paintops_fin_saved = false;
    // Stale-snapshot guard: if payments/hours changed since this form was
    // rendered, refuse the save instead of wiping the newer rows. An old
    // form without the field skips the check (back-compat).
    $paintops_fin_fp_posted = sanitize_text_field( wp_unslash( $_POST['paintops_fin_fingerprint'] ?? '' ) );
    if ( $paintops_fin_fp_posted !== '' && $paintops_fin_fp_posted !== paintops_job_financials_fingerprint( $job_id ) ) {
        return [
            'saved'    => false,
            'conflict' => true,
            'notices'  => [],
        ];
    }
    // end: is__premium_only wp.org-stripped block
    return [
        'saved'   => $paintops_fin_saved,
        'notices' => $notices,
    ];
}

/**
 * Can this user log an expense + receipt for this specific job? True for
 * any staff member (paintops_user_can_manage()), or for a Painter/Helper
 * who is assigned to this particular job (paintops_user_can_view_job(),
 * defined in job-view.php, the same "assigned worker" check ?viewEid=
 * and ?jobPhotos= already use) AND whose "Can upload receipts" permission
 * is checked on PaintOps > Painters/Helpers (defaults to true, so this
 * stays open for every existing worker unless an Administrator
 * specifically unchecks it for someone). Manual expense + receipt logging
 * is a FREE feature (Mike's call, so a lapsed-Pro site keeps recording and
 * reviewing its costs). Pro adds the profit/margin math, the Profits
 * dashboard, the business/tax reports, and the AI receipt scan on top of
 * the same data, so this permission is NOT gated on paintops_is_pro().
 */
function paintops_user_can_add_job_receipt(  int $job_id  ) : bool {
    if ( function_exists( 'paintops_user_can_manage' ) && paintops_user_can_manage() ) {
        return true;
    }
    if ( $job_id <= 0 || !function_exists( 'paintops_user_can_view_job' ) || !paintops_user_can_view_job( $job_id ) ) {
        return false;
    }
    return !function_exists( 'paintops_worker_has_permission' ) || paintops_worker_has_permission( 'upload_receipts' );
}

/**
 * Regex body (no delimiters) that spots a FUEL purchase from a vendor
 * name or note text. Shared verbatim between the PHP save-time check and
 * the form's live JS hint so the two can never disagree. Word-boundary
 * matches on purpose: "shellac" never trips "shell", "gasket" never
 * trips "gas", "mobile" never trips "mobil". "Gulf" is deliberately
 * absent (too many Gulf-coast business names would false-positive).
 */
function paintops_fuel_pattern() : string {
    return '\\b(?:fuel|gasoline|diesel|gas|gas station|shell|chevron|exxon|mobil|bp|sunoco|marathon|citgo|valero|texaco|conoco|phillips 66|amoco|arco|speedway|racetrac|raceway|quiktrip|wawa|sheetz|circle k|caseys|casey\'s|murphy usa|pilot|flying j|love\'s|loves travel|7-eleven)\\b';
}

/**
 * Does this vendor (plus optional note) read like fuel? Fuel is tracked
 * business-level in the Vehicle Log (Business > Vehicle & Mileage),
 * never as a job expense; per-job vehicle costs were removed 2026-07-18.
 * Used by the worker Add Receipt flow to WARN, not block (Mike's call,
 * 2026-07-20, "Option 2"): a gas-station receipt on a job is almost
 * always a crew lead routing fuel to the wrong books, which skews job
 * profit and double-counts against the vehicle deduction, but propane
 * or a genuine supply run from a gas station stays saveable via the
 * "save anyway" checkbox.
 */
function paintops_expense_looks_like_fuel(  string $vendor, string $note = ''  ) : bool {
    $haystack = trim( $vendor . ' ' . $note );
    if ( $haystack === '' ) {
        return false;
    }
    return (bool) preg_match( '~' . paintops_fuel_pattern() . '~i', $haystack );
}

/**
 * Lightweight "log what I bought + snap the receipt" save, used by the
 * worker-facing Add Receipt page (?jobAddReceipt=X) instead of the full
 * paintops_save_job_financials_from_post()/paintops_render_job_financial_box() pair,
 * which show Gross/Profit/Margin/Hours, data an assigned Painter/Helper
 * should never see (see paintops_user_can_view_financials(), Admin-only).
 * This only ever inserts one new row in job_expenses plus (optionally)
 * one receipt photo against it; it never reads or writes gross_amount,
 * worker hours, or any other expense row. Returns an array of HTML notice
 * strings for the caller to display, same convention as
 * paintops_save_job_financials_from_post().
 */
function paintops_save_worker_expense_receipt(  int $job_id  ) : array {
    if ( $job_id <= 0 || empty( $_POST['paintops_add_receipt_nonce'] ) ) {
        return [];
    }
    if ( !wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['paintops_add_receipt_nonce'] ) ), 'paintops_add_receipt_' . $job_id ) ) {
        return ['<div class="paintops-notice is-error">Security check failed or expired, please refresh the page and try again.</div>'];
    }
    // Refresh-resubmit guard: the identical POST already ran, skip it
    // silently, the expense list below already shows the saved entry.
    if ( function_exists( 'paintops_once_check' ) && !paintops_once_check() ) {
        return [];
    }
    if ( !function_exists( 'paintops_user_can_add_job_receipt' ) || !paintops_user_can_add_job_receipt( $job_id ) ) {
        return ['<div class="paintops-notice is-error">You do not have permission to add an expense to this job.</div>'];
    }
    $notices = [];
    // Manual expense + receipt logging is a FREE feature, so this save code
    // ships in the free build (it is NOT wrapped in is__premium_only()). It
    // only ever writes one job_expenses row plus an optional receipt photo;
    // the Pro-only profit math lives elsewhere (paintops_render_job_financial_box).
    global $wpdb;
    // "__new__" is the vendor select's sentinel for "+ New Vendor" (see
    // paintops_render_worker_add_receipt_box()), the real name typed in
    // lives in worker_expense_vendor_other instead. Same convention as
    // the full Financials expense table (paintops_save_job_financials_from_post()).
    // wp_unslash BEFORE sanitizing (2026-07-22 audit): WP slashes POST, so
    // "Casey's" was being stored as Casey\'s, polluting the vendor dropdown
    // and dodging the server-side fuel pattern (which the live JS hint,
    // reading the unslashed DOM value, correctly matched, the two guards
    // the code promises "can never disagree" disagreed).
    $raw_vendor = sanitize_text_field( wp_unslash( $_POST['worker_expense_vendor'] ?? '' ) );
    $vendor = ( $raw_vendor === '__new__' ? sanitize_text_field( wp_unslash( $_POST['worker_expense_vendor_other'] ?? '' ) ) : $raw_vendor );
    $category = sanitize_text_field( wp_unslash( $_POST['worker_expense_category'] ?? 'Materials/Supplies' ) );
    $amount = paintops_money_to_float( sanitize_text_field( wp_unslash( (string) ($_POST['worker_expense_amount'] ?? '0') ) ) );
    // Date of receipt, defaults to today, but validated against a real
    // Y-m-d date rather than trusted as-is, since it's just as easy for a
    // browser's native <input type="date"> to submit something malformed
    // (or missing entirely) as a well-formed date.
    $raw_date = sanitize_text_field( wp_unslash( $_POST['worker_expense_date'] ?? '' ) );
    $expense_date = current_time( 'Y-m-d' );
    if ( $raw_date !== '' ) {
        $date_obj = DateTime::createFromFormat( 'Y-m-d', $raw_date );
        if ( $date_obj && $date_obj->format( 'Y-m-d' ) === $raw_date ) {
            $expense_date = $raw_date;
        }
    }
    if ( $vendor === '' && $amount <= 0 ) {
        $notices[] = '<div class="paintops-notice is-error">Enter at least a vendor or an amount before saving.</div>';
        return $notices;
    }
    // Fuel guard, warn-but-allow (Mike's call 2026-07-20): bounce the
    // save once with nothing inserted; the form re-renders sticky with a
    // "save anyway" checkbox (file inputs can't be refilled by browsers,
    // so the notice says to re-pick the photo). A re-submit with the box
    // ticked passes straight through here.
    if ( empty( $_POST['worker_expense_fuel_ok'] ) && paintops_expense_looks_like_fuel( $vendor ) ) {
        $notices[] = '<div class="paintops-notice is-error">&#9981; <strong>This looks like a fuel receipt.</strong> Fuel is tracked on the company vehicle, not on jobs; hang onto the receipt and hand it to the office. If this really is a job expense (propane, supplies bought at a gas station), tick &ldquo;Not vehicle fuel&rdquo; below, re-attach the photo, and save again.</div>';
        return $notices;
    }
    $expenses_table = paintops_table( 'job_expenses' );
    $wpdb->insert( $expenses_table, [
        'job_id'       => $job_id,
        'expense_date' => $expense_date,
        'vendor'       => $vendor,
        'category'     => ( $category ?: 'Materials/Supplies' ),
        'amount'       => $amount,
        'created_by'   => get_current_user_id(),
    ], [
        '%d',
        '%s',
        '%s',
        '%s',
        '%f',
        '%d'
    ] );
    $expense_id = (int) $wpdb->insert_id;
    if ( $expense_id <= 0 ) {
        $notices[] = '<div class="paintops-notice is-error">Could not save this expense (' . esc_html( $wpdb->last_error ) . '). Nothing was uploaded.</div>';
        return $notices;
    }
    $notices[] = '<div class="paintops-notice is-success">Expense for "' . esc_html( ( $vendor ?: 'this item' ) ) . '" saved.</div>';
    // Same create-then-attach-in-one-step pattern as the staff inline
    // expense row: if a receipt photo was chosen, upload it now against
    // the ID this row just got.
    // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- the $_FILES array is validated and handed to wp_handle_upload by the shared upload helper, which checks type, size and error code
    $po_worker_receipt_file = paintops_sanitize_upload( $_FILES['worker_receipt_file'] ?? null );
    if ( function_exists( 'paintops_handle_job_photo_upload' ) && !empty( $po_worker_receipt_file ) && ($po_worker_receipt_file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_NO_FILE ) {
        $upload_result = paintops_handle_job_photo_upload(
            $job_id,
            $po_worker_receipt_file,
            'receipt',
            $expense_id
        );
        if ( is_wp_error( $upload_result ) ) {
            $notices[] = '<div class="paintops-notice is-error">Receipt photo: ' . esc_html( $upload_result->get_error_message() ) . '</div>';
        } else {
            $notices[] = '<div class="paintops-notice is-success">Receipt photo attached.</div>';
        }
    }
    return $notices;
}

/**
 * Worker-facing "Add Receipt" box, the entire point of this is what it
 * DOESN'T show: no Gross, no Profit, no Margin, no Hours, no other jobs'
 * expenses. Just "what did you buy, roughly what category, how much, and
 * a photo of the receipt" for THIS job. Staff can use it too (it's
 * reachable from the "More" menu as well as the top-level Add Receipt
 * action on ?viewEid=), but it's really built for an assigned
 * Painter/Helper who bought something on-site and doesn't have (and
 * shouldn't need) access to the full Financials page.
 */
function paintops_render_worker_add_receipt_box(  int $job_id  ) : void {
    if ( !function_exists( 'paintops_user_can_add_job_receipt' ) || !paintops_user_can_add_job_receipt( $job_id ) ) {
        return;
    }
    // Manual expense + receipt logging is FREE, so this box ships in the free
    // build (no is__premium_only wrapper, no Pro upgrade gate). The only Pro
    // piece here is the optional "Use AI to add details" receipt scan, gated
    // by $ai_available below and re-checked server-side in
    // paintops_ajax_ai_scan_receipt(); a free site can't reach it (it also
    // can't set an OpenAI key, since AI Settings is Pro-only).
    global $wpdb;
    // "No materials needed" toggle (Mike 2026-07-23, moved here from the
    // job form same day: the dashboard's no-materials reminder links
    // straight to this page, so this is where you'd look for the off
    // switch). Sets jobs.no_materials_needed, which exempts the job from
    // the "no materials logged" reminder (dashboard-alerts.php) and the
    // Business Checkup no-expense scan. Staff-only: muting a bookkeeping
    // reminder is a books decision, above the worker receipt opt-in.
    // Saved by its own tiny auto-submitting form below, handled here
    // BEFORE the read so the checkbox renders the fresh value.
    $nm_can_manage = function_exists( 'paintops_user_can_manage' ) && paintops_user_can_manage();
    if ( $nm_can_manage && isset( $_POST['paintops_set_no_materials'] ) && wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['paintops_nomat_nonce'] ?? '' ) ), 'paintops_no_materials_' . $job_id ) ) {
        $wpdb->update(
            paintops_table( 'jobs' ),
            [
                'no_materials_needed' => ( !empty( $_POST['no_materials_needed'] ) ? 1 : 0 ),
            ],
            [
                'id' => $job_id,
            ],
            ['%d'],
            ['%d']
        );
    }
    $nm_flag = ( $nm_can_manage ? (int) $wpdb->get_var( $wpdb->prepare( "SELECT no_materials_needed FROM %i WHERE id = %d LIMIT 1", paintops_table( 'jobs' ), $job_id ) ) : 0 );
    $expense_category_options = [
        'Materials/Supplies',
        'Subcontractor',
        'Equipment/Rental',
        'Other'
    ];
    // Same "remember every vendor already typed in, anywhere" dropdown as
    // the full Financials expense table (see paintops_render_job_financial_box())
    //, this used to be a plain text box here, which meant a worker's
    // typo/variant spelling never matched what staff already had on file.
    // Single (non-repeating) row on this form, so plain (non-array) field
    // names/IDs instead of the Financials table's expense_vendor[]/[i].
    $known_vendors = $wpdb->get_col( $wpdb->prepare( "SELECT DISTINCT vendor FROM %i WHERE vendor != '' ORDER BY vendor ASC", paintops_table( 'job_expenses' ) ) );
    $is_pro = function_exists( 'paintops_pro_active' ) && paintops_pro_active();
    $ai_available = function_exists( 'paintops_ai_ready' ) && paintops_ai_ready();
    // Non-billable follow-up visits (Mike 2026-07-23): a Callback or Punch
    // List with no price of its own is warranty-type work, and expenses
    // logged on IT tend to get orphaned in accounting, nothing rolls them
    // up. When this job is a follow-up with a parent AND has no billable
    // amount stored (a billable Add-On keeps its own books on purpose),
    // suggest logging the expense on the main job instead, one click away.
    $fu_suggest_html = '';
    $fu_row = $wpdb->get_row( $wpdb->prepare( "SELECT visit_type, parent_job_id FROM %i WHERE id = %d LIMIT 1", paintops_table( 'jobs' ), $job_id ) );
    if ( $fu_row && (string) ($fu_row->visit_type ?? 'original') !== 'original' && (int) ($fu_row->parent_job_id ?? 0) > 0 ) {
        $fu_parent_id = (int) $fu_row->parent_job_id;
        $fu_gross = (float) $wpdb->get_var( $wpdb->prepare( "SELECT gross_amount FROM %i WHERE job_id = %d LIMIT 1", paintops_table( 'job_financials' ), $job_id ) );
        if ( $fu_gross <= 0 ) {
            $fu_labels = [
                'callback'   => 'Callback',
                'punch_list' => 'Punch List',
                'addon'      => 'Add-On',
            ];
            $fu_type_label = $fu_labels[(string) $fu_row->visit_type] ?? 'follow-up';
            $fu_parent = $wpdb->get_row( $wpdb->prepare( "SELECT jobTitle, custFirst, custLast FROM %i WHERE id = %d LIMIT 1", paintops_table( 'jobs' ), $fu_parent_id ) );
            $fu_parent_name = ( $fu_parent ? trim( (string) $fu_parent->custFirst . ' ' . (string) $fu_parent->custLast ) : '' );
            $fu_suggest_html = '<div style="background:#fff8ec;border:1px solid #e0c36a;border-radius:6px;padding:10px 14px;margin:0 0 14px;font-size:13.5px;color:#5c4a1a;">' . '<strong>Heads up: this is a ' . esc_html( $fu_type_label ) . ' visit with no price of its own.</strong> ' . 'Costs for non-billable visits usually belong on the main job\'s books, otherwise they can slip through accounting. ' . '<a href="' . esc_url( paintops_admin_url( [
                'jobAddReceipt' => $fu_parent_id,
            ] ) ) . '" style="font-weight:600;">Add this expense to Job #' . (int) $fu_parent_id . (( $fu_parent_name !== '' ? ' (' . esc_html( $fu_parent_name ) . ')' : '' )) . ' instead</a>, or continue below to keep it on this visit.' . '</div>';
        }
    }
    // Sticky refill after a fuel-warning bounce (and ONLY then): the save
    // handler returned without inserting, and losing everything the lead
    // typed would make the warning feel like punishment. Successful saves
    // still render the form empty. File inputs can't be refilled by
    // browsers; the warning text tells them to re-pick the photo.
    $sticky_vendor_sel = '';
    $sticky_vendor_other = '';
    $sticky_category = '';
    $sticky_amount = '';
    $sticky_date = '';
    $fuel_bounced = false;
    if ( isset( $_POST['paintops_save_worker_receipt'] ) && empty( $_POST['worker_expense_fuel_ok'] ) ) {
        $sticky_vendor_sel = sanitize_text_field( wp_unslash( $_POST['worker_expense_vendor'] ?? '' ) );
        $sticky_vendor_other = sanitize_text_field( wp_unslash( $_POST['worker_expense_vendor_other'] ?? '' ) );
        $posted_vendor = ( $sticky_vendor_sel === '__new__' ? $sticky_vendor_other : $sticky_vendor_sel );
        if ( function_exists( 'paintops_expense_looks_like_fuel' ) && paintops_expense_looks_like_fuel( $posted_vendor ) ) {
            $fuel_bounced = true;
            $sticky_category = sanitize_text_field( wp_unslash( $_POST['worker_expense_category'] ?? '' ) );
            $sticky_amount = sanitize_text_field( wp_unslash( $_POST['worker_expense_amount'] ?? '' ) );
            $sticky_date = sanitize_text_field( wp_unslash( $_POST['worker_expense_date'] ?? '' ) );
        } else {
            $sticky_vendor_sel = '';
            $sticky_vendor_other = '';
        }
    }
    ?>
    <div class="cs-financial-box">
        <h3>Add Expense</h3>
        <p style="color:#777;font-size:13px;margin-top:0;">Log what you bought for this job and attach the receipt: snap a photo with your phone, choose an existing one, or upload a PDF.<?php 
    echo esc_attr( ( $ai_available ? ' Note: AI can\'t currently read PDFs, so fill in the details yourself for PDF receipts.' : '' ) );
    ?></p>
        <?php 
    echo wp_kses( $fu_suggest_html, paintops_kses_allowed_html(), paintops_kses_protocols() );
    ?>
        <?php 
    if ( $nm_can_manage ) {
        ?>
        <?php 
        // Collapsed drawer (Mike's follow-up 2026-07-23: the inline
        // checkbox was misaligned and always on screen; now the
        // standard cs-fin-acc show/hide with the On/Off state in
        // the collapsed header, so it reads at a glance).
        ?>
        <details class="cs-fin-acc" style="margin:0 0 14px;">
            <summary><span class="cs-fin-acc-title" style="font-size:14.5px;">No Materials Needed</span><span class="cs-fin-acc-meta"><?php 
        echo wp_kses( ( $nm_flag === 1 ? '<span class="pos">On</span>' : 'Off' ), paintops_kses_allowed_html(), paintops_kses_protocols() );
        ?></span></summary>
            <div class="cs-fin-acc-body">
                <form method="post" style="margin:0;">
                    <input type="hidden" name="paintops_nomat_nonce" value="<?php 
        echo esc_attr( wp_create_nonce( 'paintops_no_materials_' . $job_id ) );
        ?>">
                    <input type="hidden" name="paintops_set_no_materials" value="1">
                    <label style="display:flex;align-items:center;gap:8px;font-weight:600;cursor:pointer;margin:0 0 8px;">
                        <input type="checkbox" name="no_materials_needed" value="1" <?php 
        checked( $nm_flag, 1 );
        ?> onchange="this.form.submit()" style="margin:0;flex-shrink:0;">
                        <span>No materials needed for this job (customer supplies paint &amp; materials)</span>
                    </label>
                    <p style="margin:0;color:var(--po-text-muted,#777);font-size:13px;">Checking this stops the &ldquo;no materials logged&rdquo; reminder on the dashboard and Business Checkup for this job. Any expenses you do log still count normally. Saves immediately.</p>
                </form>
            </div>
        </details>
        <?php 
    }
    ?>

        <?php 
    if ( $ai_available && !$fuel_bounced ) {
        ?>
        <?php 
        // AI-first chooser (2026-07-25), same pattern as the Vehicle
        // Log's "Scan Receipt with AI" / "Manually Enter" choice: pick
        // AI and the photo picker opens immediately, choosing a photo
        // reveals the form already scanned; pick Manually Enter and the
        // form just appears empty. Progressive enhancement (1.0.249):
        // the chooser ships hidden and the form visible; the enqueued
        // add-expense JS flips them on load, so a no-JS browser always
        // gets a working form with no noscript styling needed. Skipped
        // entirely when $fuel_bounced (the server just sent the worker
        // back here with the rest of the form's fields already sticky
        // after a fuel-vendor warning), the form stays
        // visible so their already-filled fields aren't hidden behind
        // a chooser they already got past once.
        ?>
        <div id="workerAddChooser" style="display:none;">
            <p style="margin:0 0 12px;color:#777;font-size:13px;">Start from the receipt and let AI read the vendor, category, amount, and date, or enter them by hand.</p>
            <button type="button" id="workerScanChoice" class="paintops-btn paintops-btn-primary">Scan Receipt with AI</button>
            <button type="button" id="workerManualChoice" class="paintops-btn paintops-btn-secondary" style="margin-left:8px;">Manually Enter</button>
        </div>
        <?php 
    }
    ?>

        <form method="post" enctype="multipart/form-data" id="workerAddReceiptForm">
            <input type="hidden" name="job_id" value="<?php 
    echo esc_attr( (string) $job_id );
    ?>">
            <input type="hidden" name="paintops_add_receipt_nonce" value="<?php 
    echo esc_attr( wp_create_nonce( 'paintops_add_receipt_' . $job_id ) );
    ?>">
            <?php 
    echo wp_kses( ( function_exists( 'paintops_once_field' ) ? paintops_once_field() : '' ), paintops_kses_allowed_html(), paintops_kses_protocols() );
    ?>

            <?php 
    // Receipt first (Mike 2026-07-22): the natural flow is
    // snap the receipt, tap Analyze, and let AI fill the
    // fields BELOW, so the photo buttons and the Analyze row
    // sit at the top instead of after fields you'd otherwise
    // start typing by hand.
    ?>
            <p style="margin-top:0;">
                <label><strong>Receipt</strong></label><br>
                <?php 
    if ( function_exists( 'paintops_render_photo_picker_input' ) ) {
        paintops_render_photo_picker_input(
            'worker_receipt_file',
            'workerReceiptFile',
            false,
            false,
            false,
            true
        );
        // PDFs allowed for receipts (2026-07-22)
    } else {
        echo '<input type="file" name="worker_receipt_file" id="workerReceiptFile" class="paintops-photo-input" accept="image/*,application/pdf">';
    }
    ?>
            </p>

            <?php 
    if ( $ai_available ) {
        ?>
            <?php 
        // One action instead of two (Mike 2026-07-22): the Analyze
        // button appears once a receipt IMAGE is chosen (see the
        // change listener below; PDFs keep it hidden, the scan
        // reads photos only, and the click handler's PDF guard
        // stays as the backstop).
        ?>
            <div id="workerAiReceiptControls" style="display:none;">
                <p style="margin-bottom:2px;">
                    <button type="button" class="button paintops-btn-ai" id="workerAiAnalyzeBtn">Analyze Receipt</button>
                    <span id="workerAiReceiptStatus" style="margin-left:8px;"></span>
                </p>
                <p style="color:#777;font-size:13px;margin-top:0;">Use AI to add details. Reads the receipt photo and fills in Vendor, Category, Amount, and Date below for you to double-check.</p>
            </div>
            <?php 
    } elseif ( $is_pro && function_exists( 'paintops_user_can_manage' ) && paintops_user_can_manage() ) {
        ?>
            <p style="color:#777;font-size:13px;">Tip: add an OpenAI API key under PaintOps Settings &gt; AI Photo Estimating to let AI fill in receipt details automatically.</p>
            <?php 
    }
    ?>

            <div class="po-expense-grid">
            <p>
                <label><strong>Vendor</strong></label><br>
                <select name="worker_expense_vendor" id="workerVendorSelect" class="cs-vendor-select">
                    <option value="">Select vendor</option>
                    <?php 
    foreach ( $known_vendors as $vendor ) {
        ?>
                        <option value="<?php 
        echo esc_attr( $vendor );
        ?>"<?php 
        selected( $sticky_vendor_sel, $vendor );
        ?>><?php 
        echo esc_html( $vendor );
        ?></option>
                    <?php 
    }
    ?>
                    <option value="__new__"<?php 
    selected( $sticky_vendor_sel, '__new__' );
    ?>>+ New Vendor</option>
                </select>
                <input type="text" name="worker_expense_vendor_other" id="workerVendorOther" placeholder="New vendor name" value="<?php 
    echo esc_attr( $sticky_vendor_other );
    ?>" style="display:<?php 
    echo esc_attr( ( $sticky_vendor_sel === '__new__' ? '' : 'none' ) );
    ?>;margin-top:4px;">
                <span id="workerFuelHint" style="display:<?php 
    echo esc_attr( ( $fuel_bounced ? 'block' : 'none' ) );
    ?>;color:#8a5a00;background:#fff8ec;border:1px solid #e0c36a;border-radius:6px;padding:8px 10px;margin-top:6px;font-size:13px;">&#9981; Looks like fuel. Fuel goes in the Vehicle Log, not on jobs; hand the receipt to the office, or tick &ldquo;Not vehicle fuel&rdquo; below if this really is a job expense.</span>
            </p>
            <p>
                <label><strong>Category</strong></label><br>
                <select name="worker_expense_category" id="workerCategorySelect">
                    <?php 
    foreach ( $expense_category_options as $option ) {
        ?>
                        <option value="<?php 
        echo esc_attr( $option );
        ?>"<?php 
        selected( $sticky_category, $option );
        ?>><?php 
        echo esc_html( $option );
        ?></option>
                    <?php 
    }
    ?>
                </select>
            </p>
            <p>
                <label><strong>Amount</strong></label><br>
                <input type="text" name="worker_expense_amount" id="workerAmountInput" inputmode="decimal" placeholder="0.00" value="<?php 
    echo esc_attr( $sticky_amount );
    ?>">
            </p>
            <p>
                <label><strong>Date of Receipt</strong></label><br>
                <input type="date" name="worker_expense_date" id="workerDateInput" value="<?php 
    echo esc_attr( ( $sticky_date !== '' ? $sticky_date : current_time( 'Y-m-d' ) ) );
    ?>">
            </p>
            </div>
            <p id="workerFuelOkRow" style="display:<?php 
    echo esc_attr( ( $fuel_bounced ? '' : 'none' ) );
    ?>;background:#fff8ec;border:1px solid #e0c36a;border-radius:6px;padding:8px 10px;">
                <label><input type="checkbox" name="worker_expense_fuel_ok" value="1"> <strong>Not vehicle fuel, save to this job anyway</strong></label><br>
                <span style="color:#8a5a00;font-size:12px;">Only for a genuine job purchase from a gas station (propane, supplies). Vehicle fuel goes to the office.</span>
            </p>

            <p><button type="submit" name="paintops_save_worker_receipt" value="1" class="paintops-btn paintops-btn-primary">Add Expense &amp; Receipt</button></p>
        </form>
    </div>
    <?php 
    if ( function_exists( 'paintops_echo_photo_compress_script' ) ) {
        paintops_echo_photo_compress_script();
    }
    if ( function_exists( 'paintops_echo_photo_picker_script' ) ) {
        paintops_echo_photo_picker_script();
    }
    ?>
    <?php 
    // Static add-expense logic lives in assets/js/job-financials-add-expense.js;
    // only the per-page dynamic values (nonces, ids, fuel regex) travel as config.
    paintops_enqueue_script( 'job-financials-add-expense', 'var paintopsAddExpenseCfg = ' . wp_json_encode( [
        'fuelPattern' => paintops_fuel_pattern(),
        'aiAvailable' => (bool) $ai_available,
        'ajaxUrl'     => admin_url( 'admin-ajax.php' ),
        'scanNonce'   => wp_create_nonce( 'paintops_ai_scan_receipt' ),
        'catNonce'    => wp_create_nonce( 'paintops_ai_suggest_category' ),
        'jobId'       => (string) $job_id,
    ] ) . ';' );
    ?>
    <?php 
}

function paintops_render_job_financial_box(  int $job_id  ) : void {
    global $wpdb;
    // Defense in depth: currently only reached via manage-jobs.php's
    // ?jobFinancials= route, which already gates on
    // paintops_user_can_view_financials() before calling this, checking
    // again here means a future new call site can't accidentally show
    // Gross/Profit/Margin to a non-Administrator.
    if ( !function_exists( 'paintops_user_can_view_financials' ) || !paintops_user_can_view_financials() ) {
        echo '<p>You do not have permission to view financials for this job.</p>';
        return;
    }
    if ( !function_exists( 'paintops_pro_code_present' ) ) {
        // The page body exists only in the premium build; the free
        // build's UI never links here.
        if ( function_exists( 'paintops_render_pro_pointer' ) ) {
            paintops_render_pro_pointer( 'Job Financials & Profitability' );
        }
        return;
    }
    // end: is__premium_only wp.org-stripped block
}

// end paintops_fs()->is__premium_only()
/**
 * Print-friendly Job Profit Report
 *
 * Opens a clean cover sheet for stapling receipts behind.
 *
 * Button usage anywhere you have a job ID:
 * paintops_render_job_profit_report_button($job_id);
 *
 * Direct URL:
 * ?cs_job_profit_report=123
 */
if ( !function_exists( 'paintops_get_job_quote_for_financials' ) ) {
    function paintops_get_job_quote_for_financials(  $job_id, $estimate_id = 0  ) : array {
        global $wpdb;
        $job_id = (int) $job_id;
        $estimate_id = (int) $estimate_id;
        $quote = null;
        if ( $estimate_id > 0 ) {
            $quote = $wpdb->get_row( $wpdb->prepare( "SELECT estID, estTotal\n                     FROM %i\n                     WHERE estID = %d\n                     LIMIT 1", paintops_table( 'estimates' ), $estimate_id ) );
        }
        if ( !$quote && $job_id > 0 ) {
            $quote = $wpdb->get_row( $wpdb->prepare( "SELECT estID, estTotal\n                     FROM %i\n                     WHERE jobID = %d\n                     ORDER BY estID DESC\n                     LIMIT 1", paintops_table( 'estimates' ), $job_id ) );
        }
        if ( !$quote ) {
            return [
                'found'       => false,
                'estimate_id' => 0,
                'total'       => 0,
            ];
        }
        return [
            'found'       => true,
            'estimate_id' => (int) $quote->estID,
            'total'       => (float) $quote->estTotal,
        ];
    }

}
// end paintops_fs()->is__premium_only()
if ( !function_exists( 'paintops_render_job_profit_report_page' ) ) {
    function paintops_render_job_profit_report_page(  int $job_id  ) : void {
        // Gross/Profit/Margin data is Administrator-only everywhere else
        // in this plugin (see paintops_user_can_view_financials()), this
        // standalone printable report is reachable directly via
        // ?cs_job_profit_report=<id> (see paintops_maybe_show_job_profit_report()
        // below), outside the normal ?jobFinancials=/?viewEid= routing, so
        // it must enforce the same Administrator-only check itself rather
        // than the much weaker edit_posts capability.
        if ( !function_exists( 'paintops_user_can_view_financials' ) || !paintops_user_can_view_financials() ) {
            wp_die( 'You do not have permission to view this report.' );
        }
        wp_die( 'This report is part of PaintOps Pro.' );
        // end: is__premium_only wp.org-stripped block
    }

}
if ( !function_exists( 'paintops_render_job_profit_report_page_minimal' ) ) {
    function paintops_render_job_profit_report_page_minimal(  int $job_id  ) : void {
        // Same Administrator-only rule as paintops_render_job_profit_report_page()
        // above, this "minimal" variant is reachable via the exact same
        // unauthenticated-routing-bypassing URL, so it needs the same fix.
        if ( !function_exists( 'paintops_user_can_view_financials' ) || !paintops_user_can_view_financials() ) {
            wp_die( 'You do not have permission to view this report.' );
        }
        if ( function_exists( 'paintops_render_pro_pointer' ) ) {
            paintops_render_pro_pointer( 'Job Financials & Profitability' );
        }
        return;
        // end: is__premium_only wp.org-stripped block
    }

}
// end paintops_fs()->is__premium_only()
if ( !function_exists( 'paintops_maybe_show_job_profit_report' ) ) {
    function paintops_maybe_show_job_profit_report() : void {
        if ( empty( $_GET['cs_job_profit_report'] ) ) {
            return;
        }
        $job_id = absint( $_GET['cs_job_profit_report'] );
        if ( $job_id <= 0 ) {
            wp_die( 'Invalid job ID.' );
        }
        /*
         * current_user_can() depends on pluggable user functions. If this file is
         * loaded unusually early, wait for wp_loaded instead of throwing a fatal.
         */
        if ( !function_exists( 'wp_get_current_user' ) ) {
            return;
        }
        paintops_render_job_profit_report_page( $job_id );
    }

}
/*
 * Catch the print-report URL only after WordPress has loaded pluggable user functions.
 *
 * Do not call paintops_maybe_show_job_profit_report() directly while this file is being
 * included. At plugin-load time, current_user_can() can run before wp_get_current_user()
 * exists, which causes a fatal error.
 */
add_action( 'wp_loaded', 'paintops_maybe_show_job_profit_report', 1 );
add_action( 'admin_init', 'paintops_maybe_show_job_profit_report', 1 );
add_action( 'template_redirect', 'paintops_maybe_show_job_profit_report', 1 );
/*
 * Fallback for rare cases where this file is included after wp_loaded already fired.
 */
if ( function_exists( 'did_action' ) && did_action( 'wp_loaded' ) ) {
    paintops_maybe_show_job_profit_report();
}
// end paintops_fs()->is__premium_only()
if ( !function_exists( 'paintops_render_yearly_financial_dashboard' ) ) {
    function paintops_render_yearly_financial_dashboard(  $current_year = null  ) {
        $current_year = ( $current_year ? intval( $current_year ) : intval( gmdate( 'Y', current_time( 'timestamp' ) ) ) );
        if ( !function_exists( 'paintops_pro_code_present' ) ) {
            // The dashboard exists only in the premium build; the free
            // build's nav never links here.
            if ( function_exists( 'paintops_render_pro_pointer' ) ) {
                paintops_render_pro_pointer( 'Profits' );
            }
            return;
        }
        // paintops_fs()->is__premium_only()
    }

    // end paintops_render_yearly_financial_dashboard()
}