@stacksjs/payments 0.75.225 → 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.
- package/dist/billable/index.d.ts +1 -0
- package/dist/billable/index.js +1 -1
- package/dist/billable/webhook-events.d.ts +24 -0
- package/dist/billable/webhook-events.js +1 -0
- package/dist/billable/webhook.d.ts +22 -4
- package/dist/billable/webhook.js +1 -1
- package/dist/checkout-outcome.d.ts +49 -0
- package/dist/checkout-outcome.js +1 -0
- package/dist/connect/account.d.ts +17 -2
- package/dist/connect/account.js +1 -1
- package/dist/connect/destination.d.ts +117 -0
- package/dist/connect/destination.js +1 -0
- package/dist/connect/index.d.ts +15 -0
- package/dist/connect/index.js +1 -1
- package/dist/connect/refund.d.ts +62 -0
- package/dist/connect/refund.js +1 -0
- package/dist/driver/index.js +1 -1
- package/dist/driver/stripe.d.ts +2 -1
- package/dist/driver/stripe.js +1 -1
- package/dist/idempotency.d.ts +6 -0
- package/dist/idempotency.js +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -1
- package/package.json +8 -8
package/dist/billable/index.d.ts
CHANGED
package/dist/billable/index.js
CHANGED
|
@@ -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
|
-
*
|
|
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<
|
|
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'
|
package/dist/billable/webhook.js
CHANGED
|
@@ -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
|
|
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
|
|
32
|
-
createTestConnectedAccount: (user: UserModel, params?: Partial<Stripe.AccountCreateParams
|
|
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>>
|
package/dist/connect/account.js
CHANGED
|
@@ -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:
|
|
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}
|
package/dist/connect/index.d.ts
CHANGED
|
@@ -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';
|
package/dist/connect/index.js
CHANGED
|
@@ -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")})}
|
package/dist/driver/index.js
CHANGED
|
@@ -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()}
|
package/dist/driver/stripe.d.ts
CHANGED
|
@@ -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;
|
package/dist/driver/stripe.js
CHANGED
|
@@ -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})}}
|
package/dist/idempotency.d.ts
CHANGED
|
@@ -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;
|
package/dist/idempotency.js
CHANGED
|
@@ -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.
|
|
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.
|
|
64
|
-
"@stacksjs/database": "0.75.
|
|
65
|
-
"@stacksjs/error-handling": "0.75.
|
|
66
|
-
"@stacksjs/logging": "0.75.
|
|
67
|
-
"@stacksjs/orm": "0.75.
|
|
68
|
-
"@stacksjs/types": "0.75.
|
|
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.
|
|
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"
|