From 9ded430f2b3d3d8c7397829ce1e6e3b1e7037b61 Mon Sep 17 00:00:00 2001 From: John Yeo Date: Wed, 26 Nov 2025 20:45:18 +0000 Subject: [PATCH 1/4] refactor: moved attach to new hono router and versioned response --- .../stripe/handleStripeWebhookEvent.ts | 31 ++- .../handleCheckoutCompleted.ts | 40 ++-- .../handleSetupCheckout.ts | 13 +- .../webhookHandlers/handleSubDeleted.ts | 13 +- .../handleCusProductDeleted.ts | 19 +- .../webhookHandlers/handleSubUpdated.ts | 40 ++-- .../handleSchedulePhaseCompleted.ts | 41 ++-- .../handleSubUpdated/handleSubCanceled.ts | 14 +- .../handleSubUpdated/handleSubPastDue.ts | 13 +- .../handleSubUpdated/handleSubRenewed.ts | 12 +- .../stripe/webhookUtils/webhookUtils.ts | 11 +- .../external/vercel/misc/vercelInvoicing.ts | 17 +- .../honoMiddlewares/errorSkipMiddleware.ts | 14 -- server/src/internal/analytics/actionUtils.ts | 21 +- .../handlers/handleProductsUpdated.ts | 28 +-- .../check/handlers/getProductCheckPreview.ts | 14 +- .../api/check/handlers/handleProductCheck.ts | 6 +- .../referrals/handleRedeemReferral.ts | 4 +- .../internal/billing/attach/handleAttachV2.ts | 50 +++-- .../attach/utils/getCustomerDisplay.ts | 5 + server/src/internal/billing/billingRouter.ts | 2 + .../billing/checkout/handleCheckoutV2.ts | 12 +- .../add-product/createFullCusProduct.ts | 3 +- .../add-product/createOneTimeCusProduct.ts | 19 +- .../add-product/handleCreateCheckout.ts | 95 +++++---- .../handleCreateInvoiceCheckout.ts | 144 +++++++------- .../addProductFlow/handleAddProduct.ts | 73 ++++--- .../addProductFlow/handleOneOffFunction.ts | 83 ++++---- .../addProductFlow/handlePaidProduct.ts | 126 ++++++------ .../attachFunctions/handleRenewProduct.ts | 66 +++---- .../handleInvoiceCheckoutPaid.ts | 96 ++++----- .../multiAttach/handleMultiAttachFlow.ts | 74 ++++--- .../scheduleFlow/handleScheduleFlow2.ts | 74 +++---- .../handleQuantityDowngrade.ts | 11 +- .../handleQuantityUpgrade.ts | 7 +- .../updateFeatureQuantity.ts | 24 +-- .../updateQuantityFlow/updateQuantityFlow.ts | 51 +++-- .../upgradeFlow/handleUpgradeFlow.ts | 84 ++++---- .../upgradeFlow/handleUpgradeFlowSchedule.ts | 38 ++-- .../upgradeFlow/updateStripeSub2.ts | 16 +- .../internal/customers/attach/attachRouter.ts | 2 - .../attachParams/checkToAttachParams.ts | 16 +- .../attachParams/convertToParams.ts | 56 +++--- .../attachParams/getAttachParams.ts | 3 +- .../attach/attachUtils/getAttachFunction.ts | 49 ++--- .../internal/customers/attach/handleAttach.ts | 175 ++++++++-------- .../mergeUtils/paramsToScheduleItems.ts | 37 ++-- .../attach/mergeUtils/paramsToSubItems.ts | 7 +- .../attach/mergeUtils/subToNewSchedule.ts | 12 +- .../attach/mergeUtils/updateCurSchedule.ts | 13 +- .../customers/cancel/cancelImmediately.ts | 13 +- .../internal/customers/cancel/cancelRouter.ts | 11 +- .../customers/cancel/handleCancelProduct.ts | 27 ++- .../customers/cusProducts/AttachParams.ts | 33 ++-- .../customers/cusProducts/cusProductUtils.ts | 186 ++---------------- .../apiCusCacheUtils/setCachedApiInvoices.ts | 1 - .../apiCusUtils/getApiCustomerBase.ts | 1 - .../customers/cusUtils/createNewCustomer.ts | 7 +- .../handlers/handleTransferProductV2.ts | 8 +- .../apiEntityUtils/getApiEntityExpand.ts | 1 - server/src/internal/invoices/invoiceUtils.ts | 38 ++-- .../migrationSteps/migrateCustomer.ts | 6 +- .../migrationUtils/migrationToAttachParams.ts | 7 +- .../migrationUtils/runMigrationAttach.ts | 12 +- .../referralUtils/triggerFreePaidProduct.ts | 5 +- .../referralUtils/triggerFreeProduct.ts | 3 +- server/tests/attach/misc/attach-misc1.test.ts | 2 +- server/tests/attach/misc/attach-misc2.test.ts | 0 .../attach/changes/V0.2_AttachChange.ts | 56 ++++++ .../attach/prevVersions/attachResponseV0.ts | 13 -- .../attach/prevVersions/attachResponseV1.ts | 21 ++ shared/api/models.ts | 1 + .../versionChangeRegistry.ts | 3 + shared/index.ts | 4 +- .../attachModels/attachFunctionResponse.ts | 19 ++ 75 files changed, 1145 insertions(+), 1207 deletions(-) create mode 100644 server/src/internal/billing/attach/utils/getCustomerDisplay.ts create mode 100644 server/tests/attach/misc/attach-misc2.test.ts create mode 100644 shared/api/billing/attach/changes/V0.2_AttachChange.ts delete mode 100644 shared/api/billing/attach/prevVersions/attachResponseV0.ts create mode 100644 shared/api/billing/attach/prevVersions/attachResponseV1.ts create mode 100644 shared/models/attachModels/attachFunctionResponse.ts diff --git a/server/src/external/stripe/handleStripeWebhookEvent.ts b/server/src/external/stripe/handleStripeWebhookEvent.ts index 4c5f2ac5d..5dc52e3ed 100644 --- a/server/src/external/stripe/handleStripeWebhookEvent.ts +++ b/server/src/external/stripe/handleStripeWebhookEvent.ts @@ -3,6 +3,7 @@ import { type FullCustomer, type Organization, } from "@autumn/shared"; +import * as Sentry from "@sentry/bun"; import chalk from "chalk"; import { Stripe } from "stripe"; import { createStripeCli } from "@/external/connect/createStripeCli.js"; @@ -14,6 +15,7 @@ import { deleteCachedApiCustomer } from "../../internal/customers/cusUtils/apiCu import { setCachedApiInvoices } from "../../internal/customers/cusUtils/apiCusCacheUtils/setCachedApiInvoices.js"; import { setCachedApiSubs } from "../../internal/customers/cusUtils/apiCusCacheUtils/setCachedApiSubs.js"; import type { Logger } from "../logtail/logtailUtils.js"; +import { getSentryTags } from "../sentry/sentryUtils.js"; import { handleCheckoutSessionCompleted } from "./webhookHandlers/handleCheckoutCompleted.js"; import { handleCusDiscountDeleted } from "./webhookHandlers/handleCusDiscountDeleted.js"; import { handleInvoiceCreated } from "./webhookHandlers/handleInvoiceCreated/handleInvoiceCreated.js"; @@ -148,19 +150,9 @@ const handleStripeWebhookRefresh = async ({ export const handleStripeWebhookEvent = async ({ ctx, event, - // db, - // org, - // env, - // logger, - // req, }: { ctx: AutumnContext; event: Stripe.Event; - // db: DrizzleCli; - // org: Organization; - // env: AppEnv; - // logger: Logger; - // req: ExtendedRequest; }) => { const { db, logger, org, env } = ctx; logStripeWebhook({ logger, org, event }); @@ -181,35 +173,29 @@ export const handleStripeWebhookEvent = async ({ case "customer.subscription.updated": { const subscription = event.data.object; await handleSubscriptionUpdated({ - req: ctx as unknown as ExtendedRequest, - db, - org, + ctx, subscription, previousAttributes: event.data.previous_attributes, - env, - logger, }); break; } case "customer.subscription.deleted": await handleSubDeleted({ - req: ctx as unknown as ExtendedRequest, + ctx, stripeCli, data: event.data.object, - logger, }); break; case "checkout.session.completed": { const checkoutSession = event.data.object; await handleCheckoutSessionCompleted({ - req: ctx as unknown as ExtendedRequest, + ctx, db, data: checkoutSession, org, env, - logger, }); break; } @@ -292,6 +278,13 @@ export const handleStripeWebhookEvent = async ({ break; } } catch (error) { + Sentry.captureException(error, { + tags: getSentryTags({ + ctx, + method: event.type, + }), + }); + if (error instanceof Stripe.errors.StripeError) { if (error.message.includes("No such customer")) { logger.warn(`stripe customer missing: ${error.message}`); diff --git a/server/src/external/stripe/webhookHandlers/handleCheckoutCompleted.ts b/server/src/external/stripe/webhookHandlers/handleCheckoutCompleted.ts index 3040d7056..39dcf5239 100644 --- a/server/src/external/stripe/webhookHandlers/handleCheckoutCompleted.ts +++ b/server/src/external/stripe/webhookHandlers/handleCheckoutCompleted.ts @@ -8,6 +8,7 @@ import { import type { Stripe } from "stripe"; import type { DrizzleCli } from "@/db/initDrizzle.js"; import { createStripeCli } from "@/external/connect/createStripeCli.js"; +import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; import { createFullCusProduct } from "@/internal/customers/add-product/createFullCusProduct.js"; import { CusService } from "@/internal/customers/CusService.js"; import type { AttachParams } from "@/internal/customers/cusProducts/AttachParams.js"; @@ -17,7 +18,6 @@ import { getMetadataFromCheckoutSession } from "@/internal/metadata/metadataUtil import { attachToInsertParams } from "@/internal/products/productUtils.js"; import { JobName } from "@/queue/JobName.js"; import { addTaskToQueue } from "@/queue/queueUtils.js"; -import type { ExtendedRequest } from "@/utils/models/Request.js"; import { getEarliestPeriodEnd } from "../stripeSubUtils/convertSubUtils.js"; import { getOptionsFromCheckoutSession } from "./handleCheckoutCompleted/getOptionsFromCheckout.js"; import { handleCheckoutSub } from "./handleCheckoutCompleted/handleCheckoutSub.js"; @@ -25,23 +25,22 @@ import { handleRemainingSets } from "./handleCheckoutCompleted/handleRemainingSe import { handleSetupCheckout } from "./handleCheckoutCompleted/handleSetupCheckout.js"; export const handleCheckoutSessionCompleted = async ({ - req, + ctx, db, org, data, env, - logger, }: { - req: ExtendedRequest; + ctx: AutumnContext; db: DrizzleCli; org: Organization; data: Stripe.Checkout.Session; env: AppEnv; - logger: any; }) => { + const { logger } = ctx; const metadata = await getMetadataFromCheckoutSession(data, db); if (!metadata) { - console.log("checkout.completed: metadata not found, skipping"); + logger.info("checkout.completed: metadata not found, skipping"); return; } @@ -52,16 +51,16 @@ export const handleCheckoutSessionCompleted = async ({ expand: ["line_items", "subscription"], }); - attachParams.req = req; + attachParams.req = ctx as AutumnContext; attachParams.stripeCli = stripeCli; if (attachParams.org.id !== org.id) { - console.log("checkout.completed: org doesn't match, skipping"); + logger.info("checkout.completed: org doesn't match, skipping"); return; } if (attachParams.customer.env !== env) { - console.log("checkout.completed: environments don't match, skipping"); + logger.info("checkout.completed: environments don't match, skipping"); return; } @@ -70,14 +69,14 @@ export const handleCheckoutSessionCompleted = async ({ attachParams, }); - console.log( + logger.info( "Handling checkout.completed: autumn metadata:", checkoutSession.metadata?.autumn_metadata_id, ); if (attachParams.setupPayment) { await handleSetupCheckout({ - req, + ctx, attachParams, }); return; @@ -96,7 +95,7 @@ export const handleCheckoutSessionCompleted = async ({ }); if (activeCusProducts && activeCusProducts.length > 0) { - console.log("✅ checkout.completed: subscription already exists"); + logger.info("✅ checkout.completed: subscription already exists"); return true; } } @@ -121,14 +120,14 @@ export const handleCheckoutSessionCompleted = async ({ ? getEarliestPeriodEnd({ sub: checkoutSub! }) * 1000 : undefined; if (attachParams.productsList) { - console.log("Inserting products list"); + logger.info("Inserting products list"); for (const productOptions of attachParams.productsList) { const product = attachParams.products.find( (p) => p.id === productOptions.product_id, ); if (!product) { - logger.error( + ctx.logger.error( `checkout.completed: product not found for productOptions: ${JSON.stringify( productOptions, )}`, @@ -146,8 +145,8 @@ export const handleCheckoutSessionCompleted = async ({ subscriptionIds: checkoutSub ? [checkoutSub.id] : undefined, anchorToUnix, scenario: AttachScenario.New, - logger, productOptions, + logger: ctx.logger, }); } } else { @@ -159,12 +158,12 @@ export const handleCheckoutSessionCompleted = async ({ subscriptionIds: checkoutSub ? [checkoutSub.id] : undefined, anchorToUnix, scenario: AttachScenario.New, - logger, + logger: ctx.logger, }); } } - console.log("✅ checkout.completed: successfully created cus product"); + logger.info("✅ checkout.completed: successfully created cus product"); const batchInsertInvoice: any = []; for (const invoiceId of invoiceIds) { @@ -179,11 +178,10 @@ export const handleCheckoutSessionCompleted = async ({ } await Promise.all(batchInsertInvoice); - console.log("✅ checkout.completed: successfully inserted invoices"); + logger.info("✅ checkout.completed: successfully inserted invoices"); for (const product of attachParams.products) { - console.log("Adding task to queue for trigger checkout reward"); - console.log("Adding task to queue for trigger checkout reward"); + logger.info("Adding task to queue for trigger checkout reward"); await addTaskToQueue({ jobName: JobName.TriggerCheckoutReward, payload: { @@ -222,6 +220,4 @@ export const handleCheckoutSessionCompleted = async ({ update: updates, }); } - - return; }; diff --git a/server/src/external/stripe/webhookHandlers/handleCheckoutCompleted/handleSetupCheckout.ts b/server/src/external/stripe/webhookHandlers/handleCheckoutCompleted/handleSetupCheckout.ts index 11d9e6f7a..da5a0e976 100644 --- a/server/src/external/stripe/webhookHandlers/handleCheckoutCompleted/handleSetupCheckout.ts +++ b/server/src/external/stripe/webhookHandlers/handleCheckoutCompleted/handleSetupCheckout.ts @@ -5,17 +5,17 @@ import { handleOneOffFunction } from "@/internal/customers/attach/attachFunction import { getDefaultAttachConfig } from "@/internal/customers/attach/attachUtils/getAttachConfig.js"; import type { AttachParams } from "@/internal/customers/cusProducts/AttachParams.js"; import { isOneOff } from "@/internal/products/productUtils.js"; -import type { ExtendedRequest } from "@/utils/models/Request.js"; +import type { AutumnContext } from "../../../../honoUtils/HonoEnv.js"; import { getCusPaymentMethod } from "../../stripeCusUtils.js"; export const handleSetupCheckout = async ({ - req, + ctx, attachParams, }: { - req: ExtendedRequest; + ctx: AutumnContext; attachParams: AttachParams; }) => { - const logger = req.logger; + const { logger } = ctx; const { org, customer } = attachParams; @@ -31,16 +31,15 @@ export const handleSetupCheckout = async ({ if (isOneOff(attachParams.prices)) { await handleOneOffFunction({ - req, + ctx, attachParams, config: getDefaultAttachConfig(), - res: undefined, }); return; } // 1. Check attach prices... await handleAddProduct({ - req, + ctx, attachParams: { ...attachParams, stripeCli: createStripeCli({ org, env: customer.env }), diff --git a/server/src/external/stripe/webhookHandlers/handleSubDeleted.ts b/server/src/external/stripe/webhookHandlers/handleSubDeleted.ts index 51976c1a4..62682fa22 100644 --- a/server/src/external/stripe/webhookHandlers/handleSubDeleted.ts +++ b/server/src/external/stripe/webhookHandlers/handleSubDeleted.ts @@ -1,6 +1,6 @@ import type Stripe from "stripe"; import { CusProductService } from "@/internal/customers/cusProducts/CusProductService.js"; -import type { ExtendedRequest } from "@/utils/models/Request.js"; +import type { AutumnContext } from "../../../honoUtils/HonoEnv.js"; import { getFullStripeSub, subIsPrematurelyCanceled, @@ -8,17 +8,15 @@ import { import { handleCusProductDeleted } from "./handleSubDeleted/handleCusProductDeleted.js"; export const handleSubDeleted = async ({ - req, + ctx, stripeCli, data, - logger, }: { - req: ExtendedRequest; + ctx: AutumnContext; stripeCli: Stripe; data: Stripe.Subscription; - logger: any; }) => { - const { db, org, env } = req; + const { db, org, env, logger } = ctx; const activeCusProducts = await CusProductService.getByStripeSubId({ db, @@ -65,12 +63,11 @@ export const handleSubDeleted = async ({ // const batchUpdate = []; for (const cusProduct of activeCusProducts) { await handleCusProductDeleted({ - req, + ctx, db, stripeCli, cusProduct, subscription, - logger, prematurelyCanceled, }); } diff --git a/server/src/external/stripe/webhookHandlers/handleSubDeleted/handleCusProductDeleted.ts b/server/src/external/stripe/webhookHandlers/handleSubDeleted/handleCusProductDeleted.ts index bc73c4caa..2f593dcbf 100644 --- a/server/src/external/stripe/webhookHandlers/handleSubDeleted/handleCusProductDeleted.ts +++ b/server/src/external/stripe/webhookHandlers/handleSubDeleted/handleCusProductDeleted.ts @@ -17,28 +17,26 @@ import { activateDefaultProduct, activateFutureProduct, } from "@/internal/customers/cusProducts/cusProductUtils.js"; -import type { ExtendedRequest } from "@/utils/models/Request.js"; +import type { AutumnContext } from "../../../../honoUtils/HonoEnv.js"; import { getCusPaymentMethod } from "../../stripeCusUtils.js"; import { webhookToAttachParams } from "../../webhookUtils/webhookUtils.js"; export const handleCusProductDeleted = async ({ - req, + ctx, db, stripeCli, cusProduct, subscription, - logger, prematurelyCanceled, }: { - req: ExtendedRequest; + ctx: AutumnContext; db: DrizzleCli; stripeCli: Stripe; cusProduct: FullCusProduct; subscription: Stripe.Subscription; - logger: any; prematurelyCanceled: boolean; }) => { - const { org, env } = req; + const { org, env, logger } = ctx; const { scheduled_ids } = cusProduct; const fullCus = await CusService.getFull({ db, @@ -73,7 +71,7 @@ export const handleCusProductDeleted = async ({ await createUsageInvoice({ db, attachParams: webhookToAttachParams({ - req, + ctx, stripeCli, paymentMethod, cusProduct, @@ -114,20 +112,19 @@ export const handleCusProductDeleted = async ({ }); await addProductsUpdatedWebhookTask({ - req, + ctx, internalCustomerId: cusProduct.internal_customer_id, org, env, customerId: null, scenario: AttachScenario.Expired, cusProduct, - logger, }); if (cusProduct.product.is_add_on) return; const activatedFuture = await activateFutureProduct({ - req, + ctx, cusProduct, }); @@ -148,7 +145,7 @@ export const handleCusProductDeleted = async ({ }); await activateDefaultProduct({ - req, + ctx, productGroup: cusProduct.product.group, fullCus, curCusProduct: curMainProduct || undefined, diff --git a/server/src/external/stripe/webhookHandlers/handleSubUpdated.ts b/server/src/external/stripe/webhookHandlers/handleSubUpdated.ts index dd8a85b0d..914fe4737 100644 --- a/server/src/external/stripe/webhookHandlers/handleSubUpdated.ts +++ b/server/src/external/stripe/webhookHandlers/handleSubUpdated.ts @@ -1,39 +1,32 @@ import { - type AppEnv, type CollectionMethod, CusProductStatus, - type Organization, + InternalError, } from "@autumn/shared"; -import type { DrizzleCli } from "@/db/initDrizzle.js"; +import type Stripe from "stripe"; import { createStripeCli } from "@/external/connect/createStripeCli.js"; import { CusProductService } from "@/internal/customers/cusProducts/CusProductService.js"; import { SubService } from "@/internal/subscriptions/SubService.js"; -import type { ExtendedRequest } from "@/utils/models/Request.js"; +import type { AutumnContext } from "../../../honoUtils/HonoEnv.js"; import { handleSchedulePhaseCompleted } from "./handleSubUpdated/handleSchedulePhaseCompleted.js"; import { handleSubCanceled } from "./handleSubUpdated/handleSubCanceled.js"; import { handleSubPastDue } from "./handleSubUpdated/handleSubPastDue.js"; import { handleSubRenewed } from "./handleSubUpdated/handleSubRenewed.js"; export const handleSubscriptionUpdated = async ({ - req, - db, - org, + ctx, subscription, previousAttributes, - env, - logger, }: { - req: ExtendedRequest; - db: DrizzleCli; - org: Organization; - env: AppEnv; - subscription: any; + ctx: AutumnContext; + subscription: Stripe.Subscription; + // biome-ignore lint/suspicious/noExplicitAny: Don't know the type of previousAttributes previousAttributes: any; - logger: any; }) => { + const { db, org, env, logger } = ctx; // handle scheduled updated await handleSchedulePhaseCompleted({ - req, + ctx, subObject: subscription, prevAttributes: previousAttributes, }); @@ -93,7 +86,7 @@ export const handleSubscriptionUpdated = async ({ } await handleSubCanceled({ - req, + ctx, previousAttributes, sub: fullSub, updatedCusProducts, @@ -101,7 +94,7 @@ export const handleSubscriptionUpdated = async ({ }); await handleSubPastDue({ - req, + ctx, previousAttributes, sub: fullSub, updatedCusProducts, @@ -109,7 +102,7 @@ export const handleSubscriptionUpdated = async ({ }); await handleSubRenewed({ - req, + ctx, prevAttributes: previousAttributes, sub: fullSub, updatedCusProducts, @@ -130,6 +123,15 @@ export const handleSubscriptionUpdated = async ({ // Cancel subscription immediately if (subscription.status === "past_due" && org.config.cancel_on_past_due) { + if ( + !subscription.latest_invoice || + typeof subscription.latest_invoice !== "string" + ) { + throw new InternalError({ + message: "subscription.latest_invoice is not a string", + }); + } + const stripeCli = createStripeCli({ org, env, diff --git a/server/src/external/stripe/webhookHandlers/handleSubUpdated/handleSchedulePhaseCompleted.ts b/server/src/external/stripe/webhookHandlers/handleSubUpdated/handleSchedulePhaseCompleted.ts index 3955a3860..9455e917e 100644 --- a/server/src/external/stripe/webhookHandlers/handleSubUpdated/handleSchedulePhaseCompleted.ts +++ b/server/src/external/stripe/webhookHandlers/handleSubUpdated/handleSchedulePhaseCompleted.ts @@ -10,20 +10,22 @@ import { CusProductService } from "@/internal/customers/cusProducts/CusProductSe import { activateFutureProduct } from "@/internal/customers/cusProducts/cusProductUtils.js"; import { isFreeProduct, isOneOff } from "@/internal/products/productUtils.js"; import { notNullish } from "@/utils/genUtils.js"; -import type { ExtendedRequest } from "@/utils/models/Request.js"; import { getStripeNow } from "@/utils/scriptUtils/testClockUtils.js"; +import type { AutumnContext } from "../../../../honoUtils/HonoEnv.js"; import { deleteCachedApiCustomer } from "../../../../internal/customers/cusUtils/apiCusCacheUtils/deleteCachedApiCustomer.js"; export const handleSchedulePhaseCompleted = async ({ - req, + ctx, subObject, prevAttributes, }: { - req: ExtendedRequest; + ctx: AutumnContext; subObject: Stripe.Subscription; + // biome-ignore lint/suspicious/noExplicitAny: Don't know the type of prevAttributes prevAttributes: any; }) => { - const { db, org, env, logger } = req; + const { db, org, env, logger } = ctx; + const phasePossiblyChanged = notNullish(prevAttributes?.items) && notNullish(subObject.schedule); @@ -58,25 +60,24 @@ export const handleSchedulePhaseCompleted = async ({ `Expiring cus product: ${cusProduct.product.name} (entity ID: ${cusProduct.entity_id})`, ); await CusProductService.update({ - db: req.db, + db, cusProductId: cusProduct.id, updates: { status: CusProductStatus.Expired }, }); await addProductsUpdatedWebhookTask({ - req, + ctx, internalCustomerId: cusProduct.internal_customer_id, org, env, customerId: null, scenario: AttachScenario.Expired, cusProduct: cusProduct, - logger, }); // ACTIVATING FUTURE PRODUCT const futureCusProduct = await activateFutureProduct({ - req, + ctx, cusProduct, }); @@ -90,7 +91,7 @@ export const handleSchedulePhaseCompleted = async ({ !isOneOff(fullFutureProduct.prices) ) { await CusProductService.update({ - db: req.db, + db, cusProductId: futureCusProduct.id, updates: { subscription_ids: [subObject.id], @@ -124,21 +125,23 @@ export const handleSchedulePhaseCompleted = async ({ // Last phase, cancel schedule await stripeCli.subscriptionSchedules.release(schedule.id); await CusProductService.updateByStripeScheduledId({ - db: req.db, + db, stripeScheduledId: schedule.id, updates: { scheduled_ids: [], }, }); - } catch (error: any) { - if (process.env.NODE_ENV === "development") { - logger.warn( - `schedule.phase.completed: failed to cancel schedule ${schedule.id}, error: ${error.message}`, - ); - } else { - logger.error( - `schedule.phase.completed: failed to cancel schedule ${schedule.id}, error: ${error.message}`, - ); + } catch (error: unknown) { + if (error instanceof Error) { + if (process.env.NODE_ENV === "development") { + logger.warn( + `schedule.phase.completed: failed to cancel schedule ${schedule.id}, error: ${error.message}`, + ); + } else { + logger.error( + `schedule.phase.completed: failed to cancel schedule ${schedule.id}, error: ${error.message}`, + ); + } } } } diff --git a/server/src/external/stripe/webhookHandlers/handleSubUpdated/handleSubCanceled.ts b/server/src/external/stripe/webhookHandlers/handleSubUpdated/handleSubCanceled.ts index 053a65af5..8c93f7834 100644 --- a/server/src/external/stripe/webhookHandlers/handleSubUpdated/handleSubCanceled.ts +++ b/server/src/external/stripe/webhookHandlers/handleSubUpdated/handleSubCanceled.ts @@ -13,7 +13,7 @@ import { CusService } from "@/internal/customers/CusService.js"; import { CusProductService } from "@/internal/customers/cusProducts/CusProductService.js"; import { ProductService } from "@/internal/products/ProductService.js"; import { formatUnixToDateTime, nullish } from "@/utils/genUtils.js"; -import type { ExtendedRequest } from "@/utils/models/Request.js"; +import type { AutumnContext } from "../../../../honoUtils/HonoEnv.js"; import { getLatestPeriodEnd, subToPeriodStartEnd, @@ -86,13 +86,14 @@ const updateCusProductCanceled = async ({ }; export const handleSubCanceled = async ({ - req, + ctx, previousAttributes, org, sub, updatedCusProducts, }: { - req: ExtendedRequest; + ctx: AutumnContext; + // biome-ignore lint/suspicious/noExplicitAny: Don't know the type of previousAttributes previousAttributes: any; sub: Stripe.Subscription; org: Organization; @@ -111,7 +112,7 @@ export const handleSubCanceled = async ({ const canceledFromPortal = canceled && !isAutumnDowngrade; - const { db, env, logger } = req; + const { db, env, logger } = ctx; if (!canceledFromPortal || updatedCusProducts.length === 0) return; @@ -171,7 +172,7 @@ export const handleSubCanceled = async ({ } const insertParams = productToInsertParams({ - req, + ctx, fullCus, newProduct: product, entities, @@ -194,12 +195,11 @@ export const handleSubCanceled = async ({ for (const cusProd of updatedCusProducts) { try { await addProductsUpdatedWebhookTask({ - req, + ctx, internalCustomerId: cusProd.internal_customer_id, org, env, customerId: null, - logger, scenario: AttachScenario.Cancel, cusProduct: cusProd, scheduledCusProduct: scheduledCusProducts.find( diff --git a/server/src/external/stripe/webhookHandlers/handleSubUpdated/handleSubPastDue.ts b/server/src/external/stripe/webhookHandlers/handleSubUpdated/handleSubPastDue.ts index 4cfd8de23..843927443 100644 --- a/server/src/external/stripe/webhookHandlers/handleSubUpdated/handleSubPastDue.ts +++ b/server/src/external/stripe/webhookHandlers/handleSubUpdated/handleSubPastDue.ts @@ -5,13 +5,14 @@ import { } from "@autumn/shared"; import type Stripe from "stripe"; import { addProductsUpdatedWebhookTask } from "@/internal/analytics/handlers/handleProductsUpdated.js"; -import type { ExtendedRequest } from "@/utils/models/Request.js"; +import type { AutumnContext } from "../../../../honoUtils/HonoEnv.js"; import { deleteCachedApiCustomer } from "../../../../internal/customers/cusUtils/apiCusCacheUtils/deleteCachedApiCustomer.js"; export const isSubPastDue = ({ previousAttributes, sub, }: { + // biome-ignore lint/suspicious/noExplicitAny: Don't know the type of previousAttributes previousAttributes: any; sub: Stripe.Subscription; }) => { @@ -24,13 +25,14 @@ export const isSubPastDue = ({ }; export const handleSubPastDue = async ({ - req, + ctx, previousAttributes, org, sub, updatedCusProducts, }: { - req: ExtendedRequest; + ctx: AutumnContext; + // biome-ignore lint/suspicious/noExplicitAny: Don't know the type of previousAttributes previousAttributes: any; sub: Stripe.Subscription; org: Organization; @@ -41,7 +43,7 @@ export const handleSubPastDue = async ({ sub, }); - const { env, logger } = req; + const { env, logger } = ctx; if (!pastDue || updatedCusProducts.length === 0) return; @@ -54,12 +56,11 @@ export const handleSubPastDue = async ({ for (const cusProd of updatedCusProducts) { try { await addProductsUpdatedWebhookTask({ - req, + ctx, internalCustomerId: cusProd.internal_customer_id, org, env, customerId: null, - logger, scenario: AttachScenario.PastDue, cusProduct: cusProd, }); diff --git a/server/src/external/stripe/webhookHandlers/handleSubUpdated/handleSubRenewed.ts b/server/src/external/stripe/webhookHandlers/handleSubUpdated/handleSubRenewed.ts index 73e30fc69..40b945d68 100644 --- a/server/src/external/stripe/webhookHandlers/handleSubUpdated/handleSubRenewed.ts +++ b/server/src/external/stripe/webhookHandlers/handleSubUpdated/handleSubRenewed.ts @@ -6,7 +6,7 @@ import { getSubScenarioFromCache } from "@/internal/customers/cusCache/subCacheU import { CusProductService } from "@/internal/customers/cusProducts/CusProductService.js"; import { getExistingCusProducts } from "@/internal/customers/cusProducts/cusProductUtils/getExistingCusProducts.js"; import { notNullish, nullish } from "@/utils/genUtils.js"; -import type { ExtendedRequest } from "@/utils/models/Request.js"; +import type { AutumnContext } from "../../../../honoUtils/HonoEnv"; const isSubRenewed = ({ previousAttributes, @@ -32,17 +32,18 @@ const isSubRenewed = ({ }; export const handleSubRenewed = async ({ - req, + ctx, prevAttributes, sub, updatedCusProducts, }: { - req: ExtendedRequest; + ctx: AutumnContext; + // biome-ignore lint/suspicious/noExplicitAny: Don't know the type of prevAttributes prevAttributes: any; sub: Stripe.Subscription; updatedCusProducts: FullCusProduct[]; }) => { - const { db, org, env, logger } = req; + const { db, org, env, logger } = ctx; const { renewed } = isSubRenewed({ previousAttributes: prevAttributes, @@ -97,12 +98,11 @@ export const handleSubRenewed = async ({ try { for (const cusProd of updatedCusProducts) { await addProductsUpdatedWebhookTask({ - req, + ctx, internalCustomerId: cusProd.internal_customer_id, org, env, customerId: null, - logger, scenario: AttachScenario.Renew, cusProduct: cusProd, deletedCusProduct: deletedCusProducts.find( diff --git a/server/src/external/stripe/webhookUtils/webhookUtils.ts b/server/src/external/stripe/webhookUtils/webhookUtils.ts index f34ab452d..02778b4e2 100644 --- a/server/src/external/stripe/webhookUtils/webhookUtils.ts +++ b/server/src/external/stripe/webhookUtils/webhookUtils.ts @@ -8,17 +8,17 @@ import { } from "@autumn/shared"; import type Stripe from "stripe"; import type { AttachParams } from "@/internal/customers/cusProducts/AttachParams.js"; -import type { ExtendedRequest } from "@/utils/models/Request.js"; +import type { AutumnContext } from "../../../honoUtils/HonoEnv"; export const webhookToAttachParams = ({ - req, + ctx, stripeCli, paymentMethod, cusProduct, fullCus, entities, }: { - req: ExtendedRequest; + ctx: AutumnContext; stripeCli: Stripe; paymentMethod?: Stripe.PaymentMethod | null; cusProduct: FullCusProduct; @@ -26,16 +26,17 @@ export const webhookToAttachParams = ({ entities?: Entity[]; }): AttachParams => { const fullProduct = cusProductToProduct({ cusProduct }); + const { org, features } = ctx; const params: AttachParams = { stripeCli, paymentMethod, customer: fullCus, - org: req.org, + org, products: [fullProduct], prices: cusProductToPrices({ cusProduct }), entitlements: cusProductToEnts({ cusProduct }), - features: req.features, + features, freeTrial: cusProduct.free_trial || null, optionsList: cusProduct.options, cusProducts: [cusProduct], diff --git a/server/src/external/vercel/misc/vercelInvoicing.ts b/server/src/external/vercel/misc/vercelInvoicing.ts index f9a965b74..09b35828d 100644 --- a/server/src/external/vercel/misc/vercelInvoicing.ts +++ b/server/src/external/vercel/misc/vercelInvoicing.ts @@ -134,7 +134,7 @@ export const submitInvoiceToVercel = async ({ // Calculate total amount from invoice (includes subscription + usage charges) const totalAmount = invoice.amount_due / 100; - let memo; + let memo: string | undefined; if (org.config.invoice_memos) { try { @@ -293,13 +293,14 @@ export const getVercelAttachBody = ({ vercel_resource_id: resourceId || integrationConfigurationId, }, - req: { - db, - org, - env: env as AppEnv, - logger: c.get("ctx").logger, - features, - }, + req: c.get("ctx"), + // req: { + // db, + // org, + // env: env as AppEnv, + // logger: c.get("ctx").logger, + // features, + // }, apiVersion: ApiVersion.V1_2, }; diff --git a/server/src/honoMiddlewares/errorSkipMiddleware.ts b/server/src/honoMiddlewares/errorSkipMiddleware.ts index f740c99e6..6c20d82ab 100644 --- a/server/src/honoMiddlewares/errorSkipMiddleware.ts +++ b/server/src/honoMiddlewares/errorSkipMiddleware.ts @@ -137,20 +137,6 @@ const ZOD_RULES = [ statusCode: 400, format: (err: ZodError) => formatZodError(err), }, - { - name: "Zod error on /attach", - match: (err: Error, c: Context) => - err instanceof ZodError && c.req.url.includes("/attach"), - statusCode: 400, - format: (err: ZodError) => formatZodError(err), - }, - { - name: "Zod error on /checkout (email validation)", - match: (err: Error, c: Context) => - err instanceof ZodError && c.req.url.includes("/checkout"), - statusCode: 400, - format: (err: ZodError) => formatZodError(err), - }, ] as const; const createErrorResponse = ({ diff --git a/server/src/internal/analytics/actionUtils.ts b/server/src/internal/analytics/actionUtils.ts index 60d34f6ee..b75c0383e 100644 --- a/server/src/internal/analytics/actionUtils.ts +++ b/server/src/internal/analytics/actionUtils.ts @@ -9,17 +9,20 @@ import { } from "@autumn/shared"; import { generateId } from "@/utils/genUtils.js"; import type { ExtendedRequest } from "@/utils/models/Request.js"; +import type { AutumnContext } from "../../honoUtils/HonoEnv"; -export const parseReqForAction = ( - req: ExtendedRequest, -): Partial => { +export const parseCtxForAction = ({ + ctx, +}: { + ctx: AutumnContext; +}): Partial => { return { - id: req.id, - authType: req.authType, - originalUrl: req.originalUrl, - method: req.method, - body: req.body, - timestamp: Date.now(), + id: ctx.id, + authType: ctx.authType, + // originalUrl: ctx.originalUrl, + // method: ctx.method, + // body: ctx.body, + // timestamp: Date.now(), } as Partial; }; diff --git a/server/src/internal/analytics/handlers/handleProductsUpdated.ts b/server/src/internal/analytics/handlers/handleProductsUpdated.ts index 14531aa37..55e54d247 100644 --- a/server/src/internal/analytics/handlers/handleProductsUpdated.ts +++ b/server/src/internal/analytics/handlers/handleProductsUpdated.ts @@ -17,12 +17,11 @@ import { } from "@autumn/shared"; import { sendSvixEvent } from "@/external/svix/svixHelpers.js"; import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; -import { parseReqForAction } from "@/internal/analytics/actionUtils.js"; +import { parseCtxForAction } from "@/internal/analytics/actionUtils.js"; import { CusService } from "@/internal/customers/CusService.js"; import { FeatureService } from "@/internal/features/FeatureService.js"; import { JobName } from "@/queue/JobName.js"; import { addTaskToQueue } from "@/queue/queueUtils.js"; -import type { ExtendedRequest } from "@/utils/models/Request.js"; import { getApiCustomerBase } from "../../customers/cusUtils/apiCusUtils/getApiCustomerBase"; import { getPlanResponse } from "../../products/productUtils/productResponseUtils/getPlanResponse"; @@ -36,7 +35,7 @@ interface ActionDetails { } export const addProductsUpdatedWebhookTask = async ({ - req, + ctx, org, env, customerId, @@ -45,9 +44,8 @@ export const addProductsUpdatedWebhookTask = async ({ scheduledCusProduct, deletedCusProduct, scenario, - logger, }: { - req?: ExtendedRequest; + ctx?: AutumnContext; org: Organization; env: AppEnv; customerId: string | null; @@ -56,7 +54,6 @@ export const addProductsUpdatedWebhookTask = async ({ scheduledCusProduct?: FullCusProduct; deletedCusProduct?: FullCusProduct; scenario: string; - logger: any; }) => { // Build action @@ -64,12 +61,10 @@ export const addProductsUpdatedWebhookTask = async ({ await addTaskToQueue({ jobName: JobName.HandleProductsUpdated, payload: { - req: req ? parseReqForAction(req) : undefined, + reqCtx: ctx ? parseCtxForAction({ ctx }) : undefined, internalCustomerId, orgId: org.id, env, - // org, - // env, customerId, cusProduct, scheduledCusProduct, @@ -78,16 +73,9 @@ export const addProductsUpdatedWebhookTask = async ({ }, }); } catch (error) { - logger.error("Failed to add products updated webhook task to queue", { - error, - org_slug: org.slug, - org_id: org.id, - env, - internalCustomerId, - productId: cusProduct.product.id, - cusProductId: cusProduct.id, - // productId: product.id, - }); + ctx?.logger.error( + `Failed to add products updated webhook task to queue: ${error}`, + ); } }; @@ -97,7 +85,7 @@ export const handleProductsUpdated = async ({ }: { ctx: AutumnContext; data: { - req: Partial; + reqCtx?: Partial; actionDetails: ActionDetails; internalCustomerId: string; // org: Organization; diff --git a/server/src/internal/api/check/handlers/getProductCheckPreview.ts b/server/src/internal/api/check/handlers/getProductCheckPreview.ts index 7d3b74ee1..f37f3f51e 100644 --- a/server/src/internal/api/check/handlers/getProductCheckPreview.ts +++ b/server/src/internal/api/check/handlers/getProductCheckPreview.ts @@ -17,7 +17,6 @@ import { getProductResponse } from "@/internal/products/productUtils/productResp import { isFreeProduct, isOneOff } from "@/internal/products/productUtils.js"; import { formatAmount } from "@/utils/formatUtils.js"; import { notNullish } from "@/utils/genUtils.js"; -import type { ExtendedRequest } from "@/utils/models/Request.js"; import type { AutumnContext } from "../../../../honoUtils/HonoEnv.js"; import { getAttachScenario } from "./attachToCheckPreview/getAttachScenario.js"; @@ -136,24 +135,21 @@ export const attachToCheckPreview = async ({ }; export const getProductCheckPreview = async ({ - req, + ctx, customer, product, - logger, }: { - req: ExtendedRequest; + ctx: AutumnContext; customer: FullCustomer; product: FullProduct; - logger: any; }) => { - const { org, features, db } = req; + const { org, features, db } = ctx; // Build attach params const attachParams = await checkToAttachParams({ - req, + ctx, customer, product, - logger, }); const attachBody: AttachBodyV0 = { @@ -163,7 +159,7 @@ export const getProductCheckPreview = async ({ }; const preview = await attachParamsToPreview({ - ctx: req as AutumnContext, + ctx, attachParams, attachBody, }); diff --git a/server/src/internal/api/check/handlers/handleProductCheck.ts b/server/src/internal/api/check/handlers/handleProductCheck.ts index c964d0e4d..8a0bff66a 100644 --- a/server/src/internal/api/check/handlers/handleProductCheck.ts +++ b/server/src/internal/api/check/handlers/handleProductCheck.ts @@ -8,7 +8,6 @@ import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; import { getOrCreateCustomer } from "@/internal/customers/cusUtils/getOrCreateCustomer.js"; import { ProductService } from "@/internal/products/ProductService.js"; import { notNullish } from "@/utils/genUtils.js"; -import type { ExtendedRequest } from "../../../../utils/models/Request.js"; import { getProductCheckPreview } from "./getProductCheckPreview.js"; export const handleProductCheck = async ({ @@ -29,7 +28,7 @@ export const handleProductCheck = async ({ entity_data, } = body; - const { org, env, logger, db } = ctx; + const { org, env, db } = ctx; // 1. Get customer and org const [customer, product] = await Promise.all([ @@ -69,10 +68,9 @@ export const handleProductCheck = async ({ const preview = with_preview ? await getProductCheckPreview({ - req: ctx as ExtendedRequest, + ctx, customer, product, - logger, }) : undefined; diff --git a/server/src/internal/api/rewards/handlers/referrals/handleRedeemReferral.ts b/server/src/internal/api/rewards/handlers/referrals/handleRedeemReferral.ts index a4e817fd4..a84e8b183 100644 --- a/server/src/internal/api/rewards/handlers/referrals/handleRedeemReferral.ts +++ b/server/src/internal/api/rewards/handlers/referrals/handleRedeemReferral.ts @@ -8,7 +8,7 @@ import { RewardTriggerEvent, } from "@autumn/shared"; import { z } from "zod/v4"; -import { parseReqForAction } from "@/internal/analytics/actionUtils.js"; +import { parseCtxForAction } from "@/internal/analytics/actionUtils.js"; import { CusService } from "@/internal/customers/CusService.js"; import { RewardProgramService } from "@/internal/rewards/RewardProgramService.js"; import { RewardRedemptionService } from "@/internal/rewards/RewardRedemptionService.js"; @@ -149,7 +149,7 @@ export const handleRedeemReferral = createRoute({ const rewardCat = getRewardCat(reward); if (rewardCat === RewardCategory.FreeProduct) { await triggerFreeProduct({ - req: parseReqForAction(ctx as ExtendedRequest) as ExtendedRequest, + req: parseCtxForAction({ ctx }) as ExtendedRequest, db, referralCode, redeemer: customer, diff --git a/server/src/internal/billing/attach/handleAttachV2.ts b/server/src/internal/billing/attach/handleAttachV2.ts index 492ab5177..22f10fe21 100644 --- a/server/src/internal/billing/attach/handleAttachV2.ts +++ b/server/src/internal/billing/attach/handleAttachV2.ts @@ -1,10 +1,15 @@ +import { type AttachResponseV1, AttachResponseV1Schema } from "@autumn/shared"; import { AttachBodyV0Schema } from "../../../../../shared/api/billing/attach/prevVersions/attachBodyV0"; -import { AffectedResource } from "../../../../../shared/api/versionUtils/versionUtils"; +import { + AffectedResource, + applyResponseVersionChanges, +} from "../../../../../shared/api/versionUtils/versionUtils"; import { createRoute } from "../../../honoMiddlewares/routeHandler"; import { checkStripeConnections } from "../../customers/attach/attachRouter"; import { getAttachParams } from "../../customers/attach/attachUtils/attachParams/getAttachParams"; import { getAttachBranch } from "../../customers/attach/attachUtils/getAttachBranch"; import { getAttachConfig } from "../../customers/attach/attachUtils/getAttachConfig"; +import { runAttachFunction } from "../../customers/attach/attachUtils/getAttachFunction"; import { handleAttachErrors } from "../../customers/attach/attachUtils/handleAttachErrors"; import { insertCustomItems } from "../../customers/attach/attachUtils/insertCustomItems"; @@ -77,17 +82,38 @@ export const handleAttachV2 = createRoute({ }); } catch (_error) {} - // const response = await runAttachFunction({ - // req, - // res, - // attachParams, - // branch, - // attachBody, - // config, - // }); - - return c.json({ - message: "Hello, world!", + const response = await runAttachFunction({ + ctx, + attachParams, + branch, + attachBody, + config, }); + + const { products, customer } = attachParams; + + const responseV1 = AttachResponseV1Schema.parse({ + success: true, + product_ids: products.map((p) => p.id), + customer_id: customer.id || customer.internal_id, + ...response, + }); + + return c.json( + applyResponseVersionChanges({ + input: responseV1, + targetVersion: ctx.apiVersion, + resource: AffectedResource.Attach, + }), + ); }, }); + +// success: true, +// message: `Successfully purchased ${productNames} and attached to ${customerName}`, +// invoice: invoiceOnly +// ? attachToInvoiceResponse({ invoice: stripeInvoice }) +// : undefined, +// code: SuccessCode.OneOffProductAttached, + +// scenario: AttachScenario.New, diff --git a/server/src/internal/billing/attach/utils/getCustomerDisplay.ts b/server/src/internal/billing/attach/utils/getCustomerDisplay.ts new file mode 100644 index 000000000..0f85661cf --- /dev/null +++ b/server/src/internal/billing/attach/utils/getCustomerDisplay.ts @@ -0,0 +1,5 @@ +import type { Customer } from "@autumn/shared"; + +export const getCustomerDisplay = ({ customer }: { customer: Customer }) => { + return customer.name || customer.email || customer.id || customer.internal_id; +}; diff --git a/server/src/internal/billing/billingRouter.ts b/server/src/internal/billing/billingRouter.ts index 5e9dbd851..8f605dde0 100644 --- a/server/src/internal/billing/billingRouter.ts +++ b/server/src/internal/billing/billingRouter.ts @@ -1,5 +1,6 @@ import { Hono } from "hono"; import type { HonoEnv } from "../../honoUtils/HonoEnv.js"; +import { handleAttachV2 } from "./attach/handleAttachV2.js"; import { handleCheckoutV2 } from "./checkout/handleCheckoutV2.js"; import { handleSetupPayment } from "./handlers/handleSetupPayment.js"; @@ -7,3 +8,4 @@ export const billingRouter = new Hono(); billingRouter.post("/setup_payment", ...handleSetupPayment); billingRouter.post("/checkout", ...handleCheckoutV2); +billingRouter.post("/attach", ...handleAttachV2); diff --git a/server/src/internal/billing/checkout/handleCheckoutV2.ts b/server/src/internal/billing/checkout/handleCheckoutV2.ts index fcdd78925..09040115f 100644 --- a/server/src/internal/billing/checkout/handleCheckoutV2.ts +++ b/server/src/internal/billing/checkout/handleCheckoutV2.ts @@ -58,23 +58,21 @@ export const handleCheckoutV2 = createRoute({ if (config.invoiceCheckout) { const result = await handleCreateInvoiceCheckout({ - req: ctx as ExtendedRequest, + ctx, attachParams, - attachBody: body, - branch, config, }); - checkoutUrl = result?.invoices?.[0]?.hosted_invoice_url; + checkoutUrl = result?.checkout_url; } else { - const checkout = await handleCreateCheckout({ - req: ctx as ExtendedRequest, + const result = await handleCreateCheckout({ + ctx, attachParams, config, returnCheckout: true, }); - checkoutUrl = checkout?.url; + checkoutUrl = result?.checkout_url; } } diff --git a/server/src/internal/customers/add-product/createFullCusProduct.ts b/server/src/internal/customers/add-product/createFullCusProduct.ts index ad8dca691..c6cbaa098 100644 --- a/server/src/internal/customers/add-product/createFullCusProduct.ts +++ b/server/src/internal/customers/add-product/createFullCusProduct.ts @@ -532,7 +532,7 @@ export const createFullCusProduct = async ({ try { if (sendWebhook && !attachParams.fromMigration) { await addProductsUpdatedWebhookTask({ - req: attachParams.req, + ctx: attachParams.req, internalCustomerId: customer.internal_id, org, env: customer.env, @@ -540,7 +540,6 @@ export const createFullCusProduct = async ({ cusProduct: isDowngrade ? curCusProduct! : fullCusProduct, scheduledCusProduct: isDowngrade ? fullCusProduct : undefined, scenario, - logger, }); } } catch (_error) { diff --git a/server/src/internal/customers/add-product/createOneTimeCusProduct.ts b/server/src/internal/customers/add-product/createOneTimeCusProduct.ts index d4c321ce0..82eeeeb1e 100644 --- a/server/src/internal/customers/add-product/createOneTimeCusProduct.ts +++ b/server/src/internal/customers/add-product/createOneTimeCusProduct.ts @@ -15,6 +15,7 @@ import { addProductsUpdatedWebhookTask } from "@/internal/analytics/handlers/han import { getEntRelatedPrice } from "@/internal/products/entitlements/entitlementUtils.js"; import { getEntOptions } from "@/internal/products/prices/priceUtils.js"; import { nullish } from "@/utils/genUtils.js"; +import type { Logger } from "../../../external/logtail/logtailUtils.js"; import type { InsertCusProductParams } from "../cusProducts/AttachParams.js"; import { CusProductService } from "../cusProducts/CusProductService.js"; import { CusEntService } from "../cusProducts/cusEnts/CusEntitlementService.js"; @@ -83,7 +84,7 @@ export const updateOneTimeCusProduct = async ({ }: { db: DrizzleCli; attachParams: InsertCusProductParams; - logger: any; + logger: Logger; }) => { // 1. Sort cus products by created_at attachParams.cusProducts?.sort((a, b) => b.created_at - a.created_at); @@ -93,9 +94,18 @@ export const updateOneTimeCusProduct = async ({ (cp) => cp.product.internal_id === attachParams.product.internal_id && cp.status === CusProductStatus.Active, - )!; + ); - const existingCusEnts = existingCusProduct.customer_entitlements; + if (!existingCusProduct) { + // logger.warn("No existing cus product found", { + // data: { + // attachParams, + // }, + // }); + return; + } + + const existingCusEnts = existingCusProduct?.customer_entitlements || []; // 3. Update existing entitlements for (const entitlement of attachParams.entitlements) { @@ -169,7 +179,7 @@ export const updateOneTimeCusProduct = async ({ // Send webhook const { customer, org } = attachParams; await addProductsUpdatedWebhookTask({ - req: attachParams.req, + ctx: attachParams.req, internalCustomerId: customer.internal_id, org, env: customer.env, @@ -177,6 +187,5 @@ export const updateOneTimeCusProduct = async ({ cusProduct: existingCusProduct, scheduledCusProduct: undefined, scenario: AttachScenario.New, - logger, }); }; diff --git a/server/src/internal/customers/add-product/handleCreateCheckout.ts b/server/src/internal/customers/add-product/handleCreateCheckout.ts index 8f893eb04..abc68bf2e 100644 --- a/server/src/internal/customers/add-product/handleCreateCheckout.ts +++ b/server/src/internal/customers/add-product/handleCreateCheckout.ts @@ -1,6 +1,6 @@ import { - ApiVersion, type AttachConfig, + AttachFunctionResponseSchema, RecaseError, SuccessCode, } from "@autumn/shared"; @@ -14,25 +14,22 @@ import { freeTrialToStripeTimestamp } from "@/internal/products/free-trials/free import { getNextStartOfMonthUnix } from "@/internal/products/prices/billingIntervalUtils.js"; import { pricesContainRecurring } from "@/internal/products/prices/priceUtils.js"; import { notNullish } from "@/utils/genUtils.js"; -import { - type AttachParams, - AttachResultSchema, -} from "../cusProducts/AttachParams.js"; +import type { AutumnContext } from "../../../honoUtils/HonoEnv.js"; +import type { AttachParams } from "../cusProducts/AttachParams.js"; export const handleCreateCheckout = async ({ - req, - res, + ctx, attachParams, + // biome-ignore lint/correctness/noUnusedFunctionParameters: Might be used in the future config, returnCheckout = false, }: { - req: any; - res?: any; + ctx: AutumnContext; attachParams: AttachParams; config: AttachConfig; returnCheckout?: boolean; }) => { - const { db, logger } = req; + const { db, logger } = ctx; const { customer, org, freeTrial, successUrl, rewards } = attachParams; @@ -100,11 +97,13 @@ export const handleCreateCheckout = async ({ } : undefined; - const checkoutParams = attachParams.checkoutSessionParams || {}; + const checkoutParams = attachParams.checkoutSessionParams as + | Partial + | undefined; const allowPromotionCodes = - notNullish(checkoutParams.discounts) || notNullish(rewards) + notNullish(checkoutParams?.discounts) || notNullish(rewards) ? undefined - : checkoutParams.allow_promotion_codes || true; + : checkoutParams?.allow_promotion_codes || true; let rewardData = {}; if (rewards) { @@ -114,13 +113,13 @@ export const handleCreateCheckout = async ({ } // Prepare checkout session parameters - let checkout: Stripe.Checkout.Session; + let checkout: Stripe.Checkout.Session | undefined; const paymentMethodSet = - notNullish(checkoutParams.payment_method_types) || - notNullish(checkoutParams.payment_method_configuration); + notNullish(checkoutParams?.payment_method_types) || + notNullish(checkoutParams?.payment_method_configuration); - let sessionParams = { + let sessionParams: Stripe.Checkout.SessionCreateParams = { customer: customer.processor.id, line_items: items, subscription_data: subscriptionData, @@ -136,7 +135,7 @@ export const handleCreateCheckout = async ({ ...(attachParams.checkoutSessionParams || {}), metadata: { ...(attachParams.metadata ? attachParams.metadata : {}), - ...(attachParams.checkoutSessionParams?.metadata || {}), + ...(checkoutParams?.metadata || {}), autumn_metadata_id: metaId, }, payment_method_collection: @@ -145,7 +144,7 @@ export const handleCreateCheckout = async ({ freeTrial.card_required === false ? "if_required" : undefined, - } satisfies Stripe.Checkout.SessionCreateParams; + }; if (attachParams.setupPayment) { sessionParams = { @@ -153,9 +152,9 @@ export const handleCreateCheckout = async ({ mode: "setup", success_url: successUrl || toSuccessUrl({ org, env: customer.env }), currency: org.default_currency || "usd", - ...(checkoutParams as any), + ...checkoutParams, metadata: { - ...(attachParams.checkoutSessionParams?.metadata || {}), + ...(checkoutParams?.metadata || {}), autumn_metadata_id: metaId, }, }; @@ -166,8 +165,8 @@ export const handleCreateCheckout = async ({ logger.info( `✅ Successfully created checkout for customer ${customer.id || customer.internal_id}`, ); - } catch (error: any) { - const msg = error.message; + } catch (error) { + const msg = error instanceof Error ? error.message : undefined; if (msg?.includes("No valid payment method types") && !paymentMethodSet) { checkout = await stripeCli.checkout.sessions.create({ ...sessionParams, @@ -182,25 +181,35 @@ export const handleCreateCheckout = async ({ } } - if (returnCheckout || !res) { - return checkout; - } + const customerId = customer.id || customer.internal_id; + const productNames = attachParams.products.map((p) => p.name).join(", "); + return AttachFunctionResponseSchema.parse({ + checkout_url: checkout?.url, + message: `Successfully created checkout for customer ${customerId}, product(s) ${productNames}`, + code: SuccessCode.CheckoutCreated, - if (req.apiVersion.gte(ApiVersion.V1_1)) { - res.status(200).json( - 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, - }), - ); - } else { - res.status(200).json({ - checkout_url: checkout.url, - }); - } + checkoutSession: checkout, + }); + + // if (returnCheckout || !res) { + // return checkout; + // } + + // if (req.apiVersion.gte(ApiVersion.V1_1)) { + // res.status(200).json( + // 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, + // }), + // ); + // } else { + // res.status(200).json({ + // checkout_url: checkout.url, + // }); + // } }; diff --git a/server/src/internal/customers/add-product/handleCreateInvoiceCheckout.ts b/server/src/internal/customers/add-product/handleCreateInvoiceCheckout.ts index 685373703..303926c56 100644 --- a/server/src/internal/customers/add-product/handleCreateInvoiceCheckout.ts +++ b/server/src/internal/customers/add-product/handleCreateInvoiceCheckout.ts @@ -1,78 +1,61 @@ import { - type AttachBodyV0, - type AttachBranch, type AttachConfig, + type AttachFunctionResponse, + AttachFunctionResponseSchema, SuccessCode, } from "@autumn/shared"; -import type Stripe from "stripe"; import { createCheckoutMetadata } from "@/internal/metadata/metadataUtils.js"; import { isOneOff } from "@/internal/products/productUtils.js"; +import type { AutumnContext } from "../../../honoUtils/HonoEnv.js"; import { handleOneOffFunction } from "../attach/attachFunctions/addProductFlow/handleOneOffFunction.js"; import { handlePaidProduct } from "../attach/attachFunctions/addProductFlow/handlePaidProduct.js"; -import { handleMultiAttachFlow } from "../attach/attachFunctions/multiAttach/handleMultiAttachFlow.js"; -import { - type AttachParams, - AttachResultSchema, -} from "../cusProducts/AttachParams.js"; +import type { AttachParams } from "../cusProducts/AttachParams.js"; export const handleCreateInvoiceCheckout = async ({ - req, - res, + ctx, attachParams, - attachBody, config, - branch, }: { - req: any; - res?: any; + ctx: AutumnContext; attachParams: AttachParams; - attachBody: AttachBodyV0; config: AttachConfig; - branch: AttachBranch; -}) => { +}): Promise => { // if one off const { stripeCli } = attachParams; - let invoiceResult; + let invoiceResult: AttachFunctionResponse; - if (attachParams.productsList) { - invoiceResult = await handleMultiAttachFlow({ - req, - res, - attachParams, - attachBody, - branch, - config, - }); - } else if (isOneOff(attachParams.prices)) { + if (isOneOff(attachParams.prices)) { invoiceResult = await handleOneOffFunction({ - req, - res, + ctx, attachParams, config, }); } else { invoiceResult = await handlePaidProduct({ - req, - res, + ctx, attachParams, config, }); } - const { invoices, anchorToUnix, subs }: any = invoiceResult; + // const { invoices, anchorToUnix, subs } = invoiceResult; + const { invoice, stripeSub, anchorToUnix } = invoiceResult; + + // console.log("finalize invoice:", config.finalizeInvoice); + // console.log("invoice hosted url:", invoice?.hosted_invoice_url); const metadataId = await createCheckoutMetadata({ - db: req.db, + db: ctx.db, attachParams: { ...attachParams, anchorToUnix, - subIds: subs.map((s: Stripe.Subscription) => s.id), + subId: stripeSub?.id, config, - } as any, + }, }); - for (const invoice of invoices) { + if (invoice) { await stripeCli.invoices.update(invoice.id, { metadata: { autumn_metadata_id: metadataId, @@ -80,35 +63,62 @@ export const handleCreateInvoiceCheckout = async ({ }); } - if (res) { - if (!config.finalizeInvoice) { - res.status(200).json( - AttachResultSchema.parse({ - invoice: invoices[0], - code: SuccessCode.CheckoutCreated, - message: `Successfully created invoice 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; - } - 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, - }), - ); - } + const customerId = + attachParams.customer.id || attachParams.customer.internal_id; + const productNames = attachParams.products.map((p) => p.name).join(", "); + return AttachFunctionResponseSchema.parse({ + checkout_url: config.finalizeInvoice + ? invoice?.hosted_invoice_url + : undefined, + message: `Successfully created invoice checkout for customer ${customerId}, product(s) ${productNames}`, + code: SuccessCode.CheckoutCreated, + invoice: config.finalizeInvoice ? undefined : invoice, // if finalizeInvoice, checkout_url is used + // invoice, + // stripeSub, + // anchorToUnix, + // config, + }); - return { invoices }; + // if (res) { + // if (!config.finalizeInvoice) { + // res.status(200).json( + // AttachResultSchema.parse({ + // invoice: invoices[0], + // code: SuccessCode.CheckoutCreated, + // message: `Successfully created invoice 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; + // } + // 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 }; }; + +// if (attachParams.productsList) { +// invoiceResult = await handleMultiAttachFlow({ +// req, +// res, +// attachParams, +// attachBody, +// branch, +// config, +// }); +// } else diff --git a/server/src/internal/customers/attach/attachFunctions/addProductFlow/handleAddProduct.ts b/server/src/internal/customers/attach/attachFunctions/addProductFlow/handleAddProduct.ts index 617645234..90176f13a 100644 --- a/server/src/internal/customers/attach/attachFunctions/addProductFlow/handleAddProduct.ts +++ b/server/src/internal/customers/attach/attachFunctions/addProductFlow/handleAddProduct.ts @@ -1,37 +1,32 @@ import { - ApiVersion, AttachBranch, type AttachConfig, + AttachFunctionResponseSchema, SuccessCode, } from "@autumn/shared"; import { subToPeriodStartEnd } from "@/external/stripe/stripeSubUtils/convertSubUtils.js"; import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; import { attachToInsertParams } from "@/internal/products/productUtils.js"; -import type { ExtendedRequest } from "@/utils/models/Request.js"; import { createFullCusProduct } from "../../../add-product/createFullCusProduct.js"; -import { - type AttachParams, - AttachResultSchema, -} from "../../../cusProducts/AttachParams.js"; +import type { AttachParams } from "../../../cusProducts/AttachParams.js"; import { attachParamsToCurCusProduct } from "../../attachUtils/convertAttachParams.js"; import { getDefaultAttachConfig } from "../../attachUtils/getAttachConfig.js"; import { getMergeCusProduct } from "./getMergeCusProduct.js"; import { handlePaidProduct } from "./handlePaidProduct.js"; export const handleAddProduct = async ({ - req, - res, + ctx, attachParams, config, + // biome-ignore lint/correctness/noUnusedFunctionParameters: Might be used in the future branch, }: { - req: ExtendedRequest; - res?: any; + ctx: AutumnContext; attachParams: AttachParams; config?: AttachConfig; branch?: AttachBranch; }) => { - const { logger } = req; + const { logger, db } = ctx; const { customer, products, prices } = attachParams; const defaultConfig: AttachConfig = getDefaultAttachConfig(); @@ -39,14 +34,11 @@ export const handleAddProduct = async ({ // 1. If paid product if (prices.length > 0) { - await handlePaidProduct({ - req, - res, + return await handlePaidProduct({ + ctx, attachParams, config: config || defaultConfig, }); - - return; } logger.info("Inserting free product in handleAddProduct"); @@ -61,7 +53,7 @@ export const handleAddProduct = async ({ for (const product of products) { const curCusProduct = attachParamsToCurCusProduct({ attachParams }); - let anchorToUnix; + let anchorToUnix: number | undefined; if (curCusProduct && config?.branch === AttachBranch.NewVersion) { anchorToUnix = curCusProduct.created_at; @@ -76,7 +68,7 @@ export const handleAddProduct = async ({ batchInsert.push( createFullCusProduct({ - db: req.db, + db, attachParams: attachToInsertParams(attachParams, product), billLaterOnly: true, carryExistingUsages: config?.carryUsage || false, @@ -89,25 +81,30 @@ export const handleAddProduct = async ({ logger.info("Successfully created full cus product"); - if (res) { - const productNames = products.map((p) => p.name).join(", "); - const customerName = customer.name || customer.email || customer.id; - if (req.apiVersion.gte(ApiVersion.V1_1)) { - res.status(200).json( - AttachResultSchema.parse({ - success: true, - code: SuccessCode.FreeProductAttached, - message: `Successfully attached ${productNames} to ${customerName}`, - product_ids: products.map((p) => p.id), - customer_id: customer.id || customer.internal_id, - }), - ); - } else { - res.status(200).json({ - success: true, - }); - } - } + return AttachFunctionResponseSchema.parse({ + message: `Successfully attached ${products.map((p) => p.name).join(", ")} to ${customer.name}`, + code: SuccessCode.FreeProductAttached, + }); + + // if (res) { + // const productNames = products.map((p) => p.name).join(", "); + // const customerName = customer.name || customer.email || customer.id; + // if (req.apiVersion.gte(ApiVersion.V1_1)) { + // res.status(200).json( + // AttachResultSchema.parse({ + // success: true, + // code: SuccessCode.FreeProductAttached, + // message: `Successfully attached ${productNames} to ${customerName}`, + // product_ids: products.map((p) => p.id), + // customer_id: customer.id || customer.internal_id, + // }), + // ); + // } else { + // res.status(200).json({ + // success: true, + // }); + // } + // } }; export const handleFreeProduct = async ({ @@ -142,7 +139,7 @@ export const handleFreeProduct = async ({ for (const product of products) { const curCusProduct = attachParamsToCurCusProduct({ attachParams }); - let anchorToUnix; + let anchorToUnix: number | undefined; if (curCusProduct && config?.branch === AttachBranch.NewVersion) { anchorToUnix = curCusProduct.created_at; diff --git a/server/src/internal/customers/attach/attachFunctions/addProductFlow/handleOneOffFunction.ts b/server/src/internal/customers/attach/attachFunctions/addProductFlow/handleOneOffFunction.ts index b34cd10be..1bb0162d4 100644 --- a/server/src/internal/customers/attach/attachFunctions/addProductFlow/handleOneOffFunction.ts +++ b/server/src/internal/customers/attach/attachFunctions/addProductFlow/handleOneOffFunction.ts @@ -1,6 +1,7 @@ import { type AttachConfig, - AttachScenario, + type AttachFunctionResponse, + AttachFunctionResponseSchema, SuccessCode, type UsagePriceConfig, } from "@autumn/shared"; @@ -8,10 +9,7 @@ import { Decimal } from "decimal.js"; import { payForInvoice } from "@/external/stripe/stripeInvoiceUtils.js"; import { createFullCusProduct } from "@/internal/customers/add-product/createFullCusProduct.js"; import { handleCreateCheckout } from "@/internal/customers/add-product/handleCreateCheckout.js"; -import { - type AttachParams, - AttachResultSchema, -} from "@/internal/customers/cusProducts/AttachParams.js"; +import type { AttachParams } from "@/internal/customers/cusProducts/AttachParams.js"; import { newPriceToInvoiceDescription } from "@/internal/invoices/invoiceFormatUtils.js"; import { buildInvoiceMemoFromEntitlements } from "@/internal/invoices/invoiceMemoUtils.js"; import { @@ -24,19 +22,20 @@ import { priceToInvoiceAmount } from "@/internal/products/prices/priceUtils/pric import { isFixedPrice } from "@/internal/products/prices/priceUtils/usagePriceUtils/classifyUsagePrice.js"; import { getPriceOptions } from "@/internal/products/prices/priceUtils.js"; import { attachToInsertParams } from "@/internal/products/productUtils.js"; +import type { AutumnContext } from "../../../../../honoUtils/HonoEnv"; +import { getCustomerDisplay } from "../../../../billing/attach/utils/getCustomerDisplay"; export const handleOneOffFunction = async ({ - req, + ctx, attachParams, config, - res, }: { - req: any; + ctx: AutumnContext; attachParams: AttachParams; config: AttachConfig; - res: any; -}) => { - const logger = req.logger; +}): Promise => { + const { logger } = ctx; + logger.info("Scenario 4A: One-off prices"); const { @@ -101,7 +100,7 @@ export const handleOneOffFunction = async ({ price_data: { unit_amount: new Decimal(amount).mul(100).round().toNumber(), currency: orgToCurrency({ org }), - product: price.config?.stripe_product_id || product?.processor?.id!, + product: price.config?.stripe_product_id || product?.processor?.id, }, }; } @@ -149,7 +148,7 @@ export const handleOneOffFunction = async ({ ...invoiceItem, customer: customer.processor.id!, invoice: stripeInvoice.id, - } as any); + }); } if (config.invoiceCheckout) { @@ -160,13 +159,15 @@ export const handleOneOffFunction = async ({ } await insertInvoiceFromAttach({ - db: req.db, + db: ctx.db, attachParams, invoiceId: stripeInvoice.id, logger, }); - return { invoices: [stripeInvoice], subs: [], anchorToUnix: undefined }; + return AttachFunctionResponseSchema.parse({ + invoice: stripeInvoice, + }); } // Create invoice items @@ -186,8 +187,7 @@ export const handleOneOffFunction = async ({ if (!paid) { if (org.config.checkout_on_failed_payment) { return await handleCreateCheckout({ - req, - res, + ctx, attachParams, config, }); @@ -201,7 +201,7 @@ export const handleOneOffFunction = async ({ for (const product of products) { batchInsert.push( createFullCusProduct({ - db: req.db, + db: ctx.db, attachParams: attachToInsertParams(attachParams, product), logger, }), @@ -211,27 +211,38 @@ export const handleOneOffFunction = async ({ logger.info("5. Creating invoice from stripe"); await insertInvoiceFromAttach({ - db: req.db, + db: ctx.db, attachParams, invoiceId: stripeInvoice.id, logger, }); - if (res) { - const productNames = products.map((p) => p.name).join(", "); - const customerName = customer.name || customer.email || customer.id; - res.status(200).json( - AttachResultSchema.parse({ - success: true, - message: `Successfully purchased ${productNames} and attached to ${customerName}`, - invoice: invoiceOnly - ? attachToInvoiceResponse({ invoice: stripeInvoice }) - : undefined, - code: SuccessCode.OneOffProductAttached, - product_ids: products.map((p) => p.id), - customer_id: customer.id || customer.internal_id, - scenario: AttachScenario.New, - }), - ); - } + const customerName = getCustomerDisplay({ customer }); + const productNames = products.map((p) => p.name).join(", "); + return AttachFunctionResponseSchema.parse({ + // success: true, + message: `Successfully purchased product(s) ${productNames} and attached to customer ${customerName}`, + invoice: invoiceOnly + ? attachToInvoiceResponse({ invoice: stripeInvoice }) + : undefined, + code: SuccessCode.OneOffProductAttached, + // product_ids: products.map((p) => p.id), + // customer_id: customer.id || customer.internal_id, + // scenario: AttachScenario.New, + }); + // if (res) { + // res.status(200).json( + // AttachResultSchema.parse({ + // success: true, + // message: `Successfully purchased ${productNames} and attached to ${customerName}`, + // invoice: invoiceOnly + // ? attachToInvoiceResponse({ invoice: stripeInvoice }) + // : undefined, + // code: SuccessCode.OneOffProductAttached, + // product_ids: products.map((p) => p.id), + // customer_id: customer.id || customer.internal_id, + // scenario: AttachScenario.New, + // }), + // ); + // } }; diff --git a/server/src/internal/customers/attach/attachFunctions/addProductFlow/handlePaidProduct.ts b/server/src/internal/customers/attach/attachFunctions/addProductFlow/handlePaidProduct.ts index 87f6a8288..28c9dcc7b 100644 --- a/server/src/internal/customers/attach/attachFunctions/addProductFlow/handlePaidProduct.ts +++ b/server/src/internal/customers/attach/attachFunctions/addProductFlow/handlePaidProduct.ts @@ -1,6 +1,7 @@ import { - ApiVersion, type AttachConfig, + type AttachFunctionResponse, + AttachFunctionResponseSchema, AttachScenario, ErrCode, isTrialing, @@ -12,20 +13,15 @@ import { getStripeSubItems2 } from "@/external/stripe/stripeSubUtils/getStripeSu import { subIsCanceled } from "@/external/stripe/stripeSubUtils.js"; import { createFullCusProduct } from "@/internal/customers/add-product/createFullCusProduct.js"; import { handleCreateCheckout } from "@/internal/customers/add-product/handleCreateCheckout.js"; -import { - type AttachParams, - AttachResultSchema, -} from "@/internal/customers/cusProducts/AttachParams.js"; -import { - attachToInvoiceResponse, - insertInvoiceFromAttach, -} from "@/internal/invoices/invoiceUtils.js"; +import { type AttachParams } from "@/internal/customers/cusProducts/AttachParams.js"; +import { insertInvoiceFromAttach } from "@/internal/invoices/invoiceUtils.js"; import { getNextStartOfMonthUnix } from "@/internal/products/prices/billingIntervalUtils.js"; import { addIntervalToAnchor } from "@/internal/products/prices/billingIntervalUtils2.js"; import { getSmallestInterval } from "@/internal/products/prices/priceUtils/priceIntervalUtils.js"; import { attachToInsertParams } from "@/internal/products/productUtils.js"; import RecaseError from "@/utils/errorUtils.js"; -import type { ExtendedRequest } from "@/utils/models/Request.js"; +import type { AutumnContext } from "../../../../../honoUtils/HonoEnv.js"; +import { getCustomerDisplay } from "../../../../billing/attach/utils/getCustomerDisplay.js"; import { getCustomerSchedule, getCustomerSub, @@ -37,17 +33,15 @@ import { updateStripeSub2 } from "../upgradeFlow/updateStripeSub2.js"; import { createStripeSub2 } from "./createStripeSub2.js"; export const handlePaidProduct = async ({ - req, - res, + ctx, attachParams, config, }: { - req: ExtendedRequest; - res: any; + ctx: AutumnContext; attachParams: AttachParams; config: AttachConfig; -}) => { - const logger = req.logger; +}): Promise => { + const { logger, db } = ctx; const { org, @@ -77,7 +71,7 @@ export const handlePaidProduct = async ({ let sub: Stripe.Subscription | null = null; let schedule: Stripe.SubscriptionSchedule | null | undefined = null; let invoice: Stripe.Invoice | undefined; - let trialEndsAt; + let trialEndsAt: number | null | undefined; // 1. If merge sub @@ -93,14 +87,14 @@ export const handlePaidProduct = async ({ attachParams.freeTrial = null; // 1. If merged sub is canceled, also add to current schedule const newItemSet = await paramsToSubItems({ - req, + ctx, sub: mergeSub, attachParams, config, }); const { updatedSub, latestInvoice } = await updateStripeSub2({ - req, + ctx, attachParams, curSub: mergeSub, itemSet: newItemSet, @@ -112,7 +106,7 @@ export const handlePaidProduct = async ({ if (latestInvoice) { invoice = await insertInvoiceFromAttach({ - db: req.db, + db, stripeInvoice: latestInvoice, attachParams, logger, @@ -121,7 +115,7 @@ export const handlePaidProduct = async ({ if (subIsCanceled({ sub: mergeSub })) { logger.info("ADD PRODUCT FLOW, CREATING NEW SCHEDULE"); schedule = await subToNewSchedule({ - req, + ctx, sub: mergeSub, attachParams, config, @@ -138,8 +132,7 @@ export const handlePaidProduct = async ({ logger.info(`ADD PRODUCT FLOW, SCHEDULE ID: ${schedule?.id}`); if (schedule) { await handleUpgradeFlowSchedule({ - req, - logger, + ctx, attachParams, config, schedule, @@ -150,7 +143,7 @@ export const handlePaidProduct = async ({ } } } else { - let billingCycleAnchorUnix; + let billingCycleAnchorUnix: number | undefined; const smallestInterval = getSmallestInterval({ prices: attachParams.prices, }); @@ -180,7 +173,7 @@ export const handlePaidProduct = async ({ // console.log("Item set: ", itemSet); try { sub = await createStripeSub2({ - db: req.db, + db: ctx.db, stripeCli, attachParams, itemSet, @@ -191,21 +184,20 @@ export const handlePaidProduct = async ({ if (sub?.latest_invoice) { invoice = await insertInvoiceFromAttach({ - db: req.db, + db: ctx.db, stripeInvoice: sub.latest_invoice as Stripe.Invoice, attachParams, logger, }); } - } catch (error: any) { + } catch (error) { if ( error instanceof RecaseError && !invoiceOnly && error.code === ErrCode.CreateStripeSubscriptionFailed ) { return await handleCreateCheckout({ - req, - res, + ctx, attachParams, config, }); @@ -220,12 +212,18 @@ export const handlePaidProduct = async ({ const anchorToUnix = getEarliestPeriodEnd({ sub }) * 1000; if (config.invoiceCheckout) { - return { - invoices: subscriptions.map((s) => s.latest_invoice as Stripe.Invoice), - subs: subscriptions, + return AttachFunctionResponseSchema.parse({ + invoice: subscriptions?.[0]?.latest_invoice as Stripe.Invoice, + stripeSub: subscriptions?.[0], anchorToUnix, config, - }; + }); + // return { + // invoices: subscriptions.map((s) => s.latest_invoice as Stripe.Invoice), + // subs: subscriptions, + // anchorToUnix, + // config, + // }; } // Add product and entitlements to customer @@ -234,7 +232,7 @@ export const handlePaidProduct = async ({ for (const product of products) { batchInsert.push( createFullCusProduct({ - db: req.db, + db: ctx.db, attachParams: attachToInsertParams(attachParams, product), subscriptionIds: subscriptions.map((s) => s.id), subscriptionScheduleIds: schedule ? [schedule.id] : undefined, @@ -248,29 +246,39 @@ export const handlePaidProduct = async ({ } await Promise.all(batchInsert); - if (res) { - const productNames = products.map((p) => p.name).join(", "); - const customerName = customer.name || customer.email || customer.id; - if (req.apiVersion.gte(ApiVersion.V1_1)) { - res.status(200).json( - AttachResultSchema.parse({ - message: `Successfully created subscriptions and attached ${productNames} to ${customerName}`, - code: SuccessCode.NewProductAttached, - product_ids: products.map((p) => p.id), - customer_id: customer.id || customer.internal_id, - invoice: invoiceOnly - ? attachToInvoiceResponse({ invoice }) - : undefined, - }), - ); - } else { - res.status(200).json({ - success: true, - message: `Successfully created subscriptions and attached ${products - .map((p) => p.name) - .join(", ")} to ${customer.name}`, - invoice: invoiceOnly ? invoice : undefined, - }); - } - } + const productNames = products.map((p) => p.name).join(", "); + const customerName = getCustomerDisplay({ customer }); + return AttachFunctionResponseSchema.parse({ + message: `Successfully created subscriptions and attached product(s) ${productNames} to customer ${customerName}`, + code: SuccessCode.NewProductAttached, + product_ids: products.map((p) => p.id), + customer_id: customer.id || customer.internal_id, + invoice: invoiceOnly ? invoice : undefined, + }); + + // if (res) { + // const productNames = products.map((p) => p.name).join(", "); + // const customerName = customer.name || customer.email || customer.id; + // if (req.apiVersion.gte(ApiVersion.V1_1)) { + // res.status(200).json( + // AttachResultSchema.parse({ + // message: `Successfully created subscriptions and attached ${productNames} to ${customerName}`, + // code: SuccessCode.NewProductAttached, + // product_ids: products.map((p) => p.id), + // customer_id: customer.id || customer.internal_id, + // invoice: invoiceOnly + // ? attachToInvoiceResponse({ invoice }) + // : undefined, + // }), + // ); + // } else { + // res.status(200).json({ + // success: true, + // message: `Successfully created subscriptions and attached ${products + // .map((p) => p.name) + // .join(", ")} to ${customer.name}`, + // invoice: invoiceOnly ? invoice : undefined, + // }); + // } + // } }; diff --git a/server/src/internal/customers/attach/attachFunctions/handleRenewProduct.ts b/server/src/internal/customers/attach/attachFunctions/handleRenewProduct.ts index 3f1c69cfa..c134cbca1 100644 --- a/server/src/internal/customers/attach/attachFunctions/handleRenewProduct.ts +++ b/server/src/internal/customers/attach/attachFunctions/handleRenewProduct.ts @@ -1,5 +1,6 @@ import { type AttachConfig, + AttachFunctionResponseSchema, AttachScenario, ErrCode, SuccessCode, @@ -9,12 +10,10 @@ import type Stripe from "stripe"; import { getLatestPeriodEnd } from "@/external/stripe/stripeSubUtils/convertSubUtils.js"; import { subItemInCusProduct } from "@/external/stripe/stripeSubUtils/stripeSubItemUtils.js"; import { addProductsUpdatedWebhookTask } from "@/internal/analytics/handlers/handleProductsUpdated.js"; -import { - type AttachParams, - AttachResultSchema, -} from "@/internal/customers/cusProducts/AttachParams.js"; +import type { AttachParams } from "@/internal/customers/cusProducts/AttachParams.js"; import { CusProductService } from "@/internal/customers/cusProducts/CusProductService.js"; import RecaseError from "@/utils/errorUtils.js"; +import type { AutumnContext } from "../../../../honoUtils/HonoEnv.js"; import { addSubIdToCache } from "../../cusCache/subCacheUtils.js"; import { cusProductToSchedule, @@ -31,17 +30,15 @@ import { subToNewSchedule } from "../mergeUtils/subToNewSchedule.js"; import { updateCurSchedule } from "../mergeUtils/updateCurSchedule.js"; export const handleRenewProduct = async ({ - req, - res, + ctx, attachParams, config, }: { - req: any; - res: any; + ctx: AutumnContext; attachParams: AttachParams; config: AttachConfig; }) => { - const logger = req.logger; + const { logger, db } = ctx; const { stripeCli } = attachParams; let { curScheduledProduct } = attachParamToCusProducts({ attachParams }); @@ -92,7 +89,7 @@ export const handleRenewProduct = async ({ await stripeCli.subscriptionSchedules.release(schedule.id); await CusProductService.updateByStripeScheduledId({ - db: req.db, + db, stripeScheduledId: schedule.id, updates: { scheduled_ids: [], @@ -114,7 +111,7 @@ export const handleRenewProduct = async ({ } await CusProductService.update({ - db: req.db, + db, cusProductId: curCusProduct.id, updates: { canceled: false, @@ -132,7 +129,7 @@ export const handleRenewProduct = async ({ `RENEW FLOW: adding cur cus product back to schedule ${schedule.id}`, ); const newItems = await paramsToScheduleItems({ - req, + ctx, attachParams, config, schedule, @@ -146,7 +143,7 @@ export const handleRenewProduct = async ({ })) as Stripe.Subscription; await updateCurSchedule({ - req, + ctx, attachParams, schedule, newPhases: newItems.phases, @@ -154,7 +151,7 @@ export const handleRenewProduct = async ({ }); await CusProductService.update({ - db: req.db, + db, cusProductId: curCusProduct.id, updates: { scheduled_ids: [schedule.id], @@ -170,7 +167,7 @@ export const handleRenewProduct = async ({ await stripeCli.subscriptionSchedules.release(schedule.id); await CusProductService.updateByStripeScheduledId({ - db: req.db, + db, stripeScheduledId: schedule.id, updates: { scheduled_ids: [], @@ -178,7 +175,7 @@ export const handleRenewProduct = async ({ }); await CusProductService.update({ - db: req.db, + db, cusProductId: curCusProduct.id, updates: { canceled: false, @@ -199,7 +196,7 @@ export const handleRenewProduct = async ({ const periodEnd = getLatestPeriodEnd({ sub: curSub }); await subToNewSchedule({ - req, + ctx, sub: curSub, attachParams, config, @@ -207,7 +204,7 @@ export const handleRenewProduct = async ({ }); await CusProductService.update({ - db: req.db, + db, cusProductId: curCusProduct.id, updates: { canceled: false, @@ -221,7 +218,7 @@ export const handleRenewProduct = async ({ if (curCusProduct) { try { await addProductsUpdatedWebhookTask({ - req, + ctx, internalCustomerId: curCusProduct.internal_customer_id, org: attachParams.org, env: attachParams.customer.env, @@ -229,29 +226,32 @@ export const handleRenewProduct = async ({ attachParams.customer.id || attachParams.customer.internal_id, scenario: AttachScenario.Renew, cusProduct: curCusProduct, - logger, }); } catch (error) { - logger.error("RENEW FLOW: failed to add to webhook queue", { error }); + logger.error(`RENEW FLOW: failed to add to webhook queue: ${error}`); } } if (curScheduledProduct) { await CusProductService.delete({ - db: req.db, + db, cusProductId: curScheduledProduct.id, }); } - if (res) { - res.status(200).json( - AttachResultSchema.parse({ - code: SuccessCode.RenewedProduct, - message: `Successfully renewed product ${product.name}`, - product_ids: [product.id], - customer_id: - attachParams.customer.id || attachParams.customer.internal_id, - }), - ); - } + return AttachFunctionResponseSchema.parse({ + code: SuccessCode.RenewedProduct, + message: `Successfully renewed product ${product.name}`, + }); + // if (res) { + // res.status(200).json( + // AttachResultSchema.parse({ + // code: SuccessCode.RenewedProduct, + // message: `Successfully renewed product ${product.name}`, + // product_ids: [product.id], + // customer_id: + // attachParams.customer.id || attachParams.customer.internal_id, + // }), + // ); + // } }; diff --git a/server/src/internal/customers/attach/attachFunctions/invoiceCheckoutPaid/handleInvoiceCheckoutPaid.ts b/server/src/internal/customers/attach/attachFunctions/invoiceCheckoutPaid/handleInvoiceCheckoutPaid.ts index 8121109ef..bda91760c 100644 --- a/server/src/internal/customers/attach/attachFunctions/invoiceCheckoutPaid/handleInvoiceCheckoutPaid.ts +++ b/server/src/internal/customers/attach/attachFunctions/invoiceCheckoutPaid/handleInvoiceCheckoutPaid.ts @@ -24,15 +24,19 @@ export const handleInvoiceCheckoutPaid = async ({ invoice: Stripe.Invoice; }) => { const { logger } = req; - const metadataId = invoice.metadata?.autumn_metadata_id!; + const metadataId = invoice.metadata?.autumn_metadata_id; + + if (!metadataId) return; const metadata = await MetadataService.get({ db, id: metadataId, }); - const { subIds, anchorToUnix, config, ...rest } = metadata?.data ?? {}; - const attachParams = rest as AttachParams; + const { subId, anchorToUnix, config, ...rest }: AttachParams = + metadata?.data ?? {}; + + const attachParams = rest; if (!attachParams) return; @@ -41,55 +45,57 @@ export const handleInvoiceCheckoutPaid = async ({ if (!reqMatch) return; - if (attachParams.productsList) { - console.log("Inserting products list"); - for (const productOptions of attachParams.productsList) { - const product = attachParams.products.find( - (p) => p.id === productOptions.product_id, - ); + // if (attachParams.productsList) { + // console.log("Inserting products list"); + // for (const productOptions of attachParams.productsList) { + // const product = attachParams.products.find( + // (p) => p.id === productOptions.product_id, + // ); - if (!product) { - logger.error( - `checkout.completed: product not found for productOptions: ${JSON.stringify( - productOptions, - )}`, - ); - continue; - } + // if (!product) { + // logger.error( + // `checkout.completed: product not found for productOptions: ${JSON.stringify( + // productOptions, + // )}`, + // ); + // continue; + // } - await createFullCusProduct({ + // await createFullCusProduct({ + // db, + // attachParams: attachToInsertParams( + // attachParams, + // product, + // productOptions.entity_id || undefined, + // ), + // subscriptionIds: subIds, + // anchorToUnix, + // scenario: AttachScenario.New, + // logger, + // productOptions, + // }); + // } + // } else { + + // } + + const batchInsert = []; + for (const product of attachParams.products) { + batchInsert.push( + createFullCusProduct({ db, - attachParams: attachToInsertParams( - attachParams, - product, - productOptions.entity_id || undefined, - ), - subscriptionIds: subIds, + attachParams: attachToInsertParams(attachParams, product), + subscriptionIds: subId ? [subId] : undefined, anchorToUnix, + carryExistingUsages: config?.carryUsage, scenario: AttachScenario.New, - logger, - productOptions, - }); - } - } else { - 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); + 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/multiAttach/handleMultiAttachFlow.ts b/server/src/internal/customers/attach/attachFunctions/multiAttach/handleMultiAttachFlow.ts index a146eb925..070a44226 100644 --- a/server/src/internal/customers/attach/attachFunctions/multiAttach/handleMultiAttachFlow.ts +++ b/server/src/internal/customers/attach/attachFunctions/multiAttach/handleMultiAttachFlow.ts @@ -2,6 +2,7 @@ import { type AttachBodyV0, type AttachBranch, type AttachConfig, + AttachFunctionResponseSchema, AttachScenario, CusProductStatus, isTrialing, @@ -10,10 +11,7 @@ import { import type Stripe from "stripe"; import { getLatestPeriodEnd } from "@/external/stripe/stripeSubUtils/convertSubUtils.js"; import { createFullCusProduct } from "@/internal/customers/add-product/createFullCusProduct.js"; -import { - type AttachParams, - AttachResultSchema, -} from "@/internal/customers/cusProducts/AttachParams.js"; +import type { AttachParams } from "@/internal/customers/cusProducts/AttachParams.js"; import { CusProductService } from "@/internal/customers/cusProducts/CusProductService.js"; import { @@ -21,16 +19,12 @@ import { insertInvoiceFromAttach, } from "@/internal/invoices/invoiceUtils.js"; import { attachToInsertParams } from "@/internal/products/productUtils.js"; -import type { - ExtendedRequest, - ExtendedResponse, -} from "@/utils/models/Request.js"; +import type { AutumnContext } from "../../../../../honoUtils/HonoEnv.js"; import { getCustomerSub, paramsToCurSubSchedule, } from "../../attachUtils/convertAttachParams.js"; import { handleMultiAttachErrors } from "../../attachUtils/handleAttachErrors/handleMultiAttachErrors.js"; - import { paramsToSubItems } from "../../mergeUtils/paramsToSubItems.js"; import { createStripeSub2 } from "../addProductFlow/createStripeSub2.js"; import { handleUpgradeFlowSchedule } from "../upgradeFlow/handleUpgradeFlowSchedule.js"; @@ -41,22 +35,20 @@ import { } from "./getAddAndRemoveProducts.js"; export const handleMultiAttachFlow = async ({ - req, - res, + ctx, attachParams, attachBody, branch, config, }: { - req: ExtendedRequest; - res: ExtendedResponse; + ctx: AutumnContext; attachParams: AttachParams; attachBody: AttachBodyV0; branch: AttachBranch; config: AttachConfig; }) => { await handleMultiAttachErrors({ attachParams, attachBody, branch }); - const { db, logger } = req; + const { db, logger } = ctx; const { stripeCli } = attachParams; const productsList = attachParams.productsList!; @@ -72,7 +64,7 @@ export const handleMultiAttachFlow = async ({ }); const mergedItemSet = await paramsToSubItems({ - req, + ctx, attachParams, config, removeCusProducts, @@ -109,7 +101,7 @@ export const handleMultiAttachFlow = async ({ config.disableTrial = true; const updateResult = await updateStripeSub2({ - req, + ctx, attachParams, config, curSub: curSub!, @@ -122,13 +114,12 @@ export const handleMultiAttachFlow = async ({ const schedule = await paramsToCurSubSchedule({ attachParams }); if (schedule) { await handleUpgradeFlowSchedule({ - req, + ctx, attachParams, config, schedule, curSub, removeCusProducts, - logger, }); } @@ -166,7 +157,7 @@ export const handleMultiAttachFlow = async ({ } // Expire all existing cus products at the customer level - const batchInsert: any[] = []; + const batchInsert: unknown[] = []; const newProdList = getProdListWithoutEntities({ attachParams, productsList, @@ -190,35 +181,42 @@ export const handleMultiAttachFlow = async ({ product, productOptions.entity_id || undefined, ), - subscriptionIds: curSub ? [curSub?.id!] : undefined, + subscriptionIds: curSub ? [curSub.id] : undefined, anchorToUnix, scenario: AttachScenario.New, logger, productOptions, trialEndsAt: mergeCusProduct && isTrialing({ cusProduct: mergeCusProduct }) - ? mergeCusProduct?.trial_ends_at! + ? mergeCusProduct?.trial_ends_at || undefined : undefined, }), ); } console.log("Running multi attach flow!"); - if (res) { - const invoice = latestInvoice; - res.status(200).json( - AttachResultSchema.parse( - AttachResultSchema.parse({ - message: `Successfully created subscriptions and attached ${attachParams.products.map((p) => p.name).join(", ")} to ${attachParams.customer.name}`, - code: SuccessCode.NewProductAttached, - product_ids: attachParams.products.map((p) => p.id), - customer_id: - attachParams.customer.id || attachParams.customer.internal_id, - invoice: attachParams.invoiceOnly - ? attachToInvoiceResponse({ invoice }) - : undefined, - }), - ), - ); - } + return AttachFunctionResponseSchema.parse({ + message: `Successfully created subscriptions and attached ${attachParams.products.map((p) => p.name).join(", ")} to ${attachParams.customer.name}`, + code: SuccessCode.NewProductAttached, + invoice: attachParams.invoiceOnly + ? attachToInvoiceResponse({ invoice: latestInvoice }) + : undefined, + }); + // if (res) { + // const invoice = latestInvoice; + // res.status(200).json( + // AttachResultSchema.parse( + // AttachResultSchema.parse({ + // message: `Successfully created subscriptions and attached ${attachParams.products.map((p) => p.name).join(", ")} to ${attachParams.customer.name}`, + // code: SuccessCode.NewProductAttached, + // product_ids: attachParams.products.map((p) => p.id), + // customer_id: + // attachParams.customer.id || attachParams.customer.internal_id, + // invoice: attachParams.invoiceOnly + // ? attachToInvoiceResponse({ invoice }) + // : undefined, + // }), + // ), + // ); + // } }; diff --git a/server/src/internal/customers/attach/attachFunctions/scheduleFlow/handleScheduleFlow2.ts b/server/src/internal/customers/attach/attachFunctions/scheduleFlow/handleScheduleFlow2.ts index d27121c05..1fce19454 100644 --- a/server/src/internal/customers/attach/attachFunctions/scheduleFlow/handleScheduleFlow2.ts +++ b/server/src/internal/customers/attach/attachFunctions/scheduleFlow/handleScheduleFlow2.ts @@ -1,6 +1,6 @@ import { - ApiVersion, type AttachConfig, + AttachFunctionResponseSchema, AttachScenario, InternalError, SuccessCode, @@ -9,15 +9,13 @@ import { getLatestPeriodEnd } from "@/external/stripe/stripeSubUtils/convertSubU import { subItemInCusProduct } from "@/external/stripe/stripeSubUtils/stripeSubItemUtils.js"; import { addProductsUpdatedWebhookTask } from "@/internal/analytics/handlers/handleProductsUpdated.js"; import { createFullCusProduct } from "@/internal/customers/add-product/createFullCusProduct.js"; -import { - type AttachParams, - AttachResultSchema, -} from "@/internal/customers/cusProducts/AttachParams.js"; +import { type AttachParams } from "@/internal/customers/cusProducts/AttachParams.js"; import { CusProductService } from "@/internal/customers/cusProducts/CusProductService.js"; import { attachToInsertParams, isFreeProduct, } from "@/internal/products/productUtils.js"; +import type { AutumnContext } from "../../../../../honoUtils/HonoEnv.js"; import { attachParamsToCurCusProduct, getCustomerSchedule, @@ -29,19 +27,17 @@ import { subToNewSchedule } from "../../mergeUtils/subToNewSchedule.js"; import { updateCurSchedule } from "../../mergeUtils/updateCurSchedule.js"; export const handleScheduleFunction2 = async ({ - req, - res, + ctx, attachParams, config, skipInsertCusProduct = false, }: { - req: any; - res: any; + ctx: AutumnContext; attachParams: AttachParams; config: AttachConfig; skipInsertCusProduct?: boolean; }) => { - const logger = req.logger; + const { logger, db } = ctx; const product = attachParams.products[0]; const { stripeCli } = attachParams; @@ -87,7 +83,7 @@ export const handleScheduleFunction2 = async ({ if (schedule) { const newItems = await paramsToScheduleItems({ - req, + ctx, schedule: schedule, attachParams, config, @@ -105,13 +101,13 @@ export const handleScheduleFunction2 = async ({ ); await stripeCli.subscriptionSchedules.release(schedule.id); await CusProductService.updateByStripeScheduledId({ - db: req.db, + db, stripeScheduledId: schedule.id, updates: { scheduled_ids: [] }, }); await CusProductService.update({ - db: req.db, + db, cusProductId: curCusProduct.id, updates: { canceled: true, @@ -123,7 +119,7 @@ export const handleScheduleFunction2 = async ({ } else { logger.info(`SCHEDULE FLOW: updating schedule ${schedule?.id}`); schedule = await updateCurSchedule({ - req, + ctx, attachParams, schedule, newPhases: newItems.phases || [], @@ -131,7 +127,7 @@ export const handleScheduleFunction2 = async ({ }); await CusProductService.update({ - db: req.db, + db, cusProductId: curCusProduct.id, updates: { scheduled_ids: [schedule.id], @@ -144,7 +140,7 @@ export const handleScheduleFunction2 = async ({ } else { logger.info(`SCHEDULE FLOW: no schedule, creating new schedule`); schedule = await subToNewSchedule({ - req, + ctx, sub: curSub, attachParams, config, @@ -152,7 +148,7 @@ export const handleScheduleFunction2 = async ({ }); await CusProductService.update({ - db: req.db, + db, cusProductId: curCusProduct.id, updates: { canceled: true, @@ -174,7 +170,7 @@ export const handleScheduleFunction2 = async ({ if (!skipInsertCusProduct) { await createFullCusProduct({ - db: req.db, + db, attachParams: attachToInsertParams(attachParams, product), startsAt: expectedEnd * 1000, subscriptionScheduleIds: schedule ? [schedule.id] : [], @@ -192,7 +188,7 @@ export const handleScheduleFunction2 = async ({ if (curCusProduct) { try { await addProductsUpdatedWebhookTask({ - req, + ctx, internalCustomerId: curCusProduct.internal_customer_id, org: attachParams.org, env: attachParams.customer.env, @@ -204,28 +200,32 @@ export const handleScheduleFunction2 = async ({ : AttachScenario.Downgrade, cusProduct: curCusProduct, - logger, }); } catch (error) { logger.error("SCHEDULE FLOW: failed to add to webhook queue", { error }); } } - if (res) { - if (req.apiVersion.gte(ApiVersion.V1_1)) { - res.status(200).json( - AttachResultSchema.parse({ - code: SuccessCode.DowngradeScheduled, - message: `Successfully downgraded from ${curCusProduct.product.name} to ${product.name}`, - product_ids: [product.id], - customer_id: - attachParams.customer.id || attachParams.customer.internal_id, - }), - ); - } else { - res.status(200).json({ - success: true, - }); - } - } + return AttachFunctionResponseSchema.parse({ + code: SuccessCode.DowngradeScheduled, + message: `Successfully downgraded from ${curCusProduct.product.name} to ${product.name}`, + }); + + // if (res) { + // if (req.apiVersion.gte(ApiVersion.V1_1)) { + // res.status(200).json( + // AttachResultSchema.parse({ + // code: SuccessCode.DowngradeScheduled, + // message: `Successfully downgraded from ${curCusProduct.product.name} to ${product.name}`, + // product_ids: [product.id], + // customer_id: + // attachParams.customer.id || attachParams.customer.internal_id, + // }), + // ); + // } else { + // res.status(200).json({ + // success: true, + // }); + // } + // } }; diff --git a/server/src/internal/customers/attach/attachFunctions/updateQuantityFlow/handleQuantityDowngrade.ts b/server/src/internal/customers/attach/attachFunctions/updateQuantityFlow/handleQuantityDowngrade.ts index 835b3a346..8f6d1e686 100644 --- a/server/src/internal/customers/attach/attachFunctions/updateQuantityFlow/handleQuantityDowngrade.ts +++ b/server/src/internal/customers/attach/attachFunctions/updateQuantityFlow/handleQuantityDowngrade.ts @@ -26,9 +26,10 @@ import { shouldProrate, } from "@/internal/products/prices/priceUtils/prorationConfigUtils.js"; import { notNullish } from "@/utils/genUtils.js"; +import type { AutumnContext } from "../../../../../honoUtils/HonoEnv"; export const handleQuantityDowngrade = async ({ - req, + ctx, attachParams, attachConfig, cusProduct, @@ -37,7 +38,7 @@ export const handleQuantityDowngrade = async ({ newOptions, subItem, }: { - req: any; + ctx: AutumnContext; attachParams: AttachParams; attachConfig: AttachConfig; cusProduct: FullCusProduct; @@ -46,7 +47,7 @@ export const handleQuantityDowngrade = async ({ newOptions: FeatureOptions; subItem: Stripe.SubscriptionItem; }) => { - const { db, logger, org } = req; + const { db, logger, org, features } = ctx; const { stripeCli, paymentMethod } = attachParams; const cusPrice = featureToCusPrice({ @@ -98,13 +99,13 @@ export const handleQuantityDowngrade = async ({ }); const product = cusProductToProduct({ cusProduct }); - const feature = req.features.find( + const feature = features.find( (f: Feature) => f.internal_id === newOptions.internal_feature_id, )!; const invoiceItem = constructStripeInvoiceItem({ product, amount: amount, - org: req.org, + org: org, price: cusPrice.price, description: getFeatureInvoiceDescription({ feature: feature, diff --git a/server/src/internal/customers/attach/attachFunctions/updateQuantityFlow/handleQuantityUpgrade.ts b/server/src/internal/customers/attach/attachFunctions/updateQuantityFlow/handleQuantityUpgrade.ts index eb861d21a..b8d6be3db 100644 --- a/server/src/internal/customers/attach/attachFunctions/updateQuantityFlow/handleQuantityUpgrade.ts +++ b/server/src/internal/customers/attach/attachFunctions/updateQuantityFlow/handleQuantityUpgrade.ts @@ -26,9 +26,10 @@ import { shouldProrate, } from "@/internal/products/prices/priceUtils/prorationConfigUtils.js"; import { notNullish } from "@/utils/genUtils.js"; +import type { AutumnContext } from "../../../../../honoUtils/HonoEnv"; export const handleQuantityUpgrade = async ({ - req, + ctx, attachParams, cusProduct, stripeSubs, @@ -39,7 +40,7 @@ export const handleQuantityUpgrade = async ({ stripeSub, subItem, }: { - req: any; + ctx: AutumnContext; attachParams: AttachParams; cusProduct: FullCusProduct; attachConfig: AttachConfig; @@ -51,7 +52,7 @@ export const handleQuantityUpgrade = async ({ subItem: Stripe.SubscriptionItem; }) => { // Manually calculate prorations... - const { features, org, logger, db } = req; + const { features, org, logger, db } = ctx; const { stripeCli, now, paymentMethod } = attachParams; const difference = new Decimal(newOptions.quantity) diff --git a/server/src/internal/customers/attach/attachFunctions/updateQuantityFlow/updateFeatureQuantity.ts b/server/src/internal/customers/attach/attachFunctions/updateQuantityFlow/updateFeatureQuantity.ts index b6859168d..be7ec6167 100644 --- a/server/src/internal/customers/attach/attachFunctions/updateQuantityFlow/updateFeatureQuantity.ts +++ b/server/src/internal/customers/attach/attachFunctions/updateQuantityFlow/updateFeatureQuantity.ts @@ -9,11 +9,12 @@ import { findStripeItemForPrice } from "@/external/stripe/stripeSubUtils/stripeS import type { AttachParams } from "@/internal/customers/cusProducts/AttachParams.js"; import { featureToCusPrice } from "@/internal/customers/cusProducts/cusPrices/convertCusPriceUtils.js"; import RecaseError from "@/utils/errorUtils.js"; +import type { AutumnContext } from "../../../../../honoUtils/HonoEnv.js"; import { handleQuantityDowngrade } from "./handleQuantityDowngrade.js"; import { handleQuantityUpgrade } from "./handleQuantityUpgrade.js"; export const handleUpdateFeatureQuantity = async ({ - req, + ctx, attachParams, attachConfig, cusProduct, @@ -21,7 +22,7 @@ export const handleUpdateFeatureQuantity = async ({ oldOptions, newOptions, }: { - req: any; + ctx: AutumnContext; attachParams: AttachParams; attachConfig: AttachConfig; cusProduct: FullCusProduct; @@ -29,22 +30,7 @@ export const handleUpdateFeatureQuantity = async ({ oldOptions: FeatureOptions; newOptions: FeatureOptions; }) => { - const { db, logger } = req; - const { stripeCli } = attachParams; - - const prorationBehavior = "always_invoice"; - const subToUpdate = stripeSubs?.[0]; - // const subToUpdate = await getUsageBasedSub({ - // db, - // stripeCli: stripeCli, - // subIds: cusProduct.subscription_ids || [], - // feature: { - // internal_id: newOptions.internal_feature_id, - // id: newOptions.feature_id, - // } as Feature, - // stripeSubs: stripeSubs, - // }); const cusPrice = featureToCusPrice({ internalFeatureId: newOptions.internal_feature_id!, @@ -68,7 +54,7 @@ export const handleUpdateFeatureQuantity = async ({ if (newOptions.quantity < oldOptions.quantity) { return await handleQuantityDowngrade({ - req, + ctx, attachParams, attachConfig, cusProduct, @@ -79,7 +65,7 @@ export const handleUpdateFeatureQuantity = async ({ }); } else { return await handleQuantityUpgrade({ - req, + ctx, attachParams, attachConfig, cusProduct, diff --git a/server/src/internal/customers/attach/attachFunctions/updateQuantityFlow/updateQuantityFlow.ts b/server/src/internal/customers/attach/attachFunctions/updateQuantityFlow/updateQuantityFlow.ts index 3726e98b9..6e064426c 100644 --- a/server/src/internal/customers/attach/attachFunctions/updateQuantityFlow/updateQuantityFlow.ts +++ b/server/src/internal/customers/attach/attachFunctions/updateQuantityFlow/updateQuantityFlow.ts @@ -1,25 +1,27 @@ -import { type AttachConfig, SuccessCode } from "@autumn/shared"; +import { + type AttachConfig, + AttachFunctionResponseSchema, + SuccessCode, +} from "@autumn/shared"; import type Stripe from "stripe"; import { getStripeSubs } from "@/external/stripe/stripeSubUtils.js"; import { CusProductService } from "@/internal/customers/cusProducts/CusProductService.js"; -import { - type AttachParams, - AttachResultSchema, -} from "../../../cusProducts/AttachParams.js"; +import type { AutumnContext } from "../../../../../honoUtils/HonoEnv.js"; +import type { AttachParams } from "../../../cusProducts/AttachParams.js"; import { attachParamToCusProducts } from "../../attachUtils/convertAttachParams.js"; import { handleUpdateFeatureQuantity } from "./updateFeatureQuantity.js"; export const handleUpdateQuantityFunction = async ({ - req, - res, + ctx, attachParams, config, }: { - req: any; - res: any; + ctx: AutumnContext; attachParams: AttachParams; config: AttachConfig; }) => { + const { db } = ctx; + // 2. Update quantities const optionsToUpdate = attachParams.optionsToUpdate!; const { customer } = attachParams; @@ -36,7 +38,7 @@ export const handleUpdateQuantityFunction = async ({ const invoices: Stripe.Invoice[] = []; for (const options of optionsToUpdate) { const result = await handleUpdateFeatureQuantity({ - req, + ctx, attachParams, attachConfig: config, cusProduct, @@ -51,19 +53,26 @@ export const handleUpdateQuantityFunction = async ({ } await CusProductService.update({ - db: req.db, + db, cusProductId: cusProduct.id, updates: { options: optionsToUpdate.map((o) => o.new) }, }); - res.status(200).json( - AttachResultSchema.parse({ - customer_id: customer.id || customer.internal_id, - product_ids: attachParams.products.map((p) => p.id), - invoice: - config.invoiceOnly && invoices.length > 0 ? invoices[0] : undefined, - code: SuccessCode.FeaturesUpdated, - message: `Successfully updated quantity for features: ${optionsToUpdate.map((o) => o.new.feature_id).join(", ")}`, - }), - ); + return AttachFunctionResponseSchema.parse({ + code: SuccessCode.FeaturesUpdated, + message: `Successfully updated quantity for features: ${optionsToUpdate.map((o) => o.new.feature_id).join(", ")}`, + invoice: + config.invoiceOnly && invoices.length > 0 ? invoices[0] : undefined, + }); + + // res.status(200).json( + // AttachResultSchema.parse({ + // customer_id: customer.id || customer.internal_id, + // product_ids: attachParams.products.map((p) => p.id), + // invoice: + // config.invoiceOnly && invoices.length > 0 ? invoices[0] : undefined, + // code: SuccessCode.FeaturesUpdated, + // message: `Successfully updated quantity for features: ${optionsToUpdate.map((o) => o.new.feature_id).join(", ")}`, + // }), + // ); }; diff --git a/server/src/internal/customers/attach/attachFunctions/upgradeFlow/handleUpgradeFlow.ts b/server/src/internal/customers/attach/attachFunctions/upgradeFlow/handleUpgradeFlow.ts index e499442d6..ccd01cd13 100644 --- a/server/src/internal/customers/attach/attachFunctions/upgradeFlow/handleUpgradeFlow.ts +++ b/server/src/internal/customers/attach/attachFunctions/upgradeFlow/handleUpgradeFlow.ts @@ -1,11 +1,12 @@ import { - ApiVersion, AttachBranch, type AttachConfig, + AttachFunctionResponseSchema, AttachScenario, CusProductStatus, cusProductToProduct, ProrationBehavior, + SuccessCode, } from "@autumn/shared"; import type Stripe from "stripe"; import { getEarliestPeriodEnd } from "@/external/stripe/stripeSubUtils/convertSubUtils.js"; @@ -13,10 +14,7 @@ import { getStripeSubItems2 } from "@/external/stripe/stripeSubUtils/getStripeSu import { subIsCanceled } from "@/external/stripe/stripeSubUtils.js"; import { addProductsUpdatedWebhookTask } from "@/internal/analytics/handlers/handleProductsUpdated.js"; import { createFullCusProduct } from "@/internal/customers/add-product/createFullCusProduct.js"; -import { - type AttachParams, - AttachResultSchema, -} from "@/internal/customers/cusProducts/AttachParams.js"; +import { type AttachParams } from "@/internal/customers/cusProducts/AttachParams.js"; import { CusProductService } from "@/internal/customers/cusProducts/CusProductService.js"; import { getExistingCusProducts } from "@/internal/customers/cusProducts/cusProductUtils/getExistingCusProducts.js"; import { @@ -24,7 +22,7 @@ import { insertInvoiceFromAttach, } from "@/internal/invoices/invoiceUtils.js"; import { attachToInsertParams } from "@/internal/products/productUtils.js"; -import type { ExtendedRequest } from "@/utils/models/Request.js"; +import type { AutumnContext } from "../../../../../honoUtils/HonoEnv.js"; import { attachParamsToCurCusProduct, paramsToCurSub, @@ -36,14 +34,12 @@ import { updateStripeSub2 } from "./updateStripeSub2.js"; import { shouldCancelSub } from "./upgradeFlowUtils.js"; export const handleUpgradeFlow = async ({ - req, - res, + ctx, attachParams, config, branch, }: { - req: ExtendedRequest; - res?: any; + ctx: AutumnContext; attachParams: AttachParams; config: AttachConfig; branch: AttachBranch; @@ -51,7 +47,7 @@ export const handleUpgradeFlow = async ({ const curCusProduct = attachParamsToCurCusProduct({ attachParams }); const curSub = await paramsToCurSub({ attachParams }); - const logger = req.logger; + const { logger, db } = ctx; if (curCusProduct?.api_semver) { attachParams.apiVersion = curCusProduct.api_semver; @@ -66,7 +62,7 @@ export const handleUpgradeFlow = async ({ }); const newItemSet = await paramsToSubItems({ - req, + ctx, sub: curSub, attachParams, config, @@ -95,7 +91,7 @@ export const handleUpgradeFlow = async ({ if (curScheduledProduct) { await CusProductService.delete({ - db: req.db, + db, cusProductId: curScheduledProduct.id, }); } @@ -133,7 +129,7 @@ export const handleUpgradeFlow = async ({ // }); const res = await updateStripeSub2({ - req, + ctx, attachParams, config, curSub: curSub, @@ -144,7 +140,7 @@ export const handleUpgradeFlow = async ({ if (res?.latestInvoice) { logger.info(`UPGRADE FLOW: inserting invoice ${res.latestInvoice.id}`); await insertInvoiceFromAttach({ - db: req.db, + db, attachParams, stripeInvoice: res.latestInvoice, logger, @@ -155,8 +151,7 @@ export const handleUpgradeFlow = async ({ if (schedule) { await handleUpgradeFlowSchedule({ - req, - logger, + ctx, attachParams, config, schedule, @@ -172,7 +167,7 @@ export const handleUpgradeFlow = async ({ if (curCusProduct) { logger.info(`UPGRADE FLOW: expiring previous cus product`); await CusProductService.update({ - db: req.db, + db, cusProductId: curCusProduct.id, updates: { subscription_ids: canceled ? undefined : [], @@ -183,7 +178,7 @@ export const handleUpgradeFlow = async ({ try { await addProductsUpdatedWebhookTask({ - req, + ctx, internalCustomerId: curCusProduct.internal_customer_id, org: attachParams.org, env: attachParams.customer.env, @@ -191,7 +186,6 @@ export const handleUpgradeFlow = async ({ attachParams.customer.id || attachParams.customer.internal_id, scenario: AttachScenario.Expired, cusProduct: curCusProduct, - logger, }); } catch (error) { logger.error("UPGRADE FLOW: failed to add to webhook queue", { error }); @@ -211,7 +205,7 @@ export const handleUpgradeFlow = async ({ } await createFullCusProduct({ - db: req.db, + db, attachParams: attachToInsertParams( attachParams, attachParams.products[0], @@ -229,24 +223,32 @@ export const handleUpgradeFlow = async ({ }); } - if (res) { - if (req.apiVersion.gte(ApiVersion.V1_1)) { - res.status(200).json( - AttachResultSchema.parse({ - customer_id: attachParams.customer.id, - product_ids: attachParams.products.map((p) => p.id), - invoice: attachParams.invoiceOnly - ? attachToInvoiceResponse({ invoice: latestInvoice || undefined }) - : undefined, - code: "updated_product_successfully", - message: `Successfully updated product`, - }), - ); - } else { - res.status(200).json({ - success: true, - message: `Successfully updated product`, - }); - } - } + return AttachFunctionResponseSchema.parse({ + code: SuccessCode.UpgradedToNewProduct, + message: `Successfully updated product`, + invoice: attachParams.invoiceOnly + ? attachToInvoiceResponse({ invoice: latestInvoice || undefined }) + : undefined, + }); + + // if (res) { + // if (req.apiVersion.gte(ApiVersion.V1_1)) { + // res.status(200).json( + // AttachResultSchema.parse({ + // customer_id: attachParams.customer.id, + // product_ids: attachParams.products.map((p) => p.id), + // invoice: attachParams.invoiceOnly + // ? attachToInvoiceResponse({ invoice: latestInvoice || undefined }) + // : undefined, + // code: "updated_product_successfully", + // message: `Successfully updated product`, + // }), + // ); + // } else { + // res.status(200).json({ + // success: true, + // message: `Successfully updated product`, + // }); + // } + // } }; diff --git a/server/src/internal/customers/attach/attachFunctions/upgradeFlow/handleUpgradeFlowSchedule.ts b/server/src/internal/customers/attach/attachFunctions/upgradeFlow/handleUpgradeFlowSchedule.ts index 4dacc3ef3..e27b7dfb2 100644 --- a/server/src/internal/customers/attach/attachFunctions/upgradeFlow/handleUpgradeFlowSchedule.ts +++ b/server/src/internal/customers/attach/attachFunctions/upgradeFlow/handleUpgradeFlowSchedule.ts @@ -1,40 +1,36 @@ -import { ExtendedRequest } from "@/utils/models/Request.js"; -import { AttachConfig, FullCusProduct } from "@autumn/shared"; - -import Stripe from "stripe"; -import { paramsToScheduleItems } from "../../mergeUtils/paramsToScheduleItems.js"; -import { - logPhases, - getCurrentPhaseIndex, -} from "../../mergeUtils/phaseUtils/phaseUtils.js"; -import { updateCurSchedule } from "../../mergeUtils/updateCurSchedule.js"; -import { AttachParams } from "@/internal/customers/cusProducts/AttachParams.js"; +import type { AttachConfig, FullCusProduct } from "@autumn/shared"; +import type Stripe from "stripe"; +import type { AttachParams } from "@/internal/customers/cusProducts/AttachParams.js"; import { ACTIVE_STATUSES, CusProductService, } from "@/internal/customers/cusProducts/CusProductService.js"; -import { attachParamsToCurCusProduct } from "../../attachUtils/convertAttachParams.js"; import { isFreeProduct } from "@/internal/products/productUtils.js"; +import type { AutumnContext } from "../../../../../honoUtils/HonoEnv.js"; +import { attachParamsToCurCusProduct } from "../../attachUtils/convertAttachParams.js"; +import { paramsToScheduleItems } from "../../mergeUtils/paramsToScheduleItems.js"; +import { getCurrentPhaseIndex } from "../../mergeUtils/phaseUtils/phaseUtils.js"; +import { updateCurSchedule } from "../../mergeUtils/updateCurSchedule.js"; export const handleUpgradeFlowSchedule = async ({ - req, + ctx, attachParams, config, schedule, curSub, removeCusProducts, - logger, fromAddProduct = false, }: { - req: ExtendedRequest; + ctx: AutumnContext; attachParams: AttachParams; config: AttachConfig; schedule: Stripe.SubscriptionSchedule; curSub: Stripe.Subscription; removeCusProducts?: FullCusProduct[]; - logger: any; fromAddProduct?: boolean; }) => { + const { logger } = ctx; + if (fromAddProduct) { logger.info(`ADD PRODUCT FLOW, updating schedule ${schedule?.id}`); } else { @@ -51,11 +47,11 @@ export const handleUpgradeFlowSchedule = async ({ const nextPhaseIndex = currentPhaseIndex + 1; - if (currentPhaseIndex == -1 || nextPhaseIndex >= schedule.phases.length) + if (currentPhaseIndex === -1 || nextPhaseIndex >= schedule.phases.length) return; const newItems = await paramsToScheduleItems({ - req, + ctx, schedule, attachParams, config, @@ -72,13 +68,13 @@ export const handleUpgradeFlowSchedule = async ({ // If there are no subsequent phases, release schedule... // Example: mergedUpgrade4.test.ts, mergedCancel2.test.ts // pro, pro -> free, pro -> premium, pro (need to cancel initial schedule) - if (newCurPhaseIndex == newItems.phases.length - 1) { + if (newCurPhaseIndex === newItems.phases.length - 1) { logger.info( `UPGRADE FLOW: no subsequent phases, releasing schedule ${schedule?.id}`, ); await stripeCli.subscriptionSchedules.release(schedule!.id); await CusProductService.updateByStripeScheduledId({ - db: req.db, + db: ctx.db, stripeScheduledId: schedule!.id, updates: { scheduled_ids: [] }, }); @@ -111,7 +107,7 @@ export const handleUpgradeFlowSchedule = async ({ // }); await updateCurSchedule({ - req, + ctx, attachParams, schedule, newPhases: newItems.phases, diff --git a/server/src/internal/customers/attach/attachFunctions/upgradeFlow/updateStripeSub2.ts b/server/src/internal/customers/attach/attachFunctions/upgradeFlow/updateStripeSub2.ts index e037d2050..33b7a0972 100644 --- a/server/src/internal/customers/attach/attachFunctions/upgradeFlow/updateStripeSub2.ts +++ b/server/src/internal/customers/attach/attachFunctions/upgradeFlow/updateStripeSub2.ts @@ -11,7 +11,7 @@ import { freeTrialToStripeTimestamp } from "@/internal/products/free-trials/free import { SubService } from "@/internal/subscriptions/SubService.js"; import { nullish } from "@/utils/genUtils.js"; import type { ItemSet } from "@/utils/models/ItemSet.js"; -import type { ExtendedRequest } from "@/utils/models/Request.js"; +import type { AutumnContext } from "../../../../../honoUtils/HonoEnv.js"; import { attachParamToCusProducts } from "../../attachUtils/convertAttachParams.js"; import { createAndFilterContUseItems } from "../../attachUtils/getContUseItems/createContUseInvoiceItems.js"; import { @@ -20,21 +20,21 @@ import { } from "../upgradeDiffIntFlow/createUsageInvoiceItems.js"; export const updateStripeSub2 = async ({ - req, + ctx, attachParams, config, curSub, itemSet, fromCreate = false, }: { - req: ExtendedRequest; + ctx: AutumnContext; attachParams: AttachParams; config: AttachConfig; curSub: Stripe.Subscription; itemSet: ItemSet; fromCreate?: boolean; }) => { - const { db, logger } = req; + const { db, logger } = ctx; const { stripeCli, paymentMethod } = attachParams; const { invoiceOnly, proration } = config; @@ -72,14 +72,14 @@ export const updateStripeSub2 = async ({ : fromCreate ? "always_invoice" : "create_prorations", - // proration_behavior: "create_prorations", + trial_end: trialEnd, - // default_payment_method: paymentMethod?.id, + add_invoice_items: itemSet.invoiceItems, - ...((invoiceOnly && { + ...(invoiceOnly && { collection_method: "send_invoice", days_until_due: 30, - }) as any), + }), payment_behavior: "error_if_incomplete", expand: ["latest_invoice"], }); diff --git a/server/src/internal/customers/attach/attachRouter.ts b/server/src/internal/customers/attach/attachRouter.ts index bfdc81ca0..972a1b51f 100644 --- a/server/src/internal/customers/attach/attachRouter.ts +++ b/server/src/internal/customers/attach/attachRouter.ts @@ -27,7 +27,6 @@ import { import RecaseError from "@/utils/errorUtils.js"; import { notNullish, nullOrUndefined } from "@/utils/genUtils.js"; import type { AutumnContext } from "../../../honoUtils/HonoEnv.js"; -import { handleAttach } from "./handleAttach.js"; import { handleAttachPreview } from "./handleAttachPreview/handleAttachPreview.js"; export const attachRouter: Router = Router(); @@ -249,6 +248,5 @@ export const customerHasPm = async ({ return notNullish(paymentMethod); }; -attachRouter.post("/attach", handleAttach); attachRouter.post("/attach/preview", handleAttachPreview); // attachRouter.post("/checkout", handleCheckout); diff --git a/server/src/internal/customers/attach/attachUtils/attachParams/checkToAttachParams.ts b/server/src/internal/customers/attach/attachUtils/attachParams/checkToAttachParams.ts index d0a597716..7fa32297b 100644 --- a/server/src/internal/customers/attach/attachUtils/attachParams/checkToAttachParams.ts +++ b/server/src/internal/customers/attach/attachUtils/attachParams/checkToAttachParams.ts @@ -2,21 +2,19 @@ import type { FullCustomer, FullProduct } from "@autumn/shared"; import { createStripeCli } from "@/external/connect/createStripeCli.js"; import type { AttachParams } from "@/internal/customers/cusProducts/AttachParams.js"; import { getFreeTrialAfterFingerprint } from "@/internal/products/free-trials/freeTrialUtils.js"; -import type { ExtendedRequest } from "@/utils/models/Request.js"; +import type { AutumnContext } from "../../../../../honoUtils/HonoEnv.js"; import { getStripeCusData } from "./attachParamsUtils/getStripeCusData.js"; export const checkToAttachParams = async ({ - req, + ctx, customer, product, - logger, }: { - req: ExtendedRequest; + ctx: AutumnContext; customer: FullCustomer; product: FullProduct; - logger: any; }) => { - const { org, env, db } = req; + const { org, env, db, logger } = ctx; // const apiVersion = // orgToVersion({ @@ -62,10 +60,10 @@ export const checkToAttachParams = async ({ replaceables: [], // Others - req, - org: req.org, + req: ctx, + org: ctx.org, entities: customer.entities, - features: req.features, + features: ctx.features, internalEntityId: customer.entity?.internal_id, cusProducts: customer.customer_products, diff --git a/server/src/internal/customers/attach/attachUtils/attachParams/convertToParams.ts b/server/src/internal/customers/attach/attachUtils/attachParams/convertToParams.ts index 8493b4fb1..53c18a379 100644 --- a/server/src/internal/customers/attach/attachUtils/attachParams/convertToParams.ts +++ b/server/src/internal/customers/attach/attachUtils/attachParams/convertToParams.ts @@ -9,7 +9,6 @@ import { type FullCustomer, type FullProduct, type FullRewardProgram, - type Organization, } from "@autumn/shared"; import type Stripe from "stripe"; import { createStripeCli } from "@/external/connect/createStripeCli.js"; @@ -18,12 +17,8 @@ import type { InsertCusProductParams, } from "@/internal/customers/cusProducts/AttachParams.js"; import { newCusToFullCus } from "@/internal/customers/cusUtils/cusUtils.js"; -import { - isFreeProduct, - isOneOff, - itemsAreOneOff, -} from "@/internal/products/productUtils.js"; import type { ExtendedRequest } from "@/utils/models/Request.js"; +import type { AutumnContext } from "../../../../../honoUtils/HonoEnv"; export const webhookToAttachParams = ({ req, @@ -64,23 +59,24 @@ export const webhookToAttachParams = ({ }; export const productToInsertParams = ({ - req, + ctx, fullCus, newProduct, entities, }: { - req: ExtendedRequest; + ctx: AutumnContext; fullCus: FullCustomer; newProduct: FullProduct; entities?: Entity[]; }): InsertCusProductParams => { + const { org, features } = ctx; const params: InsertCusProductParams = { customer: fullCus, - org: req.org, + org, product: newProduct, prices: newProduct.prices, entitlements: newProduct.entitlements, - features: req.features, + features, cusProducts: fullCus.customer_products, freeTrial: null, optionsList: [], @@ -93,18 +89,19 @@ export const productToInsertParams = ({ }; export const newCusToAttachParams = ({ - req, + ctx, newCus, products, stripeCli, freeTrial = null, }: { - req: ExtendedRequest; + ctx: AutumnContext; newCus: FullCustomer; products: FullProduct[]; stripeCli: Stripe; freeTrial?: FreeTrial | null; }) => { + const { org } = ctx; if (!newCus.customer_products) { newCus.customer_products = []; } @@ -118,8 +115,8 @@ export const newCusToAttachParams = ({ const attachParams: AttachParams = { stripeCli, paymentMethod: null, - req, - org: req.org, + req: ctx, + org, customer: newCus, products, prices: products.flatMap((p) => p.prices), @@ -136,19 +133,20 @@ export const newCusToAttachParams = ({ }; export const newCusToInsertParams = ({ - req, + ctx, newCus, product, freeTrial = null, }: { - req: ExtendedRequest; + ctx: AutumnContext; newCus: Customer; product: FullProduct; freeTrial?: FreeTrial | null; }) => { + const { org } = ctx; return { - req, - org: req.org, + req: ctx, + org, customer: newCusToFullCus({ newCus }), product, prices: product.prices, @@ -163,26 +161,23 @@ export const newCusToInsertParams = ({ }; export const rewardProgramToAttachParams = ({ - req, + ctx, rewardProgram, customer, product, - org, }: { - req: ExtendedRequest; + ctx: AutumnContext; rewardProgram: FullRewardProgram; customer: FullCustomer; product: FullProduct; - org?: Organization; }): AttachParams => { + const { org, env, features } = ctx; + const reward = rewardProgram.reward; - const isPaid = !isFreeProduct(product.prices); - const isRecurring = - !isOneOff(product.prices) && !itemsAreOneOff(product.entitlements); return { - req, - org: org || req.org, + req: ctx, + org, customer, products: [product], prices: product.prices, @@ -192,11 +187,8 @@ export const rewardProgramToAttachParams = ({ optionsList: [], cusProducts: customer.customer_products, entities: [], - features: req.features, - stripeCli: createStripeCli({ - org: org || req.org, - env: req.env, - }), + features, + stripeCli: createStripeCli({ org, env }), paymentMethod: null, replaceables: [], } satisfies AttachParams; diff --git a/server/src/internal/customers/attach/attachUtils/attachParams/getAttachParams.ts b/server/src/internal/customers/attach/attachUtils/attachParams/getAttachParams.ts index e90e1f7b8..5c344b566 100644 --- a/server/src/internal/customers/attach/attachUtils/attachParams/getAttachParams.ts +++ b/server/src/internal/customers/attach/attachUtils/attachParams/getAttachParams.ts @@ -1,7 +1,6 @@ import type { AttachBodyV0 } from "@autumn/shared"; import { nullish } from "@/utils/genUtils.js"; import type { AutumnContext } from "../../../../../honoUtils/HonoEnv.js"; -import type { ExtendedRequest } from "../../../../../utils/models/Request.js"; import type { AttachParams } from "../../../cusProducts/AttachParams.js"; import { processAttachBody } from "./processAttachBody.js"; @@ -52,7 +51,7 @@ export const getAttachParams = async ({ replaceables: [], rewards, // From req - req: ctx as ExtendedRequest, + req: ctx, org: ctx.org, entities: customer.entities, diff --git a/server/src/internal/customers/attach/attachUtils/getAttachFunction.ts b/server/src/internal/customers/attach/attachUtils/getAttachFunction.ts index 3eab621c8..7073e4a6c 100644 --- a/server/src/internal/customers/attach/attachUtils/getAttachFunction.ts +++ b/server/src/internal/customers/attach/attachUtils/getAttachFunction.ts @@ -3,9 +3,12 @@ import { AttachBranch, type AttachConfig, AttachFunction, + type AttachFunctionResponse, + AttachFunctionResponseSchema, CusProductStatus, } from "@autumn/shared"; import chalk from "chalk"; +import type { AutumnContext } from "../../../../honoUtils/HonoEnv.js"; import { handleCreateCheckout } from "../../add-product/handleCreateCheckout.js"; import { handleCreateInvoiceCheckout } from "../../add-product/handleCreateInvoiceCheckout.js"; import type { AttachParams } from "../../cusProducts/AttachParams.js"; @@ -106,21 +109,19 @@ export const getAttachFunction = async ({ }; export const runAttachFunction = async ({ - req, - res, + ctx, branch, attachParams, attachBody, config, }: { - req: any; - res: any; + ctx: AutumnContext; branch: AttachBranch; attachParams: AttachParams; attachBody: AttachBodyV0; config: AttachConfig; -}) => { - const { logger, db } = req; +}): Promise => { + const { logger, db } = ctx; const { stripeCli } = attachParams; const attachFunction = await getAttachFunction({ @@ -139,8 +140,6 @@ export const runAttachFunction = async ({ attachParams, }); - const curCusProduct = attachParamsToCurCusProduct({ attachParams }); - logger.info(`--------------------------------`); logger.info( `ATTACHING ${productIdsStr} to ${customer.name} (${customer.id || customer.email}), org: ${org.slug}`, @@ -166,8 +165,7 @@ export const runAttachFunction = async ({ if (attachFunction === AttachFunction.OneOff) { return await handleOneOffFunction({ - req, - res, + ctx, attachParams, config, }); @@ -175,8 +173,7 @@ export const runAttachFunction = async ({ if (attachFunction === AttachFunction.Renew) { return await handleRenewProduct({ - req, - res, + ctx, attachParams, config, }); @@ -206,8 +203,7 @@ export const runAttachFunction = async ({ if (attachFunction === AttachFunction.MultiAttach) { return await handleMultiAttachFlow({ - req, - res, + ctx, attachParams, attachBody, branch, @@ -218,17 +214,13 @@ export const runAttachFunction = async ({ if (attachFunction === AttachFunction.CreateCheckout) { if (config.invoiceCheckout) { return await handleCreateInvoiceCheckout({ - req, - res, + ctx, attachParams, - attachBody, config, - branch, }); } return await handleCreateCheckout({ - req, - res, + ctx, attachParams, config, }); @@ -236,8 +228,7 @@ export const runAttachFunction = async ({ if (attachFunction === AttachFunction.AddProduct) { return await handleAddProduct({ - req, - res, + ctx, attachParams, config, branch, @@ -246,8 +237,7 @@ export const runAttachFunction = async ({ if (attachFunction === AttachFunction.ScheduleProduct) { return await handleScheduleFunction2({ - req, - res, + ctx, attachParams, config, }); @@ -258,8 +248,7 @@ export const runAttachFunction = async ({ attachFunction === AttachFunction.UpgradeSameInterval ) { return await handleUpgradeFlow({ - req, - res, + ctx, attachParams, config, branch, @@ -268,10 +257,14 @@ export const runAttachFunction = async ({ if (attachFunction === AttachFunction.UpdatePrepaidQuantity) { return await handleUpdateQuantityFunction({ - req, - res, + ctx, attachParams, config, }); } + + return AttachFunctionResponseSchema.parse({ + code: "attach_function_not_found", + message: `Attach function not found: ${attachFunction}`, + }); }; diff --git a/server/src/internal/customers/attach/handleAttach.ts b/server/src/internal/customers/attach/handleAttach.ts index 48f7ac29a..60cc495ff 100644 --- a/server/src/internal/customers/attach/handleAttach.ts +++ b/server/src/internal/customers/attach/handleAttach.ts @@ -1,99 +1,98 @@ -import { AttachBodyV0Schema } from "@autumn/shared"; -import { handleAttachRaceCondition } from "@/external/redis/redisUtils.js"; -import type { - ExtendedRequest, - ExtendedResponse, -} from "@/utils/models/Request.js"; -import { routeHandler } from "@/utils/routerUtils.js"; -import type { AutumnContext } from "../../../honoUtils/HonoEnv.js"; -import { checkStripeConnections } from "./attachRouter.js"; -import { getAttachParams } from "./attachUtils/attachParams/getAttachParams.js"; -import { getAttachBranch } from "./attachUtils/getAttachBranch.js"; -import { getAttachConfig } from "./attachUtils/getAttachConfig.js"; -import { runAttachFunction } from "./attachUtils/getAttachFunction.js"; -import { handleAttachErrors } from "./attachUtils/handleAttachErrors.js"; -import { insertCustomItems } from "./attachUtils/insertCustomItems.js"; +// import { AttachBodyV0Schema } from "@autumn/shared"; +// import { handleAttachRaceCondition } from "@/external/redis/redisUtils.js"; +// import type { +// ExtendedRequest, +// ExtendedResponse, +// } from "@/utils/models/Request.js"; +// import { routeHandler } from "@/utils/routerUtils.js"; +// import type { AutumnContext } from "../../../honoUtils/HonoEnv.js"; +// import { checkStripeConnections } from "./attachRouter.js"; +// import { getAttachParams } from "./attachUtils/attachParams/getAttachParams.js"; +// import { getAttachBranch } from "./attachUtils/getAttachBranch.js"; +// import { getAttachConfig } from "./attachUtils/getAttachConfig.js"; +// import { runAttachFunction } from "./attachUtils/getAttachFunction.js"; +// import { handleAttachErrors } from "./attachUtils/handleAttachErrors.js"; +// import { insertCustomItems } from "./attachUtils/insertCustomItems.js"; -export const handleAttach = async (req: any, res: any) => - routeHandler({ - req, - res, - action: "attach", - handler: async (req: ExtendedRequest, res: ExtendedResponse) => { - await handleAttachRaceCondition({ req, res }); +// export const handleAttach = async (req: any, res: any) => +// routeHandler({ +// req, +// res, +// action: "attach", +// handler: async (req: ExtendedRequest, res: ExtendedResponse) => { +// await handleAttachRaceCondition({ req, res }); - const attachBody = AttachBodyV0Schema.parse(req.body); +// const attachBody = AttachBodyV0Schema.parse(req.body); - const ctx = req as AutumnContext; +// const ctx = req as AutumnContext; - const { attachParams, customPrices, customEnts } = await getAttachParams({ - ctx, - attachBody, - }); +// const { attachParams, customPrices, customEnts } = await getAttachParams({ +// ctx, +// attachBody, +// }); - // Handle existing product - const branch = await getAttachBranch({ - ctx, - attachBody, - attachParams, - }); +// // Handle existing product +// const branch = await getAttachBranch({ +// ctx, +// attachBody, +// attachParams, +// }); - const { flags, config } = await getAttachConfig({ - ctx, - attachParams, - attachBody, - branch, - }); +// const { flags, config } = await getAttachConfig({ +// ctx, +// attachParams, +// attachBody, +// branch, +// }); - await handleAttachErrors({ - attachParams, - attachBody, - branch, - flags, - config, - }); +// await handleAttachErrors({ +// attachParams, +// attachBody, +// branch, +// flags, +// config, +// }); - await checkStripeConnections({ - ctx, - attachParams, - useCheckout: config.onlyCheckout, - }); +// await checkStripeConnections({ +// ctx, +// attachParams, +// useCheckout: config.onlyCheckout, +// }); - await insertCustomItems({ - db: req.db, - customPrices: customPrices || [], - customEnts: customEnts || [], - }); +// await insertCustomItems({ +// db: req.db, +// customPrices: customPrices || [], +// customEnts: customEnts || [], +// }); - try { - req.logger.info(`Attach params: `, { - data: { - products: attachParams.products.map((p) => ({ - id: p.id, - name: p.name, - processor: p.processor, - version: p.version, - })), - prices: attachParams.prices.map((p) => ({ - id: p.id, - config: p.config, - })), - entitlements: attachParams.entitlements.map((e) => ({ - internal_feature_id: e.internal_feature_id, - feature_id: e.feature_id, - })), - freeTrial: attachParams.freeTrial, - }, - }); - } catch (_error) {} +// try { +// req.logger.info(`Attach params: `, { +// data: { +// products: attachParams.products.map((p) => ({ +// id: p.id, +// name: p.name, +// processor: p.processor, +// version: p.version, +// })), +// prices: attachParams.prices.map((p) => ({ +// id: p.id, +// config: p.config, +// })), +// entitlements: attachParams.entitlements.map((e) => ({ +// internal_feature_id: e.internal_feature_id, +// feature_id: e.feature_id, +// })), +// freeTrial: attachParams.freeTrial, +// }, +// }); +// } catch (_error) {} - await runAttachFunction({ - req, - res, - attachParams, - branch, - attachBody, - config, - }); - }, - }); +// await runAttachFunction({ +// ctx, +// attachParams, +// branch, +// attachBody, +// config, +// }); +// }, +// }); diff --git a/server/src/internal/customers/attach/mergeUtils/paramsToScheduleItems.ts b/server/src/internal/customers/attach/mergeUtils/paramsToScheduleItems.ts index 803929388..211fb55c6 100644 --- a/server/src/internal/customers/attach/mergeUtils/paramsToScheduleItems.ts +++ b/server/src/internal/customers/attach/mergeUtils/paramsToScheduleItems.ts @@ -1,23 +1,26 @@ -import Stripe from "stripe"; -import { mergeNewScheduleItems } from "./mergeNewSubItems.js"; -import { getCusProductsToRemove } from "./paramsToSubItems.js"; -import { ItemSet } from "@/utils/models/ItemSet.js"; -import { AttachParams } from "../../cusProducts/AttachParams.js"; +import { + type AttachConfig, + cusProductToPrices, + type FullCusProduct, +} from "@autumn/shared"; +import { differenceInDays } from "date-fns"; +import type Stripe from "stripe"; import { getStripeSubItems2 } from "@/external/stripe/stripeSubUtils/getStripeSubItems.js"; -import { AttachConfig, FullCusProduct } from "@autumn/shared"; -import { ExtendedRequest } from "@/utils/models/Request.js"; -import { cusProductToPrices } from "@autumn/shared"; -import { isArrearPrice } from "@/internal/products/prices/priceUtils/usagePriceUtils/classifyUsagePrice.js"; import { priceToScheduleItem, scheduleItemInCusProduct, } from "@/external/stripe/stripeSubUtils/stripeSubItemUtils.js"; +import { isArrearPrice } from "@/internal/products/prices/priceUtils/usagePriceUtils/classifyUsagePrice.js"; import { formatPrice } from "@/internal/products/prices/priceUtils.js"; -import { differenceInDays } from "date-fns"; import { formatUnixToDateTime } from "@/utils/genUtils.js"; +import type { ItemSet } from "@/utils/models/ItemSet.js"; +import type { AutumnContext } from "../../../../honoUtils/HonoEnv.js"; +import type { AttachParams } from "../../cusProducts/AttachParams.js"; +import { mergeNewScheduleItems } from "./mergeNewSubItems.js"; +import { getQuantityToRemove } from "./mergeUtils.js"; +import { getCusProductsToRemove } from "./paramsToSubItems.js"; import { mergeAdjacentPhasesWithSameItems } from "./phaseUtils/mergeSimilarPhases.js"; import { preparePhasesForBillingPeriod } from "./phaseUtils/upsertNewPhase.js"; -import { getQuantityToRemove } from "./mergeUtils.js"; export const removeCusProductFromScheduleItems = async ({ curScheduleItems, @@ -92,7 +95,7 @@ export const removeCusProductFromScheduleItems = async ({ if ( itemSet?.subItems.some( - (si) => si.price == (existingScheduleItem.price as Stripe.Price)?.id, + (si) => si.price === (existingScheduleItem.price as Stripe.Price)?.id, ) ) { continue; @@ -151,7 +154,7 @@ const logScheduleItems = ({ for (const cusProduct of cusProducts) { const prices = cusProductToPrices({ cusProduct }); const price = prices.find((p) => { - return p.config.stripe_price_id == item.price; + return p.config.stripe_price_id === item.price; }); if (price) { @@ -225,7 +228,8 @@ const computeUpdatedScheduleItems = async ({ }; export const paramsToScheduleItems = async ({ - req, + // biome-ignore lint/correctness/noUnusedFunctionParameters: Might be used in the future + ctx, sub, schedule, attachParams, @@ -233,7 +237,7 @@ export const paramsToScheduleItems = async ({ removeCusProducts, billingPeriodEnd, }: { - req: ExtendedRequest; + ctx: AutumnContext; sub?: Stripe.Subscription; schedule?: Stripe.SubscriptionSchedule; attachParams: AttachParams; @@ -241,14 +245,11 @@ export const paramsToScheduleItems = async ({ removeCusProducts?: FullCusProduct[]; billingPeriodEnd?: number; }) => { - const { logger } = req; - const itemSet = await getStripeSubItems2({ attachParams, config, }); - let curScheduleItems: any[] = []; let phaseIndex = -1; if (billingPeriodEnd && schedule && schedule.phases.length > 1) { diff --git a/server/src/internal/customers/attach/mergeUtils/paramsToSubItems.ts b/server/src/internal/customers/attach/mergeUtils/paramsToSubItems.ts index 5965f1d38..cbf773c17 100644 --- a/server/src/internal/customers/attach/mergeUtils/paramsToSubItems.ts +++ b/server/src/internal/customers/attach/mergeUtils/paramsToSubItems.ts @@ -15,7 +15,7 @@ import { isArrearPrice } from "@/internal/products/prices/priceUtils/usagePriceU import { formatPrice } from "@/internal/products/prices/priceUtils.js"; import { notNullish } from "@/utils/genUtils.js"; import type { ItemSet } from "@/utils/models/ItemSet.js"; -import type { ExtendedRequest } from "@/utils/models/Request.js"; +import type { AutumnContext } from "../../../../honoUtils/HonoEnv.js"; import type { AttachParams } from "../../cusProducts/AttachParams.js"; import { getExistingCusProducts } from "../../cusProducts/cusProductUtils/getExistingCusProducts.js"; import { mergeNewSubItems } from "./mergeNewSubItems.js"; @@ -91,14 +91,15 @@ export const getCusProductsToRemove = ({ }; export const paramsToSubItems = async ({ - req, + // biome-ignore lint/correctness/noUnusedFunctionParameters: Might be used in the future + ctx, sub, attachParams, config, removeCusProducts, addItemSet, }: { - req: ExtendedRequest; + ctx: AutumnContext; sub?: Stripe.Subscription; attachParams: AttachParams; config: AttachConfig; diff --git a/server/src/internal/customers/attach/mergeUtils/subToNewSchedule.ts b/server/src/internal/customers/attach/mergeUtils/subToNewSchedule.ts index 7731e7c2d..a2ec3244f 100644 --- a/server/src/internal/customers/attach/mergeUtils/subToNewSchedule.ts +++ b/server/src/internal/customers/attach/mergeUtils/subToNewSchedule.ts @@ -1,28 +1,27 @@ import type { AttachConfig, FullCusProduct } from "@autumn/shared"; import type Stripe from "stripe"; import { getStripeSubItems2 } from "@/external/stripe/stripeSubUtils/getStripeSubItems.js"; -import type { ExtendedRequest } from "@/utils/models/Request.js"; +import type { AutumnContext } from "../../../../honoUtils/HonoEnv.js"; import type { AttachParams } from "../../cusProducts/AttachParams.js"; import { CusProductService } from "../../cusProducts/CusProductService.js"; import { paramsToScheduleItems } from "./paramsToScheduleItems.js"; import { getCusProductsToRemove } from "./paramsToSubItems.js"; export const subToNewSchedule = async ({ - req, + ctx, sub, attachParams, config, endOfBillingPeriod, removeCusProducts, }: { - req: ExtendedRequest; + ctx: AutumnContext; sub: Stripe.Subscription; attachParams: AttachParams; config: AttachConfig; endOfBillingPeriod: number; removeCusProducts?: FullCusProduct[]; }) => { - const { logger } = req; const itemSet = await getStripeSubItems2({ attachParams, config, @@ -39,7 +38,7 @@ export const subToNewSchedule = async ({ ); const res = await paramsToScheduleItems({ - req, + ctx, sub, attachParams, config, @@ -66,7 +65,6 @@ export const subToNewSchedule = async ({ if (res.phases[0].items.length > 0) { itemSet.subItems = res.phases[0].items; - const curSubItems = sub.items.data; // Create schedule from existing subscription newSchedule = await stripeCli.subscriptionSchedules.create({ @@ -97,7 +95,7 @@ export const subToNewSchedule = async ({ }); await CusProductService.updateByStripeSubId({ - db: req.db, + db: ctx.db, stripeSubId: sub.id!, updates: { scheduled_ids: [newSchedule!.id], diff --git a/server/src/internal/customers/attach/mergeUtils/updateCurSchedule.ts b/server/src/internal/customers/attach/mergeUtils/updateCurSchedule.ts index dbe0da6ac..0795b45e9 100644 --- a/server/src/internal/customers/attach/mergeUtils/updateCurSchedule.ts +++ b/server/src/internal/customers/attach/mergeUtils/updateCurSchedule.ts @@ -1,17 +1,16 @@ -import { ExtendedRequest } from "@/utils/models/Request.js"; -import { AttachParams } from "../../cusProducts/AttachParams.js"; -import Stripe from "stripe"; -import { CusProductService } from "../../cusProducts/CusProductService.js"; -import { ItemSet } from "@/utils/models/ItemSet.js"; +import type Stripe from "stripe"; +import type { AutumnContext } from "../../../../honoUtils/HonoEnv.js"; +import type { AttachParams } from "../../cusProducts/AttachParams.js"; export const updateCurSchedule = async ({ - req, + // biome-ignore lint/correctness/noUnusedFunctionParameters: Might be used in the future + ctx, attachParams, schedule, sub, newPhases, }: { - req: ExtendedRequest; + ctx: AutumnContext; attachParams: AttachParams; schedule: Stripe.SubscriptionSchedule; sub: Stripe.Subscription; diff --git a/server/src/internal/customers/cancel/cancelImmediately.ts b/server/src/internal/customers/cancel/cancelImmediately.ts index 101d3d369..f1a006a2b 100644 --- a/server/src/internal/customers/cancel/cancelImmediately.ts +++ b/server/src/internal/customers/cancel/cancelImmediately.ts @@ -8,24 +8,24 @@ import { import { createStripeCli } from "@/external/connect/createStripeCli.js"; import { addProductsUpdatedWebhookTask } from "@/internal/analytics/handlers/handleProductsUpdated.js"; import { isOneOff } from "@/internal/products/productUtils.js"; -import type { ExtendedRequest } from "@/utils/models/Request.js"; +import type { AutumnContext } from "../../../honoUtils/HonoEnv.js"; import { CusProductService } from "../cusProducts/CusProductService.js"; import { cusProductToSub } from "../cusProducts/cusProductUtils/convertCusProduct.js"; import { getExistingCusProducts } from "../cusProducts/cusProductUtils/getExistingCusProducts.js"; import { activateDefaultProduct } from "../cusProducts/cusProductUtils.js"; export const cancelImmediately = async ({ - req, + ctx, cusProduct, fullCus, prorate, }: { - req: ExtendedRequest; + ctx: AutumnContext; cusProduct: FullCusProduct; fullCus: FullCustomer; prorate: boolean; }) => { - const { db, org, env, logger } = req; + const { db, org, env } = ctx; const stripeCli = createStripeCli({ org, env }); const { curScheduledProduct } = getExistingCusProducts({ @@ -58,7 +58,7 @@ export const cancelImmediately = async ({ } await activateDefaultProduct({ - req, + ctx, productGroup: cusProduct.product.group, fullCus, }); @@ -75,13 +75,12 @@ export const cancelImmediately = async ({ console.log("Sending webhook for expired product"); await addProductsUpdatedWebhookTask({ - req, + ctx, internalCustomerId: fullCus.internal_id, org, env, customerId: fullCus.id || null, cusProduct, scenario: AttachScenario.Expired, - logger, }); }; diff --git a/server/src/internal/customers/cancel/cancelRouter.ts b/server/src/internal/customers/cancel/cancelRouter.ts index 240837ff1..1607285a2 100644 --- a/server/src/internal/customers/cancel/cancelRouter.ts +++ b/server/src/internal/customers/cancel/cancelRouter.ts @@ -8,6 +8,7 @@ import { CusService } from "@/internal/customers/CusService.js"; import RecaseError from "@/utils/errorUtils.js"; import { notNullish, nullish } from "@/utils/genUtils.js"; import { routeHandler } from "@/utils/routerUtils.js"; +import type { AutumnContext } from "../../../honoUtils/HonoEnv.js"; import { RELEVANT_STATUSES } from "../cusProducts/CusProductService.js"; import { handleCancelProduct } from "./handleCancelProduct.js"; @@ -68,16 +69,8 @@ cancelRouter.post("", async (req, res) => }); } - // await expireCusProduct({ - // req, - // cusProduct, - // fullCus, - // expireImmediately, - // prorate, - // }); - await handleCancelProduct({ - req, + ctx: req as unknown as AutumnContext, cusProduct, fullCus, expireImmediately, diff --git a/server/src/internal/customers/cancel/handleCancelProduct.ts b/server/src/internal/customers/cancel/handleCancelProduct.ts index b0ec1b2b3..3e62388c1 100644 --- a/server/src/internal/customers/cancel/handleCancelProduct.ts +++ b/server/src/internal/customers/cancel/handleCancelProduct.ts @@ -12,7 +12,7 @@ import { } from "@autumn/shared"; import { createStripeCli } from "@/external/connect/createStripeCli.js"; import { isFreeProduct, isOneOff } from "@/internal/products/productUtils.js"; -import type { ExtendedRequest } from "@/utils/models/Request.js"; +import type { AutumnContext } from "../../../honoUtils/HonoEnv.js"; import { handleRenewProduct } from "../attach/attachFunctions/handleRenewProduct.js"; import { handleScheduleFunction2 } from "../attach/attachFunctions/scheduleFlow/handleScheduleFlow2.js"; import { handleUpgradeFlow } from "../attach/attachFunctions/upgradeFlow/handleUpgradeFlow.js"; @@ -24,19 +24,19 @@ import { } from "../cusProducts/cusProductUtils.js"; export const handleCancelProduct = async ({ - req, + ctx, cusProduct, // cus product to expire fullCus, expireImmediately = true, prorate, }: { - req: ExtendedRequest; + ctx: AutumnContext; cusProduct: FullCusProduct; fullCus: FullCustomer; expireImmediately: boolean; prorate: boolean; }) => { - const { org, env, logger } = req; + const { org, env, logger, features } = ctx; logger.info("--------------------------------"); logger.info( `🔔 Expiring cutomer product (${ @@ -68,8 +68,7 @@ export const handleCancelProduct = async ({ const product = cusProductToProduct({ cusProduct: curMainProduct! }); await handleRenewProduct({ - req, - res: null, + ctx, attachParams: { stripeCli, customer: fullCus, @@ -84,7 +83,7 @@ export const handleCancelProduct = async ({ optionsList: curMainProduct?.options || [], replaceables: [], entities: fullCus.entities, - features: req.features, + features, }, config: getDefaultAttachConfig(), }); @@ -116,7 +115,7 @@ export const handleCancelProduct = async ({ // 2. If expire at cycle end, just cancel subscriptions if (!expireImmediately && !isFree) { const defaultProduct = await getDefaultProduct({ - req, + ctx, productGroup: product.group, }); @@ -136,8 +135,7 @@ export const handleCancelProduct = async ({ } await handleScheduleFunction2({ - req, - res: null, + ctx, attachParams: { stripeCli, customer: fullCus, @@ -152,7 +150,7 @@ export const handleCancelProduct = async ({ optionsList: [], replaceables: [], entities: fullCus.entities, - features: req.features, + features, fromCancel: true, }, config: getDefaultAttachConfig(), @@ -166,8 +164,7 @@ export const handleCancelProduct = async ({ // Cancel product immediately await handleUpgradeFlow({ - req, - res: null, + ctx, attachParams: { stripeCli, customer: fullCus, @@ -183,7 +180,7 @@ export const handleCancelProduct = async ({ optionsList: [], replaceables: [], entities: fullCus.entities, - features: req.features, + features, fromCancel: true, }, config: { @@ -199,7 +196,7 @@ export const handleCancelProduct = async ({ // Activate default product if (!product.is_add_on && !isOneOff(product.prices)) { await activateDefaultProduct({ - req, + ctx, productGroup: cusProduct.product.group, fullCus, curCusProduct: cusProduct, diff --git a/server/src/internal/customers/cusProducts/AttachParams.ts b/server/src/internal/customers/cusProducts/AttachParams.ts index e15cfdcfe..b7d8bd0fb 100644 --- a/server/src/internal/customers/cusProducts/AttachParams.ts +++ b/server/src/internal/customers/cusProducts/AttachParams.ts @@ -1,5 +1,6 @@ import type { ApiVersion, + AttachConfig, AttachReplaceable, AttachScenario, Customer, @@ -17,8 +18,7 @@ import type { Reward, } from "@autumn/shared"; import type Stripe from "stripe"; - -import { z } from "zod"; +import type { AutumnContext } from "../../../honoUtils/HonoEnv"; // Get misc @@ -68,19 +68,24 @@ export type AttachParams = { entityId?: string; internalEntityId?: string; - checkoutSessionParams?: any; + checkoutSessionParams?: unknown; apiVersion?: ApiVersion; scenario?: AttachScenario; fromMigration?: boolean; finalizeInvoice?: boolean; - req?: any; + req?: AutumnContext; fromCancel?: boolean; setupPayment?: boolean; + + // For invoice checkout... + anchorToUnix?: number; + subId?: string; + config?: AttachConfig; }; export type InsertCusProductParams = { - req?: any; + req?: AutumnContext; now?: number; customer: Customer; @@ -113,14 +118,14 @@ export type InsertCusProductParams = { finalizeInvoice?: boolean; }; -export const AttachResultSchema = z.object({ - customer_id: z.string(), - product_ids: z.array(z.string()), - code: z.string(), - message: z.string(), +// export const AttachResultSchema = z.object({ +// customer_id: z.string(), +// product_ids: z.array(z.string()), +// code: z.string(), +// message: z.string(), - checkout_url: z.string().nullish(), - invoice: z.any().nullish(), -}); +// checkout_url: z.string().nullish(), +// invoice: z.any().nullish(), +// }); -export type AttachResult = z.infer; +// export type AttachResult = z.infer; diff --git a/server/src/internal/customers/cusProducts/cusProductUtils.ts b/server/src/internal/customers/cusProducts/cusProductUtils.ts index 45d910d4c..dbfbc368b 100644 --- a/server/src/internal/customers/cusProducts/cusProductUtils.ts +++ b/server/src/internal/customers/cusProducts/cusProductUtils.ts @@ -13,7 +13,7 @@ import { ProductService } from "@/internal/products/ProductService.js"; import { isDefaultTrialFullProduct } from "@/internal/products/productUtils/classifyProduct.js"; import { isFreeProduct } from "@/internal/products/productUtils.js"; import { nullish } from "@/utils/genUtils.js"; -import type { ExtendedRequest } from "@/utils/models/Request.js"; +import type { AutumnContext } from "../../../honoUtils/HonoEnv.js"; import { handleAddProduct } from "../attach/attachFunctions/addProductFlow/handleAddProduct.js"; import { newCusToAttachParams } from "../attach/attachUtils/attachParams/convertToParams.js"; import { initStripeCusAndProducts } from "../handlers/handleCreateCustomer.js"; @@ -21,13 +21,13 @@ import { CusProductService, RELEVANT_STATUSES } from "./CusProductService.js"; import { getExistingCusProducts } from "./cusProductUtils/getExistingCusProducts.js"; export const getDefaultProduct = async ({ - req, + ctx, productGroup, }: { - req: ExtendedRequest; + ctx: AutumnContext; productGroup: string; }) => { - const { db, org, env, logger } = req; + const { db, org, env } = ctx; const defaultProducts = await ProductService.listDefault({ db, orgId: org.id, @@ -44,17 +44,17 @@ export const getDefaultProduct = async ({ // This function is only used in cancellation flows export const activateDefaultProduct = async ({ - req, + ctx, productGroup, fullCus, curCusProduct, }: { - req: ExtendedRequest; + ctx: AutumnContext; productGroup: string; fullCus: FullCustomer; curCusProduct?: FullCusProduct; }) => { - const { db, org, env, logger } = req; + const { db, org, env, logger } = ctx; // 1. Expire current product const defaultProducts = await ProductService.listDefault({ db, @@ -103,9 +103,9 @@ export const activateDefaultProduct = async ({ } await handleAddProduct({ - req, + ctx, attachParams: newCusToAttachParams({ - req, + ctx, newCus: fullCus, products: [defaultProd], stripeCli, @@ -116,13 +116,13 @@ export const activateDefaultProduct = async ({ }; export const activateFutureProduct = async ({ - req, + ctx, cusProduct, }: { - req: ExtendedRequest; + ctx: AutumnContext; cusProduct: FullCusProduct; }) => { - const { db, org, env, logger } = req; + const { db, org, env, logger } = ctx; const cusProducts = await CusProductService.list({ db, @@ -147,176 +147,18 @@ export const activateFutureProduct = async ({ }); await addProductsUpdatedWebhookTask({ - req, + ctx, internalCustomerId: cusProduct.internal_customer_id, org, env, customerId: null, scenario: AttachScenario.New, cusProduct: futureProduct, - logger, }); return futureProduct; }; -// export const processFullCusProduct = ({ -// cusProduct, -// subs, -// org, -// entities = [], -// apiVersion, -// }: { -// cusProduct: FullCusProduct; -// org: Organization; -// subs?: Subscription[]; -// entities?: Entity[]; -// apiVersion: ApiVersionClass; -// }) => { -// // Process prices - -// const prices = cusProduct.customer_prices.map((cp) => { -// const price = cp.price; - -// if (price.config?.type === PriceType.Fixed) { -// const config = price.config as FixedPriceConfig; -// return { -// amount: config.amount, -// interval: config.interval, -// }; -// } else { -// const config = price.config as UsagePriceConfig; -// const priceOptions = getPriceOptions(price, cusProduct.options); -// const usageTier = getUsageTier(price, priceOptions?.quantity!); -// const cusEnt = getRelatedCusEnt({ -// cusPrice: cp, -// cusEnts: cusProduct.customer_entitlements, -// }); - -// const ent = cusEnt?.entitlement; - -// const singleTier = -// ent?.allowance === 0 && config.usage_tiers.length === 1; - -// if (singleTier) { -// return { -// amount: usageTier.amount, -// interval: config.interval, -// quantity: priceOptions?.quantity, -// }; -// } else { -// // Add allowance to tiers -// const allowance = ent?.allowance; -// let tiers; - -// if (notNullish(allowance) && allowance! > 0) { -// tiers = [ -// { -// to: allowance, -// amount: 0, -// }, -// ...config.usage_tiers.map((tier) => { -// const isLastTier = tier.to === -1 || tier.to === TierInfinite; -// return { -// to: isLastTier ? tier.to : Number(tier.to) + allowance!, -// amount: tier.amount, -// }; -// }), -// ]; -// } else { -// tiers = config.usage_tiers.map((tier) => { -// const isLastTier = tier.to === -1 || tier.to === TierInfinite; -// return { -// to: isLastTier ? tier.to : Number(tier.to) + allowance!, -// amount: tier.amount, -// }; -// }); -// } - -// return { -// tiers: tiers, -// name: "", -// quantity: priceOptions?.quantity, -// }; -// } -// } -// }); - -// const trialing = -// cusProduct.trial_ends_at && cusProduct.trial_ends_at > Date.now(); - -// const subIds = cusProduct.subscription_ids; -// let stripeSubData = {}; - -// if (subIds && subIds.length > 0 && apiVersion.gte(ApiVersion.V0_2)) { -// const baseSub = subs?.find( -// (s) => s.id === subIds[0] || (s as Subscription).stripe_id === subIds[0], -// ); -// stripeSubData = { -// current_period_end: baseSub?.current_period_end -// ? baseSub.current_period_end * 1000 -// : null, -// current_period_start: baseSub?.current_period_start -// ? baseSub.current_period_start * 1000 -// : null, -// }; -// } - -// if (!subIds && trialing) { -// stripeSubData = { -// current_period_start: cusProduct.starts_at, -// current_period_end: cusProduct.trial_ends_at, -// }; -// } - -// if (apiVersion.gte(ApiVersion.V1_1)) { -// if ((!subIds || subIds.length === 0) && trialing) { -// stripeSubData = { -// current_period_start: cusProduct.starts_at, -// current_period_end: cusProduct.trial_ends_at, -// }; -// } - -// return ApiSubscriptionSchema.parse({ -// id: cusProduct.product.id, -// name: cusProduct.product.name, -// group: cusProduct.product.group || null, -// status: trialing ? CusProductStatus.Trialing : cusProduct.status, -// canceled_at: cusProduct.canceled_at, -// is_default: cusProduct.product.is_default || false, -// is_add_on: cusProduct.product.is_add_on || false, -// stripe_subscription_ids: cusProduct.subscription_ids || [], -// started_at: cusProduct.starts_at, -// entity_id: cusProduct.internal_entity_id -// ? entities?.find((e) => e.internal_id === cusProduct.internal_entity_id) -// ?.id -// : cusProduct.entity_id || undefined, - -// ...stripeSubData, -// }); -// } else { -// const cusProductResponse = { -// id: cusProduct.product.id, -// name: cusProduct.product.name, -// group: cusProduct.product.group, -// status: trialing ? CusProductStatus.Trialing : cusProduct.status, -// created_at: cusProduct.created_at, -// canceled_at: cusProduct.canceled_at, -// processor: { -// type: cusProduct.processor?.type, -// subscription_id: cusProduct.processor?.subscription_id || null, -// }, -// subscription_ids: cusProduct.subscription_ids || [], -// prices: prices, -// starts_at: cusProduct.starts_at, - -// ...stripeSubData, -// }; - -// return cusProductResponse; -// } -// }; - export const searchCusProducts = ({ productId, internalProductId, @@ -401,5 +243,5 @@ export const getFeatureQuantity = ({ const option = options.find( (o) => o.internal_feature_id === internalFeatureId, ); - return nullish(option?.quantity) ? 1 : option?.quantity!; + return nullish(option?.quantity) ? 1 : option?.quantity; }; diff --git a/server/src/internal/customers/cusUtils/apiCusCacheUtils/setCachedApiInvoices.ts b/server/src/internal/customers/cusUtils/apiCusCacheUtils/setCachedApiInvoices.ts index 32120e6e1..9dd388683 100644 --- a/server/src/internal/customers/cusUtils/apiCusCacheUtils/setCachedApiInvoices.ts +++ b/server/src/internal/customers/cusUtils/apiCusCacheUtils/setCachedApiInvoices.ts @@ -38,7 +38,6 @@ export const setCachedApiInvoices = async ({ // Build master api customer invoices (customer-level only) const masterApiInvoices = invoicesToResponse({ invoices: customerLevelInvoices, - logger, }); // Then write to Redis diff --git a/server/src/internal/customers/cusUtils/apiCusUtils/getApiCustomerBase.ts b/server/src/internal/customers/cusUtils/apiCusUtils/getApiCustomerBase.ts index 7f2d40809..68400b4d4 100644 --- a/server/src/internal/customers/cusUtils/apiCusUtils/getApiCustomerBase.ts +++ b/server/src/internal/customers/cusUtils/apiCusUtils/getApiCustomerBase.ts @@ -64,7 +64,6 @@ export const getApiCustomerBase = async ({ fullCus.invoices && ctx.expand.includes(CusExpand.Invoices) ? invoicesToResponse({ invoices: fullCus.invoices, - logger: ctx.logger, }) : undefined, }); diff --git a/server/src/internal/customers/cusUtils/createNewCustomer.ts b/server/src/internal/customers/cusUtils/createNewCustomer.ts index 1865a1896..397bd2419 100644 --- a/server/src/internal/customers/cusUtils/createNewCustomer.ts +++ b/server/src/internal/customers/cusUtils/createNewCustomer.ts @@ -15,7 +15,6 @@ import { isDefaultTrialFullProduct } from "@/internal/products/productUtils/clas import { isFreeProduct } from "@/internal/products/productUtils.js"; import RecaseError from "@/utils/errorUtils.js"; import { generateId } from "@/utils/genUtils.js"; -import type { ExtendedRequest } from "@/utils/models/Request.js"; import type { AutumnContext } from "../../../honoUtils/HonoEnv.js"; import { createFullCusProduct } from "../add-product/createFullCusProduct.js"; import { handleAddProduct } from "../attach/attachFunctions/addProductFlow/handleAddProduct.js"; @@ -154,13 +153,13 @@ export const createNewCustomer = async ({ }); await handleAddProduct({ - req: ctx as unknown as ExtendedRequest, + ctx, config: { ...getDefaultAttachConfig(), requirePaymentMethod: false, }, attachParams: newCusToAttachParams({ - req: ctx as unknown as ExtendedRequest, + ctx, newCus: newCustomer as FullCustomer, products: [defaultProd], stripeCli, @@ -171,7 +170,7 @@ export const createNewCustomer = async ({ await createFullCusProduct({ db, attachParams: newCusToInsertParams({ - req: ctx as unknown as ExtendedRequest, + ctx, newCus: newCustomer, product: defaultProd, freeTrial: defaultProd?.free_trial || null, diff --git a/server/src/internal/customers/handlers/handleTransferProductV2.ts b/server/src/internal/customers/handlers/handleTransferProductV2.ts index ce7a17781..1db904263 100644 --- a/server/src/internal/customers/handlers/handleTransferProductV2.ts +++ b/server/src/internal/customers/handlers/handleTransferProductV2.ts @@ -10,7 +10,6 @@ import { createRoute } from "@/honoMiddlewares/routeHandler.js"; import { addProductsUpdatedWebhookTask } from "@/internal/analytics/handlers/handleProductsUpdated.js"; import { ProductService } from "@/internal/products/ProductService.js"; import { nullish } from "@/utils/genUtils.js"; -import type { ExtendedRequest } from "../../../utils/models/Request.js"; import { CusService } from "../CusService.js"; import { CusProductService } from "../cusProducts/CusProductService.js"; import { handleDecreaseAndTransfer } from "./handleTransferProduct/handleDecreaseAndTransfer.js"; @@ -34,7 +33,7 @@ export const handleTransferProductV2 = createRoute({ const { customer_id } = c.req.param(); const { from_entity_id, to_entity_id, product_id } = c.req.valid("json"); - if(!from_entity_id && !to_entity_id) { + if (!from_entity_id && !to_entity_id) { throw new RecaseError({ message: "Must specify atleast one of: from_entity_id, to_entity_id", }); @@ -93,7 +92,7 @@ export const handleTransferProductV2 = createRoute({ throw new CusProductAlreadyExistsError({ productId: product_id, entityId: toEntity?.id, - customerId: (from_entity_id && !to_entity_id) ? customer_id : undefined, + customerId: from_entity_id && !to_entity_id ? customer_id : undefined, }); } @@ -124,7 +123,7 @@ export const handleTransferProductV2 = createRoute({ }); await addProductsUpdatedWebhookTask({ - req: ctx as ExtendedRequest, + ctx, internalCustomerId: customer.internal_id, org: ctx.org, env: ctx.env, @@ -135,7 +134,6 @@ export const handleTransferProductV2 = createRoute({ entity_id: toEntity?.id || null, internal_entity_id: toEntity?.internal_id || null, }, - logger: ctx.logger, }); } diff --git a/server/src/internal/entities/entityUtils/apiEntityUtils/getApiEntityExpand.ts b/server/src/internal/entities/entityUtils/apiEntityUtils/getApiEntityExpand.ts index 536783e66..ced6201cc 100644 --- a/server/src/internal/entities/entityUtils/apiEntityUtils/getApiEntityExpand.ts +++ b/server/src/internal/entities/entityUtils/apiEntityUtils/getApiEntityExpand.ts @@ -44,7 +44,6 @@ export const getApiEntityExpand = async ({ return { invoices: invoicesToResponse({ invoices, - logger, }), }; }; diff --git a/server/src/internal/invoices/invoiceUtils.ts b/server/src/internal/invoices/invoiceUtils.ts index 7529e73dd..0471725df 100644 --- a/server/src/internal/invoices/invoiceUtils.ts +++ b/server/src/internal/invoices/invoiceUtils.ts @@ -1,10 +1,16 @@ -import Stripe from "stripe"; -import { AttachParams } from "../customers/cusProducts/AttachParams.js"; -import { InvoiceService, processInvoice } from "./InvoiceService.js"; +import type { + Invoice, + InvoiceItem, + Price, + UsagePriceConfig, +} from "@autumn/shared"; +import type Stripe from "stripe"; +import type { DrizzleCli } from "@/db/initDrizzle.js"; import { getStripeExpandedInvoice } from "@/external/stripe/stripeInvoiceUtils.js"; -import { Invoice, InvoiceItem, Price, UsagePriceConfig } from "@autumn/shared"; -import { DrizzleCli } from "@/db/initDrizzle.js"; import { findPriceInStripeItems } from "@/external/stripe/stripeSubUtils/stripeSubItemUtils.js"; +import type { Logger } from "../../external/logtail/logtailUtils.js"; +import type { AttachParams } from "../customers/cusProducts/AttachParams.js"; +import { InvoiceService, processInvoice } from "./InvoiceService.js"; // Purpose of this function is to insert an invoice from attach params when sub is updated -> Correct product ID is set... export const insertInvoiceFromAttach = async ({ @@ -18,7 +24,7 @@ export const insertInvoiceFromAttach = async ({ attachParams: AttachParams; invoiceId?: string; stripeInvoice?: Stripe.Invoice; - logger: any; + logger: Logger; }) => { try { if (!stripeInvoice) { @@ -29,12 +35,12 @@ export const insertInvoiceFromAttach = async ({ } // Create or update - let invoice = await InvoiceService.getByStripeId({ + const invoice = await InvoiceService.getByStripeId({ db, stripeId: stripeInvoice.id!, }); - let autumnInvoiceItems = await getInvoiceItems({ + const autumnInvoiceItems = await getInvoiceItems({ stripeInvoice, prices: attachParams.prices, logger, @@ -88,13 +94,7 @@ export const insertInvoiceFromAttach = async ({ } }; -export const invoicesToResponse = ({ - invoices, - logger, -}: { - invoices: Invoice[]; - logger: any; -}) => { +export const invoicesToResponse = ({ invoices }: { invoices: Invoice[] }) => { return invoices.map((i) => processInvoice({ invoice: i, @@ -111,13 +111,13 @@ export const getInvoiceItems = async ({ }: { stripeInvoice: Stripe.Invoice; prices: Price[]; - logger: any; + logger: Logger; }) => { - let invoiceItems: InvoiceItem[] = []; + const invoiceItems: InvoiceItem[] = []; try { for (const line of stripeInvoice.lines.data) { - let price = findPriceInStripeItems({ + const price = findPriceInStripeItems({ prices, lineItem: line, }); @@ -126,7 +126,7 @@ export const getInvoiceItems = async ({ continue; } - let usageConfig = price.config as UsagePriceConfig; + const usageConfig = price.config as UsagePriceConfig; invoiceItems.push({ price_id: price.id!, stripe_id: line.id, diff --git a/server/src/internal/migrations/migrationSteps/migrateCustomer.ts b/server/src/internal/migrations/migrationSteps/migrateCustomer.ts index a0e47ca71..0994193dd 100644 --- a/server/src/internal/migrations/migrationSteps/migrateCustomer.ts +++ b/server/src/internal/migrations/migrationSteps/migrateCustomer.ts @@ -11,6 +11,8 @@ import type { DrizzleCli } from "@/db/initDrizzle.js"; import { createStripeCli } from "@/external/connect/createStripeCli.js"; import { CusService } from "@/internal/customers/CusService.js"; import type { ExtendedRequest } from "@/utils/models/Request.js"; +import type { Logger } from "../../../external/logtail/logtailUtils.js"; +import type { AutumnContext } from "../../../honoUtils/HonoEnv.js"; import { deleteCachedApiCustomer } from "../../customers/cusUtils/apiCusCacheUtils/deleteCachedApiCustomer.js"; import { migrationToAttachParams } from "../migrationUtils/migrationToAttachParams.js"; import { runMigrationAttach } from "../migrationUtils/runMigrationAttach.js"; @@ -34,7 +36,7 @@ export const migrateCustomer = async ({ orgId: string; fromProduct: FullProduct; toProduct: FullProduct; - logger: any; + logger: Logger; features: Feature[]; migrationJob?: MigrationJob; }) => { @@ -75,7 +77,7 @@ export const migrateCustomer = async ({ }); await runMigrationAttach({ - req, + ctx: req as unknown as AutumnContext, attachParams, fromProduct, }); diff --git a/server/src/internal/migrations/migrationUtils/migrationToAttachParams.ts b/server/src/internal/migrations/migrationUtils/migrationToAttachParams.ts index d91de6d57..cafd2a993 100644 --- a/server/src/internal/migrations/migrationUtils/migrationToAttachParams.ts +++ b/server/src/internal/migrations/migrationUtils/migrationToAttachParams.ts @@ -3,6 +3,7 @@ import type Stripe from "stripe"; import { getStripeCusData } from "@/internal/customers/attach/attachUtils/attachParams/attachParamsUtils/getStripeCusData.js"; import type { AttachParams } from "@/internal/customers/cusProducts/AttachParams.js"; import type { ExtendedRequest } from "@/utils/models/Request.js"; +import type { AutumnContext } from "../../../honoUtils/HonoEnv"; export const migrationToAttachParams = async ({ req, @@ -30,6 +31,8 @@ export const migrationToAttachParams = async ({ allowNoStripe: true, }); + const ctx = req as unknown as AutumnContext; + const attachParams: AttachParams = { stripeCli, stripeCus, @@ -44,10 +47,10 @@ export const migrationToAttachParams = async ({ freeTrial: newProduct.free_trial || null, replaceables: [], - req, + req: ctx, org, entities: customer.entities, - features: req.features, + features: ctx.features, internalEntityId, cusProducts: customer.customer_products, diff --git a/server/src/internal/migrations/migrationUtils/runMigrationAttach.ts b/server/src/internal/migrations/migrationUtils/runMigrationAttach.ts index 3155b40cb..d99e4589d 100644 --- a/server/src/internal/migrations/migrationUtils/runMigrationAttach.ts +++ b/server/src/internal/migrations/migrationUtils/runMigrationAttach.ts @@ -11,7 +11,7 @@ import { checkSameCustom } from "@/internal/customers/attach/attachUtils/getAtta import { intervalsAreSame } from "@/internal/customers/attach/attachUtils/getAttachConfig.js"; import type { AttachParams } from "@/internal/customers/cusProducts/AttachParams.js"; import { isFreeProduct } from "@/internal/products/productUtils.js"; -import type { ExtendedRequest } from "@/utils/models/Request.js"; +import type { AutumnContext } from "../../../honoUtils/HonoEnv"; const getAttachFunction = async ({ attachParams, @@ -32,15 +32,15 @@ const getAttachFunction = async ({ }; export const runMigrationAttach = async ({ - req, + ctx, attachParams, fromProduct, }: { - req: ExtendedRequest; + ctx: AutumnContext; attachParams: AttachParams; fromProduct: FullProduct; }) => { - const { logger } = req; + const { logger } = ctx; const sameIntervals = intervalsAreSame({ attachParams }); const branch = AttachBranch.NewVersion; @@ -88,13 +88,13 @@ export const runMigrationAttach = async ({ if (attachFunction === AttachFunction.AddProduct) { return await handleAddProduct({ - req, + ctx, attachParams, config, }); } else if (attachFunction === AttachFunction.UpgradeSameInterval) { await handleUpgradeFlow({ - req, + ctx, attachParams, config, branch: diff --git a/server/src/internal/rewards/referralUtils/triggerFreePaidProduct.ts b/server/src/internal/rewards/referralUtils/triggerFreePaidProduct.ts index b77402d6b..e6d344335 100644 --- a/server/src/internal/rewards/referralUtils/triggerFreePaidProduct.ts +++ b/server/src/internal/rewards/referralUtils/triggerFreePaidProduct.ts @@ -21,6 +21,7 @@ import { CusService } from "@/internal/customers/CusService.js"; import { isStripeConnected } from "@/internal/orgs/orgUtils.js"; import RecaseError from "@/utils/errorUtils.js"; import type { ExtendedRequest } from "@/utils/models/Request.js"; +import type { AutumnContext } from "../../../honoUtils/HonoEnv.js"; import { RewardRedemptionService } from "../RewardRedemptionService.js"; import { ReferralResponseCodes } from "../referralUtils.js"; @@ -100,7 +101,7 @@ export const triggerFreePaidProduct = async ({ const fullCus = [fullReferrer, fullRedeemer][i]; const attachParams = rewardProgramToAttachParams({ - req, + ctx: req as unknown as AutumnContext, rewardProgram, customer: fullCus, product: fullProduct, @@ -144,7 +145,7 @@ export const triggerFreePaidProduct = async ({ }); await handleAddProduct({ - req, + ctx: req as unknown as AutumnContext, attachParams, branch: AttachBranch.New, config: { diff --git a/server/src/internal/rewards/referralUtils/triggerFreeProduct.ts b/server/src/internal/rewards/referralUtils/triggerFreeProduct.ts index a9d6fa459..1ac1a3861 100644 --- a/server/src/internal/rewards/referralUtils/triggerFreeProduct.ts +++ b/server/src/internal/rewards/referralUtils/triggerFreeProduct.ts @@ -18,6 +18,7 @@ import { ProductService } from "@/internal/products/ProductService.js"; import { isFreeProduct, isOneOff } from "@/internal/products/productUtils.js"; import RecaseError from "@/utils/errorUtils.js"; import type { ExtendedRequest } from "@/utils/models/Request.js"; +import type { AutumnContext } from "../../../honoUtils/HonoEnv.js"; import { deleteCachedApiCustomer } from "../../customers/cusUtils/apiCusCacheUtils/deleteCachedApiCustomer.js"; import { RewardRedemptionService } from "../RewardRedemptionService.js"; import { ReferralResponseCodes } from "../referralUtils.js"; @@ -117,7 +118,7 @@ export const triggerFreeProduct = async ({ } const attachParams: InsertCusProductParams = { - req, + req: req as unknown as AutumnContext, org, product: fullProduct, prices: fullProduct.prices, diff --git a/server/tests/attach/misc/attach-misc1.test.ts b/server/tests/attach/misc/attach-misc1.test.ts index 7cc85dd6c..3e9124f47 100644 --- a/server/tests/attach/misc/attach-misc1.test.ts +++ b/server/tests/attach/misc/attach-misc1.test.ts @@ -1,8 +1,8 @@ import { beforeAll, describe, expect, test } from "bun:test"; import { LegacyVersion } from "@autumn/shared"; -import chalk from "chalk"; import { TestFeature } from "@tests/setup/v2Features.js"; import ctx from "@tests/utils/testInitUtils/createTestContext.js"; +import chalk from "chalk"; import { AutumnInt } from "@/external/autumn/autumnCli.js"; import { constructArrearItem } from "@/utils/scriptUtils/constructItem.js"; import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; diff --git a/server/tests/attach/misc/attach-misc2.test.ts b/server/tests/attach/misc/attach-misc2.test.ts new file mode 100644 index 000000000..e69de29bb diff --git a/shared/api/billing/attach/changes/V0.2_AttachChange.ts b/shared/api/billing/attach/changes/V0.2_AttachChange.ts new file mode 100644 index 000000000..a25e354e4 --- /dev/null +++ b/shared/api/billing/attach/changes/V0.2_AttachChange.ts @@ -0,0 +1,56 @@ +import { ApiVersion } from "@api/versionUtils/ApiVersion.js"; +import { + AffectedResource, + defineVersionChange, +} from "@api/versionUtils/versionChangeUtils/VersionChange.js"; +import type { z } from "zod/v4"; +import { + AttachResponseV0Schema, + AttachResponseV1Schema, +} from "../prevVersions/attachResponseV1.js"; + +/** + * V0_2_AttachChange: Transforms attach response TO V0.2 format + * + * Applied when: targetVersion <= V0_2 + * + * Breaking changes introduced in V1.1 (that we reverse here): + * + * 1. Structure: V1.1+ includes additional response fields + * - V1.1+: { success, customer_id, product_ids, code, message, checkout_url?, invoice? } + * - V0.2: { success, checkout_url? } + * + * 2. The V0.2 format only returns success status and optional checkout_url + * 3. The V1.1+ format includes customer_id, product_ids, code, message, and invoice fields + * + * Input: AttachResponseV1 (V1.1+ format) + * Output: AttachResponseV0 (V0.2 minimal format) + */ + +export const V0_2_AttachChange = defineVersionChange({ + name: "V0.2 Attach Change", + newVersion: ApiVersion.V1_1, // Breaking change introduced in V1_1 + oldVersion: ApiVersion.V0_2, // Applied when targetVersion <= V0_2 + description: [ + "Attach response transformed to minimal V0.2 format", + "Removes customer_id, product_ids, code, message, and invoice fields", + "Retains only success and checkout_url", + ], + affectedResources: [AffectedResource.Attach], + newSchema: AttachResponseV1Schema, + oldSchema: AttachResponseV0Schema, + affectsResponse: true, + + // Response: V1.1+ (AttachResponseV1) → V0.2 (AttachResponseV0) + transformResponse: ({ + input, + }: { + input: z.infer; + }): z.infer => { + return { + success: input.success, + checkout_url: input.checkout_url, + invoice: input.invoice ?? undefined, + }; + }, +}); diff --git a/shared/api/billing/attach/prevVersions/attachResponseV0.ts b/shared/api/billing/attach/prevVersions/attachResponseV0.ts deleted file mode 100644 index 112502606..000000000 --- a/shared/api/billing/attach/prevVersions/attachResponseV0.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { z } from "zod/v4"; - -export const AttachResultV0Schema = z.object({ - customer_id: z.string(), - product_ids: z.array(z.string()), - code: z.string(), - message: z.string(), - - checkout_url: z.string().nullish(), - invoice: z.any().nullish(), -}); - -export type AttachResultV0 = z.infer; diff --git a/shared/api/billing/attach/prevVersions/attachResponseV1.ts b/shared/api/billing/attach/prevVersions/attachResponseV1.ts new file mode 100644 index 000000000..5682303ab --- /dev/null +++ b/shared/api/billing/attach/prevVersions/attachResponseV1.ts @@ -0,0 +1,21 @@ +import { z } from "zod/v4"; + +export const AttachResponseV0Schema = z.object({ + success: z.boolean(), + checkout_url: z.string().nullish(), + invoice: z.any().nullish(), +}); + +export const AttachResponseV1Schema = z.object({ + success: z.boolean(), + customer_id: z.string(), + product_ids: z.array(z.string()), + code: z.string(), + message: z.string(), + + checkout_url: z.string().nullish(), + invoice: z.any().nullish(), +}); + +export type AttachResponseV0 = z.infer; +export type AttachResponseV1 = z.infer; diff --git a/shared/api/models.ts b/shared/api/models.ts index 8f1750fae..ea4237821 100644 --- a/shared/api/models.ts +++ b/shared/api/models.ts @@ -70,6 +70,7 @@ export * from "./balances/track/trackResponseV2.js"; export * from "./balances/track/trackTypes/pgDeductionUpdate.js"; export * from "./balances/usageModels.js"; export * from "./billing/attach/prevVersions/attachBodyV0.js"; +export * from "./billing/attach/prevVersions/attachResponseV1.js"; export * from "./billing/checkout/checkoutParamsV1.js"; export * from "./billing/checkout/prevVersions/checkoutParamsV0.js"; export * from "./billing/checkout/prevVersions/checkoutParamsV0.js"; diff --git a/shared/api/versionUtils/versionChangeUtils/versionChangeRegistry.ts b/shared/api/versionUtils/versionChangeUtils/versionChangeRegistry.ts index fb14aa30f..949f9fb15 100644 --- a/shared/api/versionUtils/versionChangeUtils/versionChangeRegistry.ts +++ b/shared/api/versionUtils/versionChangeUtils/versionChangeRegistry.ts @@ -25,6 +25,8 @@ import { V0_2_CheckChange } from "../../balances/check/changes/V0.2_CheckChange. import { V1_2_CheckChange } from "../../balances/check/changes/V1.2_CheckChange.js"; import { V1_2_CheckQueryChange } from "../../balances/check/changes/V1.2_CheckQueryChange.js"; import { V1_2_TrackChange } from "../../balances/track/changes/V1.2_TrackChange.js"; +// Import attach changes +import { V0_2_AttachChange } from "../../billing/attach/changes/V0.2_AttachChange.js"; import { ApiVersion } from "../ApiVersion.js"; import type { VersionChangeConstructor } from "./VersionChange.js"; import { VersionChangeRegistryClass } from "./VersionChangeRegistryClass.js"; @@ -57,6 +59,7 @@ export const V1_1_CHANGES: VersionChangeConstructor[] = [ V0_2_CustomerChange, // Transforms TO V0_2: splits structure + transforms features V0_2_InvoicesAlwaysExpanded, // Side effect: invoices always expanded for V0_2 and older V0_2_CheckChange, // Transforms TO V0_2: check response to balances array format + V0_2_AttachChange, // Transforms TO V0_2: minimal attach response format ]; export const V0_2_CHANGES: VersionChangeConstructor[] = [ diff --git a/shared/index.ts b/shared/index.ts index bf82442d1..6600fbf24 100644 --- a/shared/index.ts +++ b/shared/index.ts @@ -90,12 +90,14 @@ export * from "./models/featureModels/featureTable.js"; // Gen Models export * from "./models/genModels/genEnums.js"; export * from "./models/genModels/processorSchemas.js"; + // Idempotency Models +// Attach Function Response +export * from "./models/attachModels/attachFunctionResponse.js"; export * from "./models/migrationModels/migrationErrorTable.js"; export * from "./models/migrationModels/migrationJobTable.js"; export * from "./models/migrationModels/migrationModels.js"; - export * from "./models/orgModels/frontendOrg.js"; // 1. Org Models export * from "./models/orgModels/frontendOrg.js"; diff --git a/shared/models/attachModels/attachFunctionResponse.ts b/shared/models/attachModels/attachFunctionResponse.ts new file mode 100644 index 000000000..8a061df5e --- /dev/null +++ b/shared/models/attachModels/attachFunctionResponse.ts @@ -0,0 +1,19 @@ +import { z } from "zod/v4"; + +export const AttachFunctionResponseSchema = z.object({ + checkout_url: z.string().optional(), + message: z.string().optional(), + code: z.string().optional(), + + invoice: z.any().optional(), // Stripe.invoice + checkoutSession: z.any().optional(), // Stripe.checkout.session + stripeSub: z.any().optional(), // Stripe.subscription + anchorToUnix: z.number().optional(), + // product_ids: z.array(z.string()), + // customer_id: z.string(), + // scenario: z.nativeEnum(AttachScenario), +}); + +export type AttachFunctionResponse = z.infer< + typeof AttachFunctionResponseSchema +>; From 7258fc23e54cf40ee7ad56c30d5db2bdaa191cef Mon Sep 17 00:00:00 2001 From: amianthus <49116958+SirTenzin@users.noreply.github.com> Date: Thu, 27 Nov 2025 17:43:05 +0000 Subject: [PATCH 2/4] =?UTF-8?q?fix:=20=F0=9F=90=9B=20allow=20included=5Fus?= =?UTF-8?q?age=20to=20be=200?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../product-items/validateProductItems.ts | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/server/src/internal/products/product-items/validateProductItems.ts b/server/src/internal/products/product-items/validateProductItems.ts index 028b089cd..1093b0a65 100644 --- a/server/src/internal/products/product-items/validateProductItems.ts +++ b/server/src/internal/products/product-items/validateProductItems.ts @@ -103,15 +103,15 @@ const validateProductItem = ({ } } - if (isFeatureItem(item)) { - if (item.included_usage === 0 && feature?.type !== FeatureType.Boolean) { - throw new RecaseError({ - message: `Included usage for feature ${item.feature_id} must be greater than 0`, - code: ErrCode.InvalidInputs, - statusCode: StatusCodes.BAD_REQUEST, - }); - } - } + // if (isFeatureItem(item)) { + // if (item.included_usage === 0 && feature?.type !== FeatureType.Boolean) { + // throw new RecaseError({ + // message: `Included usage for feature ${item.feature_id} must be greater than 0`, + // code: ErrCode.InvalidInputs, + // statusCode: StatusCodes.BAD_REQUEST, + // }); + // } + // } // 5. If it's a price, can't have day, minute or hour interval if (isFeaturePriceItem(item) || isPriceItem(item)) { From d5e01523c49d3ac6bc1a26c852d9415a3a5f5b05 Mon Sep 17 00:00:00 2001 From: John Yeo Date: Fri, 28 Nov 2025 08:46:33 +0000 Subject: [PATCH 3/4] chore: updated import paths --- server/src/external/logtail/logtailUtils.ts | 6 ++---- server/src/internal/customers/CusService.ts | 3 ++- .../src/internal/customers/cusProducts/CusProductService.ts | 2 +- .../cusUtils/apiCusCacheUtils/deleteCachedApiCustomer.ts | 2 +- server/tsconfig.json | 1 + shared/api/customers/previousVersions/apiCustomerV2.ts | 2 +- shared/api/errors/classes/balancesErrClasses.ts | 2 +- 7 files changed, 9 insertions(+), 9 deletions(-) diff --git a/server/src/external/logtail/logtailUtils.ts b/server/src/external/logtail/logtailUtils.ts index 2b58cc0f2..c5d45860a 100644 --- a/server/src/external/logtail/logtailUtils.ts +++ b/server/src/external/logtail/logtailUtils.ts @@ -1,8 +1,6 @@ -import dotenv from "dotenv"; +import "dotenv/config"; -dotenv.config(); - -import { initLogger } from "@/errors/logger.js"; +import { initLogger } from "@server/errors/logger.js"; const pinoLogger = initLogger(); diff --git a/server/src/internal/customers/CusService.ts b/server/src/internal/customers/CusService.ts index d6b5fe48d..081865d28 100644 --- a/server/src/internal/customers/CusService.ts +++ b/server/src/internal/customers/CusService.ts @@ -10,10 +10,11 @@ import { type FullCusProduct, type FullCustomer, type Organization, + RecaseError, } from "@autumn/shared"; import { and, eq, ilike, or, sql, type Table } from "drizzle-orm"; import type { DrizzleCli } from "@/db/initDrizzle.js"; -import RecaseError from "@/utils/errorUtils.js"; + import { withSpan } from "../analytics/tracer/spanUtils.js"; import { RELEVANT_STATUSES } from "./cusProducts/CusProductService.js"; import { getFullCusQuery } from "./getFullCusQuery.js"; diff --git a/server/src/internal/customers/cusProducts/CusProductService.ts b/server/src/internal/customers/cusProducts/CusProductService.ts index 581891be0..72048e1bc 100644 --- a/server/src/internal/customers/cusProducts/CusProductService.ts +++ b/server/src/internal/customers/cusProducts/CusProductService.ts @@ -8,10 +8,10 @@ import { type FullCusProduct, InternalError, products, + RecaseError, } from "@autumn/shared"; import { and, arrayContains, eq, inArray, isNotNull, or } from "drizzle-orm"; import type { DrizzleCli } from "@/db/initDrizzle.js"; -import RecaseError from "@/utils/errorUtils.js"; export const ACTIVE_STATUSES = [ CusProductStatus.Active, diff --git a/server/src/internal/customers/cusUtils/apiCusCacheUtils/deleteCachedApiCustomer.ts b/server/src/internal/customers/cusUtils/apiCusCacheUtils/deleteCachedApiCustomer.ts index 33cd43413..d4ed3f56a 100644 --- a/server/src/internal/customers/cusUtils/apiCusCacheUtils/deleteCachedApiCustomer.ts +++ b/server/src/internal/customers/cusUtils/apiCusCacheUtils/deleteCachedApiCustomer.ts @@ -1,5 +1,5 @@ -import { redis } from "@/external/redis/initRedis.js"; import { logger } from "../../../../external/logtail/logtailUtils.js"; +import { redis } from "../../../../external/redis/initRedis.js"; /** * Delete all cached ApiCustomer data from Redis diff --git a/server/tsconfig.json b/server/tsconfig.json index 64c5c0a90..833b5afa3 100644 --- a/server/tsconfig.json +++ b/server/tsconfig.json @@ -19,6 +19,7 @@ "forceConsistentCasingInFileNames": true, "paths": { "@/*": ["./src/*"], + "@server/*": ["./src/*"], "@shared/*": ["../shared/*"], "@emails/*": ["./emails/*"], "@scripts/*": ["./scripts/*"], diff --git a/shared/api/customers/previousVersions/apiCustomerV2.ts b/shared/api/customers/previousVersions/apiCustomerV2.ts index c9989f908..c1733fc8f 100644 --- a/shared/api/customers/previousVersions/apiCustomerV2.ts +++ b/shared/api/customers/previousVersions/apiCustomerV2.ts @@ -4,11 +4,11 @@ import { ApiTrialsUsedV0Schema } from "@api/customers/components/apiTrialsUsed/p import { ApiCusFeatureV2Schema } from "@api/customers/cusFeatures/previousVersions/apiCusFeatureV2.js"; import { ApiCusProductV2Schema } from "@api/customers/cusPlans/previousVersions/apiCusProductV2.js"; import { ApiBaseEntitySchema } from "@api/entities/apiBaseEntity.js"; -import { ApiCusRewardsSchema } from "@api/models.js"; import { ApiInvoiceV0Schema } from "@api/others/apiInvoice/prevVersions/apiInvoiceV0.js"; import { AppEnv } from "@models/genModels/genEnums.js"; import { z } from "zod/v4"; +import { ApiCusRewardsSchema } from "../../others/apiDiscount.js"; /** * ApiCustomerV2Schema - Customer response format for API V1.1+ (merged format) diff --git a/shared/api/errors/classes/balancesErrClasses.ts b/shared/api/errors/classes/balancesErrClasses.ts index 9c032580b..e768d9af1 100644 --- a/shared/api/errors/classes/balancesErrClasses.ts +++ b/shared/api/errors/classes/balancesErrClasses.ts @@ -1,4 +1,4 @@ -import { RecaseError } from "../../../index.js"; +import { RecaseError } from "../base/RecaseError.js"; import { BalancesErrorCode } from "../codes/balancesErrCodes.js"; export class InsufficientBalanceError extends RecaseError { From 34ed3857f089a5cf4c8a36d4fc2b25938fd8903f Mon Sep 17 00:00:00 2001 From: John Yeo Date: Fri, 28 Nov 2025 16:58:13 +0000 Subject: [PATCH 4/4] feat: payment failure returns checkout_url --- scripts/migrations/migrate-functions.ts | 11 +- scripts/testGroups/g2.sh | 1 + server/src/cron/cronInit.ts | 15 +- server/src/cron/invoiceCron/runInvoiceCron.ts | 65 +++++++++ server/src/cron/utils/CronContext.ts | 7 + .../stripe/handleStripeWebhookEvent.ts | 5 +- server/src/external/stripe/stripeCusUtils.ts | 33 +++++ .../src/external/stripe/stripeInvoiceUtils.ts | 8 +- .../updateStripeSub/createProrationinvoice.ts | 98 +++++++++----- .../handleCheckoutCompleted.ts | 2 +- .../webhookHandlers/handleInvoicePaid.ts | 23 +--- .../handleInvoiceActionRequiredCompleted.ts | 74 ++++++++++ .../handleInvoiceCheckoutPaid.ts | 56 ++++++++ .../handleInvoicePaidMetadata.ts | 45 ++++++ .../webhookHandlers/handleInvoiceUpdated.ts | 4 +- .../attach/utils/attachParamsToMetadata.ts | 43 ++++++ .../add-product/handleCreateCheckout.ts | 10 +- .../handleCreateInvoiceCheckout.ts | 59 +------- .../handleInvoiceCheckoutPaid.ts | 108 --------------- .../upgradeFlow/handleUpgradeFlow.ts | 15 +- .../upgradeFlow/updateStripeSub2.ts | 25 +++- .../attach/mergeUtils/paramsToSubItems.ts | 8 +- .../customers/cusProducts/AttachParams.ts | 4 + .../src/internal/metadata/MetadataService.ts | 42 +++++- server/src/internal/metadata/metadataUtils.ts | 37 ----- server/src/utils/scriptUtils/initCustomer.ts | 19 ++- .../scriptUtils/testUtils/initCustomerV3.ts | 2 +- server/tests/_temp/temp1.test.ts | 85 +++++++----- .../invoice-action-required1.test.ts | 128 ++++++++++++++++++ .../invoice-action-required2.test.ts | 109 +++++++++++++++ .../invoice-action-required3.test.ts} | 57 +++++--- .../tests/utils/expectUtils/expectAttach.ts | 2 +- .../completeInvoiceConfirmation.ts | 128 ++++++++++++++++++ .../utils/testInitUtils/createTestContext.ts | 6 +- shared/enums/SuccessCode.ts | 1 + shared/index.ts | 1 - shared/models/otherModels/metadataModels.ts | 16 +-- shared/models/otherModels/metadataTable.ts | 14 +- 38 files changed, 1017 insertions(+), 349 deletions(-) create mode 100644 server/src/cron/invoiceCron/runInvoiceCron.ts create mode 100644 server/src/cron/utils/CronContext.ts create mode 100644 server/src/external/stripe/webhookHandlers/handleInvoicePaid/handleInvoiceActionRequiredCompleted.ts create mode 100644 server/src/external/stripe/webhookHandlers/handleInvoicePaid/handleInvoiceCheckoutPaid.ts create mode 100644 server/src/external/stripe/webhookHandlers/handleInvoicePaid/handleInvoicePaidMetadata.ts create mode 100644 server/src/internal/billing/attach/utils/attachParamsToMetadata.ts delete mode 100644 server/src/internal/customers/attach/attachFunctions/invoiceCheckoutPaid/handleInvoiceCheckoutPaid.ts create mode 100644 server/tests/billing/invoice-action-required/invoice-action-required1.test.ts create mode 100644 server/tests/billing/invoice-action-required/invoice-action-required2.test.ts rename server/tests/{attach/upgrade/upgrade6.test.ts => billing/invoice-action-required/invoice-action-required3.test.ts} (77%) create mode 100644 server/tests/utils/stripeUtils/completeInvoiceConfirmation.ts diff --git a/scripts/migrations/migrate-functions.ts b/scripts/migrations/migrate-functions.ts index ea1ca7edd..0073a24a7 100644 --- a/scripts/migrations/migrate-functions.ts +++ b/scripts/migrations/migrate-functions.ts @@ -1,8 +1,15 @@ -import { initializeDatabaseFunctions } from "@server/db/initializeDatabaseFunctions"; -import inquirer from "inquirer"; +loadLocalEnv(); +import { loadLocalEnv } from "@server/utils/envUtils"; +import inquirer from "inquirer"; export const migrateFunctions = async () => { + // Dynamic import to ensure env is loaded first + const { initializeDatabaseFunctions } = await import( + "@server/db/initializeDatabaseFunctions" + ); + const databaseUrl = process.env.DATABASE_URL; + console.log("databaseUrl", databaseUrl); if (databaseUrl?.includes("us-west-3")) { const { confirm } = await inquirer.prompt([ { diff --git a/scripts/testGroups/g2.sh b/scripts/testGroups/g2.sh index aff1d0319..1c61fc445 100755 --- a/scripts/testGroups/g2.sh +++ b/scripts/testGroups/g2.sh @@ -11,4 +11,5 @@ BUN_PARALLEL_COMPACT \ 'server/tests/attach/addOn' \ 'server/tests/attach/checkout' \ 'server/tests/attach/misc' \ + 'server/tests/billing/invoice-action-required' \ --max=6 \ diff --git a/server/src/cron/cronInit.ts b/server/src/cron/cronInit.ts index f0547beb8..9efb746a2 100644 --- a/server/src/cron/cronInit.ts +++ b/server/src/cron/cronInit.ts @@ -4,13 +4,16 @@ import { UTCDate } from "@date-fns/utc"; import { CronJob } from "cron"; import { format } from "date-fns"; import { initDrizzle } from "../db/initDrizzle.js"; +import { logger } from "../external/logtail/logtailUtils.js"; import { CusEntService } from "../internal/customers/cusProducts/cusEnts/CusEntitlementService.js"; import { notNullish } from "../utils/genUtils.js"; import { clearCusEntsFromCache, resetCustomerEntitlement, } from "./cronUtils.js"; +import { runInvoiceCron } from "./invoiceCron/runInvoiceCron.js"; import { runProductCron } from "./productCron/runProductCron.js"; +import type { CronContext } from "./utils/CronContext.js"; const { db, client } = initDrizzle(); @@ -66,7 +69,17 @@ const main = async () => { console.log(`Cron disabled!`); return; } - await Promise.all([cronTask(), runProductCron()]); + + const ctx: CronContext = { + db, + logger, + }; + await Promise.all([ + cronTask(), + runProductCron(), + runInvoiceCron({ ctx }), + // TODO: Add runUsageCron({ ctx }) + ]); }; new CronJob( diff --git a/server/src/cron/invoiceCron/runInvoiceCron.ts b/server/src/cron/invoiceCron/runInvoiceCron.ts new file mode 100644 index 000000000..72399f975 --- /dev/null +++ b/server/src/cron/invoiceCron/runInvoiceCron.ts @@ -0,0 +1,65 @@ +import { type Metadata, MetadataType, metadata } from "@autumn/shared"; + +import { and, eq, lt } from "drizzle-orm"; +import { createStripeCli } from "../../external/connect/createStripeCli"; +import type { AttachParams } from "../../internal/customers/cusProducts/AttachParams"; +import type { CronContext } from "../utils/CronContext"; + +export const handleVoidInvoiceCron = async ({ + ctx, + metadata, +}: { + ctx: CronContext; + metadata: Metadata; +}) => { + const { logger } = ctx; + const data = metadata.data as AttachParams; + const { org, customer } = data; + const stripeCli = createStripeCli({ org, env: customer.env }); + + if (!metadata.stripe_invoice_id) { + return; + } + + const invoice = await stripeCli.invoices.retrieve(metadata.stripe_invoice_id); + if (invoice.status === "open") { + try { + await stripeCli.invoices.voidInvoice(metadata.stripe_invoice_id); + logger.info( + `voided invoice ${metadata.stripe_invoice_id} for customer ${customer.id} (org: ${org.slug})`, + ); + } catch (error) { + logger.error(`Error voiding invoice: ${error}`); + } + } +}; + +export const runInvoiceCron = async ({ ctx }: { ctx: CronContext }) => { + console.log("Running invoice cron"); + const { db } = ctx; + + // 1. Fetch from metadata invoices + const invoices = await db + .select() + .from(metadata) + .where( + and( + eq(metadata.type, MetadataType.InvoiceActionRequired), + lt(metadata.expires_at, Date.now()), + ), + ); + + const batchSize = 50; + for (let i = 0; i < invoices.length; i += batchSize) { + const batch = invoices.slice(i, i + batchSize); + + const promises = []; + for (const metadata of batch) { + promises.push(handleVoidInvoiceCron({ ctx, metadata })); + } + await Promise.all(promises); + console.log(`Handled ${i + batch.length}/${invoices.length} invoices`); + console.log("----------------------------------\n"); + } + console.log("FINISHED INVOICE CRON"); +}; diff --git a/server/src/cron/utils/CronContext.ts b/server/src/cron/utils/CronContext.ts new file mode 100644 index 000000000..d5c554cd5 --- /dev/null +++ b/server/src/cron/utils/CronContext.ts @@ -0,0 +1,7 @@ +import type { DrizzleCli } from "../../db/initDrizzle"; +import type { Logger } from "../../external/logtail/logtailUtils"; + +export interface CronContext { + db: DrizzleCli; + logger: Logger; +} diff --git a/server/src/external/stripe/handleStripeWebhookEvent.ts b/server/src/external/stripe/handleStripeWebhookEvent.ts index 5dc52e3ed..ee9adcae9 100644 --- a/server/src/external/stripe/handleStripeWebhookEvent.ts +++ b/server/src/external/stripe/handleStripeWebhookEvent.ts @@ -203,12 +203,9 @@ export const handleStripeWebhookEvent = async ({ case "invoice.paid": { const invoice = event.data.object; await handleInvoicePaid({ - db, - org, + ctx, invoiceData: invoice, - env, event, - req: ctx as unknown as ExtendedRequest, }); break; } diff --git a/server/src/external/stripe/stripeCusUtils.ts b/server/src/external/stripe/stripeCusUtils.ts index b3915b595..50e1e74bf 100644 --- a/server/src/external/stripe/stripeCusUtils.ts +++ b/server/src/external/stripe/stripeCusUtils.ts @@ -11,6 +11,7 @@ import type { DrizzleCli } from "@/db/initDrizzle.js"; import { createStripeCli } from "@/external/connect/createStripeCli.js"; import { CusService } from "@/internal/customers/CusService.js"; import RecaseError from "@/utils/errorUtils.js"; +import type { TestContext } from "../../../tests/utils/testInitUtils/createTestContext"; export const getStripeCus = async ({ stripeCli, @@ -313,6 +314,38 @@ export const attachFailedPaymentMethod = async ({ }); }; +export const attachAuthenticatePaymentMethod = async ({ + ctx, + customerId, +}: { + ctx: TestContext; + customerId: string; +}) => { + const { org, env, db } = ctx; + const stripeCli = createStripeCli({ org, env }); + const autumnCustomer = await CusService.get({ + db, + idOrInternalId: customerId, + orgId: org.id, + env: env, + }); + + const stripeCustomer = await stripeCli.customers.retrieve( + autumnCustomer!.processor?.id, + ); + // Delete existing payment method + const paymentMethods = await stripeCli.paymentMethods.list({ + customer: stripeCustomer.id, + }); + for (const pm of paymentMethods.data) { + await stripeCli.paymentMethods.detach(pm.id); + } + + await stripeCli.paymentMethods.attach("pm_card_authenticationRequired", { + customer: stripeCustomer.id, + }); +}; + export const deleteAllStripeCustomers = async ({ org, env, diff --git a/server/src/external/stripe/stripeInvoiceUtils.ts b/server/src/external/stripe/stripeInvoiceUtils.ts index c108b0aa9..e9e1768c2 100644 --- a/server/src/external/stripe/stripeInvoiceUtils.ts +++ b/server/src/external/stripe/stripeInvoiceUtils.ts @@ -114,7 +114,11 @@ export const payForInvoice = async ({ } if (errorOnFail) { - throw error; + throw new RecaseError({ + message: error?.message, + code: ErrCode.PayInvoiceFailed, + data: invoice, + }); } else { return { paid: false, @@ -122,7 +126,7 @@ export const payForInvoice = async ({ message: `Failed to pay invoice: ${error?.message || error}`, code: ErrCode.PayInvoiceFailed, }), - invoice: null, + invoice: invoice, }; } } diff --git a/server/src/external/stripe/stripeSubUtils/updateStripeSub/createProrationinvoice.ts b/server/src/external/stripe/stripeSubUtils/updateStripeSub/createProrationinvoice.ts index e8d78c7d3..245e65fa2 100644 --- a/server/src/external/stripe/stripeSubUtils/updateStripeSub/createProrationinvoice.ts +++ b/server/src/external/stripe/stripeSubUtils/updateStripeSub/createProrationinvoice.ts @@ -1,7 +1,10 @@ -import { ErrCode } from "@autumn/shared"; +import { InternalError, MetadataType } from "@autumn/shared"; +import { addMinutes } from "date-fns"; import type Stripe from "stripe"; import type { AttachParams } from "@/internal/customers/cusProducts/AttachParams.js"; -import RecaseError from "@/utils/errorUtils.js"; +import type { AutumnContext } from "../../../../honoUtils/HonoEnv.js"; +import { attachParamsToMetadata } from "../../../../internal/billing/attach/utils/attachParamsToMetadata.js"; +import type { Logger } from "../../../logtail/logtailUtils.js"; import { payForInvoice } from "../../stripeInvoiceUtils.js"; export const undoSubUpdate = async ({ @@ -55,17 +58,19 @@ export const undoSubUpdate = async ({ }; export const createProrationInvoice = async ({ + ctx, attachParams, invoiceOnly, curSub, updatedSub, logger, }: { + ctx: AutumnContext; attachParams: AttachParams; invoiceOnly: boolean; curSub: Stripe.Subscription; updatedSub: Stripe.Subscription; - logger: any; + logger: Logger; }) => { const { stripeCli, customer, paymentMethod } = attachParams; @@ -77,49 +82,78 @@ export const createProrationInvoice = async ({ if (items.data.length === 0) { logger.info(`No items to prorate, skipping invoice creation`); - return null; + return { + invoice: null, + url: null, + }; } - // const shouldMemo = attachParams.org.config.invoice_memos && invoiceOnly; - // const invoiceMemo = shouldMemo - // ? await buildInvoiceMemoFromEntitlements({ - // org: attachParams.org, - // entitlements: attachParams.entitlements, - // features: attachParams.features, - // }) - // : undefined; - const invoice = await stripeCli.invoices.create({ customer: customer.processor.id, - subscription: curSub.id, + // subscription: curSub.id, auto_advance: false, - // ...(shouldMemo ? { description: invoiceMemo } : {}), + pending_invoice_items_behavior: "include", }); - if (invoiceOnly) return invoice; + if (invoiceOnly) + return { + invoice, + url: null, + }; await stripeCli.invoices.finalizeInvoice(invoice.id!, { auto_advance: false, }); - try { - const { invoice: subInvoice } = await payForInvoice({ - stripeCli, - paymentMethod: paymentMethod || null, - invoiceId: invoice.id!, - logger, - voidIfFailed: true, - }); + const { + paid, + error, + invoice: subInvoice, + } = await payForInvoice({ + stripeCli, + paymentMethod: paymentMethod || null, + invoiceId: invoice.id!, + logger, + voidIfFailed: false, + errorOnFail: false, + }); - return subInvoice; - } catch (error: any) { + if (!paid) { await undoSubUpdate({ stripeCli, curSub, updatedSub }); - throw new RecaseError({ - code: ErrCode.UpdateSubscriptionFailed, - message: `Failed to update subscription. ${error.message}`, - statusCode: 500, - data: `Stripe error: ${error.message}`, - }); + if (subInvoice && subInvoice.status === "open") { + logger.info( + `[update subscription] invoice action required: ${subInvoice.id}`, + ); + const metadata = await attachParamsToMetadata({ + db: ctx.db, + attachParams, + type: MetadataType.InvoiceActionRequired, + stripeInvoiceId: subInvoice.id, + expiresAt: addMinutes(Date.now(), 10).getTime(), + }); + + await stripeCli.invoices.update(subInvoice.id, { + metadata: { + autumn_metadata_id: metadata.id, + }, + }); + return { + invoice: subInvoice, + url: subInvoice?.hosted_invoice_url, + }; + } else { + throw new InternalError({ + message: `[update subscription] Failed to pay invoice: ${error?.message}`, + code: "update_subscription_failed", + statusCode: 500, + data: error, + }); + } } + + return { + invoice: subInvoice, + url: null, + }; }; diff --git a/server/src/external/stripe/webhookHandlers/handleCheckoutCompleted.ts b/server/src/external/stripe/webhookHandlers/handleCheckoutCompleted.ts index 39dcf5239..618e65955 100644 --- a/server/src/external/stripe/webhookHandlers/handleCheckoutCompleted.ts +++ b/server/src/external/stripe/webhookHandlers/handleCheckoutCompleted.ts @@ -46,7 +46,7 @@ export const handleCheckoutSessionCompleted = async ({ // Get options const stripeCli = createStripeCli({ org, env }); - const attachParams: AttachParams = metadata.data; + const attachParams: AttachParams = metadata.data as AttachParams; const checkoutSession = await stripeCli.checkout.sessions.retrieve(data.id, { expand: ["line_items", "subscription"], }); diff --git a/server/src/external/stripe/webhookHandlers/handleInvoicePaid.ts b/server/src/external/stripe/webhookHandlers/handleInvoicePaid.ts index a5ce49fbf..07161ffab 100644 --- a/server/src/external/stripe/webhookHandlers/handleInvoicePaid.ts +++ b/server/src/external/stripe/webhookHandlers/handleInvoicePaid.ts @@ -8,13 +8,13 @@ import type { import type Stripe from "stripe"; import type { DrizzleCli } from "@/db/initDrizzle.js"; import { createStripeCli } from "@/external/connect/createStripeCli.js"; -import { handleInvoiceCheckoutPaid } from "@/internal/customers/attach/attachFunctions/invoiceCheckoutPaid/handleInvoiceCheckoutPaid.js"; import { CusProductService } from "@/internal/customers/cusProducts/CusProductService.js"; import { InvoiceService } from "@/internal/invoices/InvoiceService.js"; import { getInvoiceItems } from "@/internal/invoices/invoiceUtils.js"; import { JobName } from "@/queue/JobName.js"; import { addTaskToQueue } from "@/queue/queueUtils.js"; import { nullish } from "@/utils/genUtils.js"; +import type { AutumnContext } from "../../../honoUtils/HonoEnv.js"; import { getFullStripeInvoice, getInvoiceDiscounts, @@ -23,6 +23,7 @@ import { } from "../stripeInvoiceUtils.js"; import { lineItemInCusProduct } from "../stripeSubUtils/stripeSubItemUtils.js"; import { getStripeSubs } from "../stripeSubUtils.js"; +import { handleInvoicePaidMetadata } from "./handleInvoicePaid/handleInvoicePaidMetadata.js"; import { handleInvoicePaidDiscount } from "./handleInvoicePaidDiscount.js"; const handleOneOffInvoicePaid = async ({ @@ -137,21 +138,15 @@ const convertToChargeAutomatically = async ({ }; export const handleInvoicePaid = async ({ - db, - req, - org, + ctx, invoiceData, - env, event, }: { - db: DrizzleCli; - req: any; - org: Organization; + ctx: AutumnContext; invoiceData: Stripe.Invoice; - env: AppEnv; event: Stripe.Event; }) => { - const logger = req.logger; + const { logger, org, env, db } = ctx; const stripeCli = createStripeCli({ org, env }); const invoice = await getFullStripeInvoice({ stripeCli, @@ -160,12 +155,8 @@ export const handleInvoicePaid = async ({ }); if (invoice.metadata?.autumn_metadata_id) { - await handleInvoiceCheckoutPaid({ - req, - org, - env, - db, - stripeCli, + await handleInvoicePaidMetadata({ + ctx, invoice, }); } diff --git a/server/src/external/stripe/webhookHandlers/handleInvoicePaid/handleInvoiceActionRequiredCompleted.ts b/server/src/external/stripe/webhookHandlers/handleInvoicePaid/handleInvoiceActionRequiredCompleted.ts new file mode 100644 index 000000000..01981eaa0 --- /dev/null +++ b/server/src/external/stripe/webhookHandlers/handleInvoicePaid/handleInvoiceActionRequiredCompleted.ts @@ -0,0 +1,74 @@ +import { AttachBranch, type Metadata, ProrationBehavior } from "@autumn/shared"; +import type Stripe from "stripe"; +import type { AutumnContext } from "../../../../honoUtils/HonoEnv"; +import { resetUsageBalances } from "../../../../internal/customers/attach/attachFunctions/upgradeDiffIntFlow/createUsageInvoiceItems"; +import { handleUpgradeFlow } from "../../../../internal/customers/attach/attachFunctions/upgradeFlow/handleUpgradeFlow"; +import { attachParamToCusProducts } from "../../../../internal/customers/attach/attachUtils/convertAttachParams"; +import { getDefaultAttachConfig } from "../../../../internal/customers/attach/attachUtils/getAttachConfig"; +import type { AttachParams } from "../../../../internal/customers/cusProducts/AttachParams"; +import { deleteCachedApiCustomer } from "../../../../internal/customers/cusUtils/apiCusCacheUtils/deleteCachedApiCustomer"; +import { MetadataService } from "../../../../internal/metadata/MetadataService"; +import { createStripeCli } from "../../../connect/createStripeCli"; +import { getCusPaymentMethod } from "../../stripeCusUtils"; + +export const handleInvoiceActionRequiredCompleted = async ({ + ctx, + invoice, + metadata, +}: { + ctx: AutumnContext; + invoice: Stripe.Invoice; + metadata: Metadata; +}) => { + const { logger, org, env } = ctx; + logger.info(`invoice.paid, handling action required`); + + const stripeCli = createStripeCli({ org, env }); + + const paymentMethod = await getCusPaymentMethod({ + stripeCli, + stripeId: invoice.customer as string, + }); + + const attachParams = { + ...(metadata.data as AttachParams), + stripeCli, + req: ctx, + paymentMethod, + } as AttachParams; + + const attachConfig = { + ...getDefaultAttachConfig(), + proration: ProrationBehavior.None, + }; + + ctx.logger.info(`handling upgrade flow for invoice ${invoice.id}`); + + const { curMainProduct } = attachParamToCusProducts({ attachParams }); + + await handleUpgradeFlow({ + ctx, + attachParams, + config: attachConfig, + branch: AttachBranch.Upgrade, + }); + + if (attachParams.cusEntIds && curMainProduct) { + await resetUsageBalances({ + db: ctx.db, + cusEntIds: attachParams.cusEntIds, + cusProduct: curMainProduct, + }); + } + + await MetadataService.delete({ + db: ctx.db, + id: metadata.id, + }); + + await deleteCachedApiCustomer({ + customerId: attachParams.customer.id || "", + orgId: attachParams.org.id, + env: attachParams.customer.env, + }); +}; diff --git a/server/src/external/stripe/webhookHandlers/handleInvoicePaid/handleInvoiceCheckoutPaid.ts b/server/src/external/stripe/webhookHandlers/handleInvoicePaid/handleInvoiceCheckoutPaid.ts new file mode 100644 index 000000000..651843050 --- /dev/null +++ b/server/src/external/stripe/webhookHandlers/handleInvoicePaid/handleInvoiceCheckoutPaid.ts @@ -0,0 +1,56 @@ +import type { Metadata } from "@autumn/shared"; +import { AttachScenario } from "@autumn/shared"; +import { createFullCusProduct } from "@/internal/customers/add-product/createFullCusProduct.js"; +import type { AttachParams } from "@/internal/customers/cusProducts/AttachParams.js"; +import { attachToInsertParams } from "@/internal/products/productUtils.js"; +import type { AutumnContext } from "../../../../honoUtils/HonoEnv.js"; +import { deleteCachedApiCustomer } from "../../../../internal/customers/cusUtils/apiCusCacheUtils/deleteCachedApiCustomer.js"; + +export const handleInvoiceCheckoutPaid = async ({ + ctx, + metadata, +}: { + ctx: AutumnContext; + metadata: Metadata; +}) => { + const { logger, org, env, db } = ctx; + + const { subId, anchorToUnix, config, ...rest } = + metadata.data as AttachParams; + + const attachParams = rest; + + 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: subId ? [subId] : undefined, + anchorToUnix, + carryExistingUsages: config?.carryUsage, + scenario: AttachScenario.New, + logger: logger, + }), + ); + } + + await Promise.all(batchInsert); + + logger.info( + `✅ invoice.paid, successfully inserted cus products: ${attachParams.products.map((p) => p.id).join(", ")}`, + ); + + await deleteCachedApiCustomer({ + customerId: attachParams.customer.id || "", + orgId: org.id, + env, + }); +}; diff --git a/server/src/external/stripe/webhookHandlers/handleInvoicePaid/handleInvoicePaidMetadata.ts b/server/src/external/stripe/webhookHandlers/handleInvoicePaid/handleInvoicePaidMetadata.ts new file mode 100644 index 000000000..a34632d2e --- /dev/null +++ b/server/src/external/stripe/webhookHandlers/handleInvoicePaid/handleInvoicePaidMetadata.ts @@ -0,0 +1,45 @@ +import { MetadataType } from "@autumn/shared"; +import type Stripe from "stripe"; +import type { AutumnContext } from "../../../../honoUtils/HonoEnv"; +import { MetadataService } from "../../../../internal/metadata/MetadataService"; +import { handleInvoiceActionRequiredCompleted } from "./handleInvoiceActionRequiredCompleted"; +import { handleInvoiceCheckoutPaid } from "./handleInvoiceCheckoutPaid"; + +export const handleInvoicePaidMetadata = async ({ + ctx, + invoice, +}: { + ctx: AutumnContext; + invoice: Stripe.Invoice; +}) => { + const metadataId = invoice.metadata?.autumn_metadata_id; + + if (!metadataId) return; + + const metadata = await MetadataService.get({ + db: ctx.db, + id: metadataId, + }); + + if (!metadata) return; + + if (metadata.type === MetadataType.InvoiceActionRequired) { + await handleInvoiceActionRequiredCompleted({ + ctx, + invoice, + metadata, + }); + + return; + } + + await handleInvoiceCheckoutPaid({ + ctx, + metadata, + }); + + await MetadataService.delete({ + db: ctx.db, + id: metadata.id, + }); +}; diff --git a/server/src/external/stripe/webhookHandlers/handleInvoiceUpdated.ts b/server/src/external/stripe/webhookHandlers/handleInvoiceUpdated.ts index 046459f44..ffa716e69 100644 --- a/server/src/external/stripe/webhookHandlers/handleInvoiceUpdated.ts +++ b/server/src/external/stripe/webhookHandlers/handleInvoiceUpdated.ts @@ -38,11 +38,13 @@ const handleInvoiceCheckoutVoided = async ({ id: metadataId, }); + if (!metadata) return; + const { anchorToUnix: _anchorToUnix, config: _config, ...rest - } = metadata?.data || {}; + } = metadata.data as AttachParams; const attachParams = rest as AttachParams; diff --git a/server/src/internal/billing/attach/utils/attachParamsToMetadata.ts b/server/src/internal/billing/attach/utils/attachParamsToMetadata.ts new file mode 100644 index 000000000..8a995be57 --- /dev/null +++ b/server/src/internal/billing/attach/utils/attachParamsToMetadata.ts @@ -0,0 +1,43 @@ +import type { MetadataInsert, MetadataType } from "@autumn/shared"; +import { addDays } from "date-fns"; +import type { DrizzleCli } from "../../../../db/initDrizzle"; +import { generateId } from "../../../../utils/genUtils"; +import type { AttachParams } from "../../../customers/cusProducts/AttachParams"; +import { MetadataService } from "../../../metadata/MetadataService"; + +export const attachParamsToMetadata = async ({ + db, + attachParams, + type, + stripeInvoiceId, + expiresAt, +}: { + db: DrizzleCli; + attachParams: AttachParams; + type: MetadataType; + stripeInvoiceId?: string; + expiresAt?: number; +}) => { + const { + req: _req, + checkoutSessionParams: _checkoutSessionParams, + stripeCli: _stripeCli, + paymentMethod: _paymentMethod, + ...rest + } = attachParams; + + const attachClone = structuredClone(rest); + + const metadata: MetadataInsert = { + id: generateId("meta"), + created_at: Date.now(), + expires_at: expiresAt ?? addDays(Date.now(), 10).getTime(), + data: attachClone, + type, + stripe_invoice_id: stripeInvoiceId, + }; + + await MetadataService.insert({ db, data: metadata }); + + return metadata; +}; diff --git a/server/src/internal/customers/add-product/handleCreateCheckout.ts b/server/src/internal/customers/add-product/handleCreateCheckout.ts index abc68bf2e..55e683b84 100644 --- a/server/src/internal/customers/add-product/handleCreateCheckout.ts +++ b/server/src/internal/customers/add-product/handleCreateCheckout.ts @@ -1,13 +1,13 @@ import { type AttachConfig, AttachFunctionResponseSchema, + MetadataType, RecaseError, SuccessCode, } from "@autumn/shared"; import type Stripe from "stripe"; import { createStripeCli } from "@/external/connect/createStripeCli.js"; import { getStripeSubItems } from "@/external/stripe/stripeSubUtils/getStripeSubItems.js"; -import { createCheckoutMetadata } from "@/internal/metadata/metadataUtils.js"; import { toSuccessUrl } from "@/internal/orgs/orgUtils/convertOrgUtils.js"; import { orgToCurrency } from "@/internal/orgs/orgUtils.js"; import { freeTrialToStripeTimestamp } from "@/internal/products/free-trials/freeTrialUtils.js"; @@ -15,6 +15,7 @@ import { getNextStartOfMonthUnix } from "@/internal/products/prices/billingInter import { pricesContainRecurring } from "@/internal/products/prices/priceUtils.js"; import { notNullish } from "@/utils/genUtils.js"; import type { AutumnContext } from "../../../honoUtils/HonoEnv.js"; +import { attachParamsToMetadata } from "../../billing/attach/utils/attachParamsToMetadata.js"; import type { AttachParams } from "../cusProducts/AttachParams.js"; export const handleCreateCheckout = async ({ @@ -57,9 +58,10 @@ export const handleCreateCheckout = async ({ const isRecurring = pricesContainRecurring(attachParams.prices); // Insert metadata - const metaId = await createCheckoutMetadata({ + const metadata = await attachParamsToMetadata({ db, attachParams, + type: MetadataType.CheckoutSessionCompleted, }); let billingCycleAnchorUnixSeconds = org.config.anchor_start_of_month @@ -136,7 +138,7 @@ export const handleCreateCheckout = async ({ metadata: { ...(attachParams.metadata ? attachParams.metadata : {}), ...(checkoutParams?.metadata || {}), - autumn_metadata_id: metaId, + autumn_metadata_id: metadata.id, }, payment_method_collection: freeTrial && @@ -155,7 +157,7 @@ export const handleCreateCheckout = async ({ ...checkoutParams, metadata: { ...(checkoutParams?.metadata || {}), - autumn_metadata_id: metaId, + autumn_metadata_id: metadata.id, }, }; } diff --git a/server/src/internal/customers/add-product/handleCreateInvoiceCheckout.ts b/server/src/internal/customers/add-product/handleCreateInvoiceCheckout.ts index 303926c56..7b1aa1bc0 100644 --- a/server/src/internal/customers/add-product/handleCreateInvoiceCheckout.ts +++ b/server/src/internal/customers/add-product/handleCreateInvoiceCheckout.ts @@ -2,11 +2,12 @@ import { type AttachConfig, type AttachFunctionResponse, AttachFunctionResponseSchema, + MetadataType, SuccessCode, } from "@autumn/shared"; -import { createCheckoutMetadata } from "@/internal/metadata/metadataUtils.js"; import { isOneOff } from "@/internal/products/productUtils.js"; import type { AutumnContext } from "../../../honoUtils/HonoEnv.js"; +import { attachParamsToMetadata } from "../../billing/attach/utils/attachParamsToMetadata.js"; import { handleOneOffFunction } from "../attach/attachFunctions/addProductFlow/handleOneOffFunction.js"; import { handlePaidProduct } from "../attach/attachFunctions/addProductFlow/handlePaidProduct.js"; import type { AttachParams } from "../cusProducts/AttachParams.js"; @@ -39,13 +40,9 @@ export const handleCreateInvoiceCheckout = async ({ }); } - // const { invoices, anchorToUnix, subs } = invoiceResult; const { invoice, stripeSub, anchorToUnix } = invoiceResult; - // console.log("finalize invoice:", config.finalizeInvoice); - // console.log("invoice hosted url:", invoice?.hosted_invoice_url); - - const metadataId = await createCheckoutMetadata({ + const metadata = await attachParamsToMetadata({ db: ctx.db, attachParams: { ...attachParams, @@ -53,12 +50,13 @@ export const handleCreateInvoiceCheckout = async ({ subId: stripeSub?.id, config, }, + type: MetadataType.InvoiceCheckout, }); if (invoice) { await stripeCli.invoices.update(invoice.id, { metadata: { - autumn_metadata_id: metadataId, + autumn_metadata_id: metadata.id, }, }); } @@ -73,52 +71,5 @@ export const handleCreateInvoiceCheckout = async ({ message: `Successfully created invoice checkout for customer ${customerId}, product(s) ${productNames}`, code: SuccessCode.CheckoutCreated, invoice: config.finalizeInvoice ? undefined : invoice, // if finalizeInvoice, checkout_url is used - // invoice, - // stripeSub, - // anchorToUnix, - // config, }); - - // if (res) { - // if (!config.finalizeInvoice) { - // res.status(200).json( - // AttachResultSchema.parse({ - // invoice: invoices[0], - // code: SuccessCode.CheckoutCreated, - // message: `Successfully created invoice 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; - // } - // 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 }; }; - -// if (attachParams.productsList) { -// invoiceResult = await handleMultiAttachFlow({ -// req, -// res, -// attachParams, -// attachBody, -// branch, -// config, -// }); -// } else diff --git a/server/src/internal/customers/attach/attachFunctions/invoiceCheckoutPaid/handleInvoiceCheckoutPaid.ts b/server/src/internal/customers/attach/attachFunctions/invoiceCheckoutPaid/handleInvoiceCheckoutPaid.ts deleted file mode 100644 index bda91760c..000000000 --- a/server/src/internal/customers/attach/attachFunctions/invoiceCheckoutPaid/handleInvoiceCheckoutPaid.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { type AppEnv, AttachScenario, type Organization } from "@autumn/shared"; -import type Stripe from "stripe"; -import type { DrizzleCli } from "@/db/initDrizzle.js"; -import { createFullCusProduct } from "@/internal/customers/add-product/createFullCusProduct.js"; -import type { AttachParams } from "@/internal/customers/cusProducts/AttachParams.js"; -import { MetadataService } from "@/internal/metadata/MetadataService.js"; -import { attachToInsertParams } from "@/internal/products/productUtils.js"; -import type { ExtendedRequest } from "@/utils/models/Request.js"; -import { deleteCachedApiCustomer } from "../../../cusUtils/apiCusCacheUtils/deleteCachedApiCustomer.js"; - -export const handleInvoiceCheckoutPaid = async ({ - req, - org, - env, - db, - stripeCli, - invoice, -}: { - req: ExtendedRequest; - org: Organization; - env: AppEnv; - db: DrizzleCli; - stripeCli: Stripe; - invoice: Stripe.Invoice; -}) => { - const { logger } = req; - const metadataId = invoice.metadata?.autumn_metadata_id; - - if (!metadataId) return; - - const metadata = await MetadataService.get({ - db, - id: metadataId, - }); - - const { subId, anchorToUnix, config, ...rest }: AttachParams = - metadata?.data ?? {}; - - const attachParams = rest; - - if (!attachParams) return; - - const reqMatch = - attachParams.org.id === org.id && attachParams.customer.env === env; - - if (!reqMatch) return; - - // if (attachParams.productsList) { - // console.log("Inserting products list"); - // for (const productOptions of attachParams.productsList) { - // const product = attachParams.products.find( - // (p) => p.id === productOptions.product_id, - // ); - - // if (!product) { - // logger.error( - // `checkout.completed: product not found for productOptions: ${JSON.stringify( - // productOptions, - // )}`, - // ); - // continue; - // } - - // await createFullCusProduct({ - // db, - // attachParams: attachToInsertParams( - // attachParams, - // product, - // productOptions.entity_id || undefined, - // ), - // subscriptionIds: subIds, - // anchorToUnix, - // scenario: AttachScenario.New, - // logger, - // productOptions, - // }); - // } - // } else { - - // } - - const batchInsert = []; - for (const product of attachParams.products) { - batchInsert.push( - createFullCusProduct({ - db, - attachParams: attachToInsertParams(attachParams, product), - subscriptionIds: subId ? [subId] : undefined, - 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(", ")}`, - ); - - await deleteCachedApiCustomer({ - customerId: attachParams.customer.id || "", - orgId: org.id, - env, - }); -}; diff --git a/server/src/internal/customers/attach/attachFunctions/upgradeFlow/handleUpgradeFlow.ts b/server/src/internal/customers/attach/attachFunctions/upgradeFlow/handleUpgradeFlow.ts index ccd01cd13..d50f353b2 100644 --- a/server/src/internal/customers/attach/attachFunctions/upgradeFlow/handleUpgradeFlow.ts +++ b/server/src/internal/customers/attach/attachFunctions/upgradeFlow/handleUpgradeFlow.ts @@ -14,7 +14,7 @@ import { getStripeSubItems2 } from "@/external/stripe/stripeSubUtils/getStripeSu import { subIsCanceled } from "@/external/stripe/stripeSubUtils.js"; import { addProductsUpdatedWebhookTask } from "@/internal/analytics/handlers/handleProductsUpdated.js"; import { createFullCusProduct } from "@/internal/customers/add-product/createFullCusProduct.js"; -import { type AttachParams } from "@/internal/customers/cusProducts/AttachParams.js"; +import type { AttachParams } from "@/internal/customers/cusProducts/AttachParams.js"; import { CusProductService } from "@/internal/customers/cusProducts/CusProductService.js"; import { getExistingCusProducts } from "@/internal/customers/cusProducts/cusProductUtils/getExistingCusProducts.js"; import { @@ -123,11 +123,6 @@ export const handleUpgradeFlow = async ({ logger.info(`UPGRADE FLOW, updating sub ${curSub.id}`); itemSet.subItems = subItems; - // await logPhaseItems({ - // db: req.db, - // items: itemSet.subItems, - // }); - const res = await updateStripeSub2({ ctx, attachParams, @@ -147,6 +142,14 @@ export const handleUpgradeFlow = async ({ }); } + if (res?.url) { + return AttachFunctionResponseSchema.parse({ + checkout_url: res.url, + code: SuccessCode.InvoiceActionRequired, + message: `Payment action required`, + }); + } + const schedule = await paramsToCurSubSchedule({ attachParams }); if (schedule) { diff --git a/server/src/internal/customers/attach/attachFunctions/upgradeFlow/updateStripeSub2.ts b/server/src/internal/customers/attach/attachFunctions/upgradeFlow/updateStripeSub2.ts index 33b7a0972..375c7380a 100644 --- a/server/src/internal/customers/attach/attachFunctions/upgradeFlow/updateStripeSub2.ts +++ b/server/src/internal/customers/attach/attachFunctions/upgradeFlow/updateStripeSub2.ts @@ -81,6 +81,7 @@ export const updateStripeSub2 = async ({ days_until_due: 30, }), payment_behavior: "error_if_incomplete", + expand: ["latest_invoice"], }); @@ -120,8 +121,10 @@ export const updateStripeSub2 = async ({ logger, }); + let url = null; if (proration === ProrationBehavior.Immediately) { - latestInvoice = await createProrationInvoice({ + const res = await createProrationInvoice({ + ctx, attachParams, invoiceOnly, curSub, @@ -129,20 +132,30 @@ export const updateStripeSub2 = async ({ logger, }); + latestInvoice = res.invoice; + url = res.url; + console.log(`FINALIZED INVOICE ${latestInvoice?.id}`); console.log(latestInvoice?.lines.data.map((line) => line.description)); } - await resetUsageBalances({ - db, - cusEntIds, - cusProduct: curMainProduct!, - }); + // If url is returned, it means invoice action is required, so don't reset balances. + if (!url) { + await resetUsageBalances({ + db, + cusEntIds, + cusProduct: curMainProduct!, + }); + } else { + // reset balances later when invoice is paid + attachParams.cusEntIds = cusEntIds; + } return { updatedSub, latestInvoice: latestInvoice, cusEntIds, replaceables, + url, }; }; diff --git a/server/src/internal/customers/attach/mergeUtils/paramsToSubItems.ts b/server/src/internal/customers/attach/mergeUtils/paramsToSubItems.ts index cbf773c17..44d088750 100644 --- a/server/src/internal/customers/attach/mergeUtils/paramsToSubItems.ts +++ b/server/src/internal/customers/attach/mergeUtils/paramsToSubItems.ts @@ -120,10 +120,10 @@ export const paramsToSubItems = async ({ ? removeCusProducts! : getCusProductsToRemove({ attachParams }); - console.log( - "Cus products to remove:", - cusProductsToRemove.map((cp) => cp.product.name), - ); + // console.log( + // "Cus products to remove:", + // cusProductsToRemove.map((cp) => cp.product.name), + // ); const newSubItems = mergeNewSubItems({ itemSet, diff --git a/server/src/internal/customers/cusProducts/AttachParams.ts b/server/src/internal/customers/cusProducts/AttachParams.ts index b7d8bd0fb..35d71ed86 100644 --- a/server/src/internal/customers/cusProducts/AttachParams.ts +++ b/server/src/internal/customers/cusProducts/AttachParams.ts @@ -82,6 +82,10 @@ export type AttachParams = { anchorToUnix?: number; subId?: string; config?: AttachConfig; + + // Invoice action required + stripeInvoiceId?: string; + cusEntIds?: string[]; }; export type InsertCusProductParams = { diff --git a/server/src/internal/metadata/MetadataService.ts b/server/src/internal/metadata/MetadataService.ts index c40b8f6b6..924a71240 100644 --- a/server/src/internal/metadata/MetadataService.ts +++ b/server/src/internal/metadata/MetadataService.ts @@ -1,9 +1,14 @@ -import { DrizzleCli } from "@/db/initDrizzle.js"; -import { AutumnMetadata, metadata } from "@autumn/shared"; -import { eq } from "drizzle-orm"; +import { + type Metadata, + type MetadataInsert, + type MetadataType, + metadata, +} from "@autumn/shared"; +import { and, eq } from "drizzle-orm"; +import type { DrizzleCli } from "@/db/initDrizzle.js"; export class MetadataService { - static async insert({ db, data }: { db: DrizzleCli; data: AutumnMetadata }) { + static async insert({ db, data }: { db: DrizzleCli; data: MetadataInsert }) { await db.insert(metadata).values(data); } @@ -18,6 +23,33 @@ export class MetadataService { return null; } - return data[0] as AutumnMetadata; + return data[0] as Metadata; + } + + static async getByStripeInvoiceId({ + db, + stripeInvoiceId, + type, + }: { + db: DrizzleCli; + stripeInvoiceId: string; + type?: MetadataType; + }) { + const meta = await db.query.metadata.findFirst({ + where: and( + eq(metadata.stripe_invoice_id, stripeInvoiceId), + type ? eq(metadata.type, type) : undefined, + ), + }); + + if (!meta) { + return null; + } + + return meta as Metadata; + } + + static async delete({ db, id }: { db: DrizzleCli; id: string }) { + await db.delete(metadata).where(eq(metadata.id, id)); } } diff --git a/server/src/internal/metadata/metadataUtils.ts b/server/src/internal/metadata/metadataUtils.ts index e7e4c9cd7..8416936a5 100644 --- a/server/src/internal/metadata/metadataUtils.ts +++ b/server/src/internal/metadata/metadataUtils.ts @@ -1,44 +1,7 @@ -import type { AutumnMetadata } from "@autumn/shared"; -import { addDays } from "date-fns"; import type Stripe from "stripe"; import type { DrizzleCli } from "@/db/initDrizzle.js"; -import { generateId } from "@/utils/genUtils.js"; -import type { AttachParams } from "../customers/cusProducts/AttachParams.js"; import { MetadataService } from "./MetadataService.js"; -export const createCheckoutMetadata = async ({ - db, - attachParams, -}: { - db: DrizzleCli; - attachParams: AttachParams; -}) => { - const metaId = generateId("meta"); - - const { - req: _req, - checkoutSessionParams: _checkoutSessionParams, - stripeCli: _stripeCli, - paymentMethod: _paymentMethod, - ...rest - } = attachParams; - - const attachClone = structuredClone(rest); - - const metadata: AutumnMetadata = { - id: metaId, - created_at: Date.now(), - expires_at: addDays(Date.now(), 10).getTime(), // 10 days - data: { - ...attachClone, - }, - }; - - await MetadataService.insert({ db, data: metadata }); - - return metaId; -}; - export const getMetadataFromCheckoutSession = async ( checkoutSession: Stripe.Checkout.Session, db: DrizzleCli, diff --git a/server/src/utils/scriptUtils/initCustomer.ts b/server/src/utils/scriptUtils/initCustomer.ts index 19aced8b2..e0d3d3a98 100644 --- a/server/src/utils/scriptUtils/initCustomer.ts +++ b/server/src/utils/scriptUtils/initCustomer.ts @@ -150,10 +150,27 @@ export const attachPaymentMethod = async ({ }: { stripeCli: Stripe; stripeCusId: string; - type: "success" | "fail"; + type: "success" | "fail" | "authenticate"; }) => { try { const token = type === "fail" ? "tok_chargeCustomerFail" : "tok_visa"; + + if (type === "authenticate") { + await stripeCli.paymentMethods.attach("pm_card_authenticationRequired", { + customer: stripeCusId, + }); + + const pms = await stripeCli.paymentMethods.list({ + customer: stripeCusId, + }); + + await stripeCli.customers.update(stripeCusId, { + invoice_settings: { + default_payment_method: pms.data[0].id, + }, + }); + return; + } const pm = await stripeCli.paymentMethods.create({ type: "card", card: { diff --git a/server/src/utils/scriptUtils/testUtils/initCustomerV3.ts b/server/src/utils/scriptUtils/testUtils/initCustomerV3.ts index bb2dd6399..fc1b2159e 100644 --- a/server/src/utils/scriptUtils/testUtils/initCustomerV3.ts +++ b/server/src/utils/scriptUtils/testUtils/initCustomerV3.ts @@ -17,7 +17,7 @@ export const initCustomerV3 = async ({ }: { ctx: TestContext; customerId: string; - attachPm?: "success" | "fail"; + attachPm?: "success" | "fail" | "authenticate"; customerData?: CustomerData; withTestClock?: boolean; withDefault?: boolean; diff --git a/server/tests/_temp/temp1.test.ts b/server/tests/_temp/temp1.test.ts index 444ff8092..6861ed4fb 100644 --- a/server/tests/_temp/temp1.test.ts +++ b/server/tests/_temp/temp1.test.ts @@ -1,22 +1,22 @@ import { beforeAll, describe, test } from "bun:test"; -import { LegacyVersion } from "@autumn/shared"; +import { ApiVersion } from "@autumn/shared"; import { TestFeature } from "@tests/setup/v2Features.js"; import ctx from "@tests/utils/testInitUtils/createTestContext.js"; import chalk from "chalk"; import { AutumnInt } from "@/external/autumn/autumnCli.js"; import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js"; -import { - constructProduct, - constructRawProduct, -} from "@/utils/scriptUtils/createTestProducts.js"; -import { constructPriceItem } from "../../src/internal/products/product-items/productItemUtils.js"; +import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; +import { attachAuthenticatePaymentMethod } from "../../src/external/stripe/stripeCusUtils.js"; import { initCustomerV3 } from "../../src/utils/scriptUtils/testUtils/initCustomerV3.js"; import { initProductsV0 } from "../../src/utils/scriptUtils/testUtils/initProductsV0.js"; +import { expectProductAttached } from "../utils/expectUtils/expectProductAttached.js"; +import { expectSubItemsCorrect } from "../utils/expectUtils/expectSubUtils.js"; +import { completeInvoiceConfirmation } from "../utils/stripeUtils/completeInvoiceConfirmation.js"; // UNCOMMENT FROM HERE const pro = constructProduct({ type: "pro", - isDefault: true, + isDefault: false, items: [ constructFeatureItem({ @@ -27,13 +27,9 @@ const pro = constructProduct({ ], }); -const oneOff = constructRawProduct({ - id: "one-off", +const premium = constructProduct({ + type: "premium", items: [ - constructPriceItem({ - price: 10, - interval: null, - }), constructFeatureItem({ featureId: TestFeature.Messages, includedUsage: 100, @@ -43,7 +39,7 @@ const oneOff = constructRawProduct({ describe(`${chalk.yellowBright("temp: Testing add ons")}`, () => { const customerId = "temp"; - const autumn: AutumnInt = new AutumnInt({ version: LegacyVersion.v1_4 }); + const autumn: AutumnInt = new AutumnInt({ version: ApiVersion.V1_2 }); beforeAll(async () => { await initCustomerV3({ @@ -56,40 +52,61 @@ describe(`${chalk.yellowBright("temp: Testing add ons")}`, () => { await initProductsV0({ ctx, - products: [pro, oneOff], + products: [pro, premium], prefix: customerId, }); }); test("should attach pro product", async () => { - // await autumn.customers.get(customerId); - - const res = await autumn.attach({ + await autumn.attach({ customer_id: customerId, product_id: pro.id, }); - await autumn.attach({ - customer_id: customerId, - product_id: oneOff.id, + await attachAuthenticatePaymentMethod({ + ctx, + customerId, }); - await autumn.attach({ + + const res = await autumn.attach({ customer_id: customerId, - product_id: oneOff.id, + product_id: premium.id, }); + const customer = await autumn.customers.get(customerId); - console.log("Customer:", customer); + expectProductAttached({ + customer, + product: pro, + }); - // await autumn.attach({ - // customer_id: customerId, - // product_id: oneOff.id, - // }); - // await autumn.attach({ - // customer_id: customerId, - // product_id: oneOff.id, - // }); + await expectSubItemsCorrect({ + customerId, + product: pro, + stripeCli: ctx.stripeCli, + db: ctx.db, + org: ctx.org, + env: ctx.env, + }); - // const customer = await autumn.customers.get(customerId); - // console.log("Customer:", customer); + await completeInvoiceConfirmation({ + url: res.checkout_url, + }); + }); + + test("should have premium product attached", async () => { + const customer = await autumn.customers.get(customerId); + expectProductAttached({ + customer, + product: premium, + }); + + await expectSubItemsCorrect({ + customerId, + product: premium, + stripeCli: ctx.stripeCli, + db: ctx.db, + org: ctx.org, + env: ctx.env, + }); }); }); diff --git a/server/tests/billing/invoice-action-required/invoice-action-required1.test.ts b/server/tests/billing/invoice-action-required/invoice-action-required1.test.ts new file mode 100644 index 000000000..9217efc30 --- /dev/null +++ b/server/tests/billing/invoice-action-required/invoice-action-required1.test.ts @@ -0,0 +1,128 @@ +import { beforeAll, describe, expect, test } from "bun:test"; +import { ApiVersion } from "@autumn/shared"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import { expectProductAttached } from "@tests/utils/expectUtils/expectProductAttached.js"; +import { expectSubItemsCorrect } from "@tests/utils/expectUtils/expectSubUtils.js"; +import { completeInvoiceConfirmation } from "@tests/utils/stripeUtils/completeInvoiceConfirmation.js"; +import ctx from "@tests/utils/testInitUtils/createTestContext.js"; +import chalk from "chalk"; +import { AutumnInt } from "@/external/autumn/autumnCli.js"; +import { attachAuthenticatePaymentMethod } from "@/external/stripe/stripeCusUtils.js"; +import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js"; +import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; +import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; +import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js"; + +// UNCOMMENT FROM HERE +const pro = constructProduct({ + type: "pro", + isDefault: false, + + items: [ + constructFeatureItem({ + featureId: TestFeature.Credits, + includedUsage: 200, + // unlimited: true, + }), + ], +}); + +const premium = constructProduct({ + type: "premium", + items: [ + constructFeatureItem({ + featureId: TestFeature.Messages, + includedUsage: 100, + }), + ], +}); + +describe(`${chalk.yellowBright("invoice-action-required1: Testing invoice action required")}`, () => { + const customerId = "invoice-action-required1"; + const autumn: AutumnInt = new AutumnInt({ version: ApiVersion.V1_2 }); + + beforeAll(async () => { + await initCustomerV3({ + ctx, + customerId, + customerData: {}, + attachPm: "success", + withTestClock: true, + }); + + await initProductsV0({ + ctx, + products: [pro, premium], + prefix: customerId, + }); + }); + + let checkoutUrl: string; + test("should attach pro product, then upgrade to premium and get checkout_url", async () => { + await autumn.attach({ + customer_id: customerId, + product_id: pro.id, + }); + + await attachAuthenticatePaymentMethod({ + ctx, + customerId, + }); + + const res = await autumn.attach({ + customer_id: customerId, + product_id: premium.id, + }); + + expect(res.checkout_url).toBeDefined(); + + const customer = await autumn.customers.get(customerId); + expectProductAttached({ + customer, + product: pro, + }); + + await expectSubItemsCorrect({ + customerId, + product: pro, + stripeCli: ctx.stripeCli, + db: ctx.db, + org: ctx.org, + env: ctx.env, + }); + + checkoutUrl = res.checkout_url; + }); + + test("should complete invoice action required and have premium product attached", async () => { + await completeInvoiceConfirmation({ + url: checkoutUrl, + }); + + const customer = await autumn.customers.get(customerId); + expectProductAttached({ + customer, + product: premium, + }); + + await expectSubItemsCorrect({ + customerId, + product: premium, + stripeCli: ctx.stripeCli, + db: ctx.db, + org: ctx.org, + env: ctx.env, + }); + + // Cleared cache + const nonCachedCustomer = await autumn.customers.get(customerId, { + skip_cache: "true", + }); + expect(nonCachedCustomer.invoices?.[0].status).toBe("paid"); + + expectProductAttached({ + customer: nonCachedCustomer, + product: premium, + }); + }); +}); diff --git a/server/tests/billing/invoice-action-required/invoice-action-required2.test.ts b/server/tests/billing/invoice-action-required/invoice-action-required2.test.ts new file mode 100644 index 000000000..e41b9c8e8 --- /dev/null +++ b/server/tests/billing/invoice-action-required/invoice-action-required2.test.ts @@ -0,0 +1,109 @@ +import { beforeAll, describe, expect, test } from "bun:test"; +import { ApiVersion } from "@autumn/shared"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import ctx from "@tests/utils/testInitUtils/createTestContext.js"; +import chalk from "chalk"; +import { AutumnInt } from "@/external/autumn/autumnCli.js"; +import { attachAuthenticatePaymentMethod } from "@/external/stripe/stripeCusUtils.js"; +import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js"; +import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; +import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; +import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js"; +import { handleVoidInvoiceCron } from "../../../src/cron/invoiceCron/runInvoiceCron"; +import { MetadataService } from "../../../src/internal/metadata/MetadataService"; +import { timeout } from "../../utils/genUtils"; + +// UNCOMMENT FROM HERE +const pro = constructProduct({ + type: "pro", + isDefault: false, + + items: [ + constructFeatureItem({ + featureId: TestFeature.Credits, + includedUsage: 200, + // unlimited: true, + }), + ], +}); + +const premium = constructProduct({ + type: "premium", + items: [ + constructFeatureItem({ + featureId: TestFeature.Messages, + includedUsage: 100, + }), + ], +}); + +describe(`${chalk.yellowBright("invoice-action-required2: Testing void invoice cron")}`, () => { + const customerId = "invoice-action-required2"; + const autumn: AutumnInt = new AutumnInt({ version: ApiVersion.V1_2 }); + + beforeAll(async () => { + await initCustomerV3({ + ctx, + customerId, + customerData: {}, + attachPm: "success", + withTestClock: true, + }); + + await initProductsV0({ + ctx, + products: [pro, premium], + prefix: customerId, + }); + }); + + test("should attach pro product, then upgrade to premium and get checkout_url", async () => { + await autumn.attach({ + customer_id: customerId, + product_id: pro.id, + }); + + await attachAuthenticatePaymentMethod({ + ctx, + customerId, + }); + + await autumn.attach({ + customer_id: customerId, + product_id: premium.id, + }); + + // Get latest invoice for this customer + const customer = await autumn.customers.get(customerId); + expect(customer.invoices?.[0].status).toBe("open"); + + const stripeInvoices = await ctx.stripeCli.invoices.list({ + customer: customer.stripe_id!, + }); + + const latestInvoice = stripeInvoices.data[0]; + + expect(latestInvoice.metadata?.autumn_metadata_id).toBeDefined(); + const metadata = await MetadataService.get({ + db: ctx.db, + id: latestInvoice.metadata?.autumn_metadata_id ?? "", + }); + + await handleVoidInvoiceCron({ + metadata: metadata!, + ctx: { + db: ctx.db, + logger: ctx.logger, + }, + }); + + const voidedInvoice = await ctx.stripeCli.invoices.retrieve( + latestInvoice.id, + ); + expect(voidedInvoice.status).toBe("void"); + + await timeout(3000); + const customer2 = await autumn.customers.get(customerId); + expect(customer2.invoices?.[0].status).toBe("void"); + }); +}); diff --git a/server/tests/attach/upgrade/upgrade6.test.ts b/server/tests/billing/invoice-action-required/invoice-action-required3.test.ts similarity index 77% rename from server/tests/attach/upgrade/upgrade6.test.ts rename to server/tests/billing/invoice-action-required/invoice-action-required3.test.ts index f52a04f94..87ae23421 100644 --- a/server/tests/attach/upgrade/upgrade6.test.ts +++ b/server/tests/billing/invoice-action-required/invoice-action-required3.test.ts @@ -1,8 +1,7 @@ -import { beforeAll, describe, test } from "bun:test"; +import { beforeAll, describe, expect, test } from "bun:test"; import { type AppEnv, LegacyVersion, type Organization } from "@autumn/shared"; import { TestFeature } from "@tests/setup/v2Features.js"; import { attachAndExpectCorrect } from "@tests/utils/expectUtils/expectAttach.js"; -import { expectAutumnError } from "@tests/utils/expectUtils/expectErrUtils.js"; import { expectFeaturesCorrect } from "@tests/utils/expectUtils/expectFeaturesCorrect.js"; import { expectProductAttached } from "@tests/utils/expectUtils/expectProductAttached.js"; import { expectSubItemsCorrect } from "@tests/utils/expectUtils/expectSubUtils.js"; @@ -22,7 +21,9 @@ import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js"; -const testCase = "upgrade6"; +import { completeInvoiceCheckout } from "../../utils/stripeUtils/completeInvoiceCheckout"; + +const testCase = "invoice-action-required3"; export const pro = constructProduct({ items: [ @@ -46,7 +47,7 @@ export const premium = constructProduct({ type: "premium", }); -describe(`${chalk.yellowBright(`${testCase}: Testing failed upgrades`)}`, () => { +describe(`${chalk.yellowBright(`${testCase}: Testing upgrade, failed payment`)}`, () => { const customerId = testCase; const autumn: AutumnInt = new AutumnInt({ version: LegacyVersion.v1_4 }); let testClockId: string; @@ -89,6 +90,8 @@ describe(`${chalk.yellowBright(`${testCase}: Testing failed upgrades`)}`, () => }); const usage = 100012; + + let checkoutUrl: string; test("should upgrade to premium product and fail", async () => { await autumn.track({ customer_id: customerId, @@ -106,22 +109,14 @@ describe(`${chalk.yellowBright(`${testCase}: Testing failed upgrades`)}`, () => await attachFailedPaymentMethod({ stripeCli, customer: cus! }); await timeout(2000); - await expectAutumnError({ - func: async () => { - await attachAndExpectCorrect({ - autumn, - customerId, - product: premium, - stripeCli, - db, - org, - env, - }); - }, - errMessage: "Failed to update subscription. Your card was declined.", + const res = await autumn.attach({ + customer_id: customerId, + product_id: premium.id, }); - await timeout(4000); + checkoutUrl = res.checkout_url; + expect(res.checkout_url).toBeDefined(); + const customer = await autumn.customers.get(customerId); expectProductAttached({ @@ -149,4 +144,30 @@ describe(`${chalk.yellowBright(`${testCase}: Testing failed upgrades`)}`, () => env, }); }); + + test("should complete invoice and have premium product attached", async () => { + await completeInvoiceCheckout({ + url: checkoutUrl, + }); + + const customer = await autumn.customers.get(customerId); + expectProductAttached({ + customer, + product: premium, + }); + + expectFeaturesCorrect({ + customer, + product: premium, + }); + + await expectSubItemsCorrect({ + customerId, + product: premium, + stripeCli, + db, + org, + env, + }); + }); }); diff --git a/server/tests/utils/expectUtils/expectAttach.ts b/server/tests/utils/expectUtils/expectAttach.ts index 9bfd7c588..229e133f7 100644 --- a/server/tests/utils/expectUtils/expectAttach.ts +++ b/server/tests/utils/expectUtils/expectAttach.ts @@ -122,7 +122,7 @@ export const attachAndExpectCorrect = async ({ await timeout(waitForInvoice); } - let customer; + let customer: Customer; if (entityId) { customer = await autumn.entities.get(customerId, entityId); } else { diff --git a/server/tests/utils/stripeUtils/completeInvoiceConfirmation.ts b/server/tests/utils/stripeUtils/completeInvoiceConfirmation.ts new file mode 100644 index 000000000..574dc0eca --- /dev/null +++ b/server/tests/utils/stripeUtils/completeInvoiceConfirmation.ts @@ -0,0 +1,128 @@ +import "dotenv/config"; + +import puppeteer, { type Browser } from "puppeteer-core"; +import { timeout } from "../genUtils.js"; + +// const client = new Hyperbrowser({ +// apiKey: process.env.HYPERBROWSER_API_KEY, +// }); + +export const completeInvoiceConfirmation = async ({ + url, + isLocal = false, +}: { + url: string; + isLocal?: boolean; +}) => { + let browser: 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 page to be ready + await page.waitForSelector("button", { timeout: 5000 }); + + // Find and click the "Confirm payment" button + const buttonClicked = await page.evaluate(() => { + const buttons = Array.from(document.querySelectorAll("button")); + const confirmBtn = buttons.find((b) => + /confirm payment/i.test(b.textContent || ""), + ); + if (confirmBtn) { + (confirmBtn as HTMLElement).click(); + return true; + } + return false; + }); + + if (!buttonClicked) { + throw new Error("Could not find or click Confirm payment button"); + } + + // Wait for processing/navigation + await new Promise((resolve) => setTimeout(resolve, 3000)); + + // Wait for iframe with the three-ds-2-challenge URL + let threeDSFrame = null; + for (let i = 0; i < 15; i++) { + await new Promise((resolve) => setTimeout(resolve, 2000)); + const frames = page.frames(); + + threeDSFrame = frames.find((f) => + f.url().includes("three-ds-2-challenge"), + ); + + if (threeDSFrame) { + break; + } + } + + if (!threeDSFrame) { + throw new Error("Could not find 3DS challenge frame"); + } + + // Wait for the 3DS frame content to load + await new Promise((resolve) => setTimeout(resolve, 2000)); + + // Check for nested iframes + const frameContent = await threeDSFrame.evaluate(() => { + return { + hasButton: !!document.querySelector("#test-source-authorize-3ds"), + iframes: document.querySelectorAll("iframe").length, + }; + }); + + // If there's a nested iframe, find it + if (frameContent.iframes > 0) { + const childFrames = page.frames(); + let challengeFrame = childFrames.find((f) => + f.url().includes("3d_secure_2_test"), + ); + + if (!challengeFrame) { + challengeFrame = childFrames.find((f) => f.name() === "challengeFrame"); + } + + if (challengeFrame) { + threeDSFrame = challengeFrame; + } + } + + // Wait for the button and click it + await threeDSFrame.waitForSelector("#test-source-authorize-3ds", { + timeout: 3000, + }); + + await threeDSFrame.evaluate(() => { + const button = document.querySelector( + "#test-source-authorize-3ds", + ) as HTMLElement; + if (button) { + button.click(); + } + }); + + // Wait for the 3DS authentication to complete + await timeout(10000); + } finally { + // always close browser + await browser.close(); + } +}; diff --git a/server/tests/utils/testInitUtils/createTestContext.ts b/server/tests/utils/testInitUtils/createTestContext.ts index 82ab434af..367dd63a8 100644 --- a/server/tests/utils/testInitUtils/createTestContext.ts +++ b/server/tests/utils/testInitUtils/createTestContext.ts @@ -8,7 +8,10 @@ import { type DrizzleCli, initDrizzle } from "@/db/initDrizzle.js"; import { createStripeCli } from "@/external/connect/createStripeCli.js"; import { FeatureService } from "@/internal/features/FeatureService.js"; import { OrgService } from "@/internal/orgs/OrgService.js"; -import { logger } from "../../../src/external/logtail/logtailUtils.js"; +import { + type Logger, + logger, +} from "../../../src/external/logtail/logtailUtils.js"; const DEFAULT_ENV = AppEnv.Sandbox; @@ -19,6 +22,7 @@ export type TestContext = { db: DrizzleCli; orgSecretKey: string; features: Feature[]; + logger: Logger; }; export const createTestContext = async () => { diff --git a/shared/enums/SuccessCode.ts b/shared/enums/SuccessCode.ts index 6e6dd9396..b96b6278c 100644 --- a/shared/enums/SuccessCode.ts +++ b/shared/enums/SuccessCode.ts @@ -1,4 +1,5 @@ export enum SuccessCode { + InvoiceActionRequired = "invoice_action_required", // Track SuccessfullyDeducted = "successfully_deducted", EventReceived = "event_received", diff --git a/shared/index.ts b/shared/index.ts index 6600fbf24..ba073cd97 100644 --- a/shared/index.ts +++ b/shared/index.ts @@ -104,7 +104,6 @@ export * from "./models/orgModels/frontendOrg.js"; export * from "./models/orgModels/orgConfig.js"; export * from "./models/orgModels/orgConfig.js"; export * from "./models/orgModels/orgTable.js"; -export * from "./models/otherModels/metadataModels.js"; export * from "./models/otherModels/metadataTable.js"; // Duration Types export * from "./models/productModels/durationTypes/rolloverExpiryDurationType.js"; diff --git a/shared/models/otherModels/metadataModels.ts b/shared/models/otherModels/metadataModels.ts index f531cf5fe..bf322cc04 100644 --- a/shared/models/otherModels/metadataModels.ts +++ b/shared/models/otherModels/metadataModels.ts @@ -1,10 +1,10 @@ -import { z } from "zod/v4"; +// import { z } from "zod/v4"; -export const AutumnMetadataSchema = z.object({ - id: z.string(), - created_at: z.number(), - expires_at: z.number(), - data: z.any(), -}); +// export const AutumnMetadataSchema = z.object({ +// id: z.string(), +// created_at: z.number(), +// expires_at: z.number(), +// data: z.any(), +// }); -export type AutumnMetadata = z.infer; +// export type AutumnMetadata = z.infer; diff --git a/shared/models/otherModels/metadataTable.ts b/shared/models/otherModels/metadataTable.ts index c71b3edfd..34b1d1b94 100644 --- a/shared/models/otherModels/metadataTable.ts +++ b/shared/models/otherModels/metadataTable.ts @@ -1,9 +1,21 @@ -import { pgTable, text, numeric, jsonb } from "drizzle-orm/pg-core"; +import type { InferInsertModel, InferSelectModel } from "drizzle-orm"; +import { jsonb, numeric, pgTable, text } from "drizzle-orm/pg-core"; import { sqlNow } from "../../db/utils.js"; +export enum MetadataType { + InvoiceActionRequired = "invoice_action_required", + InvoiceCheckout = "invoice_checkout", + CheckoutSessionCompleted = "checkout_session_completed", +} + export const metadata = pgTable("metadata", { id: text().primaryKey().notNull(), created_at: numeric({ mode: "number" }).notNull().default(sqlNow), expires_at: numeric({ mode: "number" }), data: jsonb(), + type: text("type").$type(), + stripe_invoice_id: text("stripe_invoice_id"), }); + +export type Metadata = InferSelectModel; +export type MetadataInsert = InferInsertModel;