From 47f28b81a11594e971808ea7db416df2f9192f15 Mon Sep 17 00:00:00 2001 From: Josh Lambert Date: Tue, 23 Jun 2026 20:02:09 -0400 Subject: [PATCH 1/3] feat(coding-plans): expose managed plan usage --- .specs/coding-plans.md | 10 + .specs/subscription-center.md | 20 +- .../web/src/app/api/trpc/[trpc]/route.test.ts | 47 +++++ .../components/payment/AutoTopUpToggle.tsx | 11 +- apps/web/src/lib/autoTopUpConstants.ts | 1 + .../lib/coding-plans/minimax-usage.test.ts | 116 +++++++++++ .../web/src/lib/coding-plans/minimax-usage.ts | 139 +++++++++++++ .../src/routers/coding-plans-router.test.ts | 186 ++++++++++++++++++ apps/web/src/routers/coding-plans-router.ts | 99 ++++++++++ apps/web/src/routers/user-router.test.ts | 92 ++++++++- apps/web/src/routers/user-router.ts | 49 +++-- 11 files changed, 751 insertions(+), 19 deletions(-) create mode 100644 apps/web/src/app/api/trpc/[trpc]/route.test.ts create mode 100644 apps/web/src/lib/coding-plans/minimax-usage.test.ts create mode 100644 apps/web/src/lib/coding-plans/minimax-usage.ts diff --git a/.specs/coding-plans.md b/.specs/coding-plans.md index 40bcfb817f..c18056a370 100644 --- a/.specs/coding-plans.md +++ b/.specs/coding-plans.md @@ -74,6 +74,8 @@ The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "S 3.5. While an Installed BYOK Configuration still contains Kilo's issued credential, user-facing BYOK surfaces **MUST** identify its Coding Plan origin. Ordinary BYOK test, enable/disable, update, and delete operations **MUST** remain available. Before updating, disabling, or deleting that configuration, Kilo **MUST** warn that the operation changes routing but does not cancel subscription billing and **MUST** direct cancellation to the Subscription Center. Updating the credential **MUST** mark the entry as user-managed and detach it from later Coding Plan cleanup; deleting it **MUST NOT** cancel or pause the subscription. Testing or re-enabling the key does not require this warning. +3.6. Cloud **MAY** query current Upstream Provider quota for an authenticated owner of an active or `past_due` Coding Plan by using the retained assigned Managed Plan Credential. Decryption and provider access **MUST** remain server-side. The Managed Plan Credential, inventory identity, Upstream Plan ID, fingerprint, ciphertext, and authorization metadata **MUST NOT** leave Cloud. + ## 4. Credential provisioning and inventory 4.1. Kilo **MUST** acquire or provision Managed Plan Credentials before accepting a purchase that depends on them. For an offering initially provisioned by operator upload, only authorized administrative tooling **MAY** insert credentials into inventory. @@ -118,6 +120,8 @@ The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "S 5.10. The initial pilot **MAY** leave an unchanged Kilo-installed BYOK configuration routable between its paid-period or grace deadline and the next scheduled billing lifecycle sweep. Once that sweep processes termination, local Kilo-installed access **MUST** be deleted regardless of whether manual upstream revocation is complete. +5.11. An active or `past_due` Coding Plan, including one pending cancellation at period end, **MUST** remain eligible for current quota presentation until Effective Cancellation. Replacing, disabling, or deleting its Installed BYOK Configuration **MUST NOT** hide the subscription or prevent quota lookup through the originally assigned Managed Plan Credential. + ## 6. Traffic routing 6.1. Initial Token Plan Plus setup **MUST** route through the Kilo Gateway using the existing ordinary personal MiniMax BYOK provider identity. The initial release **MUST NOT** expose saved raw credential values through Kilo UI or API responses. @@ -126,6 +130,8 @@ The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "S 6.3. Purchase **MUST** reject an occupied personal MiniMax BYOK slot before a charge or issued credential assignment commits. Once subscribed, a user's ordinary MiniMax BYOK actions affect routing configuration only; Coding Plan billing and revocation of Kilo's originally issued credential remain independent. +6.4. Current routing state **MUST** be reported separately from subscription and provider-quota state. Quota authorization **MUST NOT** depend on the existence, contents, or enabled state of the Installed BYOK Configuration. + ## 7. User-facing behavior 7.1. Users **MUST** be able to view catalog offerings, purchase a Coding Plan, view their subscription status and paid-period dates, and request cancellation from Kilo surfaces. @@ -140,6 +146,8 @@ The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "S 7.6. A sold-out offering **MUST** display its unavailable state and **MUST** offer an authenticated user a way to record an Availability Notification Intent. Recording the same intent again **MUST** be idempotent, **MUST NOT** reserve capacity or initiate billing, and **MUST** show the saved intent state. A successful activation **MUST** clear the activated user's intent for that Plan ID. +7.7. Authenticated Kilo clients **MAY** reuse Cloud's current personal billing and Coding Plan data to present current plans, routing state, and current provider quota. Ended plans, charged-term history, invoices, and billing history **MUST** remain in the Subscription Center rather than the current-plan response. + ## 8. Security and observability 8.1. Logs and monitoring **MUST NOT** contain raw Managed Plan Credentials, credential-bearing authorization headers, provider-management secrets, or unfiltered provider/SDK key-test error content. @@ -147,3 +155,5 @@ The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "S 8.2. General administrative credential inventory responses **MUST** return non-secret status and remediation metadata only. For a `revocation_pending` or `revocation_failed` item, the manual-revocation admin console **MAY** display its Upstream Plan ID to authorized staff. Raw credential values **MUST NOT** be returned by queue, list, or remediation APIs or appear on customer surfaces. 8.3. The initial pilot does not require a Coding Plans audit-log history for admin inventory upload or manual revocation actions. Inventory lifecycle state, Upstream Plan ID, request/completion timestamps, attempt count, and sanitized failure information **MUST** record current disposition without retaining raw credentials after remediation starts. + +8.4. Current quota responses and logs **MUST NOT** contain Managed Plan Credentials, authorization headers, raw provider bodies or messages, inventory metadata, Upstream Plan IDs, fingerprints, or ciphertext. Provider responses **MUST** be bounded, validated, and projected to an explicit non-secret field allowlist before leaving the Cloud boundary. diff --git a/.specs/subscription-center.md b/.specs/subscription-center.md index 29ab69f9e8..741459bff1 100644 --- a/.specs/subscription-center.md +++ b/.specs/subscription-center.md @@ -25,6 +25,7 @@ Updated 2026-05-28 -- Credit-funded payment source label. Updated 2026-05-28 -- Coding Plans API key configuration summary. Updated 2026-05-28 -- Coding Plans billing history USD amount display. Updated 2026-06-05 -- KiloClaw final Commit term continuation behavior. +Updated 2026-06-19 -- Current Coding Plan quota presentation and routing independence. ## Conventions @@ -273,7 +274,11 @@ historical Commit names, prices, invoices, and credit deductions. 27. A user MAY have multiple Coding Plans subscriptions — one per configured Plan ID. The Coding Plans group MUST display one Subscription Card for each non-terminal coding plan subscription, - including a `past_due` subscription in its warning state. + including a `past_due` subscription in its warning state. Authenticated + Kilo clients MAY reuse the same current personal subscription data for + current-plan presentation outside the Subscription Center. These clients + MUST NOT include terminal history, invoices, or billing history in that + current-plan response. 28. The Coding Plans detail page MUST be served at `/subscriptions/coding-plans/[subscriptionId]`. @@ -294,9 +299,17 @@ historical Commit names, prices, invoices, and credit deductions. linking to `/byok` when a managed key is installed - Traffic routing information (Kilo Gateway through the ordinary MiniMax BYOK provider setup) + - Current Upstream Provider quota for an `active` or `past_due` Coding Plan + when available, authorized through the retained Managed Plan Credential + without exposing it to the client - Inline billing history showing credit transactions with amounts in USD (see Billing History rules) + Current quota state and Installed BYOK Configuration routing state MUST be + presented separately. An active or `past_due` Coding Plan remains visible + and billable, and its current quota remains queryable, after its Installed + BYOK Configuration is replaced, disabled, or deleted. + Before update, disable, or delete, `/byok` MUST warn that routing changes do not cancel or pause Token Plan Plus billing and cancellation is managed in Subscription Center; customer surfaces MUST NOT include saved raw-key @@ -463,6 +476,11 @@ not yet enforced in the current codebase: ## Changelog +### 2026-06-19 -- Current Coding Plan quota presentation + +- Allowed authenticated Kilo clients to reuse current personal subscription data without moving billing history out of Subscription Center. +- Kept provider quota authorization on the retained Managed Plan Credential and independent from current BYOK routing state. + ### 2026-06-05 -- KiloClaw final Commit continuation - Replaced two-way post-cutoff plan switching with explicit final Commit continuation into lineage-priced Standard. diff --git a/apps/web/src/app/api/trpc/[trpc]/route.test.ts b/apps/web/src/app/api/trpc/[trpc]/route.test.ts new file mode 100644 index 0000000000..3fde2f49ec --- /dev/null +++ b/apps/web/src/app/api/trpc/[trpc]/route.test.ts @@ -0,0 +1,47 @@ +import { db } from '@/lib/drizzle'; +import { generateApiToken } from '@/lib/tokens'; +import { insertTestUser } from '@/tests/helpers/user.helper'; +import { kilocode_users } from '@kilocode/db/schema'; +import { eq } from 'drizzle-orm'; + +let mockRequestHeaders = new Headers(); + +jest.mock('next/headers', () => ({ + headers: jest.fn(async () => mockRequestHeaders), + cookies: jest.fn(async () => ({ + get: jest.fn(), + getAll: jest.fn(() => []), + set: jest.fn(), + })), +})); + +describe('Cloud tRPC route bearer authentication', () => { + it('serves existing procedures through the existing /api/trpc transport', async () => { + const user = await insertTestUser({ api_token_pepper: crypto.randomUUID() }); + const token = generateApiToken(user); + mockRequestHeaders = new Headers({ Authorization: `Bearer ${token}` }); + const { GET } = await import('./route'); + + const response = await GET( + new Request('http://localhost/api/trpc/user.getAutoTopUpPaymentMethod', { + headers: mockRequestHeaders, + }) + ); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(body).toEqual({ + result: { + data: { + enabled: false, + amountCents: 5000, + thresholdCents: 500, + configured: false, + paymentMethod: null, + }, + }, + }); + + await db.delete(kilocode_users).where(eq(kilocode_users.id, user.id)); + }); +}); diff --git a/apps/web/src/components/payment/AutoTopUpToggle.tsx b/apps/web/src/components/payment/AutoTopUpToggle.tsx index 0ef16d1987..b0accc6d05 100644 --- a/apps/web/src/components/payment/AutoTopUpToggle.tsx +++ b/apps/web/src/components/payment/AutoTopUpToggle.tsx @@ -31,6 +31,10 @@ import { } from '@/lib/autoTopUpConstants'; import { formatCents, formatPaymentMethodDescription } from '@/lib/utils'; +function isAutoTopUpAmount(value: number): value is AutoTopUpAmountCents { + return AUTO_TOP_UP_AMOUNTS_CENTS.some(amount => amount === value); +} + export function AutoTopUpToggle() { const [configureModalOpen, setConfigureModalOpen] = useState(false); const trpc = useTRPC(); @@ -40,13 +44,16 @@ export function AutoTopUpToggle() { }); const enabled = autoTopUpInfo?.enabled ?? false; const currentAmount = autoTopUpInfo?.amountCents ?? DEFAULT_AUTO_TOP_UP_AMOUNT_CENTS; + const selectableAmount = isAutoTopUpAmount(currentAmount) + ? currentAmount + : DEFAULT_AUTO_TOP_UP_AMOUNT_CENTS; const paymentMethodInfo = autoTopUpInfo?.paymentMethod; - const [selectedAmount, setSelectedAmount] = useState(currentAmount); + const [selectedAmount, setSelectedAmount] = useState(selectableAmount); // Sync selectedAmount when modal opens (to pick up server value) const handleOpenChange = (open: boolean) => { if (open) { - setSelectedAmount(currentAmount); + setSelectedAmount(selectableAmount); } setConfigureModalOpen(open); }; diff --git a/apps/web/src/lib/autoTopUpConstants.ts b/apps/web/src/lib/autoTopUpConstants.ts index e210baa142..dd66af2dab 100644 --- a/apps/web/src/lib/autoTopUpConstants.ts +++ b/apps/web/src/lib/autoTopUpConstants.ts @@ -2,6 +2,7 @@ import * as z from 'zod'; // Personal auto-top-up settings export const AUTO_TOP_UP_THRESHOLD_DOLLARS = 5; +export const AUTO_TOP_UP_THRESHOLD_CENTS = AUTO_TOP_UP_THRESHOLD_DOLLARS * 100; export const AUTO_TOP_UP_AMOUNTS_CENTS = [2000, 5000, 10000] as const; export const DEFAULT_AUTO_TOP_UP_AMOUNT_CENTS: AutoTopUpAmountCents = 5000; export const AutoTopUpAmountCentsSchema = z.union(AUTO_TOP_UP_AMOUNTS_CENTS.map(n => z.literal(n))); diff --git a/apps/web/src/lib/coding-plans/minimax-usage.test.ts b/apps/web/src/lib/coding-plans/minimax-usage.test.ts new file mode 100644 index 0000000000..bccdbdf926 --- /dev/null +++ b/apps/web/src/lib/coding-plans/minimax-usage.test.ts @@ -0,0 +1,116 @@ +import { getMiniMaxUsage, MiniMaxUsageError } from '@/lib/coding-plans/minimax-usage'; + +const API_KEY = 'sk-cp-managed-secret'; + +function payload(overrides: Record = {}) { + return { + base_resp: { status_code: 0, status_msg: 'provider message' }, + model_remains: [ + { + model_name: 'general', + current_interval_remaining_percent: 83, + current_interval_status: 1, + end_time: 1_781_280_000_000, + current_weekly_remaining_percent: 72, + current_weekly_status: 1, + weekly_end_time: 1_781_884_800_000, + unknown_secret: 'strip me', + }, + ], + unknown_top_level: 'strip me', + ...overrides, + }; +} + +function jsonResponse(body: unknown, init?: ResponseInit) { + return new Response(JSON.stringify(body), { + status: 200, + headers: { 'content-type': 'application/json' }, + ...init, + }); +} + +afterEach(() => { + jest.restoreAllMocks(); +}); + +describe('MiniMax managed usage transport', () => { + it('uses the fixed endpoint and returns only allowlisted native fields', async () => { + const request = jest.spyOn(global, 'fetch').mockResolvedValue(jsonResponse(payload())); + + const result = await getMiniMaxUsage(API_KEY); + + expect(request).toHaveBeenCalledWith( + 'https://api.minimax.io/v1/token_plan/remains', + expect.objectContaining({ + method: 'GET', + cache: 'no-store', + redirect: 'error', + signal: expect.any(AbortSignal), + headers: { + Accept: 'application/json', + Authorization: `Bearer ${API_KEY}`, + }, + }) + ); + expect(result).toEqual({ + base_resp: { status_code: 0 }, + model_remains: [ + { + model_name: 'general', + current_interval_remaining_percent: 83, + current_interval_status: 1, + end_time: 1_781_280_000_000, + current_weekly_remaining_percent: 72, + current_weekly_status: 1, + weekly_end_time: 1_781_884_800_000, + }, + ], + }); + expect(JSON.stringify(result)).not.toContain('provider message'); + expect(JSON.stringify(result)).not.toContain('unknown_secret'); + }); + + it('rejects declared and streamed oversized responses', async () => { + jest + .spyOn(global, 'fetch') + .mockResolvedValueOnce( + new Response('{}', { status: 200, headers: { 'content-length': String(64 * 1024 + 1) } }) + ) + .mockResolvedValueOnce(new Response('x'.repeat(64 * 1024 + 1), { status: 200 })); + + await expect(getMiniMaxUsage(API_KEY)).rejects.toMatchObject({ code: 'too_large' }); + await expect(getMiniMaxUsage(API_KEY)).rejects.toMatchObject({ code: 'too_large' }); + }); + + it.each([ + ['http', new Response('raw upstream body', { status: 429 })], + ['invalid_json', new Response('raw invalid json', { status: 200 })], + ['invalid_schema', jsonResponse({ base_resp: { status_code: 0 }, model_remains: 'wrong' })], + [ + 'application', + jsonResponse(payload({ base_resp: { status_code: 1004, status_msg: 'secret' } })), + ], + ] as const)('maps %s failures without exposing provider data', async (code, response) => { + jest.spyOn(global, 'fetch').mockResolvedValue(response); + + await expect(getMiniMaxUsage(API_KEY)).rejects.toEqual( + expect.objectContaining({ + code, + message: 'MiniMax usage is temporarily unavailable.', + }) + ); + }); + + it('maps request failures to a safe network error', async () => { + jest.spyOn(global, 'fetch').mockRejectedValue(new Error(`network body ${API_KEY}`)); + + const error = await getMiniMaxUsage(API_KEY).catch(value => value); + expect(error).toBeInstanceOf(MiniMaxUsageError); + expect(error).toMatchObject({ + code: 'network', + message: 'MiniMax usage is temporarily unavailable.', + }); + expect(JSON.stringify(error)).not.toContain(API_KEY); + }); +}); diff --git a/apps/web/src/lib/coding-plans/minimax-usage.ts b/apps/web/src/lib/coding-plans/minimax-usage.ts new file mode 100644 index 0000000000..1c7b78dbd0 --- /dev/null +++ b/apps/web/src/lib/coding-plans/minimax-usage.ts @@ -0,0 +1,139 @@ +import 'server-only'; + +import * as z from 'zod'; + +const MINIMAX_USAGE_URL = 'https://api.minimax.io/v1/token_plan/remains'; +const MINIMAX_USAGE_TIMEOUT_MS = 5_000; +const MINIMAX_USAGE_MAX_BYTES = 64 * 1024; + +const NativePercentSchema = z.number().finite().min(0).max(100); +const NativeIntegerSchema = z.number().int().safe(); + +const MiniMaxModelRemainsSchema = z.object({ + model_name: z.string().min(1).max(128), + current_interval_total_count: NativeIntegerSchema.nonnegative().optional(), + current_interval_usage_count: NativeIntegerSchema.nonnegative().optional(), + start_time: NativeIntegerSchema.nonnegative().optional(), + end_time: NativeIntegerSchema.nonnegative().optional(), + remains_time: NativeIntegerSchema.nonnegative().optional(), + interval_boost_permill: NativeIntegerSchema.nonnegative().optional(), + interval_boost_permille: NativeIntegerSchema.nonnegative().optional(), + current_interval_remaining_percent: NativePercentSchema.optional(), + current_interval_status: NativeIntegerSchema.optional(), + current_weekly_total_count: NativeIntegerSchema.nonnegative().optional(), + current_weekly_usage_count: NativeIntegerSchema.nonnegative().optional(), + weekly_start_time: NativeIntegerSchema.nonnegative().optional(), + weekly_end_time: NativeIntegerSchema.nonnegative().optional(), + weekly_remains_time: NativeIntegerSchema.nonnegative().optional(), + weekly_boost_permill: NativeIntegerSchema.nonnegative().optional(), + weekly_boost_permille: NativeIntegerSchema.nonnegative().optional(), + current_weekly_remaining_percent: NativePercentSchema.optional(), + current_weekly_status: NativeIntegerSchema.optional(), +}); + +export const MiniMaxUsageNativeSchema = z.object({ + base_resp: z.object({ + status_code: NativeIntegerSchema, + }), + model_remains: z.array(MiniMaxModelRemainsSchema).max(64), +}); + +export type MiniMaxUsageNative = z.infer; + +type MiniMaxUsageErrorCode = + | 'network' + | 'http' + | 'too_large' + | 'invalid_json' + | 'invalid_schema' + | 'application'; + +export class MiniMaxUsageError extends Error { + readonly code: MiniMaxUsageErrorCode; + + constructor(code: MiniMaxUsageErrorCode) { + super('MiniMax usage is temporarily unavailable.'); + this.name = 'MiniMaxUsageError'; + this.code = code; + } +} + +async function readBoundedText(response: Response): Promise { + const declared = Number(response.headers.get('content-length')); + if (Number.isFinite(declared) && declared > MINIMAX_USAGE_MAX_BYTES) { + throw new MiniMaxUsageError('too_large'); + } + + if (!response.body) { + const buffer = await response.arrayBuffer(); + if (buffer.byteLength > MINIMAX_USAGE_MAX_BYTES) { + throw new MiniMaxUsageError('too_large'); + } + return new TextDecoder().decode(buffer); + } + + const reader = response.body.getReader(); + const chunks: Uint8Array[] = []; + let size = 0; + + while (true) { + const chunk = await reader.read(); + if (chunk.done) break; + if (!chunk.value) continue; + + size += chunk.value.byteLength; + if (size > MINIMAX_USAGE_MAX_BYTES) { + await reader.cancel().catch(() => undefined); + throw new MiniMaxUsageError('too_large'); + } + chunks.push(chunk.value); + } + + const body = new Uint8Array(size); + let offset = 0; + for (const chunk of chunks) { + body.set(chunk, offset); + offset += chunk.byteLength; + } + return new TextDecoder().decode(body); +} + +export async function getMiniMaxUsage(apiKey: string): Promise { + const response = await fetch(MINIMAX_USAGE_URL, { + method: 'GET', + headers: { + Accept: 'application/json', + Authorization: `Bearer ${apiKey}`, + }, + cache: 'no-store', + redirect: 'error', + signal: AbortSignal.timeout(MINIMAX_USAGE_TIMEOUT_MS), + }).catch(() => { + throw new MiniMaxUsageError('network'); + }); + + if (!response.ok) { + response.body?.cancel().catch(() => undefined); + throw new MiniMaxUsageError('http'); + } + + const text = await readBoundedText(response).catch(error => { + if (error instanceof MiniMaxUsageError) throw error; + throw new MiniMaxUsageError('network'); + }); + const json = (() => { + try { + return JSON.parse(text); + } catch { + throw new MiniMaxUsageError('invalid_json'); + } + })(); + const result = MiniMaxUsageNativeSchema.safeParse(json); + if (!result.success) { + throw new MiniMaxUsageError('invalid_schema'); + } + if (result.data.base_resp.status_code !== 0) { + throw new MiniMaxUsageError('application'); + } + return result.data; +} diff --git a/apps/web/src/routers/coding-plans-router.test.ts b/apps/web/src/routers/coding-plans-router.test.ts index 5915157732..e465936269 100644 --- a/apps/web/src/routers/coding-plans-router.test.ts +++ b/apps/web/src/routers/coding-plans-router.test.ts @@ -23,7 +23,25 @@ function inventoryEntry(key: string) { return `${key}::minimax-plan-${crypto.randomUUID()}`; } +function usageResponse() { + return new Response( + JSON.stringify({ + base_resp: { status_code: 0, status_msg: 'success' }, + model_remains: [ + { + model_name: 'general', + current_interval_remaining_percent: 80, + current_interval_status: 1, + end_time: 1_781_280_000_000, + }, + ], + }), + { status: 200, headers: { 'content-type': 'application/json' } } + ); +} + afterEach(async () => { + jest.restoreAllMocks(); await db.delete(coding_plan_availability_intents); await db.delete(coding_plan_terms); await db.delete(coding_plan_subscriptions); @@ -224,6 +242,174 @@ describe('coding plans router', () => { ).rejects.toThrow('Coding Plan subscription not found.'); }); + it('authorizes managed usage through the original inventory assignment after BYOK changes', async () => { + const managedKey = `sk-cp-managed-${crypto.randomUUID()}`; + const owner = await insertTestUser({ + total_microdollars_acquired: COST_MICRODOLLARS, + microdollars_used: 0, + }); + const otherUser = await insertTestUser(); + await uploadKeysToInventory(PLAN_ID, [inventoryEntry(managedKey)], { + validateCredential: async () => true, + }); + const ownerCaller = await createCallerForUser(owner.id); + const otherCaller = await createCallerForUser(otherUser.id); + const activation = await ownerCaller.codingPlans.subscribe({ + planId: PLAN_ID, + idempotencyKey: 'managed-usage', + }); + const request = jest.spyOn(global, 'fetch').mockImplementation(async () => usageResponse()); + + const active = await ownerCaller.codingPlans.getUsage({ + subscriptionId: activation.subscriptionId, + }); + expect(active).toEqual({ + subscriptionId: activation.subscriptionId, + providerId: 'minimax', + region: 'global', + fetchedAt: expect.stringContaining('T'), + native: { + base_resp: { status_code: 0 }, + model_remains: [ + { + model_name: 'general', + current_interval_remaining_percent: 80, + current_interval_status: 1, + end_time: 1_781_280_000_000, + }, + ], + }, + }); + expect(JSON.stringify(active)).not.toContain(managedKey); + await expect( + otherCaller.codingPlans.getUsage({ subscriptionId: activation.subscriptionId }) + ).rejects.toMatchObject({ + code: 'NOT_FOUND', + message: 'Coding Plan subscription not found.', + }); + + const [installed] = await ownerCaller.byok.list({}); + await ownerCaller.byok.setEnabled({ id: installed.id, is_enabled: false }); + await ownerCaller.codingPlans.getUsage({ subscriptionId: activation.subscriptionId }); + await ownerCaller.byok.update({ id: installed.id, api_key: 'replacement-user-key' }); + await ownerCaller.codingPlans.getUsage({ subscriptionId: activation.subscriptionId }); + await ownerCaller.byok.delete({ id: installed.id }); + await ownerCaller.codingPlans.getUsage({ subscriptionId: activation.subscriptionId }); + + expect(request).toHaveBeenCalledTimes(4); + for (const call of request.mock.calls) { + expect(call[0]).toBe('https://api.minimax.io/v1/token_plan/remains'); + expect(call[1]?.headers).toEqual( + expect.objectContaining({ Authorization: `Bearer ${managedKey}` }) + ); + } + }); + + it('keeps past-due and pending-cancellation subscriptions eligible but rejects canceled plans', async () => { + const owner = await insertTestUser({ + total_microdollars_acquired: COST_MICRODOLLARS, + microdollars_used: 0, + }); + await uploadKeysToInventory(PLAN_ID, [inventoryEntry(`sk-cp-state-${crypto.randomUUID()}`)], { + validateCredential: async () => true, + }); + const caller = await createCallerForUser(owner.id); + const activation = await caller.codingPlans.subscribe({ + planId: PLAN_ID, + idempotencyKey: 'usage-states', + }); + jest.spyOn(global, 'fetch').mockImplementation(async () => usageResponse()); + + await db + .update(coding_plan_subscriptions) + .set({ status: 'past_due', cancel_at_period_end: true }) + .where(eq(coding_plan_subscriptions.id, activation.subscriptionId)); + await expect( + caller.codingPlans.getUsage({ subscriptionId: activation.subscriptionId }) + ).resolves.toMatchObject({ subscriptionId: activation.subscriptionId }); + + await db + .update(coding_plan_subscriptions) + .set({ status: 'canceled' }) + .where(eq(coding_plan_subscriptions.id, activation.subscriptionId)); + await expect( + caller.codingPlans.getUsage({ subscriptionId: activation.subscriptionId }) + ).rejects.toMatchObject({ + code: 'PRECONDITION_FAILED', + message: 'Coding Plan subscription is not eligible for usage.', + }); + }); + + it('fails safely for a corrupt inventory assignment without affecting database-only reads', async () => { + const owner = await insertTestUser({ + total_microdollars_acquired: COST_MICRODOLLARS, + microdollars_used: 0, + }); + await uploadKeysToInventory(PLAN_ID, [inventoryEntry(`sk-cp-corrupt-${crypto.randomUUID()}`)], { + validateCredential: async () => true, + }); + const caller = await createCallerForUser(owner.id); + const activation = await caller.codingPlans.subscribe({ + planId: PLAN_ID, + idempotencyKey: 'corrupt-assignment', + }); + const [subscription] = await db + .select({ inventoryId: coding_plan_subscriptions.key_inventory_id }) + .from(coding_plan_subscriptions) + .where(eq(coding_plan_subscriptions.id, activation.subscriptionId)); + if (!subscription.inventoryId) throw new Error('Expected assigned inventory'); + await db + .update(coding_plan_key_inventory) + .set({ assigned_to_user_id: null }) + .where(eq(coding_plan_key_inventory.id, subscription.inventoryId)); + const request = jest.spyOn(global, 'fetch'); + + await expect( + caller.codingPlans.getUsage({ subscriptionId: activation.subscriptionId }) + ).rejects.toMatchObject({ + code: 'INTERNAL_SERVER_ERROR', + message: 'Coding Plan usage is unavailable.', + }); + expect(request).not.toHaveBeenCalled(); + await expect(caller.codingPlans.listSubscriptions()).resolves.toHaveLength(1); + await expect( + caller.codingPlans.getSubscriptionDetail({ subscriptionId: activation.subscriptionId }) + ).resolves.toMatchObject({ id: activation.subscriptionId, status: 'active' }); + }); + + it('isolates upstream usage failures from subscription metadata', async () => { + const owner = await insertTestUser({ + total_microdollars_acquired: COST_MICRODOLLARS, + microdollars_used: 0, + }); + await uploadKeysToInventory( + PLAN_ID, + [inventoryEntry(`sk-cp-upstream-${crypto.randomUUID()}`)], + { + validateCredential: async () => true, + } + ); + const caller = await createCallerForUser(owner.id); + const activation = await caller.codingPlans.subscribe({ + planId: PLAN_ID, + idempotencyKey: 'upstream-failure', + }); + jest + .spyOn(global, 'fetch') + .mockResolvedValue(new Response('raw provider failure', { status: 503 })); + + await expect( + caller.codingPlans.getUsage({ subscriptionId: activation.subscriptionId }) + ).rejects.toMatchObject({ + code: 'BAD_GATEWAY', + message: 'MiniMax usage is temporarily unavailable.', + }); + await expect(caller.codingPlans.listSubscriptions()).resolves.toHaveLength(1); + await expect( + caller.codingPlans.getSubscriptionDetail({ subscriptionId: activation.subscriptionId }) + ).resolves.toMatchObject({ id: activation.subscriptionId }); + }); + it('rejects a second live purchase instead of creating a prepaid extension', async () => { const owner = await insertTestUser({ total_microdollars_acquired: COST_MICRODOLLARS * 2, diff --git a/apps/web/src/routers/coding-plans-router.ts b/apps/web/src/routers/coding-plans-router.ts index cb32e20121..4d30bb4a84 100644 --- a/apps/web/src/routers/coding-plans-router.ts +++ b/apps/web/src/routers/coding-plans-router.ts @@ -2,6 +2,7 @@ import { and, desc, eq } from 'drizzle-orm'; import { TRPCError } from '@trpc/server'; import * as z from 'zod'; +import { decryptApiKey } from '@/lib/ai-gateway/byok/encryption'; import { cancelCodingPlanSubscription, getAvailableCodingPlanIds, @@ -12,6 +13,11 @@ import { terminateCodingPlanImmediately, uploadKeysToInventory, } from '@/lib/coding-plans'; +import { + getMiniMaxUsage, + MiniMaxUsageError, + MiniMaxUsageNativeSchema, +} from '@/lib/coding-plans/minimax-usage'; import { listManualCredentialRevocations, markCredentialManuallyRevoked, @@ -23,10 +29,12 @@ import { getCodingPlanCatalog, getCodingPlanPrice, } from '@/lib/coding-plans/pricing'; +import { BYOK_ENCRYPTION_KEY } from '@/lib/config.server'; import { db } from '@/lib/drizzle'; import { billingHistoryResponseSchema } from '@/lib/subscriptions/subscription-center'; import { baseProcedure, adminProcedure, createTRPCRouter } from '@/lib/trpc/init'; import { + coding_plan_key_inventory, coding_plan_subscriptions, coding_plan_terms, credit_transactions, @@ -38,11 +46,24 @@ const BillingHistoryInputSchema = z.object({ subscriptionId: SubscriptionIdSchema, cursor: z.string().optional(), }); +const EncryptedApiKeySchema = z.object({ + iv: z.string().min(1), + data: z.string().min(1), + authTag: z.string().min(1), +}); +const CodingPlanUsageOutputSchema = z.object({ + subscriptionId: SubscriptionIdSchema, + providerId: z.literal('minimax'), + region: z.literal('global'), + fetchedAt: z.iso.datetime(), + native: MiniMaxUsageNativeSchema, +}); const codingPlanSubscriptionColumns = { id: coding_plan_subscriptions.id, planId: coding_plan_subscriptions.plan_id, providerId: coding_plan_subscriptions.provider_id, + keyInventoryId: coding_plan_subscriptions.key_inventory_id, installedByokKeyId: coding_plan_subscriptions.installed_byok_key_id, status: coding_plan_subscriptions.status, costMicrodollars: coding_plan_subscriptions.cost_microdollars, @@ -161,6 +182,84 @@ export const codingPlansRouter = createTRPCRouter({ return toCodingPlanSubscriptionView(subscription); }), + getUsage: baseProcedure + .input(z.object({ subscriptionId: SubscriptionIdSchema })) + .output(CodingPlanUsageOutputSchema) + .query(async ({ input, ctx }) => { + const subscription = await getOwnedSubscription(ctx.user.id, input.subscriptionId); + if ( + !['active', 'past_due'].includes(subscription.status) || + subscription.planId !== 'minimax-token-plan-plus' || + subscription.providerId !== 'minimax' + ) { + throw new TRPCError({ + code: 'PRECONDITION_FAILED', + message: 'Coding Plan subscription is not eligible for usage.', + }); + } + if (!subscription.keyInventoryId) { + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: 'Coding Plan usage is unavailable.', + }); + } + + const [assignment] = await db + .select({ + planId: coding_plan_key_inventory.plan_id, + providerId: coding_plan_key_inventory.provider_id, + status: coding_plan_key_inventory.status, + assignedToUserId: coding_plan_key_inventory.assigned_to_user_id, + encryptedApiKey: coding_plan_key_inventory.encrypted_api_key, + }) + .from(coding_plan_key_inventory) + .where(eq(coding_plan_key_inventory.id, subscription.keyInventoryId)) + .limit(1); + const encrypted = EncryptedApiKeySchema.safeParse(assignment?.encryptedApiKey); + if ( + !assignment || + assignment.status !== 'assigned' || + assignment.assignedToUserId !== ctx.user.id || + assignment.planId !== subscription.planId || + assignment.providerId !== subscription.providerId || + !encrypted.success + ) { + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: 'Coding Plan usage is unavailable.', + }); + } + + const apiKey = (() => { + try { + return decryptApiKey(encrypted.data, BYOK_ENCRYPTION_KEY); + } catch { + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: 'Coding Plan usage is unavailable.', + }); + } + })(); + + const native = await getMiniMaxUsage(apiKey).catch(error => { + if (error instanceof MiniMaxUsageError) { + throw new TRPCError({ code: 'BAD_GATEWAY', message: error.message }); + } + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: 'Coding Plan usage is unavailable.', + }); + }); + + return { + subscriptionId: subscription.id, + providerId: 'minimax' as const, + region: 'global' as const, + fetchedAt: new Date().toISOString(), + native, + }; + }), + getBillingHistory: baseProcedure .input(BillingHistoryInputSchema) .output(billingHistoryResponseSchema) diff --git a/apps/web/src/routers/user-router.test.ts b/apps/web/src/routers/user-router.test.ts index 5585567d49..95119e8ee3 100644 --- a/apps/web/src/routers/user-router.test.ts +++ b/apps/web/src/routers/user-router.test.ts @@ -1,10 +1,17 @@ import { createCallerForUser } from '@/routers/test-utils'; import { db } from '@/lib/drizzle'; -import { kilocode_users } from '@kilocode/db/schema'; +import { retrievePaymentMethodInfo } from '@/lib/stripePaymentMethodInfo'; +import { auto_top_up_configs, kilocode_users } from '@kilocode/db/schema'; import { eq } from 'drizzle-orm'; import { insertTestUser } from '@/tests/helpers/user.helper'; import type { User } from '@kilocode/db/schema'; +jest.mock('@/lib/stripePaymentMethodInfo', () => ({ + retrievePaymentMethodInfo: jest.fn(), +})); + +const mockRetrievePaymentMethodInfo = jest.mocked(retrievePaymentMethodInfo); + let testUser: User; let surveyTestUser: User; let skipTestUser: User; @@ -131,6 +138,89 @@ describe('user router - updateProfile', () => { }); }); +describe('user router - getAutoTopUpPaymentMethod', () => { + const users: string[] = []; + + beforeEach(() => { + mockRetrievePaymentMethodInfo.mockResolvedValue(null); + }); + + afterEach(async () => { + mockRetrievePaymentMethodInfo.mockReset(); + for (const userId of users.splice(0)) { + await db.delete(auto_top_up_configs).where(eq(auto_top_up_configs.owned_by_user_id, userId)); + await db.delete(kilocode_users).where(eq(kilocode_users.id, userId)); + } + }); + + it('reports an unconfigured account with the server-owned threshold', async () => { + const user = await insertTestUser({ auto_top_up_enabled: true }); + users.push(user.id); + const caller = await createCallerForUser(user.id); + + await expect(caller.user.getAutoTopUpPaymentMethod()).resolves.toEqual({ + enabled: true, + amountCents: 5000, + thresholdCents: 500, + configured: false, + paymentMethod: null, + }); + expect(mockRetrievePaymentMethodInfo).toHaveBeenCalledWith(undefined); + }); + + it('distinguishes a configured account from an unavailable Stripe lookup', async () => { + const user = await insertTestUser(); + users.push(user.id); + await db.insert(auto_top_up_configs).values({ + owned_by_user_id: user.id, + stripe_payment_method_id: 'pm_missing', + amount_cents: 2500, + }); + mockRetrievePaymentMethodInfo.mockResolvedValue(null); + const caller = await createCallerForUser(user.id); + + await expect(caller.user.getAutoTopUpPaymentMethod()).resolves.toEqual({ + enabled: false, + amountCents: 2500, + thresholdCents: 500, + configured: true, + paymentMethod: null, + }); + }); + + it('retains legacy browser payment fields alongside the additive state', async () => { + const user = await insertTestUser({ auto_top_up_enabled: true }); + users.push(user.id); + await db.insert(auto_top_up_configs).values({ + owned_by_user_id: user.id, + stripe_payment_method_id: 'pm_safe', + amount_cents: 2000, + }); + mockRetrievePaymentMethodInfo.mockResolvedValue({ + type: 'card', + last4: '4242', + brand: 'visa', + linkEmail: null, + stripePaymentMethodId: 'pm_safe', + }); + const caller = await createCallerForUser(user.id); + + await expect(caller.user.getAutoTopUpPaymentMethod()).resolves.toEqual({ + enabled: true, + amountCents: 2000, + thresholdCents: 500, + configured: true, + paymentMethod: { + type: 'card', + last4: '4242', + brand: 'visa', + linkEmail: null, + stripePaymentMethodId: 'pm_safe', + }, + }); + }); +}); + describe('user router - submitCustomerSource', () => { beforeAll(async () => { surveyTestUser = await insertTestUser({ diff --git a/apps/web/src/routers/user-router.ts b/apps/web/src/routers/user-router.ts index 5e62e1e8d8..1b1eb313ec 100644 --- a/apps/web/src/routers/user-router.ts +++ b/apps/web/src/routers/user-router.ts @@ -28,9 +28,9 @@ import { AuthProviderIdSchema } from '@/lib/auth/provider-metadata'; import { AUTOCOMPLETE_MODEL } from '@/lib/constants'; import { ensureOrganizationAccess } from '@/routers/organizations/utils'; import { createAutoTopUpSetupCheckoutSession } from '@/lib/stripe'; -import { retrievePaymentMethodInfo } from '@/lib/stripePaymentMethodInfo'; -import type { AutoTopUpAmountCents } from '@/lib/autoTopUpConstants'; +import { retrievePaymentMethodInfo, type PaymentMethodInfo } from '@/lib/stripePaymentMethodInfo'; import { + AUTO_TOP_UP_THRESHOLD_CENTS, AutoTopUpAmountCentsSchema, DEFAULT_AUTO_TOP_UP_AMOUNT_CENTS, } from '@/lib/autoTopUpConstants'; @@ -75,6 +75,22 @@ const AutocompleteMetricsOutputSchema = z.object({ tokens: z.number(), }); +const AutoTopUpPaymentMethodOutputSchema = z.object({ + enabled: z.boolean(), + amountCents: z.number().int().nonnegative(), + thresholdCents: z.number().int().nonnegative(), + configured: z.boolean(), + paymentMethod: z + .object({ + type: z.custom(value => typeof value === 'string'), + last4: z.string().nullable(), + brand: z.string().nullable(), + linkEmail: z.string().nullable(), + stripePaymentMethodId: z.string(), + }) + .nullable(), +}); + const LinkAuthProviderInputSchema = z.object({ provider: AuthProviderIdSchema, }); @@ -494,19 +510,22 @@ export const userRouter = createTRPCRouter({ return { redirectUrl }; }), - getAutoTopUpPaymentMethod: baseProcedure.query(async ({ ctx }) => { - const config = await db.query.auto_top_up_configs.findFirst({ - where: eq(auto_top_up_configs.owned_by_user_id, ctx.user.id), - }); - const paymentMethod = await retrievePaymentMethodInfo(config?.stripe_payment_method_id); - const amountCents = - (config?.amount_cents as AutoTopUpAmountCents) ?? DEFAULT_AUTO_TOP_UP_AMOUNT_CENTS; - return { - enabled: ctx.user.auto_top_up_enabled, - amountCents, - paymentMethod, - }; - }), + getAutoTopUpPaymentMethod: baseProcedure + .output(AutoTopUpPaymentMethodOutputSchema) + .query(async ({ ctx }) => { + const config = await db.query.auto_top_up_configs.findFirst({ + where: eq(auto_top_up_configs.owned_by_user_id, ctx.user.id), + }); + const paymentMethod = await retrievePaymentMethodInfo(config?.stripe_payment_method_id); + const amountCents = config?.amount_cents ?? DEFAULT_AUTO_TOP_UP_AMOUNT_CENTS; + return { + enabled: ctx.user.auto_top_up_enabled, + amountCents, + thresholdCents: AUTO_TOP_UP_THRESHOLD_CENTS, + configured: config !== undefined, + paymentMethod, + }; + }), updateAutoTopUpAmount: baseProcedure .input(z.object({ amountCents: AutoTopUpAmountCentsSchema })) From 76f812937ba2e12a82a7bd0dd0098ad99159ad73 Mon Sep 17 00:00:00 2001 From: Josh Lambert Date: Tue, 23 Jun 2026 20:58:08 -0400 Subject: [PATCH 2/3] test(coding-plans): remove redundant transport coverage --- .../web/src/app/api/trpc/[trpc]/route.test.ts | 47 ------------------- 1 file changed, 47 deletions(-) delete mode 100644 apps/web/src/app/api/trpc/[trpc]/route.test.ts diff --git a/apps/web/src/app/api/trpc/[trpc]/route.test.ts b/apps/web/src/app/api/trpc/[trpc]/route.test.ts deleted file mode 100644 index 3fde2f49ec..0000000000 --- a/apps/web/src/app/api/trpc/[trpc]/route.test.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { db } from '@/lib/drizzle'; -import { generateApiToken } from '@/lib/tokens'; -import { insertTestUser } from '@/tests/helpers/user.helper'; -import { kilocode_users } from '@kilocode/db/schema'; -import { eq } from 'drizzle-orm'; - -let mockRequestHeaders = new Headers(); - -jest.mock('next/headers', () => ({ - headers: jest.fn(async () => mockRequestHeaders), - cookies: jest.fn(async () => ({ - get: jest.fn(), - getAll: jest.fn(() => []), - set: jest.fn(), - })), -})); - -describe('Cloud tRPC route bearer authentication', () => { - it('serves existing procedures through the existing /api/trpc transport', async () => { - const user = await insertTestUser({ api_token_pepper: crypto.randomUUID() }); - const token = generateApiToken(user); - mockRequestHeaders = new Headers({ Authorization: `Bearer ${token}` }); - const { GET } = await import('./route'); - - const response = await GET( - new Request('http://localhost/api/trpc/user.getAutoTopUpPaymentMethod', { - headers: mockRequestHeaders, - }) - ); - const body = await response.json(); - - expect(response.status).toBe(200); - expect(body).toEqual({ - result: { - data: { - enabled: false, - amountCents: 5000, - thresholdCents: 500, - configured: false, - paymentMethod: null, - }, - }, - }); - - await db.delete(kilocode_users).where(eq(kilocode_users.id, user.id)); - }); -}); From 1576e23d1f24db723a9113ff090c344bb42492fd Mon Sep 17 00:00:00 2001 From: Josh Lambert Date: Tue, 23 Jun 2026 21:15:07 -0400 Subject: [PATCH 3/3] refactor(coding-plans): preserve auto top-up contract --- .../components/payment/AutoTopUpToggle.tsx | 11 +--- apps/web/src/routers/user-router.test.ts | 4 +- apps/web/src/routers/user-router.ts | 50 +++++++------------ 3 files changed, 21 insertions(+), 44 deletions(-) diff --git a/apps/web/src/components/payment/AutoTopUpToggle.tsx b/apps/web/src/components/payment/AutoTopUpToggle.tsx index b0accc6d05..0ef16d1987 100644 --- a/apps/web/src/components/payment/AutoTopUpToggle.tsx +++ b/apps/web/src/components/payment/AutoTopUpToggle.tsx @@ -31,10 +31,6 @@ import { } from '@/lib/autoTopUpConstants'; import { formatCents, formatPaymentMethodDescription } from '@/lib/utils'; -function isAutoTopUpAmount(value: number): value is AutoTopUpAmountCents { - return AUTO_TOP_UP_AMOUNTS_CENTS.some(amount => amount === value); -} - export function AutoTopUpToggle() { const [configureModalOpen, setConfigureModalOpen] = useState(false); const trpc = useTRPC(); @@ -44,16 +40,13 @@ export function AutoTopUpToggle() { }); const enabled = autoTopUpInfo?.enabled ?? false; const currentAmount = autoTopUpInfo?.amountCents ?? DEFAULT_AUTO_TOP_UP_AMOUNT_CENTS; - const selectableAmount = isAutoTopUpAmount(currentAmount) - ? currentAmount - : DEFAULT_AUTO_TOP_UP_AMOUNT_CENTS; const paymentMethodInfo = autoTopUpInfo?.paymentMethod; - const [selectedAmount, setSelectedAmount] = useState(selectableAmount); + const [selectedAmount, setSelectedAmount] = useState(currentAmount); // Sync selectedAmount when modal opens (to pick up server value) const handleOpenChange = (open: boolean) => { if (open) { - setSelectedAmount(selectableAmount); + setSelectedAmount(currentAmount); } setConfigureModalOpen(open); }; diff --git a/apps/web/src/routers/user-router.test.ts b/apps/web/src/routers/user-router.test.ts index 95119e8ee3..b11606c5b9 100644 --- a/apps/web/src/routers/user-router.test.ts +++ b/apps/web/src/routers/user-router.test.ts @@ -174,14 +174,14 @@ describe('user router - getAutoTopUpPaymentMethod', () => { await db.insert(auto_top_up_configs).values({ owned_by_user_id: user.id, stripe_payment_method_id: 'pm_missing', - amount_cents: 2500, + amount_cents: 10000, }); mockRetrievePaymentMethodInfo.mockResolvedValue(null); const caller = await createCallerForUser(user.id); await expect(caller.user.getAutoTopUpPaymentMethod()).resolves.toEqual({ enabled: false, - amountCents: 2500, + amountCents: 10000, thresholdCents: 500, configured: true, paymentMethod: null, diff --git a/apps/web/src/routers/user-router.ts b/apps/web/src/routers/user-router.ts index 1b1eb313ec..efb69ca6df 100644 --- a/apps/web/src/routers/user-router.ts +++ b/apps/web/src/routers/user-router.ts @@ -28,7 +28,8 @@ import { AuthProviderIdSchema } from '@/lib/auth/provider-metadata'; import { AUTOCOMPLETE_MODEL } from '@/lib/constants'; import { ensureOrganizationAccess } from '@/routers/organizations/utils'; import { createAutoTopUpSetupCheckoutSession } from '@/lib/stripe'; -import { retrievePaymentMethodInfo, type PaymentMethodInfo } from '@/lib/stripePaymentMethodInfo'; +import { retrievePaymentMethodInfo } from '@/lib/stripePaymentMethodInfo'; +import type { AutoTopUpAmountCents } from '@/lib/autoTopUpConstants'; import { AUTO_TOP_UP_THRESHOLD_CENTS, AutoTopUpAmountCentsSchema, @@ -75,22 +76,6 @@ const AutocompleteMetricsOutputSchema = z.object({ tokens: z.number(), }); -const AutoTopUpPaymentMethodOutputSchema = z.object({ - enabled: z.boolean(), - amountCents: z.number().int().nonnegative(), - thresholdCents: z.number().int().nonnegative(), - configured: z.boolean(), - paymentMethod: z - .object({ - type: z.custom(value => typeof value === 'string'), - last4: z.string().nullable(), - brand: z.string().nullable(), - linkEmail: z.string().nullable(), - stripePaymentMethodId: z.string(), - }) - .nullable(), -}); - const LinkAuthProviderInputSchema = z.object({ provider: AuthProviderIdSchema, }); @@ -510,22 +495,21 @@ export const userRouter = createTRPCRouter({ return { redirectUrl }; }), - getAutoTopUpPaymentMethod: baseProcedure - .output(AutoTopUpPaymentMethodOutputSchema) - .query(async ({ ctx }) => { - const config = await db.query.auto_top_up_configs.findFirst({ - where: eq(auto_top_up_configs.owned_by_user_id, ctx.user.id), - }); - const paymentMethod = await retrievePaymentMethodInfo(config?.stripe_payment_method_id); - const amountCents = config?.amount_cents ?? DEFAULT_AUTO_TOP_UP_AMOUNT_CENTS; - return { - enabled: ctx.user.auto_top_up_enabled, - amountCents, - thresholdCents: AUTO_TOP_UP_THRESHOLD_CENTS, - configured: config !== undefined, - paymentMethod, - }; - }), + getAutoTopUpPaymentMethod: baseProcedure.query(async ({ ctx }) => { + const config = await db.query.auto_top_up_configs.findFirst({ + where: eq(auto_top_up_configs.owned_by_user_id, ctx.user.id), + }); + const paymentMethod = await retrievePaymentMethodInfo(config?.stripe_payment_method_id); + const amountCents = + (config?.amount_cents as AutoTopUpAmountCents) ?? DEFAULT_AUTO_TOP_UP_AMOUNT_CENTS; + return { + enabled: ctx.user.auto_top_up_enabled, + amountCents, + thresholdCents: AUTO_TOP_UP_THRESHOLD_CENTS, + configured: config !== undefined, + paymentMethod, + }; + }), updateAutoTopUpAmount: baseProcedure .input(z.object({ amountCents: AutoTopUpAmountCentsSchema }))