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-contextUpgradePrompt+ a destructiveUsageMeterBannerpaused-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-7dzero-priceP7Doffer; 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-widthrole="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). Theread_onlywrite-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 sameSubscriptiontruth 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: falsegate 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.
- Given an authenticated user whose org has no subscription (
-
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.levelin {lapsed,grace,read_only} or an unknown future level on web, an owner/admin sees exactly one full-widthrole="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, orUsageMeterBannerpaused-state exists in the product. - The brief lede paragraph renders for all users (including free users) — the broken
ceo_briefentitlement key (which was alwaysfalseon the free plan because the server only emitsceo_brief_daily/ceo_brief_weekly) no longer gates it.
- Given
-
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 anentitlementsobject:plan_code,is_free,features(bool map),limits, andmeters(live consumption) — forstatus:"none"it resolves to the Free plan entitlements, not empty. - Given
GET /api/billing/subscription/, the response includes abilling_alertfield (null when no alert is needed) carryingkind,level,title,body, andcta_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, orlapsed), 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.
- Given
-
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.
- Read-only (
3. Scope Boundaries
- In: server-authoritative
entitlementsandbilling_alertcontracts; 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(wasfalse) — basic CRM pipelines are freeunlimited_projects: true(wasmax_projects: 1) — unlimited projects on freeunlimited_users: true(wasmax_users: 3) — unlimited users on all plansceo_brief_weekly: true— free plans get a weekly AI summary (not daily)ceo_brief_daily: false— daily brief is the primary paid feature gatemetersblock is new — live consumption counters exposed to clients for soft-limit UIai_extraction: trueon 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 fromuseSubscription(). The workspace is always fully usable.Layout.tsxrenders 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-dismissableBillingAlertBannerbelow 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 KMPBillingAlertBannerremains 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:
billingnamespace carriesbilling_alert_*keys for the banner copy (server also sends pre-translatedtitle/body/cta_label; client i18n is a fallback layer).
7. Test Plan
| # | Case | Type | Where |
|---|---|---|---|
| 1 | entitlements present for status:"none" resolves to Free plan (is_free true, ceo_brief false) | backend | backend/tests/test_billing.py |
| 2 | entitlements for active Starter sub → is_free false, ceo_brief true, limits from catalog | backend | same |
| 3 | Unknown/legacy plan code → features default on (paying customer not under-gated) | backend | same |
| 4 | useEntitlements returns free map when status none; flags premium locked | frontend unit | frontend/src/test/entitlements.test.tsx |
| 5 | UpgradeGate renders children when entitled, UpgradePrompt when not; member sees contact-owner copyUpgradeGate/UpgradePrompt deleted | — | — |
| 6 | UsageMeterBanner renders nothing when fraction < 0.8 | — | — |
| 7 | UsageMeterBanner renders info banner when 0.8 ≤ fraction < 1.0 | — | — |
| 8 | UsageMeterBanner renders paused banner when fraction ≥ 1.0 | — | — |
| 9 | UsageMeterBanner paused banner shows upgrade CTA | — | — |
| 10 | UsageMeterBanner paused banner shows ask-owner copy | — | — |
| 11 | UsageMeterBanner renders nothing when usageFraction returns null | — | — |
| 12 | meter_warning_title visible on BriefPage | — | — |
| 13 | E2E | frontend/tests/e2e/lumi.spec.ts (“free org enters brief page without any upgrade gate…“) | |
| 13v3 | Web 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/dismissal | frontend unit + E2E | docs/test-plan/f72-freemium-upgrade-prompts.md |
| 14 | KMP shared DTO: EntitlementsResponse with v2 feature keys + meters block deserializes correctly | KMP contract | android/shared/src/jvmTest/.../DtoContractTest.kt |
| 15 | KMP: hasFeature("crm") == true, hasFeature("ceo_brief") == false, hasFeature("ceo_brief_weekly") == true | KMP contract | same test |
| 16 | KMP: getMeter("ai_updates_used") == 40; missing meter key returns 0 | KMP contract | same test |
| 17 | KMP: limits["ai_updates_monthly"] == 50; limits["max_users"] == -1 (unlimited sentinel) | KMP contract | same test |
| 18 | iOS: free status:"none" enters MainTabView, not PaywallView | iOS | ios/UnitTests/Workspace/SubscriptionDecodeTests.swift |
| 19 | Android: toGateState for free/none → Active (enters), not NeedsPaywall | android | MainActivityViewModelTest |
Backend coverage must stay ≥99%.
8. Current Behavior
- Backend:
_serialize_subscription_statenow emitsentitlementsderived from the plan catalog; free/none resolves to the Free entitlements. - Web:
useEntitlementspreserves 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
| Error | User message | Notes |
|---|---|---|
| Subscription fetch fails on native boot | enter workspace on Free entitlements (fail-open) | Never wall a user out due to a transient billing read |
| Member taps premium feature | billing.upgrade_ask_owner with owner name | No checkout shown to non-billing roles |
| Read-only org write | existing 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
| # | Claim | Status | Evidence |
|---|---|---|---|
| 1 | entitlements block on subscription endpoint | ✅ match | backend/apps/billing/views.py; backend/tests/test_billing_api.py |
| 2 | Web useEntitlements hook (v3: gates retired; hook kept for canManageBilling / usageFraction / hasFeature in BillingAlertBanner) | ✅ match | frontend/src/hooks/queries/useEntitlements.ts; frontend/src/test/entitlements.test.tsx (UpgradeGate/UpgradePrompt cases removed in PR feat/f72-v3-remove-retired-gates) |
| 3 | iOS free users enter workspace (no paywall gate) | ✅ match | ios/Lumi/DependencyInjection/AppEnvironment.swift; ios/UnitTests/Workspace/SubscriptionDecodeTests.swift |
| 4 | Android free users enter workspace (toGateState) | ✅ match | android/app/src/main/kotlin/com/locuno/lumi/android/MainActivityViewModel.kt; android/app/src/test/kotlin/com/locuno/lumi/android/FreemiumGateTests.kt |
| 5 | iOS KMP bridge transparent to v2 entitlement fields | ✅ match | ADR-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. |
| 6 | iOS PaywallView: no hardcoded prices, no entry-blocking gate | ✅ match | ios/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_usersandmax_projectslimits 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_extractionon 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: trueon 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_teamsexceeded (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-7doffer 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
leveland 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, andctaLabelon 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
titleandcta_labelverbatim even when the app language was Vietnamese. The web client already treatedlevelas 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.
BillingAlertBannerTestpins 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. Localizeden+viinLocalizable.xcstrings. - Price per unit made explicit. Plan cards and the VoiceOver label now append the per-period suffix (
/ tháng·/ năm) todisplayPrice, matching the offlinefallbackPrice, so length and price-per-unit are unambiguous. - Test.
PaywallGateTests.test_paywall_autoRenewDisclosureVisibleasserts 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 thedescriptionindocs/mobile/app-stores/locales/en-US.jsonandvi.json(both kept < 4000-char App Store limit; vi omits the redundant privacy line since the script pushesprivacyPolicyUrlseparately). Remaining 37 ASC-live locales still to be back-filled. Runpython3 ios/scripts/asc-update-listing.py --lang en-US/--lang vito 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, andUsageMeterBanner.tsxremoved fromfrontend/src/components/billing/. Their wiring inBriefPage.tsx(both the<UsageMeterBanner meterKey="ai_updates" />and the<UpgradeGate feature="ceo_brief">wrapping the lede<p>),ChaosImportPage.tsx(<UsageMeterBanner meterKey="storage" />), andSubscriptionManagement.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 emitceo_brief_dailyandceo_brief_weekly— the bareceo_briefkey 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.tsxdeleted.UpgradeGate/UpgradePromptdescribe block removed fromentitlements.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')callsentitlements.features['ceo_brief'] === true. The v3 entitlements contract splits the singleceo_briefkey intoceo_brief_daily(paid) andceo_brief_weekly(free). No surface emits the bareceo_briefkey →hasFeature('ceo_brief')is alwaysfalse→ 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.tsxwere mocked withfeatures: { 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_alertapproach has no feature-key mismatch risk because it is opt-in (only shown when server emitsbilling_alertnon-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-contextUpgradePromptand theUsageMeterBannerpaused-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 F24read_onlywrite-gate is softened to advisory only — the only hard rule is that StoreKit / web-gateway receipts still activate the sameSubscriptiontruth 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_alerton the subscription endpoint.GET /api/billing/subscription/gains abilling_alertfield (null when no alert is needed):
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:"billing_alert": { "kind": "soft_paywall", "level": "lapsed", "title": "...", "body": "...", "cta_label": "..." }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.swiftswitches from the legacy 6com.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 leftoverios/Lumi/Resources/StoreKit.storekitconfig file — which referenced an unrelated product (com.whatsthismail.*) — is replaced with one bound to the Lumi products.PaywallViewrenders exactly the 8 real packs, prices straight fromproduct.displayPrice(StoreKit-localized), with a seat-count badge on each card. - Backend resolver + seats.
apps/billing/services/resolve_store_productis rewritten to parse the newlumi_ai_pro_5_monthlyshape →(plan_code=ai_pro, seats=5, period=monthly).Subscriptiongains aseats: intfield andPaymentIntentrecords the purchased seat count.POST /api/billing/pay/(web) acceptsseatsand computes the per-seat × seats price for the per-AI-user model (ADR-024 §3). - Shared KMP module updates.
android/shared/.../SubscriptionScreen.ktplan-code mapping is rewired from the oldstarter/team/businesskeys to the newai_starter/ai_pro/ai_ultimatekeys (the correspondingsubscription_plan_*resource strings are added incommonMainresources for bothenandvi). A sharedBillingAlertBannerComposable is added toandroid/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=shortgreen. - iOS:
cd ios && xcodebuild build-for-testing -scheme Lumigreen, 0error:lines;StoreKitManagerTestsperiod-of resolution green for all 8 new IDs. - KMP shared:
cd android && ./gradlew :shared:compileKotlinJvm :shared:jvmTestgreen; newBillingAlertBannerTestandSubscriptionPlanCodeTestpass. - Web:
cd frontend && pnpm testgreen (vitest; via the machine-wide lock script),tsc --noEmitgreen. - 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: usestypealias KMPEntitlements = shared.EntitlementsResponse; the KMPfeatures: Map<String, Boolean>andlimits: 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:EntitlementsResponseEquatable extension now includeslimitsin the equality check so freemium limit changes are correctly detected. - Extended
ios/UnitTests/Workspace/SubscriptionDecodeTests.swiftwith two new tests:test_entitlements_v2Features_allNewKeysDecodeCorrectly(verifies all new feature/limit keys decode;ceo_brief_weekly,crm,ai_extractionflags;max_users/max_projectslimit keys) andtest_entitlements_v2_bootstrapEntitlement_freeStatusEnters(confirmsstatus:"free"parses without error under the freemium model). metersfield: KMP DTOmeters: 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 inAppState.swiftdocuments the two lines to add when the XCFramework shipsmeters.- Confirmed
PaywallView.swift: no hardcoded prices (reads StoreKitdisplayPriceor/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
metersblock to the entitlements response (ai_updates_used,storage_used_mb,automation_runs_used). ceo_briefsplit intoceo_brief_daily(paid, Starter+) andceo_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→ updatePLAN_FEATURES_MATRIXwith new feature flagsbackend/apps/billing/views.py→ addmetersblock to entitlements serializationfrontend/src/hooks/queries/useEntitlements.ts→ consume newmetersand new feature flagsfrontend/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
entitlementscontract onGET /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/briefand 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
pendingand 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 -qpnpm --dir frontend exec vitest run src/test/entitlements.test.tsxpnpm --dir frontend exec tsc --noEmitpnpm --dir frontend exec playwright test tests/e2e/lumi.spec.ts -g "free entitlements show in-context upgrade prompt instead of blocking entry" --project=chromium