@stacksjs/payments 0.75.349 → 0.75.357

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 +1 @@
1
- import{stripe}from"../drivers/stripe";import{asAccount}from"./direct";export class DirectChargeFinancialsPendingError extends Error{}function id(value){return typeof value==="string"?value:String(value?.id||"")}async function allPages(read){const rows=[],seen=new Set;let after;for(let page=0;page<100;page++){const result=await read(after);for(const row of result.data){if(!row.id||seen.has(row.id))throw Error("Incomplete or repeated Stripe financial page");seen.add(row.id);rows.push(row)}if(!result.has_more)return rows;if(!result.data.length)throw Error("Incomplete Stripe financial page");after=result.data.at(-1).id}throw Error("Stripe financial pagination exceeded its bound")}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){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)!==sourceId)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");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{}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,13 +1,16 @@
1
1
  import { asAccount, connectedCustomer, directCheckout, directCheckoutPaymentData, directCheckoutSubscriptionData, directPaymentIntentParams, directSubscriptionParams, manageDirectSubscription } from './direct';
2
2
  import { checkoutLineItemsTotal, destinationCheckoutPaymentData, destinationCheckoutSubscriptionData, destinationFeeCents, destinationFeePercent, destinationPaymentIntentParams, destinationSubscriptionParams, splitFee } from './destination';
3
3
  import { connectRefund, connectRefundParams, isDestinationCharge } from './refund';
4
+ import { creditDirectInvoiceRefund } from './invoice-credit';
4
5
  import { directChargeFinancials } from './financials';
6
+ import { directInvoiceTaxFinancials } from './invoice-tax';
5
7
  import { manageConnectedAccount } from './account';
6
8
  import { manageConnectWebhook } from './webhook';
7
9
  import { manageDestinationCharge } from './charge';
8
10
  import { manageDestinationSubscription } from './subscription';
9
11
  import { managePayout } from './payout';
10
12
  import { manageTransfer } from './transfer';
13
+ import { prepareDirectInvoiceFee } from './invoice-fee';
11
14
  /**
12
15
  * Grouped Connect surface, reached as `Payment.connect.*`.
13
16
  */
@@ -31,6 +34,9 @@ export declare const connect: {
31
34
  customer: typeof connectedCustomer;
32
35
  directCheckout: typeof directCheckout;
33
36
  directChargeFinancials: typeof directChargeFinancials;
37
+ prepareDirectInvoiceFee: typeof prepareDirectInvoiceFee;
38
+ creditDirectInvoiceRefund: typeof creditDirectInvoiceRefund;
39
+ directInvoiceTaxFinancials: typeof directInvoiceTaxFinancials;
34
40
  directPaymentIntentParams: typeof directPaymentIntentParams;
35
41
  directCheckoutPaymentData: typeof directCheckoutPaymentData;
36
42
  directCheckoutSubscriptionData: typeof directCheckoutSubscriptionData;
@@ -77,6 +83,10 @@ export * from './charge';
77
83
  export * from './destination';
78
84
  export * from './direct';
79
85
  export * from './financials';
86
+ export * from './invoice-fee';
87
+ export * from './invoice-credit';
88
+ export * from './tax';
89
+ export * from './invoice-tax';
80
90
  export * from './payout';
81
91
  export * from './refund';
82
92
  export * from './subscription';
@@ -1 +1 @@
1
- export*from"./account";export*from"./charge";export*from"./destination";export*from"./direct";export*from"./financials";export*from"./payout";export*from"./refund";export*from"./subscription";export*from"./transfer";export*from"./webhook";import{manageConnectedAccount}from"./account";import{manageDestinationCharge}from"./charge";import{asAccount,connectedCustomer,directCheckout,directCheckoutPaymentData,directCheckoutSubscriptionData,directPaymentIntentParams,directSubscriptionParams,manageDirectSubscription}from"./direct";import{checkoutLineItemsTotal,destinationCheckoutPaymentData,destinationCheckoutSubscriptionData,destinationFeeCents,destinationFeePercent,destinationPaymentIntentParams,destinationSubscriptionParams,splitFee}from"./destination";import{managePayout}from"./payout";import{directChargeFinancials}from"./financials";import{connectRefund,connectRefundParams,isDestinationCharge}from"./refund";import{manageDestinationSubscription}from"./subscription";import{manageTransfer}from"./transfer";import{manageConnectWebhook}from"./webhook";export const connect={createAccount:manageConnectedAccount.createConnectedAccount,createTestAccount:manageConnectedAccount.createTestConnectedAccount,onboardingLink:manageConnectedAccount.accountOnboardingLink,accountStatus:manageConnectedAccount.accountStatus,retrieveAccount:manageConnectedAccount.retrieveConnectedAccount,destinationCharge:manageDestinationCharge.destinationCharge,applicationFeeFor:manageDestinationCharge.applicationFeeFor,feeCents:destinationFeeCents,feePercent:destinationFeePercent,paymentIntentParams:destinationPaymentIntentParams,checkoutPaymentData:destinationCheckoutPaymentData,checkoutSubscriptionData:destinationCheckoutSubscriptionData,subscriptionParams:destinationSubscriptionParams,lineItemsTotal:checkoutLineItemsTotal,splitFee,asAccount,customer:connectedCustomer,directCheckout,directChargeFinancials,directPaymentIntentParams,directCheckoutPaymentData,directCheckoutSubscriptionData,directSubscriptionParams,directSubscriptionCheckout:manageDirectSubscription.checkout,refund:connectRefund,refundParams:connectRefundParams,isDestinationCharge,subscriptionCheckout:manageDestinationSubscription.checkout,subscriptionFeePercent:manageDestinationSubscription.feePercentFor,subscriptionDestination:manageDestinationSubscription.destinationOf,createTransfer:manageTransfer.createTransfer,reverseTransfer:manageTransfer.reverseTransfer,listTransfers:manageTransfer.listTransfers,retrieveTransfer:manageTransfer.retrieveTransfer,listPayouts:managePayout.listPayouts,retrievePayout:managePayout.retrievePayout,accountBalance:managePayout.accountBalance,onAccount:manageConnectWebhook.onAccount,onPayout:manageConnectWebhook.onPayout,getAccount:manageConnectWebhook.getAccount,getPayout:manageConnectWebhook.getPayout,getConnectedAccountId:manageConnectWebhook.getConnectedAccountId,account:manageConnectedAccount,charge:manageDestinationCharge,subscription:manageDestinationSubscription,directSubscription:manageDirectSubscription,transfer:manageTransfer,payout:managePayout};
1
+ export*from"./account";export*from"./charge";export*from"./destination";export*from"./direct";export*from"./financials";export*from"./invoice-fee";export*from"./invoice-credit";export*from"./tax";export*from"./invoice-tax";export*from"./payout";export*from"./refund";export*from"./subscription";export*from"./transfer";export*from"./webhook";import{manageConnectedAccount}from"./account";import{manageDestinationCharge}from"./charge";import{asAccount,connectedCustomer,directCheckout,directCheckoutPaymentData,directCheckoutSubscriptionData,directPaymentIntentParams,directSubscriptionParams,manageDirectSubscription}from"./direct";import{checkoutLineItemsTotal,destinationCheckoutPaymentData,destinationCheckoutSubscriptionData,destinationFeeCents,destinationFeePercent,destinationPaymentIntentParams,destinationSubscriptionParams,splitFee}from"./destination";import{managePayout}from"./payout";import{directChargeFinancials}from"./financials";import{prepareDirectInvoiceFee}from"./invoice-fee";import{creditDirectInvoiceRefund}from"./invoice-credit";import{directInvoiceTaxFinancials}from"./invoice-tax";import{connectRefund,connectRefundParams,isDestinationCharge}from"./refund";import{manageDestinationSubscription}from"./subscription";import{manageTransfer}from"./transfer";import{manageConnectWebhook}from"./webhook";export const connect={createAccount:manageConnectedAccount.createConnectedAccount,createTestAccount:manageConnectedAccount.createTestConnectedAccount,onboardingLink:manageConnectedAccount.accountOnboardingLink,accountStatus:manageConnectedAccount.accountStatus,retrieveAccount:manageConnectedAccount.retrieveConnectedAccount,destinationCharge:manageDestinationCharge.destinationCharge,applicationFeeFor:manageDestinationCharge.applicationFeeFor,feeCents:destinationFeeCents,feePercent:destinationFeePercent,paymentIntentParams:destinationPaymentIntentParams,checkoutPaymentData:destinationCheckoutPaymentData,checkoutSubscriptionData:destinationCheckoutSubscriptionData,subscriptionParams:destinationSubscriptionParams,lineItemsTotal:checkoutLineItemsTotal,splitFee,asAccount,customer:connectedCustomer,directCheckout,directChargeFinancials,prepareDirectInvoiceFee,creditDirectInvoiceRefund,directInvoiceTaxFinancials,directPaymentIntentParams,directCheckoutPaymentData,directCheckoutSubscriptionData,directSubscriptionParams,directSubscriptionCheckout:manageDirectSubscription.checkout,refund:connectRefund,refundParams:connectRefundParams,isDestinationCharge,subscriptionCheckout:manageDestinationSubscription.checkout,subscriptionFeePercent:manageDestinationSubscription.feePercentFor,subscriptionDestination:manageDestinationSubscription.destinationOf,createTransfer:manageTransfer.createTransfer,reverseTransfer:manageTransfer.reverseTransfer,listTransfers:manageTransfer.listTransfers,retrieveTransfer:manageTransfer.retrieveTransfer,listPayouts:managePayout.listPayouts,retrievePayout:managePayout.retrievePayout,accountBalance:managePayout.accountBalance,onAccount:manageConnectWebhook.onAccount,onPayout:manageConnectWebhook.onPayout,getAccount:manageConnectWebhook.getAccount,getPayout:manageConnectWebhook.getPayout,getConnectedAccountId:manageConnectWebhook.getConnectedAccountId,account:manageConnectedAccount,charge:manageDestinationCharge,subscription:manageDestinationSubscription,directSubscription:manageDirectSubscription,transfer:manageTransfer,payout:managePayout};
@@ -0,0 +1,19 @@
1
+ import type Stripe from 'stripe';
2
+ /** Link a successful direct refund to its ORIGINAL invoice lines. This creates
3
+ * a credit note with Stripe's actual tax reversal, never a second refund.
4
+ * Amount-only credit notes can omit tax entirely. All reads and the credit are
5
+ * scoped to the original merchant. An existing matching credit is returned on
6
+ * replay, including after Stripe's idempotency retention has elapsed.
7
+ */
8
+ export declare function creditDirectInvoiceRefund(invoiceId: string, account: string, refundId: string, lines: readonly InvoiceRefundLine[], client?: Pick<Stripe, 'invoices' | 'invoicePayments' | 'refunds' | 'creditNotes'>): Promise<DirectInvoiceRefundCredit>;
9
+ export declare interface InvoiceRefundLine {
10
+ invoiceLineItem: string
11
+ amount: number
12
+ }
13
+ export declare interface DirectInvoiceRefundCredit {
14
+ creditNote: Stripe.CreditNote
15
+ gross: number
16
+ net: number
17
+ tax: number
18
+ currency: string
19
+ }
@@ -0,0 +1 @@
1
+ import{stripe}from"../drivers/stripe";import{idempotencyFingerprint,stacksIdempotencyKey}from"../idempotency";import{asAccount}from"./direct";import{allPages}from"./pagination";import{invoiceTaxAmounts}from"./tax";function id(value){return typeof value==="string"?value:String(value?.id||"")}export async function creditDirectInvoiceRefund(invoiceId,account,refundId,lines,client=stripe){const request=asAccount(account);if(!/^in_[a-z0-9_]+$/i.test(invoiceId)||!/^re_[a-z0-9_]+$/i.test(refundId)||!lines.length||lines.length>100||lines.some((line)=>!/^il_[a-z0-9_]+$/i.test(line.invoiceLineItem)||!Number.isSafeInteger(line.amount)||line.amount<=0)||new Set(lines.map((line)=>line.invoiceLineItem)).size!==lines.length)throw Error("[payments/connect] Expected original invoice, refund and unique positive credit lines");const invoice=await client.invoices.retrieve(invoiceId,{},request),refund=await client.refunds.retrieve(refundId,{},request);if(invoice.id!==invoiceId||invoice.status!=="paid"||refund.id!==refundId||refund.status!=="succeeded"||refund.currency!==invoice.currency||!Number.isSafeInteger(refund.amount)||refund.amount<=0||!id(refund.payment_intent))throw Error("[payments/connect] Expected the original paid invoice and successful refund in its currency");if(!(await allPages((after)=>client.invoicePayments.list({invoice:invoiceId,limit:100,...after?{starting_after:after}:{}},request))).some((payment)=>payment.status==="paid"&&id(payment.payment.payment_intent)===id(refund.payment_intent)))throw Error("[payments/connect] Refund belongs to another invoice payment");const invoiceLines=await allPages((after)=>client.invoices.listLineItems(invoiceId,{limit:100,...after?{starting_after:after}:{}},request));if(lines.some((line)=>!invoiceLines.some((original)=>original.id===line.invoiceLineItem)))throw Error("[payments/connect] Credit line belongs to another invoice");const params={invoice:invoiceId,lines:[...lines].sort((a,b)=>a.invoiceLineItem.localeCompare(b.invoiceLineItem)).map((line)=>({type:"invoice_line_item",invoice_line_item:line.invoiceLineItem,amount:line.amount})),refunds:[{refund:refundId,amount_refunded:refund.amount}]},verify=(credit)=>{if(id(credit.invoice)!==invoiceId||id(credit.customer)!==id(invoice.customer)||credit.currency!==invoice.currency||credit.total!==refund.amount)throw Error("[payments/connect] Credit does not match the original invoice or refund amount");return{creditNote:credit,...invoiceTaxAmounts(credit),currency:invoice.currency.toUpperCase()}},validateIssued=async(credit)=>{const result=verify(credit);if(credit.status!=="issued"||credit.refunds.length!==1||id(credit.refunds[0].refund)!==refundId||credit.refunds[0].amount_refunded!==refund.amount)throw Error("[payments/connect] Credit does not bind the complete original refund");const actual=await allPages((after)=>client.creditNotes.listLineItems(credit.id,{limit:100,...after?{starting_after:after}:{}},request));if(actual.length!==lines.length||actual.some((line)=>line.type!=="invoice_line_item"||!lines.some((expected)=>expected.invoiceLineItem===line.invoice_line_item&&expected.amount===line.amount)))throw Error("[payments/connect] Refund was credited with different invoice lines");return result},previous=(await allPages((after)=>client.creditNotes.list({invoice:invoiceId,limit:100,...after?{starting_after:after}:{}},request))).filter((credit)=>credit.status==="issued"&&credit.refunds.some((row)=>id(row.refund)===refundId));if(previous.length>1)throw Error("[payments/connect] Refund is associated with several credit notes");if(previous[0])return validateIssued(previous[0]);verify(await client.creditNotes.preview(params,request));const credit=await client.creditNotes.create({...params,email_type:"none"},{...request,idempotencyKey:stacksIdempotencyKey("connect.invoice.refund-credit",account,invoiceId,refundId,idempotencyFingerprint(params))});return validateIssued(credit)}
@@ -0,0 +1,23 @@
1
+ import type Stripe from 'stripe';
2
+ /** Set an exact tax-exclusive fee on a draft invoice on its original merchant.
3
+ * Collection is held before returning; the caller owns finalization/payment and
4
+ * durable retries. Use invoice.created for renewals and a subscription schedule
5
+ * for an editable first invoice. Checkout's immediately finalized first invoice
6
+ * cannot be changed by this API. Never infer a tax rate from locale or currency.
7
+ * https://docs.stripe.com/connect/subscriptions#collect-fees
8
+ */
9
+ export declare function prepareDirectInvoiceFee(invoiceId: string, account: string, options: DirectInvoiceFeeOptions, client?: Pick<Stripe, 'invoices'>): Promise<DirectInvoiceFee>;
10
+ export declare interface DirectInvoiceFeeOptions {
11
+ customer: string
12
+ subscription?: string
13
+ currency: string
14
+ platformFeePercent: number
15
+ }
16
+ export declare interface DirectInvoiceFee {
17
+ invoice: Stripe.Invoice
18
+ currency: string
19
+ gross: number
20
+ tax: number
21
+ net: number
22
+ fee: number
23
+ }
@@ -0,0 +1 @@
1
+ import{stripe}from"../drivers/stripe";import{stacksIdempotencyKey}from"../idempotency";import{asAccount}from"./direct";import{splitFee}from"./destination";import{manageDestinationSubscription}from"./subscription";import{invoiceTaxAmounts}from"./tax";function id(value){return typeof value==="string"?value:value?.id}function amounts(invoice){const totals=invoiceTaxAmounts(invoice);if(invoice.amount_due!==totals.gross)throw Error("[payments/connect] Invoice credits or balance adjustments require a separate fee settlement");return totals}export async function prepareDirectInvoiceFee(invoiceId,account,options,client=stripe){const request=asAccount(account);if(!/^in_[a-z0-9_]+$/i.test(invoiceId)||!/^cus_[a-z0-9_]+$/i.test(options.customer)||options.subscription!=null&&!/^sub_[a-z0-9_]+$/i.test(options.subscription)||!/^[a-z]{3}$/i.test(options.currency))throw Error("[payments/connect] Expected original invoice, customer, subscription and currency");if(options.platformFeePercent==null)throw Error("[payments/connect] An explicit invoice fee percentage is required");manageDestinationSubscription.feePercentFor({platformFeePercent:options.platformFeePercent});const invoice=await client.invoices.retrieve(invoiceId,{},request),verify=(value)=>{if(value.id!==invoiceId||value.status!=="draft"||id(value.customer)!==options.customer||id(value.parent?.subscription_details?.subscription)!==options.subscription||value.currency.toUpperCase()!==options.currency.toUpperCase())throw Error("[payments/connect] Fee requires the original draft invoice, customer, subscription and currency");return amounts(value)},basis=verify(invoice),feeBps=Math.round(options.platformFeePercent*100),fee=splitFee(basis.net,{platform:feeBps,merchant:1e4-feeBps},"merchant").platform,updated=await client.invoices.update(invoiceId,{auto_advance:!1,application_fee_amount:fee},{...request,idempotencyKey:stacksIdempotencyKey("connect.invoice.tax-fee",account,invoiceId,basis.gross,basis.tax,fee)}),current=verify(updated);if(current.gross!==basis.gross||current.tax!==basis.tax)throw Error("[payments/connect] Invoice tax or amount changed while preparing the fee; collection remains held");return{invoice:updated,currency:invoice.currency.toUpperCase(),...basis,fee}}
@@ -0,0 +1,20 @@
1
+ import type Stripe from 'stripe';
2
+ /** Read tax and actual credits on the original merchant. A cash refund does
3
+ * not establish a tax reversal; only issued, balanced provider credit notes do.
4
+ * Call under the application's payment lock alongside directChargeFinancials,
5
+ * and compare this invoice's gross/currency with the original payment before
6
+ * making contributor amounts available. This operation has no side effects.
7
+ */
8
+ export declare function directInvoiceTaxFinancials(invoiceId: string, paymentIntentId: string, account: string, client?: Pick<Stripe, 'invoices' | 'invoicePayments' | 'refunds' | 'creditNotes'>): Promise<DirectInvoiceTaxFinancials>;
9
+ export declare interface DirectInvoiceTaxFinancials {
10
+ invoice: Stripe.Invoice
11
+ creditNotes: Stripe.CreditNote[]
12
+ currency: string
13
+ gross: number
14
+ net: number
15
+ tax: number
16
+ creditedGross: number
17
+ creditedNet: number
18
+ creditedTax: number
19
+ requiresReview: boolean
20
+ }
@@ -0,0 +1 @@
1
+ import{stripe}from"../drivers/stripe";import{asAccount}from"./direct";import{allPages}from"./pagination";import{invoiceTaxAmounts}from"./tax";function id(value){return typeof value==="string"?value:String(value?.id||"")}export async function directInvoiceTaxFinancials(invoiceId,paymentIntentId,account,client=stripe){const request=asAccount(account);if(!/^in_[a-z0-9_]+$/i.test(invoiceId)||!/^pi_[a-z0-9_]+$/i.test(paymentIntentId))throw Error("[payments/connect] Expected original invoice and payment identities");const invoice=await client.invoices.retrieve(invoiceId,{},request);if(invoice.id!==invoiceId||invoice.status!=="paid")throw Error("[payments/connect] Expected the original paid invoice");const basis=invoiceTaxAmounts(invoice);if(!(await allPages((after)=>client.invoicePayments.list({invoice:invoiceId,limit:100,...after?{starting_after:after}:{}},request))).some((payment)=>payment.status==="paid"&&id(payment.payment.payment_intent)===paymentIntentId))throw Error("[payments/connect] Invoice belongs to another payment");const refunds=await allPages((after)=>client.refunds.list({payment_intent:paymentIntentId,limit:100,...after?{starting_after:after}:{}},request));if(refunds.some((refund)=>id(refund.payment_intent)!==paymentIntentId||refund.currency!==invoice.currency||!Number.isSafeInteger(refund.amount)||refund.amount<=0))throw Error("[payments/connect] Foreign or invalid invoice refund");const creditNotes=await allPages((after)=>client.creditNotes.list({invoice:invoiceId,limit:100,...after?{starting_after:after}:{}},request)),invoiceLines=await allPages((after)=>client.invoices.listLineItems(invoiceId,{limit:100,...after?{starting_after:after}:{}},request));let requiresReview=refunds.some((refund)=>!["succeeded","failed","canceled"].includes(String(refund.status))),creditedGross=0,creditedNet=0,creditedTax=0;const creditedRefunds=new Map;for(const credit of creditNotes){if(id(credit.invoice)!==invoiceId||id(credit.customer)!==id(invoice.customer)||credit.currency!==invoice.currency)throw Error("[payments/connect] Foreign invoice tax credit");if(credit.status==="void")continue;if(credit.status!=="issued")throw Error("[payments/connect] Unknown invoice credit status");const amounts=invoiceTaxAmounts(credit),lines=await allPages((after)=>client.creditNotes.listLineItems(credit.id,{limit:100,...after?{starting_after:after}:{}},request));if(!lines.length||lines.some((line)=>line.type!=="invoice_line_item"))requiresReview=!0;if(lines.some((line)=>line.type==="invoice_line_item"&&!invoiceLines.some((original)=>original.id===line.invoice_line_item)))throw Error("[payments/connect] Tax credit references another invoice line");creditedGross+=amounts.gross;creditedNet+=amounts.net;creditedTax+=amounts.tax;let linkedGross=0;for(const link of credit.refunds){const refund=refunds.find((row)=>row.id===id(link.refund));if(!refund||refund.status!=="succeeded"||!Number.isSafeInteger(link.amount_refunded)||link.amount_refunded<0){requiresReview=!0;continue}const credited=(creditedRefunds.get(refund.id)||0)+link.amount_refunded;creditedRefunds.set(refund.id,credited);if(credited>refund.amount)throw Error("[payments/connect] Tax credits exceed the original refund");linkedGross+=link.amount_refunded}if(linkedGross!==amounts.gross)requiresReview=!0}const refunded=refunds.filter((refund)=>refund.status==="succeeded").reduce((sum,refund)=>sum+refund.amount,0);if(creditedGross>basis.gross||creditedNet>basis.net||creditedTax>basis.tax||basis.tax>0&&(creditedGross!==refunded||refunds.some((refund)=>refund.status==="succeeded"&&creditedRefunds.get(refund.id)!==refund.amount)))requiresReview=!0;return{invoice,creditNotes,currency:invoice.currency.toUpperCase(),...basis,creditedGross,creditedNet,creditedTax,requiresReview}}
@@ -0,0 +1,2 @@
1
+ /** Read bounded Stripe lists completely; incomplete pages are never financial facts. */
2
+ export declare function allPages<T extends { id: string }>(read: (after?: string) => Promise<{ data: T[], has_more: boolean }>): Promise<T[]>;
@@ -0,0 +1 @@
1
+ export async function allPages(read){const rows=[],seen=new Set;let after;for(let page=0;page<100;page++){const result=await read(after);for(const row of result.data){if(!row.id||seen.has(row.id))throw Error("Incomplete or repeated Stripe financial page");seen.add(row.id);rows.push(row)}if(!result.has_more)return rows;if(!result.data.length)throw Error("Incomplete Stripe financial page");after=result.data.at(-1).id}throw Error("Stripe financial pagination exceeded its bound")}
@@ -9,6 +9,8 @@ export declare interface InlineRecurringPrice {
9
9
  intervalCount?: number
10
10
  productName: string
11
11
  productDescription?: string
12
+ taxBehavior?: 'inclusive' | 'exclusive'
13
+ taxCode?: string
12
14
  }
13
15
  export declare interface DestinationSubscriptionCheckoutOptions {
14
16
  destination: string
@@ -1 +1 @@
1
- import{config}from"@stacksjs/config";import{manageCheckout}from"../billable/checkout";import{manageCustomer}from"../billable/customer";import{requireAccountId}from"./account";const INTERVALS=["day","week","month","year"];function configuredFeePercent(){return(config||{}).payment?.connect?.platformFeePercent}export const manageDestinationSubscription=(()=>{function feePercentFor(options={}){const percent=options.platformFeePercent??configuredFeePercent();if(percent==null)return;if(!Number.isFinite(percent)||percent<0||percent>100)throw Error(`[payments/connect] platformFeePercent must be between 0 and 100, got ${percent}`);if(Math.round(percent*100)!==percent*100)throw Error(`[payments/connect] platformFeePercent takes at most two decimal places, got ${percent}`);return percent>0?percent:void 0}function subscriptionData(destination,options={}){requireAccountId(destination,"destinationSubscription");if(options.trialDays!=null&&(!Number.isInteger(options.trialDays)||options.trialDays<1||options.trialDays>730))throw Error(`[payments/connect] trialDays must be a whole number from 1 to 730, got ${options.trialDays}`);const fee=feePercentFor({platformFeePercent:options.platformFeePercent});return{transfer_data:{destination},...fee!=null?{application_fee_percent:fee}:{},...options.onBehalfOf?{on_behalf_of:destination}:{},...options.trialDays?{trial_period_days:options.trialDays}:{},...options.metadata?{metadata:options.metadata}:{}}}function lineItem(price,quantity=1){if(!Number.isInteger(quantity)||quantity<1)throw Error(`[payments/connect] quantity must be a positive whole number, got ${quantity}`);if(typeof price==="string"){if(!price.startsWith("price_"))throw Error(`[payments/connect] a catalog price id starts with "price_", got "${price}"`);return{price,quantity}}if(!Number.isInteger(price.amount)||price.amount<=0)throw Error("[payments/connect] a recurring price needs a positive amount in minor units");if(!INTERVALS.includes(price.interval))throw Error(`[payments/connect] interval must be one of ${INTERVALS.join(", ")}, got ${price.interval}`);const intervalCount=price.intervalCount??1;if(!Number.isInteger(intervalCount)||intervalCount<1)throw Error(`[payments/connect] intervalCount must be a positive whole number, got ${intervalCount}`);if(!price.productName?.trim())throw Error("[payments/connect] a recurring price needs a productName");return{quantity,price_data:{currency:price.currency.toLowerCase(),unit_amount:price.amount,recurring:{interval:price.interval,...intervalCount!==1?{interval_count:intervalCount}:{}},product_data:{name:price.productName.slice(0,250),...price.productDescription?{description:price.productDescription.slice(0,500)}:{}}}}}async function checkout(user,options){const customer=options.customer??(await manageCustomer.createOrGetStripeUser(user,{})).id;return await manageCheckout.create(user,{mode:"subscription",customer,line_items:[lineItem(options.price,options.quantity)],subscription_data:subscriptionData(options.destination,options),success_url:options.successUrl,cancel_url:options.cancelUrl,...options.clientReferenceId?{client_reference_id:options.clientReferenceId}:{},...options.metadata?{metadata:options.metadata}:{},...options.sessionParams})}function destinationOf(subscription){const destination=subscription.transfer_data?.destination;if(!destination)return null;return(typeof destination==="string"?destination:destination.id)||null}return{feePercentFor,subscriptionData,lineItem,checkout,destinationOf}})();
1
+ import{config}from"@stacksjs/config";import{manageCheckout}from"../billable/checkout";import{manageCustomer}from"../billable/customer";import{requireAccountId}from"./account";const INTERVALS=["day","week","month","year"];function configuredFeePercent(){return(config||{}).payment?.connect?.platformFeePercent}export const manageDestinationSubscription=(()=>{function feePercentFor(options={}){const percent=options.platformFeePercent??configuredFeePercent();if(percent==null)return;if(!Number.isFinite(percent)||percent<0||percent>100)throw Error(`[payments/connect] platformFeePercent must be between 0 and 100, got ${percent}`);if(Math.round(percent*100)!==percent*100)throw Error(`[payments/connect] platformFeePercent takes at most two decimal places, got ${percent}`);return percent>0?percent:void 0}function subscriptionData(destination,options={}){requireAccountId(destination,"destinationSubscription");if(options.trialDays!=null&&(!Number.isInteger(options.trialDays)||options.trialDays<1||options.trialDays>730))throw Error(`[payments/connect] trialDays must be a whole number from 1 to 730, got ${options.trialDays}`);const fee=feePercentFor({platformFeePercent:options.platformFeePercent});return{transfer_data:{destination},...fee!=null?{application_fee_percent:fee}:{},...options.onBehalfOf?{on_behalf_of:destination}:{},...options.trialDays?{trial_period_days:options.trialDays}:{},...options.metadata?{metadata:options.metadata}:{}}}function lineItem(price,quantity=1){if(!Number.isInteger(quantity)||quantity<1)throw Error(`[payments/connect] quantity must be a positive whole number, got ${quantity}`);if(typeof price==="string"){if(!price.startsWith("price_"))throw Error(`[payments/connect] a catalog price id starts with "price_", got "${price}"`);return{price,quantity}}if(!Number.isInteger(price.amount)||price.amount<=0)throw Error("[payments/connect] a recurring price needs a positive amount in minor units");if(!INTERVALS.includes(price.interval))throw Error(`[payments/connect] interval must be one of ${INTERVALS.join(", ")}, got ${price.interval}`);const intervalCount=price.intervalCount??1;if(!Number.isInteger(intervalCount)||intervalCount<1)throw Error(`[payments/connect] intervalCount must be a positive whole number, got ${intervalCount}`);if(!price.productName?.trim())throw Error("[payments/connect] a recurring price needs a productName");if(price.taxBehavior!=null&&price.taxBehavior!=="inclusive"&&price.taxBehavior!=="exclusive")throw Error("[payments/connect] taxBehavior must be inclusive or exclusive");if(price.taxCode!=null&&!/^txcd_\d{8}$/.test(price.taxCode))throw Error("[payments/connect] taxCode must be a Stripe tax classification (txcd_ followed by eight digits)");return{quantity,price_data:{currency:price.currency.toLowerCase(),unit_amount:price.amount,...price.taxBehavior?{tax_behavior:price.taxBehavior}:{},recurring:{interval:price.interval,...intervalCount!==1?{interval_count:intervalCount}:{}},product_data:{name:price.productName.slice(0,250),...price.productDescription?{description:price.productDescription.slice(0,500)}:{},...price.taxCode?{tax_code:price.taxCode}:{}}}}}async function checkout(user,options){const customer=options.customer??(await manageCustomer.createOrGetStripeUser(user,{})).id;return await manageCheckout.create(user,{mode:"subscription",customer,line_items:[lineItem(options.price,options.quantity)],subscription_data:subscriptionData(options.destination,options),success_url:options.successUrl,cancel_url:options.cancelUrl,...options.clientReferenceId?{client_reference_id:options.clientReferenceId}:{},...options.metadata?{metadata:options.metadata}:{},...options.sessionParams})}function destinationOf(subscription){const destination=subscription.transfer_data?.destination;if(!destination)return null;return(typeof destination==="string"?destination:destination.id)||null}return{feePercentFor,subscriptionData,lineItem,checkout,destinationOf}})();
@@ -0,0 +1,8 @@
1
+ /** Canonical provider totals, after discounts. A missing/incomplete calculation
2
+ * is never equivalent to zero tax. Shared by draft fees and issued credits. */
3
+ export declare function invoiceTaxAmounts(value: {
4
+ total: number
5
+ total_excluding_tax: number | null
6
+ total_taxes: Array<{ amount: number }> | null
7
+ automatic_tax?: { enabled: boolean, status: string | null }
8
+ }): { gross: number, net: number, tax: number };
@@ -0,0 +1 @@
1
+ export function invoiceTaxAmounts(value){const{total:gross,total_excluding_tax:net}=value;if(!Number.isSafeInteger(gross)||gross<0||net==null||!Number.isSafeInteger(net)||net<0||net>gross)throw Error("[payments/connect] Provider has no valid tax-exclusive amount");const tax=gross-net;if(!Array.isArray(value.total_taxes)||value.total_taxes.some((row)=>!Number.isSafeInteger(row.amount)||row.amount<0)||value.total_taxes.reduce((sum,row)=>sum+row.amount,0)!==tax)throw Error("[payments/connect] Provider tax amounts do not balance");if(value.automatic_tax?.enabled&&value.automatic_tax.status!=="complete")throw Error("[payments/connect] Provider tax calculation is not complete");return{gross,net,tax}}
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.349",
5
+ "version": "0.75.357",
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.349",
64
- "@stacksjs/database": "0.75.349",
65
- "@stacksjs/error-handling": "0.75.349",
66
- "@stacksjs/logging": "0.75.349",
67
- "@stacksjs/orm": "0.75.349",
68
- "@stacksjs/types": "0.75.349"
63
+ "@stacksjs/config": "0.75.357",
64
+ "@stacksjs/database": "0.75.357",
65
+ "@stacksjs/error-handling": "0.75.357",
66
+ "@stacksjs/logging": "0.75.357",
67
+ "@stacksjs/orm": "0.75.357",
68
+ "@stacksjs/types": "0.75.357"
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.349",
83
+ "@stacksjs/utils": "0.75.357",
84
84
  "@stripe/stripe-js": "^10.0.0",
85
85
  "better-dx": "^0.2.24",
86
86
  "stripe": "23.0.0"