Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/mobile/src/app/(tabs)/(you)/you.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { useTheme } from '@/theme/use-theme';

const TIER_LABELS: Record<SubscriptionTier, string> = {
trial: 'settings.tierTrial',
basic: 'settings.tierBasic',
pro: 'settings.tierPro',
legacy_free: 'settings.tierLegacyFree',
expired: 'settings.tierExpired',
Expand Down
16 changes: 5 additions & 11 deletions apps/mobile/src/lib/feature-config.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { getDefaultFeatureConfigs, type FeatureTab } from '@debatelab/core/ai/feature-configs';
import { canAccessProModels } from '@debatelab/core/ai/pro-access';
import { readEntitlement } from '@debatelab/core/supabase/entitlement';
import {
AUTO_FAST,
AUTO_PRO,
Expand Down Expand Up @@ -76,19 +76,13 @@ function normalizeConfig(value: unknown, fallback: FeatureConfig, allowProModels
export async function fetchFeatureConfig(userId: string, feature: FeatureTab): Promise<FeatureConfig> {
const { data, error } = await supabase
.from('user_settings')
.select('feature_configs, subscription_tier, grace_period_ends_at')
.select('feature_configs')
.eq('user_id', userId)
.maybeSingle();
if (error || !data) return defaultConfig(feature);
// Null when it cannot be told, as on the website, which then keeps its defaults.
const allowProModels = await canAccessProModels(data, async () => {
const { data: rows, error: billingError } = await supabase
.from('subscriptions')
.select('status, current_period_end')
.eq('user_id', userId);
return billingError ? null : rows ?? [];
});
if (allowProModels === null) return defaultConfig(feature);
// Unknown when the plan cannot be read, as on the website, which then keeps its defaults.
const allowProModels = (await readEntitlement(supabase, userId))?.canAccessProModels;
if (allowProModels === undefined) return defaultConfig(feature);
const fallback = defaultConfig(feature, allowProModels ? DEFAULT_PRO_MODEL : DEFAULT_MODEL);
return normalizeConfig(asRecord(data.feature_configs)?.[feature], fallback, allowProModels);
}
20 changes: 4 additions & 16 deletions apps/mobile/src/lib/settings.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { getDefaultFeatureConfigs } from '@debatelab/core/ai/feature-configs';
import { canAccessProModels } from '@debatelab/core/ai/pro-access';
import { readEntitlement } from '@debatelab/core/supabase/entitlement';
import { DEFAULT_MODEL, DEFAULT_PRO_MODEL } from '@debatelab/core/contract/model-catalog';
import { isAppLanguage, type AppLanguage } from '@debatelab/core/languages';
import AsyncStorage from '@react-native-async-storage/async-storage';
Expand Down Expand Up @@ -70,21 +70,9 @@ function isEmptyObject(value: unknown): boolean {
* their Pro access can't be read right now.
*/
async function defaultModelFor(userId: string): Promise<string | null> {
const { data, error } = await supabase
.from('user_settings')
.select('subscription_tier,grace_period_ends_at')
.eq('user_id', userId)
.maybeSingle();
if (error || !data) return null;
const pro = await canAccessProModels(data, async () => {
const { data: rows, error: billingError } = await supabase
.from('subscriptions')
.select('status, current_period_end')
.eq('user_id', userId);
return billingError ? null : rows ?? [];
});
if (pro === null) return null;
return pro ? DEFAULT_PRO_MODEL : DEFAULT_MODEL;
const entitlement = await readEntitlement(supabase, userId);
if (!entitlement) return null;
return entitlement.canAccessProModels ? DEFAULT_PRO_MODEL : DEFAULT_MODEL;
}

/**
Expand Down
73 changes: 14 additions & 59 deletions apps/mobile/src/lib/subscription.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { readEntitlement, type SubscriptionTier } from '@debatelab/core/supabase/entitlement';

import { supabase } from './supabase';

/**
* The plan and today's AI usage, computed exactly as the website's
* useSubscription (src/hooks/use-subscription.ts) does. That hook is not
* shared yet because it changes with in-app purchase.
* The plan and today's AI usage, read the way the website's useSubscription
* (src/hooks/use-subscription.ts) reads them: the plan from
* effective_entitlement, which also counts App Store subscriptions.
*/
export type SubscriptionTier = 'trial' | 'pro' | 'legacy_free' | 'expired';
export type { SubscriptionTier };

export interface Subscription {
tier: SubscriptionTier;
Expand All @@ -14,61 +16,9 @@ export interface Subscription {
trialEndsAt: string | null;
}

interface BillingRow {
status?: string | null;
current_period_end?: string | null;
}

export async function fetchSubscription(userId: string): Promise<Subscription> {
const snapshot: Subscription = { tier: 'expired', dailyLimit: 0, dailyUsed: 0, trialEndsAt: null };

const { data, error: settingsError } = await supabase
.from('user_settings')
.select('subscription_tier, daily_limit_override, trial_ends_at, grace_period_ends_at')
.eq('user_id', userId)
.maybeSingle();
if (settingsError) throw new Error(settingsError.message);

if (data) {
const rawTier = (data.subscription_tier || 'trial') as SubscriptionTier;
const limitOrDefault = (defaultLimit: number) => (data.daily_limit_override as number | null) ?? defaultLimit;
const now = new Date();

const { data: billingData, error: billingError } = await supabase
.from('subscriptions')
.select('status, current_period_end')
.eq('user_id', userId)
.order('current_period_end', { ascending: false, nullsFirst: false })
.limit(5);
if (billingError) console.warn('Could not load billing state:', billingError.message);
const billingRows: BillingRow[] = billingError ? [] : billingData ?? [];

const activeStripeSubscription = billingRows.some((row) =>
(row.status === 'active' || row.status === 'trialing') &&
row.current_period_end &&
new Date(row.current_period_end) > now,
);
const promoEnd = data.grace_period_ends_at ? new Date(data.grace_period_ends_at as string) : null;
const trialEnd = data.trial_ends_at ? new Date(data.trial_ends_at as string) : null;
const hasActiveTrialGift = Boolean(trialEnd && now < trialEnd);
snapshot.trialEndsAt = (data.trial_ends_at as string | null) ?? null;

if (rawTier === 'legacy_free') {
snapshot.tier = promoEnd && now < promoEnd ? 'legacy_free' : hasActiveTrialGift ? 'trial' : 'expired';
snapshot.dailyLimit = limitOrDefault(snapshot.tier === 'legacy_free' ? 50 : snapshot.tier === 'trial' ? 5 : 0);
} else if (rawTier === 'pro' && ((promoEnd && now < promoEnd) || activeStripeSubscription)) {
snapshot.tier = 'pro';
snapshot.dailyLimit = limitOrDefault(50);
} else if (rawTier === 'trial' && trialEnd && now < trialEnd) {
snapshot.tier = 'trial';
snapshot.dailyLimit = limitOrDefault(5);
} else if (hasActiveTrialGift) {
snapshot.tier = 'trial';
snapshot.dailyLimit = limitOrDefault(5);
} else {
snapshot.dailyLimit = limitOrDefault(0);
}
}
const entitlement = await readEntitlement(supabase, userId);
if (!entitlement) throw new Error('Could not load the plan');

// Usage resets at midnight UTC.
const todayStart = new Date();
Expand All @@ -80,5 +30,10 @@ export async function fetchSubscription(userId: string): Promise<Subscription> {
.gte('created_at', todayStart.toISOString());
if (usageError) console.warn('Could not load daily usage:', usageError.message);

return { ...snapshot, dailyUsed: usageError ? 0 : count || 0 };
return {
tier: entitlement.tier,
dailyLimit: entitlement.dailyLimit,
dailyUsed: usageError ? 0 : count || 0,
trialEndsAt: entitlement.trialEndsAt,
};
}
11 changes: 11 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,8 @@ Deletion runs in a fixed order, and a failure at any step leaves the account in
3. Call `prepare_account_deletion`, which deletes the workspaces the user owns and the user's own sources, threads, messages, and results in other workspaces. Those foreign keys stay `RESTRICT` so that a raw delete of an auth user cannot remove shared work by accident.
4. Delete the auth user with the Auth admin API. Every other foreign key into `auth.users` cascades or sets null, and the `on_auth_user_deleted` trigger clears the user from announcements, which have no foreign key.

The server cannot cancel an App Store subscription. Deletion removes the user's `app_store_subscriptions` rows, but Apple keeps billing until the user cancels in their Apple ID's subscription settings. The deletion response's `appStoreSubscriptionActive` says when Apple may still bill (not revoked, auto-renew not off, and ended less than 60 days ago, Apple's longest billing retry), and the app must then send the user to those settings. Notifications for the deleted subscription are acknowledged and ignored.

Admin-authored rows such as promo codes, curated sources, announcements, and grants to other users survive with the author cleared. Stripe keeps its own invoices and payments for tax and accounting. A new foreign key to `auth.users` must cascade, set null, or be cleared by `prepare_account_deletion`. `supabase/tests/account_deletion_test.sql` fails if its fixture has no row in that table or if any row still mentions the deleted user.

## Authentication and authorization
Expand All @@ -130,6 +132,15 @@ Supabase Auth owns user sessions. Protected routes provide a client navigation b

Checkout and portal functions create Stripe sessions after authenticated ownership and redirect validation. The webhook updates subscription state and records event processing for idempotency. AI handlers record the provider-reported usage and charge for each request.

A plan can come from several sources, and one database function merges them. `public.effective_entitlement(user_id)` in [`20261014123154_app_store_entitlements.sql`](../supabase/migrations/20261014123154_app_store_entitlements.sql) returns the highest active tier (pro, then legacy_free, basic, trial, expired), its daily limit (50, 50, 20, 5, 0, always replaced by `daily_limit_override`), whether it includes Pro models, the source and its expiry. The sources are `user_settings` (trial, promo and admin grants, legacy access, and the `pro` flag the Stripe webhook sets), Stripe `subscriptions` (counted only while that flag is `pro`), and `app_store_subscriptions`. A missing settings row fails closed. Every tier read goes through it: the AI and voice quota checks (`_shared/entitlements.ts`), the website's `useSubscription` and settings store, and the app, through `readEntitlement` in [`packages/core/src/supabase/entitlement.ts`](../packages/core/src/supabase/entitlement.ts). It runs with the caller's privileges, so a signed-in user reads only their own plan. The admin pages still show and edit the stored `subscription_tier` column.

The app sells Basic and Pro through StoreKit, and a subscription bought on either the website or the App Store unlocks both. Two Edge Functions write App Store subscriptions, both through `apply_app_store_transaction`, after verifying Apple's JWS signature chain against the pinned Apple Root CA - G3 ([`_shared/app-store.ts`](../supabase/functions/_shared/app-store.ts)) and the bundle id and environment:

- `app-store-notifications` receives App Store Server Notifications V2. Each notificationUUID is applied once, and Apple's retries are acknowledged.
- `app-store-purchase` takes the signed transaction the app gets from StoreKit, and records it only when its `appAccountToken` equals the caller's user id. The app sets that token to the user id at purchase.

A subscription row is linked to a user once, by that token, and later transactions update it in purchase-date order. A transaction Apple marks `isUpgraded` grants nothing, as in StoreKit's current entitlements, until the upgrade that replaces it arrives. Product ids map to tiers in `app_store_products`. Until App Store Connect has the real products, that table holds placeholders.

All model requests use the single OpenRouter backend route and `OPENROUTER_API_KEY`. The shared ten-model catalog, Luna Basic default, Pro-only entries, and request controls live in [`supabase/functions/_shared/model-catalog.ts`](../supabase/functions/_shared/model-catalog.ts). OpenRouter usage remains raw in the audit row, and the reported charge is unknown when the provider does not return a complete cost. The opponent's voice is the one exception to the OpenRouter route: the `voice` function calls ElevenLabs with `ELEVENLABS_API_KEY`. ElevenLabs reports billed characters, not dollars, so [`supabase/functions/_shared/cost.ts`](../supabase/functions/_shared/cost.ts) prices those rows from a dated list-price snapshot; promotions and plan credits are not modelled, so a voice row can overstate the charge.

`Auto Fast` and `Auto Pro` are virtual selections, not provider model IDs. The Edge Functions resolve them through TypeSafe Jev on OpenRouter before calling the concrete writing model. Auto Fast is available to Basic accounts and normally resolves to GLM 5.3 Flash; manual GLM selection keeps its Pro restriction. Auto Pro remains Pro-only. Provider calls, usage rows, and response metadata record the concrete resolved model, while response metadata also retains the requested automatic selection. A Jev router call uses the same OpenRouter key and does not consume a second daily quota unit.
Expand Down
17 changes: 10 additions & 7 deletions e2e/navigation-performance.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -366,13 +366,14 @@ test('subscription reads stay cached across sibling tool routes', async ({ page
await route.fulfill({ json: { announcements: [] } });
return;
}
if (url.pathname.endsWith('/rest/v1/rpc/effective_entitlement')) {
// The plan; the settings store reads it too, to pick default models.
subscriptionRequests.push('effective_entitlement');
await route.fulfill({ json: [{ tier: 'trial', daily_limit: 5, can_access_pro_models: false }] });
return;
}
if (url.pathname.includes('/rest/v1/')) {
const select = url.searchParams.get('select') ?? '';
if (url.pathname.endsWith('/user_settings') && select.includes('subscription_tier')) {
subscriptionRequests.push('user_settings');
} else if (url.pathname.endsWith('/subscriptions')) {
subscriptionRequests.push('subscriptions');
} else if (url.pathname.endsWith('/ai_requests')) {
if (url.pathname.endsWith('/ai_requests')) {
subscriptionRequests.push('ai_requests');
}
await route.fulfill({
Expand All @@ -389,12 +390,14 @@ test('subscription reads stay cached across sibling tool routes', async ({ page
await page.goto('/app/generate');
await waitForContent(page, { selector: 'main h1', text: 'Generate' });
await expect.poll(() => subscriptionRequests.filter((name) => name === 'ai_requests').length).toBe(1);
const readsBeforeNavigation = [...subscriptionRequests];

await openHome(page);
await homeToolLink(page, 'attack').click();
await waitForContent(page, attackContent);
// eslint-disable-next-line no-restricted-properties -- observation window: no extra subscription request may follow.
await page.waitForTimeout(100);

expect(subscriptionRequests).toEqual(['user_settings', 'ai_requests']);
expect(readsBeforeNavigation).toContain('effective_entitlement');
expect(subscriptionRequests).toEqual(readsBeforeNavigation);
});
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
"test:collaboration": "vitest run --config services/collaboration/vitest.config.ts",
"start:collaboration": "tsx services/collaboration/src/index.ts",
"test:watch": "vitest",
"test:edge": "deno test --allow-read --allow-env=DEMO_CODE_PEPPER,STRIPE_SECRET_KEY,STRIPE_PRICE_ID,STRIPE_PRICE_LOOKUP_KEY,EXA_API_KEY,SUPABASE_URL,SUPABASE_ANON_KEY,SUPABASE_SERVICE_ROLE_KEY,SPARRING_INTEGRITY_SECRET,ELEVENLABS_API_KEY,ELEVENLABS_VOICE_ID,OPENROUTER_API_KEY,OPENROUTER_API_URL,WORKSPACE_INVITE_SECRET,WORKSPACE_INVITE_MAILER_URL,WORKSPACE_INVITE_MAILER_TOKEN,WORKSPACE_INVITE_MAILER_FORMAT,WORKSPACE_INVITE_BASE_URL,WORKSPACE_INVITE_ALLOWED_ORIGINS,WORKSPACE_INVITE_SERVICE_ROLE_KEY --allow-net=127.0.0.1 supabase/functions/",
"test:edge": "deno test --allow-read --allow-env=DEMO_CODE_PEPPER,STRIPE_SECRET_KEY,STRIPE_PRICE_ID,STRIPE_PRICE_LOOKUP_KEY,EXA_API_KEY,SUPABASE_URL,SUPABASE_ANON_KEY,SUPABASE_SERVICE_ROLE_KEY,SPARRING_INTEGRITY_SECRET,ELEVENLABS_API_KEY,ELEVENLABS_VOICE_ID,OPENROUTER_API_KEY,OPENROUTER_API_URL,WORKSPACE_INVITE_SECRET,WORKSPACE_INVITE_MAILER_URL,WORKSPACE_INVITE_MAILER_TOKEN,WORKSPACE_INVITE_MAILER_FORMAT,WORKSPACE_INVITE_BASE_URL,WORKSPACE_INVITE_ALLOWED_ORIGINS,WORKSPACE_INVITE_SERVICE_ROLE_KEY,APP_STORE_BUNDLE_ID,APP_STORE_ENVIRONMENTS,APP_STORE_APP_APPLE_ID --allow-net=127.0.0.1 supabase/functions/",
"test:db": "supabase test db && bash scripts/test-daily-quota.sh && bash scripts/test-workspace-invite-concurrency.sh && bash scripts/test-workspace-mutation-concurrency.sh",
"test:e2e": "playwright test --project=desktop-chromium --project=mobile-chromium",
"test:e2e:smoke": "playwright test --project=desktop-chromium --grep @smoke",
Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/ai/feature-configs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@ const defaultConfig: ProviderConfig = {
* The model settings each tool starts with. The website saves these in
* user_settings.feature_configs, and the app writes them too when it saves the
* first setting, because the website reads an empty feature_configs as "never
* saved". Both pass DEFAULT_PRO_MODEL when canAccessProModels (pro-access.ts)
* says the student has Pro access.
* saved". Both pass DEFAULT_PRO_MODEL when the plan (supabase/entitlement.ts)
* includes Pro models.
*/
export const getDefaultFeatureConfigs = (model: string = DEFAULT_MODEL): Record<FeatureTab, ProviderConfig> => ({
generate: { ...defaultConfig, model },
Expand Down
32 changes: 0 additions & 32 deletions packages/core/src/ai/pro-access.ts

This file was deleted.

Loading
Loading