Skip to main content

stacks-payments

Use when implementing payment processing in Stacks - Stripe charges, subscriptions, checkout sessions, customer management, payment methods, invoices, coupons, promo codes, products, prices, webhooks, or the Payment facade. Covers @stacksjs/payments and config/payment.ts.

Source facts

Repository
stacksjs/wildloop
Last source activity
October 6, 2026 at 19:00
Detected SKILL.md language
English
Stars
2
Forks
0

Install options

The review-first prompt is selected by default. You can switch to a direct command or download a local copy.

Review the source files

Read SKILL.md and any companion files shown by SkillsMP before deciding whether to install.

Showing SKILL.md

SKILL.md
Source instructions · Read-only preview
name
stacks-payments
description
Use when implementing payment processing in Stacks - Stripe charges, subscriptions, checkout sessions, customer management, payment methods, invoices, coupons, promo codes, products, prices, webhooks, or the Payment facade. Covers @stacksjs/payments and config/payment.ts.
license
MIT
compatibility
Bun >= 1.3.0, TypeScript
allowed-tools
Read Edit Write Bash Grep Glob
# Stacks Payments Full Stripe integration via the Payment facade. Uses Stripe API version `2026-01-28.clover`. The Stripe SDK is initialized from `services.stripe.secretKey` (sourced from `config/payment.ts`). ## Payment Drivers (provider-neutral) `paymentDriver()` (also `Payment.driver()`) returns the driver `config.payment.driver` selects: `stripe` (default) or `adyen`, or one added with `registerPaymentDriver(name, factory)`. Source: `storage/framework/core/payments/src/driver/`. - Money is `{ amount, currency }` in minor units; `assertMoney` refuses floats before a request leaves. - Methods: `customer`, `charge(payer, money, paymentMethodId)`, `createPayment` (browser completes: `clientConfirmation` is a Stripe client secret or an Adyen session), `refund`, `checkout(payer, { mode, lines, successUrl, ... })`, `paymentMethods`, `removePaymentMethod`, `subscribe`, `cancelSubscription`, `subscriptions`, `verifyWebhook({ payload, headers })` -> `PaymentEvent[]`, `acknowledgeWebhook()`. - `checkout` copies `reference` and `metadata` onto the payment / subscription / setup intent it creates (Stripe: `payment_intent_data`, `subscription_data`, `setup_intent_data`), so their webhooks can be attributed. `allowPromotionCodes` and `automaticTax` are Stripe only; Adyen refuses them. - Unsupported operations throw `PaymentUnsupportedError` (driver + operation). Adyen has no catalog, subscriptions, invoices, coupons or portal. - Adyen: Checkout API v72, `X-API-Key`; shopper reference `user-<id>` (no PII); full refunds go to `/reversals`; webhooks verified per item with HMAC-SHA256 over 8 colon-joined fields under the hex key, acknowledged with 202. - `PaymentEvent` carries `provider` and `id` (together, the retry key), `reference` (the payment; for a refund, the payment refunded), `amount` (for a refund, that one refund - Stripe's cumulative `amount_refunded` is turned into a delta from `previous_attributes`) and `reason`. Adyen's id is `eventCode:pspReference:success`, since Adyen may resend a pair with the outcome changed. - The Stripe driver delegates to the existing billable modules, so idempotency keys and `stripe_id` handling are unchanged. - Webhook route: the `payments` default route bundle (mounted with the `commerce` feature) serves `POST /webhooks/payments` through `orders.receivePaymentWebhook(request)`: 401 `missing-config` while the driver's secret is unset (`WebhookNotConfiguredError`), 400 `invalid-signature` (`WebhookSignatureError`), otherwise applies the events and answers with `acknowledgeWebhook()`. - Commerce: `orders.handleCommercePaymentEvent(event)` from `@stacksjs/commerce` applies `payment.succeeded` / `payment.failed` (reason into `payments.failure_reason`) / `refund.succeeded` (added to `refund_amount`; `refunded` + order REFUNDED only when it covers the payment, else `partiallyRefunded`) to the order, deduplicated in `payment_webhook_events` (the `PaymentWebhookEvent` model) in the same transaction. - The Payment facade's neutral functions go through this driver; its Stripe-only functions and everything Stripe-level below return Stripe objects. ## Key Paths - Core package: `storage/framework/core/payments/src/` - Payment facade: `storage/framework/core/payments/src/payment.ts` - Stripe driver: `storage/framework/core/payments/src/drivers/stripe.ts` - Billable modules: `storage/framework/core/payments/src/billable/` - Configuration: `config/payment.ts` - SaaS config: `config/saas.ts` - Default billing functions: `storage/framework/defaults/functions/billing/payments.ts` ## Package Exports - `Payment` (default) -- facade object with all payment methods - All individual functions (`charge`, `subscribe`, `checkout`, etc.) - All billable modules (`manageCharge`, `manageCustomer`, `manageSubscription`, etc.) - `stripe` -- raw Stripe SDK instance - `Stripe` -- re-exported Stripe types namespace - `stacksIdempotencyKey`, `freshIdempotencyKey` -- see Idempotency below ## Payment Facade The `Payment` object aggregates the payment operations; every function is also a standalone export. It has two halves (`storage/framework/core/payments/src/payment.ts`): - **Provider-neutral** - through `paymentDriver()`, so they work on Stripe and Adyen alike. Money is `{ amount, currency }` in minor units; results are the driver's shapes. - **Stripe only** - Stripe's parameters and types, and `PaymentUnsupportedError` (naming the operation) under any other driver: `changeSubscription`, `updateCustomer`, `deleteCustomer`, `addPaymentMethod`, `setDefaultPaymentMethod`, `createSetupIntent`, `getInvoices`, `createInvoice`, `payInvoice`, `createProduct`, `getPrice`, `listProducts`, `createCoupon`, `createPromoCode`, `validatePromoCode`, `billingPortal`. The check is `assertStripeDriver(operation)`, shared with the billable trait. Something Stripe-specific the neutral half cannot express (a Connect destination charge's `payment_intent_data.transfer_data`, `prorate`/`invoice_now` on a cancellation, customer create params) goes through the `manage*` modules or the raw `stripe` client. ### Charges ```typescript import { Payment } from '@stacksjs/payments' // Charge a stored payment method, server side const result = await Payment.charge(user, { amount: 2999, currency: 'usd' }, 'pm_xxx', { reference: 'order-1' }) // A payment the browser completes: result.clientConfirmation is a Stripe client secret or an Adyen session const pending = await Payment.createPayment(user, { amount: 2999, currency: 'usd' }) // Refund -- partial, or full when the amount is left out const partial = await Payment.refund('pi_xxx', { amount: { amount: 1000, currency: 'usd' } }) const full = await Payment.refund('pi_xxx') ``` `PaymentResult` has `id`, `status` (`succeeded`, `processing`, `requires_action`, `requires_payment_method`, `failed`, `canceled`), `amount`, `clientConfirmation`, `redirectUrl` and `raw` (the provider's object; strip it before a browser sees it). The `manageCharge` module is the Stripe-level API (`findPayment(id)` and the PaymentIntent params). ### Checkout Sessions ```typescript const session = await Payment.checkout(user, { mode: 'payment', lines: [{ price: 'price_xxx', quantity: 1 }], successUrl: 'https://app.example.com/success', cancelUrl: 'https://app.example.com/cancel', }) // One price in subscription mode const upgrade = await Payment.subscriptionCheckout(user, 'price_xxx', { successUrl: 'https://app.example.com/welcome', cancelUrl: 'https://app.example.com/pricing', metadata: { user_id: String(user.id) }, }) ``` Send the payer to `session.url`. `trialDays` starts a subscription checkout with a trial. `reference` and `metadata` are copied onto the payment or subscription the checkout creates. Return URLs must be on the app's origin. ### Subscriptions ```typescript // Subscribe to a price (a Stripe lookup key); `type` names it locally, `default` unless given const sub = await Payment.subscribe(user, 'premium-monthly') // Cancel now await Payment.cancelSubscription('sub_xxx') // Stop renewing: entitled until the period ends, recorded in the row's ends_at await Payment.cancelSubscription('sub_xxx', { atPeriodEnd: true }) // The provider's subscriptions, every status const all = await Payment.subscriptions(user) // Entitlement, from the local subscriptions table const hasActive = await Payment.hasActiveSubscription(user, 'default') // Stripe only: swap the subscription item's price const changed = await Payment.changeSubscription(user, 'enterprise-monthly') ``` `subscribe()` resolves the price by lookup key, creates the subscription with `payment_behavior: 'allow_incomplete'` and `expand: ['latest_invoice.confirmation_secret']` (an incomplete subscription's summary carries that as `clientConfirmation`), and stores it in `subscriptions`. Dates are stored as timestamps from the subscription item's `current_period_end`, where Stripe keeps it since API 2025-03-31.basil. `isValid()` returns `true` if the subscription status is `'active'` or `'trialing'`. `isIncomplete()` checks for `'incomplete'` status. `cancelSubscription(id, immediately)` is gone: its `false` also cancelled at once, only with a proration. For Stripe's `prorate` / `invoice_now`, call `manageSubscription.cancel(id, params)`. ### Customers ```typescript // The provider's customer, created on first use: { id, email, name } const customer = await Payment.getOrCreateCustomer(user) // Stripe only const updated = await Payment.updateCustomer(user, { name: 'Jane' }) const deleted = await Payment.deleteCustomer(user) ``` `createOrGetStripeUser()` (what the Stripe driver uses) checks `user.stripe_id` first. If the user has one, it retrieves the customer from Stripe. If the customer was deleted (404 or `deleted: true`), it creates a new one. On creation, it auto-fills `name` and `email` from the user model and calls `user.update({ stripe_id: customer.id })`. Pass Stripe customer create params through `manageCustomer.createOrGetStripeUser(user, params)`. Additional methods on `manageCustomer`: - `stripeId(user)` -- returns `user.stripe_id` - `hasStripeId(user)` -- boolean check - `createStripeCustomer(user, options)` -- throws if user already has a stripe_id - `createOrUpdateStripeUser(user, options)` -- creates or updates - `retrieveStripeUser(user)` -- returns customer or undefined - `syncStripeCustomerDetails(user, options)` -- updates Stripe with user's name, email, address, locales, metadata ### Payment Methods ```typescript // The provider's stored methods, default marked: { id, type, brand, last4, expMonth, expYear, isDefault } const methods = await Payment.paymentMethods(user) // Detach one, by the provider's id await Payment.removePaymentMethod(user, 'pm_xxx') // Stripe only const pm = await Payment.addPaymentMethod(user, 'pm_xxx') const customer = await Payment.setDefaultPaymentMethod(user, 'pm_xxx') const intent = await Payment.createSetupIntent(user, { payment_method_types: ['card'] }) ``` `removePaymentMethod()` used to take the local `payment_methods` row id as well; `managePaymentMethod.deletePaymentMethod(user, id)` still does. `addPaymentMethod()` accepts a string (Stripe PM ID) or Stripe.PaymentMethod object. It attaches the PM to the customer if not already attached, then stores it in the `payment_methods` table with `type`, `last_four`, `brand`, `exp_year`, `exp_month`, `user_id`, `provider_id`. `setUserDefaultPayment()` accepts a Stripe PM ID string, clears existing `is_default` flags, sets the new default in the database, and updates `invoice_settings.default_payment_method` on the Stripe customer. `setDefaultPaymentMethod()` accepts a database record ID number. Additional methods on `managePaymentMethod`: - `updatePaymentMethod(user, pmId, params)` -- updates PM in Stripe - `listPaymentMethods(user)` -- queries `payment_methods` table by `user_id` - `retrievePaymentMethod(user, pmId)` -- by database ID - `retrieveDefaultPaymentMethod(user)` -- finds where `is_default: true` ### Invoices ```typescript const invoices = await Payment.getInvoices(user) // lists with expanded payment_intent.payment_method const invoice = await Payment.createInvoice('cus_xxx', { description: 'Custom invoice' }) const paid = await Payment.payInvoice('inv_xxx') ``` `getInvoices()` requires the user to have a `stripe_id` and expands `data.payment_intent.payment_method`. ### Products & Prices ```typescript // Create a product with its price const { product, price } = await Payment.createProduct('Pro Plan', 2999, { currency: 'usd', interval: 'month', description: 'Pro features' }) // Get a price by Stripe lookup_key const price = await Payment.getPrice('premium-monthly') // List active products const products = await Payment.listProducts({ limit: 10 }) ``` `createProduct()` creates the Stripe product, then creates a price linked to it. If `interval` is provided, the price gets `recurring: { interval }`. Additional methods on `manageProduct`: - `create(params)`, `retrieve(productId)`, `update(productId, params)` - `archive(productId)` -- sets `active: false` - `search(query, params)` -- Stripe product search - `list(params)` -- defaults to `active: true` Additional methods on `managePriceExtended`: - `create(params)`, `retrieve(priceId)`, `update(priceId, params)` - `list(params)` -- defaults to `active: true` - `listByProduct(productId, params)` -- prices for a specific product - `search(query, params)` -- Stripe price search - `archive(priceId)` -- sets `active: false` ### Coupons & Promo Codes ```typescript const coupon = await Payment.createCoupon({ percentOff: 20, duration: 'once', name: 'Holiday Sale', maxRedemptions: 100 }) // Or amount-based: { amountOff: 500, currency: 'usd', duration: 'forever' } const promo = await Payment.createPromoCode('coupon_xxx', 'SAVE20') const valid = await Payment.validatePromoCode('SAVE20') // returns PromotionCode | null ``` `createCoupon()` supports `percentOff`, `amountOff`, `currency`, `duration` ('forever' | 'once' | 'repeating'), `durationInMonths`, `name`, `maxRedemptions`. Additional methods on `manageCoupon`: - `retrieve(couponId)`, `update(couponId, params)`, `delete(couponId)`, `list(params)` - `createPromotionCode(params)` -- wraps `stripe.promotionCodes.create()` - `retrievePromotionCode(code)` -- lists active promotion codes matching the code string ### Transactions ```typescript import { manageTransaction } from '@stacksjs/payments' const tx = await manageTransaction.store(user, productId, { brand: 'visa', provider_id: 'pi_xxx', description: 'Purchase', type: 'one-time' }) const txList = await manageTransaction.list(user) ```
View on GitHub
This SKILL.md is very large, so SkillsMP previews the first section here. View on GitHub