Ctrl K
ComunidadGuías…

docs/architecture/cloud-integrations.md

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
# Cloud integrations

Every network feature FloCafe has, what it talks to, what enables it, and what happens when the
network is absent. The principle that keeps all of them optional is
[0001: offline-first core](../decisions/0001-offline-first-core.md); this page is the current
inventory, not the rationale.

All outbound work is either an outbox flush or a best-effort background task. Nothing on this page
sits on a path between an order and a paid bill.

## Offline mode

The owner can switch every optional outbound connection off with the `offline_mode` setting
(`PUT /api/settings/offline-mode`, permission `offline-mode.manage`, Settings → Privacy). It is off
by default and is restore-protected across database restore and import.
`main/services/offline-mode.ts` exposes `isOfflineMode()`, which is also true when
`FLO_MATRIX_OFFLINE` is `1`, and `getOfflineModeAbortSignal()`, which aborts when Offline mode turns
on. While it is on:

- FloAdmin cloud sync starts no timers (including auto-registration retries) and `trackedFetch`
  refuses or aborts every request; turning the mode off reloads sync and refreshes registration.
- Google Drive scheduled backups do not run, and connecting, listing, backing up and restoring fail
  with the `offline` error code, and in-flight Drive work is cancelled. Disconnecting retains the
  local tokens and records the revoke as unconfirmed, which blocks further Drive access.
- WhatsApp closes its socket without unpairing, cancels in-flight connection and version-lookup
  work, refuses to connect and does not start; turning the mode off reconnects it.
- Update checks report the `offline` update state, an in-flight update check or download is
  cancelled into the `offline` state, and the startup tax pack check and tax pack catalog and
  artifact downloads (including in-flight requests) are refused or aborted.
- Telemetry, diagnostics and PostHog send nothing (stopping the daily ping timer, aborting in-flight
  telemetry, diagnostics and PostHog requests, and shutting down the PostHog client without flushing
  queued events; turning the mode off resumes the daily telemetry timer without resending
  `app_launch`), and web image import returns `503` (aborting in-flight fetches).

The Express API, KDS and Server App stay bound to `0.0.0.0`, so devices on the local network keep
working.

## Google Drive backup

Backs up the local database to a folder in the merchant's own Google Drive, and restores from it.

**Enabled by** an OAuth flow the merchant completes. The redirect is served from an ephemeral
loopback listener bound to `127.0.0.1` on a kernel-assigned port, and the authorization request uses
PKCE with `code_challenge_method: 'S256'`.

**Scope rule.** `DRIVE_FILE_SCOPE` is `https://www.googleapis.com/auth/drive.file`, requesting access
only to files the app creates, alongside `openid` and `email` for identity. The scope must not be
widened: `drive.file` is what keeps a compromised token from being able to enumerate the merchant's
whole Drive. Authorization URLs are additionally checked by `isSafeGoogleAuthorizationUrl()` before
the browser is opened.

**Credential storage.** Tokens are encrypted with Electron's `safeStorage` before being written, and
decrypted on read. Backup directories, including the staging area, are created with mode `0o700`.

**Warning gate.** Before a backup runs, the merchant must acknowledge a warning, recorded under the
`google_drive_warning_acknowledged` setting. A backup attempt without that acknowledgement fails
with `warning_acknowledgement_required`.

**Revoke on disconnect.** Disconnecting revokes the grant at Google rather than only deleting the
local token.

**Two-step backup and retention pruning.** Each backup separates snapshot upload from retention
cleanup: the upload step writes the database snapshot to Google Drive and immediately stamps
`last_backup_status` as `'success'` (`last_backup_at` and `last_success_at`), while the retention
step (`applyRetention`) moves older automatic backups beyond `retention_count` to Google Drive
Trash. If a transient error interrupts retention pruning, the completed snapshot upload remains
valid (`last_backup_status` stays `'success'`) and the service records `retention_pending`
(`retention_status = 'pending'`) so automatic background retries finish pruning older backups
without invalidating the backup.

**Offline behaviour.** Every operation fails with a `driveError` code rather than hanging. The last
error code is persisted so the settings screen can explain the failure after a restart.

## Google Drive QR menu

Settings → QR Menu publishes a customer-facing PDF from the catalog or replaces it with a merchant
PDF up to 15 MB. It uses the existing `drive.file` grant. The generated menu defaults to the
phone-oriented Mobile-First Cafe template; Classic Bistro and Standard layouts are also available.

The menu lives in a dedicated `<store>_flocafe_menu` folder marked with
`flo_destination_kind=flocafe_menu`. The folder remains private. Only the PDF marked
`flo_file_kind=public_menu_pdf` receives an `anyone`/`reader` permission. Backup discovery excludes
the menu folder, and every overwrite or permission change revalidates the PDF marker, MIME type,
installation marker, trash state, and parents before touching the stored file ID.

The first publish creates a six-character menu ID and one Drive file. Later generated or uploaded
publishes update that same file ID, including after a store-name change, so the public URL and
printed QR codes remain stable. Pausing sharing removes the public permission without deleting the
file; resuming restores it on the same file. Disconnect and database-restore cleanup remove the
Drive-bound menu IDs while preserving the local generator preferences and short ID.

Publishing and sharing require `catalog.manage` or `google-drive.manage`; connecting and
disconnecting the account still require `google-drive.manage`. Offline mode fails these operations
with `offline` and serializes them with backup and restore work.

## Anonymous telemetry

Posts anonymous usage and error events to `https://telemetry.flopos.com/collect`.

**Enabled by** the `telemetry_enabled` setting, read as on when the value is `'true'`. It ships
**on** for a new install, which writes `telemetry_enabled` as `'true'`. An install that already
carries an explicit value keeps it, because the `telemetry_default_on_for_new_installs` migration
that flips the default uses `INSERT OR IGNORE`; an install that already ran the earlier
`seed_telemetry_settings` migration therefore still holds `'false'`.

**Offline behaviour.** Events are dropped, not queued. There is no telemetry outbox.

The same `telemetry_enabled` switch also gates PostHog usage events and the installation email,
described under PostHog below. The collector at `https://telemetry.flopos.com/collect` sends no name
and no email.

**Run mode and install source.** Every telemetry event and every FloAdmin registration carries two
provenance fields, so a merchant install is distinguishable from a developer's local run. `run_mode`
is `packaged` or `dev`, read from `app.isPackaged`. `install_source` is `github`, `ms_store`,
`mac_app_store`, `snap`, `appimage`, `flatpak`, `deb`, `rpm`, `linux_package`, or `dev`, resolved in
this order:

1. An unpackaged build reports `dev` for both fields, which also covers non-Electron hosts.
2. `process.windowsStore` -> `ms_store`; `process.mas` -> `mac_app_store`.
3. `SNAP` -> `snap`; `APPIMAGE` -> `appimage`; `FLATPAK_ID` -> `flatpak`. An empty or whitespace
   variable is not a signal.
4. `win32` or `darwin` -> `github`, the GitHub-release installers.
5. Anything else is a native Linux package, and its format is read from `/etc/os-release`:
   `ID` first, then the `ID_LIKE` family list, giving `deb` for the Debian/Ubuntu families and `rpm`
   for the RHEL/Fedora/SUSE families. Derivatives resolve through `ID_LIKE`, so Rocky or Linux Mint
   are classified without being listed themselves.

An unreadable or unrecognised `/etc/os-release` reports `linux_package`. That is a real value, not a
placeholder for "unknown": the distribution could not be identified, so the format is not guessed and
neither `deb` nor `rpm` is claimed. Detection is total - outside Electron, in unit tests and scripts,
it degrades to `dev` rather than throwing.

## PostHog

Optional product analytics and error tracking, next to the anonymous collector. The client sends
only when a project token and a host resolve. `readPostHogConfig()` in
`main/services/posthog-config.ts` takes the token from `FLO_POSTHOG_DEV_TOKEN` (development only),
then the `posthog_project_token` setting, then `posthog_project_token` in
`dist/main/build-identity.json`; the host comes from the `posthog_host` setting, then
`posthog_host` in `build-identity.json`, then the EU Cloud public capture host
`https://eu.i.posthog.com`. No migration seeds either setting. `scripts/copy-runtime-assets.cjs`
writes the build-time `POSTHOG_PROJECT_TOKEN` and `POSTHOG_HOST` into `build-identity.json`, and
only the release and Mac App Store workflows set them, so local builds and forks carry no token. No
environment variable configures the client at runtime other than the development overrides below.
A host that is not `http` or `https`, or that includes credentials, is ignored, and the client sends
nothing.

**Only released builds count.** `postHogEnvironment()` in `main/services/posthog-config.ts`
decides, and only for an Electron main process (`process.type === 'browser'`); test suites run as
plain Node and never connect, even when they stub `app.isPackaged`.

| Run                                                                               | Environment         |
| --------------------------------------------------------------------------------- | ------------------- |
| Packaged build whose `dist/main/build-identity.json` has `official_release: true` | `production`        |
| Any Electron run with `FLO_POSTHOG_DEV=1`                                         | `development`       |
| Anything else: `npm run dev`, local `release:*` packages, CI and matrix packages  | none; no connection |

`official_release` is written by `scripts/copy-runtime-assets.cjs` only when the build runs with
`FLO_OFFICIAL_RELEASE=1`, which only the release and Mac App Store workflows set. Every event and
the person carry `environment` and `release_channel` (`stable` or `beta`). In development,
`FLO_POSTHOG_DEV_TOKEN` (and optionally `FLO_POSTHOG_DEV_HOST`) sends to a separate project
instead of the release one. The client also sends nothing when `CI` is set, when
`FLO_MATRIX_OFFLINE` is `1`, or when the e2e harness is running (`E2E_TASK_LOCAL_PORTS`).

In the PostHog project, **Settings → Project → Filter out internal and test users** should exclude
person property `environment` = `development` and the team's email domain, with **Enable this
filter on all new insights** turned on.

Every event passes through `scrubPostHogEvent` in `shared/posthog-scrub.ts`, in the main process
(`posthog-node`) and in the renderer (`posthog-js`). It is an allowlist: an event name, property, or
person property it does not list is dropped. Every event carries `app_version`, `platform`,
`run_mode`, `install_source`, `environment`, and `release_channel`; `install_ref` is added at the
`anonymous` and `full` levels, and `store_ref` at `full` only (see **Sharing levels** below).
`process_type` names the process that failed: `main` for the main-process handlers, `api` for a
local API 5xx, and `renderer` for the window (`kds` and `server-app` are allowed for later use).
`$release_id` is kept when the SDK sends one. Release builds use `--release-mode symbol-set`, so
chunk code carries no release id and PostHog binds an exception to its release through the symbol
set uploaded for it.

**Sharing levels** (`product_analytics_level`, read by `readProductAnalyticsLevel()` in
`main/services/posthog-identity.ts`).

| Level       | Distinct id          | Person properties                         | Links                                                                           |
| ----------- | -------------------- | ----------------------------------------- | ------------------------------------------------------------------------------- |
| `full`      | Account (`acct_`)    | Store email and store configuration       | `install_ref`, `store_ref`, `staff_ref`; the device is aliased into the account |
| `anonymous` | Device (`inst_`)     | Store configuration only, never the email | `install_ref`, `staff_ref`                                                      |
| `off`       | Per launch (`anon_`) | None                                      | None; only exceptions, when diagnostics transmission is on                      |

`telemetry_enabled` set to anything but `true` means `off`. Otherwise a stored `full` is `full`,
any other stored value is `anonymous`, and a store with no stored level is `anonymous` once
`onboarding_completed` is `true` and `off` before setup. `PUT /api/settings/product-analytics`
(`settings.manage`) stores the level and writes `telemetry_enabled` and `anonymous_data_consent`
(`false` for `off`, `true` otherwise), so the anonymous collector follows the same choice.
`product_analytics_level` is restore-protected like `telemetry_enabled`. The scrubber's
`accountLinked` option is on only at `full`; below it the scrubber drops `$create_alias`, any event
on an `acct_` id, and `email` and `store_ref` wherever they appear.

Setup asks for the level with two equal options, "Link my email" (`full`) and "Keep it anonymous"
(`anonymous`), plus "Don't share usage data" (`off`); none is preselected and setup cannot
continue without one. A setup request without a level stores none. An onboarded store with
telemetry on and no stored level, such as one upgraded from a release without PostHog, shares
anonymously and shows `ProductAnalyticsDialog` once to users with `settings.manage`. The dialog
opens outside POS and KDS, only when `readProductAnalyticsChoice()` reports `choice_pending` (a
configured client that can send), and offers the same two options with the error-report switch.
Closing it without an answer asks again on the next launch. Settings → Privacy offers all three
levels.

**Identities** (`main/services/posthog-identity.ts`). The collector keeps `telemetry_anon_id`;
PostHog does not use it.

| Id               | Value                                                                                                        | Lifetime                                                               |
| ---------------- | ------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------- |
| Account (person) | `acct_` + 16 hex of SHA-256 over the lower-cased store email                                                 | Same for every terminal and every reinstall that uses that email       |
| Device           | `inst_` + 16 hex of SHA-256 over `posthog_install_seed`, a random UUID generated once and stored in settings | Same for the life of the database; a new database gets a new device id |
| Per launch       | `anon_` + a random UUID                                                                                      | One app launch                                                         |

At `full`, the account is the person once a store email exists. Main sends one `$create_alias`
that folds the device into the account (`posthog_aliased` records the pair), so events the device
sent at `anonymous` join the account. The first account key a database derives is kept in
`posthog_account_ref`, so editing the store email does not split the person; a reinstall derives it
again from the current email. At `anonymous`, main sets store configuration on the device person
with a `$set` event and never derives the account key. Moving from `full` to `anonymous` stops new
links; events already linked stay linked. At `off`, exceptions use the per-launch id with
`$process_person_profile` set to `false`. Stopping cloud services or requesting cloud deletion
turns telemetry off and rotates `telemetry_anon_id`, but `posthog_install_seed` does not rotate, so
a later opt-in on the same database links to the same device again.

Only the main process sets the person, on launch and when the level, email or store settings change.
The renderer does not call `posthog.init` until main returns the id over `get-posthog-bootstrap`;
init disables persistence and bootstraps that id, so the SDK cannot mint another one. Main checks
consent and the id on every send (cached for up to 30 seconds and invalidated immediately when
`settings` or `users` rows change via `getSettingsRevision()` or `invalidatePostHogConsentCache()`);
the renderer re-reads both every 30 seconds and on focus and
stamps the current id on each event, so a level change takes effect without a reload. When usage
and diagnostics were both off at load, the same check starts PostHog once either is turned on, and
the current screen is recorded then. At `off`, renderer events move to the per-launch id and the
scrubber drops `install_ref`, `store_ref`, and `staff_ref`; below `full` it drops `store_ref`. Only the Electron main window loads PostHog; the Server App,
KDS, and customer display do not.

**Staff are dimensions, not persons.** Many staff share one terminal, and group analytics is a paid
add-on, so a person is never a staff member. The renderer tags events with `staff_role` (the
signed-in role) and `staff_ref` (the first 16 hex characters of SHA-256 over the distinct id and the
user id), and drops both on logout; logout never changes the distinct id. One email shared by
several branches is one account; terminals are told apart by `install_ref`. A database restored
onto another terminal brings its seed, so both terminals share a device id.

**Usage follows the sharing level.** At `anonymous` and `full`:

- Person properties are store configuration (`country`, `currency`, `language`, `service_model`,
  `kds_enabled`), plus the store email at `full`. The email is `settings.email`, the store email
  cloud registration also sends, or the email of the earliest `users` row when that setting is
  empty. No name is sent.
- A usage event keeps only the properties in `POSTHOG_USAGE_PROPERTY_KEYS`; any other property is
  dropped. `slowest_query` is the one free-text field: it must be a normalized statement (string,
  number, and double-quoted literals replaced by `?`, comments removed, white space collapsed, 400
  characters at most), and a value that still holds a literal or a `"` is dropped.
- `app_launch` and `feature_used` mirror the collector events. `daily_ping` runs on the
  collector's hourly check but does not depend on the collector: it is sent at most once every 24
  hours (`posthog_last_daily_ping_at`) with counts for the last 24 hours (orders by type, including
  `online`, cancelled orders, paid bills, staff who rang up an order) and totals (active staff,
  products, tables, customers, printers, kitchen stations). `setup_completed` records the setup
  profile, service model, and business type. `first_order_created` and `first_bill_paid` are sent
  once per installation with `hours_since_setup`, read hourly from the database. A milestone older
  than the first check on this installation (`posthog_milestones_since`), such as an upgraded
  store's first order, carries `backfilled: true`. `api_slow_request` is sent for a local API
  request that takes 200 ms or more only when the time spent in SQL (`db_ms`) is 200 ms or more, so
  a request that is slow for another reason, such as a printer or the network, sends nothing. The
  same route (method and route pattern) sends one event per hour per process; the requests folded
  into that event are counted in `occurrences`. The properties are `http_method`, the route pattern
  (`http_route`), `http_status`, `duration_ms`, `db_ms`, the statement count (`db_queries`),
  `occurrences`, and the slowest statement (`slowest_query_ms`, `slowest_query`). Every such request
  writes one `console.warn` line, whether or not telemetry is on.
- The renderer sends at most one `$pageview` per screen per staff member every 30 minutes
  (`shared/posthog-screen-views.ts`); views inside the window are folded into the next pageview's
  `repeat_views`. A screen is the route path with id-like segments removed, plus `settings_tab` on
  Settings. No other query value, hash, or title is sent.

At `off`, nothing is identified and no usage event or pageview is sent. Autocapture, heatmaps, dead clicks, performance events, session replay,
surveys, and feature-flag requests are off. `disable_external_dependency_loading` stops the renderer
SDK from loading remote scripts.

GeoIP enrichment is on in both SDKs (`posthog-node` needs `disableGeoip: false`), so PostHog adds
`$geoip_*` location properties from the store's public IP at ingestion. Whether the IP itself is
stored follows the project's IP data capture setting.

**Exceptions follow diagnostics transmission.** `isDiagnosticsTransmissionEnabled()` is the existing
"Sending diagnostics automatically" check: `diagnostics_consent` is on unless it is the string
`false`, and `diagnostics_transmission_enabled` must be the string `true`. A scrubbed exception is
sent when that check is on, at every sharing level. When the check is off, the
exception stays on the machine. The client itself is disabled only at `off` with diagnostics
transmission also off. `sendAnonymousDiagnosticReport` is unchanged and still requires
both checks.

| Capture point                             | Source                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    |
| ----------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `uncaughtException`, `unhandledRejection` | Main process handlers in `main/index.ts`                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  |
| `renderer-autocapture`                    | Unhandled errors and rejections in the main window (bundled `posthog-js` extension, always loaded; the scrubber drops them while diagnostics is off)                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      |
| `react-error-boundary`                    | `reportRendererError`, called by the dashboard and global error boundaries                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                |
| `api-5xx`                                 | A local API response with status 500 or higher: method, route pattern (mount prefix included, also for errors from the global handler, or `<unmatched>` when no route matched), and status. The error a route caught, or the global handler saw, gives its type and its stack frames, and its chained cause gives its type and frames too; the message is always the route pattern, never the error's own text, which can name a customer. A 5xx sent without one keeps the frames of the code that sent it. Requests under `/api/settings/cloud` are never reported, because they fail for every store without a cloud subscription. A 502 or 503 from `/api/products/fetch-url` or `/api/mobile/devices` is not reported either, because those routes answer that way while offline. One exception per method, route, and status is sent per process per hour |

An exception keeps its type, a redacted first line of the message, the mechanism, and stack frames
with a project-relative file (`_next/static/...` for the packaged renderer), line, column, function
name, and chunk id. The mechanism keeps `handled`, `synthetic`, `type`, the cause indexes
`exception_id` and `parent_id`, and a short `source`. Message redaction replaces emails, URLs,
quoted text, and digit runs. A chained `cause` is kept, scrubbed the same way, and a rejection
reason that is a plain string becomes the message. Renderer exceptions also carry `$pathname` and
`$current_url` as a route pattern: no host, no query, and an all-digit segment is `:id`. An error
captured through `capturePostHogBoundaryError` whose failure came from axios also carries
`http_method`, `http_route`, and `http_status`, because its frames are all inside
`node_modules/axios`; `http_route` keeps only the first path segment followed by `/*`, because a
call can put a customer name, phone number, or search term in the next one. Errors and rejections
that `posthog-js` autocaptures are sent without those three fields; in the renderer only that
boundary path adds them. Request
params, query, body, digest, log tail, and any order or customer field are dropped. Release builds
upload source maps when PostHog credentials are configured; see
[PostHog release builds](../maintainers/releases.md#posthog-release-builds).

**Offline behaviour.** Sends are best-effort. A failed send is dropped; there is no PostHog outbox,
and a send does not sit on the path between an order and a paid bill. Shutdown waits at most
`POSTHOG_SHUTDOWN_TIMEOUT_MS` (2 s) for pending events, so an unreachable host cannot hold up the
database close.

## Store-attributed diagnostics

Diagnostics have a local support view and a separate automatic aggregate channel. Capture does not
depend on either transmission setting. The local `local_diagnostics` log is capped at 200 rows. Its
access controls, legacy event intake, and clearing behavior are documented in the
[API reference](../reference/api.md#diagnostics).

Automatic reports come from the closed capture registry in
[`main/lib/operation-failure.ts`](../../main/lib/operation-failure.ts). A report contains the
registered reason, operation, stage, classification, outcome, count, bounded time window, optional
UUID correlation references, and allowlisted facts. It contains no exception text, SQL, request path,
customer or order identifiers, payment amounts, or transaction references. Frontend, Electron, and
backend capture call sites use the registry; it owns the exact list and normalized tuples. Legacy
source-derived signatures remain local support evidence and are never sent through the v2 automatic
channel.

Menu CSV imports capture malformed input and row rejections with only bounded failed and changed-row
counts; the outcome is `partial` only when the import actually changed at least one row. Routine
login failures, authentication middleware rejections, and ordinary empty query results are not
capture points.

Reports are aggregated by UTC hour, sealed with immutable report IDs, and stored in
`store_diagnostics_outbox` before sending. The outbox holds at most 200 rows, and each flush sends at
most 20 reports. If reports are evicted, permanently rejected, or cannot be acknowledged, bounded
delivery-gap counters remain visible locally and are retried as safe aggregate reports when space is
available. Retries honor server `Retry-After` hints up to one hour. A report with an invalid or
mismatched acknowledgment is retained as a visible permanent rejection.

**Transmission gates.** Automatic delivery requires both `diagnostics_consent` and
`diagnostics_transmission_enabled`. The one-time upgrade migration enables transmission for
unconfigured legacy installs that already have users while preserving an explicit refusal; a fresh
install keeps transmission off until the merchant opts in through Settings. A merchant can change the
choice at any time through Settings. Consent changes synchronize with FloAdmin
using an idempotent change ID, and remote opt-out takes precedence over startup initialization. Cloud
deletion blocks all diagnostics delivery. With a configured signed cloud connection reports go to
FloAdmin. Without one, they use the anonymous collector only when `telemetry_enabled` is also on;
otherwise they remain local. Consent and transmission are separate from anonymous usage telemetry.

The health endpoint's response contract is documented in the
[API reference](../reference/api.md#diagnostics). Support tickets remain a separate user-initiated
channel and their outbox is unaffected by diagnostics preferences.

## WhatsApp receipt delivery

Sends receipts, owner reports, and automated customer follow-ups over WhatsApp ([`main/services/whatsapp.ts`](../../main/services/whatsapp.ts),
[`shared/whatsapp-automation.ts`](../../shared/whatsapp-automation.ts)).

**Enabled by** the merchant through the WhatsApp settings route, which calls `enable()`. The module
starts with `state.enabled` false, so nothing is connected until a merchant turns it on.

Receipt outcome is summarised on the order as `whatsapp_receipt_status`, which `GET /api/orders`
returns per order.

**Templates and automated rules.** `whatsapp_automation_config` in `settings` stores customizable
templates (`receipt`, `feedback`, `loyalty_milestone`, `visit_milestone`, `winback`), six trigger
rules, and safety settings. `renderWhatsAppTemplate()` performs single-pass `{...}` placeholder
substitution across customer, order, loyalty, and store fields (formatting monetary amounts and
rounding loyalty points to the tenant currency's `currencyFractionDigits`) so substituted values are
never re-parsed as tokens. All six automated rules ship disabled (`enabled: false`):

- When a bill transitions to `paid` (through `POST /api/bills/:id/payment`,
  `POST /api/bills/:id/payments`, or committed exact-balance auto-settlement via
  `completeExactBalanceSettlement()`), `triggerBillPaidAutomation()` runs asynchronously in the
  background to evaluate `after_payment`, `points_threshold`, and `nth_visit` (`nth_visit` is
  deduplicated per order and customer across concurrent and sequential split-bill settlements).
- While WhatsApp is enabled and connected, a 5-minute background timer (`runScheduledAutomationSweep()`,
  also callable on demand via `POST /api/whatsapp/automation/run-sweep`) evaluates `feedback_delay`
  (bills settled at least `delayHours` ago and within 7 days of becoming due) and `winback` (active
  customers whose last paid visit was at least `inactiveDays` ago).
- When a store-level day close commits (`POST /api/cash-closures` with `scope = 'day'`; never on a
  per-staff shift close), `claimDayCloseZReportAttempt()` claims the `queued` row inside a SQLite
  savepoint in the close transaction and `triggerDayCloseZReport()` dispatches it after commit,
  formatting the stored day-close Z-report snapshot via `formatStoredZReportWhatsAppMessage()` (using
  the store `language`, regional formatting, and `timezone`, bounded to 4096 characters while preserving
  financial and drawer-reconciliation totals) rather than rendering a customer template. Configuring
  `z_report_day_close` requires role `owner` with `reports.view` and an explicit E.164 `recipientPhone`
  (never inferred from `business_phone`). Each day close claims at most one row in `whatsapp_messages`
  keyed by `cash_closure_id` (`idx_whatsapp_messages_auto_z_closure`) so repeated close requests or
  restarts cannot send duplicates, and claim/send errors are isolated so closing never rolls back. When
  WhatsApp is disabled or explicit Offline mode is on, automatic day-close dispatch is suppressed
  without queuing a row or triggering a delayed send on reconnection. When WhatsApp is disconnected or
  delivery fails, the single row is recorded as `failed` without automatic retry or opening a manual
  share window, leaving manual retry available through `POST /api/whatsapp/messages/:messageId/retry`.
- `POST /api/whatsapp/automation/broadcast` sends a selected template to up to 50 active customers
  matching visit, inactivity, and loyalty-point filters.
- Automated sends record `created_by_user_id` as `rule:<trigger>` (or `rule:broadcast`), which
  `GET /api/whatsapp/messages` exposes as `trigger_rule`. Report message kinds require
  `reports.view` for `z_report` and both `reports.view` and `reports.daily-sales.export` for
  `daily_sales_report` across history visibility, sending, and retry.

**Safety and anti-ban controls.** Inbound non-group DMs matching opt-out keywords (`STOP`,
`UNSUBSCRIBE`, `OPT OUT`, `OPTOUT`) automatically add the sender to `whatsapp_blocklist` when
`autoOptOutEnabled` is on. Customer-campaign automated sends check store-timezone quiet hours
(`quietHoursEnabled`, `quietHoursStart` to `quietHoursEnd`) and a per-customer 7-day automated message
cap (`maxPerCustomerPerWeek`), returning `quiet_hours` or `weekly_cap` when blocked; owner report sends
(`z_report` and `daily_sales_report`, manual or `z_report_day_close`) bypass quiet hours and weekly
customer caps and do not consume customer caps. Outbound sends to the
same recipient are serialized through `sendLocks` (`sendMessageWithLock()`), keeping the lock tail
chained onto the active send even when a waiting request cancels or aborts. All outbound sends enforce
disabled WhatsApp (`whatsapp_enabled`), Offline mode, `MAX_BODY_CHARS` (4096 characters on report
kinds `z_report` and `daily_sales_report`), transport
cooldown (`isInCooldown()`), the blocklist, a per-recipient hourly cap (`RATE_LIMIT_MAX_PER_HOUR`,
plus `RATE_LIMIT_MIN_GAP_MS` spacing on manual and report sends), a 10-minute duplicate-body filter,
simulated typing delay, and a content filter that rejects non-HTTPS URLs and URL shorteners.

**ACK timeout, late ACKs, and retry.** Outbound messages remain in `typing` (refreshing `typing_at` when
`socket.sendMessage` completes) until a Baileys `messages.update` server ACK (`status = 2/3/4`) arrives,
and every submitted attempt's external ID is persisted in `whatsapp_message_attempts`
(`recordMessageAttemptExternalId()`). `sweepAckTimeouts()` runs every 60 seconds
(`ACK_SWEEP_INTERVAL_MS`) while connected, on socket open, during scheduled automation sweeps, and on
startup (`initFromDb()`) to mark non-in-flight outbound rows still in `queued`, `seen`, or `typing` past
`ACK_TIMEOUT_MS` (5 minutes, or any unfinished pre-restart or unsubmitted rows on startup) as `failed`
with `error = 'ack_timeout'`. If a late ACK arrives after a row was marked `failed`, `advanceStatus()`
resolves the row by `external_message_id` or `whatsapp_message_attempts` (across retries and service
restarts), promotes it to `sent`, `delivered`, or `read`, clears `error` and `failed_at`, backfills
earlier timestamps via `COALESCE`, and ignores out-of-order lower-rank updates.
`POST /api/whatsapp/messages/:messageId/retry` (`retryMessage()`) coalesces concurrent retry requests and
atomically re-queues a `failed` row in place without creating duplicate rows, enforcing Offline mode
(`offline_mode`), `RATE_LIMIT_MIN_GAP_MS`, and all safety controls while excluding the retried row from
self-blocking in the 1-hour message count (`RATE_LIMIT_MAX_PER_HOUR`), weekly-cap, and duplicate-body
checks.

**Offline behaviour.** No work is started when the module is disabled, when Offline mode is on, when
it is in a terminal shutdown state, or when the request signal is aborted. Work already in flight is
cancelled when Offline mode turns on or during shutdown rather than left to fail late.

**Credential handling and session lifecycle.** Session files live under
`path.join(app.getPath('userData'), 'whatsapp-auth')`, created with mode `0o700`. On macOS DMG and ZIP
builds this resolves to `~/Library/Application Support/Flo Cafe/whatsapp-auth`; on Mac App Store
sandbox builds (`com.flo.desktop`) it resolves inside the app container at
`~/Library/Containers/com.flo.desktop/Data/Library/Application Support/Flo Cafe/whatsapp-auth`. The
session credential is not encrypted at rest, which is a consequence of the protocol library FloCafe
uses and a reason the containing filesystem matters:

- **Pairing and restart sequence (`515`).** Credential writes queued from an active socket's
  `creds.update` are serialized through `credentialWriteTail` (surviving a same-tick `515` socket
  replacement or shutdown unless disabled or invalidated by an auth wipe), and `startSocket()` awaits
  both `authCleanupPromise` and `credentialWriteTail` before calling `useMultiFileAuthState()`. When
  WhatsApp completes pairing with status `515` (`restartRequired`), the service reconnects immediately
  without surfacing a disconnect error banner.
- **Unpaired timeout (`408`), pairing-code fallback, and non-destructive cancel.** If an unpaired QR
  or pairing-code attempt closes with `408` (`timedOut`), the service returns to `disconnected` so
  the merchant can request a fresh code on demand. `connectWithPairingCode()` validates socket
  identity and cancellation before publishing a pairing code and restores QR mode if code generation
  fails while the socket remains open. Cancelling an in-progress pairing attempt via
  `cancelPairing()` (`POST /api/whatsapp/cancel-pairing`) closes the pairing socket without calling
  `logout()` or wiping `whatsapp-auth/`.
- **Transient disconnect and sleep/wake recovery.** When a paired session drops for a non-`401`
  reason, the service enters `connecting` with `lastErrorReason: 'reconnecting'` and retries after
  `RECONNECT_DELAY_MS`. On system resume (`powerMonitor.on('resume')` -> `handleSystemResume()`), the
  service reconnects immediately if the socket is absent or stale, and runs a bounded presence probe
  on an open socket to replace half-open connections left behind by macOS sleep.
- **Remote device unlink (`401`).** When WhatsApp closes the session with `401` (`loggedOut`), the
  service clears timers, sets `state: 'disconnected'` and `lastErrorReason: 'logged_out'`, clears
  `whatsapp_connected_phone`, and wipes `whatsapp-auth/` via `scheduleAuthWipe()` so the next
  connection attempt starts a clean pairing flow.

## FloAdmin cloud sync

Optional synchronisation with the FloAdmin back office, plus account deletion and recovery.

**Enabled by** `cloud_sync_enabled`, which ships **on**: a fresh install writes it as `'1'`. Other
flags in the same group are `cloud_orders_enabled`, `cloud_reports_enabled`, and
`cloud_command_polling_enabled`.

**Upgrade behaviour.** Migration 40, `v2_cloud_defaults_and_tax_toggle`, flips a store that had
`cloud_sync_enabled` set to `'0'` back on, but only when the setting's `updated_at` still lacks a
`T`. Seed-written SQLite timestamps have no `T`; an ISO timestamp means a merchant deliberately
changed it. The discriminator is that timestamp shape, and the migration deletes the obsolete
`cloud_pending_store_id` setting in the same step.

**Registration is zero-touch.** `start()` calls `maybeAutoRegister()` once at boot. The code states
the contract directly: the v2 endpoint creates or finds the live store and returns a working API
key immediately, so **there is no claim step, no pending state, and no human approval**. The
`cloud_registration_status` values in use are `unregistered`, `registered`, `registration_failed`,
`deletion_pending`, and `deleted`.

**One stale claim message survives.** `mobilePairingErrorMessage()` in
[`main/routes/index.ts`](../../main/routes/index.ts) still tells a merchant that the POS "hasn't
been claimed in FloAdmin yet" and to complete registration there, returned with HTTP 409 when the
backend reports the store is not registered. That message contradicts the zero-touch behaviour
above: the merchant has nothing to claim. The message is reachable from the mobile pairing
endpoint, so this is shipped behaviour, not dead text. Treat it as a product-copy bug to raise
separately, not as a description of how registration works.

**Payment outcomes stay local-authoritative.** Payment routes capture typed diagnostics only after
the operation's outcome is known. A rollback is recorded as not applied; a notification failure
after commit is recorded as applied. Captures are queued asynchronously, and delivery does not
participate in the bill transaction or change its financial result.

**Offline behaviour.** `reload()` starts the signed sync and support-ticket flushers when cloud sync
is configured. The diagnostics flusher runs when consent and transmission are enabled and either a
signed connection or permitted anonymous telemetry is available. It uses its own timer and does not
require an API key for the anonymous path. A failure in one flusher does not stop the others.

**Deletion and recovery.** A deletion request puts the store into `deletion_pending` or `deleted`.
`isCloudDeletionBlocking()` reports whether the current status blocks normal cloud work, and while
it does, `start()` returns without starting and queueing refuses with `queued: false`. Deletion
clears `cloud_sync_outbox`, `support_ticket_outbox`, and `store_diagnostics_outbox` together, so a
store that has asked for deletion is not holding queued payload for it.

## RevFlo pairing

RevFlo is the mobile companion app, and pairing happens through a pairing code the store issues and
the phone redeems.

`getCachedPairingCode()` and `setCachedPairingCode()` in
[`main/db.ts`](../../main/db.ts) keep the code locally under the `mobile_pairing_code` and
`mobile_pairing_code_expires_at` settings, with an expiry. The code is stored in plaintext: the
cloud returns it exactly once, so the local copy is the only one, and the code is short-lived and
useless without the store's API key. Both settings are cleared as part of the cloud data deletion
path.

Issuing a pairing code is the endpoint that returns the stale claim message described above, and
its failure is reported as HTTP 409 for an unregistered store and HTTP 502 for any other cloud
failure, so a connectivity problem is distinguishable from a registration problem.

## Support ticket outbox

A support request is written to the `support_ticket_outbox` table with a `status` of `pending`,
`sending`, `delivered`, or `failed`, plus `attempt_count`, `next_attempt_at`, and `last_error`. The
endpoint returns HTTP 202 when the ticket was queued and HTTP 503 when it could not be, so the
merchant gets a distinct answer for "saved, will send later" and "not accepted".

### The `log_tail` field

A ticket may carry a `log_tail` string holding the tail of the current session's log file.

- **Cap.** `LOG_TAIL_MAX_BYTES` is 200,000. The tail is cut from the end, keeping the most recent
  bytes, in both the IPC handler and the route. In the POS one-click help flow, the client bounds
  the attached log tail to 16,384 UTF-16 code units (`SUPPORT_LOG_TAIL_MAX_CHARS`) when opted in.
- **Window.** `LOG_TAIL_MAX_AGE_MS` is 7 days. The cut is made at the first line whose timestamp
  falls inside the window, not at a byte offset, so a quiet store's log is not cut mid-record. The
  byte cap is the backstop when the windowed content is still large.
- **Absent, not null.** A ticket with no log tail omits the field rather than sending an empty
  string. `logTail` is `undefined` when the request body has no non-blank `log_tail`.
- **Where it is persisted.** In the outbox row's `payload`, alongside the ticket's other fields,
  and sent by `queueSupportTicket()` as the `log_tail` property.
- **Redaction.** Both the `get-log-tail` IPC handler and `POST /api/support-ticket` pass the tail
  through `sanitizeLogTail()` in [`main/lib/log-sanitizer.ts`](../../main/lib/log-sanitizer.ts) to
  redact bearer JWTs, `Authorization` headers, PIN/password/API-key/secret fields, and OAuth tokens
  while preserving stack traces, timestamps, and error codes.

### Linking a ticket to a known operation failure

When a cashier opens help from a structured failure, `POST /api/support-ticket` may include a
top-level `operation_failure` object:

```json
{
  "correlation_id": "550e8400-e29b-41d4-a716-446655440000",
  "client_ticket_id": "a stable UUID for this ticket submission",
  "operation_failure": {
    "capture_point": "fc.backend.payment.create.validation",
    "operation_id": "550e8400-e29b-41d4-a716-446655440000",
    "outcome": "not_applied",
    "facts": { "display_code": "customer_mismatch" }
  }
}
```

`operation_id` must be a UUID and must exactly match the top-level `correlation_id`. The server
validates the capture point, outcome, and optional facts against its closed operation-failure
registry, then stores the normalized tuple under `diagnostics.operation_failure`. The normalized
tuple contains the server-owned reason, operation, stage, classification, outcome, correlation ID,
and validated facts; callers cannot supply or override those fields. Invalid or unregistered
context is rejected without queuing a ticket. `client_ticket_id` remains the independent replay key,
so retrying the same ticket does not replace its original correlation or operation context.

For hardware printer dispatch failures, `operation_failure` captures
(`fc.backend.printer.{receipt,kot}.{queue,timeout}`) carry validated `kind`, `connection_type`, and
`failure_class` facts only when returned with a trusted backend correlation ID and approved capture
stage. Client-side printer errors without backend dispatch failure context omit `operation_failure`
while preserving sanitized diagnostics in the ticket body. Expected validation rejections (such as
insufficient stock, closed cash days, or required cash sessions) surface actionable guidance in the
POS and are classified as operational guidance rather than defects.

Tickets without `operation_failure` retain the existing manual-help contract. The optional
structured context does not add raw automatic logs or change the permission required to submit a
support ticket.

## Error taxonomy and correlation

[`main/errors.ts`](../../main/errors.ts) gives every network operation a `FloErrorCode` and a
`correlationId`.

- `FloErrorCode` is a template-literal union namespaced by subsystem: `print.`, `tax.`,
  `migration.`, `backup.`, `cloud.`, and `update.`. A new subsystem adds a namespace rather than a
  new ad hoc string.
- `correlationId()` returns a `randomUUID()`.
- `correlatedError(code, message, cause?)` builds an error named `FloOperationError` carrying the
  code, the correlation id, and optionally the cause.
- `errorDetails(error, fallbackCode)` reads a code and correlation id off any thrown value, falling
  back to a supplied code and a fresh correlation id, and always returns a `message`.

For support-ticket correlation, see
[Linking a ticket to a known operation failure](#linking-a-ticket-to-a-known-operation-failure).

## Verification

```sh
npm run test:offline-mode
npm run test:google-drive
npm run test:telemetry
npm run test:support-ticket
npm run test:cloud-account-status
npm run test:cloud-live-orders-batching
npm run test:recovery-cloud   # includes tests/cloud-deletion-recovery.test.ts
```