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:
- Fixed tiers (
planstable +/plansmanager +Organisation.planenum:starter | pro | premium) — used for invites, gating, and dashboard signup. - Pricing calculator (
/pricingroute) — 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:
planstable features,lib/features/definitions.tsdefaults,pricing-table.tsxtier bands, andOrganisation.planenum 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-dashboardvia/api/stripe/checkoutand is out of scope here.
Out of Scope
- Re-deriving the pricing maths. Constants in
pricing-table.tsxare 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 / Premiumtiers. - 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)
| Surface | Path | Role |
|---|---|---|
| Calculator UI | envo-ops/app/(console)/pricing/pricing-table.tsx | Client-side only; no persistence; Copy Breakdown → clipboard text |
| Pricing page wrapper | envo-ops/app/(console)/pricing/page.tsx | super_admin gate |
| Plans table (DB) | prisma/schema.prisma:81 model Plan | Fixed name, monthly/setup price, features JSON, bullets |
| Plans admin UI | envo-ops/app/(console)/plans/plans-manager.tsx (~750 LoC) | CRUD over plans |
| Plans API | envo-ops/app/api/plans/route.ts, [id]/route.ts | Create / update |
| Org tier linkage | Organisation.plan enum: `starter | pro |
| Invite tier | invitations.tier (string, nullable) + createInviteSchema in lib/validations/invitation.ts | Carried onto org when invite accepted |
| Stripe | envo-dashboard/supabase/migrations/008_stripe_fields.sql + checkout there. stripe_customer_id, stripe_subscription_id, stripe_subscription_status already on organisations | No Stripe SDK in envo-ops yet (checked package.json) |
Key observations:
Organisation.planis 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/packagesmodel exists yet. invitations.tieris 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 Breakdownkept for now (useful for phone calls); a new primary CTA appears once billing email + label are present:- “Save quote” →
POST /api/packagesreturns{ id, shareable_url }. Statusquoted. - “Generate payment link” → chains: save quote → create Stripe artefacts → return
stripe_payment_link_url.
- “Save quote” →
2. Deliver payment link
- 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.urlpoints tohttps://ehq.tech/thanks?package=<id>(static page, no auth — just a “we’ll be in touch” screen).- Webhook (
checkout.session.completedscoped to that Payment Link) fires toPOST /api/stripe/webhookinenvo-ops, marks thePackageaspaid.
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
paidpackage: “Provision organisation” button. Uses existingPOST /api/orglogic with additional linkage:- Creates
Organisationwithbilling_email, attachesstripe_customer_id,stripe_subscription_idfrom the package. - Writes
package.organisation_id, flips status toprovisioned. - Seeds feature flags from package (
channels, seats) via existingorg_feature_overrides— no “tier” involved.
- Creates
- A config flag
PACKAGE_AUTO_PROVISION=truecan 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/invitewithtype: 'landlord'. invitations.organisation_idis 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
Payment Link vs Checkout Session
You asked specifically about Payment Links. Both are viable; here’s the delta:
| Payment Link | Checkout Session (dynamic) | |
|---|---|---|
| Hosted URL | Reusable, long-lived | One-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 email | ✅ prefilled_email | ✅ |
| Collect name/phone | ✅ via custom_fields | ✅ |
| After-payment redirect | ✅ after_completion.redirect | ✅ success_url |
| Shareable by humans (Slack/WhatsApp) | Designed for this | Weird — 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; extractmetadata.package_id,customer,subscription, mark package paid.customer.subscription.created— backfillstripe_subscription_idon the package.customer.subscription.updated/.deleted— propagate status toOrganisation.stripe_subscription_statuswhen 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
quotedwith arequires_manual_pricing = trueflag. - Ops overrides
total_monthly_pence/setup_fee_pencemanually (inline edit on the package), which unblocks link generation.
Tech Debt Cleanup
In scope for this plan:
- Deprecate
/plansadmin UI — but keep theplanstable for 1 release soenvo-dashboard’s Growth-motion onboarding keeps working. Add a banner on the page: “Fixed tiers are deprecated for new deals — use Packages.” Hide theCreate Planbutton behind a feature flag. - Stop writing
invitations.tierin the landlord invite path when the invite originates from a package (the link ispackage_idinstead). Keep the column for backward compat. Organisation.planenum → nullable. Organisations provisioned from packages haveplan = nulland derive gating from theirorg_feature_overrides+ package features. Dashboard gating code (lib/plans.tson the dashboard side) needs a branch: “no plan ⇒ package-driven features”.- Single source of pricing constants. Extract the constants at the top of
pricing-table.tsx(BASE_PLATFORM_FEE, TIERS, etc.) intoenvo-ops/lib/pricing/config.tswith apricing_versionexport. Server-sidecomputePackage()imports the same config so client/server always agree. - 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
Planmodel — blocked on Growth-motion migration. FUTURE_FEATURESset inplans-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 quote → Generate 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_ENABLEDflag (default off in staging/prod).
Rollout
| Phase | Scope | Gate |
|---|---|---|
| 0 | Pricing constants extracted to shared module + pure computePackage() fn + tests | Merge freely |
| 1 | packages migration + Prisma model + CRUD API (no Stripe) | Feature-flag PACKAGES_ENABLED off |
| 2 | Stripe SDK added to envo-ops, Payment Link creation, webhook receiver, signed verification | PACKAGES_ENABLED on in staging, Stripe test keys |
| 3 | Packages list + detail UI + calculator wiring | same flag |
| 4 | Provisioning action → Organisation + invite trigger | same flag |
| 5 | Plans admin deprecation banner, Organisation.plan nullable | Flag on in prod for super_admin |
| 6 | Danny’s finalised pricing → swap constants, bump pricing_version | Separate PR |
Risks & Open Questions
| Risk | Mitigation |
|---|---|
| Pricing constants change while quotes are outstanding | pricing_version stamped on every package; breakdown frozen at quote time; old links keep their price |
Stripe webhook missed ⇒ paid never set | Reconciliation cron (daily) lists stripe_payment_link completions and repairs status |
| Customer pays, then requirements change | Ops can manually adjust Organisation features post-provision; package itself is immutable once paid (audit trail) |
Dashboard gating assumes plan enum | Dashboard 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 tiers | Explicitly 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
paidwithin 30 seconds. - From a
paidpackage, a single action provisions anOrganisationand 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.