Welcome to the Jose Madrid Salsa developer docs — explore features, APIs, and deployment guides.
Jose Madrid SalsaJMS Docs

Admin Platform Gap Analysis

What the admin console already covers against a full commerce-operations spec, what is missing, and what each gap costs to close

Assessed against apps/storefront on branch feature/admin-operating-system. Every Present/Partial claim below cites a model in prisma/schema.prisma, a route under app/admin / app/api/admin, or a file path.

Estimates are engineer-days for one developer working with Claude, reusing the patterns already in this repo. They assume no new infrastructure. Ranges, not point estimates.

Headline

The admin is far further along than a gap list makes it look. Of the ~17 areas in the spec, 9 are substantially built, 6 are partial, and 2 are genuinely absent (returns/RMA and content management). The console has 70+ route folders and 99 admin API route files with write handlers.

What is missing is not breadth — it is three structural things that every remaining feature depends on:

  1. Fulfillment has no state of its own. Order and payment states are separated; fulfillment is not.
  2. There is no domain event bus. Every reaction to a business action is hand-wired at the call site.
  3. Sales channel is encoded across four uncoordinated fields.

Fix those three first (~11–17 days) and the rest of the backlog gets meaningfully cheaper, because most of it is a consumer of one of them.

Structural findings (fix before feature work)

Status

Phases 1 and 2 are complete and deployed to production. Migrations through 20260807210000_admin_two_factor are applied to both dev Neon and production Supabase. The sections below keep the original findings and record what shipped against each.

Corrections

Verifying this document against the code found claims that no longer hold. They are marked inline where they appear, and the full list with evidence is in the checklist. The one that changes an estimate most: OrderNotificationRule and OrderNotificationEvent have no code behind them, so the workflow-automation estimate here rested on a foundation that does not exist. The general lesson is recorded at the top of the checklist — a model in the schema is not a feature until something calls it.

Phase 1 detail

All of Phase 1 is implemented on branch feature/admin-operating-system — findings 1–3, the Refund.provider defect, and the audit-log backfill. The migration (20260807120000_add_fulfillment_channel_domain_events) is applied to dev Neon and is not yet applied to production Supabase. Each section below keeps the original finding and records what shipped.

1. Fulfillment state is conflated into OrderStatus

Order correctly separates payment (paymentStatus: PaymentStatus) from lifecycle (status: OrderStatus), but OrderStatus mixes lifecycle and fulfillment:

PENDING · CONFIRMED · PROCESSING · SHIPPED · DELIVERED · CANCELLED · REFUNDED

OrderItem has quantity but no quantityFulfilled. Partial fulfillment, split shipments, and per-item returns are therefore structurally impossible, not merely un-built UI. ShippingLabel exists and relates to Order, but nothing ties a label to the specific items in that box.

Also: PaymentStatus carries both PAID and SUCCEEDED. This is already reconciled at the code layer — lib/payments/status.ts makes PAID the canonical writer and exposes PAID_PAYMENT_STATUSES / isPaid() for reads, precisely because the checkout route and the Stripe webhook once wrote different values and could not recognise each other's work. Rows written before that fix are still stored as SUCCEEDED, so reads must keep going through the helper. Collapsing the enum itself is a separate data migration across every payment row and ~39 call sites; it is deliberately not in Phase 1 — estimate it at 2–3 days on its own.

Shipped. FulfillmentStatus enum (UNFULFILLED · PARTIALLY_FULFILLED · FULFILLED · DELIVERED · RETURNED), Order.fulfillmentStatus, OrderItem.quantityFulfilled, and Fulfillment + FulfillmentItem models so a shipment can cover a subset of an order.

The important part is lib/orders/fulfillment.ts. Five places in the codebase advance an order to shipped or delivered — admin/orders/[id]/update-status, admin/orders/bulk-status, admin/orders/[id]/tracking, admin/orders/[id]/shipping-label, and lib/tracking/webhook-handlers.ts (EasyPost). Rather than editing five routes to each set two fields, buildFulfillmentUpdate() returns status, fulfillment status and timestamps as one object and all five call it. Nothing outside that module assigns fulfillmentStatus directly, so the two fields cannot drift.

Rules the helper encodes, each with a test: a partially shipped order is PROCESSING, never SHIPPED; a late tracking webhook records delivery without resurrecting a cancelled or refunded order; shippedAt is stamped on first delivery when the carrier never sent an in-transit event; and refunding an order that never shipped does not mark it RETURNED.

Migration backfill derives history from status/shippedAt/deliveredAt rather than defaulting everything to UNFULFILLED — otherwise the first report run off the new column would claim the business has never shipped anything.

What is schema-only so far. Fulfillment, FulfillmentItem, OrderItem.quantityFulfilled and deriveFulfillmentStatus() exist and are tested, but nothing in the app writes them yet — every current path goes through buildFulfillmentUpdate(), which sets the order-level enum directly. So PARTIALLY_FULFILLED is not yet reachable in production and quantityFulfilled holds its backfilled value. That is the intended Phase 1 boundary: the data model is what unblocks the Phase 2 partial-fulfillment and returns UI, which is where the writing path gets built. Do not read Fulfillment as live.

2. No domain event system

lib/scraper/event-bus.ts exists but is a scraper-progress SSE bus — unrelated. There is no order.created / payment.completed / inventory.low emitter. Reactions are wired inline in webhook handlers and route bodies.

The consequence shows up everywhere else in this document: workflow automation, the notification centre, activity timelines, and analytics all want to subscribe to the same facts, and today each would have to re-instrument the same call sites.

Shipped. DomainEvent (append-only, indexed by (entityType, entityId, createdAt) so it doubles as the timeline source), a typed catalogue in lib/domain-events/types.ts, and emitDomainEvent() in lib/domain-events/emit.ts.

Two design points worth knowing:

  • It never throws. Emitting is observability, not the operation — a failed event write must not roll back a captured payment. This mirrors how logAudit swallows its own errors. The prisma client is resolved inside the try block rather than as a default parameter, because default parameters evaluate before the try and would throw straight past the catch.
  • It accepts a transaction client. Callers inside prisma.$transaction pass tx, so the event participates in the transaction and a rolled-back payment leaves no phantom event.

Wired so far: the fulfillment transitions from all six writers above, shipment.created on label purchase, and payment.completed / payment.failed in the Stripe webhook only. Still to instrument: the Square, PayPal and checkout/complete payment paths (all three set status: 'CONFIRMED' without emitting), order.created across the five checkout paths, inventory.low, and customer.created.

3. Sales channel is four fields pretending to be one

The spec asks for Website / POS / Festival / Wholesale / Manual / Marketplace / Phone. Today an order's origin is inferable from paymentChannel (ONLINE | POS only), importSource, fundraiserId, and participantId — none of which are mutually exclusive or enumerated.

This is a consolidation job, not a greenfield one: the data mostly exists.

Shipped. SalesChannel enum (WEBSITE · POS · FUNDRAISER · WHOLESALE · MANUAL · MARKETPLACE · PHONE · IMPORT) + Order.salesChannel, set at creation in all five order paths via deriveSalesChannel() in lib/orders/sales-channel.ts.

The signals genuinely overlap — a fundraiser sale can be rung up on the POS terminal — so the precedence is written down as a business decision rather than left to fall out of statement order: explicit → fundraiser → POS → marketplace → import → website. The same precedence appears in the migration backfill and in the helper; change both together.

Now exposed as an admin filter and a CSV export column (Phase 2). Still to do: use it as a reporting dimension in the analytics views.

Area-by-area, in your navigation order

Overview

ItemStateEvidence
Sales/revenue/AOV tilesPresentapp/admin/page.tsx (452 lines), components/admin/SalesOverview.tsx, RevenueChart.tsx, StatsCard.tsx
Low-stock alertsPresentcomponents/admin/LowStockAlert.tsx, InventoryAlert model
Top productsPresentcomponents/admin/PopularProducts.tsx
Failed payments / unfulfilled queueMissingno fulfillmentStatus to query (see #1)
Secondary growth dashboardPresent/admin/growth — 12-month revenue, new-vs-total customers, top products
Operational vs analytical splitMissingone page does both

The dashboard is informational rather than action-oriented: the eight StatsCard instances in app/admin/page.tsx:247-310 take no href, and the only navigation on the page is generic ("View Analytics", "View All", "Manage Users" at lines 236, 355, 412). Clicking "12 low stock" does not land you in a filtered product list. Splitting it into an operational "what needs doing" page plus the existing analytical view is 3–4 days, and depends on #1 for the unfulfilled queue.

Raw SQL — resolved. This noted four prisma.$queryRaw template literals in app/admin/growth/page.tsx. By the time it was acted on there were seven: the operational dashboard at app/admin/page.tsx had picked up the same pattern. All are now Prisma aggregates and groupBy, via lib/analytics/monthly-series.ts.

They were never an injection risk, being parameter-free — but they did carry a real bug. Grouping only rows that exist means a month with no orders was absent from the series rather than zero, so every chart drawn from them joined the months either side of an empty one and showed a trend that had not happened. The bucketing helper always emits every month in the window.

The only remaining $queryRaw is SELECT 1 in the developer console's connectivity probe, which builds no SQL from data.

Orders

Present: list, detail, row-selection with bulk status change and "export selected" (components/admin/OrdersTableClient.tsx:130-180), api/admin/orders/bulk-status, export, import, refund (orders/[id]/refund), invoice + packing slip (InvoicePDF.tsx, PackingSlipButton.tsx, printedInvoiceAt / printedPackingSlipAt), label purchase (BuyShippingLabelDialog.tsx), tracking (trackingHistory, trackingUrl, EasyPost webhook), customer + admin notes, modificationHistory JSON.

Missing: partial fulfillment, split shipments, returns/exchanges, manual order creation, a real timeline (modificationHistory is an untyped JSON blob, not an event log), saved views.

Filtering is confirmed narrow: app/admin/orders/page.tsx:42-59 supports exactly two filters — a text search across orderNumber / guestEmail / user name+email, and a single OrderStatus dropdown (lines 182-189). The spec's date + payment status + fulfillment status + customer + product + shipping method + channel + value + tags + location needs #1 and #3 first.

Work: filters + saved views 3–4 days; manual orders 2–3 days; timeline 1–2 days once #2 lands.

Products

Present: full CRUD, variants (ProductVariant), SKU/barcode, price / compareAtPrice / costPrice (COGS is there), taxCode, categories, tags (Tag/ProductTag), images, weight + lengthInches/widthInches/heightInches, SEO (metaTitle, metaDescription, ogImage, searchKeywords), isActive/isFeatured, unitsPerCase, nutrition + ingredients, import/export.

Missing: collections (distinct from categories), bundles, subscriptions, scheduled launches, related products, supplier info, custom attributes.

Adjacent: /admin/merchandise exists but is driven entirely by static config in lib/merchandise/config (adminMerchProducts, merchCollections, adminVendorCredentials) — it is a read-only display of a hard-coded catalog, not a managed one. Promoting merch to real Product rows is 1–2 days if you want it editable.

On custom attributes: heatLevel is a hard-coded HeatLevel enum column, exactly the pattern the spec warns about. A ProductAttribute/AttributeValue pair would generalise it — but note this is only worth doing if you actually intend the multi-client framework (see the closing section). For one salsa company, the enum is correct and cheaper.

Work: collections 2 days; bundles 3–4 days; scheduled launches 1 day; subscriptions 6–10 days (recurring billing is its own project).

Inventory

Present: stock by SKU, stockReserved vs inventory (available/on-hand exists), lowStockThreshold, stockStatus, full history via InventoryTransaction with a genuinely good type enum (SALE · RESTOCK · ADJUSTMENT · RETURN · DAMAGED · SAMPLE · TRANSFER · INITIAL · RESERVATION · RELEASE · IMPORT), InventoryAlert with acknowledge/resolve workflow, RestockNotification, import/export, transactional decrement in lib/inventory-manager.ts.

This is the strongest area in the console.

Purchase orders and receiving — shipped (Phase 3). Supplier, PurchaseOrder, PurchaseOrderItem, PurchaseOrderReceipt, PurchaseOrderReceiptItem, plus API routes for suppliers, purchase orders, submit/cancel and receiving. Stock arrives through adjustInventory with a RESTOCK transaction carrying a purchaseOrderId, so receiving records inventory history and re-evaluates low stock the same way every other movement does. quantityReceived follows the fulfillment invariant — a cached rollup with exactly one writer — and PARTIALLY_RECEIVED/RECEIVED are derived from quantities rather than stamped. Admin screens at /admin/purchase-orders cover the list with status filters, drafting an order, the detail view with delivery history, and receiving; suppliers live at /admin/purchase-orders/suppliers. Both are linked under Products in the sidebar.

Missing: backorders, multi-location/warehouse (the TRANSFER transaction type exists but there is no location dimension to transfer between). Editing a draft PO's lines after creation is also not built — a wrong draft is cancelled and re-drafted.

Fixed: /admin/inventory had no sidebar entry in lib/permissions-map.ts, so it was only reachable by typing the URL or via the dashboard widget. It is now listed under Products.

Work: multi-location 4–5 days (touches every stock read); backorders 2–3 days; draft PO line editing 1 day.

Customers

Present: Customer model with CustomerSource and CustomerAccountType, /admin/customers + import/export/sync/mailing-list, addresses, order history, LoyaltyAccount + PointTransaction + LoyaltyReward + RewardRedemption, AbandonedCart, WholesaleAccount + WholesaleStatus with a real approval queue at /admin/wholesale (321 lines, paginated, status-filtered), tags, ContactSubmission, /admin/messages + /admin/messages/live over ChatConversation/ChatThread.

Missing: computed LTV/average-spend surfaced in the UI, segments (EmailSegment exists but is email-scoped), disputes/chargebacks.

Work: LTV + spend rollups 2 days; general-purpose segments 3–4 days.

Marketing

The most complete area. EmailCampaign, EmailRecipient, EmailLog, EmailTemplate + versions + compositions, EmailAutomation / AutomationStep / AutomationTrigger / AutomationEnrollment / AutomationLog, MailingList + subscribers, EmailSegment, EmailBounce / EmailSuppression / UnsubscribePreference, BrandKit, EmailSchedule, plus /admin/email-marketing, /admin/email-campaigns, /admin/communications, /admin/social, /admin/feeds, /admin/lead-generation, ShopListing for Meta/TikTok/Amazon catalogs.

Missing: SMS, referral/affiliate tracking, UTM attribution stored on orders.

Work: UTM capture → order 1–2 days; affiliate/referral 4–5 days; SMS 3–4 days.

Content

Present: /admin/blog (posts, series, categories, comments), /admin/media + Media/MediaTag, /admin/seo with SeoConfiguration + StructuredData + search-console integration, Recipe, DeveloperPageContent, /admin/forms (a template builder over static lib/forms/templates definitions, for downloadable business forms — not web page content).

Missing: homepage/banner management, announcements, landing pages, FAQs, navigation editing, redirects (no Redirect model — relevant given the BigCommerce cutover), reusable page sections.

/admin/content is a 5-line redirect to /admin/media (app/admin/content/page.tsx) — the nav slot exists but there is no content management behind it. This is the thinnest area relative to the spec.

Work: redirects 1–2 days; banners/announcements 2–3 days; navigation + sections 4–6 days.

Analytics

Present: /admin/analytics with orders, inventory, fundraisers, social sub-views; /admin/growth (12-month revenue, order counts, new-vs-cumulative customers, top products); Analytics/AnalyticsSetting, lib/analytics/* including abandoned-cart-metrics.ts, Google Analytics reporting, Amplitude.

Margin — shipped at /admin/analytics/margin. OrderItem.unitCost snapshots cost at sale time, and lib/analytics/margin.ts computes gross profit with coverage reported rather than averaged away. Scoped deliberately as operational margin — per product, order and channel — not accounting; QuickBooks Online stays the source of truth for the books.

The blocker was data, not code: all 28 products had a null costPrice, so a dashboard built first would have reported "no data" indefinitely. Bulk Set cost and Apply latest purchase cost now exist to get real numbers in. Coverage will be zero until someone uses them, and the reporting says so in words rather than showing a confident wrong number.

Fundraiser commission is shown as a separate "after commission" contribution figure rather than blended into the margin, which would net commission on some orders and not others.

Exchange orders are excluded from this and every other revenue report via SALES_ONLY. They carry a real unitCost against a zero total, so counted as sales they would report a loss on every exchange.

Missing: cohort retention, repeat-customer rate, inventory turnover, days-of-stock, sell-through, custom date-range export.

Work: margin dashboard 1–2 days; cohort + retention 3–4 days; inventory turnover/days-of-stock 2 days.

Finance

Present: /admin/financials with expenses, payroll, taxes; /admin/invoices (status filter across DRAFT/SENT/PAID/OVERDUE/CANCELLED plus text search, app/admin/invoices/page.tsx:26-32); QuickBooks Online integration (QuickBooksConnection, QuickBooksSyncRecord, QuickBooksEntityMap, QuickBooksSettings, lib/quickbooks/*) syncing paid orders and refunds and pulling live P&L; Invoice; GiftCertificate + usage; MileageEntry; TimeClockEntry.

Correctness issue (fixed in Phase 1). Refund.stripeRefundId is a required unique field, and the PayPal and Square webhooks both stuff their provider refund ID into it. Worse, app/api/admin/refunds/route.ts wrote provider === 'STRIPE' ? id : '' — so every admin-initiated PayPal or Square refund stored an empty string in a UNIQUE column, meaning the second such refund ever taken would hit the constraint and roll back the whole refund transaction. Refund.provider has now been added and backfilled from Payment.provider, and the admin route stores the real provider ID. The column is deliberately not renamed to providerRefundId: Prisma emits DROP + ADD for a rename, which would destroy every stored refund ID.

Processor fees — shipped (Phase 3). Payment.processorFee plus a /api/cron/processor-fees reconciliation sweep. Not captured at webhook time because Square does not know its own fee until after settlement; since a sweep is unavoidable there, all providers use it. Stripe and PayPal lookups are implemented; Square's is not yet, and reports itself as uncovered rather than being silently skipped. Null always means "not known" — never zero.

Square fee lookup — shipped. fetchSquareFee calls payments.get and hands the response to the existing readSquareFee parser, so all three providers are covered. Two details worth keeping: the parser had been written against snake_case (processing_fee) while the v43 SDK returns camelCase (processingFee), which would have read as "not settled yet" forever; and the sweep prefers Payment.squarePaymentId over providerPaymentId, because on a terminal sale the latter can hold the terminal checkout ID, which payments.get rejects.

Taxes collected — shipped. /admin/financials/taxes reports tax by calendar period and destination state with a CSV export. Calendar periods rather than the rolling 7d/30d keys the other analytics use, because a return covers a named month or quarter and a rolling 90 days cannot be filed against anything.

Missing: chargebacks / disputes.

Work: disputes 2–3 days.

Operations (shipping, reviews, returns, staff, audit)

Shipping — Present: EasyPost via lib/shipping-*, ShippingCarrier / ShippingLabel, ShippingSettings (origin address, enabled carriers), real-time rates, tracking webhooks, OrderNotificationRule + OrderNotificationSetting for automated customer notifications. Missing: zones, package presets, insurance, local pickup/delivery. 3–4 days.

Reviews — Present: Review (with isVerified, ReviewStatus, moderatedAt/moderatedBy), SiteReview, /admin/reviews, BlogComment moderation. Missing: photo reviews, Q&A, review-request automation. 2–3 days (automation is nearly free once #2 exists).

Returns / RMA — complete. Was "missing entirely" when this was written; Phase 2 built the model and workflow, and the resolutions now settle for real:

  • Refund issues at the processor through lib/payments/refund.ts, extracted from the admin refund route so both paths run the same code. Before this, nothing wrote ReturnRequest.refundId at all — the link the schema advertised did not exist.
  • Exchange raises a replacement order, zero total, marked exchangeForReturnId. That column is what keeps it out of revenue: an exchange has real unitCost against no revenue, so counted as a sale it reports a loss on every exchange. SALES_ONLY in lib/orders/sales-population.ts is the single definition every revenue query composes.
  • Store credit issues a gift certificate coded JMS-CR-…, valid a year.

All three settle to the same value, and a return produces exactly one outcome — enforced in lib/orders/return-resolution.ts, since unique columns can express "at most one of each" but not "at most one in total".

Return labels — shipped, as a real EasyPost purchase. Stored on ReturnRequest rather than as a ShippingLabel row, because lib/tracking/webhook-handlers.ts resolves a label to its order and advances that order to delivered — a return label would mark the customer's original order delivered when the parcel reached the warehouse. The cost is that return shipments are untracked.

Still mock: app/api/admin/orders/[id]/shipping-label synthesises ${CARRIER}${Date.now()} as a tracking number and never calls the carrier. Outbound labels are not real purchases. That is pre-existing and separate from returns, but it is the reason return labels did not reuse it.

Staff & permissions — Present and good: UserRole (CUSTOMER · ADMIN · DEVELOPER · STAFF · WHOLESALE · FUNDRAISER), Permission + RolePermission with 18 PermissionCategory values, lib/rbac.ts, lib/admin-auth.ts, /admin/users with import/export, ServiceCredential + CredentialAccessGrant encrypted vault, PartnerApiKey with scopes. Missing: 2FA (zero hits for twoFactor/TOTP/authenticator anywhere), session management UI, password policy. 2FA: 2–3 days.

Audit log — was under-applied, now backfilled. AuditLog model with the right indexes, lib/audit.ts, /admin/audit-logs. At the start of Phase 1, 48 of the 99 admin API route files containing a POST/PATCH/PUT/DELETE handler referenced it at all.

That is now 93 of 99. The six that do not are deliberate: campaigns/[id]/test-send, email-templates/[id]/test, email-templates/[id]/composition/render, fundraiser-teams/preview-story, locations/geocode, and lead-generation/[id]/parse are POST endpoints that render a preview, geocode a lookup, or send a test message to the admin who asked for it. They change no persistent state, so an audit entry would be noise.

Two defects surfaced while doing this, both fixed:

  • lib/audit.ts could throw and 500 the operation it was recording. getRequestMetadata ran outside any try/catch inside logAuditWithRequest, so a request object without usable headers turned a completed refund into a 500 for the caller. Audit logging records something that already happened; it must never be able to fail the caller. Both functions are now total, with regression tests in tests/lib/audit.test.ts.
  • Two admin endpoints had no authentication at all. POST /api/admin/locations/fetch-photos and POST /api/admin/locations/update-local-photos had no session or permission check. proxy.ts does not guard /api/admin — it only handles fundraising redirects — so both were anonymously callable. fetch-photos would enumerate every active retail location, spend billable Google Places quota, and write photo URLs back to the database. Both now require content:write.

Settings

Present: /admin/settings is an overview hub (role counts, permission count, service keys, recent audit activity — app/admin/settings/page.tsx:24-45) linking to profile, payments (PaymentProviderConfig), shipping, email (EmailConfiguration), integrations (ThirdPartyIntegration), and discount codes. Plus .env secrets. Missing (confirmed by reading the hub): store identity/currency, tax configuration UI (Stripe Tax is code-configured in lib/tax-calculator.ts), legal pages, notification preferences beyond order events. Work: 2–3 days.

Cross-cutting requests

RequestStateNoteEst.
Workflow automationPartialCorrection: this said "two half-engines already exist". Only one does. EmailAutomation/AutomationStep/AutomationTrigger/AutomationEnrollment is real, complete and cron-driven (lib/email/automation-engine.ts) but email-scoped. OrderNotificationRule/OrderNotificationEvent have zero code references — schema only, no engine. The live order-side piece is OrderNotificationSetting, which is per-event on/off, not trigger→action. Re-based accordingly; being built as separate work.6–8 d
Global searchMissingcomponents/ui/command.tsx (cmdk) is installed but unused for search. Cross-entity search over orders/customers/SKUs/tracking/coupons needs a unified index.3–5 d
Bulk operationsPartialOrders are genuinely done — select-all, bulk status change, export selected (OrdersTableClient.tsx:130-180) — plus endpoints for events, leads, mailing lists. Absent for products, inventory, customers, shipments.3–4 d
Activity timelinesDone for ordersbuildOrderTimeline() merges recorded DomainEvent rows with the timestamps on the order/payments/refunds, so orders predating the event log still show a history; derived entries are labelled reconstructed. Other entities still to do.shipped
Notification centrePartialNotification model + NotificationType, but all 5 values are order/system (ORDER_NEW, ORDER_STATUS_CHANGE, ORDER_HIGH_VALUE, ORDER_MODIFIED, SYSTEM). No inventory, payment-failure, integration-failure, or dispute types. No in-admin centre UI.3–4 d
IdempotencyPartialGenuinely handled on the money paths — WebhookEvent dedup plus explicit keys in webhooks/stripe, checkout/complete, checkout/square/process-payment, checkout/paypal/capture-order, pos/create-terminal-checkout, lib/orders/redeem-codes.ts, lib/email/template-sender.ts. Not applied to inventory adjustments or shipment creation.1–2 d
Security baselineMostly presentRBAC, encrypted credentials (lib/crypto.ts), signed webhook verification, rate limiting (lib/rate-limit, rate-limiter.ts), Zod validation, audit log, API scopes. Gap is 2FA + session management.see above
AI admin assistantPartiallib/ai-rag, app/api/ai-chat, ChatDomain already spans orders/products/payments. Natural-language querying over commerce data is a genuinely reachable extension.5–8 d

Sequencing and totals

Each phase total is the arithmetic sum of its line items above — no overlap discount is applied, because the dependencies run sequentially rather than in parallel for a solo dev.

Phase 1 — Structural: 14–21 days. Mostly done. Fulfillment model 5–8 ✅ · domain events 4–6 ✅ (schema, emitter and the fulfillment/payment call sites; order.created, inventory.low and customer.created still to instrument) · sales channel 2–3 ✅ (admin filter deferred to Phase 2) · Refund.provider fix 0.5 ✅ · audit-log backfill 2–3 ✅ (48/99 route files → 93/99, six deliberate skips). Phase 1 is complete. Most later items get cheaper from here.

Before this reaches production: the migration has been applied to dev Neon only. It must run against Supabase, and the directUrl/env-precedence trap noted below applies.

Phase 2 — Operational core: 23–33 days. Complete. Order filters + saved views ✅ · timelines ✅ · partial fulfillment & split shipments ✅ · returns/RMA ✅ · operational dashboard split ✅ · notification centre ✅ · global search ✅ · bulk operations ✅ · 2FA ✅.

Phase 2 also absorbed the fulfillment writer path, which was not in the original estimate: Fulfillment/quantityFulfilled shipped as schema in Phase 1 with nothing writing them, and returns could not be bounded honestly until they were.

Phase 3 — Commercial depth: 35–48 days. Workflow automation 5–7 · purchase orders + receiving 4–6 · profit/margin 3–4 · cohort + retention 3–4 · inventory turnover 2 · processor fees 2–3 · customer LTV rollups 2 · collections 2 · bundles 3–4 · redirects 1–2 · banners/announcements 2–3 · shipping zones + presets + pickup 3–4 · settings completion 2–3 · UTM capture 1–2.

Phase 4 — Optional: 37–54 days. Subscriptions 6–10 · AI admin assistant 5–8 · multi-location inventory 4–5 · affiliate/referral 4–5 · custom product attributes 4–6 · navigation + page sections 4–6 · SMS 3–4 · general segments 3–4 · disputes/chargebacks 2–3 · review gaps (photo, Q&A, request automation) 2–3.

Engineer-daysWeeks at 5 productive days
Phase 114–213–4
Phase 223–335–7
Phase 335–487–10
Phases 1–372–10215–20
Phase 437–547–11
All four109–15622–31

Engineer-days are not calendar days. At a realistic part-time pace alongside running the business, treat Phases 1–3 as 5 to 8 calendar months, and Phase 1 alone — the part that unblocks everything else — as 3 to 5 weeks.

Constraints that affect the plan

  • Migrations are the long pole, not the React. Items requiring schema changes that must land on both dev (Neon) and prod (Supabase): fulfillment, sales channel, Refund.provider, domain events, returns, purchase orders, multi-location, collections/bundles. Note the Prisma CLI env-precedence trap when targeting production.

  • Cron frequency is no longer a constraint. The project moved to Vercel Pro on 2026-08-07: 100 cron jobs per project, minimum interval once per minute, per-minute precision. Under Hobby this was daily-only with ±59 minutes of slop, which is why several crons were built expecting a cadence they never got, and why scheduled social publishing was driven from GitHub Actions. Both have been corrected. Workflow automation can now be scheduled honestly rather than designed around a daily tick.

    The new limit is idempotency, not the plan. A time-window query that was harmless once a day becomes a spam cannon hourly; raising any cron's frequency requires it to carry a persisted "already did this" marker first. The review-request cron is the worked example — see Order.reviewRequestSentAt.

On the multi-client framework idea

Recommend against it now. Abstracting products, orders, inventory, and permissions into reusable modules with per-client branding and attribute schemas would multiply nearly every estimate above, and there is exactly one client. The reusable asset you get from doing Phase 1 well — a clean event bus, a proper fulfillment model, an audited mutation layer — is most of what a second deployment would want anyway, and it can be extracted later against a real second set of requirements rather than an imagined one.

How is this guide?

Edit on GitHub

Last updated on

On this page