diff --git a/server/shell/g1.sh b/server/shell/g1.sh index 68777cac2..d7928b279 100755 --- a/server/shell/g1.sh +++ b/server/shell/g1.sh @@ -4,19 +4,19 @@ source "$(dirname "$0")/config.sh" # If contains setup then run $MOCHA_SETUP -# if [[ "$2" == *"setup"* ]]; then -# MOCHA_PARALLEL=true $MOCHA_SETUP -# fi +if [[ "$1" == *"setup"* ]]; then + MOCHA_PARALLEL=true $MOCHA_SETUP +fi $MOCHA_CMD \ 'tests/attach/basic/*.ts' \ 'tests/attach/upgrade/*.ts' \ 'tests/attach/downgrade/*.ts' \ -'tests/attach/checkout/*.ts' - -$MOCHA_CMD \ -'tests/attach/entities/*.ts' \ -'tests/attach/free/*.ts'\ 'tests/attach/addOn/*.ts' +$MOCHA_CMD \ +'tests/attach/checkout/*.ts' \ +'tests/attach/entities/*.ts' \ +'tests/attach/free/*.ts'\ + # 'tests/attach/basic/basic2.ts' \ \ No newline at end of file diff --git a/server/shell/g3.sh b/server/shell/g3.sh index 60aa554da..97d2db32e 100755 --- a/server/shell/g3.sh +++ b/server/shell/g3.sh @@ -17,15 +17,15 @@ $MOCHA_CMD 'tests/contUse/track/*.ts' $MOCHA_CMD 'tests/contUse/roles/*.ts' -# G4 -$MOCHA_CMD 'tests/advanced/multiFeature/*.ts' \ - 'tests/advanced/coupons/*.ts' \ - 'tests/attach/updateQuantity/*.ts' \ - 'tests/advanced/referrals/*.ts' \ - 'tests/advanced/rollovers/*.ts' \ - 'tests/advanced/customInterval/*.ts' +# # G4 +# $MOCHA_CMD 'tests/advanced/multiFeature/*.ts' \ +# 'tests/advanced/coupons/*.ts' \ +# 'tests/attach/updateQuantity/*.ts' \ +# 'tests/advanced/referrals/*.ts' \ +# 'tests/advanced/rollovers/*.ts' \ +# 'tests/advanced/customInterval/*.ts' -$MOCHA_CMD 'tests/attach/multiProduct/*.ts' \ - 'tests/advanced/usageLimit/*.ts' +# $MOCHA_CMD 'tests/attach/multiProduct/*.ts' \ +# 'tests/advanced/usageLimit/*.ts' -$MOCHA_CMD 'tests/advanced/usage/*.ts' \ No newline at end of file +# $MOCHA_CMD 'tests/advanced/usage/*.ts' \ No newline at end of file diff --git a/server/src/external/autumn/autumnCli.ts b/server/src/external/autumn/autumnCli.ts index 0557c6ad2..eb2510e2d 100644 --- a/server/src/external/autumn/autumnCli.ts +++ b/server/src/external/autumn/autumnCli.ts @@ -170,7 +170,9 @@ export class AutumnInt { return data; } - async checkout(params: CheckoutParams) { + async checkout( + params: CheckoutParams & { invoice?: boolean; force_checkout?: boolean } + ) { // const data = await this.post(`/attach`, { // customer_id: customerId, // product_id: productId, diff --git a/server/src/external/stripe/stripeOnboardingUtils.ts b/server/src/external/stripe/stripeOnboardingUtils.ts index 207b864fd..351afbe3d 100644 --- a/server/src/external/stripe/stripeOnboardingUtils.ts +++ b/server/src/external/stripe/stripeOnboardingUtils.ts @@ -42,6 +42,7 @@ export const createWebhookEndpoint = async ( "invoice.upcoming", "invoice.created", "invoice.finalized", + "invoice.updated", "subscription_schedule.canceled", "customer.discount.deleted", ], diff --git a/server/src/external/stripe/stripeSubUtils/createStripeSub.ts b/server/src/external/stripe/stripeSubUtils/createStripeSub.ts index 2d8ea6417..c8241ff77 100644 --- a/server/src/external/stripe/stripeSubUtils/createStripeSub.ts +++ b/server/src/external/stripe/stripeSubUtils/createStripeSub.ts @@ -92,11 +92,16 @@ export const createStripeSub = async ({ : undefined, coupon: reward ? reward.id : undefined, + expand: ["latest_invoice"], }); - if (invoiceOnly && finalizeInvoice) { - await stripeCli.invoices.finalizeInvoice( - subscription.latest_invoice as string + if ( + invoiceOnly && + finalizeInvoice && + (subscription.latest_invoice as Stripe.Invoice).status === "draft" + ) { + subscription.latest_invoice = await stripeCli.invoices.finalizeInvoice( + (subscription.latest_invoice as Stripe.Invoice).id ); } diff --git a/server/src/external/stripe/stripeWebhooks.ts b/server/src/external/stripe/stripeWebhooks.ts index 7a755dd70..2ec239a7f 100644 --- a/server/src/external/stripe/stripeWebhooks.ts +++ b/server/src/external/stripe/stripeWebhooks.ts @@ -21,6 +21,7 @@ import { createStripeCli } from "./utils.js"; import { deleteCusCache } from "@/internal/customers/cusCache/updateCachedCus.js"; import { CusService } from "@/internal/customers/CusService.js"; import { DrizzleCli } from "@/db/initDrizzle.js"; +import { handleInvoiceUpdated } from "./webhookHandlers/handleInvoiceUpdated.js"; export const stripeWebhookRouter: Router = express.Router(); @@ -183,6 +184,15 @@ stripeWebhookRouter.post( }); break; + case "invoice.updated": + await handleInvoiceUpdated({ + stripeCli, + env, + event, + req: request, + }); + break; + case "invoice.created": const createdInvoice = event.data.object; await handleInvoiceCreated({ diff --git a/server/src/external/stripe/webhookHandlers/handleCheckoutCompleted/handleRemainingSets.ts b/server/src/external/stripe/webhookHandlers/handleCheckoutCompleted/handleRemainingSets.ts index 9e3f5c9ae..47532492a 100644 --- a/server/src/external/stripe/webhookHandlers/handleCheckoutCompleted/handleRemainingSets.ts +++ b/server/src/external/stripe/webhookHandlers/handleCheckoutCompleted/handleRemainingSets.ts @@ -101,7 +101,8 @@ export const handleRemainingSets = async ({ })) as Stripe.Subscription; subs.push(subscription); - invoiceIds.push(subscription.latest_invoice as string); + const latestInvoice = subscription.latest_invoice as Stripe.Invoice; + invoiceIds.push(latestInvoice.id); } return { diff --git a/server/src/external/stripe/webhookHandlers/handleInvoicePaid.ts b/server/src/external/stripe/webhookHandlers/handleInvoicePaid.ts index 52790bb8d..1210e9474 100644 --- a/server/src/external/stripe/webhookHandlers/handleInvoicePaid.ts +++ b/server/src/external/stripe/webhookHandlers/handleInvoicePaid.ts @@ -22,6 +22,7 @@ import { DrizzleCli } from "@/db/initDrizzle.js"; import { InvoiceService } from "@/internal/invoices/InvoiceService.js"; import { getInvoiceItems } from "@/internal/invoices/invoiceUtils.js"; import { handleInvoicePaidDiscount } from "./handleInvoicePaidDiscount.js"; +import { handleInvoiceCheckoutPaid } from "@/internal/customers/attach/attachFunctions/invoiceCheckoutPaid/handleInvoiceCheckoutPaid.js"; const handleOneOffInvoicePaid = async ({ db, @@ -92,12 +93,12 @@ const convertToChargeAutomatically = async ({ logger.info(`Converting to charge automatically`); // 1. Get payment intent const paymentIntent = await stripeCli.paymentIntents.retrieve( - invoice.payment_intent as string, + invoice.payment_intent as string ); // 2. Get payment method const paymentMethod = await stripeCli.paymentMethods.retrieve( - paymentIntent.payment_method as string, + paymentIntent.payment_method as string ); await stripeCli.paymentMethods.attach(paymentMethod.id, { @@ -113,7 +114,7 @@ const convertToChargeAutomatically = async ({ }); } catch (error) { logger.warn( - `Convert to charge automatically: error updating subscription ${sub.id}`, + `Convert to charge automatically: error updating subscription ${sub.id}` ); logger.warn(error); } @@ -153,6 +154,17 @@ export const handleInvoicePaid = async ({ stripeId: invoiceData.id, }); + if (invoice.metadata?.autumn_metadata_id) { + await handleInvoiceCheckoutPaid({ + req, + org, + env, + db, + stripeCli, + invoice, + }); + } + await handleInvoicePaidDiscount({ db, expandedInvoice: invoice, @@ -174,7 +186,7 @@ export const handleInvoicePaid = async ({ // TODO: Send alert if (invoice.livemode) { logger.warn( - `invoice.paid: customer product not found for invoice ${invoice.id}`, + `invoice.paid: customer product not found for invoice ${invoice.id}` ); } return; @@ -199,7 +211,7 @@ export const handleInvoicePaid = async ({ let invoiceItems = await getInvoiceItems({ stripeInvoice: invoice, prices: activeCusProducts.flatMap((p) => - p.customer_prices.map((cpr: FullCustomerPrice) => cpr.price), + p.customer_prices.map((cpr: FullCustomerPrice) => cpr.price) ), logger, }); diff --git a/server/src/external/stripe/webhookHandlers/handleInvoiceUpdated.ts b/server/src/external/stripe/webhookHandlers/handleInvoiceUpdated.ts new file mode 100644 index 000000000..24bd0a0c8 --- /dev/null +++ b/server/src/external/stripe/webhookHandlers/handleInvoiceUpdated.ts @@ -0,0 +1,39 @@ +import { AppEnv, InvoiceStatus } from "@autumn/shared"; +import Stripe from "stripe"; +import { getFullStripeInvoice } from "../stripeInvoiceUtils.js"; +import { InvoiceService } from "@/internal/invoices/InvoiceService.js"; + +export const handleInvoiceUpdated = async ({ + env, + event, + stripeCli, + req, +}: { + env: AppEnv; + event: Stripe.Event; + stripeCli: Stripe; + req: any; +}) => { + const invoiceObject = event.data.object as Stripe.Invoice; + const invoice = await getFullStripeInvoice({ + stripeCli, + stripeId: invoiceObject.id, + }); + + const prevAttributes = event.data.previous_attributes as any; + const invoiceVoided = + prevAttributes?.status !== "void" && invoice.status === "void"; + + const { logger } = req; + + if (invoiceVoided) { + logger.info(`Invoice has been voided!`); + await InvoiceService.updateByStripeId({ + db: req.db, + stripeId: invoiceObject.id, + updates: { + status: InvoiceStatus.Void, + }, + }); + } +}; diff --git a/server/src/internal/api/events/usageRouter.ts b/server/src/internal/api/events/usageRouter.ts index 7af89e419..d5d046922 100644 --- a/server/src/internal/api/events/usageRouter.ts +++ b/server/src/internal/api/events/usageRouter.ts @@ -202,6 +202,12 @@ export const handleUsageEvent = async ({ entityId: entity_id, }; + // console.log("Customer:", customer); + // console.log( + // "Is paid continuous use:", + // isPaidContinuousUse({ feature, fullCus: customer }) + // ); + if (isPaidContinuousUse({ feature, fullCus: customer })) { console.log(`Running update usage task synchronously`); await runUpdateUsageTask({ diff --git a/server/src/internal/customers/add-product/handleCreateInvoiceCheckout.ts b/server/src/internal/customers/add-product/handleCreateInvoiceCheckout.ts new file mode 100644 index 000000000..f9eb4349a --- /dev/null +++ b/server/src/internal/customers/add-product/handleCreateInvoiceCheckout.ts @@ -0,0 +1,99 @@ +import RecaseError from "@/utils/errorUtils.js"; +import { + AttachParams, + AttachResultSchema, +} from "../cusProducts/AttachParams.js"; +import { createStripeCli } from "@/external/stripe/utils.js"; +import { createCheckoutMetadata } from "@/internal/metadata/metadataUtils.js"; +import { getStripeSubItems } from "@/external/stripe/stripeSubUtils/getStripeSubItems.js"; +import { ErrCode } from "@/errors/errCodes.js"; +import { getNextStartOfMonthUnix } from "@/internal/products/prices/billingIntervalUtils.js"; +import { isOneOff } from "@/internal/products/productUtils.js"; +import { attachParamsToProduct } from "../attach/attachUtils/convertAttachParams.js"; +import { handlePaidProduct } from "../attach/attachFunctions/addProductFlow/handlePaidProduct.js"; +import { + AttachBranch, + AttachConfig, + ProrationBehavior, + SuccessCode, +} from "@autumn/shared"; +import Stripe from "stripe"; +import { handleOneOffFunction } from "../attach/attachFunctions/addProductFlow/handleOneOffFunction.js"; + +export const handleCreateInvoiceCheckout = async ({ + req, + res, + attachParams, + config, +}: { + req: any; + res?: any; + attachParams: AttachParams; + config: AttachConfig; +}) => { + // if one off + const { stripeCli } = attachParams; + + let invoiceResult; + if (isOneOff(attachParams.prices)) { + invoiceResult = await handleOneOffFunction({ + req, + res, + attachParams, + config, + }); + } else { + invoiceResult = await handlePaidProduct({ + req, + res, + attachParams, + config, + }); + } + + const { invoices, anchorToUnix, subs }: any = invoiceResult; + + const metadataId = await createCheckoutMetadata({ + db: req.db, + attachParams: { + ...attachParams, + anchorToUnix, + subIds: subs.map((s: Stripe.Subscription) => s.id), + config, + } as any, + }); + + for (const invoice of invoices) { + await stripeCli.invoices.update(invoice.id, { + metadata: { + autumn_metadata_id: metadataId, + }, + }); + } + + // AttachResultSchema.parse({ + // checkout_url: checkout.url, + // code: SuccessCode.CheckoutCreated, + // message: `Successfully created checkout for customer ${ + // customer.id || customer.internal_id + // }, product(s) ${attachParams.products.map((p) => p.name).join(", ")}`, + // product_ids: attachParams.products.map((p) => p.id), + // customer_id: customer.id || customer.internal_id, + // }); + if (res) { + res.status(200).json( + AttachResultSchema.parse({ + checkout_url: invoices[0].hosted_invoice_url, + code: SuccessCode.CheckoutCreated, + message: `Successfully created invoice checkout for customer ${ + attachParams.customer.id || attachParams.customer.internal_id + }, product(s) ${attachParams.products.map((p) => p.name).join(", ")}`, + product_ids: attachParams.products.map((p) => p.id), + customer_id: + attachParams.customer.id || attachParams.customer.internal_id, + }) + ); + } + + return { invoices }; +}; diff --git a/server/src/internal/customers/attach/attachFunctions/addProductFlow/handleOneOffFunction.ts b/server/src/internal/customers/attach/attachFunctions/addProductFlow/handleOneOffFunction.ts index f2ba40aa4..9ef0eae74 100644 --- a/server/src/internal/customers/attach/attachFunctions/addProductFlow/handleOneOffFunction.ts +++ b/server/src/internal/customers/attach/attachFunctions/addProductFlow/handleOneOffFunction.ts @@ -106,7 +106,7 @@ export const handleOneOffFunction = async ({ // Create invoice logger.info("1. Creating invoice"); - const stripeInvoice = await stripeCli.invoices.create({ + let stripeInvoice = await stripeCli.invoices.create({ customer: customer.processor.id!, auto_advance: false, currency: org.default_currency!, @@ -117,6 +117,8 @@ export const handleOneOffFunction = async ({ }, ] : undefined, + collection_method: attachParams.invoiceOnly ? "send_invoice" : undefined, + days_until_due: attachParams.invoiceOnly ? 30 : undefined, }); logger.info("2. Creating invoice items"); @@ -128,6 +130,23 @@ export const handleOneOffFunction = async ({ } as any); } + if (config.invoiceCheckout) { + if (stripeInvoice.status === "draft") { + stripeInvoice = await stripeCli.invoices.finalizeInvoice( + stripeInvoice.id + ); + } + + await insertInvoiceFromAttach({ + db: req.db, + attachParams, + invoiceId: stripeInvoice.id, + logger, + }); + + return { invoices: [stripeInvoice], subs: [], anchorToUnix: undefined }; + } + // Create invoice items if (!invoiceOnly) { await stripeCli.invoices.finalizeInvoice(stripeInvoice.id); diff --git a/server/src/internal/customers/attach/attachFunctions/addProductFlow/handlePaidProduct.ts b/server/src/internal/customers/attach/attachFunctions/addProductFlow/handlePaidProduct.ts index ac5e55f7d..919759d83 100644 --- a/server/src/internal/customers/attach/attachFunctions/addProductFlow/handlePaidProduct.ts +++ b/server/src/internal/customers/attach/attachFunctions/addProductFlow/handlePaidProduct.ts @@ -1,3 +1,5 @@ +import RecaseError from "@/utils/errorUtils.js"; + import { getStripeSubs } from "@/external/stripe/stripeSubUtils.js"; import { createStripeSub } from "@/external/stripe/stripeSubUtils/createStripeSub.js"; import { getStripeSubItems } from "@/external/stripe/stripeSubUtils/getStripeSubItems.js"; @@ -14,8 +16,6 @@ import { } from "@/internal/invoices/invoiceUtils.js"; import { getNextStartOfMonthUnix } from "@/internal/products/prices/billingIntervalUtils.js"; import { attachToInsertParams } from "@/internal/products/productUtils.js"; -import RecaseError from "@/utils/errorUtils.js"; -import { formatUnixToDateTime } from "@/utils/genUtils.js"; import { ExtendedRequest } from "@/utils/models/Request.js"; import { APIVersion, @@ -122,7 +122,7 @@ export const handlePaidProduct = async ({ freeTrial, invoiceOnly, itemSet, - finalizeInvoice: attachParams.finalizeInvoice, + finalizeInvoice: config.invoiceCheckout, anchorToUnix: billingCycleAnchorUnix, reward: i == 0 ? reward : undefined, now: attachParams.now, @@ -148,9 +148,6 @@ export const handlePaidProduct = async ({ } } - // Add product and entitlements to customer - const batchInsert = []; - const anchorToUnix = subscriptions.length > 0 ? subscriptions[0].current_period_end * 1000 @@ -158,6 +155,32 @@ export const handlePaidProduct = async ({ ? mergeSubs[0].current_period_end * 1000 : undefined; + const batchInsertInvoice: any = []; + for (const sub of subscriptions) { + batchInsertInvoice.push( + insertInvoiceFromAttach({ + db: req.db, + // invoiceId: sub.latest_invoice as string, + stripeInvoice: sub.latest_invoice as Stripe.Invoice, + attachParams, + logger, + }) + ); + } + const invoices = await Promise.all(batchInsertInvoice); + + if (config.invoiceCheckout) { + return { + invoices: subscriptions.map((s) => s.latest_invoice as Stripe.Invoice), + subs: subscriptions, + anchorToUnix, + config, + }; + } + + // Add product and entitlements to customer + const batchInsert = []; + for (const product of products) { batchInsert.push( createFullCusProduct({ @@ -173,19 +196,6 @@ export const handlePaidProduct = async ({ } await Promise.all(batchInsert); - const batchInsertInvoice: any = []; - for (const sub of subscriptions) { - batchInsertInvoice.push( - insertInvoiceFromAttach({ - db: req.db, - invoiceId: sub.latest_invoice as string, - attachParams, - logger, - }) - ); - } - const invoices = await Promise.all(batchInsertInvoice); - if (res) { let apiVersion = attachParams.apiVersion || APIVersion.v1; const productNames = products.map((p) => p.name).join(", "); diff --git a/server/src/internal/customers/attach/attachFunctions/attachFuncUtils.ts b/server/src/internal/customers/attach/attachFunctions/attachFuncUtils.ts index fe974c146..5d55c32b9 100644 --- a/server/src/internal/customers/attach/attachFunctions/attachFuncUtils.ts +++ b/server/src/internal/customers/attach/attachFunctions/attachFuncUtils.ts @@ -8,10 +8,14 @@ export const addSubItemsToRemove = async ({ cusProduct, itemSet, }: { - sub: Stripe.Subscription; + sub?: Stripe.Subscription | null; cusProduct: FullCusProduct; itemSet: ItemSet; }) => { + if (!sub) { + return; + } + for (const item of sub.items.data) { let shouldRemove = subItemInCusProduct({ cusProduct, diff --git a/server/src/internal/customers/attach/attachFunctions/invoiceCheckoutPaid/handleInvoiceCheckoutPaid.ts b/server/src/internal/customers/attach/attachFunctions/invoiceCheckoutPaid/handleInvoiceCheckoutPaid.ts new file mode 100644 index 000000000..b693b4430 --- /dev/null +++ b/server/src/internal/customers/attach/attachFunctions/invoiceCheckoutPaid/handleInvoiceCheckoutPaid.ts @@ -0,0 +1,64 @@ +import { DrizzleCli } from "@/db/initDrizzle.js"; +import { createFullCusProduct } from "@/internal/customers/add-product/createFullCusProduct.js"; +import { AttachParams } from "@/internal/customers/cusProducts/AttachParams.js"; +import { MetadataService } from "@/internal/metadata/MetadataService.js"; +import { attachToInsertParams } from "@/internal/products/productUtils.js"; +import { ExtendedRequest } from "@/utils/models/Request.js"; +import { AppEnv, AttachScenario, Organization } from "@autumn/shared"; +import Stripe from "stripe"; + +export const handleInvoiceCheckoutPaid = async ({ + req, + org, + env, + db, + stripeCli, + invoice, +}: { + req: ExtendedRequest; + org: Organization; + env: AppEnv; + db: DrizzleCli; + stripeCli: Stripe; + invoice: Stripe.Invoice; +}) => { + const metadataId = invoice.metadata?.autumn_metadata_id!; + + const metadata = await MetadataService.get({ + db, + id: metadataId, + }); + + const { subIds, anchorToUnix, config, ...rest } = metadata?.data; + const attachParams = rest as AttachParams; + + if (!attachParams) { + return; + } + + const reqMatch = + attachParams.org.id === org.id && attachParams.customer.env === env; + + if (!reqMatch) return; + + const batchInsert = []; + for (const product of attachParams.products) { + batchInsert.push( + createFullCusProduct({ + db, + attachParams: attachToInsertParams(attachParams, product), + subscriptionIds: subIds, + anchorToUnix, + carryExistingUsages: config.carryUsage, + scenario: AttachScenario.New, + logger: req.logger, + }) + ); + } + + await Promise.all(batchInsert); + + req.logger.info( + `✅ invoice.paid, successfully inserted cus products: ${attachParams.products.map((p) => p.id).join(", ")}` + ); +}; diff --git a/server/src/internal/customers/attach/attachFunctions/upgradeDiffIntFlow/handleUpgradeDiffInt.ts b/server/src/internal/customers/attach/attachFunctions/upgradeDiffIntFlow/handleUpgradeDiffInt.ts index a6aecbb98..276839fa6 100644 --- a/server/src/internal/customers/attach/attachFunctions/upgradeDiffIntFlow/handleUpgradeDiffInt.ts +++ b/server/src/internal/customers/attach/attachFunctions/upgradeDiffIntFlow/handleUpgradeDiffInt.ts @@ -23,6 +23,7 @@ import { insertInvoiceFromAttach, } from "@/internal/invoices/invoiceUtils.js"; import { updateSubsDiffInt } from "./updateSubsDiffInt.js"; +import { attachParamsToCurCusProduct } from "../../attachUtils/convertAttachParams.js"; export const handleUpgradeDiffInterval = async ({ req, @@ -41,12 +42,12 @@ export const handleUpgradeDiffInterval = async ({ const product = products[0]; - let { curMainProduct: curCusProduct } = getExistingCusProducts({ - product, - cusProducts: cusProducts || [], - internalEntityId: attachParams.internalEntityId, - }); - + // let { curMainProduct: curCusProduct } = getExistingCusProducts({ + // product, + // cusProducts: cusProducts || [], + // internalEntityId: attachParams.internalEntityId, + // }); + let curCusProduct = attachParamsToCurCusProduct({ attachParams }); curCusProduct = curCusProduct!; const stripeSubs = await getStripeSubs({ diff --git a/server/src/internal/customers/attach/attachFunctions/upgradeDiffIntFlow/updateSubsDiffInt.ts b/server/src/internal/customers/attach/attachFunctions/upgradeDiffIntFlow/updateSubsDiffInt.ts index 0f3227d04..163f6db46 100644 --- a/server/src/internal/customers/attach/attachFunctions/upgradeDiffIntFlow/updateSubsDiffInt.ts +++ b/server/src/internal/customers/attach/attachFunctions/upgradeDiffIntFlow/updateSubsDiffInt.ts @@ -39,8 +39,8 @@ export const updateSubsDiffInt = async ({ carryExistingUsages: config.carryUsage, }); - const firstSub = stripeSubs[0]; - const firstItemSet = itemSets[0]; + const firstSub = stripeSubs?.[0]; + const firstItemSet = itemSets?.[0]; await addSubItemsToRemove({ sub: firstSub, @@ -127,7 +127,8 @@ export const updateSubsDiffInt = async ({ }); newSubs.push(newSub); - newInvoiceIds.push(newSub.latest_invoice as string); + const latestInvoice = newSub.latest_invoice as Stripe.Invoice; + newInvoiceIds.push(latestInvoice.id); } return { diff --git a/server/src/internal/customers/attach/attachFunctions/upgradeSameIntFlow/updateSubsSameInt.ts b/server/src/internal/customers/attach/attachFunctions/upgradeSameIntFlow/updateSubsSameInt.ts index 44dd4c087..c0af66502 100644 --- a/server/src/internal/customers/attach/attachFunctions/upgradeSameIntFlow/updateSubsSameInt.ts +++ b/server/src/internal/customers/attach/attachFunctions/upgradeSameIntFlow/updateSubsSameInt.ts @@ -39,25 +39,11 @@ export const updateSubsByInt = async ({ attachParams.replaceables = replaceables; - // logger.info(`Cont use items`); - // logger.info( - // `New items: `, - // newItems.map( - // (item) => `${item.description} | Amount: ${item.amount || item.price}`, - // ), - // ); - // logger.info( - // "Replaceables: ", - // replaceables.map((r) => `${r.ent.feature_id}`), - // ); - const itemSets = await getStripeSubItems({ attachParams }); + const invoices: Stripe.Invoice[] = []; - // const replaceables: Replaceable[] = []; for (const sub of stripeSubs) { - // let interval = subToAutumnInterval(sub); - let subInterval = subToAutumnInterval(sub); let itemSet = itemSets.find((itemSet) => { return intervalsSame({ diff --git a/server/src/internal/customers/attach/attachRouter.ts b/server/src/internal/customers/attach/attachRouter.ts index 8fc326af1..8a13beef1 100644 --- a/server/src/internal/customers/attach/attachRouter.ts +++ b/server/src/internal/customers/attach/attachRouter.ts @@ -1,6 +1,11 @@ import { Router } from "express"; import RecaseError from "@/utils/errorUtils.js"; -import { APIVersion, BillingType, FullCusProduct } from "@autumn/shared"; +import { + APIVersion, + AttachConfig, + BillingType, + FullCusProduct, +} from "@autumn/shared"; import { ErrCode } from "@/errors/errCodes.js"; import { getCusPaymentMethod } from "@/external/stripe/stripeCusUtils.js"; @@ -22,6 +27,7 @@ import { createStripePriceIFNotExist } from "@/external/stripe/createStripePrice import { notNullish, notNullOrUndefined, + nullish, nullOrUndefined, } from "@/utils/genUtils.js"; import { CusService } from "@/internal/customers/CusService.js"; @@ -35,14 +41,17 @@ import { handleAttachPreview } from "./handleAttachPreview/handleAttachPreview.j import { handleAttach } from "./handleAttach.js"; import { handleCheckout } from "./checkout/handleCheckout.js"; import { createStripeCusIfNotExists } from "@/external/stripe/stripeCusUtils.js"; +import { attachParamsToCurCusProduct } from "./attachUtils/convertAttachParams.js"; export const attachRouter: Router = Router(); export const handlePrepaidErrors = async ({ attachParams, + config, useCheckout = false, }: { attachParams: AttachParams; + config: AttachConfig; useCheckout?: boolean; }) => { const { prices, entitlements, optionsList } = attachParams; @@ -57,7 +66,8 @@ export const handlePrepaidErrors = async ({ let options = getEntOptions(optionsList, priceEnt); // 1. If not checkout, quantity should be defined - if (!useCheckout && nullOrUndefined(options?.quantity)) { + const regularCheckout = useCheckout && !config.invoiceCheckout; + if (!regularCheckout && nullOrUndefined(options?.quantity)) { throw new RecaseError({ message: `Pass in 'quantity' for feature ${priceEnt.feature_id} in options`, code: ErrCode.InvalidOptions, @@ -161,6 +171,7 @@ export const checkStripeConnections = async ({ } const batchProductUpdates = []; + if (createCus) { batchProductUpdates.push( createStripeCusIfNotExists({ @@ -172,6 +183,7 @@ export const checkStripeConnections = async ({ }) ); } + for (const product of products) { batchProductUpdates.push( checkStripeProductExists({ @@ -207,6 +219,9 @@ export const createStripePrices = async ({ const { prices, entitlements, products, org, stripeCli } = attachParams; const batchPriceUpdates = []; + + // const curCusProduct = attachParamsToCurCusProduct({ attachParams }); + for (const price of prices) { let product = getProductForPrice(price, products); diff --git a/server/src/internal/customers/attach/attachUtils/attachParams/checkToAttachParams.ts b/server/src/internal/customers/attach/attachUtils/attachParams/checkToAttachParams.ts index e352e61f9..ad19d234e 100644 --- a/server/src/internal/customers/attach/attachUtils/attachParams/checkToAttachParams.ts +++ b/server/src/internal/customers/attach/attachUtils/attachParams/checkToAttachParams.ts @@ -77,13 +77,6 @@ export const checkToAttachParams = async ({ // Others apiVersion, - // successUrl: attachBody.success_url, - // invoiceOnly: attachBody.invoice_only, - // billingAnchor: attachBody.billing_cycle_anchor, - // metadata: attachBody.metadata, - // disableFreeTrial: attachBody.free_trial === false || false, - // checkoutSessionParams: attachBody.checkout_session_params, - // isCustom: attachBody.is_custom, }; return attachParams; diff --git a/server/src/internal/customers/attach/attachUtils/attachParams/getAttachParams.ts b/server/src/internal/customers/attach/attachUtils/attachParams/getAttachParams.ts index 55e754e03..9870f7c63 100644 --- a/server/src/internal/customers/attach/attachUtils/attachParams/getAttachParams.ts +++ b/server/src/internal/customers/attach/attachUtils/attachParams/getAttachParams.ts @@ -66,8 +66,9 @@ export const getAttachParams = async ({ // Others apiVersion, successUrl: attachBody.success_url, - invoiceOnly: attachBody.invoice_only || attachBody.invoice, - finalizeInvoice: attachBody.invoice, + invoiceOnly: attachBody.invoice, + // || attachBody.invoice_only + billingAnchor: attachBody.billing_cycle_anchor, metadata: attachBody.metadata, disableFreeTrial: attachBody.free_trial === false || false, diff --git a/server/src/internal/customers/attach/attachUtils/convertAttachParams.ts b/server/src/internal/customers/attach/attachUtils/convertAttachParams.ts index 2a2bd5c04..9488f5b37 100644 --- a/server/src/internal/customers/attach/attachUtils/convertAttachParams.ts +++ b/server/src/internal/customers/attach/attachUtils/convertAttachParams.ts @@ -10,7 +10,7 @@ export const attachParamsToCurCusProduct = ({ const { curMainProduct, curSameProduct, curScheduledProduct } = attachParamToCusProducts({ attachParams }); - return curMainProduct || curSameProduct; + return curSameProduct || curMainProduct; }; export const attachParamToCusProducts = ({ diff --git a/server/src/internal/customers/attach/attachUtils/getAttachBranch.ts b/server/src/internal/customers/attach/attachUtils/getAttachBranch.ts index 526745120..10fc4c8a0 100644 --- a/server/src/internal/customers/attach/attachUtils/getAttachBranch.ts +++ b/server/src/internal/customers/attach/attachUtils/getAttachBranch.ts @@ -135,9 +135,6 @@ const checkSameCustom = async ({ features: attachParams.features, }); - // console.log("Attach params free trial:", attachParams.freeTrial); - // console.log("Cur same product free trial:", curSameProduct.free_trial); - if (itemsSame && freeTrialsSame) { throw new RecaseError({ message: `Items specified for ${product.name} are the same as the existing product, can't attach again`, @@ -145,6 +142,11 @@ const checkSameCustom = async ({ }); } + // const curPrices = cusProductToPrices({ cusProduct: curSameProduct }); + // if (isFreeProduct(curPrices) && !isFreeProduct(attachParams.prices)) { + // return AttachBranch.MainIsFree; + // } + if (onlyEntsChanged) { return AttachBranch.SameCustomEnts; } diff --git a/server/src/internal/customers/attach/attachUtils/getAttachConfig.ts b/server/src/internal/customers/attach/attachUtils/getAttachConfig.ts index 910228d92..ece4ef887 100644 --- a/server/src/internal/customers/attach/attachUtils/getAttachConfig.ts +++ b/server/src/internal/customers/attach/attachUtils/getAttachConfig.ts @@ -23,7 +23,7 @@ export const intervalsAreSame = ({ attachParams, }); - let curCusProduct = curMainProduct || curSameProduct; + let curCusProduct = curSameProduct || curMainProduct; if (!curCusProduct) { return false; @@ -143,8 +143,17 @@ export const getAttachConfig = async ({ branch == AttachBranch.MainIsTrial || org.config.merge_billing_cycles === false; + const invoiceAndEnable = + attachParams.invoiceOnly && attachBody.enable_product_immediately; + + const invoiceCheckout = + attachParams.invoiceOnly === true && !attachBody.enable_product_immediately; + const checkoutFlow = - isPublic || forceCheckout || (noPaymentMethod && !invoiceOnly); + isPublic || + forceCheckout || + invoiceCheckout || + (noPaymentMethod && !invoiceAndEnable); const onlyCheckout = !isFree && checkoutFlow; @@ -155,6 +164,7 @@ export const getAttachConfig = async ({ proration, disableTrial, invoiceOnly: flags.invoiceOnly, + invoiceCheckout, disableMerge, sameIntervals, carryTrial, @@ -174,6 +184,7 @@ export const getDefaultAttachConfig = () => { disableMerge: false, sameIntervals: false, carryTrial: false, + invoiceCheckout: false, }; return config; diff --git a/server/src/internal/customers/attach/attachUtils/getAttachFunction.ts b/server/src/internal/customers/attach/attachUtils/getAttachFunction.ts index e15a3d410..b516c5ea7 100644 --- a/server/src/internal/customers/attach/attachUtils/getAttachFunction.ts +++ b/server/src/internal/customers/attach/attachUtils/getAttachFunction.ts @@ -25,6 +25,7 @@ import { deleteCurrentScheduledProduct } from "./deleteCurrentScheduledProduct.j import { handleOneOffFunction } from "../attachFunctions/addProductFlow/handleOneOffFunction.js"; import { handleUpgradeSameInterval } from "../attachFunctions/upgradeSameIntFlow/handleUpgradeSameInt.js"; import { CusProductService } from "../../cusProducts/CusProductService.js"; +import { handleCreateInvoiceCheckout } from "../../add-product/handleCreateInvoiceCheckout.js"; /* 1. If from new version, free trial should just carry over @@ -217,6 +218,14 @@ export const runAttachFunction = async ({ } if (attachFunction == AttachFunction.CreateCheckout) { + if (config.invoiceCheckout) { + return await handleCreateInvoiceCheckout({ + req, + res, + attachParams, + config, + }); + } return await handleCreateCheckout({ req, res, diff --git a/server/src/internal/customers/attach/attachUtils/handleAttachErrors.ts b/server/src/internal/customers/attach/attachUtils/handleAttachErrors.ts index 396dabac5..675075336 100644 --- a/server/src/internal/customers/attach/attachUtils/handleAttachErrors.ts +++ b/server/src/internal/customers/attach/attachUtils/handleAttachErrors.ts @@ -2,9 +2,15 @@ import RecaseError from "@/utils/errorUtils.js"; import { ErrCode } from "@/errors/errCodes.js"; import { StatusCodes } from "http-status-codes"; import { AttachParams } from "../../cusProducts/AttachParams.js"; -import { AttachBranch, AttachErrCode, UsagePriceConfig } from "@autumn/shared"; +import { + AttachBranch, + AttachConfig, + AttachErrCode, + UsagePriceConfig, +} from "@autumn/shared"; import { AttachBody } from "@autumn/shared"; -import { AttachConfig, AttachFlags } from "../models/AttachFlags.js"; +import { AttachFlags } from "../models/AttachFlags.js"; + import { getEntOptions, priceIsOneOffAndTiered, @@ -25,8 +31,10 @@ import { Decimal } from "decimal.js"; const handleNonCheckoutErrors = ({ flags, action, + config, }: { flags: AttachFlags; + config: AttachConfig; action: string; }) => { const { isPublic, forceCheckout, noPaymentMethod } = flags; @@ -47,14 +55,22 @@ const handleNonCheckoutErrors = ({ message: `Not allowed to ${action} because customer has no payment method on file`, code: ErrCode.InvalidRequest, }); + } else if (config.invoiceCheckout) { + throw new RecaseError({ + message: `Not allowed to ${action} when using 'invoice': true`, + code: ErrCode.InvalidRequest, + statusCode: StatusCodes.BAD_REQUEST, + }); } }; const handlePrepaidErrors = async ({ attachParams, + config, useCheckout = false, }: { attachParams: AttachParams; + config: AttachConfig; useCheckout?: boolean; }) => { const { prices, entitlements, optionsList } = attachParams; @@ -69,7 +85,10 @@ const handlePrepaidErrors = async ({ let options = getEntOptions(optionsList, priceEnt); // 1. If not checkout, quantity should be defined - if (!useCheckout && nullOrUndefined(options?.quantity)) { + + const regularCheckout = useCheckout && !config.invoiceCheckout; + + if (!regularCheckout && nullOrUndefined(options?.quantity)) { throw new RecaseError({ message: `Pass in 'quantity' for feature ${priceEnt.feature_id} in options`, code: ErrCode.InvalidOptions, @@ -197,6 +216,8 @@ export const handleAttachErrors = async ({ }) => { const { onlyCheckout } = config; + // Invoice no payment enabled: onlyCheckout + if (onlyCheckout || flags.isPublic) { let upgradeDowngradeFlows = [ AttachBranch.Upgrade, @@ -206,6 +227,7 @@ export const handleAttachErrors = async ({ if (upgradeDowngradeFlows.includes(branch)) { handleNonCheckoutErrors({ flags, + config, action: "perform upgrade or downgrade", }); } @@ -218,6 +240,7 @@ export const handleAttachErrors = async ({ handleNonCheckoutErrors({ flags, action: "update current product", + config, }); } } @@ -236,6 +259,7 @@ export const handleAttachErrors = async ({ await handlePrepaidErrors({ attachParams, + config, useCheckout: onlyCheckout, }); diff --git a/server/src/internal/customers/attach/checkout/handleCheckout.ts b/server/src/internal/customers/attach/checkout/handleCheckout.ts index 5ffdedd8a..974c06b7d 100644 --- a/server/src/internal/customers/attach/checkout/handleCheckout.ts +++ b/server/src/internal/customers/attach/checkout/handleCheckout.ts @@ -14,7 +14,10 @@ import { getAttachBranch } from "../attachUtils/getAttachBranch.js"; import { getAttachConfig } from "../attachUtils/getAttachConfig.js"; import { getAttachFunction } from "../attachUtils/getAttachFunction.js"; import { handleCreateCheckout } from "../../add-product/handleCreateCheckout.js"; -import { checkStripeConnections } from "../attachRouter.js"; +import { + checkStripeConnections, + handlePrepaidErrors, +} from "../attachRouter.js"; import { attachParamsToPreview } from "../handleAttachPreview/attachParamsToPreview.js"; import { previewToCheckoutRes } from "./previewToCheckoutRes.js"; import { getProductResponse } from "@/internal/products/productUtils/productResponseUtils/getProductResponse.js"; @@ -24,6 +27,7 @@ import { isPrepaidPrice } from "@/internal/products/prices/priceUtils/usagePrice import { priceToFeature } from "@/internal/products/prices/priceUtils/convertPrice.js"; import { getPriceOptions } from "@/internal/products/prices/priceUtils.js"; import { getHasProrations } from "./getHasProrations.js"; +import { handleCreateInvoiceCheckout } from "../../add-product/handleCreateInvoiceCheckout.js"; const getAttachVars = async ({ req, @@ -108,12 +112,19 @@ export const handleCheckout = (req: any, res: any) => const { logger, features } = req; const attachBody = AttachBodySchema.parse(req.body); - const { attachParams, branch, func } = await getAttachVars({ + const { attachParams, branch, func, config } = await getAttachVars({ req, attachBody, }); let checkoutUrl = null; + + await handlePrepaidErrors({ + attachParams, + config, + useCheckout: config.onlyCheckout, + }); + if (func == AttachFunction.CreateCheckout) { await checkStripeConnections({ req, @@ -122,31 +133,24 @@ export const handleCheckout = (req: any, res: any) => useCheckout: true, }); - const checkout = await handleCreateCheckout({ - req, - res, - attachParams, - returnCheckout: true, - }); + if (config.invoiceCheckout) { + const result = await handleCreateInvoiceCheckout({ + req, + attachParams, + config, + }); - checkoutUrl = checkout?.url; + checkoutUrl = result?.invoices?.[0]?.hosted_invoice_url; + } else { + const checkout = await handleCreateCheckout({ + req, + res, + attachParams, + returnCheckout: true, + }); - // const customer = attachParams.customer; - // res.status(200).json( - // CheckoutResponseSchema.parse({ - // url: checkout?.url, - // customer_id: customer.id || customer.internal_id, - // scenario: AttachScenario.New, - // lines: [], - // product: await getProductResponse({ - // product: attachParams.products[0], - // features: features, - // withDisplay: false, - // options: attachParams.optionsList, - // }), - // }) - // ); - // return; + checkoutUrl = checkout?.url; + } } await getCheckoutOptions({ diff --git a/server/src/internal/customers/attach/handleAttachPreview/handleAttachPreview.ts b/server/src/internal/customers/attach/handleAttachPreview/handleAttachPreview.ts index 01285787b..3fa87a05b 100644 --- a/server/src/internal/customers/attach/handleAttachPreview/handleAttachPreview.ts +++ b/server/src/internal/customers/attach/handleAttachPreview/handleAttachPreview.ts @@ -30,6 +30,9 @@ export const handleAttachPreview = (req: any, res: any) => logger, }); + console.log("Branch:", attachPreview.branch); + console.log("Func:", attachPreview.func); + res.status(200).json(attachPreview); return; diff --git a/server/src/internal/customers/cusCache/getCusWithCache.ts b/server/src/internal/customers/cusCache/getCusWithCache.ts index 229a56156..54f442a62 100644 --- a/server/src/internal/customers/cusCache/getCusWithCache.ts +++ b/server/src/internal/customers/cusCache/getCusWithCache.ts @@ -58,9 +58,7 @@ export const getCusWithCache = async ({ if (!skipCache && !skipGet) { try { - const start = Date.now(); const cached = await upstash!.get(cacheKey); - const end = Date.now(); if (cached) { return cached as FullCustomer; } else { @@ -88,8 +86,9 @@ export const getCusWithCache = async ({ if (!skipCache && notNullish(customer)) { try { - await upstash!.set(cacheKey, customer); - await upstash!.expire(cacheKey, 300); // Expire after 5 minutes... + await upstash!.set(cacheKey, customer, { + ex: 300, + }); } catch (error) { logger.error(`Failed to set cache: ${cacheKey}`, { error }); } diff --git a/server/src/internal/customers/cusProducts/cusProductUtils/convertCusProduct.ts b/server/src/internal/customers/cusProducts/cusProductUtils/convertCusProduct.ts index 8117bc74f..beb7d6ac1 100644 --- a/server/src/internal/customers/cusProducts/cusProductUtils/convertCusProduct.ts +++ b/server/src/internal/customers/cusProducts/cusProductUtils/convertCusProduct.ts @@ -16,10 +16,11 @@ import { import Stripe from "stripe"; import { DrizzleCli } from "@/db/initDrizzle.js"; import { getBillingType } from "@/internal/products/prices/priceUtils.js"; +import { ACTIVE_STATUSES } from "../CusProductService.js"; export const cusProductsToCusPrices = ({ cusProducts, - inStatuses = [CusProductStatus.Active], + inStatuses, billingType, }: { cusProducts: FullCusProduct[]; @@ -29,7 +30,7 @@ export const cusProductsToCusPrices = ({ const cusPrices: FullCustomerPrice[] = []; for (const cusProduct of cusProducts) { - if (!inStatuses.includes(cusProduct.status)) { + if (inStatuses && !inStatuses.includes(cusProduct.status)) { continue; } diff --git a/server/src/internal/customers/cusUtils/cusFeatureResponseUtils/getCusFeaturesResponse.ts b/server/src/internal/customers/cusUtils/cusFeatureResponseUtils/getCusFeaturesResponse.ts index 02003af13..c39b6a363 100644 --- a/server/src/internal/customers/cusUtils/cusFeatureResponseUtils/getCusFeaturesResponse.ts +++ b/server/src/internal/customers/cusUtils/cusFeatureResponseUtils/getCusFeaturesResponse.ts @@ -10,6 +10,7 @@ import { cusProductsToCusPrices, } from "../../cusProducts/cusProductUtils/convertCusProduct.js"; import { getCusBalances } from "./getCusBalances.js"; +import { ACTIVE_STATUSES } from "../../cusProducts/CusProductService.js"; export const getCusFeaturesResponse = async ({ cusProducts, @@ -26,7 +27,9 @@ export const getCusFeaturesResponse = async ({ const balances = await getCusBalances({ cusEntsWithCusProduct: cusEnts, - cusPrices: cusProductsToCusPrices({ cusProducts }), + cusPrices: cusProductsToCusPrices({ + cusProducts, + }), org, entity, apiVersion, diff --git a/server/src/internal/features/featureUtils.ts b/server/src/internal/features/featureUtils.ts index b9d6aee1d..f64a5975f 100644 --- a/server/src/internal/features/featureUtils.ts +++ b/server/src/internal/features/featureUtils.ts @@ -23,6 +23,7 @@ import { cusProductToPrices, } from "../customers/cusProducts/cusProductUtils/convertCusProduct.js"; import { priceToFeature } from "../products/prices/priceUtils/convertPrice.js"; +import { ACTIVE_STATUSES } from "../customers/cusProducts/CusProductService.js"; export const validateFeatureId = (featureId: string) => { if (!featureId.match(/^[a-zA-Z0-9_-]+$/)) { @@ -227,7 +228,9 @@ export const isPaidContinuousUse = ({ let cusPrices = cusProductsToCusPrices({ cusProducts: fullCus.customer_products, + inStatuses: ACTIVE_STATUSES, }); + let hasPaid = cusPrices.some((cp) => { let config = cp.price.config as UsagePriceConfig; if (config.internal_feature_id == feature.internal_id) { diff --git a/server/src/internal/metadata/metadataUtils.ts b/server/src/internal/metadata/metadataUtils.ts index 0ad4c3a1b..6d900624b 100644 --- a/server/src/internal/metadata/metadataUtils.ts +++ b/server/src/internal/metadata/metadataUtils.ts @@ -36,7 +36,7 @@ export const createCheckoutMetadata = async ({ export const getMetadataFromCheckoutSession = async ( checkoutSession: Stripe.Checkout.Session, - db: DrizzleCli, + db: DrizzleCli ) => { const metadataId = checkoutSession.metadata?.autumn_metadata_id; diff --git a/server/src/internal/migrations/migrationUtils/migrationToAttachParams.ts b/server/src/internal/migrations/migrationUtils/migrationToAttachParams.ts index fafa9b7c5..f03ffd724 100644 --- a/server/src/internal/migrations/migrationUtils/migrationToAttachParams.ts +++ b/server/src/internal/migrations/migrationUtils/migrationToAttachParams.ts @@ -60,13 +60,6 @@ export const migrationToAttachParams = async ({ // Others apiVersion, - // successUrl: attachBody.success_url, - // invoiceOnly: attachBody.invoice_only, - // billingAnchor: attachBody.billing_cycle_anchor, - // metadata: attachBody.metadata, - // disableFreeTrial: attachBody.free_trial === false || false, - // checkoutSessionParams: attachBody.checkout_session_params, - // isCustom: attachBody.is_custom, }; return attachParams; diff --git a/server/src/trigger/updateBalanceTask.ts b/server/src/trigger/updateBalanceTask.ts index 375424648..eb0b3575a 100644 --- a/server/src/trigger/updateBalanceTask.ts +++ b/server/src/trigger/updateBalanceTask.ts @@ -696,6 +696,7 @@ export const runUpdateBalanceTask = async ({ entityId, }); + // console.time("refreshCusCache"); await refreshCusCache({ db, customerId, @@ -703,6 +704,7 @@ export const runUpdateBalanceTask = async ({ env, entityId, }); + // console.timeEnd("refreshCusCache"); if (!cusEnts || cusEnts.length === 0) { return; diff --git a/server/src/utils/scriptUtils/clearOrg.ts b/server/src/utils/scriptUtils/clearOrg.ts index 59e0ccc10..999d6c360 100644 --- a/server/src/utils/scriptUtils/clearOrg.ts +++ b/server/src/utils/scriptUtils/clearOrg.ts @@ -8,7 +8,7 @@ import { } from "@autumn/shared"; import { and, eq, inArray } from "drizzle-orm"; -const clearCustomersInBatches = async ({ +export const clearCustomersInBatches = async ({ db, org, batchSize = 450, @@ -38,6 +38,8 @@ const clearCustomersInBatches = async ({ .map((c) => c.internalId) .filter((id) => id !== null); + console.log("Deleting customers:", customerIds); + await db .delete(customers) .where(inArray(customers.internal_id, customerIds)); diff --git a/server/tests/attach/addOn/addOn1.ts b/server/tests/attach/addOn/addOn1.ts index 91b87b130..426c84b4f 100644 --- a/server/tests/attach/addOn/addOn1.ts +++ b/server/tests/attach/addOn/addOn1.ts @@ -45,7 +45,7 @@ const testCase = "addOn1"; describe(`${chalk.yellowBright(`${testCase}: Testing free add on, and updating free add on`)}`, () => { let customerId = testCase; - let autumn: AutumnInt = new AutumnInt({ version: APIVersion.v1_4 }); + let autumn: AutumnInt = new AutumnInt({ version: APIVersion.v1_2 }); let testClockId: string; let db: DrizzleCli, org: Organization, env: AppEnv; let stripeCli: Stripe; @@ -112,7 +112,7 @@ describe(`${chalk.yellowBright(`${testCase}: Testing free add on, and updating f const customer = await autumn.customers.get(customerId); - expect(customer.products.length).to.equal(2); + expect(customer.products.length).to.equal(3); expectProductAttached({ customer, product: addOn, @@ -124,7 +124,7 @@ describe(`${chalk.yellowBright(`${testCase}: Testing free add on, and updating f }); const customItems = replaceItems({ - items: pro.items, + items: addOn.items, featureId: TestFeature.Messages, newItem: constructFeatureItem({ featureId: TestFeature.Messages, @@ -149,7 +149,7 @@ describe(`${chalk.yellowBright(`${testCase}: Testing free add on, and updating f const customer = await autumn.customers.get(customerId); - expect(customer.products.length).to.equal(2); + expect(customer.products.length).to.equal(3); expectProductAttached({ customer, product: addOn, diff --git a/server/tests/attach/checkout/checkout5.ts b/server/tests/attach/checkout/checkout5.ts new file mode 100644 index 000000000..9dfbb7b21 --- /dev/null +++ b/server/tests/attach/checkout/checkout5.ts @@ -0,0 +1,118 @@ +import { AutumnInt } from "@/external/autumn/autumnCli.js"; +import { initCustomer } from "@/utils/scriptUtils/initCustomer.js"; +import { APIVersion, AppEnv, Organization } from "@autumn/shared"; +import chalk from "chalk"; +import Stripe from "stripe"; +import { DrizzleCli } from "@/db/initDrizzle.js"; +import { setupBefore } from "tests/before.js"; +import { createProducts, createReward } from "tests/utils/productUtils.js"; +import { addPrefixToProducts } from "../utils.js"; +import { + constructCoupon, + constructProduct, +} from "@/utils/scriptUtils/createTestProducts.js"; +import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js"; +import { TestFeature } from "tests/setup/v2Features.js"; +import { completeCheckoutForm } from "tests/utils/stripeUtils.js"; +import { timeout } from "@/utils/genUtils.js"; +import { expectProductAttached } from "tests/utils/expectUtils/expectProductAttached.js"; +import { expect } from "chai"; +import { getBasePrice } from "tests/utils/testProductUtils/testProductUtils.js"; +import { completeInvoiceCheckout } from "tests/utils/stripeUtils/completeInvoiceCheckout.js"; +import { expectFeaturesCorrect } from "tests/utils/expectUtils/expectFeaturesCorrect.js"; + +export let pro = constructProduct({ + items: [ + constructFeatureItem({ + featureId: TestFeature.Messages, + includedUsage: 100, + }), + ], + type: "pro", +}); + +const testCase = "checkout5"; +describe(`${chalk.yellowBright(`${testCase}: Testing invoice checkout, no product till paid`)}`, () => { + let customerId = testCase; + let autumn: AutumnInt = new AutumnInt({ version: APIVersion.v1_2 }); + let testClockId: string; + let db: DrizzleCli, org: Organization, env: AppEnv; + let stripeCli: Stripe; + let curUnix = new Date().getTime(); + + before(async function () { + await setupBefore(this); + const { autumnJs } = this; + db = this.db; + org = this.org; + env = this.env; + + stripeCli = this.stripeCli; + + addPrefixToProducts({ + products: [pro], + prefix: testCase, + }); + + await createProducts({ + autumn, + products: [pro], + customerId, + db, + orgId: org.id, + env, + }); + + const { testClockId: testClockId1 } = await initCustomer({ + autumn: autumnJs, + customerId, + db, + org, + env, + // attachPm: "success", + }); + + testClockId = testClockId1!; + }); + + it("should attach pro product", async function () { + const res = await autumn.attach({ + customer_id: customerId, + product_id: pro.id, + invoice: true, + }); + + const customer = await autumn.customers.get(customerId); + + const invoice = customer.invoices?.[0]; + + expect(invoice).to.exist; + expect(invoice.total).to.equal(getBasePrice({ product: pro })); + expect(invoice.status).to.equal("open"); + + const product = customer.products.find((p) => p.id === pro.id); + expect(product).to.not.exist; + + await completeInvoiceCheckout({ + url: res.checkout_url, + }); + + const customer2 = await autumn.customers.get(customerId); + + const invoice2 = customer2.invoices?.[0]; + + expect(customer2.invoices.length).to.equal(1); + expect(invoice2).to.exist; + expect(invoice2.status).to.equal("paid"); + + expectProductAttached({ + customer: customer2, + product: pro, + }); + + expectFeaturesCorrect({ + customer: customer2, + product: pro, + }); + }); +}); diff --git a/server/tests/attach/checkout/checkout6.ts b/server/tests/attach/checkout/checkout6.ts new file mode 100644 index 000000000..44c7f12f5 --- /dev/null +++ b/server/tests/attach/checkout/checkout6.ts @@ -0,0 +1,158 @@ +import chalk from "chalk"; +import Stripe from "stripe"; + +import { AutumnInt } from "@/external/autumn/autumnCli.js"; +import { initCustomer } from "@/utils/scriptUtils/initCustomer.js"; +import { APIVersion, AppEnv, Organization } from "@autumn/shared"; +import { DrizzleCli } from "@/db/initDrizzle.js"; +import { setupBefore } from "tests/before.js"; +import { createProducts } from "tests/utils/productUtils.js"; +import { addPrefixToProducts } from "../utils.js"; +import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; +import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js"; +import { TestFeature } from "tests/setup/v2Features.js"; +import { completeInvoiceCheckout } from "tests/utils/stripeUtils/completeInvoiceCheckout.js"; +import { expect } from "chai"; +import { expectProductAttached } from "tests/utils/expectUtils/expectProductAttached.js"; +import { expectFeaturesCorrect } from "tests/utils/expectUtils/expectFeaturesCorrect.js"; +import { expectAutumnError } from "tests/utils/expectUtils/expectErrUtils.js"; +import { getBasePrice } from "tests/utils/testProductUtils/testProductUtils.js"; + +export let pro = constructProduct({ + items: [ + constructFeatureItem({ + featureId: TestFeature.Messages, + includedUsage: 100, + }), + ], + type: "pro", +}); + +export let premium = constructProduct({ + items: [ + constructFeatureItem({ + featureId: TestFeature.Messages, + includedUsage: 250, + }), + ], + type: "premium", +}); + +const testCase = "checkout6"; +describe(`${chalk.yellowBright(`${testCase}: Testing invoice checkout via checkout endpoint`)}`, () => { + let customerId = testCase; + let autumn: AutumnInt = new AutumnInt({ version: APIVersion.v1_2 }); + let testClockId: string; + let db: DrizzleCli, org: Organization, env: AppEnv; + let stripeCli: Stripe; + let curUnix = new Date().getTime(); + + before(async function () { + await setupBefore(this); + const { autumnJs } = this; + db = this.db; + org = this.org; + env = this.env; + + stripeCli = this.stripeCli; + + addPrefixToProducts({ + products: [pro, premium], + prefix: testCase, + }); + + await createProducts({ + autumn, + products: [pro, premium], + customerId, + db, + orgId: org.id, + env, + }); + + const { testClockId: testClockId1 } = await initCustomer({ + autumn: autumnJs, + customerId, + db, + org, + env, + attachPm: "success", + }); + + testClockId = testClockId1!; + }); + + it("should attach pro product via invoice checkout", async function () { + const res = await autumn.checkout({ + customer_id: customerId, + product_id: pro.id, + invoice: true, + }); + + expect(res.url).to.exist; + + await completeInvoiceCheckout({ + url: res.url!, + }); + + const customer = await autumn.customers.get(customerId); + + expectProductAttached({ + customer, + product: pro, + }); + + expectFeaturesCorrect({ + customer, + product: pro, + }); + }); + + it("should have no URL returned if try to attach premium (with invoice true)", async function () { + await expectAutumnError({ + func: async () => { + await autumn.attach({ + customer_id: customerId, + product_id: premium.id, + invoice: true, + }); + }, + }); + + const res = await autumn.checkout({ + customer_id: customerId, + product_id: premium.id, + invoice: true, + }); + + expect(res.url).to.not.exist; + }); + + it("should attach premium product via invoice enable immediately", async function () { + const res = await autumn.attach({ + customer_id: customerId, + product_id: premium.id, + invoice: true, + enable_product_immediately: true, + }); + + const customer = await autumn.customers.get(customerId); + + expectProductAttached({ + customer, + product: premium, + }); + + expectFeaturesCorrect({ + customer, + product: premium, + }); + + const invoices = customer.invoices; + expect(invoices.length).to.equal(2); + expect(invoices[0].status).to.equal("draft"); + expect(invoices[0].total).to.equal( + getBasePrice({ product: premium }) - getBasePrice({ product: pro }) + ); // proration... + }); +}); diff --git a/server/tests/attach/checkout/checkout7.ts b/server/tests/attach/checkout/checkout7.ts new file mode 100644 index 000000000..f4950a57c --- /dev/null +++ b/server/tests/attach/checkout/checkout7.ts @@ -0,0 +1,141 @@ +import chalk from "chalk"; +import Stripe from "stripe"; + +import { AutumnInt } from "@/external/autumn/autumnCli.js"; +import { initCustomer } from "@/utils/scriptUtils/initCustomer.js"; +import { APIVersion, AppEnv, Organization } from "@autumn/shared"; +import { DrizzleCli } from "@/db/initDrizzle.js"; +import { setupBefore } from "tests/before.js"; +import { createProducts } from "tests/utils/productUtils.js"; +import { addPrefixToProducts } from "../utils.js"; +import { + constructProduct, + constructRawProduct, +} from "@/utils/scriptUtils/createTestProducts.js"; +import { + constructFeatureItem, + constructPrepaidItem, +} from "@/utils/scriptUtils/constructItem.js"; +import { TestFeature } from "tests/setup/v2Features.js"; +import { completeInvoiceCheckout } from "tests/utils/stripeUtils/completeInvoiceCheckout.js"; +import { expect } from "chai"; +import { expectProductAttached } from "tests/utils/expectUtils/expectProductAttached.js"; +import { expectFeaturesCorrect } from "tests/utils/expectUtils/expectFeaturesCorrect.js"; + +export let pro = constructProduct({ + items: [ + constructFeatureItem({ + featureId: TestFeature.Messages, + includedUsage: 100, + }), + ], + type: "pro", +}); + +export let addOn = constructRawProduct({ + id: "addOn", + items: [ + constructPrepaidItem({ + featureId: TestFeature.Messages, + billingUnits: 100, + price: 10, + isOneOff: true, + }), + ], +}); + +const testCase = "checkout7"; +describe(`${chalk.yellowBright(`${testCase}: Testing invoice checkout with one off product`)}`, () => { + let customerId = testCase; + let autumn: AutumnInt = new AutumnInt({ version: APIVersion.v1_2 }); + let testClockId: string; + let db: DrizzleCli, org: Organization, env: AppEnv; + let stripeCli: Stripe; + let curUnix = new Date().getTime(); + + before(async function () { + await setupBefore(this); + const { autumnJs } = this; + db = this.db; + org = this.org; + env = this.env; + + stripeCli = this.stripeCli; + + addPrefixToProducts({ + products: [pro, addOn], + prefix: testCase, + }); + + await createProducts({ + autumn, + products: [pro, addOn], + customerId, + db, + orgId: org.id, + env, + }); + + const { testClockId: testClockId1 } = await initCustomer({ + autumn: autumnJs, + customerId, + db, + org, + env, + attachPm: "success", + }); + + testClockId = testClockId1!; + }); + + it("should attach pro product, then add on product via invoice checkout", async function () { + await autumn.attach({ + customer_id: customerId, + product_id: pro.id, + }); + + const options = [ + { + quantity: 200, + feature_id: TestFeature.Messages, + }, + ]; + + const res2 = await autumn.checkout({ + customer_id: customerId, + product_id: addOn.id, + invoice: true, + options, + }); + + expect(res2.url).to.exist; + + await completeInvoiceCheckout({ + url: res2.url!, + }); + + const customer = await autumn.customers.get(customerId); + + expectProductAttached({ + customer, + product: addOn, + }); + + expectFeaturesCorrect({ + customer, + product: addOn, + otherProducts: [pro], + options, + }); + }); + + // it("should have no URL returned if try to attach add on (with invoice true)", async function () { + // const res = await autumn.checkout({ + // customer_id: customerId, + // product_id: addOn.id, + // invoice: true, + // }); + + // expect(res.url).to.not.exist; + // }); +}); diff --git a/server/tests/attach/others/others5.ts b/server/tests/attach/others/others5.ts index ae6539792..2323d8a24 100644 --- a/server/tests/attach/others/others5.ts +++ b/server/tests/attach/others/others5.ts @@ -12,12 +12,14 @@ const checkEntitledOnProduct = async ({ totalAllowance, finish = false, usageBased = false, + timeoutMs = 8000, }: { customerId: string; product: any; totalAllowance?: number; finish?: boolean; usageBased?: boolean; + timeoutMs?: number; }) => { // 1. Send events const allowance = totalAllowance || product.entitlements.metered1.allowance; @@ -35,7 +37,7 @@ const checkEntitledOnProduct = async ({ } await Promise.all(batchUpdates); - await timeout(8000); + await timeout(timeoutMs); let used = randomNum; // 2. Check entitled @@ -74,7 +76,7 @@ const checkEntitledOnProduct = async ({ ); } await Promise.all(batchUpdates2); - await timeout(8000); + await timeout(timeoutMs); used += allowance - randomNum; // 3. Check entitled again @@ -124,13 +126,13 @@ describe(`${chalk.yellowBright( }); }); - it("should have correct entitlements (free)", async function () { - await checkEntitledOnProduct({ - customerId: customerId, - product: products.free, - finish: true, - }); - }); + // it("should have correct entitlements (free)", async function () { + // await checkEntitledOnProduct({ + // customerId: customerId, + // product: products.free, + // finish: true, + // }); + // }); it("should attach pro", async function () { await AutumnCli.attach({ @@ -170,6 +172,7 @@ describe(`${chalk.yellowBright( product: products.oneTimeAddOnMetered1, finish: true, totalAllowance: curAllowance + oneTimeQuantity, + timeoutMs: 15000, }); }); }); @@ -231,7 +234,7 @@ describe(`${chalk.yellowBright( } await Promise.all(batchUpdates); - await timeout(14000); + await timeout(10000); const { allowed: allowed2, balanceObj: balanceObj2 }: any = await AutumnCli.entitled(customerId, features.metered1.id, true); diff --git a/server/tests/attach/others/others6.ts b/server/tests/attach/others/others6.ts index afc3d3b26..4b90730c3 100644 --- a/server/tests/attach/others/others6.ts +++ b/server/tests/attach/others/others6.ts @@ -93,7 +93,8 @@ describe(`${chalk.yellowBright(`${testCase}: Testing attach with customer ID and customer_id: internalCustomerId, entity_id: internalEntityId, product_id: pro.id, - invoice_only: true, + invoice: true, + enable_product_immediately: true, }); const customer = await autumn.customers.get(internalCustomerId); diff --git a/server/tests/utils/stripeUtils/completeInvoiceCheckout.ts b/server/tests/utils/stripeUtils/completeInvoiceCheckout.ts new file mode 100644 index 000000000..d449aafc2 --- /dev/null +++ b/server/tests/utils/stripeUtils/completeInvoiceCheckout.ts @@ -0,0 +1,144 @@ +import "dotenv/config"; + +import { Stripe } from "stripe"; +import puppeteer from "puppeteer-core"; +import Browserbase from "@browserbasehq/sdk"; + +import { Hyperbrowser } from "@hyperbrowser/sdk"; +import { timeout } from "../genUtils.js"; + +const client = new Hyperbrowser({ + apiKey: process.env.HYPERBROWSER_API_KEY, +}); + +export const completeInvoiceCheckout = async ({ + url, + isLocal = false, +}: { + url: string; + isLocal?: boolean; +}) => { + let browser; + + if (process.env.NODE_ENV === "development" && !isLocal) { + const session = await client.sessions.create(); + browser = await puppeteer.connect({ + browserWSEndpoint: session!.wsEndpoint, + defaultViewport: null, + }); + } else { + browser = await puppeteer.launch({ + headless: false, + executablePath: "/Applications/Chromium.app/Contents/MacOS/Chromium", + args: ["--no-sandbox", "--disable-setuid-sandbox"], + }); + } + + try { + const page = await browser.newPage(); + await page.setViewport({ width: 1280, height: 800 }); // Set standard desktop viewport size + await page.goto(url); + + // Wait for the payment element to load + + await page.waitForSelector("#payment-element", { timeout: 10000 }); + + // Wait a bit more for the iframe to fully load + await new Promise((resolve) => setTimeout(resolve, 3000)); + + // Try clicking on the payment element container to expand the accordion + + await page.click("#payment-element"); + + await new Promise((resolve) => setTimeout(resolve, 3000)); + + // Get the iframe containing the Stripe elements + const stripeFrame = await page.$("#payment-element iframe"); + if (!stripeFrame) { + throw new Error("Stripe iframe not found"); + } + + const frame = await stripeFrame.contentFrame(); + if (!frame) { + throw new Error("Could not access iframe content"); + } + + // Enter card number - try different possible selectors + try { + await frame.waitForSelector( + 'input[name="number"], input[data-elements-stable-field-name="cardNumber"], input[placeholder*="1234"], input[aria-label*="Card number"]', + { timeout: 2000 } + ); + const cardNumberInput = await frame.$( + 'input[name="number"], input[data-elements-stable-field-name="cardNumber"], input[placeholder*="1234"], input[aria-label*="Card number"]' + ); + if (cardNumberInput) { + await cardNumberInput.click(); + await cardNumberInput.type("4242424242424242"); + } + } catch (error) { + console.log("Could not find card number input:", error); + } + + // Enter expiry date + try { + await frame.waitForSelector( + 'input[name="expiry"], input[data-elements-stable-field-name="cardExpiry"], input[placeholder*="MM"], input[aria-label*="expir"]', + { timeout: 2000 } + ); + const expiryInput = await frame.$( + 'input[name="expiry"], input[data-elements-stable-field-name="cardExpiry"], input[placeholder*="MM"], input[aria-label*="expir"]' + ); + if (expiryInput) { + await expiryInput.click(); + await expiryInput.type("1227"); + } + } catch (error) { + console.log("Could not find expiry input:", error); + } + + // Enter CVC + try { + await frame.waitForSelector( + 'input[name="cvc"], input[data-elements-stable-field-name="cardCvc"], input[placeholder*="CVC"], input[aria-label*="CVC"]', + { timeout: 2000 } + ); + const cvcInput = await frame.$( + 'input[name="cvc"], input[data-elements-stable-field-name="cardCvc"], input[placeholder*="CVC"], input[aria-label*="CVC"]' + ); + if (cvcInput) { + await cvcInput.click(); + await cvcInput.type("123"); + } + } catch (error) { + console.log("Could not find CVC input:", error); + } + + // Enter postal code + try { + await frame.waitForSelector( + 'input[name="postalCode"], input[data-elements-stable-field-name="postalCode"], input[placeholder*="12345"], input[aria-label*="ZIP"]', + { timeout: 2000 } + ); + const postalInput = await frame.$( + 'input[name="postalCode"], input[data-elements-stable-field-name="postalCode"], input[placeholder*="12345"], input[aria-label*="ZIP"]' + ); + if (postalInput) { + await postalInput.click(); + await postalInput.type("12345"); + } + } catch (error) { + console.log("Could not find postal code input:", error); + } + + // Wait a bit for all inputs to be processed + await new Promise((resolve) => setTimeout(resolve, 2000)); + + const submitButton = await page.$(".SubmitButton-IconContainer"); + await submitButton?.evaluate((b: any) => (b as HTMLElement).click()); + await timeout(20000); + } finally { + // always close browser + await browser.close(); + } +}; diff --git a/shared/models/attachModels/attachBody.ts b/shared/models/attachModels/attachBody.ts index 6d14dfd3c..e80597ff3 100644 --- a/shared/models/attachModels/attachBody.ts +++ b/shared/models/attachModels/attachBody.ts @@ -44,6 +44,7 @@ export const AttachBodySchema = z checkout_session_params: z.any().optional(), reward: z.string().optional(), invoice: z.boolean().optional(), + enable_product_immediately: z.boolean().optional(), }) .refine( (data) => { diff --git a/shared/models/attachModels/attachEnums/AttachConfig.ts b/shared/models/attachModels/attachEnums/AttachConfig.ts index 560ca9b19..bdd1dd27e 100644 --- a/shared/models/attachModels/attachEnums/AttachConfig.ts +++ b/shared/models/attachModels/attachEnums/AttachConfig.ts @@ -8,6 +8,7 @@ export enum ProrationBehavior { export interface AttachConfig { onlyCheckout: boolean; + invoiceCheckout: boolean; carryUsage: boolean; // Whether to carry over existing usages branch: AttachBranch; proration: ProrationBehavior; diff --git a/vite/src/views/customers/customer/customer-sidebar/customer-details.tsx b/vite/src/views/customers/customer/customer-sidebar/customer-details.tsx index c8925a834..83fcea525 100644 --- a/vite/src/views/customers/customer/customer-sidebar/customer-details.tsx +++ b/vite/src/views/customers/customer/customer-sidebar/customer-details.tsx @@ -111,7 +111,7 @@ export const CustomerDetails = ({ > diff --git a/vite/src/views/customers/customer/product/components/AttachModal.tsx b/vite/src/views/customers/customer/product/components/AttachModal.tsx index 56f22e4f6..f57744588 100644 --- a/vite/src/views/customers/customer/product/components/AttachModal.tsx +++ b/vite/src/views/customers/customer/product/components/AttachModal.tsx @@ -31,6 +31,7 @@ import { cn } from "@/lib/utils"; import { Separator } from "@/components/ui/separator"; import { AttachInfo } from "./attach-preview/AttachInfo"; import { getAttachBody } from "./attachProductUtils"; +import { InvoiceCustomerButton } from "./InvoiceCustomerButton"; export const AttachModal = ({ open, @@ -118,8 +119,15 @@ export const AttachModal = ({ return "Charge Customer"; }; - const handleAttachClicked = async (useInvoice: boolean) => { - const setLoading = useInvoice ? setInvoiceLoading : setCheckoutLoading; + const handleAttachClicked = async ({ + useInvoice, + enableProductImmediately, + setLoading, + }: { + useInvoice: boolean; + enableProductImmediately?: boolean; + setLoading: (loading: boolean) => void; + }) => { const cusId = getCusId(); for (const option of options) { @@ -145,6 +153,7 @@ export const AttachModal = ({ optionsInput: options, attachState, useInvoice, + enableProductImmediately, successUrl: `${import.meta.env.VITE_FRONTEND_URL}${redirectUrl}`, version: version || product.version, }); @@ -155,9 +164,8 @@ export const AttachModal = ({ window.open(data.checkout_url, "_blank"); } else if (data.invoice) { window.open(getStripeInvoiceLink(data.invoice), "_blank"); - } else { - navigateTo(`/customers/${cusId}`, navigation, env); } + navigateTo(`/customers/${cusId}`, navigation, env); toast.success(data.message || "Successfully attached product"); setOpen(false); @@ -186,7 +194,7 @@ export const AttachModal = ({ const mainWidth = "w-lg"; return ( - +
{invoiceAllowed() && ( - + + // )} diff --git a/vite/src/views/customers/customer/product/components/InvoiceCustomerButton.tsx b/vite/src/views/customers/customer/product/components/InvoiceCustomerButton.tsx new file mode 100644 index 000000000..8eb952b83 --- /dev/null +++ b/vite/src/views/customers/customer/product/components/InvoiceCustomerButton.tsx @@ -0,0 +1,103 @@ +import FieldLabel from "@/components/general/modal-components/FieldLabel"; +import { Button } from "@/components/ui/button"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { AttachBranch, AttachFunction } from "@autumn/shared"; +import { ArrowUpRightFromSquare } from "lucide-react"; +import { useState } from "react"; + +export const InvoiceCustomerButton = ({ + handleAttachClicked, + preview, +}: { + handleAttachClicked: any; + preview?: any; +}) => { + const [immediateLoading, setImmediateLoading] = useState(false); + const [afterPaymentLoading, setAfterPaymentLoading] = useState(false); + const buttonsDisabled = immediateLoading || afterPaymentLoading; + + const allowedBranches = [ + AttachBranch.New, + AttachBranch.MainIsTrial, + AttachBranch.MainIsFree, + AttachBranch.OneOff, + AttachBranch.AddOn, + ]; + + // const immediateDisabled = !allowedBranches.includes(preview?.branch); + + console.log("Preview:", preview); + + return ( + + + + + +
+
+

Enable Product Immediately

+

+ This will enable the product for the customer immediately, and + redirect you to Stripe to finalize the invoice +

+ +
+
+ {preview?.func == AttachFunction.CreateCheckout && ( +
+
+

Enable Product After Payment

+

+ This will generate an invoice link for the customer, and enable + the product after they pay the invoice +

+ +
+
+ )} +
+
+ ); +}; diff --git a/vite/src/views/customers/customer/product/components/attachProductUtils.ts b/vite/src/views/customers/customer/product/components/attachProductUtils.ts index 0f98b5a57..767d1ed9a 100644 --- a/vite/src/views/customers/customer/product/components/attachProductUtils.ts +++ b/vite/src/views/customers/customer/product/components/attachProductUtils.ts @@ -13,6 +13,7 @@ export const getAttachBody = ({ entityId, optionsInput, useInvoice, + enableProductImmediately = true, successUrl, version, }: { @@ -22,6 +23,7 @@ export const getAttachBody = ({ entityId: string; optionsInput?: FeatureOptions[]; useInvoice?: boolean; + enableProductImmediately?: boolean; successUrl?: string; version?: number; }) => { @@ -47,7 +49,14 @@ export const getAttachBody = ({ ...customData, free_trial: isCustom ? product.free_trial || undefined : undefined, - invoice_only: useInvoice, + invoice: useInvoice, + enable_product_immediately: useInvoice + ? enableProductImmediately + : undefined, + + force_checkout: + useInvoice && enableProductImmediately === false ? true : undefined, + success_url: successUrl, version: version ? Number(version) : undefined, }; diff --git a/vite/src/views/main-sidebar/org-dropdown/hooks/useMemberships.tsx b/vite/src/views/main-sidebar/org-dropdown/hooks/useMemberships.tsx index b3fec796e..12bc96b90 100644 --- a/vite/src/views/main-sidebar/org-dropdown/hooks/useMemberships.tsx +++ b/vite/src/views/main-sidebar/org-dropdown/hooks/useMemberships.tsx @@ -11,31 +11,31 @@ export const useMemberships = () => { url: "/organization/members", }); - const { data: session } = useSession(); - const { data: orgs } = useListOrganizations(); + // const { data: session } = useSession(); + // const { data: orgs } = useListOrganizations(); - const handleRemovedFromOrg = async () => { - const inOrg = orgs?.find( - (org: any) => org.id === session?.session?.activeOrganizationId, - ); + // const handleRemovedFromOrg = async () => { + // const inOrg = orgs?.find( + // (org: any) => org.id === session?.session?.activeOrganizationId, + // ); - if (!inOrg) { - if (orgs && orgs.length > 0) { - await authClient.organization.setActive({ - organizationId: orgs[0].id, - }); - } else { - const { data, error } = await authClient.revokeSessions(); - console.log("Revoked sessions", data, error); - } - window.location.reload(); - } - }; + // if (!inOrg) { + // if (orgs && orgs.length > 0) { + // await authClient.organization.setActive({ + // organizationId: orgs[0].id, + // }); + // } else { + // const { data, error } = await authClient.revokeSessions(); + // console.log("Revoked sessions", data, error); + // } + // window.location.reload(); + // } + // }; - useEffect(() => { - if (!orgs) return; - handleRemovedFromOrg(); - }, [orgs]); + // useEffect(() => { + // if (!orgs) return; + // handleRemovedFromOrg(); + // }, [orgs]); return { memberships: data?.memberships || [],