# Xero Token Refresh Buffer Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Stop client-facing `401 TokenExpired` errors during Xero invoice sync by making the token refresh cron proactive (refresh before expiry, not after) and by tightening its cadence so a single skipped scheduler tick can't reopen the expiry window.

**Architecture:** Two small, independent edits to existing files — no new files, no new classes. `XeroRefreshToken::handle()` switches its refresh trigger from `AccessToken->hasExpired()` (strict, zero-buffer) to a 15-minute-remaining buffer check. `Kernel.php`'s schedule entry for `xero:refresh_token` moves from `everyTenMinutes()` to `everyFiveMinutes()`.

**Tech Stack:** Laravel 11 console command + scheduler, `league/oauth2-client` `AccessToken`, `langleyfoxall/xero-laravel` `OAuth2` client.

## Global Constraints

- Buffer threshold is exactly 15 minutes (900 seconds) — from spec `docs/superpowers/specs/2026-07-14-xero-token-refresh-buffer-design.md`.
- Cadence changes from every 10 minutes to every 5 minutes — from spec.
- `withoutOverlapping(10)`, `onOneServer()`, and `runInBackground()` on the `xero_refresh_token` schedule entry must be preserved unchanged (per `CLAUDE.md` scheduler-safety rule and the spec).
- No changes to any of the ~15 consumer commands/controllers that read `xero_access_token` directly (out of scope per spec).
- No changes to `InvoiceSyncTasks` retry/requeue logic (out of scope per spec).
- No automated unit test is added for the buffer branch — `getOAuth2()` is a private method that isn't mockable without a refactor the spec explicitly defers. Verification is manual, against a real Xero-connected environment, as specified.

---

## File Structure

- Modify: `app/Console/Commands/Xero/XeroRefreshToken.php` — add the 15-minute refresh buffer constant and switch the refresh trigger from `hasExpired()` to a remaining-time check.
- Modify: `app/Console/Kernel.php` — change the `xero_refresh_token` schedule entry from `everyTenMinutes()` to `everyFiveMinutes()`.

No other files change. Both edits are independently deployable, but Task 1 should land first since it's the primary fix; Task 2 is the defense-in-depth follow-up the spec calls for.

---

### Task 1: Proactive refresh buffer in `XeroRefreshToken`

**Files:**
- Modify: `app/Console/Commands/Xero/XeroRefreshToken.php:27-33` (constants block)
- Modify: `app/Console/Commands/Xero/XeroRefreshToken.php:124-131` (refresh trigger)

**Interfaces:**
- Consumes: `League\OAuth2\Client\Token\AccessToken::getExpires(): ?int` (UTC epoch seconds, or `null`/`0` if unset), `Carbon::now()->timezone(...)->timestamp: int` (already computed as `$now` at line 69 of this file).
- Produces: no new public interface — behavior-only change to `XeroRefreshToken::handle()`. The command's existing CLI output contract (`Status : not_expired` / `Status : updated` / `Status : blocked` / `Status : error`) is preserved so nothing downstream (log scrapers, alert emails) breaks.

- [ ] **Step 1: Add the buffer constant**

In `app/Console/Commands/Xero/XeroRefreshToken.php`, add a new constant alongside the existing ones:

```php
    private const XERO_ACCESS_TOKEN_KEY = 'xero_access_token';
    private const XERO_API_BLOCKED_KEY = 'xero_api_blocked';
    private const XERO_API_BLOCKED_UNTIL_KEY = 'xero_api_blocked_until';
    private const XERO_API_BLOCKED_REASON_KEY = 'xero_api_blocked_reason';
    private const XERO_API_BLOCK_ALERT_SENT_KEY = 'xero_api_block_alert_sent';
    private const XERO_API_UNBLOCK_ALERT_SENT_KEY = 'xero_api_unblock_alert_sent';
    private const XERO_BLOCK_RETRY_MINUTES = 60;
    private const XERO_TOKEN_REFRESH_BUFFER_SECONDS = 900; // 15 minutes
```

(Only the last line is new — it's appended after `XERO_BLOCK_RETRY_MINUTES`.)

- [ ] **Step 2: Switch the refresh trigger from `hasExpired()` to a buffer check**

Replace this block (currently lines 124-131):

```php
        $status = "not_expired";
        try {
            if ($accessToken->hasExpired()) {
                $accessToken = $this->getOAuth2()->refreshAccessToken($accessToken);
                $this->setConfigValue(self::XERO_ACCESS_TOKEN_KEY, json_encode($accessToken));

                $status = "updated";
            }
```

with:

```php
        $status = "not_expired";
        try {
            $expiresAt = $accessToken->getExpires();
            $secondsRemaining = $expiresAt ? ($expiresAt - $now->timestamp) : null;
            $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";
            }
```

Nothing else in `handle()` changes — the `catch` blocks, block/alert logic, and final `$this->info(...)` output calls below this block stay exactly as they are.

- [ ] **Step 3: Static syntax check**

Run: `php -l app/Console/Commands/Xero/XeroRefreshToken.php`
Expected: `No syntax errors detected in app/Console/Commands/Xero/XeroRefreshToken.php`

- [ ] **Step 4: Manual verification — token inside the buffer gets refreshed**

This requires an environment with a real, already-authorized Xero connection (i.e. `XeroConfig.xero_access_token` already populated via the Settings → Xero integration flow, with valid `XERO_CLIENT_ID`/`XERO_CLIENT_SECRET` in `.env`). Run:

```bash
php artisan tinker
```

Inside tinker:

```php
$config = \App\Models\Xero\XeroConfig::where('name', 'xero_access_token')->first();
$token = json_decode($config->value, true);
$token['expires'] = time() + 600; // 10 minutes remaining — inside the 15-minute buffer
$config->value = json_encode($token);
$config->save();
exit
```

Then run:

```bash
php artisan xero:refresh_token
```

Expected output includes:
```
Status : updated
```

Confirm the stored token changed:

```bash
php artisan tinker
```

```php
$config = \App\Models\Xero\XeroConfig::where('name', 'xero_access_token')->first();
$token = json_decode($config->value, true);
echo $token['expires'] - time(); // should print a value close to 1800 (30 minutes)
exit
```

- [ ] **Step 5: Manual verification — token outside the buffer is left alone**

```bash
php artisan tinker
```

```php
$config = \App\Models\Xero\XeroConfig::where('name', 'xero_access_token')->first();
$token = json_decode($config->value, true);
$token['expires'] = time() + 1200; // 20 minutes remaining — outside the 15-minute buffer
$config->value = json_encode($token);
$config->save();
exit
```

```bash
php artisan xero:refresh_token
```

Expected output includes:
```
Status : not_expired
```

- [ ] **Step 6: Commit**

```bash
git add app/Console/Commands/Xero/XeroRefreshToken.php
git commit -m "Refresh Xero token proactively with a 15-minute buffer instead of only after expiry"
```

---

### Task 2: Tighten `xero:refresh_token` cadence to every 5 minutes

**Files:**
- Modify: `app/Console/Kernel.php:154-160`

**Interfaces:**
- Consumes: nothing from Task 1 (independent edit to the scheduler definition).
- Produces: no new interface — only changes how often the already-existing `xero:refresh_token` artisan command (fixed in Task 1) is invoked by the scheduler.

- [ ] **Step 1: Change the schedule frequency**

In `app/Console/Kernel.php`, replace:

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

with:

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

Only `->everyTenMinutes()` → `->everyFiveMinutes()` changes. `withoutOverlapping(10)`, `onOneServer()`, `name(...)`, and `runInBackground()` are left exactly as-is.

- [ ] **Step 2: Static syntax check**

Run: `php -l app/Console/Kernel.php`
Expected: `No syntax errors detected in app/Console/Kernel.php`

- [ ] **Step 3: Verify the scheduler picks up the new cadence**

Run: `php artisan schedule:list`
Expected: a row for `xero:refresh_token` (name `xero_refresh_token`) showing a 5-minute interval (next run times 5 minutes apart) instead of 10.

- [ ] **Step 4: Commit**

```bash
git add app/Console/Kernel.php
git commit -m "Run xero:refresh_token every 5 minutes to tolerate a skipped scheduler tick"
```

---

## Self-Review Notes

- **Spec coverage:** 15-minute buffer (Task 1) ✓; cadence change to every 5 minutes (Task 2) ✓; `withoutOverlapping(10)` preserved unchanged (Task 2, Step 1) ✓; consumer commands and `InvoiceSyncTasks` retries explicitly untouched (no task touches them) ✓; error handling / alert paths explicitly left unchanged (Task 1, Step 2 note) ✓; manual verification approach in lieu of unit tests (Task 1, Steps 4-5) ✓.
- **Placeholder scan:** no TBD/TODO markers; all code blocks are complete and copy-pasteable; verification steps include concrete commands and expected output.
- **Type consistency:** `$now` (Carbon, from `handle()` line 69) and `$expiresAt`/`$secondsRemaining` (int/null) are used consistently within Task 1's single edit; no cross-task type dependencies exist since the two tasks touch unrelated files.
