@stacksjs/payments 0.75.168 → 0.75.170

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.
@@ -0,0 +1,129 @@
1
+ export declare function isPaymentMethodType(value: unknown): value is PaymentMethodType;
2
+ /** Whether a payment method's outcome arrives later, by webhook (a bank debit). */
3
+ export declare function isDelayedPaymentMethod(type: unknown): boolean;
4
+ /**
5
+ * A stored list of payment methods (an array, or a comma-separated string as a
6
+ * text column keeps it), known types only, each once, in the order given.
7
+ * Empty or unreadable is cards only.
8
+ */
9
+ export declare function parsePaymentMethods(value: unknown): PaymentMethodType[];
10
+ /** Whether a method can be charged in `currency` (ISO 4217, any case). */
11
+ export declare function methodSupportsCurrency(type: PaymentMethodType, currency: string): boolean;
12
+ /**
13
+ * The methods a checkout in `currency` offers, from those the merchant allows:
14
+ * a method the currency cannot use (SEPA outside euros) is left out, and a
15
+ * list left empty falls back to cards. This is Stripe's `allowed_payment_method_types`.
16
+ */
17
+ export declare function paymentMethodsFor(allowed: unknown, currency: string): PaymentMethodType[];
18
+ /** The region of a BCP 47 language tag, upper case: 'de-AT' is 'AT', plain 'de' is null. */
19
+ export declare function regionOfLocale(tag: unknown): string | null;
20
+ /**
21
+ * The currency a payer in `country` (ISO 3166 alpha-2) pays in, if the app
22
+ * sells in it, else `fallback`: euros across the euro area, pounds in the UK,
23
+ * and so on.
24
+ */
25
+ export declare function currencyForCountry(country: unknown, supported: readonly string[], fallback: string): string;
26
+ /**
27
+ * The debit behind a charge (`payment_method_details`) or a payment method
28
+ * (its own `sepa_debit`, `bacs_debit`... block): the method, the account's
29
+ * last four digits and the mandate. Null for a card or anything else that is
30
+ * not a bank debit.
31
+ */
32
+ export declare function debitDetailsOf(source: any): DebitDetails | null;
33
+ /** What a Stripe Mandate says about itself: its reference, page and status. */
34
+ export declare function mandateReferenceOf(mandate: any): MandateReference | null;
35
+ /** The mandate's reference and status, from Stripe. `stripe` is the client (`@stacksjs/payments`' `stripe`). */
36
+ export declare function retrieveDebitMandate(stripe: { mandates: { retrieve: (id: string) => Promise<any> } }, mandateId: string): Promise<MandateReference | null>;
37
+ /**
38
+ * A Stripe event, read as a step in a bank debit's life; null when it is not
39
+ * one (a card charge, an unrelated event). Covers Checkout's asynchronous
40
+ * results, the charge's `pending` / `succeeded` / `failed`, a dispute (the
41
+ * bank's return), and a mandate that went inactive. Invoices are left to the
42
+ * caller: `invoice.paid` and `invoice.payment_failed` mean the same for a
43
+ * card and a debit.
44
+ */
45
+ export declare function debitEventOf(event: { type?: string, data?: { object?: any } } | null | undefined): DebitEvent | null;
46
+ /**
47
+ * What to ask of the payer after a failed debit, by Stripe's failure code:
48
+ * `retry` (the account is fine, it just could not pay: insufficient funds),
49
+ * `update` (new bank details or a new mandate are needed), or `contact` (the
50
+ * bank gave no reason, or one only a conversation can settle).
51
+ */
52
+ export declare function debitFailureNextStep(code: unknown): 'retry' | 'update' | 'contact';
53
+ /**
54
+ * Whether today is the day to send the notice for a debit on `debitDate`
55
+ * (yyyy-mm-dd): from `leadDays` before it, until the debit. `leadDays` is
56
+ * raised to the method's `noticeDays` so the notice is never planned later
57
+ * than the rules allow.
58
+ */
59
+ export declare function isDebitNoticeDue(input: { debitDate: string, today: string, leadDays: number, method?: PaymentMethodType }): boolean;
60
+ /** Whether a notice sent on `sentOn` came early enough for a debit on `debitDate`. */
61
+ export declare function isDebitNoticeTimely(input: { sentOn: string, debitDate: string, method: PaymentMethodType }): boolean;
62
+ export declare const PAYMENT_METHODS: Readonly<Record<PaymentMethodType, PaymentMethodInfo>>;
63
+ /** Countries that pay in euros: the euro area (Bulgaria from 2026) and the states that use it by agreement. */
64
+ export declare const EURO_COUNTRIES: readonly string[];
65
+ /** Countries in the SEPA scheme, whose bank accounts SEPA Direct Debit reaches. */
66
+ export declare const SEPA_COUNTRIES: readonly string[];
67
+ export declare interface PaymentMethodInfo {
68
+ currencies: readonly string[] | null
69
+ delayed: boolean
70
+ mandate: boolean
71
+ noticeDays: number | null
72
+ }
73
+ export declare interface DebitDetails {
74
+ type: PaymentMethodType
75
+ mandateId: string | null
76
+ last4: string | null
77
+ bankCode: string | null
78
+ country: string | null
79
+ expectedDebitDate: string | null
80
+ }
81
+ export declare interface MandateReference {
82
+ id: string
83
+ reference: string | null
84
+ url: string | null
85
+ status: string
86
+ type: string | null
87
+ }
88
+ export declare interface DebitEvent {
89
+ outcome: DebitOutcome
90
+ method: PaymentMethodType | null
91
+ paymentIntentId: string | null
92
+ chargeId: string | null
93
+ checkoutSessionId: string | null
94
+ mandateId: string | null
95
+ amount: number | null
96
+ currency: string | null
97
+ failureCode: string | null
98
+ failureMessage: string | null
99
+ details: DebitDetails | null
100
+ metadata: Record<string, string>
101
+ }
102
+ /**
103
+ * Bank debits through Stripe: SEPA Direct Debit, Bacs, ACH and BECS next to
104
+ * cards. Which methods a checkout may offer in a currency, which currency a
105
+ * payer's country pays in, what a webhook event means for a debit (still
106
+ * processing, collected, failed, or returned by the bank), and the mandate a
107
+ * debit runs under.
108
+ *
109
+ * Pure: no Stripe client and no database, so a browser bundle or a unit test
110
+ * can import it (`@stacksjs/payments/direct-debit`). The one function that
111
+ * talks to Stripe, `retrieveDebitMandate`, takes the client as an argument.
112
+ *
113
+ * A debit is a delayed-notification payment: Checkout completes with
114
+ * `payment_status: 'unpaid'`, the charge stays `pending` for days, and only
115
+ * `checkout.session.async_payment_succeeded`, `invoice.paid` or
116
+ * `charge.succeeded` says the money arrived. A subscription can turn `active`
117
+ * while its first debit is still processing, so never grant what was bought
118
+ * from the subscription status alone. Weeks later the payer's bank can still
119
+ * take the money back (`charge.dispute.created`): for SEPA, a "no questions
120
+ * asked" refund for eight weeks, and final.
121
+ */
122
+ export type PaymentMethodType = 'card' | 'sepa_debit' | 'bacs_debit' | 'us_bank_account' | 'au_becs_debit';
123
+ /**
124
+ * What a debit event means: the debit was submitted and is `processing`, it
125
+ * `succeeded`, it `failed` before the money arrived, the bank `returned` it
126
+ * after it had succeeded (a dispute, which for SEPA is final), or the mandate
127
+ * stopped working (`mandate_inactive`).
128
+ */
129
+ export type DebitOutcome = 'processing' | 'succeeded' | 'failed' | 'returned' | 'mandate_inactive';
@@ -0,0 +1 @@
1
+ export const PAYMENT_METHODS={card:{currencies:null,delayed:!1,mandate:!1,noticeDays:null},sepa_debit:{currencies:["eur"],delayed:!0,mandate:!0,noticeDays:2},bacs_debit:{currencies:["gbp"],delayed:!0,mandate:!0,noticeDays:null},us_bank_account:{currencies:["usd"],delayed:!0,mandate:!0,noticeDays:null},au_becs_debit:{currencies:["aud"],delayed:!0,mandate:!0,noticeDays:null}};export function isPaymentMethodType(value){return typeof value==="string"&&Object.prototype.hasOwnProperty.call(PAYMENT_METHODS,value)}export function isDelayedPaymentMethod(type){return isPaymentMethodType(type)&&PAYMENT_METHODS[type].delayed}export function parsePaymentMethods(value){const items=Array.isArray(value)?value:typeof value==="string"?value.split(","):[],types=[];for(const item of items){const type=typeof item==="string"?item.trim().toLowerCase():item;if(isPaymentMethodType(type)&&!types.includes(type))types.push(type)}return types.length?types:["card"]}export function methodSupportsCurrency(type,currency){const currencies=PAYMENT_METHODS[type].currencies;return currencies===null||currencies.includes(String(currency||"").toLowerCase())}export function paymentMethodsFor(allowed,currency){const usable=parsePaymentMethods(allowed).filter((type)=>methodSupportsCurrency(type,currency));return usable.length?usable:["card"]}export const EURO_COUNTRIES=["AT","BE","BG","CY","DE","EE","ES","FI","FR","GR","HR","IE","IT","LT","LU","LV","MT","NL","PT","SI","SK","AD","MC","SM","VA","ME","XK"],SEPA_COUNTRIES=["AT","BE","BG","CY","CZ","DE","DK","EE","ES","FI","FR","GR","HR","HU","IE","IT","LT","LU","LV","MT","NL","PL","PT","RO","SE","SI","SK","IS","LI","NO","CH","GB","AD","MC","SM","VA","GI"];const COUNTRY_CURRENCIES={US:"usd",GB:"gbp",CH:"chf",LI:"chf",AU:"aud",CA:"cad",NZ:"nzd",SE:"sek",DK:"dkk",NO:"nok",PL:"pln",CZ:"czk",JP:"jpy"};export function regionOfLocale(tag){if(typeof tag!=="string")return null;const parts=tag.trim().replace(/_/g,"-").split("-");for(const part of parts.slice(1)){if(/^[a-z]{2}$/i.test(part))return part.toUpperCase();if(/^\d{3}$/.test(part))return null}return null}export function currencyForCountry(country,supported,fallback){const code=typeof country==="string"?country.trim().toUpperCase():"",currency=EURO_COUNTRIES.includes(code)?"eur":COUNTRY_CURRENCIES[code],offered=supported.map((value)=>value.toLowerCase());return currency&&offered.includes(currency)?currency:fallback.toLowerCase()}function ymdOf(value){if(typeof value==="number"&&Number.isFinite(value)&&value>0)return new Date(value*1000).toISOString().slice(0,10);if(typeof value==="string"&&/^\d{4}-\d{2}-\d{2}/.test(value))return value.slice(0,10);return null}export function debitDetailsOf(source){const details=source?.payment_method_details??source,type=details?.type;if(!isDelayedPaymentMethod(type))return null;const block=details[type]??{},str=(value)=>typeof value==="string"&&value?value:null;return{type,mandateId:str(typeof block.mandate==="object"?block.mandate?.id:block.mandate),last4:str(block.last4),bankCode:str(block.bank_code??block.sort_code??block.routing_number??block.bsb_number),country:str(block.country),expectedDebitDate:ymdOf(block.expected_debit_date)}}export function mandateReferenceOf(mandate){if(!mandate?.id)return null;const type=mandate.payment_method_details?.type??null,block=type?mandate.payment_method_details?.[type]??{}:{};return{id:String(mandate.id),reference:block.reference?String(block.reference):null,url:block.url?String(block.url):null,status:String(mandate.status||"active"),type:type?String(type):null}}export async function retrieveDebitMandate(stripe,mandateId){if(!mandateId)return null;return mandateReferenceOf(await stripe.mandates.retrieve(mandateId))}const idOf=(value)=>value?String(typeof value==="object"?value.id:value):null;export function debitEventOf(event){const type=String(event?.type||""),object=event?.data?.object??{},base=(outcome,extra={})=>({outcome,method:null,paymentIntentId:null,chargeId:null,checkoutSessionId:null,mandateId:null,amount:null,currency:null,failureCode:null,failureMessage:null,details:null,metadata:object?.metadata&&typeof object.metadata==="object"?{...object.metadata}:{},...extra});if(type.startsWith("checkout.session.")){const session={checkoutSessionId:idOf(object.id),paymentIntentId:idOf(object.payment_intent),amount:Number.isFinite(object.amount_total)?Number(object.amount_total):null,currency:object.currency?String(object.currency).toLowerCase():null};if(type==="checkout.session.completed"&&object.payment_status==="unpaid")return base("processing",session);if(type==="checkout.session.async_payment_succeeded")return base("succeeded",session);if(type==="checkout.session.async_payment_failed")return base("failed",session);return null}if(type==="charge.pending"||type==="charge.succeeded"||type==="charge.failed"){const details=debitDetailsOf(object);if(!details)return null;const outcome=type==="charge.pending"?"processing":type==="charge.succeeded"?"succeeded":"failed";return base(outcome,{method:details.type,paymentIntentId:idOf(object.payment_intent),chargeId:idOf(object.id),mandateId:details.mandateId,amount:Number.isFinite(object.amount)?Number(object.amount):null,currency:object.currency?String(object.currency).toLowerCase():null,failureCode:outcome==="failed"&&object.failure_code?String(object.failure_code):null,failureMessage:outcome==="failed"&&object.failure_message?String(object.failure_message):null,details})}if(type==="charge.dispute.created"){const method=object.payment_method_details?.type;if(method==="card")return null;return base("returned",{method:isDelayedPaymentMethod(method)?method:null,paymentIntentId:idOf(object.payment_intent),chargeId:idOf(object.charge),amount:Number.isFinite(object.amount)?Number(object.amount):null,currency:object.currency?String(object.currency).toLowerCase():null,failureCode:object.reason?String(object.reason):null})}if(type==="mandate.updated"){if(object.status!=="inactive")return null;const method=object.payment_method_details?.type;return base("mandate_inactive",{method:isDelayedPaymentMethod(method)?method:null,mandateId:idOf(object.id)})}return null}export function debitFailureNextStep(code){switch(code){case"insufficient_funds":return"retry";case"account_closed":case"authorization_revoked":case"debit_not_authorized":case"debit_authorization_not_match":case"incorrect_account_holder_name":case"invalid_account_number":case"branch_does_not_exist":case"bank_account_restricted":return"update";default:return"contact"}}function addDays(ymd,days){const date=new Date(`${ymd}T00:00:00Z`);date.setUTCDate(date.getUTCDate()+days);return date.toISOString().slice(0,10)}export function isDebitNoticeDue(input){const debit=ymdOf(input.debitDate),today=ymdOf(input.today);if(!debit||!today)return!1;const minimum=input.method?PAYMENT_METHODS[input.method].noticeDays??0:0,opens=addDays(debit,-Math.max(minimum,Math.floor(input.leadDays)));return today>=opens&&today<debit}export function isDebitNoticeTimely(input){const sent=ymdOf(input.sentOn),debit=ymdOf(input.debitDate);if(!sent||!debit)return!1;return sent<=addDays(debit,-(PAYMENT_METHODS[input.method].noticeDays??0))}
@@ -1 +1 @@
1
- import{createHmac,randomUUID,timingSafeEqual}from"node:crypto";import{assertMoney,PaymentProviderError,PaymentUnsupportedError,WebhookNotConfiguredError,WebhookSignatureError}from"./types";export const ADYEN_API_VERSION="v72";const CAPABILITIES=new Set(["customers","charge","createPayment","refund","checkout","paymentMethods","webhooks"]);export function adyenStatus(resultCode){switch(resultCode){case"Authorised":return"succeeded";case"Refused":case"Error":return"failed";case"Cancelled":return"canceled";case"RedirectShopper":case"ChallengeShopper":case"IdentifyShopper":case"PresentToShopper":return"requires_action";default:return"processing"}}const EVENT_TYPES={AUTHORISATION:["payment.succeeded","payment.failed"],CANCELLATION:["payment.canceled","unknown"],REFUND:["refund.succeeded","refund.failed"],REFUND_FAILED:["refund.failed","refund.failed"],CANCEL_OR_REFUND:["refund.succeeded","refund.failed"]};export function adyenSigningPayload(item){return[item.pspReference??"",item.originalReference??"",item.merchantAccountCode??"",item.merchantReference??"",item.amount?.value??"",item.amount?.currency??"",item.eventCode??"",String(item.success??"")].join(":")}export function adyenSignature(item,hmacKey){return createHmac("sha256",Buffer.from(hmacKey,"hex")).update(adyenSigningPayload(item),"utf8").digest("base64")}export class AdyenDriver{name="adyen";capabilities=CAPABILITIES;config;fetch;constructor(config,options={}){if(!config.apiKey)throw Error("The Adyen payment driver needs an API key: set ADYEN_API_KEY or payment.adyen.apiKey.");if(!config.merchantAccount)throw Error("The Adyen payment driver needs a merchant account: set ADYEN_MERCHANT_ACCOUNT or payment.adyen.merchantAccount.");if(config.environment==="live"&&!config.liveUrlPrefix)throw Error("Adyen live requests go to your account's own endpoint: set ADYEN_LIVE_URL_PREFIX or payment.adyen.liveUrlPrefix.");this.config=config;this.fetch=options.fetch??fetch}get baseUrl(){return this.config.environment==="live"?`https://${this.config.liveUrlPrefix}-checkout-live.adyenpayments.com/checkout/${ADYEN_API_VERSION}`:`https://checkout-test.adyen.com/${ADYEN_API_VERSION}`}shopperReference(payer){return`${this.config.shopperReferencePrefix??"user-"}${payer.id}`}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={"X-API-Key":this.config.apiKey,Accept:"application/json"};if(options.body!==void 0)headers["Content-Type"]="application/json";if(options.idempotencyKey)headers["Idempotency-Key"]=options.idempotencyKey;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;throw new PaymentProviderError("adyen",response.status,error?.message??response.statusText,error?.errorCode??null)}return body}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})`)}amount(money){assertMoney(money);return{value:money.amount,currency:money.currency.toUpperCase()}}async customer(payer){return{id:this.shopperReference(payer),email:payer.email??null,name:payer.name??null}}async charge(payer,amount,paymentMethod,options={}){const response=await this.request("POST","/payments",{body:{merchantAccount:this.config.merchantAccount,amount:this.amount(amount),reference:options.reference??randomUUID(),paymentMethod:{type:"scheme",storedPaymentMethodId:paymentMethod},shopperReference:this.shopperReference(payer),shopperInteraction:"ContAuth",recurringProcessingModel:"UnscheduledCardOnFile",...options.metadata?{metadata:options.metadata}:{}},idempotencyKey:options.idempotencyKey});return{id:response.pspReference,status:adyenStatus(response.resultCode),amount:{amount:amount.amount,currency:amount.currency.toLowerCase()},...response.action?.url?{redirectUrl:response.action.url}:{},...response.refusalReason?{failureReason:response.refusalReason}:{},raw:response}}async createPayment(payer,amount,options={}){if(!options.returnUrl)throw TypeError("An Adyen payment session needs a returnUrl: where the payer comes back after authenticating.");this.assertReturnUrl(options.returnUrl,"returnUrl");const session=await this.request("POST","/sessions",{body:{merchantAccount:this.config.merchantAccount,amount:this.amount(amount),reference:options.reference??randomUUID(),returnUrl:options.returnUrl,mode:"embedded",shopperReference:this.shopperReference(payer),...options.metadata?{metadata:options.metadata}:{}},idempotencyKey:options.idempotencyKey});return{id:session.id,status:"requires_payment_method",amount:{amount:amount.amount,currency:amount.currency.toLowerCase()},clientConfirmation:{provider:"adyen",sessionId:session.id,sessionData:session.sessionData},raw:session}}async refund(paymentId,options={}){const reference=randomUUID(),response=options.amount?await this.request("POST",`/payments/${encodeURIComponent(paymentId)}/refunds`,{body:{merchantAccount:this.config.merchantAccount,amount:this.amount(options.amount),reference},idempotencyKey:options.idempotencyKey}):await this.request("POST",`/payments/${encodeURIComponent(paymentId)}/reversals`,{body:{merchantAccount:this.config.merchantAccount,reference},idempotencyKey:options.idempotencyKey});return{id:response.pspReference,paymentId,status:"pending",amount:options.amount?{amount:options.amount.amount,currency:options.amount.currency.toLowerCase()}:null,raw:response}}async checkout(payer,request){if(request.trialDays&&request.mode!=="subscription")throw TypeError("trialDays applies to a subscription checkout.");if(request.mode==="subscription")throw new PaymentUnsupportedError("adyen","subscription checkout",'Adyen has no subscriptions; store a card with mode "setup" and charge it on your schedule');if(request.cancelUrl&&request.cancelUrl!==request.successUrl)throw new PaymentUnsupportedError("adyen","a separate cancelUrl","Adyen's hosted checkout returns to one URL, with the outcome in its query string");if(request.allowPromotionCodes)throw new PaymentUnsupportedError("adyen","promotion codes","Adyen has no coupons; discount the line amounts instead");if(request.automaticTax)throw new PaymentUnsupportedError("adyen","automatic tax","Adyen does not calculate tax; price each line including it");if(!request.currency)throw TypeError("An Adyen checkout needs a currency.");this.assertReturnUrl(request.successUrl,"successUrl");const lines=request.lines.map((line)=>{if("price"in line)throw new PaymentUnsupportedError("adyen","catalog prices","Adyen has no product catalog; price each line with name and unitAmount");return line}),total=lines.reduce((sum,line)=>sum+line.unitAmount*line.quantity,0),setup=request.mode==="setup",session=await this.request("POST","/sessions",{body:{merchantAccount:this.config.merchantAccount,amount:this.amount({amount:setup?0:total,currency:request.currency}),reference:request.reference??randomUUID(),returnUrl:request.successUrl,mode:"hosted",shopperReference:this.shopperReference(payer),...payer.email?{shopperEmail:payer.email}:{},...setup?{storePaymentMethodMode:"enabled",recurringProcessingModel:"UnscheduledCardOnFile"}:{},...setup?{}:{lineItems:lines.map((line)=>({description:line.name,quantity:line.quantity,amountIncludingTax:line.unitAmount}))},...request.metadata?{metadata:request.metadata}:{}}});if(!session.url)throw new PaymentProviderError("adyen",200,"the hosted session came back without a url");return{id:session.id,url:session.url,expiresAt:session.expiresAt?new Date(session.expiresAt):null,raw:session}}async paymentMethods(payer){return((await this.request("GET","/storedPaymentMethods",{query:{merchantAccount:this.config.merchantAccount,shopperReference:this.shopperReference(payer)}}))?.storedPaymentMethods??[]).map((method)=>({id:String(method.id),type:String(method.type??"scheme"),brand:method.brand??null,last4:method.lastFour??null,expMonth:method.expiryMonth?Number(method.expiryMonth):null,expYear:method.expiryYear?Number(String(method.expiryYear).length===2?`20${method.expiryYear}`:method.expiryYear):null,isDefault:!1,raw:method}))}async removePaymentMethod(payer,paymentMethodId){await this.request("DELETE",`/storedPaymentMethods/${encodeURIComponent(paymentMethodId)}`,{query:{merchantAccount:this.config.merchantAccount,shopperReference:this.shopperReference(payer)}})}async subscribe(){throw new PaymentUnsupportedError("adyen","subscriptions","charge a stored card on your own schedule with charge()")}async cancelSubscription(){throw new PaymentUnsupportedError("adyen","subscriptions")}async subscriptions(){throw new PaymentUnsupportedError("adyen","subscriptions")}async verifyWebhook(request){if(!this.config.hmacKey)throw new WebhookNotConfiguredError("adyen","Adyen webhooks cannot be verified without the HMAC key: set ADYEN_HMAC_KEY or payment.adyen.hmacKey.");let body;try{body=JSON.parse(request.payload)}catch{throw new WebhookSignatureError("adyen","the body is not JSON")}const items=(body.notificationItems??[]).map((entry)=>entry.NotificationRequestItem).filter((item)=>Boolean(item));if(items.length===0)throw new WebhookSignatureError("adyen","the delivery holds no notification items");return items.map((item)=>{const given=item.additionalData?.hmacSignature;if(!given)throw new WebhookSignatureError("adyen",`item ${item.pspReference??"?"} has no hmacSignature`);const expected=Buffer.from(adyenSignature(item,this.config.hmacKey)),actual=Buffer.from(given);if(expected.length!==actual.length||!timingSafeEqual(expected,actual))throw new WebhookSignatureError("adyen",`item ${item.pspReference??"?"} is signed with another key`);const success=String(item.success)==="true",pair=EVENT_TYPES[item.eventCode??""];return{provider:this.name,id:`${item.eventCode}:${item.pspReference}:${success}`,type:pair?pair[success?0:1]:"unknown",providerType:item.eventCode??"",reference:item.originalReference||item.pspReference||null,reason:item.reason||null,merchantReference:item.merchantReference??null,amount:item.amount?.value!==void 0&&item.amount.currency?{amount:Number(item.amount.value),currency:item.amount.currency.toLowerCase()}:null,raw:item}})}acknowledgeWebhook(){return new Response("[accepted]",{status:202,headers:{"Content-Type":"text/plain"}})}}
1
+ import{createHmac,randomUUID,timingSafeEqual}from"node:crypto";import{assertMoney,PaymentProviderError,PaymentUnsupportedError,WebhookNotConfiguredError,WebhookSignatureError}from"./types";export const ADYEN_API_VERSION="v72";const CAPABILITIES=new Set(["customers","charge","createPayment","refund","checkout","paymentMethods","webhooks"]);export function adyenStatus(resultCode){switch(resultCode){case"Authorised":return"succeeded";case"Refused":case"Error":return"failed";case"Cancelled":return"canceled";case"RedirectShopper":case"ChallengeShopper":case"IdentifyShopper":case"PresentToShopper":return"requires_action";default:return"processing"}}const ADYEN_PAYMENT_METHODS={card:"scheme",sepa_debit:"sepadirectdebit"},EVENT_TYPES={AUTHORISATION:["payment.succeeded","payment.failed"],CANCELLATION:["payment.canceled","unknown"],REFUND:["refund.succeeded","refund.failed"],REFUND_FAILED:["refund.failed","refund.failed"],CANCEL_OR_REFUND:["refund.succeeded","refund.failed"]};export function adyenSigningPayload(item){return[item.pspReference??"",item.originalReference??"",item.merchantAccountCode??"",item.merchantReference??"",item.amount?.value??"",item.amount?.currency??"",item.eventCode??"",String(item.success??"")].join(":")}export function adyenSignature(item,hmacKey){return createHmac("sha256",Buffer.from(hmacKey,"hex")).update(adyenSigningPayload(item),"utf8").digest("base64")}export class AdyenDriver{name="adyen";capabilities=CAPABILITIES;config;fetch;constructor(config,options={}){if(!config.apiKey)throw Error("The Adyen payment driver needs an API key: set ADYEN_API_KEY or payment.adyen.apiKey.");if(!config.merchantAccount)throw Error("The Adyen payment driver needs a merchant account: set ADYEN_MERCHANT_ACCOUNT or payment.adyen.merchantAccount.");if(config.environment==="live"&&!config.liveUrlPrefix)throw Error("Adyen live requests go to your account's own endpoint: set ADYEN_LIVE_URL_PREFIX or payment.adyen.liveUrlPrefix.");this.config=config;this.fetch=options.fetch??fetch}get baseUrl(){return this.config.environment==="live"?`https://${this.config.liveUrlPrefix}-checkout-live.adyenpayments.com/checkout/${ADYEN_API_VERSION}`:`https://checkout-test.adyen.com/${ADYEN_API_VERSION}`}shopperReference(payer){return`${this.config.shopperReferencePrefix??"user-"}${payer.id}`}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={"X-API-Key":this.config.apiKey,Accept:"application/json"};if(options.body!==void 0)headers["Content-Type"]="application/json";if(options.idempotencyKey)headers["Idempotency-Key"]=options.idempotencyKey;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;throw new PaymentProviderError("adyen",response.status,error?.message??response.statusText,error?.errorCode??null)}return body}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})`)}amount(money){assertMoney(money);return{value:money.amount,currency:money.currency.toUpperCase()}}async customer(payer){return{id:this.shopperReference(payer),email:payer.email??null,name:payer.name??null}}async charge(payer,amount,paymentMethod,options={}){const response=await this.request("POST","/payments",{body:{merchantAccount:this.config.merchantAccount,amount:this.amount(amount),reference:options.reference??randomUUID(),paymentMethod:{type:"scheme",storedPaymentMethodId:paymentMethod},shopperReference:this.shopperReference(payer),shopperInteraction:"ContAuth",recurringProcessingModel:"UnscheduledCardOnFile",...options.metadata?{metadata:options.metadata}:{}},idempotencyKey:options.idempotencyKey});return{id:response.pspReference,status:adyenStatus(response.resultCode),amount:{amount:amount.amount,currency:amount.currency.toLowerCase()},...response.action?.url?{redirectUrl:response.action.url}:{},...response.refusalReason?{failureReason:response.refusalReason}:{},raw:response}}async createPayment(payer,amount,options={}){if(!options.returnUrl)throw TypeError("An Adyen payment session needs a returnUrl: where the payer comes back after authenticating.");this.assertReturnUrl(options.returnUrl,"returnUrl");const session=await this.request("POST","/sessions",{body:{merchantAccount:this.config.merchantAccount,amount:this.amount(amount),reference:options.reference??randomUUID(),returnUrl:options.returnUrl,mode:"embedded",shopperReference:this.shopperReference(payer),...options.metadata?{metadata:options.metadata}:{}},idempotencyKey:options.idempotencyKey});return{id:session.id,status:"requires_payment_method",amount:{amount:amount.amount,currency:amount.currency.toLowerCase()},clientConfirmation:{provider:"adyen",sessionId:session.id,sessionData:session.sessionData},raw:session}}async refund(paymentId,options={}){const reference=randomUUID(),response=options.amount?await this.request("POST",`/payments/${encodeURIComponent(paymentId)}/refunds`,{body:{merchantAccount:this.config.merchantAccount,amount:this.amount(options.amount),reference},idempotencyKey:options.idempotencyKey}):await this.request("POST",`/payments/${encodeURIComponent(paymentId)}/reversals`,{body:{merchantAccount:this.config.merchantAccount,reference},idempotencyKey:options.idempotencyKey});return{id:response.pspReference,paymentId,status:"pending",amount:options.amount?{amount:options.amount.amount,currency:options.amount.currency.toLowerCase()}:null,raw:response}}async checkout(payer,request){if(request.trialDays&&request.mode!=="subscription")throw TypeError("trialDays applies to a subscription checkout.");if(request.mode==="subscription")throw new PaymentUnsupportedError("adyen","subscription checkout",'Adyen has no subscriptions; store a card with mode "setup" and charge it on your schedule');if(request.cancelUrl&&request.cancelUrl!==request.successUrl)throw new PaymentUnsupportedError("adyen","a separate cancelUrl","Adyen's hosted checkout returns to one URL, with the outcome in its query string");if(request.allowPromotionCodes)throw new PaymentUnsupportedError("adyen","promotion codes","Adyen has no coupons; discount the line amounts instead");if(request.automaticTax)throw new PaymentUnsupportedError("adyen","automatic tax","Adyen does not calculate tax; price each line including it");if(!request.currency)throw TypeError("An Adyen checkout needs a currency.");const allowedPaymentMethods=request.paymentMethods?.map((method)=>{const adyen=ADYEN_PAYMENT_METHODS[method];if(!adyen)throw new PaymentUnsupportedError("adyen",`the ${method} payment method`,"name it in Adyen's own terms through the Customer Area, or offer card and sepa_debit");return adyen});this.assertReturnUrl(request.successUrl,"successUrl");const lines=request.lines.map((line)=>{if("price"in line)throw new PaymentUnsupportedError("adyen","catalog prices","Adyen has no product catalog; price each line with name and unitAmount");return line}),total=lines.reduce((sum,line)=>sum+line.unitAmount*line.quantity,0),setup=request.mode==="setup",session=await this.request("POST","/sessions",{body:{merchantAccount:this.config.merchantAccount,amount:this.amount({amount:setup?0:total,currency:request.currency}),reference:request.reference??randomUUID(),returnUrl:request.successUrl,mode:"hosted",shopperReference:this.shopperReference(payer),...payer.email?{shopperEmail:payer.email}:{},...setup?{storePaymentMethodMode:"enabled",recurringProcessingModel:"UnscheduledCardOnFile"}:{},...setup?{}:{lineItems:lines.map((line)=>({description:line.name,quantity:line.quantity,amountIncludingTax:line.unitAmount}))},...request.metadata?{metadata:request.metadata}:{},...allowedPaymentMethods?.length?{allowedPaymentMethods}:{}}});if(!session.url)throw new PaymentProviderError("adyen",200,"the hosted session came back without a url");return{id:session.id,url:session.url,expiresAt:session.expiresAt?new Date(session.expiresAt):null,raw:session}}async paymentMethods(payer){return((await this.request("GET","/storedPaymentMethods",{query:{merchantAccount:this.config.merchantAccount,shopperReference:this.shopperReference(payer)}}))?.storedPaymentMethods??[]).map((method)=>({id:String(method.id),type:String(method.type??"scheme"),brand:method.brand??null,last4:method.lastFour??null,expMonth:method.expiryMonth?Number(method.expiryMonth):null,expYear:method.expiryYear?Number(String(method.expiryYear).length===2?`20${method.expiryYear}`:method.expiryYear):null,isDefault:!1,raw:method}))}async removePaymentMethod(payer,paymentMethodId){await this.request("DELETE",`/storedPaymentMethods/${encodeURIComponent(paymentMethodId)}`,{query:{merchantAccount:this.config.merchantAccount,shopperReference:this.shopperReference(payer)}})}async subscribe(){throw new PaymentUnsupportedError("adyen","subscriptions","charge a stored card on your own schedule with charge()")}async cancelSubscription(){throw new PaymentUnsupportedError("adyen","subscriptions")}async subscriptions(){throw new PaymentUnsupportedError("adyen","subscriptions")}async verifyWebhook(request){if(!this.config.hmacKey)throw new WebhookNotConfiguredError("adyen","Adyen webhooks cannot be verified without the HMAC key: set ADYEN_HMAC_KEY or payment.adyen.hmacKey.");let body;try{body=JSON.parse(request.payload)}catch{throw new WebhookSignatureError("adyen","the body is not JSON")}const items=(body.notificationItems??[]).map((entry)=>entry.NotificationRequestItem).filter((item)=>Boolean(item));if(items.length===0)throw new WebhookSignatureError("adyen","the delivery holds no notification items");return items.map((item)=>{const given=item.additionalData?.hmacSignature;if(!given)throw new WebhookSignatureError("adyen",`item ${item.pspReference??"?"} has no hmacSignature`);const expected=Buffer.from(adyenSignature(item,this.config.hmacKey)),actual=Buffer.from(given);if(expected.length!==actual.length||!timingSafeEqual(expected,actual))throw new WebhookSignatureError("adyen",`item ${item.pspReference??"?"} is signed with another key`);const success=String(item.success)==="true",pair=EVENT_TYPES[item.eventCode??""];return{provider:this.name,id:`${item.eventCode}:${item.pspReference}:${success}`,type:pair?pair[success?0:1]:"unknown",providerType:item.eventCode??"",reference:item.originalReference||item.pspReference||null,reason:item.reason||null,merchantReference:item.merchantReference??null,amount:item.amount?.value!==void 0&&item.amount.currency?{amount:Number(item.amount.value),currency:item.amount.currency.toLowerCase()}:null,raw:item}})}acknowledgeWebhook(){return new Response("[accepted]",{status:202,headers:{"Content-Type":"text/plain"}})}}
@@ -1 +1 @@
1
- import{createHash,createHmac,timingSafeEqual}from"node:crypto";import{assertMoney,headerOf,PaymentProviderError,PaymentUnsupportedError,WebhookNotConfiguredError,WebhookSignatureError}from"./types";export const LEMONSQUEEZY_API_URL="https://api.lemonsqueezy.com/v1";const CAPABILITIES=new Set(["customers","refund","checkout","subscriptions","webhooks"]),SUBSCRIPTION_STATUSES={on_trial:"trialing",active:"active",paused:"paused",past_due:"past_due",unpaid:"unpaid",cancelled:"active",expired:"canceled"};export function summarizeLemonSqueezySubscription(subscription){const{attributes}=subscription,cancelled=attributes.status==="cancelled"||attributes.cancelled===!0,periodEnd=cancelled?attributes.ends_at:attributes.renews_at;return{id:String(subscription.id),status:SUBSCRIPTION_STATUSES[attributes.status]??"unknown",price:attributes.variant_id!==void 0?{id:String(attributes.variant_id),name:attributes.variant_name??attributes.product_name??null,amount:null,interval:null}:null,currentPeriodEnd:periodEnd?new Date(periodEnd):null,cancelAtPeriodEnd:cancelled&&attributes.status!=="expired",raw:subscription}}export function lemonSqueezySignature(payload,secret){return createHmac("sha256",secret).update(payload,"utf8").digest("hex")}export class LemonSqueezyDriver{name="lemonsqueezy";capabilities=CAPABILITIES;config;fetch;storeCurrency;constructor(config,options={}){if(!config.apiKey)throw Error("The Lemon Squeezy payment driver needs an API key: set LEMONSQUEEZY_API_KEY or payment.lemonsqueezy.apiKey.");if(!config.storeId)throw Error("The Lemon Squeezy payment driver needs a store: set LEMONSQUEEZY_STORE_ID or payment.lemonsqueezy.storeId.");this.config=config;this.fetch=options.fetch??fetch}async request(method,path,options={}){const url=new URL(`${LEMONSQUEEZY_API_URL}${path}`);for(const[key,value]of Object.entries(options.query??{}))url.searchParams.set(key,value);const headers={Authorization:`Bearer ${this.config.apiKey}`,Accept:"application/vnd.api+json"};if(options.body!==void 0)headers["Content-Type"]="application/vnd.api+json";const response=await this.fetch(url,{method,headers,body:options.body===void 0?void 0:JSON.stringify(options.body)}),text=await response.text(),body=text?JSON.parse(text):{};if(!response.ok){const error=body.errors?.[0];throw new PaymentProviderError("lemonsqueezy",response.status,error?.detail??error?.title??response.statusText,error?.code??null)}return body}async list(path,filters){const all=[];for(let page=1;;page++){const result=await this.request("GET",path,{query:{...filters,"page[number]":String(page),"page[size]":"100"}});all.push(...result.data??[]);if(page>=(result.meta?.page?.lastPage??1))return all}}store(){return{data:{type:"stores",id:String(this.config.storeId)}}}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})`)}currency(){this.storeCurrency??=this.request("GET",`/stores/${encodeURIComponent(this.config.storeId)}`).then((result)=>result.data.attributes.currency.toLowerCase());return this.storeCurrency}async customer(payer){if(!payer.email)throw TypeError("A Lemon Squeezy customer is identified by email: the payer has none.");const existing=(await this.request("GET","/customers",{query:{"filter[store_id]":String(this.config.storeId),"filter[email]":payer.email}})).data?.[0];if(existing)return{id:String(existing.id),email:existing.attributes.email,name:existing.attributes.name??null};const created=await this.request("POST","/customers",{body:{data:{type:"customers",attributes:{email:payer.email,name:payer.name||payer.email},relationships:{store:this.store()}}}});return{id:String(created.data.id),email:created.data.attributes.email,name:created.data.attributes.name??null}}async charge(){throw new PaymentUnsupportedError("lemonsqueezy","charge","Lemon Squeezy takes every payment on its hosted checkout: use checkout")}async createPayment(){throw new PaymentUnsupportedError("lemonsqueezy","createPayment","Lemon Squeezy takes every payment on its hosted checkout: use checkout")}async refund(paymentId,options={}){const attributes={};if(options.amount){assertMoney(options.amount,"refund amount");const order=await this.request("GET",`/orders/${encodeURIComponent(paymentId)}`);if(order.data.attributes.currency.toLowerCase()!==options.amount.currency.toLowerCase())throw TypeError(`The refund is in ${options.amount.currency.toUpperCase()}, but the order is in ${order.data.attributes.currency.toUpperCase()}.`);attributes.amount=options.amount.amount}const result=await this.request("POST",`/orders/${encodeURIComponent(paymentId)}/refund`,{body:{data:{type:"orders",id:String(paymentId),...options.amount?{attributes}:{}}}}),order=result.data.attributes;return{id:`${paymentId}:refunded:${order.refunded_amount??0}`,paymentId,status:"succeeded",amount:options.amount?{amount:options.amount.amount,currency:options.amount.currency.toLowerCase()}:null,raw:result.data}}async checkout(payer,request){if(request.mode==="setup")throw new PaymentUnsupportedError("lemonsqueezy","setup checkout","Lemon Squeezy saves a payment method while taking a payment, never on its own");if(request.lines.length!==1)throw new PaymentUnsupportedError("lemonsqueezy",`a checkout with ${request.lines.length} lines`,"a Lemon Squeezy checkout sells one variant");if(request.cancelUrl&&request.cancelUrl!==request.successUrl)throw new PaymentUnsupportedError("lemonsqueezy","a separate cancelUrl","the hosted checkout has no cancel page");if(request.trialDays)throw new PaymentUnsupportedError("lemonsqueezy","a checkout trial","a Lemon Squeezy trial is set on the subscription variant");this.assertReturnUrl(request.successUrl,"successUrl");const line=request.lines[0];if(!Number.isInteger(line.quantity)||line.quantity<1)throw TypeError(`A checkout line's quantity must be a positive whole number, got ${line.quantity}`);let variantId;const attributes={},productOptions={redirect_url:request.successUrl};if("price"in line)variantId=line.price;else{if(request.mode==="subscription")throw TypeError("A Lemon Squeezy subscription checkout needs a subscription variant: a line priced here has no billing interval.");if(!this.config.customPriceVariantId)throw TypeError("A checkout line priced here needs a variant to sell it as: set LEMONSQUEEZY_VARIANT_ID or payment.lemonsqueezy.customPriceVariantId.");const currency=await this.currency();if(request.currency&&request.currency.toLowerCase()!==currency)throw TypeError(`The checkout is in ${request.currency.toUpperCase()}, but the Lemon Squeezy store sells in ${currency.toUpperCase()}.`);const total={amount:line.unitAmount*line.quantity,currency};assertMoney(total,"checkout amount");variantId=this.config.customPriceVariantId;attributes.custom_price=total.amount;productOptions.name=line.quantity===1?line.name:`${line.name} x ${line.quantity}`}const checkoutData={custom:{...request.metadata,...request.reference?{reference:request.reference}:{},stacks_user_id:String(payer.id)}};if(payer.email)checkoutData.email=payer.email;if(payer.name)checkoutData.name=payer.name;if("price"in line&&line.quantity>1)checkoutData.variant_quantities=[{variant_id:Number(variantId),quantity:line.quantity}];const result=await this.request("POST","/checkouts",{body:{data:{type:"checkouts",attributes:{...attributes,product_options:productOptions,checkout_options:{discount:request.allowPromotionCodes===!0},checkout_data:checkoutData,...this.config.testMode?{test_mode:!0}:{}},relationships:{store:this.store(),variant:{data:{type:"variants",id:String(variantId)}}}}}});return{id:String(result.data.id),url:result.data.attributes.url,expiresAt:result.data.attributes.expires_at?new Date(result.data.attributes.expires_at):null,raw:result.data}}async paymentMethods(){throw new PaymentUnsupportedError("lemonsqueezy","paymentMethods","Lemon Squeezy keeps the card on the subscription; send the payer to its update_payment_method URL")}async removePaymentMethod(){throw new PaymentUnsupportedError("lemonsqueezy","removePaymentMethod","Lemon Squeezy keeps the card on the subscription; send the payer to its update_payment_method URL")}async subscribe(){throw new PaymentUnsupportedError("lemonsqueezy","subscribe","a Lemon Squeezy subscription starts at checkout: use checkout with a subscription variant")}async cancelSubscription(subscriptionId,options={}){if(!options.atPeriodEnd)throw new PaymentUnsupportedError("lemonsqueezy","an immediate cancellation","Lemon Squeezy cancels at the end of the billing period: pass { atPeriodEnd: true }");const result=await this.request("DELETE",`/subscriptions/${encodeURIComponent(subscriptionId)}`);return summarizeLemonSqueezySubscription(result.data)}async subscriptions(payer){if(!payer.email)throw TypeError("Lemon Squeezy finds a payer's subscriptions by email: the payer has none.");return(await this.list("/subscriptions",{"filter[store_id]":String(this.config.storeId),"filter[user_email]":payer.email})).map(summarizeLemonSqueezySubscription)}async verifyWebhook(request){if(!this.config.webhookSecret)throw new WebhookNotConfiguredError("lemonsqueezy","Lemon Squeezy webhooks cannot be verified without the signing secret: set LEMONSQUEEZY_WEBHOOK_SECRET or payment.lemonsqueezy.webhookSecret.");const given=headerOf(request.headers,"x-signature");if(!given)throw new WebhookSignatureError("lemonsqueezy","there is no X-Signature header");const expected=Buffer.from(lemonSqueezySignature(request.payload,this.config.webhookSecret)),actual=Buffer.from(given.trim());if(actual.length!==expected.length||!timingSafeEqual(actual,expected))throw new WebhookSignatureError("lemonsqueezy","it is signed with another secret");let notification;try{notification=JSON.parse(request.payload)}catch{throw new WebhookSignatureError("lemonsqueezy","the body is not JSON")}const id=`${notification.meta?.event_name??"unknown"}:${createHash("sha256").update(request.payload,"utf8").digest("hex").slice(0,32)}`;return[this.translate(id,notification)]}translate(id,notification){const name=notification.meta?.event_name??"",data=notification.data,attributes=data?.attributes??{},reference=typeof notification.meta?.custom_data?.reference==="string"?notification.meta.custom_data.reference:null,money=(amount)=>typeof amount==="number"&&typeof attributes.currency==="string"?{amount,currency:attributes.currency.toLowerCase()}:null,base={provider:this.name,id,providerType:name,reference:data?.id!==void 0?String(data.id):null,merchantReference:reference,raw:notification};if(name==="order_created"){const type=attributes.status==="paid"?"payment.succeeded":attributes.status==="failed"?"payment.failed":"unknown";return{...base,type,amount:money(attributes.total),reason:null}}if(name==="order_refunded"){const refundedTotal=money(attributes.refunded_amount);return{...base,type:"refund.succeeded",amount:null,...refundedTotal?{refundedTotal}:{},reason:null}}const type={subscription_created:"subscription.created",subscription_updated:"subscription.updated",subscription_cancelled:"subscription.updated",subscription_resumed:"subscription.updated",subscription_paused:"subscription.updated",subscription_unpaused:"subscription.updated",subscription_expired:"subscription.canceled",subscription_payment_success:"invoice.paid",subscription_payment_recovered:"invoice.paid",subscription_payment_failed:"invoice.payment_failed"}[name]??"unknown";return{...base,type,amount:type.startsWith("invoice.")?money(attributes.total):null,reason:null}}acknowledgeWebhook(){return new Response(null,{status:200})}}
1
+ import{createHash,createHmac,timingSafeEqual}from"node:crypto";import{assertMoney,headerOf,PaymentProviderError,PaymentUnsupportedError,WebhookNotConfiguredError,WebhookSignatureError}from"./types";export const LEMONSQUEEZY_API_URL="https://api.lemonsqueezy.com/v1";const CAPABILITIES=new Set(["customers","refund","checkout","subscriptions","webhooks"]),SUBSCRIPTION_STATUSES={on_trial:"trialing",active:"active",paused:"paused",past_due:"past_due",unpaid:"unpaid",cancelled:"active",expired:"canceled"};export function summarizeLemonSqueezySubscription(subscription){const{attributes}=subscription,cancelled=attributes.status==="cancelled"||attributes.cancelled===!0,periodEnd=cancelled?attributes.ends_at:attributes.renews_at;return{id:String(subscription.id),status:SUBSCRIPTION_STATUSES[attributes.status]??"unknown",price:attributes.variant_id!==void 0?{id:String(attributes.variant_id),name:attributes.variant_name??attributes.product_name??null,amount:null,interval:null}:null,currentPeriodEnd:periodEnd?new Date(periodEnd):null,cancelAtPeriodEnd:cancelled&&attributes.status!=="expired",raw:subscription}}export function lemonSqueezySignature(payload,secret){return createHmac("sha256",secret).update(payload,"utf8").digest("hex")}export class LemonSqueezyDriver{name="lemonsqueezy";capabilities=CAPABILITIES;config;fetch;storeCurrency;constructor(config,options={}){if(!config.apiKey)throw Error("The Lemon Squeezy payment driver needs an API key: set LEMONSQUEEZY_API_KEY or payment.lemonsqueezy.apiKey.");if(!config.storeId)throw Error("The Lemon Squeezy payment driver needs a store: set LEMONSQUEEZY_STORE_ID or payment.lemonsqueezy.storeId.");this.config=config;this.fetch=options.fetch??fetch}async request(method,path,options={}){const url=new URL(`${LEMONSQUEEZY_API_URL}${path}`);for(const[key,value]of Object.entries(options.query??{}))url.searchParams.set(key,value);const headers={Authorization:`Bearer ${this.config.apiKey}`,Accept:"application/vnd.api+json"};if(options.body!==void 0)headers["Content-Type"]="application/vnd.api+json";const response=await this.fetch(url,{method,headers,body:options.body===void 0?void 0:JSON.stringify(options.body)}),text=await response.text(),body=text?JSON.parse(text):{};if(!response.ok){const error=body.errors?.[0];throw new PaymentProviderError("lemonsqueezy",response.status,error?.detail??error?.title??response.statusText,error?.code??null)}return body}async list(path,filters){const all=[];for(let page=1;;page++){const result=await this.request("GET",path,{query:{...filters,"page[number]":String(page),"page[size]":"100"}});all.push(...result.data??[]);if(page>=(result.meta?.page?.lastPage??1))return all}}store(){return{data:{type:"stores",id:String(this.config.storeId)}}}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})`)}currency(){this.storeCurrency??=this.request("GET",`/stores/${encodeURIComponent(this.config.storeId)}`).then((result)=>result.data.attributes.currency.toLowerCase());return this.storeCurrency}async customer(payer){if(!payer.email)throw TypeError("A Lemon Squeezy customer is identified by email: the payer has none.");const existing=(await this.request("GET","/customers",{query:{"filter[store_id]":String(this.config.storeId),"filter[email]":payer.email}})).data?.[0];if(existing)return{id:String(existing.id),email:existing.attributes.email,name:existing.attributes.name??null};const created=await this.request("POST","/customers",{body:{data:{type:"customers",attributes:{email:payer.email,name:payer.name||payer.email},relationships:{store:this.store()}}}});return{id:String(created.data.id),email:created.data.attributes.email,name:created.data.attributes.name??null}}async charge(){throw new PaymentUnsupportedError("lemonsqueezy","charge","Lemon Squeezy takes every payment on its hosted checkout: use checkout")}async createPayment(){throw new PaymentUnsupportedError("lemonsqueezy","createPayment","Lemon Squeezy takes every payment on its hosted checkout: use checkout")}async refund(paymentId,options={}){const attributes={};if(options.amount){assertMoney(options.amount,"refund amount");const order=await this.request("GET",`/orders/${encodeURIComponent(paymentId)}`);if(order.data.attributes.currency.toLowerCase()!==options.amount.currency.toLowerCase())throw TypeError(`The refund is in ${options.amount.currency.toUpperCase()}, but the order is in ${order.data.attributes.currency.toUpperCase()}.`);attributes.amount=options.amount.amount}const result=await this.request("POST",`/orders/${encodeURIComponent(paymentId)}/refund`,{body:{data:{type:"orders",id:String(paymentId),...options.amount?{attributes}:{}}}}),order=result.data.attributes;return{id:`${paymentId}:refunded:${order.refunded_amount??0}`,paymentId,status:"succeeded",amount:options.amount?{amount:options.amount.amount,currency:options.amount.currency.toLowerCase()}:null,raw:result.data}}async checkout(payer,request){if(request.mode==="setup")throw new PaymentUnsupportedError("lemonsqueezy","setup checkout","Lemon Squeezy saves a payment method while taking a payment, never on its own");if(request.lines.length!==1)throw new PaymentUnsupportedError("lemonsqueezy",`a checkout with ${request.lines.length} lines`,"a Lemon Squeezy checkout sells one variant");if(request.paymentMethods?.length)throw new PaymentUnsupportedError("lemonsqueezy","choosing payment methods","Lemon Squeezy shows the methods its store settings allow");if(request.cancelUrl&&request.cancelUrl!==request.successUrl)throw new PaymentUnsupportedError("lemonsqueezy","a separate cancelUrl","the hosted checkout has no cancel page");if(request.trialDays)throw new PaymentUnsupportedError("lemonsqueezy","a checkout trial","a Lemon Squeezy trial is set on the subscription variant");this.assertReturnUrl(request.successUrl,"successUrl");const line=request.lines[0];if(!Number.isInteger(line.quantity)||line.quantity<1)throw TypeError(`A checkout line's quantity must be a positive whole number, got ${line.quantity}`);let variantId;const attributes={},productOptions={redirect_url:request.successUrl};if("price"in line)variantId=line.price;else{if(request.mode==="subscription")throw TypeError("A Lemon Squeezy subscription checkout needs a subscription variant: a line priced here has no billing interval.");if(!this.config.customPriceVariantId)throw TypeError("A checkout line priced here needs a variant to sell it as: set LEMONSQUEEZY_VARIANT_ID or payment.lemonsqueezy.customPriceVariantId.");const currency=await this.currency();if(request.currency&&request.currency.toLowerCase()!==currency)throw TypeError(`The checkout is in ${request.currency.toUpperCase()}, but the Lemon Squeezy store sells in ${currency.toUpperCase()}.`);const total={amount:line.unitAmount*line.quantity,currency};assertMoney(total,"checkout amount");variantId=this.config.customPriceVariantId;attributes.custom_price=total.amount;productOptions.name=line.quantity===1?line.name:`${line.name} x ${line.quantity}`}const checkoutData={custom:{...request.metadata,...request.reference?{reference:request.reference}:{},stacks_user_id:String(payer.id)}};if(payer.email)checkoutData.email=payer.email;if(payer.name)checkoutData.name=payer.name;if("price"in line&&line.quantity>1)checkoutData.variant_quantities=[{variant_id:Number(variantId),quantity:line.quantity}];const result=await this.request("POST","/checkouts",{body:{data:{type:"checkouts",attributes:{...attributes,product_options:productOptions,checkout_options:{discount:request.allowPromotionCodes===!0},checkout_data:checkoutData,...this.config.testMode?{test_mode:!0}:{}},relationships:{store:this.store(),variant:{data:{type:"variants",id:String(variantId)}}}}}});return{id:String(result.data.id),url:result.data.attributes.url,expiresAt:result.data.attributes.expires_at?new Date(result.data.attributes.expires_at):null,raw:result.data}}async paymentMethods(){throw new PaymentUnsupportedError("lemonsqueezy","paymentMethods","Lemon Squeezy keeps the card on the subscription; send the payer to its update_payment_method URL")}async removePaymentMethod(){throw new PaymentUnsupportedError("lemonsqueezy","removePaymentMethod","Lemon Squeezy keeps the card on the subscription; send the payer to its update_payment_method URL")}async subscribe(){throw new PaymentUnsupportedError("lemonsqueezy","subscribe","a Lemon Squeezy subscription starts at checkout: use checkout with a subscription variant")}async cancelSubscription(subscriptionId,options={}){if(!options.atPeriodEnd)throw new PaymentUnsupportedError("lemonsqueezy","an immediate cancellation","Lemon Squeezy cancels at the end of the billing period: pass { atPeriodEnd: true }");const result=await this.request("DELETE",`/subscriptions/${encodeURIComponent(subscriptionId)}`);return summarizeLemonSqueezySubscription(result.data)}async subscriptions(payer){if(!payer.email)throw TypeError("Lemon Squeezy finds a payer's subscriptions by email: the payer has none.");return(await this.list("/subscriptions",{"filter[store_id]":String(this.config.storeId),"filter[user_email]":payer.email})).map(summarizeLemonSqueezySubscription)}async verifyWebhook(request){if(!this.config.webhookSecret)throw new WebhookNotConfiguredError("lemonsqueezy","Lemon Squeezy webhooks cannot be verified without the signing secret: set LEMONSQUEEZY_WEBHOOK_SECRET or payment.lemonsqueezy.webhookSecret.");const given=headerOf(request.headers,"x-signature");if(!given)throw new WebhookSignatureError("lemonsqueezy","there is no X-Signature header");const expected=Buffer.from(lemonSqueezySignature(request.payload,this.config.webhookSecret)),actual=Buffer.from(given.trim());if(actual.length!==expected.length||!timingSafeEqual(actual,expected))throw new WebhookSignatureError("lemonsqueezy","it is signed with another secret");let notification;try{notification=JSON.parse(request.payload)}catch{throw new WebhookSignatureError("lemonsqueezy","the body is not JSON")}const id=`${notification.meta?.event_name??"unknown"}:${createHash("sha256").update(request.payload,"utf8").digest("hex").slice(0,32)}`;return[this.translate(id,notification)]}translate(id,notification){const name=notification.meta?.event_name??"",data=notification.data,attributes=data?.attributes??{},reference=typeof notification.meta?.custom_data?.reference==="string"?notification.meta.custom_data.reference:null,money=(amount)=>typeof amount==="number"&&typeof attributes.currency==="string"?{amount,currency:attributes.currency.toLowerCase()}:null,base={provider:this.name,id,providerType:name,reference:data?.id!==void 0?String(data.id):null,merchantReference:reference,raw:notification};if(name==="order_created"){const type=attributes.status==="paid"?"payment.succeeded":attributes.status==="failed"?"payment.failed":"unknown";return{...base,type,amount:money(attributes.total),reason:null}}if(name==="order_refunded"){const refundedTotal=money(attributes.refunded_amount);return{...base,type:"refund.succeeded",amount:null,...refundedTotal?{refundedTotal}:{},reason:null}}const type={subscription_created:"subscription.created",subscription_updated:"subscription.updated",subscription_cancelled:"subscription.updated",subscription_resumed:"subscription.updated",subscription_paused:"subscription.updated",subscription_unpaused:"subscription.updated",subscription_expired:"subscription.canceled",subscription_payment_success:"invoice.paid",subscription_payment_recovered:"invoice.paid",subscription_payment_failed:"invoice.payment_failed"}[name]??"unknown";return{...base,type,amount:type.startsWith("invoice.")?money(attributes.total):null,reason:null}}acknowledgeWebhook(){return new Response(null,{status:200})}}
@@ -1 +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})}}
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.paymentMethods?.length)throw new PaymentUnsupportedError("paddle","choosing payment methods","Paddle shows the methods its checkout settings allow");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})}}
@@ -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}}:{}});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;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})}}
@@ -62,6 +62,7 @@ export declare interface CheckoutRequest {
62
62
  trialDays?: number
63
63
  allowPromotionCodes?: boolean
64
64
  automaticTax?: boolean
65
+ paymentMethods?: string[]
65
66
  }
66
67
  export declare interface CheckoutSession {
67
68
  id: string
package/dist/index.d.ts CHANGED
@@ -24,3 +24,9 @@ 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
+ // Bank debits (SEPA Direct Debit, Bacs, ACH, BECS): methods per currency,
28
+ // debit webhook outcomes and mandates. Pure; also `@stacksjs/payments/direct-debit`.
29
+ export * from './direct-debit';
30
+ // Card-present payments with Stripe Terminal: locations, readers, charges on a
31
+ // reader, refunds to the card. Also `@stacksjs/payments/terminal`.
32
+ export * from './terminal';
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";
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";
@@ -0,0 +1,163 @@
1
+ /**
2
+ * Stripe's form encoding: nested objects as `a[b]=c`, arrays as `a[0]=x`,
3
+ * booleans as `true`/`false`; null and undefined are left out.
4
+ */
5
+ export declare function stripeFormEncode(params: Record<string, FormValue>): string;
6
+ /** A minimal Stripe REST client: one call, Stripe's errors as StripeApiError. */
7
+ export declare function stripeRest(options: StripeRestOptions): StripeRest;
8
+ /** The Terminal calls a till makes, against Stripe or a double of it (`apiBase`). */
9
+ export declare function stripeTerminal(options: StripeRestOptions): StripeTerminal;
10
+ /**
11
+ * Reads a charge's state the way Stripe's server-driven guide does: the
12
+ * PaymentIntent decides success and cancellation; while it waits, the
13
+ * reader's action for this PaymentIntent says whether the card was declined
14
+ * or the reader gave up. A failed attempt leaves the PaymentIntent
15
+ * `requires_payment_method`, so the same charge can go to the reader again.
16
+ */
17
+ export declare function terminalChargeState(paymentIntent: { id?: string, status?: string, last_payment_error?: { code?: string, decline_code?: string, message?: string } | null } | null, reader?: Pick<TerminalReader, 'action'> | null): TerminalChargeOutcome;
18
+ /** The card details of a charge (or of a PaymentIntent with `latest_charge` expanded). */
19
+ export declare function cardPresentDetails(chargeOrIntent: any): CardPresentDetails | null;
20
+ /** The charge id behind a PaymentIntent (`latest_charge`, expanded or not). */
21
+ export declare function latestChargeId(paymentIntent: any): string | null;
22
+ /** Whether a secret key is a test-mode key (simulated readers, test cards). */
23
+ export declare function isTestKey(secretKey: string | null | undefined): boolean;
24
+ /**
25
+ * The API version these calls are written for. Pinned, so an account's
26
+ * default version cannot change the shapes: on it a card-present
27
+ * PaymentIntent names its method with `allowed_payment_method_types` (the
28
+ * older `payment_method_types` is refused since 2026-09-30.endive).
29
+ */
30
+ export declare const STRIPE_TERMINAL_API_VERSION: '2026-09-30.endive';
31
+ declare const path: (value: string) => string;
32
+ /** Registration codes Stripe accepts for a simulated reader in test mode. */
33
+ export declare const SIMULATED_READER_CODES: readonly ['simulated-wpe', 'simulated-s700', 'simulated-s710', 'simulated-v660p', 'simulated-wp3'];
34
+ export declare interface StripeRestOptions {
35
+ secretKey: string
36
+ apiBase?: string
37
+ apiVersion?: string | null
38
+ stripeAccount?: string
39
+ fetch?: typeof fetch
40
+ timeoutMs?: number
41
+ }
42
+ export declare interface StripeRequestOptions {
43
+ idempotencyKey?: string
44
+ }
45
+ export declare interface StripeRest {
46
+ request: <T = any>(method: 'GET' | 'POST' | 'DELETE', path: string, params?: Record<string, FormValue>, options?: StripeRequestOptions) => Promise<T>
47
+ }
48
+ export declare interface TerminalAddress {
49
+ line1: string
50
+ line2?: string | null
51
+ city?: string | null
52
+ postal_code?: string | null
53
+ state?: string | null
54
+ country: string
55
+ }
56
+ export declare interface TerminalLocation {
57
+ id: string
58
+ object: 'terminal.location'
59
+ display_name: string
60
+ address: TerminalAddress
61
+ metadata: Record<string, string>
62
+ livemode: boolean
63
+ }
64
+ export declare interface ReaderAction {
65
+ type: 'process_payment_intent' | 'process_setup_intent' | 'refund_payment' | 'set_reader_display' | string
66
+ status: 'in_progress' | 'succeeded' | 'failed'
67
+ failure_code: string | null
68
+ failure_message: string | null
69
+ process_payment_intent?: { payment_intent: string } | null
70
+ }
71
+ export declare interface TerminalReader {
72
+ id: string
73
+ object: 'terminal.reader'
74
+ label: string
75
+ device_type: string
76
+ serial_number: string
77
+ status: 'online' | 'offline' | null
78
+ location: string | null
79
+ ip_address: string | null
80
+ device_sw_version: string | null
81
+ action: ReaderAction | null
82
+ metadata: Record<string, string>
83
+ livemode: boolean
84
+ }
85
+ export declare interface CardPresentPaymentIntentInput {
86
+ amount: number
87
+ currency: string
88
+ description?: string
89
+ metadata?: Record<string, string>
90
+ onBehalfOf?: string
91
+ transferDestination?: string
92
+ captureMethod?: 'automatic' | 'manual'
93
+ statementDescriptorSuffix?: string
94
+ }
95
+ export declare interface StripeTerminal {
96
+ connectionToken: (location?: string) => Promise<{ object: 'terminal.connection_token', secret: string, location?: string }>
97
+ createLocation: (input: { displayName: string, address: TerminalAddress, metadata?: Record<string, string> }, idempotencyKey?: string) => Promise<TerminalLocation>
98
+ retrieveLocation: (id: string) => Promise<TerminalLocation>
99
+ registerReader: (input: { registrationCode: string, label?: string, location: string, metadata?: Record<string, string> }) => Promise<TerminalReader>
100
+ retrieveReader: (id: string) => Promise<TerminalReader>
101
+ listReaders: (filter?: { location?: string, limit?: number }) => Promise<TerminalReader[]>
102
+ deleteReader: (id: string) => Promise<{ id: string, deleted: boolean }>
103
+ createPaymentIntent: (input: CardPresentPaymentIntentInput, idempotencyKey: string) => Promise<any>
104
+ retrievePaymentIntent: (id: string) => Promise<any>
105
+ cancelPaymentIntent: (id: string) => Promise<any>
106
+ processPaymentIntent: (readerId: string, paymentIntentId: string, idempotencyKey?: string) => Promise<TerminalReader>
107
+ cancelReaderAction: (readerId: string) => Promise<TerminalReader>
108
+ presentPaymentMethod: (readerId: string, card?: { number?: string, type?: 'card_present' | 'interac_present' }) => Promise<TerminalReader>
109
+ refund: (input: { paymentIntent: string, amount?: number, reverseTransfer?: boolean, metadata?: Record<string, string>, reason?: 'duplicate' | 'fraudulent' | 'requested_by_customer' }, idempotencyKey: string) => Promise<any>
110
+ }
111
+ export declare interface TerminalChargeOutcome {
112
+ state: TerminalChargeState
113
+ failureCode: string | null
114
+ failureMessage: string | null
115
+ }
116
+ /** What a card receipt prints about the card, from a card-present charge. */
117
+ export declare interface CardPresentDetails {
118
+ brand: string | null
119
+ last4: string | null
120
+ funding: string | null
121
+ readMethod: string | null
122
+ applicationName: string | null
123
+ applicationId: string | null
124
+ authorizationCode: string | null
125
+ authorizationResponseCode: string | null
126
+ accountType: string | null
127
+ cardholderVerification: string | null
128
+ }
129
+ export type FormValue = string | number | boolean | null | undefined | FormValue[] | { [key: string]: FormValue }
130
+ /** Where a card-present charge stands, from the PaymentIntent and the reader that has it. */
131
+ export type TerminalChargeState = 'waiting' | 'succeeded' | 'failed' | 'canceled';
132
+ /**
133
+ * Card-present payments through Stripe Terminal, the server-driven way: the
134
+ * till asks the server to charge, the server tells a smart reader (WisePOS E,
135
+ * S700, or a simulated one) to collect the card, and the till asks the server
136
+ * how it went. Also `@stacksjs/payments/terminal`.
137
+ *
138
+ * - A Terminal **location** groups readers (one per shop or gym location).
139
+ * - A **reader** is registered with the code it shows on screen and a label.
140
+ * - A charge is a PaymentIntent with `payment_method_types: ['card_present']`,
141
+ * handed to a reader with `process_payment_intent`. The reader's `action`
142
+ * says whether it is waiting for the card, done, or failed (a decline, a
143
+ * timeout); `cancel_action` takes the charge off its screen.
144
+ * - A refund to the card is an ordinary refund of that PaymentIntent (not
145
+ * Interac, which needs the card back at the reader).
146
+ * - A connection token is for the client SDKs (Tap to Pay, Bluetooth readers).
147
+ *
148
+ * The client talks to Stripe's REST API directly (form-encoded requests, JSON
149
+ * answers, `Idempotency-Key` on every create) instead of through the `stripe`
150
+ * SDK: it needs no optional dependency, and `apiBase` lets a test point it at
151
+ * an HTTP double of Stripe. The pure helpers (`terminalChargeState`,
152
+ * `cardPresentDetails`) read Stripe's objects without any client.
153
+ */
154
+ /** Stripe's error object (`{ error: { type, code, decline_code, message, param } }`). */
155
+ export declare class StripeApiError extends Error {
156
+ readonly type: string;
157
+ readonly code: string | null;
158
+ readonly declineCode: string | null;
159
+ readonly param: string | null;
160
+ readonly status: number;
161
+ constructor(message: string, status: number, error?: { type?: string, code?: string, decline_code?: string, param?: string });
162
+ get definitive(): boolean;
163
+ }
@@ -0,0 +1 @@
1
+ export class StripeApiError extends Error{status;type;code;declineCode;param;constructor(message,status,error={}){super(message);this.status=status;this.name="StripeApiError";this.type=error.type||(status===0?"api_connection_error":"api_error");this.code=error.code??null;this.declineCode=error.decline_code??null;this.param=error.param??null}get definitive(){return["invalid_request_error","card_error","authentication_error","permission_error"].includes(this.type)}}export const STRIPE_TERMINAL_API_VERSION="2026-09-30.endive";export function stripeFormEncode(params){const pairs=[],add=(key,value)=>{if(value===null||value===void 0)return;if(Array.isArray(value)){value.forEach((item,index)=>add(`${key}[${index}]`,item));return}if(typeof value==="object"){for(const[child,inner]of Object.entries(value))add(`${key}[${child}]`,inner);return}pairs.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`)};for(const[key,value]of Object.entries(params))add(key,value);return pairs.join("&")}export function stripeRest(options){const base=(options.apiBase||"https://api.stripe.com").replace(/\/$/,""),doFetch=options.fetch??fetch;return{async request(method,path,params={},requestOptions={}){const body=stripeFormEncode(params),url=method==="POST"||!body?`${base}${path}`:`${base}${path}?${body}`,headers={Authorization:`Bearer ${options.secretKey}`,Accept:"application/json"};if(method==="POST")headers["Content-Type"]="application/x-www-form-urlencoded";const version=options.apiVersion===void 0?STRIPE_TERMINAL_API_VERSION:options.apiVersion;if(version)headers["Stripe-Version"]=version;if(options.stripeAccount)headers["Stripe-Account"]=options.stripeAccount;if(requestOptions.idempotencyKey)headers["Idempotency-Key"]=requestOptions.idempotencyKey;let res;try{res=await doFetch(url,{method,headers,body:method==="POST"?body:void 0,signal:AbortSignal.timeout(options.timeoutMs??30000)})}catch(error){throw new StripeApiError(`Could not reach Stripe: ${error instanceof Error?error.message:String(error)}`,0)}const text=await res.text();let json=null;try{json=text?JSON.parse(text):null}catch{json=null}if(!res.ok){const error=json?.error&&typeof json.error==="object"?json.error:{};throw new StripeApiError(typeof error.message==="string"&&error.message?error.message:`Stripe answered ${res.status}`,res.status,error)}return json}}}const path=(value)=>encodeURIComponent(value);export function stripeTerminal(options){const api=stripeRest(options);return{connectionToken:(location)=>api.request("POST","/v1/terminal/connection_tokens",{location}),createLocation:(input,idempotencyKey)=>api.request("POST","/v1/terminal/locations",{display_name:input.displayName,address:{...input.address},metadata:input.metadata},{idempotencyKey}),retrieveLocation:(id)=>api.request("GET",`/v1/terminal/locations/${path(id)}`),registerReader:(input)=>api.request("POST","/v1/terminal/readers",{registration_code:input.registrationCode,label:input.label,location:input.location,metadata:input.metadata}),retrieveReader:(id)=>api.request("GET",`/v1/terminal/readers/${path(id)}`),async listReaders(filter={}){const page=await api.request("GET","/v1/terminal/readers",{location:filter.location,limit:filter.limit??100});return Array.isArray(page?.data)?page.data:[]},deleteReader:(id)=>api.request("DELETE",`/v1/terminal/readers/${path(id)}`),createPaymentIntent:(input,idempotencyKey)=>api.request("POST","/v1/payment_intents",{amount:input.amount,currency:input.currency.toLowerCase(),allowed_payment_method_types:["card_present"],capture_method:input.captureMethod??"automatic",description:input.description,metadata:input.metadata,on_behalf_of:input.onBehalfOf,transfer_data:input.transferDestination?{destination:input.transferDestination}:void 0,statement_descriptor_suffix:input.statementDescriptorSuffix},{idempotencyKey}),retrievePaymentIntent:(id)=>api.request("GET",`/v1/payment_intents/${path(id)}`,{expand:["latest_charge"]}),cancelPaymentIntent:(id)=>api.request("POST",`/v1/payment_intents/${path(id)}/cancel`),processPaymentIntent:(readerId,paymentIntentId,idempotencyKey)=>api.request("POST",`/v1/terminal/readers/${path(readerId)}/process_payment_intent`,{payment_intent:paymentIntentId},{idempotencyKey}),cancelReaderAction:(readerId)=>api.request("POST",`/v1/terminal/readers/${path(readerId)}/cancel_action`),presentPaymentMethod:(readerId,card={})=>api.request("POST",`/v1/test_helpers/terminal/readers/${path(readerId)}/present_payment_method`,{type:card.type,card_present:card.number?{number:card.number}:void 0}),refund:(input,idempotencyKey)=>api.request("POST","/v1/refunds",{payment_intent:input.paymentIntent,amount:input.amount,reverse_transfer:input.reverseTransfer,reason:input.reason,metadata:input.metadata},{idempotencyKey})}}export function terminalChargeState(paymentIntent,reader){const status=paymentIntent?.status;if(status==="succeeded"||status==="requires_capture")return{state:"succeeded",failureCode:null,failureMessage:null};if(status==="canceled")return{state:"canceled",failureCode:null,failureMessage:null};const action=reader?.action,forThis=!!action&&action.type==="process_payment_intent"&&(!paymentIntent?.id||action.process_payment_intent?.payment_intent===paymentIntent.id);if(forThis&&action.status==="failed")return{state:"failed",failureCode:action.failure_code||"reader_action_failed",failureMessage:action.failure_message||null};if(forThis&&action.status==="in_progress")return{state:"waiting",failureCode:null,failureMessage:null};const error=paymentIntent?.last_payment_error;if(status==="requires_payment_method"&&error)return{state:"failed",failureCode:error.decline_code||error.code||"card_declined",failureMessage:error.message||null};return{state:"waiting",failureCode:null,failureMessage:null}}export function cardPresentDetails(chargeOrIntent){const charge=chargeOrIntent?.object==="payment_intent"?typeof chargeOrIntent.latest_charge==="object"&&chargeOrIntent.latest_charge?chargeOrIntent.latest_charge:chargeOrIntent.charges?.data?.[0]:chargeOrIntent,card=charge?.payment_method_details?.card_present??charge?.payment_method_details?.interac_present;if(!card)return null;const receipt=card.receipt??{},text=(value)=>typeof value==="string"&&value?value:null;return{brand:text(card.brand),last4:text(card.last4),funding:text(card.funding),readMethod:text(card.read_method),applicationName:text(receipt.application_preferred_name),applicationId:text(receipt.dedicated_file_name),authorizationCode:text(charge.authorization_code??receipt.authorization_code),authorizationResponseCode:text(receipt.authorization_response_code),accountType:text(receipt.account_type),cardholderVerification:text(receipt.cardholder_verification_method)}}export function latestChargeId(paymentIntent){const charge=paymentIntent?.latest_charge;if(typeof charge==="string"&&charge)return charge;if(charge&&typeof charge.id==="string")return charge.id;const legacy=paymentIntent?.charges?.data?.[0]?.id;return typeof legacy==="string"?legacy:null}export const SIMULATED_READER_CODES=["simulated-wpe","simulated-s700","simulated-s710","simulated-v660p","simulated-wp3"];export function isTestKey(secretKey){return typeof secretKey==="string"&&/^(sk|rk)_test_/.test(secretKey)}
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.168",
5
+ "version": "0.75.170",
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.168",
64
- "@stacksjs/database": "0.75.168",
65
- "@stacksjs/error-handling": "0.75.168",
66
- "@stacksjs/logging": "0.75.168",
67
- "@stacksjs/orm": "0.75.168",
68
- "@stacksjs/types": "0.75.168"
63
+ "@stacksjs/config": "0.75.170",
64
+ "@stacksjs/database": "0.75.170",
65
+ "@stacksjs/error-handling": "0.75.170",
66
+ "@stacksjs/logging": "0.75.170",
67
+ "@stacksjs/orm": "0.75.170",
68
+ "@stacksjs/types": "0.75.170"
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.168",
83
+ "@stacksjs/utils": "0.75.170",
84
84
  "@stripe/stripe-js": "^10.0.0",
85
85
  "better-dx": "^0.2.24",
86
86
  "stripe": "23.0.0"