# Xero Access Token Refresh: Proactive Buffer + Cadence Fix

## Problem

Clients see invoice sync failures logged as:

```json
{"Type":null,"Title":"Unauthorized","Status":401,"Detail":"TokenExpired: token expired at 07/14/2026 04:40:02", ...}
```

Xero access tokens are valid for exactly 30 minutes with no grace period (per [Xero's OAuth2 token types doc](https://developer.xero.com/documentation/guides/oauth2/token-types/)). The `xero:refresh_token` scheduled command (`app/Console/Commands/Xero/XeroRefreshToken.php`) runs every 10 minutes and refreshes the stored token, so on the surface this looks like it should never let a token actually expire. It still does, because of two compounding issues:

1. **Reactive, not proactive, refresh condition.** `XeroRefreshToken::handle()` only refreshes when `$accessToken->hasExpired()` is `true` (`XeroRefreshToken.php:126`). League OAuth2's `hasExpired()` has zero buffer — it's a strict `expires < now()` check (`vendor/league/oauth2-client/src/Token/AccessToken.php:199-207`). The command waits until the token is *already dead* before acting.

2. **No self-healing in consumers.** ~15 other places read the token directly out of the `XeroConfig` DB row via `Helper::getXeroSettings('xero_access_token')` and call the Xero API with it immediately — `SyncXeroInvoices`, `SyncPayrates`, `SyncTaxRates`, `SyncInvoiceContacts`, `SyncAccounts`, `PayrunsController`, `XeroPayItems`, `XeroPayrollCalendars`, `XeroUserUpdate`, `XeroLeaveUpdate`, `XeroEmployeesUpdate`, `UsersController`, `ClientController`, `StaffController`, `InvoiceSyncTasksController` (all confirmed via grep for `xero_access_token`). None of them check expiry or self-refresh; they trust whatever the cron last wrote.

Combined effect: because refresh only happens *after* expiry and only every 10 minutes, there is a window — up to ~10 minutes in the normal case, longer if a scheduler tick is skipped — where the DB holds an already-expired token. Any consumer command that runs in that window fails with `401 TokenExpired`. This matches the reported symptom exactly.

## Scope

This is a **minimal, targeted fix** confined to the refresh cron itself:

- Change the refresh trigger from reactive (`hasExpired()`) to proactive (refresh when the token has less than a fixed buffer of life remaining).
- Tighten the cron cadence so a single skipped scheduler tick can't reopen the expiry gap.

**Explicitly out of scope** (by design, confirmed with stakeholder):

- Changing how the ~15 consumer commands/controllers fetch or use the token. They keep reading directly from `XeroConfig` as they do today.
- Adding a 401-triggered on-demand refresh/retry in consumer commands.
- Requeuing/retrying the `InvoiceSyncTasks` rows that have already failed with `status = 3` due to this bug — that's a separate, manual ops action.

## Design

### 1. Proactive refresh buffer (15 minutes)

In `XeroRefreshToken::handle()` (`app/Console/Commands/Xero/XeroRefreshToken.php`), replace:

```php
if ($accessToken->hasExpired()) {
    $accessToken = $this->getOAuth2()->refreshAccessToken($accessToken);
    ...
}
```

with a buffer-based check:

```php
private const XERO_TOKEN_REFRESH_BUFFER_SECONDS = 900; // 15 minutes

...

$secondsRemaining = $accessToken->getExpires() - $now->timestamp;
$needsRefresh = $secondsRemaining === null || $secondsRemaining <= self::XERO_TOKEN_REFRESH_BUFFER_SECONDS;

if ($needsRefresh) {
    $accessToken = $this->getOAuth2()->refreshAccessToken($accessToken);
    $this->setConfigValue(self::XERO_ACCESS_TOKEN_KEY, json_encode($accessToken));
    $status = "updated";
}
```

Everything downstream of the refresh decision — persisting the new token to `XeroConfig`, the `403`/API-blocked handling, the block/unblock alert emails, and command output/logging — is unchanged. Only the trigger condition changes, so the blast radius stays limited to this one file.

`$now` is already a `Carbon` instance timezoned to `config('app.timezone')` earlier in `handle()`; `$now->timestamp` is a UTC epoch value directly comparable to `AccessToken::getExpires()` (also a UTC epoch), so no timezone-conversion bugs are introduced.

### 2. Cadence: every 10 minutes → every 5 minutes

The 15-minute buffer alone is not sufficient against a **skipped scheduler tick** (e.g. a `withoutOverlapping` lock still held from a slow prior run, a deploy restart, or the scheduler process being down for a few minutes) — with a 10-minute cadence, one skipped tick doubles the check gap to 20 minutes, which is enough to run past the 15-minute buffer and let the token actually expire before the next check ever runs. This is the same class of failure as the original bug, just with a much smaller window.

Tightening `app/Console/Kernel.php` from:

```php
$schedule->command('xero:refresh_token')->timezone(config('app.timezone'))
    ->everyTenMinutes()
    ->withoutOverlapping(10)
    ->onOneServer()
    ->name('xero_refresh_token')
    ->runInBackground();
```

to:

```php
$schedule->command('xero:refresh_token')->timezone(config('app.timezone'))
    ->everyFiveMinutes()
    ->withoutOverlapping(10)
    ->onOneServer()
    ->name('xero_refresh_token')
    ->runInBackground();
```

`withoutOverlapping(10)` is left unchanged — it's a stale-lock TTL (protects against a crashed run leaving the mutex held forever), not a throttle, and the command itself is a single lightweight HTTP call that completes in well under a second, so there's no risk of legitimate overlap at the new cadence.

**Why this closes the gap:** with 5-minute checks and a 15-minute buffer, a token normally gets refreshed with ~10+ minutes of life still remaining. Even if exactly one tick is skipped entirely, the following tick (10 minutes later) still lands with margin before actual expiry. Two consecutive skipped ticks in a row (a 15-minute scheduler outage) could still reopen the gap, but that is a materially different and rarer failure mode (indicating the scheduler itself is down, which would already be surfaced by other missed jobs) than the routine race this fix targets.

## Error handling

No changes to error handling paths. The existing 403/blocked detection, alert emails (`sendBlockedAlertEmailIfNeeded` / `sendUnblockedAlertEmailIfNeeded`), and command status output (`not_expired` / `updated` / `error`) all continue to function exactly as before — they operate on the outcome of the refresh attempt, which is unaffected by *when* that attempt is triggered.

## Testing

`XeroRefreshToken::getOAuth2()` is a private method that directly constructs `new LangleyFoxall\XeroLaravel\OAuth2()`, so the refresh call isn't mockable without a small refactor to make the OAuth2 client injectable. Given the minimal scope of this fix, automated unit testing of the buffer branch is not included. Verification will instead be manual:

1. Manually set a `XeroConfig` row's `xero_access_token` JSON `expires` value to `now + 600` seconds (10 minutes remaining — inside the 15-minute buffer) and run `php artisan xero:refresh_token`; confirm it refreshes (`Status : updated`) and the stored token's new `expires` is ~30 minutes out.
2. Set `expires` to `now + 1200` seconds (20 minutes remaining — outside the buffer) and run the command again; confirm it does **not** refresh (`Status : not_expired`).
3. Confirm `php artisan schedule:list` shows `xero:refresh_token` on the new 5-minute cadence.

If tighter automated coverage is wanted later, refactor `getOAuth2()` to accept an injected `OAuth2` client (constructor or method injection) so it can be swapped for a mock in a feature test — flagged here as a possible follow-up, not part of this change.

## Files touched

- `app/Console/Commands/Xero/XeroRefreshToken.php` — buffer-based refresh condition
- `app/Console/Kernel.php` — cadence change for the `xero_refresh_token` schedule entry
