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/billing/cancel' \
'server/tests/billing/new-billing-subscription' \
'server/tests/billing/invoice-action-required/new-subscription' \
--max=6

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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