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).
- Covered states: active, grace (grace is contractually still covered).
- Lapse ladder: 15-day grace, suspended to 30 days, lapsed after.
- Coverage states are DERIVED from the subscription/rider source of truth on every read — there is no separate stored state to drift.
Served live at GET /api/v1/meta/state-machines/protection_plan.
API surfaces
GET /api/v1/machinesandGET /api/v1/machines/{no}— every machine now carries aprotectionPlanblock ({state, covered, enrollable, subscriptionId, signedAt, signingUrl}). The list builds the whole map from ONE subscription scan (no N+1).GET /api/v1/fleet/protection— fleet roll-up: counts by state,uncoveredMachineNos,enrollable(machineNo + machineName + subscriptionId to enroll through), plan pricing snapshot, and thenudgedecision below.GET /api/v1/fleet/glance— gainedprotectionUncovered(widget chip).POST /api/v1/fleet/protection/nudge{action: shown|snooze|dismiss}— records popup events.
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:
- Popup only if the operator has ≥1 uncovered, enrollable machine.
- First popup no earlier than 120 s into a session (a session starts on
the first coverage poll after ≥30 min of silence — clients poll
GET /fleet/protectionevery ~60–90 s). - "Remind me later" → snoozed 3 days.
- Hard frequency cap: at most one popup per 3 days (
shownstarts it). - "No thanks" 3× → the popup stops permanently for that operator; the passive surfaces (badges, banner, Plans tab) always remain.
- Fully-covered fleet → never shows; enrolling clears badges and the nudge on the next poll automatically.
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
GET /api/v1/machines/{no}/protection-offer— the inline-step payload: open offer, coverage state, plan pricing, and how enrollment attaches (basePlan.subscriptionId, orbasePlan.required: truewhen accepting must start the $60/mo device plan — always explicit, never implicit).POST .../protection-offer/accept{esignConsent, signerName?, signerTitle?, createBasePlan?}— 428 without consent (same gate as the rider endpoint); sends the machine-specific agreement (serial, model, operator, premium pre-filled) through the existing Documenso rail and returns the signing URL. Coverage showspending_signatureuntil the webhook/poll flips the rider active, attaches the $20/mo line, and closes the offer assigned.POST .../protection-offer/decline{reason?}— always allowed; timestamped on the offer + lifecycle trail; the machine keeps the uninsured badge; the decline counts as ashownpopup so the nudge cooldown starts (no instant re-nudge).GET /api/v1/fleet/protectiongainedopenOffers— open offers the surfaces can route to;GET /api/v1/machines/{no}/lifecycleembedsprotectionOfferand leadsnextActionswith it while the offer is open.
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):
- Team settings → Webhooks → Create webhook.
- URL:
https://api.kiosk-x.ai/api/v1/subscriptions/webhooks/esign - Triggers:
document.completed(the handler also acceptsdocument.signed; other events are acknowledged and ignored). - Secret: the value of
KIOSKX_DOCUMENSO_WEBHOOK_SECRETfrom thekiosk-x-secretsk8s secret (aicart namespace) — Documenso sends it back in theX-Documenso-Secretheader. - 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
- Web console (
kiosk-x-operator): "Not protected" badges on machine rows/cards/detail, a fleet banner ("N of M machines unprotected"), and a glass popup driven by the policy above with per-machine / protect-all enrollment (ESIGN consent checkbox → signing links). The inline offer (components/ProtectionOfferCard.tsx) renders in the machine-detail lifecycle card and on the DFY deal timeline the moment the offer opens: acknowledge terms + ESIGN consent → Approve & e-sign (Documenso link) or No thanks (recorded decline). - Operator X app (
kiosk-x-operator/flutter): coverage badge in Fleet list + machine detail, uncovered chip on Home, and a Liquid Glass bottom sheet with the same CTAs deep-linking into Money → Plans. The inline offer block inscreens/lifecycle.dartrenders the same acknowledge → approve → e-sign / decline step inside the machine lifecycle card at delivery / onboarding completion.