Roadmap — Path to Launch
This document is for the team. It sequences the work between now (week of 2026-06-02) and the public launch on 2026-07-06, and flags the items that still need a decision rather than just execution.
Read alongside:
docs/in-progress.md— current status of Stripe, WEX, emaildocs/shiphero-integration.md— what ShipHero owns vs. what we ownbusiness-decisions.md— open product questionsHEALTHCAREINSURANCE.md— SSO contract with the partner team
Go-live target: 2026-07-06
The platform is feature-complete for the launch scope: catalog, cart, member flows, subscriptions, ShipHero fulfillment, and Healthcare Insurance SSO. What is not done is the money path (Stripe end-to-end, WEX partnership), real outbound email, and the "last-mile" admin coordination work that has to land before we put a real card or a real benefits account in front of a real member. The four weeks between now and July 6 are about wiring those last pieces, doing UAT against the staging stack, and cutting over to the production server with a tight rollback plan.
Working backward from July 6 (illustrative)
2026-06-02 ┃ Mon ──────────────────────────────────────────────── TODAY
WEEK 1 ┃ [ ] Admin testing pass on stage-api
┃ [ ] Inventory controls v1 (thresholds + delisting)
┃ [ ] Pin WEX status — go / no-go for launch scope
┃ [ ] Production mail driver decision (Postmark vs. SES vs. Resend)
┃
2026-06-09 ┃ Mon
WEEK 2 ┃ [ ] Email notifications wired to production driver
┃ [ ] MAIL_FROM_ADDRESS, MAIL_FROM_NAME set
┃ [ ] Welcome email built (currently does not exist)
┃ [ ] Shipment-confirmation email tied to ShipHero webhook
┃ [ ] Horizon / queue worker confirmed in deploy
┃
2026-06-16 ┃ Mon
WEEK 3 ┃ [ ] Stripe production keys provisioned
┃ [ ] Checkout HTTP action + Stripe Elements wired
┃ [ ] payment_intent.succeeded / charge.refunded webhook handler
┃ [ ] Final UAT with real test cards on stage
┃ [ ] Reconciliation report draft delivered to card admins
┃
2026-06-23 ┃ Mon
WEEK 4 ┃ [ ] Card-admin final integration (BIN ranges, settlement URL)
┃ [ ] WEX live OR officially deferred (see "open decisions")
┃ [ ] Server migration dry-run on a staging clone
┃ [ ] Runbook + rollback plan signed off
┃
2026-06-30 ┃ Mon — cutover week
WEEK 5 ┃ [ ] DNS TTL lowered to 300s (Mon)
┃ [ ] Cutover window scheduled (Wed/Thu off-peak)
┃ [ ] Smoke tests: SSO, browse, cart, checkout, ShipHero order push
┃ [ ] On-call rotation in place
┃
2026-07-06 ┃ Mon — GO-LIVE
┃ [ ] Announce to Healthcare Insurance agents
┃ [ ] Watch dashboards for 48hDates are illustrative — slip any item right if a dependency is not resolved by the start of its week. If WEX is not signed by 2026-06-23, the team needs to make an explicit call to launch with purse-only / card-only flows and add WEX post-launch.
Server migration plan
The team flagged that we want to move off the current host before launch. Below is what we know, what is assumed, and the sequence we'd run.
Current state
- API: Laravel Forge-provisioned EC2 instance on AWS. Stage host is
stage-api.easyotc.com(server nameeasyotc-stage). Productionapi.easyotc.comis reserved perdocs/healthinsurance-sso.mdandHEALTHCAREINSURANCE.mdbut the prod box is not yet stood up. - Storefront: Nuxt site, built statically and served from S3 behind CloudFront (separate repo).
- Database: PostgreSQL on the same Forge box (single-node, no read replica).
- Cache / queue: Redis on the same box (also acts as
dump.rdbsource). - Background work: Laravel scheduler + queue worker (Horizon if installed — needs confirmation in the deploy).
Reasons it might move
To be filled in based on the team's rationale — likely one of:
- [ ] Cost — moving off EC2 to a flat-rate provider (Hetzner, DigitalOcean, a managed Laravel host)
- [ ] Control — current Forge config is owned by one person; we want a host the whole team can administer
- [ ] Reliability — single-node DB on the app box is a known SPOF
- [ ] Compliance — if WEX or carrier contracts impose data-residency or audit requirements
TBD — the team to confirm which of these is the actual driver before we pick a destination.
Migration sequence
- Provision the new host. Same Ubuntu LTS, PHP 8.3, PostgreSQL 16, Redis 7. Identical extensions. Mirror Forge's site config (Nginx vhost, PHP-FPM pool, deploy script, environment file). Lock down SSH to known IPs.
- Copy deploy keys + GitHub deploy access so CI/CD points at the new host. Do not delete the old keys yet.
- Sync
.envto the new box (with the production Stripe, ShipHero, Healthcare Insurance secrets). Treat this as the production.env— it should differ from stage on at least:APP_ENV=productionAPP_URL=https://api.easyotc.com- Production Stripe + ShipHero credentials
- Production mail driver (see "in-progress")
- Migrate the database. Two options, pick one:
pg_dump+ restore — simpler, requires a maintenance window (~5–15 min for our current data volume). Run this twice: once rehearsal, once cutover.- Streaming replication — set up the new box as a hot standby, promote at cutover. Zero data loss, but more moving parts. Only justifiable if we expect significant traffic at cutover, which we don't. Recommendation:
pg_dump+ restore during a planned window.
- Replay
dump.rdbto the new Redis (or just warm a cold Redis — nothing in Redis is durable state). - Run migrations + verify on the new host with traffic still on the old one. Hit
/api/health(if it exists; otherwise add one) and runphp artisan health:checkfrom the existing console schedule. - Lower DNS TTL to 300s 24h ahead of the cutover.
- Cutover — final
pg_dump, restore, flip DNS forapi.easyotc.com, smoke test the SSO callback, place a test order through the storefront. - Keep the old box warm for 24–48h with read-only DB access in case we need to inspect or roll back.
Downtime expectations
- Cutover window: 5–15 minutes, dominated by the final
pg_dump+ restore and Stripe / ShipHero webhook URL changes. - DNS propagation: assume up to 5 minutes with a 300s TTL.
- Stripe webhook switchover: must be updated in the Stripe dashboard at the moment of cutover — schedule someone to do this live.
- ShipHero webhook: same —
/api/webhooks/shiphero/shipment-updateURL needs to be re-pointed at production.
Rollback
- Revert DNS to the old box's IP (TTL is already 300s).
- The old DB will be behind by however many writes hit production after cutover. Replay those from the new box's WAL or accept the gap — depends on how far past cutover we are.
- Re-point Stripe and ShipHero webhooks back at the old URL.
Pre-launch rehearsal: do the full sequence on a staging clone in week of 2026-06-23 so the cutover week is muscle memory, not a first attempt.
Inventory controls (explicitly requested)
Current state
- ShipHero is the source of truth for inventory.
inventory_countonproductsis updated byphp artisan shiphero:sync-products. - The sync is manual.
routes/console.phpscheduleshealth:check,subscriptions:process-due, andsubscriptions:send-reminders, but notshiphero:sync-products. Someone has to run it from the CLI. Cadence today: zero. This must be fixed before launch. - Order-time decrements happen in
OrderService::deductInventory()and mirror to ShipHero viaShipHeroService::removeInventory(). If the ShipHero call fails, our local count drifts (logged, not retried). - Filament admin has a "Low Stock (≤10)" filter on the products table (
app/Filament/Resources/Products/Tables/ProductTable.php:96) — visual only, no notifications. SubscriptionOrderServicealready flipsis_available = falsewhen a subscription order can't be filled (SubscriptionOrderService.php:131) — that pattern needs to be generalized.- ShipHero exposes
reorder_levelandreorder_amountperwarehouse_productin its GraphQL schema (referenced inShipHeroService.php:248–249, 331–332) but we don't read or store either field today.
What's missing — v1 proposals
1. Scheduled inventory sync
- [ ] Add to
routes/console.php:phpSchedule::command('shiphero:sync-products')->hourly(); - [ ] Add a Sentry / log alert if the command exits non-zero.
- Effort: 1 hour. Blocker for everything else in this section.
2. Reorder thresholds per product
- [ ] Add
reorder_threshold(int, nullable) andreorder_quantity(int, nullable) to theproductstable. - [ ] Extend
shiphero:sync-productsto also pullreorder_levelandreorder_amountfrom the warehouse_product payload and write them into those columns (ShipHero already exposes them — see refs above). - [ ] Filament: surface them in
ProductFormso admins can override. - [ ] Default threshold for products without a ShipHero value: 10 (matches the existing "Low Stock" filter).
- Effort: half a day.
3. Low-stock alerts (email + Slack)
- [ ] New scheduled command:
inventory:check-low-stockrunning daily at 07:00 (after the 06:00 subscription processing). - [ ] For every product where
inventory_count <= reorder_thresholdandis_available = true:- email a digest to
config('mail.to.address')(the same address that receives contact inquiries today) - optional: post to Slack via a webhook if
SLACK_INVENTORY_WEBHOOKis set
- email a digest to
- [ ] Idempotency: track last-alerted-at on the product so we don't spam the same SKU every morning while it sits below threshold.
- Effort: 1 day. New Mailable + new command + migration for
last_low_stock_alert_at.
4. Stock-out automatic delisting
- [ ] Extend
SyncProductsFromShipHeroCommand::processProduct()to setis_available = falsewheninventory_count <= 0. Today it already derivesis_availablefrominventory_count > 0(shiphero-integration.md:38), but verify this works end-to-end — including the path where a sale takes the local count to zero between syncs. Order-timedeductInventory()should also flipis_availableif the new count hits zero. - [ ] Decide: when stock comes back, do we auto-relist? Recommendation: yes, but only if it was previously available — track a
auto_delisted_attimestamp so admins can distinguish stock-out delisting from intentional admin delisting. - Effort: half a day. Touches
OrderService,SyncProductsFromShipHeroCommand, and one migration.
5. Admin dashboard widget — inventory health
- [ ] New Filament widget on the admin dashboard:
- Out of stock (count + link to filtered table)
- Below reorder threshold (count + link)
- Auto-delisted in last 7 days (count + link)
- Stale: products not synced from ShipHero in >24h
- [ ] Place it above the existing
TopSellersTablewidget. - Effort: half a day. Pure Filament work.
Risk callouts
- The hourly sync hits ShipHero's GraphQL rate limit budget (4,004 credits, 60/sec restore — see
shiphero-integration.mdfailure modes). At--limit=100per page and ~500 products this is well under the budget. If the catalog grows, switch to delta-based syncing. - The 20–200 random-inventory fallback in
SyncProductsFromShipHeroCommand::calculateInventory()lines 273–278 must be removed before launch — it's a sandbox leftover and will make the "out of stock" math lie if a real SKU returns zero on_hand.
In-progress workstreams — status snapshot
Status as of 2026-06-02. Full detail in docs/in-progress.md.
| Workstream | Status | Owner | Needed by | Blocker |
|---|---|---|---|---|
| Stripe — service layer | Done. PaymentService + PaymentIntent model + webhook handler interfaces exist. | — | — | None |
| Stripe — checkout wiring | Not started. StoreOrderAction does not call PaymentService; no checkout HTTP action exists. | TBD (eng) | 2026-06-16 | Decision on whether to add Laravel Cashier or stay on raw SDK |
| Stripe — webhook handler | Not started. Webhooks/ only contains the ShipHero controller. | TBD (eng) | 2026-06-16 | Stripe production keys |
Stripe — env keys in .env.example | Missing. STRIPE_KEY, STRIPE_SECRET, STRIPE_WEBHOOK_SECRET not declared. | TBD (eng) | 2026-06-09 | None — 5-min fix |
| WEX — partnership | Pending signature. | Team | 2026-06-23 hard deadline | Partnership agreement |
| WEX — API contract | Unknown. We don't know which APIs we'll get. | Team + WEX | 2026-06-23 | Partnership |
| WEX — code | Zero code written. Purse system is the local stand-in. | TBD (eng) | Post-launch unless WEX signs by 06-16 | API contract |
| Email — Mailables | Done for orders + subscriptions. Password reset wired. | — | — | None |
| Email — production driver | Not configured. .env.example has MAIL_MAILER=log. | TBD (ops) | 2026-06-09 | Decision: Postmark vs. SES vs. Resend |
| Email — welcome email | Does not exist. No WelcomeMail, no listener on member registration. | TBD (eng) | 2026-06-09 | None |
| Email — shipment-confirmation | Does not exist. ShipHero webhook arrives but isn't persisted (see shiphero-integration.md). | TBD (eng) | 2026-06-16 | shipments table migration |
Email — MAIL_FROM_ADDRESS | easyotc@example.com in .env.example — must be replaced. | TBD (ops) | 2026-06-09 | Decision on the official sender address |
| Inventory controls | None of the v1 work exists. See section above. | TBD (eng) | 2026-06-09 | None |
| Server migration | Not started. Production box for api.easyotc.com not provisioned. | TBD (ops) | 2026-06-30 | Decision on destination host |
"TBD" means needs assignment — Please name owners.
Card admin coordination
The "last min stuff with card admins" line covers two parties: WEX (benefits cards — HSA / FSA / HRA / purse-funded health benefit cards) and Stripe (everything else — credit, debit, customer-funded). Treat them separately.
What we owe them
Stripe
- [ ] Production webhook URL (
https://api.easyotc.com/api/webhooks/stripe). This route does not exist yet — must be built before we hand it over. - [ ] List of event types we'll subscribe to: at minimum
payment_intent.succeeded,payment_intent.payment_failed,charge.refunded,charge.dispute.created. - [ ] Business details for the Stripe account (already in place — confirm with the team).
- [ ] Statement descriptor — what shows on the cardholder's bill. Needs decision:
EASY OTCvs. carrier-specific. (Currently no carrier separation logic exists — seebusiness-decisions.mddecision #15.)
WEX
- [ ] Settlement endpoint URL on our side (not built yet).
- [ ] Format spec for reconciliation reports — daily CSV/JSON of purse-funded orders with member code, SKU, quantity, amount, timestamp.
- [ ] Member onboarding form: how members link their WEX-issued card to their Easy OTC account. TBD — design depends on the WEX API contract.
- [ ] Eligibility check contract — which SKUs are OTC-eligible (we already classify products; need to map our category taxonomy to WEX's eligible-product list).
What they owe us
Stripe
- [ ] Production publishable + secret keys.
- [ ] Webhook signing secret (
STRIPE_WEBHOOK_SECRET). - [ ] Test card numbers we can use during UAT — Stripe's standard test cards are public, but WEX-branded test BIN ranges also need to be issued for benefits-card flows.
- [ ] Confirmation of any required compliance fields on PaymentIntent metadata (HSA/FSA orders may require itemization on the receipt side — needs verification).
WEX
- [ ] Test card BIN ranges for HSA / FSA / HRA / purse-only cards so we can validate routing rules in
PaymentMethodEnum. - [ ] Production API credentials (none of this exists today — see
in-progress.md"WEX integration"). - [ ] Settlement schedule — daily? weekly? Affects our reconciliation cron timing.
- [ ] Member lookup API contract — given a member_code (from the Healthcare Insurance SSO state payload), how do we resolve to a WEX purse?
- [ ] Eligible-product mapping — confirmation that our category taxonomy maps cleanly to WEX's eligible-OTC catalog, or a list of SKUs they reject so we can suppress them.
Coordination calls to schedule
- [ ] Stripe — 30-min call week of 2026-06-16 to confirm webhook URL, keys, and test cards.
- [ ] WEX — call immediately once the partnership agreement is signed. Until that happens this whole column is blocked.
- [ ] Healthcare Insurance — see
email-draft-brent-plan-data.md. We've asked forplan_typeandplan_hpbpin the SSO state payload. Not blocking for July 6 because we can categorize members manually post-launch, but worth following up on this week.
Post-launch backlog (out of scope for July 6)
These are explicitly not going into the July 6 launch. Listing them here so the team can point to a parked list when scope creep shows up.
- [ ] Repeated email notifications — welcome series, order confirmation follow-ups, shipment tracking updates, delivery delayed / delivered / delivery issue (see
business-decisions.mdQ7), payment-receipt and refund-confirmation emails, order-cancelled and payment-failed customer emails, email verification flow (Userhas the fields but doesn't implementMustVerifyEmail). - [ ] A/B test slots — no experiment framework exists today. Consider Statsig or homegrown feature flags after launch metrics are stable.
- [ ] Reviews & ratings — no model, no UI, no moderation queue.
- [ ] Member referrals — refer-a-friend, referral codes, attribution.
- [ ] Internationalization — i18n is wired (Laravel + Nuxt) but only
enandestranslation files exist. Adding new locales is a content task once we have demand signal. - [ ] One-time skip on subscriptions (
business-decisions.mdQ2). - [ ] Substitutions when a SKU is out of stock (
business-decisions.mdQ9). - [ ] Carrier separation / multi-tenancy (
business-decisions.mdQ15) — the carrier model exists but does nothing. - [ ] Discreet packaging option (
business-decisions.mdQ14). - [ ] Agent/admin weekly digest email (
business-decisions.mdQ13). - [ ] Quantity-change confirmation email (
business-decisions.mdQ3). - [ ] Cadence selection on subscriptions (
business-decisions.mdQ1 / "Subscribe & Save"). - [ ]
shipmentstable + persistence of ShipHero webhook payloads — currently logged but discarded (shiphero-integration.md"Webhooks"). - [ ] Order-cancellation push to ShipHero — no
cancelOrder()method exists; an Easy OTC cancellation aftercreateShipHeroOrder()may still ship. (shiphero-integration.md"Cancellation / refund".)
Open decisions blocking the plan
The launch date is achievable, but the following decisions cannot wait:
- Production mail driver — Postmark, SES, or Resend? Needed by 2026-06-09. (team.)
- Production sender address (
MAIL_FROM_ADDRESS) — what should the "from" line read?hello@easyotc.com?no-reply@easyotc.com? Needed by 2026-06-09. (team.) - WEX go / no-go for launch — if the partnership isn't signed by 2026-06-23, we ship without WEX and add it post-launch. the team needs to make the call by then. (team.)
- Server destination — where are we migrating to and why? Needed by 2026-06-09 to leave two weeks for provisioning + rehearsal. (Team.)
- Statement descriptor on Stripe charges — what appears on the cardholder bill? Needed by 2026-06-16. (team.)
- Whether to add Laravel Cashier or keep using the raw Stripe SDK. Cashier gives us customer + subscription helpers for free but adds a dependency and a migration. The current code is built around raw
PaymentIntentand a customPaymentService. Recommendation: stay on raw SDK for launch; revisit if we want hosted billing portals post-launch. (Eng lead.)