@stacksjs/payments 0.75.367 → 0.75.370

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -70,4 +70,6 @@ Call after acquiring the application's payment lock. Persist immutable movement
70
70
 
71
71
  Balance amounts, fees and net remain in their actual settlement currency with the provider exchange rate. Contributor allocations belong in their own presentment-currency ledger; a dispute status alone does not prove reinstatement, and a charge refund does not prove that the application fee was returned.
72
72
 
73
+ `directChargeFeeAmounts(facts)` uses the application fee's canonical `amount_refunded` in its declared currency. Individual fee refunds can settle in another currency; their amounts are never added across currencies. When the fee and charge share a currency, partial and full returns are exact. When they differ, only a fully retained or fully returned fee establishes exact presentment cents; partial returns require review without an inferred exchange rate. See Stripe's [application fee object](https://docs.stripe.com/api/application_fees/object).
74
+
73
75
  References: [direct charges](https://docs.stripe.com/connect/direct-charges), [refund pagination](https://docs.stripe.com/api/refunds/list), [disputes](https://docs.stripe.com/api/disputes/list), [application fees](https://docs.stripe.com/api/application_fees/list).
@@ -1 +1 @@
1
- import{stripe}from"../drivers/stripe";import{asAccount}from"./direct";import{allPages}from"./pagination";export class DirectChargeFinancialsPendingError extends Error{}export function directChargeFeeAmounts(facts){const cents=(value)=>{if(typeof value!=="number"||!Number.isSafeInteger(value)||value<0)throw Error("Invalid direct-charge fee amount");return value},currency=facts.charge.currency.toUpperCase(),chargedCents=cents(facts.charge.application_fee_amount??0),unknown=()=>({currency,chargedCents,returnedCents:null,remainingCents:null,requiresReview:!0}),known=(returnedCents)=>({currency,chargedCents,returnedCents,remainingCents:chargedCents-returnedCents,requiresReview:!1}),fee=facts.applicationFee;if(!fee)return chargedCents===0&&!facts.applicationFeeRefunds.length?known(0):unknown();const amount=cents(fee.amount),returned=cents(fee.amount_refunded),recorded=facts.applicationFeeRefunds.reduce((sum,refund)=>sum+cents(refund.amount),0);if(id(fee.account)!==facts.account||id(fee.charge)!==facts.charge.id||facts.applicationFeeRefunds.some((refund)=>id(refund.fee)!==fee.id||refund.currency.toUpperCase()!==fee.currency.toUpperCase()||refund.amount<=0)||!Number.isSafeInteger(recorded)||recorded!==returned||returned>amount||chargedCents===0&&amount>0)return unknown();if(fee.currency.toUpperCase()===currency)return amount===chargedCents?known(returned):unknown();if(returned===0)return known(0);if(returned===amount)return known(chargedCents);return unknown()}function id(value){return typeof value==="string"?value:String(value?.id||"")}export async function directChargeFinancials(paymentIntentId,account,client=stripe){const options=asAccount(account);if(!/^pi_[a-z0-9_]+$/i.test(paymentIntentId))throw Error("Expected a Stripe PaymentIntent ID");const intent=await client.paymentIntents.retrieve(paymentIntentId,{},options);if(intent.id!==paymentIntentId||intent.status!=="succeeded"||!id(intent.latest_charge))throw new DirectChargeFinancialsPendingError("The original payment has no successful charge yet");const charge=await client.charges.retrieve(id(intent.latest_charge),{},options);if(id(charge.payment_intent)!==paymentIntentId||charge.id!==id(intent.latest_charge)||!charge.paid||!charge.captured)throw Error("Charge does not match the successful direct payment");const refunds=await allPages((after)=>client.refunds.list({charge:charge.id,limit:100,...after?{starting_after:after}:{}},options)),disputes=await allPages((after)=>client.disputes.list({charge:charge.id,limit:100,...after?{starting_after:after}:{}},options));for(const item of[...refunds,...disputes])if(id(item.charge)!==charge.id||item.payment_intent&&id(item.payment_intent)!==paymentIntentId)throw Error("Foreign refund or dispute in direct-charge financials");let applicationFee=null;if(id(charge.application_fee))applicationFee=await client.applicationFees.retrieve(id(charge.application_fee));else if(Number(charge.application_fee_amount)>0){const fees=await allPages((after)=>client.applicationFees.list({charge:charge.id,limit:100,...after?{starting_after:after}:{}}));if(fees.length!==1)throw new DirectChargeFinancialsPendingError("The direct-charge application fee is not available yet");applicationFee=fees[0]}if(applicationFee&&(id(applicationFee.account)!==account||id(applicationFee.charge)!==charge.id))throw Error("Application fee belongs to another direct charge or account");const applicationFeeRefunds=applicationFee?await allPages((after)=>client.applicationFees.listRefunds(applicationFee.id,{limit:100,...after?{starting_after:after}:{}})):[];if(applicationFeeRefunds.some((refund)=>id(refund.fee)!==applicationFee.id))throw Error("Foreign application-fee refund");const movements=[],seen=new Map;async function movement(reference,sourceId,kind,scope,required=!0,providerSourceId=sourceId){const transactionId=id(reference);if(!transactionId){if(required)throw new DirectChargeFinancialsPendingError("A provider balance transaction is not available yet");return}if(!/^txn_[a-z0-9_]+$/i.test(transactionId))throw Error("Invalid provider balance transaction");const transaction=await client.balanceTransactions.retrieve(transactionId,{},scope==="platform"?void 0:options);if(transaction.id!==transactionId||id(transaction.source)&&id(transaction.source)!==providerSourceId)throw Error("Foreign provider balance transaction");const entry={account:scope,sourceId,kind,transaction},key=`${scope}:${transactionId}`;if(seen.has(key)){if(JSON.stringify(seen.get(key))!==JSON.stringify(entry))throw Error("Conflicting provider balance transaction");return}seen.set(key,entry);movements.push(entry)}await movement(charge.balance_transaction,charge.id,"charge",account);for(const refund of refunds){await movement(refund.balance_transaction,refund.id,"refund",account,refund.status==="succeeded");await movement(refund.failure_balance_transaction,refund.id,"refund",account,!1)}for(const dispute of disputes)for(const transaction of dispute.balance_transactions)await movement(transaction,dispute.id,"dispute",account);if(applicationFee)await movement(applicationFee.balance_transaction,applicationFee.id,"application_fee","platform");for(const refund of applicationFeeRefunds)await movement(refund.balance_transaction,refund.id,"application_fee_refund","platform",!0,applicationFee.id);return{account,charge,refunds,disputes,applicationFee,applicationFeeRefunds,movements}}
1
+ import{stripe}from"../drivers/stripe";import{asAccount}from"./direct";import{allPages}from"./pagination";export class DirectChargeFinancialsPendingError extends Error{}export function directChargeFeeAmounts(facts){const cents=(value)=>{if(typeof value!=="number"||!Number.isSafeInteger(value)||value<0)throw Error("Invalid direct-charge fee amount");return value},currency=facts.charge.currency.toUpperCase(),chargedCents=cents(facts.charge.application_fee_amount??0),unknown=()=>({currency,chargedCents,returnedCents:null,remainingCents:null,requiresReview:!0}),known=(returnedCents)=>({currency,chargedCents,returnedCents,remainingCents:chargedCents-returnedCents,requiresReview:!1}),fee=facts.applicationFee;if(!fee)return chargedCents===0&&!facts.applicationFeeRefunds.length?known(0):unknown();const amount=cents(fee.amount),returned=cents(fee.amount_refunded),feeCurrency=fee.currency?.toUpperCase(),refunds=facts.applicationFeeRefunds,amounts=refunds.map((refund)=>cents(refund.amount)),recorded=refunds.every((refund)=>refund.currency?.toUpperCase()===feeCurrency)?amounts.reduce((sum,value)=>sum+value,0):null;if(id(fee.account)!==facts.account||id(fee.charge)!==facts.charge.id||!/^[A-Z]{3}$/.test(feeCurrency||"")||refunds.some((refund)=>id(refund.fee)!==fee.id||!/^[a-z]{3}$/i.test(refund.currency||"")||refund.amount<=0)||recorded!==null&&(!Number.isSafeInteger(recorded)||recorded!==returned)||returned>0!==refunds.length>0||returned>amount||chargedCents===0&&amount>0)return unknown();if(feeCurrency===currency)return amount===chargedCents?known(returned):unknown();if(returned===0)return known(0);if(returned===amount)return known(chargedCents);return unknown()}function id(value){return typeof value==="string"?value:String(value?.id||"")}export async function directChargeFinancials(paymentIntentId,account,client=stripe){const options=asAccount(account);if(!/^pi_[a-z0-9_]+$/i.test(paymentIntentId))throw Error("Expected a Stripe PaymentIntent ID");const intent=await client.paymentIntents.retrieve(paymentIntentId,{},options);if(intent.id!==paymentIntentId||intent.status!=="succeeded"||!id(intent.latest_charge))throw new DirectChargeFinancialsPendingError("The original payment has no successful charge yet");const charge=await client.charges.retrieve(id(intent.latest_charge),{},options);if(id(charge.payment_intent)!==paymentIntentId||charge.id!==id(intent.latest_charge)||!charge.paid||!charge.captured)throw Error("Charge does not match the successful direct payment");const refunds=await allPages((after)=>client.refunds.list({charge:charge.id,limit:100,...after?{starting_after:after}:{}},options)),disputes=await allPages((after)=>client.disputes.list({charge:charge.id,limit:100,...after?{starting_after:after}:{}},options));for(const item of[...refunds,...disputes])if(id(item.charge)!==charge.id||item.payment_intent&&id(item.payment_intent)!==paymentIntentId)throw Error("Foreign refund or dispute in direct-charge financials");let applicationFee=null;if(id(charge.application_fee))applicationFee=await client.applicationFees.retrieve(id(charge.application_fee));else if(Number(charge.application_fee_amount)>0){const fees=await allPages((after)=>client.applicationFees.list({charge:charge.id,limit:100,...after?{starting_after:after}:{}}));if(fees.length!==1)throw new DirectChargeFinancialsPendingError("The direct-charge application fee is not available yet");applicationFee=fees[0]}if(applicationFee&&(id(applicationFee.account)!==account||id(applicationFee.charge)!==charge.id))throw Error("Application fee belongs to another direct charge or account");const applicationFeeRefunds=applicationFee?await allPages((after)=>client.applicationFees.listRefunds(applicationFee.id,{limit:100,...after?{starting_after:after}:{}})):[];if(applicationFeeRefunds.some((refund)=>id(refund.fee)!==applicationFee.id))throw Error("Foreign application-fee refund");const movements=[],seen=new Map;async function movement(reference,sourceId,kind,scope,required=!0,providerSourceId=sourceId){const transactionId=id(reference);if(!transactionId){if(required)throw new DirectChargeFinancialsPendingError("A provider balance transaction is not available yet");return}if(!/^txn_[a-z0-9_]+$/i.test(transactionId))throw Error("Invalid provider balance transaction");const transaction=await client.balanceTransactions.retrieve(transactionId,{},scope==="platform"?void 0:options);if(transaction.id!==transactionId||id(transaction.source)&&id(transaction.source)!==providerSourceId)throw Error("Foreign provider balance transaction");const entry={account:scope,sourceId,kind,transaction},key=`${scope}:${transactionId}`;if(seen.has(key)){if(JSON.stringify(seen.get(key))!==JSON.stringify(entry))throw Error("Conflicting provider balance transaction");return}seen.set(key,entry);movements.push(entry)}await movement(charge.balance_transaction,charge.id,"charge",account);for(const refund of refunds){await movement(refund.balance_transaction,refund.id,"refund",account,refund.status==="succeeded");await movement(refund.failure_balance_transaction,refund.id,"refund",account,!1)}for(const dispute of disputes)for(const transaction of dispute.balance_transactions)await movement(transaction,dispute.id,"dispute",account);if(applicationFee)await movement(applicationFee.balance_transaction,applicationFee.id,"application_fee","platform");for(const refund of applicationFeeRefunds)await movement(refund.balance_transaction,refund.id,"application_fee_refund","platform",!0,applicationFee.id);return{account,charge,refunds,disputes,applicationFee,applicationFeeRefunds,movements}}
@@ -20,3 +20,8 @@ export declare interface DirectInvoicePurchaseOptions extends Pick<DirectInvoice
20
20
  metadata?: Record<string, string>
21
21
  }
22
22
  declare type PurchaseClient = Pick<Stripe, 'setupIntents' | 'prices' | 'paymentMethods' | 'invoices' | 'invoiceItems' | 'subscriptionSchedules' | 'subscriptions' | 'invoicePayments' | 'paymentIntents'>;
23
+ /** The quote expired without a bound purchase or an unbound provider resource
24
+ * that could be an interrupted purchase. Original local IDs still need review. */
25
+ export declare class DirectInvoicePurchaseExpiredError extends Error {
26
+
27
+ }
@@ -1 +1 @@
1
- import{stripe}from"../drivers/stripe";import{idempotencyFingerprint,stacksIdempotencyKey}from"../idempotency";import{asAccount}from"./direct";import{collectDirectInvoice}from"./invoice-collection";import{allPages}from"./pagination";import{manageDestinationSubscription}from"./subscription";export function directInvoicePurchaseMetadata(account,options){asAccount(account);if(typeof options.reference!=="string"||!/^[a-z0-9:._-]{1,160}$/i.test(options.reference)||!/^cus_[a-z0-9_]+$/i.test(options.customer)||!/^price_[a-z0-9_]+$/i.test(options.price)||!Number.isSafeInteger(options.expiresAt)||options.expiresAt<=0||!Number.isSafeInteger(options.amount)||options.amount<=0||!/^[a-z]{3}$/i.test(options.currency)||!/^txcd_\d{8}$/.test(options.taxCode)||options.interval!==void 0&&!["day","week","month","year"].includes(options.interval)||options.intervalCount!==void 0&&(!options.interval||!Number.isSafeInteger(options.intervalCount)||options.intervalCount<1)||options.trialDays!==void 0&&(!Number.isSafeInteger(options.trialDays)||options.trialDays<0||options.trialDays>730||!options.interval&&options.trialDays>0))throw Error("[payments/connect] Expected original customer, price and frozen invoice purchase terms");if(options.platformFeePercent==null)throw Error("[payments/connect] An explicit invoice fee percentage is required");manageDestinationSubscription.feePercentFor({platformFeePercent:options.platformFeePercent});const metadata=options.metadata??{};if(typeof metadata!=="object"||Array.isArray(metadata)||Object.keys(metadata).length>47||Object.entries(metadata).some(([key,value])=>!key||key.length>40||/[\[\]]/.test(key)||key.startsWith("stacks_purchase_")||typeof value!=="string"||value.length>500))throw Error("[payments/connect] Invalid purchase metadata or reserved binding fields");const terms={account,reference:options.reference,customer:options.customer,price:options.price,amount:options.amount,currency:options.currency.toLowerCase(),taxCode:options.taxCode,expiresAt:options.expiresAt,interval:options.interval??null,intervalCount:options.interval?options.intervalCount??1:null,trialDays:options.trialDays??0,platformFeePercent:options.platformFeePercent,metadata};return{...metadata,stacks_purchase_reference:options.reference,stacks_purchase_quote:idempotencyFingerprint(terms)}}function id(value){return typeof value==="string"?value:value?.id}export async function completeDirectInvoicePurchase(setupIntentId,account,options,client=stripe){const quote=directInvoicePurchaseMetadata(account,options),request=asAccount(account);if(!/^seti_[a-z0-9_]+$/i.test(setupIntentId))throw Error("[payments/connect] Expected original successful payment setup");const setup=await client.setupIntents.retrieve(setupIntentId,{},request),matches=(metadata,expected)=>Object.entries(expected).every(([key,value])=>metadata?.[key]===value);if(setup.id!==setupIntentId||setup.status!=="succeeded"||setup.usage!=="off_session"||!Number.isSafeInteger(setup.created)||setup.created<=0||id(setup.customer)!==options.customer||!/^pm_[a-z0-9_]+$/i.test(id(setup.payment_method)||"")||!matches(setup.metadata,quote))throw Error("[payments/connect] Payment setup does not match the original customer and frozen quote");const paymentMethod=id(setup.payment_method),metadata={...quote,stacks_purchase_setup:setupIntentId},key=(operation)=>stacksIdempotencyKey(`connect.invoice.purchase.${operation}`,account,options.reference,quote.stacks_purchase_quote),canCreate=()=>{if(Date.now()/1000>=Math.min(options.expiresAt,setup.created+86400))throw Error("[payments/connect] Expired purchase can only recover its original provider invoice or schedule")},existing=(rows)=>{const own=rows.filter((row)=>row.metadata?.stacks_purchase_reference===options.reference);if(own.length>1||own.some((row)=>!matches(row.metadata,metadata)))throw Error("[payments/connect] Purchase reference has ambiguous or different provider bindings");return own[0]},verifyPrice=async()=>{const price=await client.prices.retrieve(options.price,{expand:["product"]},request),product=price.product;if(price.id!==options.price||!price.active||price.currency!==options.currency.toLowerCase()||price.unit_amount!==options.amount||price.tax_behavior!=="inclusive"||price.type!==(options.interval?"recurring":"one_time")||(options.interval?price.recurring?.interval!==options.interval||price.recurring?.interval_count!==(options.intervalCount??1)||price.recurring?.usage_type!=="licensed":price.recurring!=null)||typeof product==="string"||product.deleted||!product.active||id(product.tax_code)!==options.taxCode)throw Error("[payments/connect] Catalog price does not match the approved inclusive purchase");const method=await client.paymentMethods.retrieve(paymentMethod,{},request);if(method.id!==paymentMethod||id(method.customer)!==options.customer)throw Error("[payments/connect] Purchase payment method belongs to another customer")};let invoice,subscription,reloadSubscription;if(options.interval){let schedule=existing(await allPages((after)=>client.subscriptionSchedules.list({customer:options.customer,limit:100,...after?{starting_after:after}:{}},request)));if(!schedule){canCreate();await verifyPrice();const phase={items:[{price:options.price,quantity:1}],automatic_tax:{enabled:!0},metadata};schedule=await client.subscriptionSchedules.create({customer:options.customer,start_date:"now",end_behavior:"release",default_settings:{default_payment_method:paymentMethod},phases:options.trialDays?[{...phase,trial:!0,duration:{interval:"day",interval_count:options.trialDays}},phase]:[phase],metadata},{...request,idempotencyKey:key("schedule")})}const subscriptionId=id(schedule.subscription)??id(schedule.released_subscription);if(!/^sub_sched_[a-z0-9_]+$/i.test(schedule.id)||id(schedule.customer)!==options.customer||!matches(schedule.metadata,metadata)||!/^sub_[a-z0-9_]+$/i.test(subscriptionId||""))throw Error("[payments/connect] Purchase schedule has no original customer and subscription");const readSubscription=async()=>{const value=await client.subscriptions.retrieve(subscriptionId,{},request);if(value.id!==subscriptionId||id(value.customer)!==options.customer||!matches(value.metadata,metadata)||value.items.has_more||value.items.data.length!==1||id(value.items.data[0].price)!==options.price||value.items.data[0].quantity!==1)throw Error("[payments/connect] Subscription does not match the original frozen purchase");return value};reloadSubscription=readSubscription;subscription=await readSubscription();const initial=(await allPages((after)=>client.invoices.list({customer:options.customer,subscription:subscriptionId,limit:100,...after?{starting_after:after}:{}},request))).filter((value)=>value.billing_reason==="subscription_create"&&id(value.parent?.subscription_details?.subscription)===subscriptionId);if(initial.length!==1)throw Error("[payments/connect] Purchase has no unique original subscription invoice");invoice=await client.invoices.retrieve(initial[0].id,{},request);if(invoice.id!==initial[0].id||id(invoice.customer)!==options.customer||id(invoice.parent?.subscription_details?.subscription)!==subscriptionId||!matches(invoice.parent?.subscription_details?.metadata,metadata))throw Error("[payments/connect] First invoice does not match the original subscription quote");if(invoice.status!=="paid"&&["canceled","incomplete_expired","unpaid"].includes(subscription.status))throw Error("[payments/connect] Subscription no longer permits its initial collection");if(invoice.status==="draft"&&invoice.auto_advance!==!1){invoice=await client.invoices.update(invoice.id,{auto_advance:!1},{...request,idempotencyKey:key("hold")});if(invoice.id!==initial[0].id||id(invoice.customer)!==options.customer||id(invoice.parent?.subscription_details?.subscription)!==subscriptionId||!matches(invoice.parent?.subscription_details?.metadata,metadata))throw Error("[payments/connect] Held invoice no longer matches the original subscription")}if(schedule.status==="active"){if(id(subscription.schedule)!==schedule.id)throw Error("[payments/connect] Subscription belongs to another schedule");const trialEnd=subscription.trial_end,released=await client.subscriptionSchedules.release(schedule.id,{},{...request,idempotencyKey:key("release")});if(released.id!==schedule.id||released.status!=="released"||id(released.released_subscription)!==subscriptionId)throw Error("[payments/connect] Schedule release did not preserve its original subscription");subscription=await readSubscription();if(subscription.schedule!=null||subscription.trial_end!==trialEnd)throw Error("[payments/connect] Released purchase changed its schedule or native trial");if(invoice.status!=="paid"&&["canceled","incomplete_expired","unpaid"].includes(subscription.status))throw Error("[payments/connect] Subscription no longer permits its initial collection")}else if(!["released","completed","canceled"].includes(schedule.status))throw Error("[payments/connect] Purchase schedule has not started")}else{const previous=existing(await allPages((after)=>client.invoices.list({customer:options.customer,limit:100,...after?{starting_after:after}:{}},request)));if(previous)invoice=await client.invoices.retrieve(previous.id,{},request);else{canCreate();await verifyPrice();invoice=await client.invoices.create({customer:options.customer,currency:options.currency.toLowerCase(),auto_advance:!1,automatic_tax:{enabled:!0},default_payment_method:paymentMethod,pending_invoice_items_behavior:"exclude",metadata},{...request,idempotencyKey:key("create")})}if(!/^in_[a-z0-9_]+$/i.test(invoice.id)||previous&&invoice.id!==previous.id||id(invoice.customer)!==options.customer||!matches(invoice.metadata,metadata))throw Error("[payments/connect] Purchase invoice belongs to another customer or quote");let lines=await allPages((after)=>client.invoices.listLineItems(invoice.id,{limit:100,...after?{starting_after:after}:{}},request));if(lines.length===0&&invoice.status==="draft"){if(previous)await verifyPrice();await client.invoiceItems.create({customer:options.customer,invoice:invoice.id,pricing:{price:options.price},quantity:1},{...request,idempotencyKey:key("line")});lines=await allPages((after)=>client.invoices.listLineItems(invoice.id,{limit:100,...after?{starting_after:after}:{}},request))}if(lines.length!==1||lines[0].quantity!==1||id(lines[0].pricing?.price_details?.price)!==options.price)throw Error("[payments/connect] Purchase invoice contains different catalog lines")}if(!invoice.automatic_tax.enabled)throw Error("[payments/connect] Purchase invoice no longer uses its original automatic tax policy");return{...await collectDirectInvoice(invoice.id,account,{customer:options.customer,...subscription?{subscription:subscription.id}:{},currency:options.currency,platformFeePercent:options.platformFeePercent,approvedTotal:options.trialDays?0:options.amount,paymentMethod},client),...reloadSubscription?{subscription:await reloadSubscription()}:{}}}
1
+ import{stripe}from"../drivers/stripe";import{idempotencyFingerprint,stacksIdempotencyKey}from"../idempotency";import{asAccount}from"./direct";import{collectDirectInvoice}from"./invoice-collection";import{allPages}from"./pagination";import{manageDestinationSubscription}from"./subscription";export class DirectInvoicePurchaseExpiredError extends Error{}export function directInvoicePurchaseMetadata(account,options){asAccount(account);if(typeof options.reference!=="string"||!/^[a-z0-9:._-]{1,160}$/i.test(options.reference)||!/^cus_[a-z0-9_]+$/i.test(options.customer)||!/^price_[a-z0-9_]+$/i.test(options.price)||!Number.isSafeInteger(options.expiresAt)||options.expiresAt<=0||!Number.isSafeInteger(options.amount)||options.amount<=0||!/^[a-z]{3}$/i.test(options.currency)||!/^txcd_\d{8}$/.test(options.taxCode)||options.interval!==void 0&&!["day","week","month","year"].includes(options.interval)||options.intervalCount!==void 0&&(!options.interval||!Number.isSafeInteger(options.intervalCount)||options.intervalCount<1)||options.trialDays!==void 0&&(!Number.isSafeInteger(options.trialDays)||options.trialDays<0||options.trialDays>730||!options.interval&&options.trialDays>0))throw Error("[payments/connect] Expected original customer, price and frozen invoice purchase terms");if(options.platformFeePercent==null)throw Error("[payments/connect] An explicit invoice fee percentage is required");manageDestinationSubscription.feePercentFor({platformFeePercent:options.platformFeePercent});const metadata=options.metadata??{};if(typeof metadata!=="object"||Array.isArray(metadata)||Object.keys(metadata).length>47||Object.entries(metadata).some(([key,value])=>!key||key.length>40||/[\[\]]/.test(key)||key.startsWith("stacks_purchase_")||typeof value!=="string"||value.length>500))throw Error("[payments/connect] Invalid purchase metadata or reserved binding fields");const terms={account,reference:options.reference,customer:options.customer,price:options.price,amount:options.amount,currency:options.currency.toLowerCase(),taxCode:options.taxCode,expiresAt:options.expiresAt,interval:options.interval??null,intervalCount:options.interval?options.intervalCount??1:null,trialDays:options.trialDays??0,platformFeePercent:options.platformFeePercent,metadata};return{...metadata,stacks_purchase_reference:options.reference,stacks_purchase_quote:idempotencyFingerprint(terms)}}function id(value){return typeof value==="string"?value:value?.id}export async function completeDirectInvoicePurchase(setupIntentId,account,options,client=stripe){const quote=directInvoicePurchaseMetadata(account,options),request=asAccount(account);if(!/^seti_[a-z0-9_]+$/i.test(setupIntentId))throw Error("[payments/connect] Expected original successful payment setup");const setup=await client.setupIntents.retrieve(setupIntentId,{},request),matches=(metadata,expected)=>Object.entries(expected).every(([key,value])=>metadata?.[key]===value);if(setup.id!==setupIntentId||setup.status!=="succeeded"||setup.usage!=="off_session"||!Number.isSafeInteger(setup.created)||setup.created<=0||id(setup.customer)!==options.customer||!/^pm_[a-z0-9_]+$/i.test(id(setup.payment_method)||"")||!matches(setup.metadata,quote))throw Error("[payments/connect] Payment setup does not match the original customer and frozen quote");const paymentMethod=id(setup.payment_method),metadata={...quote,stacks_purchase_setup:setupIntentId},key=(operation)=>stacksIdempotencyKey(`connect.invoice.purchase.${operation}`,account,options.reference,quote.stacks_purchase_quote),canCreate=(rows)=>{if(Date.now()/1000>=Math.min(options.expiresAt,setup.created+86400)){if(rows.some((row)=>!row.metadata?.stacks_purchase_reference||!row.metadata.stacks_purchase_quote||!row.metadata.stacks_purchase_setup||row.metadata.stacks_purchase_setup===setupIntentId))throw Error("[payments/connect] Expired purchase requires review of unbound original-customer resources");throw new DirectInvoicePurchaseExpiredError("[payments/connect] Expired purchase can only recover its original provider invoice or schedule")}},existing=(rows)=>{const own=rows.filter((row)=>row.metadata?.stacks_purchase_reference===options.reference);if(own.length>1||own.some((row)=>!matches(row.metadata,metadata)))throw Error("[payments/connect] Purchase reference has ambiguous or different provider bindings");return own[0]},verifyPrice=async()=>{const price=await client.prices.retrieve(options.price,{expand:["product"]},request),product=price.product;if(price.id!==options.price||!price.active||price.currency!==options.currency.toLowerCase()||price.unit_amount!==options.amount||price.tax_behavior!=="inclusive"||price.type!==(options.interval?"recurring":"one_time")||(options.interval?price.recurring?.interval!==options.interval||price.recurring?.interval_count!==(options.intervalCount??1)||price.recurring?.usage_type!=="licensed":price.recurring!=null)||typeof product==="string"||product.deleted||!product.active||id(product.tax_code)!==options.taxCode)throw Error("[payments/connect] Catalog price does not match the approved inclusive purchase");const method=await client.paymentMethods.retrieve(paymentMethod,{},request);if(method.id!==paymentMethod||id(method.customer)!==options.customer)throw Error("[payments/connect] Purchase payment method belongs to another customer")};let invoice,subscription,reloadSubscription;if(options.interval){const schedules=await allPages((after)=>client.subscriptionSchedules.list({customer:options.customer,limit:100,...after?{starting_after:after}:{}},request));let schedule=existing(schedules);if(!schedule){canCreate(schedules);await verifyPrice();const phase={items:[{price:options.price,quantity:1}],automatic_tax:{enabled:!0},metadata};schedule=await client.subscriptionSchedules.create({customer:options.customer,start_date:"now",end_behavior:"release",default_settings:{default_payment_method:paymentMethod},phases:options.trialDays?[{...phase,trial:!0,duration:{interval:"day",interval_count:options.trialDays}},phase]:[phase],metadata},{...request,idempotencyKey:key("schedule")})}const subscriptionId=id(schedule.subscription)??id(schedule.released_subscription);if(!/^sub_sched_[a-z0-9_]+$/i.test(schedule.id)||id(schedule.customer)!==options.customer||!matches(schedule.metadata,metadata)||!/^sub_[a-z0-9_]+$/i.test(subscriptionId||""))throw Error("[payments/connect] Purchase schedule has no original customer and subscription");const readSubscription=async()=>{const value=await client.subscriptions.retrieve(subscriptionId,{},request);if(value.id!==subscriptionId||id(value.customer)!==options.customer||!matches(value.metadata,metadata)||value.items.has_more||value.items.data.length!==1||id(value.items.data[0].price)!==options.price||value.items.data[0].quantity!==1)throw Error("[payments/connect] Subscription does not match the original frozen purchase");return value};reloadSubscription=readSubscription;subscription=await readSubscription();const initial=(await allPages((after)=>client.invoices.list({customer:options.customer,subscription:subscriptionId,limit:100,...after?{starting_after:after}:{}},request))).filter((value)=>value.billing_reason==="subscription_create"&&id(value.parent?.subscription_details?.subscription)===subscriptionId);if(initial.length!==1)throw Error("[payments/connect] Purchase has no unique original subscription invoice");invoice=await client.invoices.retrieve(initial[0].id,{},request);if(invoice.id!==initial[0].id||id(invoice.customer)!==options.customer||id(invoice.parent?.subscription_details?.subscription)!==subscriptionId||!matches(invoice.parent?.subscription_details?.metadata,metadata))throw Error("[payments/connect] First invoice does not match the original subscription quote");if(invoice.status!=="paid"&&["canceled","incomplete_expired","unpaid"].includes(subscription.status))throw Error("[payments/connect] Subscription no longer permits its initial collection");if(invoice.status==="draft"&&invoice.auto_advance!==!1){invoice=await client.invoices.update(invoice.id,{auto_advance:!1},{...request,idempotencyKey:key("hold")});if(invoice.id!==initial[0].id||id(invoice.customer)!==options.customer||id(invoice.parent?.subscription_details?.subscription)!==subscriptionId||!matches(invoice.parent?.subscription_details?.metadata,metadata))throw Error("[payments/connect] Held invoice no longer matches the original subscription")}if(schedule.status==="active"){if(id(subscription.schedule)!==schedule.id)throw Error("[payments/connect] Subscription belongs to another schedule");const trialEnd=subscription.trial_end,released=await client.subscriptionSchedules.release(schedule.id,{},{...request,idempotencyKey:key("release")});if(released.id!==schedule.id||released.status!=="released"||id(released.released_subscription)!==subscriptionId)throw Error("[payments/connect] Schedule release did not preserve its original subscription");subscription=await readSubscription();if(subscription.schedule!=null||subscription.trial_end!==trialEnd)throw Error("[payments/connect] Released purchase changed its schedule or native trial");if(invoice.status!=="paid"&&["canceled","incomplete_expired","unpaid"].includes(subscription.status))throw Error("[payments/connect] Subscription no longer permits its initial collection")}else if(!["released","completed","canceled"].includes(schedule.status))throw Error("[payments/connect] Purchase schedule has not started")}else{const invoices=await allPages((after)=>client.invoices.list({customer:options.customer,limit:100,...after?{starting_after:after}:{}},request)),previous=existing(invoices);if(previous)invoice=await client.invoices.retrieve(previous.id,{},request);else{canCreate(invoices);await verifyPrice();invoice=await client.invoices.create({customer:options.customer,currency:options.currency.toLowerCase(),auto_advance:!1,automatic_tax:{enabled:!0},default_payment_method:paymentMethod,pending_invoice_items_behavior:"exclude",metadata},{...request,idempotencyKey:key("create")})}if(!/^in_[a-z0-9_]+$/i.test(invoice.id)||previous&&invoice.id!==previous.id||id(invoice.customer)!==options.customer||!matches(invoice.metadata,metadata))throw Error("[payments/connect] Purchase invoice belongs to another customer or quote");let lines=await allPages((after)=>client.invoices.listLineItems(invoice.id,{limit:100,...after?{starting_after:after}:{}},request));if(lines.length===0&&invoice.status==="draft"){if(previous)await verifyPrice();await client.invoiceItems.create({customer:options.customer,invoice:invoice.id,pricing:{price:options.price},quantity:1},{...request,idempotencyKey:key("line")});lines=await allPages((after)=>client.invoices.listLineItems(invoice.id,{limit:100,...after?{starting_after:after}:{}},request))}if(lines.length!==1||lines[0].quantity!==1||id(lines[0].pricing?.price_details?.price)!==options.price)throw Error("[payments/connect] Purchase invoice contains different catalog lines")}if(!invoice.automatic_tax.enabled)throw Error("[payments/connect] Purchase invoice no longer uses its original automatic tax policy");return{...await collectDirectInvoice(invoice.id,account,{customer:options.customer,...subscription?{subscription:subscription.id}:{},currency:options.currency,platformFeePercent:options.platformFeePercent,approvedTotal:options.trialDays?0:options.amount,paymentMethod},client),...reloadSubscription?{subscription:await reloadSubscription()}:{}}}
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.367",
5
+ "version": "0.75.370",
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.367",
64
- "@stacksjs/database": "0.75.367",
65
- "@stacksjs/error-handling": "0.75.367",
66
- "@stacksjs/logging": "0.75.367",
67
- "@stacksjs/orm": "0.75.367",
68
- "@stacksjs/types": "0.75.367"
63
+ "@stacksjs/config": "0.75.370",
64
+ "@stacksjs/database": "0.75.370",
65
+ "@stacksjs/error-handling": "0.75.370",
66
+ "@stacksjs/logging": "0.75.370",
67
+ "@stacksjs/orm": "0.75.370",
68
+ "@stacksjs/types": "0.75.370"
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.367",
83
+ "@stacksjs/utils": "0.75.370",
84
84
  "@stripe/stripe-js": "^10.0.0",
85
85
  "better-dx": "^0.2.24",
86
86
  "stripe": "23.0.0"