<?php
// ============================================================
//  MediCore — Sales API
//  File: api/sales.php
// ============================================================
// ── CSV Export (GET ?action=export_csv) ──────────────────────
if (($_GET['action'] ?? '') === 'export_csv') {
    require_once '../includes/db.php';
    require_once '../includes/auth.php';
    require_login();
session_write_close(); // release session lock — prevents XAMPP request queuing
    ensure_soft_delete('sales');
    // Profit column is gated server-side (not just hidden in the UI) by
    // the "Profit Visibility" permission — a CSV download bypasses any
    // client-side hiding, so the check has to happen here too.
    $showProfit = can_view_profit();
    $rows = db_rows(
        "SELECT s.invoice, s.customer_name, s.date, s.final_price,
                s.paid, s.due, s.approx_profit, s.payment_type,
                s.sale_mode, s.status, s.notes, u.name AS created_by_name
           FROM sales s
      LEFT JOIN users u ON u.id = s.created_by
          WHERE s.is_deleted = 0 ORDER BY s.date DESC, s.id DESC"
    );
    header('Content-Type: text/csv; charset=utf-8');
    header('Content-Disposition: attachment; filename="sales_export_'.date('Y-m-d').'.csv"');
    $out = fopen('php://output','w');
    $header = ['Invoice','Customer','Date','Total','Paid','Due'];
    if ($showProfit) $header[] = 'Profit';
    $header = array_merge($header, ['Payment','Mode','Status','Staff','Notes']);
    fputcsv($out, $header);
    foreach ($rows as $r) {
        $row = [$r['invoice'],$r['customer_name'],$r['date'],
            $r['final_price'],$r['paid'],$r['due']];
        if ($showProfit) $row[] = $r['approx_profit'];
        $row = array_merge($row, [
            $r['payment_type'],$r['sale_mode'],$r['status'],$r['created_by_name'],$r['notes']]);
        fputcsv($out, $row);
    }
    fclose($out); exit;
}

require_once '../includes/db.php';
require_once '../includes/auth.php';
require_once '../includes/audit.php';
require_login();

header('Content-Type: application/json');

// ── Defensive migration: guarantee UNIQUE INDEX on sales.invoice ──
// The base schema already defines `invoice VARCHAR(30) NOT NULL UNIQUE`,
// but this check protects any older/manually-modified database where
// that constraint might be missing — without it, the atomic ID-based
// invoice generation below cannot guarantee global uniqueness.
try {
    $hasUnique = db_val(
        "SELECT COUNT(*) FROM information_schema.STATISTICS
          WHERE TABLE_SCHEMA = DATABASE()
            AND TABLE_NAME   = 'sales'
            AND COLUMN_NAME  = 'invoice'
            AND NON_UNIQUE   = 0"
    );
    if (!$hasUnique) {
        db_run("ALTER TABLE sales ADD UNIQUE INDEX uq_sales_invoice (invoice)");
    }
} catch (Throwable $e) { /* best-effort — ignore if insufficient privileges */ }

ensure_soft_delete('sales');

// Must run BEFORE any transaction starts, never inside one: CREATE TABLE
// is DDL, and MySQL implicitly commits whatever transaction is currently
// open the moment a DDL statement executes — even one guarded by
// "IF NOT EXISTS". publish_stock_event() (called mid-transaction below)
// calls this too, but its own static-guard makes that call a no-op once
// it's already run here, so the table never gets created mid-transaction.
ensure_stock_events_table();
ensure_sync_tables();
ensure_stock_ledger_table();

// ── Defensive migration: guarantee rounding columns exist ─────
try {
    $hasRoundCol = db_val(
        "SELECT COUNT(*) FROM information_schema.COLUMNS
          WHERE TABLE_SCHEMA = DATABASE()
            AND TABLE_NAME   = 'sales'
            AND COLUMN_NAME  = 'round_adjustment'"
    );
    if (!$hasRoundCol) {
        db_run("ALTER TABLE sales ADD COLUMN round_adjustment DECIMAL(6,2) DEFAULT 0.00 AFTER final_price");
        db_run("ALTER TABLE sales ADD COLUMN payable_amount   DECIMAL(12,2) DEFAULT 0.00 AFTER round_adjustment");
        // Backfill existing rows so reports don't show blank/zero payable amounts
        db_run("UPDATE sales SET payable_amount = final_price WHERE payable_amount = 0");
    }
} catch (Throwable $e) { /* best-effort */ }

// ── Defensive migration: guarantee advance_used column exists ──
// Tracks how much of a customer's pre-paid advance balance was applied
// to each individual sale — previously only customers.advance was
// decremented, with no per-sale record, so Financial Reports' Advance
// Usage tab had nothing to query. Older rows default to 0 (unknown).
try {
    $hasAdvUsedCol = db_val(
        "SELECT COUNT(*) FROM information_schema.COLUMNS
          WHERE TABLE_SCHEMA = DATABASE()
            AND TABLE_NAME   = 'sales'
            AND COLUMN_NAME  = 'advance_used'"
    );
    if (!$hasAdvUsedCol) {
        db_run("ALTER TABLE sales ADD COLUMN advance_used DECIMAL(12,2) DEFAULT 0.00 AFTER paid");
    }
} catch (Throwable $e) { /* best-effort */ }

$body   = json_decode(file_get_contents('php://input'), true) ?? [];
$action = trim($body['action'] ?? '');

try {
    match($action) {
        'save'     => api_save($body),
        'next_ref' => api_next_ref(),
        'list'     => api_list($body),
        'detail'   => api_detail($body),
        'pay_due'  => api_pay_due($body),
        'delete'   => api_delete($body),
        default    => fail('Unknown action')
    };
} catch (Throwable $e) {
    fail($e->getMessage());
}

// ── Save Sale ─────────────────────────────────────────────────
function api_save(array $b): void {

    // ── Validate ──────────────────────────────────────────────
    $items   = $b['items']  ?? [];
    $custId  = (int)($b['customer_id'] ?? 1);

    if (!$items) fail('No items in sale');

    $customer = db_row("SELECT * FROM customers WHERE id = ?", [$custId]);
    if (!$customer) fail('Customer not found');

    $cfg    = get_settings();
    $prefix = $cfg['inv_prefix'] ?? 'INV';
    $year   = date('Y');

    $date        = $b['date']          ?? date('Y-m-d');
    $subTotal    = (float)($b['sub_total']    ?? 0);
    $discPct     = (float)($b['discount_pct'] ?? 0);
    $discAmt     = (float)($b['discount_amt'] ?? 0);
    $finalPrice  = (float)($b['final_price']  ?? 0);
    $paid        = (float)($b['paid']         ?? 0);
    $due         = (float)($b['due']          ?? 0);
    $profit      = (float)($b['approx_profit']?? 0);
    $payType     = trim($b['payment_type']    ?? 'Cash');
    $saleMode    = in_array($b['sale_mode']??'retail',['retail','wholesale'])
                   ? $b['sale_mode'] : 'retail';
    $advUsed     = max(0, (float)($b['advance_used'] ?? 0));
    $overpayAsAdv= !empty($b['overpay_as_advance']);
    $overpayAmt  = $overpayAsAdv ? max(0, (float)($b['overpay_amount'] ?? 0)) : 0;
    $notes       = trim($b['notes']           ?? '');

    // ── Payment Rounding — recalculated server-side, never trusted
    // from the client, so the database is always the source of truth
    // for what the customer actually owes.
    $roundingEnabled = (get_settings()['rounding_enabled'] ?? '0') === '1';
    $roundingMode    = get_settings()['rounding_mode'] ?? 'nearest';
    $roundAdjustment = 0.0;
    $payableAmount   = $finalPrice;
    if ($roundingEnabled) {
        $payableAmount = match ($roundingMode) {
            'up'   => ceil($finalPrice),
            'down' => floor($finalPrice),
            default => round($finalPrice),
        };
        $roundAdjustment = round($payableAmount - $finalPrice, 2);
    }
    // Recompute due against the rounded payable amount, not the raw total —
    // this is the figure the customer is actually being asked to pay.
    $totalPaid = $paid + $advUsed;
    $due       = max(0, round($payableAmount - $totalPaid, 2));
    $status    = $due <= 0.001 ? 'Paid' : ($totalPaid > 0 ? 'Partial' : 'Due');

    // ── Due Sale Validation ──────────────────────────────────────
    // A sale with an outstanding due balance must be attached to a
    // real, registered customer — Walk-in (id=1) is only valid for
    // fully-paid, zero-due transactions.
    if ($due > 0.001 && $custId <= 1) {
        fail('Due sales require a registered customer.');
    }

    // Verify stock availability before starting transaction —
    // skipped entirely when the admin has enabled Allow Negative Stock.
    $allowNegStock = (get_settings()['allow_negative_stock'] ?? '0') === '1';
    if (!$allowNegStock) {
        foreach ($items as $item) {
            $pid  = (int)($item['product_id'] ?? 0);
            $qty  = (int)($item['qty']        ?? 0);
            $avail = (int) db_val(
                "SELECT stock_pcs FROM products WHERE id = ?", [$pid]
            );
            if ($qty > $avail) {
                $name = trim($item['product_name'] ?? "Product #$pid");
                fail("Insufficient stock for \"$name\". Available: $avail, Requested: $qty");
            }
        }
    }

    // ── Atomic invoice number generation ────────────────────────
    // We NEVER scan with SELECT MAX()/COUNT() — that pattern races
    // under concurrent writes (two requests can read the same count
    // and both compute the same "next" number). Instead:
    //   1. Insert the sale row with a unique temporary placeholder
    //      (the invoice column is NOT NULL UNIQUE, so it must always
    //      hold *some* unique value, even momentarily).
    //   2. Read back the database-guaranteed-unique AUTO_INCREMENT id.
    //   3. Derive the real invoice number directly from that id —
    //      this is inherently race-free because InnoDB's AUTO_INCREMENT
    //      allocation is itself atomic, with no two concurrent inserts
    //      ever receiving the same id.
    //   4. UPDATE the row to set the final invoice number.
    // If a duplicate-key violation still occurs at any point (only
    // possible from manual data edits, legacy rows, or extremely rare
    // multi-branch id overlap), the entire attempt is rolled back and
    // automatically retried with a fresh id — the cashier never sees
    // an error for this.
    $maxAttempts = 5;
    $saleId  = null;
    $invoice = null;
    $lastErr = null;

    for ($attempt = 1; $attempt <= $maxAttempts; $attempt++) {
        db()->beginTransaction();
        try {
            // 1. Insert sale header with a temporary unique placeholder
            $tempInvoice = 'TMP-' . bin2hex(random_bytes(8));

            $saleId = db_insert(
                "INSERT INTO sales
                   (invoice, customer_id, customer_name, date,
                    sub_total, discount_pct, discount_amt,
                    total_price, final_price, round_adjustment, payable_amount,
                    paid, advance_used, due,
                    approx_profit, payment_type, status, notes, created_by)
                 VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
                [
                    $tempInvoice,
                    $custId,
                    $customer['name'],
                    $date,
                    $subTotal,
                    $discPct,
                    $discAmt,
                    $subTotal,      // total_price before discount
                    $finalPrice,
                    $roundAdjustment,
                    $payableAmount,
                    $paid,
                    $advUsed,
                    $due,
                    $profit,
                    $payType,
                    $status,
                    $notes,
                    current_user_id(),
                ]
            );

            // 2 & 3. Derive the real invoice number from the atomic
            // AUTO_INCREMENT id — guaranteed unique by the database
            // engine itself, no scanning, no race window.
            $invoice = sprintf('%s-%s-%06d', $prefix, $year, $saleId);

            // 4. Assign the final invoice number
            db_run("UPDATE sales SET invoice = ? WHERE id = ?", [$invoice, $saleId]);

            // 5. Insert sale items + deduct stock
            foreach ($items as $item) {
                $pid      = (int)($item['product_id']  ?? 0);
                $qty      = (int)($item['qty']          ?? 0);
                $price    = (float)($item['price']      ?? 0);
                $cost     = (float)($item['cost']       ?? 0);
                $discItem = (float)($item['disc_pct']   ?? 0);
                $total    = (float)($item['total']      ?? 0);
                $prodName = trim($item['product_name']  ?? '');

                db_run(
                    "INSERT INTO sale_items
                       (sale_id, product_id, product_name,
                        qty, price, cost, discount_pct, total, after_disc)
                     VALUES (?,?,?,?,?,?,?,?,?)",
                    [
                        $saleId, $pid, $prodName,
                        $qty, $price, $cost,
                        $discItem, $total, $total,
                    ]
                );

                // The pre-check above (before the transaction) is only an
                // early, friendly rejection for the common case — it can't
                // actually prevent two terminals racing to sell the last
                // few units of the same product, since both could read
                // "enough stock" a moment apart and both proceed. THIS
                // update is what actually enforces it: when negative-stock
                // sale is off, the `AND stock_pcs >= ?` guard is evaluated
                // atomically by InnoDB's row lock, so if two concurrent
                // sales would jointly oversell, only one UPDATE can match
                // and affect a row — the other affects 0 rows and this
                // sale is rejected/rolled back instead of silently
                // clamping to 0 and completing anyway.
                if ($allowNegStock) {
                    db_run(
                        "UPDATE products
                            SET stock_pcs = GREATEST(0, stock_pcs - ?),
                                updated_at = NOW()
                          WHERE id = ?",
                        [$qty, $pid]
                    );
                } else {
                    $affected = db_run(
                        "UPDATE products
                            SET stock_pcs = stock_pcs - ?,
                                updated_at = NOW()
                          WHERE id = ? AND stock_pcs >= ?",
                        [$qty, $pid, $qty]
                    );
                    if ($affected === 0) {
                        db()->rollBack();
                        $avail = (int) db_val("SELECT stock_pcs FROM products WHERE id = ?", [$pid]);
                        fail("Insufficient stock for \"$prodName\". Available: $avail, Requested: $qty");
                    }
                }

                $newStock = (int) db_val("SELECT stock_pcs FROM products WHERE id = ?", [$pid]);
                publish_stock_event($pid, 'sale', -$qty, $newStock, 'sale', $saleId);
                record_stock_ledger($pid, 'sale', 0, $qty, $newStock, 'sales', $saleId, $invoice);
            }

            // 6. Deduct advance used + add new due
            if ($custId > 1) {
                if ($advUsed > 0) {
                    db_run(
                        "UPDATE customers SET advance = GREATEST(0, advance - ?) WHERE id = ?",
                        [$advUsed, $custId]
                    );
                }
                if ($due > 0) {
                    db_run(
                        "UPDATE customers SET due = due + ? WHERE id = ?",
                        [$due, $custId]
                    );
                }
                if ($overpayAsAdv && $overpayAmt > 0) {
                    db_run(
                        "UPDATE customers SET advance = advance + ? WHERE id = ?",
                        [$overpayAmt, $custId]
                    );
                    $ledgerId = db_insert(
                        "INSERT INTO cash_ledger
                           (date, description, account, type, amount, ref)
                         VALUES (?, ?, ?, 'in', ?, ?)",
                        [
                            $date,
                            "Advance Deposit — {$customer['name']} (over-pay on {$invoice})",
                            $payType,
                            $overpayAmt,
                            $invoice,
                        ]
                    );
                    enqueue_sync('cash_ledger', $ledgerId, 'INSERT',
                        db_row("SELECT * FROM cash_ledger WHERE id = ?", [$ledgerId]));
                }
            }

            // 7. Add cash_ledger entry if paid > 0 (receipt)
            if ($paid > 0) {
                $ledgerId = db_insert(
                    "INSERT INTO cash_ledger
                       (date, description, account, type, amount, ref)
                     VALUES (?, ?, ?, 'in', ?, ?)",
                    [
                        $date,
                        "Sale — {$customer['name']} ({$invoice})",
                        $payType,
                        $paid,
                        $invoice,
                    ]
                );
                enqueue_sync('cash_ledger', $ledgerId, 'INSERT',
                    db_row("SELECT * FROM cash_ledger WHERE id = ?", [$ledgerId]));
            }

            // 8. Enqueue the sale itself (header + line items as one
            // denormalized snapshot — ideal for the Cloud side's
            // reporting/monitoring use case, avoids N extra queue rows
            // per sale for items that are never independently edited).
            $saleSnapshot = db_row("SELECT * FROM sales WHERE id = ?", [$saleId]);
            $saleSnapshot['items'] = db_rows("SELECT * FROM sale_items WHERE sale_id = ?", [$saleId]);
            enqueue_sync('sales', $saleId, 'INSERT', $saleSnapshot);

            db()->commit();

            log_action('create', 'sales', $saleId,
                "Invoice {$invoice} — Total: " . number_format((float)$finalPrice, 2) .
                ($custId > 1 ? " — Customer: {$customer['name']}" : ' — Walk-in'));

            ok([
                'sale_id'          => $saleId,
                'invoice'          => $invoice,
                'status'           => $status,
                'round_adjustment' => $roundAdjustment,
                'payable_amount'   => $payableAmount,
                'due'              => $due,
                'msg'              => 'Sale saved successfully',
            ]);
            return; // success — stop retry loop

        } catch (Throwable $e) {
            db()->rollBack();
            $lastErr = $e;

            // Duplicate-key violation (SQLSTATE 23000) → retry with a
            // fresh attempt instead of surfacing an error to the cashier.
            $isDuplicate = ($e instanceof PDOException && $e->getCode() === '23000')
                || str_contains(strtolower($e->getMessage()), 'duplicate');

            if ($isDuplicate && $attempt < $maxAttempts) {
                usleep(30000 * $attempt); // small increasing backoff
                continue;
            }

            // Not a duplicate, or attempts exhausted — surface the error.
            fail('Save failed: ' . $e->getMessage());
        }
    }

    // Should never reach here, but fail safely if it does.
    fail('Save failed after ' . $maxAttempts . ' attempts: ' .
         ($lastErr ? $lastErr->getMessage() : 'Unknown error'));
}

// ── Next invoice reference ────────────────────────────────────
function api_next_ref(): void {
    // This is a DISPLAY PREVIEW ONLY — shown on page load so the cashier
    // has something to look at. It is never used to determine the real
    // invoice number; api_save() always derives the final number
    // atomically from the AUTO_INCREMENT id at insert time, so this
    // preview can never cause a collision even if it's slightly stale.
    $cfg    = get_settings();
    $prefix = $cfg['inv_prefix'] ?? 'INV';
    $year   = date('Y');
    $nextId = (int) db_val(
        "SELECT AUTO_INCREMENT FROM information_schema.TABLES
          WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'sales'"
    );
    if (!$nextId) $nextId = 1;
    ok(['ref' => sprintf('%s-%s-%06d', $prefix, $year, $nextId)]);
}

// ── List sales ────────────────────────────────────────────────
function api_list(array $b): void {
    $q         = trim($b['q']    ?? '');
    $from      = trim($b['from'] ?? '');
    $to        = trim($b['to']   ?? '');
    $status    = trim($b['status'] ?? '');
    $createdBy = (int)($b['created_by'] ?? 0);
    $where     = ['1=1'];
    $params    = [];

    if ($q) {
        $where[]  = "(s.invoice LIKE ? OR s.customer_name LIKE ?)";
        $params[] = "%$q%";
        $params[] = "%$q%";
    }
    if ($from)      { $where[] = "s.date >= ?"; $params[] = $from; }
    if ($to)        { $where[] = "s.date <= ?"; $params[] = $to;   }
    if ($status)    { $where[] = "s.status = ?"; $params[] = $status; }
    if ($createdBy) { $where[] = "s.created_by = ?"; $params[] = $createdBy; }

    $wSql = implode(' AND ', $where);
    $rows = db_rows(
        "SELECT s.*, COUNT(si.id) AS item_count, u.name AS created_by_name
           FROM sales s
      LEFT JOIN sale_items si ON si.sale_id = s.id
      LEFT JOIN users u ON u.id = s.created_by
          WHERE s.is_deleted = 0 AND $wSql
          GROUP BY s.id
          ORDER BY s.date DESC, s.id DESC",
        $params
    );
    ok(['data' => $rows]);
}

// ── Detail — sale + items ─────────────────────────────────────
function api_detail(array $b): void {
    $id = (int)($b['id'] ?? 0);
    if (!$id) fail('Missing id');

    $sale = db_row("SELECT * FROM sales WHERE id = ? AND is_deleted = 0", [$id]);
    if (!$sale) fail('Sale not found');

    // Matches the CSV export's server-side gate — a user without Profit
    // Visibility shouldn't receive approx_profit in the response at all,
    // not just have it hidden client-side (sale/list.php's viewDetail()).
    if (!can_view_profit()) unset($sale['approx_profit']);

    $items = db_rows(
        "SELECT si.*, p.barcode, p.rack, p.form, p.strength
           FROM sale_items si
      LEFT JOIN products p ON p.id = si.product_id
          WHERE si.sale_id = ?
          ORDER BY si.id",
        [$id]
    );
    ok(['data' => $sale, 'items' => $items]);
}

// ── Collect due on existing sale ──────────────────────────────
function api_pay_due(array $b): void {
    $id      = (int)($b['id']     ?? 0);
    $amount  = (float)($b['amount'] ?? 0);
    $account = trim($b['account'] ?? 'Cash');

    if (!$id)         fail('Missing sale id');
    if ($amount <= 0) fail('Amount must be greater than zero');

    $sale = db_row("SELECT * FROM sales WHERE id = ? AND is_deleted = 0", [$id]);
    if (!$sale) fail('Sale not found');

    $due = (float)$sale['due'];
    if ($amount > $due + 0.001)
        fail("Amount exceeds due ({$due})");

    db()->beginTransaction();
    try {
        $newDue    = max(0, $due - $amount);
        $newPaid   = (float)$sale['paid'] + $amount;
        $newStatus = $newDue <= 0.001 ? 'Paid' : 'Partial';

        // Update sale
        db_run(
            "UPDATE sales SET due = ?, paid = ?, status = ? WHERE id = ?",
            [$newDue, $newPaid, $newStatus, $id]
        );

        // Update customer due
        $custId = (int)$sale['customer_id'];
        if ($custId > 1) {
            db_run(
                "UPDATE customers SET due = GREATEST(0, due - ?) WHERE id = ?",
                [$amount, $custId]
            );
        }

        // Cash ledger entry (receipt)
        $ledgerId = db_insert(
            "INSERT INTO cash_ledger
               (date, description, account, type, amount, ref)
             VALUES (?, ?, ?, 'in', ?, ?)",
            [
                date('Y-m-d'),
                "Due collected — {$sale['customer_name']} ({$sale['invoice']})",
                $account,
                $amount,
                $sale['invoice'] . '-DUE',
            ]
        );
        enqueue_sync('cash_ledger', $ledgerId, 'INSERT',
            db_row("SELECT * FROM cash_ledger WHERE id = ?", [$ledgerId]));
        enqueue_sync('sales', $id, 'UPDATE', db_row("SELECT * FROM sales WHERE id = ?", [$id]));

        db()->commit();
        ok(['msg' => 'Payment collected', 'new_status' => $newStatus]);

    } catch (Throwable $e) {
        db()->rollBack();
        fail('Payment failed: ' . $e->getMessage());
    }
}

// ── Delete sale (admin only) ──────────────────────────────────
function api_delete(array $b): void {
    require_admin();
    $id = (int)($b['id'] ?? 0);
    if (!$id) fail('Missing id');

    $sale = db_row("SELECT * FROM sales WHERE id = ? AND is_deleted = 0", [$id]);
    if (!$sale) fail('Sale not found');

    db()->beginTransaction();
    try {
        // Restore stock (undo the deduction when sale was created)
        $items = db_rows("SELECT * FROM sale_items WHERE sale_id = ?", [$id]);
        foreach ($items as $it) {
            db_run(
                "UPDATE products SET stock_pcs = stock_pcs + ? WHERE id = ?",
                [$it['qty'], $it['product_id']]
            );
            $newStock = (int) db_val("SELECT stock_pcs FROM products WHERE id = ?", [$it['product_id']]);
            publish_stock_event((int)$it['product_id'], 'sale_delete', (int)$it['qty'], $newStock, 'sale', $id);
            // Recorded under the 'sale' type (not a separate "reversal"
            // type — none exists in the fixed transaction-type vocabulary)
            // but as a qty_in movement with a remark, so the ledger's
            // in/out columns still accurately show stock flowing back in.
            record_stock_ledger((int) $it['product_id'], 'sale', (int) $it['qty'], 0, $newStock,
                'sales', $id, $sale['invoice'], 'Sale deleted — stock restored');
        }

        // Reverse customer due balance
        if ((float)$sale['due'] > 0 && (int)$sale['customer_id'] > 1) {
            db_run(
                "UPDATE customers SET due = GREATEST(0, due - ?) WHERE id = ?",
                [$sale['due'], $sale['customer_id']]
            );
        }

        // Soft-delete — record kept for audit/recycle bin
        $uid = current_user_id();
        db_run(
            "UPDATE sales SET is_deleted = 1, deleted_at = NOW(), deleted_by = ? WHERE id = ?",
            [$uid, $id]
        );
        // Soft-delete on Local is synced as an UPDATE (not DELETE) — the
        // Cloud copy should reflect the same is_deleted flag, not lose the
        // row, since it's still needed for audit/reporting there too.
        enqueue_sync('sales', $id, 'UPDATE', db_row("SELECT * FROM sales WHERE id = ?", [$id]));

        db()->commit();

        log_action('delete', 'sales', $id,
            'Soft-deleted sale: ' . ($sale['invoice'] ?? '#' . $id));

        ok(['msg' => 'Sale moved to Recycle Bin']);

    } catch (Throwable $e) {
        db()->rollBack();
        fail('Delete failed: ' . $e->getMessage());
    }
}


// ── Helpers ───────────────────────────────────────────────────
function ok(array $extra = []): void {
    echo json_encode(array_merge(['success' => true], $extra));
    exit;
}
function fail(string $msg): never {
    echo json_encode(['success' => false, 'error' => $msg]);
    exit;
}
