Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/thick-plums-sneeze.md
Original file line number Diff line number Diff line change
@@ -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
4 changes: 3 additions & 1 deletion examples/utils/chatbot-minimal/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=***
4 changes: 2 additions & 2 deletions packages/nextjs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |

Expand All @@ -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
Expand All @@ -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`:
Expand Down
37 changes: 37 additions & 0 deletions packages/nextjs/src/mock-billing-data.ts
Original file line number Diff line number Diff line change
@@ -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;
33 changes: 13 additions & 20 deletions packages/nextjs/src/polar/createCheckout.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof getNarevClient>);

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<typeof getNarevClient>);
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();
});
});
26 changes: 10 additions & 16 deletions packages/nextjs/src/polar/createCheckout.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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<string> {
void productId;
void userId;
return successUrl;
}
95 changes: 12 additions & 83 deletions packages/nextjs/src/polar/fetchPolarUsage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof getNarevClient>);

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<typeof getNarevClient>);

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<typeof getNarevClient>);

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<typeof getNarevClient>);

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();
});
});
33 changes: 8 additions & 25 deletions packages/nextjs/src/polar/fetchPolarUsage.ts
Original file line number Diff line number Diff line change
@@ -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<PolarUsageData> {
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;
}
56 changes: 17 additions & 39 deletions packages/nextjs/src/polar/fetchTopUpConfig.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof getNarevClient>);

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<typeof getNarevClient>);

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<typeof getNarevClient>);

const result = await fetchTopUpConfig();
expect(result).toEqual({ packages: [] });
consoleError.mockRestore();
it('does not call getNarevClient', async () => {
await fetchTopUpConfig();
expect(getNarevClient).not.toHaveBeenCalled();
});
});
Loading
Loading