From a28e2d0dba3e13ef4671f7c46362f4e2ccd57fba Mon Sep 17 00:00:00 2001 From: Anil Kulkarni Date: Fri, 25 Sep 2026 12:18:29 -0700 Subject: [PATCH 1/2] Prevent new signups of Minimax plans --- .specs/coding-plans.md | 8 +- .../coding-plans/CodingPlansGroup.tsx | 3 +- .../billing-lifecycle-cron.test.ts | 85 ++++- apps/web/src/lib/coding-plans/index.test.ts | 298 ++++++++++++------ apps/web/src/lib/coding-plans/index.ts | 14 +- apps/web/src/lib/coding-plans/pricing.ts | 15 + .../src/routers/coding-plans-router.test.ts | 272 +++++++++++----- apps/web/src/routers/coding-plans-router.ts | 12 + 8 files changed, 509 insertions(+), 198 deletions(-) diff --git a/.specs/coding-plans.md b/.specs/coding-plans.md index a890b2d51cf..60bd8dde804 100644 --- a/.specs/coding-plans.md +++ b/.specs/coding-plans.md @@ -46,7 +46,7 @@ The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "S 1.4. A Plan ID **MUST NOT** be treated as the upstream provider identity. Multiple future plans from one upstream provider **MAY** coexist without extending or replacing one another unless their product rules explicitly state otherwise. -1.5. The MiniMax token catalog **MUST** contain Token Plan Plus, Token Plan Max, and Token Plan Ultra. All MiniMax token offerings **MUST** share the ordinary MiniMax BYOK provider ID, `minimax`. +1.5. MiniMax Token Plan Plus, Token Plan Max, and Token Plan Ultra are no longer supported and are being removed from the Plan Catalog. The system **MUST NOT** create a new MiniMax subscription or accept a new MiniMax Availability Notification Intent; customer-facing catalog and purchase surfaces **MUST NOT** offer these plans for a new purchase. A live MiniMax subscription created before closure **MAY** continue under its existing terms while removal is in progress. Plan pricing, naming, and provider ID data for these plans **MAY** remain resolvable for existing subscribers and administrative inventory and remediation surfaces until removal is complete. Any MiniMax token offering that remains resolvable **MUST** continue to share the ordinary MiniMax BYOK provider ID, `minimax`. 1.6. The catalog **MUST** contain BytePlus Enterprise Coding Plan Lite with Plan ID `byteplus-coding-plan-team-lite`, provider ID `byteplus-coding`, a price of $20 in Kilo Credits, and a 30-day billing period. Its displayed limits **MUST** be described as approximately 1,900 requests every 5 hours, 12,000 requests per week, and 24,000 requests per subscription period. The plan's supported model IDs are `dola-seed-2.0-pro`, `dola-seed-2.0-lite`, `dola-seed-2.0-code`, `bytedance-seed-code`, `kimi-k2.5`, `glm-5.1`, `glm-5.2`, `deepseek-v4-flash`, `deepseek-v4-pro`, and `gpt-oss-120b`. Upstream Auto, Kimi-K2-Thinking, and GLM-4.7 **MUST NOT** be exposed. @@ -168,6 +168,12 @@ The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "S 8.4. Current quota responses and logs **MUST NOT** contain Managed Plan Credentials, Provider Management Credentials, authorization headers, raw provider bodies or messages, inventory metadata, Upstream Plan IDs, Upstream Usage IDs, usernames, fingerprints, ciphertext, or provider-native quota fields. Provider responses **MUST** be bounded, validated, and normalized to an explicit non-secret subscription quota-window contract before leaving the Cloud boundary. The initial quota-window contract **MUST NOT** represent monetary balances or purchased-credit balances as subscription quota. +### 2026-09-25 -- MiniMax Token Plan closed to new signups + +- Closed MiniMax Token Plan Plus, Max, and Ultra to new signups and new Availability Notification Intents, superseding 1.5's prior unconditional catalog requirement. +- Confirmed a live MiniMax subscription created before closure continues under its existing terms while Kilo completes removal of MiniMax from the Plan Catalog. +- Administrative inventory, upload, and remediation surfaces retain MiniMax visibility per 4.2 and 8.2 for as long as any MiniMax inventory or subscription remains. + ### 2026-08-11 -- BytePlus Enterprise Coding Plan catalog copy - Renamed BytePlus catalog offerings to Enterprise Coding Plan Lite and Enterprise Coding Plan Pro. diff --git a/apps/web/src/components/subscriptions/coding-plans/CodingPlansGroup.tsx b/apps/web/src/components/subscriptions/coding-plans/CodingPlansGroup.tsx index 561c5df1d08..953892c8ef4 100644 --- a/apps/web/src/components/subscriptions/coding-plans/CodingPlansGroup.tsx +++ b/apps/web/src/components/subscriptions/coding-plans/CodingPlansGroup.tsx @@ -57,7 +57,7 @@ export function CodingPlansGroup({ } | null>(null); const subscriptions = subscriptionQuery.data ?? []; - const catalog = catalogQuery.data ?? []; + const catalog = (catalogQuery.data ?? []).filter(plan => plan.purchasable); const byokKeys = byokQuery.data ?? []; const selectedPlan = catalog.find(plan => plan.planId === subscriptionRequest?.planId) ?? null; const nonTerminalSubscriptions = subscriptions.filter( @@ -322,6 +322,7 @@ type CodingPlanOffer = { features: readonly string[]; availabilityStatus: 'available' | 'sold_out'; notificationRequested: boolean; + purchasable: boolean; }; function CodingPlanAccessNotice({ diff --git a/apps/web/src/lib/coding-plans/billing-lifecycle-cron.test.ts b/apps/web/src/lib/coding-plans/billing-lifecycle-cron.test.ts index 545030bc6ef..a9c03c40587 100644 --- a/apps/web/src/lib/coding-plans/billing-lifecycle-cron.test.ts +++ b/apps/web/src/lib/coding-plans/billing-lifecycle-cron.test.ts @@ -1,6 +1,8 @@ /* eslint-disable drizzle/enforce-delete-with-where */ import { eq } from 'drizzle-orm'; +import { encryptApiKey } from '@/lib/ai-gateway/byok/encryption'; +import { BYOK_ENCRYPTION_KEY } from '@/lib/config.server'; import { processCodingPlanCancellationAtPeriodEnd, processCodingPlanRenewal, @@ -23,7 +25,6 @@ import { jest.mock('@/lib/autoTopUp', () => ({ maybePerformAutoTopUp: jest.fn(async () => undefined), })); -const PLAN_ID = 'minimax-token-plan-plus'; const MAX_PLAN_ID = 'minimax-token-plan-max'; const BYTEPLUS_PLAN_ID = 'byteplus-coding-plan-team-lite'; const BYTEPLUS_PRO_PLAN_ID = 'byteplus-coding-plan-team-pro'; @@ -35,21 +36,22 @@ const dueAt = new Date(Date.now() - 60_000).toISOString(); async function createSubscription( balance = COST_MICRODOLLARS, autoTopUpEnabled = false, - planId: CodingPlanId = PLAN_ID + planId: CodingPlanId = BYTEPLUS_PLAN_ID ) { const user = await insertTestUser({ total_microdollars_acquired: balance, microdollars_used: 0, auto_top_up_enabled: autoTopUpEnabled, }); + const upstreamPlanId = `upstream-plan-${crypto.randomUUID()}`; await uploadKeysToInventory( planId === BYTEPLUS_PLAN_ID || planId === BYTEPLUS_PRO_PLAN_ID ? 'byteplus-coding' : 'minimax', planId, - [`cron-key-${crypto.randomUUID()}::minimax-plan-${crypto.randomUUID()}`], + [`cron-key-${crypto.randomUUID()}::${upstreamPlanId}`], { - validateCredential: async ({ providerId, planId }) => + validateCredential: async ({ providerId }) => providerId === 'byteplus-coding' - ? { valid: true, upstreamUsageId: `seat-${planId}` } + ? { valid: true, upstreamUsageId: `seat-${upstreamPlanId}` } : { valid: true }, } ); @@ -61,6 +63,57 @@ async function createSubscription( return { user, subscriptionId: created.subscriptionId }; } +// MiniMax Token Plans are closed to new signups (see pricing.ts). This seeds a +// pre-existing MiniMax subscription directly, bypassing subscribeToCodingPlan, +// to confirm the billing lifecycle cron still renews a grandfathered MiniMax +// subscriber correctly. +async function createGrandfatheredMiniMaxSubscription(planId: CodingPlanId, cost: number) { + const user = await insertTestUser({ + total_microdollars_acquired: cost * 2, + microdollars_used: 0, + }); + const [inventory] = await db + .insert(coding_plan_key_inventory) + .values({ + plan_id: planId, + provider_id: 'minimax', + upstream_plan_id: `minimax-grandfathered-${crypto.randomUUID()}`, + encrypted_api_key: encryptApiKey(`grandfathered-${crypto.randomUUID()}`, BYOK_ENCRYPTION_KEY), + credential_fingerprint: crypto.randomUUID(), + status: 'assigned', + assigned_to_user_id: user.id, + assigned_at: new Date().toISOString(), + }) + .returning(); + const [installedByok] = await db + .insert(byok_api_keys) + .values({ + kilo_user_id: user.id, + provider_id: 'minimax', + encrypted_api_key: inventory.encrypted_api_key!, + management_source: 'coding_plan', + created_by: user.id, + }) + .returning(); + const [subscription] = await db + .insert(coding_plan_subscriptions) + .values({ + user_id: user.id, + plan_id: planId, + provider_id: 'minimax', + key_inventory_id: inventory.id, + installed_byok_key_id: installedByok.id, + status: 'active', + cost_microdollars: cost, + billing_period_days: 30, + current_period_start: new Date().toISOString(), + current_period_end: dueAt, + credit_renewal_at: dueAt, + }) + .returning(); + return { user, subscriptionId: subscription.id }; +} + afterEach(async () => { jest.mocked(maybePerformAutoTopUp).mockClear(); await db.delete(coding_plan_terms); @@ -94,25 +147,29 @@ describe('Coding Plan billing lifecycle cron', () => { createdAt: credit_transactions.created_at, }) .from(credit_transactions) - .where(eq(credit_transactions.description, 'Coding plan renewal: MiniMax Token Plan Plus')); + .where( + eq( + credit_transactions.description, + 'Coding plan renewal: BytePlus Enterprise Coding Plan Lite' + ) + ); expect(summary.renewals).toBe(1); expect(subscription.status).toBe('active'); expect(terms.map(term => term.kind)).toEqual(['activation', 'renewal']); expect(renewalTransaction).toEqual([ { - description: 'Coding plan renewal: MiniMax Token Plan Plus', + description: 'Coding plan renewal: BytePlus Enterprise Coding Plan Lite', createdAt: expect.any(String), }, ]); expect(credential.status).toBe('assigned'); }); - it('uses the subscribed MiniMax token plan name and snapshotted cost during renewal', async () => { - const { subscriptionId } = await createSubscription( - MAX_COST_MICRODOLLARS * 2, - false, - MAX_PLAN_ID + it('uses the subscribed MiniMax token plan name and snapshotted cost during renewal for a grandfathered subscriber', async () => { + const { subscriptionId } = await createGrandfatheredMiniMaxSubscription( + MAX_PLAN_ID, + MAX_COST_MICRODOLLARS ); const summary = await runCodingPlanBillingLifecycleCron(db); @@ -217,7 +274,7 @@ describe('Coding Plan billing lifecycle cron', () => { ]); }); - it('renews after the subscriber deletes the installed MiniMax BYOK key', async () => { + it('renews after the subscriber deletes the installed provider BYOK key', async () => { const { subscriptionId } = await createSubscription(COST_MICRODOLLARS * 2); const [before] = await db .select() @@ -396,7 +453,7 @@ describe('Coding Plan billing lifecycle cron', () => { expect(credential.encrypted_api_key).toBeNull(); }); - it('preserves a replacement MiniMax key when scheduled cancellation is processed', async () => { + it('preserves a replacement provider key when scheduled cancellation is processed', async () => { const { subscriptionId, user } = await createSubscription(COST_MICRODOLLARS); const [subscription] = await db .select() diff --git a/apps/web/src/lib/coding-plans/index.test.ts b/apps/web/src/lib/coding-plans/index.test.ts index 3f37c1701b7..dddc0d96605 100644 --- a/apps/web/src/lib/coding-plans/index.test.ts +++ b/apps/web/src/lib/coding-plans/index.test.ts @@ -7,11 +7,16 @@ import { codingPlanCredentialFingerprint } from '@/lib/coding-plans/credential-f import { cancelCodingPlanSubscription, getKeyInventoryCounts, + requestCodingPlanAvailabilityNotification, subscribeToCodingPlan, terminateCodingPlanImmediately, uploadKeysToInventory, } from '@/lib/coding-plans'; -import { CODING_PLAN_CATALOG, type CodingPlanId } from '@/lib/coding-plans/pricing'; +import { + CODING_PLAN_CATALOG, + isCodingPlanDisabledForNewSignups, + type CodingPlanId, +} from '@/lib/coding-plans/pricing'; import { BYTEPLUS_CODING_MODEL_IDS } from '@/lib/ai-gateway/providers/direct-byok/byteplus-coding'; import { markCredentialManuallyRevoked, @@ -23,6 +28,7 @@ import { db } from '@/lib/drizzle'; import { insertTestUser } from '@/tests/helpers/user.helper'; import { byok_api_keys, + coding_plan_availability_intents, coding_plan_key_inventory, coding_plan_subscriptions, coding_plan_terms, @@ -43,13 +49,19 @@ const ULTRA_COST_MICRODOLLARS = 120_000_000; const BYTEPLUS_PRO_COST_MICRODOLLARS = 100_000_000; const validatedInventoryUpload = { - validateCredential: async ({ providerId, planId }: { providerId: string; planId: string }) => + validateCredential: async ({ + providerId, + upstreamPlanId, + }: { + providerId: string; + upstreamPlanId: string; + }) => providerId === BYTEPLUS_PROVIDER_ID - ? { valid: true, upstreamUsageId: `seat-${planId}` } + ? { valid: true, upstreamUsageId: `seat-${upstreamPlanId}` } : { valid: true }, }; -function inventoryEntry(key: string, upstreamPlanId = `minimax-plan-${crypto.randomUUID()}`) { +function inventoryEntry(key: string, upstreamPlanId = `upstream-plan-${crypto.randomUUID()}`) { return `${key}::${upstreamPlanId}`; } @@ -69,6 +81,7 @@ async function createUserWithBalance(microdollars: number) { } afterEach(async () => { + await db.delete(coding_plan_availability_intents); await db.delete(coding_plan_terms); await db.delete(coding_plan_subscriptions); await db.delete(byok_api_keys); @@ -144,12 +157,85 @@ describe('coding plans', () => { }); }); - it('activates BytePlus alongside MiniMax using its managed BYOK provider slot', async () => { + it('flags exactly the three MiniMax token plans as closed to new signups', () => { + expect(isCodingPlanDisabledForNewSignups(PLAN_ID)).toBe(true); + expect(isCodingPlanDisabledForNewSignups(MAX_PLAN_ID)).toBe(true); + expect(isCodingPlanDisabledForNewSignups(ULTRA_PLAN_ID)).toBe(true); + expect(isCodingPlanDisabledForNewSignups(BYTEPLUS_PLAN_ID)).toBe(false); + expect(isCodingPlanDisabledForNewSignups(BYTEPLUS_PRO_PLAN_ID)).toBe(false); + }); + + it('rejects a new MiniMax purchase even when a managed credential is available', async () => { + const user = await createUserWithBalance(COST_MICRODOLLARS); + await seedInventoryKey('disabled-signup-key'); + + await expect(subscribeToCodingPlan(user.id, PLAN_ID, 'new-signup')).rejects.toThrow( + 'MiniMax Token Plan Plus is not currently accepting new signups.' + ); + const [updatedUser] = await db + .select() + .from(kilocode_users) + .where(eq(kilocode_users.id, user.id)); + expect(updatedUser.microdollars_used).toBe(0); + expect(await db.select().from(coding_plan_subscriptions)).toHaveLength(0); + expect(await db.select().from(coding_plan_terms)).toHaveLength(0); + }); + + it('rejects a new MiniMax availability-notification request', async () => { + const user = await createUserWithBalance(0); + + await expect(requestCodingPlanAvailabilityNotification(user.id, PLAN_ID)).rejects.toThrow( + 'MiniMax Token Plan Plus is not currently accepting new signups.' + ); + expect(await db.select().from(coding_plan_availability_intents)).toHaveLength(0); + }); + + it('activates BytePlus using its own BYOK provider slot for a grandfathered MiniMax subscriber', async () => { + // MiniMax Token Plans are closed to new signups (see pricing.ts), so this + // seeds a pre-existing MiniMax subscription directly rather than through + // subscribeToCodingPlan, to confirm the live-subscription and BYOK-slot + // checks stay scoped per provider_id for a user who already has one. const user = await createUserWithBalance(COST_MICRODOLLARS * 2); - await seedInventoryKey('minimax-key'); await seedInventoryKey('byteplus-key', BYTEPLUS_PLAN_ID, BYTEPLUS_PROVIDER_ID); + const [minimaxByokKey] = await db + .insert(byok_api_keys) + .values({ + kilo_user_id: user.id, + provider_id: PROVIDER_ID, + encrypted_api_key: encryptApiKey('grandfathered-minimax-key', BYOK_ENCRYPTION_KEY), + management_source: 'coding_plan', + created_by: user.id, + }) + .returning(); + const [minimaxInventory] = await db + .insert(coding_plan_key_inventory) + .values({ + plan_id: PLAN_ID, + provider_id: PROVIDER_ID, + upstream_plan_id: `minimax-grandfathered-${crypto.randomUUID()}`, + encrypted_api_key: encryptApiKey('grandfathered-minimax-key', BYOK_ENCRYPTION_KEY), + credential_fingerprint: crypto.randomUUID(), + status: 'assigned', + assigned_to_user_id: user.id, + assigned_at: new Date().toISOString(), + }) + .returning(); + const periodStart = new Date().toISOString(); + const periodEnd = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(); + await db.insert(coding_plan_subscriptions).values({ + user_id: user.id, + plan_id: PLAN_ID, + provider_id: PROVIDER_ID, + key_inventory_id: minimaxInventory.id, + installed_byok_key_id: minimaxByokKey.id, + status: 'active', + cost_microdollars: COST_MICRODOLLARS, + billing_period_days: 30, + current_period_start: periodStart, + current_period_end: periodEnd, + credit_renewal_at: periodEnd, + }); - await subscribeToCodingPlan(user.id, PLAN_ID, 'minimax-activation'); const byteplus = await subscribeToCodingPlan(user.id, BYTEPLUS_PLAN_ID, 'byteplus-activation'); const [subscription] = await db .select() @@ -173,14 +259,14 @@ describe('coding plans', () => { provider_id: BYTEPLUS_PROVIDER_ID, management_source: 'coding_plan', }); - expect(inventory.upstream_usage_id).toBe(`seat-${BYTEPLUS_PLAN_ID}`); + expect(inventory.upstream_usage_id).toBe(`seat-${inventory.upstream_plan_id}`); }); it('activates an episode with one charged term and managed BYOK entry', async () => { const user = await createUserWithBalance(COST_MICRODOLLARS); - await seedInventoryKey(); + await seedInventoryKey('lite-key', BYTEPLUS_PLAN_ID, BYTEPLUS_PROVIDER_ID); - const result = await subscribeToCodingPlan(user.id, PLAN_ID, 'activation-request'); + const result = await subscribeToCodingPlan(user.id, BYTEPLUS_PLAN_ID, 'activation-request'); const [subscription] = await db .select() .from(coding_plan_subscriptions) @@ -202,14 +288,14 @@ describe('coding plans', () => { .from(coding_plan_key_inventory) .where(eq(coding_plan_key_inventory.id, subscription.key_inventory_id!)); - expect(subscription.plan_id).toBe(PLAN_ID); - expect(subscription.provider_id).toBe(PROVIDER_ID); + expect(subscription.plan_id).toBe(BYTEPLUS_PLAN_ID); + expect(subscription.provider_id).toBe(BYTEPLUS_PROVIDER_ID); expect(subscription.status).toBe('active'); expect(terms).toHaveLength(1); expect(terms[0].kind).toBe('activation'); expect(terms[0].cost_microdollars).toBe(COST_MICRODOLLARS); expect(deduction.amount_microdollars).toBe(-COST_MICRODOLLARS); - expect(managedKey.provider_id).toBe(PROVIDER_ID); + expect(managedKey.provider_id).toBe(BYTEPLUS_PROVIDER_ID); expect(managedKey.management_source).toBe('coding_plan'); expect(inventoryKey.status).toBe('assigned'); expect(inventoryKey.assigned_to_user_id).toBe(user.id); @@ -217,11 +303,11 @@ describe('coding plans', () => { it('returns prior outcome when an activation request is retried', async () => { const user = await createUserWithBalance(COST_MICRODOLLARS * 2); - await seedInventoryKey(); - await seedInventoryKey(); + await seedInventoryKey('retry-key-1', BYTEPLUS_PLAN_ID, BYTEPLUS_PROVIDER_ID); + await seedInventoryKey('retry-key-2', BYTEPLUS_PLAN_ID, BYTEPLUS_PROVIDER_ID); - const first = await subscribeToCodingPlan(user.id, PLAN_ID, 'same-request'); - const retried = await subscribeToCodingPlan(user.id, PLAN_ID, 'same-request'); + const first = await subscribeToCodingPlan(user.id, BYTEPLUS_PLAN_ID, 'same-request'); + const retried = await subscribeToCodingPlan(user.id, BYTEPLUS_PLAN_ID, 'same-request'); const terms = await db.select().from(coding_plan_terms); const assigned = await db .select() @@ -240,16 +326,16 @@ describe('coding plans', () => { it('rejects a new purchase while an active subscription exists', async () => { const user = await createUserWithBalance(COST_MICRODOLLARS * 2); - await seedInventoryKey(); - const activation = await subscribeToCodingPlan(user.id, PLAN_ID, 'activate'); + await seedInventoryKey('active-exists-key', BYTEPLUS_PLAN_ID, BYTEPLUS_PROVIDER_ID); + const activation = await subscribeToCodingPlan(user.id, BYTEPLUS_PLAN_ID, 'activate'); const [before] = await db .select() .from(coding_plan_subscriptions) .where(eq(coding_plan_subscriptions.id, activation.subscriptionId)); - await expect(subscribeToCodingPlan(user.id, PLAN_ID, 'second-purchase')).rejects.toThrow( - 'already has a live subscription' - ); + await expect( + subscribeToCodingPlan(user.id, BYTEPLUS_PLAN_ID, 'second-purchase') + ).rejects.toThrow('already has a live subscription'); const [after] = await db .select() .from(coding_plan_subscriptions) @@ -268,15 +354,15 @@ describe('coding plans', () => { expect(updatedUser.microdollars_used).toBe(COST_MICRODOLLARS); }); - it('rejects a different MiniMax token plan while a MiniMax subscription is live', async () => { - const user = await createUserWithBalance(COST_MICRODOLLARS + MAX_COST_MICRODOLLARS); - await seedInventoryKey('provider-plus-key', PLAN_ID); - await seedInventoryKey('provider-max-key', MAX_PLAN_ID); - await subscribeToCodingPlan(user.id, PLAN_ID, 'activate-plus'); + it('rejects a different BytePlus coding plan while a BytePlus subscription is live', async () => { + const user = await createUserWithBalance(COST_MICRODOLLARS + BYTEPLUS_PRO_COST_MICRODOLLARS); + await seedInventoryKey('provider-lite-key', BYTEPLUS_PLAN_ID, BYTEPLUS_PROVIDER_ID); + await seedInventoryKey('provider-pro-key', BYTEPLUS_PRO_PLAN_ID, BYTEPLUS_PROVIDER_ID); + await subscribeToCodingPlan(user.id, BYTEPLUS_PLAN_ID, 'activate-lite'); - await expect(subscribeToCodingPlan(user.id, MAX_PLAN_ID, 'activate-max')).rejects.toThrow( - 'MiniMax Coding Plan already has a live subscription' - ); + await expect( + subscribeToCodingPlan(user.id, BYTEPLUS_PRO_PLAN_ID, 'activate-pro') + ).rejects.toThrow('BytePlus Coding Plan already has a live subscription'); const terms = await db.select().from(coding_plan_terms); const subscriptions = await db .select() @@ -292,14 +378,14 @@ describe('coding plans', () => { expect(updatedUser.microdollars_used).toBe(COST_MICRODOLLARS); }); - it('allows a fresh MiniMax token plan after the prior MiniMax subscription is canceled', async () => { - const user = await createUserWithBalance(COST_MICRODOLLARS + MAX_COST_MICRODOLLARS); - await seedInventoryKey('first-plus-key', PLAN_ID); - await seedInventoryKey('second-max-key', MAX_PLAN_ID); - const first = await subscribeToCodingPlan(user.id, PLAN_ID, 'first-plan'); + it('allows a fresh BytePlus coding plan after the prior BytePlus subscription is canceled', async () => { + const user = await createUserWithBalance(COST_MICRODOLLARS + BYTEPLUS_PRO_COST_MICRODOLLARS); + await seedInventoryKey('first-lite-key', BYTEPLUS_PLAN_ID, BYTEPLUS_PROVIDER_ID); + await seedInventoryKey('second-pro-key', BYTEPLUS_PRO_PLAN_ID, BYTEPLUS_PROVIDER_ID); + const first = await subscribeToCodingPlan(user.id, BYTEPLUS_PLAN_ID, 'first-plan'); await terminateCodingPlanImmediately(first.subscriptionId); - const second = await subscribeToCodingPlan(user.id, MAX_PLAN_ID, 'second-plan'); + const second = await subscribeToCodingPlan(user.id, BYTEPLUS_PRO_PLAN_ID, 'second-plan'); expect(second.subscriptionId).not.toBe(first.subscriptionId); const subscriptions = await db @@ -313,20 +399,20 @@ describe('coding plans', () => { expect( subscriptions.find(subscription => subscription.id === second.subscriptionId) ).toMatchObject({ - plan_id: MAX_PLAN_ID, - provider_id: PROVIDER_ID, - cost_microdollars: MAX_COST_MICRODOLLARS, + plan_id: BYTEPLUS_PRO_PLAN_ID, + provider_id: BYTEPLUS_PROVIDER_ID, + cost_microdollars: BYTEPLUS_PRO_COST_MICRODOLLARS, }); }); - it('cannot create two MiniMax provider subscriptions during concurrent cross-plan purchases', async () => { - const user = await createUserWithBalance(MAX_COST_MICRODOLLARS + ULTRA_COST_MICRODOLLARS); - await seedInventoryKey('concurrent-max-key', MAX_PLAN_ID); - await seedInventoryKey('concurrent-ultra-key', ULTRA_PLAN_ID); + it('cannot create two BytePlus provider subscriptions during concurrent cross-plan purchases', async () => { + const user = await createUserWithBalance(COST_MICRODOLLARS + BYTEPLUS_PRO_COST_MICRODOLLARS); + await seedInventoryKey('concurrent-lite-key', BYTEPLUS_PLAN_ID, BYTEPLUS_PROVIDER_ID); + await seedInventoryKey('concurrent-pro-key', BYTEPLUS_PRO_PLAN_ID, BYTEPLUS_PROVIDER_ID); const outcomes = await Promise.allSettled([ - subscribeToCodingPlan(user.id, MAX_PLAN_ID, 'concurrent-max'), - subscribeToCodingPlan(user.id, ULTRA_PLAN_ID, 'concurrent-ultra'), + subscribeToCodingPlan(user.id, BYTEPLUS_PLAN_ID, 'concurrent-lite'), + subscribeToCodingPlan(user.id, BYTEPLUS_PRO_PLAN_ID, 'concurrent-pro'), ]); const subscriptions = await db .select() @@ -339,19 +425,19 @@ describe('coding plans', () => { expect(outcomes.filter(result => result.status === 'fulfilled')).toHaveLength(1); expect(subscriptions).toHaveLength(1); - expect([MAX_COST_MICRODOLLARS, ULTRA_COST_MICRODOLLARS]).toContain( + expect([COST_MICRODOLLARS, BYTEPLUS_PRO_COST_MICRODOLLARS]).toContain( updatedUser.microdollars_used ); }); it('cannot overspend credits during concurrent purchase requests', async () => { const user = await createUserWithBalance(COST_MICRODOLLARS); - await seedInventoryKey(); - await seedInventoryKey(); + await seedInventoryKey('overspend-key-1', BYTEPLUS_PLAN_ID, BYTEPLUS_PROVIDER_ID); + await seedInventoryKey('overspend-key-2', BYTEPLUS_PLAN_ID, BYTEPLUS_PROVIDER_ID); const outcomes = await Promise.allSettled([ - subscribeToCodingPlan(user.id, PLAN_ID, 'concurrent-one'), - subscribeToCodingPlan(user.id, PLAN_ID, 'concurrent-two'), + subscribeToCodingPlan(user.id, BYTEPLUS_PLAN_ID, 'concurrent-one'), + subscribeToCodingPlan(user.id, BYTEPLUS_PLAN_ID, 'concurrent-two'), ]); const [updatedUser] = await db .select() @@ -369,8 +455,8 @@ describe('coding plans', () => { it('rolls back activation when credits are insufficient or capacity is absent', async () => { const poorUser = await createUserWithBalance(COST_MICRODOLLARS - 1); - await seedInventoryKey(); - await expect(subscribeToCodingPlan(poorUser.id, PLAN_ID, 'poor')).rejects.toThrow( + await seedInventoryKey('rollback-key', BYTEPLUS_PLAN_ID, BYTEPLUS_PROVIDER_ID); + await expect(subscribeToCodingPlan(poorUser.id, BYTEPLUS_PLAN_ID, 'poor')).rejects.toThrow( 'Insufficient credit balance' ); @@ -383,9 +469,9 @@ describe('coding plans', () => { .update(coding_plan_key_inventory) .set({ status: 'assigned' }) .where(eq(coding_plan_key_inventory.id, availableKey.id)); - await expect(subscribeToCodingPlan(fundedUser.id, PLAN_ID, 'capacity')).rejects.toThrow( - 'No managed credential' - ); + await expect( + subscribeToCodingPlan(fundedUser.id, BYTEPLUS_PLAN_ID, 'capacity') + ).rejects.toThrow('No managed credential'); const [unchargedUser] = await db .select() .from(kilocode_users) @@ -393,19 +479,19 @@ describe('coding plans', () => { expect(unchargedUser.microdollars_used).toBe(0); }); - it('rejects activation when the personal MiniMax BYOK slot is occupied', async () => { + it('rejects activation when the personal BytePlus BYOK slot is occupied', async () => { const user = await createUserWithBalance(COST_MICRODOLLARS); - await seedInventoryKey(); + await seedInventoryKey('occupied-slot-key', BYTEPLUS_PLAN_ID, BYTEPLUS_PROVIDER_ID); await db.insert(byok_api_keys).values({ kilo_user_id: user.id, - provider_id: PROVIDER_ID, - encrypted_api_key: encryptApiKey('existing-minimax', BYOK_ENCRYPTION_KEY), + provider_id: BYTEPLUS_PROVIDER_ID, + encrypted_api_key: encryptApiKey('existing-byteplus', BYOK_ENCRYPTION_KEY), is_enabled: false, created_by: user.id, }); - await expect(subscribeToCodingPlan(user.id, PLAN_ID, 'occupied-slot')).rejects.toThrow( - 'Remove your existing MiniMax BYOK key from /byok before subscribing to a MiniMax Coding Plan' + await expect(subscribeToCodingPlan(user.id, BYTEPLUS_PLAN_ID, 'occupied-slot')).rejects.toThrow( + 'Remove your existing BytePlus BYOK key from /byok before subscribing to a BytePlus Coding Plan' ); const [updatedUser] = await db .select() @@ -423,16 +509,16 @@ describe('coding plans', () => { it('creates a new episode and new credential after immediate termination', async () => { const user = await createUserWithBalance(COST_MICRODOLLARS * 2); - await seedInventoryKey(); - await seedInventoryKey(); - const first = await subscribeToCodingPlan(user.id, PLAN_ID, 'first-episode'); + await seedInventoryKey('episode-key-1', BYTEPLUS_PLAN_ID, BYTEPLUS_PROVIDER_ID); + await seedInventoryKey('episode-key-2', BYTEPLUS_PLAN_ID, BYTEPLUS_PROVIDER_ID); + const first = await subscribeToCodingPlan(user.id, BYTEPLUS_PLAN_ID, 'first-episode'); const [firstSubscription] = await db .select() .from(coding_plan_subscriptions) .where(eq(coding_plan_subscriptions.id, first.subscriptionId)); await terminateCodingPlanImmediately(first.subscriptionId); - const second = await subscribeToCodingPlan(user.id, PLAN_ID, 'second-episode'); + const second = await subscribeToCodingPlan(user.id, BYTEPLUS_PLAN_ID, 'second-episode'); const [terminatedCredential] = await db .select() .from(coding_plan_key_inventory) @@ -450,10 +536,10 @@ describe('coding plans', () => { ]); }); - it('preserves a detached user-managed MiniMax key on termination', async () => { + it('preserves a detached user-managed key on termination', async () => { const user = await createUserWithBalance(COST_MICRODOLLARS); - await seedInventoryKey(); - const activation = await subscribeToCodingPlan(user.id, PLAN_ID, 'replace-before-end'); + await seedInventoryKey('detached-key', BYTEPLUS_PLAN_ID, BYTEPLUS_PROVIDER_ID); + const activation = await subscribeToCodingPlan(user.id, BYTEPLUS_PLAN_ID, 'replace-before-end'); const [subscription] = await db .select() .from(coding_plan_subscriptions) @@ -479,8 +565,8 @@ describe('coding plans', () => { it('schedules user cancellation without immediately removing installed access', async () => { const user = await createUserWithBalance(COST_MICRODOLLARS); - await seedInventoryKey(); - const result = await subscribeToCodingPlan(user.id, PLAN_ID, 'cancel-request'); + await seedInventoryKey('cancel-key', BYTEPLUS_PLAN_ID, BYTEPLUS_PROVIDER_ID); + const result = await subscribeToCodingPlan(user.id, BYTEPLUS_PLAN_ID, 'cancel-request'); await cancelCodingPlanSubscription(user.id, result.subscriptionId); const [subscription] = await db @@ -560,12 +646,14 @@ describe('coding plans', () => { it('clears credential material once revocation work starts and can remove stock permanently', async () => { const user = await createUserWithBalance(COST_MICRODOLLARS); await uploadKeysToInventory( - PROVIDER_ID, - PLAN_ID, - [inventoryEntry('revoke-success-key', 'minimax-revoke-plan')], - validatedInventoryUpload + BYTEPLUS_PROVIDER_ID, + BYTEPLUS_PLAN_ID, + [inventoryEntry('revoke-success-key', 'byteplus-revoke-plan')], + { + validateCredential: async () => ({ valid: true, upstreamUsageId: 'seat-revoke-success' }), + } ); - const activation = await subscribeToCodingPlan(user.id, PLAN_ID, 'revoke-success'); + const activation = await subscribeToCodingPlan(user.id, BYTEPLUS_PLAN_ID, 'revoke-success'); const [subscription] = await db .select() .from(coding_plan_subscriptions) @@ -577,7 +665,7 @@ describe('coding plans', () => { .from(coding_plan_key_inventory) .where(eq(coding_plan_key_inventory.id, subscription.key_inventory_id!)); expect(credential.status).toBe('revocation_pending'); - expect(credential.upstream_plan_id).toBe('minimax-revoke-plan'); + expect(credential.upstream_plan_id).toBe('byteplus-revoke-plan'); expect(credential.encrypted_api_key).toBeNull(); await markCredentialManuallyRevoked(subscription.key_inventory_id!); @@ -587,21 +675,26 @@ describe('coding plans', () => { .where(eq(coding_plan_key_inventory.id, subscription.key_inventory_id!)); expect(credential.status).toBe('revoked'); - expect(credential.upstream_plan_id).toBe('minimax-revoke-plan'); + expect(credential.upstream_plan_id).toBe('byteplus-revoke-plan'); expect(credential.encrypted_api_key).toBeNull(); expect(credential.revocation_attempt_count).toBe(1); }); it('validates and stores a replacement credential for the same upstream plan ID', async () => { const user = await createUserWithBalance(COST_MICRODOLLARS); - const validateCredential = jest.fn(async () => true); + const validateCredential = jest.fn(async () => ({ + valid: true, + upstreamUsageId: 'seat-replace-success', + })); await uploadKeysToInventory( - PROVIDER_ID, - PLAN_ID, - [inventoryEntry('replace-original-key', 'minimax-replace-plan')], - validatedInventoryUpload + BYTEPLUS_PROVIDER_ID, + BYTEPLUS_PLAN_ID, + [inventoryEntry('replace-original-key', 'byteplus-replace-plan')], + { + validateCredential: async () => ({ valid: true, upstreamUsageId: 'seat-replace-plan' }), + } ); - const activation = await subscribeToCodingPlan(user.id, PLAN_ID, 'replace-success'); + const activation = await subscribeToCodingPlan(user.id, BYTEPLUS_PLAN_ID, 'replace-success'); const [subscription] = await db .select() .from(coding_plan_subscriptions) @@ -618,18 +711,18 @@ describe('coding plans', () => { expect(validateCredential).toHaveBeenCalledWith({ apiKey: 'replace-new-key', - planId: PLAN_ID, - providerId: PROVIDER_ID, - upstreamPlanId: 'minimax-replace-plan', + planId: BYTEPLUS_PLAN_ID, + providerId: BYTEPLUS_PROVIDER_ID, + upstreamPlanId: 'byteplus-replace-plan', }); expect(credential.status).toBe('available'); - expect(credential.upstream_plan_id).toBe('minimax-replace-plan'); + expect(credential.upstream_plan_id).toBe('byteplus-replace-plan'); expect(credential.encrypted_api_key).not.toBeNull(); expect(credential.assigned_to_user_id).toBeNull(); expect(credential.revocation_requested_at).toBeNull(); expect(credential.revocation_attempt_count).toBe(1); - expect(await getKeyInventoryCounts(PLAN_ID)).toEqual([ - { providerId: PROVIDER_ID, planId: PLAN_ID, status: 'available', count: 1 }, + expect(await getKeyInventoryCounts(BYTEPLUS_PLAN_ID)).toEqual([ + { providerId: BYTEPLUS_PROVIDER_ID, planId: BYTEPLUS_PLAN_ID, status: 'available', count: 1 }, ]); }); @@ -682,8 +775,8 @@ describe('coding plans', () => { it('rejects invalid replacement credentials before returning stock to inventory', async () => { const user = await createUserWithBalance(COST_MICRODOLLARS); - await seedInventoryKey('replace-invalid-original-key'); - const activation = await subscribeToCodingPlan(user.id, PLAN_ID, 'replace-invalid'); + await seedInventoryKey('replace-invalid-original-key', BYTEPLUS_PLAN_ID, BYTEPLUS_PROVIDER_ID); + const activation = await subscribeToCodingPlan(user.id, BYTEPLUS_PLAN_ID, 'replace-invalid'); const [subscription] = await db .select() .from(coding_plan_subscriptions) @@ -707,15 +800,20 @@ describe('coding plans', () => { const user = await createUserWithBalance(COST_MICRODOLLARS); const validateCredential = jest.fn(async () => true); await uploadKeysToInventory( - PROVIDER_ID, - PLAN_ID, + BYTEPLUS_PROVIDER_ID, + BYTEPLUS_PLAN_ID, [ - inventoryEntry('replace-unchanged-original-key', 'minimax-replace-unchanged-plan'), - inventoryEntry('replace-duplicate-existing-key', 'minimax-replace-duplicate-plan'), + inventoryEntry('replace-unchanged-original-key', 'byteplus-replace-unchanged-plan'), + inventoryEntry('replace-duplicate-existing-key', 'byteplus-replace-duplicate-plan'), ], - validatedInventoryUpload + { + validateCredential: async ({ upstreamPlanId }: { upstreamPlanId: string }) => ({ + valid: true, + upstreamUsageId: `seat-${upstreamPlanId}`, + }), + } ); - const activation = await subscribeToCodingPlan(user.id, PLAN_ID, 'replace-unchanged'); + const activation = await subscribeToCodingPlan(user.id, BYTEPLUS_PLAN_ID, 'replace-unchanged'); const [subscription] = await db .select() .from(coding_plan_subscriptions) @@ -757,8 +855,8 @@ describe('coding plans', () => { it('keeps failed manual revocation terminal and retryable', async () => { const user = await createUserWithBalance(COST_MICRODOLLARS); - await seedInventoryKey('revoke-failure-key'); - const activation = await subscribeToCodingPlan(user.id, PLAN_ID, 'revoke-failure'); + await seedInventoryKey('revoke-failure-key', BYTEPLUS_PLAN_ID, BYTEPLUS_PROVIDER_ID); + const activation = await subscribeToCodingPlan(user.id, BYTEPLUS_PLAN_ID, 'revoke-failure'); const [subscription] = await db .select() .from(coding_plan_subscriptions) @@ -767,7 +865,7 @@ describe('coding plans', () => { await markCredentialManualRevocationFailed( subscription.key_inventory_id!, - 'MiniMax admin request failed with api_key=secret-value' + 'BytePlus admin request failed with api_key=secret-value' ); let [credential] = await db .select() diff --git a/apps/web/src/lib/coding-plans/index.ts b/apps/web/src/lib/coding-plans/index.ts index bb065a08d22..9a88145eeea 100644 --- a/apps/web/src/lib/coding-plans/index.ts +++ b/apps/web/src/lib/coding-plans/index.ts @@ -14,7 +14,12 @@ import { type CodingPlanCredentialValidationResult, validateCodingPlanCredential, } from '@/lib/coding-plans/inventory-validation'; -import { getCodingPlanPrice, isCodingPlanId, type CodingPlanId } from '@/lib/coding-plans/pricing'; +import { + getCodingPlanPrice, + isCodingPlanDisabledForNewSignups, + isCodingPlanId, + type CodingPlanId, +} from '@/lib/coding-plans/pricing'; import { db } from '@/lib/drizzle'; import { maybeIssueKiloPassBonusFromUsageThreshold } from '@/lib/kilo-pass/usage-triggered-bonus'; import { sentryLogger } from '@/lib/utils.server'; @@ -221,6 +226,10 @@ export async function subscribeToCodingPlan( } satisfies SubscriptionOutcome; } + if (isCodingPlanDisabledForNewSignups(plan.planId)) { + throw new Error(`${plan.providerName} ${plan.name} is not currently accepting new signups.`); + } + const [liveSubscription] = await tx .select() .from(coding_plan_subscriptions) @@ -761,6 +770,9 @@ export async function requestCodingPlanAvailabilityNotification( if (!plan) { throw new Error(`Plan "${planId}" is not available as a coding plan.`); } + if (isCodingPlanDisabledForNewSignups(plan.planId)) { + throw new Error(`${plan.providerName} ${plan.name} is not currently accepting new signups.`); + } return db.transaction(async tx => { const [availableCredential] = await tx diff --git a/apps/web/src/lib/coding-plans/pricing.ts b/apps/web/src/lib/coding-plans/pricing.ts index b898168bbca..9e41b511be7 100644 --- a/apps/web/src/lib/coding-plans/pricing.ts +++ b/apps/web/src/lib/coding-plans/pricing.ts @@ -133,3 +133,18 @@ export function hasCodingPlanForModel(providerId: string, modelId: string): bool export function isMonthlyCodingPlan(planId?: string): boolean { return planId ? getCodingPlanPrice(planId)?.billingPeriodDays === 30 : false; } + +// MiniMax Token Plans (Plus/Max/Ultra) are closed to new subscribers. Existing +// subscriptions continue unaffected: they keep routing, renewing, and can +// still be canceled; only new purchases and new "notify me" waitlist entries +// are rejected. See .specs/coding-plans.md for the governed catalog rules +// this deliberately overrides for these three plan IDs. +const CODING_PLANS_DISABLED_FOR_NEW_SIGNUPS = new Set([ + 'minimax-token-plan-plus', + 'minimax-token-plan-max', + 'minimax-token-plan-ultra', +]); + +export function isCodingPlanDisabledForNewSignups(planId: CodingPlanId): boolean { + return CODING_PLANS_DISABLED_FOR_NEW_SIGNUPS.has(planId); +} diff --git a/apps/web/src/routers/coding-plans-router.test.ts b/apps/web/src/routers/coding-plans-router.test.ts index bc41b23af16..63756fe77cf 100644 --- a/apps/web/src/routers/coding-plans-router.test.ts +++ b/apps/web/src/routers/coding-plans-router.test.ts @@ -7,6 +7,7 @@ import { BYOK_ENCRYPTION_KEY } from '@/lib/config.server'; import { db } from '@/lib/drizzle'; import { uploadKeysToInventory } from '@/lib/coding-plans'; import { getBytePlusUsage } from '@/lib/coding-plans/byteplus-usage'; +import { CodingPlanUsageError } from '@/lib/coding-plans/usage-contract'; import { CODING_PLAN_IDS } from '@/lib/coding-plans/pricing'; import { redisClient } from '@/lib/redis'; import { createCallerForUser } from '@/routers/test-utils'; @@ -47,7 +48,7 @@ const ULTRA_PLAN_ID = 'minimax-token-plan-ultra'; const BYTEPLUS_PLAN_ID = 'byteplus-coding-plan-team-lite'; const BYTEPLUS_PRO_PLAN_ID = 'byteplus-coding-plan-team-pro'; const COST_MICRODOLLARS = 20_000_000; -const MAX_COST_MICRODOLLARS = 50_000_000; +const BYTEPLUS_PRO_COST_MICRODOLLARS = 100_000_000; const mockedGenerateText = jest.mocked(generateText); const mockedGetBytePlusUsage = jest.mocked(getBytePlusUsage); const mockedRedisGet = jest.mocked(redisClient.get); @@ -196,6 +197,7 @@ describe('coding plans router', () => { features: expect.arrayContaining(['~1.7B tokens per month of M3 usage.']), availabilityStatus: 'sold_out', notificationRequested: false, + purchasable: false, }, { planId: MAX_PLAN_ID, @@ -210,6 +212,7 @@ describe('coding plans router', () => { ]), availabilityStatus: 'sold_out', notificationRequested: false, + purchasable: false, }, { planId: ULTRA_PLAN_ID, @@ -224,6 +227,7 @@ describe('coding plans router', () => { ]), availabilityStatus: 'sold_out', notificationRequested: false, + purchasable: false, }, { planId: BYTEPLUS_PLAN_ID, @@ -238,6 +242,7 @@ describe('coding plans router', () => { ]), availabilityStatus: 'sold_out', notificationRequested: false, + purchasable: true, }, { planId: BYTEPLUS_PRO_PLAN_ID, @@ -253,26 +258,77 @@ describe('coding plans router', () => { ]), availabilityStatus: 'sold_out', notificationRequested: false, + purchasable: true, }, ]); }); - it('reports available capacity without exposing inventory and rejects notify requests while in stock', async () => { + it('flags MiniMax plans as closed to new signups while BytePlus remains purchasable', async () => { const user = await insertTestUser(); const caller = await createCallerForUser(user.id); + + const catalog = await caller.codingPlans.catalog(); + const purchasableByPlan = Object.fromEntries( + catalog.map(plan => [plan.planId, plan.purchasable]) + ); + + expect(purchasableByPlan).toEqual({ + [PLAN_ID]: false, + [MAX_PLAN_ID]: false, + [ULTRA_PLAN_ID]: false, + [BYTEPLUS_PLAN_ID]: true, + [BYTEPLUS_PRO_PLAN_ID]: true, + }); + }); + + it('rejects new MiniMax subscribe and notify requests, and reports non-purchasable catalog entries', async () => { + const user = await insertTestUser({ + total_microdollars_acquired: COST_MICRODOLLARS, + microdollars_used: 0, + }); + const caller = await createCallerForUser(user.id); await uploadKeysToInventory( 'minimax', PLAN_ID, + [inventoryEntry(`minimax-disabled-${crypto.randomUUID()}`)], + { validateCredential: async () => true } + ); + + await expect( + caller.codingPlans.subscribe({ planId: PLAN_ID, idempotencyKey: 'disabled-signup' }) + ).rejects.toMatchObject({ + code: 'FORBIDDEN', + message: 'MiniMax Token Plan Plus is not currently accepting new signups.', + }); + await expect( + caller.codingPlans.requestAvailabilityNotification({ planId: PLAN_ID }) + ).rejects.toMatchObject({ + code: 'FORBIDDEN', + message: 'MiniMax Token Plan Plus is not currently accepting new signups.', + }); + expect(await db.select().from(coding_plan_subscriptions)).toHaveLength(0); + expect(await db.select().from(coding_plan_availability_intents)).toHaveLength(0); + }); + + it('reports available capacity without exposing inventory and rejects notify requests while in stock', async () => { + const user = await insertTestUser(); + const caller = await createCallerForUser(user.id); + await uploadKeysToInventory( + 'byteplus-coding', + BYTEPLUS_PLAN_ID, [inventoryEntry(`catalog-available-${crypto.randomUUID()}`)], { - validateCredential: async () => true, + validateCredential: async () => ({ + valid: true, + upstreamUsageId: 'seat-catalog-available', + }), } ); await expect(caller.codingPlans.catalog()).resolves.toEqual([ expect.objectContaining({ planId: PLAN_ID, - availabilityStatus: 'available', + availabilityStatus: 'sold_out', notificationRequested: false, }), expect.objectContaining({ @@ -287,7 +343,7 @@ describe('coding plans router', () => { }), expect.objectContaining({ planId: BYTEPLUS_PLAN_ID, - availabilityStatus: 'sold_out', + availabilityStatus: 'available', notificationRequested: false, }), expect.objectContaining({ @@ -297,7 +353,7 @@ describe('coding plans router', () => { }), ]); await expect( - caller.codingPlans.requestAvailabilityNotification({ planId: PLAN_ID }) + caller.codingPlans.requestAvailabilityNotification({ planId: BYTEPLUS_PLAN_ID }) ).rejects.toThrow('currently available'); }); @@ -306,20 +362,20 @@ describe('coding plans router', () => { const caller = await createCallerForUser(user.id); await expect( - caller.codingPlans.requestAvailabilityNotification({ planId: PLAN_ID }) + caller.codingPlans.requestAvailabilityNotification({ planId: BYTEPLUS_PRO_PLAN_ID }) ).resolves.toEqual({ requested: true }); await expect( - caller.codingPlans.requestAvailabilityNotification({ planId: PLAN_ID }) + caller.codingPlans.requestAvailabilityNotification({ planId: BYTEPLUS_PRO_PLAN_ID }) ).resolves.toEqual({ requested: true }); const intents = await db.select().from(coding_plan_availability_intents); expect(intents).toHaveLength(1); - expect(intents[0]).toMatchObject({ user_id: user.id, plan_id: PLAN_ID }); + expect(intents[0]).toMatchObject({ user_id: user.id, plan_id: BYTEPLUS_PRO_PLAN_ID }); await expect(caller.codingPlans.catalog()).resolves.toEqual([ expect.objectContaining({ planId: PLAN_ID, availabilityStatus: 'sold_out', - notificationRequested: true, + notificationRequested: false, }), expect.objectContaining({ planId: MAX_PLAN_ID, @@ -339,7 +395,7 @@ describe('coding plans router', () => { expect.objectContaining({ planId: BYTEPLUS_PRO_PLAN_ID, availabilityStatus: 'sold_out', - notificationRequested: false, + notificationRequested: true, }), ]); }); @@ -350,42 +406,51 @@ describe('coding plans router', () => { microdollars_used: 0, }); const caller = await createCallerForUser(user.id); - await caller.codingPlans.requestAvailabilityNotification({ planId: PLAN_ID }); + await caller.codingPlans.requestAvailabilityNotification({ planId: BYTEPLUS_PLAN_ID }); await uploadKeysToInventory( - 'minimax', - PLAN_ID, + 'byteplus-coding', + BYTEPLUS_PLAN_ID, [inventoryEntry(`notify-activation-${crypto.randomUUID()}`)], { - validateCredential: async () => true, + validateCredential: async () => ({ + valid: true, + upstreamUsageId: 'seat-notify-activation', + }), } ); - await caller.codingPlans.subscribe({ planId: PLAN_ID, idempotencyKey: 'notify-activation' }); + await caller.codingPlans.subscribe({ + planId: BYTEPLUS_PLAN_ID, + idempotencyKey: 'notify-activation', + }); expect(await db.select().from(coding_plan_availability_intents)).toHaveLength(0); }); - it('rejects purchase while a disabled personal MiniMax BYOK key occupies setup', async () => { + it('rejects purchase while a disabled personal BytePlus BYOK key occupies setup', async () => { const user = await insertTestUser({ total_microdollars_acquired: COST_MICRODOLLARS, microdollars_used: 0, }); const caller = await createCallerForUser(user.id); - const key = await caller.byok.create({ provider_id: 'minimax', api_key: 'existing-key' }); + const key = await caller.byok.create({ + provider_id: 'byteplus-coding', + api_key: 'existing-key', + }); await caller.byok.setEnabled({ id: key.id, is_enabled: false }); await uploadKeysToInventory( - 'minimax', - PLAN_ID, + 'byteplus-coding', + BYTEPLUS_PLAN_ID, [inventoryEntry(`unused-router-key-${crypto.randomUUID()}`)], { - validateCredential: async () => true, + validateCredential: async () => ({ valid: true, upstreamUsageId: 'seat-unused-router' }), } ); await expect( - caller.codingPlans.subscribe({ planId: PLAN_ID, idempotencyKey: 'blocked-slot' }) + caller.codingPlans.subscribe({ planId: BYTEPLUS_PLAN_ID, idempotencyKey: 'blocked-slot' }) ).rejects.toThrow( - 'Remove your existing MiniMax BYOK key from /byok before subscribing to a MiniMax Coding Plan' + 'Remove your existing BytePlus BYOK key from /byok before subscribing to a BytePlus Coding Plan' ); const [savedUser] = await db.select().from(kilocode_users); const subscriptions = await db.select().from(coding_plan_subscriptions); @@ -403,18 +468,18 @@ describe('coding plans router', () => { }); const otherUser = await insertTestUser(); await uploadKeysToInventory( - 'minimax', - PLAN_ID, + 'byteplus-coding', + BYTEPLUS_PLAN_ID, [inventoryEntry(`router-managed-key-${crypto.randomUUID()}`)], { - validateCredential: async () => true, + validateCredential: async () => ({ valid: true, upstreamUsageId: 'seat-router-managed' }), } ); const ownerCaller = await createCallerForUser(owner.id); const otherCaller = await createCallerForUser(otherUser.id); const activation = await ownerCaller.codingPlans.subscribe({ - planId: PLAN_ID, + planId: BYTEPLUS_PLAN_ID, idempotencyKey: 'router-activation-request', }); const subscriptions = await ownerCaller.codingPlans.listSubscriptions(); @@ -429,12 +494,14 @@ describe('coding plans router', () => { expect(subscriptions[0]).toMatchObject({ canQueryUsage: true }); expect(detail).toMatchObject({ id: activation.subscriptionId, - planId: PLAN_ID, - planName: 'Token Plan Plus', - providerName: 'MiniMax', - providerId: 'minimax', - routeLabel: 'MiniMax via Kilo Gateway', - features: expect.arrayContaining(['~1.7B tokens per month of M3 usage.']), + planId: BYTEPLUS_PLAN_ID, + planName: 'Enterprise Coding Plan Lite', + providerName: 'BytePlus', + providerId: 'byteplus-coding', + routeLabel: 'BytePlus via Kilo Gateway', + features: expect.arrayContaining([ + 'Kilo automatically configures BytePlus in your BYOK settings.', + ]), canQueryUsage: true, hasInstalledByokKey: true, status: 'active', @@ -450,7 +517,7 @@ describe('coding plans router', () => { id: expect.any(String), date: expect.stringContaining('T'), amountMicrodollars: COST_MICRODOLLARS, - description: 'Coding plan: MiniMax Token Plan Plus', + description: 'Coding plan: BytePlus Enterprise Coding Plan Lite', }, ], hasMore: false, @@ -477,32 +544,70 @@ describe('coding plans router', () => { ).rejects.toThrow('Coding Plan subscription not found.'); }); - it('returns owner-scoped managed usage without exposing the credential', async () => { + it('returns owner-scoped managed usage for a grandfathered MiniMax subscriber without exposing the credential', async () => { + // MiniMax Token Plans are closed to new signups (see pricing.ts), so this + // seeds a pre-existing MiniMax subscription directly rather than through + // codingPlans.subscribe, to confirm the MiniMax usage integration still + // works for a subscriber who purchased before the plan was disabled. 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('minimax', PLAN_ID, [inventoryEntry(managedKey)], { - validateCredential: async () => true, - }); + const [inventory] = await db + .insert(coding_plan_key_inventory) + .values({ + plan_id: PLAN_ID, + provider_id: 'minimax', + upstream_plan_id: `minimax-grandfathered-${crypto.randomUUID()}`, + encrypted_api_key: encryptApiKey(managedKey, BYOK_ENCRYPTION_KEY), + credential_fingerprint: crypto.randomUUID(), + status: 'assigned', + assigned_to_user_id: owner.id, + assigned_at: new Date().toISOString(), + }) + .returning(); + const [installedByok] = await db + .insert(byok_api_keys) + .values({ + kilo_user_id: owner.id, + provider_id: 'minimax', + encrypted_api_key: encryptApiKey(managedKey, BYOK_ENCRYPTION_KEY), + management_source: 'coding_plan', + created_by: owner.id, + }) + .returning(); + const periodStart = new Date().toISOString(); + const periodEnd = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(); + const [subscription] = await db + .insert(coding_plan_subscriptions) + .values({ + user_id: owner.id, + plan_id: PLAN_ID, + provider_id: 'minimax', + key_inventory_id: inventory.id, + installed_byok_key_id: installedByok.id, + status: 'active', + cost_microdollars: COST_MICRODOLLARS, + billing_period_days: 30, + current_period_start: periodStart, + current_period_end: periodEnd, + credit_renewal_at: periodEnd, + }) + .returning(); 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, + subscriptionId: subscription.id, }); expect(active).toEqual({ schemaVersion: 1, fetchedAt: expect.stringContaining('T'), subscription: { - id: activation.subscriptionId, + id: subscription.id, planId: PLAN_ID, planName: 'Token Plan Plus', providerId: 'minimax', @@ -527,7 +632,7 @@ describe('coding plans router', () => { }); expect(JSON.stringify(active)).not.toContain(managedKey); await expect( - otherCaller.codingPlans.getUsage({ subscriptionId: activation.subscriptionId }) + otherCaller.codingPlans.getUsage({ subscriptionId: subscription.id }) ).rejects.toMatchObject({ code: 'NOT_FOUND', message: 'Coding Plan subscription not found.', @@ -603,19 +708,18 @@ describe('coding plans router', () => { microdollars_used: 0, }); await uploadKeysToInventory( - 'minimax', - PLAN_ID, + 'byteplus-coding', + BYTEPLUS_PLAN_ID, [inventoryEntry(`sk-cp-state-${crypto.randomUUID()}`)], { - validateCredential: async () => true, + validateCredential: async () => ({ valid: true, upstreamUsageId: 'seat-usage-states' }), } ); const caller = await createCallerForUser(owner.id); const activation = await caller.codingPlans.subscribe({ - planId: PLAN_ID, + planId: BYTEPLUS_PLAN_ID, idempotencyKey: 'usage-states', }); - jest.spyOn(global, 'fetch').mockImplementation(async () => usageResponse()); const past = new Date(Date.now() - 60_000).toISOString(); // A period deadline that already passed does not end usage early; the @@ -659,16 +763,16 @@ describe('coding plans router', () => { microdollars_used: 0, }); await uploadKeysToInventory( - 'minimax', - PLAN_ID, + 'byteplus-coding', + BYTEPLUS_PLAN_ID, [inventoryEntry(`sk-cp-corrupt-${crypto.randomUUID()}`)], { - validateCredential: async () => true, + validateCredential: async () => ({ valid: true, upstreamUsageId: 'seat-corrupt' }), } ); const caller = await createCallerForUser(owner.id); const activation = await caller.codingPlans.subscribe({ - planId: PLAN_ID, + planId: BYTEPLUS_PLAN_ID, idempotencyKey: 'corrupt-assignment', }); const [subscription] = await db @@ -680,7 +784,6 @@ describe('coding plans router', () => { .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 }) @@ -688,7 +791,7 @@ describe('coding plans router', () => { code: 'INTERNAL_SERVER_ERROR', message: 'Coding Plan usage is unavailable.', }); - expect(request).not.toHaveBeenCalled(); + expect(mockedGetBytePlusUsage).not.toHaveBeenCalled(); await expect(caller.codingPlans.listSubscriptions()).resolves.toHaveLength(1); await expect( caller.codingPlans.getSubscriptionDetail({ subscriptionId: activation.subscriptionId }) @@ -705,21 +808,19 @@ describe('coding plans router', () => { microdollars_used: 0, }); await uploadKeysToInventory( - 'minimax', - PLAN_ID, + 'byteplus-coding', + BYTEPLUS_PLAN_ID, [inventoryEntry(`sk-cp-upstream-${crypto.randomUUID()}`)], { - validateCredential: async () => true, + validateCredential: async () => ({ valid: true, upstreamUsageId: 'seat-upstream' }), } ); const caller = await createCallerForUser(owner.id); const activation = await caller.codingPlans.subscribe({ - planId: PLAN_ID, + planId: BYTEPLUS_PLAN_ID, idempotencyKey: 'upstream-failure', }); - jest - .spyOn(global, 'fetch') - .mockResolvedValue(new Response('raw provider failure', { status: 503 })); + mockedGetBytePlusUsage.mockRejectedValueOnce(new CodingPlanUsageError('http')); await expect( caller.codingPlans.getUsage({ subscriptionId: activation.subscriptionId }) @@ -739,49 +840,58 @@ describe('coding plans router', () => { microdollars_used: 0, }); await uploadKeysToInventory( - 'minimax', - PLAN_ID, + 'byteplus-coding', + BYTEPLUS_PLAN_ID, [inventoryEntry(`second-purchase-key-${crypto.randomUUID()}`)], { - validateCredential: async () => true, + validateCredential: async () => ({ valid: true, upstreamUsageId: 'seat-second-purchase' }), } ); const caller = await createCallerForUser(owner.id); - await caller.codingPlans.subscribe({ planId: PLAN_ID, idempotencyKey: 'first-purchase' }); + await caller.codingPlans.subscribe({ + planId: BYTEPLUS_PLAN_ID, + idempotencyKey: 'first-purchase', + }); await expect( - caller.codingPlans.subscribe({ planId: PLAN_ID, idempotencyKey: 'new-purchase' }) + caller.codingPlans.subscribe({ planId: BYTEPLUS_PLAN_ID, idempotencyKey: 'new-purchase' }) ).rejects.toThrow('already has a live subscription'); expect(await db.select().from(coding_plan_terms)).toHaveLength(1); }); - it('rejects subscribing to another MiniMax token plan while one is live', async () => { + it('rejects subscribing to another BytePlus coding plan while one is live', async () => { const owner = await insertTestUser({ - total_microdollars_acquired: COST_MICRODOLLARS + MAX_COST_MICRODOLLARS, + total_microdollars_acquired: COST_MICRODOLLARS + BYTEPLUS_PRO_COST_MICRODOLLARS, microdollars_used: 0, }); await uploadKeysToInventory( - 'minimax', - PLAN_ID, - [inventoryEntry(`provider-plus-key-${crypto.randomUUID()}`)], + 'byteplus-coding', + BYTEPLUS_PLAN_ID, + [inventoryEntry(`provider-lite-key-${crypto.randomUUID()}`)], { - validateCredential: async () => true, + validateCredential: async () => ({ valid: true, upstreamUsageId: 'seat-provider-lite' }), } ); await uploadKeysToInventory( - 'minimax', - MAX_PLAN_ID, - [inventoryEntry(`provider-max-key-${crypto.randomUUID()}`)], + 'byteplus-coding', + BYTEPLUS_PRO_PLAN_ID, + [inventoryEntry(`provider-pro-key-${crypto.randomUUID()}`)], { - validateCredential: async () => true, + validateCredential: async () => ({ valid: true, upstreamUsageId: 'seat-provider-pro' }), } ); const caller = await createCallerForUser(owner.id); - await caller.codingPlans.subscribe({ planId: PLAN_ID, idempotencyKey: 'first-provider-plan' }); + await caller.codingPlans.subscribe({ + planId: BYTEPLUS_PLAN_ID, + idempotencyKey: 'first-provider-plan', + }); await expect( - caller.codingPlans.subscribe({ planId: MAX_PLAN_ID, idempotencyKey: 'second-provider-plan' }) - ).rejects.toThrow('MiniMax Coding Plan already has a live subscription'); + caller.codingPlans.subscribe({ + planId: BYTEPLUS_PRO_PLAN_ID, + idempotencyKey: 'second-provider-plan', + }) + ).rejects.toThrow('BytePlus Coding Plan already has a live subscription'); expect(await db.select().from(coding_plan_terms)).toHaveLength(1); }); diff --git a/apps/web/src/routers/coding-plans-router.ts b/apps/web/src/routers/coding-plans-router.ts index 9282092b226..665625e45cf 100644 --- a/apps/web/src/routers/coding-plans-router.ts +++ b/apps/web/src/routers/coding-plans-router.ts @@ -43,6 +43,7 @@ import { CODING_PLAN_IDS, getCodingPlanCatalog, getCodingPlanPrice, + isCodingPlanDisabledForNewSignups, } from '@/lib/coding-plans/pricing'; import { db } from '@/lib/drizzle'; import { UserByokProviderIdSchema } from '@/lib/ai-gateway/providers/openrouter/inference-provider-id'; @@ -579,6 +580,11 @@ export const codingPlansRouter = createTRPCRouter({ billingPeriodDays: plan.billingPeriodDays, availabilityStatus: toAvailabilityStatus(availablePlans.has(plan.planId)), notificationRequested: requestedNotifications.has(plan.planId), + // False for plans closed to new signups (see pricing.ts); the admin + // console still needs every catalog entry to manage existing + // inventory/subscriptions, so filtering happens in the customer-facing + // UI rather than here. + purchasable: !isCodingPlanDisabledForNewSignups(plan.planId), })); }), @@ -745,6 +751,9 @@ export const codingPlansRouter = createTRPCRouter({ if (message.includes('not available as a coding plan')) { throw new TRPCError({ code: 'NOT_FOUND', message }); } + if (message.includes('not currently accepting new signups')) { + throw new TRPCError({ code: 'FORBIDDEN', message }); + } if (message.includes('already has a live subscription')) { throw new TRPCError({ code: 'CONFLICT', message }); } @@ -762,6 +771,9 @@ export const codingPlansRouter = createTRPCRouter({ if (message.includes('currently available')) { throw new TRPCError({ code: 'CONFLICT', message }); } + if (message.includes('not currently accepting new signups')) { + throw new TRPCError({ code: 'FORBIDDEN', message }); + } throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message }); } }), From 490b0f78d2f23548613309f966ec0c03796a3c57 Mon Sep 17 00:00:00 2001 From: Anil Kulkarni Date: Fri, 25 Sep 2026 12:52:36 -0700 Subject: [PATCH 2/2] Fix failing test --- .../src/components/home/home-screen.mounted.test.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/mobile/src/components/home/home-screen.mounted.test.tsx b/apps/mobile/src/components/home/home-screen.mounted.test.tsx index 9fe397af826..aaa1817a02f 100644 --- a/apps/mobile/src/components/home/home-screen.mounted.test.tsx +++ b/apps/mobile/src/components/home/home-screen.mounted.test.tsx @@ -665,7 +665,12 @@ describe('Home live presentation', () => { // indicator while the live content is pending, so the platform pull // control must not also hold the scroll inset open for a second one. expect(refresh().props.refreshing).toBe(false); - expect(nodes('Skeleton')[0]?.props.className).toContain('min-h-[72px]'); + expect( + nodes('View').some(view => { + const className = String(view.props.className ?? ''); + return className.includes('min-h-[72px]') && className.includes('rounded-2xl'); + }) + ).toBe(true); await act(async () => { pending.resolve(true); await pending.promise;