by Intelliverse X

Machine Protection Plan — coverage visibility, conversion nudges & the right-moment offer

How the platform surfaces which machines carry the Machine Protection Plan ($20/mo per-machine rider, an equipment service contract — NOT insurance; see docs/legal-contracts.md), how operators get nudged to enroll, and how the plan is offered at the delivery / installation moment via the canonical machine lifecycle.

Everything here is derived from the existing subscription/rider source of truth (app/store.py subscriptions + their per-machine insurance slots). There is no parallel enrollment path: every enroll CTA — nudge popup, Plans tab, AND the lifecycle-triggered offer — lands on the same rail (ESIGN-consent-gated → Documenso template 37 → signature → $20/mo line).

Coverage states (app/protection.py)

state meaning covered
active signed plan, covering subscription live (or cancelled but inside its paid-through period)
grace covering subscription cancelled, ≤15 days past paid-through (plan §Termination grace)
suspended 15–30 days past paid-through — coverage suspended
lapsed >30 days past paid-through — plan lapsed
pending_signature agreement sent, awaiting eSignature (not billing yet)
none no plan (never enrolled / rider cancelled / signature abandoned)

Honesty note on the lapse ladder: the sandbox only learns subscription lifecycle transitions (Active / Cancelled + the frozen paid-through date) from Stripe/RevenueCat webhooks. Mid-cycle payment failures (past_due) are NOT visible locally, so grace/suspended/lapsed are modeled solely off a cancelled subscription's paid-through date. A machine on a live, paying subscription with a signed plan is always active.

When one machine appears on several subscriptions (an old cancelled plan next to the live one), the best state wins (active > grace > pending_signature > suspended > lapsed > none).

enrollable: true marks an uncovered machine that sits on a live base subscription — the one-tap target. Machines with no base plan at all are surfaced as unprotected but can't take the rider until a plan starts.

Generated state graph (offer flow + coverage ladder)

Generated from the live transition tables by app/graph_spec.py — do not edit; run python scripts/gen_state_machine_docs.py to regenerate (CI enforces freshness).

stateDiagram-v2 direction LR [*] --> none none : No plan offered : Offered (right-moment ask) declined : Declined pending_signature : Pending eSignature active : Active ✓covered grace : Grace (≤15d past due) ✓covered suspended : Suspended (15–30d) lapsed : Lapsed (>30d) none --> offered : lifecycle trigger crossed (concierge@delivered, delegated@delivered, dfy@delivered, existing@live, self_install@live) offered --> pending_signature : accepted — agreement sent for eSignature offered --> declined : declined (always allowed) declined --> offered : re-offer after 30d cooldown (nudge policy owns the interim) pending_signature --> active : Documenso completion webhook — rider billing pending_signature --> none : signature abandoned on a dead subscription active --> grace : paid-through passed (≤15d) grace --> active : payment cures within the grace window grace --> suspended : >15d past due suspended --> lapsed : >30d past due suspended --> pending_signature : re-enrollment cures (new agreement) lapsed --> pending_signature : re-enrollment (new agreement)

Served live at GET /api/v1/meta/state-machines/protection_plan.

API surfaces

Nudge policy (server-driven, per operator, fleet-sync tier)

Persisted in STORE.protection_nudges (FLEET tier — both replicas see the same snooze state; reset-proof — a /sandbox/reset never re-arms a dismissed popup). Constants in app/protection.py, tested in tests/test_protection_plan_coverage.py:

Admin principals get the platform-wide summary with the nudge disabled.

The right-moment offer (app/protection_offer.py)

The nudges convert an existing uncovered fleet; the lifecycle-triggered offer puts the plan in front of the operator exactly when coverage starts mattering. One offer record per machine tenure (FLEET tier, reset-proof, store dfy_protection_offers), opened by lifecycle.transition when the machine crosses its installation flavor's trigger stage:

Flavor Trigger Why there
dfy / concierge / delegated delivered managed placements put the machine on a third-party site (or in an installer's hands) the moment freight lands — the damage/theft exposure the plan covers starts then, before install completes, and delivery confirmation is the operator's first custody touchpoint on the DFY timeline / work-order surface
self_install / existing live (onboarding completion) existing machines never had a delivery on our books (they enter at onboarding); a self-installer's delivered is usually skip-filled from the manufacturer sync with nobody looking at a screen — onboarding completion is the guaranteed in-app guided moment, and it's when the base subscription the rider attaches to typically starts

A skip-fill jump that passes the trigger stage (e.g. ordered → live) still fires. Offer states: offered → accepted → signed or offered → declined; every step lands on the machine's lifecycle event trail (protection.offered / .accepted / .declined / .signed).

No double-offer, in guard order: (1) machines active/grace/ pending_signature never re-trigger; (2) an open/accepted/signed offer is an idempotent no-op; (3) a decline younger than 30 days (DECLINE_COOLDOWN_DAYS) suppresses re-offers — the nudge policy owns conversion from there; (4) only forward pre-live pipeline moves trigger at all, so maintenance → live and relocating → live (relocation → reinstall) never re-ask.

Offer API

Tests: tests/test_protection_offer.py (trigger per flavor, skip-jump, consent gate, base-plan attach/create, decline + nudge fallback, no-double-offer incl. relocation loops, webhook + polling completion, restart survival, tenant isolation).

Documenso completion webhook (ops: one-time registration)

Signature completion reaches the backend two ways: a push webhook (POST /api/v1/subscriptions/webhooks/esign, verified via the X-Documenso-Secret header, idempotent per document/event id) and the polling fallback (any read of the machine's plan while pending_signature polls Documenso, so the state is never staler than one page refresh). Both flip the rider through the same _activate_signed_insurance path — the webhook writes event id esign_<documentId>, the poll writes poll_<documentId>.

The webhook must be registered in the Documenso dashboard — this deployment's v2 REST API exposes no webhook endpoints, and the tRPC webhook.* procedures only accept a signed-in dashboard session (an API token returns 401). Registration (team owner of the Kiosk-X templates, at https://contracts.intelli-verse-x.ai):

  1. Team settings → Webhooks → Create webhook.
  2. URL: https://api.kiosk-x.ai/api/v1/subscriptions/webhooks/esign
  3. Triggers: document.completed (the handler also accepts document.signed; other events are acknowledged and ignored).
  4. Secret: the value of KIOSKX_DOCUMENSO_WEBHOOK_SECRET from the kiosk-x-secrets k8s secret (aicart namespace) — Documenso sends it back in the X-Documenso-Secret header.
  5. Enabled: yes.

Verify: complete any protection-plan signature and check the kiosk-x pod logs for POST /api/v1/subscriptions/webhooks/esign … 200; the plan slot's eventIds should show esign_<docId> (push) rather than poll_<docId> (fallback). Until registration happens, the polling fallback carries activation on its own.

Client surfaces