| name | payments |
| description | Use this skill when: Add Stripe checkout, fix billing logic, deduct credits for a feature, create a credits-consuming API route or mutation, check subscription status, gate features by plan, handle one-time purchases, top up credits, or work with the billing snapshot. Focus on user intent. Trigger keywords: payment, billing, credits, subscription, stripe, checkout, one-time, top-up, deductCredits, InsufficientCreditsError, useCredits, useSubscriptions. |
Payments Context
Use this context for billing docs, billing UI, and payment-related changes.
Current scope
- Provider: Stripe (
APP_CONFIG.billing.provider = 'stripe').
- Flows:
subscription, one_time, credits checkout kinds.
- Main client hooks:
useSubscriptions() for snapshot, checkout, plan update, cancel, portal.
useCredits() for credits top-up + billing refresh.
Config entry points
src/config/app.ts
billing.plans.items[].prices[] includes recurring prices.
billing.oneTime.items[].prices[] for one-time products.
billing.credits.packages[] for fixed credit packs.
billing.credits.enterManually + pricePerCredit for manual amount flow.
src/config/env.ts
STRIPE_SECRET_KEY
STRIPE_WEBHOOK_SECRET
One-time payment flow
Config shape
billing: {
oneTime: {
enabled: true,
items: [
{
id: 'prod_STRIPE_PRODUCT_ID',
name: 'Basic',
description: '...',
features: ['...'],
prices: [
{
priceId: 'price_STRIPE_PRICE_ID',
amount: 99,
currency: 'usd',
},
],
},
],
},
},
Ownership tracking
user.oneTimeProductIds String[] — Prisma array field on the User model.
- Appended in
handleStripeCheckoutCompleted when kind === 'one_time' and payment is paid.
- Idempotent: only pushed if not already present (guarded by
existingTransaction.status).
- Exposed via
getBillingSnapshot → oneTimeProductIds: string[].
Re-purchase guard
- UI: Buy button replaced with disabled "Purchased" when product ID is in
oneTimeProductIds.
- Service:
createCheckoutLink queries user.oneTimeProductIds before creating a Stripe session; throws BillingConfigError('Product already purchased') if already owned.
Key service functions (all in payment.service.ts)
getPriceFromConfig(priceId) — searches both plans.items and oneTime.items prices.
getOneTimeItemByPriceId(priceId) — returns the oneTime.items entry for a given priceId, or null.
- Checkout metadata includes
productId for one-time items so the webhook can record ownership.
fulfillOneTimeProduct — shared fulfilment entry point (see below).
fulfillOneTimeProduct — shared fulfilment function
Location: src/features/common/payments/service/payment.service.ts
Called by both the Stripe webhook and the admin manual-grant action. Never call the individual steps (ownership push, email) directly — always go through this function.
Supports two modes — authenticated and guest:
import { fulfillOneTimeProduct } from '@/features/common/payments/service/payment.service';
await fulfillOneTimeProduct({
userId,
productId,
amountText,
});
await fulfillOneTimeProduct({
guest: true,
email,
name,
productId,
amountText,
});
What it does (in order):
- Validates
productId exists in config (throws BillingConfigError if not).
- Authenticated: Idempotently pushes
productId onto user.oneTimeProductIds.
- Runs post-purchase side-effects — edit the
console.log block to add custom actions, 3rd-party webhook calls, feature flags, etc.
- Sends
OneTimePurchaseEmail (non-fatal — logged on failure). Authenticated users get a dashboard link; guests get a homepage link.
Dashboard tab
- Route:
/dashboard/billings/one-time (BillingTab = 'one-time').
- Component:
src/features/dashboard/billing/components/one-time-tab.tsx — self-contained, calls useOneTimePayment() internally (no props).
- Shown when
APP_CONFIG.billing.oneTime.enabled is true.
- No portal / refund links — display and checkout only.
useOneTimePayment hook
Location: src/features/common/payments/hooks/use-one-time-payment.ts
const {
items,
oneTimeProductIds,
ownsProduct,
startCheckout,
isCheckoutPending,
pendingItemId,
isLoading,
snapshotQuery,
} = useOneTimePayment({ snapshotEnabled: true });
Use snapshotEnabled: isAuthenticated on public pages (pricing) to avoid fetching for logged-out users.
Email template
src/shared/emails/one-time-purchase.tsx
- Props:
name, productName, amountText, dashboardLink
Checking ownership (client)
import { useOneTimePayment } from '@/features/common/payments/hooks/use-one-time-payment';
const { ownsProduct } = useOneTimePayment();
const owned = ownsProduct('prod_ID');
const { data } = useQuery(orpc.payments.getBillingSnapshot.queryOptions());
const owned = data?.oneTimeProductIds.includes('prod_ID') ?? false;
Checking ownership (server)
const user = await prisma.user.findUnique({
where: { id: userId },
select: { oneTimeProductIds: true },
});
const owned = user?.oneTimeProductIds.includes('prod_ID') ?? false;
Credits-consuming feature pattern
Always use deductCreditsBonusFirst from credits-balance.service.ts — never write manual credit deduction logic (never directly call prisma.user.update with creditsBalance: { decrement } yourself). The service handles bonus-first ordering, the DB transaction, and throws InsufficientCreditsError on failure.
Choose your backend pattern based on response type
| Response type | Use |
|---|
| JSON (standard mutation) | oRPC router procedure |
| Streaming text (AI generation) | Next.js API route + streamText |
Pattern A — oRPC router (JSON response)
1. Service — deduct credits
import {
deductCreditsBonusFirst,
InsufficientCreditsError,
} from '@/features/common/payments/service/credits-balance.service';
export async function runMyFeature(userId: string, input: string) {
await deductCreditsBonusFirst({
userId,
credits: 10,
reason: 'my-feature',
});
return doWork(input);
}
2. oRPC router — surface PAYMENT_REQUIRED
import { ORPCError } from '@orpc/server';
import { InsufficientCreditsError } from '@/features/common/payments/service/credits-balance.service';
import { authorized } from '@/lib/orpc/context/authorized';
export const myFeatureRouter = {
run: authorized
.route({ method: 'POST', path: '/my-feature/run' })
.input(MyFeatureInputSchema)
.output(MyFeatureOutputSchema)
.handler(async ({ context, input }) => {
try {
return await runMyFeature(context.user.id, input.prompt);
} catch (error) {
if (error instanceof InsufficientCreditsError) {
throw new ORPCError('PAYMENT_REQUIRED', {
message: 'Insufficient credits. Please top up your balance.',
});
}
throw error;
}
}),
};
Register in src/lib/orpc/router.ts:
export const router = { ..., myFeature: myFeatureRouter };
3. UI — mutation + top-up modal
'use client';
import { useState } from 'react';
import { useMutation } from '@tanstack/react-query';
import { toast } from 'sonner';
import { orpc } from '@/lib/orpc';
import { CreditsTopUpModal } from '@/features/common/payments/components/credits-top-up-modal';
import { useCreditsTopUpModal } from '@/features/common/payments/hooks/use-credits-top-up-modal';
import { Alert, AlertDescription } from '@/shared/components/ui/alert';
import { Button } from '@/shared/components/ui/button';
export function MyFeatureWidget() {
const [noCredits, setNoCredits] = useState(false);
const modal = useCreditsTopUpModal();
const runMutation = useMutation(
orpc.myFeature.run.mutationOptions({
onSuccess: () => { setNoCredits(false); toast.success('Done!'); },
onError: (error) => {
if (error.message?.includes('Insufficient')) setNoCredits(true);
else toast.error(error.message ?? 'Something went wrong');
},
}),
);
return (
<>
{noCredits && (
<Alert variant='destructive'>
<AlertDescription>
Insufficient credits.{' '}
<button type='button' onClick={modal.openTopUpModal}
className='underline font-medium'>
Top up balance
</button>
</AlertDescription>
</Alert>
)}
<Button onClick={() => runMutation.mutate({ prompt: 'hello' })}
disabled={runMutation.isPending}>
Run (costs 10 credits)
</Button>
<CreditsTopUpModal open={modal.isOpen} onOpenChange={modal.setIsOpen} />
</>
);
}
Pattern B — Next.js API route (streaming response)
Use this when the feature streams tokens to the client (e.g. AI text generation) — oRPC cannot stream, so a regular Next.js route handler is required.
1. API route — deduct then stream
Located at e.g. src/app/api/ai/my-feature/route.ts.
import { headers } from 'next/headers';
import { streamText } from 'ai';
import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server';
import { auth } from '@/lib/auth';
import {
deductCreditsBonusFirst,
InsufficientCreditsError,
} from '@/features/common/payments/service/credits-balance.service';
const COST = 50;
export async function POST(req: NextRequest) {
const session = await auth.api.getSession({ headers: await headers() });
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { prompt } = await req.json();
try {
await deductCreditsBonusFirst({
userId: session.user.id,
credits: COST,
reason: 'my-feature',
});
} catch (error) {
if (error instanceof InsufficientCreditsError) {
return NextResponse.json({ error: 'INSUFFICIENT_CREDITS' }, { status: 402 });
}
throw error;
}
const result = streamText({
model: 'openai/gpt-5-nano',
prompt,
abortSignal: req.signal,
});
return result.toTextStreamResponse();
}
Key rules:
- Deduct credits before starting the stream — if deduction fails the client gets a 402 immediately.
- Return
result.toTextStreamResponse() — this is the correct format for useCompletion.
- Do not use
toUIMessageStreamResponse() here — that is only for useChat.
2. UI — useCompletion hook + top-up modal
'use client';
import { useState } from 'react';
import { useCompletion } from '@ai-sdk/react';
import { toast } from 'sonner';
import { useCredits } from '@/features/common/payments/hooks/use-credits';
import { useCreditsTopUpModal } from '@/features/common/payments/hooks/use-credits-top-up-modal';
import { CreditsTopUpModal } from '@/features/common/payments/components/credits-top-up-modal';
import { Button } from '@/shared/components/ui/button';
const COST = 50;
export function MyStreamingWidget() {
const [prompt, setPrompt] = useState('');
const modal = useCreditsTopUpModal();
const { creditsBalance, creditsBonus, refreshBillingState } = useCredits();
const available = (creditsBalance ?? 0) + (creditsBonus ?? 0);
const { completion, isLoading, complete, setCompletion } = useCompletion({
api: '/api/ai/my-feature',
onFinish: () => {
toast.success('Done!');
refreshBillingState();
},
onError: (error) => {
if (error.message.includes('INSUFFICIENT_CREDITS')) {
toast.error('Insufficient credits');
modal.openTopUpModal();
} else {
toast.error('Something went wrong');
}
},
});
const handleRun = () => {
if (available < COST) {
toast.error('Insufficient credits');
modal.openTopUpModal();
return;
}
setCompletion('');
complete(prompt);
};
return (
<>
<Button onClick={handleRun} disabled={isLoading}>
Generate ({COST} credits)
</Button>
{completion && <div>{completion}</div>}
<CreditsTopUpModal open={modal.isOpen} onOpenChange={modal.setIsOpen} />
</>
);
}
Notes:
- Call
refreshBillingState() in onFinish so the credits display updates after generation.
- The client-side available-credits check is a UX courtesy only — the server always wins.
@ai-sdk/react is already installed; no additional dependencies needed.
Guardrails
- Do not leak internal service logic in user docs.
- Always include success/cancel URL handling in checkout examples.
- Keep naming aligned with real IDs from
APP_CONFIG examples.
Validation & Gotchas
- Ensure all paths use forward slashes
/.
- Check edge cases related to Integrating Payments.