-
Notifications
You must be signed in to change notification settings - Fork 994
docs: Billing system overview for external billing migration #2649
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
bengotow
wants to merge
2
commits into
master
Choose a base branch
from
indent-2026-03-23-billing-overview
base: master
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from 1 commit
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,209 @@ | ||
| # Billing System Overview | ||
|
|
||
| This document catalogs all billing-related code in Mailspring to support the migration from in-app billing modals/webviews to an external browser-based billing flow. | ||
|
|
||
| --- | ||
|
|
||
| ## Architecture Summary | ||
|
|
||
| The current billing flow is built around three layers: | ||
|
|
||
| 1. **IdentityStore** (`app/src/flux/stores/identity-store.ts`) — the source of truth for the user's identity and subscription state. | ||
| 2. **BillingModal** (`app/src/components/billing-modal.tsx`) — an Electron `<webview>` that loads the payment page inline. | ||
| 3. **FeatureUsageStore** / **FeatureUsedUpModal** — quota enforcement that triggers upgrade prompts when free-tier limits are hit. | ||
|
|
||
| When a user clicks "Upgrade", the app fetches an SSO URL via `IdentityStore.fetchSingleSignOnURL('/payment?embedded=true')`, opens `BillingModal` with the URL in an Electron `<webview>`, and after payment, scrapes the DOM for a `#payment-success-data` element before calling `IdentityStore.fetchIdentity()` to refresh the subscription state. | ||
|
|
||
| --- | ||
|
|
||
| ## Key Files | ||
|
|
||
| ### 1. Identity Store (Source of Truth) | ||
|
|
||
| **File:** `app/src/flux/stores/identity-store.ts` | ||
|
|
||
| - **`IIdentity` interface** (L15-31): The user object shape — `id`, `token`, `firstName`, `lastName`, `emailAddress`, `stripePlan`, `stripePlanEffective`, `featureUsage`. | ||
| - **`hasProFeatures()`** (L85-87): Returns `true` when `stripePlanEffective !== 'Basic'`. This is the primary gating check used throughout the app. | ||
| - **`fetchIdentity()`** (L220-243): `GET /api/me` on the identity server. Merges the response into the current identity and calls `saveIdentity()`. | ||
| - **`fetchSingleSignOnURL(path)`** (L169-218): `POST /api/login-link` to get a one-time SSO URL for `id.getmailspring.com`. Used by BillingModal, FeatureUsedUpModal, OpenIdentityPageButton, and EventedIFrame. | ||
| - **Polling** (L89-97): In the main window, fetches identity after 1 second and then every 10 minutes. | ||
| - **Storage**: Token is stored in the OS keychain via `KeyManager`. The rest of the identity is in `AppEnv.config` under key `'identity'`. | ||
|
|
||
| **For the new flow:** The `IIdentity` interface is where feature flags will be added. `fetchIdentity()` is what should be called when the app is foregrounded after the user returns from the external billing page. | ||
|
|
||
| ### 2. Billing Modal (Webview-Based Payment) | ||
|
|
||
| **File:** `app/src/components/billing-modal.tsx` | ||
|
|
||
| - Loads `id.getmailspring.com/payment?embedded=true` in an Electron `<webview>` (via the `Webview` component). | ||
| - After load, executes JS in the webview to scrape `#payment-success-data` and listen for `#continue-btn` clicks. | ||
| - On success, calls `IdentityStore.fetchIdentity()` to refresh the plan, then `Actions.closeModal()`. | ||
| - Props: `upgradeUrl?: string`, `source?: string`. | ||
| - Dimensions: 412x540. | ||
|
|
||
| **Style:** `app/static/style/components/billing-modal.less` | ||
|
|
||
| **For the new flow:** This is the primary component to be replaced. Instead of opening a modal with a webview, the new path will call `shell.openExternal()` to open the billing page in the system browser. | ||
|
|
||
| ### 3. Feature Used Up Modal (Upgrade Prompt) | ||
|
|
||
| **File:** `app/src/components/feature-used-up-modal.tsx` | ||
|
|
||
| - Shown when a user exhausts their free-tier quota for a feature. | ||
| - Pre-fetches SSO URL on mount (`IdentityStore.fetchSingleSignOnURL('/payment?embedded=true')`). | ||
| - The **"Upgrade" button** (L76) opens `BillingModal` with `source="feature-limit"`. | ||
| - Also links externally to `https://getmailspring.com/pro`. | ||
|
|
||
| **Style:** `app/static/style/components/feature-used-up-modal.less` | ||
|
|
||
| **For the new flow:** The "Upgrade" button handler (`onUpgrade`) is the key point to intercept. Under the feature flag, it should open the billing URL externally instead of opening BillingModal. | ||
|
|
||
| ### 4. Feature Usage Store (Quota Enforcement) | ||
|
|
||
| **File:** `app/src/flux/stores/feature-usage-store.tsx` | ||
|
|
||
| - `isUsable(feature)` (L112): Checks `usedInPeriod < quota` from the identity's `featureUsage` map. | ||
| - `markUsedOrUpgrade(feature, lexicon)` (L120): If not usable, calls `displayUpgradeModal()`. Otherwise marks used. | ||
| - `displayUpgradeModal(feature, lexicon)` (L81): Opens `FeatureUsedUpModal` via `Actions.openModal()`. Returns a Promise that resolves/rejects based on whether the user upgraded (determined by re-checking `isUsable` when the modal closes). | ||
|
|
||
| **For the new flow:** The Promise-based flow in `displayUpgradeModal` currently resolves when the modal closes. In the new flow, the modal won't contain a webview — the user will be redirected to the browser. Resolution will need to come from the identity refresh triggered on app foreground. | ||
|
|
||
| ### 5. Webview Component | ||
|
|
||
| **File:** `app/src/components/webview.tsx` | ||
|
|
||
| - Generic Electron `<webview>` wrapper used by BillingModal. | ||
| - Handles loading states, error states, retry, `did-finish-load` callbacks. | ||
| - Uses `partition="in-memory-only"` for session isolation. | ||
|
|
||
| **For the new flow:** Will no longer be needed for billing but is still used elsewhere. | ||
|
|
||
| ### 6. Open Identity Page Button | ||
|
|
||
| **File:** `app/src/components/open-identity-page-button.tsx` | ||
|
|
||
| - A button that fetches an SSO URL and opens it in the **external browser** via `shell.openExternal`. | ||
| - Already uses the pattern we want for the new billing flow. | ||
| - Props: `path`, `label`, `source`, `campaign`. | ||
|
|
||
| **For the new flow:** This component is a reference implementation for how to open billing pages externally. The same pattern (fetch SSO URL, then `shell.openExternal`) should be used in the new billing flow. | ||
|
|
||
| --- | ||
|
|
||
| ## Entry Points (Where Users Trigger Billing Actions) | ||
|
|
||
| ### Preferences > Subscription Tab | ||
|
|
||
| **File:** `app/internal_packages/preferences/lib/tabs/preferences-identity.tsx` | ||
|
|
||
| Three states: | ||
|
|
||
| | State | UI | Action | | ||
| |---|---|---| | ||
| | No identity | "Setup Mailspring ID" button | IPC `application:add-identity` | | ||
| | Basic plan | "Get Mailspring Pro" button (L234-241) | Opens `BillingModal` via `_onUpgrade` (L163) | | ||
| | Paid plan | "Manage Billing" button (L276-281) | Opens `/dashboard#billing` via `OpenIdentityPageButton` (already external) | | ||
|
|
||
| Also renders `ExploreMailspringPro` and `ExploreMailspringSmall` feature grids with a link to `https://getmailspring.com/pro`. | ||
|
|
||
| **Registered in:** `app/internal_packages/preferences/lib/main.tsx` (tabId: `'Subscription'`, order 3). | ||
|
|
||
| ### Please-Subscribe Notification | ||
|
|
||
| **File:** `app/internal_packages/notifications/lib/items/please-subscribe-notif.tsx` | ||
|
|
||
| Triggers when: | ||
| - `stripePlan === 'Basic'` and user has >4 accounts | ||
| - `stripePlan !== stripePlanEffective` (billing failure) | ||
|
|
||
| Action: "Manage" button opens Preferences > Subscription tab. | ||
|
|
||
| ### Onboarding Upsell | ||
|
|
||
| **File:** `app/internal_packages/onboarding/lib/page-initial-subscription.tsx` | ||
|
|
||
| - Shown during onboarding for non-Pro users (gated in `page-initial-preferences.tsx` L208-214 via `IdentityStore.hasProFeatures()`). | ||
| - Displays pricing and feature list. "Finish Setup" button closes onboarding. | ||
| - Does not directly trigger a purchase — tells users to upgrade from Preferences > Subscription. | ||
|
|
||
| ### Feature Quota Exhaustion (Per-Feature Paywalls) | ||
|
|
||
| All of these trigger `FeatureUsedUpModal` which leads to `BillingModal`: | ||
|
|
||
| | Feature | File | Feature ID | | ||
| |---|---|---| | ||
| | Snooze | `internal_packages/thread-snooze/lib/snooze-store.ts` | `snooze` | | ||
| | Send Later | `internal_packages/send-later/lib/send-later-button.tsx` | `send-later` | | ||
| | Send Reminders | `internal_packages/send-reminders/lib/send-reminders-utils.ts` | `reminders` | | ||
| | Thread Sharing | `internal_packages/thread-sharing/lib/thread-sharing-popover.tsx` | `thread-sharing` | | ||
| | Contact Profiles | `internal_packages/participant-profile/lib/sidebar-participant-profile.tsx` | `contact-profiles` | | ||
| | Grammar Check | `internal_packages/composer-grammar-check/lib/grammar-check-store.ts` | `grammar-check` | | ||
| | Open/Link Tracking | `app/src/components/metadata-composer-toggle-button.tsx` | plugin-specific | | ||
| | Translation | `internal_packages/translation/lib/message-header.tsx` | `translation` | | ||
|
|
||
| ### Email Body Link Interception | ||
|
|
||
| **File:** `app/src/components/evented-iframe.tsx` (L438+) | ||
|
|
||
| - If a link in an email body points to `id.getmailspring.com`, the click is intercepted and routed through `IdentityStore.fetchSingleSignOnURL()` before opening externally. | ||
|
|
||
| --- | ||
|
|
||
| ## Billing URLs | ||
|
|
||
| | URL | Purpose | Used By | | ||
| |---|---|---| | ||
| | `id.getmailspring.com/payment?embedded=true` | In-app payment (webview) | BillingModal, FeatureUsedUpModal | | ||
| | `id.getmailspring.com/dashboard#billing` | Manage billing (external) | PreferencesIdentity, OpenIdentityPageButton | | ||
| | `id.getmailspring.com/api/login-link` | Generate SSO URLs | IdentityStore.fetchSingleSignOnURL | | ||
| | `id.getmailspring.com/api/me` | Fetch/refresh identity | IdentityStore.fetchIdentity | | ||
| | `https://getmailspring.com/pro` | Marketing page (external) | FeatureUsedUpModal, PreferencesIdentity | | ||
|
|
||
| --- | ||
|
|
||
| ## Component Registration | ||
|
|
||
| `BillingModal`, `FeatureUsedUpModal`, `OpenIdentityPageButton`, and `Webview` are registered as lazy-loaded components in: | ||
| - `app/src/global/mailspring-component-kit.js` | ||
| - `app/src/global/mailspring-component-kit.d.ts` | ||
|
|
||
| --- | ||
|
|
||
| ## shell.openExternal Patterns | ||
|
|
||
| The app already uses `shell.openExternal` extensively (23 call sites). The billing-relevant ones are: | ||
|
|
||
| - **`open-identity-page-button.tsx`** (L49) — SSO URL opened externally. This is the reference pattern. | ||
| - **`feature-used-up-modal.tsx`** (L30) — Opens `getmailspring.com/pro` externally. | ||
| - **`window-event-handler.ts`** (L371) — Catch-all for `http:`, `https:`, `tel:` links. | ||
|
|
||
| --- | ||
|
|
||
| ## Migration Plan Considerations | ||
|
|
||
| ### Feature Flag Gating | ||
|
|
||
| The `IIdentity` interface will need a new field (e.g., `featureFlags` or similar) returned from the backend. The feature flag check should be centralized — likely as a method on `IdentityStore` (e.g., `useExternalBilling()`). | ||
|
|
||
| ### Points of Change | ||
|
|
||
| 1. **`FeatureUsedUpModal.onUpgrade`** — Check flag, either open BillingModal (old) or `shell.openExternal` (new). | ||
| 2. **`PreferencesIdentity._onUpgrade`** — Same branch for the "Get Mailspring Pro" button. | ||
| 3. **`FeatureUsageStore.displayUpgradeModal`** — The Promise-based resolution flow may need reworking. Currently it resolves when `Actions.closeModal` fires and the feature becomes usable. In the new flow, resolution would come from `IdentityStore.fetchIdentity()` triggered on app foreground. | ||
| 4. **App foreground listener** — New code needed to call `IdentityStore.fetchIdentity()` when the app window is focused/foregrounded. The 10-minute polling interval is too slow. | ||
|
|
||
| ### What Stays the Same | ||
|
|
||
| - `IdentityStore.fetchIdentity()` — still the mechanism to refresh billing state. | ||
| - `IdentityStore.fetchSingleSignOnURL()` — still needed to generate authenticated URLs, just opened via `shell.openExternal` instead of webview. | ||
| - `OpenIdentityPageButton` — already works the right way (external browser). | ||
| - `FeatureUsageStore` quota logic — unchanged. | ||
| - All per-feature paywall call sites — unchanged; they call into `FeatureUsageStore` which handles the modal. | ||
|
|
||
| ### What Can Eventually Be Removed (After Full Migration) | ||
|
|
||
| - `BillingModal` component and its styles. | ||
| - The `?embedded=true` URL path. | ||
| - DOM scraping logic (`#payment-success-data`, `#continue-btn`). | ||
| - Zoom factor workaround in BillingModal. | ||
| - The `Webview` component (if not used elsewhere). | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.