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.

معلومات المصدر

المستودع
stacksjs/wildloop
آخر نشاط في المصدر
١٤ أكتوبر ٢٠٢٦ في ١٤:١٨
لغة SKILL.md المكتشفة
الإنجليزية
النجوم
٢
التفرعات
٠

خيارات التثبيت

يُحدَّد Prompt الذي يراجع المصدر أولًا بشكل افتراضي. يمكنك التبديل إلى أمر مباشر أو تنزيل نسخة محلية.

مراجعة ملفات المصدر

اقرأ SKILL.md وأي ملفات مرافقة يعرضها SkillsMP قبل أن تقرر التثبيت.

عرض SKILL.md

SKILL.md
تعليمات المصدر · معاينة للقراءة فقط
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.
عرض على GitHub
ملف SKILL.md هذا كبير جدا، لذلك يعرض SkillsMP القسم الاول فقط هنا. عرض على GitHub