fix: updated new subscription fail to add invoice_checkout metadata type
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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");
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user