Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
8bc3637
feat(state): subscription status from the ping, plans from subscripti…
alpha5611331 Oct 13, 2026
12555fc
feat(ui): subscription page, plan status, and the start gate
alpha5611331 Oct 13, 2026
27ebefa
docs: subscription pricing in CLAUDE.md and SPEC.md
alpha5611331 Oct 13, 2026
1da8436
fix(copy): out-of-free-time wording that is true for every account
alpha5611331 Oct 13, 2026
2ee2279
refactor(payment): names that match the subscription UI
alpha5611331 Oct 13, 2026
142b9d1
style(payment): format the purchase summary
alpha5611331 Oct 13, 2026
bfc16c0
docs(payment): drop the note about the removed legacy plans endpoint
alpha5611331 Oct 13, 2026
4422b92
docs(payment): yearly pass is $96 ($8 a month)
alpha5611331 Oct 13, 2026
3a86c61
refactor(credit-gate): effectiveRate is internal now
alpha5611331 Oct 13, 2026
b33eb16
refactor(i18n): one set of plan names for the plan line and the plans…
alpha5611331 Oct 13, 2026
74d6b03
fix(copy): mock refusal message speaks of free time, not credits
alpha5611331 Oct 13, 2026
5e5acf3
fix(state): subscription fields are server-owned and cleared between …
alpha5611331 Oct 14, 2026
6d54c7a
fix(copy): under a minute of free time is not 'no free time left'
alpha5611331 Oct 14, 2026
8489105
refactor(payment): remove the unused balance fetch and payment poller
alpha5611331 Oct 14, 2026
b7fa44b
feat(subscription): toast before a pass ends, and when it lapses
alpha5611331 Oct 14, 2026
d3a98ff
docs: the mock setup estimate is minutes only, plus free time left on…
alpha5611331 Oct 14, 2026
15149fa
test(subscription): pin the conditions that keep the expiry toasts ho…
alpha5611331 Oct 14, 2026
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
33 changes: 25 additions & 8 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -667,8 +667,20 @@ body text in every question.

### What an interview costs

**Every interview, live or mock, is billed by the minute on its ASR socket, at the same rate, and
nothing else charges.** A mock used to be priced per question, follow-up and report, with
**Pricing is a subscription.** A free hour once on the free-tier model, then a prepaid monthly
($20) or yearly ($96) pass: unlimited interviews on the SOTA model, no auto-renewal. A
subscriber's sockets are not metered; everything below is about the free hour.

**`AppState.credits` is the free balance, and `AppState.subscribed` sits beside it.** The backend
sends the free balance as `free_credits` and keeps `credits` for clients that predate
subscriptions - for a subscriber that is the time left expressed in credits, so an old client does
not lock a paying user out. `balanceState` (`src/main/utils/balance.ts`) reads `free_credits` when
present and falls back to `credits` against an older backend; `test/balance.test.mjs` pins it.
Never show the legacy figure. Anything that reads the clock in render goes through `useNow`: the
React Compiler refuses `Date.now()` there.

**Every free interview, live or mock, is billed by the minute on its ASR socket, at the same rate,
and nothing else charges.** A mock used to be priced per question, follow-up and report, with
`billing=per_turn` on its requests and `metered=0` on its socket; both are gone. The mock socket is
open from Start until the session reaches Idle or Finished (`use-mock-interview.ts`), so metering it
already covers question generation, speaking, scoring and the report.
Expand All @@ -679,12 +691,13 @@ the live session's two, so without it a mock pays half. `test/mock-billing-contr
pins it, and that nothing still declares per-turn billing - a backend that predates the switch
would honour such a declaration *and* run the clock.

**One start rule for both** (`lib/credit-gate.ts`): at least one minute of credit. The home cards,
the live control panel and the mock setup form all use it and send the user to Buy credits. The
**One start rule for both** (`lib/credit-gate.ts`): subscribed, or at least one minute of the free
hour. The home cards, the live control panel and the mock setup form all use it and send the user
to the subscription page. The
backend refuses only at zero, because it cannot tell a new session from a reconnect inside one; this
side can, so the minimum lives here. An unknown balance (before the first ping) is let through
rather than greying both cards out at launch. The mock setup screen gives an estimate in minutes and
credits and says how far the balance goes, rather than disabling lengths.
rather than greying both cards out at launch. The mock setup screen gives an estimate in minutes and,
on the free hour, says how far the free time goes, rather than disabling lengths.

**The backend ends a socket at zero with close code 4402** (`WS_CLOSE_INSUFFICIENT_CREDITS`), and
`AudioWsStream` must never reconnect on it - a reconnect is refused the same way, and the backoff
Expand All @@ -704,7 +717,11 @@ to start, so a refusal that lands while the session still reads Idle is held and
main's start resolves. `test/out-of-credits.test.mjs` pins all
of it.

`useLowBalanceWarning` toasts once at five minutes and once at one, off the ping balance. Sockets
`useLowBalanceWarning` toasts once at five minutes and once at one, off the ping balance, and never
for a subscriber. A subscription within three days of its end shows "Ends in N days" in the title
bar instead of its end date, and `useSubscriptionExpiryWarning` (mounted in `MainFrame`) toasts
once at each of three, two and one days left, and once when a subscription this session saw active
lapses - never for one that lapsed before launch, and never across a sign-out. Sockets
also carry `kind=mock` and a per-session `client_session_id`, which the backend uses only to label
and group its ledger rows.

Expand All @@ -716,7 +733,7 @@ The main window reference is passed to `windowControlService` and `zoomService`
36px titlebar. The home screen did not: six full-width rows down a column half the window wide
came to roughly 580px against 564px of room, so the app's front door opened on a scrollbar with
the sign-out row below the fold. Nothing was dropped to fix it - the two launch cards share a row,
the account strip carries Buy Credits on its own line, and the three navigations are one row of
the account strip carries Subscription on its own line, and the three navigations are one row of
three, which is spending the width the window already has rather than the height it does not.
The outer container keeps `overflow-auto` as a safety valve for large zoom factors.

Expand Down
14 changes: 7 additions & 7 deletions SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,11 +69,9 @@ Earlier attempts worked on this from the other end - having the reveal wait for

### What a Mock Interview Costs

A live interview is metered per minute; a mock one is priced per question (20 credits), follow-up (10) and report (40), and its transcription socket is not metered at all. The two spend their wall clock differently: a mock session spends much of it generating the question, speaking it, scoring the turn and writing the report - none of which the candidate can act during - and most of the rest on think-time, which is the behaviour the feature exists to train.
Nothing extra. Pricing is a subscription: a free hour once on the free-tier model, then a $20 monthly or $96 yearly pass with unlimited interviews on the SOTA model. A subscriber's mock and live sessions are not metered at all. A free user's sessions, live or mock, spend the free hour by the minute on their transcription socket and stop when it runs out; a mock still ends with its report.

The prices come from the backend on the 5-second ping, never from local constants, so a quote can never name a number the user is not charged. The setup dialog shows two: what the session is guaranteed to cost, and the ceiling if the interviewer follows up on every answer. The smaller one is the promise - every question quoted and the report are guaranteed once the session starts, and a follow-up is declined rather than allowed to eat into them.

A length the balance cannot cover is disabled in the question-count picker with the reason, so the answer to "not enough credits" is a shorter interview chosen on the same screen; the home screen's mock card says the same thing one level earlier, against the shortest session there is. A backend that predates per-turn pricing sends no prices, and the client then quotes nothing and gates nothing.
The setup dialog gives an estimate in minutes. For a free user it adds how far the free time goes and that the session ends early if it runs out; for a subscriber it says the session is unlimited. No length is ever refused.

### Hint-Only Mode

Expand All @@ -99,7 +97,7 @@ Nothing in it is a trap: Skip is on every step, every setting has a working defa

### Navigation

`/` is a launch hub naming the five things a user comes to the app to do: start a mock interview, start the live assistant, open Account (`/account` - sign-in identity, profile, context, password), open Configuration (`/configuration` - app language, microphone, interview language, suggestion style, interface size, transcript panel), or buy credits.
`/` is a launch hub naming the five things a user comes to the app to do: start a mock interview, start the live assistant, open Account (`/account` - sign-in identity, profile, context, password), open Configuration (`/configuration` - app language, microphone, interview language, suggestion style, interface size, transcript panel), or manage the subscription.

**It is the only place a session begins.** Both launch buttons start one; neither implements starting one. Live hands off to `/main` through router state, because `/main`'s control panel owns the whole start sequence; mock hands off to `/mock-interview` with the setup its dialog collected. `/main` itself carries only Stop - it is the live assistant, not a place to choose one - and shows a way back to `/` on the rare idle visit (a start cancelled at the headphone notice, or the route opened directly). Stopping asks whether to save the interview, clears it, and returns to `/`. See [docs/ux-conventions.md](docs/ux-conventions.md) for where a new capability belongs.

Expand All @@ -111,9 +109,11 @@ While stealth mode is on - or while the assistant is running with the opt-in "Hi

Full name, profile/CV, and context are stored on the user's backend account and pulled on login or a remembered session, so the setup follows the user across devices. Service: [src/main/services/account.service.ts](src/main/services/account.service.ts). The full values are kept in the main process and fetched on demand over `account:get`; the app-state broadcast carries only a `{ fullName, hasProfileData }` summary, since the profile and context can each run to 128,000 characters.

### Credits and Payments
### Subscription and Payments

Route: `/payment`. The Plans tab shows the account's plan (the active pass and its end date, or the free time left) and the passes on sale from `/api/payment/subscription-plans`, bought through the same crypto checkout as before. Passes are prepaid and never renew; buying one while subscribed adds its days after the current end date. Plans, the balance and the subscription are always served by the backend (`credits`, `free_credits`, `subscribed`, `subscription_expires_at`, `subscription_plan` and `credits_per_minute` on every 5-second `/api/health-check/ping-client`); a failed plan fetch surfaces as an error rather than falling back to stale figures. A toast warns at three, two and one days before a pass ends, and once when it lapses while the app is open.

Purchase and usage tracking via the payment API. Route: `/payment`. Plans, the credit balance and the per-minute burn rate are always served by the backend (`/api/payment/plans`, `/api/payment/credits`, plus the balance and `credits_per_minute` carried on every 5-second `/api/health-check/ping-client`); a failed plan fetch surfaces as an error rather than falling back to stale figures. `CREDITS_PER_MINUTE` in `renderer/lib/consts.ts` is a fallback mirror only, used while `AppState.creditsPerMinute` is `undefined` - before the first ping answers, or against a backend old enough not to send it - since the real rate is an env-overridable deployment setting rather than a constant.
`AppState.credits` is always the free balance: `free_credits` when the backend sends it, else `credits` (`balanceState` in `src/main/utils/balance.ts`). The backend's own `credits` is, for a subscriber, a compatibility figure for clients that predate subscriptions, and must not be shown. `CREDITS_PER_MINUTE` in `renderer/lib/consts.ts` is a fallback mirror only, used while `AppState.creditsPerMinute` is `undefined` - before the first ping answers, or against a backend old enough not to send it - since the real rate is an env-overridable deployment setting rather than a constant.

### Auto-Updates

Expand Down
2 changes: 1 addition & 1 deletion docs/ux-conventions.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ feature can still fail a non-developer if it doesn't read as polished and easy t
new or changed UI surface must:

- **Use plain, user-facing language**, not internal identifiers — labels, menu items, and error
messages are written for the person using the app, not for the codebase (e.g. "Buy Credits",
messages are written for the person using the app, not for the codebase (e.g. "Subscription",
not a raw enum or hotkey constant name).
- **Define an explicit state for empty, loading, and error** — no surface ships with only a
"happy path" and a blank area or unhandled exception for everything else.
Expand Down
23 changes: 12 additions & 11 deletions src/main/api/payment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,20 +7,28 @@ import {
AvailableCurrency,
CreatePaymentRequest,
CreatePaymentResponse,
CreditPlan,
PaymentHistory,
PaymentPlan,
PaymentStatusResponse,
} from '../types/payment.js';
import { ApiClient, ApiResponse } from './client.js';

export class PaymentApi extends ApiClient {
/**
* Get available credit plans
* Get the subscription passes on sale.
*/
async getPlans(): Promise<
ApiResponse<{ plan: CreditPlan; credits: number; price_usd: number; popular: boolean }[]>
ApiResponse<
{
plan: PaymentPlan;
price_usd: number;
days: number;
price_per_month_usd: number;
popular: boolean;
}[]
>
> {
return this.get('/api/payment/plans');
return this.get('/api/payment/subscription-plans');
}

/**
Expand Down Expand Up @@ -50,11 +58,4 @@ export class PaymentApi extends ApiClient {
async getPaymentHistory(): Promise<ApiResponse<PaymentHistory[]>> {
return this.get('/api/payment/history');
}

/**
* Get current user credits
*/
async getCredits(): Promise<ApiResponse<{ credits: number }>> {
return this.get('/api/payment/credits');
}
}
10 changes: 9 additions & 1 deletion src/main/ipc/app-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,15 @@ import { type AppState } from '../types/app-state.js';
* `FAILURE_INTERVAL`/`SUCCESS_INTERVAL` later) silently overwrote it - an unguarded path onto a
* financial field that happened to have no caller today.
*/
const SERVER_OWNED_KEYS = ['credits', 'creditsPerMinute', 'userRole'] as const;
const SERVER_OWNED_KEYS = [
'credits',
'creditsPerMinute',
'userRole',
// `subscribed` overrides `credits` in the start gate, so it needs the same guard.
'subscribed',
'subscriptionExpiresAt',
'subscriptionPlan',
] as const;

function stripServerOwnedFields(updates: Partial<AppState>): Partial<AppState> {
const sanitized = { ...updates };
Expand Down
16 changes: 3 additions & 13 deletions src/main/ipc/payment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,18 +10,18 @@ import {
AvailableCurrency,
CreatePaymentRequest,
CreatePaymentResponse,
CreditPlanInfo,
PaymentHistory,
PaymentStatusResponse,
SubscriptionPlanInfo,
} from '../types/payment.js';

export function registerPaymentHandlers(): void {
/**
* Get available credit plans
* Get the subscription passes on sale
*/
ipcMain.handle(
'payment:get-plans',
async (): Promise<{ success: boolean; data?: CreditPlanInfo[]; error?: string }> => {
async (): Promise<{ success: boolean; data?: SubscriptionPlanInfo[]; error?: string }> => {
return paymentService.getPlans();
}
);
Expand Down Expand Up @@ -71,14 +71,4 @@ export function registerPaymentHandlers(): void {
return paymentService.getPaymentHistory();
}
);

/**
* Get current user credits
*/
ipcMain.handle(
'payment:get-credits',
async (): Promise<{ success: boolean; credits?: number; error?: string }> => {
return paymentService.getCredits();
}
);
}
1 change: 0 additions & 1 deletion src/main/preload.cts
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,6 @@ const electronApi = {
create: (data: Record<string, unknown>) => ipcRenderer.invoke('payment:create', data),
getStatus: (paymentId: string) => ipcRenderer.invoke('payment:get-status', paymentId),
getHistory: () => ipcRenderer.invoke('payment:get-history'),
getCredits: () => ipcRenderer.invoke('payment:get-credits'),
},

appState: {
Expand Down
3 changes: 3 additions & 0 deletions src/main/services/app-state.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ const DEFAULT_STATE: AppState = {
liveSuggestions: [],
actionSuggestions: [],
credits: undefined,
subscribed: undefined,
subscriptionExpiresAt: undefined,
subscriptionPlan: undefined,
userRole: undefined,
providedLLMModel: undefined,
creditsPerMinute: undefined,
Expand Down
5 changes: 3 additions & 2 deletions src/main/services/auth.service.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { AuthApi } from '../api/auth.js';
import { configStore } from '../store/config.store.js';
import { CLEARED_BALANCE } from '../utils/balance.js';
import { uiStrings } from '../utils/ui-strings.js';
import { accountService } from './account.service.js';
import { appStateService } from './app-state.service.js';
Expand Down Expand Up @@ -121,7 +122,7 @@ export class AuthService {
configStore.updateConfig({ sessionToken: response.data?.session_token });

// update app state to logged in
appStateService.updateState({ isLoggedIn: true });
appStateService.updateState({ isLoggedIn: true, ...CLEARED_BALANCE });

// pull this account's synced config (full name, profile, context) from the backend.
// Cleared first so a failed pull leaves the config unloaded rather than whatever
Expand Down Expand Up @@ -163,7 +164,7 @@ export class AuthService {
disableStealth();
// clear session token and update app state
configStore.updateConfig({ sessionToken: '' });
appStateService.updateState({ isLoggedIn: false });
appStateService.updateState({ isLoggedIn: false, ...CLEARED_BALANCE });
accountService.clearState();

// The interview goes with the session it belonged to. The transcript, the suggestions and
Expand Down
5 changes: 3 additions & 2 deletions src/main/services/health-check.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

import { HealthCheckApi } from '../api/health-check.js';
import { MockInterviewApi } from '../api/mock-interview.js';
import { balanceState, CLEARED_BALANCE } from '../utils/balance.js';
import { safeSleep } from '../utils/sleep.js';
import { uiStrings } from '../utils/ui-strings.js';
import { accountService } from './account.service.js';
Expand Down Expand Up @@ -60,7 +61,7 @@ export class HealthCheckService {
loggedIn = res.status === 200;
appStateService.updateState({
isLoggedIn: loggedIn,
credits: res.data?.credits,
...(res.data ? balanceState(res.data) : CLEARED_BALANCE),
userRole: res.data?.user_role,
providedLLMModel: res.data?.provided_llm_model,
// Undefined here means "not answered yet", not free and not the compiled-in default -
Expand Down Expand Up @@ -192,7 +193,7 @@ export class HealthCheckService {
} else if (res.data?.credits !== undefined) {
console.log('client ping response:', res.data);
appStateService.updateState({
credits: res.data?.credits,
...balanceState(res.data),
providedLLMModel: res.data?.provided_llm_model,
userRole: res.data?.user_role,
creditsPerMinute: usableRate(res.data?.credits_per_minute),
Expand Down
Loading
Loading