# Agency Pulse Backup — WordPress Plugin Plan

> **Plugin name:** `agency-pulse-backup`
> **Status:** Planning / Pre-implementation. No code written yet.
> **Last updated:** 2026-03-30
> **Companion document:** `BACKUP_SERVER_CONTRACT.md` — defines the API contract with the external server.

---

## Role of the WP Plugin

The WP plugin has two jobs and no others:

1. **Push agent** — detect changes to invoices and addon entities, build payloads, and send them to the external server.
2. **Inbound receiver** — expose a signed endpoint that accepts operation commands from the external server (restore, archive, entity restore) and executes them locally.

No browse UI, search UI, or restore UI is built in WordPress. The management console lives entirely on the external server.

---

## Dependencies

- **Pro Connector (`agency-pulse-pro`)** — required. Provides two distinct capabilities:
  - **Outbound (WP → Server):** `AgencyPulseWP_Api_Client` and `AgencyPulseWP_Token_Manager::get_valid_token()` — JWT Bearer authentication for backup push requests to the external server.
  - **Inbound (Server → WP):** `AgencyPulseWP_Verifier` — Ed25519 signature verification for operation requests arriving from the external server. The public key is already stored in connector state; the backup plugin calls the verifier, it does not implement crypto itself.
  - The plugin checks for `AgencyPulseWP_Api_Client`, `AgencyPulseWP_Token_Manager`, and `AgencyPulseWP_Verifier` on load, the same availability-check pattern as secure-sign.
- **Core invoice (`agency-pulse-invoice`)** — required. All invoice hooks, meta, and PDF version history are defined there.
- All other addons (portal, multi-currency, enhancements, products, secure-sign) are **optional**. The plugin detects which are active and includes their data contributions accordingly.

---

## Payload Assembly

Each backup payload is a self-contained envelope. The payload builder checks which addons are active at backup time and appends their contributions. The `plugins_active` array records which sections are present so the restore handler can skip missing sections cleanly.

```json
{
  "entity_type": "invoice",
  "plugins_active": ["agency-pulse-portal", "agency-pulse-multi-currency"],
  "core": {
    "post_id": 123,
    "post_title": "Invoice for Acme Co",
    "post_status": "publish",
    "post_date": "2026-03-01 10:00:00",
    "meta": { "_invoice_number": "INV-0042", "_invoice_status": "paid", "..." : "..." },
    "line_items": [ { "description": "...", "quantity": 1, "unit_price": "500.00", "..." : "..." } ],
    "pdf_versions": [ { "generated_at": 1743000000, "path": "...", "hash": "..." } ]
  },
  "portal": {
    "client_email": "client@example.com",
    "client_snapshot": { "_client_name": "...", "_client_address": "...", "..." : "..." },
    "invoice_access_token": "abc123"
  },
  "multi_currency": {
    "invoice_currency": "EUR",
    "base_currency": "USD",
    "exchange_rate_locked": "1.0823",
    "currency_config_snapshot": { "EUR": { "symbol": "€", "decimals": 2, "position": "before" } }
  },
  "enhancements": {
    "company_name": "My Agency",
    "company_index": 0,
    "company_snapshot": { "name": "My Agency", "address": "...", "logo_url": "..." },
    "external_file_map": { "456": "https://s3.example.com/files/invoice-123/brief.pdf" },
    "hide_files_until_paid": false
  },
  "products": {
    "order_id": 789,
    "order_meta": { "_order_status": "paid", "_transaction_id": "txn_abc", "..." : "..." },
    "product_snapshots": [
      { "name": "Logo Design", "price": "500.00", "type": "digital", "tax_ids": [] }
    ]
  },
  "secure_sign": {
    "signed_pdf_path": "s3://bucket/signed/invoice-123.pdf",
    "signed_pdf_hash": "sha256...",
    "signed_at": 1743100000,
    "signed_ip": "1.2.3.4"
  },
  "backup_meta": {
    "remote_id": "server-assigned-record-id",
    "triggered_by": "status_change",
    "backed_up_at": 1743200000,
    "payload_hash": "sha256..."
  }
}
```

`backup_meta.remote_id` is `null` on the first push for a new entity. Once the server returns a `remote_id` in its success response, WP stores it as `_apab_backup_remote_id` and includes it in all subsequent pushes for that entity. The server uses this to distinguish creates from updates without inspecting payload content.

Sections are **omitted entirely** (not null) when the corresponding plugin is not active.

### Core Invoice Fields (always present)

- All post fields: `post_title`, `post_status`, `post_date`, `post_modified`
- All post meta with `_invoice_*` prefix
- All post meta with `_apss_*` prefix (secure-sign, if active)
- Line items from `wp_api_invoice_items`
- Full `_invoice_pdf_versions` array (metadata only — timestamps, hashes, S3 paths; no binary files)
- Local unsigned PDF binaries are **not** included — regenerable from HTML template snapshot on restore

### Portal Addon Contribution

- `_invoice_portal_client_id` — stored as reference context only; not used for restore (post IDs are not portable)
- Full snapshot of all `_client_*` meta from the linked `app_portal_client` post at backup time
- `_invoice_access_token` — preserved so bookmarked portal links survive a restore
- If enhancements is also active: `_invoice_template_{UUID}` and `_invoice_template_count` from the client post are included in the client snapshot

### Multi-Currency Addon Contribution

- `_invoice_currency`, `_invoice_base_currency`, `_invoice_exchange_rate_locked` (from invoice meta)
- `_client_currency` from linked client (if portal also active)
- Snapshot of `apmc_currencies` and `apmc_default_currency` options at backup time — needed for display accuracy on restore even if the config has since changed

### Enhancements Addon Contribution

- `_invoice_company`, `_invoice_company_index`
- Company snapshot from `agency_pulse_companies` option at backup time (name, address, logo)
- `_invoice_external_file_map` — **critical**: maps local attachment IDs to external storage paths. Permanent data loss if omitted.
- `_invoice_external_image_post_ids`
- `_invoice_hide_files_until_paid`

### Products Addon Contribution

- If an `agency_pulse_order` is linked: all `_order_*` meta
- Denormalized product snapshot for each product in line items: name, price, type, tax IDs. Captured at backup time so the record is accurate if the product is later modified or deleted.

### Secure Sign Addon Contribution

- `_apss_signed_pdf_path` — S3 path (already on external server; reference only, no re-upload)
- `_apss_signed_pdf_hash`
- `_apss_signed_at`, `_apss_signed_ip`, `_apss_signature_image`

---

## Entity-Scoped Backup Triggers

Backups are entity-scoped. Invoice events trigger invoice backups. Addon entity events trigger their own backups independently. An invoice save does not re-backup the client record; a client edit does not trigger an invoice backup. The invoice payload always captures a client snapshot at that moment, but the client's own standalone backup record is updated only when the client changes.

### Invoice Triggers (always active)

| Event | Action |
|---|---|
| Invoice status transition (any) | Queue immediate invoice backup |
| Invoice saved / edited | Queue immediate invoice backup |
| New local PDF generated | Queue immediate invoice backup |
| Signing completed (`apss_invoice_signed` action) | Update invoice backup record with signed PDF S3 reference |
| Scheduled catch-up (cron) | Re-sync invoices where local state is newer than `_apab_last_backup_at` |

### Portal Plugin Triggers (when `agency-pulse-portal` active)

| Event | Action |
|---|---|
| `app_portal_client` post saved / edited | Queue client backup |
| `app_portal_client` post trashed / deleted | Send archive notification to external server for client record |
| Client template meta updated | Queue client backup (templates stored on client post) |

The client push payload includes `modified_at` (`post_modified` timestamp) so the deduplication handler can compare record ages when a client with the same email already exists locally.

### Products Plugin Triggers (when `agency-pulse-products` active)

| Event | Action |
|---|---|
| `agency_pulse_product` post saved / edited | Queue product backup |
| `agency_pulse_product` post trashed / deleted | Send inactive notification to external server |
| `agency_pulse_order` post created / updated | Queue order backup |

### Enhancements Plugin Triggers (when `agency-pulse-invoice-enhancements` active)

| Event | Action |
|---|---|
| `agency_pulse_companies` option updated | Queue global config backup |
| `apie_invoice_numbering_config` option updated | Queue global config backup |
| External file mapped to invoice | Queue invoice backup (captures updated `_invoice_external_file_map`) |

### Multi-Currency Plugin Triggers (when `agency-pulse-multi-currency` active)

| Event | Action |
|---|---|
| `apmc_currencies` or `apmc_default_currency` option updated | Queue global config backup |

### Manual Backup All

A **Backup All** button in the plugin admin settings queues every entity of every type (invoices, clients, products, global configs) regardless of current backup status. Used deliberately for initial setup, post-gap recovery, or site migration. Not automatic — admin-initiated only.

### Catch-Up Cron (all entity types)

The scheduled catch-up cron is not a bulk re-sync. It re-queues only entities where confirmation from the external server was never received: status `failed`, stale `in_progress` (started but not resolved within a reasonable window), or no `_apab_backup_remote_id` despite a prior attempt. Entities with `complete` status are only re-queued if local `post_modified` is newer than `_apab_last_backup_at`.

---

## Backup Queue

All backup sends are asynchronous. The triggering request queues the item; a separate cron process sends it.

- Queue item: entity type + entity ID (post ID or `global_config`) + trigger reason
- Processed via WP-Cron: immediate single-item dispatch or batch on schedule
- Per-entity backup status tracked in post meta (invoices, clients, products) or option (global configs):
  - `never | queued | in_progress | complete | failed | failed_permanent`
- On failure: exponential backoff, up to `apab_max_retry_attempts`
- After max failures: mark `failed_permanent`, surface via `agency_pulse_admin_notices`
- Change detection via SHA-256 hash of payload (`_apab_backup_hash`) — skip send if payload unchanged since last successful backup

---

## Inbound Operations Endpoint

The external server sends Ed25519 signed operation payloads to a WP REST endpoint. The endpoint validates the signature via `AgencyPulseWP_Verifier`, dispatches to the appropriate handler, and returns a JSON result.

### Authentication

Inbound operations are authenticated using **Ed25519 signed requests** — the same mechanism used by the portal mission scheduler. The server signs each request with its Ed25519 private key; WP verifies the signature using the public key stored in the Pro Connector's connector state.

The backup plugin delegates verification entirely to `AgencyPulseWP_Verifier` from the Pro Connector. It does not implement any crypto itself. Required headers on every inbound request:

```
X-Timestamp: {unix_timestamp}
X-Signature: {base64_encoded_ed25519_signature}
```

The signed message is `{X-Timestamp}.{raw_request_body}`. WP rejects any request where the signature is invalid or the timestamp is more than 300 seconds from server time.

### Delivery Patterns

**Pattern A — Server-to-Server (primary):** The external server POSTs directly to the WP endpoint with the signed headers. No browser involvement. Used for automated and bulk operations in production environments.

**Pattern B — Signed URL (fallback):** The external management console generates a URL that embeds the payload and signature. The admin clicks it; their browser delivers the signed request to WP. Used for single-invoice confirmable operations and for local/dev environments where the WP site is not publicly reachable from the external server.

Both patterns hit the same endpoint and pass through the same `AgencyPulseWP_Verifier` validation. No WP configuration change is required to switch patterns.

### Inbound Payload Structure

Every inbound payload body contains:

```json
{
  "operation": "restore",
  "entity_type": "invoice",
  "payload": { "...entity data from backup record..." },
  "remote_id": "ext-server-record-id",
  "expiry": 1743999999,
  "callback_url": "https://server.example.com/api/plugin/backup/result"
}
```

- `operation` — see supported operations below
- `entity_type` — `invoice | client | product | global_config`
- `payload` — the full backup record for this entity (for restore operations)
- `remote_id` — external server record ID (returned in the WP response for tracking)
- `expiry` — Unix timestamp; WP rejects payloads past this time as a secondary time bound
- `callback_url` — optional; WP POSTs the result here when complete using the Pro Connector JWT

### Supported Inbound Operations

| Operation | Entity Type | WP Action |
|---|---|---|
| `restore` | `invoice` | Recreate invoice post with all meta and line items; resolve addon entity IDs |
| `archive` | `invoice` | Move post to trash (default) or hard delete if `archive_mode: delete` in payload; set `_apab_archived` and `_apab_archived_at` meta |
| `restore_client` | `client` | Create or update `app_portal_client` post with deduplication; return local post ID |
| `restore_product` | `product` | Create or update `agency_pulse_product` post with deduplication; return local post ID |
| `restore_global_config` | `global_config` | Overwrite specified global options (currency config, company profiles, numbering config) |
| `restore_batch` | any | Paginated batch of any of the above entity types; returns per-item results |

> **Orders:** There is no `restore_order` operation. Order records are included in the invoice backup payload under the `products` section and are restored inline as part of the `restore` invoice operation. The `/api/plugin/backup/order` push endpoint exists for keeping the server's order records current, but orders are never restored as standalone entities.

### Response Format

Every operation returns:

```json
{
  "success": true,
  "operation": "restore",
  "remote_id": "ext-server-record-id",
  "local_id": 456,
  "conflict": false,
  "conflict_reason": null,
  "errors": []
}
```

On deduplication skip: `"success": true, "conflict": true, "conflict_reason": "client_email_exists", "local_id": <existing-id>`.
On failure: `"success": false, "errors": ["..."]`.

---

## Addon Entity Restore — Deduplication

Invoice restore and addon entity restore are separate operations. An invoice restore does not automatically recreate clients or products — it resolves existing local records or uses the denormalized snapshot data already in the invoice payload.

### Full Restore Sequence (Disaster Recovery)

The external server controls this sequence:

1. **Global configs first** — `restore_global_config` for currency config, company profiles, numbering config. Stateless overwrites; no deduplication.
2. **Addon entities** — `restore_client` and `restore_product` for all records. Must complete before invoice restore so local IDs are available.
3. **Invoices** — `restore` (or `restore_batch`) for all invoice records, using resolved local IDs from steps 1–2.

### Deduplication Per Entity Type

#### Portal Clients
- **Natural key**: `_client_email`. If a client with this email already exists locally, skip creation and return the existing post ID.
- **Conflict resolution**: if local record is newer (by `post_modified`), keep local. If backup is newer, update local meta with backup values.
- **Never match by post ID** — post IDs are not portable across installs.

#### Products
- **Natural key**: product name + company index. If a matching product exists, treat as the same product.
- **Conflict resolution**: keep local values for price and tax IDs (catalog may have been intentionally updated). Log discrepancy; do not overwrite.
- **Note**: a product record does not need to exist for an invoice to be restored. Line items carry price and description inline.

#### Company Profiles
- **Natural key**: company name within the `agency_pulse_companies` option array.
- **Conflict resolution**: if name exists, do not overwrite. Add as new entry only if name is absent.
- **Index mapping**: build a `backup_index → local_index` map after restore; pass in subsequent invoice restore payloads so `_invoice_company_index` is rewritten to the correct local value.

#### Client Templates
- Stored as meta on the client post. Restored as part of `restore_client`.
- If a template UUID already exists on the local client post, skip (do not overwrite).

### Invoice Restore With Addon Context

When `restore` is received for an invoice:

1. Read `plugins_active` from payload to know which sections are present.
2. Resolve local IDs for each addon section:
   - **Portal**: look up client by `client_email` → write resolved local post ID to `_invoice_portal_client_id`.
   - **Multi-currency**: write `_invoice_currency`, `_invoice_base_currency`, `_invoice_exchange_rate_locked` directly — no ID resolution needed.
   - **Enhancements**: apply company index mapping if available. If unavailable, write `_invoice_company` (name) and flag for admin review.
   - **Products**: line items restored as-is from payload; no product ID rewrite needed.
3. If portal plugin is active and the client does not exist locally: recreate the `app_portal_client` post from the snapshot only if `apab_restore_client_record` is `true`. If the setting is `false`, skip client creation but still write the snapshot to denormalized `_invoice_client_*` meta so the invoice is self-contained.
4. If portal plugin is not active at all, write client snapshot to denormalized `_invoice_client_*` meta fields regardless of settings.

---

## Local Data Model

### Post Meta (per invoice, client, product)

| Key | Type | Description |
|---|---|---|
| `_apab_backup_status` | string | `never \| queued \| in_progress \| complete \| failed \| failed_permanent` |
| `_apab_last_backup_at` | int | Unix timestamp of last successful backup |
| `_apab_backup_remote_id` | string | External server record ID for this entity |
| `_apab_backup_hash` | string | SHA-256 of last backup payload (change detection) |
| `_apab_backup_error` | string | Last error message if failed |
| `_apab_backup_attempts` | int | Retry counter |
| `_apab_archived` | bool | Entity has been archived (local record removed) |
| `_apab_archived_at` | int | Unix timestamp of archive action |

These meta keys apply to invoices and, when the relevant addon is active, to `app_portal_client` and `agency_pulse_product` posts as well.

### Global Options

| Option key | Default | Description |
|---|---|---|
| `apab_backup_enabled` | `true` | Master switch |
| `apab_backup_on_status_change` | `true` | Trigger invoice backup on status transitions |
| `apab_backup_on_save` | `true` | Trigger invoice backup on post save |
| `apab_backup_on_pdf` | `true` | Trigger invoice backup on new PDF |
| `apab_schedule_interval` | `hourly` | Catch-up cron interval |
| `apab_max_retry_attempts` | `3` | Retry limit before `failed_permanent` |
| `apab_last_catchup_run` | `0` | Timestamp of last catch-up cron run |
| `apab_default_archive_mode` | `trash` | Default action on archive command: `trash` \| `delete` |
| `apab_restore_conflict_mode` | `skip` | Conflict handling on restore: `skip` \| `overwrite` \| `duplicate` |
| `apab_restore_client_record` | `true` | Whether to recreate linked portal client record on invoice restore if missing |
| `apab_global_config_remote_id` | `""` | External server record ID for the global config record |
| `apab_global_config_last_backup_at` | `0` | Timestamp of last successful global config backup |

---

## Admin UI (Minimal)

### Settings Tab

Added to Agency Pulse Invoice settings, same pattern as secure-sign.

- Master on/off switch
- Trigger toggles: on save, on status change, on PDF generation
- Catch-up schedule interval (hourly / daily / weekly)
- Max retry attempts
- Archive mode: trash / hard delete (default: trash)
- Restore conflict mode: skip / overwrite / duplicate (default: skip)
- Restore client record when missing: on / off (default: on, requires portal plugin)
- **Backup All** button — queues every entity immediately
- Pro Connector status indicator (green/red)
- Link to external management console (opens in new tab)

### Backup Status Column (Invoice Dashboard)

An additional column in the invoice list table showing per-invoice backup state:

- Icon: never backed up / queued / current (timestamp on hover) / failed

### Admin Notice

Via `agency_pulse_admin_notices`:

- Surfaces `failed_permanent` backup failures with entity name and last error
- Clears automatically when the next backup for that entity succeeds

---

## Integration With Existing Plugins

| Plugin | Integration |
|---|---|
| **Core invoice** | Hooks: `save_post_agency_pulse_invoice`, status transition hooks, `_invoice_pdf_versions` meta, `agency_pulse_invoice_can_be_issued` (no gate — backup never blocks issuance) |
| **Pro Connector** | Required. Outbound: `AgencyPulseWP_Api_Client` + `AgencyPulseWP_Token_Manager::get_valid_token()` for JWT Bearer on backup push requests. Inbound: `AgencyPulseWP_Verifier` for Ed25519 signature validation on operation requests from the server. |
| **Secure Sign** | `apss_invoice_signed` action triggers backup update. `_apss_signed_pdf_path` and `_apss_signed_pdf_hash` included in payload if present. |
| **Admin Notices** | `agency_pulse_admin_notices` action for persistent failure notices. |
| **Portal** | `save_post_app_portal_client` hook for client backup triggers. Client meta read at invoice backup time for snapshot. |
| **Enhancements** | Option update hooks for company profiles and numbering config. `_invoice_external_file_map` meta read at invoice backup time. |
| **Products** | `save_post_agency_pulse_product` and `save_post_agency_pulse_order` hooks. Product meta read at invoice backup time for product snapshots. |
| **Multi-Currency** | `update_option_apmc_currencies` and `update_option_apmc_default_currency` hooks. Currency meta read at invoice backup time. |

---

## Implementation Phases

### Phase 1 — Foundation
Plugin scaffold, constants, settings tab, Pro Connector availability check, `_apab_*` meta structure, basic invoice payload builder (core meta + line items, no PDFs, no addon sections yet).

### Phase 2 — Invoice Backup Engine
Queue system, WP-Cron integration, invoice trigger hooks (save + status change), change detection via payload hash, per-invoice status tracking, backup status column in invoice dashboard, manual backup trigger per invoice.

### Phase 3 — Addon Payload Contributions
Conditional addon detection. Add portal, multi-currency, enhancements, products, and secure-sign sections to the invoice payload builder. Add client, product, and global config entity backup triggers and queue items.

### Phase 4 — PDF Inclusion
Include full `_invoice_pdf_versions` array in every invoice backup payload. Reference signed PDF S3 path and hash from secure-sign meta (`_apss_signed_pdf_path`, `_apss_signed_pdf_hash`) when present. Local unsigned PDF binaries are **not** uploaded — the HTML template snapshot is sufficient for regeneration on restore.

### Phase 5 — Inbound Operations Endpoint
Ed25519-validated REST endpoint (`POST /wp-json/apab/v1/operation`). Operation dispatcher. Invoice restore handler: conflict behaviour driven by `apab_restore_conflict_mode` setting (skip / overwrite / duplicate), always returns conflict result to server. Archive handler: trash or hard delete per `apab_default_archive_mode` setting, overridable per-operation via `archive_mode` field. Client restore handler with email deduplication, respects `apab_restore_client_record` setting. Product restore handler. Global config restore handler. Signed URL support for browser-mediated delivery. Callback URL dispatch on completion via Pro Connector JWT.

### Phase 6 — Bulk Operations and Catch-Up
`restore_batch` operation with pagination. Manual **Backup All** button (queues all entities, admin-initiated). Scheduled catch-up cron targeting unconfirmed and failed sends only — not a full re-sync. Failure notices via admin notice system.

### Phase 7 — Stub Plugin and Polish
Stub plugin (same pattern as secure-sign and portal stubs). Logging. Feature list document. AI widget page registration.

---

## Resolved Decisions

1. **Inbound endpoint type** — **WP REST API.** `POST /wp-json/apab/v1/operation`, registered via `rest_api_init` → `register_rest_route()` with `permission_callback => '__return_true'`. Security enforced inside the handler by `AgencyPulseWP_Verifier`. Same pattern as `agency-pulse-pro/v1/mission-callback`.

2. **Conflict handling on invoice restore** — **Configurable plugin option, default skip.** A plugin setting (`apab_restore_conflict_mode`) controls what happens when an invoice with the same invoice number already exists locally: `skip` (default — do not touch the live record), `overwrite` (replace local with backup state), or `duplicate` (create with new post ID and flag for admin review). In all cases WP returns `conflict: true` with `conflict_reason: "invoice_number_exists"` and the existing local post ID so the server can surface the outcome in the management console.

3. **Local PDF upload strategy** — **Option C: Do not upload local PDFs.** Only signed PDFs (already on the external server via secure-sign's S3 path) are referenced in the backup record. Local unsigned PDFs are not included in payloads. On restore, the HTML template snapshot is always available for regeneration of a local PDF. This keeps payloads manageable and avoids duplicating data that can be reconstructed. The `_invoice_pdf_versions` array is still backed up in full so the version history is preserved, but local binary files are not transmitted.

4. **Archive action** — **Trash by default, configurable.** When the server sends an archive command, WP moves the post to trash (recoverable within the standard 30-day WP trash window) rather than hard-deleting. This can be overridden per-operation by including `"archive_mode": "delete"` in the inbound payload, allowing the server to send a hard-delete instruction for specific cases. A global setting in the WP plugin admin (`apab_default_archive_mode`: `trash` | `delete`) sets the site-wide default.

5. **Catch-up scope** — **Manual Backup All + confirmation-gap cron.** Initial and deliberate bulk backups are triggered by a manual **Backup All** button in the plugin admin — this queues every entity of every type regardless of status and is used intentionally (first setup, after a gap, migration). The scheduled catch-up cron is not a bulk re-sync; it re-queues only entities where the last send was not confirmed by the external server (status `failed`, stale `in_progress`, or no `_apab_backup_remote_id` despite a prior attempt). It does not re-queue already-confirmed records unless local state has changed since `_apab_last_backup_at`.

6. **External API readiness** — **Open. Blocks Phase 2.** The server endpoints defined in `BACKUP_SERVER_CONTRACT.md` must exist before the backup engine can be tested. Confirm with the server team which endpoints are already available before beginning Phase 2 implementation.
