---
name: evolution_xero_deleted_payment_sweep
description: "Xero payment cron can't detect deleted payments via the incremental delta alone; needs an independent Status==\"DELETED\" sweep"
metadata: 
  node_type: memory
  type: project
  originSessionId: ed53f55c-c001-4cfb-a2ef-0d424ca6a2f9
---

`cron/xero_payment_cron.php` failed to reverse a payment DELETED in Xero. CONFIRMED root cause (verified by manually rolling `lastPaymentSync` back ~2h → deletion came through the normal delta and the EVO payment row was removed):

- Xero's plain modified-since `GET /Payments` DOES return the deleted AP payment (in-window). The ONLY failure was the per-tenant high-water `addonXeroTenants.lastPaymentSync` having advanced past the deletion's UpdatedDateUTC → out of window forever.
- It got *permanently* stuck because the pre-fix run PULLED the deletion, hit the `empty($guid)` skip (deleted payments can arrive with no invoice linkage), did nothing, BUT still advanced the high-water past it. Consumed-but-not-acted-upon.

**Fixes applied:** (a) GUID-match now falls back to local payment lookup by `externalRef = payment_id` on ANY miss including empty GUID, carrying the local doc id in a plain `$invoiceId` var (not the payment's invoice node, which is null for deleted payments). (b) Added an independent **safety sweep** after the main delta: `getPayments('Status=="DELETED"', $sweepSince)` where `$sweepSince` = later of company cutoff and now-12-months (NOT the high-water — self-heals deletions already behind it). Reverses any deleted payment that still has a local row via `addPayment` status=DELETED; idempotent (dedups, no-op once row gone).

**AP reversal balance sign bug (found while testing deletions):** `evoXero::addPayment()` maintained the AP bill balance incrementally and SUBTRACTED the amount on a reversal (DELETED) too, driving paid-off bills negative (−38.71 in test). Fixed: apply → `balance − amount`, reversal → `balance + amount` (via an `$isReversal` flag), pstatus now derived from resulting balance. KEY CONSTRAINT: `bill::updatePayments()` deliberately does NOT recompute `total+tax − sum(payments)` (unlike `invoice::updatePayments()` for AR) because a committed bill's balance is net of RETENTION — so AP must stay incremental and self-correcting recompute is NOT an option. Corollary: incremental drift can't auto-heal; a corrupted bill balance needs a manual one-time reset to `total+tax` before re-syncing.

**Payment GL journal NOT reversed on delete (found 2026-06-24):** `evoXero::addPayment()`'s DELETED branch deleted the payment row and (AR only) called `clearInvoiceAdjustment()` — which reverses ONLY the Xero alignment line, never the payment's own journal. So the original payment posting (AR: credit AR/debit bank from `commitPayments`; AP: credit accPay/debit bank from `commitBillPayments`) was left on the ledger → AR/bank (or AP/bank) overstated after every Xero-first delete. Fix: BEFORE the `delete from payment`, read the row's OWN stored `keyID` (NOT `$vars['key']` — the sweep passes the Xero payment id, but a batched payment is stored under `batch_payment_id`, see cron line 293) and call `uncommitPayments`/`uncommitBillPayments($keyID)`. Those reverse the whole keyID group + set committed=0; the end-of-`addPayment` `commitPayments()`/`commitBillPayments()` sweep then re-posts surviving batch siblings → net = exactly this one payment's journal removed. `committed='1'` filter makes it a no-op on the Evo-first path (payment.php already reversed) and for never-committed rows. AR posts aggregated-by-keyID, AP per-row — both handled by the reverse-group-then-recommit-remainder pattern.

**Why:** the high-water self-advances past a deletion the moment any run consumes it without acting, so a missed deletion is unrecoverable through the incremental delta — the GUID fix prevents the skip, the sweep recovers ones already behind the mark.
**How to apply:** a stuck deletion can be force-recovered manually by rolling `addonXeroTenants.lastPaymentSync` back before the deletion time. The sweep now does this automatically (12-month DELETED window, high-water-independent). Sweep `Status=="DELETED"` where-clause path not yet exercised live (the recovery test used the normal delta) — confirm "Deleted-payment sweep: N" goes non-zero on the next fresh deletion. Related: [[evolution_xero_payment_externalref.md]], [[evolution_xero_ap_payment_sync]].
