recur-skills 0.0.11 → 0.0.13

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.
@@ -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.11",
5
+ "version": "0.0.13",
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.11"
13
+ "version": "0.0.13"
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.11",
20
+ "version": "0.0.13",
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.11",
5
+ "version": "0.0.13",
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "recur-skills",
3
- "version": "0.0.11",
3
+ "version": "0.0.13",
4
4
  "description": "Claude Code skills and MCP plugin for Recur - Taiwan's subscription payment platform",
5
5
  "keywords": [
6
6
  "recur",
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.11",
4
+ "version": "0.0.13",
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,7 +4,7 @@ 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.11"
7
+ version: "0.0.13"
8
8
  ---
9
9
 
10
10
  # Recur Checkout Integration
@@ -261,7 +261,7 @@ function PricingPage() {
261
261
 
262
262
  `Product` fields: `id`, `name`, `slug`, `description`, `type`, `billingPeriod`
263
263
  (`'MONTHLY' | 'YEARLY' | ... | null`), `price` (whole TWD), `currency`, `trialDays`,
264
- `metadata`. There is no `priceFormatted` on the SDK type — format with
264
+ `metadata`, `productFamily`, `displayOrder`. There is no `priceFormatted` on the SDK type — format with
265
265
  `` `NT$${product.price.toLocaleString()}` ``.
266
266
 
267
267
  ## Payment Failed Handling
@@ -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.11"
7
+ version: "0.0.13"
8
8
  ---
9
9
 
10
10
  # Recur Entitlements & Access Control
@@ -15,13 +15,18 @@ You are helping implement access control using Recur's entitlements system. Enti
15
15
 
16
16
  ```tsx
17
17
  import { RecurProvider, useCustomer } from 'recur-tw'
18
+ // Your own hook, defined in "Knowing when the answer is real" below.
19
+ import { useEntitlementsReady } from './hooks/use-entitlements-ready'
20
+
21
+ // The identifier you hand the provider. Pass the same value to the readiness hook.
22
+ const customerEmail = 'user@example.com'
18
23
 
19
24
  // 1. Wrap app with provider and identify customer
20
25
  function App() {
21
26
  return (
22
27
  <RecurProvider
23
28
  config={{ publishableKey: process.env.NEXT_PUBLIC_RECUR_PUBLISHABLE_KEY }}
24
- customer={{ email: 'user@example.com' }}
29
+ customer={{ email: customerEmail }}
25
30
  >
26
31
  <MyApp />
27
32
  </RecurProvider>
@@ -30,9 +35,10 @@ function App() {
30
35
 
31
36
  // 2. Check access anywhere in your app
32
37
  function PremiumFeature() {
33
- const { check, isLoading } = useCustomer()
38
+ const { check } = useCustomer()
39
+ const ready = useEntitlementsReady(customerEmail)
34
40
 
35
- if (isLoading) return <div>Loading...</div>
41
+ if (!ready) return <div>Loading...</div>
36
42
 
37
43
  const { allowed } = check('pro-plan')
38
44
 
@@ -44,6 +50,101 @@ function PremiumFeature() {
44
50
  }
45
51
  ```
46
52
 
53
+ ### Knowing when the answer is real
54
+
55
+ `useCustomer()` has no "initialized" flag. `isLoading` starts `false` because the request
56
+ only begins in an effect, and `customer` is `null` both before the request and when the
57
+ customer genuinely does not exist — so neither one can tell "not fetched yet" from "no such
58
+ customer". Gating on `isLoading` alone flashes an upgrade prompt on the first paint; gating
59
+ on `!customer` leaves a brand-new customer on the spinner forever.
60
+
61
+ Track the first completed load yourself:
62
+
63
+ ```tsx
64
+ // hooks/use-entitlements-ready.ts
65
+ import { useEffect, useRef, useState } from 'react'
66
+ import { useCustomer } from 'recur-tw'
67
+
68
+ // `customerKey` is the identifier you pass to RecurProvider (email, externalId or id).
69
+ export function useEntitlementsReady(customerKey: string | null | undefined) {
70
+ const { isLoading, error, customer } = useCustomer()
71
+ const [ready, setReady] = useState(false)
72
+ const [seenKey, setSeenKey] = useState(customerKey)
73
+ const pending = useRef(false)
74
+ const switched = useRef(false)
75
+
76
+ // Adjusted during render, not in an effect: RecurProvider kicks off the new request
77
+ // from an effect and leaves the old cache with `isLoading === false` until it does,
78
+ // so an effect-only guard would still pass for one render after a switch.
79
+ if (customerKey !== seenKey) {
80
+ setSeenKey(customerKey)
81
+ setReady(false)
82
+ switched.current = true
83
+ }
84
+
85
+ useEffect(() => {
86
+ if (isLoading) {
87
+ pending.current = true
88
+ setReady(false) // a new fetch: the cache still holds the previous customer
89
+ } else if (pending.current) {
90
+ pending.current = false
91
+ setReady(!error) // ready only when that request actually succeeded
92
+ }
93
+ }, [isLoading, error])
94
+
95
+ // A customer already in the cache proves a request finished. Without this, a gate that
96
+ // first mounts AFTER the provider settled never sees isLoading go true, so it would wait
97
+ // forever. Only before a switch: afterwards the cache still holds the previous customer.
98
+ return ready || (!switched.current && !isLoading && customer !== null)
99
+ }
100
+ ```
101
+
102
+ The `!error` reset matters because the SDK restores its last successful cache on a failed
103
+ request and still clears `isLoading`; without it a failed fetch would open the guard over
104
+ whatever was cached before. Read `error` from `useCustomer()` to tell "still loading" from
105
+ "the fetch failed".
106
+
107
+ It stays `false` if you did not pass a `customer` to `RecurProvider`, because no request
108
+ ever runs — that is the case to catch in development.
109
+
110
+ ### Mount the gate with the provider
111
+
112
+ Put the guard in one component directly under `RecurProvider`, not in every leaf:
113
+
114
+ ```tsx
115
+ function EntitlementsGate({ children }: { children: React.ReactNode }) {
116
+ const ready = useEntitlementsReady(customerEmail)
117
+ if (!ready) return <Spinner />
118
+ return <>{children}</>
119
+ }
120
+
121
+ <RecurProvider config={{ publishableKey }} customer={{ email: customerEmail }}>
122
+ <EntitlementsGate>
123
+ <App /> {/* everything below can call check() freely */}
124
+ </EntitlementsGate>
125
+ </RecurProvider>
126
+ ```
127
+
128
+ A gate that mounts with the provider watches the whole request, which is the only way the
129
+ hook sees the load at all. A gate that first mounts much later relies on the cache fallback
130
+ in the last line of the hook, and that cannot help when the customer does not exist in
131
+ Recur: the entitlements endpoint answers `200` with `customer: null` both for "no such
132
+ customer" and for "not fetched yet", so a late-mounted gate for an unknown customer waits.
133
+
134
+ ### What this hook cannot do
135
+
136
+ It does not make a customer switch safe. `RecurProvider` has no request sequencing: when
137
+ the identifier changes it starts a second request while the first is still in flight, and
138
+ whichever lands last wins. The previous customer's response can overwrite the new one's
139
+ cache and clear the shared `isLoading` flag, at which point this hook reports ready over the
140
+ wrong data. The render-time reset narrows the window but cannot close it from outside the
141
+ SDK.
142
+
143
+ So if one session can switch between customers in place, do not treat the client cache as an
144
+ authorization decision. Confirm on the server, where you hold the secret key and read the
145
+ customer you actually mean. Remounting the app on a switch also avoids the overlap.
146
+
147
+
47
148
  ## Customer Identification
48
149
 
49
150
  Identify customers using one of these methods:
@@ -82,20 +183,28 @@ if (allowed) {
82
183
 
83
184
  ### Async Check (Live)
84
185
 
85
- Fetches fresh data from API. Use for critical operations.
186
+ Refetches before answering. Useful, but **not authoritative** — see the warning below.
86
187
 
87
188
  ```tsx
88
189
  const { check } = useCustomer()
89
190
 
90
- // Real-time check
191
+ // Refetches, then answers
91
192
  const { allowed, entitlement } = await check('pro-plan', { live: true })
92
193
 
93
194
  // Good for:
94
- // - Before processing important actions
95
- // - After checkout to confirm access
96
- // - When cached data might be stale
195
+ // - Refreshing the UI after checkout
196
+ // - When cached data is probably stale
97
197
  ```
98
198
 
199
+ > **`{ live: true }` can answer from the previous snapshot.** In the current SDK it awaits
200
+ > the refetch and then calls the synchronous check captured by the render it was called
201
+ > from, so the fresh data is in the cache but the answer is not computed from it. Right
202
+ > after a purchase it can still say `false`.
203
+ >
204
+ > Never use it as the gate on something that matters — granting access, releasing a
205
+ > download, starting paid work. Check on the server with the secret key instead. On the
206
+ > client, `{ live: true }` is a way to refresh what the user sees, not a decision.
207
+
99
208
  ### Manual Refetch
100
209
 
101
210
  ```tsx
@@ -168,7 +277,7 @@ async function checkAccess(userEmail: string) {
168
277
  ### Using REST API Directly
169
278
 
170
279
  ```typescript
171
- // GET /api/v1/customers/entitlements
280
+ // GET /v1/customers/entitlements
172
281
  const response = await fetch(
173
282
  `https://api.recur.tw/v1/customers/entitlements?email=${encodeURIComponent(email)}`,
174
283
  {
@@ -192,15 +301,19 @@ const { customer, subscription, entitlements } = await response.json()
192
301
  function Paywall({
193
302
  children,
194
303
  product,
304
+ customerKey,
195
305
  fallback
196
306
  }: {
197
307
  children: React.ReactNode
198
308
  product: string
309
+ /** The identifier passed to RecurProvider, so the guard closes on a customer switch. */
310
+ customerKey: string | null | undefined
199
311
  fallback?: React.ReactNode
200
312
  }) {
201
- const { check, isLoading } = useCustomer()
313
+ const { check } = useCustomer()
314
+ const ready = useEntitlementsReady(customerKey)
202
315
 
203
- if (isLoading) {
316
+ if (!ready) {
204
317
  return <div>Loading...</div>
205
318
  }
206
319
 
@@ -214,7 +327,7 @@ function Paywall({
214
327
  }
215
328
 
216
329
  // Usage
217
- <Paywall product="pro-plan">
330
+ <Paywall product="pro-plan" customerKey={customerEmail}>
218
331
  <PremiumDashboard />
219
332
  </Paywall>
220
333
  ```
@@ -222,11 +335,14 @@ function Paywall({
222
335
  ### Feature Flag Style
223
336
 
224
337
  ```tsx
225
- function useFeature(featureProduct: string) {
226
- const { check, isLoading } = useCustomer()
227
-
228
- if (isLoading) {
229
- return { enabled: false, loading: true }
338
+ function useFeature(featureProduct: string, customerKey: string | null | undefined) {
339
+ const { check, error } = useCustomer()
340
+ const ready = useEntitlementsReady(customerKey)
341
+
342
+ // `isLoading` alone is not enough here either: it starts false, so the first paint
343
+ // would read an empty cache and report the feature as off.
344
+ if (!ready) {
345
+ return { enabled: false, loading: !error, error, entitlement: undefined }
230
346
  }
231
347
 
232
348
  const { allowed, entitlement } = check(featureProduct)
@@ -234,6 +350,7 @@ function useFeature(featureProduct: string) {
234
350
  return {
235
351
  enabled: allowed,
236
352
  loading: false,
353
+ error: null,
237
354
  entitlement,
238
355
  isTrial: entitlement?.status === 'trialing',
239
356
  isPastDue: entitlement?.status === 'past_due',
@@ -242,7 +359,7 @@ function useFeature(featureProduct: string) {
242
359
 
243
360
  // Usage
244
361
  function MyComponent() {
245
- const { enabled, isTrial } = useFeature('pro-plan')
362
+ const { enabled, isTrial } = useFeature('pro-plan', customerEmail)
246
363
 
247
364
  if (!enabled) return <UpgradeButton />
248
365
 
@@ -337,7 +454,7 @@ const { entitlement } = check('pro-plan')
337
454
 
338
455
  if (entitlement?.status === 'trialing') {
339
456
  const trialEnds = new Date(entitlement.expiresAt!)
340
- const daysLeft = Math.ceil((trialEnds - Date.now()) / (1000 * 60 * 60 * 24))
457
+ const daysLeft = Math.ceil((trialEnds.getTime() - Date.now()) / (1000 * 60 * 60 * 24))
341
458
 
342
459
  return <TrialBanner daysLeft={daysLeft} />
343
460
  }
@@ -393,7 +510,9 @@ if (!allowed) {
393
510
  ## Best Practices
394
511
 
395
512
  1. **Use cached checks for UI** - Fast rendering, good UX
396
- 2. **Use live checks for actions** - Ensure fresh data for important operations
513
+ 2. **Decide on the server** - Anything that grants access, releases a file or starts paid
514
+ work is checked with the secret key. `{ live: true }` refreshes the UI; it can still
515
+ answer from the previous snapshot, so it is not an authorization decision
397
516
  3. **Handle all statuses** - active, trialing, past_due, canceled
398
517
  4. **Refetch after checkout** - Ensure UI updates after purchase
399
518
  5. **Implement graceful degradation** - Show upgrade prompts, not errors
@@ -4,7 +4,7 @@ description: List all available Recur skills and how to use them. Use when user
4
4
  license: MIT
5
5
  metadata:
6
6
  author: recur
7
- version: "0.0.11"
7
+ version: "0.0.13"
8
8
  ---
9
9
 
10
10
  # Recur Skills 使用指南
@@ -4,7 +4,7 @@ description: Implement Customer Portal for subscription self-service. Use when b
4
4
  license: MIT
5
5
  metadata:
6
6
  author: recur
7
- version: "0.0.11"
7
+ version: "0.0.13"
8
8
  ---
9
9
 
10
10
  # Recur Customer Portal Integration
@@ -4,7 +4,7 @@ description: Quick setup guide for Recur payment integration, from account signu
4
4
  license: MIT
5
5
  metadata:
6
6
  author: recur
7
- version: "0.0.11"
7
+ version: "0.0.13"
8
8
  ---
9
9
 
10
10
  # Recur Quickstart
@@ -4,7 +4,7 @@ description: Set up and handle Recur webhook events for payment notifications. U
4
4
  license: MIT
5
5
  metadata:
6
6
  author: recur
7
- version: "0.0.11"
7
+ version: "0.0.13"
8
8
  ---
9
9
 
10
10
  # Recur Webhook Integration
@@ -22,8 +22,8 @@ You are helping implement Recur webhooks to receive real-time payment and subscr
22
22
  | `subscription.cancelled` | Subscription cancelled — check `data.status` / `data.current_period_end`: period-end cancellation keeps access until that date, while dashboard or refund cancellation revokes it immediately |
23
23
  | `subscription.renewed` | Recurring payment successful |
24
24
  | `subscription.past_due` | Payment failed, subscription at risk |
25
- | `invoice.paid` / `invoice.payment_failed` | Invoice outcome — check `billing_reason` (`subscription_cycle` = renewal, `subscription_update` = plan switch, `subscription_create`, `purchase`). A paid renewal also sends `subscription.renewed`; a failed charge also sends `subscription.past_due` only with a grace period. A renewal with no usable card sends NO invoice event — see `subscription.payment_method_required` / `subscription.revoked` |
26
- | `order.paid` | One-time purchase completed |
25
+ | `invoice.paid` / `invoice.payment_failed` | Invoice outcome. Invoices exist only for renewals and plan switches, so `billing_reason` is `subscription_cycle` or `subscription_update` and **never** `subscription_create` — a first payment produces no invoice. A paid renewal also sends `subscription.renewed`; a failed charge also sends `subscription.past_due` only with a grace period. A renewal with no usable card sends NO invoice event — see `subscription.payment_method_required` / `subscription.revoked` |
26
+ | `order.paid` / `order.payment_failed` | An order was paid or failed — **not one-time purchases only**. A subscription's first payment is an order, not an invoice, so both events also carry `billing_reason: 'subscription_create'`, and these are the ONLY events that report that first charge. Handle both branches: `purchase` is the one-time buy, `subscription_create` the signup. An empty `subscription_create` branch silently loses every new subscriber |
27
27
  | `refund.created` | Refund initiated |
28
28
 
29
29
  ### All Supported Events (`WEBHOOK_EVENT_TYPES` in `recur-tw/server`)
@@ -97,13 +97,32 @@ export async function POST(request: Request) {
97
97
  // subscription.payment_method_required (grace) or subscription.revoked (no grace).
98
98
  // Handle those below, or you will miss those failures entirely.
99
99
  case 'invoice.paid':
100
- // invoice.paid is not renewal-only: a plan switch pays an invoice too. Branch on
101
- // billing_reason — 'subscription_cycle' is the renewal, 'subscription_update' the
102
- // switch, 'subscription_create' the first payment, 'purchase' a one-off.
100
+ // Invoices exist only for renewals and plan switches, so billing_reason here is
101
+ // 'subscription_cycle' or 'subscription_update' — never 'subscription_create'.
102
+ // A subscription's FIRST payment produces no invoice at all; it arrives as
103
+ // order.paid. One-time purchases produce no invoice either.
103
104
  if (event.data.billing_reason === 'subscription_cycle') {
104
105
  await handleRenewal(event.data) // extend access, record recurring revenue
105
106
  } else {
106
- await handleInvoicePaid(event.data) // switches, first payments, one-offs
107
+ await handleSwitchInvoice(event.data) // 'subscription_update': the plan switch
108
+ }
109
+ break
110
+ case 'order.paid':
111
+ // Orders cover BOTH one-time buys and a subscription's first payment. Nothing else
112
+ // reports that first payment, so both branches have to do real work.
113
+ if (event.data.billing_reason === 'purchase') {
114
+ await handleOneTimePurchase(event.data)
115
+ } else {
116
+ await handleFirstSubscriptionPayment(event.data) // 'subscription_create'
117
+ }
118
+ break
119
+ case 'order.payment_failed':
120
+ // Same split. A failed signup emits ONLY this event — no invoice.payment_failed
121
+ // follows it — so an empty branch here loses the failure entirely.
122
+ if (event.data.billing_reason === 'purchase') {
123
+ await handlePurchaseFailed(event.data)
124
+ } else {
125
+ await handleSignupFailed(event.data) // 'subscription_create'
107
126
  }
108
127
  break
109
128
  case 'subscription.renewed':
@@ -143,8 +162,12 @@ async function handleSubscriptionCancelled(data: Payload) {
143
162
  // Period-end cancellation: keep access until current_period_end.
144
163
  // Immediate cancellation (dashboard, refund): status is already "canceled" — revoke now.
145
164
  }
146
- async function handleRenewal(data: Payload) {} // billing_reason 'subscription_cycle'
147
- async function handleInvoicePaid(data: Payload) {} // every other billing_reason
165
+ async function handleRenewal(data: Payload) {} // invoice, 'subscription_cycle'
166
+ async function handleSwitchInvoice(data: Payload) {} // invoice, 'subscription_update'
167
+ async function handleOneTimePurchase(data: Payload) {} // order, 'purchase'
168
+ async function handleFirstSubscriptionPayment(data: Payload) {} // order, 'subscription_create'
169
+ async function handlePurchaseFailed(data: Payload) {} // order failure, 'purchase'
170
+ async function handleSignupFailed(data: Payload) {} // order failure, 'subscription_create'
148
171
  async function handlePaymentFailure(data: Payload) {}
149
172
  async function handleRefundCreated(data: Payload) {}
150
173
  ```
@@ -154,12 +177,14 @@ async function handleRefundCreated(data: Payload) {}
154
177
  ```typescript
155
178
  import express from 'express'
156
179
  import { Recur } from 'recur-tw/server'
180
+ // Your own dispatch — the claim-first version is in "Idempotency" below.
181
+ import { handleEvent } from './handle-event'
157
182
 
158
183
  const app = express()
159
184
  const recur = new Recur(process.env.RECUR_SECRET_KEY!)
160
185
 
161
186
  // Use the raw body for signature verification
162
- app.post('/api/webhooks/recur', express.raw({ type: 'application/json' }), (req, res) => {
187
+ app.post('/api/webhooks/recur', express.raw({ type: 'application/json' }), async (req, res) => {
163
188
  const payload = req.body.toString()
164
189
  const signature = req.header('x-recur-signature') ?? null
165
190
 
@@ -170,7 +195,14 @@ app.post('/api/webhooks/recur', express.raw({ type: 'application/json' }), (req,
170
195
  return res.status(401).json({ error: 'Invalid signature' })
171
196
  }
172
197
 
173
- console.log('Received event:', event.type, event.id) // envelope id: present on every event
198
+ // Dispatch before answering: a 2xx tells Recur the event is handled.
199
+ try {
200
+ await handleEvent(event)
201
+ } catch (err) {
202
+ console.error('webhook handler failed', event.id, err)
203
+ return res.status(500).json({ error: 'handler failed' }) // Recur retries
204
+ }
205
+
174
206
  res.json({ received: true })
175
207
  })
176
208
  ```
@@ -233,6 +265,8 @@ interface WebhookEvent {
233
265
 
234
266
  // Enum-derived fields (status, type, billing_reason, switch_type, reason) are lowercased
235
267
  // before delivery: "active", "canceled", "complete" — compare against lowercase values.
268
+ // The REST API does NOT do this: the same field reads "SUBSCRIPTION_UPDATE" / "ACTIVE"
269
+ // there (see openapi.json). Only webhook payloads are lowercased.
236
270
 
237
271
  // Example: checkout.completed
238
272
  {
@@ -290,7 +324,8 @@ MCP: `test_webhook` sends a test event to your endpoint; `get_webhook_events` sh
290
324
 
291
325
  ### 1. Always Verify Signatures
292
326
 
293
- Never trust webhook payloads without `recur.webhooks.verify()`.
327
+ Never trust a webhook payload you have not verified — with `recur.webhooks.verify()`, or
328
+ with one of the constant-time verifiers above if you are not using the SDK.
294
329
 
295
330
  ### 2. Handle Idempotency
296
331