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:
- HTML callout
docs/rendered/launch-plan-stage-1.htmlPhase 1.2 → flipped к ratified state (mirror Phase 1.3 pattern) - Workstream
workstreams/onboarding-trial-implementation.md— ✅ created CONV-35 - ADR 0008 amendment (config-driven pricing + Trial = T2 default) — ✅ pushed
- ADR 0013 v1.1 amendment (deprecated
upcoming_invoice→create_preview) — ✅ pushed - ADR 0017 NEW (Stripe Stage 1 formal ratification) — ✅ pushed
- Phase 1.2.2 HTML amendment (Org subdomain + project path) — apply при ratification PR commit
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):
- 13 picks ratified (Tier naming Hybrid · Free Guest expiry · Conversion triggers · Trial lock UX · URL architecture · Slug rules · Custom domains · AI Floor Plan tags · Live Preview empty state · Object Builder reuse · Free Guest sidebar · Plan & Billing scope)
- 6 HTML open questions resolved (slug change · uniqueness · custom domain count · transfer · AI infra · Live Preview · 2nd project flow)
- Phase 1.2.2 amendment proposal (Org subdomain + project path)
- ADR 0008 amendment proposal (status + config-driven numbers note)
OUT (explicit parking):
- Конкретные цены / лимиты per tier →
pricing-config.tspost-Roman input + hosting cost projection (Phase 1.7.13) - Email copy для 6 templates (T-7/T-1/T+0/T+7/T+23/T+29) — pending designer/copywriter
- 8-12 upgrade modal templates copy + screenshots — pending designer
- ADR 0011 (email sender) full spec — separate sub-plan / dedicated /plan session
- ADR 0014 (MCP wrapper auth) full spec — separate /plan session (per CONV-34 placeholder shell)
Anchored ADRs + Sub-plans + Foundational sections
- ADR 0008 — Tier model (status
accepted-structure-pending-numbers→ remains after this sub-plan; numbers config-driven но не финализированы) - ADR 0011 — Email sender architecture (Shell, ratification gate for Phase 1.2 launch — 6 emails depend on it)
- ADR 0014 — MCP wrapper auth (placeholder, gate for Phase 1.5.6 AI Floor Plan tags)
- ADR 0009 — Tenancy & Permission (Full, ratified CONV-31) — Organisation entity + 5 roles
- ADR 0013 — Tier change proration policy (ratified CONV-34) — upgrade prorated, downgrade end-of-period
- Phase 1.3 sub-plan
plans/permission-and-tenancy-model.md:- § 1.4.D — invite token entropy (≥128-bit CSPRNG + hashed + constant-time compare + rate-limit) — same primitive for Phase 1.2 signup email verification + invite tokens
- § 1.7.J — buyer tokenised URL mechanics (≥128-bit + scope + rotation + noindex headers)
- § 1.8.A/B/E — tier transitions (Trial → Paid, Trial expired → Free Guest, Free Guest → Paid)
- § 2.8 — Free Guest → paid conversion mechanics
- Foundational §2 — Onboarding paths A/B/C
- Foundational §3 — Billing + tier scaffold
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:
- (A) Numeric only (T1/T2/T3) — marketing-неживо, ниже conversion rate
- (B) Marketing-only с принятым «Studio» collision — конфликт с Organisation type «Studio» (multi-select per ADR 0009)
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 | «7 days left in your trial · Unlock Pro by adding a card →» (daily cron lookup) | |
| T-3d | «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 | «Trial ends tomorrow · Add card to continue with Pro» (daily cron) | |
| T+0 | «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 | «You've been on Free Guest for a week · Reactivate?» | |
| T+23d | «Your published sales page freezes in 7 days · Reactivate to keep it live» | |
| T+29d | «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):
- All Full Admin nav items visible AND ACTIVE during Trial (Projects · Team · Analytics · Buyer records · Audit log · Settings → all subsections).
- Team = active (per B1 ratification: up to 3 Trial seats; Owner can invite 2 teammates).
- Custom domain настройка = active (DNS CNAME instructions visible; setup может занять > 14d → продолжается на post-Trial subscription if user upgrades).
- DKIM настройка = active.
- Object Builder = entry point для «+ New Project» (per Pick #12 — 1 project hard cap; Object Builder remains во время Trial).
Lock modals during Trial — fire ONLY для:
- Microsoft / SAML SSO click → «Contact sales →» modal (Enterprise tier — not unlocked by Trial).
- Click on «+ New Project» after 1 project already created (Pick #12 hard cap) → «Trial limited to 1 project · Upgrade to Pro for more →» modal.
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):
- All Pro features visible но locked.
- Lock modals fire on click per ORIGINAL Pick #4 mapping (full feature pitch + upgrade CTA per tier).
- Returns к pre-B4 lock modal structure: Team locked «Upgrade to Starter →» / Analytics locked «Upgrade to Pro →» / Custom domain locked «Upgrade to Pro →» / etc.
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.online → organisation_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:
- Org slug taken → «{slug}-2 / {slug}-3 / try another» suggestions
- Project slug taken within Org → inline form error «You already have a project with this slug»
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+)
- Stage 1: 1 primary custom domain per project + N aliases (301 redirect к primary). No upper limit on aliases.
- Use case: Studio published project на
palmresidences.com+ aliaspalmresidences.ae(regional TLD) → both work, aliases redirect to primary. - Stage 2 revisit: if abuse pattern (e.g. 1 project = 50+ aliases для SEO spam) → introduce limit.
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):
- Support ticket request → operator verifies identity + Org ownership of both source + target projects
- Operator console action
custom_domain_transferwith audit log entry - Target SLA: 2 business days (same как ownership transfer)
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:
- User uploads PDF → server splits to JPEG pages (existing pipeline OR Phase 1.5.6 endpoint)
- All pages shown in vertical list with radio buttons:
[Floor plan] [Render] [Brochure / Other] - User one-click per page → publish
- Bulk action «Mark all as Floor Plan» if uniform PDF
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):
- Branded skeleton — Org name + colour tokens (Org default sand/navy/gold if Brand не задан) + section headers:
- Exterior — placeholder gradient + «AWAITING UPLOAD →»
- Floor Plans — placeholder grid + «AWAITING UPLOAD →»
- Gallery — placeholder thumbnails + «AWAITING UPLOAD →»
- Subdomain badge (top-left):
{org-slug}.offplan.online/{project-slug}(live as soon as project name + slug entered) - Desktop / Tablet / Mobile switcher above preview (per CONV-21 Atelier mockup)
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):
- Subdomain squatting (
emaar,dubai,palm-residences) → Pick #6 reserved-list extension в Phase 1.2 launch checklist (add brand-relevant slugs to 58-slug reserved set). - Email reputation abuse (Trial sends phishing via
{slug}.offplan.online) → ADR 0011 sub-plan resolution (email-sender architecture включает per-Org sending rate-limit + abuse detection). - AI cost arbitrage (Phase 1.5.6 Floor Plan tagging бот гоняет paid AI endpoint) → ADR 0014 sub-plan resolution (MCP wrapper auth + per-Org AI quota).
Post-launch monitoring (Phase 1.7.11 analytics):
- KPI:
trial_abuse_flag_count(signups flagged by any layer-#1 component). - KPI:
trial_to_paid_conversion_rate(must hold within industry baseline ~15-25%). - Revisit trigger: if abuse flag rate >10% sustained 30 days, escalate to layer-#2 (device fingerprinting via FingerprintJS Pro $99-500/mo + behavioural monitoring per research F10). Layer #2 НЕ deploy'им Stage 1 — overcaution для нашего scale.
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):
- Status:
accepted - Decision: Stripe Stage 1 payment provider for offplan.online. Closes open question в
launch-plan-v3.md. - Alternatives Considered: Paddle (MoR, defers tax compliance — but 5-10% fee premium, lock-in concerns). Paddle revisit trigger: ARR > $250k–$500k where manual tax compliance cost exceeds Paddle fee premium.
- Consequences: cross-ref ADR 0006 (chargeback) · ADR 0008 (tier model) · ADR 0013 (proration policy) · Phase 1.7.10 (custom domain DKIM = Tier 2+) · Phase 1.7.11 (free tier billing tracking).
- Revisit Trigger: ARR threshold ($250k–$500k) OR jurisdiction-specific tax compliance cost spike (e.g. UAE FTA complex requirements) OR competitor analysis suggests MoR market signal.
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
- ✅ All 14 Picks (Part 1) + 2 Recommendations (Part 1B) implementable из plan body без re-asking «what should happen at X».
- ✅ Day-by-day Trial timeline (T-14 → T+30) explicit (triggers · UI states · email sends · webhook events).
- ✅ Stripe API patterns called out по exact endpoint name + 2026 API version (
2026-01-28.previewгде relevant — нет «figure out the right Stripe API» tech debt). - ✅ Files / Dependencies / Testing sections concrete для Phase 1.2 workstream creation Step 5.
- ✅ ADR 0017 NEW + ADR 0013 v1.1 + ADR 0008 amendment + Phase 1.2.2 HTML amendment drafted внутри sub-plan (ratify same PR).
- ✅ Jurisdiction-agnostic body (per memory directive — Cyprus de-prioritised CONV-35, Abu Dhabi leading candidate). Tax/VAT specifics flagged for resolution on jurisdiction lock.
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:
- Stripe Customer lifecycle (deferred creation, trial mechanics, conversion, restore)
- Tier transitions (upgrade/downgrade/preview UI flows)
- Webhook event subscriptions (8-event tier state machine)
- Multi-currency pricing (
currency_optionssingle Price) - Trial abuse mitigation Stage 1 (layer #1 stack expansion)
- Tax handling (jurisdiction-agnostic Stage 1)
- GDPR + retention (
customers.del()+ invoice cold-storage) - Day-by-day Trial timeline (T-14 → T+30 detailed cascade)
- Free Guest archive cascade (12mo inactivity → hard delete)
- URL architecture + slug rules (Org subdomain + project path)
- Plan & Billing UI (Stage 1 minimal surface)
- 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):
- Path A signup: user completes signup form → email verify. Signup form MUST capture billing address fields (country mandatory; line1 / city / postal optional Stage 1 — per Finance H5 fix:
automatic_tax: truerequires complete validated address with country code on Customer record BEFORE first paid invoice finalises).- After verify → entered «Quick Build / Trial onboarding» state. At this moment server calls
POST /v1/customerswith payload: ``` { email: "[email protected]", name: "Studio Alpha Org Owner", address: { country: "AE", // mandatory; ISO 3166-1 alpha-2 line1: "Sheikh Zayed Rd 123", city: "Dubai", postal_code: "12345" }, metadata: { org_id: "org_alpha", cohort: "trial_path_a", signup_date: "2026-05-11" } } ``` - Response
cus_xxxsaved asorganisations.stripe_customer_id(Phase 1.3 §1.3.A schema column). - Fallback if signup form skips address (operator-onboarded Org или imported data): use Checkout Session при Trial-to-Paid conversion с
billing_address_collection: "required"to capture address before first paid invoice. Trial subscription still works без address (no invoice generates during trial); BUT first paid invoice will failautomatic_taxif address still missing.
- After verify → entered «Quick Build / Trial onboarding» state. At this moment server calls
- Path B signup (reverse invite Guest): user accepts Guest membership → лочится в Free Guest tier. NO Stripe Customer record created. Owner-side Organisation already имеет
stripe_customer_id; Guest Organisation остаётся без Stripe footprint until он upgrades.
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"
}
}
- Stripe Price =
price_tier_2_monthly(Pro tier) per B4 ratification — Trial unlocks Pro features for evaluation; matches industry baseline (Notion / Linear / Vercel). payment_behavior: "default_incomplete"— mandatory когда no payment method attached. Prevents phantom $0 invoice.- Subscription enters
trialingstatus.trial_endtimestamp =created + 14d. - При истечении 14d без attached PM → Stripe auto-transitions к
incomplete_expiredчерез ~23 hours. Webhookcustomer.subscription.deletedfires → our handler flips Organisationtier: trial→tier: free_guest.
Post-Trial conversion paths (B4 implications):
- Stay on Pro: user adds card before trial_end → subscription transitions
trialing→activeat original cycle date. Pro features remain unlocked. First charge ontrial_end. - Downgrade to Starter at conversion: user explicitly chooses Starter via Plan & Billing page during trial → server creates
SubscriptionScheduleс phase change attrial_end(T2 → T1 transition). Subscription remainstrialinguntiltrial_end, then auto-switches к T1. - Don't convert (no card by trial_end): Free Guest auto-downgrade per existing flow. Pro features lock; Pick #4 post-Trial modal pattern fires on click.
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 trialing → active 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):
- Pre-reactivation modal renders: ``` Welcome back. Your Organisation data is restored.
Current pricing: · Pro: $X/mo · Starter: $Y/mo {if changed since original Trial: «When you started your trial: Pro was $Z/mo» line — comparison helps customer understand pricing context} [Reactivate Pro] [Reactivate Starter] [Not now]
- After reactivation: «You're now back on {tier}. Your subscription will renew on {next_billing_date}.» — confirms instant DATA restore + new subscription clock + new pricing acknowledged.
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:
- Destination: S3 bucket с Object Lock в Compliance mode (jurisdiction lock revisit может switch к Governance mode per Security L5 — Compliance is over-spec'd if neither Cyprus nor UAE mandate). Path:
invoices/{org_id}/{invoice_id}.pdf. - Retention: jurisdiction-dependent (placeholder
{retention_duration}pending lock; defaults 7yr conservative). - Purpose: tax audit defence + GDPR Art. 17 evidence trail.
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:
- Daily cron retries stuck journals (
last_attempted_at < NOW() - INTERVAL '24 hours'ANDstage != 'complete'). Max retry 5 attempts → operator alert. - Per-Org cascade should complete <72h typical. Alert anything
> 7 daysот queue. - Stages 5/6/7 idempotent — re-run safe (Stripe
customers.del()returns 404 if already deleted = success; local purge usesIS NOT NULLpredicates).
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:
- Receives
customer.subscription.updated+invoice.paidevents. - Updates local
organisations.tier→tier_2(immediately). - Triggers «upgrade success» onboarding modal in admin UI: «You're now on Pro. New features unlocked: custom domain, DKIM email branding.»
- Writes audit log entry
tier_change_upgradeсpii_class = sensitiveper ADR 0004 v2.
Checkout Session reserved для NEW-subscription scenarios only:
- First paid subscription из Free Guest state (no existing subscription к update).
- Trial-to-Paid via Free Guest detour (rare; usually direct PM attach per Step 1.3).
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:
- Upgrade preview: «You'll be charged ${prorated_amount} today. Next renewal: ${full_tier_amount} on {next_billing_date}.»
- Downgrade preview: «No charge today. Your plan switches to Starter on {current_period_end}. You'll be charged ${starter_amount} on that date.»
⚠️ 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):
- Raw-body capture mandatory: Stripe signature is computed over raw request bytes. If body-parser middleware JSON-parses before signature verification → signature breaks. Route handler MUST register с
express.raw({ type: 'application/json' })(или равноценный) BEFORE any global JSON parser. - Signature verification via Stripe SDK
Webhook.constructEvent(rawBody, sigHeader, webhookSecret, tolerance=300)— 5-minute clock skew tolerance. - Failure mode: signature failure → HTTP 400 + audit event
webhook_signature_invalid+ alerting on burst (>10 failures/min). - Idempotency:
stripe_webhook_logtable сevent_idUNIQUE constraint — duplicate replays no-op safely.
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.tier → free_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.status → suspended; 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.status → suspended; 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):
- Nightly cron:
GET /v1/events?type=customer.subscription.*&created[gte]=<yesterday>→ compare к localstripe_webhook_log; replay any missed events. - NEW: revenue totals reconciliation — daily
sum(invoices.paid amount where created in last 24h)Stripe vs localaudit_eventstable → discrepancy alerts (anchor для accounting audit defence).
Webhook secret management (Security M5 fix):
STRIPE_WEBHOOK_SECRETstored в env via SOPS / KMS / Vercel encrypted env (operational choice Phase 1.5).- Rotation procedure: Stripe supports parallel endpoint secrets during rotation window. Procedure: (1) add new secret к Stripe Dashboard, (2) deploy code reading EITHER
STRIPE_WEBHOOK_SECRET_OLDORSTRIPE_WEBHOOK_SECRET_NEW, (3) wait 24h confirm new secret active, (4) remove old secret from Stripe + code. - Rotation cadence: ≤180d default + on-incident.
- Audit log entry
webhook_secret_rotatedper rotation event.
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:
- Integration via Turnstile widget JS на signup form (Path A). Free tier (no Cloudflare account upgrade needed).
- Server validates token via
POST https://challenges.cloudflare.com/turnstile/v0/siteverifybefore создания Stripe Customer. - Failure mode: «We couldn't verify you're not a bot. Please try again or contact [email protected].»
5.2 — Email verification (already in plan + SPEC-AMEND v1.1 CONV-35 — Security H3 fix: token URL hardening).
- Confirms existing onboarding pattern (Phase 1.2.1 includes email-verify step).
- Verification email uses Phase 1.3 §1.4.D invite token primitive (≥128-bit CSPRNG + hashed + constant-time compare + 24h expiry).
- Until verified → user в «pending verification» state; cannot enter Trial.
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):
- HTTP headers on verify endpoint response:
Referrer-Policy: no-referrer— prevents Referer leak к external tracking domains если verify landing page redirects к external onboarding tour.Cache-Control: no-store, no-cache, must-revalidate— prevents browser cache + intermediate proxy cache.Pragma: no-cache(legacy clients).X-Robots-Tag: noindex, nofollow— search engine isolation.Content-Security-Policy: frame-ancestors 'none'— clickjacking防御.
- Single-use consumption: token rotation на first use — token hash marked
consumed_atв DB immediately upon validation; subsequent requests с same token → 410 Gone «This verification link was already used. Please request a new one.» - Constant-time compare (re-affirm §1.4.D): SHA-256 hash compare через
crypto.timingSafeEqual()to prevent timing oracle. - POST-on-first-use pattern: verify landing renders intermediate page с auto-submitted POST form (prevents pre-fetch tools / WhatsApp link previews accidentally consuming tokens via GET request).
5.3 — Email domain age check:
- Pre-Customer-creation, server queries domain registration date через WHOIS API (e.g. who-dat.as93.net free, OR RDAP via VeriSign/IANA).
- Block если domain
created < (now - 7 days). Display: «We can't process signups from new email domains. Please use a business or established personal email, or contact [email protected].» - Cache domain age in Redis (TTL 30 days) — avoid repeated WHOIS lookups for same domain.
5.4 — IP rate limit:
- Redis store:
signups:ip:{ip}:24hcounter. Increment on each signup attempt; expire after 24h. - Threshold: 3 signups per IP per 24h. Beyond — return HTTP 429 «Too many signups from your network. Try again later or contact [email protected].»
- IPv6 normalisation: strip к /64 prefix (avoid trivial bypass via address rotation в same /64).
5.5 — Pick #12 hard cap (1 project Trial):
- Enforced in Phase 1.2 admin UI: «+ New Project» button disabled if
tier === trial && projects.count >= 1. - Server-side enforcement: project creation endpoint checks tier + count → returns 403 if exceeded.
5.6 — Post-launch monitoring (+ SPEC-AMEND v1.1 CONV-35 — Sales motion H5 fix: reactivation funnel KPIs):
- Analytics events (Phase 1.7.11) — Trial signup + activation:
signup_attempted(success + abort reasons tagged: turnstile_failed / domain_blocked / rate_limited / email_unverified)trial_started(Customer + Subscription successfully created)trial_to_paid_converted(subscription transitionedtrialing→active)trial_expired_to_free_guest(subscription auto-cancelled, tier flipped)time_to_first_publish(Sales motion M5 fold — PLG activation canonical KPI: время отtrial_startedдо first project published)
- Post-trial reactivation funnel KPIs (Sales motion H5 NEW):
free_guest_reactivated(Free Guest → tier_1+ subscription, regardless of timing within or after archive window)t+7_email_open_click/t+23_email_open_click/t+29_email_open_click(per-cadence attribution)t+29_save_rate(% of T+29 email recipients who reactivate within 7 days → measures «final hour» recovery effectiveness)sales_app_freeze_to_reactivation(% of Orgs that hit T+30 freeze + later reactivate at any point — measures whether freeze itself is a conversion trigger)
- KPI dashboard:
trial_abuse_flag_count= sum of aborts ≠ legitimate user errors. Acceptable Stage 1: <10% of total signup attempts. Above 10% → layer #2 escalation trigger. - Acquisition channel tier-mix (Sales motion L2 fold): tag every
signup_attemptedevent withacquisition_source(Path A direct / Path A invited / Path B reverse-invite) → cohort-level conversion analysis для channel-spend allocation.
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):
- Enable
automatic_tax: { enabled: true }on all Checkout Sessions. - Stripe Tax computes tax based on Customer address + tax IDs + selling entity location.
- Invoice rendering includes line-item tax breakdown + reverse-charge notice (if applicable).
6.2 — Tax ID capture (+ SPEC-AMEND v1.1 CONV-35 — Finance M4 fix: VIES async validation + corrective invoice flow):
- Enable
tax_id_collection: { enabled: true }on all Checkout Sessions. - B2B customer (rendering studio, agency) provides tax ID (EU VAT / UAE TRN / US EIN / etc.). Stripe validates format only — sync.
- VIES validity validation (Finance M4 fix): post-Checkout background job (within 24h) calls VIES SOAP API для EU VAT IDs (
http://ec.europa.eu/taxation_customs/vies/services/checkVatService) OR equivalent для UAE TRN (FTA portal) / etc. Result stored ascustomer.tax_ids[*].verification.status(verified/unverified/pending). - Corrective invoice flow если verification fails:
- Stripe Tax originally applied reverse-charge based на supplied tax ID format match.
- Verification returns
unverified→ background job issues credit note для original invoice + new invoice с VAT applied (place-of-supply seller jurisdiction VAT rate). - Customer notified via email с both invoices attached.
- Audit event
tax_id_verification_failedсpii_class = sensitive.
- Stored at
customer.tax_idsдля downstream invoice generation.
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:
- Captured during Checkout (
billing_address_collection: "required"). - Persisted at
customer.address. Used by Stripe Tax for jurisdiction-correct tax calculation.
6.4 — Jurisdiction-specific rules (resolved on entity lock):
- ⚠️ Placeholder — once legal entity locked (Cyprus / ADGM / Mainland UAE / etc.), this subsection populates с:
- Seller VAT registration jurisdiction + rate (Cyprus 19% / UAE 5% / etc.)
- Reverse-charge rules для B2B cross-border (Cyprus = EU OSS reverse-charge; UAE = FTA export-of-services zero-rated)
- Tax ID validation behaviour (VIES для EU / FTA portal для UAE / IRS для US EIN)
- E-invoicing pipeline (Cyprus OASIS cUBL / UAE FTA portal submission / etc.)
- Companion research task on jurisdiction lock: parallel UAE-specific Stripe Tax research analogous к
docs/research/stripe-billing-onboarding-stage1-2026-05-11.md.
6.5 — MoR revisit trigger:
- Stage 1 = Stripe + manual tax handling (per ADR 0017 NEW).
- Revisit Paddle MoR at ARR threshold $250k–$500k OR jurisdiction-specific tax compliance ops cost spike (e.g. UAE FTA filing complexity).
Implementation Step 7 — GDPR + retention (per Research F12)
7.1 — Customer data minimisation:
- Capture only fields needed for service: email · name · billing address (for tax) · phone (optional, Tier 2+ for support escalation).
- NO marketing-flavoured fields (job title, company size, etc.) Stage 1.
7.2 — customers.del() semantics (Stripe 2026):
- Deletes PII (name, email, billing_address, phone). Invoice history retained indefinitely + queryable by
cus_xxxID. - Stripe official: GDPR Art. 17 right-to-erasure satisfied by PII deletion; transactional records retained per tax/legal requirement (Cyprus 7yr / UAE TBD / etc.).
- Our compounded layer: cold-storage invoice PDF export pre-delete (Step 1.5) = defence-in-depth.
7.3 — Retention duration:
- ⚠️ Jurisdiction-dependent (Cyprus 7yr stated in ADR 0004 v2; UAE TBD on jurisdiction lock).
- Sub-plan body uses placeholder «{retention_duration}» pending lock. ADR 0004 v2 reworded к jurisdiction-agnostic in same PR (assumes Cyprus; flag for revision).
7.4 — GDPR DSR (Data Subject Request) flow Stage 1:
- Stage 1 surface = email
[email protected]for DSR requests (per Phase 1.7 placeholder). - Operator dashboard (Phase 1.4.7) has «GDPR DSR» action — operator confirms identity + scope, triggers automated cascade:
- Export user data bundle (Organisation data + invoices from Stripe + audit log entries) к user.
- На «deletion» DSR: trigger Free Guest archive flow accelerated (skip 12mo wait + email cascade; immediate archive → 30d retention для legal hold → hard delete).
- Stage 2: self-serve DSR via account settings (deferred).
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 | «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 | «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 | «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.tier → free_guest. If PM attached: subscription transitions trialing → active; first paid charge processed. |
| Day 14 (T+0) | 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) | «You've been on Free Guest for a week · Reactivate?» | ||
| Day 37 (T+23) | «Your published sales page freezes in 7 days · Reactivate to keep it live» | ||
| Day 43 (T+29) | «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):
- Admin pages: TRIAL countdown badge always visible в header.
- Public sales-app pages (
{slug}.offplan.online): NO Trial badge. Buyer-facing → no exposure of seller's billing state. - Operator dashboard (
staff.offplan.online): Org Trial status visible в Org Detail view (operator UX, per Phase 1.4 placeholder).
Trial email scheduling:
- T-7 and T-1 emails: scheduled via daily cron + lookup
WHERE tier='trial' AND trial_end_at BETWEEN NOW()+6d AND NOW()+8d(T-7) and analogous (T-1). - T-3 email: triggered by Stripe
customer.subscription.trial_will_endwebhook (fires automatically by Stripe 3 days beforetrial_end). - T+0 / T+7 / T+23 / T+29: similar daily cron pattern, based on
tier='free_guest' AND last_paid_subscription_ended_at BETWEEN NOW()-Nd AND ....
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:
- Query:
SELECT * FROM organisations WHERE tier='free_guest' AND last_activity_at < NOW() - INTERVAL '12 months'. last_activity_atupdated by: any login event · any Stripe webhook event for the Org · any invite accept event · any buyer token issue event.
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:
free_guest_archive_scheduled— pii_class =personal_meta, recorded at T-30d email send.free_guest_archived— pii_class =personal_meta, recorded at T-0 flip.free_guest_unarchived— pii_class =personal_meta, operator-initiated reversal с mandatory reason field.free_guest_hard_deleted— pii_class =pseudonymous(event survives hard delete с stripped PII).
9.4 — Operator un-archive flow (per Phase 1.4.7):
- Operator searches by email / org slug / Stripe customer ID в console.
- Confirms identity through support ticket trail.
- Triggers
archive_un_archiveaction with mandatory reason field («support ticket #1234: user requested reactivation after vacation»). - DB flip:
status: free_guest_archived→status: free_guest_active.last_activity_at = NOW(). Audit entry recorded. - 2 business days SLA (matches ownership transfer SLA per Phase 1.3 §1.5.F).
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):
- Wildcard DNS:
*.offplan.online→ app origin.
Host header canonicalisation (Security M1 fix — prevents Host injection / X-Forwarded-Host trust attacks):
- Parse
Hostheader against strict allowlist regex:^([a-z0-9-]+\.)?offplan\.online$(slug + central app) OR exact match againstverified_custom_domainstable entries (Tier 2+ — see 10.3). - Reject otherwise → HTTP 400 «Invalid host».
- NEVER trust
X-Forwarded-Hostunless from explicitly-whitelisted upstream proxy (Cloudflare / Vercel edge). Trust list configurable, audited. - IPv6 / port stripping normalisation pre-match.
Org resolution:
- After canonicalisation, extract
{slug}.offplan.online→ lookuporganisations WHERE slug = ?→ attachorganisation_idк request context. - If unmatched slug: return branded 404 «Organisation not found. Did you mean to visit https://offplan.online?»
- Reserved subdomains (58 per Phase 1.3 §2.7 + brand-relevant additions per R1 specific risks): hardcoded blocklist checked at signup AND middleware level.
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):
- CSP: admin app —
default-src 'self'; script-src 'self' 'nonce-{generated}' https://js.stripe.com; frame-src https://js.stripe.com; connect-src 'self' https://api.stripe.com; img-src 'self' data: blob:; style-src 'self' 'unsafe-inline'. - HSTS:
max-age=31536000; includeSubDomains; preload(admin + tenant sales-app). __Host-cookies на admin app (Security threat model gap #2): admin session cookie__Host-session(no Domain attribute → scoped к exact host only). Sales-app tenant content uses separate cookie boundary OR no auth cookie at all (public-facing).X-Frame-Options: DENYon admin (clickjacking防御; tenant sales-app keepsframe-ancestors 'none'per Phase 1.11 buyer URL spec).X-Content-Type-Options: nosniffuniversal.Strict-Transport-Securityuniversal.
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:
- After org resolution, route
{slug}.offplan.online/{project-slug}→ project lookupWHERE organisation_id = ? AND slug = ?. - If unmatched project slug AND org exists: branded 404 «Project not found in {Org} workspace».
10.3 — Custom domain resolution (Tier 2+) + DNS verification (SPEC-AMEND v1.1 CONV-35 — Security H2 fix: subdomain takeover prevention):
- CNAME record
palmresidences.com → cname.offplan.online(single CNAME target для всех custom domains). - Middleware reads canonicalised
Host→ looks upcustom_domains WHERE hostname = ? AND verified_at IS NOT NULL AND verified_at > NOW() - INTERVAL '24 hours'→ resolves кorganisation_id + project_id. - Aliases (Pick #8):
palmresidences.ae → palmresidences.com301 redirect. Implemented as same CNAME pattern, ноalias_of_hostnamefield вcustom_domainstable determines redirect target.
DNS verification flow (Security H2 — prevents subdomain takeover where customer's CNAME removed/expired but backend still routes):
- On domain add: customer submits hostname → backend generates one-time TXT challenge value (random ≥128-bit). Customer adds
_offplan-verify.{hostname} TXT "{challenge}"к their DNS. Backend polls DNS resolver every 60s; on match →custom_domains.verified_at = NOW()+ provisioning CNAME confirmation. - Continuous re-verification: nightly cron re-checks DNS state. If CNAME no longer points к
cname.offplan.onlineOR TXT challenge missing → flipverified_at = NULL+ return 503 «Domain verification expired» from middleware + alert operator (Phase 1.4.7) + email customer. - SSL provisioning: ACME (Let's Encrypt) via standard challenge — handled by edge proxy (Cloudflare / Vercel) after DNS verification confirmed.
- Domain removal: customer removes domain via Settings → custom_domains row deleted + ACME cert revoked + middleware returns 404 immediately.
10.4 — Slug change UI (per Pick #7):
- Org slug change: Settings → Organisation → «Change subdomain» form. Field shows current slug + cooldown countdown («N months until next change available» if in cooldown). Submission validates new slug (uniqueness + reserved list + char rules) → flip
organisations.slug→ write 301 redirect recordold_slug → new_slug(TTL 90d). - Project slug change: Project Settings → «Change slug». Pre-change warning modal: «Changing this slug will invalidate {N} active buyer links. They will need to be re-issued.» On confirm: flip
projects.slug+ invalidate allbuyer_tokens WHERE project_id = ?per Phase 1.3 §1.7.J rotation rules.
10.5 — Slug change cooldown enforcement:
- Org slug change cooldown: 12 calendar months. Enforced server-side via
organisations.slug_last_changed_atcolumn. Edge case: change DURING active 90d redirect window of previous change → blocked with «Wait until {date} when current redirect expires».
10.6 — Custom domain transfer (operator-mediated) per Pick #9:
- User submits ticket к [email protected]: «Transfer palmresidences.com from project X к project Y».
- Operator verifies (via dashboard): both projects belong to same Org; user has Owner role на Org.
- Operator action
custom_domain_transfer(Phase 1.4.7): atomically reassignscustom_domains.project_id. Audit log entry. - SLA: 2 business days.
- Self-serve Stage 2 (if abuse не материализуется).
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:
- Renders
{tier.display} · ${pricing.tier.base_currency_display[locale]}/mo(config-driven per Pick #14). Renews on {next_billing_date}pulled fromsubscription.current_period_end.- «Manage payment & invoices →» button opens Stripe Customer Portal session (server
POST /v1/billing_portal/sessions→ returns hosted URL → redirect user).
11.3 — Trial countdown banner:
- Visible если
organisations.tier === 'trial'. Renders «Trial ends in N days · Add a card to continue with Pro →» с inline Stripe Elements (or Checkout redirect). - Hidden when not on Trial.
11.4 — Free Guest banner:
- Visible если
organisations.tier === 'free_guest'. Renders «Upgrade to Starter to create your first project →» с tier comparison CTA.
11.5 — Compare plans table [+ SPEC-AMEND v1.1 CONV-35 — B7 ratification: Enterprise mailto Stage 1]:
- 3 columns (Starter / Pro / Enterprise). Free Guest implicit (если user is Free Guest, «Get started» CTA on Starter column).
- Row data from
pricing-config.ts: display name · monthly price · top 3-5 features per tier (curated subset of Pick #4 mapping). - «Current plan» highlight on user's current tier card. «Upgrade →» button on tiers above current.
- Enterprise tier CTA (B7 Stage 1):
<a href="mailto:[email protected]?subject=Enterprise%20inquiry">Contact sales →</a>— plain mailto Stage 1. Stage 1.5 upgrade trigger: если ≥3 inbound Enterprise inquiries в первые 30d post-launch → ship Cal.com embed + lead-qualification form (Org size / projects / geo) Stage 1.5. - Pricing display:
{pricing[tier].base_currency_display[locale] ?? "Pricing TBD — contact us"}— graceful empty state pre-Roman-numbers.
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:
- One-time setup via
POST /v1/billing_portal/configurations(API version2026-01-28.previewper Research): ``` { business_profile: { headline: "Manage your offplan.online subscription and billing" }, features: { customer_update: { enabled: true, allowed_updates: ["email", "tax_id"] }, payment_method_update: { enabled: true }, invoice_history: { enabled: true }, subscription_cancel: { enabled: true, mode: "at_period_end" }, subscription_update: { enabled: true, default_allowed_updates: ["price"], proration_behavior: "create_prorations" } } } ``` - Stage 1 enabled features: subscription view + cancel · payment method update · invoice history · tax ID update.
- Stage 1 gated behind [email protected]: subscription pause/resume · custom billing cycle adjustments · Tier 3 SSO setup.
11.7 — Tier downgrade preview UI:
- Within Stripe Customer Portal (subscription_update enabled with
default_allowed_updates: ["price"]). - OR custom UI in Plan & Billing page: «Switch to Starter» button → preview modal с
create_previewendpoint result → confirm → serverPOST /v1/subscription_schedulesper Step 2.2.
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):
- ALL Full Admin items visible during Trial.
- Object Builder = active (Trial = Object Builder only working surface per CONV-21).
- Click on any locked item → upgrade modal per 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):
- Already built-in в Atelier canonical mockup (per CONV-21 + ADR 0015 design inputs).
- 3 required uploads listed с progress «X / 3» counter:
- i. Exterior — primary 360° / hero render
- ii. Floor Plans — PDF (Phase 1.5.6 AI tags pages) or manual page picker (Pick #10 Stage 1)
- iii. Gallery — renders, lifestyle, brochure pages
- Each item state: empty / uploaded ✓ / replaceable
[SAVE & CONTINUE]button = publish (gated until все 3 uploaded) /[SKIP]= save draft state- Bottom-line: «Three uploads stand between you and a sales-ready landing page on its own subdomain.»
Live Preview right panel (Pick #11):
- Before any upload: branded skeleton (Org name + colour tokens) + 3 section placeholders:
- Exterior — placeholder gradient + «AWAITING UPLOAD →»
- Floor Plans — placeholder grid + «AWAITING UPLOAD →»
- Gallery — placeholder thumbnails + «AWAITING UPLOAD →»
- Subdomain badge top-left:
{org-slug}.offplan.online/{project-slug}(live as soon as project name + slug entered). - Desktop / Tablet / Mobile switcher (per CONV-21 Atelier mockup).
- Mid-upload: placeholder заменяется real content per section по мере появления.
- No fake demo content (per CONV-21 supersedes v4.3 demo-project pattern).
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:
- Each modal fires once per Org lifetime (track via
org.success_modals_seenarray). - Dismissable («Got it» / outside click / Esc) — НЕ block continued workflow.
- Upgrade CTA passes through к Plan & Billing → Pro upgrade flow (Step 2.1 / 11.5).
- Designer scope: ~2 days copy + screenshot per modal = ~6 days total.
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:
[email protected]— general CS, Trial recovery, custom domain transfer, slug change, billing questions. Triage SLA 24h ack Stage 1; 2 business days resolution target.[email protected]— GDPR DSR requests (export + erasure). SLA 72h ack; full response 30d (GDPR Art. 12 compliance window).[email protected]— abuse reports, suspected phishing via offplan-hosted content, security disclosures. SLA 24h ack; severity-dependent resolution.
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:
- 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.
- 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).
- 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.closedwebhook fires upon resolution.
- Operator assembles evidence packet via Stripe dispute UI:
- Stage 4 — Outcome handling:
- Won (
status: won):chargeback_unfreezeaction → fliporganisations.status→active+ 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).
- Won (
- 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:
- Last 30d email send count + bounce rate + spam-complaint rate (from ADR 0011 email-sender webhook integration).
- Flag
email_delivery_degradedif bounce rate > 5% OR spam complaint > 0.1%. - Pre-archive (Step 9 T-30d trigger) — operator dashboard surfaces «N Orgs scheduled archive в next 30d, of which M have degraded email delivery» → proactive outreach option (e.g. phone call, alternative contact).
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:
- Block signup → return HTTP 409 «An archived workspace exists for this email. Please contact support to reactivate before creating a new account.»
- Operator dashboard flags: «N email collisions in last 24h» → operators reach out proactively к verify whether to (a) un-archive existing OR (b) explicitly release email для new signup (deletion path).
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:
- Sub-plan 2 stance: при GDPR DSR delete-request от Owner, buyer records cascade-deleted IFF Org is SOLE controller (
controller_org_id == this_org). If buyer records have multi-Org controller annotation (External SA case per legal-multi-party sub-plan) → operator escalates к legal review, no automated cascade. - Pending legal-multi-party sub-plan ratification — see Part 4 parked items.
Files to Create/Modify
Application code (Roma + Ilya scope):
apps/admin/src/app/billing/page.tsx— Plan & Billing page (Step 11)apps/admin/src/lib/stripe/client.ts— Stripe client init + typesapps/admin/src/lib/stripe/checkout.ts— Checkout Session creationapps/admin/src/lib/stripe/portal.ts— Customer Portal session creationapps/admin/src/lib/stripe/preview.ts—create_previewinvocationapps/admin/src/lib/stripe/schedule.ts— Downgrade SubscriptionScheduleapps/admin/src/lib/abuse/turnstile.ts— Cloudflare Turnstile server validationapps/admin/src/lib/abuse/domain-age.ts— WHOIS RDAP domain age checkapps/admin/src/lib/abuse/rate-limit.ts— IP rate-limit Redis clientapps/admin/src/lib/billing/pricing-config.ts— config-driven tier pricing (numbers TBD Roman)apps/admin/src/lib/billing/tier-state-machine.ts— webhook handler dispatchapps/admin/src/app/api/stripe/webhooks/route.ts— webhook endpointapps/admin/src/app/api/billing/checkout/route.ts— upgrade Checkout endpointapps/admin/src/components/billing/PlanCard.tsx— Current Plan cardapps/admin/src/components/billing/TierComparison.tsx— Compare plans tableapps/admin/src/components/billing/TrialBanner.tsx— Trial countdown bannerapps/admin/src/components/object-builder/ObjectBuilder.tsx— Quick Build entryapps/admin/src/components/object-builder/LivePreview.tsx— Live Preview right panel (Pick #11)apps/admin/src/components/sidebar/FreeGuestSidebar.tsx— minimal sidebar (Pick #13)apps/admin/src/components/sidebar/TrialSidebar.tsx— full sidebar с lock modals (Pick #4)apps/admin/src/components/upgrade-modal/UpgradeModal.tsx— 2 Trial-period lock modals (SSO + 2nd project) + 8-12 post-Trial templates (copy + screenshot TBD Ilya)apps/admin/src/components/team/InviteSeatModal.tsx— Trial 3-seat invite UI (B1)apps/admin/src/components/billing/InvoiceReferenceSettings.tsx— invoice custom reference field (B3)apps/admin/src/components/onboarding/FirstPublishModal.tsx— success-moment upsell modal #1 (B6)apps/admin/src/components/onboarding/FirstBuyerLinkModal.tsx— success-moment upsell modal #2 (B6)apps/admin/src/components/onboarding/FirstFiftyViewsModal.tsx— success-moment upsell modal #3 (B6)apps/admin/src/components/billing/ReactivationModal.tsx— transparent reactivation pricing modal (B5)apps/admin/src/middleware/subdomain-resolver.ts— Org subdomain resolution (Step 10.1)apps/admin/src/middleware/custom-domain-resolver.ts— Custom domain CNAME lookupinfra/cron/trial-reminder-T7.ts— daily T-7 email croninfra/cron/trial-reminder-T1.ts— daily T-1 email croninfra/cron/free-guest-archive.ts— daily inactivity check + cascadeinfra/cron/free-guest-hard-delete.ts— T+90d hard delete + cold storage exportinfra/cron/invoice-coldstorage-export.ts— S3 Object Lock invoice PDF exportschemas/migrations/2026XXXX_billing_columns.sql—organisationsschema additions
Config / setup (one-time, Stripe Dashboard or API):
- 3 Stripe Products:
prod_tier_1_starter,prod_tier_2_pro,prod_tier_3_enterprise - 3 Stripe Prices с
currency_optionsUSD/EUR/AED (numbers TBD Roman) - Stripe Customer Portal configuration (per Step 11.6)
- Stripe webhook endpoint registration с 8 event types subscription
Documentation:
docs/decisions/0017-payment-provider-stripe-stage1.md— NEW ADR (Part 3)docs/decisions/0013-proration-policy.md— v1.1 amendment (Part 3)docs/decisions/0008-tier-model.md— amendment (Part 3)docs/rendered/launch-plan-stage-1.html— Phase 1.2.2 HTML amendment (Part 3)
Dependencies
Hard prerequisites (block Phase 1.2 launch):
- ADR 0011 (Email sender) ratified + Phase 1.8.6 transactional email shipped. Fallback if not ready: in-app banners only Phase 1.2 launch; emails phased in Stage 1.5.
- ADR 0014 (MCP wrapper auth) ratified before Phase 1.5.6 AI Floor Plan tags (Stage 1.5 if not ready).
- Legal entity locked for tax handling specifics + retention duration (current memory: Abu Dhabi likely candidate). Sub-plan body jurisdiction-agnostic; specifics resolved on lock.
Soft prerequisites (degrade gracefully if not ready):
- Roman pricing strategy input →
pricing-config.tsnumbers populated. Without: UI renders «Pricing TBD — contact us» fallback. - Hosting cost projection (Phase 1.7.13) → tier price finalisation. Without: same UI fallback.
- Designer email copy (6 templates) → email content. Without: in-app banners only, emails Stage 1.5.
- Designer upgrade modals (8-12 templates copy + screenshots) → Pick #4 modal content. Without: text-only modals Stage 1.
External services:
- Stripe account (Cyprus / UAE / etc. — jurisdiction-dependent). Setup ~2 hours post-jurisdiction lock.
- Cloudflare Turnstile site key (~10 min setup, free tier).
- AWS S3 bucket с Object Lock Compliance mode (или GCP equivalent). Setup ~1 hour.
- Email service provider per ADR 0011 (TBD architecture).
- WHOIS RDAP API (free, no signup).
Testing
Unit tests (Vitest):
lib/stripe/checkout.ts— Checkout Session payload construction.lib/stripe/schedule.ts— SubscriptionSchedule payload construction.lib/abuse/turnstile.ts— token validation mock responses.lib/abuse/domain-age.ts— WHOIS response parsing + edge cases (no created_date / future date / parse errors).lib/abuse/rate-limit.ts— counter increment + TTL behaviour.lib/billing/pricing-config.ts— config schema validation + locale fallback.lib/billing/tier-state-machine.ts— webhook event → tier flip mapping (8 events × multiple state transitions).
Integration tests (Vitest + Stripe test mode):
- Trial signup happy path: signup form → Customer + Subscription
trialingcreated → webhook fires → tier=trial. - Trial → Paid conversion: PM attach → subscription
trialing→active→ first invoice on anniversary. - Trial expiry no-PM: Stripe test clock advances 14d →
customer.subscription.deletedfires → tier=free_guest. - Tier upgrade T1→T2: Checkout Session completion → tier=tier_2 + prorated invoice.
- Tier downgrade T2→T1: SubscriptionSchedule creation → test clock advances к period_end → tier=tier_1.
- Re-pay after archive:
subscriptions.createfresh с metadata.previous_subscription_id → tier restore. - Webhook idempotency: replay same event_id → no-op.
E2E tests (Playwright):
- Signup → email verify → Trial activation (full Path A flow).
- Trial countdown badge visibility (admin pages) + invisibility (public sales-app).
- Plan & Billing page rendering across tiers (Trial / Free Guest / Starter / Pro / Enterprise).
- Upgrade modal launch on locked feature click (Trial UX) per Pick #4.
- Free Guest sidebar visibility per Pick #13 table.
- Object Builder «+ New Project» block on Trial 2nd project per Pick #12.
Manual smoke (pre-launch):
- Stripe Dashboard sanity: 3 Products + 3 Prices с correct currency_options.
- Webhook endpoint signature verification (Stripe CLI
stripe listen --forward-to ...). - Cloudflare Turnstile widget renders + validates на dev env.
- S3 Object Lock bucket: invoice PDF write + read + delete-block confirmation.
- Customer Portal: login from app → manage subscription → cancel → resubscribe round-trip.
Test data:
- 3 test Organisations: 1 на Trial, 1 на Free Guest, 1 на Pro (active subscription).
- 1 archived Org с 90d retention timer.
- 1 Org with custom domain + 2 aliases (Pick #8).
Risks
- 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).
- 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.
- 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.
- 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.
- 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.
- 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.
- Invoice cold-storage export job failures → audit defence gap. Mitigation: nightly verification job comparing Stripe invoice count vs S3 archive count; alerting on discrepancies.
- 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):
- Phase 1.2.1 — Signup + email verify + abuse mitigation layer #1 + Customer creation deferred pattern.
- Phase 1.2.2 — URL architecture middleware (Org subdomain + project path + reserved list + slug rules).
- Phase 1.2.3 — Trial subscription create + trial-end webhook handler + 14-day timeline emails + countdown badge.
- Phase 1.2.4 — Object Builder + Live Preview + Free Guest sidebar + Trial sidebar with lock modals.
- Phase 1.2.5 — Plan & Billing UI + Stripe Checkout Session upgrade flow + Customer Portal integration + tier preview API + downgrade SubscriptionSchedule.
Cross-references existing workstreams:
phase-1-3-implementation— Track B (Roma scaffold) дальше использует invite token primitive § 1.4.D в Phase 1.2.1.sales-app-react-wrap— depends on Phase 1.2.2 middleware для subdomain resolution.
Evaluation
Done when (Step 4 closure → ratify same PR):
- ✅ Plan body complete (this Part 2)
- ✅ ADR 0017 NEW written (Part 3)
- ✅ ADR 0013 v1.1 amendment (Part 3)
- ✅ ADR 0008 amendment (Part 3)
- ✅ Phase 1.2.2 HTML amendment text (Part 3)
- ✅ Workstream
onboarding-trial-implementationcreated (Step 5) - ✅ Plan status flipped
scratch-interview-closed→ratified(after Step 4.4 business review + Step 4.5 ratification sweep) - ✅ Phase 1.2 HTML callout in
docs/rendered/launch-plan-stage-1.htmlflipped к ratified state - ✅ Same-PR commit: sub-plan body + 3 ADRs + HTML amendments
- ✅ Preview repo synced (
offplan-online/previewcommit + push)
Phase 1.2 launch-ready when (Step 5 workstream complete):
- All 12 Implementation Steps shipped per workstream Phase 1.2.1-1.2.5.
- Pre-launch dependencies resolved (ADR 0011, ADR 0014 if Phase 1.5.6 in scope, jurisdiction lock, hosting cost projection, Roman pricing numbers, designer email copy + upgrade modals).
- Pre-launch smoke testing passed (manual + automated suite).
- Operator playbook (Phase 1.4.7) updated с Trial/Free Guest support flows.
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_invoiceendpoint 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_previewendpoint (replaces deprecatedGET /v1/invoices/upcomingper 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.mdPick #14, pricing rendered frompricing-config.tsschema. UI layout, components, Stripe integration — all ratified (CONV-34 + CONV-35). Numbers (monthly_price,limits.*) = config values populated post-Roman input. Statusaccepted-structure-pending-numbersremains correct: structure ratified; numbers config-driven, не code-blocking.»
Update Cross-references к include:
- ADR 0017 — Payment provider Stripe Stage 1 (confirms Stripe Price IDs map к tier slugs).
- Sub-plan 2
plans/onboarding-trial-mode.md— Pick #14 + Part 1B R2 + Part 2 Step 4 (pricing-config schema).
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.onlineOperator dashboard staff.offplan.onlineBuyer 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(notproject_id). Project resolution = path-based subsequent step. Cross-link Sub-plan 2plans/onboarding-trial-mode.mdStep 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
- CONV-15 (2026-05-04) — Roman call note #1 «trial = отдельная sidebar секция»
- CONV-21 (?) — Atelier model decision (Object Builder = onboarding)
- CONV-34 (2026-05-11) — Step 1 interview closed (13 picks ratified)
- Phase 1.3 sub-plan
plans/permission-and-tenancy-model.mdratified CONV-33 + SPEC-AMEND v4.17/v4.18 CONV-34 - Mockup canonical:
docs/mockups/admin-quick-build-atelier-standalone.html(supersededadmin-quick-build-v2.html)
Changelog
- 2026-05-04 — Stub created (CONV-15 era).
- 2026-05-11 (CONV-34) — Interview closed Step 1 /plan. 13 picks ratified. Status flipped
stub→scratch-interview-closed. Steps 2-5 pending. - 2026-05-11 (CONV-35) — Step 2 Research closed. Findings persisted:
docs/research/stripe-billing-onboarding-stage1-2026-05-11.md. 12 findings (5 REFINE picks F1-F5 / 4 CONFIRM F6-F9 / 3 NEW F10-F12) + 5 decisions surfaced (D1-D5). ADR 0013 amendment v1.1 queued (deprecatedupcoming_invoice→create_preview). ADR 0017 NEW proposed (Stripe Stage 1 formal ratification). - 2026-05-11 (CONV-35) — Step 3 Approaches closed. Single approach confirmed. 3 ratification questions resolved: D1 ✅ Stripe ADR 0017 YES · D3 ❌ Trial daily caps DROPPED (layer #1 only, routed specific risks to natural homes) · D5 ❌ Cyprus e-invoicing DROPPED (jurisdiction shift CONV-35 — Abu Dhabi likely candidate, sub-plan jurisdiction-agnostic). Part 1B added (R1 abuse mitigation Stage 1 stack + R2 Stripe Stage 1 ratification). Memory updated:
project_legal_entity_cyprus.md→ Abu Dhabi leading candidate. Step 4 Plan write-up next. - 2026-05-11 (CONV-35) — Step 4 Plan write-up closed. Part 2 body (~800 lines): Goal · Success Criteria · Approach · 12 Implementation Steps (Stripe Customer lifecycle / Tier transitions / Webhooks / Multi-currency / Abuse mitigation / Tax handling / GDPR / Trial timeline / Free Guest archive / URL architecture / Plan & Billing UI / Free Guest sidebar) · Files (~30 files identified) · Dependencies · Testing (Unit/Integration/E2E/Manual) · Risks (8 identified) · Workstreams · Evaluation. Part 3 amendments: ADR 0017 NEW (Stripe Stage 1 full text) · ADR 0013 v1.1 amendment text (endpoint rename) · ADR 0008 amendment (config-driven pricing note) · Phase 1.2.2 HTML amendment text (Org subdomain + project path). Part 4 renumbered (Open items parked, expanded с jurisdiction lock + UAE companion research). ADR files written:
docs/decisions/0017-payment-provider-stripe-stage1.mdNEW ·docs/decisions/0013-proration-policy.mdv1.1 inline ·docs/decisions/0008-tier-model.mdamendment inline. - 2026-05-11 (CONV-35) — Step 4.4 Business review closed. 5 concern agents executed в parallel (Studios / Sales motion / CS / Finance + Billing / Security). 76 findings classified (24 HIGH / 32 MEDIUM / 20 LOW), persisted к
docs/research/business-review-sub-plan-2-2026-05-11.md. Category A applied directly (16 SPEC-AMEND v1.1 fixes inline plan body): Finance H1 (Checkout-vs-update bug) · Finance H2/H3/H4 (3 missing webhooks: dispute/payment_failed/refunds) · Finance H5 (billing address Customer create) · Finance M1 (subscription_schedulesfrom_subscriptionanchor) · Finance M3 (open-dispute pre-delete check) · Finance M4 (VIES async validation + corrective invoice) · Security H1 (deletion_journal 7-stage atomic cascade) · Security H2 (custom domain DNS verification + continuous re-check) · Security H3 (token URL hardening §1.7.J header set) · Security M2 (webhook raw-body capture + signature failure handling) · Security M1+M6 (Host header allowlist +requireMembershippattern + CSP/HSTS/__Host-cookies baseline) · Sales motion H2 (T-3 email reconciliation Pick #3 vs Step 8) · Sales motion H5 (4 post-trial reactivation KPIs) · Studios M4 («Frozen project preview» row Pick #13) · CS M5 (multi-Org membership cascade login fix Step 9.2). NEW Step 13 «Operator playbook & support workflows» (CS H1+H3 + 9 operator action dependencies + chargeback dispute resolution + email collision recovery + reason field enum). Webhook event count expanded 8 → 14. Category B parked для Step 4.5 ratification (13 scope/UX decisions). Category C deferred (47 findings). - 2026-05-11 (CONV-35) — Step 4.5 Ratification sweep closed. All 13 Category B items ratified inline interview (interview-style one-at-a-time + B9-B13 batch confirm Step 13 specs). Ratifications applied: B1 3 seats Trial (creative+business pair pattern) · B2 Model B Free Guest×host-project (Edit content, no publish) · B3 Optional invoice reference field (
Subscription.invoice_settings.custom_fields) · B4 Full Pro 14d Trial (Stripe Pricetier_2_monthly, ADR 0008 amendment, Pick #1 + Pick #4 + Step 1.2 rewrites) · B5 Current advertised price + transparent reactivation modal · B6 3 success-moment upsell modals Stage 1 + Atelier REQUIRED UPLOADS checklist cross-ref · B7 Mailto Stage 1 для Enterprise, Cal.com Stage 1.5 trigger ≥3 inquiries · B8 Checkout redirect для Free Guest → first paid (existing flows inline per Step 2.1) · B9-B13 confirmed Step 13 defaults (inbox topology + reason enum + email collision + custom domain transfer §1.5.F clone + GDPR DSR dual-control). 7 new component files added к Files list. Plan status remainsscratch-interview-closedpending Step 5 workstream + final flip кratified.