@stacksjs/payments 0.75.407 → 0.75.408

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.
@@ -30,20 +30,37 @@ export declare function directSubscriptionParams(options?: Pick<DirectFeeOptions
30
30
  /**
31
31
  * The idempotency key for creating `user`'s customer on `accountId`: the same
32
32
  * for every attempt with the same details, so two requests racing to create
33
- * one make one, while a changed name or email is a new request rather than a
34
- * reused key Stripe would refuse.
33
+ * one make one, while a changed name, email or install is a new request
34
+ * rather than a reused key Stripe would refuse or answer with another
35
+ * install's customer.
35
36
  */
36
37
  export declare function connectedCustomerIdempotencyKey(accountId: string, userId: number | string, params?: Stripe.CustomerCreateParams): string;
38
+ /**
39
+ * This install's mark on the customers it creates on connected accounts
40
+ * (`metadata.stacks_install`): an HMAC of a fixed label under the app key,
41
+ * so it is stable for the life of the install, differs between installs that
42
+ * share a Connect account, and does not reveal the key. Without an app key it
43
+ * falls back to the app URL, then to one shared mark, which leaves the email
44
+ * as the only proof of ownership.
45
+ */
46
+ export declare function connectInstallFingerprint(appKey?: string | null | undefined, appUrl?: string | null | undefined): string;
37
47
  /**
38
48
  * The user's customer on a connected account, created on first use. A
39
49
  * customer belongs to one account, so the platform's own customer (the user's
40
50
  * `stripe_id`) cannot pay a direct charge; each merchant the user buys from
41
- * holds its own. Found again by the user's id in the customer's metadata,
42
- * looked up by email first (immediately consistent) and by metadata search
43
- * when the email has changed, and created with a deterministic idempotency
44
- * key, so nothing on the app's side has to remember it.
51
+ * holds its own.
52
+ *
53
+ * A customer is reused only when it provably belongs to this buyer in this
54
+ * install: its `stacks_user_id` is the user's id, its email is the user's
55
+ * (case aside; both empty for a user without one), and its `stacks_install`
56
+ * is this install's mark. Local ids repeat across databases that share an
57
+ * account (CI runs, previews, restored backups, two installs on one Connect
58
+ * account), so an id alone would hand one person another's customer. A
59
+ * customer from before the mark existed is adopted on a matching email and
60
+ * then marked as this install's. Anything else gets a new customer, created
61
+ * with an idempotency key over the full parameters, mark included.
45
62
  */
46
- export declare function connectedCustomer(user: ConnectPayer, accountId: string, client?: CustomerClient): Promise<Stripe.Customer>;
63
+ export declare function connectedCustomer(user: ConnectPayer, accountId: string, client?: CustomerClient, customerOptions?: ConnectedCustomerOptions): Promise<Stripe.Customer>;
47
64
  /**
48
65
  * A Checkout Session on the connected account, as `manageCheckout.create`
49
66
  * makes one on the platform: the user's customer there unless `params` names
@@ -62,6 +79,10 @@ export declare function connectedCustomer(user: ConnectPayer, accountId: string,
62
79
  export declare function directCheckout(user: ConnectPayer, accountId: string, params: Stripe.Checkout.SessionCreateParams, client?: Pick<Stripe, 'customers' | 'checkout'>, request?: { idempotencyKey?: string }): Promise<Stripe.Response<Stripe.Checkout.Session>>;
63
80
  /** Subscriptions billed on the merchant's account, the platform paid its fee from each invoice. */
64
81
  export declare const manageDirectSubscription: ManageDirectSubscription;
82
+ /** Options for {@link connectedCustomer}. */
83
+ export declare interface ConnectedCustomerOptions {
84
+ install?: string
85
+ }
65
86
  export declare interface DirectSubscriptionCheckoutOptions {
66
87
  idempotencyKey?: string
67
88
  account: string
@@ -1 +1 @@
1
- import{stripe}from"../drivers/stripe";import{assertSameOriginUrl}from"../billable/checkout";import{freshIdempotencyKey,idempotencyFingerprint,stacksIdempotencyKey}from"../idempotency";import{requireAccountId}from"./account";import{destinationFeeCents,destinationFeePercent}from"./destination";import{manageDestinationSubscription}from"./subscription";export function asAccount(accountId,extra={}){requireAccountId(accountId,"asAccount");return{...extra,stripeAccount:accountId}}export function directPaymentIntentParams(amount,options={}){const fee=destinationFeeCents(amount,options);return fee>0?{application_fee_amount:fee}:{}}export function directCheckoutPaymentData(amount,options={},extra={}){const rest={...extra};delete rest.application_fee_amount;delete rest.transfer_data;delete rest.on_behalf_of;delete rest.transfer_group;return{...rest,...directPaymentIntentParams(amount,options)}}export function directCheckoutSubscriptionData(options={},extra={}){const percent=destinationFeePercent(options),rest={...extra};delete rest.application_fee_percent;delete rest.transfer_data;delete rest.on_behalf_of;return{...rest,...percent!=null?{application_fee_percent:percent}:{}}}export function directSubscriptionParams(options={}){const percent=destinationFeePercent(options);return percent!=null?{application_fee_percent:percent}:{}}export function connectedCustomerIdempotencyKey(accountId,userId,params={}){return stacksIdempotencyKey("connect.customer.create",userId,accountId,idempotencyFingerprint(params))}export async function connectedCustomer(user,accountId,client=stripe){requireAccountId(accountId,"connectedCustomer");const userId=String(user.id),options=asAccount(accountId),own=(customer)=>!(("deleted"in customer)&&customer.deleted)&&customer.metadata?.stacks_user_id===userId;if(user.email){const found=(await client.customers.list({email:user.email,limit:100},options)).data.find(own);if(found)return found}try{const found=(await client.customers.search({query:`metadata['stacks_user_id']:'${userId.replace(/'/g,"")}'`,limit:1},options)).data.find(own);if(found)return found}catch{}const params={...user.email?{email:user.email}:{},...user.name?{name:user.name}:{},metadata:{stacks_user_id:userId}};return await client.customers.create(params,asAccount(accountId,{idempotencyKey:connectedCustomerIdempotencyKey(accountId,userId,params)}))}export async function directCheckout(user,accountId,params,client=stripe,request={}){requireAccountId(accountId,"directCheckout");if(request.idempotencyKey!==void 0&&(typeof request.idempotencyKey!=="string"||!/^[\x21-\x7e]{1,255}$/.test(request.idempotencyKey)))throw TypeError("A checkout idempotency key must contain 1-255 printable ASCII characters");assertSameOriginUrl(params.success_url,"success_url");assertSameOriginUrl(params.cancel_url,"cancel_url");const customer=params.customer??(params.customer_email?void 0:(await connectedCustomer(user,accountId,client)).id);return await client.checkout.sessions.create({mode:"payment",...params,...customer?{customer}:{}},asAccount(accountId,{idempotencyKey:request.idempotencyKey??freshIdempotencyKey("connect.checkout.create",user.id,accountId)}))}export const manageDirectSubscription=(()=>{function subscriptionData(options={}){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=manageDestinationSubscription.feePercentFor({platformFeePercent:options.platformFeePercent});return{...fee!=null?{application_fee_percent:fee}:{},...options.trialDays?{trial_period_days:options.trialDays}:{},...options.metadata?{metadata:options.metadata}:{}}}async function checkout(user,options,client=stripe){requireAccountId(options.account,"manageDirectSubscription.checkout");const customer=options.customer??(await connectedCustomer(user,options.account,client)).id;return await directCheckout(user,options.account,{mode:"subscription",customer,line_items:[manageDestinationSubscription.lineItem(options.price,options.quantity)],subscription_data:subscriptionData(options),success_url:options.successUrl,cancel_url:options.cancelUrl,...options.clientReferenceId?{client_reference_id:options.clientReferenceId}:{},...options.metadata?{metadata:options.metadata}:{},...options.sessionParams},client,{idempotencyKey:options.idempotencyKey})}return{subscriptionData,checkout}})();
1
+ import{createHmac}from"node:crypto";import{app}from"@stacksjs/config";import{stripe}from"../drivers/stripe";import{assertSameOriginUrl}from"../billable/checkout";import{freshIdempotencyKey,idempotencyFingerprint,stacksIdempotencyKey}from"../idempotency";import{requireAccountId}from"./account";import{destinationFeeCents,destinationFeePercent}from"./destination";import{manageDestinationSubscription}from"./subscription";export function asAccount(accountId,extra={}){requireAccountId(accountId,"asAccount");return{...extra,stripeAccount:accountId}}export function directPaymentIntentParams(amount,options={}){const fee=destinationFeeCents(amount,options);return fee>0?{application_fee_amount:fee}:{}}export function directCheckoutPaymentData(amount,options={},extra={}){const rest={...extra};delete rest.application_fee_amount;delete rest.transfer_data;delete rest.on_behalf_of;delete rest.transfer_group;return{...rest,...directPaymentIntentParams(amount,options)}}export function directCheckoutSubscriptionData(options={},extra={}){const percent=destinationFeePercent(options),rest={...extra};delete rest.application_fee_percent;delete rest.transfer_data;delete rest.on_behalf_of;return{...rest,...percent!=null?{application_fee_percent:percent}:{}}}export function directSubscriptionParams(options={}){const percent=destinationFeePercent(options);return percent!=null?{application_fee_percent:percent}:{}}export function connectedCustomerIdempotencyKey(accountId,userId,params={}){return stacksIdempotencyKey("connect.customer.create",userId,accountId,idempotencyFingerprint(params))}export function connectInstallFingerprint(appKey=app?.key,appUrl=app?.url){const secret=String(appKey||"").trim()||String(appUrl||"").trim()||"stacks";return createHmac("sha256",secret).update("stacks.payments.connect.install").digest("hex").slice(0,16)}function normalizedEmail(email){return String(email??"").trim().toLowerCase()}export async function connectedCustomer(user,accountId,client=stripe,customerOptions={}){requireAccountId(accountId,"connectedCustomer");const userId=String(user.id),email=normalizedEmail(user.email),install=customerOptions.install??connectInstallFingerprint(),options=asAccount(accountId),claim=(customer)=>{if("deleted"in customer&&customer.deleted)return 0;const found=customer;if(found.metadata?.stacks_user_id!==userId||normalizedEmail(found.email)!==email)return 0;const mark=found.metadata?.stacks_install;if(mark===install)return 2;return!mark&&email?1:0},best=(customers)=>{let pick,rank=0;for(const customer of customers){const score=claim(customer);if(score>rank){pick=customer;rank=score}}return pick},adopt=async(customer)=>{if(customer.metadata?.stacks_install===install)return customer;try{return await client.customers.update(customer.id,{metadata:{stacks_install:install}},options)}catch{return customer}};if(user.email){const byEmail=await client.customers.list({email:user.email,limit:100},options),found=best(byEmail.data);if(found)return await adopt(found)}try{const searched=await client.customers.search({query:`metadata['stacks_user_id']:'${userId.replace(/'/g,"")}'`,limit:100},options),found=best(searched.data);if(found)return await adopt(found)}catch{}const params={...user.email?{email:user.email}:{},...user.name?{name:user.name}:{},metadata:{stacks_user_id:userId,stacks_install:install}};return await client.customers.create(params,asAccount(accountId,{idempotencyKey:connectedCustomerIdempotencyKey(accountId,userId,params)}))}export async function directCheckout(user,accountId,params,client=stripe,request={}){requireAccountId(accountId,"directCheckout");if(request.idempotencyKey!==void 0&&(typeof request.idempotencyKey!=="string"||!/^[\x21-\x7e]{1,255}$/.test(request.idempotencyKey)))throw TypeError("A checkout idempotency key must contain 1-255 printable ASCII characters");assertSameOriginUrl(params.success_url,"success_url");assertSameOriginUrl(params.cancel_url,"cancel_url");const customer=params.customer??(params.customer_email?void 0:(await connectedCustomer(user,accountId,client)).id);return await client.checkout.sessions.create({mode:"payment",...params,...customer?{customer}:{}},asAccount(accountId,{idempotencyKey:request.idempotencyKey??freshIdempotencyKey("connect.checkout.create",user.id,accountId)}))}export const manageDirectSubscription=(()=>{function subscriptionData(options={}){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=manageDestinationSubscription.feePercentFor({platformFeePercent:options.platformFeePercent});return{...fee!=null?{application_fee_percent:fee}:{},...options.trialDays?{trial_period_days:options.trialDays}:{},...options.metadata?{metadata:options.metadata}:{}}}async function checkout(user,options,client=stripe){requireAccountId(options.account,"manageDirectSubscription.checkout");const customer=options.customer??(await connectedCustomer(user,options.account,client)).id;return await directCheckout(user,options.account,{mode:"subscription",customer,line_items:[manageDestinationSubscription.lineItem(options.price,options.quantity)],subscription_data:subscriptionData(options),success_url:options.successUrl,cancel_url:options.cancelUrl,...options.clientReferenceId?{client_reference_id:options.clientReferenceId}:{},...options.metadata?{metadata:options.metadata}:{},...options.sessionParams},client,{idempotencyKey:options.idempotencyKey})}return{subscriptionData,checkout}})();
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.407",
5
+ "version": "0.75.408",
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.407",
64
- "@stacksjs/database": "0.75.407",
65
- "@stacksjs/error-handling": "0.75.407",
66
- "@stacksjs/logging": "0.75.407",
67
- "@stacksjs/orm": "0.75.407",
68
- "@stacksjs/types": "0.75.407"
63
+ "@stacksjs/config": "0.75.408",
64
+ "@stacksjs/database": "0.75.408",
65
+ "@stacksjs/error-handling": "0.75.408",
66
+ "@stacksjs/logging": "0.75.408",
67
+ "@stacksjs/orm": "0.75.408",
68
+ "@stacksjs/types": "0.75.408"
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.407",
83
+ "@stacksjs/utils": "0.75.408",
84
84
  "@stripe/stripe-js": "^10.0.0",
85
85
  "better-dx": "^0.2.24",
86
86
  "stripe": "23.0.0"