Access Control
Access control solves two questions: can this user use my product? and how much should be deducted?
Pay4SaaS provides two dimensions of access control:
- Basic access (
allowed) — yes/no, works across all four modes - Tier access (
useTierAccess) — checks if the user's subscription plan is high enough for a specific feature, subscription modes only
The Hero section on the homepage (
components/landing/Hero.tsx) contains complete frontend examples, including permission checks, consuming credits, and granting bonus credits.
Four Modes at a Glance
Before diving into the API, understand which mode your product uses — it determines how allowed and useService behave:
| Mode | allowed means | useService behavior |
|---|---|---|
| Credits | Has purchased credits (purchased_credits > 0) | Deducts credits per SERVICE_COSTS[serviceType] |
| Unlimited subscription | Has active subscription (incl. trial) | Logs usage only, no deduction; isUnlimited = true |
| Quota subscription | Has active subscription | Deducts in priority: quota → bonus credits → purchased credits; quota resets monthly |
| Lifetime | Has lifetime purchase | Logs usage only, no deduction; isUnlimited = true |
useAccess Hook
import { useAccess } from '@/hooks/useAccess'
function MyComponent() {
const { status, loading, error, refreshStatus, useService } = useAccess()
if (loading) return <div>Loading...</div>
if (!status.allowed) {
return <div>Please subscribe or purchase credits first</div>
}
return <div>Welcome!</div>
}Backend equivalent:
import { checkUserAccess } from '@/lib/payment/access'
const access = await checkUserAccess(userId)
if (!access.allowed) {
return Response.json({ error: 'No access' }, { status: 403 })
}status Object
{
allowed: boolean, // Whether the user has access
accessType: AccessType, // Access type (see below)
details: {
hasSubscription: boolean,
subscriptionPlan: string, // basic / pro / max
subscriptionStatus: string,
subscriptionBillingCycle: string, // monthly / yearly
subscriptionEndDate: string,
subscriptionProvider: string,
hasLifetime: boolean,
lifetimePlan: string,
availableCredits: number,
quota: { // Quota subscription mode only
monthlyLimit: number,
used: number,
remaining: number,
resetDate: string
},
isUnlimited: boolean,
hasUsedTrial: boolean,
}
}accessType Values
| Value | Description |
|---|---|
subscription_unlimited | Unlimited subscription |
subscription_quota | Quota subscription |
lifetime | Lifetime purchase |
credits | Credits |
none | No access |
useService — Consume a Service Unit
When a user clicks "Generate", "Use", etc., call useService to check access and deduct in one step:
const { useService } = useAccess()
async function handleGenerate() {
const result = await useService(
'article_generation', // Service type (must match a key in SERVICE_COSTS)
'Generated a blog post', // Description (optional)
'article-123' // Related ID (optional)
)
if (result.success) {
console.log('Remaining credits:', result.remainingCredits)
} else {
console.log('Error:', result.message)
}
}Important: The frontend only passes serviceType — the actual deduction amount is determined server-side by SERVICE_COSTS in config/payment.ts. Users cannot tamper with it via DevTools.
Backend equivalent:
import { fastConsumeService } from '@/lib/payment/access'
import { SERVICE_COSTS } from '@/config/payment'
const amount = SERVICE_COSTS['article_generation']
const result = await fastConsumeService(
userId,
amount, // Looked up from SERVICE_COSTS
'article_generation', // Service type
'Generate article', // Description (optional)
relatedId // Related ID (optional)
)
if (!result.success) {
return Response.json({ error: result.message }, { status: 403 })
}fastConsumeService atomically deducts credits/quota via a database RPC. The deduction amount always comes from the server-side SERVICE_COSTS config.
SERVICE_COSTS — Pricing Table
All service costs are defined in one place:
// config/payment.ts
export const SERVICE_COSTS: Record<string, number> = {
'demo-consume': 25,
'article_generation': 10, // Your custom service
}To add a new service, just add a line here — no other changes needed.
useService Return Value
{
success: boolean,
accessType: AccessType, // The actual access type used
remainingCredits: number,
message: string,
error?: string
}Tier Access — Feature Gating by Plan
Tier access answers a different question: not "does the user have credits?", but "is their subscription plan high enough for this feature?" It never deducts credits or records usage — it's a pure gate.
basic < pro < max(order derived fromSUBSCRIPTION_PLANSdeclaration order)- Only applies to subscription modes — lifetime and credits don't have tiers
- No subscription always fails any tier gate
useTierAccess — Frontend
import { useTierAccess } from '@/hooks/useTierAccess'
function ProFeatureButton() {
const canUse = useTierAccess('pro')
if (!canUse) {
return (
<button onClick={() => router.push('/pricing?plan=pro')}>
Upgrade to Pro to unlock
</button>
)
}
return <button onClick={handleDoFeature}>Use Pro Feature</button>
}hasTierAccess — Backend
Always verify on the server — frontend disabled is UX only and can be bypassed.
import { hasTierAccess } from '@/lib/payment/access'
export async function POST(req) {
const userId = await getUser(req)
if (!await hasTierAccess(userId, 'pro')) {
return Response.json({ error: 'tier_required', required: 'pro' }, { status: 403 })
}
return doProFeature()
}allowed vs useTierAccess — When to Use Which
status.allowed | useTierAccess('pro') | |
|---|---|---|
| Question | "Do they have something?" (credits, subscription, or lifetime) | "Is their plan high enough for this feature?" |
| Applicable modes | All four | Subscription only |
Practical guidance:
- Credits mode →
status.allowed+useService - Subscription mode →
status.allowedfor general access,useTierAccessfor premium features - Hybrid → both:
allowedfor entry,useTierAccessfor premium features
Docs home
Return to the full implementation guide.
Pricing
Review subscriptions, credits, and lifetime options.
Blog
Read more notes on SaaS payments and growth.