Merge branch 'main' into dev

This commit is contained in:
John Yeo
2025-12-18 18:25:26 +00:00
21 changed files with 391 additions and 193 deletions

View File

@@ -3,8 +3,8 @@
source "$(dirname "$0")/config.sh"
BUN_PARALLEL_COMPACT \
'server/tests/attach/basic' \
'server/tests/attach/entities' \
'server/tests/attach/basic' \
'server/tests/attach/upgrade' \
'server/tests/attach/downgrade' \
'server/tests/attach/free' \
@@ -16,3 +16,4 @@ BUN_PARALLEL_COMPACT \
'server/tests/billing/cancel/add-ons' \
'server/tests/renew' \
--max=6 \

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,12 +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";
@@ -18,14 +19,16 @@ export const handleVoidInvoiceCron = async ({
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);
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);
@@ -33,6 +36,15 @@ export const handleVoidInvoiceCron = async ({
`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}`);
}
}
await MetadataService.delete({
db,
id: metadata.id,
@@ -40,7 +52,11 @@ export const handleVoidInvoiceCron = async ({
} catch (error) {
logger.error(`Error voiding invoice: ${error}`);
}
} else if (invoice.status === "void") {
} else if (
invoice.status === "void" ||
invoice.status === "paid" ||
invoice.status === "uncollectible"
) {
await MetadataService.delete({
db,
id: metadata.id,
@@ -49,31 +65,40 @@ export const handleVoidInvoiceCron = async ({
};
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

@@ -12,80 +12,74 @@ import { batchDeleteCachedCustomers } from "../../internal/customers/cusUtils/ap
export const runProductCron = async () => {
console.log("Running product cron");
// Get customer_products that have 0 customer_prices, and trial_ends_at is not null, and trial_ends_at > now
const results = await db
.select()
.from(customerProducts)
.innerJoin(
customers,
eq(customerProducts.internal_customer_id, customers.internal_id),
)
.where(
and(
// No customer_prices exist for this customer_product
notExists(
db
.select()
.from(customerPrices)
.where(eq(customerPrices.customer_product_id, customerProducts.id)),
try {
// Get customer_products that have 0 customer_prices, and trial_ends_at is not null, and trial_ends_at > now
const results = await db
.select()
.from(customerProducts)
.innerJoin(
customers,
eq(customerProducts.internal_customer_id, customers.internal_id),
)
.where(
and(
// No customer_prices exist for this customer_product
notExists(
db
.select()
.from(customerPrices)
.where(
eq(customerPrices.customer_product_id, customerProducts.id),
),
),
// status is not expired
inArray(customerProducts.status, ACTIVE_STATUSES),
// trial_ends_at is not null
isNotNull(customerProducts.trial_ends_at),
// is already expired
lt(
customerProducts.trial_ends_at,
sql`(EXTRACT(EPOCH FROM NOW()) * 1000)::bigint`,
),
),
// status is not expired
inArray(customerProducts.status, ACTIVE_STATUSES),
);
// trial_ends_at is not null
isNotNull(customerProducts.trial_ends_at),
// is already expired
lt(
customerProducts.trial_ends_at,
sql`(EXTRACT(EPOCH FROM NOW()) * 1000)::bigint`,
),
),
);
console.log(
`Found ${results.length} customer products with no prices and active trials`,
);
const expireCusProducts = async (ids: string[]) => {
await db
.update(customerProducts)
.set({
status: CusProductStatus.Expired,
})
.where(inArray(customerProducts.id, ids));
};
const batchSize = 250;
for (let i = 0; i < results.length; i += batchSize) {
const batch = results.slice(i, i + batchSize);
await expireCusProducts(batch.map((r) => r.customer_products.id));
console.log(
`Expired batch of ${i + batch.length}/${results.length} customer products`,
`Found ${results.length} customer products with no prices and active trials`,
);
await batchDeleteCachedCustomers({
customers: batch
.filter((r) => notNullish(r.customers.id))
.map((r) => ({
orgId: r.customers.org_id,
env: r.customers.env,
customerId: r.customers.id!,
})),
});
// const clearCachePromises = [];
// for (const result of batch) {
// clearCachePromises.push(
// deleteCachedApiCustomer({
// customerId: result.customers.id ?? "",
// orgId: result.customers.org_id,
// env: result.customers.env,
// }),
// );
// }
// await Promise.all(clearCachePromises);
}
const expireCusProducts = async (ids: string[]) => {
await db
.update(customerProducts)
.set({
status: CusProductStatus.Expired,
})
.where(inArray(customerProducts.id, ids));
};
return results;
const batchSize = 250;
for (let i = 0; i < results.length; i += batchSize) {
const batch = results.slice(i, i + batchSize);
await expireCusProducts(batch.map((r) => r.customer_products.id));
console.log(
`Expired batch of ${i + batch.length}/${results.length} customer products`,
);
await batchDeleteCachedCustomers({
customers: batch
.filter((r) => notNullish(r.customers.id))
.map((r) => ({
orgId: r.customers.org_id,
env: r.customers.env,
customerId: r.customers.id!,
})),
});
}
return results;
} catch (error) {
console.log("Error running product cron:", error);
}
};

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

@@ -9,6 +9,8 @@ import type { ZodType } from "zod/v4";
* For query validation, this uses the parsed query from queryMiddleware
* to ensure boolean/array conversions are applied before validation
*
* For JSON validation, empty bodies are treated as {} to allow optional body schemas
*
* Usage:
* ```ts
* router.post(
@@ -39,6 +41,27 @@ export const validator = <T extends ZodType>(
};
}
// Handle JSON body - allow empty body if schema allows it
if (target === "json") {
return async (c: any, next: any) => {
let body: unknown;
try {
body = await c.req.json();
} catch {
// Empty body or whitespace-only body - default to empty object
// Real JSON parse errors will still fail schema validation
body = {};
}
const result = schema.safeParse(body);
if (!result.success) {
throw result.error;
}
c.req.addValidatedData(target, result.data);
await next();
};
}
return zValidator(target, schema, (result, _c) => {
if (!result.success) {
throw result.error;

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;
@@ -68,7 +69,8 @@ export const createStripeSub2 = async ({
// The subscription will become active after external payment is confirmed via Payment Records API
payment_behavior: isCustomPaymentMethod
? "default_incomplete"
: "error_if_incomplete",
: "allow_incomplete",
add_invoice_items: invoiceItems,
collection_method: invoiceOnly ? "send_invoice" : "charge_automatically",
days_until_due: invoiceOnly ? 30 : undefined,
@@ -165,6 +167,7 @@ export const createStripeSub2 = async ({
console.log("Error code:", error.code);
console.log("Message:", error.message);
console.log("Decline code:", error.decline_code);
console.log("Error original stack:", error.stack);
throw new RecaseError({
code: ErrCode.CreateStripeSubscriptionFailed,

View File

@@ -2,9 +2,11 @@ import {
type AttachConfig,
type AttachFunctionResponse,
AttachFunctionResponseSchema,
MetadataType,
SuccessCode,
type UsagePriceConfig,
} from "@autumn/shared";
import { addMinutes } from "date-fns";
import { Decimal } from "decimal.js";
import { payForInvoice } from "@/external/stripe/stripeInvoiceUtils.js";
import { createFullCusProduct } from "@/internal/customers/add-product/createFullCusProduct.js";
@@ -20,6 +22,7 @@ import { isFixedPrice } from "@/internal/products/prices/priceUtils/usagePriceUt
import { getPriceOptions } from "@/internal/products/prices/priceUtils.js";
import { attachToInsertParams } from "@/internal/products/productUtils.js";
import type { AutumnContext } from "../../../../../honoUtils/HonoEnv";
import { attachParamsToMetadata } from "../../../../billing/attach/utils/attachParamsToMetadata";
import { getCustomerDisplay } from "../../../../billing/attach/utils/getCustomerDisplay";
export const handleOneOffFunction = async ({
@@ -167,21 +170,63 @@ export const handleOneOffFunction = async ({
});
}
logger.info("3. Creating invoice from stripe");
await insertInvoiceFromAttach({
db: ctx.db,
attachParams,
invoiceId: stripeInvoice.id,
logger,
});
// Create invoice items
if (!invoiceOnly) {
await stripeCli.invoices.finalizeInvoice(stripeInvoice.id!);
stripeInvoice = await stripeCli.invoices.finalizeInvoice(stripeInvoice.id!);
logger.info("3. Paying invoice");
const { paid, error } = await payForInvoice({
logger.info("4. Paying invoice");
const {
paid,
error,
invoice: paidInvoice,
} = await payForInvoice({
stripeCli,
invoiceId: stripeInvoice.id!,
paymentMethod,
logger,
errorOnFail: false,
voidIfFailed: true,
voidIfFailed: false,
});
if (paidInvoice) {
stripeInvoice = paidInvoice;
}
if (!paid) {
// Check if invoice is still open (payment failed but invoice not voided)
if (stripeInvoice && stripeInvoice.status === "open") {
logger.info(
`[one off function] invoice action required: ${stripeInvoice.id}`,
);
const metadata = await attachParamsToMetadata({
db: ctx.db,
attachParams,
type: MetadataType.InvoiceActionRequired,
stripeInvoiceId: stripeInvoice.id as string,
expiresAt: addMinutes(Date.now(), 10).getTime(),
});
await stripeCli.invoices.update(stripeInvoice.id, {
metadata: {
autumn_metadata_id: metadata.id,
},
});
return AttachFunctionResponseSchema.parse({
checkout_url: stripeInvoice.hosted_invoice_url,
code: SuccessCode.InvoiceActionRequired,
message: "Payment action required",
});
}
if (org.config.checkout_on_failed_payment) {
return await handleCreateCheckout({
ctx,
@@ -193,7 +238,7 @@ export const handleOneOffFunction = async ({
}
}
logger.info("4. Creating full customer product");
logger.info("6. Creating full customer product");
const batchInsert = [];
for (const product of products) {
batchInsert.push(
@@ -206,14 +251,6 @@ export const handleOneOffFunction = async ({
}
await Promise.all(batchInsert);
logger.info("5. Creating invoice from stripe");
await insertInvoiceFromAttach({
db: ctx.db,
attachParams,
invoiceId: stripeInvoice.id,
logger,
});
const customerName = getCustomerDisplay({ customer });
const productNames = products.map((p) => p.name).join(", ");
return AttachFunctionResponseSchema.parse({

View File

@@ -4,23 +4,24 @@ import {
type AttachFunctionResponse,
AttachFunctionResponseSchema,
AttachScenario,
ErrCode,
isTrialing,
MetadataType,
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 { isStripeSubscriptionCanceled } 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";
import { insertInvoiceFromAttach } from "@/internal/invoices/invoiceUtils.js";
import { getNextStartOfMonthUnix } from "@/internal/products/prices/billingIntervalUtils.js";
import { addIntervalToAnchor } from "@/internal/products/prices/billingIntervalUtils2.js";
import { getSmallestInterval } from "@/internal/products/prices/priceUtils/priceIntervalUtils.js";
import { attachToInsertParams } from "@/internal/products/productUtils.js";
import RecaseError from "@/utils/errorUtils.js";
import type { AutumnContext } from "../../../../../honoUtils/HonoEnv.js";
import { getCustomerDisplay } from "../../../../billing/attach/utils/getCustomerDisplay.js";
import {
@@ -166,16 +167,16 @@ export const handlePaidProduct = async ({
prices: attachParams.prices,
});
// 1. If anchor to start of month, get next month anchor
if (org.config.anchor_start_of_month) {
// 1. If anchor to start of month, get next month anchor
billingCycleAnchorUnix = getNextStartOfMonthUnix({
interval: smallestInterval!.interval,
intervalCount: smallestInterval!.intervalCount,
});
}
// 2. If merge sub anchor, use it
if (mergeSub && !config.disableMerge) {
// 2. If merge sub anchor, use it
billingCycleAnchorUnix = addIntervalToAnchor({
anchorUnix: mergeSub.billing_cycle_anchor * 1000,
intervalConfig: smallestInterval!,
@@ -183,45 +184,61 @@ export const handlePaidProduct = async ({
});
}
// 3. If billing cycle anchor, just use it
if (attachParams.billingAnchor) {
// 3. If billing cycle anchor, just use it
billingCycleAnchorUnix = attachParams.billingAnchor;
}
// console.log("Item set: ", itemSet);
try {
sub = await createStripeSub2({
sub = await createStripeSub2({
db: ctx.db,
stripeCli,
attachParams,
itemSet,
billingCycleAnchorUnix,
config,
logger,
});
if (sub?.latest_invoice) {
invoice = await insertInvoiceFromAttach({
db: ctx.db,
stripeCli,
stripeInvoice: sub.latest_invoice as Stripe.Invoice,
attachParams,
itemSet,
billingCycleAnchorUnix,
config,
logger,
});
}
if (sub?.latest_invoice) {
invoice = await insertInvoiceFromAttach({
db: ctx.db,
stripeInvoice: sub.latest_invoice as Stripe.Invoice,
attachParams,
logger,
});
}
} catch (error) {
if (
error instanceof RecaseError &&
!invoiceOnly &&
error.code === ErrCode.CreateStripeSubscriptionFailed
) {
return await handleCreateCheckout({
ctx,
attachParams,
const subInvoice: Stripe.Invoice | undefined =
sub.latest_invoice as Stripe.Invoice;
if (subInvoice && subInvoice.status === "open" && !config.invoiceCheckout) {
logger.info(
`[create subscription] invoice checkout created because invoice is open: ${subInvoice.id}`,
);
const metadata = await attachParamsToMetadata({
db: ctx.db,
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(),
});
throw error;
await stripeCli.invoices.update(subInvoice.id, {
metadata: {
autumn_metadata_id: metadata.id,
},
});
return AttachFunctionResponseSchema.parse({
checkout_url: subInvoice.hosted_invoice_url,
code: SuccessCode.InvoiceActionRequired,
message: "Payment action required",
});
}
}

View File

@@ -7,13 +7,15 @@ import {
} from "@autumn/shared";
import type Stripe from "stripe";
import { sanitizeSubItems } from "@/external/stripe/stripeSubUtils/getStripeSubItems.js";
import { createProrationInvoice } from "@/external/stripe/stripeSubUtils/updateStripeSub/createProrationinvoice.js";
import type { AttachParams } from "@/internal/customers/cusProducts/AttachParams.js";
import { freeTrialToStripeTimestamp } from "@/internal/products/free-trials/freeTrialUtils.js";
import { SubService } from "@/internal/subscriptions/SubService.js";
import { nullish } from "@/utils/genUtils.js";
import type { ItemSet } from "@/utils/models/ItemSet.js";
import { createProrationInvoice } from "../../../../../external/stripe/stripeSubUtils/updateStripeSub/createProrationinvoice.js";
import { isStripeSubscriptionCanceled } from "../../../../../external/stripe/stripeSubUtils.js";
import type { AutumnContext } from "../../../../../honoUtils/HonoEnv.js";
import { attachParamsToCurCusProduct } from "../../attachUtils/convertAttachParams.js";
import { createAndFilterContUseItems } from "../../attachUtils/getContUseItems/createContUseInvoiceItems.js";

View File

@@ -29,7 +29,6 @@ cusRouter.post("/:customer_id/transfer", ...handleTransferProductV2);
// Billing portal
cusRouter.post("/:customer_id/billing_portal", ...handleCreateBillingPortal);
// cusRouter.get("/:customer_id/billing_portal", ...handleCreateBillingPortal);
// Legacy...
cusRouter.post("/:customer_id/balances", ...handleUpdateBalancesV2);

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,20 @@ 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,
product_id: oneOff.id,
});
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

@@ -3,7 +3,6 @@ import { ApiVersion } from "@autumn/shared";
import { AutumnCli } from "@tests/cli/AutumnCli.js";
import { TestFeature } from "@tests/setup/v2Features.js";
import { expectCustomerV0Correct } from "@tests/utils/expectUtils/expectCustomerV0Correct.js";
import { expectFeaturesCorrect } from "@tests/utils/expectUtils/expectFeaturesCorrect.js";
import { expectProductAttached } from "@tests/utils/expectUtils/expectProductAttached.js";
import ctx from "@tests/utils/testInitUtils/createTestContext.js";
import chalk from "chalk";
@@ -59,26 +58,10 @@ describe(`${chalk.yellowBright("basic1: Testing attach free, default product")}`
await expectCustomerV0Correct({
sent: sharedDefaultFree,
cusRes: data,
// skipEntitlements: true,
});
});
test("should have correct entitlements", async () => {
// Expected: 5 allowance for Messages feature
const entitled = (await AutumnCli.entitled(
customerId,
TestFeature.Messages,
)) as any;
const metered1Balance = entitled.balances.find(
(balance: any) => balance.feature_id === TestFeature.Messages,
);
expect(entitled.allowed).toBe(true);
expect(metered1Balance).toBeDefined();
expect(metered1Balance.balance).toBe(5);
expect(metered1Balance.unlimited).toBeUndefined();
});
test("should have correct boolean1 entitlement", async () => {
// Dashboard feature is not included in freeProd, should be false
const entitled = await AutumnCli.entitled(
@@ -101,10 +84,10 @@ describe(`${chalk.yellowBright("basic1: Testing attach free, default product")}`
product: free2,
});
expectFeaturesCorrect({
customer,
product: free2,
otherProducts: [sharedDefaultFree],
});
// expectFeaturesCorrect({
// customer,
// product: free2,
// otherProducts: [sharedDefaultFree],
// });
});
});

View File

@@ -0,0 +1,98 @@
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 { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3";
import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js";
import { expectSubToBeCorrect } from "../../../merged/mergeUtils/expectSubCorrect";
const pro = constructProduct({
type: "pro",
items: [
constructFeatureItem({
featureId: TestFeature.Messages,
includedUsage: 100,
}),
],
});
const premium = constructProduct({
type: "premium",
items: [
constructFeatureItem({
featureId: TestFeature.Messages,
includedUsage: 100,
}),
],
});
const oneOff = constructProduct({
type: "one_off",
items: [
constructFeatureItem({
featureId: TestFeature.Messages,
includedUsage: 100,
}),
],
});
const testCase = "new-subscription-action-required1";
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 });
beforeAll(async () => {
await initCustomerV3({
ctx,
customerId,
withTestClock: true,
attachPm: "fail",
});
await initProductsV0({
ctx,
products: [pro, premium, oneOff],
prefix: testCase,
});
});
it("should call attach and get invoice action required", async () => {
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");
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: pro,
});
await expectSubToBeCorrect({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
});
});
});

View File

@@ -39,11 +39,13 @@ export const compareMainProduct = ({
cusRes,
status = CusProductStatus.Active,
optionsList = [],
skipEntitlements = false,
}: {
sent: any;
cusRes: any;
status?: CusProductStatus;
optionsList?: FeatureOptions[];
skipEntitlements?: boolean;
}) => {
const { products, add_ons, entitlements } = cusRes;
const prod = products.find(
@@ -55,6 +57,8 @@ export const compareMainProduct = ({
`Product ${sent.id} not found (status: ${status}), (${sent.is_add_on ? "add-on" : "main"})`,
).toBeDefined();
if (skipEntitlements) return;
// Check entitlements
const sentEntitlements = Object.values(sent.entitlements) as Entitlement[];
const recEntitlements = entitlements;

View File

@@ -23,11 +23,13 @@ export const expectCustomerV0Correct = async ({
cusRes,
status,
optionsList,
skipEntitlements,
}: {
sent: ProductV2;
cusRes: any; // V0.1 customer response
status?: CusProductStatus;
optionsList?: FeatureOptions[];
skipEntitlements?: boolean;
}) => {
const { org, features } = ctx;
@@ -44,5 +46,6 @@ export const expectCustomerV0Correct = async ({
cusRes,
status,
optionsList,
skipEntitlements,
});
};

View File

@@ -17,7 +17,7 @@ const openapi2_0 = createDocument(
openapi: "3.1.0",
info: {
title: "Autumn API",
version: "1.2.0",
version: "2.0.0",
},
servers: [

View File

@@ -101,7 +101,9 @@ export function AttachProductActions({
});
// Handle checkout URLs and invoice links
if (result.data.invoice) {
if (result.data.checkout_url) {
window.open(result.data.checkout_url, "_blank");
} else if (result.data.invoice) {
window.open(
getStripeInvoiceLink({
stripeInvoice: result.data.invoice,

View File

@@ -62,6 +62,13 @@ export function useAttachProductMutation({
return await CusService.attach(axiosInstance, attachBody);
},
onSuccess: async (response) => {
// Don't show success toast if checkout_url is returned - product not attached yet
if (response.data.checkout_url) {
toast.success("Redirecting to checkout URL");
closeSheet();
return;
}
toast.success(successMessage);
closeSheet();
queryClient.invalidateQueries({ queryKey: ["customer", customerId] });

View File

@@ -136,6 +136,7 @@ export function CustomerActions() {
onClick={handleOpenBillingPortal}
className="flex gap-2"
disabled={portalLoading}
shortcut="b"
>
<UserCircleGearIcon />
{portalLoading ? "Opening..." : "Open customer portal"}