shipped (v3 soft paywall, Gmail approach)

Spec: F72 — Freemium & Soft Paywall (Gmail Approach)

Status: shipped (v3 soft paywall, Gmail approach) Supersedes: F24 Billing gating strategy, F30 Mobile entry gate, F53 Pricing CTA copy, and F72 v2 (per-feature UpgradeGate/UpgradePrompt + UsageMeterBanner paused-state). The entitlements block, server-authoritative entitlement truth, and the iOS/Android bootstrap letting free users into the workspace are all retained from v2.

Proposed extension: Onboarding 30-seat offer — a server-granted, store-authoritative acquisition package. It is not contract-active or approved for release until its commercial decisions and F24 live-provider gates close.

v3 update (2026-06-29). The v2 strategy of scattered per-feature UpgradeGate / in-context UpgradePrompt + a destructive UsageMeterBanner paused-state did not work for the wedge: every extra friction point is a tax the CEO pays before they have decided to stay. v3 replaces that with a single, dismissable, top-of-shell soft notice for owners/admins when their org’s subscription needs attention — modeled on Gmail’s storage-quota bar.

v3.2 native activation refinement (2026-07-21). After first-time organization setup, iOS and Android present the existing native pricing surface once when server truth says the user can manage billing and the organization is not active or trialing. It is dismissible and overlays an already-enterable workspace, so it is an activation prompt, not an entry gate. Active organizations and members who cannot manage billing skip it. Returning launches never replay it.

v3.3 native seven-day trial (2026-07-21). The upgrade surface advertises a seven-day free trial only when the live store catalog proves the current store account is eligible for the exact configured offer. iOS requires a seven-day free introductory offer plus StoreKit eligibility. Android requires Google Play to return the tagged lumi-trial-7d zero-price P7D offer; otherwise the regular base plan is selected. Offline fallback pricing never claims a trial. Store activation remains blocked until F24 replaces mock provider verification and proves trial lifecycle notifications.

Restore feedback repair (2026-07-24). The iOS Restore Purchases control can be tapped before the paywall’s asynchronous StoreKit bootstrap has created its manager. That tap now starts or joins bootstrap, immediately presents restore progress, and then returns either server-confirmed entitlement or a recoverable error. It is never discarded silently.

v3.1 web refinement (2026-07-15). A healthy Free plan (billing_alert.level:"none") is not a billing incident. On web it is one compact, dismissable header upgrade control, not a full-width late-loading banner. Grace, lapsed, read-only, and unknown future levels remain full-width role="alert" notices below the header. This keeps the upgrade path available while preventing a routine promotion from displacing workspace content or becoming the page’s late Largest Contentful Paint element.

Nothing in the workspace is ever blocked or paused for billing reasons again. Members see nothing extra (they cannot pay anyway). The free workspace is always fully usable: AI extraction runs against the 50/month allowance, storage grows until the org upgrades, CRM is free, unlimited users and projects on all plans. The ai_extraction “pause at 100%” behavior from v2 is removed (extraction always runs; the banner just reminds the owner). The read_only write-gate from F24 is softened to advisory only for v3; the only hard rule is that StoreKit / web-gateway payments must continue to record their receipts and activate the same Subscription truth they did in v2.

v2 history (2026-06-25). ADR-029 changed the free tier: CRM (basic pipelines), unlimited projects, and unlimited users became free. Prior entries in §11/§13 are historical. The crm: false gate in the v1 contract was superseded in v2.

1. Problem & CEO Job

A CEO in Hanoi, Kuala Lumpur, or Jakarta downloads Lumi to fix today’s chaos. If the first screen after sign-up is a wall requiring payment before they have felt value, most leave. The old model was a hard paywall: a brand-new org with no subscription returned status:"none", and the mobile apps routed that straight to a full-screen PaywallView / PaywallScreen, locking the user out of the entire product. The v3.2 activation prompt deliberately shows the same pricing information after organization setup, but removes the coercive boundary: it is dismissible, the workspace is already enterable, and server role truth prevents purchase controls from reaching ordinary members.

The CEO job: Let me understand the commercial choice without holding my company hostage. Freemium means the leader can dismiss the initial native pricing presentation, use Lumi free, and later return to the same upgrade path from the durable billing notice. Pricing may be introduced during activation; payment is never a barrier to entry.

This is convergence, not accumulation (CLAUDE.md North Star): we are not adding a feature, we are making the promise “adopting Lumi is a no-brainer” truer by removing the friction that contradicts it.

2. User Stories & Acceptance Criteria

  • As a brand-new CEO with no subscription, I want to enter Lumi and use the core workspace immediately, so that I feel the value before anyone asks me to pay.

    • Given an authenticated user whose org has no subscription (status:"none"), when the web app or a native app boots, then they land in the main workspace — never a full-screen paywall.
    • Given the Free plan, then daily pulse, tasks, projects, CRM (basic pipelines), teams, today, and the weekly AI summary are fully usable with no upgrade wall and with unlimited users.
  • As a Free-plan owner/admin, I want one proportionate billing notice, so that I can find the upgrade path without routine promotion displacing my work.

    • Given billing_alert.level:"none" on web, an established owner/admin (can_manage_billing:true) sees exactly one compact, dismissable upgrade control in the workspace header. Members see nothing extra.
    • Given native first-time organization setup resolves to a non-active subscription with can_manage_billing:true, iOS and Android present the existing native pricing surface once; dismissal enters the workspace and no durable replay flag is written.
    • Given the resulting organization is active/trialing or can_manage_billing:false, native setup completes directly into the workspace without purchase controls.
    • Given billing_alert.level in {lapsed, grace, read_only} or an unknown future level on web, an owner/admin sees exactly one full-width role="alert" notice below the header. It is not dismissable. No compact upgrade control is shown at the same time.
    • Nothing in the workspace is ever blocked or paused for billing reasons. AI extraction runs against the 50/month allowance without ever pausing. No per-feature UpgradeGate, UpgradePrompt, or UsageMeterBanner paused-state exists in the product.
    • The brief lede paragraph renders for all users (including free users) — the broken ceo_brief entitlement key (which was always false on the free plan because the server only emits ceo_brief_daily/ceo_brief_weekly) no longer gates it.
  • As a first-time native subscriber, I want a truthful seven-day trial offer, so that I can evaluate Lumi without being promised an offer my store account cannot redeem.

    • Given Apple or Google returns the exact configured seven-day offer as eligible, the selected plan shows “7 days free,” the CTA starts the trial, and the renewal price, cadence, cancellation path, and no-charge-today terms are visible before purchase.
    • Given the store account used an introductory trial, the offer is missing, malformed, untagged, or the store catalog is unavailable, no trial wording appears and the normal base plan or non-purchasable fallback renders.
  • As an existing iOS subscriber, I want Restore Purchases to acknowledge an immediate tap while StoreKit starts, so that I know the request is being processed rather than disappearing.

    • Given the StoreKit manager has not yet been created, when I tap Restore Purchases, the paywall initializes it and shows restore progress before resolving the entitlement or a recoverable error.
  • As any client (web, iOS, Android), I want a single server-authoritative entitlements contract, so that every surface gates identically and entitlement truth stays server-side.

    • Given GET /api/billing/subscription/, then the response includes an entitlements object: plan_code, is_free, features (bool map), limits, and meters (live consumption) — for status:"none" it resolves to the Free plan entitlements, not empty.
    • Given GET /api/billing/subscription/, the response includes a billing_alert field (null when no alert is needed) carrying kind, level, title, body, and cta_label. Clients localize the level-specific message; the server level and call to action remain authoritative across web, iOS, and Android.
    • Given Android receives a known billing_alert.level (none, grace, read_only, or lapsed), the shell renders native title and CTA copy in the selected app language. An unknown future level preserves server copy instead of hiding the notice.
  • As a lapsed payer, I want grace then read-only writes (data preserved), so that non-payment never deletes my work.

    • Read-only (status:"read_only") remains the only hard gate, and it gates writes for a previously paying org — not entry for a free user. Free users are never read-only.

3. Scope Boundaries

  • In: server-authoritative entitlements and billing_alert contracts; free-user workspace entry; one role-aware billing notice per shell; the web urgency split between a compact healthy-Free control and full-width real billing alerts; org-scoped dismissal; i18n.
  • Out: changing payment rails or webhook logic (F24 unchanged); per-feature upgrade gates; hard enforcement of AI update overage; recurring billing; changing native notice presentation in the v3.1 web refinement.

4. Data Model

No new tables. entitlements is derived at request time from the existing Plan / Subscription and the hardcoded PLAN_FEATURES_MATRIX / PLAN_CATALOG in backend/apps/billing/features.py. Free / none resolves to the free catalog entry.

5. API Contract

GET /api/billing/subscription/ — unchanged path & auth; response gains an entitlements block:

{
  "status": "none",
  "plan": null,
  "can_manage_billing": true,
  "contact_owner_name": null,
  "entitlements": {
    "plan_code": "free",
    "is_free": true,
    "features": {
      "daily_pulse": true,
      "ceo_brief_daily": false,
      "ceo_brief_weekly": true,
      "ai_extraction": true,
      "crm": true,
      "unlimited_projects": true,
      "unlimited_users": true,
      "decisions": false,
      "internal_chat": false,
      "approvals": false,
      "advanced_analytics": false,
      "audit_log": false,
      "multi_branch": false,
      "integrations": false,
      "advanced_rbac": false
    },
    "limits": {
      "ai_updates_monthly": 50,
      "storage_mb": 512,
      "automation_runs_monthly": 5,
      "max_teams": 2,
      "max_branches": 1
    },
    "meters": {
      "ai_updates_used": 0,
      "storage_used_mb": 0,
      "automation_runs_used": 0
    }
  }
}

Key v2 changes from v1:

  • crm: true (was false) — basic CRM pipelines are free
  • unlimited_projects: true (was max_projects: 1) — unlimited projects on free
  • unlimited_users: true (was max_users: 3) — unlimited users on all plans
  • ceo_brief_weekly: true — free plans get a weekly AI summary (not daily)
  • ceo_brief_daily: false — daily brief is the primary paid feature gate
  • meters block is new — live consumption counters exposed to clients for soft-limit UI
  • ai_extraction: true on free — extraction runs on the 50/mo allowance (not fully gated)

For an active subscription, plan_code is the subscription’s plan code, is_free:false, and features/limits/meters come from that plan’s catalog row. Unknown/legacy plan codes fall back to all-features-on so paying customers are never under-gated.

6. UI / UX

  • Web: useEntitlements() derives feature truth from useSubscription(). The workspace is always fully usable. Layout.tsx renders exactly one billing surface for an eligible owner/admin: level:"none" becomes a compact, dismissable header control after first-run; all other levels remain a full-width, non-dismissable BillingAlertBanner below the header. The compact control’s accessible name includes the localized title, body, and CTA while its visible label stays short enough for a $150 Android-sized viewport. Members see nothing extra. The brief lede renders unconditionally.
  • iOS / Android: the bootstrap entitlement gate no longer blocks free users — authenticated users with an org always enter the main tab view. Immediately after first-time organization setup only, an eligible owner/admin receives one dismissible presentation of the same PaywallView / PaywallScreen; active/trialing orgs and non-billing members skip it, and later launches do not replay it. The shared KMP BillingAlertBanner remains the durable in-shell upgrade path.
  • Native trial offer: trial copy is selected-product-specific and store-authoritative. The app never persists or infers trial eligibility. Store fallback cards show prices only. Exact console setup and release proof live in Seven-day native free trial.
  • i18n: billing namespace carries billing_alert_* keys for the banner copy (server also sends pre-translated title/body/cta_label; client i18n is a fallback layer).

7. Test Plan

#CaseTypeWhere
1entitlements present for status:"none" resolves to Free plan (is_free true, ceo_brief false)backendbackend/tests/test_billing.py
2entitlements for active Starter sub → is_free false, ceo_brief true, limits from catalogbackendsame
3Unknown/legacy plan code → features default on (paying customer not under-gated)backendsame
4useEntitlements returns free map when status none; flags premium lockedfrontend unitfrontend/src/test/entitlements.test.tsx
5UpgradeGate renders children when entitled, UpgradePrompt when not; member sees contact-owner copy REMOVED (v3) — UpgradeGate/UpgradePrompt deleted——
6UsageMeterBanner renders nothing when fraction < 0.8 REMOVED (v3)——
7UsageMeterBanner renders info banner when 0.8 ≤ fraction < 1.0 REMOVED (v3)——
8UsageMeterBanner renders paused banner when fraction ≥ 1.0 REMOVED (v3)——
9UsageMeterBanner paused banner shows upgrade CTA REMOVED (v3)——
10UsageMeterBanner paused banner shows ask-owner copy REMOVED (v3)——
11UsageMeterBanner renders nothing when usageFraction returns null REMOVED (v3)——
12Free org at 80% AI limit: meter_warning_title visible on BriefPage REMOVED (v3)——
13Free org at 100% AI limit: paused banner visible on BriefPage REMOVED (v3) — replaced by v3 E2EE2Efrontend/tests/e2e/lumi.spec.ts (“free org enters brief page without any upgrade gate…“)
13v3Web routes healthy Free to one compact header control, routes real billing problems to one full-width alert, suppresses fresh-org/member notices, and preserves i18n/accessibility/dismissalfrontend unit + E2Edocs/test-plan/f72-freemium-upgrade-prompts.md
14KMP shared DTO: EntitlementsResponse with v2 feature keys + meters block deserializes correctlyKMP contractandroid/shared/src/jvmTest/.../DtoContractTest.kt
15KMP: hasFeature("crm") == true, hasFeature("ceo_brief") == false, hasFeature("ceo_brief_weekly") == trueKMP contractsame test
16KMP: getMeter("ai_updates_used") == 40; missing meter key returns 0KMP contractsame test
17KMP: limits["ai_updates_monthly"] == 50; limits["max_users"] == -1 (unlimited sentinel)KMP contractsame test
18iOS: free status:"none" enters MainTabView, not PaywallViewiOSios/UnitTests/Workspace/SubscriptionDecodeTests.swift
19Android: toGateState for free/none → Active (enters), not NeedsPaywallandroidMainActivityViewModelTest

Backend coverage must stay ≥99%.

8. Current Behavior

  • Backend: _serialize_subscription_state now emits entitlements derived from the plan catalog; free/none resolves to the Free entitlements.
  • Web: useEntitlements preserves server feature truth; no per-feature gate remains. The established healthy-Free state is one compact header control; real billing problems remain one full-width alert.
  • iOS/Android: bootstrap gate lets free users into the workspace; paywall is an upgrade surface.

9. Error States

ErrorUser messageNotes
Subscription fetch fails on native bootenter workspace on Free entitlements (fail-open)Never wall a user out due to a transient billing read
Member taps premium featurebilling.upgrade_ask_owner with owner nameNo checkout shown to non-billing roles
Read-only org writeexisting subscription_read_only 403 (F24)Unchanged; lapsed-payer only

10. Diagrams & Docs Touched

frontend/DIAGRAMS.md (auth/entitlement states) · F24 §1/§6 strategy note · F30 entitlement-gate section · F53 CTA copy.

11. Implementation Audit

#ClaimStatusEvidence
1entitlements block on subscription endpoint✅ matchbackend/apps/billing/views.py; backend/tests/test_billing_api.py
2Web useEntitlements hook (v3: gates retired; hook kept for canManageBilling / usageFraction / hasFeature in BillingAlertBanner)✅ matchfrontend/src/hooks/queries/useEntitlements.ts; frontend/src/test/entitlements.test.tsx (UpgradeGate/UpgradePrompt cases removed in PR feat/f72-v3-remove-retired-gates)
3iOS free users enter workspace (no paywall gate)✅ matchios/Lumi/DependencyInjection/AppEnvironment.swift; ios/UnitTests/Workspace/SubscriptionDecodeTests.swift
4Android free users enter workspace (toGateState)✅ matchandroid/app/src/main/kotlin/com/locuno/lumi/android/MainActivityViewModel.kt; android/app/src/test/kotlin/com/locuno/lumi/android/FreemiumGateTests.kt
5iOS KMP bridge transparent to v2 entitlement fields✅ matchADR-025: ios/Lumi/Networking/KMPDtoBridge.swift uses typealias KMPEntitlements = shared.EntitlementsResponse; KMP features: Map<String,Boolean> and limits: Map<String,Int> auto-accept any new key. AppState.swift Equatable extension updated to include limits (v2). SubscriptionDecodeTests.swift extended with v2 feature flag + limits decode test.
6iOS PaywallView: no hardcoded prices, no entry-blocking gate✅ matchios/Lumi/Billing/PaywallView.swift reads product.displayPrice (StoreKit) or falls back to /api/plans/ — zero hardcoded VND amounts; no full-screen gate for free users

Known gap (P-backlog): per-feature backend enforcement is advisory in v1 — clients gate. Promote to hard server enforcement only when abuse is observed. | 7 | v3: server emits billing_alert (kind + level) for non-active subscriptions | ✅ match | backend/apps/billing/billing_payment_views.py → _build_billing_alert; backend/tests/billing/test_billing_state.py | | 8 | v3.1: web routes healthy Free to one compact, dismissable header control; real billing states to one full-width alert; fresh orgs and members see neither | ✅ match | frontend/src/components/billing/BillingAlertBanner.tsx; frontend/src/layouts/Layout.tsx; frontend/src/test/billing/billing-alert.test.tsx; frontend/src/test/_shared/Layout.test.tsx | | 9 | v3: KMP shared BillingAlertBanner Composable rendered at top of iOS/Android shell; known Android/iOS billing levels use native English/Vietnamese title and CTA resources with server-copy fallback for unknown levels; iOS native paywall rewired to the 8 real App Store Connect products | ✅ match | android/shared/src/commonMain/kotlin/com/locuno/lumi/shared/ui/common/BillingAlertBanner.kt; android/shared/src/iosMain/kotlin/com/locuno/lumi/shared/MainViewController.kt; ios/Lumi/KMP/KMPBillingAlertBannerView.swift; android/shared/src/jvmTest/.../BillingAlertBannerTest.kt; .maestro/critical/82_android_billing_alert_localization.yaml; .maestro/critical/27_forecast_screen.yaml; ios/Lumi/Billing/StoreKitManager.swift (8 lumi_ai_pro_* products) | | 10 | v3: zero in-app feature-level UpgradeGate/UsageMeterBanner-paused remaining; per-feature gates are removed; AI extraction is no longer paused at 100% | ✅ match | UpgradeGate.tsx, UpgradePrompt.tsx, UsageMeterBanner.tsx deleted; BriefPage.tsx, ChaosImportPage.tsx, SubscriptionManagement.tsx unwired; all wiring tests removed (PR feat/f72-v3-remove-retired-gates, 2026-07-02). Fixes brief lede hidden behind dead ceo_brief entitlement key. | | 11 | v3.3: native paywalls advertise a seven-day trial only from live store eligibility and otherwise select the regular plan without trial copy | ✅ source/unit match | Android BillingOfferSelector requires eligible returned tag + zero P7D; iOS StoreKitManager requires an exact free offer + isEligibleForIntroOffer; named sandbox lifecycle and real backend provider verification remain explicit gaps. |

12. Open Questions / Decisions

  • v2 (ADR-029): max_users and max_projects limits removed from the free tier. Users and projects are unlimited on all plans. Free tier gates are now: ai_updates_monthly (50), storage_mb (512), automation_runs_monthly (5), max_teams (2), max_branches (1).
  • “Daily brief” (ceo_brief_daily) is the primary upgrade driver and is gated at Starter+. A weekly AI summary (ceo_brief_weekly) remains free so new users can see the AI in action during their first session.
  • ai_extraction on free: extraction runs against the 50/mo allowance. When the allowance is exhausted, new inputs are stored as raw text; extraction resumes when the org upgrades or when the monthly counter resets. This is a soft pause, never a hard lock.
  • CRM (crm: true on free): includes basic contact management, company records, and pipeline deals. Advanced CRM reporting and multi-pipeline governance are gated at Growth+.
  • Upgrade prompt triggers for v2: ceo_brief_daily (primary), ai_updates_used ≥ 80% (soft limit banner), ai_updates_used = 100% (extraction pause + CTA), max_teams exceeded (Growth+), multi_branch (Growth+), audit_log (Business+), governance (Business+).

13. Bug / Change Log

2026-07-21 — Store-authoritative seven-day native trial

Type: Native billing UX and eligibility hardening

Summary:

  • Android now deterministically selects the eligible lumi-trial-7d offer returned by Play, or the regular base plan when it is absent. It no longer purchases the first arbitrary offer in the catalog.
  • iOS requires both an exact seven-day free introductory offer and StoreKit’s current-account eligibility before showing trial copy.
  • Both paywalls show the renewal price and cancellation terms for an eligible selection. Fallback catalogs never advertise a trial.
  • Store setup records the manual console steps and blocks production activation while F24 provider verification remains a mock.

2026-07-20 — iOS billing notice localization contract

Type: Mobile i18n correction

Summary:

  • iOS reconstructed the shared alert with an empty stable level and mounted its Compose host without the selected app locale, so a Vietnamese workspace rendered the backend’s English compatibility copy.
  • The bridge now passes the active locale at controller construction, recreates the host on language changes, and preserves kind, level, title, body, and ctaLabel on every reactive update.
  • The iOS populated Forecast journey now requires the Vietnamese Free-plan title and CTA and rejects the English fallback. Simulator evidence remains mechanics-only; named-iPhone bilingual and StoreKit sandbox evidence remain open.

2026-07-18 — Android billing notice localization

Type: Mobile i18n correction

Summary:

  • Android rendered the backend’s English title and cta_label verbatim even when the app language was Vietnamese. The web client already treated level as the stable authority and resolved localized copy client-side.
  • The shared banner now maps all four known levels to native English/Vietnamese resources. Unknown future levels deliberately retain backend copy, keeping the notice visible while the client catches up.
  • BillingAlertBannerTest pins the level allowlist and fallback policy. Critical Maestro flow 82 rejects the observed English leak on Android; the iOS Forecast fixture runner preserves the complete alert DTO and active locale, requires Vietnamese title/CTA copy, and rejects the English fallback. Emulator/simulator output remains UI-only; named-device bilingual proof remains open.

2026-07-15 — v3.1 web notice proportionality and corrected LCP diagnosis

Type: UX Correction + Performance Diagnosis

Summary:

  • The healthy Free-plan promotion loaded after the subscription query as a long full-width text row. It became the observed late LCP element on Today, Brief, Chat, Pulse, and Deals even though it represented no billing incident.
  • Web now renders level:"none" as one compact header upgrade control after the first-run gate. It remains owner/admin-only, dismissable per organization, localized, accessible, and linked to billing settings.
  • Grace, lapsed, read-only, and unknown levels retain the full-width warning surface and role="alert"; they cannot be dismissed. Native presentation is unchanged.
  • The canonical rerun showed that the promotion was masking, not causing, slow route readiness: with less displacement, actual workspace content becomes LCP later. Authenticated CLS improves on every route (Tasks 0.163→0.000), but LCP does not. W-SYS-01 remains 6 and the next lever is route-content readiness.
  • F72 gains a dedicated TDD plan and CUE contract. Tenant isolation and web runtime are now focused proofs; named native runtime remains explicit in generated gaps.

2026-07-05 — iOS paywall: Apple 3.1.2(c) subscription-disclosure hardening

Type: Compliance Fix (App Store review rejection)

Summary:

  • App Review rejection (Guideline 3.1.2(c)). Apple flagged the submission as missing required auto-renewable-subscription information. The in-app paywall (ios/Lumi/Billing/PaywallView.swift) already surfaced plan title (displayName), price (displayPrice), and functional Terms-of-Use (standard Apple EULA) + Privacy-Policy links; the primary gap was the App Store Connect metadata (standard EULA link absent from the App Description) plus the absence of the binding auto-renewal disclosure text in the purchase flow.
  • In-app hardening (this PR). Added PaywallView.autoRenewDisclosure — the binding disclosure (charged to Apple ID at confirmation; renews unless auto-renew is off ≥24h before period end; charged within 24h of renewal; manage/cancel in App Store settings), shown verbatim above the Terms/Privacy links so title + length + price + renewal terms + legal links all appear together. Localized en + vi in Localizable.xcstrings.
  • Price per unit made explicit. Plan cards and the VoiceOver label now append the per-period suffix (/ tháng · / năm) to displayPrice, matching the offline fallbackPrice, so length and price-per-unit are unambiguous.
  • Test. PaywallGateTests.test_paywall_autoRenewDisclosureVisible asserts the disclosure is present in the purchase flow and states the “Apple ID” payer and the “24”-hour renewal rule.
  • Metadata (in-repo, pushed via ios/scripts/asc-update-listing.py). ASC has no separate EULA field in the pipeline, so the standard Apple EULA link (https://www.apple.com/legal/internet-services/itunes/dev/stdeula/) plus a subscription-info block (title, tiers, monthly/annual price example, binding auto-renewal terms) was appended to the description in docs/mobile/app-stores/locales/en-US.json and vi.json (both kept < 4000-char App Store limit; vi omits the redundant privacy line since the script pushes privacyPolicyUrl separately). Remaining 37 ASC-live locales still to be back-filled. Run python3 ios/scripts/asc-update-listing.py --lang en-US / --lang vi to push.
  • Manual App Store Connect steps (not scriptable here). Confirm Privacy Policy field resolves logged-out (https://runlumi.app/store/{lang}/privacy — see postmortem 2026-07-02); reply to the reviewer with a paywall screen recording; paste the disclosure block into App Review Information → Notes for future submissions.

2026-07-02 — v3 completion: retire per-feature gates; fix brief lede bug

Type: Bug Fix + Cleanup

Summary:

  • Retired gating components deleted. UpgradeGate.tsx, UpgradePrompt.tsx, and UsageMeterBanner.tsx removed from frontend/src/components/billing/. Their wiring in BriefPage.tsx (both the <UsageMeterBanner meterKey="ai_updates" /> and the <UpgradeGate feature="ceo_brief"> wrapping the lede <p>), ChaosImportPage.tsx (<UsageMeterBanner meterKey="storage" />), and SubscriptionManagement.tsx (<UpgradeGate feature="ceo_brief">) is removed. All unused imports cleaned up.
  • Brief lede bug fixed. The <UpgradeGate feature="ceo_brief"> was always false for every user, including paying subscribers: the server entitlements only emit ceo_brief_daily and ceo_brief_weekly — the bare ceo_brief key it tested never existed in the v3 contract. Removing the gate makes the brief summary paragraph visible to all users immediately.
  • Test cleanup. UsageMeterBanner.test.tsx deleted. UpgradeGate/UpgradePrompt describe block removed from entitlements.test.tsx. E2E: the two v2 billing tests (“upgrade prompt instead of blocking entry”, “paused meter banner”) are replaced with a single v3 test asserting no gate and no paused banner on the brief page.
  • Implementation Audit rows 7–10 flipped to ✅. All §11 rows are now verified.

Bug root cause:

  • hasFeature('ceo_brief') calls entitlements.features['ceo_brief'] === true. The v3 entitlements contract splits the single ceo_brief key into ceo_brief_daily (paid) and ceo_brief_weekly (free). No surface emits the bare ceo_brief key → hasFeature('ceo_brief') is always false → the gate always rendered <UpgradePrompt> regardless of plan, hiding the lede from all users including paying ones.

Why detection missed it:

  • The unit tests in entitlements.test.tsx were mocked with features: { ceo_brief: false } — the same wrong key — so they passed even though the gate was always-false in production. The E2E test only asserted the upgrade prompt was visible (which it was, because the gate was always-false), rather than asserting that a paying user could not see the prompt.

Prevention:

  • v3 removes per-feature gates entirely. The billing_alert approach has no feature-key mismatch risk because it is opt-in (only shown when server emits billing_alert non-null).

2026-06-29 — v3 soft paywall (Gmail approach) + App Store Connect IAP

Type: Behavior Change + Spec Rewrite

Summary:

  • Gating strategy flip. v3 retires per-feature UpgradeGate / in-context UpgradePrompt and the UsageMeterBanner paused-state. Nothing in the workspace is ever blocked or paused for billing reasons again. AI extraction runs against the 50/month allowance on free plans without ever pausing. The F24 read_only write-gate is softened to advisory only — the only hard rule is that StoreKit / web-gateway receipts still activate the same Subscription truth they did in v2.
  • Single top-of-shell soft notice. Owners and admins (can_manage_billing: true) of a non-active org (status in {none, lapsed, grace, expired, read_only}) see one dismissable <BillingAlertBanner> rendered at the top of the workspace shell, on every platform. Members (can_manage_billing: false) see nothing extra.
  • billing_alert on the subscription endpoint. GET /api/billing/subscription/ gains a billing_alert field (null when no alert is needed):
    "billing_alert": { "kind": "soft_paywall", "level": "lapsed", "title": "...", "body": "...", "cta_label": "..." }
    Copy is server-side so the same words are used on web, iOS, and Android; i18n (en + vi) is layered on top by the client. Levels: none (never paid), grace (3-day grace after expiry), expired (subscription expired), lapsed (lapsed, no auto-renew), read_only (legacy soft signal only).
  • Real App Store Connect products on iOS. ios/Lumi/Billing/StoreKitManager.swift switches from the legacy 6 com.runlumi.{starter|team|business}_{monthly|annual} IDs to the 8 real ASC products: lumi_ai_pro_{5|10|20|50}_{monthly|annual} (5/10/20/50 AI seats, AI Pro plan). The leftover ios/Lumi/Resources/StoreKit.storekit config file — which referenced an unrelated product (com.whatsthismail.*) — is replaced with one bound to the Lumi products. PaywallView renders exactly the 8 real packs, prices straight from product.displayPrice (StoreKit-localized), with a seat-count badge on each card.
  • Backend resolver + seats. apps/billing/services/resolve_store_product is rewritten to parse the new lumi_ai_pro_5_monthly shape → (plan_code=ai_pro, seats=5, period=monthly). Subscription gains a seats: int field and PaymentIntent records the purchased seat count. POST /api/billing/pay/ (web) accepts seats and computes the per-seat × seats price for the per-AI-user model (ADR-024 §3).
  • Shared KMP module updates. android/shared/.../SubscriptionScreen.kt plan-code mapping is rewired from the old starter/team/business keys to the new ai_starter/ai_pro/ai_ultimate keys (the corresponding subscription_plan_* resource strings are added in commonMain resources for both en and vi). A shared BillingAlertBanner Composable is added to android/shared/.../ui/common/ and consumed by both iOS and Android shells.
  • No per-feature backend enforcement. The F24 read-only 403 stays as a server-side soft signal in indicate the banner level, but it is no longer used as a hard write-blocker in v3 (Gmail doctrine: the user can always use the product).

Reason:

  • v2 was correct that “no hard entry paywall” matters, but wrong that scattered upgrade prompts are the answer. In a wedge product, every extra “this is a paid feature” notice is friction the user pays before they trust us. The v3 surface is one calm banner that says “your subscription needs attention” — only to the person who can fix it — and never blocks anything else.

Done-means gate (per cmd-agents-team r-billing-iap):

  • Backend: cd backend && DJANGO_SETTINGS_MODULE=config.settings.test python -m pytest tests/ --cov=apps --cov=core --cov-fail-under=99 -x --tb=short green.
  • iOS: cd ios && xcodebuild build-for-testing -scheme Lumi green, 0 error: lines; StoreKitManagerTests period-of resolution green for all 8 new IDs.
  • KMP shared: cd android && ./gradlew :shared:compileKotlinJvm :shared:jvmTest green; new BillingAlertBannerTest and SubscriptionPlanCodeTest pass.
  • Web: cd frontend && pnpm test green (vitest; via the machine-wide lock script), tsc --noEmit green.
  • DIAGRAMS.md + spec/registry in the same PR; main untouched until the four agents merge.

2026-06-25 — iOS KMP bridge aligned to v2 entitlements contract (ADR-029)

Type: Verification / Alignment

Summary:

  • Audited ios/Lumi/Networking/KMPDtoBridge.swift: uses typealias KMPEntitlements = shared.EntitlementsResponse; the KMP features: Map<String, Boolean> and limits: Map<String, Int> fields are open maps so new v2 keys (ceo_brief_weekly, crm, ai_extraction, max_users: -1, max_projects: -1, etc.) require no Swift code change — transparent by design (ADR-025).
  • Updated ios/Lumi/Core/AppState.swift: EntitlementsResponse Equatable extension now includes limits in the equality check so freemium limit changes are correctly detected.
  • Extended ios/UnitTests/Workspace/SubscriptionDecodeTests.swift with two new tests: test_entitlements_v2Features_allNewKeysDecodeCorrectly (verifies all new feature/limit keys decode; ceo_brief_weekly, crm, ai_extraction flags; max_users/max_projects limit keys) and test_entitlements_v2_bootstrapEntitlement_freeStatusEnters (confirms status:"free" parses without error under the freemium model).
  • meters field: KMP DTO meters: Map<String, Int> will be added by the Android Task D agent; once the shared XCFramework is rebuilt the iOS bridge will transparently expose it. A TODO comment in AppState.swift documents the two lines to add when the XCFramework ships meters.
  • Confirmed PaywallView.swift: no hardcoded prices (reads StoreKit displayPrice or /api/plans/), no blocking paywall gate for free users.

2026-06-25 — v2 entitlements: freemium workspace (ADR-029)

Type: Behavior Change

Summary:

  • Free tier now includes CRM (basic), unlimited projects, unlimited users (was: crm:false, max_projects:1, max_users:3).
  • Primary upgrade gate shifts from feature presence (CRM, projects) to AI consumption limits and operational complexity (multi-branch, unlimited teams, advanced analytics, audit log, governance).
  • Added meters block to the entitlements response (ai_updates_used, storage_used_mb, automation_runs_used).
  • ceo_brief split into ceo_brief_daily (paid, Starter+) and ceo_brief_weekly (free).
  • AI extraction is partially free (50 updates/month, then paused — never hard-blocked).

Reason:

  • ADR-029: give away the collaboration layer (tasks/projects/CRM/unlimited users); charge for AI intelligence and operational complexity. Getting customers is the hardest part — the free product must be genuinely useful.

Implementation required:

  • backend/apps/billing/features.py → update PLAN_FEATURES_MATRIX with new feature flags
  • backend/apps/billing/views.py → add meters block to entitlements serialization
  • frontend/src/hooks/queries/useEntitlements.ts → consume new meters and new feature flags
  • frontend/src/components/billing/UpgradePrompt.tsx → add AI-limit soft-limit prompts
  • Seed data / plan fixtures → update plan codes and feature sets

2026-06-15 — Strategy switch: hard paywall → freemium

Type: Behavior Change

Summary:

  • New spec; introduced server-authoritative entitlements contract on GET /api/billing/subscription/.
  • Removed the mobile bootstrap paywall gate for free users; paywall repurposed as an upgrade surface.
  • Added web useEntitlements + UpgradeGate/UpgradePrompt; advanced-brief soft-gate.

Reason:

  • A hard entry paywall contradicts the wedge in underserved markets; users must feel value before being asked to pay.

2026-06-17 — Web E2E + audit truthing

Type: Verification

Summary:

  • Added a focused browser test proving that a free org can still enter /intel/brief and sees an in-context upgrade prompt instead of a blocking paywall.
  • Updated the Implementation Audit to reflect the current backend, web, iOS, and Android evidence already in-tree.

Reason:

  • The codebase had largely implemented F72, but the spec still marked its core claims as pending and lacked the browser-level proof for the canonical web soft-gate surface.

Impact:

  • F72 remains 🚧 overall because native shells are still port-in-progress at the registry level, but the web E2E rail is now verified and the spec no longer understates shipped behavior.

Tests:

  • backend/.venv/bin/python -m pytest tests/test_billing_api.py -q
  • pnpm --dir frontend exec vitest run src/test/entitlements.test.tsx
  • pnpm --dir frontend exec tsc --noEmit
  • pnpm --dir frontend exec playwright test tests/e2e/lumi.spec.ts -g "free entitlements show in-context upgrade prompt instead of blocking entry" --project=chromium