recur-skills 0.0.9 → 0.0.11
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/marketplace.json +3 -3
- package/.claude-plugin/plugin.json +1 -1
- package/package.json +1 -1
- package/plugin.json +1 -1
- package/skills/recur-checkout/SKILL.md +209 -121
- package/skills/recur-entitlements/SKILL.md +25 -13
- package/skills/recur-help/SKILL.md +1 -1
- package/skills/recur-portal/SKILL.md +1 -1
- package/skills/recur-quickstart/SKILL.md +39 -18
- package/skills/recur-webhooks/SKILL.md +306 -208
- package/skills/recur-webhooks/scripts/test-webhook.sh +52 -45
- package/skills/recur-webhooks/scripts/verify-signature.ts +14 -9
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"$schema": "https://anthropic.com/claude-code/marketplace.schema.json",
|
|
3
3
|
"name": "recur-skills",
|
|
4
4
|
"description": "Claude Code skills for integrating Recur — Taiwan's subscription payment platform",
|
|
5
|
-
"version": "0.0.
|
|
5
|
+
"version": "0.0.11",
|
|
6
6
|
"owner": {
|
|
7
7
|
"name": "Recur",
|
|
8
8
|
"email": "hi@recur.tw",
|
|
@@ -10,14 +10,14 @@
|
|
|
10
10
|
},
|
|
11
11
|
"metadata": {
|
|
12
12
|
"description": "Claude Code skills for integrating Recur - Taiwan's subscription payment platform",
|
|
13
|
-
"version": "0.0.
|
|
13
|
+
"version": "0.0.11"
|
|
14
14
|
},
|
|
15
15
|
"plugins": [
|
|
16
16
|
{
|
|
17
17
|
"name": "recur-skills",
|
|
18
18
|
"displayName": "Recur Skills",
|
|
19
19
|
"description": "Skills for Recur SDK integration (quickstart, checkout, webhooks, entitlements, portal) and the Recur MCP server for signup, API keys, and products",
|
|
20
|
-
"version": "0.0.
|
|
20
|
+
"version": "0.0.11",
|
|
21
21
|
"source": "./",
|
|
22
22
|
"category": "development",
|
|
23
23
|
"homepage": "https://docs.recur.tw/guides/skills",
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
|
|
3
3
|
"name": "recur-skills",
|
|
4
4
|
"displayName": "Recur Skills",
|
|
5
|
-
"version": "0.0.
|
|
5
|
+
"version": "0.0.11",
|
|
6
6
|
"description": "Skills for Recur SDK integration (quickstart, checkout, webhooks, entitlements, customer portal) plus the Recur MCP server (https://mcp.recur.tw/) for account signup, API keys, and products.",
|
|
7
7
|
"author": {
|
|
8
8
|
"name": "Recur",
|
package/package.json
CHANGED
package/plugin.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
|
|
3
3
|
"name": "recur-skills",
|
|
4
|
-
"version": "0.0.
|
|
4
|
+
"version": "0.0.11",
|
|
5
5
|
"description": "Skills for Recur SDK integration: quickstart, checkout, webhooks, entitlements, and customer portal. Taiwan subscription billing via PAYUNi.",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "Recur",
|
|
@@ -4,109 +4,161 @@ description: Implement Recur checkout flows including embedded, modal, and redir
|
|
|
4
4
|
license: MIT
|
|
5
5
|
metadata:
|
|
6
6
|
author: recur
|
|
7
|
-
version: "0.0.
|
|
7
|
+
version: "0.0.11"
|
|
8
8
|
---
|
|
9
9
|
|
|
10
10
|
# Recur Checkout Integration
|
|
11
11
|
|
|
12
|
-
You are helping implement Recur checkout flows.
|
|
12
|
+
You are helping implement Recur checkout flows. Everything below is checked against the
|
|
13
|
+
`recur-tw` typings (`RecurContextValue`, `UseSubscribeResult`, `CheckoutOptions`,
|
|
14
|
+
`RedirectToCheckoutOptions`, `SubscriptionResult`). Product IDs are CUIDs from
|
|
15
|
+
`list_products` / the dashboard (e.g. `cmfxq8n2a0001l8yz3k5p9t7d`); `prod_xxx` in the
|
|
16
|
+
examples is a placeholder. You can also pass `productSlug`.
|
|
13
17
|
|
|
14
18
|
## Checkout Modes
|
|
15
19
|
|
|
16
|
-
| Mode |
|
|
17
|
-
|
|
18
|
-
| `
|
|
19
|
-
| `modal` | Quick purchases
|
|
20
|
-
|
|
|
20
|
+
| Mode | How | Best For |
|
|
21
|
+
|------|-----|----------|
|
|
22
|
+
| **Hosted** (recommended) | `useRecur().redirectToCheckout()` → checkout.recur.tw | Any app, works on localhost |
|
|
23
|
+
| **Modal** | `useSubscribe()` / `useRecur().checkout()` with provider `checkoutMode: 'modal'` | Quick purchases without leaving the page |
|
|
24
|
+
| **Embedded** | same hooks with provider `checkoutMode: 'embedded'` + `containerElementId` | Custom checkout pages |
|
|
21
25
|
|
|
22
|
-
|
|
26
|
+
`checkoutMode` lives on `<RecurProvider config>`, not on the call. The provider default is
|
|
27
|
+
`'embedded'`, which throws without `containerElementId` — set `checkoutMode: 'modal'` when
|
|
28
|
+
you want the popup. There is no `mode: 'hosted' | 'modal'` option on any call.
|
|
23
29
|
|
|
24
|
-
|
|
30
|
+
## Hosted Checkout (recommended)
|
|
31
|
+
|
|
32
|
+
`isCheckingOut` is only set by `checkout()`; `redirectToCheckout()` creates the session and
|
|
33
|
+
navigates away, so track your own pending flag.
|
|
25
34
|
|
|
26
35
|
```tsx
|
|
36
|
+
'use client'
|
|
37
|
+
|
|
38
|
+
import { useState } from 'react'
|
|
27
39
|
import { useRecur } from 'recur-tw'
|
|
28
40
|
|
|
29
41
|
function CheckoutButton({ productId }: { productId: string }) {
|
|
30
|
-
const {
|
|
42
|
+
const { redirectToCheckout } = useRecur()
|
|
43
|
+
const [redirecting, setRedirecting] = useState(false)
|
|
31
44
|
|
|
32
45
|
const handleClick = async () => {
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
// result.id - Subscription/Order ID
|
|
48
|
-
// result.status - 'ACTIVE', 'TRIALING', etc.
|
|
49
|
-
},
|
|
50
|
-
onPaymentFailed: (error) => {
|
|
51
|
-
console.error('Failed:', error)
|
|
52
|
-
return { action: 'retry' } // or 'close' or 'custom'
|
|
53
|
-
},
|
|
54
|
-
onPaymentCancel: () => {
|
|
55
|
-
console.log('User cancelled')
|
|
56
|
-
},
|
|
57
|
-
})
|
|
46
|
+
setRedirecting(true)
|
|
47
|
+
try {
|
|
48
|
+
await redirectToCheckout({
|
|
49
|
+
productId, // or productSlug: 'pro-plan'
|
|
50
|
+
successUrl: `${window.location.origin}/success?session_id={CHECKOUT_SESSION_ID}`,
|
|
51
|
+
cancelUrl: `${window.location.origin}/pricing`,
|
|
52
|
+
customerEmail: 'user@example.com', // optional, pre-fills the checkout page
|
|
53
|
+
customerName: 'John Doe', // optional
|
|
54
|
+
externalCustomerId: 'user_123', // optional, links to your user system
|
|
55
|
+
})
|
|
56
|
+
} catch (err) {
|
|
57
|
+
setRedirecting(false)
|
|
58
|
+
console.error('Failed to start checkout:', err)
|
|
59
|
+
}
|
|
58
60
|
}
|
|
59
61
|
|
|
60
62
|
return (
|
|
61
|
-
<button onClick={handleClick} disabled={
|
|
62
|
-
{
|
|
63
|
+
<button onClick={handleClick} disabled={redirecting}>
|
|
64
|
+
{redirecting ? 'Redirecting...' : 'Subscribe'}
|
|
63
65
|
</button>
|
|
64
66
|
)
|
|
65
67
|
}
|
|
66
68
|
```
|
|
67
69
|
|
|
68
|
-
|
|
70
|
+
The browser navigates away; no callbacks fire. Confirm the payment on your success page
|
|
71
|
+
(via the session id) or with webhooks.
|
|
72
|
+
|
|
73
|
+
## Modal / Embedded Checkout
|
|
74
|
+
|
|
75
|
+
### Using useSubscribe (recommended for modal)
|
|
76
|
+
|
|
77
|
+
Callbacks are options of the hook; `subscribe()` takes the checkout options only.
|
|
69
78
|
|
|
70
79
|
```tsx
|
|
80
|
+
'use client'
|
|
81
|
+
|
|
71
82
|
import { useSubscribe } from 'recur-tw'
|
|
83
|
+
import { useRouter } from 'next/navigation'
|
|
72
84
|
|
|
73
85
|
function SubscribeButton({ productId }: { productId: string }) {
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
if (subscription) {
|
|
87
|
-
return <p>Subscribed! ID: {subscription.id}</p>
|
|
88
|
-
}
|
|
86
|
+
const router = useRouter()
|
|
87
|
+
const { subscribe, isLoading, error, reset } = useSubscribe({
|
|
88
|
+
onPaymentComplete: (subscription) => {
|
|
89
|
+
// subscription: SubscriptionResult — id, status, productId, amount, currentPeriodEnd
|
|
90
|
+
router.push('/dashboard')
|
|
91
|
+
},
|
|
92
|
+
onPaymentFailed: (error) => {
|
|
93
|
+
console.error('Failed:', error.code, error.message)
|
|
94
|
+
return { action: 'retry' } // or 'close' or 'custom'
|
|
95
|
+
},
|
|
96
|
+
onPaymentCancel: () => console.log('User cancelled'),
|
|
97
|
+
})
|
|
89
98
|
|
|
90
99
|
return (
|
|
91
100
|
<>
|
|
92
|
-
<button
|
|
93
|
-
|
|
101
|
+
<button
|
|
102
|
+
onClick={() => subscribe({ productId, customerEmail: 'user@example.com' })}
|
|
103
|
+
disabled={isLoading}
|
|
104
|
+
>
|
|
105
|
+
{isLoading ? 'Processing...' : 'Subscribe'}
|
|
94
106
|
</button>
|
|
95
|
-
{error && <p className="error">{error.message}</p>}
|
|
107
|
+
{error && <p className="error" onClick={reset}>{error.message}</p>}
|
|
96
108
|
</>
|
|
97
109
|
)
|
|
98
110
|
}
|
|
99
111
|
```
|
|
100
112
|
|
|
101
|
-
|
|
113
|
+
`useSubscribe()` returns `{ subscribe, mutate, isLoading, error, reset }` — there is no
|
|
114
|
+
`subscription` field; use `onPaymentComplete` for the result.
|
|
115
|
+
|
|
116
|
+
### Using useRecur().checkout (callbacks inline)
|
|
117
|
+
|
|
118
|
+
```tsx
|
|
119
|
+
'use client'
|
|
120
|
+
|
|
121
|
+
import { useRecur } from 'recur-tw'
|
|
122
|
+
|
|
123
|
+
function CheckoutButton({ productId }: { productId: string }) {
|
|
124
|
+
const { checkout, isCheckingOut } = useRecur()
|
|
125
|
+
|
|
126
|
+
const handleClick = () =>
|
|
127
|
+
checkout({
|
|
128
|
+
productId,
|
|
129
|
+
customerEmail: 'user@example.com', // optional; collected in the form if omitted
|
|
130
|
+
customerName: 'John Doe',
|
|
131
|
+
externalCustomerId: 'user_123',
|
|
132
|
+
onPaymentComplete: (subscription) => console.log('Success!', subscription.id, subscription.status),
|
|
133
|
+
onPaymentFailed: (error) => ({ action: 'retry' }),
|
|
134
|
+
onPaymentCancel: () => console.log('User cancelled'),
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
return (
|
|
138
|
+
<button onClick={handleClick} disabled={isCheckingOut}>
|
|
139
|
+
{isCheckingOut ? 'Processing...' : 'Subscribe'}
|
|
140
|
+
</button>
|
|
141
|
+
)
|
|
142
|
+
}
|
|
143
|
+
```
|
|
102
144
|
|
|
103
|
-
|
|
145
|
+
### Provider setup for modal / embedded
|
|
104
146
|
|
|
105
147
|
```tsx
|
|
106
|
-
//
|
|
148
|
+
// Modal (popup)
|
|
149
|
+
<RecurProvider
|
|
150
|
+
config={{
|
|
151
|
+
publishableKey: process.env.NEXT_PUBLIC_RECUR_PUBLISHABLE_KEY!,
|
|
152
|
+
checkoutMode: 'modal',
|
|
153
|
+
}}
|
|
154
|
+
>
|
|
155
|
+
{children}
|
|
156
|
+
</RecurProvider>
|
|
157
|
+
|
|
158
|
+
// Embedded (inline form) — the container must exist in the DOM
|
|
107
159
|
<RecurProvider
|
|
108
160
|
config={{
|
|
109
|
-
publishableKey: process.env.NEXT_PUBLIC_RECUR_PUBLISHABLE_KEY
|
|
161
|
+
publishableKey: process.env.NEXT_PUBLIC_RECUR_PUBLISHABLE_KEY!,
|
|
110
162
|
checkoutMode: 'embedded',
|
|
111
163
|
containerElementId: 'recur-checkout-container',
|
|
112
164
|
}}
|
|
@@ -114,47 +166,76 @@ For embedded mode, you need a container element:
|
|
|
114
166
|
{children}
|
|
115
167
|
</RecurProvider>
|
|
116
168
|
|
|
117
|
-
// In your checkout page
|
|
118
169
|
function CheckoutPage() {
|
|
119
170
|
return (
|
|
120
171
|
<div>
|
|
121
172
|
<h1>Complete Your Purchase</h1>
|
|
122
|
-
{/* Recur
|
|
173
|
+
{/* Recur renders the payment form here */}
|
|
123
174
|
<div id="recur-checkout-container" />
|
|
124
175
|
</div>
|
|
125
176
|
)
|
|
126
177
|
}
|
|
127
178
|
```
|
|
128
179
|
|
|
129
|
-
|
|
180
|
+
Modal/embedded checkout needs a registered domain; on `localhost` use Hosted Checkout.
|
|
130
181
|
|
|
131
|
-
|
|
182
|
+
## Option Types
|
|
132
183
|
|
|
133
|
-
```
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
//
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
184
|
+
```typescript
|
|
185
|
+
// redirectToCheckout()
|
|
186
|
+
interface RedirectToCheckoutOptions {
|
|
187
|
+
productId?: string // or productSlug — one is required
|
|
188
|
+
productSlug?: string
|
|
189
|
+
mode?: 'PAYMENT' | 'SUBSCRIPTION' | 'SETUP' // usually inferred from the product
|
|
190
|
+
successUrl: string
|
|
191
|
+
cancelUrl: string
|
|
192
|
+
customerEmail?: string
|
|
193
|
+
customerName?: string
|
|
194
|
+
externalCustomerId?: string
|
|
195
|
+
}
|
|
141
196
|
|
|
142
|
-
|
|
197
|
+
// checkout() / subscribe()
|
|
198
|
+
interface CheckoutOptions {
|
|
199
|
+
productId?: string
|
|
200
|
+
productSlug?: string
|
|
201
|
+
customerEmail?: string
|
|
202
|
+
customerName?: string
|
|
203
|
+
externalCustomerId?: string
|
|
204
|
+
successUrl?: string // used if 3-D Secure needs a redirect
|
|
205
|
+
cancelUrl?: string
|
|
206
|
+
// checkout() only — for subscribe() pass these to useSubscribe():
|
|
207
|
+
onPaymentComplete?: (subscription: SubscriptionResult) => void
|
|
208
|
+
onPaymentFailed?: (error: CheckoutError) => PaymentFailedAction | void
|
|
209
|
+
onPaymentCancel?: () => void
|
|
210
|
+
onSuccess?: (result: CheckoutResult) => void // session created (before payment)
|
|
211
|
+
onError?: (error: CheckoutError) => void
|
|
212
|
+
}
|
|
143
213
|
|
|
144
|
-
|
|
214
|
+
interface SubscriptionResult { // onPaymentComplete argument
|
|
215
|
+
id: string
|
|
216
|
+
status: string // 'ACTIVE', 'TRIALING', ...
|
|
217
|
+
productId: string
|
|
218
|
+
amount: number // whole TWD (499 = NT$499). NEVER divide by 100
|
|
219
|
+
billingPeriod: string
|
|
220
|
+
currentPeriodStart: string
|
|
221
|
+
currentPeriodEnd: string
|
|
222
|
+
trialEndsAt?: string
|
|
223
|
+
nextBillingDate?: string
|
|
224
|
+
}
|
|
225
|
+
```
|
|
145
226
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
checkout({ productId: 'prod_subscription_xxx' })
|
|
227
|
+
No `trialDays`, `quantity`, or `metadata` on these options — trials and pricing come from
|
|
228
|
+
the product (the server SDK's `checkoutSessions.create()` does accept `metadata`).
|
|
149
229
|
|
|
150
|
-
|
|
151
|
-
checkout({ productId: 'prod_onetime_xxx' })
|
|
230
|
+
## Product Types
|
|
152
231
|
|
|
153
|
-
|
|
154
|
-
checkout({ productId: 'prod_credits_xxx' })
|
|
232
|
+
All four types go through the same calls; the product decides the behaviour:
|
|
155
233
|
|
|
156
|
-
|
|
157
|
-
|
|
234
|
+
```tsx
|
|
235
|
+
redirectToCheckout({ productSlug: 'pro-monthly', successUrl, cancelUrl }) // SUBSCRIPTION (recurring)
|
|
236
|
+
redirectToCheckout({ productSlug: 'ebook', successUrl, cancelUrl }) // ONE_TIME
|
|
237
|
+
redirectToCheckout({ productSlug: 'credits-100', successUrl, cancelUrl }) // CREDITS (prepaid wallet)
|
|
238
|
+
redirectToCheckout({ productSlug: 'support-us', successUrl, cancelUrl }) // DONATION (variable amount)
|
|
158
239
|
```
|
|
159
240
|
|
|
160
241
|
## Listing Products
|
|
@@ -163,15 +244,14 @@ checkout({ productId: 'prod_donation_xxx' })
|
|
|
163
244
|
import { useProducts } from 'recur-tw'
|
|
164
245
|
|
|
165
246
|
function PricingPage() {
|
|
166
|
-
const { products, isLoading } = useProducts({
|
|
167
|
-
type: 'SUBSCRIPTION', // Filter by type
|
|
168
|
-
})
|
|
247
|
+
const { data: products, isLoading, error } = useProducts({ type: 'SUBSCRIPTION' })
|
|
169
248
|
|
|
170
249
|
if (isLoading) return <div>Loading...</div>
|
|
250
|
+
if (error) return <div>{error.message}</div>
|
|
171
251
|
|
|
172
252
|
return (
|
|
173
253
|
<div className="pricing-grid">
|
|
174
|
-
{products
|
|
254
|
+
{products?.map((product) => (
|
|
175
255
|
<PricingCard key={product.id} product={product} />
|
|
176
256
|
))}
|
|
177
257
|
</div>
|
|
@@ -179,36 +259,56 @@ function PricingPage() {
|
|
|
179
259
|
}
|
|
180
260
|
```
|
|
181
261
|
|
|
262
|
+
`Product` fields: `id`, `name`, `slug`, `description`, `type`, `billingPeriod`
|
|
263
|
+
(`'MONTHLY' | 'YEARLY' | ... | null`), `price` (whole TWD), `currency`, `trialDays`,
|
|
264
|
+
`metadata`. There is no `priceFormatted` on the SDK type — format with
|
|
265
|
+
`` `NT$${product.price.toLocaleString()}` ``.
|
|
266
|
+
|
|
182
267
|
## Payment Failed Handling
|
|
183
268
|
|
|
269
|
+
`CheckoutError.code` is `'PAYMENT_FAILED'` for a declined payment; the gateway's own code
|
|
270
|
+
is in `error.details.failure_code` (with `failure_message` and `can_retry`).
|
|
271
|
+
`CheckoutErrorDetails` is a union — an array for conflict errors, an object for payment
|
|
272
|
+
failures — so narrow it before reading the fields.
|
|
273
|
+
|
|
184
274
|
```tsx
|
|
185
275
|
onPaymentFailed: (error) => {
|
|
186
|
-
|
|
187
|
-
|
|
276
|
+
const details = Array.isArray(error.details) ? undefined : error.details
|
|
277
|
+
|
|
278
|
+
switch (details?.failure_code) {
|
|
188
279
|
case 'CARD_DECLINED':
|
|
189
280
|
return { action: 'retry' }
|
|
190
281
|
case 'INSUFFICIENT_FUNDS':
|
|
191
|
-
return {
|
|
192
|
-
action: 'custom',
|
|
193
|
-
customTitle: '餘額不足',
|
|
194
|
-
customMessage: '請使用其他付款方式',
|
|
195
|
-
}
|
|
282
|
+
return { action: 'custom', customTitle: '餘額不足', customMessage: '請使用其他付款方式' }
|
|
196
283
|
default:
|
|
197
|
-
return { action: 'close' }
|
|
284
|
+
return details?.can_retry ? { action: 'retry' } : { action: 'close' }
|
|
198
285
|
}
|
|
199
286
|
}
|
|
200
287
|
```
|
|
201
288
|
|
|
202
|
-
## Server-Side Checkout (
|
|
289
|
+
## Server-Side Checkout (Hosted, no React)
|
|
290
|
+
|
|
291
|
+
```typescript
|
|
292
|
+
import { Recur } from 'recur-tw/server'
|
|
293
|
+
|
|
294
|
+
const recur = new Recur(process.env.RECUR_SECRET_KEY!)
|
|
295
|
+
const session = await recur.checkoutSessions.create({
|
|
296
|
+
productId: 'prod_xxx', // a CUID from list_products
|
|
297
|
+
successUrl: 'https://yourapp.com/success',
|
|
298
|
+
cancelUrl: 'https://yourapp.com/cancel',
|
|
299
|
+
customerEmail: 'user@example.com',
|
|
300
|
+
metadata: { plan: 'pro' }, // optional, server-side only
|
|
301
|
+
})
|
|
302
|
+
// redirect the customer to session.url
|
|
303
|
+
```
|
|
203
304
|
|
|
204
|
-
|
|
305
|
+
Or with plain REST:
|
|
205
306
|
|
|
206
307
|
```typescript
|
|
207
|
-
|
|
208
|
-
const response = await fetch('https://api.recur.tw/v1/checkouts', {
|
|
308
|
+
const response = await fetch('https://api.recur.tw/v1/checkout/sessions', {
|
|
209
309
|
method: 'POST',
|
|
210
310
|
headers: {
|
|
211
|
-
|
|
311
|
+
Authorization: `Bearer ${process.env.RECUR_SECRET_KEY}`, // X-Recur-Secret-Key also works
|
|
212
312
|
'Content-Type': 'application/json',
|
|
213
313
|
},
|
|
214
314
|
body: JSON.stringify({
|
|
@@ -219,31 +319,19 @@ const response = await fetch('https://api.recur.tw/v1/checkouts', {
|
|
|
219
319
|
}),
|
|
220
320
|
})
|
|
221
321
|
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
## Checkout Result Structure
|
|
227
|
-
|
|
228
|
-
```typescript
|
|
229
|
-
interface CheckoutResult {
|
|
230
|
-
id: string // Subscription or Order ID
|
|
231
|
-
status: string // 'ACTIVE', 'TRIALING', 'PENDING'
|
|
232
|
-
productId: string
|
|
233
|
-
amount: number // In cents (e.g., 29900 = NT$299)
|
|
234
|
-
billingPeriod?: string // 'MONTHLY', 'YEARLY' for subscriptions
|
|
235
|
-
currentPeriodEnd?: string // ISO date
|
|
236
|
-
trialEndsAt?: string // ISO date if trial
|
|
237
|
-
}
|
|
322
|
+
// The session object is the response body itself (no outer `data` wrapper, snake_case keys)
|
|
323
|
+
const { url } = await response.json()
|
|
324
|
+
// Redirect the customer to `url`. (/v1/checkouts is the embedded-form endpoint, different shape.)
|
|
238
325
|
```
|
|
239
326
|
|
|
240
327
|
## Best Practices
|
|
241
328
|
|
|
242
|
-
1. **
|
|
243
|
-
2. **
|
|
244
|
-
3. **
|
|
245
|
-
4. **
|
|
246
|
-
5. **
|
|
329
|
+
1. **Default to Hosted Checkout** — works everywhere, including localhost
|
|
330
|
+
2. **Handle every callback** on modal/embedded — onPaymentComplete, onPaymentFailed, onPaymentCancel
|
|
331
|
+
3. **Show loading states** — `isCheckingOut` (useRecur, modal/embedded only) / `isLoading` (useSubscribe); for `redirectToCheckout()` track your own flag
|
|
332
|
+
4. **Pre-fill customer info** when you already have it; it is optional
|
|
333
|
+
5. **Use externalCustomerId** to link Recur customers to your user system
|
|
334
|
+
6. **Test in sandbox first** — `pk_test_` / `sk_test_` keys
|
|
247
335
|
|
|
248
336
|
## Related Skills
|
|
249
337
|
|
|
@@ -4,7 +4,7 @@ description: Implement access control and permission checking with Recur entitle
|
|
|
4
4
|
license: MIT
|
|
5
5
|
metadata:
|
|
6
6
|
author: recur
|
|
7
|
-
version: "0.0.
|
|
7
|
+
version: "0.0.11"
|
|
8
8
|
---
|
|
9
9
|
|
|
10
10
|
# Recur Entitlements & Access Control
|
|
@@ -71,7 +71,7 @@ const { check } = useCustomer()
|
|
|
71
71
|
// Check by product slug
|
|
72
72
|
const { allowed, entitlement } = check('pro-plan')
|
|
73
73
|
|
|
74
|
-
// Check by product ID
|
|
74
|
+
// Check by product ID (a CUID from list_products, e.g. 'cmfxq8n2a0001l8yz3k5p9t7d'; 'prod_xxx' is a placeholder)
|
|
75
75
|
const { allowed } = check('prod_xxx')
|
|
76
76
|
|
|
77
77
|
if (allowed) {
|
|
@@ -111,14 +111,24 @@ onPaymentComplete: async () => {
|
|
|
111
111
|
## Entitlement Response Structure
|
|
112
112
|
|
|
113
113
|
```typescript
|
|
114
|
+
interface CheckResult { // what check() returns
|
|
115
|
+
allowed: boolean
|
|
116
|
+
reason?: 'no_customer' | 'no_entitlement' | 'not_found' | 'expired' | 'insufficient_balance'
|
|
117
|
+
entitlement?: Entitlement
|
|
118
|
+
balance?: number // CREDITS products
|
|
119
|
+
unlimited?: boolean
|
|
120
|
+
subscription?: { id: string; status: string; product: { id: string; slug: string; name: string }; currentPeriodEnd: string }
|
|
121
|
+
}
|
|
122
|
+
|
|
114
123
|
interface Entitlement {
|
|
115
124
|
product: string // Product slug
|
|
116
|
-
productId: string // Product ID
|
|
125
|
+
productId: string // Product ID (CUID)
|
|
117
126
|
status: EntitlementStatus
|
|
118
127
|
source: 'subscription' | 'order' // How they got access
|
|
119
128
|
sourceId: string // Subscription/Order ID
|
|
120
129
|
grantedAt: string // When access was granted
|
|
121
130
|
expiresAt: string | null // When access expires (null = permanent)
|
|
131
|
+
subscriptionId?: string
|
|
122
132
|
}
|
|
123
133
|
|
|
124
134
|
type EntitlementStatus =
|
|
@@ -140,7 +150,9 @@ const recur = new Recur(process.env.RECUR_SECRET_KEY!)
|
|
|
140
150
|
|
|
141
151
|
// In API route or server action
|
|
142
152
|
async function checkAccess(userEmail: string) {
|
|
143
|
-
|
|
153
|
+
// Server EntitlementCheckResult is { allowed, subscription? } — there is no
|
|
154
|
+
// `entitlement` field here (that one belongs to the React check()).
|
|
155
|
+
const { allowed, subscription } = await recur.entitlements.check({
|
|
144
156
|
product: 'pro-plan',
|
|
145
157
|
customer: { email: userEmail },
|
|
146
158
|
})
|
|
@@ -149,7 +161,7 @@ async function checkAccess(userEmail: string) {
|
|
|
149
161
|
throw new Error('Upgrade required')
|
|
150
162
|
}
|
|
151
163
|
|
|
152
|
-
return
|
|
164
|
+
return subscription
|
|
153
165
|
}
|
|
154
166
|
```
|
|
155
167
|
|
|
@@ -166,6 +178,9 @@ const response = await fetch(
|
|
|
166
178
|
}
|
|
167
179
|
)
|
|
168
180
|
|
|
181
|
+
// REST responses are snake_case (the API wrapper converts them), unlike the SDK's
|
|
182
|
+
// camelCase types: customer { id, email, name, external_id }, subscription | null,
|
|
183
|
+
// entitlements[] with product_id / granted_at / expires_at / source_id.
|
|
169
184
|
const { customer, subscription, entitlements } = await response.json()
|
|
170
185
|
```
|
|
171
186
|
|
|
@@ -254,16 +269,13 @@ export async function requireSubscription(
|
|
|
254
269
|
) {
|
|
255
270
|
const userEmail = await getUserEmail(req) // Your auth logic
|
|
256
271
|
|
|
257
|
-
const { allowed
|
|
272
|
+
const { allowed } = await recur.entitlements.check({
|
|
258
273
|
product,
|
|
259
274
|
customer: { email: userEmail },
|
|
260
275
|
})
|
|
261
276
|
|
|
262
277
|
if (!allowed) {
|
|
263
|
-
throw new Response(JSON.stringify({
|
|
264
|
-
error: 'Subscription required',
|
|
265
|
-
reason: denial?.reason, // 'no_customer', 'no_entitlement', etc.
|
|
266
|
-
}), {
|
|
278
|
+
throw new Response(JSON.stringify({ error: 'Subscription required' }), {
|
|
267
279
|
status: 403,
|
|
268
280
|
headers: { 'Content-Type': 'application/json' },
|
|
269
281
|
})
|
|
@@ -349,13 +361,13 @@ if (entitlement?.status === 'canceled') {
|
|
|
349
361
|
|
|
350
362
|
## Denial Reasons
|
|
351
363
|
|
|
352
|
-
When `allowed` is `false`,
|
|
364
|
+
When `allowed` is `false`, `CheckResult.reason` says why (`'no_customer' | 'no_entitlement' | 'not_found' | 'expired' | 'insufficient_balance'`):
|
|
353
365
|
|
|
354
366
|
```typescript
|
|
355
|
-
const { allowed,
|
|
367
|
+
const { allowed, reason } = check('pro-plan')
|
|
356
368
|
|
|
357
369
|
if (!allowed) {
|
|
358
|
-
switch (
|
|
370
|
+
switch (reason) {
|
|
359
371
|
case 'no_customer':
|
|
360
372
|
// Customer not found
|
|
361
373
|
return <CreateAccountPrompt />
|