@stacksjs/payments 0.72.63 → 0.72.64

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,2 +1,33 @@
1
1
  import type { Result } from '@stacksjs/error-handling';
2
- export declare function createStripeProduct(): Promise<Result<string, Error>>;
2
+ /**
3
+ * Provision the products and prices declared in `config/saas.ts`.
4
+ *
5
+ * Idempotent by construction, because this is a command people re-run: a second
6
+ * environment, a price change, an added plan, an unguarded CI step. The previous
7
+ * implementation created unconditionally, so a second run left a duplicate
8
+ * product behind and then failed on `prices.create` — a lookup key may only
9
+ * belong to one active price — exiting non-zero having half-applied
10
+ * (stacksjs/stacks#2359).
11
+ *
12
+ * Stripe prices are immutable, so a changed amount cannot be edited in place.
13
+ * The lookup key is moved onto a new price with `transfer_lookup_key`, which
14
+ * Stripe applies atomically, and the superseded price is left active but
15
+ * unkeyed so existing subscriptions on it keep billing.
16
+ */
17
+ export declare function createStripeProduct(options?: SetupProductsOptions): Promise<Result<SetupProductsReport, Error>>;
18
+ /** Render a report as one line per action, for the CLI to print. */
19
+ export declare function formatSetupReport(report: SetupProductsReport): string[];
20
+ export declare interface SetupProductsOptions {
21
+ dryRun?: boolean
22
+ }
23
+ /** One line of the plan of record, in the order it would be applied. */
24
+ export declare interface SetupProductAction {
25
+ kind: 'product' | 'price'
26
+ verb: 'create' | 'reuse' | 'replace'
27
+ target: string
28
+ detail?: string
29
+ }
30
+ export declare interface SetupProductsReport {
31
+ dryRun: boolean
32
+ actions: SetupProductAction[]
33
+ }
@@ -1 +1 @@
1
- import{saas}from"@stacksjs/config";import{err,ok}from"@stacksjs/error-handling";import{log}from"@stacksjs/logging";import{stripe}from"@stacksjs/payments";export async function createStripeProduct(){const plans=saas.plans;try{if(plans!==void 0&&plans.length)for(const plan of plans){const product=await stripe.products.create({name:plan.productName,description:plan.description,metadata:plan.metadata});for(const pricing of plan.pricing)if(product){const priceParams={unit_amount:pricing.price,currency:pricing.currency,product:product.id,lookup_key:pricing.key};if(pricing.interval)priceParams.recurring={interval:pricing.interval};await stripe.prices.create(priceParams)}}return ok("Migrations generated")}catch(error){const e=error instanceof Error?error:Error(String(error));log.error(e);return err(e)}}
1
+ import{saas}from"@stacksjs/config";import{err,ok}from"@stacksjs/error-handling";import{log}from"@stacksjs/logging";import{stripe}from"@stacksjs/payments";async function findProductByName(name){for await(const product of stripe.products.list({active:!0,limit:100}))if(product.name===name)return product;return}async function findPriceByLookupKey(lookupKey){return(await stripe.prices.list({lookup_keys:[lookupKey],active:!0,limit:1})).data[0]}function priceMatches(price,params){return price.unit_amount===params.unit_amount&&price.currency===params.currency&&price.product===params.product&&(price.recurring?.interval??void 0)===params.recurring?.interval}export async function createStripeProduct(options={}){const dryRun=options.dryRun??!1,actions=[],plans=saas.plans;try{if(plans===void 0||!plans.length)return ok({dryRun,actions});for(const plan of plans){const existingProduct=await findProductByName(plan.productName);let productId=existingProduct?.id;if(existingProduct)actions.push({kind:"product",verb:"reuse",target:plan.productName,detail:existingProduct.id});else{actions.push({kind:"product",verb:"create",target:plan.productName});if(!dryRun)productId=(await stripe.products.create({name:plan.productName,description:plan.description,metadata:plan.metadata})).id}for(const pricing of plan.pricing){if(!productId){actions.push({kind:"price",verb:"create",target:pricing.key});continue}const priceParams={unit_amount:pricing.price,currency:pricing.currency,product:productId,lookup_key:pricing.key};if(pricing.interval)priceParams.recurring={interval:pricing.interval};const existingPrice=await findPriceByLookupKey(pricing.key);if(existingPrice&&priceMatches(existingPrice,priceParams)){actions.push({kind:"price",verb:"reuse",target:pricing.key,detail:existingPrice.id});continue}if(existingPrice){actions.push({kind:"price",verb:"replace",target:pricing.key,detail:`${existingPrice.id} -> new price (${pricing.price} ${pricing.currency})`});priceParams.transfer_lookup_key=!0}else actions.push({kind:"price",verb:"create",target:pricing.key});if(!dryRun)await stripe.prices.create(priceParams)}}return ok({dryRun,actions})}catch(error){const e=error instanceof Error?error:Error(String(error));log.error(e);return err(e)}}export function formatSetupReport(report){if(!report.actions.length)return["No plans are declared in config/saas.ts, so there is nothing to provision."];return report.actions.map((action)=>{const verb=action.verb==="reuse"?"already exists":action.verb==="replace"?"moves lookup key to a new price":"creates";return` ${action.kind} "${action.target}" ${verb}${action.detail?` (${action.detail})`:""}`})}
@@ -1,5 +1,14 @@
1
1
  import type { UserModel } from '@stacksjs/orm';
2
2
  import type Stripe from 'stripe';
3
+ /**
4
+ * The provider statuses that mean the customer currently has what they paid for.
5
+ *
6
+ * Kept as one list because `isValid` is the question every entitlement check
7
+ * ultimately asks, and the answer has to be the same everywhere it is asked.
8
+ */
9
+ export declare const ENTITLING_STATUSES: readonly ['active', 'trialing'];
10
+ /** Statuses meaning payment was started but never completed. */
11
+ export declare const INCOMPLETE_STATUSES: readonly ['incomplete'];
3
12
  export declare const manageSubscription: SubscriptionManager;
4
13
  export declare interface SubscriptionManager {
5
14
  create: (user: UserModel, type: string, lookupKey: string, params: Partial<Stripe.SubscriptionCreateParams>) => Promise<Stripe.Response<Stripe.Subscription>>
@@ -1 +1 @@
1
- import{db}from"@stacksjs/database";import{HttpError}from"@stacksjs/error-handling";import{isUniqueViolation}from"@stacksjs/orm";import{stripe}from"../drivers/stripe";import{manageCustomer}from"./customer";import{managePrice}from"./price";import{stacksIdempotencyKey}from"../idempotency";export const manageSubscription=(()=>{async function create(user,type,lookupKey,params){const price=await managePrice.retrieveByLookupKey(lookupKey);if(!price)throw Error("Price does not exist in Stripe");const subscriptionItems=[{price:price.id,quantity:1}],mergedParams={...{customer:await manageCustomer.createOrGetStripeUser(user,{}).then((customer)=>{if(!customer||!customer.id)throw Error("Customer does not exist in Stripe");return customer.id}),payment_behavior:"allow_incomplete",expand:["latest_invoice.payment_intent"],items:subscriptionItems},...params},subscription=await stripe.subscriptions.create(mergedParams,{idempotencyKey:stacksIdempotencyKey("subscription.create",user.id,type,lookupKey)});await storeSubscription(user,type,lookupKey,subscription);return subscription}async function update(user,type,lookupKey,_params={}){const newPrice=await managePrice.retrieveByLookupKey(lookupKey),activeSubscription=await user?.activeSubscription();if(!newPrice)throw Error("New price does not exist in Stripe");if(!activeSubscription)throw Error("No active subscription for user!");const subscriptionId=activeSubscription.subscription?.provider_id;if(!subscriptionId)throw Error("Active subscription has no provider ID");const subscription=await stripe.subscriptions.retrieve(subscriptionId);if(!subscription)throw Error("Subscription does not exist in Stripe");const subscriptionItemId=subscription.items.data[0]?.id;if(!subscriptionItemId)throw Error("No subscription items found in the subscription");await stripe.subscriptions.update(subscriptionId,{items:[{id:subscriptionItemId,price:newPrice.id,quantity:1}],proration_behavior:"create_prorations"},{idempotencyKey:stacksIdempotencyKey("subscription.update",subscriptionId,newPrice.id)});const updatedSubscription=await stripe.subscriptions.retrieve(subscriptionId);if(!activeSubscription.subscription?.id)throw Error("Active subscription has no database ID");await updateSubscription(activeSubscription.subscription.id,type,updatedSubscription);return updatedSubscription}async function cancel(subscriptionId,params){if(!await stripe.subscriptions.retrieve(subscriptionId))throw Error("Subscription does not exist or does not belong to the user");const updatedSubscription=await stripe.subscriptions.cancel(subscriptionId,params,{idempotencyKey:stacksIdempotencyKey("subscription.cancel",subscriptionId)});await updateStoredSubscription(subscriptionId);return updatedSubscription}async function retrieve(user,subscriptionId){if(!user.hasStripeId())throw Error("Customer does not exist in Stripe");return await stripe.subscriptions.retrieve(subscriptionId)}async function updateStoredSubscription(subscriptionId){await db.updateTable("subscriptions").set({provider_status:"canceled"}).where("provider_id","=",subscriptionId).executeTakeFirst()}function isActive(subscription){return subscription.provider_status==="active"}function isTrial(subscription){return subscription.provider_status==="trialing"}async function isIncomplete(user,type){const subscription=await db.selectFrom("subscriptions").where("user_id","=",user.id).where("type","=",type).selectAll().executeTakeFirst();if(!subscription)return!1;return subscription.provider_status==="incomplete"}async function isValid(user,type){const subscription=await db.selectFrom("subscriptions").where("user_id","=",user.id).where("type","=",type).selectAll().executeTakeFirst();if(!subscription)return!1;const active=await isActive(subscription),trial=await isTrial(subscription);return active||trial}async function storeSubscription(user,type,_lookupKey,options){const firstItem=options.items.data[0];if(!firstItem)throw Error("Stripe subscription contains no line items - cannot store subscription");const data=removeNullValues({user_id:user.id,type,unit_price:Number(firstItem.price.unit_amount),provider_id:options.id,provider_status:options.status,provider_price_id:firstItem.price.id,quantity:firstItem.quantity,trial_ends_at:options.trial_end!=null?String(options.trial_end):void 0,ends_at:options.current_period_end!=null?String(options.current_period_end):void 0,provider_type:"stripe",last_used_at:options.current_period_end!=null?String(options.current_period_end):void 0});let subscriptionModelCreated;try{subscriptionModelCreated=await db.insertInto("subscriptions").values(data).executeTakeFirst()}catch(error){if(isUniqueViolation(error))throw new HttpError(409,"A subscription with this provider ID already exists");throw error}if(!subscriptionModelCreated)throw Error("Failed to insert subscription record");return await db.selectFrom("subscriptions").where("id","=",Number(subscriptionModelCreated.insertId)).selectAll().executeTakeFirst()}async function updateSubscription(activeSubId,type,options){const subscription=await db.selectFrom("subscriptions").where("id","=",activeSubId).selectAll().executeTakeFirst(),firstItem=options.items.data[0];if(!firstItem)throw Error("Stripe subscription contains no line items - cannot update subscription");await db?.updateTable("subscriptions").set({type,provider_price_id:firstItem.price.id,unit_price:Number(firstItem.price.unit_amount)}).where("id","=",activeSubId).executeTakeFirst();return subscription}function removeNullValues(obj){return Object.fromEntries(Object.entries(obj).filter(([_,value])=>value!=null))}return{create,update,isValid,isIncomplete,cancel,retrieve}})();
1
+ import{db}from"@stacksjs/database";import{HttpError}from"@stacksjs/error-handling";import{isUniqueViolation}from"@stacksjs/orm";import{stripe}from"../drivers/stripe";import{manageCustomer}from"./customer";import{managePrice}from"./price";import{stacksIdempotencyKey}from"../idempotency";export const ENTITLING_STATUSES=["active","trialing"],INCOMPLETE_STATUSES=["incomplete"],manageSubscription=(()=>{async function create(user,type,lookupKey,params){const price=await managePrice.retrieveByLookupKey(lookupKey);if(!price)throw Error("Price does not exist in Stripe");const subscriptionItems=[{price:price.id,quantity:1}],mergedParams={...{customer:await manageCustomer.createOrGetStripeUser(user,{}).then((customer)=>{if(!customer||!customer.id)throw Error("Customer does not exist in Stripe");return customer.id}),payment_behavior:"allow_incomplete",expand:["latest_invoice.payment_intent"],items:subscriptionItems},...params},subscription=await stripe.subscriptions.create(mergedParams,{idempotencyKey:stacksIdempotencyKey("subscription.create",user.id,type,lookupKey)});await storeSubscription(user,type,lookupKey,subscription);return subscription}async function update(user,type,lookupKey,_params={}){const newPrice=await managePrice.retrieveByLookupKey(lookupKey),activeSubscription=await user?.activeSubscription();if(!newPrice)throw Error("New price does not exist in Stripe");if(!activeSubscription)throw Error("No active subscription for user!");const subscriptionId=activeSubscription.subscription?.provider_id;if(!subscriptionId)throw Error("Active subscription has no provider ID");const subscription=await stripe.subscriptions.retrieve(subscriptionId);if(!subscription)throw Error("Subscription does not exist in Stripe");const subscriptionItemId=subscription.items.data[0]?.id;if(!subscriptionItemId)throw Error("No subscription items found in the subscription");await stripe.subscriptions.update(subscriptionId,{items:[{id:subscriptionItemId,price:newPrice.id,quantity:1}],proration_behavior:"create_prorations"},{idempotencyKey:stacksIdempotencyKey("subscription.update",subscriptionId,newPrice.id)});const updatedSubscription=await stripe.subscriptions.retrieve(subscriptionId);if(!activeSubscription.subscription?.id)throw Error("Active subscription has no database ID");await updateSubscription(activeSubscription.subscription.id,type,updatedSubscription);return updatedSubscription}async function cancel(subscriptionId,params){if(!await stripe.subscriptions.retrieve(subscriptionId))throw Error("Subscription does not exist or does not belong to the user");const updatedSubscription=await stripe.subscriptions.cancel(subscriptionId,params,{idempotencyKey:stacksIdempotencyKey("subscription.cancel",subscriptionId)});await updateStoredSubscription(subscriptionId);return updatedSubscription}async function retrieve(user,subscriptionId){if(!user.hasStripeId())throw Error("Customer does not exist in Stripe");return await stripe.subscriptions.retrieve(subscriptionId)}async function updateStoredSubscription(subscriptionId){await db.updateTable("subscriptions").set({provider_status:"canceled"}).where("provider_id","=",subscriptionId).executeTakeFirst()}function isActive(subscription){return subscription.provider_status==="active"}function isTrial(subscription){return subscription.provider_status==="trialing"}async function hasSubscriptionInStatus(user,type,statuses){const match=await db.selectFrom("subscriptions").where("user_id","=",user.id).where("type","=",type).whereIn("provider_status",[...statuses]).select(["id"]).limit(1).executeTakeFirst();return Boolean(match)}async function isIncomplete(user,type){return await hasSubscriptionInStatus(user,type,INCOMPLETE_STATUSES)}async function isValid(user,type){return await hasSubscriptionInStatus(user,type,ENTITLING_STATUSES)}async function storeSubscription(user,type,_lookupKey,options){const firstItem=options.items.data[0];if(!firstItem)throw Error("Stripe subscription contains no line items - cannot store subscription");const data=removeNullValues({user_id:user.id,type,unit_price:Number(firstItem.price.unit_amount),provider_id:options.id,provider_status:options.status,provider_price_id:firstItem.price.id,quantity:firstItem.quantity,trial_ends_at:options.trial_end!=null?String(options.trial_end):void 0,ends_at:options.current_period_end!=null?String(options.current_period_end):void 0,provider_type:"stripe",last_used_at:options.current_period_end!=null?String(options.current_period_end):void 0});let subscriptionModelCreated;try{subscriptionModelCreated=await db.insertInto("subscriptions").values(data).executeTakeFirst()}catch(error){if(isUniqueViolation(error))throw new HttpError(409,"A subscription with this provider ID already exists");throw error}if(!subscriptionModelCreated)throw Error("Failed to insert subscription record");return await db.selectFrom("subscriptions").where("id","=",Number(subscriptionModelCreated.insertId)).selectAll().executeTakeFirst()}async function updateSubscription(activeSubId,type,options){const subscription=await db.selectFrom("subscriptions").where("id","=",activeSubId).selectAll().executeTakeFirst(),firstItem=options.items.data[0];if(!firstItem)throw Error("Stripe subscription contains no line items - cannot update subscription");await db?.updateTable("subscriptions").set({type,provider_price_id:firstItem.price.id,unit_price:Number(firstItem.price.unit_amount)}).where("id","=",activeSubId).executeTakeFirst();return subscription}function removeNullValues(obj){return Object.fromEntries(Object.entries(obj).filter(([_,value])=>value!=null))}return{create,update,isValid,isIncomplete,cancel,retrieve}})();
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@stacksjs/payments",
3
3
  "type": "module",
4
4
  "sideEffects": false,
5
- "version": "0.72.63",
5
+ "version": "0.72.64",
6
6
  "description": "The Stacks payments package. Currently supporting Stripe.",
7
7
  "author": "Chris Breuer",
8
8
  "contributors": [
@@ -72,9 +72,9 @@
72
72
  }
73
73
  },
74
74
  "devDependencies": {
75
- "@stacksjs/config": "0.72.63",
75
+ "@stacksjs/config": "0.72.64",
76
76
  "better-dx": "^0.2.24",
77
- "@stacksjs/utils": "0.72.63",
77
+ "@stacksjs/utils": "0.72.64",
78
78
  "@stripe/stripe-js": "^9.10.0",
79
79
  "stripe": "22.3.2"
80
80
  }