Skip to content

Commit bb36239

Browse files
authored
Merge pull request #141 from Programcode/native-ios/08a-entitlements
Native iOS app: merged App Store and Stripe entitlements (backend)
2 parents 376f746 + 04e9abf commit bb36239

44 files changed

Lines changed: 2646 additions & 440 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎apps/mobile/src/app/(tabs)/(you)/you.tsx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import { useTheme } from '@/theme/use-theme';
2020

2121
const TIER_LABELS: Record<SubscriptionTier, string> = {
2222
trial: 'settings.tierTrial',
23+
basic: 'settings.tierBasic',
2324
pro: 'settings.tierPro',
2425
legacy_free: 'settings.tierLegacyFree',
2526
expired: 'settings.tierExpired',

‎apps/mobile/src/lib/feature-config.ts‎

Lines changed: 5 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { getDefaultFeatureConfigs, type FeatureTab } from '@debatelab/core/ai/feature-configs';
2-
import { canAccessProModels } from '@debatelab/core/ai/pro-access';
2+
import { readEntitlement } from '@debatelab/core/supabase/entitlement';
33
import {
44
AUTO_FAST,
55
AUTO_PRO,
@@ -76,19 +76,13 @@ function normalizeConfig(value: unknown, fallback: FeatureConfig, allowProModels
7676
export async function fetchFeatureConfig(userId: string, feature: FeatureTab): Promise<FeatureConfig> {
7777
const { data, error } = await supabase
7878
.from('user_settings')
79-
.select('feature_configs, subscription_tier, grace_period_ends_at')
79+
.select('feature_configs')
8080
.eq('user_id', userId)
8181
.maybeSingle();
8282
if (error || !data) return defaultConfig(feature);
83-
// Null when it cannot be told, as on the website, which then keeps its defaults.
84-
const allowProModels = await canAccessProModels(data, async () => {
85-
const { data: rows, error: billingError } = await supabase
86-
.from('subscriptions')
87-
.select('status, current_period_end')
88-
.eq('user_id', userId);
89-
return billingError ? null : rows ?? [];
90-
});
91-
if (allowProModels === null) return defaultConfig(feature);
83+
// Unknown when the plan cannot be read, as on the website, which then keeps its defaults.
84+
const allowProModels = (await readEntitlement(supabase, userId))?.canAccessProModels;
85+
if (allowProModels === undefined) return defaultConfig(feature);
9286
const fallback = defaultConfig(feature, allowProModels ? DEFAULT_PRO_MODEL : DEFAULT_MODEL);
9387
return normalizeConfig(asRecord(data.feature_configs)?.[feature], fallback, allowProModels);
9488
}

‎apps/mobile/src/lib/settings.tsx‎

Lines changed: 4 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { getDefaultFeatureConfigs } from '@debatelab/core/ai/feature-configs';
2-
import { canAccessProModels } from '@debatelab/core/ai/pro-access';
2+
import { readEntitlement } from '@debatelab/core/supabase/entitlement';
33
import { DEFAULT_MODEL, DEFAULT_PRO_MODEL } from '@debatelab/core/contract/model-catalog';
44
import { isAppLanguage, type AppLanguage } from '@debatelab/core/languages';
55
import AsyncStorage from '@react-native-async-storage/async-storage';
@@ -70,21 +70,9 @@ function isEmptyObject(value: unknown): boolean {
7070
* their Pro access can't be read right now.
7171
*/
7272
async function defaultModelFor(userId: string): Promise<string | null> {
73-
const { data, error } = await supabase
74-
.from('user_settings')
75-
.select('subscription_tier,grace_period_ends_at')
76-
.eq('user_id', userId)
77-
.maybeSingle();
78-
if (error || !data) return null;
79-
const pro = await canAccessProModels(data, async () => {
80-
const { data: rows, error: billingError } = await supabase
81-
.from('subscriptions')
82-
.select('status, current_period_end')
83-
.eq('user_id', userId);
84-
return billingError ? null : rows ?? [];
85-
});
86-
if (pro === null) return null;
87-
return pro ? DEFAULT_PRO_MODEL : DEFAULT_MODEL;
73+
const entitlement = await readEntitlement(supabase, userId);
74+
if (!entitlement) return null;
75+
return entitlement.canAccessProModels ? DEFAULT_PRO_MODEL : DEFAULT_MODEL;
8876
}
8977

9078
/**
Lines changed: 14 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
1+
import { readEntitlement, type SubscriptionTier } from '@debatelab/core/supabase/entitlement';
2+
13
import { supabase } from './supabase';
24

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

1012
export interface Subscription {
1113
tier: SubscriptionTier;
@@ -14,61 +16,9 @@ export interface Subscription {
1416
trialEndsAt: string | null;
1517
}
1618

17-
interface BillingRow {
18-
status?: string | null;
19-
current_period_end?: string | null;
20-
}
21-
2219
export async function fetchSubscription(userId: string): Promise<Subscription> {
23-
const snapshot: Subscription = { tier: 'expired', dailyLimit: 0, dailyUsed: 0, trialEndsAt: null };
24-
25-
const { data, error: settingsError } = await supabase
26-
.from('user_settings')
27-
.select('subscription_tier, daily_limit_override, trial_ends_at, grace_period_ends_at')
28-
.eq('user_id', userId)
29-
.maybeSingle();
30-
if (settingsError) throw new Error(settingsError.message);
31-
32-
if (data) {
33-
const rawTier = (data.subscription_tier || 'trial') as SubscriptionTier;
34-
const limitOrDefault = (defaultLimit: number) => (data.daily_limit_override as number | null) ?? defaultLimit;
35-
const now = new Date();
36-
37-
const { data: billingData, error: billingError } = await supabase
38-
.from('subscriptions')
39-
.select('status, current_period_end')
40-
.eq('user_id', userId)
41-
.order('current_period_end', { ascending: false, nullsFirst: false })
42-
.limit(5);
43-
if (billingError) console.warn('Could not load billing state:', billingError.message);
44-
const billingRows: BillingRow[] = billingError ? [] : billingData ?? [];
45-
46-
const activeStripeSubscription = billingRows.some((row) =>
47-
(row.status === 'active' || row.status === 'trialing') &&
48-
row.current_period_end &&
49-
new Date(row.current_period_end) > now,
50-
);
51-
const promoEnd = data.grace_period_ends_at ? new Date(data.grace_period_ends_at as string) : null;
52-
const trialEnd = data.trial_ends_at ? new Date(data.trial_ends_at as string) : null;
53-
const hasActiveTrialGift = Boolean(trialEnd && now < trialEnd);
54-
snapshot.trialEndsAt = (data.trial_ends_at as string | null) ?? null;
55-
56-
if (rawTier === 'legacy_free') {
57-
snapshot.tier = promoEnd && now < promoEnd ? 'legacy_free' : hasActiveTrialGift ? 'trial' : 'expired';
58-
snapshot.dailyLimit = limitOrDefault(snapshot.tier === 'legacy_free' ? 50 : snapshot.tier === 'trial' ? 5 : 0);
59-
} else if (rawTier === 'pro' && ((promoEnd && now < promoEnd) || activeStripeSubscription)) {
60-
snapshot.tier = 'pro';
61-
snapshot.dailyLimit = limitOrDefault(50);
62-
} else if (rawTier === 'trial' && trialEnd && now < trialEnd) {
63-
snapshot.tier = 'trial';
64-
snapshot.dailyLimit = limitOrDefault(5);
65-
} else if (hasActiveTrialGift) {
66-
snapshot.tier = 'trial';
67-
snapshot.dailyLimit = limitOrDefault(5);
68-
} else {
69-
snapshot.dailyLimit = limitOrDefault(0);
70-
}
71-
}
20+
const entitlement = await readEntitlement(supabase, userId);
21+
if (!entitlement) throw new Error('Could not load the plan');
7222

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

83-
return { ...snapshot, dailyUsed: usageError ? 0 : count || 0 };
33+
return {
34+
tier: entitlement.tier,
35+
dailyLimit: entitlement.dailyLimit,
36+
dailyUsed: usageError ? 0 : count || 0,
37+
trialEndsAt: entitlement.trialEndsAt,
38+
};
8439
}

‎docs/architecture.md‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,8 @@ Deletion runs in a fixed order, and a failure at any step leaves the account in
113113
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.
114114
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.
115115

116+
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.
117+
116118
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.
117119

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

131133
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.
132134

135+
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.
136+
137+
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:
138+
139+
- `app-store-notifications` receives App Store Server Notifications V2. Each notificationUUID is applied once, and Apple's retries are acknowledged.
140+
- `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.
141+
142+
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.
143+
133144
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.
134145

135146
`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.

‎e2e/navigation-performance.spec.ts‎

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -366,13 +366,14 @@ test('subscription reads stay cached across sibling tool routes', async ({ page
366366
await route.fulfill({ json: { announcements: [] } });
367367
return;
368368
}
369+
if (url.pathname.endsWith('/rest/v1/rpc/effective_entitlement')) {
370+
// The plan; the settings store reads it too, to pick default models.
371+
subscriptionRequests.push('effective_entitlement');
372+
await route.fulfill({ json: [{ tier: 'trial', daily_limit: 5, can_access_pro_models: false }] });
373+
return;
374+
}
369375
if (url.pathname.includes('/rest/v1/')) {
370-
const select = url.searchParams.get('select') ?? '';
371-
if (url.pathname.endsWith('/user_settings') && select.includes('subscription_tier')) {
372-
subscriptionRequests.push('user_settings');
373-
} else if (url.pathname.endsWith('/subscriptions')) {
374-
subscriptionRequests.push('subscriptions');
375-
} else if (url.pathname.endsWith('/ai_requests')) {
376+
if (url.pathname.endsWith('/ai_requests')) {
376377
subscriptionRequests.push('ai_requests');
377378
}
378379
await route.fulfill({
@@ -389,12 +390,14 @@ test('subscription reads stay cached across sibling tool routes', async ({ page
389390
await page.goto('/app/generate');
390391
await waitForContent(page, { selector: 'main h1', text: 'Generate' });
391392
await expect.poll(() => subscriptionRequests.filter((name) => name === 'ai_requests').length).toBe(1);
393+
const readsBeforeNavigation = [...subscriptionRequests];
392394

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

399-
expect(subscriptionRequests).toEqual(['user_settings', 'ai_requests']);
401+
expect(readsBeforeNavigation).toContain('effective_entitlement');
402+
expect(subscriptionRequests).toEqual(readsBeforeNavigation);
400403
});

‎package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
"test:collaboration": "vitest run --config services/collaboration/vitest.config.ts",
2828
"start:collaboration": "tsx services/collaboration/src/index.ts",
2929
"test:watch": "vitest",
30-
"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/",
30+
"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/",
3131
"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",
3232
"test:e2e": "playwright test --project=desktop-chromium --project=mobile-chromium",
3333
"test:e2e:smoke": "playwright test --project=desktop-chromium --grep @smoke",

‎packages/core/src/ai/feature-configs.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,8 @@ const defaultConfig: ProviderConfig = {
1414
* The model settings each tool starts with. The website saves these in
1515
* user_settings.feature_configs, and the app writes them too when it saves the
1616
* first setting, because the website reads an empty feature_configs as "never
17-
* saved". Both pass DEFAULT_PRO_MODEL when canAccessProModels (pro-access.ts)
18-
* says the student has Pro access.
17+
* saved". Both pass DEFAULT_PRO_MODEL when the plan (supabase/entitlement.ts)
18+
* includes Pro models.
1919
*/
2020
export const getDefaultFeatureConfigs = (model: string = DEFAULT_MODEL): Record<FeatureTab, ProviderConfig> => ({
2121
generate: { ...defaultConfig, model },

‎packages/core/src/ai/pro-access.ts‎

Lines changed: 0 additions & 32 deletions
This file was deleted.

0 commit comments

Comments
 (0)