1313 lines
52 KiB
TypeScript
1313 lines
52 KiB
TypeScript
import Stripe from 'stripe'
|
|
import { Request } from 'express'
|
|
import { UsageCacheManager } from './UsageCacheManager'
|
|
import { UserPlan } from './Interface'
|
|
import { GeneralErrorMessage, LICENSE_QUOTAS } from './utils/constants'
|
|
import { InternalFlowiseError } from './errors/internalFlowiseError'
|
|
import { StatusCodes } from 'http-status-codes'
|
|
import logger from './utils/logger'
|
|
|
|
export class StripeManager {
|
|
private static instance: StripeManager
|
|
private stripe?: Stripe
|
|
private cacheManager: UsageCacheManager
|
|
|
|
public static async getInstance(): Promise<StripeManager> {
|
|
if (!StripeManager.instance) {
|
|
StripeManager.instance = new StripeManager()
|
|
await StripeManager.instance.initialize()
|
|
}
|
|
return StripeManager.instance
|
|
}
|
|
|
|
private async initialize() {
|
|
if (!this.stripe && process.env.STRIPE_SECRET_KEY) {
|
|
this.stripe = new Stripe(process.env.STRIPE_SECRET_KEY)
|
|
}
|
|
this.cacheManager = await UsageCacheManager.getInstance()
|
|
}
|
|
|
|
public getStripe() {
|
|
if (!this.stripe) throw new Error('Stripe is not initialized')
|
|
return this.stripe
|
|
}
|
|
|
|
public getSubscriptionObject(subscription: Stripe.Response<Stripe.Subscription>) {
|
|
return {
|
|
customer: subscription.customer,
|
|
status: subscription.status,
|
|
created: subscription.created
|
|
}
|
|
}
|
|
|
|
public async getProductIdFromSubscription(subscriptionId: string) {
|
|
if (!this.stripe) {
|
|
throw new Error('Stripe is not initialized')
|
|
}
|
|
|
|
const subscriptionData = await this.cacheManager.getSubscriptionDataFromCache(subscriptionId)
|
|
if (subscriptionData?.productId) {
|
|
return subscriptionData.productId
|
|
}
|
|
|
|
try {
|
|
const subscription = await this.stripe.subscriptions.retrieve(subscriptionId)
|
|
const items = subscription.items.data
|
|
if (items.length === 0) {
|
|
return ''
|
|
}
|
|
|
|
const productId = items[0].price.product as string
|
|
await this.cacheManager.updateSubscriptionDataToCache(subscriptionId, {
|
|
productId,
|
|
subsriptionDetails: this.getSubscriptionObject(subscription)
|
|
})
|
|
|
|
return productId
|
|
} catch (error) {
|
|
console.error('Error getting product ID from subscription:', error)
|
|
throw error
|
|
}
|
|
}
|
|
|
|
public async getFeaturesByPlan(subscriptionId: string, withoutCache: boolean = false) {
|
|
if (!this.stripe || !subscriptionId) {
|
|
return {}
|
|
}
|
|
|
|
if (!withoutCache) {
|
|
const subscriptionData = await this.cacheManager.getSubscriptionDataFromCache(subscriptionId)
|
|
if (subscriptionData?.features) {
|
|
return subscriptionData.features
|
|
}
|
|
}
|
|
|
|
const subscription = await this.stripe.subscriptions.retrieve(subscriptionId, {
|
|
timeout: 5000
|
|
})
|
|
const items = subscription.items.data
|
|
if (items.length === 0) {
|
|
return {}
|
|
}
|
|
|
|
const productId = items[0].price.product as string
|
|
const product = await this.stripe.products.retrieve(productId, {
|
|
timeout: 5000
|
|
})
|
|
const productMetadata = product.metadata
|
|
|
|
if (!productMetadata || Object.keys(productMetadata).length === 0) {
|
|
return {}
|
|
}
|
|
|
|
const features: Record<string, string> = {}
|
|
for (const key in productMetadata) {
|
|
if (key.startsWith('feat:')) {
|
|
features[key] = productMetadata[key]
|
|
}
|
|
}
|
|
|
|
await this.cacheManager.updateSubscriptionDataToCache(subscriptionId, {
|
|
features,
|
|
subsriptionDetails: this.getSubscriptionObject(subscription)
|
|
})
|
|
|
|
return features
|
|
}
|
|
|
|
public async createStripeCustomerPortalSession(req: Request) {
|
|
if (!this.stripe) {
|
|
throw new Error('Stripe is not initialized')
|
|
}
|
|
|
|
const customerId = req.user?.activeOrganizationCustomerId
|
|
if (!customerId) {
|
|
throw new Error('Customer ID is required')
|
|
}
|
|
|
|
const subscriptionId = req.user?.activeOrganizationSubscriptionId
|
|
if (!subscriptionId) {
|
|
throw new Error('Subscription ID is required')
|
|
}
|
|
|
|
try {
|
|
const prodPriceIds = await this.getPriceIds()
|
|
const configuration = await this.createPortalConfiguration(prodPriceIds)
|
|
|
|
const portalSession = await this.stripe.billingPortal.sessions.create({
|
|
customer: customerId,
|
|
configuration: configuration.id,
|
|
return_url: `${process.env.APP_URL}/account`
|
|
/* We can't have flow_data because it does not support multiple subscription items
|
|
flow_data: {
|
|
type: 'subscription_update',
|
|
subscription_update: {
|
|
subscription: subscriptionId
|
|
},
|
|
after_completion: {
|
|
type: 'redirect',
|
|
redirect: {
|
|
return_url: `${process.env.APP_URL}/account/subscription?subscriptionId=${subscriptionId}`
|
|
}
|
|
}
|
|
}*/
|
|
})
|
|
|
|
return { url: portalSession.url }
|
|
} catch (error) {
|
|
console.error('Error creating customer portal session:', error)
|
|
throw error
|
|
}
|
|
}
|
|
|
|
private async getPriceIds() {
|
|
const prodPriceIds: Record<string, { product: string; price: string }> = {
|
|
[UserPlan.STARTER]: {
|
|
product: process.env.CLOUD_STARTER_ID as string,
|
|
price: ''
|
|
},
|
|
[UserPlan.PRO]: {
|
|
product: process.env.CLOUD_PRO_ID as string,
|
|
price: ''
|
|
},
|
|
[UserPlan.FREE]: {
|
|
product: process.env.CLOUD_FREE_ID as string,
|
|
price: ''
|
|
},
|
|
SEAT: {
|
|
product: process.env.ADDITIONAL_SEAT_ID as string,
|
|
price: ''
|
|
}
|
|
}
|
|
|
|
for (const key in prodPriceIds) {
|
|
const prices = await this.stripe!.prices.list({
|
|
product: prodPriceIds[key].product,
|
|
active: true,
|
|
limit: 1
|
|
})
|
|
|
|
if (prices.data.length) {
|
|
prodPriceIds[key].price = prices.data[0].id
|
|
}
|
|
}
|
|
|
|
return prodPriceIds
|
|
}
|
|
|
|
private async createPortalConfiguration(_: Record<string, { product: string; price: string }>) {
|
|
return await this.stripe!.billingPortal.configurations.create({
|
|
business_profile: {
|
|
privacy_policy_url: `${process.env.APP_URL}/privacy-policy`,
|
|
terms_of_service_url: `${process.env.APP_URL}/terms-of-service`
|
|
},
|
|
features: {
|
|
invoice_history: {
|
|
enabled: true
|
|
},
|
|
payment_method_update: {
|
|
enabled: true
|
|
},
|
|
subscription_cancel: {
|
|
enabled: false
|
|
}
|
|
/*subscription_update: {
|
|
enabled: false,
|
|
default_allowed_updates: ['price'],
|
|
products: [
|
|
{
|
|
product: prodPriceIds[UserPlan.FREE].product,
|
|
prices: [prodPriceIds[UserPlan.FREE].price]
|
|
},
|
|
{
|
|
product: prodPriceIds[UserPlan.STARTER].product,
|
|
prices: [prodPriceIds[UserPlan.STARTER].price]
|
|
},
|
|
{
|
|
product: prodPriceIds[UserPlan.PRO].product,
|
|
prices: [prodPriceIds[UserPlan.PRO].price]
|
|
}
|
|
],
|
|
proration_behavior: 'always_invoice'
|
|
}*/
|
|
}
|
|
})
|
|
}
|
|
|
|
public async getAdditionalSeatsQuantity(subscriptionId: string): Promise<{ quantity: number; includedSeats: number }> {
|
|
if (!this.stripe) {
|
|
throw new Error('Stripe is not initialized')
|
|
}
|
|
|
|
try {
|
|
const subscription = await this.stripe.subscriptions.retrieve(subscriptionId)
|
|
const additionalSeatsItem = subscription.items.data.find(
|
|
(item) => (item.price.product as string) === process.env.ADDITIONAL_SEAT_ID
|
|
)
|
|
const quotas = await this.cacheManager.getQuotas(subscriptionId)
|
|
|
|
return { quantity: additionalSeatsItem?.quantity || 0, includedSeats: quotas[LICENSE_QUOTAS.USERS_LIMIT] }
|
|
} catch (error) {
|
|
console.error('Error getting additional seats quantity:', error)
|
|
throw error
|
|
}
|
|
}
|
|
|
|
public async getCustomerWithDefaultSource(customerId: string) {
|
|
if (!this.stripe) {
|
|
throw new Error('Stripe is not initialized')
|
|
}
|
|
|
|
try {
|
|
const customer = (await this.stripe.customers.retrieve(customerId, {
|
|
expand: ['default_source', 'invoice_settings.default_payment_method']
|
|
})) as Stripe.Customer
|
|
|
|
return customer
|
|
} catch (error) {
|
|
console.error('Error retrieving customer with default source:', error)
|
|
throw error
|
|
}
|
|
}
|
|
|
|
public async getAdditionalSeatsProration(subscriptionId: string, quantity: number) {
|
|
if (!this.stripe) {
|
|
throw new Error('Stripe is not initialized')
|
|
}
|
|
|
|
try {
|
|
const subscription = await this.stripe.subscriptions.retrieve(subscriptionId)
|
|
|
|
// Get customer's credit balance
|
|
const customer = await this.stripe.customers.retrieve(subscription.customer as string)
|
|
const creditBalance = (customer as Stripe.Customer).balance // Balance is in cents, negative for credit, positive for amount owed
|
|
|
|
// Get the current subscription's base price (without seats)
|
|
const basePlanItem = subscription.items.data.find((item) => (item.price.product as string) !== process.env.ADDITIONAL_SEAT_ID)
|
|
const basePlanAmount = basePlanItem ? basePlanItem.price.unit_amount! * 1 : 0
|
|
|
|
const existingInvoice = await this.stripe.invoices.createPreview({
|
|
customer: subscription.customer as string,
|
|
subscription: subscriptionId
|
|
})
|
|
|
|
const existingInvoiceTotal = existingInvoice.total
|
|
|
|
// Get the price ID for additional seats
|
|
const prices = await this.stripe.prices.list({
|
|
product: process.env.ADDITIONAL_SEAT_ID,
|
|
active: true,
|
|
limit: 1
|
|
})
|
|
|
|
if (prices.data.length === 0) {
|
|
throw new Error('No active price found for additional seats')
|
|
}
|
|
|
|
const seatPrice = prices.data[0]
|
|
const pricePerSeat = seatPrice.unit_amount || 0
|
|
|
|
// TODO: Fix proration date for sandbox testing - use subscription period bounds
|
|
const prorationDate = this.calculateSafeProrationDate(subscription)
|
|
|
|
const additionalSeatsItem = subscription.items.data.find(
|
|
(item) => (item.price.product as string) === process.env.ADDITIONAL_SEAT_ID
|
|
)
|
|
|
|
const upcomingInvoice = await this.stripe.invoices.createPreview({
|
|
customer: subscription.customer as string,
|
|
subscription: subscriptionId,
|
|
subscription_details: {
|
|
proration_behavior: 'always_invoice',
|
|
proration_date: prorationDate,
|
|
items: [
|
|
additionalSeatsItem
|
|
? {
|
|
id: additionalSeatsItem.id,
|
|
quantity: quantity
|
|
}
|
|
: {
|
|
price: prices.data[0].id,
|
|
quantity: quantity
|
|
}
|
|
]
|
|
}
|
|
})
|
|
|
|
// Calculate proration amount from the relevant line items
|
|
// Only consider prorations that match our proration date
|
|
const prorationLineItems = upcomingInvoice.lines.data.filter(
|
|
(line: any) => line.type === 'invoiceitem' && line.period.start === prorationDate
|
|
)
|
|
|
|
const prorationAmount = prorationLineItems.reduce((total: number, item: any) => total + item.amount, 0)
|
|
|
|
return {
|
|
basePlanAmount: basePlanAmount / 100,
|
|
additionalSeatsProratedAmount: (existingInvoiceTotal + prorationAmount - basePlanAmount) / 100,
|
|
seatPerUnitPrice: pricePerSeat / 100,
|
|
prorationAmount: prorationAmount / 100,
|
|
nextInvoiceTotal: (existingInvoiceTotal + prorationAmount) / 100,
|
|
currency: upcomingInvoice.currency.toUpperCase(),
|
|
prorationDate,
|
|
currentPeriodStart: subscription.items.data[0]?.current_period_start,
|
|
currentPeriodEnd: subscription.items.data[0]?.current_period_end
|
|
}
|
|
} catch (error) {
|
|
console.error('Error calculating additional seats proration:', error)
|
|
throw error
|
|
}
|
|
}
|
|
|
|
public async updateAdditionalSeats(subscriptionId: string, quantity: number, _prorationDate: number, increase: boolean) {
|
|
if (!this.stripe) {
|
|
throw new Error('Stripe is not initialized')
|
|
}
|
|
|
|
try {
|
|
// Get the price ID for additional seats if needed
|
|
const prices = await this.stripe.prices.list({
|
|
product: process.env.ADDITIONAL_SEAT_ID,
|
|
active: true,
|
|
limit: 1
|
|
})
|
|
if (prices.data.length === 0) {
|
|
throw new Error('No active price found for additional seats')
|
|
}
|
|
|
|
const openInvoices = await this.stripe.invoices.list({
|
|
subscription: subscriptionId,
|
|
status: 'open'
|
|
})
|
|
const openAdditionalSeatsInvoices = openInvoices.data.filter((invoice) =>
|
|
invoice.lines?.data?.some((line) => (line as any).price?.id === prices.data[0].id)
|
|
)
|
|
if (openAdditionalSeatsInvoices.length > 0 && increase === true)
|
|
throw new InternalFlowiseError(StatusCodes.PAYMENT_REQUIRED, "Not allow to add seats when there're unsuccessful payment")
|
|
|
|
const subscription = await this.stripe.subscriptions.retrieve(subscriptionId)
|
|
const additionalSeatsItem = subscription.items.data.find(
|
|
(item) => (item.price.product as string) === process.env.ADDITIONAL_SEAT_ID
|
|
)
|
|
|
|
// TODO: Fix proration date for sandbox testing - use subscription period bounds
|
|
const adjustedProrationDate = this.calculateSafeProrationDate(subscription)
|
|
|
|
// Create an invoice immediately for the proration
|
|
const subscriptionUpdateData: any = {
|
|
items: [
|
|
additionalSeatsItem
|
|
? {
|
|
id: additionalSeatsItem.id
|
|
}
|
|
: {
|
|
price: prices.data[0].id
|
|
}
|
|
]
|
|
}
|
|
if (openAdditionalSeatsInvoices.length > 0) {
|
|
let newQuantity = 0
|
|
// When there is a paid and unpaid lines in the invoice, we need to remove the unpaid quantity of that invoice
|
|
if (openAdditionalSeatsInvoices[0].lines.data.length > 1) {
|
|
openAdditionalSeatsInvoices[0].lines.data.forEach((line) => {
|
|
if (line.amount < 0) newQuantity += line.quantity ?? 0
|
|
})
|
|
// If there is only one line in the invoice, we need to remove the whole quantity of that invoice
|
|
} else if (openAdditionalSeatsInvoices[0].lines.data.length === 1) {
|
|
newQuantity = 0
|
|
} else {
|
|
throw new InternalFlowiseError(StatusCodes.INTERNAL_SERVER_ERROR, GeneralErrorMessage.UNHANDLED_EDGE_CASE)
|
|
}
|
|
quantity = newQuantity
|
|
await this.stripe.invoices.voidInvoice(openAdditionalSeatsInvoices[0].id!)
|
|
subscriptionUpdateData.proration_behavior = 'none'
|
|
} else {
|
|
;(subscriptionUpdateData.proration_behavior = 'always_invoice'),
|
|
(subscriptionUpdateData.proration_date = adjustedProrationDate)
|
|
}
|
|
subscriptionUpdateData.items[0].quantity = quantity
|
|
const updatedSubscription = await this.stripe.subscriptions.update(subscriptionId, subscriptionUpdateData)
|
|
|
|
// Get the latest invoice for this subscription
|
|
const invoice = await this.stripe.invoices.list({
|
|
subscription: subscriptionId,
|
|
limit: 1
|
|
})
|
|
|
|
let paymentFailed = false
|
|
let paymentError: any = null
|
|
|
|
if (invoice.data.length > 0) {
|
|
const latestInvoice = invoice.data[0]
|
|
// Only try to pay if the invoice is not already paid
|
|
if (latestInvoice.status !== 'paid') {
|
|
try {
|
|
await this.stripe.invoices.pay(latestInvoice.id!)
|
|
} catch (error: any) {
|
|
// Payment failed but we still want to provision access
|
|
// This keeps Stripe and our app in sync - both will show the new seats
|
|
// Stripe will retry payment for a few days, then send invoice.marked_uncollectible
|
|
// Our webhook will handle setting org status to past_due at that point
|
|
paymentFailed = true
|
|
paymentError = error
|
|
console.error('Payment failed during additional seats update, but provisioning access anyway:', error)
|
|
}
|
|
}
|
|
}
|
|
|
|
return {
|
|
success: true,
|
|
subscription: updatedSubscription,
|
|
invoice: invoice.data[0],
|
|
paymentFailed, // Indicates if payment failed but seats were still updated
|
|
paymentError: paymentFailed ? paymentError : null // Error details for frontend display
|
|
}
|
|
} catch (error) {
|
|
console.error('Error updating additional seats:', error)
|
|
throw error
|
|
}
|
|
}
|
|
|
|
public async getPlanProration(subscriptionId: string, newPlanId: string) {
|
|
if (!this.stripe) {
|
|
throw new Error('Stripe is not initialized')
|
|
}
|
|
|
|
try {
|
|
const subscription = await this.stripe.subscriptions.retrieve(subscriptionId)
|
|
const customerId = subscription.customer as string
|
|
|
|
// Get customer's credit balance and metadata
|
|
const customer = await this.stripe.customers.retrieve(customerId)
|
|
const creditBalance = (customer as Stripe.Customer).balance
|
|
const customerMetadata = (customer as Stripe.Customer).metadata || {}
|
|
|
|
// Get the price ID for the new plan
|
|
const prices = await this.stripe.prices.list({
|
|
product: newPlanId,
|
|
active: true,
|
|
limit: 1
|
|
})
|
|
|
|
if (prices.data.length === 0) {
|
|
throw new Error('No active price found for the selected plan')
|
|
}
|
|
|
|
const newPlan = prices.data[0]
|
|
const newPlanPrice = newPlan.unit_amount || 0
|
|
|
|
// Check if this is the STARTER plan and eligible for first month free
|
|
const isStarterPlan = newPlanId === process.env.CLOUD_STARTER_ID
|
|
const hasUsedFirstMonthFreeCoupon = customerMetadata?.has_used_first_month_free === 'true'
|
|
const eligibleForFirstMonthFree = isStarterPlan && !hasUsedFirstMonthFreeCoupon
|
|
|
|
// TODO: Fix proration date for sandbox testing - use subscription period bounds
|
|
const subscriptionForProration = await this.stripe.subscriptions.retrieve(subscriptionId)
|
|
const prorationDate = this.calculateSafeProrationDate(subscriptionForProration)
|
|
|
|
// Check if this is a downgrade to free plan (Issue 1)
|
|
const isDowngradeToFree = newPlanId === process.env.CLOUD_FREE_ID
|
|
let prorationBehavior: 'always_invoice' | 'none' = 'always_invoice'
|
|
|
|
if (isDowngradeToFree) {
|
|
// Get the latest invoice to determine proration behavior
|
|
const latestInvoicesList = await this.stripe.invoices.list({
|
|
subscription: subscriptionId,
|
|
limit: 1
|
|
})
|
|
|
|
if (latestInvoicesList.data.length > 0) {
|
|
const latestInvoice = latestInvoicesList.data[0]
|
|
// Issue 1: Check if latest invoice was paid and non-zero
|
|
prorationBehavior = latestInvoice.status === 'paid' && latestInvoice.amount_paid > 0 ? 'always_invoice' : 'none'
|
|
} else {
|
|
// No invoices found, use 'none' for free plan downgrade
|
|
prorationBehavior = 'none'
|
|
}
|
|
}
|
|
|
|
const subscriptionDetails: any = {
|
|
proration_behavior: prorationBehavior,
|
|
items: [
|
|
{
|
|
id: subscription.items.data[0].id,
|
|
price: newPlan.id
|
|
}
|
|
]
|
|
}
|
|
|
|
// Only set proration_date if we're actually doing proration
|
|
if (prorationBehavior === 'always_invoice') {
|
|
subscriptionDetails.proration_date = prorationDate
|
|
}
|
|
|
|
const upcomingInvoice = await this.stripe.invoices.createPreview({
|
|
customer: customerId,
|
|
subscription: subscriptionId,
|
|
subscription_details: subscriptionDetails
|
|
})
|
|
|
|
let prorationAmount = upcomingInvoice.lines.data.reduce((total: number, item: any) => total + item.amount, 0)
|
|
if (eligibleForFirstMonthFree) {
|
|
prorationAmount = 0
|
|
}
|
|
|
|
return {
|
|
newPlanAmount: newPlanPrice / 100,
|
|
prorationAmount: prorationAmount / 100,
|
|
creditBalance: creditBalance / 100,
|
|
currency: upcomingInvoice.currency.toUpperCase(),
|
|
prorationDate,
|
|
currentPeriodStart: subscription.items.data[0]?.current_period_start,
|
|
currentPeriodEnd: subscription.items.data[0]?.current_period_end,
|
|
eligibleForFirstMonthFree,
|
|
prorationBehavior
|
|
}
|
|
} catch (error) {
|
|
console.error('Error calculating plan proration:', error)
|
|
throw error
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Helper function to calculate a safe proration date within subscription period bounds
|
|
* TODO: Remove this helper when sandbox testing is complete
|
|
*/
|
|
private calculateSafeProrationDate(subscription: any): number {
|
|
return Math.max(
|
|
subscription.current_period_start + 60, // At least 1 minute into current period
|
|
Math.min(
|
|
Math.floor(Date.now() / 1000) - 60, // Prefer current time minus 1 minute
|
|
subscription.current_period_end - 60 // But no later than 1 minute before period end
|
|
)
|
|
)
|
|
}
|
|
|
|
public async updateSubscriptionPlan(subscriptionId: string, newPlanId: string, _prorationDate: number) {
|
|
if (!this.stripe) {
|
|
throw new Error('Stripe is not initialized')
|
|
}
|
|
|
|
try {
|
|
const subscription = await this.stripe.subscriptions.retrieve(subscriptionId)
|
|
const customerId = subscription.customer as string
|
|
|
|
// Get customer details and metadata
|
|
const customer = await this.stripe.customers.retrieve(customerId)
|
|
const customerMetadata = (customer as Stripe.Customer).metadata || {}
|
|
|
|
// Get the price ID for the new plan
|
|
const prices = await this.stripe.prices.list({
|
|
product: newPlanId,
|
|
active: true,
|
|
limit: 1
|
|
})
|
|
|
|
if (prices.data.length === 0) {
|
|
throw new Error('No active price found for the selected plan')
|
|
}
|
|
|
|
const newPlan = prices.data[0]
|
|
let updatedSubscription: Stripe.Response<Stripe.Subscription>
|
|
|
|
// Check if this is an upgrade to CLOUD_STARTER_ID and eligible for first month free
|
|
const isStarterPlan = newPlanId === process.env.CLOUD_STARTER_ID
|
|
const hasUsedFirstMonthFreeCoupon = customerMetadata.has_used_first_month_free === 'true'
|
|
|
|
// Check if this is a downgrade to free plan
|
|
const isDowngradeToFree = newPlanId === process.env.CLOUD_FREE_ID
|
|
|
|
// Handle downgrade to free plan during retry period (Issues 1 & 2)
|
|
if (isDowngradeToFree) {
|
|
// Get the latest invoice
|
|
const latestInvoicesList = await this.stripe.invoices.list({
|
|
subscription: subscriptionId,
|
|
limit: 1,
|
|
status: 'open'
|
|
})
|
|
|
|
if (latestInvoicesList.data.length > 0) {
|
|
const latestInvoice = latestInvoicesList.data[0]
|
|
|
|
// Check if the subscription is in past_due and invoice is in retry
|
|
if (subscription.status === 'past_due' && latestInvoice.status === 'open') {
|
|
// Issue 2: Void the latest invoice and activate subscription
|
|
await this.stripe.invoices.voidInvoice(latestInvoice.id!)
|
|
|
|
// Update subscription to free plan
|
|
updatedSubscription = await this.stripe.subscriptions.update(subscriptionId, {
|
|
items: [
|
|
{
|
|
id: subscription.items.data[0].id,
|
|
price: newPlan.id
|
|
}
|
|
],
|
|
proration_behavior: 'none'
|
|
})
|
|
|
|
// Create a $0 invoice and mark it as paid
|
|
const zeroInvoice = await this.stripe.invoices.create({
|
|
customer: customerId,
|
|
subscription: subscriptionId,
|
|
collection_method: 'charge_automatically',
|
|
auto_advance: false
|
|
})
|
|
|
|
await this.stripe.invoices.pay(zeroInvoice.id!)
|
|
|
|
return {
|
|
success: true,
|
|
subscription: updatedSubscription,
|
|
invoice: zeroInvoice,
|
|
special_case: 'downgrade_from_past_due'
|
|
}
|
|
} else {
|
|
// Issue 1: Check if latest invoice was paid and non-zero
|
|
const prorationBehavior =
|
|
latestInvoice.status === 'paid' && latestInvoice.amount_paid > 0 ? 'always_invoice' : 'none'
|
|
|
|
const subscriptionUpdateData: any = {
|
|
items: [
|
|
{
|
|
id: subscription.items.data[0].id,
|
|
price: newPlan.id
|
|
}
|
|
],
|
|
proration_behavior: prorationBehavior
|
|
}
|
|
|
|
// Only set proration_date if we're actually doing proration
|
|
if (prorationBehavior === 'always_invoice') {
|
|
// TODO: Fix proration date for sandbox testing - use subscription period bounds
|
|
subscriptionUpdateData.proration_date = this.calculateSafeProrationDate(subscription)
|
|
}
|
|
|
|
updatedSubscription = await this.stripe.subscriptions.update(subscriptionId, subscriptionUpdateData)
|
|
}
|
|
} else {
|
|
// No invoices found, proceed with normal downgrade
|
|
updatedSubscription = await this.stripe.subscriptions.update(subscriptionId, {
|
|
items: [
|
|
{
|
|
id: subscription.items.data[0].id,
|
|
price: newPlan.id
|
|
}
|
|
],
|
|
proration_behavior: 'none'
|
|
})
|
|
}
|
|
} else if (isStarterPlan && !hasUsedFirstMonthFreeCoupon) {
|
|
// Create the one-time 100% off coupon
|
|
const coupon = await this.stripe!.coupons.create({
|
|
duration: 'once',
|
|
percent_off: 100,
|
|
max_redemptions: 1,
|
|
metadata: {
|
|
type: 'first_month_free',
|
|
customer_id: customerId,
|
|
plan_id: process.env.CLOUD_STARTER_ID || ''
|
|
}
|
|
})
|
|
|
|
// Create a promotion code linked to the coupon
|
|
const promotionCode = await this.stripe.promotionCodes.create({
|
|
coupon: coupon.id,
|
|
max_redemptions: 1
|
|
})
|
|
|
|
// TODO: Fix proration date for sandbox testing - use subscription period bounds
|
|
const adjustedProrationDate = this.calculateSafeProrationDate(subscription)
|
|
|
|
// Update the subscription with the new plan and apply the promotion code
|
|
updatedSubscription = await this.stripe.subscriptions.update(subscriptionId, {
|
|
items: [
|
|
{
|
|
id: subscription.items.data[0].id,
|
|
price: newPlan.id
|
|
}
|
|
],
|
|
proration_behavior: 'always_invoice',
|
|
proration_date: adjustedProrationDate,
|
|
discounts: [{ promotion_code: promotionCode.id }]
|
|
})
|
|
|
|
// Update customer metadata to mark the coupon as used
|
|
await this.stripe.customers.update(customerId, {
|
|
metadata: {
|
|
...customerMetadata,
|
|
has_used_first_month_free: 'true',
|
|
first_month_free_date: new Date().toISOString()
|
|
}
|
|
})
|
|
} else {
|
|
// TODO: Fix proration date for sandbox testing - use subscription period bounds
|
|
const adjustedProrationDate = this.calculateSafeProrationDate(subscription)
|
|
|
|
// Regular plan update without coupon
|
|
updatedSubscription = await this.stripe.subscriptions.update(subscriptionId, {
|
|
items: [
|
|
{
|
|
id: subscription.items.data[0].id,
|
|
price: newPlan.id
|
|
}
|
|
],
|
|
proration_behavior: 'always_invoice',
|
|
proration_date: adjustedProrationDate
|
|
})
|
|
}
|
|
|
|
// Get and pay the latest invoice (only if not a special case)
|
|
const invoice = await this.stripe.invoices.list({
|
|
subscription: subscriptionId,
|
|
limit: 1
|
|
})
|
|
|
|
let paymentFailed = false
|
|
let paymentError: any = null
|
|
|
|
if (invoice.data.length > 0) {
|
|
const latestInvoice = invoice.data[0]
|
|
if (latestInvoice.status !== 'paid') {
|
|
try {
|
|
await this.stripe.invoices.pay(latestInvoice.id!)
|
|
} catch (error: any) {
|
|
// Payment failed but we still want to provision access
|
|
// This keeps Stripe and our app in sync - both will show the new plan
|
|
// Stripe will retry payment for a few days, then send invoice.marked_uncollectible
|
|
// Our webhook will handle setting org status to past_due at that point
|
|
paymentFailed = true
|
|
paymentError = error
|
|
console.error('Payment failed during upgrade, but provisioning access anyway:', error)
|
|
}
|
|
}
|
|
}
|
|
|
|
return {
|
|
success: true,
|
|
subscription: updatedSubscription,
|
|
invoice: invoice.data[0],
|
|
paymentFailed, // Indicates if payment failed but plan was still upgraded
|
|
paymentError: paymentFailed ? paymentError : null // Error details for frontend display
|
|
}
|
|
} catch (error) {
|
|
console.error('Error updating subscription plan:', error)
|
|
throw error
|
|
}
|
|
}
|
|
|
|
public async checkPredictionEligibility(orgId: string, subscriptionId: string): Promise<Record<string, any>> {
|
|
if (!this.stripe) {
|
|
throw new Error('Stripe is not initialized')
|
|
}
|
|
|
|
try {
|
|
if (!subscriptionId || !orgId) {
|
|
throw new Error('Subscription ID and Organization ID are required')
|
|
}
|
|
|
|
// Get current usage and quotas
|
|
const usageCacheManager = this.cacheManager
|
|
const currentPredictions: number = (await usageCacheManager.get(`predictions:${orgId}`)) || 0
|
|
const quotas = await usageCacheManager.getQuotas(subscriptionId)
|
|
const predictionsLimit = quotas['quota:predictions']
|
|
|
|
// Check if within plan limits
|
|
if (predictionsLimit === -1 || currentPredictions < predictionsLimit) {
|
|
return {
|
|
allowed: true,
|
|
useCredits: false,
|
|
remainingCredits: null,
|
|
creditBalance: null,
|
|
currentUsage: currentPredictions,
|
|
planLimit: predictionsLimit,
|
|
withinPlanLimits: true
|
|
}
|
|
}
|
|
|
|
// Check credit balance for overage
|
|
const subscription = await this.stripe.subscriptions.retrieve(subscriptionId)
|
|
const customerId = subscription.customer as string
|
|
|
|
const creditBalance = await this.getCreditBalance(subscriptionId)
|
|
|
|
logger.info(`credit balance: ${JSON.stringify(creditBalance)}`)
|
|
|
|
const availableCredits = creditBalance.availableCredits || 0
|
|
const requestCost = 1 // 1 credit per prediction
|
|
|
|
logger.info(`available credits: ${availableCredits}`)
|
|
|
|
return {
|
|
allowed: availableCredits >= requestCost,
|
|
useCredits: true,
|
|
remainingCredits: availableCredits,
|
|
creditBalance: availableCredits,
|
|
currentUsage: currentPredictions,
|
|
planLimit: predictionsLimit,
|
|
withinPlanLimits: false
|
|
}
|
|
} catch (error) {
|
|
console.error('Error checking prediction eligibility:', error)
|
|
throw error
|
|
}
|
|
}
|
|
|
|
public async purchaseCredits(subscriptionId: string, packageType: string): Promise<Record<string, any>> {
|
|
if (!this.stripe) {
|
|
throw new Error('Stripe is not initialized')
|
|
}
|
|
|
|
try {
|
|
if (!process.env.CREDIT_PRODUCT_ID) {
|
|
throw new Error('CREDIT_PRODUCT_ID environment variable is required')
|
|
}
|
|
if (!process.env.METERED_PRICE_ID) {
|
|
throw new Error('METERED_PRICE_ID environment variable is required')
|
|
}
|
|
if (!subscriptionId || !packageType) {
|
|
throw new Error('Subscription ID and package type are required')
|
|
}
|
|
|
|
const subscription = await this.stripe.subscriptions.retrieve(subscriptionId)
|
|
const customerId = subscription.customer as string
|
|
|
|
if (!customerId) {
|
|
throw new Error('Customer ID not found in subscription')
|
|
}
|
|
|
|
// Get credit packages
|
|
const packages = await this.getCreditsPackages()
|
|
logger.info(`Retrieved credit packages: ${JSON.stringify(packages)}`)
|
|
const selectedPackage = packages.find((pkg: any) => pkg.id === packageType)
|
|
logger.info(`Selected credit packages: ${JSON.stringify(selectedPackage)}`)
|
|
|
|
if (!selectedPackage) {
|
|
throw new Error(`No active price found for ${packageType} package`)
|
|
}
|
|
|
|
// Create invoice for credit purchase
|
|
const invoice = await this.stripe.invoices.create({
|
|
customer: customerId,
|
|
auto_advance: true,
|
|
collection_method: 'charge_automatically'
|
|
})
|
|
|
|
if (!invoice.id) {
|
|
throw new Error('Invoice creation failed')
|
|
}
|
|
|
|
await this.stripe.invoiceItems.create({
|
|
customer: customerId,
|
|
amount: selectedPackage.price,
|
|
invoice: invoice.id,
|
|
currency: 'usd',
|
|
description: `${selectedPackage.credits} Credits Package`
|
|
})
|
|
|
|
const finalizedInvoice = await this.stripe.invoices.finalizeInvoice(invoice.id)
|
|
|
|
if (!finalizedInvoice.id) {
|
|
throw new Error('Failed to finalize invoice')
|
|
}
|
|
|
|
const paidInvoice = await this.stripe.invoices.pay(finalizedInvoice.id)
|
|
|
|
if (paidInvoice.status !== 'paid') {
|
|
throw new Error('Payment failed')
|
|
}
|
|
|
|
// Add metered subscription item if it doesn't exist
|
|
const meteredItemResult = await this.addMeteredSubscriptionItem(subscriptionId)
|
|
|
|
// Get price
|
|
const price = await this.stripe.prices.retrieve(process.env.METERED_PRICE_ID!)
|
|
|
|
// Create credit grant
|
|
const creditGrant = await this.stripe.billing.creditGrants.create({
|
|
customer: customerId,
|
|
amount: {
|
|
type: 'monetary' as any,
|
|
monetary: {
|
|
currency: 'usd',
|
|
value: selectedPackage.price
|
|
}
|
|
},
|
|
applicability_config: {
|
|
scope: {
|
|
prices: [{ id: price.id }]
|
|
}
|
|
} as any,
|
|
category: 'paid',
|
|
name: `${selectedPackage.credits} Credits Purchase`,
|
|
metadata: {
|
|
usage_count: '0'
|
|
}
|
|
})
|
|
|
|
// Clear cache
|
|
await this.cacheManager.del(`credits:balance:${customerId}`)
|
|
|
|
return {
|
|
invoice: paidInvoice,
|
|
creditGrant,
|
|
creditsGranted: selectedPackage.credits,
|
|
meteredItemAdded: meteredItemResult.added,
|
|
meteredItemMessage: meteredItemResult.message
|
|
}
|
|
} catch (error) {
|
|
console.error('Error purchasing credits:', error)
|
|
throw error
|
|
}
|
|
}
|
|
|
|
private calculateTotalCredits = (grants: any[]) => {
|
|
if (!grants || !Array.isArray(grants)) return 0
|
|
return grants.reduce((total, grant) => {
|
|
const grantValue = grant?.amount?.monetary?.value || 0
|
|
return total + grantValue
|
|
}, 0)
|
|
}
|
|
|
|
public async getCreditBalance(subscriptionId: string): Promise<Record<string, any>> {
|
|
if (!this.stripe) {
|
|
throw new Error('Stripe is not initialized')
|
|
}
|
|
|
|
try {
|
|
if (!subscriptionId) {
|
|
throw new Error('Subscription ID is required')
|
|
}
|
|
|
|
const subscription = await this.stripe.subscriptions.retrieve(subscriptionId)
|
|
const customerId = subscription.customer as string
|
|
|
|
if (!customerId) {
|
|
throw new Error('Customer ID not found in subscription')
|
|
}
|
|
|
|
const creditBalance = await this.stripe.billing.creditBalanceSummary.retrieve({
|
|
customer: customerId,
|
|
filter: {
|
|
type: 'applicability_scope',
|
|
applicability_scope: {
|
|
price_type: 'metered'
|
|
}
|
|
}
|
|
})
|
|
|
|
const balance = creditBalance.balances?.[0]?.available_balance?.monetary?.value || 0
|
|
const balanceInDollars = balance / 100
|
|
|
|
// Get credit grants for detailed info
|
|
const grants = await this.stripe.billing.creditGrants.list({
|
|
customer: customerId,
|
|
limit: 100
|
|
})
|
|
|
|
// Calculate total credits and usage from grants
|
|
let totalCredits = 0
|
|
let totalUsage = 0
|
|
const grantsInfo = grants.data.map((grant) => {
|
|
const grantAmount = grant.amount?.monetary?.value || 0
|
|
const grantCredits = this.getCreditsFromPrice(grantAmount)
|
|
const usage = parseInt(grant.metadata?.usage_count || '0')
|
|
|
|
totalCredits += grantCredits
|
|
totalUsage += usage
|
|
|
|
return {
|
|
id: grant.id,
|
|
amount: grant.amount,
|
|
name: grant.name,
|
|
created: grant.created,
|
|
credits: grantCredits,
|
|
usage: usage,
|
|
effectiveBalance: (grant as any).effective_balance?.monetary?.value || 0
|
|
}
|
|
})
|
|
|
|
const availableCredits = Math.max(0, totalCredits - totalUsage)
|
|
|
|
return {
|
|
balance,
|
|
balanceInDollars,
|
|
totalCredits,
|
|
totalUsage,
|
|
availableCredits,
|
|
grants: grantsInfo
|
|
}
|
|
} catch (error) {
|
|
console.error('Error getting credit balance:', error)
|
|
throw error
|
|
}
|
|
}
|
|
|
|
public async getUsageWithCredits(orgId: string, subscriptionId: string): Promise<Record<string, any>> {
|
|
try {
|
|
if (!orgId || !subscriptionId) {
|
|
throw new Error('Organization ID and Subscription ID are required')
|
|
}
|
|
|
|
const usageCacheManager = this.cacheManager
|
|
|
|
// Get current usage
|
|
const currentStorageUsage = (await usageCacheManager.get(`storage:${orgId}`)) || 0
|
|
const currentPredictionsUsage = (await usageCacheManager.get(`predictions:${orgId}`)) || 0
|
|
|
|
const quotas = await usageCacheManager.getQuotas(subscriptionId)
|
|
const storageLimit = quotas['quota:storage']
|
|
const predLimit = quotas['quota:predictions']
|
|
|
|
// Get credit balance
|
|
const creditBalance = await this.getCreditBalance(subscriptionId)
|
|
|
|
return {
|
|
predictions: {
|
|
usage: currentPredictionsUsage,
|
|
limit: predLimit
|
|
},
|
|
storage: {
|
|
usage: currentStorageUsage,
|
|
limit: storageLimit
|
|
},
|
|
credits: {
|
|
balance: creditBalance.balance,
|
|
balanceInDollars: creditBalance.balanceInDollars,
|
|
totalCredits: creditBalance.totalCredits,
|
|
totalUsage: creditBalance.totalUsage,
|
|
availableCredits: creditBalance.availableCredits,
|
|
grants: creditBalance.grants,
|
|
costPerPrediction: 0.01
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error('Error getting usage with credits:', error)
|
|
throw error
|
|
}
|
|
}
|
|
|
|
public async getCreditsPackages(): Promise<any[]> {
|
|
try {
|
|
if (!process.env.CREDIT_PRODUCT_ID) {
|
|
throw new Error('CREDIT_PRODUCT_ID environment variable is required')
|
|
}
|
|
|
|
// Check cache first
|
|
const cacheKey = 'credits:packages'
|
|
const cachedPackages = await this.cacheManager.get(cacheKey)
|
|
if (cachedPackages) {
|
|
return cachedPackages as any[]
|
|
}
|
|
|
|
const pricesData = await this.stripe!.prices.list({
|
|
product: process.env.CREDIT_PRODUCT_ID,
|
|
active: true,
|
|
type: 'one_time'
|
|
})
|
|
|
|
const packages = pricesData.data.map((price) => {
|
|
const credits = this.getCreditsFromPrice(price.unit_amount || 0)
|
|
return {
|
|
id: this.getPackageTypeFromCredits(credits),
|
|
priceId: price.id,
|
|
credits,
|
|
price: price.unit_amount || 0,
|
|
priceFormatted: `$${((price.unit_amount || 0) / 100).toFixed(2)}`,
|
|
creditsPerDollar: credits / ((price.unit_amount || 0) / 100),
|
|
costPerPrediction: '$0.01'
|
|
}
|
|
})
|
|
|
|
// Cache for 1 hour
|
|
this.cacheManager.set(cacheKey, packages, 3600000)
|
|
|
|
return packages
|
|
} catch (error) {
|
|
console.error('Error getting credits packages:', error)
|
|
throw error
|
|
}
|
|
}
|
|
|
|
public async addMeteredSubscriptionItem(subscriptionId: string): Promise<{ added: boolean; message: string }> {
|
|
if (!this.stripe) {
|
|
throw new Error('Stripe is not initialized')
|
|
}
|
|
|
|
try {
|
|
if (!process.env.METERED_PRICE_ID) {
|
|
throw new Error('METERED_PRICE_ID environment variable is required')
|
|
}
|
|
if (!subscriptionId) {
|
|
throw new Error('Subscription ID is required')
|
|
}
|
|
|
|
const subscription = await this.stripe.subscriptions.retrieve(subscriptionId)
|
|
|
|
// Check if metered item already exists
|
|
const existingMeteredItem = subscription.items.data.find((item) => item.price.id === process.env.METERED_PRICE_ID)
|
|
|
|
if (existingMeteredItem) {
|
|
return {
|
|
added: false,
|
|
message: 'Metered subscription item already exists'
|
|
}
|
|
}
|
|
|
|
// Add metered subscription item
|
|
await this.stripe.subscriptionItems.create({
|
|
subscription: subscriptionId,
|
|
price: process.env.METERED_PRICE_ID
|
|
})
|
|
|
|
return {
|
|
added: true,
|
|
message: 'Metered subscription item added successfully'
|
|
}
|
|
} catch (error) {
|
|
console.error('Error adding metered subscription item:', error)
|
|
throw error
|
|
}
|
|
}
|
|
|
|
public async reportMeterUsage(customerId: string, quantity: number = 1): Promise<void> {
|
|
if (!this.stripe) {
|
|
throw new Error('Stripe is not initialized')
|
|
}
|
|
|
|
try {
|
|
if (!process.env.METER_EVENT_NAME) {
|
|
throw new Error('METER_EVENT_NAME environment variable is required')
|
|
}
|
|
if (!customerId) {
|
|
throw new Error('Customer ID is required')
|
|
}
|
|
|
|
logger.info(`[reportMeterUsage] Reporting ${quantity} usage for customer ${customerId}`)
|
|
|
|
// Report meter usage to Stripe
|
|
await this.stripe.billing.meterEvents.create({
|
|
event_name: process.env.METER_EVENT_NAME,
|
|
payload: {
|
|
stripe_customer_id: customerId,
|
|
value: quantity.toString()
|
|
}
|
|
})
|
|
|
|
logger.info(`[reportMeterUsage] Successfully reported meter usage to Stripe`)
|
|
|
|
// Track usage in credit grant metadata
|
|
await this.updateCreditGrantUsage(customerId, quantity)
|
|
|
|
logger.info(`[reportMeterUsage] Completed usage tracking for customer ${customerId}`)
|
|
} catch (error) {
|
|
logger.error('Error reporting meter usage:', error)
|
|
throw error
|
|
}
|
|
}
|
|
|
|
private async updateCreditGrantUsage(customerId: string, quantity: number): Promise<void> {
|
|
if (!this.stripe) {
|
|
throw new Error('Stripe is not initialized')
|
|
}
|
|
|
|
if (!customerId) {
|
|
logger.error('[updateCreditGrantUsage] Customer ID is required')
|
|
return
|
|
}
|
|
|
|
if (quantity <= 0) {
|
|
logger.error('[updateCreditGrantUsage] Quantity must be positive')
|
|
return
|
|
}
|
|
|
|
try {
|
|
logger.info(`[updateCreditGrantUsage] Starting update for customer ${customerId}, quantity: ${quantity}`)
|
|
|
|
// Get all credit grants for this customer (not just active ones)
|
|
const grants = await this.stripe.billing.creditGrants.list({
|
|
customer: customerId,
|
|
limit: 100
|
|
})
|
|
|
|
logger.info(`[updateCreditGrantUsage] Found ${grants.data.length} credit grants for customer ${customerId}`)
|
|
|
|
if (grants.data.length === 0) {
|
|
logger.info('[updateCreditGrantUsage] No credit grants found for customer')
|
|
return
|
|
}
|
|
|
|
// Sort by creation date (oldest first) to use FIFO approach
|
|
grants.data.sort((a, b) => a.created - b.created)
|
|
|
|
// Find the grant that should be used for tracking usage
|
|
// We'll use the first grant that still has available credits or the oldest one
|
|
let grantToUpdate = grants.data.find((grant) => {
|
|
const effectiveBalance = (grant as any).effective_balance?.monetary?.value || 0
|
|
logger.debug(`[updateCreditGrantUsage] Grant ${grant.id} has effective balance: ${effectiveBalance}`)
|
|
return effectiveBalance > 0
|
|
})
|
|
|
|
// If no grant has remaining balance, use the most recent one for tracking
|
|
if (!grantToUpdate && grants.data.length > 0) {
|
|
grantToUpdate = grants.data[grants.data.length - 1]
|
|
logger.info(`[updateCreditGrantUsage] No grants with balance found, using most recent: ${grantToUpdate.id}`)
|
|
}
|
|
|
|
if (!grantToUpdate) {
|
|
logger.warn('[updateCreditGrantUsage] No suitable grant found for usage tracking')
|
|
return
|
|
}
|
|
|
|
// Validate metadata access
|
|
const metadata = grantToUpdate.metadata || {}
|
|
const currentUsageStr = metadata.usage_count || '0'
|
|
const currentUsage = parseInt(currentUsageStr)
|
|
|
|
if (isNaN(currentUsage)) {
|
|
logger.error(`[updateCreditGrantUsage] Invalid usage_count in metadata: ${currentUsageStr}`)
|
|
return
|
|
}
|
|
|
|
const newUsage = currentUsage + quantity
|
|
|
|
logger.info(`[updateCreditGrantUsage] Updating grant ${grantToUpdate.id}: current usage ${currentUsage} -> ${newUsage}`)
|
|
|
|
const updateResult = await this.stripe.billing.creditGrants.update(grantToUpdate.id, {
|
|
metadata: {
|
|
...metadata,
|
|
usage_count: newUsage.toString()
|
|
}
|
|
})
|
|
|
|
logger.info(`[updateCreditGrantUsage] Successfully updated credit grant usage for grant ${grantToUpdate.id}`)
|
|
logger.debug(`[updateCreditGrantUsage] Updated metadata:`, updateResult.metadata)
|
|
|
|
// Clear cache to ensure fresh data on next request
|
|
if (this.cacheManager) {
|
|
await this.cacheManager.del(`credits:balance:${customerId}`)
|
|
logger.debug(`[updateCreditGrantUsage] Cleared cache for customer ${customerId}`)
|
|
}
|
|
} catch (error) {
|
|
logger.error('[updateCreditGrantUsage] Error updating credit grant usage:', error)
|
|
// Log additional details for debugging
|
|
if (error instanceof Error) {
|
|
logger.error('[updateCreditGrantUsage] Error message:', error.message)
|
|
logger.debug('[updateCreditGrantUsage] Error stack:', error.stack)
|
|
}
|
|
// Don't throw here as meter usage was already reported successfully
|
|
}
|
|
}
|
|
|
|
private getCreditsFromPrice(unitAmount: number): number {
|
|
// $10.00 = 1000 credits, so 1 cent = 1 credit
|
|
return unitAmount
|
|
}
|
|
|
|
private getPackageTypeFromCredits(credits: number): string {
|
|
if (credits === 1000) return 'SMALL'
|
|
if (credits === 2500) return 'MEDIUM'
|
|
if (credits === 5000) return 'LARGE'
|
|
return 'CUSTOM'
|
|
}
|
|
}
|