diff --git a/server/src/cron/cronInit.ts b/server/src/cron/cronInit.ts index 40beddff9..c14283b94 100644 --- a/server/src/cron/cronInit.ts +++ b/server/src/cron/cronInit.ts @@ -3,6 +3,7 @@ import { CronJob } from "cron"; import { initDrizzle } from "../db/initDrizzle.js"; import { logger } from "../external/logtail/logtailUtils.js"; import { runInvoiceCron } from "./invoiceCron/runInvoiceCron.js"; +import { runOneOffCleanup } from "./oneoffCron/runOneOffCleanup.js"; import { runProductCron } from "./productCron/runProductCron.js"; import { runResetCron } from "./resetCron/runResetCron.js"; import type { CronContext } from "./utils/CronContext.js"; @@ -23,6 +24,7 @@ const main = async () => { runResetCron({ ctx }), runProductCron(), runInvoiceCron({ ctx }), + runOneOffCleanup({ ctx }), ]); }; diff --git a/server/src/cron/oneoffCron/runOneOffCleanup.ts b/server/src/cron/oneoffCron/runOneOffCleanup.ts new file mode 100644 index 000000000..637a2009c --- /dev/null +++ b/server/src/cron/oneoffCron/runOneOffCleanup.ts @@ -0,0 +1,49 @@ +import { CusProductStatus } from "@autumn/shared"; +import { getOneOffCustomerProductsToCleanup } from "@/internal/customers/cusProducts/actions/cleanupOneOff/getOneOffToCleanup.js"; +import { batchUpdateCustomerProducts } from "@/internal/customers/cusProducts/repos/batchUpdateCustomerProducts.js"; +import type { CronContext } from "../utils/CronContext.js"; + +/** Dry-run: fetches one-off customer products eligible for cleanup and logs them without making any updates. */ +export const runOneOffCleanup = async ({ ctx }: { ctx: CronContext }) => { + const { logger } = ctx; + try { + const toCleanup = await getOneOffCustomerProductsToCleanup({ + ctx, + }); + + console.log(`Found ${toCleanup.length} customer products to cleanup`); + logger.info(`Found ${toCleanup.length} customer products to cleanup`); + + // Log the results + for (const result of toCleanup) { + logger.info( + `[One-off Cleanup] expiring customer product: ${result.customer_product.id} (${result.org.slug})`, + { + context: { + org_id: result.org.id, + org_slug: result.org.slug, + customer_id: result.customer.id, + env: result.customer.env, + }, + data: { + customerProductId: result.customer_product.id, + productId: result.product.id, + }, + }, + ); + } + + await batchUpdateCustomerProducts({ + db: ctx.db, + updates: toCleanup.map((result) => ({ + id: result.customer_product.id, + updates: { status: CusProductStatus.Expired }, + })), + }); + + logger.info(`Expired ${toCleanup.length} customer products`); + console.log(`Expired ${toCleanup.length} customer products`); + } catch (error) { + console.error("[One-off Cleanup DRY RUN] Error:", error); + } +}; diff --git a/server/src/internal/customers/cusProducts/CusProductService.ts b/server/src/internal/customers/cusProducts/CusProductService.ts index e38413e00..6fc35b898 100644 --- a/server/src/internal/customers/cusProducts/CusProductService.ts +++ b/server/src/internal/customers/cusProducts/CusProductService.ts @@ -180,6 +180,15 @@ export class CusProductService { return cusProducts as FullCusProduct[]; } + static async getFull({ db, id }: { db: DrizzleCli; id: string }) { + const data = await db.query.customerProducts.findFirst({ + where: eq(customerProducts.id, id), + with: getFullCusProdRelations(), + }); + + return data as FullCusProduct | undefined; + } + static async getByInternalProductId({ db, internalProductId, diff --git a/server/src/internal/customers/cusProducts/actions/cleanupOneOff/getOneOffToCleanup.ts b/server/src/internal/customers/cusProducts/actions/cleanupOneOff/getOneOffToCleanup.ts index 95deffe11..a21680c5a 100644 --- a/server/src/internal/customers/cusProducts/actions/cleanupOneOff/getOneOffToCleanup.ts +++ b/server/src/internal/customers/cusProducts/actions/cleanupOneOff/getOneOffToCleanup.ts @@ -11,7 +11,7 @@ import { type Price, type Product, } from "@autumn/shared"; -import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; +import type { CronContext } from "@/cron/utils/CronContext"; export type OneOffCleanupResult = { customer_product: CustomerProduct; @@ -36,7 +36,7 @@ export type OneOffCleanupResult = { export const getOneOffCustomerProductsToCleanup = async ({ ctx, }: { - ctx: AutumnContext; + ctx: CronContext; }): Promise => { const result = await ctx.db.execute<{ customer_product: CustomerProduct; diff --git a/server/src/internal/customers/cusProducts/repos/batchUpdteCustomerProducts.ts b/server/src/internal/customers/cusProducts/repos/batchUpdteCustomerProducts.ts deleted file mode 100644 index 155319584..000000000 --- a/server/src/internal/customers/cusProducts/repos/batchUpdteCustomerProducts.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type { InsertCustomerProduct } from "@shared/models/cusProductModels/cusProductTable"; -import type { AutumnContext } from "@/honoUtils/HonoEnv"; - -export const batchUpdateCustomerProducts = async ({ - ctx, - updates, -}: { - ctx: AutumnContext; - updates: { - id: string; - updates: Partial; - }[]; -}) => { - const { db } = ctx; -}; diff --git a/shared/api/billing/attachV2/attachParamsV1.ts b/shared/api/billing/attachV2/attachParamsV1.ts index a4275f435..72f227fbf 100644 --- a/shared/api/billing/attachV2/attachParamsV1.ts +++ b/shared/api/billing/attachV2/attachParamsV1.ts @@ -28,6 +28,11 @@ export const AttachParamsV1Schema = BillingParamsBaseV1Schema.extend({ description: "When the plan change should take effect. 'immediate' applies now, 'end_of_cycle' schedules for the end of the current billing cycle. By default, upgrades are immediate and downgrades are scheduled.", }), + + checkout_session_params: z.record(z.string(), z.unknown()).optional().meta({ + description: + "Additional parameters to pass into the creation of the Stripe checkout session.", + }), }); export type AttachParamsV1 = z.infer; diff --git a/shared/api/billing/index.ts b/shared/api/billing/index.ts index 00e9724ac..ac378d559 100644 --- a/shared/api/billing/index.ts +++ b/shared/api/billing/index.ts @@ -15,6 +15,10 @@ export * from "./checkout/prevVersions/checkoutResponseV0"; export * from "./common/index"; export * from "./openBillingPortal/openBillingPortalParamsV1"; export * from "./openBillingPortal/openBillingPortalResponse"; +// Setup Payment +export * from "./setupPayment/setupPaymentParamsV0"; +export * from "./setupPayment/setupPaymentParamsV1"; + // Update Subscription export * from "./updateSubscription/previewUpdateSubscriptionResponse"; export * from "./updateSubscription/updateSubscriptionV0Params"; diff --git a/shared/api/billing/setupPayment/setupPaymentParamsV0.ts b/shared/api/billing/setupPayment/setupPaymentParamsV0.ts new file mode 100644 index 000000000..8074509a5 --- /dev/null +++ b/shared/api/billing/setupPayment/setupPaymentParamsV0.ts @@ -0,0 +1,27 @@ +import { z } from "zod/v4"; +import { CustomerDataSchema } from "../../common/customerData"; + +export const SetupPaymentParamsSchema = z.object({ + customer_id: z.string().meta({ + description: "The ID of the customer", + }), + success_url: z.string().optional().meta({ + description: + "URL to redirect to after successful payment setup. Must start with either http:// or https://", + }), + + checkout_session_params: z.record(z.string(), z.unknown()).optional().meta({ + description: "Additional parameters for the checkout session", + }), + + customer_data: CustomerDataSchema.optional(), +}); + +export const SetupPaymentResponseV0Schema = z.object({ + customer_id: z.string().meta({ + description: "The ID of the customer", + }), + url: z.string().meta({ + description: "URL to the payment setup page", + }), +}); diff --git a/shared/api/billing/setupPayment/setupPaymentParamsV1.ts b/shared/api/billing/setupPayment/setupPaymentParamsV1.ts new file mode 100644 index 000000000..a8d546284 --- /dev/null +++ b/shared/api/billing/setupPayment/setupPaymentParamsV1.ts @@ -0,0 +1,26 @@ +import { AttachParamsV1Schema } from "@api/models"; +import { z } from "zod/v4"; + +export const SetupPaymentParamsV1Schema = AttachParamsV1Schema.omit({ + invoice_mode: true, + redirect_mode: true, + new_billing_subscription: true, + plan_schedule: true, +}); + +export const SetupPaymentResponseV1Schema = z.object({ + customer_id: z.string().meta({ + description: "The ID of the customer", + }), + + entity_id: z.string().optional().meta({ + description: + "The ID of the entity the plan (if specified) will be attached to after setup.", + }), + + url: z.string().meta({ + description: "URL to redirect the customer to setup their payment.", + }), +}); + +export type SetupPaymentParamsV1 = z.infer; diff --git a/shared/api/core/coreOpModels.ts b/shared/api/core/coreOpModels.ts index 19b3d6800..d22a3f41e 100644 --- a/shared/api/core/coreOpModels.ts +++ b/shared/api/core/coreOpModels.ts @@ -1,5 +1,4 @@ import { z } from "zod/v4"; -import { CustomerDataSchema } from "../common/customerData.js"; // Cancel Schemas export const CancelBodySchema = z.object({ @@ -68,29 +67,6 @@ export const QueryResultSchema = z.object({ }), }); -export const SetupPaymentParamsSchema = z.object({ - customer_id: z.string().meta({ - description: "The ID of the customer", - }), - success_url: z.string().optional().meta({ - description: - "URL to redirect to after successful payment setup. Must start with either http:// or https://", - }), - customer_data: CustomerDataSchema.optional(), - checkout_session_params: z.record(z.string(), z.unknown()).optional().meta({ - description: "Additional parameters for the checkout session", - }), -}); - -export const SetupPaymentResultSchema = z.object({ - customer_id: z.string().meta({ - description: "The ID of the customer", - }), - url: z.string().meta({ - description: "URL to the payment setup page", - }), -}); - export const BillingPortalParamsSchema = z.object({ return_url: z.string().optional().meta({ description: @@ -111,6 +87,6 @@ export type CancelBody = z.infer; export type CancelResult = z.infer; export type QueryParams = z.infer; export type QueryResult = z.infer; -export type SetupPaymentParams = z.infer; + export type BillingPortalParams = z.infer; export type BillingPortalResult = z.infer;