Personalised Pricing Packages

Status: Draft Created: 2026-04-18 Author: bilal Supersedes (partial): Fixed-tier plans management surface in envo-ops Related: docs/05-Specs/PRICING_SPEC.md (Growth tier model), onboarding-experience.md, ADR-004 Billing Integration Linear: TBD (new issue to be raised — suggested scope: “Replace fixed tiers with per-org quote → pay → provision flow”)


TL;DR

Today the ops console carries two parallel representations of price:

  1. Fixed tiers (plans table + /plans manager + Organisation.plan enum: starter | pro | premium) — used for invites, gating, and dashboard signup.
  2. Pricing calculator (/pricing route) — a rich, dynamic ROI calculator that we currently use only to suggest a number over the phone and copy-paste a breakdown into Slack/email.

We want to collapse (1) into (2): the calculator becomes the canonical package builder for Acquisition-motion customers, each organisation gets a bespoke, persisted package, and the journey is end-to-end:

Quote → Stripe Payment Link → Payment → Auto-provision Organisation → (manual) Invite

The pricing maths itself is not being re-validated in this plan — Danny owns the numbers, and pricing-table.tsx already encodes his latest spec. This plan is about the shape of the concept, the data model, the Stripe integration, and the tech-debt cleanup, so that when Danny’s final pricing lands we flip constants and ship.


Why

  • The fixed-tier model no longer matches how we sell. Acquisition deals are bespoke (property count, channels, team size, setup complexity) and the calculator already produces a defensible, ROI-backed number per prospect.
  • Duplicated truth: plans table features, lib/features/definitions.ts defaults, pricing-table.tsx tier bands, and Organisation.plan enum all drift against each other. Every pricing tweak touches 4 surfaces.
  • The current hand-off (quote on phone → create invite manually → land on dashboard → pay on a generic Stripe checkout sized for Growth tiers) leaks trust. Money changing hands should feel surgical.
  • Growth-motion (self-serve Starter/Pro/Premium on the marketing site) stays as-is — that flow lives in envo-dashboard via /api/stripe/checkout and is out of scope here.

Out of Scope

  • Re-deriving the pricing maths. Constants in pricing-table.tsx are treated as a black box. When Danny signs off, we swap them.
  • Growth-motion self-serve checkout. Marketing-site → dashboard → Stripe Checkout pipeline continues to use fixed Starter / Pro / Premium tiers.
  • Usage metering, credits, overage billing. All deferred to EHQ-69 / EHQ-74.
  • Automated invite dispatch on payment. Explicit product decision: invite remains operator-triggered after the org is provisioned. Payment ≠ ready-to-onboard; we may need VA prep first.

Current State (Audit)

SurfacePathRole
Calculator UIenvo-ops/app/(console)/pricing/pricing-table.tsxClient-side only; no persistence; Copy Breakdown → clipboard text
Pricing page wrapperenvo-ops/app/(console)/pricing/page.tsxsuper_admin gate
Plans table (DB)prisma/schema.prisma:81 model PlanFixed name, monthly/setup price, features JSON, bullets
Plans admin UIenvo-ops/app/(console)/plans/plans-manager.tsx (~750 LoC)CRUD over plans
Plans APIenvo-ops/app/api/plans/route.ts, [id]/route.tsCreate / update
Org tier linkageOrganisation.plan enum: `starterpro
Invite tierinvitations.tier (string, nullable) + createInviteSchema in lib/validations/invitation.tsCarried onto org when invite accepted
Stripeenvo-dashboard/supabase/migrations/008_stripe_fields.sql + checkout there. stripe_customer_id, stripe_subscription_id, stripe_subscription_status already on organisationsNo Stripe SDK in envo-ops yet (checked package.json)

Key observations:

  • Organisation.plan is a Postgres enum → schema change needed to become nullable / renamed.
  • Calculator output already contains everything we’d persist (tenants, properties, enquiries, channels, seats, breakdown, setup fee, total monthly, ROI).
  • No quotes / packages model exists yet.
  • invitations.tier is free-form string — already flexible enough to carry a quote id instead of a tier name.

Target Concept

Entity rename / addition: Package (per-organisation)

A Package is the persisted output of the calculator for one prospective organisation. It replaces the role Plan plays for Acquisition customers.

Package {
  id
  status           draft | quoted | paid | provisioned | cancelled | expired
  label            human label ("Greenfield HMO — 450 tenants")

  // Inputs (reproducible)
  tenants
  properties
  enquiries_per_month
  channels         jsonb array ["voice", "whatsapp", ...]
  staff_seats
  pricing_version  e.g. "2026-04-danny-v1" — so old quotes don't silently re-price

  // Breakdown (computed at quote time, frozen)
  base_platform_pence
  tenant_addon_pence
  enquiry_addon_pence
  channel_addon_pence
  seat_addon_pence
  total_monthly_pence
  setup_fee_pence
  is_custom        boolean (any tier overflowed to "Custom")
  roi_hours_saved
  roi_value_pence

  // Payment
  stripe_customer_id        — created lazily
  stripe_setup_price_id     — one-off Price in Stripe
  stripe_recurring_price_id — recurring Price in Stripe
  stripe_payment_link_id
  stripe_payment_link_url
  stripe_checkout_session_id (captured via webhook)
  paid_at

  // Provisioning outcome
  organisation_id  nullable until provisioned
  billing_email    — email Stripe link is sent to
  contact_name
  notes            — ops-facing free text

  created_by  user_id
  created_at, updated_at, expires_at (default +14 days)
}

State machine

draft ──(save quote)──▶ quoted ──(customer pays)──▶ paid ──(ops provisions)──▶ provisioned
  │                       │                          │
  │                       └─(timeout/ops cancel)─▶ expired/cancelled
  └─(ops discards)──▶ cancelled

Transitions are server-authoritative; client only initiates. paid → provisioned is what spins up the Organisation + Stripe subscription link.


Journey

1. Build the quote (ops-facing)

  • Route: existing /pricing — minimal visual change.
  • New above-the-fold fields: Organisation label, Contact name, Billing email, optional notes.
  • Existing calculator body unchanged.
  • Copy Breakdown kept for now (useful for phone calls); a new primary CTA appears once billing email + label are present:
    • “Save quote”POST /api/packages returns { id, shareable_url }. Status quoted.
    • “Generate payment link” → chains: save quote → create Stripe artefacts → return stripe_payment_link_url.
  • Ops copies the Stripe-hosted link and sends via their channel of choice (email, WhatsApp, in-call chat). No automated email from Envo yet — keeps blast radius small.
  • Optional V1.1: one-click “Email to billing contact” using Resend template.

3. Customer pays

  • Payment happens on Stripe’s hosted Payment Link page.
  • after_completion.redirect.url points to https://ehq.tech/thanks?package=<id> (static page, no auth — just a “we’ll be in touch” screen).
  • Webhook (checkout.session.completed scoped to that Payment Link) fires to POST /api/stripe/webhook in envo-ops, marks the Package as paid.

4. Provision organisation (ops-triggered, with default auto-provision toggle)

  • Ops console shows a Packages index (/(console)/packages): statuses, customer name, total monthly, age.
  • For each paid package: “Provision organisation” button. Uses existing POST /api/org logic with additional linkage:
    • Creates Organisation with billing_email, attaches stripe_customer_id, stripe_subscription_id from the package.
    • Writes package.organisation_id, flips status to provisioned.
    • Seeds feature flags from package (channels, seats) via existing org_feature_overrides — no “tier” involved.
  • A config flag PACKAGE_AUTO_PROVISION=true can make this automatic on webhook receipt for trusted flows.

5. Send invite (ops-triggered)

  • From the provisioned package, ops clicks “Send invite” → reuses POST /api/invite with type: 'landlord'.
  • invitations.organisation_id is populated (already supported in schema) so the invite attaches to the real org, not a tier-at-signup.
  • Keeps VA prep control.

Stripe Integration Details

You asked specifically about Payment Links. Both are viable; here’s the delta:

Payment LinkCheckout Session (dynamic)
Hosted URLReusable, long-livedOne-shot, expires in 24h
Subscription + one-off in one flow✅ (supported: recurring line items + one-off line item)
Custom price per customer✅ (create Price on the fly, attach)
Metadata for webhook correlation
Pre-filled customer emailprefilled_email
Collect name/phone✅ via custom_fields
After-payment redirectafter_completion.redirectsuccess_url
Shareable by humans (Slack/WhatsApp)Designed for thisWeird — link looks like an API URL

Recommendation: Payment Link — matches user’s stated preference, the link is the artefact ops hands over, and “long-lived” is useful because customers often click 3 days later.

Artefacts created per quote

createStripePackage(pkg):
  1. Customer:
       stripe.customers.create({ email, name, metadata: { package_id } })
  2. Product (reusable "Envo Automation Package" singleton in Stripe):
       one-time fetch of product id from env STRIPE_PACKAGE_PRODUCT_ID
  3. Prices (one-shot, not reused — prices are per-quote):
       recurring = stripe.prices.create({
         product, currency: 'gbp',
         unit_amount: pkg.total_monthly_pence,
         recurring: { interval: 'month' },
         metadata: { package_id }
       })
       setup = stripe.prices.create({
         product, currency: 'gbp',
         unit_amount: pkg.setup_fee_pence,
         metadata: { package_id, kind: 'setup_fee' }
       })
  4. PaymentLink:
       stripe.paymentLinks.create({
         line_items: [
           { price: recurring.id, quantity: 1 },
           { price: setup.id,     quantity: 1 }
         ],
         after_completion: { type: 'redirect',
           redirect: { url: `${BASE}/thanks?package=${pkg.id}` } },
         metadata: { package_id: pkg.id },
         customer_creation: 'always',
         subscription_data: { metadata: { package_id: pkg.id } }
       })

Webhook events we care about

  • checkout.session.completed — primary “paid” signal; extract metadata.package_id, customer, subscription, mark package paid.
  • customer.subscription.created — backfill stripe_subscription_id on the package.
  • customer.subscription.updated / .deleted — propagate status to Organisation.stripe_subscription_status when provisioned.
  • invoice.paid — useful later for ledger; noop for V1.

Idempotency: keyed on metadata.package_id + event id. Same discipline as the existing dashboard webhook handler (see ADR-004).

”Custom” tier handling

The calculator can produce isCustom = true when inputs overflow the top band. In that case:

  • Payment Link generation is blocked with message “This quote needs Danny — total is custom.”
  • Quote still persists as quoted with a requires_manual_pricing = true flag.
  • Ops overrides total_monthly_pence / setup_fee_pence manually (inline edit on the package), which unblocks link generation.

Tech Debt Cleanup

In scope for this plan:

  1. Deprecate /plans admin UI — but keep the plans table for 1 release so envo-dashboard’s Growth-motion onboarding keeps working. Add a banner on the page: “Fixed tiers are deprecated for new deals — use Packages.” Hide the Create Plan button behind a feature flag.
  2. Stop writing invitations.tier in the landlord invite path when the invite originates from a package (the link is package_id instead). Keep the column for backward compat.
  3. Organisation.plan enum → nullable. Organisations provisioned from packages have plan = null and derive gating from their org_feature_overrides + package features. Dashboard gating code (lib/plans.ts on the dashboard side) needs a branch: “no plan ⇒ package-driven features”.
  4. Single source of pricing constants. Extract the constants at the top of pricing-table.tsx (BASE_PLATFORM_FEE, TIERS, etc.) into envo-ops/lib/pricing/config.ts with a pricing_version export. Server-side computePackage() imports the same config so client/server always agree.
  5. Unit test the calc. computePackage() becomes a pure function, tested in vitest. Addresses TD-001 incidentally for this module.

Deferred tech debt (documented, not actioned):

  • Full removal of Plan model — blocked on Growth-motion migration.
  • FUTURE_FEATURES set in plans-manager.tsx — no longer needed post-cleanup.

Data Model Changes

New migration

-- packages table
CREATE TABLE packages (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  status TEXT NOT NULL DEFAULT 'draft'
    CHECK (status IN ('draft','quoted','paid','provisioned','cancelled','expired')),
  label TEXT NOT NULL,
  contact_name TEXT,
  billing_email TEXT NOT NULL,
  notes TEXT,
 
  tenants INTEGER NOT NULL DEFAULT 0,
  properties INTEGER NOT NULL DEFAULT 0,
  enquiries_per_month INTEGER NOT NULL DEFAULT 0,
  channels TEXT[] NOT NULL DEFAULT '{}',
  staff_seats INTEGER NOT NULL DEFAULT 1,
  pricing_version TEXT NOT NULL,
 
  base_platform_pence INTEGER NOT NULL,
  tenant_addon_pence INTEGER NOT NULL,
  enquiry_addon_pence INTEGER NOT NULL,
  channel_addon_pence INTEGER NOT NULL,
  seat_addon_pence INTEGER NOT NULL,
  total_monthly_pence INTEGER NOT NULL,
  setup_fee_pence INTEGER NOT NULL,
  is_custom BOOLEAN NOT NULL DEFAULT FALSE,
  requires_manual_pricing BOOLEAN NOT NULL DEFAULT FALSE,
  roi_hours_saved INTEGER,
  roi_value_pence INTEGER,
 
  stripe_customer_id TEXT,
  stripe_recurring_price_id TEXT,
  stripe_setup_price_id TEXT,
  stripe_payment_link_id TEXT,
  stripe_payment_link_url TEXT,
  stripe_checkout_session_id TEXT,
  stripe_subscription_id TEXT,
  paid_at TIMESTAMPTZ,
 
  organisation_id UUID REFERENCES organisations(id) ON DELETE SET NULL,
 
  created_by UUID NOT NULL REFERENCES users(id),
  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  expires_at TIMESTAMPTZ NOT NULL DEFAULT (NOW() + INTERVAL '14 days')
);
 
CREATE INDEX packages_status_idx ON packages(status);
CREATE INDEX packages_billing_email_idx ON packages(billing_email);
CREATE INDEX packages_organisation_idx ON packages(organisation_id);
 
-- Make Organisation.plan nullable (relax enum constraint)
ALTER TABLE organisations ALTER COLUMN plan DROP NOT NULL;
 
-- RLS: super_admin-only
ALTER TABLE packages ENABLE ROW LEVEL SECURITY;
CREATE POLICY packages_admin_all ON packages
  USING (auth.jwt() ->> 'system_role' = 'super_admin');

All amounts stored in pence (integer) to match existing convention in rent_payments etc.


API Surface (new)

POST   /api/packages                   Create (status: draft|quoted)
GET    /api/packages                   List (super_admin)
GET    /api/packages/:id               Read
PATCH  /api/packages/:id               Edit (before paid)
POST   /api/packages/:id/payment-link  Create Stripe artefacts + return URL
POST   /api/packages/:id/provision     Create Organisation + link
POST   /api/packages/:id/cancel
POST   /api/stripe/webhook             Listen for checkout.session.completed, subscription.*

All gated by requireSystemRole('super_admin') except the webhook, which verifies the Stripe signature.


UI Surface

Existing /pricing — calculator becomes “Quote Builder”

  • Add compact “Quote details” section at top (label, contact, billing email, notes).
  • Primary CTA shifts from Copy (now secondary) to Save quoteGenerate payment link.
  • After save: in-page toast with the Payment Link URL + copy button + “View in Packages”.

New /(console)/packages

  • Tabbed list: All / Quoted / Paid / Provisioned / Cancelled.
  • Row actions: Copy link, Provision, Cancel, Open in Stripe.
  • Detail page /(console)/packages/[id]: full breakdown, state timeline, Stripe links, provisioning controls.

/(console)/plans — deprecated banner

  • Soft-deprecate messaging; hide create button behind PLANS_ADMIN_ENABLED flag (default off in staging/prod).

Rollout

PhaseScopeGate
0Pricing constants extracted to shared module + pure computePackage() fn + testsMerge freely
1packages migration + Prisma model + CRUD API (no Stripe)Feature-flag PACKAGES_ENABLED off
2Stripe SDK added to envo-ops, Payment Link creation, webhook receiver, signed verificationPACKAGES_ENABLED on in staging, Stripe test keys
3Packages list + detail UI + calculator wiringsame flag
4Provisioning action → Organisation + invite triggersame flag
5Plans admin deprecation banner, Organisation.plan nullableFlag on in prod for super_admin
6Danny’s finalised pricing → swap constants, bump pricing_versionSeparate PR

Risks & Open Questions

RiskMitigation
Pricing constants change while quotes are outstandingpricing_version stamped on every package; breakdown frozen at quote time; old links keep their price
Stripe webhook missed ⇒ paid never setReconciliation cron (daily) lists stripe_payment_link completions and repairs status
Customer pays, then requirements changeOps can manually adjust Organisation features post-provision; package itself is immutable once paid (audit trail)
Dashboard gating assumes plan enumDashboard branch: if (!org.plan) derive features from org_feature_overrides — already the primary path, just needs the null-safe fallback
Double-provision (webhook + manual)packages.organisation_id unique-ish check; idempotent POST /provision
Growth self-serve still needs fixed tiersExplicitly out of scope; plans table + Plan enum remain for Growth

Open questions:

  • Do we want VAT handling on Payment Links? Stripe Tax can be enabled per-link. Default: on.
  • Should setup fee be billed separately (one-off invoice) vs. bundled into the first subscription invoice? V1: bundled via mixed line items on the Payment Link. Simpler.
  • Commitment period? Currently month-to-month. Annual discount deferred (per PRICING_SPEC open questions).
  • Do we need a “renegotiate package” flow (paid → draft)? V1: no — cancel existing sub, cut new package.

Acceptance Criteria

  • Super admin can build a calculator quote, save it as a Package, and obtain a Stripe Payment Link that reflects exact monthly + setup amounts.
  • Customer paying the link triggers a webhook that transitions the package to paid within 30 seconds.
  • From a paid package, a single action provisions an Organisation and links the Stripe subscription.
  • Ops can then send a landlord invite that lands the customer directly into the provisioned organisation.
  • Fixed-tier plans admin is behind a deprecation flag; Growth-motion self-serve flow is unaffected.
  • computePackage() has unit tests covering every tier boundary + the custom overflow case.
  • No duplicated pricing constants between client and server.

Non-goals (explicit)

  • Auto-sending invites on payment.
  • Re-validating Danny’s pricing maths.
  • Migrating existing Starter/Pro/Premium organisations to packages.
  • Usage metering, credits, or overage billing.