fix: updated new subscription fail to add invoice_checkout metadata type

This commit is contained in:
John Yeo
2025-12-18 13:59:42 +00:00
parent 2dc460696c
commit 1cfcbc4b69
8 changed files with 131 additions and 148 deletions

View File

@@ -26,5 +26,6 @@ BUN_PARALLEL_COMPACT \
'server/tests/interval/multiSub' \ 'server/tests/interval/multiSub' \
'server/tests/billing/cancel' \ 'server/tests/billing/cancel' \
'server/tests/billing/new-billing-subscription' \ 'server/tests/billing/new-billing-subscription' \
'server/tests/billing/invoice-action-required/new-subscription' \
--max=6 --max=6

View File

@@ -74,13 +74,7 @@ const main = async () => {
db, db,
logger, logger,
}; };
await Promise.all([ await Promise.all([cronTask(), runProductCron(), runInvoiceCron({ ctx })]);
cronTask(),
runProductCron(),
runInvoiceCron({ ctx }),
// TODO: Add runUsageCron({ ctx })
]);
}; };
new CronJob( new CronJob(

View File

@@ -1,7 +1,8 @@
import { type Metadata, MetadataType, metadata } from "@autumn/shared"; import { type Metadata, MetadataType, metadata } from "@autumn/shared";
import { and, eq, lt } from "drizzle-orm"; import { and, eq, isNotNull, lt, or } from "drizzle-orm";
import { createStripeCli } from "../../external/connect/createStripeCli"; import { createStripeCli } from "../../external/connect/createStripeCli";
import { invoiceToSubId } from "../../external/stripe/stripeInvoiceUtils";
import type { AttachParams } from "../../internal/customers/cusProducts/AttachParams"; import type { AttachParams } from "../../internal/customers/cusProducts/AttachParams";
import { MetadataService } from "../../internal/metadata/MetadataService"; import { MetadataService } from "../../internal/metadata/MetadataService";
import type { CronContext } from "../utils/CronContext"; import type { CronContext } from "../utils/CronContext";
@@ -13,73 +14,91 @@ export const handleVoidInvoiceCron = async ({
ctx: CronContext; ctx: CronContext;
metadata: Metadata; metadata: Metadata;
}) => { }) => {
try { const { logger, db } = ctx;
const { logger, db } = ctx; const data = metadata.data as AttachParams;
const data = metadata.data as AttachParams; const { org, customer } = data;
const { org, customer } = data; const stripeCli = createStripeCli({ org, env: customer.env });
const stripeCli = createStripeCli({ org, env: customer.env });
if (!metadata.stripe_invoice_id) { if (!metadata.stripe_invoice_id) return;
return;
}
const invoice = await stripeCli.invoices.retrieve( const invoice = await stripeCli.invoices.retrieve(metadata.stripe_invoice_id);
metadata.stripe_invoice_id, const subId = invoiceToSubId({ invoice });
); const voidSub = metadata.type === MetadataType.InvoiceCheckout;
console.log(
`Invoice: ${metadata.stripe_invoice_id} for customer ${customer.id} (org: ${org.slug})`,
);
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})`,
);
await MetadataService.delete({ console.log(
db, `Invoice: ${metadata.stripe_invoice_id} for customer ${customer.id} (org: ${org.slug})`,
id: metadata.id, );
});
} catch (error) { if (invoice.status === "open") {
logger.error(`Error voiding invoice: ${error}`); try {
await stripeCli.invoices.voidInvoice(metadata.stripe_invoice_id);
logger.info(
`voided invoice ${metadata.stripe_invoice_id} for customer ${customer.id} (org: ${org.slug})`,
);
if (voidSub && subId) {
logger.info(`Voiding sub ${subId} [created through invoice checkout]`);
try {
await stripeCli.subscriptions.cancel(subId);
} catch (error) {
logger.warn(`Error voiding sub ${subId}: ${error}`);
}
} }
} else if (invoice.status === "void") {
await MetadataService.delete({ await MetadataService.delete({
db, db,
id: metadata.id, id: metadata.id,
}); });
} catch (error) {
logger.error(`Error voiding invoice: ${error}`);
} }
} catch (error) { } else if (
console.log("Error running invoice cron:", error); invoice.status === "void" ||
invoice.status === "paid" ||
invoice.status === "uncollectible"
) {
await MetadataService.delete({
db,
id: metadata.id,
});
} }
}; };
export const runInvoiceCron = async ({ ctx }: { ctx: CronContext }) => { export const runInvoiceCron = async ({ ctx }: { ctx: CronContext }) => {
console.log("Running invoice cron"); try {
const { db } = ctx; console.log("Running invoice cron");
const { db } = ctx;
// 1. Fetch from metadata invoices // 1. Fetch from metadata invoices
const invoices = await db const invoices = await db
.select() .select()
.from(metadata) .from(metadata)
.where( .where(
and( and(
eq(metadata.type, MetadataType.InvoiceActionRequired), or(
lt(metadata.expires_at, Date.now()), eq(metadata.type, MetadataType.InvoiceActionRequired),
), eq(metadata.type, MetadataType.InvoiceCheckout),
); ),
lt(metadata.expires_at, Date.now()),
isNotNull(metadata.stripe_invoice_id),
),
);
const batchSize = 50; const batchSize = 50;
for (let i = 0; i < invoices.length; i += batchSize) { for (let i = 0; i < invoices.length; i += batchSize) {
const batch = invoices.slice(i, i + batchSize); const batch = invoices.slice(i, i + batchSize);
const promises = []; const promises = [];
for (const metadata of batch) { for (const metadata of batch) {
promises.push(handleVoidInvoiceCron({ ctx, metadata })); promises.push(handleVoidInvoiceCron({ ctx, metadata }));
}
await Promise.all(promises);
console.log(`Handled ${i + batch.length}/${invoices.length} invoices`);
console.log("----------------------------------\n");
} }
await Promise.all(promises); console.log("FINISHED INVOICE CRON");
console.log(`Handled ${i + batch.length}/${invoices.length} invoices`); } catch (error) {
console.log("----------------------------------\n"); console.error("Error running invoice cron:", error);
return;
} }
console.log("FINISHED INVOICE CRON");
}; };

View File

@@ -15,6 +15,9 @@ export const handleInvoiceCheckoutPaid = async ({
metadata: Metadata; metadata: Metadata;
}) => { }) => {
const { logger, org, env, db } = ctx; const { logger, org, env, db } = ctx;
logger.info(
`invoice.paid, handling invoice checkout paid for metadata: ${metadata.id}`,
);
const { subId, anchorToUnix, config, ...rest } = const { subId, anchorToUnix, config, ...rest } =
metadata.data as AttachParams; metadata.data as AttachParams;

View File

@@ -1,4 +1,5 @@
import { type AttachConfig, ErrCode } from "@autumn/shared"; import { type AttachConfig, ErrCode } from "@autumn/shared";
import type { Logger } from "@server/external/logtail/logtailUtils";
import type Stripe from "stripe"; import type Stripe from "stripe";
import type { DrizzleCli } from "@/db/initDrizzle.js"; import type { DrizzleCli } from "@/db/initDrizzle.js";
import { getCusPaymentMethod } from "@/external/stripe/stripeCusUtils.js"; import { getCusPaymentMethod } from "@/external/stripe/stripeCusUtils.js";
@@ -30,7 +31,7 @@ export const createStripeSub2 = async ({
config: AttachConfig; config: AttachConfig;
billingCycleAnchorUnix?: number; billingCycleAnchorUnix?: number;
itemSet: ItemSet; itemSet: ItemSet;
logger: any; logger: Logger;
}) => { }) => {
const { customer, invoiceOnly, freeTrial, org, now, rewards, metadata } = const { customer, invoiceOnly, freeTrial, org, now, rewards, metadata } =
attachParams; attachParams;
@@ -69,6 +70,7 @@ export const createStripeSub2 = async ({
payment_behavior: isCustomPaymentMethod payment_behavior: isCustomPaymentMethod
? "default_incomplete" ? "default_incomplete"
: "allow_incomplete", : "allow_incomplete",
add_invoice_items: invoiceItems, add_invoice_items: invoiceItems,
collection_method: invoiceOnly ? "send_invoice" : "charge_automatically", collection_method: invoiceOnly ? "send_invoice" : "charge_automatically",
days_until_due: invoiceOnly ? 30 : undefined, days_until_due: invoiceOnly ? 30 : undefined,

View File

@@ -4,14 +4,17 @@ import {
type AttachFunctionResponse, type AttachFunctionResponse,
AttachFunctionResponseSchema, AttachFunctionResponseSchema,
AttachScenario, AttachScenario,
ErrCode, isTrialing, ErrCode,
isTrialing,
MetadataType, MetadataType,
SuccessCode SuccessCode,
} from "@autumn/shared"; } from "@autumn/shared";
import { addMinutes } from "date-fns";
import type Stripe from "stripe"; import type Stripe from "stripe";
import { getEarliestPeriodEnd } from "@/external/stripe/stripeSubUtils/convertSubUtils.js"; import { getEarliestPeriodEnd } from "@/external/stripe/stripeSubUtils/convertSubUtils.js";
import { getStripeSubItems2 } from "@/external/stripe/stripeSubUtils/getStripeSubItems.js"; import { getStripeSubItems2 } from "@/external/stripe/stripeSubUtils/getStripeSubItems.js";
import { subIsCanceled } from "@/external/stripe/stripeSubUtils.js"; import { subIsCanceled } from "@/external/stripe/stripeSubUtils.js";
import { attachParamsToMetadata } from "@/internal/billing/attach/utils/attachParamsToMetadata.js";
import { createFullCusProduct } from "@/internal/customers/add-product/createFullCusProduct.js"; import { createFullCusProduct } from "@/internal/customers/add-product/createFullCusProduct.js";
import { handleCreateCheckout } from "@/internal/customers/add-product/handleCreateCheckout.js"; import { handleCreateCheckout } from "@/internal/customers/add-product/handleCreateCheckout.js";
import type { AttachParams } from "@/internal/customers/cusProducts/AttachParams.js"; import type { AttachParams } from "@/internal/customers/cusProducts/AttachParams.js";
@@ -32,8 +35,6 @@ import { subToNewSchedule } from "../../mergeUtils/subToNewSchedule.js";
import { handleUpgradeFlowSchedule } from "../upgradeFlow/handleUpgradeFlowSchedule.js"; import { handleUpgradeFlowSchedule } from "../upgradeFlow/handleUpgradeFlowSchedule.js";
import { updateStripeSub2 } from "../upgradeFlow/updateStripeSub2.js"; import { updateStripeSub2 } from "../upgradeFlow/updateStripeSub2.js";
import { createStripeSub2 } from "./createStripeSub2.js"; import { createStripeSub2 } from "./createStripeSub2.js";
import { attachParamsToMetadata } from "@/internal/billing/attach/utils/attachParamsToMetadata.js";
import { addMinutes } from "date-fns";
export const handlePaidProduct = async ({ export const handlePaidProduct = async ({
ctx, ctx,
@@ -210,21 +211,31 @@ export const handlePaidProduct = async ({
logger, logger,
}); });
} }
const subInvoice: Stripe.Invoice | undefined = (sub.latest_invoice as Stripe.Invoice)
if(subInvoice && subInvoice.status === "open" && !config.invoiceCheckout) { const subInvoice: Stripe.Invoice | undefined =
sub.latest_invoice as Stripe.Invoice;
if (
subInvoice &&
subInvoice.status === "open" &&
!config.invoiceCheckout
) {
logger.info( logger.info(
`[update subscription] invoice action required: ${subInvoice.id}`, `[create subscription] invoice checkout created because invoice is open: ${subInvoice.id}`,
); );
const metadata = await attachParamsToMetadata({ const metadata = await attachParamsToMetadata({
db: ctx.db, db: ctx.db,
attachParams, attachParams: {
type: MetadataType.InvoiceActionRequired, ...attachParams,
subId: sub.id,
anchorToUnix: sub.billing_cycle_anchor * 1000,
config,
},
type: MetadataType.InvoiceCheckout,
stripeInvoiceId: subInvoice.id as string, stripeInvoiceId: subInvoice.id as string,
expiresAt: addMinutes(Date.now(), 10).getTime(), expiresAt: addMinutes(Date.now(), 10).getTime(),
}); });
await stripeCli.invoices.update(subInvoice.id, { await stripeCli.invoices.update(subInvoice.id, {
metadata: { metadata: {
autumn_metadata_id: metadata.id, autumn_metadata_id: metadata.id,
@@ -241,7 +252,6 @@ export const handlePaidProduct = async ({
error instanceof RecaseError && error instanceof RecaseError &&
!invoiceOnly && !invoiceOnly &&
error.code === ErrCode.CreateStripeSubscriptionFailed error.code === ErrCode.CreateStripeSubscriptionFailed
) { ) {
return await handleCreateCheckout({ return await handleCreateCheckout({
ctx, ctx,

View File

@@ -7,7 +7,6 @@ import { AutumnInt } from "@/external/autumn/autumnCli.js";
import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js"; import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js";
import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js";
import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js"; import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js";
import { CusService } from "../../src/internal/customers/CusService";
import { initCustomerV3 } from "../../src/utils/scriptUtils/testUtils/initCustomerV3"; import { initCustomerV3 } from "../../src/utils/scriptUtils/testUtils/initCustomerV3";
const free = constructProduct({ const free = constructProduct({
@@ -33,8 +32,8 @@ const pro = constructProduct({
], ],
}); });
const premium = constructProduct({ const oneOff = constructProduct({
type: "premium", type: "one_off",
items: [ items: [
constructFeatureItem({ constructFeatureItem({
featureId: TestFeature.Messages, featureId: TestFeature.Messages,
@@ -65,11 +64,11 @@ describe(`${chalk.yellowBright("temp: temporary script for testing")}`, () => {
const autumnV1: AutumnInt = new AutumnInt({ version: ApiVersion.V1_2 }); const autumnV1: AutumnInt = new AutumnInt({ version: ApiVersion.V1_2 });
beforeAll(async () => { beforeAll(async () => {
await CusService.deleteByOrgId({ // await CusService.deleteByOrgId({
db: ctx.db, // db: ctx.db,
orgId: ctx.org.id, // orgId: ctx.org.id,
env: ctx.env, // env: ctx.env,
}); // });
await initCustomerV3({ await initCustomerV3({
ctx, ctx,
@@ -80,19 +79,21 @@ describe(`${chalk.yellowBright("temp: temporary script for testing")}`, () => {
await initProductsV0({ await initProductsV0({
ctx, ctx,
products: [free, pro, premium], products: [free, pro, oneOff],
prefix: testCase, prefix: testCase,
}); });
await autumnV1.attach({ const res = await autumnV1.attach({
customer_id: customerId, customer_id: customerId,
product_id: pro.id, product_id: pro.id,
invoice: true,
}); });
await autumnV1.attach({ console.log(res);
customer_id: customerId, // await autumnV1.attach({
product_id: free.id, // customer_id: customerId,
}); // product_id: free.id,
// });
// await autumnV1.attach({ // await autumnV1.attach({
// customer_id: customerId, // customer_id: customerId,
// product_id: pro.id, // product_id: pro.id,

View File

@@ -1,19 +1,16 @@
import { beforeAll, describe, expect, it } from "bun:test"; import { beforeAll, describe, expect, it } from "bun:test";
import { ApiVersion, SuccessCode } from "@autumn/shared"; import { ApiVersion, SuccessCode } from "@autumn/shared";
import { TestFeature } from "@tests/setup/v2Features.js"; import { TestFeature } from "@tests/setup/v2Features.js";
import { expectProductAttached } from "@tests/utils/expectUtils/expectProductAttached";
import { completeInvoiceCheckout } from "@tests/utils/stripeUtils/completeInvoiceCheckout";
import ctx from "@tests/utils/testInitUtils/createTestContext.js"; import ctx from "@tests/utils/testInitUtils/createTestContext.js";
import chalk from "chalk"; import chalk from "chalk";
import { AutumnInt } from "@/external/autumn/autumnCli.js"; import { AutumnInt } from "@/external/autumn/autumnCli.js";
import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js"; import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js";
import { import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js";
constructProduct
} from "@/utils/scriptUtils/createTestProducts.js";
import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js";
import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3"; import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3";
import { expectProductAttached } from "@tests/utils/expectUtils/expectProductAttached"; import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js";
import { completeInvoiceCheckout } from "@tests/utils/stripeUtils/completeInvoiceCheckout"; import { expectSubToBeCorrect } from "../../../merged/mergeUtils/expectSubCorrect";
import { attachAuthenticatePaymentMethod } from "@/external/stripe/stripeCusUtils";
import { completeInvoiceConfirmation } from "@tests/utils/stripeUtils/completeInvoiceConfirmation";
const pro = constructProduct({ const pro = constructProduct({
type: "pro", type: "pro",
@@ -46,10 +43,10 @@ const oneOff = constructProduct({
], ],
}); });
const testCase = "temp"; const testCase = "new-subscription-action-required1";
describe(`${chalk.yellowBright("temp: temporary script for testing")}`, () => { describe(`${chalk.yellowBright("new-subscription-action-required1: new subscription, invoice action required (payment failed)")}`, () => {
const customerId = "temp"; const customerId = "new-subscription-action-required1";
const autumnV1: AutumnInt = new AutumnInt({ version: ApiVersion.V1_2 }); const autumnV1: AutumnInt = new AutumnInt({ version: ApiVersion.V1_2 });
@@ -69,10 +66,11 @@ describe(`${chalk.yellowBright("temp: temporary script for testing")}`, () => {
}); });
it("should call attach and get invoice action required", async () => { it("should call attach and get invoice action required", async () => {
let attachRes = await autumnV1.attach({ const attachRes = await autumnV1.attach({
customer_id: customerId, customer_id: customerId,
product_id: pro.id, product_id: pro.id,
}); });
expect(attachRes.code).toBe(SuccessCode.InvoiceActionRequired); expect(attachRes.code).toBe(SuccessCode.InvoiceActionRequired);
expect(attachRes.checkout_url).toBeDefined(); expect(attachRes.checkout_url).toBeDefined();
expect(attachRes.checkout_url).toContain("invoice.stripe.com"); expect(attachRes.checkout_url).toContain("invoice.stripe.com");
@@ -81,7 +79,7 @@ describe(`${chalk.yellowBright("temp: temporary script for testing")}`, () => {
await completeInvoiceCheckout({ await completeInvoiceCheckout({
url: attachRes.checkout_url, url: attachRes.checkout_url,
}); });
}) });
it("should have attached product after completing invoice action required", async () => { it("should have attached product after completing invoice action required", async () => {
const customer = await autumnV1.customers.get(customerId); const customer = await autumnV1.customers.get(customerId);
@@ -89,57 +87,12 @@ describe(`${chalk.yellowBright("temp: temporary script for testing")}`, () => {
customer, customer,
product: pro, product: pro,
}); });
});
it("should call attach for one-off and get invoice action required", async () => { await expectSubToBeCorrect({
let attachRes = await autumnV1.attach({ db: ctx.db,
customer_id: customerId,
product_id: oneOff.id,
});
expect(attachRes.code).toBe(SuccessCode.InvoiceActionRequired);
expect(attachRes.checkout_url).toBeDefined();
expect(attachRes.checkout_url).toContain("invoice.stripe.com");
expect(attachRes.message).toBe("Payment action required");
await completeInvoiceCheckout({
url: attachRes.checkout_url,
});
});
it("should have attached product after completing invoice action required", async () => {
const customer = await autumnV1.customers.get(customerId);
expectProductAttached({
customer,
product: oneOff,
});
});
it("should call attach for pro and get invoice action required with 3ds", async () => {
await attachAuthenticatePaymentMethod({
ctx,
customerId, customerId,
}); org: ctx.org,
env: ctx.env,
let attachRes = await autumnV1.attach({
customer_id: customerId,
product_id: premium.id,
});
expect(attachRes.code).toBe(SuccessCode.InvoiceActionRequired);
expect(attachRes.checkout_url).toBeDefined();
expect(attachRes.checkout_url).toContain("invoice.stripe.com");
expect(attachRes.message).toBe("Payment action required");
await completeInvoiceConfirmation({
url: attachRes.checkout_url,
});
});
it("should have attached product after completing invoice action required", async () => {
const customer = await autumnV1.customers.get(customerId);
expectProductAttached({
customer,
product: premium,
}); });
}); });
}); });