## Summary - Native Capacitor: RevenueCat purchase/restore (`@revenuecat/purchases-capacitor`) - Web: Stripe checkout/portal unchanged - Billing history shows store rows (`provider` / `revenuecat_store`) - RC `logIn`/`logOut` on auth lifecycle; appUserID = user pk Closes #100. Depends on backend [chat_backend#68](ai_ml_operations/chat_backend#68) / monetization PR. ## Test plan - [x] Unit tests: BillingSection, revenueCat, finance helpers, SignIn/Up, AuthCallback, Header2, DeleteAccount (38 OK) - [ ] Fill `REACT_APP_REVENUECAT_*` keys in `.env.mobile` - [ ] Align RC offerings/products with plan slugs - [ ] `npm run build:mobile` + `cap sync`; sandbox purchase on Android/iOS - [ ] Confirm web Stripe checkout/portal still work - [ ] After RC webhook, Account → Billing history shows store invoiceReviewed-on: #102
186 lines
5.2 KiB
TypeScript
186 lines
5.2 KiB
TypeScript
import {
|
|
canOpenBillingPortal,
|
|
formatMoneyCents,
|
|
formatTokenCount,
|
|
humanizeStatus,
|
|
invoiceProviderLabel,
|
|
isComplimentarySubscription,
|
|
isStoreSubscription,
|
|
isStripeSubscription,
|
|
pickPrimaryInvoice,
|
|
planAllowsRag,
|
|
} from './finance';
|
|
import type { FinanceInvoice, SubscriptionMe, SubscriptionPlanInfo } from './finance';
|
|
|
|
const basePlan = (overrides: Partial<SubscriptionPlanInfo> = {}): SubscriptionPlanInfo => ({
|
|
slug: 'standard',
|
|
name: 'Standard',
|
|
description: '',
|
|
price_cents: 999,
|
|
currency: 'usd',
|
|
interval: 'month',
|
|
is_public: true,
|
|
is_selectable: true,
|
|
features: {
|
|
text_generation: true,
|
|
image_generation: false,
|
|
rag: false,
|
|
all_future_features: false,
|
|
},
|
|
prompt_quota_per_window: 100,
|
|
prompt_window_hours: 6,
|
|
monthly_token_quota: null,
|
|
sort_order: 1,
|
|
...overrides,
|
|
});
|
|
|
|
const baseUsage: SubscriptionMe['usage'] = {
|
|
prompts_in_window: 0,
|
|
prompt_quota: null,
|
|
prompts_remaining: null,
|
|
window_hours: 6,
|
|
tokens_in_period: null,
|
|
tokens_out_period: null,
|
|
tokens_total_period: null,
|
|
turns_missing_token_usage: 0,
|
|
monthly_token_quota: null,
|
|
tokens_remaining: null,
|
|
period_start: null,
|
|
period_end: null,
|
|
};
|
|
|
|
const baseInvoice = (overrides: Partial<FinanceInvoice> = {}): FinanceInvoice => ({
|
|
id: 1,
|
|
provider: 'stripe',
|
|
status: 'open',
|
|
currency: 'usd',
|
|
amount_due: 1000,
|
|
amount_paid: 0,
|
|
period_start: null,
|
|
period_end: null,
|
|
stripe_invoice_id: null,
|
|
stripe_checkout_session_id: 'cs_1',
|
|
stripe_subscription_id: null,
|
|
hosted_invoice_url: '',
|
|
description: 'Chat Subscription',
|
|
created: '2026-07-01T00:00:00Z',
|
|
last_modified: '2026-07-01T00:00:00Z',
|
|
...overrides,
|
|
});
|
|
|
|
describe('finance helpers', () => {
|
|
it('formats money in cents', () => {
|
|
expect(formatMoneyCents(1000, 'usd')).toMatch(/10/);
|
|
});
|
|
|
|
it('shows em dash for missing token usage', () => {
|
|
expect(formatTokenCount(null)).toBe('—');
|
|
expect(formatTokenCount(undefined)).toBe('—');
|
|
expect(formatTokenCount(0)).toBe('0');
|
|
expect(formatTokenCount(1200)).toMatch(/1/);
|
|
});
|
|
|
|
it('humanizes status labels', () => {
|
|
expect(humanizeStatus('past_due')).toBe('Past Due');
|
|
});
|
|
|
|
it('prefers subscription-backed invoices for plan summary', () => {
|
|
const invoices = [
|
|
baseInvoice({ id: 1, status: 'open' }),
|
|
baseInvoice({
|
|
id: 2,
|
|
status: 'paid',
|
|
stripe_subscription_id: 'sub_1',
|
|
}),
|
|
];
|
|
expect(pickPrimaryInvoice(invoices)?.id).toBe(2);
|
|
});
|
|
|
|
it('detects portal access from paid Stripe invoices only', () => {
|
|
expect(canOpenBillingPortal([baseInvoice({ status: 'open' })])).toBe(false);
|
|
expect(canOpenBillingPortal([baseInvoice({ status: 'paid' })])).toBe(true);
|
|
expect(
|
|
canOpenBillingPortal([
|
|
baseInvoice({
|
|
status: 'paid',
|
|
provider: 'revenuecat',
|
|
stripe_subscription_id: null,
|
|
revenuecat_store: 'PLAY_STORE',
|
|
}),
|
|
])
|
|
).toBe(false);
|
|
});
|
|
|
|
it('labels invoice providers for history', () => {
|
|
expect(invoiceProviderLabel(baseInvoice({ provider: 'stripe' }))).toBe('Stripe');
|
|
expect(
|
|
invoiceProviderLabel(
|
|
baseInvoice({ provider: 'revenuecat', revenuecat_store: 'PLAY_STORE' })
|
|
)
|
|
).toBe('Play Store');
|
|
expect(
|
|
invoiceProviderLabel(
|
|
baseInvoice({ provider: 'revenuecat', revenuecat_store: 'APP_STORE' })
|
|
)
|
|
).toBe('App Store');
|
|
});
|
|
|
|
it('classifies subscription sources', () => {
|
|
expect(isStoreSubscription('revenuecat')).toBe(true);
|
|
expect(isStripeSubscription('stripe')).toBe(true);
|
|
expect(
|
|
isComplimentarySubscription(
|
|
{ source: 'revenuecat', needs_checkout: false },
|
|
false
|
|
)
|
|
).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('planAllowsRag', () => {
|
|
it('is false when there is no plan/subscription', () => {
|
|
expect(planAllowsRag(null)).toBe(false);
|
|
expect(planAllowsRag(undefined)).toBe(false);
|
|
});
|
|
|
|
it('is false when the rag feature flag is off', () => {
|
|
expect(planAllowsRag(basePlan())).toBe(false);
|
|
});
|
|
|
|
it('is true when the rag feature flag is on', () => {
|
|
expect(
|
|
planAllowsRag(basePlan({ features: { text_generation: true, image_generation: false, rag: true, all_future_features: false } }))
|
|
).toBe(true);
|
|
});
|
|
|
|
it('is true when all_future_features unlocks it', () => {
|
|
expect(
|
|
planAllowsRag(basePlan({ features: { text_generation: true, image_generation: true, rag: false, all_future_features: true } }))
|
|
).toBe(true);
|
|
});
|
|
|
|
it('accepts a SubscriptionMe wrapper and reads its plan', () => {
|
|
const subscription: SubscriptionMe = {
|
|
plan: basePlan({ features: { text_generation: true, image_generation: false, rag: true, all_future_features: false } }),
|
|
status: 'active',
|
|
source: 'stripe',
|
|
needs_checkout: false,
|
|
stripe_subscription_id: 'sub_1',
|
|
usage: baseUsage,
|
|
};
|
|
expect(planAllowsRag(subscription)).toBe(true);
|
|
});
|
|
|
|
it('is false when a subscription has no plan yet', () => {
|
|
const subscription: SubscriptionMe = {
|
|
plan: null,
|
|
status: 'none',
|
|
source: 'none',
|
|
needs_checkout: true,
|
|
stripe_subscription_id: '',
|
|
usage: baseUsage,
|
|
};
|
|
expect(planAllowsRag(subscription)).toBe(false);
|
|
});
|
|
});
|