recur-skills 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +25 -0
- package/README.md +133 -0
- package/dist/cli.js +163 -0
- package/dist/index.d.ts +27 -0
- package/dist/index.js +72 -0
- package/package.json +64 -0
- package/skills/recur-checkout/SKILL.md +248 -0
- package/skills/recur-entitlements/SKILL.md +389 -0
- package/skills/recur-quickstart/SKILL.md +114 -0
- package/skills/recur-quickstart/scripts/check-env.sh +57 -0
- package/skills/recur-webhooks/SKILL.md +349 -0
- package/skills/recur-webhooks/scripts/test-webhook.sh +111 -0
- package/skills/recur-webhooks/scripts/verify-signature.ts +59 -0
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: recur-checkout
|
|
3
|
+
description: Implement Recur checkout flows including embedded, modal, and redirect modes. Use when adding payment buttons, checkout forms, subscription purchase flows, or when user mentions "checkout", "結帳", "付款按鈕", "embedded checkout".
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Recur Checkout Integration
|
|
7
|
+
|
|
8
|
+
You are helping implement Recur checkout flows. Recur supports multiple checkout modes for different use cases.
|
|
9
|
+
|
|
10
|
+
## Checkout Modes
|
|
11
|
+
|
|
12
|
+
| Mode | Best For | User Experience |
|
|
13
|
+
|------|----------|-----------------|
|
|
14
|
+
| `embedded` | SPA apps | Form renders inline in your page |
|
|
15
|
+
| `modal` | Quick purchases | Form appears in a dialog overlay |
|
|
16
|
+
| `redirect` | Simple integration | Full page redirect to Recur |
|
|
17
|
+
|
|
18
|
+
## Basic Implementation
|
|
19
|
+
|
|
20
|
+
### Using useRecur Hook
|
|
21
|
+
|
|
22
|
+
```tsx
|
|
23
|
+
import { useRecur } from 'recur-tw'
|
|
24
|
+
|
|
25
|
+
function CheckoutButton({ productId }: { productId: string }) {
|
|
26
|
+
const { checkout, isLoading } = useRecur()
|
|
27
|
+
|
|
28
|
+
const handleClick = async () => {
|
|
29
|
+
await checkout({
|
|
30
|
+
productId,
|
|
31
|
+
// Or use productSlug: 'pro-plan'
|
|
32
|
+
|
|
33
|
+
// Optional: Pre-fill customer info
|
|
34
|
+
customerEmail: 'user@example.com',
|
|
35
|
+
customerName: 'John Doe',
|
|
36
|
+
|
|
37
|
+
// Optional: Link to your user system
|
|
38
|
+
externalCustomerId: 'user_123',
|
|
39
|
+
|
|
40
|
+
// Callbacks
|
|
41
|
+
onPaymentComplete: (result) => {
|
|
42
|
+
console.log('Success!', result)
|
|
43
|
+
// result.id - Subscription/Order ID
|
|
44
|
+
// result.status - 'ACTIVE', 'TRIALING', etc.
|
|
45
|
+
},
|
|
46
|
+
onPaymentFailed: (error) => {
|
|
47
|
+
console.error('Failed:', error)
|
|
48
|
+
return { action: 'retry' } // or 'close' or 'custom'
|
|
49
|
+
},
|
|
50
|
+
onPaymentCancel: () => {
|
|
51
|
+
console.log('User cancelled')
|
|
52
|
+
},
|
|
53
|
+
})
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return (
|
|
57
|
+
<button onClick={handleClick} disabled={isLoading}>
|
|
58
|
+
{isLoading ? 'Processing...' : 'Subscribe'}
|
|
59
|
+
</button>
|
|
60
|
+
)
|
|
61
|
+
}
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
### Using useSubscribe Hook (with state management)
|
|
65
|
+
|
|
66
|
+
```tsx
|
|
67
|
+
import { useSubscribe } from 'recur-tw'
|
|
68
|
+
|
|
69
|
+
function SubscribeButton({ productId }: { productId: string }) {
|
|
70
|
+
const { subscribe, isLoading, error, subscription } = useSubscribe()
|
|
71
|
+
|
|
72
|
+
const handleClick = () => {
|
|
73
|
+
subscribe({
|
|
74
|
+
productId,
|
|
75
|
+
onPaymentComplete: (sub) => {
|
|
76
|
+
// Subscription created successfully
|
|
77
|
+
router.push('/dashboard')
|
|
78
|
+
},
|
|
79
|
+
})
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (subscription) {
|
|
83
|
+
return <p>Subscribed! ID: {subscription.id}</p>
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return (
|
|
87
|
+
<>
|
|
88
|
+
<button onClick={handleClick} disabled={isLoading}>
|
|
89
|
+
Subscribe
|
|
90
|
+
</button>
|
|
91
|
+
{error && <p className="error">{error.message}</p>}
|
|
92
|
+
</>
|
|
93
|
+
)
|
|
94
|
+
}
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## Embedded Mode Setup
|
|
98
|
+
|
|
99
|
+
For embedded mode, you need a container element:
|
|
100
|
+
|
|
101
|
+
```tsx
|
|
102
|
+
// In RecurProvider config
|
|
103
|
+
<RecurProvider
|
|
104
|
+
config={{
|
|
105
|
+
publishableKey: process.env.NEXT_PUBLIC_RECUR_PUBLISHABLE_KEY,
|
|
106
|
+
checkoutMode: 'embedded',
|
|
107
|
+
containerElementId: 'recur-checkout-container',
|
|
108
|
+
}}
|
|
109
|
+
>
|
|
110
|
+
{children}
|
|
111
|
+
</RecurProvider>
|
|
112
|
+
|
|
113
|
+
// In your checkout page
|
|
114
|
+
function CheckoutPage() {
|
|
115
|
+
return (
|
|
116
|
+
<div>
|
|
117
|
+
<h1>Complete Your Purchase</h1>
|
|
118
|
+
{/* Recur will render the payment form here */}
|
|
119
|
+
<div id="recur-checkout-container" />
|
|
120
|
+
</div>
|
|
121
|
+
)
|
|
122
|
+
}
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
## Handling 3D Verification
|
|
126
|
+
|
|
127
|
+
Recur handles 3D Secure automatically. For mobile apps or specific flows:
|
|
128
|
+
|
|
129
|
+
```tsx
|
|
130
|
+
await checkout({
|
|
131
|
+
productId,
|
|
132
|
+
// These URLs are used when 3D verification requires redirect
|
|
133
|
+
successUrl: 'https://yourapp.com/checkout/success',
|
|
134
|
+
cancelUrl: 'https://yourapp.com/checkout/cancel',
|
|
135
|
+
})
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
## Product Types
|
|
139
|
+
|
|
140
|
+
Recur supports different product types:
|
|
141
|
+
|
|
142
|
+
```tsx
|
|
143
|
+
// Subscription (recurring)
|
|
144
|
+
checkout({ productId: 'prod_subscription_xxx' })
|
|
145
|
+
|
|
146
|
+
// One-time purchase
|
|
147
|
+
checkout({ productId: 'prod_onetime_xxx' })
|
|
148
|
+
|
|
149
|
+
// Credits (prepaid wallet)
|
|
150
|
+
checkout({ productId: 'prod_credits_xxx' })
|
|
151
|
+
|
|
152
|
+
// Donation (variable amount)
|
|
153
|
+
checkout({ productId: 'prod_donation_xxx' })
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
## Listing Products
|
|
157
|
+
|
|
158
|
+
```tsx
|
|
159
|
+
import { useProducts } from 'recur-tw'
|
|
160
|
+
|
|
161
|
+
function PricingPage() {
|
|
162
|
+
const { products, isLoading } = useProducts({
|
|
163
|
+
type: 'SUBSCRIPTION', // Filter by type
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
if (isLoading) return <div>Loading...</div>
|
|
167
|
+
|
|
168
|
+
return (
|
|
169
|
+
<div className="pricing-grid">
|
|
170
|
+
{products.map(product => (
|
|
171
|
+
<PricingCard key={product.id} product={product} />
|
|
172
|
+
))}
|
|
173
|
+
</div>
|
|
174
|
+
)
|
|
175
|
+
}
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
## Payment Failed Handling
|
|
179
|
+
|
|
180
|
+
```tsx
|
|
181
|
+
onPaymentFailed: (error) => {
|
|
182
|
+
// error.code tells you what went wrong
|
|
183
|
+
switch (error.code) {
|
|
184
|
+
case 'CARD_DECLINED':
|
|
185
|
+
return { action: 'retry' }
|
|
186
|
+
case 'INSUFFICIENT_FUNDS':
|
|
187
|
+
return {
|
|
188
|
+
action: 'custom',
|
|
189
|
+
customTitle: '餘額不足',
|
|
190
|
+
customMessage: '請使用其他付款方式',
|
|
191
|
+
}
|
|
192
|
+
default:
|
|
193
|
+
return { action: 'close' }
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
## Server-Side Checkout (API)
|
|
199
|
+
|
|
200
|
+
For server-rendered apps or custom flows:
|
|
201
|
+
|
|
202
|
+
```typescript
|
|
203
|
+
// Create checkout session
|
|
204
|
+
const response = await fetch('https://api.recur.tw/v1/checkouts', {
|
|
205
|
+
method: 'POST',
|
|
206
|
+
headers: {
|
|
207
|
+
'X-Recur-Secret-Key': process.env.RECUR_SECRET_KEY,
|
|
208
|
+
'Content-Type': 'application/json',
|
|
209
|
+
},
|
|
210
|
+
body: JSON.stringify({
|
|
211
|
+
productId: 'prod_xxx',
|
|
212
|
+
customerEmail: 'user@example.com',
|
|
213
|
+
successUrl: 'https://yourapp.com/success',
|
|
214
|
+
cancelUrl: 'https://yourapp.com/cancel',
|
|
215
|
+
}),
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
const { checkoutUrl } = await response.json()
|
|
219
|
+
// Redirect user to checkoutUrl
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
## Checkout Result Structure
|
|
223
|
+
|
|
224
|
+
```typescript
|
|
225
|
+
interface CheckoutResult {
|
|
226
|
+
id: string // Subscription or Order ID
|
|
227
|
+
status: string // 'ACTIVE', 'TRIALING', 'PENDING'
|
|
228
|
+
productId: string
|
|
229
|
+
amount: number // In cents (e.g., 29900 = NT$299)
|
|
230
|
+
billingPeriod?: string // 'MONTHLY', 'YEARLY' for subscriptions
|
|
231
|
+
currentPeriodEnd?: string // ISO date
|
|
232
|
+
trialEndsAt?: string // ISO date if trial
|
|
233
|
+
}
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
## Best Practices
|
|
237
|
+
|
|
238
|
+
1. **Always handle all callbacks** - onPaymentComplete, onPaymentFailed, onPaymentCancel
|
|
239
|
+
2. **Show loading states** - Use isLoading to disable buttons during checkout
|
|
240
|
+
3. **Pre-fill customer info** - Reduces friction if you already have user data
|
|
241
|
+
4. **Use externalCustomerId** - Links Recur customers to your user system
|
|
242
|
+
5. **Test in sandbox first** - Use `pk_test_` keys during development
|
|
243
|
+
|
|
244
|
+
## Related Skills
|
|
245
|
+
|
|
246
|
+
- `/recur-quickstart` - Initial SDK setup
|
|
247
|
+
- `/recur-webhooks` - Receive payment notifications
|
|
248
|
+
- `/recur-entitlements` - Check subscription access
|
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: recur-entitlements
|
|
3
|
+
description: Implement access control and permission checking with Recur entitlements API. Use when building paywalls, checking subscription status, gating premium features, or when user mentions "paywall", "權限檢查", "entitlements", "access control", "premium features".
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Recur Entitlements & Access Control
|
|
7
|
+
|
|
8
|
+
You are helping implement access control using Recur's entitlements system. Entitlements let you check if a customer has access to your products (subscriptions or one-time purchases).
|
|
9
|
+
|
|
10
|
+
## Quick Start: Client-Side Check
|
|
11
|
+
|
|
12
|
+
```tsx
|
|
13
|
+
import { RecurProvider, useCustomer } from 'recur-tw'
|
|
14
|
+
|
|
15
|
+
// 1. Wrap app with provider and identify customer
|
|
16
|
+
function App() {
|
|
17
|
+
return (
|
|
18
|
+
<RecurProvider
|
|
19
|
+
config={{ publishableKey: process.env.NEXT_PUBLIC_RECUR_PUBLISHABLE_KEY }}
|
|
20
|
+
customer={{ email: 'user@example.com' }}
|
|
21
|
+
>
|
|
22
|
+
<MyApp />
|
|
23
|
+
</RecurProvider>
|
|
24
|
+
)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// 2. Check access anywhere in your app
|
|
28
|
+
function PremiumFeature() {
|
|
29
|
+
const { check, isLoading } = useCustomer()
|
|
30
|
+
|
|
31
|
+
if (isLoading) return <div>Loading...</div>
|
|
32
|
+
|
|
33
|
+
const { allowed } = check('pro-plan')
|
|
34
|
+
|
|
35
|
+
if (!allowed) {
|
|
36
|
+
return <UpgradePrompt />
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return <PremiumContent />
|
|
40
|
+
}
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Customer Identification
|
|
44
|
+
|
|
45
|
+
Identify customers using one of these methods:
|
|
46
|
+
|
|
47
|
+
```tsx
|
|
48
|
+
// By email (most common)
|
|
49
|
+
<RecurProvider customer={{ email: 'user@example.com' }}>
|
|
50
|
+
|
|
51
|
+
// By your system's user ID
|
|
52
|
+
<RecurProvider customer={{ externalId: 'user_123' }}>
|
|
53
|
+
|
|
54
|
+
// By Recur customer ID
|
|
55
|
+
<RecurProvider customer={{ id: 'cus_xxx' }}>
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Checking Access
|
|
59
|
+
|
|
60
|
+
### Synchronous Check (Cached)
|
|
61
|
+
|
|
62
|
+
Fast, uses cached data. Good for UI rendering.
|
|
63
|
+
|
|
64
|
+
```tsx
|
|
65
|
+
const { check } = useCustomer()
|
|
66
|
+
|
|
67
|
+
// Check by product slug
|
|
68
|
+
const { allowed, entitlement } = check('pro-plan')
|
|
69
|
+
|
|
70
|
+
// Check by product ID
|
|
71
|
+
const { allowed } = check('prod_xxx')
|
|
72
|
+
|
|
73
|
+
if (allowed) {
|
|
74
|
+
// User has access
|
|
75
|
+
// entitlement contains details like status, expiresAt
|
|
76
|
+
}
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
### Async Check (Live)
|
|
80
|
+
|
|
81
|
+
Fetches fresh data from API. Use for critical operations.
|
|
82
|
+
|
|
83
|
+
```tsx
|
|
84
|
+
const { check } = useCustomer()
|
|
85
|
+
|
|
86
|
+
// Real-time check
|
|
87
|
+
const { allowed, entitlement } = await check('pro-plan', { live: true })
|
|
88
|
+
|
|
89
|
+
// Good for:
|
|
90
|
+
// - Before processing important actions
|
|
91
|
+
// - After checkout to confirm access
|
|
92
|
+
// - When cached data might be stale
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
### Manual Refetch
|
|
96
|
+
|
|
97
|
+
```tsx
|
|
98
|
+
const { refetch } = useCustomer()
|
|
99
|
+
|
|
100
|
+
// After checkout completion
|
|
101
|
+
onPaymentComplete: async () => {
|
|
102
|
+
await refetch() // Refresh entitlements
|
|
103
|
+
router.push('/dashboard')
|
|
104
|
+
}
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## Entitlement Response Structure
|
|
108
|
+
|
|
109
|
+
```typescript
|
|
110
|
+
interface Entitlement {
|
|
111
|
+
product: string // Product slug
|
|
112
|
+
productId: string // Product ID
|
|
113
|
+
status: EntitlementStatus
|
|
114
|
+
source: 'subscription' | 'order' // How they got access
|
|
115
|
+
sourceId: string // Subscription/Order ID
|
|
116
|
+
grantedAt: string // When access was granted
|
|
117
|
+
expiresAt: string | null // When access expires (null = permanent)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
type EntitlementStatus =
|
|
121
|
+
| 'active' // Subscription active
|
|
122
|
+
| 'trialing' // In trial period
|
|
123
|
+
| 'past_due' // Payment failed, in grace period
|
|
124
|
+
| 'canceled' // Cancelled but access until period end
|
|
125
|
+
| 'purchased' // One-time purchase (permanent)
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
## Server-Side Checking
|
|
129
|
+
|
|
130
|
+
### Using Server SDK
|
|
131
|
+
|
|
132
|
+
```typescript
|
|
133
|
+
import { Recur } from 'recur-tw/server'
|
|
134
|
+
|
|
135
|
+
const recur = new Recur(process.env.RECUR_SECRET_KEY!)
|
|
136
|
+
|
|
137
|
+
// In API route or server action
|
|
138
|
+
async function checkAccess(userEmail: string) {
|
|
139
|
+
const { allowed, entitlement } = await recur.entitlements.check({
|
|
140
|
+
product: 'pro-plan',
|
|
141
|
+
customer: { email: userEmail },
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
if (!allowed) {
|
|
145
|
+
throw new Error('Upgrade required')
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return entitlement
|
|
149
|
+
}
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
### Using REST API Directly
|
|
153
|
+
|
|
154
|
+
```typescript
|
|
155
|
+
// GET /api/v1/customers/entitlements
|
|
156
|
+
const response = await fetch(
|
|
157
|
+
`https://api.recur.tw/v1/customers/entitlements?email=${encodeURIComponent(email)}`,
|
|
158
|
+
{
|
|
159
|
+
headers: {
|
|
160
|
+
'X-Recur-Secret-Key': process.env.RECUR_SECRET_KEY!,
|
|
161
|
+
},
|
|
162
|
+
}
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
const { customer, subscription, entitlements } = await response.json()
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
## Common Patterns
|
|
169
|
+
|
|
170
|
+
### Paywall Component
|
|
171
|
+
|
|
172
|
+
```tsx
|
|
173
|
+
function Paywall({
|
|
174
|
+
children,
|
|
175
|
+
product,
|
|
176
|
+
fallback
|
|
177
|
+
}: {
|
|
178
|
+
children: React.ReactNode
|
|
179
|
+
product: string
|
|
180
|
+
fallback?: React.ReactNode
|
|
181
|
+
}) {
|
|
182
|
+
const { check, isLoading } = useCustomer()
|
|
183
|
+
|
|
184
|
+
if (isLoading) {
|
|
185
|
+
return <div>Loading...</div>
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const { allowed } = check(product)
|
|
189
|
+
|
|
190
|
+
if (!allowed) {
|
|
191
|
+
return fallback || <UpgradePrompt product={product} />
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return <>{children}</>
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Usage
|
|
198
|
+
<Paywall product="pro-plan">
|
|
199
|
+
<PremiumDashboard />
|
|
200
|
+
</Paywall>
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
### Feature Flag Style
|
|
204
|
+
|
|
205
|
+
```tsx
|
|
206
|
+
function useFeature(featureProduct: string) {
|
|
207
|
+
const { check, isLoading } = useCustomer()
|
|
208
|
+
|
|
209
|
+
if (isLoading) {
|
|
210
|
+
return { enabled: false, loading: true }
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const { allowed, entitlement } = check(featureProduct)
|
|
214
|
+
|
|
215
|
+
return {
|
|
216
|
+
enabled: allowed,
|
|
217
|
+
loading: false,
|
|
218
|
+
entitlement,
|
|
219
|
+
isTrial: entitlement?.status === 'trialing',
|
|
220
|
+
isPastDue: entitlement?.status === 'past_due',
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Usage
|
|
225
|
+
function MyComponent() {
|
|
226
|
+
const { enabled, isTrial } = useFeature('pro-plan')
|
|
227
|
+
|
|
228
|
+
if (!enabled) return <UpgradeButton />
|
|
229
|
+
|
|
230
|
+
return (
|
|
231
|
+
<>
|
|
232
|
+
{isTrial && <TrialBanner />}
|
|
233
|
+
<ProFeature />
|
|
234
|
+
</>
|
|
235
|
+
)
|
|
236
|
+
}
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
### API Middleware
|
|
240
|
+
|
|
241
|
+
```typescript
|
|
242
|
+
// middleware/requireSubscription.ts
|
|
243
|
+
import { Recur } from 'recur-tw/server'
|
|
244
|
+
|
|
245
|
+
const recur = new Recur(process.env.RECUR_SECRET_KEY!)
|
|
246
|
+
|
|
247
|
+
export async function requireSubscription(
|
|
248
|
+
req: Request,
|
|
249
|
+
product: string
|
|
250
|
+
) {
|
|
251
|
+
const userEmail = await getUserEmail(req) // Your auth logic
|
|
252
|
+
|
|
253
|
+
const { allowed, denial } = await recur.entitlements.check({
|
|
254
|
+
product,
|
|
255
|
+
customer: { email: userEmail },
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
if (!allowed) {
|
|
259
|
+
throw new Response(JSON.stringify({
|
|
260
|
+
error: 'Subscription required',
|
|
261
|
+
reason: denial?.reason, // 'no_customer', 'no_entitlement', etc.
|
|
262
|
+
}), {
|
|
263
|
+
status: 403,
|
|
264
|
+
headers: { 'Content-Type': 'application/json' },
|
|
265
|
+
})
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Usage in API route
|
|
270
|
+
export async function GET(req: Request) {
|
|
271
|
+
await requireSubscription(req, 'pro-plan')
|
|
272
|
+
|
|
273
|
+
// User has access, continue...
|
|
274
|
+
return Response.json({ data: 'premium content' })
|
|
275
|
+
}
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
### Multiple Product Tiers
|
|
279
|
+
|
|
280
|
+
```tsx
|
|
281
|
+
function PricingGate() {
|
|
282
|
+
const { check } = useCustomer()
|
|
283
|
+
|
|
284
|
+
const hasPro = check('pro-plan').allowed
|
|
285
|
+
const hasEnterprise = check('enterprise-plan').allowed
|
|
286
|
+
|
|
287
|
+
if (hasEnterprise) {
|
|
288
|
+
return <EnterpriseDashboard />
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
if (hasPro) {
|
|
292
|
+
return <ProDashboard />
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
return <FreeDashboard />
|
|
296
|
+
}
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
## Handling Edge Cases
|
|
300
|
+
|
|
301
|
+
### Past Due Subscriptions
|
|
302
|
+
|
|
303
|
+
```tsx
|
|
304
|
+
const { allowed, entitlement } = check('pro-plan')
|
|
305
|
+
|
|
306
|
+
if (allowed && entitlement?.status === 'past_due') {
|
|
307
|
+
// Show warning but allow access during grace period
|
|
308
|
+
return (
|
|
309
|
+
<>
|
|
310
|
+
<PaymentFailedBanner />
|
|
311
|
+
<PremiumContent />
|
|
312
|
+
</>
|
|
313
|
+
)
|
|
314
|
+
}
|
|
315
|
+
```
|
|
316
|
+
|
|
317
|
+
### Trial Subscriptions
|
|
318
|
+
|
|
319
|
+
```tsx
|
|
320
|
+
const { entitlement } = check('pro-plan')
|
|
321
|
+
|
|
322
|
+
if (entitlement?.status === 'trialing') {
|
|
323
|
+
const trialEnds = new Date(entitlement.expiresAt!)
|
|
324
|
+
const daysLeft = Math.ceil((trialEnds - Date.now()) / (1000 * 60 * 60 * 24))
|
|
325
|
+
|
|
326
|
+
return <TrialBanner daysLeft={daysLeft} />
|
|
327
|
+
}
|
|
328
|
+
```
|
|
329
|
+
|
|
330
|
+
### Cancelled but Active
|
|
331
|
+
|
|
332
|
+
```tsx
|
|
333
|
+
const { entitlement } = check('pro-plan')
|
|
334
|
+
|
|
335
|
+
if (entitlement?.status === 'canceled') {
|
|
336
|
+
// User cancelled but still has access until period end
|
|
337
|
+
return (
|
|
338
|
+
<>
|
|
339
|
+
<ResubscribeBanner expiresAt={entitlement.expiresAt} />
|
|
340
|
+
<PremiumContent />
|
|
341
|
+
</>
|
|
342
|
+
)
|
|
343
|
+
}
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
## Denial Reasons
|
|
347
|
+
|
|
348
|
+
When `allowed` is `false`, check the denial reason:
|
|
349
|
+
|
|
350
|
+
```typescript
|
|
351
|
+
const { allowed, denial } = check('pro-plan')
|
|
352
|
+
|
|
353
|
+
if (!allowed) {
|
|
354
|
+
switch (denial?.reason) {
|
|
355
|
+
case 'no_customer':
|
|
356
|
+
// Customer not found
|
|
357
|
+
return <CreateAccountPrompt />
|
|
358
|
+
|
|
359
|
+
case 'no_entitlement':
|
|
360
|
+
// No subscription to this product
|
|
361
|
+
return <SubscribePrompt />
|
|
362
|
+
|
|
363
|
+
case 'expired':
|
|
364
|
+
// Subscription/access expired
|
|
365
|
+
return <RenewPrompt />
|
|
366
|
+
|
|
367
|
+
case 'insufficient_balance':
|
|
368
|
+
// For credit-based products
|
|
369
|
+
return <BuyCreditsPrompt />
|
|
370
|
+
|
|
371
|
+
default:
|
|
372
|
+
return <GenericUpgradePrompt />
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
```
|
|
376
|
+
|
|
377
|
+
## Best Practices
|
|
378
|
+
|
|
379
|
+
1. **Use cached checks for UI** - Fast rendering, good UX
|
|
380
|
+
2. **Use live checks for actions** - Ensure fresh data for important operations
|
|
381
|
+
3. **Handle all statuses** - active, trialing, past_due, canceled
|
|
382
|
+
4. **Refetch after checkout** - Ensure UI updates after purchase
|
|
383
|
+
5. **Implement graceful degradation** - Show upgrade prompts, not errors
|
|
384
|
+
|
|
385
|
+
## Related Skills
|
|
386
|
+
|
|
387
|
+
- `/recur-quickstart` - Initial SDK setup
|
|
388
|
+
- `/recur-checkout` - Implement purchase flows
|
|
389
|
+
- `/recur-webhooks` - Sync entitlements with webhooks
|