@stacksjs/payments 0.75.85 → 0.75.86

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.
@@ -1,5 +1,6 @@
1
1
  import { stripe } from '../drivers/stripe';
2
2
  import type { AdyenConfig } from './adyen';
3
+ import type { PaddleConfig } from './paddle';
3
4
  import type { PaymentDriver } from './types';
4
5
  import type { StripeOperations } from './stripe';
5
6
  /**
@@ -9,6 +10,8 @@ import type { StripeOperations } from './stripe';
9
10
  export declare function registerPaymentDriver(name: string, factory: DriverFactory): void;
10
11
  /** Adyen's settings: config/payment.ts, then the ADYEN_* environment. */
11
12
  export declare function adyenConfig(settings?: Partial<AdyenConfig>, env?: Record<string, string | undefined>): AdyenConfig;
13
+ /** Paddle's settings: config/payment.ts, then the PADDLE_* environment. */
14
+ export declare function paddleConfig(settings?: Partial<PaddleConfig>, env?: Record<string, string | undefined>): PaddleConfig;
12
15
  /** The driver `config.payment.driver` names: `stripe` unless it says otherwise. */
13
16
  export declare function configuredPaymentDriver(): string;
14
17
  /**
@@ -27,5 +30,7 @@ export declare function paymentDriver(name?: string): PaymentDriver;
27
30
  export declare const defaultStripeOperations: StripeOperations;
28
31
  declare type DriverFactory = () => PaymentDriver;
29
32
  export * from './adyen';
33
+ export * from './paddle';
34
+ export * from './paddle-checkout';
30
35
  export * from './stripe';
31
36
  export * from './types';
@@ -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{PaymentUnsupportedError}from"./types";import{StripeDriver}from"./stripe";export*from"./adyen";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 custom=new Map;export function registerPaymentDriver(name,factory){if(name==="stripe"||name==="adyen")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 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}:{}})}const factory=custom.get(chosen);if(!factory)throw Error(`No payment driver named "${chosen}". Built in: stripe, adyen${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{PaddleDriver}from"./paddle";import{PaymentUnsupportedError}from"./types";import{StripeDriver}from"./stripe";export*from"./adyen";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 custom=new Map;export function registerPaymentDriver(name,factory){if(name==="stripe"||name==="adyen"||name==="paddle")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 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}:{}})}const factory=custom.get(chosen);if(!factory)throw Error(`No payment driver named "${chosen}". Built in: stripe, adyen, paddle${custom.size?`; registered: ${[...custom.keys()].join(", ")}`:""}.`);return factory()}
@@ -0,0 +1,29 @@
1
+ import type { PaymentDriver } from './types';
2
+ /** The checkout page for one transaction. */
3
+ export declare function paddleCheckoutPage(settings: { environment: 'sandbox' | 'live', token: string, successUrl: string | null }): string;
4
+ /**
5
+ * `GET /payments/checkout?_ptxn=txn_...`. `driver` defaults to the
6
+ * configured one; tests pass their own.
7
+ */
8
+ export declare function servePaddleCheckout(request: Request, driver?: PaymentDriver): Promise<Response>;
9
+ /** `GET /payments/checkout.js`. */
10
+ export declare function servePaddleCheckoutScript(): Response;
11
+ /**
12
+ * The page a Paddle payment link opens (stacksjs/stacks#665).
13
+ *
14
+ * Paddle has no hosted checkout page: a transaction's payment link is the
15
+ * default payment link set in Paddle - a page of yours that loads Paddle.js -
16
+ * with `?_ptxn=<transaction>` appended, and Paddle.js opens the checkout for
17
+ * it. The success page is a Paddle.js setting, not a transaction one, so this
18
+ * page reads the one `checkout()` stored on the transaction and hands it to
19
+ * Paddle.js. Set the default payment link in Paddle to
20
+ * `https://<your app>/payments/checkout`.
21
+ *
22
+ * No inline script: the settings travel as JSON in a non-executing block and
23
+ * the bootstrap is a same-origin script, so the page works under an app's
24
+ * Content-Security-Policy that allows `self` and Paddle's CDN.
25
+ */
26
+ export declare const PADDLE_JS_URL: 'https://cdn.paddle.com/paddle/v2/paddle.js';
27
+ export declare const PADDLE_CHECKOUT_SCRIPT_PATH: '/payments/checkout.js';
28
+ /** Reads the settings block, then lets Paddle.js open the `_ptxn` checkout. */
29
+ export declare const PADDLE_CHECKOUT_SCRIPT: string;
@@ -0,0 +1,27 @@
1
+ import{PaddleDriver}from"./paddle";import{PaymentProviderError}from"./types";export const PADDLE_JS_URL="https://cdn.paddle.com/paddle/v2/paddle.js",PADDLE_CHECKOUT_SCRIPT_PATH="/payments/checkout.js";const TRANSACTION_ID=/^txn_[a-z\d]{26}$/;export const PADDLE_CHECKOUT_SCRIPT=`(function () {
2
+ var block = document.getElementById('stacks-paddle-checkout')
3
+ if (!block || !window.Paddle) return
4
+ var settings = JSON.parse(block.textContent || '{}')
5
+ if (settings.environment === 'sandbox') window.Paddle.Environment.set('sandbox')
6
+ var checkout = { displayMode: 'overlay' }
7
+ if (settings.successUrl) checkout.successUrl = settings.successUrl
8
+ window.Paddle.Initialize({ token: settings.token, checkout: { settings: checkout } })
9
+ })()
10
+ `;function scriptJson(value){return JSON.stringify(value).replace(/</g,"\\u003c").replace(/>/g,"\\u003e").replace(/&/g,"\\u0026")}export function paddleCheckoutPage(settings){return`<!DOCTYPE html>
11
+ <html lang="en">
12
+ <head>
13
+ <meta charset="utf-8">
14
+ <meta name="viewport" content="width=device-width, initial-scale=1">
15
+ <meta name="robots" content="noindex">
16
+ <title>Checkout</title>
17
+ <style>body{margin:0;min-height:100vh;display:grid;place-items:center;font-family:system-ui,sans-serif;color:#4b5563}</style>
18
+ </head>
19
+ <body>
20
+ <p>Opening checkout...</p>
21
+ <noscript><p>Checkout needs JavaScript. Turn it on and reload this page.</p></noscript>
22
+ <script id="stacks-paddle-checkout" type="application/json">${scriptJson(settings)}</script>
23
+ <script src="${PADDLE_JS_URL}"></script>
24
+ <script src="${PADDLE_CHECKOUT_SCRIPT_PATH}"></script>
25
+ </body>
26
+ </html>
27
+ `}function text(status,message){return new Response(message,{status,headers:{"Content-Type":"text/plain; charset=utf-8","Cache-Control":"no-store"}})}export async function servePaddleCheckout(request,driver){let payments=driver;if(!payments){const{paymentDriver}=await import("./index");payments=paymentDriver()}if(!(payments instanceof PaddleDriver))return text(404,"Not found.");const token=payments.clientToken;if(!token)return text(503,"Checkout is not set up: set PADDLE_CLIENT_TOKEN, a client-side token from Paddle.");const transactionId=new URL(request.url).searchParams.get("_ptxn")??"";if(!TRANSACTION_ID.test(transactionId))return text(400,"This link does not name a checkout.");let successUrl;try{successUrl=await payments.checkoutSuccessUrl(transactionId)}catch(error){if(error instanceof PaymentProviderError)return text(error.status===404?404:502,error.status===404?"This checkout does not exist.":"Checkout is unavailable right now. Try again in a moment.");if(error instanceof TypeError)return text(400,"This checkout cannot be opened here.");throw error}return new Response(paddleCheckoutPage({environment:payments.environment,token,successUrl}),{headers:{"Content-Type":"text/html; charset=utf-8","Cache-Control":"no-store"}})}export function servePaddleCheckoutScript(){return new Response(PADDLE_CHECKOUT_SCRIPT,{headers:{"Content-Type":"text/javascript; charset=utf-8","Cache-Control":"public, max-age=3600"}})}
@@ -0,0 +1,82 @@
1
+ import type { CheckoutRequest, CheckoutSession, CreatePaymentOptions, Customer, Money, Payer, PaymentDriver, PaymentEvent, PaymentResult, RefundOptions, RefundResult, RefundStatus, StoredPaymentMethod, SubscriptionSummary, WebhookRequest } from './types';
2
+ /** A Paddle adjustment's status in our terms. */
3
+ export declare function paddleRefundStatus(status: string | undefined): RefundStatus;
4
+ /** A Paddle subscription in our terms. */
5
+ export declare function summarizePaddleSubscription(subscription: PaddleSubscription): SubscriptionSummary;
6
+ /**
7
+ * A `Paddle-Signature` header: `ts=<unix seconds>;h1=<hex>`, with more than
8
+ * one `h1` while Paddle rotates a secret.
9
+ */
10
+ export declare function parsePaddleSignature(header: string): { timestamp: number, signatures: string[] } | null;
11
+ /** HMAC-SHA256 of `<ts>:<raw body>` under the secret key, hex, as Paddle signs a webhook. */
12
+ export declare function paddleSignature(timestamp: number, payload: string, secret: string): string;
13
+ export declare const PADDLE_API_VERSION: '1';
14
+ /** The transaction custom_data key the checkout page reads its success page from. */
15
+ export declare const PADDLE_SUCCESS_URL_KEY: 'stacks_success_url';
16
+ /**
17
+ * The Paddle Billing driver (stacksjs/stacks#665), on API version 1.
18
+ *
19
+ * Paddle is a merchant of record, not a processor: it is the seller, it
20
+ * charges and remits the tax, and it owns refunds. That shows in the API, and
21
+ * this driver says so rather than papering over it:
22
+ *
23
+ * - Every payment is a transaction the payer completes in Paddle.js. There
24
+ * is no server-side charge of a stored card (`charge` is unsupported), and
25
+ * a subscription starts at checkout, never from the server (`subscribe`
26
+ * is unsupported; checkout in `subscription` mode with recurring prices).
27
+ * - A refund is a request: Paddle reviews it, so it comes back `pending`,
28
+ * and the outcome arrives as an `adjustment.updated` webhook.
29
+ * - Checkout is not a Paddle-hosted page. A transaction's payment link is
30
+ * your own approved page, which loads Paddle.js and opens the checkout.
31
+ * Stacks serves that page at `GET /payments/checkout`; set it as the
32
+ * default payment link in Paddle.
33
+ * - Tax is always Paddle's: amounts priced in a request are tax-inclusive
34
+ * (`tax_mode: internal`), so the payer pays exactly what the request says.
35
+ *
36
+ * Verified against Paddle's API reference and its signature-verification
37
+ * reference implementation; not yet run against a live Paddle account.
38
+ */
39
+ export declare interface PaddleConfig {
40
+ apiKey: string
41
+ environment: 'sandbox' | 'live'
42
+ webhookSecret?: string
43
+ clientToken?: string
44
+ taxCategory?: string
45
+ webhookTolerance?: number
46
+ appUrl?: string
47
+ }
48
+ declare interface PaddlePrice {
49
+ id: string
50
+ name?: string | null
51
+ description?: string
52
+ unit_price?: { amount: string, currency_code: string }
53
+ billing_cycle?: { interval: string, frequency: number } | null
54
+ }
55
+ declare interface PaddleSubscription {
56
+ id: string
57
+ status: string
58
+ current_billing_period?: { starts_at: string, ends_at: string } | null
59
+ scheduled_change?: { action: string, effective_at: string } | null
60
+ items?: Array<{ price?: PaddlePrice, quantity?: number }>
61
+ }
62
+ export declare class PaddleDriver implements PaymentDriver {
63
+ readonly name: string;
64
+ readonly capabilities: unknown;
65
+ constructor(config: PaddleConfig, options?: { fetch?: typeof fetch, now?: () => number });
66
+ get environment(): 'sandbox' | 'live';
67
+ get clientToken(): string | undefined;
68
+ get baseUrl(): string;
69
+ customer(payer: Payer): Promise<Customer>;
70
+ charge(): Promise<PaymentResult>;
71
+ createPayment(payer: Payer, amount: Money, options?: CreatePaymentOptions): Promise<PaymentResult>;
72
+ refund(paymentId: string, options?: RefundOptions): Promise<RefundResult>;
73
+ checkout(payer: Payer, request: CheckoutRequest): Promise<CheckoutSession>;
74
+ checkoutSuccessUrl(transactionId: string): Promise<string | null>;
75
+ paymentMethods(payer: Payer): Promise<StoredPaymentMethod[]>;
76
+ removePaymentMethod(payer: Payer, paymentMethodId: string): Promise<void>;
77
+ subscribe(): Promise<SubscriptionSummary>;
78
+ cancelSubscription(subscriptionId: string, options?: { atPeriodEnd?: boolean }): Promise<SubscriptionSummary>;
79
+ subscriptions(payer: Payer): Promise<SubscriptionSummary[]>;
80
+ verifyWebhook(request: WebhookRequest): Promise<PaymentEvent[]>;
81
+ acknowledgeWebhook(): Response;
82
+ }
@@ -0,0 +1 @@
1
+ import{createHmac,timingSafeEqual}from"node:crypto";import{assertMoney,headerOf,PaymentProviderError,PaymentUnsupportedError,WebhookNotConfiguredError,WebhookSignatureError}from"./types";export const PADDLE_API_VERSION="1";const CAPABILITIES=new Set(["customers","createPayment","refund","checkout","paymentMethods","subscriptions","webhooks"]);export const PADDLE_SUCCESS_URL_KEY="stacks_success_url";const TRANSACTION_ID=/^txn_[a-z\d]{26}$/,CUSTOMER_ID_IN_DETAIL=/ctm_[a-z\d]{26}/;export function paddleRefundStatus(status){switch(status){case"approved":return"succeeded";case"rejected":return"failed";case"reversed":return"canceled";default:return"pending"}}const SUBSCRIPTION_STATUSES=new Set(["active","trialing","past_due","paused","canceled"]),INTERVALS=new Set(["day","week","month","year"]);export function summarizePaddleSubscription(subscription){const price=subscription.items?.[0]?.price,interval=price?.billing_cycle?.interval;return{id:subscription.id,status:SUBSCRIPTION_STATUSES.has(subscription.status)?subscription.status:"unknown",price:price?{id:price.id,name:price.name??price.description??null,amount:price.unit_price?{amount:Number(price.unit_price.amount),currency:price.unit_price.currency_code.toLowerCase()}:null,interval:interval&&INTERVALS.has(interval)?interval:null}:null,currentPeriodEnd:subscription.current_billing_period?.ends_at?new Date(subscription.current_billing_period.ends_at):null,cancelAtPeriodEnd:subscription.scheduled_change?.action==="cancel",raw:subscription}}export function parsePaddleSignature(header){let timestamp=null;const signatures=[];for(const part of header.split(";")){const separator=part.indexOf("=");if(separator===-1)continue;const key=part.slice(0,separator).trim(),value=part.slice(separator+1).trim();if(key==="ts"&&/^\d+$/.test(value))timestamp=Number(value);else if(key==="h1"&&value)signatures.push(value)}return timestamp===null||signatures.length===0?null:{timestamp,signatures}}export function paddleSignature(timestamp,payload,secret){return createHmac("sha256",secret).update(`${timestamp}:${payload}`,"utf8").digest("hex")}export class PaddleDriver{name="paddle";capabilities=CAPABILITIES;config;fetch;now;constructor(config,options={}){if(!config.apiKey)throw Error("The Paddle payment driver needs an API key: set PADDLE_API_KEY or payment.paddle.apiKey.");const keyEnvironment=config.apiKey.startsWith("pdl_live_")?"live":config.apiKey.startsWith("pdl_sdbx_")?"sandbox":null;if(keyEnvironment&&keyEnvironment!==config.environment)throw Error(`The Paddle API key is a ${keyEnvironment} key, but payment.paddle.environment is ${config.environment}.`);this.config=config;this.fetch=options.fetch??fetch;this.now=options.now??Date.now}get environment(){return this.config.environment}get clientToken(){return this.config.clientToken||void 0}get baseUrl(){return this.config.environment==="live"?"https://api.paddle.com":"https://sandbox-api.paddle.com"}async request(method,path,options={}){const url=new URL(`${this.baseUrl}${path}`);for(const[key,value]of Object.entries(options.query??{}))url.searchParams.set(key,value);const headers={Authorization:`Bearer ${this.config.apiKey}`,"Paddle-Version":PADDLE_API_VERSION,Accept:"application/json"};if(options.body!==void 0)headers["Content-Type"]="application/json";const response=await this.fetch(url,{method,headers,body:options.body===void 0?void 0:JSON.stringify(options.body)});if(response.status===204)return;const text=await response.text(),body=text?JSON.parse(text):void 0;if(!response.ok){const error=body?.error;throw new PaymentProviderError("paddle",response.status,error?.detail??response.statusText,error?.code??null)}return body.data}assertReturnUrl(url,label){let target;try{target=new URL(url)}catch{throw TypeError(`${label} is not a valid URL: ${url}`)}if(!this.config.appUrl)return;const app=new URL(/^https?:\/\//.test(this.config.appUrl)?this.config.appUrl:`https://${this.config.appUrl}`);if(target.origin!==app.origin)throw TypeError(`${label} (${target.origin}) is not on the application's origin (${app.origin})`)}pricedLine(name,amount,quantity){assertMoney(amount);return{quantity,price:{description:name.length>=2?name:`${name} `.padEnd(2," "),name,tax_mode:"internal",unit_price:{amount:String(amount.amount),currency_code:amount.currency.toUpperCase()},product:{name,tax_category:this.config.taxCategory??"standard"}}}}async customer(payer){if(!payer.email)throw TypeError("A Paddle customer is identified by email: the payer has none.");const found=await this.request("GET","/customers",{query:{email:payer.email}});if(found?.[0])return{id:found[0].id,email:found[0].email,name:found[0].name??null};try{const created=await this.request("POST","/customers",{body:{email:payer.email,...payer.name?{name:payer.name}:{},custom_data:{stacks_user_id:String(payer.id)}}});return{id:created.id,email:created.email,name:created.name??null}}catch(error){const existing=error instanceof PaymentProviderError&&error.code==="customer_already_exists"?CUSTOMER_ID_IN_DETAIL.exec(error.message)?.[0]:void 0;if(!existing)throw error;const customer=await this.request("GET",`/customers/${existing}`);return{id:customer.id,email:customer.email,name:customer.name??null}}}async charge(){throw new PaymentUnsupportedError("paddle","charge","Paddle takes every payment through its checkout; open one with createPayment or checkout")}async createPayment(payer,amount,options={}){if(options.returnUrl)this.assertReturnUrl(options.returnUrl,"returnUrl");const customer=await this.customer(payer),transaction=await this.request("POST","/transactions",{body:{items:[this.pricedLine(options.description??"Payment",amount,1)],customer_id:customer.id,currency_code:amount.currency.toUpperCase(),collection_mode:"automatic",custom_data:{...options.metadata,...options.reference?{reference:options.reference}:{}}}});return{id:transaction.id,status:"requires_payment_method",amount:{amount:amount.amount,currency:amount.currency.toLowerCase()},clientConfirmation:{provider:"paddle",transactionId:transaction.id,...options.returnUrl?{successUrl:options.returnUrl}:{}},raw:transaction}}async refund(paymentId,options={}){const reason=options.reason??"requested_by_customer";let body={action:"refund",transaction_id:paymentId,reason,type:"full"};if(options.amount){assertMoney(options.amount,"refund amount");const transaction=await this.request("GET",`/transactions/${encodeURIComponent(paymentId)}`),currency=(transaction.details?.totals?.currency_code??transaction.currency_code??"").toLowerCase();if(currency&&currency!==options.amount.currency.toLowerCase())throw TypeError(`The refund is in ${options.amount.currency.toUpperCase()}, but the transaction is in ${currency.toUpperCase()}.`);const items=[];let left=options.amount.amount;for(const line of transaction.details?.line_items??[]){if(left<=0)break;const total=Number(line.totals?.total??0);if(total<=0)continue;const take=Math.min(left,total);items.push(take===total?{item_id:line.id,type:"full"}:{item_id:line.id,type:"partial",amount:String(take)});left-=take}if(left>0)throw TypeError(`The refund of ${options.amount.amount} is more than the transaction's ${options.amount.amount-left}.`);body={action:"refund",transaction_id:paymentId,reason,type:"partial",tax_mode:"internal",items}}const adjustment=await this.request("POST","/adjustments",{body}),total=adjustment.totals?.total,currency=adjustment.totals?.currency_code??adjustment.currency_code;return{id:adjustment.id,paymentId,status:paddleRefundStatus(adjustment.status),amount:total!==void 0&&currency?{amount:Number(total),currency:currency.toLowerCase()}:null,raw:adjustment}}async checkout(payer,request){if(request.mode==="setup")throw new PaymentUnsupportedError("paddle","setup checkout","Paddle saves a payment method while taking a payment, never on its own");if(request.cancelUrl&&request.cancelUrl!==request.successUrl)throw new PaymentUnsupportedError("paddle","a separate cancelUrl","Paddle checkout opens over your page, and closing it leaves the payer there");if(request.allowPromotionCodes)throw new PaymentUnsupportedError("paddle","promotion codes","whether the discount field shows is a Paddle checkout setting, not a per-checkout option");if(request.trialDays)throw new PaymentUnsupportedError("paddle","a checkout trial","a Paddle trial is part of the price: set its trial period on the price");this.assertReturnUrl(request.successUrl,"successUrl");const items=request.lines.map((line)=>{if("price"in line)return{price_id:line.price,quantity:line.quantity};if(request.mode==="subscription")throw TypeError("A Paddle subscription checkout needs catalog prices (pri_...) with a billing cycle: a line priced here has none.");if(!request.currency)throw TypeError("A checkout line priced with unitAmount needs the checkout's currency.");return this.pricedLine(line.name,{amount:line.unitAmount,currency:request.currency},line.quantity)}),customer=await this.customer(payer),transaction=await this.request("POST","/transactions",{body:{items,customer_id:customer.id,...request.currency?{currency_code:request.currency.toUpperCase()}:{},collection_mode:"automatic",custom_data:{...request.metadata,...request.reference?{reference:request.reference}:{},[PADDLE_SUCCESS_URL_KEY]:request.successUrl},checkout:{url:null}}});if(!transaction.checkout?.url)throw Error("Paddle returned no payment link: set a default payment link in Paddle (Checkout > Checkout settings), pointing at /payments/checkout.");return{id:transaction.id,url:transaction.checkout.url,expiresAt:null,raw:transaction}}async checkoutSuccessUrl(transactionId){if(!TRANSACTION_ID.test(transactionId))throw TypeError("Not a Paddle transaction id.");const successUrl=(await this.request("GET",`/transactions/${transactionId}`)).custom_data?.[PADDLE_SUCCESS_URL_KEY];if(typeof successUrl!=="string")return null;this.assertReturnUrl(successUrl,"successUrl");return successUrl}async paymentMethods(payer){const customer=await this.customer(payer);return(await this.request("GET",`/customers/${customer.id}/payment-methods`)??[]).map((method)=>({id:method.id,type:method.type,brand:method.card?.type??null,last4:method.card?.last4??null,expMonth:method.card?.expiry_month??null,expYear:method.card?.expiry_year??null,isDefault:!1,raw:method}))}async removePaymentMethod(payer,paymentMethodId){const customer=await this.customer(payer);await this.request("DELETE",`/customers/${customer.id}/payment-methods/${encodeURIComponent(paymentMethodId)}`)}async subscribe(){throw new PaymentUnsupportedError("paddle","subscribe",'a Paddle subscription starts at checkout: use checkout with mode "subscription"')}async cancelSubscription(subscriptionId,options={}){const subscription=await this.request("POST",`/subscriptions/${encodeURIComponent(subscriptionId)}/cancel`,{body:{effective_from:options.atPeriodEnd?"next_billing_period":"immediately"}});return summarizePaddleSubscription(subscription)}async subscriptions(payer){const customer=await this.customer(payer);return(await this.request("GET","/subscriptions",{query:{customer_id:customer.id}})??[]).map(summarizePaddleSubscription)}async verifyWebhook(request){if(!this.config.webhookSecret)throw new WebhookNotConfiguredError("paddle","Paddle webhooks cannot be verified without the endpoint secret key: set PADDLE_WEBHOOK_SECRET or payment.paddle.webhookSecret.");const header=headerOf(request.headers,"paddle-signature");if(!header)throw new WebhookSignatureError("paddle","there is no Paddle-Signature header");const signature=parsePaddleSignature(header);if(!signature)throw new WebhookSignatureError("paddle","the Paddle-Signature header is not ts=...;h1=...");const tolerance=this.config.webhookTolerance??5;if(Math.abs(this.now()/1000-signature.timestamp)>tolerance)throw new WebhookSignatureError("paddle",`the signature is more than ${tolerance} seconds from now`);const expected=Buffer.from(paddleSignature(signature.timestamp,request.payload,this.config.webhookSecret));if(!signature.signatures.some((given)=>{const actual=Buffer.from(given);return actual.length===expected.length&&timingSafeEqual(actual,expected)}))throw new WebhookSignatureError("paddle","it is signed with another key");let notification;try{notification=JSON.parse(request.payload)}catch{throw new WebhookSignatureError("paddle","the body is not JSON")}return[this.translate(notification)]}translate(notification){const data=notification.data??{},base={provider:this.name,id:notification.event_id,providerType:notification.event_type,merchantReference:typeof data.custom_data?.reference==="string"?data.custom_data.reference:null,raw:notification};if(notification.event_type.startsWith("transaction.")){const totals=data.details?.totals,amount=totals?.grand_total!==void 0&&(totals.currency_code??data.currency_code)?{amount:Number(totals.grand_total),currency:String(totals.currency_code??data.currency_code).toLowerCase()}:null,type=notification.event_type==="transaction.completed"?"payment.succeeded":notification.event_type==="transaction.payment_failed"?"payment.failed":notification.event_type==="transaction.canceled"?"payment.canceled":"unknown",lastAttempt=Array.isArray(data.payments)?data.payments.at(-1):void 0;return{...base,type,reference:typeof data.id==="string"?data.id:null,amount,reason:type==="payment.failed"?lastAttempt?.error_code??null:null}}if(notification.event_type==="adjustment.created"||notification.event_type==="adjustment.updated"){const adjustment=data,settled=adjustment.action==="refund"&&(adjustment.status==="approved"||adjustment.status==="rejected"),currency=adjustment.totals?.currency_code??adjustment.currency_code;return{...base,id:settled?`adjustment:${adjustment.id}:${adjustment.status}`:notification.event_id,type:settled?adjustment.status==="approved"?"refund.succeeded":"refund.failed":"unknown",reference:adjustment.transaction_id??null,amount:adjustment.totals?.total!==void 0&&currency?{amount:Number(adjustment.totals.total),currency:currency.toLowerCase()}:null,reason:adjustment.status==="rejected"?adjustment.reason??null:null}}return{...base,type:{"subscription.created":"subscription.created","subscription.canceled":"subscription.canceled","subscription.updated":"subscription.updated","subscription.activated":"subscription.updated","subscription.trialing":"subscription.updated","subscription.past_due":"subscription.updated","subscription.paused":"subscription.updated","subscription.resumed":"subscription.updated"}[notification.event_type]??"unknown",reference:typeof data.id==="string"?data.id:null,amount:null,reason:null}}acknowledgeWebhook(){return new Response(null,{status:200})}}
@@ -163,6 +163,12 @@ export type PaymentStatus = 'succeeded' | 'processing' | 'requires_action' | 're
163
163
  */
164
164
  export type ClientConfirmation = | { provider: 'stripe', clientSecret: string }
165
165
  | { provider: 'adyen', sessionId: string, sessionData: string }
166
+ /**
167
+ * Open Paddle.js on the transaction: `Paddle.Checkout.open({ transactionId,
168
+ * settings: { successUrl } })`. Paddle takes the success page in the
169
+ * browser, so it travels with the confirmation.
170
+ */
171
+ | { provider: 'paddle', transactionId: string, successUrl?: string }
166
172
  export type RefundStatus = 'pending' | 'succeeded' | 'failed' | 'canceled';
167
173
  /** A line on a checkout: priced here, or a price the provider's catalog holds. */
168
174
  export type CheckoutLine = | { name: string, unitAmount: number, quantity: number }
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.85",
5
+ "version": "0.75.86",
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.85",
64
- "@stacksjs/database": "0.75.85",
65
- "@stacksjs/error-handling": "0.75.85",
66
- "@stacksjs/logging": "0.75.85",
67
- "@stacksjs/orm": "0.75.85",
68
- "@stacksjs/types": "0.75.85"
63
+ "@stacksjs/config": "0.75.86",
64
+ "@stacksjs/database": "0.75.86",
65
+ "@stacksjs/error-handling": "0.75.86",
66
+ "@stacksjs/logging": "0.75.86",
67
+ "@stacksjs/orm": "0.75.86",
68
+ "@stacksjs/types": "0.75.86"
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.85",
83
+ "@stacksjs/utils": "0.75.86",
84
84
  "@stripe/stripe-js": "^10.0.0",
85
85
  "better-dx": "^0.2.24",
86
86
  "stripe": "23.0.0"