skills-ws
Version:
87 agent skills for AI coding assistants — marketing, growth, web3, dev, design & operations. Built for OpenClaw, Claude Code, Cursor, and Codex.
253 lines (217 loc) • 8.91 kB
Markdown
## Contents
- Subscription Lifecycle
- Creating a Customer
- Trials
- Upgrade / Downgrade (Plan Changes)
- Seat Changes
- Cancellation
- Pausing Subscriptions
## Subscription Lifecycle
### Creating a Customer
```js
async function getOrCreateStripeCustomer(user) {
if (user.stripe_customer_id) {
return user.stripe_customer_id;
}
const customer = await stripe.customers.create({
email: user.email,
name: user.name,
metadata: {
user_id: user.id,
},
});
await db.query(
'UPDATE users SET stripe_customer_id = $1 WHERE id = $2',
[customer.id, user.id]
);
return customer.id;
}
```
### Trials
```js
// Via Checkout Session
const session = await stripe.checkout.sessions.create({
mode: 'subscription',
customer: customerId,
line_items: [{ price: priceId, quantity: 1 }],
subscription_data: {
trial_period_days: 14,
},
// Collect payment method upfront (card saved, charged after trial)
payment_method_collection: 'always',
success_url: `${BASE_URL}/billing/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${BASE_URL}/pricing`,
});
// Via API directly
const subscription = await stripe.subscriptions.create({
customer: customerId,
items: [{ price: priceId }],
trial_period_days: 14,
payment_behavior: 'default_incomplete',
// basil and later removed invoice.payment_intent; read the client secret from
// subscription.latest_invoice.confirmation_secret.client_secret, and list
// payments via the invoice.payments array if you need PaymentIntent records.
expand: ['latest_invoice.confirmation_secret'],
});
```
### Upgrade / Downgrade (Plan Changes)
> **Never assume `items.data[0]` is "the plan".** Hybrid subscriptions (base +
> metered) have multiple items, and Stripe does not guarantee their order.
> Targeting the wrong item silently changes the metered item instead of the base
> price (or vice versa). Identify items explicitly — by `price.lookup_key`,
> product metadata, or a stored subscription-item id.
```js
// Resolve a specific subscription item by lookup_key (preferred) or by a
// predicate over its price/product. Falls back to throwing rather than guessing.
function findSubscriptionItem(subscription, { lookupKey, match } = {}) {
const items = subscription.items.data;
const found = items.find((it) =>
(lookupKey && it.price.lookup_key === lookupKey) ||
(match && match(it))
);
if (!found) {
throw new Error(
`No subscription item matched ${lookupKey ?? 'predicate'} ` +
`on ${subscription.id} (has ${items.length} item(s))`
);
}
return found;
}
// 2025-03-31.basil and later removed current_period_start/current_period_end
// from the Subscription object: they now live on each subscription item.
// Read the period end from the base (non-metered) item.
function periodEnd(subscription) {
const item = subscription.items.data.find(
(it) => it.price.recurring?.usage_type !== 'metered'
) || subscription.items.data[0];
return item.current_period_end;
}
// Change the BASE plan item only, leaving any metered item untouched.
async function changePlan(subscriptionId, newPriceId, { prorate = true, lookupKey } = {}) {
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
// Pick the base item explicitly. If the sub has exactly one item, that's it;
// otherwise require a lookupKey (or a metadata match) to be unambiguous.
const target = subscription.items.data.length === 1
? subscription.items.data[0]
: findSubscriptionItem(subscription, {
lookupKey,
match: (it) => it.price.recurring?.usage_type !== 'metered', // the flat/base item
});
return stripe.subscriptions.update(subscriptionId, {
items: [{ id: target.id, price: newPriceId }],
proration_behavior: prorate ? 'create_prorations' : 'none',
// For period-end downgrades, prefer a Subscription Schedule (below) — calling
// update() with proration_behavior: 'none' switches the price object NOW
// (no immediate proration, but the new price is on the subscription already).
});
}
// Upgrade immediately with proration (single-item sub)
await changePlan(subId, 'price_enterprise_monthly', { prorate: true });
// Hybrid sub: name the base item so the metered item isn't touched
await changePlan(subId, 'price_enterprise_monthly', { prorate: true, lookupKey: 'base_plan' });
// Downgrade at period end — use Subscription Schedules to defer the change.
// Simply calling subscriptions.update() with proration_behavior: 'none'
// still switches the price immediately (billing changes at next cycle, but
// the price object on the subscription changes right away).
async function downgradeAtPeriodEnd(subscriptionId, newPriceId) {
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
const endTs = periodEnd(subscription); // item-level period end (basil+)
// Create a schedule from the existing subscription
const schedule = await stripe.subscriptionSchedules.create({
from_subscription: subscriptionId,
});
// Update the schedule: keep current phase, add new phase at period end.
// IMPORTANT: Use 'now' for start_date of the first phase, not
// subscription.current_period_start — that timestamp is in the past,
// and Stripe rejects past start_date values.
await stripe.subscriptionSchedules.update(schedule.id, {
end_behavior: 'release',
phases: [
{
items: [{ price: subscription.items.data[0].price.id, quantity: 1 }],
start_date: 'now',
end_date: endTs,
},
{
items: [{ price: newPriceId, quantity: 1 }],
start_date: endTs,
iterations: 1,
},
],
});
}
```
### Seat Changes
```js
// Update quantity on the per-seat item. Pass the seat item's lookup_key so this
// works on hybrid/multi-item subscriptions (metered items have no quantity).
async function updateSeats(subscriptionId, newQuantity, { lookupKey = 'per_seat' } = {}) {
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
const seatItem = subscription.items.data.length === 1
? subscription.items.data[0]
: findSubscriptionItem(subscription, { lookupKey });
return stripe.subscriptionItems.update(seatItem.id, {
quantity: newQuantity,
proration_behavior: 'create_prorations',
});
}
```
### Cancellation
```js
// Cancel at period end (recommended — user keeps access until paid period expires)
async function cancelAtPeriodEnd(subscriptionId) {
return stripe.subscriptions.update(subscriptionId, {
cancel_at_period_end: true,
});
}
// Cancel immediately (rare — refund / abuse scenarios)
async function cancelImmediately(subscriptionId) {
// `subscriptions.cancel` (DELETE) ends the subscription NOW. Its supported
// options are `invoice_now` and `prorate` — NOT `proration_behavior`
// (that belongs to subscriptions.update). Passing proration_behavior here
// is ignored/invalid depending on API version.
return stripe.subscriptions.cancel(subscriptionId, {
invoice_now: true, // finalize any pending metered usage into a final invoice
prorate: true, // credit unused time as a proration on that final invoice
});
// `prorate: true`/`invoice_now: true` are the cancel-time flags. Immediate
// cancellation does NOT auto-refund the customer — issue a refund or credit
// note separately if you owe money back:
// await stripe.refunds.create({ payment_intent: '<pi_...>' });
}
// Alternative: schedule a hard cancel at a specific future timestamp without
// ending access now. Use cancel_at (a Unix timestamp) on update:
async function cancelAt(subscriptionId, unixTs) {
return stripe.subscriptions.update(subscriptionId, {
cancel_at: unixTs,
proration_behavior: 'none', // proration_behavior IS valid on update
});
}
// Reactivate before period end
async function reactivateSubscription(subscriptionId) {
return stripe.subscriptions.update(subscriptionId, {
cancel_at_period_end: false,
});
}
```
### Pausing Subscriptions
Stripe supports pausing via `pause_collection`:
```js
// Pause — stop invoicing, keep subscription active
async function pauseSubscription(subscriptionId) {
return stripe.subscriptions.update(subscriptionId, {
pause_collection: {
behavior: 'void', // 'void' = skip invoices, 'keep_as_draft' = draft them
// resumes_at: Math.floor(Date.now() / 1000) + 30 * 86400, // optional auto-resume
},
});
}
// Resume — set pause_collection to null (not empty string) to clear the pause
async function resumeSubscription(subscriptionId) {
return stripe.subscriptions.update(subscriptionId, {
pause_collection: null,
});
}
```
**Decision:** Should paused users keep access? Usually no — revoke API keys / feature access on pause, restore on resume. Handle this in your webhook for `customer.subscription.updated`.
---