Merge branch 'main' into dev
This commit is contained in:
@@ -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 \
|
||||
|
||||
|
||||
@@ -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,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(
|
||||
|
||||
@@ -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");
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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],
|
||||
// });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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] });
|
||||
|
||||
@@ -136,6 +136,7 @@ export function CustomerActions() {
|
||||
onClick={handleOpenBillingPortal}
|
||||
className="flex gap-2"
|
||||
disabled={portalLoading}
|
||||
shortcut="b"
|
||||
>
|
||||
<UserCircleGearIcon />
|
||||
{portalLoading ? "Opening..." : "Open customer portal"}
|
||||
|
||||
Reference in New Issue
Block a user