diff --git a/.changeset/thick-plums-sneeze.md b/.changeset/thick-plums-sneeze.md new file mode 100644 index 00000000..2b301b8b --- /dev/null +++ b/.changeset/thick-plums-sneeze.md @@ -0,0 +1,5 @@ +--- +"@ai-billing/nextjs": patch +--- + +fix(nextjs): mock removed Narev balance/credit-topup endpoints in billing UI server actions to prevent 404s diff --git a/examples/utils/chatbot-minimal/.env.example b/examples/utils/chatbot-minimal/.env.example index 45e8655b..7ede1463 100644 --- a/examples/utils/chatbot-minimal/.env.example +++ b/examples/utils/chatbot-minimal/.env.example @@ -8,6 +8,8 @@ POLAR_ACCESS_TOKEN=*** # Optional: Polar server environment (defaults to 'sandbox') # POLAR_SERVER=sandbox -# Required: Narev API key for pricing and credit management +# Required: Narev API key for model pricing # https://narev.ai +# Note: the credit balance / top-up UI (CreditUsagePolar, CreditTopUpPolar) +# currently returns mocked data and does not require this key. NAREV_API_KEY=*** diff --git a/packages/nextjs/README.md b/packages/nextjs/README.md index 6580c4a1..6f3dd23e 100644 --- a/packages/nextjs/README.md +++ b/packages/nextjs/README.md @@ -26,7 +26,6 @@ import { CreditUsagePolar, CreditTopUpPolar } from '@ai-billing/nextjs'; | Variable | Required | |----------|----------| -| `NAREV_API_KEY` | Config fetch | | `POLAR_ACCESS_TOKEN` | Meter usage + top-up | | `POLAR_SERVER` | `sandbox` or `production` | @@ -46,7 +45,6 @@ Stripe meters report values in nano-units. The component converts them to dollar | Variable | Required | |----------|----------| -| `NAREV_API_KEY` | Config fetch | | `STRIPE_SECRET_KEY` | Meter usage | ## Server Actions @@ -57,6 +55,8 @@ Server actions are available for advanced use cases: import { fetchPolarUsage, fetchStripeUsage, createCheckout } from '@ai-billing/nextjs/server'; ``` +> **Note:** `fetchPolarUsage`, `fetchStripeUsage`, `fetchTopUpConfig`, and `createCheckout` currently return mocked data. The Narev balance/credit endpoints they used to call have been removed, so no `NAREV_API_KEY` is required for these UI billing/config calls anymore. `NAREV_API_KEY` is still required for model pricing lookups (e.g. `createOpenAIWithBilling` and friends, `fetchModelPricing`), which continue to call the live Narev API. + ## Theming Components use CSS custom properties for styling. Override them via `className`: diff --git a/packages/nextjs/src/mock-billing-data.ts b/packages/nextjs/src/mock-billing-data.ts new file mode 100644 index 00000000..8b4a11c8 --- /dev/null +++ b/packages/nextjs/src/mock-billing-data.ts @@ -0,0 +1,37 @@ +import type { CreditPackage } from '@ai-billing/types'; +import type { PolarUsageData } from './polar/types.js'; +import type { StripeUsageData } from './stripe/types.js'; + +/** + * Temporary mock fixtures for the nextjs billing UI server actions. + * + * The Narev endpoints these actions used to call (`GET /v1/balance` and + * `GET`/`POST /v1/credit`) no longer exist. Until a replacement backend is + * available, `fetchPolarUsage`, `fetchStripeUsage`, `fetchTopUpConfig`, and + * `createCheckout` return deterministic mock data derived from these + * fixtures instead of calling `getNarevClient`. + */ + +/** Mock usage data returned by {@link fetchPolarUsage}. */ +export const MOCK_POLAR_USAGE_DATA: PolarUsageData = { + consumedUnits: 42, + creditedUnits: 100, + meterName: 'Usage', + found: true, +}; + +/** Mock usage data returned by {@link fetchStripeUsage}. */ +export const MOCK_STRIPE_USAGE_DATA: StripeUsageData = { + aggregatedValue: 12.5, + found: true, +}; + +/** Mock credit packages returned by {@link fetchTopUpConfig}. */ +export const MOCK_CREDIT_PACKAGES: CreditPackage[] = [ + { id: 'pkg_small', credits: 100, priceCents: 500 }, + { id: 'pkg_medium', credits: 500, priceCents: 2000 }, + { id: 'pkg_large', credits: 1000, priceCents: 3500 }, +]; + +/** Mock tax behavior returned alongside {@link MOCK_CREDIT_PACKAGES}. */ +export const MOCK_TAX_BEHAVIOR = 'exclusive' as const; diff --git a/packages/nextjs/src/polar/createCheckout.test.ts b/packages/nextjs/src/polar/createCheckout.test.ts index cbbf920c..a38c94a6 100644 --- a/packages/nextjs/src/polar/createCheckout.test.ts +++ b/packages/nextjs/src/polar/createCheckout.test.ts @@ -9,32 +9,25 @@ import { createCheckout } from './createCheckout.js'; beforeEach(() => { vi.clearAllMocks(); - process.env.NAREV_API_KEY = 'test-key'; }); describe('createCheckout', () => { - it('returns checkout URL on success', async () => { - vi.mocked(getNarevClient).mockReturnValueOnce({ - createCheckout: vi.fn().mockResolvedValueOnce({ - data: { url: 'https://polar.sh/checkout/sess_abc' }, - }), - } as ReturnType); - + it('returns the passed successUrl as the checkout URL', async () => { const url = await createCheckout('pkg_1', 'user_1', 'https://myapp.com'); - expect(url).toBe('https://polar.sh/checkout/sess_abc'); + expect(url).toBe('https://myapp.com'); }); - it('throws when checkout fails', async () => { - const consoleError = vi - .spyOn(console, 'error') - .mockImplementation(() => {}); - vi.mocked(getNarevClient).mockReturnValueOnce({ - createCheckout: vi.fn().mockRejectedValueOnce(new Error('API error')), - } as ReturnType); + it('is harmless for any productId/userId combination', async () => { + const url = await createCheckout( + 'pkg_2', + 'user_2', + 'https://myapp.com/success', + ); + expect(url).toBe('https://myapp.com/success'); + }); - await expect( - createCheckout('pkg_1', 'user_1', 'https://myapp.com'), - ).rejects.toThrow('Failed to create checkout'); - consoleError.mockRestore(); + it('does not call getNarevClient', async () => { + await createCheckout('pkg_1', 'user_1', 'https://myapp.com'); + expect(getNarevClient).not.toHaveBeenCalled(); }); }); diff --git a/packages/nextjs/src/polar/createCheckout.ts b/packages/nextjs/src/polar/createCheckout.ts index b86b14a2..9fc0270d 100644 --- a/packages/nextjs/src/polar/createCheckout.ts +++ b/packages/nextjs/src/polar/createCheckout.ts @@ -1,9 +1,12 @@ 'use server'; -import { getNarevClient } from '../narev-client.js'; - /** - * Creates a checkout session via Narev and returns the URL. + * Returns a mock checkout URL for a credit package purchase. + * + * The Narev checkout endpoint (`POST /v1/credit`) this action used to call + * no longer exists. Until a replacement backend is available this safely + * returns the passed-in `successUrl` instead of making a network call, so + * the caller's redirect flow keeps working harmlessly. * @param productId - the credit package product ID * @param userId - the end-user ID * @param successUrl - URL to redirect after successful purchase @@ -12,17 +15,8 @@ export async function createCheckout( productId: string, userId: string, successUrl: string, -) { - try { - const client = getNarevClient(); - const response = await client.createCheckout({ - productId, - userId, - successUrl, - }); - return response.data.url; - } catch (error) { - console.error('Create checkout failed:', error); - throw new Error('Failed to create checkout', { cause: error }); - } +): Promise { + void productId; + void userId; + return successUrl; } diff --git a/packages/nextjs/src/polar/fetchPolarUsage.test.ts b/packages/nextjs/src/polar/fetchPolarUsage.test.ts index 3df31bb1..34cb1b88 100644 --- a/packages/nextjs/src/polar/fetchPolarUsage.test.ts +++ b/packages/nextjs/src/polar/fetchPolarUsage.test.ts @@ -6,100 +6,29 @@ vi.mock('../narev-client.js', () => ({ import { getNarevClient } from '../narev-client.js'; import { fetchPolarUsage } from './fetchPolarUsage.js'; +import { MOCK_POLAR_USAGE_DATA } from '../mock-billing-data.js'; beforeEach(() => { vi.clearAllMocks(); - process.env.NAREV_API_KEY = 'test-key'; }); describe('fetchPolarUsage', () => { - it('returns usage data from Narev balance', async () => { - vi.mocked(getNarevClient).mockReturnValueOnce({ - getBalance: vi.fn().mockResolvedValueOnce({ - data: { - unitsBalance: 50, - unitsConsumed: 42, - unitsCredited: 100, - unit: 'base', - currency: 'USD', - meterName: 'Tokens', - found: true, - }, - }), - } as ReturnType); - - const result = await fetchPolarUsage('user_1'); - expect(result).toEqual({ - consumedUnits: 42, - creditedUnits: 100, - meterName: 'Tokens', - found: true, - }); - }); - - it('maps null creditedUnits to 0', async () => { - vi.mocked(getNarevClient).mockReturnValueOnce({ - getBalance: vi.fn().mockResolvedValueOnce({ - data: { - unitsBalance: null, - unitsConsumed: 10, - unitsCredited: null, - unit: 'base', - currency: 'USD', - meterName: 'Usage', - found: true, - }, - }), - } as ReturnType); - + it('returns the mock usage data', async () => { const result = await fetchPolarUsage('user_1'); - expect(result).toEqual({ - consumedUnits: 10, - creditedUnits: 0, - meterName: 'Usage', - found: true, - }); + expect(result).toEqual(MOCK_POLAR_USAGE_DATA); }); - it('returns empty when not found', async () => { - vi.mocked(getNarevClient).mockReturnValueOnce({ - getBalance: vi.fn().mockResolvedValueOnce({ - data: { - unitsBalance: null, - unitsConsumed: 0, - unitsCredited: null, - unit: 'base', - currency: 'USD', - meterName: 'Usage', - found: false, - }, - }), - } as ReturnType); - + it('returns a found, non-empty, contract-valid result', async () => { const result = await fetchPolarUsage('user_1'); - expect(result).toEqual({ - consumedUnits: 0, - creditedUnits: 0, - meterName: 'Usage', - found: false, - }); + expect(result.found).toBe(true); + expect(result.consumedUnits).toBeGreaterThanOrEqual(0); + expect(result.creditedUnits).toBeGreaterThanOrEqual(0); + expect(typeof result.meterName).toBe('string'); + expect(result.meterName.length).toBeGreaterThan(0); }); - it('returns empty when Narev API throws', async () => { - const consoleError = vi - .spyOn(console, 'error') - .mockImplementation(() => {}); - vi.mocked(getNarevClient).mockReturnValueOnce({ - getBalance: vi.fn().mockRejectedValueOnce(new Error('API error')), - } as ReturnType); - - const result = await fetchPolarUsage('user_1'); - expect(result).toEqual({ - consumedUnits: 0, - creditedUnits: 0, - meterName: 'Usage', - found: false, - }); - consoleError.mockRestore(); + it('does not call getNarevClient', async () => { + await fetchPolarUsage('user_1'); + expect(getNarevClient).not.toHaveBeenCalled(); }); }); diff --git a/packages/nextjs/src/polar/fetchPolarUsage.ts b/packages/nextjs/src/polar/fetchPolarUsage.ts index c74a8b3b..8bd25c90 100644 --- a/packages/nextjs/src/polar/fetchPolarUsage.ts +++ b/packages/nextjs/src/polar/fetchPolarUsage.ts @@ -1,34 +1,17 @@ 'use server'; -import { getNarevClient } from '../narev-client.js'; +import { MOCK_POLAR_USAGE_DATA } from '../mock-billing-data.js'; import type { PolarUsageData } from './types.js'; /** - * Fetches usage data for a given user via the Narev API. + * Returns mock usage data for a given user. + * + * The Narev balance endpoint (`GET /v1/balance`) this action used to call no + * longer exists. Until a replacement backend is available this returns + * deterministic mock data instead of making a network call. * @param userId - the end-user ID */ export async function fetchPolarUsage(userId: string): Promise { - const empty = { - consumedUnits: 0, - creditedUnits: 0, - meterName: 'Usage', - found: false, - }; - - try { - const client = getNarevClient(); - const response = await client.getBalance({ userId }); - const data = response.data; - - return { - consumedUnits: data.unitsConsumed, - creditedUnits: data.unitsCredited ?? 0, - meterName: data.meterName, - found: data.found, - }; - } catch (error) { - console.error('fetchPolarUsage:', error); - } - - return empty; + void userId; + return MOCK_POLAR_USAGE_DATA; } diff --git a/packages/nextjs/src/polar/fetchTopUpConfig.test.ts b/packages/nextjs/src/polar/fetchTopUpConfig.test.ts index 64c6ddee..6514f694 100644 --- a/packages/nextjs/src/polar/fetchTopUpConfig.test.ts +++ b/packages/nextjs/src/polar/fetchTopUpConfig.test.ts @@ -6,56 +6,34 @@ vi.mock('../narev-client.js', () => ({ import { getNarevClient } from '../narev-client.js'; import { fetchTopUpConfig } from './fetchTopUpConfig.js'; +import { + MOCK_CREDIT_PACKAGES, + MOCK_TAX_BEHAVIOR, +} from '../mock-billing-data.js'; beforeEach(() => { vi.clearAllMocks(); - process.env.NAREV_API_KEY = 'test-key'; }); describe('fetchTopUpConfig', () => { - it('returns packages and tax behavior from Narev', async () => { - vi.mocked(getNarevClient).mockReturnValueOnce({ - getCreditConfig: vi.fn().mockResolvedValueOnce({ - data: { - packages: [ - { id: 'pkg_1', credits: 100, priceCents: 1000 }, - { id: 'pkg_2', credits: 500, priceCents: 4500 }, - ], - taxBehavior: 'inclusive', - }, - }), - } as ReturnType); - + it('returns the mock packages and tax behavior', async () => { const result = await fetchTopUpConfig(); - expect(result.packages).toEqual([ - { id: 'pkg_1', credits: 100, priceCents: 1000 }, - { id: 'pkg_2', credits: 500, priceCents: 4500 }, - ]); - expect(result.taxBehavior).toBe('inclusive'); + expect(result.packages).toEqual(MOCK_CREDIT_PACKAGES); + expect(result.taxBehavior).toBe(MOCK_TAX_BEHAVIOR); }); - it('returns empty packages when no packages available', async () => { - vi.mocked(getNarevClient).mockReturnValueOnce({ - getCreditConfig: vi.fn().mockResolvedValueOnce({ - data: { packages: [] }, - }), - } as ReturnType); - + it('returns a non-empty, contract-valid package list', async () => { const result = await fetchTopUpConfig(); - expect(result.packages).toEqual([]); - expect(result.taxBehavior).toBeUndefined(); + expect(result.packages.length).toBeGreaterThan(0); + for (const pkg of result.packages) { + expect(typeof pkg.id).toBe('string'); + expect(pkg.credits).toBeGreaterThan(0); + expect(pkg.priceCents).toBeGreaterThan(0); + } }); - it('returns empty config when Narev API throws', async () => { - const consoleError = vi - .spyOn(console, 'error') - .mockImplementation(() => {}); - vi.mocked(getNarevClient).mockReturnValueOnce({ - getCreditConfig: vi.fn().mockRejectedValueOnce(new Error('API down')), - } as ReturnType); - - const result = await fetchTopUpConfig(); - expect(result).toEqual({ packages: [] }); - consoleError.mockRestore(); + it('does not call getNarevClient', async () => { + await fetchTopUpConfig(); + expect(getNarevClient).not.toHaveBeenCalled(); }); }); diff --git a/packages/nextjs/src/polar/fetchTopUpConfig.ts b/packages/nextjs/src/polar/fetchTopUpConfig.ts index 487ee512..03a7e4a1 100644 --- a/packages/nextjs/src/polar/fetchTopUpConfig.ts +++ b/packages/nextjs/src/polar/fetchTopUpConfig.ts @@ -1,6 +1,9 @@ 'use server'; -import { getNarevClient } from '../narev-client.js'; +import { + MOCK_CREDIT_PACKAGES, + MOCK_TAX_BEHAVIOR, +} from '../mock-billing-data.js'; import type { CreditPackage } from './types.js'; interface TopUpConfig { @@ -8,23 +11,16 @@ interface TopUpConfig { taxBehavior?: 'inclusive' | 'exclusive' | 'location'; } -/** Fetches available top-up packages and optional tax behavior from Narev. */ +/** + * Returns mock top-up packages and tax behavior. + * + * The Narev credit config endpoint (`GET /v1/credit`) this action used to + * call no longer exists. Until a replacement backend is available this + * returns deterministic mock data instead of making a network call. + */ export async function fetchTopUpConfig(): Promise { - const config: TopUpConfig = { packages: [] }; - - try { - const client = getNarevClient(); - const response = await client.getCreditConfig(); - const data = response.data; - - config.packages = data.packages; - - if (data.taxBehavior) { - config.taxBehavior = data.taxBehavior; - } - } catch (error) { - console.error('fetchTopUpConfig: config fetch failed', error); - } - - return config; + return { + packages: MOCK_CREDIT_PACKAGES, + taxBehavior: MOCK_TAX_BEHAVIOR, + }; } diff --git a/packages/nextjs/src/stripe/fetchStripeUsage.test.ts b/packages/nextjs/src/stripe/fetchStripeUsage.test.ts index 7d3103c9..88ce9fd5 100644 --- a/packages/nextjs/src/stripe/fetchStripeUsage.test.ts +++ b/packages/nextjs/src/stripe/fetchStripeUsage.test.ts @@ -6,80 +6,31 @@ vi.mock('../narev-client.js', () => ({ import { getNarevClient } from '../narev-client.js'; import { fetchStripeUsage } from './fetchStripeUsage.js'; +import { MOCK_STRIPE_USAGE_DATA } from '../mock-billing-data.js'; beforeEach(() => { vi.clearAllMocks(); - process.env.NAREV_API_KEY = 'test-key'; }); describe('fetchStripeUsage', () => { - it('converts nanos to dollars when unit is nanos', async () => { - vi.mocked(getNarevClient).mockReturnValueOnce({ - getBalance: vi.fn().mockResolvedValueOnce({ - data: { - unitsBalance: null, - unitsConsumed: 1_417_500, - unitsCredited: null, - unit: 'nanos', - currency: 'USD', - meterName: 'Usage', - found: true, - }, - }), - } as ReturnType); - - const result = await fetchStripeUsage({ stripeCustomerId: 'cus_1' }); - expect(result).toEqual({ aggregatedValue: 0.0014175, found: true }); - }); - - it('does not convert when unit is base', async () => { - vi.mocked(getNarevClient).mockReturnValueOnce({ - getBalance: vi.fn().mockResolvedValueOnce({ - data: { - unitsBalance: 50, - unitsConsumed: 25, - unitsCredited: 100, - unit: 'base', - currency: 'USD', - meterName: 'Usage', - found: true, - }, - }), - } as ReturnType); - + it('returns the mock usage data for a userId lookup', async () => { const result = await fetchStripeUsage({ userId: 'user_1' }); - expect(result).toEqual({ aggregatedValue: 25, found: true }); + expect(result).toEqual(MOCK_STRIPE_USAGE_DATA); }); - it('returns empty when not found', async () => { - vi.mocked(getNarevClient).mockReturnValueOnce({ - getBalance: vi.fn().mockResolvedValueOnce({ - data: { - unitsBalance: null, - unitsConsumed: 0, - unitsCredited: null, - unit: 'nanos', - currency: 'USD', - meterName: 'Usage', - found: false, - }, - }), - } as ReturnType); - + it('returns the mock usage data for a stripeCustomerId lookup', async () => { const result = await fetchStripeUsage({ stripeCustomerId: 'cus_1' }); - expect(result).toEqual({ aggregatedValue: 0, found: false }); + expect(result).toEqual(MOCK_STRIPE_USAGE_DATA); }); - it('returns empty on Narev API error', async () => { - const consoleError = vi - .spyOn(console, 'error') - .mockImplementation(() => {}); - vi.mocked(getNarevClient).mockReturnValueOnce({ - getBalance: vi.fn().mockRejectedValueOnce(new Error('API down')), - } as ReturnType); + it('returns a found, non-empty, contract-valid result', async () => { + const result = await fetchStripeUsage({ userId: 'user_1' }); + expect(result.found).toBe(true); + expect(result.aggregatedValue).toBeGreaterThan(0); + }); - const result = await fetchStripeUsage({ stripeCustomerId: 'cus_1' }); - expect(result).toEqual({ aggregatedValue: 0, found: false }); - consoleError.mockRestore(); + it('does not call getNarevClient', async () => { + await fetchStripeUsage({ userId: 'user_1' }); + expect(getNarevClient).not.toHaveBeenCalled(); }); }); diff --git a/packages/nextjs/src/stripe/fetchStripeUsage.ts b/packages/nextjs/src/stripe/fetchStripeUsage.ts index 6e95f466..dde9e57b 100644 --- a/packages/nextjs/src/stripe/fetchStripeUsage.ts +++ b/packages/nextjs/src/stripe/fetchStripeUsage.ts @@ -1,35 +1,20 @@ 'use server'; import type { GetBalanceRequest } from '@ai-billing/types'; -import { getNarevClient } from '../narev-client.js'; +import { MOCK_STRIPE_USAGE_DATA } from '../mock-billing-data.js'; import type { StripeUsageData } from './types.js'; /** - * Fetches usage data for a given customer via the Narev API. + * Returns mock usage data for a given customer. + * + * The Narev balance endpoint (`GET /v1/balance`) this action used to call no + * longer exists. Until a replacement backend is available this returns + * deterministic mock data instead of making a network call. * @param request - User identifier — either `{ userId }` or `{ stripeCustomerId }`. */ export async function fetchStripeUsage( request: GetBalanceRequest, ): Promise { - const empty = { aggregatedValue: 0, found: false }; - - try { - const client = getNarevClient(); - const response = await client.getBalance(request); - const data = response.data; - - const aggregatedValue = - data.unit === 'nanos' - ? data.unitsConsumed / 1_000_000_000 - : data.unitsConsumed; - - return { - aggregatedValue, - found: data.found, - }; - } catch (error) { - console.error('fetchStripeUsage:', error); - } - - return empty; + void request; + return MOCK_STRIPE_USAGE_DATA; }