mirror of
https://github.com/lawrencehook/remove-youtube-suggestions.git
synced 2026-07-25 06:54:31 +00:00
8825666512
Add RYS Premium subscription system Introduce optional paid tier with sign-in, Stripe billing, and premium feature gating. Core features remain free. Includes Node.js server for auth, payments, and license management. Extension changes: - Sign-in flow with magic link email - Premium/upgrade/account modals with branded UI - Premium feature gating (60+ advanced settings) - Password lock and scheduling (premium) - Fix "hide all but first row" for YouTube's new flat homepage DOM - Hide sidebar ad panels by default - Enable "Hide all Shorts" by default Server: - Magic link auth with rate limiting - Stripe checkout, webhooks, and billing portal - JWT-based license tokens - AWS SES transactional emails (welcome, sign-in, cancellation) - Grandfathered donor support Also adds CI workflow, server test suite, and extension unit tests.
683 lines
21 KiB
Markdown
683 lines
21 KiB
Markdown
# Premium Subscription System Spec
|
|
|
|
This document specifies the implementation of a premium subscription system for a browser extension. It is intended to be used as a reference for implementation.
|
|
|
|
## Overview
|
|
|
|
The system enables monetization of a free, open-source browser extension through a subscription model. Users authenticate via email magic links, purchase subscriptions through Stripe, and the extension gates premium features based on subscription status.
|
|
|
|
### Key Design Decisions
|
|
|
|
- **Single public repo**: Premium feature code is not hidden. Access is gated by license checks.
|
|
- **No application database**: Stripe is the source of truth for subscription status.
|
|
- **Magic link authentication**: Users verify email ownership by clicking a link sent to their inbox.
|
|
- **Polling-based handoff**: Extension polls the server to detect when magic link has been clicked.
|
|
- **Bypass tolerance**: Determined users circumventing the paywall is acceptable.
|
|
|
|
---
|
|
|
|
## Configuration Constants
|
|
|
|
All timing values should be defined in a central config file for easy adjustment.
|
|
|
|
| Constant | Default Value | Description |
|
|
|----------|---------------|-------------|
|
|
| `MAGIC_LINK_EXPIRY` | 15 minutes | How long a magic link remains valid |
|
|
| `REQUEST_ID_EXPIRY` | 20 minutes | How long the server holds a pending auth request |
|
|
| `POLL_INTERVAL` | 2 seconds | How often extension polls during auth |
|
|
| `POLL_TIMEOUT` | 16 minutes | How long extension polls before giving up |
|
|
| `SESSION_TOKEN_LIFETIME` | 30 days | How long a session token remains valid |
|
|
| `LICENSE_TOKEN_LIFETIME` | 3 days | How long a license token remains valid |
|
|
| `GRANDFATHERED_TOKEN_LIFETIME` | 730 days | How long a grandfathered license token remains valid |
|
|
| `LICENSE_REFRESH_THRESHOLD` | 24 hours | Refresh license token if expiring within this window |
|
|
| `RATE_LIMIT_WINDOW` | 1 hour | Rate limit window for magic link requests |
|
|
| `RATE_LIMIT_MAX_REQUESTS` | 5 | Max magic link requests per email per window |
|
|
|
|
---
|
|
|
|
## Data Models
|
|
|
|
### AuthRequest (server-side, in-memory or temporary storage)
|
|
|
|
Temporary record created when user initiates login. Can be stored in Redis, or in-memory if single-server.
|
|
|
|
```
|
|
{
|
|
request_id: string, // Random, unguessable ID (e.g., UUID v4)
|
|
email: string, // User's email address
|
|
status: "pending" | "verified",
|
|
created_at: timestamp,
|
|
session_token: string | null // Populated once verified
|
|
}
|
|
```
|
|
|
|
### Session Token (JWT)
|
|
|
|
Issued after successful magic link verification. Contains:
|
|
|
|
```
|
|
{
|
|
email: string, // Verified email address
|
|
iat: number, // Issued at timestamp
|
|
exp: number // Expiration timestamp
|
|
}
|
|
```
|
|
|
|
Signed with a server-side secret (`JWT_SECRET` environment variable).
|
|
|
|
### License Token (JWT)
|
|
|
|
Issued by the server during license checks. Embedded premium status avoids extra network calls. Contains:
|
|
|
|
```
|
|
{
|
|
email: string, // User's email address
|
|
premium: boolean, // Whether user has premium access
|
|
grandfathered: boolean, // Whether user is a past donor
|
|
exp: number // Expiration timestamp
|
|
}
|
|
```
|
|
|
|
Lifetime is 3 days for regular users, 730 days for grandfathered users. The extension refreshes the token when it's within 24 hours of expiry.
|
|
|
|
### Extension Local Storage
|
|
|
|
Stored via `chrome.storage.local` (works in both Chrome and Firefox):
|
|
|
|
```
|
|
{
|
|
session_token: string | null, // JWT from auth flow
|
|
license_token: string | null, // JWT with embedded premium status
|
|
user_email: string | null // User's email for display
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Pricing
|
|
|
|
| Plan | Price | Stripe Price ID |
|
|
|------|-------|-----------------|
|
|
| Monthly | $X.XX/month | Set in environment variable `STRIPE_PRICE_MONTHLY` |
|
|
| Yearly | $X.XX/year | Set in environment variable `STRIPE_PRICE_YEARLY` |
|
|
|
|
Yearly plan should be positioned as a discount (e.g., ~2 months free).
|
|
|
|
---
|
|
|
|
## Server Specification
|
|
|
|
### Environment Variables
|
|
|
|
```
|
|
JWT_SECRET=<random-secret-for-signing-tokens>
|
|
STRIPE_SECRET_KEY=<stripe-secret-key>
|
|
STRIPE_PRICE_MONTHLY=<stripe-price-id>
|
|
STRIPE_PRICE_YEARLY=<stripe-price-id>
|
|
STRIPE_WEBHOOK_SECRET=<stripe-webhook-signing-secret>
|
|
EMAIL_FROM=<sender-email-address>
|
|
BASE_URL=<https://yourserver.com>
|
|
AWS_REGION=<aws-region> # defaults to us-east-1
|
|
DATA_DIR=<data-directory-path> # defaults to ./data
|
|
```
|
|
|
|
Email is sent via AWS SES. AWS credentials are expected via standard environment or IAM role.
|
|
|
|
### Endpoints
|
|
|
|
---
|
|
|
|
#### `POST /auth/send-magic-link`
|
|
|
|
Initiates the authentication flow.
|
|
|
|
**Request body:**
|
|
```json
|
|
{
|
|
"email": "user@example.com"
|
|
}
|
|
```
|
|
|
|
**Server behavior:**
|
|
1. Validate email format
|
|
2. Generate a random `request_id` (UUID v4)
|
|
3. Store AuthRequest with status "pending"
|
|
4. Send email containing link: `{BASE_URL}/auth/verify?token={request_id}`
|
|
5. Return `request_id` to caller
|
|
|
|
**Response:**
|
|
```json
|
|
{
|
|
"request_id": "abc123..."
|
|
}
|
|
```
|
|
|
|
**Errors:**
|
|
- `400` — Invalid email format
|
|
- `429` — Rate limited (5 requests per email per hour). Response includes `Retry-After` header.
|
|
|
|
---
|
|
|
|
#### `GET /auth/verify`
|
|
|
|
Magic link target. User's browser hits this when they click the email link.
|
|
|
|
**Query parameters:**
|
|
- `token` — The `request_id`
|
|
|
|
**Server behavior:**
|
|
1. Look up AuthRequest by `request_id`
|
|
2. If not found or expired → show error page
|
|
3. If found and pending:
|
|
- Generate JWT session token with user's email
|
|
- Update AuthRequest: status = "verified", session_token = JWT
|
|
4. Show success page: "You're signed in! You can close this tab."
|
|
|
|
**Response:** HTML page (not JSON)
|
|
|
|
---
|
|
|
|
#### `GET /auth/poll`
|
|
|
|
Extension polls this to check if magic link has been clicked.
|
|
|
|
**Query parameters:**
|
|
- `request_id` — The `request_id` returned from `/auth/send-magic-link`
|
|
|
|
**Server behavior:**
|
|
1. Look up AuthRequest by `request_id`
|
|
2. If not found or expired → return error
|
|
3. If status is "pending" → return pending status
|
|
4. If status is "verified" → return session token, then delete the AuthRequest
|
|
|
|
**Response (pending):**
|
|
```json
|
|
{
|
|
"status": "pending"
|
|
}
|
|
```
|
|
|
|
**Response (verified):**
|
|
```json
|
|
{
|
|
"status": "verified",
|
|
"session_token": "eyJhbGc...",
|
|
"email": "user@example.com"
|
|
}
|
|
```
|
|
|
|
**Errors:**
|
|
- `404` — Unknown or expired `request_id`
|
|
|
|
---
|
|
|
|
#### `GET /license/check`
|
|
|
|
Returns a signed license token containing the user's premium status.
|
|
|
|
**Headers:**
|
|
```
|
|
Authorization: Bearer <session_token>
|
|
```
|
|
|
|
**Server behavior:**
|
|
1. Validate and decode JWT session token
|
|
2. Extract email from token
|
|
3. Check if email is in the grandfathered list (case-insensitive)
|
|
4. If grandfathered → return license token with `premium: true, grandfathered: true` (730-day lifetime)
|
|
5. Otherwise, query Stripe for customer by email
|
|
6. Check for active subscription
|
|
7. Return license token with `premium: true/false, grandfathered: false` (3-day lifetime)
|
|
|
|
**Response:**
|
|
```json
|
|
{
|
|
"license_token": "eyJhbGc..."
|
|
}
|
|
```
|
|
|
|
The license token is a JWT containing `{ email, premium, grandfathered, exp }`.
|
|
|
|
**Errors:**
|
|
- `401` — Missing, invalid, or expired session token
|
|
|
|
---
|
|
|
|
#### `POST /checkout/create`
|
|
|
|
Creates a Stripe Checkout session for purchasing a subscription.
|
|
|
|
**Headers:**
|
|
```
|
|
Authorization: Bearer <session_token>
|
|
```
|
|
|
|
**Request body:**
|
|
```json
|
|
{
|
|
"plan": "monthly" | "yearly"
|
|
}
|
|
```
|
|
|
|
**Server behavior:**
|
|
1. Validate and decode JWT
|
|
2. Extract email from token
|
|
3. Look up or create Stripe customer by email
|
|
4. Create Stripe Checkout session with:
|
|
- Customer ID
|
|
- Appropriate price ID based on plan
|
|
- Success URL: `{BASE_URL}/checkout/success`
|
|
- Cancel URL: `{BASE_URL}/checkout/cancel`
|
|
5. Return checkout URL
|
|
|
|
**Response:**
|
|
```json
|
|
{
|
|
"checkout_url": "https://checkout.stripe.com/..."
|
|
}
|
|
```
|
|
|
|
**Errors:**
|
|
- `401` — Missing, invalid, or expired token
|
|
- `400` — Invalid plan value
|
|
|
|
---
|
|
|
|
#### `POST /webhook/stripe`
|
|
|
|
Receives Stripe webhook events.
|
|
|
|
**Headers:**
|
|
- `Stripe-Signature` — Used to verify webhook authenticity
|
|
|
|
**Server behavior:**
|
|
1. Verify webhook signature using `STRIPE_WEBHOOK_SECRET`
|
|
2. Handle relevant events:
|
|
- `checkout.session.completed` — Subscription created (optional: send welcome email)
|
|
- `customer.subscription.deleted` — Subscription canceled
|
|
- `customer.subscription.updated` — Subscription changed
|
|
- `invoice.payment_failed` — Payment failed (optional: send warning email)
|
|
|
|
Since Stripe is the source of truth, these events are mainly useful for sending transactional emails or logging. The `/license/check` endpoint always queries Stripe directly.
|
|
|
|
**Response:**
|
|
```json
|
|
{
|
|
"received": true
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
#### `GET /checkout/success`
|
|
|
|
Simple HTML page shown after successful checkout.
|
|
|
|
**Content:** "Payment successful! You can close this tab and return to the extension."
|
|
|
|
---
|
|
|
|
#### `GET /checkout/cancel`
|
|
|
|
Simple HTML page shown if user cancels checkout.
|
|
|
|
**Content:** "Payment canceled. You can close this tab and try again from the extension."
|
|
|
|
---
|
|
|
|
#### `POST /billing/portal`
|
|
|
|
Creates a Stripe billing portal session for managing an existing subscription.
|
|
|
|
**Headers:**
|
|
```
|
|
Authorization: Bearer <session_token>
|
|
```
|
|
|
|
**Server behavior:**
|
|
1. Validate and decode JWT
|
|
2. Look up Stripe customer by email
|
|
3. Create billing portal session
|
|
4. Return portal URL
|
|
|
|
**Response:**
|
|
```json
|
|
{
|
|
"url": "https://billing.stripe.com/..."
|
|
}
|
|
```
|
|
|
|
**Errors:**
|
|
- `401` — Missing, invalid, or expired token
|
|
- `404` — No Stripe customer found for this email
|
|
|
|
---
|
|
|
|
#### `GET /billing/return`
|
|
|
|
Simple HTML page shown after returning from the Stripe billing portal.
|
|
|
|
**Content:** "Billing updated. You can close this tab and return to the extension."
|
|
|
|
---
|
|
|
|
### Stripe API Usage
|
|
|
|
**Find customer by email:**
|
|
```
|
|
stripe.customers.list({ email: email, limit: 1 })
|
|
```
|
|
|
|
**Check for active subscription:**
|
|
```
|
|
stripe.subscriptions.list({ customer: customer_id, status: 'active', limit: 1 })
|
|
```
|
|
|
|
**Create customer:**
|
|
```
|
|
stripe.customers.create({ email: email })
|
|
```
|
|
|
|
**Create checkout session:**
|
|
```
|
|
stripe.checkout.sessions.create({
|
|
customer: customer_id,
|
|
mode: 'subscription',
|
|
line_items: [{ price: price_id, quantity: 1 }],
|
|
success_url: success_url,
|
|
cancel_url: cancel_url
|
|
})
|
|
```
|
|
|
|
---
|
|
|
|
## Extension Specification
|
|
|
|
### Permissions Required
|
|
|
|
**manifest.json (Manifest V3 for Chrome, V2/V3 for Firefox):**
|
|
|
|
```json
|
|
{
|
|
"permissions": [
|
|
"storage"
|
|
],
|
|
"host_permissions": [
|
|
"*://*.youtube.com/*"
|
|
]
|
|
}
|
|
```
|
|
|
|
No special permissions beyond storage and YouTube host access. The premium server is accessed via `fetch()` which does not require additional host permissions in MV3.
|
|
|
|
---
|
|
|
|
### Auth Module
|
|
|
|
Handles sign-in and session management.
|
|
|
|
#### `sendMagicLink(email: string): Promise<string>`
|
|
|
|
1. Call `POST /auth/send-magic-link` with email
|
|
2. Handle 429 (rate limit) with user-friendly error
|
|
3. Return `request_id`
|
|
|
|
#### `pollForVerification(requestId, onStatusUpdate, options): Promise<Result>`
|
|
|
|
1. Call `GET /auth/poll?request_id={requestId}` every 2 seconds
|
|
2. If status is "pending", call `onStatusUpdate` with elapsed time, continue polling
|
|
3. If status is "verified", store session token and email in `chrome.storage.local`, return `{ success: true }`
|
|
4. Timeout after 16 minutes with error
|
|
5. Supports `AbortController` signal via `options.signal` for cancellation
|
|
6. If aborted, return `{ canceled: true }`
|
|
7. Network errors during polling are silently retried; fatal errors (404, non-OK) throw
|
|
|
|
#### `isSignedIn(): Promise<boolean>`
|
|
|
|
Returns true if a session token exists in storage.
|
|
|
|
#### `getUserEmail(): Promise<string | null>`
|
|
|
|
Returns the stored user email.
|
|
|
|
#### `getSessionToken(): Promise<string | null>`
|
|
|
|
Returns the stored session token.
|
|
|
|
#### `signOut(): Promise<void>`
|
|
|
|
Clears session token, license token, and email from `chrome.storage.local`.
|
|
|
|
---
|
|
|
|
### License Module
|
|
|
|
Handles premium status checking and caching via license tokens (JWTs with embedded premium status).
|
|
|
|
#### `checkLicense(forceRefresh?: boolean): Promise<LicenseResult>`
|
|
|
|
1. Read cached session token and license token from `chrome.storage.local`
|
|
2. If no session token, return `{ isPremium: false }`
|
|
3. Decode license token JWT (no signature verification — we trust our server)
|
|
4. If not forcing refresh, token is valid, and not expiring within 24 hours:
|
|
- Return cached status from token payload
|
|
5. Otherwise, call `GET /license/check` with session token
|
|
6. If 401 response: auto sign-out, return `{ isPremium: false, signedOut: true }`
|
|
7. Store new license token in `chrome.storage.local`
|
|
8. Return status from new token
|
|
9. On network error: fall back to cached token if still valid (not expired)
|
|
|
|
Returns `{ isPremium, source, cached?, offline?, signedOut?, error? }` where `source` is `'grandfathered'` or `null`.
|
|
|
|
#### `isPremium(): Promise<boolean>`
|
|
|
|
Quick synchronous-style check. Reads cached license token, decodes JWT, returns `true` only if `premium === true` and token not expired. No network call.
|
|
|
|
#### `createCheckoutSession(plan): Promise<string>`
|
|
|
|
Calls `POST /checkout/create` with plan, returns checkout URL. Auto signs out on 401.
|
|
|
|
#### `createBillingPortalSession(): Promise<string>`
|
|
|
|
Calls `POST /billing/portal`, returns portal URL. Auto signs out on 401.
|
|
|
|
---
|
|
|
|
### Checkout Module
|
|
|
|
Handles upgrade flow.
|
|
|
|
#### `startCheckout(plan: 'monthly' | 'yearly'): Promise<void>`
|
|
|
|
1. Get session token from auth module
|
|
2. If not signed in, throw error (UI should prompt sign-in first)
|
|
3. Call `POST /checkout/create` with plan
|
|
4. Open returned `checkout_url` in new tab
|
|
|
|
---
|
|
|
|
### Feature Gating
|
|
|
|
Features are marked with `premium: true` in `src/shared/main.js`. When a user clicks a premium-gated option:
|
|
|
|
1. Check `HTML.getAttribute('is_premium')`
|
|
2. If not premium:
|
|
- Check if signed in via `Auth.isSignedIn()`
|
|
- If not signed in → show Premium Required Modal (then sign-in flow)
|
|
- If signed in but not premium → show Upgrade Modal
|
|
3. The `is_premium` attribute is set on page load via `License.checkLicense()` and updated after sign-in, checkout, and tab focus events
|
|
|
|
Schedule and Password settings menus are also gated with the same `handlePremiumFeatureClick()` handler.
|
|
|
|
---
|
|
|
|
### UI Components
|
|
|
|
1. **Premium Required Modal** (for non-signed-in users)
|
|
- Shown when a non-signed-in user clicks a premium feature
|
|
- "Sign In" button → opens sign-in flow
|
|
- "Cancel" button → closes modal
|
|
|
|
2. **Sign-In Modal**
|
|
- Email input field with Enter key support
|
|
- "Send magic link" button
|
|
- "Check your email" waiting state with countdown timer (16 min)
|
|
- Cancel button to abort polling (uses AbortController)
|
|
- Error state with retry button
|
|
- **Important**: When triggered from popup, opens a new tab (`main.html?signin=1`) because popup closing would kill the polling loop
|
|
|
|
3. **Account Modal**
|
|
- Shows signed-in email
|
|
- Premium status display:
|
|
- "Lifetime Premium" (for grandfathered users, no billing button)
|
|
- "Premium Active" (for subscribers, with billing button)
|
|
- "Free Plan" (with upgrade button)
|
|
- Billing button → opens Stripe customer portal in new tab
|
|
- Sign-out button → clears all auth data
|
|
|
|
4. **Upgrade Modal**
|
|
- Plan selection: Monthly / Yearly (defaults to yearly)
|
|
- "Subscribe" button → creates Stripe checkout session, opens in new tab
|
|
- Sets `awaitingUpgrade` flag; on tab refocus, auto-refreshes license
|
|
|
|
5. **Header UI changes**
|
|
- "Sign In" text when not signed in → "Account" when signed in
|
|
- "Donate" link → changes to "Premium" (no link) when premium is active
|
|
|
|
---
|
|
|
|
## Sequence Diagrams
|
|
|
|
### Authentication Flow
|
|
|
|
```
|
|
User Extension Server Email
|
|
| | | |
|
|
|--Enter email---->| | |
|
|
| |--POST /send-magic-->| |
|
|
| |<--{ request_id }----| |
|
|
| | |----Magic link----->|
|
|
| |--GET /poll--------->| |
|
|
| |<--{ pending }-------| |
|
|
| | ... | |
|
|
|<-----------------+---------------------+-<--Click link------|
|
|
| | | |
|
|
| | GET /verify |
|
|
| | |--Show success page |
|
|
| |--GET /poll--------->| |
|
|
| |<--{ verified, token } |
|
|
| | | |
|
|
| |--Store token locally| |
|
|
|<--Signed in!-----| | |
|
|
```
|
|
|
|
### License Check Flow
|
|
|
|
```
|
|
Extension Server Stripe
|
|
| | |
|
|
|---GET /license/check----->| |
|
|
| (with session token) | |
|
|
| |---Get customer by email-->|
|
|
| |<--Customer data-----------|
|
|
| | |
|
|
| |---List subscriptions----->|
|
|
| |<--Subscription data-------|
|
|
| | |
|
|
|<--{ premium: true/false }--| |
|
|
| | |
|
|
|---Cache result locally | |
|
|
```
|
|
|
|
### Purchase Flow
|
|
|
|
```
|
|
User Extension Server Stripe
|
|
| | | |
|
|
|--Click upgrade-->| | |
|
|
| |--POST /checkout---->| |
|
|
| | { plan: "yearly" }| |
|
|
| | |--Create session--->|
|
|
| | |<--Session URL------|
|
|
| |<--{ checkout_url }--| |
|
|
| | | |
|
|
|<--Open checkout--| | |
|
|
| | | |
|
|
|------------ User completes payment on Stripe ------------->|
|
|
| | | |
|
|
| | |<--Webhook: paid----|
|
|
| | | |
|
|
|--Return to ext-->| | |
|
|
| |---Check license---->| |
|
|
| |<--{ premium: true }-| |
|
|
|<--Premium active!| | |
|
|
```
|
|
|
|
---
|
|
|
|
## Security Considerations
|
|
|
|
1. **JWT secret**: Use a strong, random secret (>32 characters). Rotate periodically.
|
|
2. **HTTPS only**: All server endpoints must be HTTPS.
|
|
3. **Webhook verification**: Always verify Stripe webhook signatures.
|
|
4. **Rate limiting**: 5 requests per email per hour on `/auth/send-magic-link`. Returns 429 with `Retry-After` header.
|
|
5. **Request ID entropy**: UUIDv4 (128 bits of entropy).
|
|
6. **Token in memory during polling**: `request_id` kept in memory only, not persisted to storage.
|
|
7. **CORS**: Only chrome-extension:// and moz-extension:// origins allowed.
|
|
8. **License token decoding**: Extension decodes JWT payload without signature verification (trusts the server). This is acceptable given the bypass-tolerant design.
|
|
|
|
---
|
|
|
|
## Error Handling
|
|
|
|
### Extension-side
|
|
|
|
- **Network errors**: Show retry option, fall back to cached license status
|
|
- **Auth expired**: Clear session, prompt re-login
|
|
- **Poll timeout**: Show "Link expired, try again" message
|
|
|
|
### Server-side
|
|
|
|
- **Invalid email**: 400 error with message
|
|
- **Expired magic link**: Show friendly error page with "Request new link" option
|
|
- **Stripe API errors**: Log error, return 500 with generic message
|
|
- **Invalid JWT**: 401 error
|
|
|
|
---
|
|
|
|
## Testing
|
|
|
|
See `TESTING.md` for detailed manual testing instructions covering all user paths.
|
|
|
|
---
|
|
|
|
## Grandfathered Users
|
|
|
|
Past donors are stored in `data/grandfathered.json` (array of email strings). During license checks:
|
|
|
|
1. Email is matched case-insensitively
|
|
2. Grandfathered users receive a license token with `premium: true, grandfathered: true` and a 730-day lifetime
|
|
3. The UI displays "Lifetime Premium" and hides the billing portal button
|
|
4. No Stripe query is made for grandfathered users
|
|
|
|
## Analytics
|
|
|
|
Events are tracked via Mixpanel. Key events:
|
|
|
|
- `License Check` — with `isPremium`, `source`, `cached`, `offline`, `error` properties
|
|
- `Premium Feature Click` — with `signedIn` flag
|
|
- `Sign In Started`, `Magic Link Sent`, `Sign In Success`, `Sign In Error`, `Sign In Canceled`
|
|
- `Checkout Started`, `Checkout Completed`, `Checkout Error` — with `plan` and `source`
|
|
- `Upgrade Modal Opened`, `Account Modal Opened`
|
|
- `Billing Portal Opened`, `Billing Portal Error`
|
|
- `Session Expired`, `Sign Out`
|
|
|
|
## Future Considerations (Out of Scope)
|
|
|
|
These are not part of the initial implementation but may be relevant later:
|
|
|
|
- **Lifetime plans**: One-time purchase option via Stripe
|
|
- **Team/family plans**: Multiple users under one subscription
|
|
- **Promo codes**: Stripe coupon integration
|
|
- **Trial periods**: Free trial before requiring payment
|