# POS Data Catchup — Desktop App Integration Guide

This document describes the **POS Data Catchup** system used by BuyerKiosk to detect and recover missing POS data (sales, buys, S-File daily-close) for each store, and the integration contract the desktop DRS app must implement to participate.

**Audience:** Desktop app (DRS / S-File relay) developers
**Last updated:** 2026-04-10

---

## 1. Overview

Each BuyerKiosk store generates three streams of data the central system needs:

| Data type    | Source                          | Stored in                                  |
|--------------|----------------------------------|--------------------------------------------|
| `sales`      | POS sales line items            | `kiosk_sales.sales` (item-level)            |
| `buys`       | POS buy line items              | `kiosk_sales.buys` (item-level)             |
| `dailyClose` | Daily S-File (end-of-day report) | `kiosk_{typeNum}.drsDailySFileData`         |

The desktop app already pushes this data to BuyerKiosk via authenticated HTTP endpoints. The **catchup system** adds a feedback loop:

1. A daily background job on BuyerKiosk inspects the central database and identifies dates where data is missing or stale.
2. For each gap it identifies, it publishes a **catchup request** message via Ably to the store's channel.
3. The desktop app **subscribes** to its store's Ably channel, receives the request, looks up the missing data locally, and **POSTs** it back to BuyerKiosk using the existing ingestion endpoints.
4. The next time the catchup job runs, it sees the row was updated and marks the gap resolved.

The desktop app does **not** need to know which dates are missing — BuyerKiosk tells it. It just needs to listen, fetch the local data for the requested range, and POST it.

---

## 2. Authentication

All HTTP endpoints in this guide are authenticated via the existing per-store API key:

```http
X-API-Key: <60-character store API key>
```

- The API key is the value of `stores.api_key` in `kiosk_buykiosk` (delivered to each store at provisioning time).
- It is exactly 60 characters, alphanumeric.
- Validated by `validateAPIKey()` in `userfrosting/models/BaseModel.php:1913` — checks length, store existence, store active status, and exact match.
- Returns HTTP 401 with `{"success":false,"error":"...","code":"UNAUTHORIZED"}` on failure.

A few legacy endpoints also accept the API key as a form field named `api`. New integrations should always use the header.

---

## 3. Ably channel & message format

### 3.1 Channel name

Each store has exactly one Ably channel:

```
{typeNum}
```

Examples: `pc00`, `ou20369`, `pa15`, `pc80586`. The channel name is the store identifier with no prefix — the same channel the desktop app already subscribes to for buy-flow events (`startSort`, `sortComplete`, `startBuy`, `processBuy`, `deleteBuy`, `changeMPC`, `reprint`, `clearFlags`, `checkOut`, `batchCheckout`, etc., all published from `userfrosting/routes/groups/drs.php:1866` via `sendEncodedData()`).

**No new authentication is needed.** Whatever connection the desktop app already uses to receive those existing events is the same connection that will receive `drs:catchup:request`. You're just adding a new event handler.

### 3.2 Subscribing

Add a handler on the existing `{typeNum}` channel subscription that listens for the event name `drs:catchup:request`. That's it.

The catchup system currently publishes exactly one event name on this channel. Other catchup-related events may be added later — ignore anything you don't recognize.

### 3.3 Catchup request message

When BuyerKiosk detects gaps, it publishes a message like this:

```json
{
  "action": "drs:catchup:request",
  "category": "pc80586",
  "timestamp": 1744300800,
  "source": "catchup",
  "requestId": "pc80586_catchup_661a4f2c8a1b03.42718901",
  "version": 1,
  "dataTypes": ["sales", "buys", "dailyClose"],
  "dateRange": {
    "startDate": "2026-03-15",
    "endDate": "2026-04-09"
  },
  "reportBoundary": false,
  "endpoints": {
    "sales": "/api/pc80586/sales/salesDay/{date}",
    "buys": "/api/pc80586/buys/buysDay",
    "dailyClose": "/api/pc80586/daily-close",
    "boundary": "/api/pc80586/drs/catchup/boundary"
  }
}
```

| Field            | Type        | Notes |
|------------------|-------------|-------|
| `action`         | string      | Always `"drs:catchup:request"` (also matches the Ably event name). |
| `category`       | string      | The store's `typeNum`. Same as the channel name. |
| `timestamp`      | int (epoch) | Unix timestamp when the message was published. |
| `source`         | string      | Always `"catchup"`. Identifies the publishing subsystem. |
| `requestId`      | string      | Unique per chunk. Echo this in logs and responses if you can — it's how we trace round-trips. Format: `{typeNum}_catchup_{uniqid}` for gap-fill, `{typeNum}_resync_{uniqid}` for column-refresh resyncs. |
| `version`        | int         | Schema version. Currently `1`. |
| `dataTypes`      | string[]    | Subset of `["sales","buys","dailyClose"]`. Only the listed types are missing for this date range — you only need to send those. |
| `dateRange.startDate` | string (YYYY-MM-DD) | Inclusive. |
| `dateRange.endDate`   | string (YYYY-MM-DD) | Inclusive. Range is at most 30 days (`batchChunkDays` config). |
| `reportBoundary` | bool        | If `true`, **also** call the boundary endpoint (§5) to tell us the earliest date you have any data for. Set on the first chunk of a session when we don't yet know the boundary. |
| `endpoints`      | object      | Pre-formatted relative URLs. The `sales` URL contains `{date}` which you must substitute per day. |

### 3.4 What the desktop app should do on receipt

For each `drs:catchup:request` message:

1. Iterate `dataRange.startDate` → `endDate` day by day.
2. For each day, for each entry in `dataTypes`:
   - **`sales`** → POST that day's sales records to `/api/{typeNum}/sales/salesDay/{YYYY-MM-DD}` (§4.1).
   - **`buys`**  → POST that day's buy records to `/api/{typeNum}/buys/buysDay` (§4.2). Each record carries its own `buyDate`.
   - **`dailyClose`** → POST that day's S-File row to `/api/{typeNum}/daily-close` (§4.3).
3. If `reportBoundary` is `true`, also POST the earliest available date to `/api/{typeNum}/drs/catchup/boundary` (§5).

If the desktop app has **no data** for a requested date — for example because the store wasn't operating that day — that's fine. Just don't send anything for that date. BuyerKiosk will eventually mark the gap as `persistent` after 7 days (configurable).

The expected end state is **idempotency** — the same request should produce the same outcome whether it's seen once or ten times. All ingestion endpoints upsert by their natural key, so re-sending data is always safe.

### 3.5 Rate limiting & throttling

The publisher (`CatchupAblyPublisher`) uses a Redis-backed throttle (`AblyPublishThrottle`) keyed by `typeNum + 'drs:catchup:request' + 'catchup'`. You will not receive duplicate messages within the throttle window for the same store. In normal operation we publish at most ~50 messages per store per day (configured by `maxCommandsPerRun`).

---

## 4. Ingestion endpoints

All paths below are relative to the BuyerKiosk base URL (e.g. `https://buyerkiosk.com`). All require the `X-API-Key` header.

### 4.1 POST /api/{typeNum}/sales/salesDay/{date}

Submit one day's sales line items.

- **Path params:** `typeNum`, `date` (`YYYY-MM-DD`).
- **Headers:** `X-API-Key`, `Content-Type: application/json`
- **Body:** JSON array of sales item objects:

```json
[
  {
    "code": "ABC123",
    "Description": "Nike Air Max",
    "quantity": 1,
    "price": 49.99,
    "cost": 12.50,
    "deptID": 5,
    "catID": 12,
    "subCatID": 33,
    "sizeID": 7,
    "brandID": 41,
    "buyDate": "2025-12-01"
  }
]
```

| Field         | Type    | Notes |
|---------------|---------|-------|
| `code`        | string  | POS item code (≤20 chars). Required. Used as part of the upsert key. |
| `Description` | string  | Note the capital D. ≤255 chars. |
| `quantity`    | int     | tinyint. |
| `price`       | decimal | Sale price. |
| `cost`        | decimal | Buy cost. |
| `deptID`      | int     | Department FK. |
| `catID`       | int     | Category FK. |
| `subCatID`    | int     | Sub-category FK. |
| `sizeID`      | int     | Size FK. |
| `brandID`     | int     | Brand FK. |
| `buyDate`     | date    | When the item was originally bought (not the sale date). |

The `salesDate` for every record in the request is taken from the URL path param `:date`. The server passes the array element-by-element through `SalesItem::createFromData()` in `userfrosting/src/BuyerKiosk/Sales/SalesItem.php:84`, which UPSERTs into `kiosk_sales.sales` (ON DUPLICATE KEY UPDATE). Re-sending is always safe.

**Response (200):**
```json
{
  "message": "Sales items processing completed",
  "success": 42,
  "failed": 0
}
```

**Common error responses:**
- `400 EMPTY_BODY` — no body
- `400 INVALID_JSON` — malformed JSON (encoding is auto-fixed Windows-1252 → UTF-8 and control chars are stripped before parsing)
- `401 UNAUTHORIZED` — bad/missing API key
- `500` — server error

Source: `userfrosting/routes/groups/sales.php:63`

### 4.2 POST /api/{typeNum}/buys/buysDay

Submit one or more days' buy line items. Unlike sales, the date is **inside each record** (not in the URL).

- **Headers:** `X-API-Key`, `Content-Type: application/json`
- **Body:** JSON array of buy item objects:

```json
[
  {
    "code": "WIDGET-007",
    "Description": "Vintage denim jacket",
    "quantity": 1,
    "price": 25.00,
    "cost": 8.00,
    "deptID": "5",
    "catID": "12",
    "subCatID": "33",
    "sizeID": "M",
    "brandID": "41",
    "buyDate": "2026-04-08"
  }
]
```

Same field semantics as sales (note that on the buys table, the `*ID` columns are `varchar(20)` not `int`). The `buyDate` field on each record is required and used for the upsert key.

**Response (200):**
```json
{
  "message": "Buys items processing completed",
  "success": 17,
  "failed": 0
}
```

Source: `userfrosting/routes/groups/buys.php:63`, `userfrosting/src/BuyerKiosk/Sales/BuysItem.php:79`

You may include records spanning multiple dates in a single POST. Common pattern: one POST per requested date is simplest, but batching is allowed.

### 4.3 POST /api/{typeNum}/daily-close

Submit one day's S-File data as parsed JSON.

- **Headers:** `X-API-Key`, `Content-Type: application/json`
- **Body:** JSON object with `closingDate` (required) and any subset of S-File fields:

```json
{
  "closingDate": "2026-04-09",
  "grossSalesCost": 412.50,
  "grossSalesRetail": 1138.00,
  "grossSalesNumber": 40,
  "buyPaidCash": 89.00,
  "buyPaidDigital": 0.00,
  "buyPaidGiftCard": 0.00,
  "paidOutGiftCard": 0.00,
  "cashExpected": 250.00,
  "cashActual": 248.50,
  "cashVariance": -1.50
}
```

**Required:** `closingDate` only (must be `YYYY-MM-DD`).
**All other fields are optional** but the server is strict about what it accepts:

- The full set of recognized fields is the union of `DECIMAL_COLUMNS` and `INT_COLUMNS` in `userfrosting/src/BuyerKiosk/QuickBooks/Controllers/DailyCloseApiController.php:35-82`.
- Decimal columns (price-shaped values) accept any numeric value; non-numeric values become `NULL` and produce a warning.
- Int columns (count-shaped values) accept any integer; non-numeric values become `NULL` and produce a warning.
- **Unknown fields are silently dropped** (logged at debug level).
- **`null` and `""` are treated as NULL** and stored as NULL.
- **Fields you simply don't include in the request body are stored as NULL** (`array_key_exists` check). This is important — see §4.4.

The full column list (113 fields) is in the source — copy from there directly into your serializer rather than reproducing it here.

**Response (201):**
```json
{
  "success": true,
  "reportId": 8421,
  "closingDate": "2026-04-09",
  "fieldsReceived": 87,
  "fieldsParsed": 84,
  "fieldsNulled": 3,
  "warnings": ["cashVariance: non-numeric value 'N/A'"]
}
```

The `fieldsNulled` and `warnings` arrays let you spot parsing problems on the desktop side. If `fieldsNulled` is unexpectedly high, your S-File parser is likely producing values the server can't cast.

The endpoint upserts on `closingDate` (UNIQUE key on the column). Re-sending the same date overwrites the existing row.

Source: `userfrosting/routes/groups/daily-close.php:49`, `DailyCloseApiController::submitDailyClose():106`

### 4.4 Important: NULL vs 0 in S-File payload

This is a recent change (2026-04-10) that you must follow correctly:

- **Omit a field entirely** if your S-File doesn't contain that key for the day. The server will store it as `NULL`, which means "the POS didn't report this".
- **Send `0` (or `0.00`)** if your S-File explicitly contains the value `0`. This means "the POS reported zero of this thing".

The two are no longer interchangeable. The catchup system uses a stale-data detection rule (`sfile_payment_columns_2026`) that flags rows where specific payment columns are *all NULL* — meaning we never received them — and triggers a resync. If you send `0` for a field that wasn't actually in the S-File, you'll mask the gap and we'll never know to ask for it again. Conversely, sending `null` for a field that genuinely *was* `0` will cause us to keep flagging the row as stale forever (mitigated server-side by the "skip already-attempted" guard, but still wasteful).

In practice: **only include keys for fields your parser actually extracted from the S-File.** Don't pre-populate the JSON object with zeros.

### 4.5 Helper: oldest-date endpoints

Three helper endpoints let the desktop app figure out what its own historical coverage looks like (e.g., on first run or after a reinstall). The catchup system also calls these, but you may find them useful too.

```
POST /api/{typeNum}/sales/oldestDate         → { success, oldestDate, oldestSalesDate, recordCount }
POST /api/{typeNum}/buys/oldestDate          → { success, oldestDate, recordCount }
POST /api/{typeNum}/daily-close/oldestDate   → { success, oldestDate, newestDate, recordCount }
```

Each requires `X-API-Key` and an empty body.

---

## 5. Boundary endpoint

```
POST /api/{typeNum}/drs/catchup/boundary
```

Used to tell BuyerKiosk: "I cannot supply data older than this date." Once you call this, BuyerKiosk marks all gaps for `gapDate < earliestDate` as `unavailable` and stops asking.

Call this **once per session** when:

1. You receive an Ably catchup message with `reportBoundary: true`, OR
2. You start up fresh and want to declare your earliest available data.

**Headers:** `X-API-Key`, `Content-Type: application/json`
**Body:**
```json
{ "earliestDate": "2025-01-15" }
```

`earliestDate` must be in `YYYY-MM-DD` format and a real calendar date.

**Response (200):**
```json
{
  "success": true,
  "earliestDate": "2025-01-15",
  "gapsMarkedUnavailable": 12
}
```

**Errors:** `400` for missing/invalid date, `401` for bad API key.

The earliestDate is the older of:
- The oldest closing date in your local S-File store, OR
- The oldest sales/buy record date you can still reach.

Pick the **most recent** of those (i.e., the one farthest forward in time) so we don't keep asking for data you can't actually produce.

Source: `userfrosting/routes/groups/catchup.php:39`, `userfrosting/src/BuyerKiosk/PosDataCatchup/Controllers/CatchupBoundaryController.php:62`

---

## 6. End-to-end example flow

Scenario: store `pc80586`, the desktop app has been offline for 5 days (2026-04-04 → 2026-04-08 inclusive).

1. **05:00 UTC** — BuyerKiosk's daily `pos-data-catchup` job runs for `pc80586`.
2. The `GapDetector` reads:
   - `kiosk_sales.sales` and `kiosk_sales.buys` for any rows on those 5 dates → finds none.
   - `kiosk_pc80586.drsDailySFileData` for those 5 dates → finds none.
   - `kiosk_pc80586.buyQueue` for `dateAdded` activity on those dates → confirms the store *was* operating.
3. Five gap rows are upserted into `kiosk_buykiosk.posDataGaps` with `missingSales=1, missingBuys=1, missingDailyClose=1`.
4. Dates are grouped into one contiguous range (`2026-04-04 → 2026-04-08`), then chunked into one 30-day chunk.
5. `CatchupAblyPublisher::publishCatchupRequest()` publishes:
   ```json
   {
     "action": "drs:catchup:request",
     "category": "pc80586",
     "timestamp": 1744358401,
     "source": "catchup",
     "requestId": "pc80586_catchup_661a4f2c8a1b03.42718901",
     "version": 1,
     "dataTypes": ["sales", "buys", "dailyClose"],
     "dateRange": { "startDate": "2026-04-04", "endDate": "2026-04-08" },
     "reportBoundary": false,
     "endpoints": {
       "sales": "/api/pc80586/sales/salesDay/{date}",
       "buys": "/api/pc80586/buys/buysDay",
       "dailyClose": "/api/pc80586/daily-close",
       "boundary": "/api/pc80586/drs/catchup/boundary"
     }
   }
   ```
6. The gap rows are marked `commanded` (status changes from `detected` → `commanded`, `commandCount += 1`, `lastCommandSentAt = now()`, `lastRequestId` recorded).
7. Desktop app, having reconnected to Ably, receives the message.
8. For each of the 5 dates, it POSTs:
   - `POST /api/pc80586/sales/salesDay/2026-04-04` (and 04-05, 04-06, 04-07, 04-08)
   - `POST /api/pc80586/buys/buysDay` × 5 (one per date, or one batched POST)
   - `POST /api/pc80586/daily-close` × 5
9. **Next day, 05:00 UTC** — Catchup job runs again.
10. The `checkResolutions` step compares each commanded gap's `lastCommandSentAt` against the actual data timestamps. For dates where data is now present, the gap row is updated to `status='resolved'` and `resolvedAt=now()`.
11. If after 7 days (configurable via `persistentThresholdDays`) a gap is still unresolved, it's marked `persistent` and surfaced for manual review.

---

## 7. Stale-data resync rules

In addition to filling missing rows, the system can ask you to **re-send** existing rows whose specific columns look stale. This is currently used for the `sfile_payment_columns_2026` rule (introduced when 3 new payment columns were added to the S-File schema).

How it works:

- The catchup job evaluates rules from `task_job_definitions.config.resyncRules` against existing `drsDailySFileData` rows.
- The current rule checks rows where `buyPaidDigital`, `buyPaidGiftCard`, and `paidOutGiftCard` are *all NULL* (`condition: all_null`).
- For each matching row, it publishes a normal `drs:catchup:request` message (same shape as a gap request) for the relevant date.
- After issuing the command, the date is recorded in `kiosk_buykiosk.posDataResyncs`. **A given date is only ever requested once** under each rule — if your POS legitimately doesn't have those fields, we won't keep nagging. To force a retry (e.g., after a POS firmware upgrade), an operator can `DELETE FROM posDataResyncs WHERE typeNum=... AND ruleId=...`.

From the desktop app's perspective, **gap requests and resync requests are indistinguishable** — both are `drs:catchup:request` messages. The only difference is the `requestId` prefix (`_catchup_` vs `_resync_`), which is purely informational. Treat them identically: look up the data and POST it.

---

## 8. Configuration reference

Current production config (`task_job_definitions` row where `name='pos-data-catchup'`):

```json
{
  "lookbackDays": 365,
  "batchChunkDays": 30,
  "bufferDays": 1,
  "persistentThresholdDays": 7,
  "maxCommandsPerRun": 50,
  "resyncRules": [
    {
      "id": "sfile_payment_columns_2026",
      "table": "drsDailySFileData",
      "dataType": "dailyClose",
      "dateColumn": "closingDate",
      "staleCriteria": {
        "columns": ["buyPaidDigital", "buyPaidGiftCard", "paidOutGiftCard"],
        "condition": "all_null"
      },
      "enabled": true
    }
  ],
  "resyncPersistentThresholdDays": 14
}
```

| Key | Meaning |
|---|---|
| `lookbackDays` | How far back to scan for gaps each run. |
| `batchChunkDays` | Maximum width of a single Ably request. Larger ranges get split. |
| `bufferDays` | Days to skip at the trailing edge (gives the POS time to push end-of-day data before we flag it as missing). |
| `persistentThresholdDays` | After this many days, an unresolved gap is marked `persistent`. |
| `maxCommandsPerRun` | Hard cap on Ably messages per store per run. |
| `resyncPersistentThresholdDays` | Same idea as `persistentThresholdDays` but for resync rules. |

Schedule: `0 5 * * *` (daily at 05:00 UTC). The job is per-store, so each store gets its own execution.

---

## 9. Server-side data model (for reference)

### kiosk_buykiosk.posDataGaps
Tracks missing-row gaps. One row per `(typeNum, gapDate)`.

| Column | Type | Notes |
|---|---|---|
| `id` | uint | PK |
| `typeNum` | varchar(10) | Store ID |
| `gapDate` | date | The day with missing data |
| `missingSales` | tinyint(1) | 1 if `sales` missing |
| `missingBuys` | tinyint(1) | 1 if `buys` missing |
| `missingDailyClose` | tinyint(1) | 1 if `dailyClose` missing |
| `verificationSource` | enum | `buyQueue`, `storeHours`, or `manual` (how we know the store was open) |
| `status` | enum | `detected` → `commanded` → `resolved` / `persistent` / `unavailable` |
| `commandCount` | int | How many times we've published a request for this gap |
| `lastCommandSentAt` | datetime | |
| `lastRequestId` | varchar(64) | Last published `requestId` |
| `resolvedAt` | datetime | When it transitioned to `resolved` |
| `firstDetectedAt` | datetime | |
| `updatedAt` | datetime | |

### kiosk_buykiosk.posDataResyncs
Tracks stale-column resync requests. One row per `(typeNum, resyncDate, ruleId)`.

| Column | Type | Notes |
|---|---|---|
| `id` | uint | PK |
| `typeNum` | varchar(10) | |
| `resyncDate` | date | |
| `ruleId` | varchar(64) | e.g. `sfile_payment_columns_2026` |
| `dataType` | varchar(20) | `dailyClose`, `sales`, `buys` |
| `status` | enum | `detected` → `commanded` → `resolved` / `persistent` |
| `commandCount` | uint | |
| `lastCommandSentAt` | datetime | |
| `lastRequestId` | varchar(64) | |
| `resolvedAt` | datetime | |
| `firstDetectedAt` | datetime | |
| `updatedAt` | datetime | |

### kiosk_buykiosk.stores.drsEarliestDataDate
Set by the boundary endpoint. Drives "skip dates older than this" logic in gap detection.

---

## 10. Testing

**Manually trigger a catchup run for a specific store** (server-side):

```bash
php userfrosting/bin/task job:dispatch pos-data-catchup --store=pc80586
```

**Force a single date range and bypass detection:**

```bash
# Job accepts startDate / endDate payload overrides
php userfrosting/bin/task job:dispatch pos-data-catchup \
  --store=pc80586 \
  --payload='{"startDate":"2026-04-01","endDate":"2026-04-09"}'
```

**Inspect gaps for a store:**

```sql
SELECT gapDate, missingSales, missingBuys, missingDailyClose, status, commandCount, lastCommandSentAt, resolvedAt
FROM kiosk_buykiosk.posDataGaps
WHERE typeNum='pc80586'
ORDER BY gapDate DESC;
```

**Inspect resyncs:**

```sql
SELECT resyncDate, ruleId, status, commandCount, lastCommandSentAt, resolvedAt
FROM kiosk_buykiosk.posDataResyncs
WHERE typeNum='pc80586'
ORDER BY resyncDate DESC;
```

**Force a resync retry for a date** (after the POS has been fixed):

```sql
DELETE FROM kiosk_buykiosk.posDataResyncs
WHERE typeNum='pc80586' AND ruleId='sfile_payment_columns_2026' AND resyncDate='2026-03-15';
```

**Watch publishes in real time** (debug log written by the publisher):

```bash
tail -f /home/bkweb/logs/sfile-debug.log
```

This log will be removed once backfill validation is complete (it's marked as temporary in the source).

---

## 11. Open items / things to coordinate before launch

1. **Replay on reconnect.** Ably's default channel history is 2 minutes. If the desktop app is offline longer than that and we publish a catchup request in the gap, the message is lost. Options: (a) on reconnect, immediately call the oldest-date helper endpoints (§4.5) and self-trigger a catch-up against your local data, (b) wait for the next daily catchup run (5am UTC) to re-publish, or (c) expose a manual refresh action so the operator can re-trigger. Decide which behavior you want.
2. **Field name `Description`** in the sales/buys payloads has a capital D. This is a legacy naming we're stuck with — match it exactly.

---

## 12. Source-code map

| Concern | File |
|---|---|
| Ably publisher | `userfrosting/src/BuyerKiosk/PosDataCatchup/Messaging/CatchupAblyPublisher.php` |
| Catchup job (orchestrator) | `userfrosting/src/BuyerKiosk/PosDataCatchup/Jobs/PosDataCatchupJob.php` |
| Gap detection | `userfrosting/src/BuyerKiosk/PosDataCatchup/Services/GapDetector.php` |
| Stale-column detection | `userfrosting/src/BuyerKiosk/PosDataCatchup/Services/StaleDataDetector.php` |
| Gap persistence | `userfrosting/src/BuyerKiosk/PosDataCatchup/Persistence/GapTracker.php` |
| Resync persistence | `userfrosting/src/BuyerKiosk/PosDataCatchup/Persistence/ResyncTracker.php` |
| Boundary endpoint | `userfrosting/src/BuyerKiosk/PosDataCatchup/Controllers/CatchupBoundaryController.php` |
| Sales ingestion | `userfrosting/routes/groups/sales.php`, `userfrosting/src/BuyerKiosk/Sales/Controllers/SalesController.php`, `userfrosting/src/BuyerKiosk/Sales/SalesItem.php` |
| Buys ingestion | `userfrosting/routes/groups/buys.php`, `userfrosting/src/BuyerKiosk/Sales/Controllers/BuysController.php`, `userfrosting/src/BuyerKiosk/Sales/BuysItem.php` |
| Daily-close ingestion | `userfrosting/routes/groups/daily-close.php`, `userfrosting/src/BuyerKiosk/QuickBooks/Controllers/DailyCloseApiController.php` |
| Catchup route | `userfrosting/routes/groups/catchup.php` |
| API key validation | `userfrosting/models/BaseModel.php:1913` (`validateAPIKey`) |
