offplan · online
Plan · onboarding-trial-mode

Onboarding + Quick Build Trial Mode — Phase 1.2 Sub-plan

Approvedplanonboarding-trial-modepriority P0
Ratified
2026-05-11
Created
2026-05-04
Priority
P0

Status

RATIFIED (CONV-35, 2026-05-11). Все 5 Steps closed: Step 1 interview (CONV-34, 14 picks) · Step 2 Research (Perplexity Sonar Deep Research persisted) · Step 3 Approaches (single approach + 3 ratifications: Stripe ADR 0017 / Trial daily caps DROPPED / Cyprus e-invoicing DROPPED jurisdiction shift) · Step 4 Plan body (~1750 lines + 12 implementation steps + Step 13 operator playbook + ADR amendments) · Step 4.4 Business review (5 concern agents → 76 findings; 16 Category A applied + 13 Category B ratified) · Step 4.5 Ratification sweep · Step 5 Workstream onboarding-trial-implementation.md created.

Post-ratification commits:

Goal

Phase 1.2 implementation-ready spec — UX flows + tier transitions + trial/Free Guest mechanics + URL architecture resolution. Не tech-level (DB schema / API contracts / library choice = implementation choice тех команды). Не numbers (цены / лимиты per tier — config-driven, populated post-Roman input).

Scope

IN (closed Step 1):

OUT (explicit parking):

Anchored ADRs + Sub-plans + Foundational sections


Part 1 — Ratified Picks (Step 1 closed CONV-34)

Pick #1 — Tier naming finalisation (C-Hybrid) [+ SPEC-AMEND v1.1 CONV-35 B4 ratification: Trial default = T2 Pro]

Architecture: stable internal slugs + configurable display names.

Slug (Stripe + DB) Display Stage 1 Назначение
free_guest «Free Guest» гостевая Org через reverse invite, не может создавать projects
tier_1 «Starter» свои projects, base limits, default subdomain
tier_2 «Pro» + custom domain + DKIM + повышенные limits. Trial default tier (per B4 ratification — Notion/Linear/Vercel pattern, conversion lift via «evaluate what you'll buy»)
tier_3_enterprise «Enterprise» + Microsoft/SAML SSO + SLA + индивидуальные limits

Config source of truth: pricing-config.ts (или эквивалент) с structure:

{
  tier_1: { display: "Starter", monthly_price: null, limits: {...} },
  tier_2: { display: "Pro", monthly_price: null, limits: {...} },
  tier_3_enterprise: { display: "Enterprise", monthly_price: "Contact sales", limits: {...} }
}

Numbers populated post-Roman input.

Industry pattern: Notion / Linear / Figma / Vercel — все используют hybrid (stable slug + display string). Slug в Stripe не меняется никогда (миграция = nightmare); display = config-driven, можно менять без миграций.

Rejected:

Pick #2 — Free Guest expiry policy (B-Soft 12mo)

12mo inactivity → email cascade → archive (90d preserved) → hard delete.

Inactivity definition: ни login event ни webhook event (Stripe activity, invite accept, buyer token issue) за 12 calendar months.

Cascade timeline:

Trigger Action
T-30d Email «Your Free Guest workspace will be archived in 30 days · Sign in to keep it active»
T-7d Email reminder с inline reactivation link
T-0 Archive flip: status: free_guest_archived · login blocked · data preserved 90d · public sales-app returns 410 Gone
T+90d Hard delete: PII anonymised, buyer records purged, Stripe customer record deleted, audit log entries pseudonymised per ADR 0004 v2

Restoration: support email → operator un-archive через Phase 1.4.7 UI (archive_un_archive action, mandatory reason field, audit log pii_class = personal_meta).

GDPR alignment: Art. 5(1)(e) minimisation satisfied через 12mo expiry. ToS must ratify: «Inactive Free Guest workspaces (12 months no activity) are archived; after 90 days post-archive, data is permanently deleted.»

Implementation: scheduled daily job → check organisations WHERE tier='free_guest' AND last_activity_at < NOW() - INTERVAL '12 months' → trigger email at T-30/T-7/T-0 → flip status at T-0 + 90d cron for hard delete.

Audit: new event categories в Phase 1.3 §2.4.A — free_guest_archive_scheduled / free_guest_archived / free_guest_unarchived / free_guest_hard_deleted.

Pick #3 — Conversion triggers (C-Full cascade)

Total: 6 emails (3 trial + 3 post-trial). Plus in-app banner + inline modals.

Trial period (T-14 → T-0) — SPEC-AMEND v1.1 CONV-35 reconciled cascade (Sales motion H2 fix — Pick #3 originally listed T-7/T-1/T+0; Step 8 silently added T-3 via Stripe webhook; now explicit 4-email Trial cadence):

Trigger Surface Content
All days T-14 → T-0 Header banner «TRIAL — N days left» с countdown
Click on locked feature (any day) Modal Feature pitch + screenshot + «Upgrade to {tier} →» CTA (per Pick #4)
T-7d Email «7 days left in your trial · Unlock Pro by adding a card →» (daily cron lookup)
T-3d Email «Trial ends in 3 days. Add a card to keep your Org active.» (triggered by Stripe customer.subscription.trial_will_end webhook — fires 3 days before trial_end)
T-1d Email «Trial ends tomorrow · Add card to continue with Pro» (daily cron)
T+0 Email «Your trial ended · You've been moved to Free Guest. Reactivate anytime →» (triggered by Stripe customer.subscription.deleted webhook)

Post-trial (T+0 → T+30):

Trigger Surface Content
T+0 Dashboard banner «Your trial ended. Reactivate to publish more projects →»
Any click on Object Builder / paid feature Modal Upgrade pitch (per Pick #4 click-through)
T+7d Email «You've been on Free Guest for a week · Reactivate?»
T+23d Email «Your published sales page freezes in 7 days · Reactivate to keep it live»
T+29d Email «Last day before your sales page goes offline»
T+30d System action Public sales-app freeze (per Phase 1.3 §1.8.B) — returns 410 Gone with «Site temporarily offline» branded page

Re-pay anytime: instant restore (per Phase 1.3 §1.8.B) — data preserved, no re-onboarding needed.

⚠ Hard prerequisite for Phase 1.2 launch: ADR 0011 (email sender architecture) ratified + Phase 1.8.6 (transactional email) shipped. Without it — fall back to Pick #3 alternative A (in-app only) Stage 1, add emails Stage 1.5.

Pick #4 — Trial lock UX (C-Click-through) [+ SPEC-AMEND v1.1 CONV-35 — B4 ratification: Trial = Full Pro 14d, lock modals only для SSO]

Pattern after B4 ratification: Trial = full Pro tier для 14d (per Pick #1 amendment) → all Starter + Pro features ACTIVE (not locked) during Trial. Lock modals fire only для Enterprise features (SSO) + tier-specific edge cases beyond Pro entitlement.

Trial sidebar layout (post-B4):

Lock modals during Trial — fire ONLY для:

Lock modal template (when fired):

[Feature icon] [Feature name]
[Screenshot of feature in use]
[One-sentence value prop]
[Available on {Enterprise / Pro tier}]
[Contact sales / Upgrade to Pro →]  [Continue trial]

Modal closes by clicking outside / Esc / «Continue trial» → returns to current page.

Tier-feature mapping (Stage 1 — numbers config-driven; post-B4):

Feature Min Tier Trial behaviour Modal CTA (если locked)
Team management (up to 3 seats Trial) Starter (T1) ✅ ACTIVE in Trial (3 seats)
Project analytics (views / leads / conversion) Pro (T2) ✅ ACTIVE in Trial
Buyer records Starter (T1) ✅ ACTIVE in Trial
Audit log Starter (T1) ✅ ACTIVE in Trial
Custom domain CNAME Pro (T2) ✅ ACTIVE in Trial (setup может outlive Trial)
DKIM email branding Pro (T2) ✅ ACTIVE in Trial
Microsoft / SAML SSO Enterprise (T3) 🔒 Locked (Enterprise tier) «Contact sales →»
2nd project creation Trial limit 🔒 Locked (Pick #12 1-project hard cap) «Upgrade to Pro для more projects →»
API access (Stage 4 backlog) (hidden) (hidden)

Post-Trial behaviour (Trial → Free Guest auto-downgrade):

Build effort (post-B4): 2 lock modal templates Stage 1 (SSO + 1-project cap during Trial) + 8-12 post-Trial lock modals = ~3-5 days designer/copywriter work (same total as pre-B4, modals just shift к post-Trial trigger).

Pick #5 — URL architecture (resolves Phase 1.2.2 vs Phase 1.3 sub-plan §1.7.J conflict)

Confirmed canonical model:

Surface URL pattern
Public sales-app (default) {org-slug}.offplan.online/{project-slug}/... (Org subdomain + project path)
Public sales-app (Tier 2+ custom domain) palmresidences.com/... (custom domain → same project)
Admin panel (central) app.offplan.online
Operator dashboard staff.offplan.online
Buyer tokenised unit URL {org-slug}.offplan.online/{project-slug}/units/{unit-slug}?b={token} (per Phase 1.3 §1.7.J)
Custom domain buyer URL palmresidences.com/units/{unit-slug}?b={token}

Phase 1.2.2 HTML amendment required — currently выглядит как project-slug.offplan.online (each project own subdomain). Outdated since Phase 1.3 sub-plan ratification. Amendment: rewrite §1.2.2 description к Org subdomain + project path. Sub-plan ratification + Phase 1.2.2 amendment = same PR.

Wildcard DNS: *.offplan.online → origin (same as before, но resolves Org subdomain not project subdomain).

Subdomain resolution middleware: {slug}.offplan.onlineorganisation_id lookup (instead of project_id). Project resolution = path-based subsequent step.

Pick #6 — Slug uniqueness

Slug type Uniqueness Reserved list
Org slug Global (across all Organisations) 58 reserved subdomains per Phase 1.3 §2.7 + validation rules
Project slug Per-Org (within Organisation namespace) Только reserved chars ([a-z0-9-], no leading/trailing hyphen)

Conflict resolution на signup:

Pick #7 — Slug change later

Slug type Policy
Org slug Changeable раз в 12 calendar months (cooldown). Old slug → 301 redirect 90d (preserve customer bookmarks / inbound links), потом freed для re-use. Audit log entry per §2.4.A Membership lifecycle. UI: Settings → Organisation → «Change subdomain» (with «N months until next change available» if in cooldown)
Project slug Free change (no cooldown). Buyer tokenised URLs invalidate on slug change → forced rotation per Phase 1.3 §1.7.J rules. Warning modal pre-change: «Changing this slug will invalidate N active buyer links. They will need to be re-issued.»

Edge case: Org slug change DURING active 90d redirect window of previous change → blocked, message «Wait until {date} when current redirect expires».

Pick #8 — Custom domains per project (Tier 2+)

Pick #9 — Custom domain transfer between projects

Stage 1: NOT allowed self-serve. Operator-mediated transfer через Phase 1.4.7 (same flow paradigm as ownership transfer post-payment per Phase 1.3 §1.5.F):

Security rationale: prevents «domain-squatting» internal attacks (rogue team member transfers customer-facing domain to abandoned project to cause harm).

Stage 2: self-serve if abuse не материализуется.

Pick #10 — AI Floor Plan tags infrastructure

Stage 1: Manual page picker. CONV-21 Atelier promise «Drop a folder — AI tags pages» degraded gracefully:

Phase 1.5.6 deliverable (post-ADR 0014 ratification): AI overlay auto-tags pages with confidence score; user reviews + edits if needed (1-click correction).

Rationale: Phase 1.2 launch не блокируем на Phase 1.5.6 + ADR 0014 ratification. Manual fallback unlocks Trial → Paid funnel immediately. AI tags = enhancement, not gate.

Pick #11 — Live Preview empty state

Before any upload (right panel в Object Builder):

Mid-upload: placeholder заменяется real content per section по мере появления. Progress indicator in left panel: Upload 1/3 → 2/3 → 3/3 → Publish ready.

No fake demo content. Live Preview = WYSIWYG of actual project state (per CONV-21 Atelier supersedes v4.3 demo-project pattern).

Pick #12 — Object Builder для 2nd+ project [+ SPEC-AMEND v1.1 CONV-35 — B1 ratification: 3 seats Trial]

Object Builder = «+ New Project» entry point ALWAYS для Paid users (per CONV-21 Atelier).

Action Trial user (up to 3 seats) Free Guest Starter+ user
Click «+ New Project» (1st time) Opens Object Builder split-screen Blocked — modal «Upgrade to Starter to create your first project» Opens Object Builder split-screen
Click «+ New Project» (2nd attempt during Trial) Blocked (Trial = 1 project hard limit) — modal «Trial limited to 1 project · Upgrade to Pro for more →» n/a (no limit per Pro tier)
Click «Invite teammate» (Trial) Active — Owner can invite up to 2 additional users (B1 ratification — 3 seats total). Limit reached → modal «Trial limited to 3 seats» Hidden Per-tier seat limits config-driven
[SAVE & CONTINUE] in Object Builder Publishes project → returns to Full Admin с new project active n/a Publishes → returns to Full Admin
[SKIP] in Object Builder Creates draft project (no uploads), edit via Full Admin manually n/a Same

Trial limits (B1 + Pick #12 + B4 ratified): 1 project hard · 3 seats hard · Pro features active (per B4) · 14 days duration.

Pick #13 — Free Guest sidebar/dashboard [+ SPEC-AMEND v1.1 CONV-35 — B2 ratification: Free Guest × host-project capability matrix]

Free Guest × host-project capability matrix (Model B per B2 — Edit content, no publish):

Capability Free Guest in host Org project Notes
View project (admin + public sales-app) All sections accessible read-only
Edit units / floor plans / hero / gallery / copy Content collaboration enabled
Edit project settings (slug / custom domain) Owner-only — slug change invalidates buyer URLs (revenue surface)
Publish / unpublish project Owner-only — publish = revenue moment
Invite more Guests Owner-only
View analytics / buyer records / audit log Owner-only — sensitive Org data
Configure Pro features (DKIM / custom domain DNS) Owner-only
Receive invoice / pay Owner's Stripe Customer pays

Phase 1.3 §1.3.C cross-ref: requires amendment Stage 1.5 to formalise this matrix в tenancy-permission sub-plan. Sub-plan 2 locks Stage 1 implementation; Phase 1.3 sub-plan amendment ratifies long-term tenancy contract.

Workflow example (Studios S2 scenario resolved): Studio Alpha owner invites developer-client (Emaar) as Free Guest на «Palm Residences» project. Developer logs in → sees project in their Free Guest dashboard Guest projects section → clicks → enters studio-alpha.offplan.online/palm-residences с Free Guest capabilities (per matrix above). Developer can collaborate on content (renders, floor plans) но не публикует / settings / analytics. Если developer хочет full control → ownership transfer flow (Phase 1.3 §1.5.F).

Free Guest = minimal sidebar:

Item State Notes
Dashboard Active Welcome message + guest memberships preview + «+ Create project» CTA (= lazy conversion trigger per Phase 1.3 §2.8.A)
Guest projects Active if N>0 guest memberships List view + Org switcher (Guest section per Phase 1.3 §1.3.C) — клик на guest membership → перенаправляет в {guest-org-slug}.offplan.online/{project-slug} с External SA role
Frozen project preview _[SPEC-AMEND v1.1 CONV-35 — Studios M4 fix]_ Active if previous_tier ∈ {trial, tier_1+} and Trial-or-paid projects exist in Free Guest archive window Read-only preview of the 1 Trial-era project (or N previously-paid projects). Visible card: «{Project Name} — frozen» + «Reactivate to edit & publish →» CTA. Без этого row пользователи думают что их работа потеряна at Trial expiry.
Object Builder Hidden Не появляется до first paid subscription
Projects (own) Hidden Free Guest can't create projects (NEW ones; previous projects shown via Frozen preview row above)
Team Hidden No team в Free Guest
Analytics Hidden No own projects → no data
Audit log Hidden
Buyer records Hidden
Settings → Profile Active Name / email / locale / password
Settings → Plan & Billing Active Main upgrade surface
Settings → Referrals Visible disabled «Upgrade to start referring» per Phase 1.3 §2.3 pick 6
Header «Free Guest» badge (gold accent) + Org name + avatar dropdown

Empty state on Dashboard если no guest memberships: «Welcome — you're on Free Guest. Create your first project to get started →» [Upgrade CTA].

Pick #14 — «Plan & Billing» page Stage 1 scope

Page layout (Settings → Plan & Billing):

Section Stage 1 Stage 2
Current plan badge ✅ — large card «Starter · $X/mo · Renews on {date}»
Trial countdown banner ✅ if tier === tier_1 + trial_active → «Trial ends in N days · Add card to continue →»
Free Guest banner ✅ if tier === free_guest → «Upgrade to access full features →»
Tier comparison table ✅ — 3 columns (Starter / Pro / Enterprise), Free Guest implicit via «Get started» CTA refined visual
Pricing display config-driven — UI renders {tier.monthly_price ?? "Pricing TBD — contact us"} from pricing-config.ts numbers final
Upgrade CTA per tier ✅ → Stripe Checkout (per Pick #1 stable slugs tier_1 / tier_2 / tier_3_enterprise)
Stripe customer portal link ✅ — «Manage payment method · Invoices» button → Stripe-hosted portal
Downgrade flow ✅ — modal preview per ADR 0013 (asymmetric: upgrade prorated, downgrade end-of-period)
Tier change schedule preview ✅ — Stripe upcoming_invoice API call → «You'll be charged $X on {date}» pre-confirmation
Usage metrics (X/Y projects, Z/N team, units) ✅ inline per tier card
Invoice history inline — (use Stripe portal) ✅ inline list
Referral payout status (placeholder per Phase 4.2)

Config schema (illustrative — Roma + Ilya finalise):

// pricing-config.ts
export const pricing = {
  tier_1: {
    slug: "tier_1",
    display: "Starter",
    monthly_price: null,  // populated post-Roman
    limits: {
      projects: null,
      team_members: null,
      units_per_project: null,
      guest_organisations: null,
    },
    features: ["object_builder", "team_management", "buyer_records", "audit_log"],
  },
  tier_2: { ... },
  tier_3_enterprise: { ... },
};

Part 1B — Stage 1 Recommendations (Step 3 closures CONV-35)

Research-derived recommendations locked в plan body alongside Step 1 ratified picks. Не Picks (которые user-facing UX decisions), а implementation-pattern recommendations from research findings.

R1 — Trial abuse mitigation Stage 1 (layer #1 only)

Trial-без-карты = индустрия-стандарт 2026 (Notion / Linear / Vercel — no-card; Figma — optional card). Stage 1 abuse risk низкий (~100 projects target), и specific risks (subdomain squatting / email phishing / AI cost arbitrage) лучше решаются на естественных слоях (Pick #6 reserved list / ADR 0011 / ADR 0014) чем generic Trial caps.

Stage 1 abuse mitigation stack (layer #1 only — recommendation lock'нут CONV-35):

Component Source Notes
Cloudflare Turnstile on signup form Research F10 Free tier, ~5 min integration. Blocks majority of automated bots.
Email verification required pre-Trial activation Already in plan (Path A signup) Existing onboarding flow — confirm не дропнут implementation.
Email domain age check Research F10 Block domains registered <7 days ago (signal for disposable inbox providers).
IP rate limit Research F10 Max 3 signups per IP per 24h. Stored Redis OR Stripe metadata.
Pick #12 hard cap Step 1 ratified Trial = exactly 1 project. Limits compute/storage abuse extraction.

Specific risks routed to their natural homes (NOT in Trial caps):

Post-launch monitoring (Phase 1.7.11 analytics):

Explicit anti-scope: NO daily caps на API calls / buyer presentation views Stage 1. Trial UX остаётся clean. Сompute/storage abuse devaluation = Pick #12 hard cap (1 project) already locks it sufficiently.

R2 — Stripe formal Stage 1 ratification (ADR 0017 NEW)

Stripe = de-facto payment provider across ADRs 0006 (chargeback webhook charge.dispute.created), 0013 (proration SubscriptionSchedule + create_prorations), Pick #14 (Checkout + Portal). Never formally ratified — launch-plan-v3.md:798-815 still has «Stripe vs Paddle TBD» as open question. CONV-35 user confirmed «Yes — draft ADR 0017».

ADR 0017 scope (draft в Step 4):

Note: ADR 0017 written jurisdiction-agnostic. Tax/VAT specifics resolved on legal entity lock per memory directive.


Part 2 — Plan Body (Step 4 closures CONV-35)

Goal

Phase 1.2 implementation-ready spec. Roma reads plan + Phase 1.2 ratified callout → понимает все user-facing flows для Phase 1.2.1-1.2.5 (signup, Trial, Quick Build/Object Builder, tier transitions, Plan & Billing UI) без новых вопросов «что должно происходить». Ilya reads → даёт tech estimate. ADR 0008 status остаётся accepted-structure-pending-numbers (numbers config-driven через pricing-config.ts, populated post-Roman input). Phase 1.2.2 HTML amendment + ADR 0008/0013/0017 amendments + Sub-plan 2 ratification = единый PR.

Success Criteria

Approach

Single approach (Step 3 closed). Picks ratified + research findings concrete → no competing alternative architectures. Plan body = implementation specification of ratified picks + new research-derived sections.

12 implementation step groups:

  1. Stripe Customer lifecycle (deferred creation, trial mechanics, conversion, restore)
  2. Tier transitions (upgrade/downgrade/preview UI flows)
  3. Webhook event subscriptions (8-event tier state machine)
  4. Multi-currency pricing (currency_options single Price)
  5. Trial abuse mitigation Stage 1 (layer #1 stack expansion)
  6. Tax handling (jurisdiction-agnostic Stage 1)
  7. GDPR + retention (customers.del() + invoice cold-storage)
  8. Day-by-day Trial timeline (T-14 → T+30 detailed cascade)
  9. Free Guest archive cascade (12mo inactivity → hard delete)
  10. URL architecture + slug rules (Org subdomain + project path)
  11. Plan & Billing UI (Stage 1 minimal surface)
  12. Free Guest sidebar + Object Builder gating

Reuse где возможно: Phase 1.3 §1.4.D invite token entropy (signup verification reuses same primitive), §1.7.J buyer URL token mechanics (reused для tokenised invite emails), §1.8.A/B/E tier transition state machine (ratified CONV-33+34).


Implementation Step 1 — Stripe Customer lifecycle

1.1 — Deferred Customer creation pattern (per Research F2 + D2 lock + SPEC-AMEND v1.1 CONV-35 billing address fix).

User registers platform signup (Path A или B per Foundational §2):

Rationale (F2): 80%+ Path B Guest users never upgrade → нет смысла создавать Stripe namespace pollution. GDPR Art. 17 deletion для них = просто local DB record delete (no Stripe customers.del() call needed).

1.2 — Trial subscription creation (no-card pattern, per Research F6 + SPEC-AMEND v1.1 CONV-35 B4 ratification: Trial = T2 Pro default).

Immediately after Path A signup Customer creation, server calls POST /v1/subscriptions с Pro tier Price (per B4):

{
  customer: "cus_xxx",
  items: [{ price: "price_tier_2_monthly" }],
  trial_period_days: 14,
  payment_behavior: "default_incomplete",
  payment_settings: { payment_method_types: ["card"] },
  metadata: {
    org_id: "org_alpha",
    trial_cohort_date: "2026-05-11",
    trial_tier: "tier_2"
  }
}

Post-Trial conversion paths (B4 implications):

1.3 — Trial → Paid conversion mechanics (per Research F4).

User adds payment method (через embedded Checkout / Customer Portal / direct POST /v1/payment_methods). Stripe auto-finalizes pending invoice + transitions subscription trialingactive at original billing cycle date (NOT immediately).

Edge case: user adds card on Day 3 of trial → trial continues для оставшихся 11 days → first paid charge lands Day 14 (subscription anniversary).

Если требуется immediate charge (rare — UX prefers delayed): server calls POST /v1/invoices/{subscription_id}/finalize_invoice после PM attach → forces invoice generation + payment attempt within seconds.

Stage 1 default: delayed charge (industry-standard). UI message after PM attach: «You're now on the Pro plan. Your first charge of ${amount} will occur on {trial_end_date}.»

1.4 — Re-pay после Free Guest archive (per Research F3).

Per Pick #2 cascade: user inactive 12mo → archive → 90d data retention → hard delete. Subscription cancelled на archive flip (T-0 in cascade). User decides to re-pay BEFORE hard delete (T+0d to T+90d window):

subscriptions.resume does NOT exist Stripe 2026 API. Server calls fresh POST /v1/subscriptions:

{
  customer: "cus_xxx",  // same Customer; never deleted unless T+90d hard-delete fired
  items: [{ price: "price_tier_1_monthly" }],
  trial_period_days: 0,  // no new trial — reactivation flow
  metadata: {
    org_id: "org_alpha",
    reactivation_reason: "archive_recovery",
    previous_subscription_id: "sub_xxx_previous",
    reactivation_date: "2026-08-15"
  }
}

UX message (per B5 ratification — current advertised price + transparency):

1.5 — Invoice cold-storage export + deletion journal atomicity (per Research F12 + SPEC-AMEND v1.1 CONV-35 — Security H1 + Finance M3 fixes).

⚠️ Cold-storage = load-bearing, NOT defence-in-depth (Finance audit gap #5 fix): Cyprus / UAE / etc. 7yr tax retention claim is anchored на our S3 Object Lock archive, NOT Stripe's «indefinite» policy (which is not contractually guaranteed for 7yr in any single jurisdiction). ADR 0004 v2 amendment reframes this — see Part 3.

Continuous export trigger (Finance L2 fold): invoice.finalized webhook event fires immediate cold-storage write для that single invoice → invoices accumulate в archive throughout Org lifecycle, not just at hard-delete.

Archive structure:

deletion_journal schema (Security H1 fix — atomic staged deletion с idempotent resume):

CREATE TABLE deletion_journal (
  org_id UUID PRIMARY KEY,
  stage TEXT NOT NULL,  -- enum: 'queued' / 'write_lock' / 'dispute_check' / 'export_verify' / 'stripe_delete' / 'local_purge' / 'audit_pseudonymise' / 'complete'
  stage_completed_at TIMESTAMPTZ,
  last_attempted_at TIMESTAMPTZ,
  retry_count INT NOT NULL DEFAULT 0,
  error_message TEXT,
  metadata JSONB  -- counts, IDs, error details per stage
);

Cascading hard-delete at T+90d — 7-stage idempotent state machine. Each stage idempotent + crash-safe (resume jobs read journal, skip completed stages):

Stage 0 — Queue: INSERT INTO deletion_journal с stage='queued'. Triggered by daily free_guest_hard_delete cron (per Step 9.2) querying organisations WHERE status='free_guest_archived' AND archived_at < NOW() - INTERVAL '90 days'.

Stage 1 — Write lock (Security H1 sub-fix): set organisations.write_locked_at = NOW() + flag «deletion in progress». Stripe Customer metadata deletion_lock: true. Concurrent webhook handlers see lock → no-op tier flips / PII recreation. Audit event deletion_write_lock_acquired.

Stage 2 — Open dispute check (Finance M3 fix): disputes.list({ customer: cus_xxx, status: ['needs_response', 'warning_needs_response', 'under_review'] }). If non-empty → ABORT cascade, flag для operator review (error_message: "Open dispute X — manual review required"). Operator decides: complete dispute first OR explicit override.

Stage 3 — Final invoice quiesce (Security M3 fix): cancel subscription if exists. Wait 48h quiesce window для any final period-end invoices / credit notes / dispute-related events к settle. Re-poll disputes.list after quiesce.

Stage 4 — Export verify: GET /v1/invoices?customer=cus_xxx (paginated) → confirm every invoice has corresponding S3 Object Lock archive. Re-export any missing. Count match Stripe vs local S3 OR abort с error.

Stage 5 — Stripe delete: DELETE /v1/customers/{cus_xxx}. Stripe deletes PII (name, email, billing address). Invoice history retained queryable by cus_xxx ID. Audit event stripe_customer_deleted.

Stage 6 — Local purge: cascade delete personal data (email, name, phone, IP history) per ADR 0004 v2 pseudonymisation rules в users + organisations + sessions + invitations tables. Keep audit log entries (Stage 7 handles pseudonymisation там).

Stage 7 — Audit pseudonymise: per ADR 0004 v2 — strip PII fields from audit_events entries older than 12mo active retention; replace с pseudonymous tokens. Hash-chain seal recomputed.

Stage Complete: UPDATE deletion_journal SET stage = 'complete'. Audit event free_guest_hard_deleted final entry (with pii_class = pseudonymous — survives с stripped PII).

Resume / monitoring:


Implementation Step 2 — Tier transitions UI flows

2.1 — Upgrade flow (T1 → T2, T1 → T3, T2 → T3) per Pick #14 + ADR 0013.

⚠️ CRITICAL — SPEC-AMEND v1.1 CONV-35 (Finance H1 fix): использовать subscriptions.update, НЕ Checkout Session. Checkout Session с mode: "subscription" СОЗДАЁТ новую subscription для существующего Customer → двойной billing (ADR 0013 line 33 explicitly mandates subscriptions.update pattern; Sub-plan body initially diverged — fixed CONV-35 per business review).

Existing-subscription tier upgrade pattern:

User в Plan & Billing page (Settings → Plan & Billing per Pick #14). Clicks «Upgrade to Pro →» button next to Pro tier card.

Server retrieves existing subscription, then calls:

POST /v1/subscriptions/{sub_xxx}
{
  items: [
    { id: "{si_xxx_existing_tier_1_item}", price: "price_tier_2_monthly" }
  ],
  proration_behavior: "create_prorations",
  payment_behavior: "default_incomplete",
  expand: ["latest_invoice.payment_intent"],
  metadata: { org_id: "org_alpha", tier_change: "tier_1→tier_2", initiated: "2026-05-11" }
}

If a fresh PaymentIntent is required (e.g. SCA needed для prorated charge), expose latest_invoice.payment_intent.client_secret к client → confirm via Stripe Elements inline. No redirect необходим для existing-Customer upgrade.

После payment success → webhook handler:

Checkout Session reserved для NEW-subscription scenarios only:

Proration: proration_behavior: "create_prorations" per ADR 0013 — Stripe charges prorated difference immediately; new tier features unlock instantly. Full Pro period billing resumes at next anniversary.

2.2 — Downgrade flow (T3 → T2, T3 → T1, T2 → T1) per ADR 0013.

User в Plan & Billing page. Clicks «Downgrade to Starter →» on Starter tier card (visible only if currently on higher tier).

UI shows preview modal:

You'll switch to Starter on {renewal_date} ({N} days from now).
Until then, you keep Pro access.
No refund will be issued for the unused Pro period.

[Confirm downgrade]  [Keep Pro]

Server creates schedule anchored к existing subscription (SPEC-AMEND v1.1 CONV-35 — Finance M1 fix: from_subscription parameter prevents duplicate-subscription bug):

POST /v1/subscription_schedules
{
  from_subscription: "sub_xxx_current",
  metadata: { org_id: "org_alpha", tier_change: "tier_2→tier_1", scheduled: "2026-05-11" }
}

Returns schedule с phases inferred from existing subscription. Then update schedule с downgrade phase:

POST /v1/subscription_schedules/{schedule_id}
{
  end_behavior: "release",
  phases: [
    {
      items: [{ price: "price_tier_2_monthly", quantity: 1 }],
      start_date: "now",
      end_date: <current_period_end>
    },
    {
      items: [{ price: "price_tier_1_monthly", quantity: 1 }],
      iterations: 1,
      proration_behavior: "none"
    }
  ]
}

On schedule activation date → Stripe fires customer.subscription.updated event → handler flips Organisation tier.

2.3 — Tier change preview (per Research F1 — replaces deprecated upcoming_invoice).

Preview modal calls POST /v1/invoices/create_preview (NOT deprecated GET /v1/invoices/upcoming):

{
  customer: "cus_xxx",
  subscription: "sub_xxx_current",
  subscription_details: {
    items: [{ price: "price_tier_2_monthly", quantity: 1 }]
  }
}

Response includes prorated amounts → UI renders:

⚠️ Critical: ADR 0013 originally referenced upcoming_invoice endpoint → ADR 0013 v1.1 amendment fixes endpoint name (see Part 3).


Implementation Step 3 — Webhook event subscriptions (per Research F9 + SPEC-AMEND v1.1 CONV-35)

Single webhook endpoint at POST /api/stripe/webhooks handles 14 events (expanded from initial 8 per Finance H2/H3/H4 + M2 + Security M2 business review fixes).

Endpoint security baseline (Security M2 fix):

Event subscription table (14 events):

Event Handler action Critical?
customer.subscription.created Set organisations.tier = subscription tier; record trial_start_at if trialing Critical
customer.subscription.updated Update organisations.tier если status или price changed; emit audit event tier_change_* per ADR 0004 v2 Critical
customer.subscription.trial_will_end Fires 3d before trial end → triggers T-3 email (per Pick #3 cascade); banner update High
customer.subscription.deleted Flip organisations.tierfree_guest; trigger T+0 email + dashboard banner; schedule 30d public sales-app freeze cron per Phase 1.3 §1.8.B Critical
customer.subscription.paused (Reserved для ADR 0006 chargeback auto-freeze) — flip organisations.statussuspended; admin UI banner High
customer.subscription.resumed Reverse of paused — restore tier access; audit event High
invoice.paid Provision/extend tier access; emit «paid invoice» event для revenue recognition (cash-basis per Phase 1.3 §1.8.C) Critical
invoice.payment_failed Dunning trigger (separate from charge.failed — invoice-level signal including SCA flows + bank declines где no charge attempt); trigger «payment failed» email Critical
invoice.payment_action_required SCA / 3D Secure required; trigger «card requires verification» email + flag account state High
invoice.finalized Trigger invoice cold-storage export job per Step 1.5 (Object Lock S3 PDF write) Medium
charge.failed Log к dunning queue (charge-level failure; complementary к invoice.payment_failed); retry orchestration default Stripe handles up к 4 retries Low
charge.refunded Emit NEGATIVE revenue event (cash-basis recognition divergence prevention per Finance audit gap #1); audit log refund_issued pii_class = sensitive Critical
credit_note.created Same as charge.refunded для credit-note refund pattern (used by support-issued refunds via Stripe Dashboard per ADR 0013) High
charge.dispute.created ADR 0006 auto-freeze trigger — flip organisations.statussuspended; notify operator + Sergey via Phase 1.4.7; assemble dispute evidence packet per Step 14 Critical
charge.dispute.closed Process dispute outcome — если status: won → unfreeze; если status: lost → keep suspended + operator action к offer refund/payment-method-update Critical
checkout.session.completed Reconciliation event (confirms Checkout flow completed cleanly — paired с subscription.created/updated) Medium

NOT subscribed: payment_intent.succeeded (redundant с invoice.paid для subscription flows per F9).

Daily reconciliation job (Finance audit gap #2 fix):

Webhook secret management (Security M5 fix):


Implementation Step 4 — Multi-currency pricing (per Research F5)

Per Pick #14 config-driven pricing + Research F5: single Stripe Price ID per tier, currency auto-resolved by Stripe via currency_options.

Setup (one-time, Stripe Dashboard or API):

POST /v1/prices
{
  product: "prod_tier_1_starter",
  currency: "usd",  // base currency
  unit_amount: <TBD post-Roman>,
  recurring: { interval: "month" },
  currency_options: {
    eur: { unit_amount: <TBD> },
    aed: { unit_amount: <TBD> }
  }
}

Returns single price_tier_1_monthly ID — used everywhere в codebase. Stripe auto-charges customer в local currency on Checkout based on Customer's address country (set during signup OR captured at Checkout).

pricing-config.ts schema (refined Pick #14):

export const pricing = {
  tier_1: {
    slug: "tier_1",
    display: "Starter",
    stripe_price_id: "price_tier_1_monthly_xxx",  // single ID, multi-currency
    base_currency_display: { usd: null, eur: null, aed: null },  // numbers TBD Roman
    limits: { /* TBD Roman */ },
    features: ["object_builder", "team_management", "buyer_records", "audit_log"]
  },
  tier_2: { /* ... */ },
  tier_3_enterprise: { /* ... */ }
};

Currency display in UI: detect user's locale from browser OR Organisation country setting → render appropriate base_currency_display value. Fallback к USD если unmapped locale.

Stage 1 scope: 3 currencies (USD/EUR/AED). Additional currencies = currency_options extension, no code change.

Stage 2 deferred: Stripe Adaptive Pricing (auto-FX 100+ currencies) — Stage 1 prefers manual control над cohort financial modelling.


Implementation Step 5 — Trial abuse mitigation Stage 1 (R1 expansion)

Layer #1 stack from Part 1B R1, implementation detail:

5.1 — Cloudflare Turnstile:

5.2 — Email verification (already in plan + SPEC-AMEND v1.1 CONV-35 — Security H3 fix: token URL hardening).

Token URL hardening (Security H3 — mirror Phase 1.3 §1.7.J buyer-URL header set; addresses Referer / cache / browser-history / third-party tracker leakage):

5.3 — Email domain age check:

5.4 — IP rate limit:

5.5 — Pick #12 hard cap (1 project Trial):

5.6 — Post-launch monitoring (+ SPEC-AMEND v1.1 CONV-35 — Sales motion H5 fix: reactivation funnel KPIs):


Implementation Step 6 — Tax handling Stage 1 (jurisdiction-agnostic)

Per memory directive (Cyprus de-prioritised CONV-35, Abu Dhabi leading) — этот section стесняется к jurisdiction-agnostic. Specific tax rules resolved on legal entity lock.

6.1 — Stripe Tax baseline (works any jurisdiction):

6.2 — Tax ID capture (+ SPEC-AMEND v1.1 CONV-35 — Finance M4 fix: VIES async validation + corrective invoice flow):

Abuse vector: invalid VAT ID → reverse-charge applied → seller liable для unreceived VAT. Mitigation: VIES async + corrective invoice closes loop. Stage 1 acceptable risk (low fraud vector at our scale); Stage 2 может add real-time VIES check pre-Checkout-complete.

6.3 — Customer address resolution:

6.4 — Jurisdiction-specific rules (resolved on entity lock):

6.5 — MoR revisit trigger:


Implementation Step 7 — GDPR + retention (per Research F12)

7.1 — Customer data minimisation:

7.2 — customers.del() semantics (Stripe 2026):

7.3 — Retention duration:

7.4 — GDPR DSR (Data Subject Request) flow Stage 1:


Implementation Step 8 — Day-by-day Trial timeline (T-14 → T+30)

Expands Pick #3 cascade в actionable timeline. Times relative to Trial start (Day 0).

Day Event Surface Action
Day 0 Signup + email verified Onboarding modal Trial starts. Welcome email sent. Subscription trialing created (per Step 1.2).
Day 0 → 13 All days Header banner «TRIAL — {N} days left» counter (visible on все Admin pages, NOT on public sales-app per Pick #4 clarification — sales-app is buyer-facing)
Day 0 → 13 Click on locked feature Modal Per Pick #4 modal template — feature pitch + upgrade CTA
Day 7 T-7 reminder Email «7 days left in your trial · Unlock Pro by adding a card →» (Stripe webhook customer.subscription.trial_will_end does NOT fire here — too early — manually-scheduled job)
Day 11 T-3 reminder Email «Trial ends in 3 days. Add a card to keep your Org active.» (Stripe webhook customer.subscription.trial_will_end fires Day 11 = 3d before trial end — handler triggers this email)
Day 13 T-1 reminder Email «Trial ends tomorrow · Add card to continue with Pro»
Day 14 (T+0 trial end) Auto-flip Stripe webhook If no PM attached: subscription auto-transitions к incomplete_expired via Stripe; webhook customer.subscription.deleted fires; handler flips organisations.tierfree_guest. If PM attached: subscription transitions trialingactive; first paid charge processed.
Day 14 (T+0) Email If no-conversion path «Your trial ended · You've been moved to Free Guest. Reactivate anytime →»
Day 14 (T+0) Dashboard banner If no-conversion path «Your trial ended. Reactivate to publish more projects →»
Day 14 → 43 (T+0 → T+30) Any click on Object Builder Modal Upgrade pitch per Pick #4 click-through
Day 21 (T+7) Email «You've been on Free Guest for a week · Reactivate?»
Day 37 (T+23) Email «Your published sales page freezes in 7 days · Reactivate to keep it live»
Day 43 (T+29) Email «Last day before your sales page goes offline»
Day 44 (T+30) Public sales-app freeze System action Phase 1.3 §1.8.B — sales-app returns 410 Gone с branded «Site temporarily offline» page. Admin remains accessible (Free Guest sidebar per Pick #13).

Trial badge edge cases (resolved):

Trial email scheduling:

Pre-launch dependency: ADR 0011 (email sender architecture) ratified + Phase 1.8.6 transactional email shipped. Fallback if ADR 0011 not ratified by Phase 1.2 launch: in-app banner + dashboard banner only; emails phased in once ADR 0011 + Phase 1.8.6 ready (Stage 1.5).


Implementation Step 9 — Free Guest archive cascade (12mo inactivity)

Implements Pick #2 cascade. Triggered nightly by Free Guest archive cron.

9.1 — Inactivity detection:

9.2 — Cascade timeline (+ SPEC-AMEND v1.1 CONV-35 — CS M5 fix: multi-Org membership cascade scoped к Org status, NOT user login):

⚠️ User login NOT blocked at archive flip. Archive flips organisations.status: free_guest_archived для THAT Org only. User holds memberships across N Orgs (per Phase 1.3 §1.3.C Guest section + Pick #13 sidebar); archiving one Org должно НЕ блокировать access к active Orgs. Login session checks membership scope per Org; archived Orgs hidden из Org switcher.

Trigger Action Reversible?
T-30d (inactivity threshold + 11mo grace) Email «Your Free Guest workspace will be archived in 30 days · Sign in to keep it active» Yes — login resets last_activity_at, cascade aborts
T-7d Email reminder с inline reactivation link Yes — same
T-0 (archive flip) DB: organisations.status: free_guest_archived (NOT user login — see CS M5 fix) · public sales-app returns 410 Gone · data preserved 90d · Stripe subscription cancelled if exists Yes — support email → operator un-archive via Phase 1.4.7 (archive_un_archive action)
T+90d (hard delete) (1) Cold-storage invoice export verify; (2) customers.del() if Stripe Customer exists; (3) Local DB cascade delete PII per ADR 0004 v2; (4) Audit log pseudonymisation No

9.3 — Audit events added к Phase 1.3 §2.4.A:

9.4 — Operator un-archive flow (per Phase 1.4.7):


Implementation Step 10 — URL architecture + slug rules

Implements Picks #5 (URL architecture), #6 (slug uniqueness), #7 (slug change), #8 (custom domains), #9 (custom domain transfer).

10.1 — Subdomain resolution middleware (per Pick #5 + SPEC-AMEND v1.1 CONV-35 — Security M1/M6 + threat model gaps fixes):

Host header canonicalisation (Security M1 fix — prevents Host injection / X-Forwarded-Host trust attacks):

Org resolution:

requireMembership pattern (Security M6 fix — explicit tenant-boundary enforcement на every admin route):

// Every admin route handler MUST gate via:
const { user, resolved_org_id } = await ctx();
const membership = await requireMembership(user.id, resolved_org_id);
if (!membership || !membership.scope.includes('admin_access')) {
  return forbidden();
}

Subdomain middleware sets resolved_org_id from Host, but route handlers MUST re-verify membership against (user_id, org_id, scope) per Phase 1.3 §1.5.B. Без этой проверки — stolen cookie + crafted Host header = cross-tenant access.

Security baseline headers (threat model gap fix — applied to ALL routes Stage 1):

Cookie domain scope: admin session cookies scoped к app.offplan.online ONLY (NOT .offplan.online wildcard). Prevents tenant XSS → admin session leak attack (Security threat model gap #2). Cross-domain SSO Phase 1.5.4 implements via signed token exchange, not shared cookie scope.

10.2 — Project path resolution:

10.3 — Custom domain resolution (Tier 2+) + DNS verification (SPEC-AMEND v1.1 CONV-35 — Security H2 fix: subdomain takeover prevention):

DNS verification flow (Security H2 — prevents subdomain takeover where customer's CNAME removed/expired but backend still routes):

10.4 — Slug change UI (per Pick #7):

10.5 — Slug change cooldown enforcement:

10.6 — Custom domain transfer (operator-mediated) per Pick #9:


Implementation Step 11 — Plan & Billing UI (Stage 1 minimal)

Implements Pick #14. Settings → Plan & Billing page.

11.1 — Page structure:

┌─────────────────────────────────────────────┐
│ Plan & Billing                              │
├─────────────────────────────────────────────┤
│                                             │
│  [Current Plan]                             │
│  ┌─────────────────────────────────────┐   │
│  │ Pro · ${monthly_price}/mo           │   │
│  │ Renews on {next_billing_date}       │   │
│  │ [Manage payment & invoices →]       │   │
│  └─────────────────────────────────────┘   │
│                                             │
│  [Trial countdown banner — if tier=trial]   │
│  «Trial ends in N days · Add a card →»     │
│                                             │
│  [Compare plans]                            │
│  ┌─────────┬─────────┬─────────┐           │
│  │ Starter │  Pro    │ Enterpr │           │
│  │ ${...}  │ ${...}  │ Contact │           │
│  │ [Curr.] │ [Upgr→] │ [Contact│           │
│  └─────────┴─────────┴─────────┘           │
│                                             │
│  [Need help?] Contact support@...           │
└─────────────────────────────────────────────┘

11.2 — Current Plan card:

11.3 — Trial countdown banner:

11.4 — Free Guest banner:

11.5 — Compare plans table [+ SPEC-AMEND v1.1 CONV-35 — B7 ratification: Enterprise mailto Stage 1]:

11.5.1 — Invoice reference field [+ SPEC-AMEND v1.1 CONV-35 — B3 ratification]:

Settings → Plan & Billing → «Invoice Reference» optional text field:

Invoice Reference (optional)
[                                        ]
This appears on your invoice PDFs — useful for forwarding to your end-client (e.g. "Emaar Palm Residences Q3-2026 PO #ABC").
[Save]

Server applies via Stripe Subscription.invoice_settings.custom_fields:

POST /v1/subscriptions/{sub_xxx}
{
  invoice_settings: {
    custom_fields: [
      { name: "Reference", value: "{user_input}" }
    ]
  }
}

Reference rendered on all future invoice PDFs auto-generated by Stripe (включая first paid invoice + every renewal). Empty field = no custom_fields applied. Studios resell к developers / agencies resell к brokerages — этим field'ом get clean P.O./project ref attribution at invoice level.

11.6 — Stripe Customer Portal configuration:

11.7 — Tier downgrade preview UI:


Implementation Step 12 — Free Guest sidebar + Object Builder gating

Implements Pick #11 (Live Preview), #12 (Object Builder для 2nd project), #13 (Free Guest sidebar).

12.1 — Free Guest sidebar visibility (Pick #13):

Sidebar item Visible? Disabled? Notes
Dashboard Welcome message + Guest memberships preview + «+ Create project» CTA
Guest projects ✅ if guest_memberships.count > 0 List view + Org switcher
Object Builder Hidden entirely (не появляется до first paid subscription)
Projects (own) Hidden
Team Hidden
Analytics Hidden
Audit log Hidden
Buyer records Hidden
Settings → Profile Name / email / locale / password
Settings → Plan & Billing Main upgrade surface
Settings → Referrals ✅ disabled «Upgrade to start referring» per Phase 1.3 §2.3
Header — «Free Guest» badge Gold accent (Pick #13)

12.2 — Trial sidebar visibility (Pick #4):

12.3 — Object Builder gating (Pick #12):

Click Trial user Free Guest Starter+ user
«+ New Project» button Blocked: «Reactivate to create more projects» (Trial = 1 project hard) Blocked: «Upgrade to Starter to create your first project» Opens Object Builder
[SAVE & CONTINUE] in Object Builder n/a n/a Publishes project → returns к Full Admin с new project active
[SKIP] in Object Builder Hidden Hidden Creates draft project (no uploads), edit via Full Admin

12.4 — Live Preview empty state (Pick #11) + Day 0 activation checklist [+ SPEC-AMEND v1.1 CONV-35 — B6 ratification: Atelier REQUIRED UPLOADS cross-ref clarification]:

Day 0 activation checklist = REQUIRED UPLOADS panel в Object Builder per Atelier mockup (docs/mockups/admin-quick-build-atelier-standalone.html):

Live Preview right panel (Pick #11):

Success-moment upsell modals (B6 ratification — 3 modals Stage 1):

Triggered on positive engagement events (NOT lock-on-click defensive pattern); fires euphoria-moment upsell vs friction-moment friction.

Modal Trigger event Stage 1 messaging (designer copy TBD)
First publish modal First project.published event (Object Builder SAVE & CONTINUE → public sales-app live) «🎉 Your sales page is live at {project_url}. Share it with your team or developer-client. Pro features that grow it: analytics shows who's viewing · custom domain for brand · DKIM email for white-label inquiries →»
First buyer link modal First buyer_token.issued event (agent generates tokenised URL for buyer per Phase 1.3 §1.7.J / future Pick action) «You sent your first buyer link. Track who's viewing + leads on Pro analytics →»
First 50 unit-views modal First unit_view_count >= 50 aggregate event «Your page is getting traction — 50+ unit views. Analyze visitor behaviour + capture leads on Pro →»

Behavioral characteristics:


Implementation Step 13 — Operator playbook & support workflows (SPEC-AMEND v1.1 CONV-35 — CS H1+H3 fix + 9 operator action dependencies)

Sub-plan 2 generates Day-1 support load that hits Phase 1.4.7 Operator Dashboard. Без specification operator team будет improvise → inconsistent decisions + audit gaps. Step 13 consolidates 9 operator actions + support workflows для Phase 1.4.7 sub-plan dependency.

13.1 — Support inbox topology (CS H1 fix):

Stage 1 routing — 3 inboxes:

Stage 1.5 routing: shared inbox via Front / Help Scout / equivalent (out of scope Sub-plan 2; Phase 1.4.7 sub-plan ratifies tooling choice).

13.2 — 9 operator actions (Phase 1.4.7 dependencies):

Action Trigger Identity verification Reason field Audit event SLA
archive_un_archive User missed cascade, support ticket request Verified email ownership OR support ticket trail с signed identity Enum (см. 13.3) free_guest_unarchived 2 business days
custom_domain_transfer Support ticket, customer-to-customer or intra-Org Clone §1.5.F 4-signal fraud scoring (per CS H5 fix + B12 ratify) — ≥2 signals → 5d cooldown Enum (см. 13.3) custom_domain_transferred 2 business days
signup_allowlist False positive on domain-age block / IP rate-limit Operator judgement Freetext («legitimate business domain X verified manually») signup_allowlisted 4h
trial_extend_grace Vacation / out-of-band Trial expiry Verified email ownership Enum (см. 13.3) trial_extended 24h; capped +7d max, once per Org lifetime
slug_force_change Org slug typo at signup, 12mo cooldown not yet elapsed Verified email ownership + Owner role Freetext («typo correction: {old} → {new}») slug_force_changed 4h
chargeback_unfreeze См. 13.4 chargeback dispute resolution Verified email ownership + dispute submission evidence Enum chargeback_unfrozen 4h
gdpr_dsr_export GDPR DSR (data export request) Signed challenge к Owner email + 2-factor если enabled Enum (per B13 ratify) gdpr_dsr_exported 30d (Art. 12)
gdpr_dsr_delete GDPR DSR (right to erasure) Dual-control (two operators sign-off per Security H4 + B13 ratify) + signed challenge + 7d cooldown Enum + legal_hold_reason if delayed gdpr_dsr_deleted 7d cooldown + 24h execute
tier_manual_adjustment Out-of-band billing correction (e.g. refund-tied tier reversal) Operator + Sergey approval Enum + Stripe charge/refund ID tier_manually_adjusted 4h

13.3 — Reason field enums (CS H2 fix + B10 ratify):

Single canonical enum across operator actions (categorical reason taxonomy + freetext fallback):

reason_enum = [
  'vacation_recovery',           // user away during cascade
  'email_delivery_failure',      // bounce / spam folder / wrong address
  'ownership_dispute',           // Org ownership contested
  'acquisition',                 // M&A / studio acquired by larger entity
  'compliance_request',          // legal / regulatory / GDPR
  'fraud_recovery',              // post-Radar / chargeback aftermath
  'typo_correction',             // signup error
  'other'                        // + mandatory freetext
]

Audit log stores reason_enum + optional reason_freetext. Aggregation queries per enum surface abuse patterns (e.g. spike в vacation_recovery ≠ vacation = phishing recovery; spike в acquisition = M&A trend signal).

13.4 — Chargeback dispute resolution playbook (CS H3 fix — CS6 scenario):

Chargeback charge.dispute.created webhook fires (Step 3) → auto-freeze per ADR 0006 → operator playbook activates:

  1. Stage 1 — Triage (within 4h ack):
    • Operator opens dispute case в operator dashboard (Phase 1.4.7).
    • Pulls dispute evidence: Stripe dispute reason code (e.g. fraudulent / subscription_canceled / product_not_received / general) + customer history (invoice paid timestamps, login activity, project activity).
    • Pulls all related audit events для that Customer/Org.
  2. Stage 2 — Customer outreach (within 24h):
    • Email customer at registered email: «We received a chargeback notice from your bank for ${amount}. Your account is temporarily frozen. Please respond within 5 days с context.»
    • Template variants per reason code (4 templates, copy TBD designer).
  3. Stage 3 — Evidence submission к Stripe (within Stripe's deadline, typically 7-21 days):
    • Operator assembles evidence packet via Stripe dispute UI:
      • Invoice PDF (from cold-storage archive Step 1.5)
      • Audit log entries showing service delivery (login events, project published timestamps, buyer URL issued events)
      • Customer email correspondence trail
      • ToS acceptance timestamp
    • Submit via Stripe Dashboard → charge.dispute.closed webhook fires upon resolution.
  4. Stage 4 — Outcome handling:
    • Won (status: won): chargeback_unfreeze action → flip organisations.statusactive + refund any payment-method-update charges. Customer notified.
    • Lost (status: lost): keep frozen. Operator offers customer 2 paths: (a) settle via payment method update + small reactivation fee (если Customer Success authority allows; default Stage 1 = no fee) → tier restored; (b) decline → permanent freeze + invoice cold-storage retention preserved (no hard delete until standard 12mo+90d cascade).
  5. Refund authority matrix:
    • Up to ${refund_authority_low TBD Sergey, e.g. $100}: operator unilateral.
    • Above threshold: Sergey approval required.
    • Audit log с full justification mandatory.

13.5 — Email-delivery-status surface (CS M1 fold):

Operator dashboard shows per-Org email delivery health metric:

13.6 — Email collision on signup recovery (CS H4 fix — addresses race condition where archived-Org email re-signs-up during T-0 → T+90d window):

Signup endpoint check pre-Customer-creation: SELECT * FROM organisations WHERE owner_email = ? AND status IN ('free_guest_archived', 'suspended'). If match:

13.7 — Multi-tenant buyer-data GDPR DSR (CS M3 acknowledgement):

Multi-tenant buyer-data ownership cascade depends on legal-multi-party-framework.md sub-plan ratification (P1, parked). Step 13 documents the dependency:


Files to Create/Modify

Application code (Roma + Ilya scope):

Config / setup (one-time, Stripe Dashboard or API):

Documentation:


Dependencies

Hard prerequisites (block Phase 1.2 launch):

Soft prerequisites (degrade gracefully if not ready):

External services:


Testing

Unit tests (Vitest):

Integration tests (Vitest + Stripe test mode):

E2E tests (Playwright):

Manual smoke (pre-launch):

Test data:


Risks

  1. ADR 0011 not ratified by Phase 1.2 launch → email cascade (Pick #3) can't ship. Mitigation: in-app banner fallback + Stage 1.5 email enablement. Risk lowering: schedule ADR 0011 /plan session ASAP (Roma flagged it 5+ sessions carry-over).
  2. Stripe API breaking changes 2026-01-28.preview → stable → Customer Portal config endpoint version pin needs update. Mitigation: monitor Stripe changelog; integration test suite catches breakage. Risk acceptable — preview API has 12-month deprecation policy.
  3. Domain age check false positives → legitimate users on new business domains blocked. Mitigation: support email override → operator allowlist. Logging captures all blocks → tune threshold post-launch.
  4. Stripe Tax not yet supporting final jurisdiction (UAE if confirmed) → manual tax handling Stage 1. Mitigation: research UAE Stripe Tax status on jurisdiction lock; fallback к flat-rate VAT if Stripe Tax unavailable.
  5. Pick #14 config-driven pricing numbers parked indefinitely → Plan & Billing page renders «Pricing TBD — contact us» longer than expected. Mitigation: visible pre-launch escalation to Roman for pricing decision. Acceptable Stage 1: Studios/Agencies inquire via [email protected] → manual quote.
  6. Cloudflare Turnstile abuse (Cloudflare service disruption blocks all signups) → legitimate users locked out. Mitigation: feature flag toggle bypass Turnstile; rollback к email-verify-only Stage 1 fallback.
  7. Invoice cold-storage export job failures → audit defence gap. Mitigation: nightly verification job comparing Stripe invoice count vs S3 archive count; alerting on discrepancies.
  8. Free Guest archive cascade emails not delivered (deliverability issue) → users miss reactivation window. Mitigation: in-app banner sufficient signal until ADR 0011 / Phase 1.8.8 DKIM ships.

Workstreams

Creates ONE new workstream Step 5:

workstreams/onboarding-trial-implementation.md (P0, tags: ux, billing, vendor):

Cross-references existing workstreams:


Evaluation

Done when (Step 4 closure → ratify same PR):

Phase 1.2 launch-ready when (Step 5 workstream complete):


Part 3 — ADR Amendments + HTML Amendment (Step 4 PR scope)

A1 — ADR 0017 NEW (full text для docs/decisions/0017-payment-provider-stripe-stage1.md)

---
id: "0017"
title: "Payment provider — Stripe Stage 1"
status: accepted
date: 2026-05-11
ratified_session: CONV-35
---

# 0017 — Payment provider — Stripe Stage 1

## Context

offplan.online is a self-serve B2B subscription SaaS productising property sales-presentation software. Stage 1 launch targets ~100 live projects across rendering studios (UAE/EU/US) + real estate agents/agencies. Payment provider choice impacts: tax compliance complexity, vendor lock-in, fee structure, UX (Checkout flow), webhook/API surface, multi-currency capability.

Stripe was **de-facto chosen** across multiple ADRs without explicit ratification:
- ADR 0006 (chargeback auto-freeze) references Stripe webhook `charge.dispute.created`.
- ADR 0013 (proration policy) uses Stripe primitives `SubscriptionSchedule` + `proration_behavior: create_prorations`.
- Sub-plan 2 (`plans/onboarding-trial-mode.md`) Pick #14 references Stripe Checkout + Customer Portal.
- `launch-plan-v3.md:798-815` still has «Stripe vs Paddle TBD» as open question — this ADR closes that.

CONV-35 user confirmation: «Yes — draft ADR 0017».

## Decision

**Stripe = Stage 1 payment provider for offplan.online.**

Operational stance:
- All payment processing, subscription management, tax calculation, invoice generation routed через Stripe.
- Stripe Tax enabled (`automatic_tax: true` on Checkout) — Stripe handles VAT / sales tax / FTA computation based on Customer address + tax IDs.
- Stripe Customer Portal = primary self-serve UI для card updates, invoice history, subscription cancellation.
- Stripe Checkout Session = primary upgrade flow surface (vs. embedded components — Stage 1 prefers redirect simplicity).
- 8-event webhook subscription для tier state machine (per `plans/onboarding-trial-mode.md` Part 2 Step 3).
- Multi-currency через single `Price` ID с `currency_options` (USD/EUR/AED Stage 1).

## Alternatives Considered

- **(A) Paddle (Merchant of Record).** Defers tax compliance burden to Paddle (Paddle is contractual seller; handles all VAT / sales tax / e-invoicing globally). Trade-off: 5-10% higher platform fees + vendor lock-in (subscription data tied to Paddle account; migration complexity). **Rejected Stage 1**: financial inflection point ~$250k–$500k ARR — below that, Stripe + manual tax handling cheaper. Revisit при ARR threshold OR jurisdiction-specific compliance ops cost spike.
- **(B) Both Stripe + Paddle hybrid.** Stripe for some regions, Paddle for others. Rejected: complexity tax (2× integration + 2× webhook surfaces + 2× reconciliation logic) outweighs benefit at Stage 1 scale.
- **(C) Custom billing infrastructure (no provider).** Build invoice generation + payment intent + tax calculation in-house. Rejected: 6-12 month engineering investment с substantial compliance + audit risk. Inappropriate Stage 1.
- **(D) Chargebee / Recurly / other billing platform.** Adds abstraction layer over Stripe. Rejected: unnecessary Stage 1 (Stripe API surface sufficient); cost premium (~$300-1000/mo) not justified; Stripe direct = simplicity.

## Consequences

- **Phase 1.2 implementation** (Sub-plan `plans/onboarding-trial-mode.md`) — Stripe API integration во всех subscription flows. Specifics: Checkout Session redirect upgrade · Customer Portal self-serve mgmt · SubscriptionSchedule downgrade · `create_preview` endpoint для preview UI.
- **Phase 1.7.10** (Custom domain self-serve flow + DKIM Tier 2+) — Stripe Tax enables proper invoice content для custom-domain customers. DKIM email sending handled by Phase 1.8 stack (per ADR 0011); Stripe не interferes.
- **Phase 1.7.11** (Free tier billing tracking) — `tier: free_guest | trial | tier_1 | tier_2 | tier_3_enterprise` enum в billing schema. Stripe Customer record exists for tier_1+ only (deferred creation per Sub-plan 2 Step 1.1).
- **ADR 0006** (chargeback auto-freeze) — confirmed Stripe webhook `charge.dispute.created` integration pattern.
- **ADR 0008** (tier model) — Stripe Price IDs map к tier slugs (stable IDs). Numbers parked pending Roman input.
- **ADR 0013** (proration policy) — confirmed Stripe `proration_behavior: create_prorations` upgrade pattern + `SubscriptionSchedule` downgrade pattern. v1.1 amendment fixes deprecated `upcoming_invoice` → `create_preview` endpoint.
- **MoR revisit trigger** — ARR > $250k–$500k OR jurisdiction-specific tax compliance ops cost spike (e.g. UAE FTA filing complexity post-jurisdiction lock).
- **Closes** `launch-plan-v3.md:798-815` open question «Stripe vs Paddle TBD».

## Revisit trigger

- ARR threshold $250k–$500k → MoR (Paddle) cost-benefit recalculation.
- Cyprus / UAE / jurisdiction-specific tax compliance ops cost spike (manual filing exceeds Paddle fee premium).
- Competitor analysis shows MoR as meaningful market signal (e.g. category-leading SaaS shift к Paddle for global SaaS sales).
- Stripe outage / reliability concerns sustained > 1 month (multi-provider failover discussion).

## Cross-references

- Sub-plan 2 — `plans/onboarding-trial-mode.md` (Part 1 + Part 1B + Part 2 implementation steps)
- ADR 0006 — Chargeback auto-freeze (Stripe webhook integration)
- ADR 0008 — Tier model (Stripe Price IDs map к tier slugs)
- ADR 0009 — Tenancy & Permission (Organisation = subscription unit)
- ADR 0011 — Email sender architecture (separate from payment provider)
- ADR 0013 — Tier change proration policy (Stripe SubscriptionSchedule + create_prorations)
- Phase 1.7.10 — Custom domain DKIM Tier 2+ feature
- Phase 1.7.11 — Free tier billing tracking
- `launch-plan-v3.md:798-815` — closes «Stripe vs Paddle TBD»
- Learning «PaymentProvider abstraction для кипрской компании» (2026-04-29) — superseded by this ADR (Cyprus-specific framing obsolete per CONV-35 jurisdiction shift)

A2 — ADR 0013 v1.1 amendment (inline edit to docs/decisions/0013-proration-policy.md)

Change: replace deprecated endpoint name in Consequences §3.

Find (current text):

«UI: tier-switch modal computes preview via Stripe upcoming_invoice endpoint to show «You'll be charged {amount} today» on upgrade, «No charge today — switches on {date}» on downgrade.»

Replace with (v1.1):

«UI: tier-switch modal computes preview via Stripe POST /v1/invoices/create_preview endpoint (replaces deprecated GET /v1/invoices/upcoming per Stripe 2025-03-31 changelog) to show «You'll be charged {amount} today» on upgrade, «No charge today — switches on {date}» on downgrade.»

Add к frontmatter:

amendments:
  - { version: "v1.1", date: "2026-05-11", session: "CONV-35", change: "Endpoint rename — deprecated upcoming_invoice replaced with create_preview" }

A3 — ADR 0008 amendment (inline edit to docs/decisions/0008-tier-model.md)

Change: add note про config-driven pricing structure (resolves «numbers parked» open question elegantly without changing status).

Add к Decision section, после tier structure description:

«Config-driven pricing surface: per Sub-plan 2 plans/onboarding-trial-mode.md Pick #14, pricing rendered from pricing-config.ts schema. UI layout, components, Stripe integration — all ratified (CONV-34 + CONV-35). Numbers (monthly_price, limits.*) = config values populated post-Roman input. Status accepted-structure-pending-numbers remains correct: structure ratified; numbers config-driven, не code-blocking.»

Update Cross-references к include:

A4 — Phase 1.2.2 HTML amendment text (для docs/rendered/launch-plan-stage-1.html)

Change: Phase 1.2.2 (URL architecture) currently describes per-project subdomain pattern ({project-slug}.offplan.online). Outdated since Phase 1.3 sub-plan ratification + Sub-plan 2 Pick #5 — actual model is Org subdomain + project path.

Find (current text — Phase 1.2.2 section, approx line range — Roma confirms on apply):

«Phase 1.2.2 — URL architecture (per-project subdomain) Каждый project получает subdomain: {project-slug}.offplan.online. Wildcard DNS *.offplan.online → app origin. Middleware resolves к project_id...»

Replace with:

«Phase 1.2.2 — URL architecture (Org subdomain + project path) _[v4.19 CONV-35]_

Canonical model (resolves CONV-33 Phase 1.3 sub-plan §1.7.J × Phase 1.2.2 conflict + Sub-plan 2 Pick #5):

Surface URL pattern
Public sales-app (default) {org-slug}.offplan.online/{project-slug}/...
Public sales-app (Tier 2+ custom domain) palmresidences.com/...
Admin panel (central) app.offplan.online
Operator dashboard staff.offplan.online
Buyer tokenised unit URL {org-slug}.offplan.online/{project-slug}/units/{unit-slug}?b={token}

Wildcard DNS *.offplan.online → origin. Subdomain middleware resolves {slug}.offplan.online к organisation_id (not project_id). Project resolution = path-based subsequent step. Cross-link Sub-plan 2 plans/onboarding-trial-mode.md Step 10.»

Update Phase 1.2.2 callout (top of Phase 1.2 section) к reflect v4.19 amendment.


Part 4 — Open items explicitly parked (need external input)

Item Blocker Owner
Tier monthly prices ($X / $Y / $Z) Hosting cost projection (Phase 1.7.13) + Roman pricing strategy Roman + Sergey
Exact limits per tier (projects / team / units / guests) Same as above + Roman Roman + Sergey
Email copy (6 templates: T-7/T-3/T-1/T+0/T+7/T+23/T+29) Designer / copywriter resource Ilya (designer)
Upgrade modal copy + screenshots (8-12 templates) Same as above Ilya
ADR 0011 email sender architecture Separate /plan session needed Sergey
ADR 0014 MCP wrapper auth full spec Separate /plan session needed Sergey
Hosting cost projection on 100/500/1000 Orgs (Phase 1.7.13) Source data from VV AWS billing Roma/Ilya
Legal entity lock (Cyprus / Abu Dhabi / etc.) User decision pending Sergey
UAE-specific tax research (parallel к Cyprus research) Triggered on legal entity lock Sergey
Cyprus / UAE / etc. e-invoicing pipeline (XML submission) Stage 2 Phase 1.7 build session Roma + Ilya

Source


Changelog