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;