@stacksjs/payments 0.75.76 → 0.75.77

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,12 @@
1
1
  import type { UserModel } from '@stacksjs/orm';
2
2
  import type Stripe from 'stripe';
3
+ /**
4
+ * Invoices are listed with their payments expanded. This expanded
5
+ * `data.payment_intent.payment_method`, but Stripe removed `payment_intent`
6
+ * from the Invoice in API 2025-03-31.basil; an invoice can be paid by several
7
+ * payments now, listed in `payments`. Expanding the old path failed the list.
8
+ */
9
+ export declare const INVOICE_LIST_EXPAND: readonly ['data.payments'];
3
10
  export declare const manageInvoice: ManageInvoice;
4
11
  export declare interface ManageInvoice {
5
12
  list: (user: UserModel) => Promise<Stripe.Response<Stripe.ApiList<Stripe.Invoice>>>
@@ -1 +1 @@
1
- import{stripe}from"../drivers/stripe";import{manageCustomer}from"./customer";export const manageInvoice=(()=>{async function list(user){if(!manageCustomer.hasStripeId(user))throw Error("Customer does not exist in Stripe");if(!user.stripe_id)throw Error("User has no Stripe ID");return await stripe.invoices.list({customer:user.stripe_id,expand:["data.payment_intent.payment_method"]})}return{list}})();
1
+ import{stripe}from"../drivers/stripe";import{manageCustomer}from"./customer";export const INVOICE_LIST_EXPAND=["data.payments"],manageInvoice=(()=>{async function list(user){if(!manageCustomer.hasStripeId(user))throw Error("Customer does not exist in Stripe");if(!user.stripe_id)throw Error("User has no Stripe ID");return await stripe.invoices.list({customer:user.stripe_id,expand:[...INVOICE_LIST_EXPAND]})}return{list}})();
@@ -9,6 +9,17 @@ import type Stripe from 'stripe';
9
9
  export declare const ENTITLING_STATUSES: readonly ['active', 'trialing'];
10
10
  /** Statuses meaning payment was started but never completed. */
11
11
  export declare const INCOMPLETE_STATUSES: readonly ['incomplete'];
12
+ /**
13
+ * What a new subscription is created with expanded: the first invoice's
14
+ * confirmation secret, which the browser confirms an incomplete subscription's
15
+ * first payment with.
16
+ *
17
+ * It expanded `latest_invoice.payment_intent`, which Stripe removed from the
18
+ * Invoice in API 2025-03-31.basil. On the API version the installed SDK pins,
19
+ * Stripe refuses to expand a field that does not exist, so creating any
20
+ * subscription failed.
21
+ */
22
+ export declare const SUBSCRIPTION_CREATE_EXPAND: readonly ['latest_invoice.confirmation_secret'];
12
23
  export declare const manageSubscription: SubscriptionManager;
13
24
  export declare interface SubscriptionManager {
14
25
  create: (user: UserModel, type: string, lookupKey: string, params: Partial<Stripe.SubscriptionCreateParams>) => Promise<Stripe.Response<Stripe.Subscription>>
@@ -1 +1 @@
1
- import{db}from"@stacksjs/database/runtime";import{HttpError}from"@stacksjs/error-handling";import{isUniqueViolation}from"@stacksjs/orm";import{stripe}from"../drivers/stripe";import{manageCustomer}from"./customer";import{managePrice}from"./price";import{stacksIdempotencyKey}from"../idempotency";export const ENTITLING_STATUSES=["active","trialing"],INCOMPLETE_STATUSES=["incomplete"],manageSubscription=(()=>{async function create(user,type,lookupKey,params){const price=await managePrice.retrieveByLookupKey(lookupKey);if(!price)throw Error("Price does not exist in Stripe");const subscriptionItems=[{price:price.id,quantity:1}],mergedParams={...{customer:await manageCustomer.createOrGetStripeUser(user,{}).then((customer)=>{if(!customer||!customer.id)throw Error("Customer does not exist in Stripe");return customer.id}),payment_behavior:"allow_incomplete",expand:["latest_invoice.payment_intent"],items:subscriptionItems},...params},subscription=await stripe.subscriptions.create(mergedParams,{idempotencyKey:stacksIdempotencyKey("subscription.create",user.id,type,lookupKey)});await storeSubscription(user,type,lookupKey,subscription);return subscription}async function update(user,type,lookupKey,_params={}){const newPrice=await managePrice.retrieveByLookupKey(lookupKey),activeSubscription=await user?.activeSubscription();if(!newPrice)throw Error("New price does not exist in Stripe");if(!activeSubscription)throw Error("No active subscription for user!");const subscriptionId=activeSubscription.subscription?.provider_id;if(!subscriptionId)throw Error("Active subscription has no provider ID");const subscription=await stripe.subscriptions.retrieve(subscriptionId);if(!subscription)throw Error("Subscription does not exist in Stripe");const subscriptionItemId=subscription.items.data[0]?.id;if(!subscriptionItemId)throw Error("No subscription items found in the subscription");await stripe.subscriptions.update(subscriptionId,{items:[{id:subscriptionItemId,price:newPrice.id,quantity:1}],proration_behavior:"create_prorations"},{idempotencyKey:stacksIdempotencyKey("subscription.update",subscriptionId,newPrice.id)});const updatedSubscription=await stripe.subscriptions.retrieve(subscriptionId);if(!activeSubscription.subscription?.id)throw Error("Active subscription has no database ID");await updateSubscription(activeSubscription.subscription.id,type,updatedSubscription);return updatedSubscription}async function cancel(subscriptionId,params){if(!await stripe.subscriptions.retrieve(subscriptionId))throw Error("Subscription does not exist or does not belong to the user");const updatedSubscription=await stripe.subscriptions.cancel(subscriptionId,params,{idempotencyKey:stacksIdempotencyKey("subscription.cancel",subscriptionId)});await updateStoredSubscription(subscriptionId);return updatedSubscription}async function retrieve(user,subscriptionId){if(!manageCustomer.hasStripeId(user))throw Error("Customer does not exist in Stripe");return await stripe.subscriptions.retrieve(subscriptionId)}async function updateStoredSubscription(subscriptionId){await db.updateTable("subscriptions").set({provider_status:"canceled"}).where("provider_id","=",subscriptionId).executeTakeFirst()}function isActive(subscription){return subscription.provider_status==="active"}function isTrial(subscription){return subscription.provider_status==="trialing"}async function hasSubscriptionInStatus(user,type,statuses){const match=await db.selectFrom("subscriptions").where("user_id","=",user.id).where("type","=",type).whereIn("provider_status",[...statuses]).select(["id"]).limit(1).executeTakeFirst();return Boolean(match)}async function isIncomplete(user,type){return await hasSubscriptionInStatus(user,type,INCOMPLETE_STATUSES)}async function isValid(user,type){return await hasSubscriptionInStatus(user,type,ENTITLING_STATUSES)}async function storeSubscription(user,type,_lookupKey,options){const firstItem=options.items.data[0];if(!firstItem)throw Error("Stripe subscription contains no line items - cannot store subscription");const data=removeNullValues({user_id:user.id,type,unit_price:Number(firstItem.price.unit_amount),provider_id:options.id,provider_status:options.status,provider_price_id:firstItem.price.id,quantity:firstItem.quantity,trial_ends_at:options.trial_end!=null?String(options.trial_end):void 0,ends_at:options.current_period_end!=null?String(options.current_period_end):void 0,provider_type:"stripe",last_used_at:options.current_period_end!=null?String(options.current_period_end):void 0});let subscriptionModelCreated;try{subscriptionModelCreated=await db.insertInto("subscriptions").values(data).executeTakeFirst()}catch(error){if(isUniqueViolation(error))throw new HttpError(409,"A subscription with this provider ID already exists");throw error}if(!subscriptionModelCreated)throw Error("Failed to insert subscription record");return await db.selectFrom("subscriptions").where("id","=",Number(subscriptionModelCreated.insertId)).selectAll().executeTakeFirst()}async function updateSubscription(activeSubId,type,options){const subscription=await db.selectFrom("subscriptions").where("id","=",activeSubId).selectAll().executeTakeFirst(),firstItem=options.items.data[0];if(!firstItem)throw Error("Stripe subscription contains no line items - cannot update subscription");await db?.updateTable("subscriptions").set({type,provider_price_id:firstItem.price.id,unit_price:Number(firstItem.price.unit_amount)}).where("id","=",activeSubId).executeTakeFirst();return subscription}function removeNullValues(obj){return Object.fromEntries(Object.entries(obj).filter(([_,value])=>value!=null))}return{create,update,isValid,isIncomplete,cancel,retrieve}})();
1
+ import{db}from"@stacksjs/database/runtime";import{HttpError}from"@stacksjs/error-handling";import{isUniqueViolation}from"@stacksjs/orm";import{stripe}from"../drivers/stripe";import{manageCustomer}from"./customer";import{managePrice}from"./price";import{stacksIdempotencyKey}from"../idempotency";export const ENTITLING_STATUSES=["active","trialing"],INCOMPLETE_STATUSES=["incomplete"],SUBSCRIPTION_CREATE_EXPAND=["latest_invoice.confirmation_secret"],manageSubscription=(()=>{async function create(user,type,lookupKey,params){const price=await managePrice.retrieveByLookupKey(lookupKey);if(!price)throw Error("Price does not exist in Stripe");const subscriptionItems=[{price:price.id,quantity:1}],mergedParams={...{customer:await manageCustomer.createOrGetStripeUser(user,{}).then((customer)=>{if(!customer||!customer.id)throw Error("Customer does not exist in Stripe");return customer.id}),payment_behavior:"allow_incomplete",expand:[...SUBSCRIPTION_CREATE_EXPAND],items:subscriptionItems},...params},subscription=await stripe.subscriptions.create(mergedParams,{idempotencyKey:stacksIdempotencyKey("subscription.create",user.id,type,lookupKey)});await storeSubscription(user,type,lookupKey,subscription);return subscription}async function update(user,type,lookupKey,_params={}){const newPrice=await managePrice.retrieveByLookupKey(lookupKey),activeSubscription=await user?.activeSubscription();if(!newPrice)throw Error("New price does not exist in Stripe");if(!activeSubscription)throw Error("No active subscription for user!");const subscriptionId=activeSubscription.subscription?.provider_id;if(!subscriptionId)throw Error("Active subscription has no provider ID");const subscription=await stripe.subscriptions.retrieve(subscriptionId);if(!subscription)throw Error("Subscription does not exist in Stripe");const subscriptionItemId=subscription.items.data[0]?.id;if(!subscriptionItemId)throw Error("No subscription items found in the subscription");await stripe.subscriptions.update(subscriptionId,{items:[{id:subscriptionItemId,price:newPrice.id,quantity:1}],proration_behavior:"create_prorations"},{idempotencyKey:stacksIdempotencyKey("subscription.update",subscriptionId,newPrice.id)});const updatedSubscription=await stripe.subscriptions.retrieve(subscriptionId);if(!activeSubscription.subscription?.id)throw Error("Active subscription has no database ID");await updateSubscription(activeSubscription.subscription.id,type,updatedSubscription);return updatedSubscription}async function cancel(subscriptionId,params){if(!await stripe.subscriptions.retrieve(subscriptionId))throw Error("Subscription does not exist or does not belong to the user");const updatedSubscription=await stripe.subscriptions.cancel(subscriptionId,params,{idempotencyKey:stacksIdempotencyKey("subscription.cancel",subscriptionId)});await updateStoredSubscription(subscriptionId);return updatedSubscription}async function retrieve(user,subscriptionId){if(!manageCustomer.hasStripeId(user))throw Error("Customer does not exist in Stripe");return await stripe.subscriptions.retrieve(subscriptionId)}async function updateStoredSubscription(subscriptionId){await db.updateTable("subscriptions").set({provider_status:"canceled"}).where("provider_id","=",subscriptionId).executeTakeFirst()}function isActive(subscription){return subscription.provider_status==="active"}function isTrial(subscription){return subscription.provider_status==="trialing"}async function hasSubscriptionInStatus(user,type,statuses){const match=await db.selectFrom("subscriptions").where("user_id","=",user.id).where("type","=",type).whereIn("provider_status",[...statuses]).select(["id"]).limit(1).executeTakeFirst();return Boolean(match)}async function isIncomplete(user,type){return await hasSubscriptionInStatus(user,type,INCOMPLETE_STATUSES)}async function isValid(user,type){return await hasSubscriptionInStatus(user,type,ENTITLING_STATUSES)}async function storeSubscription(user,type,_lookupKey,options){const firstItem=options.items.data[0];if(!firstItem)throw Error("Stripe subscription contains no line items - cannot store subscription");const data=removeNullValues({user_id:user.id,type,unit_price:Number(firstItem.price.unit_amount),provider_id:options.id,provider_status:options.status,provider_price_id:firstItem.price.id,quantity:firstItem.quantity,trial_ends_at:options.trial_end!=null?String(options.trial_end):void 0,ends_at:options.current_period_end!=null?String(options.current_period_end):void 0,provider_type:"stripe",last_used_at:options.current_period_end!=null?String(options.current_period_end):void 0});let subscriptionModelCreated;try{subscriptionModelCreated=await db.insertInto("subscriptions").values(data).executeTakeFirst()}catch(error){if(isUniqueViolation(error))throw new HttpError(409,"A subscription with this provider ID already exists");throw error}if(!subscriptionModelCreated)throw Error("Failed to insert subscription record");return await db.selectFrom("subscriptions").where("id","=",Number(subscriptionModelCreated.insertId)).selectAll().executeTakeFirst()}async function updateSubscription(activeSubId,type,options){const subscription=await db.selectFrom("subscriptions").where("id","=",activeSubId).selectAll().executeTakeFirst(),firstItem=options.items.data[0];if(!firstItem)throw Error("Stripe subscription contains no line items - cannot update subscription");await db?.updateTable("subscriptions").set({type,provider_price_id:firstItem.price.id,unit_price:Number(firstItem.price.unit_amount)}).where("id","=",activeSubId).executeTakeFirst();return subscription}function removeNullValues(obj){return Object.fromEntries(Object.entries(obj).filter(([_,value])=>value!=null))}return{create,update,isValid,isIncomplete,cancel,retrieve}})();
@@ -31,6 +31,7 @@ export declare interface AdyenConfig {
31
31
  environment: 'test' | 'live'
32
32
  liveUrlPrefix?: string
33
33
  hmacKey?: string
34
+ appUrl?: string
34
35
  shopperReferencePrefix?: string
35
36
  }
36
37
  declare interface NotificationItem {
@@ -41,6 +42,7 @@ declare interface NotificationItem {
41
42
  amount?: { value?: number, currency?: string }
42
43
  eventCode?: string
43
44
  success?: string | boolean
45
+ reason?: string
44
46
  additionalData?: Record<string, string>
45
47
  }
46
48
  export declare class AdyenDriver implements PaymentDriver {
@@ -1 +1 @@
1
- import{createHmac,randomUUID,timingSafeEqual}from"node:crypto";import{assertMoney,PaymentProviderError,PaymentUnsupportedError,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}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.");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.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.currency)throw TypeError("An Adyen checkout needs a currency.");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 Error("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{id:`${item.eventCode}:${item.pspReference}`,type:pair?pair[success?0:1]:"unknown",providerType:item.eventCode??"",reference:item.pspReference??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,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.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.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 Error("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 process from"node:process";import{config,services}from"@stacksjs/config";import{manageCharge}from"../billable/charge";import{manageCheckout}from"../billable/checkout";import{manageCustomer}from"../billable/customer";import{managePaymentMethod}from"../billable/payment-method";import{manageSubscription}from"../billable/subscription";import{constructEvent}from"../billable/webhook";import{stripe}from"../drivers/stripe";import{freshIdempotencyKey}from"../idempotency";import{AdyenDriver}from"./adyen";import{StripeDriver}from"./stripe";export*from"./adyen";export*from"./stripe";export*from"./types";export const defaultStripeOperations={customer:(payer)=>manageCustomer.createOrGetStripeUser(payer,{}),createIntent:(payer,params,idempotencyKey)=>stripe.paymentIntents.create(params,{idempotencyKey:idempotencyKey??freshIdempotencyKey("payment_intent.create",payer.id,params.amount)}),refund:(paymentIntentId,params)=>manageCharge.refund(paymentIntentId,params),createCheckout:(payer,params)=>manageCheckout.create(payer,params),listPaymentMethods:async(customerId)=>(await stripe.paymentMethods.list({customer:customerId,limit:100})).data,deletePaymentMethod:(payer,paymentMethodId)=>managePaymentMethod.deletePaymentMethod(payer,paymentMethodId),subscribe:(payer,type,lookupKey)=>manageSubscription.create(payer,type,lookupKey,{}),cancelSubscription:(subscriptionId,atPeriodEnd)=>atPeriodEnd?stripe.subscriptions.update(subscriptionId,{cancel_at_period_end:!0}):manageSubscription.cancel(subscriptionId),listSubscriptions:async(customerId)=>(await stripe.subscriptions.list({customer:customerId,status:"all",limit:100})).data,constructEvent:(payload,signature,secret)=>constructEvent(payload,signature,secret)};const custom=new Map;export function registerPaymentDriver(name,factory){if(name==="stripe"||name==="adyen")throw Error(`"${name}" is a built-in payment driver and cannot be replaced.`);custom.set(name,factory)}export function adyenConfig(settings={},env=process.env){const environment=settings.environment??(env.ADYEN_ENVIRONMENT==="live"?"live":"test");return{apiKey:settings.apiKey||env.ADYEN_API_KEY||"",merchantAccount:settings.merchantAccount||env.ADYEN_MERCHANT_ACCOUNT||"",environment,liveUrlPrefix:settings.liveUrlPrefix||env.ADYEN_LIVE_URL_PREFIX||void 0,hmacKey:settings.hmacKey||env.ADYEN_HMAC_KEY||void 0,shopperReferencePrefix:settings.shopperReferencePrefix}}export function paymentDriver(name){const settings=config.payment??{},chosen=name??settings.driver??"stripe";if(chosen==="stripe")return new StripeDriver({operations:defaultStripeOperations,webhookSecret:settings.stripe?.webhookSecret||services?.stripe?.webhookSecret||process.env.STRIPE_WEBHOOK_SECRET||void 0});if(chosen==="adyen")return new AdyenDriver(adyenConfig(settings.adyen));const factory=custom.get(chosen);if(!factory)throw Error(`No payment driver named "${chosen}". Built in: stripe, adyen${custom.size?`; registered: ${[...custom.keys()].join(", ")}`:""}.`);return factory()}
1
+ import process from"node:process";import{config,services}from"@stacksjs/config";import{manageCharge}from"../billable/charge";import{manageCheckout}from"../billable/checkout";import{manageCustomer}from"../billable/customer";import{managePaymentMethod}from"../billable/payment-method";import{manageSubscription}from"../billable/subscription";import{constructEvent}from"../billable/webhook";import{stripe}from"../drivers/stripe";import{freshIdempotencyKey}from"../idempotency";import{AdyenDriver}from"./adyen";import{StripeDriver}from"./stripe";export*from"./adyen";export*from"./stripe";export*from"./types";export const defaultStripeOperations={customer:(payer)=>manageCustomer.createOrGetStripeUser(payer,{}),createIntent:(payer,params,idempotencyKey)=>stripe.paymentIntents.create(params,{idempotencyKey:idempotencyKey??freshIdempotencyKey("payment_intent.create",payer.id,params.amount)}),refund:(paymentIntentId,params)=>manageCharge.refund(paymentIntentId,params),createCheckout:(payer,params)=>manageCheckout.create(payer,params),listPaymentMethods:async(customerId)=>(await stripe.paymentMethods.list({customer:customerId,limit:100})).data,deletePaymentMethod:(payer,paymentMethodId)=>managePaymentMethod.deletePaymentMethod(payer,paymentMethodId),subscribe:(payer,type,lookupKey)=>manageSubscription.create(payer,type,lookupKey,{}),cancelSubscription:(subscriptionId,atPeriodEnd)=>atPeriodEnd?stripe.subscriptions.update(subscriptionId,{cancel_at_period_end:!0}):manageSubscription.cancel(subscriptionId),listSubscriptions:async(customerId)=>(await stripe.subscriptions.list({customer:customerId,status:"all",limit:100})).data,constructEvent:(payload,signature,secret)=>constructEvent(payload,signature,secret)};const custom=new Map;export function registerPaymentDriver(name,factory){if(name==="stripe"||name==="adyen")throw Error(`"${name}" is a built-in payment driver and cannot be replaced.`);custom.set(name,factory)}export function adyenConfig(settings={},env=process.env){const environment=settings.environment??(env.ADYEN_ENVIRONMENT==="live"?"live":"test");return{apiKey:settings.apiKey||env.ADYEN_API_KEY||"",merchantAccount:settings.merchantAccount||env.ADYEN_MERCHANT_ACCOUNT||"",environment,liveUrlPrefix:settings.liveUrlPrefix||env.ADYEN_LIVE_URL_PREFIX||void 0,hmacKey:settings.hmacKey||env.ADYEN_HMAC_KEY||void 0,shopperReferencePrefix:settings.shopperReferencePrefix}}export function paymentDriver(name){const settings=config.payment??{},chosen=name??settings.driver??"stripe";if(chosen==="stripe")return new StripeDriver({operations:defaultStripeOperations,webhookSecret:settings.stripe?.webhookSecret||services?.stripe?.webhookSecret||process.env.STRIPE_WEBHOOK_SECRET||void 0});if(chosen==="adyen"){const appUrl=config.app?.url;return new AdyenDriver({...adyenConfig(settings.adyen),...appUrl?{appUrl}:{}})}const factory=custom.get(chosen);if(!factory)throw Error(`No payment driver named "${chosen}". Built in: stripe, adyen${custom.size?`; registered: ${[...custom.keys()].join(", ")}`:""}.`);return factory()}
@@ -5,6 +5,15 @@ import type Stripe from 'stripe';
5
5
  * not yet captured - no money has moved - so it is still `processing`.
6
6
  */
7
7
  export declare function stripePaymentStatus(intent: Pick<Stripe.PaymentIntent, 'status' | 'last_payment_error'>): PaymentStatus;
8
+ /**
9
+ * What one `charge.refunded` refunded. The charge carries the running total,
10
+ * so a second partial refund would otherwise be counted as the first plus
11
+ * itself; the event's `previous_attributes` holds the total before it.
12
+ */
13
+ export declare function refundedThisTime(charge: Record<string, any>, previous?: Record<string, any> | null): number | undefined;
14
+ export declare function summarizeSubscription(subscription: Stripe.Subscription): SubscriptionSummary;
15
+ /** A Stripe PaymentMethod in our terms. */
16
+ export declare function toStoredPaymentMethod(method: Stripe.PaymentMethod, defaultId?: string | null): StoredPaymentMethod;
8
17
  /**
9
18
  * The Stripe calls the driver needs, separated from the mapping so the mapping
10
19
  * is testable without Stripe. The defaults delegate to the billable modules
@@ -1 +1 @@
1
- import{assertMoney,headerOf,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"}}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"}function summarizeSubscription(subscription){const item=subscription.items?.data?.[0],periodEnd=item?.current_period_end??subscription.current_period_end;return{id:subscription.id,status:subscriptionStatus(subscription.status),price:item?.price?.id??null,currentPeriodEnd:periodEnd?new Date(periodEnd*1000):null,cancelAtPeriodEnd:Boolean(subscription.cancel_at_period_end),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"};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){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}}}}),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}:{}});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)=>({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}))}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 Error("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"?object.payment_intent??object.id:object.id,amount=typeof object.amount==="number"&&object.currency?{amount:object.amount,currency:String(object.currency)}:typeof object.amount_total==="number"&&object.currency?{amount:object.amount_total,currency:String(object.currency)}:null;return[{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,raw:event}]}acknowledgeWebhook(){return Response.json({received:!0})}}
1
+ import{assertMoney,headerOf,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 summarizeSubscription(subscription){const item=subscription.items?.data?.[0],periodEnd=item?.current_period_end??subscription.current_period_end,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){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}}}}),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}:{}});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 Error("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})}}
@@ -76,22 +76,31 @@ export declare interface StoredPaymentMethod {
76
76
  isDefault: boolean
77
77
  raw: unknown
78
78
  }
79
+ export declare interface SubscriptionPrice {
80
+ id: string
81
+ name: string | null
82
+ amount: Money | null
83
+ interval: 'day' | 'week' | 'month' | 'year' | null
84
+ }
79
85
  export declare interface SubscriptionSummary {
80
86
  id: string
81
87
  status: SubscriptionStatus
82
- price: string | null
88
+ price: SubscriptionPrice | null
83
89
  currentPeriodEnd: Date | null
84
90
  cancelAtPeriodEnd: boolean
91
+ clientConfirmation?: ClientConfirmation
85
92
  raw: unknown
86
93
  }
87
94
  /** A verified webhook notification, in our terms. */
88
95
  export declare interface PaymentEvent {
96
+ provider: string
89
97
  id: string
90
98
  type: PaymentEventType
91
99
  providerType: string
92
100
  reference: string | null
93
101
  merchantReference: string | null
94
102
  amount: Money | null
103
+ reason: string | null
95
104
  raw: unknown
96
105
  }
97
106
  export declare interface WebhookRequest {
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.76",
5
+ "version": "0.75.77",
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.76",
64
- "@stacksjs/database": "0.75.76",
65
- "@stacksjs/error-handling": "0.75.76",
66
- "@stacksjs/logging": "0.75.76",
67
- "@stacksjs/orm": "0.75.76",
68
- "@stacksjs/types": "0.75.76"
63
+ "@stacksjs/config": "0.75.77",
64
+ "@stacksjs/database": "0.75.77",
65
+ "@stacksjs/error-handling": "0.75.77",
66
+ "@stacksjs/logging": "0.75.77",
67
+ "@stacksjs/orm": "0.75.77",
68
+ "@stacksjs/types": "0.75.77"
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.76",
83
+ "@stacksjs/utils": "0.75.77",
84
84
  "@stripe/stripe-js": "^10.0.0",
85
85
  "better-dx": "^0.2.24",
86
86
  "stripe": "23.0.0"