Skip to main content

stacks-payments

Use when implementing Stacks payment drivers, charges, subscriptions, checkout, customer methods, Stripe billing/catalog/Connect, provider webhooks, or the Payment facade. Covers @stacksjs/payments and config/payment.ts.

Informações da origem

Repositório
stacksjs/wildloop
Última atividade na origem
14 de outubro de 2026 às 14:18
Idioma detectado do SKILL.md
inglês
Estrelas
2
Forks
0

Opções de instalação

Por padrão, está selecionado o prompt que primeiro revisa a origem. Você pode mudar para um comando direto ou baixar uma cópia local.

Revise os arquivos de origem

Leia o SKILL.md e os arquivos complementares exibidos pelo SkillsMP antes de decidir se vai instalar.

Exibindo SKILL.md

SKILL.md
Instruções da origem · Visualização somente leitura
name
stacks-payments
description
Use when implementing Stacks payment drivers, charges, subscriptions, checkout, customer methods, Stripe billing/catalog/Connect, provider 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), `adyen`, `paddle` or `lemonsqueezy`, 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; Paddle has no server-side charge, server-side subscribe, setup checkout, promotion-code or trial option on a checkout, or separate cancel URL. - Paddle (`driver: 'paddle'`, Billing API v1, merchant of record): customers found or created by email; every payment is a transaction completed in Paddle.js (`clientConfirmation: { provider: 'paddle', transactionId, successUrl? }`), so `charge` and `subscribe` are unsupported and subscriptions start at `checkout` in `subscription` mode with catalog prices; refunds are adjustments Paddle reviews (`pending`, settled by `adjustment.updated`; a refund's event id is the adjustment's, so a created-and-updated approval counts once); webhooks `Paddle-Signature` = HMAC-SHA256 hex of `<ts>:<raw body>` under the secret, 5 s tolerance. Paddle has no hosted page: `GET /payments/checkout` (mounted while the driver is `paddle`) is the default payment link page, reading `stacks_success_url` from the transaction for Paddle.js. Env: `PADDLE_API_KEY`, `PADDLE_ENVIRONMENT`, `PADDLE_CLIENT_TOKEN`, `PADDLE_WEBHOOK_SECRET`, `PADDLE_TAX_CATEGORY`. - Lemon Squeezy (`driver: 'lemonsqueezy'`, API v1, merchant of record): customers found or created by email in the store; `checkout` sells exactly one variant (a `price` line is a variant id; a line priced here sells as `LEMONSQUEEZY_VARIANT_ID` at a custom price in the store's currency); `charge`, `createPayment`, `subscribe` and payment methods are unsupported; refunds are immediate against the order; `cancelSubscription` needs `{ atPeriodEnd: true }`; webhooks `X-Signature` = HMAC-SHA256 hex of the raw body, event id = digest of the bytes, `order_refunded` carries `refundedTotal` (the order's running total), which commerce applies as a difference. Env: `LEMONSQUEEZY_API_KEY`, `LEMONSQUEEZY_STORE_ID`, `LEMONSQUEEZY_WEBHOOK_SECRET`, `LEMONSQUEEZY_VARIANT_ID`, `LEMONSQUEEZY_TEST_MODE`. - 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)`. To undo a cancel at period end before the period ends, call `manageSubscription.resume(id)`: Stripe renews again and the stored `ends_at` is cleared. Both use a fresh idempotency key per attempt, so cancel → resume → cancel within Stripe's 24-hour key window still reaches Stripe each time. ### Stripe Connect (marketplaces) `Payment.connect` (source `src/connect/`) onboards merchants as Express connected accounts and settles sales to them, while the customer, the charge and its refunds and disputes stay on the platform. ```typescript import { manageConnectedAccount, manageDestinationCharge, manageDestinationSubscription } from '@stacksjs/payments' // Onboard: create once (deterministic idempotency key), then a hosted onboarding link const account = await manageConnectedAccount.createConnectedAccount(user) const link = await manageConnectedAccount.accountOnboardingLink(account.id, { refreshUrl, returnUrl }) const status = await manageConnectedAccount.accountStatus(account.id) // chargesEnabled, payoutsEnabled, currentlyDue... // One-off sale: a destination charge (or Checkout with payment_intent_data.transfer_data) const fee = manageDestinationCharge.applicationFeeFor(4900, { platformFeePercent: 10 }) // 490, rounded down // Recurring sale: a destination subscription through Checkout const session = await manageDestinationSubscription.checkout(user, { destination: 'acct_xxx', price: { amount: 9900, currency: 'eur', interval: 'month', productName: 'Coaching' }, // or 'price_xxx' trialDays: 14, platformFeePercent: 0, metadata: { product_id: '7' }, // on the session AND the subscription successUrl: 'https://app.example/purchases', cancelUrl: 'https://app.example/store/coaching', }) ``` - A destination subscription lives on the platform with `transfer_data.destination`, so renewals (`invoice.paid`), failed payments (`invoice.payment_failed`), status changes and cancellation (`customer.subscription.updated` / `.deleted`) arrive on the platform's normal webhook endpoint, not the Connect one. `destinationOf(subscription)` (also `connect.subscriptionDestination`) tells a merchant's subscription from the platform's own. - Its fee is `application_fee_percent` (`feePercentFor`, at most two decimals; 0 sends no fee field), applied to every invoice, unlike a charge's fee amount. - Cancel, resume and read it with `manageSubscription` (`cancelAtPeriodEnd`, `resume`, `cancel`); entitlement from `subscriptionGrantsAccess(status)` and dates from `subscriptionPeriod(subscription)`. - Events about a connected account itself (`account.updated`, `payout.*`) come to the Connect endpoint with `event.account`; read it with `getConnectedAccountId(event)`. ### Bank debits (SEPA Direct Debit, Bacs, ACH, BECS) `src/direct-debit.ts` (also `@stacksjs/payments/direct-debit`, pure and browser-safe) holds what a debit needs next to cards: ```typescript import { currencyForCountry, debitEventOf, paymentMethodsFor, retrieveDebitMandate, stripe } from '@stacksjs/payments' paymentMethodsFor('card,sepa_debit', 'eur') // ['card', 'sepa_debit'] -> Checkout's allowed_payment_method_types paymentMethodsFor('card,sepa_debit', 'usd') // ['card']: SEPA is euros only currencyForCountry('AT', ['usd', 'eur'], 'usd') // 'eur' const debit = debitEventOf(event) // null unless the event is a step in a debit's life // debit.outcome: 'processing' | 'succeeded' | 'failed' | 'returned' | 'mandate_inactive' const mandate = await retrieveDebitMandate(stripe, debit.mandateId!) // { reference, url, status } ``` - `PAYMENT_METHODS` says per method which currencies it takes, whether it is delayed, whether it has a mandate, and `noticeDays` (SEPA: 2, what Stripe's mandate text agrees to; the rulebook's default is 14). - A debit is a delayed-notification payment. Checkout completes with `payment_status: 'unpaid'` (`processing`); only `checkout.session.async_payment_succeeded`, `invoice.paid` or `charge.succeeded` mean the money arrived. **A subscription can turn `active` while its first debit is still processing**, so never grant access from the subscription status alone. - A bank can take a SEPA debit back for eight weeks with no questions asked (13 months if unauthorised). Stripe reports it as `charge.dispute.created`; `debitEventOf` reads it as `returned` unless the dispute is a card's. SEPA disputes are final. - `debitDetailsOf(charge | paymentMethod)` gives the method, the mandate id, the last four digits and the expected debit date. Store the mandate reference and last four, never an IBAN: the payer types it into Stripe Checkout or the Payment Element. - `debitFailureNextStep(code)` turns Stripe's failure code into `retry` (insufficient funds), `update` (new bank details or mandate) or `contact`. - `isDebitNoticeDue({ debitDate, today, leadDays, method })` and `isDebitNoticeTimely` plan the pre-notification each SEPA debit needs (amount, date, mandate reference, last four digits, creditor). Stripe sends its own debit emails while you use Stripe's creditor ID. - `CheckoutRequest.paymentMethods` (Stripe's names) limits a neutral checkout's methods: Stripe's `allowed_payment_method_types` (Stripe API 2026-01-28.clover has no `payment_method_types`, and only offers methods enabled in the Dashboard); Adyen maps `card` and `sepa_debit` to `allowedPaymentMethods`; Paddle and Lemon Squeezy refuse it. - Connect: a destination charge without `on_behalf_of` debits under the platform's creditor ID and mandate, so enable SEPA on the platform account. ### Card-present payments (Stripe Terminal) `src/terminal.ts` (also `@stacksjs/payments/terminal`) is the server-driven Terminal flow: a smart reader (WisePOS E, S700, simulated) collects the card while the server holds the PaymentIntent.
Ver no GitHub
Este SKILL.md e muito grande, entao o SkillsMP mostra aqui apenas a primeira secao. Ver no GitHub