@stacksjs/payments 0.75.226 → 0.75.230

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.
@@ -13,3 +13,4 @@ export * from './setup-products';
13
13
  export * from './subscription';
14
14
  export * from './transaction';
15
15
  export * from './webhook';
16
+ export * from './webhook-events';
@@ -1 +1 @@
1
- export*from"./billing-enabled";export*from"./charge";export*from"./checkout";export*from"./customer";export*from"./intent";export*from"./invoice";export*from"./payment-method";export*from"./price";export*from"./portal";export*from"./product";export*from"./setup-products";export*from"./subscription";export*from"./transaction";export*from"./webhook";
1
+ export*from"./billing-enabled";export*from"./charge";export*from"./checkout";export*from"./customer";export*from"./intent";export*from"./invoice";export*from"./payment-method";export*from"./price";export*from"./portal";export*from"./product";export*from"./setup-products";export*from"./subscription";export*from"./transaction";export*from"./webhook";export*from"./webhook-events";
@@ -0,0 +1,24 @@
1
+ /**
2
+ * An in-process store with a time to live. What the database store falls back
3
+ * to before `payment_webhook_events` is migrated, and what a test can pass.
4
+ * It forgets on restart and is not shared between processes.
5
+ */
6
+ export declare function memoryWebhookEventStore(options?: { ttlSeconds?: number, maxEntries?: number, now?: () => number }): WebhookEventStore;
7
+ /**
8
+ * The default store: `payment_webhook_events`, falling back to memory (with a
9
+ * warning, once) when the table is not there. Any other database error throws,
10
+ * so `processWebhook` answers non-2xx and the provider retries later rather
11
+ * than an event being applied twice or dropped.
12
+ */
13
+ export declare function databaseWebhookEventStore(provider?: string): WebhookEventStore;
14
+ /** The store `handleWebhookEvent` uses when none is passed. */
15
+ export declare function webhookEventStore(): WebhookEventStore;
16
+ /** Replace the default store (a Redis-backed one, say), or pass null to restore the database store. */
17
+ export declare function setWebhookEventStore(store: WebhookEventStore | null): void;
18
+ /** The `provider` value the registry path writes; commerce writes the driver name (`stripe`). */
19
+ export declare const WEBHOOK_HANDLERS_PROVIDER: 'stripe:handlers';
20
+ /** Where handled event ids are remembered. */
21
+ export declare interface WebhookEventStore {
22
+ has: (eventId: string) => Promise<boolean>
23
+ add: (eventId: string) => Promise<void>
24
+ }
@@ -0,0 +1 @@
1
+ import{log}from"@stacksjs/logging";import{db,sqlDateTime}from"@stacksjs/database/runtime";export const WEBHOOK_HANDLERS_PROVIDER="stripe:handlers";const DEFAULT_TTL_SECONDS=345600;export function memoryWebhookEventStore(options={}){const ttl=(options.ttlSeconds??DEFAULT_TTL_SECONDS)*1000,maxEntries=options.maxEntries??1e4,now=options.now??Date.now,seen=new Map;return{async has(eventId){const expires=seen.get(eventId);if(expires===void 0)return!1;if(expires>now())return!0;seen.delete(eventId);return!1},async add(eventId){seen.delete(eventId);seen.set(eventId,now()+ttl);while(seen.size>maxEntries){const oldest=seen.keys().next().value;if(oldest===void 0)break;seen.delete(oldest)}}}}let warnedAboutMissingTable=!1;function warnOnceAboutMissingTable(){if(warnedAboutMissingTable)return;warnedAboutMissingTable=!0;log.warn("[payments/webhook] payment_webhook_events table missing - replayed webhook events are only deduplicated in this process. Run `buddy migrate` to remember them across restarts.")}function isMissingTableError(error){const e=error,message=e?.message??"";return e?.code==="42P01"||message.includes("no such table")||message.includes("doesn't exist")||/relation "[^"]*" does not exist/i.test(message)}export function databaseWebhookEventStore(provider=WEBHOOK_HANDLERS_PROVIDER){const fallback=memoryWebhookEventStore();return{async has(eventId){try{return!!await db.selectFrom("payment_webhook_events").where("provider","=",provider).where("event_id","=",eventId).select(["id"]).executeTakeFirst()||await fallback.has(eventId)}catch(error){if(!isMissingTableError(error))throw error;warnOnceAboutMissingTable();return await fallback.has(eventId)}},async add(eventId){await fallback.add(eventId);const now=sqlDateTime(new Date);try{await db.insertOrIgnore("payment_webhook_events",{provider,event_id:eventId,processed_at:now,created_at:now,updated_at:now})}catch(error){if(!isMissingTableError(error))throw error;warnOnceAboutMissingTable()}}}}let defaultStore=null;export function webhookEventStore(){defaultStore??=databaseWebhookEventStore();return defaultStore}export function setWebhookEventStore(store){defaultStore=store}
@@ -1,3 +1,4 @@
1
+ import type { WebhookEventStore } from './webhook-events';
1
2
  import type Stripe from 'stripe';
2
3
  /**
3
4
  * Register a webhook event handler
@@ -45,13 +46,20 @@ export declare function constructEvent(payload: string | Buffer, signature: stri
45
46
  */
46
47
  export declare function constructEventAsync(payload: string | Buffer, signature: string, secret: string, tolerance?: number): Promise<Stripe.Event>;
47
48
  /**
48
- * Handle an incoming webhook event
49
+ * Run the handlers registered for an event, once per event id.
50
+ *
51
+ * An event whose id already ran its handlers is skipped (`duplicate: true`):
52
+ * Stripe delivers at least once, and a retry must not grant, refund or email
53
+ * twice. The id is recorded only when every handler succeeded, so a handler
54
+ * that throws leaves the event to the provider's retry. A second delivery that
55
+ * arrives while the first is still running waits for it and answers the same.
56
+ * Events with no id (built by hand in a test) are never deduplicated.
49
57
  */
50
- export declare function handleWebhookEvent(event: Stripe.Event): Promise<{ handled: boolean, eventType: string, errors?: string[] }>;
58
+ export declare function handleWebhookEvent(event: Stripe.Event, options?: HandleWebhookEventOptions): Promise<HandleWebhookEventResult>;
51
59
  /**
52
60
  * Process a webhook request
53
61
  */
54
- export declare function processWebhook(payload: string | Buffer, signature: string, config: WebhookConfig): Promise<{ success: boolean, eventType?: string, error?: string }>;
62
+ export declare function processWebhook(payload: string | Buffer, signature: string, config: WebhookConfig): Promise<{ success: boolean, eventType?: string, error?: string, duplicate?: boolean }>;
55
63
  /**
56
64
  * Register payment intent handlers
57
65
  */
@@ -139,10 +147,20 @@ export declare const manageWebhook: {
139
147
  getCharge: typeof getCharge;
140
148
  getCustomer: typeof getCustomer
141
149
  };
142
- export declare interface WebhookConfig {
150
+ export declare interface WebhookConfig extends HandleWebhookEventOptions {
143
151
  secret: string
144
152
  tolerance?: number
145
153
  }
154
+ export declare interface HandleWebhookEventOptions {
155
+ dedupe?: boolean
156
+ store?: WebhookEventStore
157
+ }
158
+ export declare interface HandleWebhookEventResult {
159
+ handled: boolean
160
+ eventType: string
161
+ errors?: string[]
162
+ duplicate?: boolean
163
+ }
146
164
  export type WebhookEventType = | 'payment_intent.succeeded'
147
165
  | 'payment_intent.payment_failed'
148
166
  | 'payment_intent.created'
@@ -1 +1 @@
1
- import{stripe}from"../drivers/stripe";const handlers=new Map;export function onWebhookEvent(eventType,handler){const existing=handlers.get(eventType)||[];existing.push(handler);handlers.set(eventType,existing)}export function registerWebhookHandlers(handlerMap){for(const[eventType,handler]of Object.entries(handlerMap))onWebhookEvent(eventType,handler)}export async function constructEvent(payload,signature,secret,tolerance){if(tolerance!=null&&Number.isFinite(tolerance)&&tolerance>0)return await stripe.webhooks.constructEventAsync(payload,signature,secret,tolerance);return await stripe.webhooks.constructEventAsync(payload,signature,secret)}export async function constructEventAsync(payload,signature,secret,tolerance){return await constructEvent(payload,signature,secret,tolerance)}export async function handleWebhookEvent(event){const eventHandlers=handlers.get(event.type)||[];if(eventHandlers.length===0)return{handled:!1,eventType:event.type};const errors=[];for(const handler of eventHandlers)try{await handler(event)}catch(error){errors.push(error instanceof Error?error.message:String(error))}return{handled:!0,eventType:event.type,...errors.length>0?{errors}:{}}}export async function processWebhook(payload,signature,config){try{const event=await constructEventAsync(payload,signature,config.secret,config.tolerance),result=await handleWebhookEvent(event);if(result.errors&&result.errors.length>0)return{success:!1,eventType:result.eventType,error:result.errors.join("; ")};return{success:!0,eventType:result.eventType}}catch(error){return{success:!1,error:error instanceof Error?error.message:"Unknown error"}}}export function onPaymentIntent(handlers){if(handlers.succeeded)onWebhookEvent("payment_intent.succeeded",handlers.succeeded);if(handlers.failed)onWebhookEvent("payment_intent.payment_failed",handlers.failed);if(handlers.created)onWebhookEvent("payment_intent.created",handlers.created);if(handlers.canceled)onWebhookEvent("payment_intent.canceled",handlers.canceled)}export function onSubscription(handlers){if(handlers.created)onWebhookEvent("customer.subscription.created",handlers.created);if(handlers.updated)onWebhookEvent("customer.subscription.updated",handlers.updated);if(handlers.deleted)onWebhookEvent("customer.subscription.deleted",handlers.deleted);if(handlers.trialWillEnd)onWebhookEvent("customer.subscription.trial_will_end",handlers.trialWillEnd)}export function onInvoice(handlers){if(handlers.paid)onWebhookEvent("invoice.paid",handlers.paid);if(handlers.paymentFailed)onWebhookEvent("invoice.payment_failed",handlers.paymentFailed);if(handlers.created)onWebhookEvent("invoice.created",handlers.created);if(handlers.finalized)onWebhookEvent("invoice.finalized",handlers.finalized)}export function onCheckout(handlers){if(handlers.completed)onWebhookEvent("checkout.session.completed",handlers.completed);if(handlers.expired)onWebhookEvent("checkout.session.expired",handlers.expired)}export function onCharge(handlers){if(handlers.succeeded)onWebhookEvent("charge.succeeded",handlers.succeeded);if(handlers.failed)onWebhookEvent("charge.failed",handlers.failed);if(handlers.refunded)onWebhookEvent("charge.refunded",handlers.refunded);if(handlers.disputed)onWebhookEvent("charge.dispute.created",handlers.disputed)}export function getPaymentIntent(event){if(event.type.startsWith("payment_intent."))return event.data.object;return null}export function getSubscription(event){if(event.type.startsWith("customer.subscription."))return event.data.object;return null}export function getInvoice(event){if(event.type.startsWith("invoice."))return event.data.object;return null}export function getCheckoutSession(event){if(event.type.startsWith("checkout.session."))return event.data.object;return null}export function getCharge(event){if(event.type.startsWith("charge."))return event.data.object;return null}export function getCustomer(event){if(event.type.startsWith("customer.")&&!event.type.includes("subscription"))return event.data.object;return null}export const manageWebhook={onWebhookEvent,registerWebhookHandlers,constructEvent,constructEventAsync,handleWebhookEvent,processWebhook,onPaymentIntent,onSubscription,onInvoice,onCheckout,onCharge,getPaymentIntent,getSubscription,getInvoice,getCheckoutSession,getCharge,getCustomer};
1
+ import{log}from"@stacksjs/logging";import{stripe}from"../drivers/stripe";import{webhookEventStore}from"./webhook-events";const handlers=new Map;export function onWebhookEvent(eventType,handler){const existing=handlers.get(eventType)||[];existing.push(handler);handlers.set(eventType,existing)}export function registerWebhookHandlers(handlerMap){for(const[eventType,handler]of Object.entries(handlerMap))onWebhookEvent(eventType,handler)}export async function constructEvent(payload,signature,secret,tolerance){if(tolerance!=null&&Number.isFinite(tolerance)&&tolerance>0)return await stripe.webhooks.constructEventAsync(payload,signature,secret,tolerance);return await stripe.webhooks.constructEventAsync(payload,signature,secret)}export async function constructEventAsync(payload,signature,secret,tolerance){return await constructEvent(payload,signature,secret,tolerance)}const inFlight=new Map;export async function handleWebhookEvent(event,options={}){const eventHandlers=handlers.get(event.type)||[];if(eventHandlers.length===0)return{handled:!1,eventType:event.type};const eventId=typeof event.id==="string"?event.id:"";if(options.dedupe===!1||!eventId)return await runHandlers(event,eventHandlers);const running=inFlight.get(eventId);if(running)return await running;const store=options.store??webhookEventStore(),attempt=(async()=>{if(await store.has(eventId))return{handled:!0,eventType:event.type,duplicate:!0};const result=await runHandlers(event,eventHandlers);if(!result.errors)try{await store.add(eventId)}catch(error){log.warn(`[payments/webhook] ${event.type} ${eventId} was handled but could not be recorded, so a retry may run it again: ${error instanceof Error?error.message:String(error)}`)}return result})();inFlight.set(eventId,attempt);try{return await attempt}finally{inFlight.delete(eventId)}}async function runHandlers(event,eventHandlers){const errors=[];for(const handler of eventHandlers)try{await handler(event)}catch(error){errors.push(error instanceof Error?error.message:String(error))}return{handled:!0,eventType:event.type,...errors.length>0?{errors}:{}}}export async function processWebhook(payload,signature,config){try{const event=await constructEvent(payload,signature,config.secret,config.tolerance),result=await handleWebhookEvent(event,{dedupe:config.dedupe,store:config.store});if(result.errors&&result.errors.length>0)return{success:!1,eventType:result.eventType,error:result.errors.join("; ")};return{success:!0,eventType:result.eventType,...result.duplicate?{duplicate:!0}:{}}}catch(error){return{success:!1,error:error instanceof Error?error.message:"Unknown error"}}}export function onPaymentIntent(handlers){if(handlers.succeeded)onWebhookEvent("payment_intent.succeeded",handlers.succeeded);if(handlers.failed)onWebhookEvent("payment_intent.payment_failed",handlers.failed);if(handlers.created)onWebhookEvent("payment_intent.created",handlers.created);if(handlers.canceled)onWebhookEvent("payment_intent.canceled",handlers.canceled)}export function onSubscription(handlers){if(handlers.created)onWebhookEvent("customer.subscription.created",handlers.created);if(handlers.updated)onWebhookEvent("customer.subscription.updated",handlers.updated);if(handlers.deleted)onWebhookEvent("customer.subscription.deleted",handlers.deleted);if(handlers.trialWillEnd)onWebhookEvent("customer.subscription.trial_will_end",handlers.trialWillEnd)}export function onInvoice(handlers){if(handlers.paid)onWebhookEvent("invoice.paid",handlers.paid);if(handlers.paymentFailed)onWebhookEvent("invoice.payment_failed",handlers.paymentFailed);if(handlers.created)onWebhookEvent("invoice.created",handlers.created);if(handlers.finalized)onWebhookEvent("invoice.finalized",handlers.finalized)}export function onCheckout(handlers){if(handlers.completed)onWebhookEvent("checkout.session.completed",handlers.completed);if(handlers.expired)onWebhookEvent("checkout.session.expired",handlers.expired)}export function onCharge(handlers){if(handlers.succeeded)onWebhookEvent("charge.succeeded",handlers.succeeded);if(handlers.failed)onWebhookEvent("charge.failed",handlers.failed);if(handlers.refunded)onWebhookEvent("charge.refunded",handlers.refunded);if(handlers.disputed)onWebhookEvent("charge.dispute.created",handlers.disputed)}export function getPaymentIntent(event){if(event.type.startsWith("payment_intent."))return event.data.object;return null}export function getSubscription(event){if(event.type.startsWith("customer.subscription."))return event.data.object;return null}export function getInvoice(event){if(event.type.startsWith("invoice."))return event.data.object;return null}export function getCheckoutSession(event){if(event.type.startsWith("checkout.session."))return event.data.object;return null}export function getCharge(event){if(event.type.startsWith("charge."))return event.data.object;return null}export function getCustomer(event){if(event.type.startsWith("customer.")&&!event.type.includes("subscription"))return event.data.object;return null}export const manageWebhook={onWebhookEvent,registerWebhookHandlers,constructEvent,constructEventAsync,handleWebhookEvent,processWebhook,onPaymentIntent,onSubscription,onInvoice,onCheckout,onCharge,getPaymentIntent,getSubscription,getInvoice,getCheckoutSession,getCharge,getCustomer};
@@ -0,0 +1,49 @@
1
+ import type Stripe from 'stripe';
2
+ /**
3
+ * Read a `checkout.session.*` event into the outcome it stands for, or null
4
+ * for any other event (and for `completed` with a status Stripe may add later).
5
+ *
6
+ * @example
7
+ * const outcome = checkoutOutcome(event)
8
+ * if (!outcome || outcome.metadata.order_id == null) return
9
+ * if (outcome.status === 'paid') await fulfil(outcome.reference, outcome.paymentIntentId)
10
+ * else if (outcome.status === 'failed' || outcome.status === 'expired') await release(outcome.reference)
11
+ */
12
+ export declare function checkoutOutcome(event: { type: string, data?: { object?: unknown } } | null | undefined): CheckoutOutcome | null;
13
+ export declare interface CheckoutOutcome {
14
+ status: CheckoutOutcomeStatus
15
+ session: Stripe.Checkout.Session
16
+ sessionId: string
17
+ reference: string | null
18
+ paymentIntentId: string | null
19
+ subscriptionId: string | null
20
+ customerId: string | null
21
+ mode: Stripe.Checkout.Session.Mode | null
22
+ amountTotal: number | null
23
+ currency: string | null
24
+ metadata: Record<string, string>
25
+ }
26
+ /**
27
+ * What a Checkout Session webhook means for the thing it was paying for.
28
+ *
29
+ * Four event types and a `payment_status` combine into the four states a
30
+ * handler acts on, and getting the combination wrong is how an order ships
31
+ * unpaid or a member waits forever:
32
+ *
33
+ * | Event | `payment_status` | Outcome |
34
+ * |--------------------------------------------|---------------------------------|--------------|
35
+ * | `checkout.session.completed` | `paid` / `no_payment_required` | `paid` |
36
+ * | `checkout.session.completed` | `unpaid` | `processing` |
37
+ * | `checkout.session.async_payment_succeeded` | `paid` | `paid` |
38
+ * | `checkout.session.async_payment_failed` | any | `failed` |
39
+ * | `checkout.session.expired` | any | `expired` |
40
+ *
41
+ * `processing` is a delayed method (SEPA Direct Debit, Bacs, ACH, a bank
42
+ * redirect): the buyer finished, the money has not arrived, and one of the
43
+ * async events follows in a few days. Fulfil on `paid` only.
44
+ *
45
+ * Every other event type is `null`, so a handler can call this first and
46
+ * return early. It reads only the event; verify the signature and match the
47
+ * session to your own record (its id or `client_reference_id`) before acting.
48
+ */
49
+ export type CheckoutOutcomeStatus = 'paid' | 'processing' | 'failed' | 'expired';
@@ -0,0 +1 @@
1
+ const PAID=["paid","no_payment_required"];function idOf(value){if(typeof value==="string")return value||null;if(value&&typeof value==="object"&&typeof value.id==="string")return value.id||null;return null}export function checkoutOutcome(event){if(!event||typeof event.type!=="string"||!event.type.startsWith("checkout.session."))return null;const session=event.data?.object;if(!session||typeof session!=="object"||typeof session.id!=="string")return null;const paymentStatus=String(session.payment_status??"");let status=null;switch(event.type){case"checkout.session.completed":status=PAID.includes(paymentStatus)?"paid":paymentStatus==="unpaid"?"processing":null;break;case"checkout.session.async_payment_succeeded":status=PAID.includes(paymentStatus)?"paid":"processing";break;case"checkout.session.async_payment_failed":status="failed";break;case"checkout.session.expired":status="expired";break}if(!status)return null;return{status,session,sessionId:session.id,reference:session.client_reference_id??null,paymentIntentId:idOf(session.payment_intent),subscriptionId:idOf(session.subscription),customerId:idOf(session.customer),mode:session.mode??null,amountTotal:Number.isFinite(session.amount_total)?Number(session.amount_total):null,currency:session.currency?String(session.currency).toLowerCase():null,metadata:session.metadata??{}}}
@@ -1,5 +1,12 @@
1
1
  import type { UserModel } from '@stacksjs/orm';
2
2
  import type Stripe from 'stripe';
3
+ /**
4
+ * The idempotency key for creating a connected account: the user, the
5
+ * caller's reference and a fingerprint of the creation parameters. The same
6
+ * call retried gives the same key; a different reference or different
7
+ * parameters give another.
8
+ */
9
+ export declare function connectedAccountIdempotencyKey(scope: 'connect.account.create' | 'connect.account.create_test', userId: number | string, params: Stripe.AccountCreateParams, reference?: string): string;
3
10
  /**
4
11
  * Connected-account ids are `acct_...`. Checking the shape here turns a
5
12
  * confusing Stripe 404 (which reads as "no such account" even when the caller
@@ -27,9 +34,17 @@ export declare interface ConnectAccountStatus {
27
34
  disabledReason: string | null
28
35
  raw: Stripe.Account
29
36
  }
37
+ /**
38
+ * Which of a user's connected accounts this is. One user can own several (a
39
+ * gym and a coach storefront), and each needs its own idempotency key or the
40
+ * second creation inside Stripe's 24-hour window collides with the first.
41
+ */
42
+ export declare interface CreateConnectedAccountOptions {
43
+ reference?: string
44
+ }
30
45
  export declare interface ManageConnectedAccount {
31
- createConnectedAccount: (user: UserModel, params?: Partial<Stripe.AccountCreateParams>) => Promise<Stripe.Response<Stripe.Account>>
32
- createTestConnectedAccount: (user: UserModel, params?: Partial<Stripe.AccountCreateParams>) => Promise<Stripe.Response<Stripe.Account>>
46
+ createConnectedAccount: (user: UserModel, params?: Partial<Stripe.AccountCreateParams>, options?: CreateConnectedAccountOptions) => Promise<Stripe.Response<Stripe.Account>>
47
+ createTestConnectedAccount: (user: UserModel, params?: Partial<Stripe.AccountCreateParams>, options?: CreateConnectedAccountOptions) => Promise<Stripe.Response<Stripe.Account>>
33
48
  accountOnboardingLink: (accountId: string, options: OnboardingLinkOptions) => Promise<Stripe.Response<Stripe.AccountLink>>
34
49
  accountStatus: (accountId: string) => Promise<ConnectAccountStatus>
35
50
  retrieveConnectedAccount: (accountId: string) => Promise<Stripe.Response<Stripe.Account>>
@@ -1 +1 @@
1
- import{stripe}from"../drivers/stripe";import{stacksIdempotencyKey}from"../idempotency";export const manageConnectedAccount=(()=>{async function createConnectedAccount(user,params={}){const merged={...{type:"express",email:user.email??void 0,metadata:{stacks_user_id:String(user.id)}},...params};return await stripe.accounts.create(merged,{idempotencyKey:stacksIdempotencyKey("connect.account.create",user.id)})}async function createTestConnectedAccount(user,params={}){const merged={...{type:"express",email:user.email??void 0,business_type:"individual",capabilities:{card_payments:{requested:!0},transfers:{requested:!0}},business_profile:{url:"https://example.com",mcc:"5812"},metadata:{stacks_user_id:String(user.id),stacks_test_account:"true"}},...params};return await stripe.accounts.create(merged,{idempotencyKey:stacksIdempotencyKey("connect.account.create_test",user.id)})}async function accountOnboardingLink(accountId,options){requireAccountId(accountId,"accountOnboardingLink");if(!options?.refreshUrl||!options?.returnUrl)throw Error("[payments/connect] accountOnboardingLink requires refreshUrl and returnUrl");return await stripe.accountLinks.create({account:accountId,refresh_url:options.refreshUrl,return_url:options.returnUrl,type:options.type??"account_onboarding",...options.collect?{collection_options:{fields:options.collect}}:{}})}async function retrieveConnectedAccount(accountId){requireAccountId(accountId,"retrieveConnectedAccount");return await stripe.accounts.retrieve(accountId)}async function accountStatus(accountId){const account=await retrieveConnectedAccount(accountId),requirements=account.requirements;return{chargesEnabled:account.charges_enabled===!0,payoutsEnabled:account.payouts_enabled===!0,detailsSubmitted:account.details_submitted===!0,currentlyDue:requirements?.currently_due??[],pastDue:requirements?.past_due??[],disabledReason:requirements?.disabled_reason??null,raw:account}}return{createConnectedAccount,createTestConnectedAccount,accountOnboardingLink,accountStatus,retrieveConnectedAccount}})();export function requireAccountId(accountId,caller){if(!accountId||typeof accountId!=="string")throw Error(`[payments/connect] ${caller} requires a connected account id`);if(!accountId.startsWith("acct_"))throw Error(`[payments/connect] ${caller} expects a connected account id (acct_...), got "${accountId}"`)}
1
+ import{stripe}from"../drivers/stripe";import{idempotencyFingerprint,stacksIdempotencyKey}from"../idempotency";export function connectedAccountIdempotencyKey(scope,userId,params,reference){return stacksIdempotencyKey(scope,userId,reference,idempotencyFingerprint(params))}export const manageConnectedAccount=(()=>{async function createConnectedAccount(user,params={},options={}){const merged={...{type:"express",email:user.email??void 0,metadata:{stacks_user_id:String(user.id)}},...params};return await stripe.accounts.create(merged,{idempotencyKey:connectedAccountIdempotencyKey("connect.account.create",user.id,merged,options.reference)})}async function createTestConnectedAccount(user,params={},options={}){const merged={...{type:"express",email:user.email??void 0,business_type:"individual",capabilities:{card_payments:{requested:!0},transfers:{requested:!0}},business_profile:{url:"https://example.com",mcc:"5812"},metadata:{stacks_user_id:String(user.id),stacks_test_account:"true"}},...params};return await stripe.accounts.create(merged,{idempotencyKey:connectedAccountIdempotencyKey("connect.account.create_test",user.id,merged,options.reference)})}async function accountOnboardingLink(accountId,options){requireAccountId(accountId,"accountOnboardingLink");if(!options?.refreshUrl||!options?.returnUrl)throw Error("[payments/connect] accountOnboardingLink requires refreshUrl and returnUrl");return await stripe.accountLinks.create({account:accountId,refresh_url:options.refreshUrl,return_url:options.returnUrl,type:options.type??"account_onboarding",...options.collect?{collection_options:{fields:options.collect}}:{}})}async function retrieveConnectedAccount(accountId){requireAccountId(accountId,"retrieveConnectedAccount");return await stripe.accounts.retrieve(accountId)}async function accountStatus(accountId){const account=await retrieveConnectedAccount(accountId),requirements=account.requirements;return{chargesEnabled:account.charges_enabled===!0,payoutsEnabled:account.payouts_enabled===!0,detailsSubmitted:account.details_submitted===!0,currentlyDue:requirements?.currently_due??[],pastDue:requirements?.past_due??[],disabledReason:requirements?.disabled_reason??null,raw:account}}return{createConnectedAccount,createTestConnectedAccount,accountOnboardingLink,accountStatus,retrieveConnectedAccount}})();export function requireAccountId(accountId,caller){if(!accountId||typeof accountId!=="string")throw Error(`[payments/connect] ${caller} requires a connected account id`);if(!accountId.startsWith("acct_"))throw Error(`[payments/connect] ${caller} expects a connected account id (acct_...), got "${accountId}"`)}
@@ -0,0 +1,117 @@
1
+ import type Stripe from 'stripe';
2
+ /**
3
+ * The platform's fee on a one-off charge of `amount` minor units: `feeCents`
4
+ * when given, else `feePercent` of the amount, rounded as asked. 0 means take
5
+ * nothing. Throws for a negative, fractional or larger-than-the-charge fee.
6
+ *
7
+ * @example
8
+ * destinationFeeCents(1999, { feePercent: 7.5 }) // 149
9
+ * destinationFeeCents(1999, { feePercent: 7.5, rounding: 'round' }) // 150
10
+ */
11
+ export declare function destinationFeeCents(amount: number, options?: Pick<DestinationOptions, 'feePercent' | 'feeCents' | 'rounding'>): number;
12
+ /**
13
+ * A subscription's fee percent as Stripe takes it (at most two decimals), or
14
+ * undefined to take nothing. Float noise from adding two percents
15
+ * (`0.1 + 0.2`) is snapped; a genuinely finer value is refused rather than
16
+ * rounded into a fee nobody configured.
17
+ */
18
+ export declare function destinationFeePercent(options?: Pick<DestinationOptions, 'feePercent' | 'feeCents'>): number | undefined;
19
+ /**
20
+ * The Connect fields of a PaymentIntent for `amount` minor units, to spread
21
+ * into `stripe.paymentIntents.create(...)`.
22
+ *
23
+ * @example
24
+ * await stripe.paymentIntents.create({ amount: 4900, currency: 'eur', customer, ...destinationPaymentIntentParams(4900, { destination, feePercent: 10 }) })
25
+ */
26
+ export declare function destinationPaymentIntentParams(amount: number, options: DestinationOptions): DestinationPaymentFields;
27
+ /**
28
+ * `payment_intent_data` for a Checkout Session in `mode: 'payment'` whose
29
+ * line items total `amount` minor units (see `checkoutLineItemsTotal`).
30
+ * Pass `extra` for the session's other PaymentIntent fields (metadata, a
31
+ * description); the Connect fields win over it.
32
+ *
33
+ * @example
34
+ * await stripe.checkout.sessions.create({
35
+ * mode: 'payment',
36
+ * line_items,
37
+ * payment_intent_data: destinationCheckoutPaymentData(checkoutLineItemsTotal(line_items), { destination, feePercent: 5, rounding: 'round' }, { metadata }),
38
+ * success_url, cancel_url,
39
+ * })
40
+ */
41
+ export declare function destinationCheckoutPaymentData(amount: number, options: DestinationOptions, extra?: Stripe.Checkout.SessionCreateParams.PaymentIntentData): Stripe.Checkout.SessionCreateParams.PaymentIntentData;
42
+ /**
43
+ * `subscription_data` for a Checkout Session in `mode: 'subscription'`: each
44
+ * paid invoice settles to the merchant less `feePercent`. Pass `extra` for
45
+ * the subscription's other fields (metadata, `trial_period_days`).
46
+ */
47
+ export declare function destinationCheckoutSubscriptionData(options: DestinationOptions, extra?: Stripe.Checkout.SessionCreateParams.SubscriptionData): Stripe.Checkout.SessionCreateParams.SubscriptionData;
48
+ /**
49
+ * The Connect fields of a subscription created with
50
+ * `stripe.subscriptions.create(...)`, for a member who already has a payment
51
+ * method on file.
52
+ *
53
+ * @example
54
+ * await stripe.subscriptions.create({ customer, items: [{ price }], ...destinationSubscriptionParams({ destination, feePercent: 2.5 }) })
55
+ */
56
+ export declare function destinationSubscriptionParams(options: DestinationOptions): DestinationSubscriptionFields;
57
+ /**
58
+ * The total of Checkout line items defined with `price_data`, in minor
59
+ * units: the amount a `mode: 'payment'` session will charge, before tax and
60
+ * discounts. A line that names a catalog `price` has no amount here, so it is
61
+ * refused rather than counted as zero; pass the amount yourself for those.
62
+ */
63
+ export declare function checkoutLineItemsTotal(lineItems: readonly Stripe.Checkout.SessionCreateParams.LineItem[]): number;
64
+ /**
65
+ * Split a fee (or any amount of minor units) into named shares by weight, so
66
+ * the shares always add up to the amount exactly.
67
+ *
68
+ * Each share gets its whole part, and the cents left over go one each to the
69
+ * shares with the largest fractions (ties to the earlier key). A negative
70
+ * amount, a refunded fee, splits the same way with every share negated. When
71
+ * every weight is 0 the whole amount goes to `fallback` (the first key unless
72
+ * named): a fee taken under weights since set to nothing still belongs
73
+ * somewhere.
74
+ *
75
+ * @example
76
+ * splitFee(101, { tip: 2, planet: 1 }) // { tip: 67, planet: 34 }
77
+ * splitFee(-101, { tip: 2, planet: 1 }) // { tip: -67, planet: -34 }
78
+ * splitFee(50, { tip: 0, planet: 0 }) // { tip: 50, planet: 0 }
79
+ */
80
+ export declare function splitFee<K extends string>(amount: number, weights: Readonly<Record<K, number>>, fallback?: K): Record<K, number>;
81
+ export declare interface DestinationOptions {
82
+ destination: string
83
+ feePercent?: number
84
+ feeCents?: number
85
+ onBehalfOf?: boolean
86
+ rounding?: FeeRounding
87
+ transferGroup?: string
88
+ }
89
+ /**
90
+ * Destination-charge parameters, built pure.
91
+ *
92
+ * A marketplace sale is a charge on the platform that Stripe settles to a
93
+ * merchant's connected account, less the platform's application fee. The
94
+ * same three or four fields say so on every Stripe object that can charge,
95
+ * but each spells them differently:
96
+ *
97
+ * | Where the money is taken | Fields |
98
+ * |------------------------------------------|----------------------------------------------------------|
99
+ * | Checkout, `mode: 'payment'` | `payment_intent_data.{transfer_data, application_fee_amount, on_behalf_of}` |
100
+ * | Checkout, `mode: 'subscription'` | `subscription_data.{transfer_data, application_fee_percent, on_behalf_of}` |
101
+ * | `stripe.subscriptions.create` | `transfer_data`, `application_fee_percent`, `on_behalf_of` |
102
+ * | `stripe.paymentIntents.create` | `transfer_data`, `application_fee_amount`, `on_behalf_of` |
103
+ *
104
+ * A one-off charge carries its fee as an AMOUNT, a subscription as a PERCENT
105
+ * (Stripe applies it to every invoice, whatever prorations, coupons and
106
+ * trials make of the total). These builders take one options shape for all
107
+ * four, validate it, and return only the Connect fields, typed with Stripe's
108
+ * own parameter types, to spread into the params you are already building.
109
+ * They read no config and call no API, so they are safe in a unit test and
110
+ * in a request handler alike.
111
+ */
112
+ /** How a percentage fee becomes whole minor units. */
113
+ export type FeeRounding = 'round' | 'floor';
114
+ /** The Connect fields of a one-off charge. */
115
+ export type DestinationPaymentFields = Pick<Stripe.PaymentIntentCreateParams, 'transfer_data' | 'application_fee_amount' | 'on_behalf_of' | 'transfer_group'>;
116
+ /** The Connect fields of a subscription created directly. */
117
+ export type DestinationSubscriptionFields = Pick<Stripe.SubscriptionCreateParams, 'transfer_data' | 'application_fee_percent' | 'on_behalf_of'>;
@@ -0,0 +1 @@
1
+ import{requireAccountId}from"./account";function fail(message){throw Error(`[payments/connect] ${message}`)}function checkPercent(percent){if(!Number.isFinite(percent)||percent<0||percent>100)fail(`feePercent must be between 0 and 100, got ${percent}`)}export function destinationFeeCents(amount,options={}){if(!Number.isSafeInteger(amount)||amount<0)fail(`a charge amount is a non-negative whole number of minor units, got ${amount}`);if(options.feeCents!=null){if(!Number.isSafeInteger(options.feeCents)||options.feeCents<0)fail(`feeCents must be a non-negative whole number of minor units, got ${options.feeCents}`);if(options.feeCents>amount)fail(`feeCents ${options.feeCents} exceeds the charge amount ${amount}`);return options.feeCents}if(options.feePercent==null)return 0;checkPercent(options.feePercent);const exact=amount*options.feePercent/100,snapped=Math.abs(exact-Math.round(exact))<0.000000001?Math.round(exact):exact,rounding=options.rounding??"floor";if(rounding!=="floor"&&rounding!=="round")fail(`rounding must be 'round' or 'floor', got ${String(rounding)}`);return Math.min(amount,rounding==="round"?Math.round(snapped):Math.floor(snapped))}export function destinationFeePercent(options={}){if(options.feeCents!=null)fail("a subscription takes its fee as feePercent (Stripe applies it to every invoice), not feeCents");if(options.feePercent==null)return;checkPercent(options.feePercent);const hundredths=options.feePercent*100;if(Math.abs(hundredths-Math.round(hundredths))>0.000001)fail(`feePercent takes at most two decimal places on a subscription, got ${options.feePercent}`);const percent=Math.round(hundredths)/100;return percent>0?percent:void 0}function common(options,caller){if(!options||typeof options!=="object")fail(`${caller} requires options with a destination`);requireAccountId(options.destination,caller);return{transfer_data:{destination:options.destination},...options.onBehalfOf?{on_behalf_of:options.destination}:{}}}export function destinationPaymentIntentParams(amount,options){const base=common(options,"destinationPaymentIntentParams"),fee=destinationFeeCents(amount,options);return{...base,...fee>0?{application_fee_amount:fee}:{},...options.transferGroup?{transfer_group:options.transferGroup}:{}}}export function destinationCheckoutPaymentData(amount,options,extra={}){const base=common(options,"destinationCheckoutPaymentData"),fee=destinationFeeCents(amount,options),rest={...extra};delete rest.application_fee_amount;return{...rest,...base,...fee>0?{application_fee_amount:fee}:{},...options.transferGroup?{transfer_group:options.transferGroup}:{}}}export function destinationCheckoutSubscriptionData(options,extra={}){const base=common(options,"destinationCheckoutSubscriptionData"),percent=destinationFeePercent(options),rest={...extra};delete rest.application_fee_percent;return{...rest,...base,...percent!=null?{application_fee_percent:percent}:{}}}export function destinationSubscriptionParams(options){const base=common(options,"destinationSubscriptionParams"),percent=destinationFeePercent(options);return{...base,...percent!=null?{application_fee_percent:percent}:{}}}export function checkoutLineItemsTotal(lineItems){let total=0;for(const item of lineItems){const unit=item.price_data?.unit_amount;if(unit==null)fail("checkoutLineItemsTotal counts price_data lines only; a catalog price has no amount here");const quantity=item.quantity??1;if(!Number.isSafeInteger(unit)||unit<0||!Number.isSafeInteger(quantity)||quantity<0)fail("a line item needs a whole unit_amount and quantity");total+=unit*quantity}if(!Number.isSafeInteger(total))fail("line items total more than a safe integer");return total}export function splitFee(amount,weights,fallback){if(!Number.isSafeInteger(amount))fail(`splitFee expects a whole number of minor units, got ${amount}`);const keys=Object.keys(weights);if(!keys.length)fail("splitFee needs at least one share");for(const key of keys){const weight=weights[key];if(!Number.isFinite(weight)||weight<0)fail(`share "${key}" needs a non-negative weight, got ${weight}`)}if(fallback!=null&&!keys.includes(fallback))fail(`fallback "${fallback}" is not one of the shares`);const result=Object.fromEntries(keys.map((key)=>[key,0])),sign=amount<0?-1:1,whole=Math.abs(amount),totalWeight=keys.reduce((sum,key)=>sum+weights[key],0);if(totalWeight===0){result[fallback??keys[0]]=amount;return result}const exact=keys.map((key)=>whole*weights[key]/totalWeight),floors=exact.map((value)=>Math.floor(value+0.000000001));let left=whole-floors.reduce((sum,value)=>sum+value,0);const order=keys.map((_,index)=>index).sort((a,b)=>exact[b]-floors[b]-(exact[a]-floors[a])||a-b);for(const index of order){if(left<=0)break;floors[index]+=1;left--}keys.forEach((key,index)=>{result[key]=sign*floors[index]||0});return result}
@@ -1,3 +1,5 @@
1
+ import { checkoutLineItemsTotal, destinationCheckoutPaymentData, destinationCheckoutSubscriptionData, destinationFeeCents, destinationFeePercent, destinationPaymentIntentParams, destinationSubscriptionParams, splitFee } from './destination';
2
+ import { connectRefund, connectRefundParams, isDestinationCharge } from './refund';
1
3
  import { manageConnectedAccount } from './account';
2
4
  import { manageConnectWebhook } from './webhook';
3
5
  import { manageDestinationCharge } from './charge';
@@ -15,6 +17,17 @@ export declare const connect: {
15
17
  retrieveAccount: typeof manageConnectedAccount.retrieveConnectedAccount;
16
18
  destinationCharge: typeof manageDestinationCharge.destinationCharge;
17
19
  applicationFeeFor: typeof manageDestinationCharge.applicationFeeFor;
20
+ feeCents: typeof destinationFeeCents;
21
+ feePercent: typeof destinationFeePercent;
22
+ paymentIntentParams: typeof destinationPaymentIntentParams;
23
+ checkoutPaymentData: typeof destinationCheckoutPaymentData;
24
+ checkoutSubscriptionData: typeof destinationCheckoutSubscriptionData;
25
+ subscriptionParams: typeof destinationSubscriptionParams;
26
+ lineItemsTotal: typeof checkoutLineItemsTotal;
27
+ splitFee: typeof splitFee;
28
+ refund: typeof connectRefund;
29
+ refundParams: typeof connectRefundParams;
30
+ isDestinationCharge: typeof isDestinationCharge;
18
31
  subscriptionCheckout: typeof manageDestinationSubscription.checkout;
19
32
  subscriptionFeePercent: typeof manageDestinationSubscription.feePercentFor;
20
33
  subscriptionDestination: typeof manageDestinationSubscription.destinationOf;
@@ -49,7 +62,9 @@ export declare const connect: {
49
62
  */
50
63
  export * from './account';
51
64
  export * from './charge';
65
+ export * from './destination';
52
66
  export * from './payout';
67
+ export * from './refund';
53
68
  export * from './subscription';
54
69
  export * from './transfer';
55
70
  export * from './webhook';
@@ -1 +1 @@
1
- export*from"./account";export*from"./charge";export*from"./payout";export*from"./subscription";export*from"./transfer";export*from"./webhook";import{manageConnectedAccount}from"./account";import{manageDestinationCharge}from"./charge";import{managePayout}from"./payout";import{manageDestinationSubscription}from"./subscription";import{manageTransfer}from"./transfer";import{manageConnectWebhook}from"./webhook";export const connect={createAccount:manageConnectedAccount.createConnectedAccount,createTestAccount:manageConnectedAccount.createTestConnectedAccount,onboardingLink:manageConnectedAccount.accountOnboardingLink,accountStatus:manageConnectedAccount.accountStatus,retrieveAccount:manageConnectedAccount.retrieveConnectedAccount,destinationCharge:manageDestinationCharge.destinationCharge,applicationFeeFor:manageDestinationCharge.applicationFeeFor,subscriptionCheckout:manageDestinationSubscription.checkout,subscriptionFeePercent:manageDestinationSubscription.feePercentFor,subscriptionDestination:manageDestinationSubscription.destinationOf,createTransfer:manageTransfer.createTransfer,reverseTransfer:manageTransfer.reverseTransfer,listTransfers:manageTransfer.listTransfers,retrieveTransfer:manageTransfer.retrieveTransfer,listPayouts:managePayout.listPayouts,retrievePayout:managePayout.retrievePayout,accountBalance:managePayout.accountBalance,onAccount:manageConnectWebhook.onAccount,onPayout:manageConnectWebhook.onPayout,getAccount:manageConnectWebhook.getAccount,getPayout:manageConnectWebhook.getPayout,getConnectedAccountId:manageConnectWebhook.getConnectedAccountId,account:manageConnectedAccount,charge:manageDestinationCharge,subscription:manageDestinationSubscription,transfer:manageTransfer,payout:managePayout};
1
+ export*from"./account";export*from"./charge";export*from"./destination";export*from"./payout";export*from"./refund";export*from"./subscription";export*from"./transfer";export*from"./webhook";import{manageConnectedAccount}from"./account";import{manageDestinationCharge}from"./charge";import{checkoutLineItemsTotal,destinationCheckoutPaymentData,destinationCheckoutSubscriptionData,destinationFeeCents,destinationFeePercent,destinationPaymentIntentParams,destinationSubscriptionParams,splitFee}from"./destination";import{managePayout}from"./payout";import{connectRefund,connectRefundParams,isDestinationCharge}from"./refund";import{manageDestinationSubscription}from"./subscription";import{manageTransfer}from"./transfer";import{manageConnectWebhook}from"./webhook";export const connect={createAccount:manageConnectedAccount.createConnectedAccount,createTestAccount:manageConnectedAccount.createTestConnectedAccount,onboardingLink:manageConnectedAccount.accountOnboardingLink,accountStatus:manageConnectedAccount.accountStatus,retrieveAccount:manageConnectedAccount.retrieveConnectedAccount,destinationCharge:manageDestinationCharge.destinationCharge,applicationFeeFor:manageDestinationCharge.applicationFeeFor,feeCents:destinationFeeCents,feePercent:destinationFeePercent,paymentIntentParams:destinationPaymentIntentParams,checkoutPaymentData:destinationCheckoutPaymentData,checkoutSubscriptionData:destinationCheckoutSubscriptionData,subscriptionParams:destinationSubscriptionParams,lineItemsTotal:checkoutLineItemsTotal,splitFee,refund:connectRefund,refundParams:connectRefundParams,isDestinationCharge,subscriptionCheckout:manageDestinationSubscription.checkout,subscriptionFeePercent:manageDestinationSubscription.feePercentFor,subscriptionDestination:manageDestinationSubscription.destinationOf,createTransfer:manageTransfer.createTransfer,reverseTransfer:manageTransfer.reverseTransfer,listTransfers:manageTransfer.listTransfers,retrieveTransfer:manageTransfer.retrieveTransfer,listPayouts:managePayout.listPayouts,retrievePayout:managePayout.retrievePayout,accountBalance:managePayout.accountBalance,onAccount:manageConnectWebhook.onAccount,onPayout:manageConnectWebhook.onPayout,getAccount:manageConnectWebhook.getAccount,getPayout:manageConnectWebhook.getPayout,getConnectedAccountId:manageConnectWebhook.getConnectedAccountId,account:manageConnectedAccount,charge:manageDestinationCharge,subscription:manageDestinationSubscription,transfer:manageTransfer,payout:managePayout};
@@ -0,0 +1,62 @@
1
+ import type Stripe from 'stripe';
2
+ /**
3
+ * True when the money of this PaymentIntent or Charge settled to a connected
4
+ * account: `transfer_data.destination` on either, or a Charge's `transfer`.
5
+ * A PaymentIntent read without `latest_charge` expanded is judged on its own
6
+ * `transfer_data`, which every destination charge carries.
7
+ */
8
+ export declare function isDestinationCharge(source: RefundSource | null | undefined): boolean;
9
+ /** True when the charge carried a platform fee (so there is a fee to give back). */
10
+ export declare function tookApplicationFee(source: RefundSource | null | undefined): boolean;
11
+ /**
12
+ * `stripe.refunds.create` params for refunding `source`, with
13
+ * `reverse_transfer` and `refund_application_fee` set when it was a
14
+ * destination charge (and took a fee, for the second). Refunds by
15
+ * `payment_intent` for a PaymentIntent (`pi_...`) and by `charge` otherwise.
16
+ *
17
+ * @example
18
+ * const intent = await stripe.paymentIntents.retrieve(id, { expand: ['latest_charge'] })
19
+ * await stripe.refunds.create(connectRefundParams(intent, { amount: 500 }), { idempotencyKey })
20
+ */
21
+ export declare function connectRefundParams(source: RefundSource, options?: ConnectRefundOptions): Stripe.RefundCreateParams;
22
+ /**
23
+ * Refund a PaymentIntent (`pi_...`) or Charge (`ch_...`, `py_...`), reversing
24
+ * the merchant's transfer and the platform's fee when it was a destination
25
+ * charge. Retrieves the object first (one extra read) to find out.
26
+ *
27
+ * Pass a deterministic `idempotencyKey` (your refund row's id, say) so a
28
+ * retried request cannot refund twice; without one each call is a new refund.
29
+ */
30
+ export declare function connectRefund(paymentId: string, options?: ConnectRefundOptions & { idempotencyKey?: string }): Promise<Stripe.Response<Stripe.Refund>>;
31
+ /**
32
+ * Refunds that know about Connect.
33
+ *
34
+ * A destination charge gave the money to a merchant. Refunded like any other
35
+ * charge, the buyer is paid back out of the PLATFORM's balance and the
36
+ * merchant keeps the sale; the platform's fee stays taken too. Stripe undoes
37
+ * both only when the refund asks: `reverse_transfer` pulls the refunded share
38
+ * back from the connected account, `refund_application_fee` gives back the
39
+ * platform's proportional cut. Forgetting them is silent, and it is the
40
+ * platform that pays.
41
+ *
42
+ * `isDestinationCharge` and `connectRefundParams` are pure: they read a
43
+ * PaymentIntent (ideally with `latest_charge` expanded) or a Charge you
44
+ * already hold. `connectRefund` retrieves it and creates the refund.
45
+ */
46
+ /** A PaymentIntent or Charge, or the parts of one that say where the money went. */
47
+ export declare interface RefundSource {
48
+ id: string
49
+ object?: string
50
+ transfer_data?: { destination?: string | { id?: string } | null } | null
51
+ transfer?: string | { id?: string } | null
52
+ application_fee_amount?: number | null
53
+ application_fee?: string | { id?: string } | null
54
+ latest_charge?: string | RefundSource | null
55
+ }
56
+ export declare interface ConnectRefundOptions {
57
+ amount?: number
58
+ reason?: Stripe.RefundCreateParams.Reason
59
+ metadata?: Record<string, string>
60
+ refundApplicationFee?: boolean
61
+ reverseTransfer?: boolean
62
+ }
@@ -0,0 +1 @@
1
+ import{stripe}from"../drivers/stripe";import{freshIdempotencyKey}from"../idempotency";function expandedCharge(source){return source.latest_charge&&typeof source.latest_charge==="object"?source.latest_charge:null}export function isDestinationCharge(source){if(!source)return!1;const charge=expandedCharge(source);return Boolean(source.transfer_data?.destination||source.transfer||charge?.transfer_data?.destination||charge?.transfer)}export function tookApplicationFee(source){if(!source)return!1;const charge=expandedCharge(source);return Boolean((source.application_fee_amount??0)>0||source.application_fee||(charge?.application_fee_amount??0)>0||charge?.application_fee)}export function connectRefundParams(source,options={}){if(!source?.id||typeof source.id!=="string")throw Error("[payments/connect] connectRefundParams needs the PaymentIntent or Charge to refund");if(options.amount!=null&&(!Number.isSafeInteger(options.amount)||options.amount<=0))throw Error(`[payments/connect] a refund amount is a positive whole number of minor units, got ${options.amount}`);const isIntent=source.object==="payment_intent"||source.id.startsWith("pi_"),destination=isDestinationCharge(source),reverse=destination&&(options.reverseTransfer??!0),refundFee=destination&&tookApplicationFee(source)&&(options.refundApplicationFee??!0);return{...isIntent?{payment_intent:source.id}:{charge:source.id},...options.amount!=null?{amount:options.amount}:{},...options.reason?{reason:options.reason}:{},...options.metadata?{metadata:options.metadata}:{},...reverse?{reverse_transfer:!0}:{},...refundFee?{refund_application_fee:!0}:{}}}export async function connectRefund(paymentId,options={}){if(!paymentId||typeof paymentId!=="string")throw Error("[payments/connect] connectRefund needs a PaymentIntent or Charge id");const{idempotencyKey,...refundOptions}=options,source=paymentId.startsWith("pi_")?await stripe.paymentIntents.retrieve(paymentId,{expand:["latest_charge"]}):await stripe.charges.retrieve(paymentId);return await stripe.refunds.create(connectRefundParams(source,refundOptions),{idempotencyKey:idempotencyKey??freshIdempotencyKey("connect.refund",paymentId,refundOptions.amount??"full")})}
@@ -1 +1 @@
1
- import process from"node:process";import{config,services}from"@stacksjs/config";import{manageCharge}from"../billable/charge";import{manageCheckout}from"../billable/checkout";import{manageCustomer}from"../billable/customer";import{managePaymentMethod}from"../billable/payment-method";import{manageSubscription}from"../billable/subscription";import{constructEvent}from"../billable/webhook";import{stripe}from"../drivers/stripe";import{freshIdempotencyKey}from"../idempotency";import{AdyenDriver}from"./adyen";import{LemonSqueezyDriver}from"./lemonsqueezy";import{PaddleDriver}from"./paddle";import{PaymentUnsupportedError}from"./types";import{StripeDriver}from"./stripe";export*from"./adyen";export*from"./lemonsqueezy";export*from"./paddle";export*from"./paddle-checkout";export*from"./stripe";export*from"./types";export const defaultStripeOperations={customer:(payer)=>manageCustomer.createOrGetStripeUser(payer,{}),createIntent:(payer,params,idempotencyKey)=>stripe.paymentIntents.create(params,{idempotencyKey:idempotencyKey??freshIdempotencyKey("payment_intent.create",payer.id,params.amount)}),refund:(paymentIntentId,params)=>manageCharge.refund(paymentIntentId,params),createCheckout:(payer,params)=>manageCheckout.create(payer,params),listPaymentMethods:async(customerId)=>(await stripe.paymentMethods.list({customer:customerId,limit:100})).data,deletePaymentMethod:(payer,paymentMethodId)=>managePaymentMethod.deletePaymentMethod(payer,paymentMethodId),subscribe:(payer,type,lookupKey)=>manageSubscription.create(payer,type,lookupKey,{}),cancelSubscription:(subscriptionId,atPeriodEnd)=>atPeriodEnd?manageSubscription.cancelAtPeriodEnd(subscriptionId):manageSubscription.cancel(subscriptionId),listSubscriptions:async(customerId)=>(await stripe.subscriptions.list({customer:customerId,status:"all",limit:100})).data,constructEvent:(payload,signature,secret)=>constructEvent(payload,signature,secret)};const BUILT_IN_DRIVERS=["stripe","adyen","paddle","lemonsqueezy"],custom=new Map;export function registerPaymentDriver(name,factory){if(BUILT_IN_DRIVERS.includes(name))throw Error(`"${name}" is a built-in payment driver and cannot be replaced.`);custom.set(name,factory)}export function adyenConfig(settings={},env=process.env){const environment=settings.environment??(env.ADYEN_ENVIRONMENT==="live"?"live":"test");return{apiKey:settings.apiKey||env.ADYEN_API_KEY||"",merchantAccount:settings.merchantAccount||env.ADYEN_MERCHANT_ACCOUNT||"",environment,liveUrlPrefix:settings.liveUrlPrefix||env.ADYEN_LIVE_URL_PREFIX||void 0,hmacKey:settings.hmacKey||env.ADYEN_HMAC_KEY||void 0,shopperReferencePrefix:settings.shopperReferencePrefix}}export function paddleConfig(settings={},env=process.env){const environment=settings.environment??(env.PADDLE_ENVIRONMENT==="live"?"live":"sandbox");return{apiKey:settings.apiKey||env.PADDLE_API_KEY||"",environment,webhookSecret:settings.webhookSecret||env.PADDLE_WEBHOOK_SECRET||void 0,clientToken:settings.clientToken||env.PADDLE_CLIENT_TOKEN||void 0,taxCategory:settings.taxCategory||env.PADDLE_TAX_CATEGORY||void 0,webhookTolerance:settings.webhookTolerance}}export function lemonSqueezyConfig(settings={},env=process.env){return{apiKey:settings.apiKey||env.LEMONSQUEEZY_API_KEY||"",storeId:settings.storeId||env.LEMONSQUEEZY_STORE_ID||"",webhookSecret:settings.webhookSecret||env.LEMONSQUEEZY_WEBHOOK_SECRET||void 0,customPriceVariantId:settings.customPriceVariantId||env.LEMONSQUEEZY_VARIANT_ID||void 0,testMode:settings.testMode??env.LEMONSQUEEZY_TEST_MODE==="true"}}export function configuredPaymentDriver(){return(config.payment??{}).driver??"stripe"}export function assertStripeDriver(operation){const configured=configuredPaymentDriver();if(configured!=="stripe")throw new PaymentUnsupportedError(configured,operation,"it is a Stripe-only operation")}export function paymentDriver(name){const settings=config.payment??{},chosen=name??configuredPaymentDriver();if(chosen==="stripe")return new StripeDriver({operations:defaultStripeOperations,webhookSecret:settings.stripe?.webhookSecret||services?.stripe?.webhookSecret||process.env.STRIPE_WEBHOOK_SECRET||void 0});if(chosen==="adyen"){const appUrl=config.app?.url;return new AdyenDriver({...adyenConfig(settings.adyen),...appUrl?{appUrl}:{}})}if(chosen==="paddle"){const appUrl=config.app?.url;return new PaddleDriver({...paddleConfig(settings.paddle),...appUrl?{appUrl}:{}})}if(chosen==="lemonsqueezy"){const appUrl=config.app?.url;return new LemonSqueezyDriver({...lemonSqueezyConfig(settings.lemonsqueezy),...appUrl?{appUrl}:{}})}const factory=custom.get(chosen);if(!factory)throw Error(`No payment driver named "${chosen}". Built in: ${BUILT_IN_DRIVERS.join(", ")}${custom.size?`; registered: ${[...custom.keys()].join(", ")}`:""}.`);return factory()}
1
+ import process from"node:process";import{config,services}from"@stacksjs/config";import{manageCharge}from"../billable/charge";import{manageCheckout}from"../billable/checkout";import{manageCustomer}from"../billable/customer";import{managePaymentMethod}from"../billable/payment-method";import{manageSubscription}from"../billable/subscription";import{constructEvent}from"../billable/webhook";import{stripe}from"../drivers/stripe";import{freshIdempotencyKey}from"../idempotency";import{AdyenDriver}from"./adyen";import{LemonSqueezyDriver}from"./lemonsqueezy";import{PaddleDriver}from"./paddle";import{PaymentUnsupportedError}from"./types";import{StripeDriver}from"./stripe";export*from"./adyen";export*from"./lemonsqueezy";export*from"./paddle";export*from"./paddle-checkout";export*from"./stripe";export*from"./types";export const defaultStripeOperations={customer:(payer)=>manageCustomer.createOrGetStripeUser(payer,{}),createIntent:(payer,params,idempotencyKey)=>stripe.paymentIntents.create(params,{idempotencyKey:idempotencyKey??freshIdempotencyKey("payment_intent.create",payer.id,params.amount)}),refund:(paymentIntentId,params)=>manageCharge.refund(paymentIntentId,params),createCheckout:(payer,params)=>manageCheckout.create(payer,params),listPaymentMethods:async(customerId)=>(await stripe.paymentMethods.list({customer:customerId,limit:100})).data,deletePaymentMethod:(payer,paymentMethodId)=>managePaymentMethod.deletePaymentMethod(payer,paymentMethodId),subscribe:(payer,type,lookupKey)=>manageSubscription.create(payer,type,lookupKey,{}),cancelSubscription:(subscriptionId,atPeriodEnd)=>atPeriodEnd?manageSubscription.cancelAtPeriodEnd(subscriptionId):manageSubscription.cancel(subscriptionId),listSubscriptions:async(customerId)=>(await stripe.subscriptions.list({customer:customerId,status:"all",limit:100})).data,constructEvent:(payload,signature,secret,tolerance)=>constructEvent(payload,signature,secret,tolerance)};const BUILT_IN_DRIVERS=["stripe","adyen","paddle","lemonsqueezy"],custom=new Map;export function registerPaymentDriver(name,factory){if(BUILT_IN_DRIVERS.includes(name))throw Error(`"${name}" is a built-in payment driver and cannot be replaced.`);custom.set(name,factory)}export function adyenConfig(settings={},env=process.env){const environment=settings.environment??(env.ADYEN_ENVIRONMENT==="live"?"live":"test");return{apiKey:settings.apiKey||env.ADYEN_API_KEY||"",merchantAccount:settings.merchantAccount||env.ADYEN_MERCHANT_ACCOUNT||"",environment,liveUrlPrefix:settings.liveUrlPrefix||env.ADYEN_LIVE_URL_PREFIX||void 0,hmacKey:settings.hmacKey||env.ADYEN_HMAC_KEY||void 0,shopperReferencePrefix:settings.shopperReferencePrefix}}export function paddleConfig(settings={},env=process.env){const environment=settings.environment??(env.PADDLE_ENVIRONMENT==="live"?"live":"sandbox");return{apiKey:settings.apiKey||env.PADDLE_API_KEY||"",environment,webhookSecret:settings.webhookSecret||env.PADDLE_WEBHOOK_SECRET||void 0,clientToken:settings.clientToken||env.PADDLE_CLIENT_TOKEN||void 0,taxCategory:settings.taxCategory||env.PADDLE_TAX_CATEGORY||void 0,webhookTolerance:settings.webhookTolerance}}export function lemonSqueezyConfig(settings={},env=process.env){return{apiKey:settings.apiKey||env.LEMONSQUEEZY_API_KEY||"",storeId:settings.storeId||env.LEMONSQUEEZY_STORE_ID||"",webhookSecret:settings.webhookSecret||env.LEMONSQUEEZY_WEBHOOK_SECRET||void 0,customPriceVariantId:settings.customPriceVariantId||env.LEMONSQUEEZY_VARIANT_ID||void 0,testMode:settings.testMode??env.LEMONSQUEEZY_TEST_MODE==="true"}}export function configuredPaymentDriver(){return(config.payment??{}).driver??"stripe"}export function assertStripeDriver(operation){const configured=configuredPaymentDriver();if(configured!=="stripe")throw new PaymentUnsupportedError(configured,operation,"it is a Stripe-only operation")}export function paymentDriver(name){const settings=config.payment??{},chosen=name??configuredPaymentDriver();if(chosen==="stripe")return new StripeDriver({operations:defaultStripeOperations,webhookSecret:settings.stripe?.webhookSecret||services?.stripe?.webhookSecret||process.env.STRIPE_WEBHOOK_SECRET||void 0,webhookTolerance:settings.stripe?.webhookTolerance??settings.webhook?.tolerance});if(chosen==="adyen"){const appUrl=config.app?.url;return new AdyenDriver({...adyenConfig(settings.adyen),...appUrl?{appUrl}:{}})}if(chosen==="paddle"){const appUrl=config.app?.url;return new PaddleDriver({...paddleConfig(settings.paddle),...appUrl?{appUrl}:{}})}if(chosen==="lemonsqueezy"){const appUrl=config.app?.url;return new LemonSqueezyDriver({...lemonSqueezyConfig(settings.lemonsqueezy),...appUrl?{appUrl}:{}})}const factory=custom.get(chosen);if(!factory)throw Error(`No payment driver named "${chosen}". Built in: ${BUILT_IN_DRIVERS.join(", ")}${custom.size?`; registered: ${[...custom.keys()].join(", ")}`:""}.`);return factory()}
@@ -38,11 +38,12 @@ export declare interface StripeOperations {
38
38
  subscribe: (payer: Payer, type: string, lookupKey: string) => Promise<Stripe.Subscription>
39
39
  cancelSubscription: (subscriptionId: string, atPeriodEnd: boolean) => Promise<Stripe.Subscription>
40
40
  listSubscriptions: (customerId: string) => Promise<Stripe.Subscription[]>
41
- constructEvent: (payload: string, signature: string, secret: string) => Promise<Stripe.Event>
41
+ constructEvent: (payload: string, signature: string, secret: string, tolerance?: number) => Promise<Stripe.Event>
42
42
  }
43
43
  export declare interface StripeDriverOptions {
44
44
  operations: StripeOperations
45
45
  webhookSecret?: string
46
+ webhookTolerance?: number
46
47
  }
47
48
  export declare class StripeDriver implements PaymentDriver {
48
49
  readonly name: string;
@@ -1 +1 @@
1
- import{assertMoney,headerOf,WebhookNotConfiguredError,WebhookSignatureError}from"./types";const CAPABILITIES=new Set(["customers","charge","createPayment","refund","checkout","paymentMethods","subscriptions","webhooks"]);export function stripePaymentStatus(intent){switch(intent.status){case"succeeded":return"succeeded";case"canceled":return"canceled";case"requires_action":return"requires_action";case"processing":case"requires_capture":return"processing";default:return intent.last_payment_error?"failed":"requires_payment_method"}}export function refundedThisTime(charge,previous){if(typeof charge.amount_refunded!=="number")return;const before=typeof previous?.amount_refunded==="number"?previous.amount_refunded:0;return charge.amount_refunded-before}function refundStatus(status){if(status==="succeeded"||status==="failed"||status==="canceled")return status;return"pending"}const SUBSCRIPTION_STATUSES=new Set(["active","trialing","past_due","canceled","incomplete","unpaid","paused"]);function subscriptionStatus(status){if(status==="incomplete_expired")return"canceled";return SUBSCRIPTION_STATUSES.has(status)?status:"unknown"}const INTERVALS=new Set(["day","week","month","year"]);export function subscriptionPeriodEnd(subscription){const fromItem=subscription.items?.data?.[0]?.current_period_end;if(typeof fromItem==="number")return fromItem;const legacy=subscription.current_period_end;return typeof legacy==="number"?legacy:void 0}export function summarizeSubscription(subscription){const item=subscription.items?.data?.[0],periodEnd=subscriptionPeriodEnd(subscription),price=item?.price,invoice=subscription.latest_invoice,secret=invoice&&typeof invoice==="object"?invoice.confirmation_secret?.client_secret:void 0;return{id:subscription.id,status:subscriptionStatus(subscription.status),price:price?{id:price.id,name:price.nickname??price.lookup_key??null,amount:typeof price.unit_amount==="number"?{amount:price.unit_amount,currency:price.currency}:null,interval:INTERVALS.has(price.recurring?.interval??"")?price.recurring.interval:null}:null,currentPeriodEnd:periodEnd?new Date(periodEnd*1000):null,cancelAtPeriodEnd:Boolean(subscription.cancel_at_period_end),...secret&&subscription.status==="incomplete"?{clientConfirmation:{provider:"stripe",clientSecret:secret}}:{},raw:subscription}}const EVENT_TYPES={"payment_intent.succeeded":"payment.succeeded","payment_intent.payment_failed":"payment.failed","payment_intent.canceled":"payment.canceled","charge.refunded":"refund.succeeded","refund.failed":"refund.failed","checkout.session.completed":"checkout.completed","customer.subscription.created":"subscription.created","customer.subscription.updated":"subscription.updated","customer.subscription.deleted":"subscription.canceled","invoice.paid":"invoice.paid","invoice.payment_failed":"invoice.payment_failed"};export function toStoredPaymentMethod(method,defaultId=null){return{id:method.id,type:method.type,brand:method.card?.brand??null,last4:method.card?.last4??null,expMonth:method.card?.exp_month??null,expYear:method.card?.exp_year??null,isDefault:method.id===defaultId,raw:method}}function toPayment(intent){const status=stripePaymentStatus(intent);return{id:intent.id,status,amount:{amount:intent.amount,currency:intent.currency},...intent.client_secret&&(status==="requires_payment_method"||status==="requires_action")?{clientConfirmation:{provider:"stripe",clientSecret:intent.client_secret}}:{},...intent.next_action?.redirect_to_url?.url?{redirectUrl:intent.next_action.redirect_to_url.url}:{},...intent.last_payment_error?.message?{failureReason:intent.last_payment_error.message}:{},raw:intent}}export class StripeDriver{name="stripe";capabilities=CAPABILITIES;ops;webhookSecret;constructor(options){this.ops=options.operations;this.webhookSecret=options.webhookSecret}async customer(payer){const customer=await this.ops.customer(payer);return{id:customer.id,email:customer.email??null,name:customer.name??null}}async charge(payer,amount,paymentMethod,options={}){assertMoney(amount);const customer=await this.ops.customer(payer),intent=await this.ops.createIntent(payer,{amount:amount.amount,currency:amount.currency.toLowerCase(),customer:customer.id,payment_method:paymentMethod,confirm:!0,automatic_payment_methods:{enabled:!0,allow_redirects:"never"},off_session:!0,...options.description?{description:options.description}:{},metadata:{...options.metadata,...options.reference?{reference:options.reference}:{}}},options.idempotencyKey);return toPayment(intent)}async createPayment(payer,amount,options={}){assertMoney(amount);const customer=await this.ops.customer(payer),intent=await this.ops.createIntent(payer,{amount:amount.amount,currency:amount.currency.toLowerCase(),customer:customer.id,automatic_payment_methods:{enabled:!0},...options.description?{description:options.description}:{},metadata:{...options.metadata,...options.reference?{reference:options.reference}:{}}},options.idempotencyKey);return toPayment(intent)}async refund(paymentId,options={}){if(options.amount)assertMoney(options.amount,"refund amount");const refund=await this.ops.refund(paymentId,{...options.amount?{amount:options.amount.amount}:{},...options.reason?{reason:options.reason}:{},...options.idempotencyKey?{idempotencyKey:options.idempotencyKey}:{}});return{id:refund.id,paymentId,status:refundStatus(refund.status),amount:{amount:refund.amount,currency:refund.currency},raw:refund}}async checkout(payer,request){if(request.trialDays!==void 0&&(!Number.isSafeInteger(request.trialDays)||request.trialDays<1))throw TypeError("trialDays is a whole number of days, at least 1.");if(request.trialDays&&request.mode!=="subscription")throw TypeError("trialDays applies to a subscription checkout.");const customer=await this.ops.customer(payer),lineItems=request.lines.map((line)=>{if("price"in line)return{price:line.price,quantity:line.quantity};if(!request.currency)throw TypeError("A checkout line priced with unitAmount needs the checkout's currency.");return{quantity:line.quantity,price_data:{currency:request.currency.toLowerCase(),unit_amount:line.unitAmount,product_data:{name:line.name}}}}),carried={...request.metadata,...request.reference?{reference:request.reference}:{}},hasCarried=Object.keys(carried).length>0,session=await this.ops.createCheckout(payer,{customer:customer.id,mode:request.mode,success_url:request.successUrl,...request.cancelUrl?{cancel_url:request.cancelUrl}:{},...request.mode==="setup"?{currency:request.currency?.toLowerCase()}:{line_items:lineItems},...request.reference?{client_reference_id:request.reference}:{},...request.metadata?{metadata:request.metadata}:{},...hasCarried&&request.mode==="payment"?{payment_intent_data:{metadata:carried}}:{},...request.mode==="subscription"&&(hasCarried||request.trialDays)?{subscription_data:{...hasCarried?{metadata:carried}:{},...request.trialDays?{trial_period_days:request.trialDays}:{}}}:{},...hasCarried&&request.mode==="setup"?{setup_intent_data:{metadata:carried}}:{},...request.allowPromotionCodes?{allow_promotion_codes:!0}:{},...request.automaticTax?{automatic_tax:{enabled:!0}}:{},...request.paymentMethods?.length?{allowed_payment_method_types:[...request.paymentMethods]}:{}});if(!session.url)throw Error("Stripe returned a checkout session without a url.");return{id:session.id,url:session.url,expiresAt:session.expires_at?new Date(session.expires_at*1000):null,raw:session}}async paymentMethods(payer){const customer=await this.ops.customer(payer),defaultId=typeof customer.invoice_settings?.default_payment_method==="string"?customer.invoice_settings.default_payment_method:customer.invoice_settings?.default_payment_method?.id??null;return(await this.ops.listPaymentMethods(customer.id)).map((method)=>toStoredPaymentMethod(method,defaultId))}async removePaymentMethod(payer,paymentMethodId){await this.ops.deletePaymentMethod(payer,paymentMethodId)}async subscribe(payer,price,options={}){return summarizeSubscription(await this.ops.subscribe(payer,options.type??"default",price))}async cancelSubscription(subscriptionId,options={}){return summarizeSubscription(await this.ops.cancelSubscription(subscriptionId,options.atPeriodEnd??!1))}async subscriptions(payer){const customer=await this.ops.customer(payer);return(await this.ops.listSubscriptions(customer.id)).map(summarizeSubscription)}async verifyWebhook(request){if(!this.webhookSecret)throw new WebhookNotConfiguredError("stripe","Stripe webhooks cannot be verified without the signing secret: set STRIPE_WEBHOOK_SECRET or payment.stripe.webhookSecret.");const signature=headerOf(request.headers,"stripe-signature");if(!signature)throw new WebhookSignatureError("stripe","there is no Stripe-Signature header");let event;try{event=await this.ops.constructEvent(request.payload,signature,this.webhookSecret)}catch(error){throw new WebhookSignatureError("stripe",error.message)}const object=event.data.object,reference=event.type==="charge.refunded"||event.type==="refund.failed"?object.payment_intent??object.id:object.id,minor=event.type==="charge.refunded"?refundedThisTime(object,event.data.previous_attributes):object.amount??object.amount_total,amount=typeof minor==="number"&&object.currency?{amount:minor,currency:String(object.currency)}:null,reason=object.last_payment_error?.message??object.failure_reason??null;return[{provider:this.name,id:event.id,type:EVENT_TYPES[event.type]??"unknown",providerType:event.type,reference:typeof reference==="string"?reference:null,merchantReference:object.client_reference_id??object.metadata?.reference??null,amount,reason,raw:event}]}acknowledgeWebhook(){return Response.json({received:!0})}}
1
+ import{assertMoney,headerOf,WebhookNotConfiguredError,WebhookSignatureError}from"./types";const CAPABILITIES=new Set(["customers","charge","createPayment","refund","checkout","paymentMethods","subscriptions","webhooks"]);export function stripePaymentStatus(intent){switch(intent.status){case"succeeded":return"succeeded";case"canceled":return"canceled";case"requires_action":return"requires_action";case"processing":case"requires_capture":return"processing";default:return intent.last_payment_error?"failed":"requires_payment_method"}}export function refundedThisTime(charge,previous){if(typeof charge.amount_refunded!=="number")return;const before=typeof previous?.amount_refunded==="number"?previous.amount_refunded:0;return charge.amount_refunded-before}function refundStatus(status){if(status==="succeeded"||status==="failed"||status==="canceled")return status;return"pending"}const SUBSCRIPTION_STATUSES=new Set(["active","trialing","past_due","canceled","incomplete","unpaid","paused"]);function subscriptionStatus(status){if(status==="incomplete_expired")return"canceled";return SUBSCRIPTION_STATUSES.has(status)?status:"unknown"}const INTERVALS=new Set(["day","week","month","year"]);export function subscriptionPeriodEnd(subscription){const fromItem=subscription.items?.data?.[0]?.current_period_end;if(typeof fromItem==="number")return fromItem;const legacy=subscription.current_period_end;return typeof legacy==="number"?legacy:void 0}export function summarizeSubscription(subscription){const item=subscription.items?.data?.[0],periodEnd=subscriptionPeriodEnd(subscription),price=item?.price,invoice=subscription.latest_invoice,secret=invoice&&typeof invoice==="object"?invoice.confirmation_secret?.client_secret:void 0;return{id:subscription.id,status:subscriptionStatus(subscription.status),price:price?{id:price.id,name:price.nickname??price.lookup_key??null,amount:typeof price.unit_amount==="number"?{amount:price.unit_amount,currency:price.currency}:null,interval:INTERVALS.has(price.recurring?.interval??"")?price.recurring.interval:null}:null,currentPeriodEnd:periodEnd?new Date(periodEnd*1000):null,cancelAtPeriodEnd:Boolean(subscription.cancel_at_period_end),...secret&&subscription.status==="incomplete"?{clientConfirmation:{provider:"stripe",clientSecret:secret}}:{},raw:subscription}}const EVENT_TYPES={"payment_intent.succeeded":"payment.succeeded","payment_intent.payment_failed":"payment.failed","payment_intent.canceled":"payment.canceled","charge.refunded":"refund.succeeded","refund.failed":"refund.failed","checkout.session.completed":"checkout.completed","customer.subscription.created":"subscription.created","customer.subscription.updated":"subscription.updated","customer.subscription.deleted":"subscription.canceled","invoice.paid":"invoice.paid","invoice.payment_failed":"invoice.payment_failed"};export function toStoredPaymentMethod(method,defaultId=null){return{id:method.id,type:method.type,brand:method.card?.brand??null,last4:method.card?.last4??null,expMonth:method.card?.exp_month??null,expYear:method.card?.exp_year??null,isDefault:method.id===defaultId,raw:method}}function toPayment(intent){const status=stripePaymentStatus(intent);return{id:intent.id,status,amount:{amount:intent.amount,currency:intent.currency},...intent.client_secret&&(status==="requires_payment_method"||status==="requires_action")?{clientConfirmation:{provider:"stripe",clientSecret:intent.client_secret}}:{},...intent.next_action?.redirect_to_url?.url?{redirectUrl:intent.next_action.redirect_to_url.url}:{},...intent.last_payment_error?.message?{failureReason:intent.last_payment_error.message}:{},raw:intent}}export class StripeDriver{name="stripe";capabilities=CAPABILITIES;ops;webhookSecret;webhookTolerance;constructor(options){this.ops=options.operations;this.webhookSecret=options.webhookSecret;this.webhookTolerance=options.webhookTolerance}async customer(payer){const customer=await this.ops.customer(payer);return{id:customer.id,email:customer.email??null,name:customer.name??null}}async charge(payer,amount,paymentMethod,options={}){assertMoney(amount);const customer=await this.ops.customer(payer),intent=await this.ops.createIntent(payer,{amount:amount.amount,currency:amount.currency.toLowerCase(),customer:customer.id,payment_method:paymentMethod,confirm:!0,automatic_payment_methods:{enabled:!0,allow_redirects:"never"},off_session:!0,...options.description?{description:options.description}:{},metadata:{...options.metadata,...options.reference?{reference:options.reference}:{}}},options.idempotencyKey);return toPayment(intent)}async createPayment(payer,amount,options={}){assertMoney(amount);const customer=await this.ops.customer(payer),intent=await this.ops.createIntent(payer,{amount:amount.amount,currency:amount.currency.toLowerCase(),customer:customer.id,automatic_payment_methods:{enabled:!0},...options.description?{description:options.description}:{},metadata:{...options.metadata,...options.reference?{reference:options.reference}:{}}},options.idempotencyKey);return toPayment(intent)}async refund(paymentId,options={}){if(options.amount)assertMoney(options.amount,"refund amount");const refund=await this.ops.refund(paymentId,{...options.amount?{amount:options.amount.amount}:{},...options.reason?{reason:options.reason}:{},...options.idempotencyKey?{idempotencyKey:options.idempotencyKey}:{}});return{id:refund.id,paymentId,status:refundStatus(refund.status),amount:{amount:refund.amount,currency:refund.currency},raw:refund}}async checkout(payer,request){if(request.trialDays!==void 0&&(!Number.isSafeInteger(request.trialDays)||request.trialDays<1))throw TypeError("trialDays is a whole number of days, at least 1.");if(request.trialDays&&request.mode!=="subscription")throw TypeError("trialDays applies to a subscription checkout.");const customer=await this.ops.customer(payer),lineItems=request.lines.map((line)=>{if("price"in line)return{price:line.price,quantity:line.quantity};if(!request.currency)throw TypeError("A checkout line priced with unitAmount needs the checkout's currency.");return{quantity:line.quantity,price_data:{currency:request.currency.toLowerCase(),unit_amount:line.unitAmount,product_data:{name:line.name}}}}),carried={...request.metadata,...request.reference?{reference:request.reference}:{}},hasCarried=Object.keys(carried).length>0,session=await this.ops.createCheckout(payer,{customer:customer.id,mode:request.mode,success_url:request.successUrl,...request.cancelUrl?{cancel_url:request.cancelUrl}:{},...request.mode==="setup"?{currency:request.currency?.toLowerCase()}:{line_items:lineItems},...request.reference?{client_reference_id:request.reference}:{},...request.metadata?{metadata:request.metadata}:{},...hasCarried&&request.mode==="payment"?{payment_intent_data:{metadata:carried}}:{},...request.mode==="subscription"&&(hasCarried||request.trialDays)?{subscription_data:{...hasCarried?{metadata:carried}:{},...request.trialDays?{trial_period_days:request.trialDays}:{}}}:{},...hasCarried&&request.mode==="setup"?{setup_intent_data:{metadata:carried}}:{},...request.allowPromotionCodes?{allow_promotion_codes:!0}:{},...request.automaticTax?{automatic_tax:{enabled:!0}}:{},...request.paymentMethods?.length?{allowed_payment_method_types:[...request.paymentMethods]}:{}});if(!session.url)throw Error("Stripe returned a checkout session without a url.");return{id:session.id,url:session.url,expiresAt:session.expires_at?new Date(session.expires_at*1000):null,raw:session}}async paymentMethods(payer){const customer=await this.ops.customer(payer),defaultId=typeof customer.invoice_settings?.default_payment_method==="string"?customer.invoice_settings.default_payment_method:customer.invoice_settings?.default_payment_method?.id??null;return(await this.ops.listPaymentMethods(customer.id)).map((method)=>toStoredPaymentMethod(method,defaultId))}async removePaymentMethod(payer,paymentMethodId){await this.ops.deletePaymentMethod(payer,paymentMethodId)}async subscribe(payer,price,options={}){return summarizeSubscription(await this.ops.subscribe(payer,options.type??"default",price))}async cancelSubscription(subscriptionId,options={}){return summarizeSubscription(await this.ops.cancelSubscription(subscriptionId,options.atPeriodEnd??!1))}async subscriptions(payer){const customer=await this.ops.customer(payer);return(await this.ops.listSubscriptions(customer.id)).map(summarizeSubscription)}async verifyWebhook(request){if(!this.webhookSecret)throw new WebhookNotConfiguredError("stripe","Stripe webhooks cannot be verified without the signing secret: set STRIPE_WEBHOOK_SECRET or payment.stripe.webhookSecret.");const signature=headerOf(request.headers,"stripe-signature");if(!signature)throw new WebhookSignatureError("stripe","there is no Stripe-Signature header");let event;try{event=await this.ops.constructEvent(request.payload,signature,this.webhookSecret,this.webhookTolerance)}catch(error){throw new WebhookSignatureError("stripe",error.message)}const object=event.data.object,reference=event.type==="charge.refunded"||event.type==="refund.failed"?object.payment_intent??object.id:object.id,minor=event.type==="charge.refunded"?refundedThisTime(object,event.data.previous_attributes):object.amount??object.amount_total,amount=typeof minor==="number"&&object.currency?{amount:minor,currency:String(object.currency)}:null,reason=object.last_payment_error?.message??object.failure_reason??null;return[{provider:this.name,id:event.id,type:EVENT_TYPES[event.type]??"unknown",providerType:event.type,reference:typeof reference==="string"?reference:null,merchantReference:object.client_reference_id??object.metadata?.reference??null,amount,reason,raw:event}]}acknowledgeWebhook(){return Response.json({received:!0})}}
@@ -24,3 +24,9 @@ export declare function stacksIdempotencyKey(scope: string, ...parts: Array<stri
24
24
  * still benefits from in-flight idempotency.
25
25
  */
26
26
  export declare function freshIdempotencyKey(scope: string, ...parts: Array<string | number | null | undefined>): string;
27
+ /**
28
+ * A short, stable digest of a value, for folding request parameters into an
29
+ * idempotency key. Object keys are sorted first, so `{ a, b }` and `{ b, a }`
30
+ * give the same digest; `undefined` properties are dropped, as JSON drops them.
31
+ */
32
+ export declare function idempotencyFingerprint(value: unknown): string;
@@ -1 +1 @@
1
- import{createHash}from"node:crypto";const KEY_PREFIX="stacks",KEY_VERSION="v1",MAX_KEY_LENGTH=255;export function stacksIdempotencyKey(scope,...parts){const tail=parts.filter((p)=>p!==null&&p!==void 0&&p!=="").map((p)=>String(p)).join(":"),raw=tail?`${KEY_PREFIX}:${scope}:${tail}:${KEY_VERSION}`:`${KEY_PREFIX}:${scope}:${KEY_VERSION}`;if(raw.length<=MAX_KEY_LENGTH)return raw;const hashed=createHash("sha256").update(tail).digest("hex").slice(0,32);return`${KEY_PREFIX}:${scope}:${hashed}:${KEY_VERSION}`}export function freshIdempotencyKey(scope,...parts){const random=createHash("sha256").update(`${Date.now()}:${Math.random()}`).digest("hex").slice(0,16);return stacksIdempotencyKey(scope,...parts,random)}
1
+ import{createHash}from"node:crypto";const KEY_PREFIX="stacks",KEY_VERSION="v1",MAX_KEY_LENGTH=255;export function stacksIdempotencyKey(scope,...parts){const tail=parts.filter((p)=>p!==null&&p!==void 0&&p!=="").map((p)=>String(p)).join(":"),raw=tail?`${KEY_PREFIX}:${scope}:${tail}:${KEY_VERSION}`:`${KEY_PREFIX}:${scope}:${KEY_VERSION}`;if(raw.length<=MAX_KEY_LENGTH)return raw;const hashed=createHash("sha256").update(tail).digest("hex").slice(0,32);return`${KEY_PREFIX}:${scope}:${hashed}:${KEY_VERSION}`}export function freshIdempotencyKey(scope,...parts){const random=createHash("sha256").update(`${Date.now()}:${Math.random()}`).digest("hex").slice(0,16);return stacksIdempotencyKey(scope,...parts,random)}export function idempotencyFingerprint(value){return createHash("sha256").update(stableJson(value)).digest("hex").slice(0,24)}function stableJson(value){if(value===null||typeof value!=="object")return JSON.stringify(value)??"null";if(Array.isArray(value))return`[${value.map((item)=>stableJson(item===void 0?null:item)).join(",")}]`;return`{${Object.keys(value).filter((key)=>value[key]!==void 0).sort().map((key)=>`${JSON.stringify(key)}:${stableJson(value[key])}`).join(",")}}`}
package/dist/index.d.ts CHANGED
@@ -24,6 +24,8 @@ export * from './drivers/stripe';
24
24
  // retries don't produce duplicate objects (stacksjs/stacks#1876 X-1).
25
25
  export { freshIdempotencyKey, stacksIdempotencyKey } from './idempotency';
26
26
  export { subscriptionGrantsAccess, subscriptionPeriod } from './subscription-state';
27
+ // What a checkout.session.* webhook means: paid, processing, failed or expired.
28
+ export * from './checkout-outcome';
27
29
  // Bank debits (SEPA Direct Debit, Bacs, ACH, BECS): methods per currency,
28
30
  // debit webhook outcomes and mandates. Pure; also `@stacksjs/payments/direct-debit`.
29
31
  export * from './direct-debit';
package/dist/index.js CHANGED
@@ -1 +1 @@
1
- export*from"./payment";export{default as Payment}from"./payment";export*from"./driver";export*from"./billable";export*from"./connect";export*from"./drivers/stripe";export{freshIdempotencyKey,stacksIdempotencyKey}from"./idempotency";export{subscriptionGrantsAccess,subscriptionPeriod}from"./subscription-state";export*from"./direct-debit";export*from"./terminal";
1
+ export*from"./payment";export{default as Payment}from"./payment";export*from"./driver";export*from"./billable";export*from"./connect";export*from"./drivers/stripe";export{freshIdempotencyKey,stacksIdempotencyKey}from"./idempotency";export{subscriptionGrantsAccess,subscriptionPeriod}from"./subscription-state";export*from"./checkout-outcome";export*from"./direct-debit";export*from"./terminal";
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@stacksjs/payments",
3
3
  "type": "module",
4
4
  "sideEffects": false,
5
- "version": "0.75.226",
5
+ "version": "0.75.230",
6
6
  "description": "The Stacks payments package. Currently supporting Stripe.",
7
7
  "author": "Chris Breuer",
8
8
  "contributors": [
@@ -60,12 +60,12 @@
60
60
  "prepublishOnly": "bun run build"
61
61
  },
62
62
  "dependencies": {
63
- "@stacksjs/config": "0.75.226",
64
- "@stacksjs/database": "0.75.226",
65
- "@stacksjs/error-handling": "0.75.226",
66
- "@stacksjs/logging": "0.75.226",
67
- "@stacksjs/orm": "0.75.226",
68
- "@stacksjs/types": "0.75.226"
63
+ "@stacksjs/config": "0.75.230",
64
+ "@stacksjs/database": "0.75.230",
65
+ "@stacksjs/error-handling": "0.75.230",
66
+ "@stacksjs/logging": "0.75.230",
67
+ "@stacksjs/orm": "0.75.230",
68
+ "@stacksjs/types": "0.75.230"
69
69
  },
70
70
  "peerDependencies": {
71
71
  "@stripe/stripe-js": "^10.0.0",
@@ -80,7 +80,7 @@
80
80
  }
81
81
  },
82
82
  "devDependencies": {
83
- "@stacksjs/utils": "0.75.226",
83
+ "@stacksjs/utils": "0.75.230",
84
84
  "@stripe/stripe-js": "^10.0.0",
85
85
  "better-dx": "^0.2.24",
86
86
  "stripe": "23.0.0"