feat: invoice checkout mode

This commit is contained in:
John Yeo
2025-08-14 15:54:18 -07:00
parent 73349a32a5
commit f3ef6e1596
50 changed files with 1213 additions and 192 deletions

View File

@@ -4,19 +4,19 @@
source "$(dirname "$0")/config.sh"
# If contains setup then run $MOCHA_SETUP
# if [[ "$2" == *"setup"* ]]; then
# MOCHA_PARALLEL=true $MOCHA_SETUP
# fi
if [[ "$1" == *"setup"* ]]; then
MOCHA_PARALLEL=true $MOCHA_SETUP
fi
$MOCHA_CMD \
'tests/attach/basic/*.ts' \
'tests/attach/upgrade/*.ts' \
'tests/attach/downgrade/*.ts' \
'tests/attach/checkout/*.ts'
$MOCHA_CMD \
'tests/attach/entities/*.ts' \
'tests/attach/free/*.ts'\
'tests/attach/addOn/*.ts'
$MOCHA_CMD \
'tests/attach/checkout/*.ts' \
'tests/attach/entities/*.ts' \
'tests/attach/free/*.ts'\
# 'tests/attach/basic/basic2.ts' \

View File

@@ -17,15 +17,15 @@ $MOCHA_CMD 'tests/contUse/track/*.ts'
$MOCHA_CMD 'tests/contUse/roles/*.ts'
# G4
$MOCHA_CMD 'tests/advanced/multiFeature/*.ts' \
'tests/advanced/coupons/*.ts' \
'tests/attach/updateQuantity/*.ts' \
'tests/advanced/referrals/*.ts' \
'tests/advanced/rollovers/*.ts' \
'tests/advanced/customInterval/*.ts'
# # G4
# $MOCHA_CMD 'tests/advanced/multiFeature/*.ts' \
# 'tests/advanced/coupons/*.ts' \
# 'tests/attach/updateQuantity/*.ts' \
# 'tests/advanced/referrals/*.ts' \
# 'tests/advanced/rollovers/*.ts' \
# 'tests/advanced/customInterval/*.ts'
$MOCHA_CMD 'tests/attach/multiProduct/*.ts' \
'tests/advanced/usageLimit/*.ts'
# $MOCHA_CMD 'tests/attach/multiProduct/*.ts' \
# 'tests/advanced/usageLimit/*.ts'
$MOCHA_CMD 'tests/advanced/usage/*.ts'
# $MOCHA_CMD 'tests/advanced/usage/*.ts'

View File

@@ -170,7 +170,9 @@ export class AutumnInt {
return data;
}
async checkout(params: CheckoutParams) {
async checkout(
params: CheckoutParams & { invoice?: boolean; force_checkout?: boolean }
) {
// const data = await this.post(`/attach`, {
// customer_id: customerId,
// product_id: productId,

View File

@@ -42,6 +42,7 @@ export const createWebhookEndpoint = async (
"invoice.upcoming",
"invoice.created",
"invoice.finalized",
"invoice.updated",
"subscription_schedule.canceled",
"customer.discount.deleted",
],

View File

@@ -92,11 +92,16 @@ export const createStripeSub = async ({
: undefined,
coupon: reward ? reward.id : undefined,
expand: ["latest_invoice"],
});
if (invoiceOnly && finalizeInvoice) {
await stripeCli.invoices.finalizeInvoice(
subscription.latest_invoice as string
if (
invoiceOnly &&
finalizeInvoice &&
(subscription.latest_invoice as Stripe.Invoice).status === "draft"
) {
subscription.latest_invoice = await stripeCli.invoices.finalizeInvoice(
(subscription.latest_invoice as Stripe.Invoice).id
);
}

View File

@@ -21,6 +21,7 @@ import { createStripeCli } from "./utils.js";
import { deleteCusCache } from "@/internal/customers/cusCache/updateCachedCus.js";
import { CusService } from "@/internal/customers/CusService.js";
import { DrizzleCli } from "@/db/initDrizzle.js";
import { handleInvoiceUpdated } from "./webhookHandlers/handleInvoiceUpdated.js";
export const stripeWebhookRouter: Router = express.Router();
@@ -183,6 +184,15 @@ stripeWebhookRouter.post(
});
break;
case "invoice.updated":
await handleInvoiceUpdated({
stripeCli,
env,
event,
req: request,
});
break;
case "invoice.created":
const createdInvoice = event.data.object;
await handleInvoiceCreated({

View File

@@ -101,7 +101,8 @@ export const handleRemainingSets = async ({
})) as Stripe.Subscription;
subs.push(subscription);
invoiceIds.push(subscription.latest_invoice as string);
const latestInvoice = subscription.latest_invoice as Stripe.Invoice;
invoiceIds.push(latestInvoice.id);
}
return {

View File

@@ -22,6 +22,7 @@ import { DrizzleCli } from "@/db/initDrizzle.js";
import { InvoiceService } from "@/internal/invoices/InvoiceService.js";
import { getInvoiceItems } from "@/internal/invoices/invoiceUtils.js";
import { handleInvoicePaidDiscount } from "./handleInvoicePaidDiscount.js";
import { handleInvoiceCheckoutPaid } from "@/internal/customers/attach/attachFunctions/invoiceCheckoutPaid/handleInvoiceCheckoutPaid.js";
const handleOneOffInvoicePaid = async ({
db,
@@ -92,12 +93,12 @@ const convertToChargeAutomatically = async ({
logger.info(`Converting to charge automatically`);
// 1. Get payment intent
const paymentIntent = await stripeCli.paymentIntents.retrieve(
invoice.payment_intent as string,
invoice.payment_intent as string
);
// 2. Get payment method
const paymentMethod = await stripeCli.paymentMethods.retrieve(
paymentIntent.payment_method as string,
paymentIntent.payment_method as string
);
await stripeCli.paymentMethods.attach(paymentMethod.id, {
@@ -113,7 +114,7 @@ const convertToChargeAutomatically = async ({
});
} catch (error) {
logger.warn(
`Convert to charge automatically: error updating subscription ${sub.id}`,
`Convert to charge automatically: error updating subscription ${sub.id}`
);
logger.warn(error);
}
@@ -153,6 +154,17 @@ export const handleInvoicePaid = async ({
stripeId: invoiceData.id,
});
if (invoice.metadata?.autumn_metadata_id) {
await handleInvoiceCheckoutPaid({
req,
org,
env,
db,
stripeCli,
invoice,
});
}
await handleInvoicePaidDiscount({
db,
expandedInvoice: invoice,
@@ -174,7 +186,7 @@ export const handleInvoicePaid = async ({
// TODO: Send alert
if (invoice.livemode) {
logger.warn(
`invoice.paid: customer product not found for invoice ${invoice.id}`,
`invoice.paid: customer product not found for invoice ${invoice.id}`
);
}
return;
@@ -199,7 +211,7 @@ export const handleInvoicePaid = async ({
let invoiceItems = await getInvoiceItems({
stripeInvoice: invoice,
prices: activeCusProducts.flatMap((p) =>
p.customer_prices.map((cpr: FullCustomerPrice) => cpr.price),
p.customer_prices.map((cpr: FullCustomerPrice) => cpr.price)
),
logger,
});

View File

@@ -0,0 +1,39 @@
import { AppEnv, InvoiceStatus } from "@autumn/shared";
import Stripe from "stripe";
import { getFullStripeInvoice } from "../stripeInvoiceUtils.js";
import { InvoiceService } from "@/internal/invoices/InvoiceService.js";
export const handleInvoiceUpdated = async ({
env,
event,
stripeCli,
req,
}: {
env: AppEnv;
event: Stripe.Event;
stripeCli: Stripe;
req: any;
}) => {
const invoiceObject = event.data.object as Stripe.Invoice;
const invoice = await getFullStripeInvoice({
stripeCli,
stripeId: invoiceObject.id,
});
const prevAttributes = event.data.previous_attributes as any;
const invoiceVoided =
prevAttributes?.status !== "void" && invoice.status === "void";
const { logger } = req;
if (invoiceVoided) {
logger.info(`Invoice has been voided!`);
await InvoiceService.updateByStripeId({
db: req.db,
stripeId: invoiceObject.id,
updates: {
status: InvoiceStatus.Void,
},
});
}
};

View File

@@ -202,6 +202,12 @@ export const handleUsageEvent = async ({
entityId: entity_id,
};
// console.log("Customer:", customer);
// console.log(
// "Is paid continuous use:",
// isPaidContinuousUse({ feature, fullCus: customer })
// );
if (isPaidContinuousUse({ feature, fullCus: customer })) {
console.log(`Running update usage task synchronously`);
await runUpdateUsageTask({

View File

@@ -0,0 +1,99 @@
import RecaseError from "@/utils/errorUtils.js";
import {
AttachParams,
AttachResultSchema,
} from "../cusProducts/AttachParams.js";
import { createStripeCli } from "@/external/stripe/utils.js";
import { createCheckoutMetadata } from "@/internal/metadata/metadataUtils.js";
import { getStripeSubItems } from "@/external/stripe/stripeSubUtils/getStripeSubItems.js";
import { ErrCode } from "@/errors/errCodes.js";
import { getNextStartOfMonthUnix } from "@/internal/products/prices/billingIntervalUtils.js";
import { isOneOff } from "@/internal/products/productUtils.js";
import { attachParamsToProduct } from "../attach/attachUtils/convertAttachParams.js";
import { handlePaidProduct } from "../attach/attachFunctions/addProductFlow/handlePaidProduct.js";
import {
AttachBranch,
AttachConfig,
ProrationBehavior,
SuccessCode,
} from "@autumn/shared";
import Stripe from "stripe";
import { handleOneOffFunction } from "../attach/attachFunctions/addProductFlow/handleOneOffFunction.js";
export const handleCreateInvoiceCheckout = async ({
req,
res,
attachParams,
config,
}: {
req: any;
res?: any;
attachParams: AttachParams;
config: AttachConfig;
}) => {
// if one off
const { stripeCli } = attachParams;
let invoiceResult;
if (isOneOff(attachParams.prices)) {
invoiceResult = await handleOneOffFunction({
req,
res,
attachParams,
config,
});
} else {
invoiceResult = await handlePaidProduct({
req,
res,
attachParams,
config,
});
}
const { invoices, anchorToUnix, subs }: any = invoiceResult;
const metadataId = await createCheckoutMetadata({
db: req.db,
attachParams: {
...attachParams,
anchorToUnix,
subIds: subs.map((s: Stripe.Subscription) => s.id),
config,
} as any,
});
for (const invoice of invoices) {
await stripeCli.invoices.update(invoice.id, {
metadata: {
autumn_metadata_id: metadataId,
},
});
}
// AttachResultSchema.parse({
// checkout_url: checkout.url,
// code: SuccessCode.CheckoutCreated,
// message: `Successfully created checkout for customer ${
// customer.id || customer.internal_id
// }, product(s) ${attachParams.products.map((p) => p.name).join(", ")}`,
// product_ids: attachParams.products.map((p) => p.id),
// customer_id: customer.id || customer.internal_id,
// });
if (res) {
res.status(200).json(
AttachResultSchema.parse({
checkout_url: invoices[0].hosted_invoice_url,
code: SuccessCode.CheckoutCreated,
message: `Successfully created invoice checkout for customer ${
attachParams.customer.id || attachParams.customer.internal_id
}, product(s) ${attachParams.products.map((p) => p.name).join(", ")}`,
product_ids: attachParams.products.map((p) => p.id),
customer_id:
attachParams.customer.id || attachParams.customer.internal_id,
})
);
}
return { invoices };
};

View File

@@ -106,7 +106,7 @@ export const handleOneOffFunction = async ({
// Create invoice
logger.info("1. Creating invoice");
const stripeInvoice = await stripeCli.invoices.create({
let stripeInvoice = await stripeCli.invoices.create({
customer: customer.processor.id!,
auto_advance: false,
currency: org.default_currency!,
@@ -117,6 +117,8 @@ export const handleOneOffFunction = async ({
},
]
: undefined,
collection_method: attachParams.invoiceOnly ? "send_invoice" : undefined,
days_until_due: attachParams.invoiceOnly ? 30 : undefined,
});
logger.info("2. Creating invoice items");
@@ -128,6 +130,23 @@ export const handleOneOffFunction = async ({
} as any);
}
if (config.invoiceCheckout) {
if (stripeInvoice.status === "draft") {
stripeInvoice = await stripeCli.invoices.finalizeInvoice(
stripeInvoice.id
);
}
await insertInvoiceFromAttach({
db: req.db,
attachParams,
invoiceId: stripeInvoice.id,
logger,
});
return { invoices: [stripeInvoice], subs: [], anchorToUnix: undefined };
}
// Create invoice items
if (!invoiceOnly) {
await stripeCli.invoices.finalizeInvoice(stripeInvoice.id);

View File

@@ -1,3 +1,5 @@
import RecaseError from "@/utils/errorUtils.js";
import { getStripeSubs } from "@/external/stripe/stripeSubUtils.js";
import { createStripeSub } from "@/external/stripe/stripeSubUtils/createStripeSub.js";
import { getStripeSubItems } from "@/external/stripe/stripeSubUtils/getStripeSubItems.js";
@@ -14,8 +16,6 @@ import {
} from "@/internal/invoices/invoiceUtils.js";
import { getNextStartOfMonthUnix } from "@/internal/products/prices/billingIntervalUtils.js";
import { attachToInsertParams } from "@/internal/products/productUtils.js";
import RecaseError from "@/utils/errorUtils.js";
import { formatUnixToDateTime } from "@/utils/genUtils.js";
import { ExtendedRequest } from "@/utils/models/Request.js";
import {
APIVersion,
@@ -122,7 +122,7 @@ export const handlePaidProduct = async ({
freeTrial,
invoiceOnly,
itemSet,
finalizeInvoice: attachParams.finalizeInvoice,
finalizeInvoice: config.invoiceCheckout,
anchorToUnix: billingCycleAnchorUnix,
reward: i == 0 ? reward : undefined,
now: attachParams.now,
@@ -148,9 +148,6 @@ export const handlePaidProduct = async ({
}
}
// Add product and entitlements to customer
const batchInsert = [];
const anchorToUnix =
subscriptions.length > 0
? subscriptions[0].current_period_end * 1000
@@ -158,6 +155,32 @@ export const handlePaidProduct = async ({
? mergeSubs[0].current_period_end * 1000
: undefined;
const batchInsertInvoice: any = [];
for (const sub of subscriptions) {
batchInsertInvoice.push(
insertInvoiceFromAttach({
db: req.db,
// invoiceId: sub.latest_invoice as string,
stripeInvoice: sub.latest_invoice as Stripe.Invoice,
attachParams,
logger,
})
);
}
const invoices = await Promise.all(batchInsertInvoice);
if (config.invoiceCheckout) {
return {
invoices: subscriptions.map((s) => s.latest_invoice as Stripe.Invoice),
subs: subscriptions,
anchorToUnix,
config,
};
}
// Add product and entitlements to customer
const batchInsert = [];
for (const product of products) {
batchInsert.push(
createFullCusProduct({
@@ -173,19 +196,6 @@ export const handlePaidProduct = async ({
}
await Promise.all(batchInsert);
const batchInsertInvoice: any = [];
for (const sub of subscriptions) {
batchInsertInvoice.push(
insertInvoiceFromAttach({
db: req.db,
invoiceId: sub.latest_invoice as string,
attachParams,
logger,
})
);
}
const invoices = await Promise.all(batchInsertInvoice);
if (res) {
let apiVersion = attachParams.apiVersion || APIVersion.v1;
const productNames = products.map((p) => p.name).join(", ");

View File

@@ -8,10 +8,14 @@ export const addSubItemsToRemove = async ({
cusProduct,
itemSet,
}: {
sub: Stripe.Subscription;
sub?: Stripe.Subscription | null;
cusProduct: FullCusProduct;
itemSet: ItemSet;
}) => {
if (!sub) {
return;
}
for (const item of sub.items.data) {
let shouldRemove = subItemInCusProduct({
cusProduct,

View File

@@ -0,0 +1,64 @@
import { DrizzleCli } from "@/db/initDrizzle.js";
import { createFullCusProduct } from "@/internal/customers/add-product/createFullCusProduct.js";
import { AttachParams } from "@/internal/customers/cusProducts/AttachParams.js";
import { MetadataService } from "@/internal/metadata/MetadataService.js";
import { attachToInsertParams } from "@/internal/products/productUtils.js";
import { ExtendedRequest } from "@/utils/models/Request.js";
import { AppEnv, AttachScenario, Organization } from "@autumn/shared";
import Stripe from "stripe";
export const handleInvoiceCheckoutPaid = async ({
req,
org,
env,
db,
stripeCli,
invoice,
}: {
req: ExtendedRequest;
org: Organization;
env: AppEnv;
db: DrizzleCli;
stripeCli: Stripe;
invoice: Stripe.Invoice;
}) => {
const metadataId = invoice.metadata?.autumn_metadata_id!;
const metadata = await MetadataService.get({
db,
id: metadataId,
});
const { subIds, anchorToUnix, config, ...rest } = metadata?.data;
const attachParams = rest as AttachParams;
if (!attachParams) {
return;
}
const reqMatch =
attachParams.org.id === org.id && attachParams.customer.env === env;
if (!reqMatch) return;
const batchInsert = [];
for (const product of attachParams.products) {
batchInsert.push(
createFullCusProduct({
db,
attachParams: attachToInsertParams(attachParams, product),
subscriptionIds: subIds,
anchorToUnix,
carryExistingUsages: config.carryUsage,
scenario: AttachScenario.New,
logger: req.logger,
})
);
}
await Promise.all(batchInsert);
req.logger.info(
`✅ invoice.paid, successfully inserted cus products: ${attachParams.products.map((p) => p.id).join(", ")}`
);
};

View File

@@ -23,6 +23,7 @@ import {
insertInvoiceFromAttach,
} from "@/internal/invoices/invoiceUtils.js";
import { updateSubsDiffInt } from "./updateSubsDiffInt.js";
import { attachParamsToCurCusProduct } from "../../attachUtils/convertAttachParams.js";
export const handleUpgradeDiffInterval = async ({
req,
@@ -41,12 +42,12 @@ export const handleUpgradeDiffInterval = async ({
const product = products[0];
let { curMainProduct: curCusProduct } = getExistingCusProducts({
product,
cusProducts: cusProducts || [],
internalEntityId: attachParams.internalEntityId,
});
// let { curMainProduct: curCusProduct } = getExistingCusProducts({
// product,
// cusProducts: cusProducts || [],
// internalEntityId: attachParams.internalEntityId,
// });
let curCusProduct = attachParamsToCurCusProduct({ attachParams });
curCusProduct = curCusProduct!;
const stripeSubs = await getStripeSubs({

View File

@@ -39,8 +39,8 @@ export const updateSubsDiffInt = async ({
carryExistingUsages: config.carryUsage,
});
const firstSub = stripeSubs[0];
const firstItemSet = itemSets[0];
const firstSub = stripeSubs?.[0];
const firstItemSet = itemSets?.[0];
await addSubItemsToRemove({
sub: firstSub,
@@ -127,7 +127,8 @@ export const updateSubsDiffInt = async ({
});
newSubs.push(newSub);
newInvoiceIds.push(newSub.latest_invoice as string);
const latestInvoice = newSub.latest_invoice as Stripe.Invoice;
newInvoiceIds.push(latestInvoice.id);
}
return {

View File

@@ -39,25 +39,11 @@ export const updateSubsByInt = async ({
attachParams.replaceables = replaceables;
// logger.info(`Cont use items`);
// logger.info(
// `New items: `,
// newItems.map(
// (item) => `${item.description} | Amount: ${item.amount || item.price}`,
// ),
// );
// logger.info(
// "Replaceables: ",
// replaceables.map((r) => `${r.ent.feature_id}`),
// );
const itemSets = await getStripeSubItems({ attachParams });
const invoices: Stripe.Invoice[] = [];
// const replaceables: Replaceable[] = [];
for (const sub of stripeSubs) {
// let interval = subToAutumnInterval(sub);
let subInterval = subToAutumnInterval(sub);
let itemSet = itemSets.find((itemSet) => {
return intervalsSame({

View File

@@ -1,6 +1,11 @@
import { Router } from "express";
import RecaseError from "@/utils/errorUtils.js";
import { APIVersion, BillingType, FullCusProduct } from "@autumn/shared";
import {
APIVersion,
AttachConfig,
BillingType,
FullCusProduct,
} from "@autumn/shared";
import { ErrCode } from "@/errors/errCodes.js";
import { getCusPaymentMethod } from "@/external/stripe/stripeCusUtils.js";
@@ -22,6 +27,7 @@ import { createStripePriceIFNotExist } from "@/external/stripe/createStripePrice
import {
notNullish,
notNullOrUndefined,
nullish,
nullOrUndefined,
} from "@/utils/genUtils.js";
import { CusService } from "@/internal/customers/CusService.js";
@@ -35,14 +41,17 @@ import { handleAttachPreview } from "./handleAttachPreview/handleAttachPreview.j
import { handleAttach } from "./handleAttach.js";
import { handleCheckout } from "./checkout/handleCheckout.js";
import { createStripeCusIfNotExists } from "@/external/stripe/stripeCusUtils.js";
import { attachParamsToCurCusProduct } from "./attachUtils/convertAttachParams.js";
export const attachRouter: Router = Router();
export const handlePrepaidErrors = async ({
attachParams,
config,
useCheckout = false,
}: {
attachParams: AttachParams;
config: AttachConfig;
useCheckout?: boolean;
}) => {
const { prices, entitlements, optionsList } = attachParams;
@@ -57,7 +66,8 @@ export const handlePrepaidErrors = async ({
let options = getEntOptions(optionsList, priceEnt);
// 1. If not checkout, quantity should be defined
if (!useCheckout && nullOrUndefined(options?.quantity)) {
const regularCheckout = useCheckout && !config.invoiceCheckout;
if (!regularCheckout && nullOrUndefined(options?.quantity)) {
throw new RecaseError({
message: `Pass in 'quantity' for feature ${priceEnt.feature_id} in options`,
code: ErrCode.InvalidOptions,
@@ -161,6 +171,7 @@ export const checkStripeConnections = async ({
}
const batchProductUpdates = [];
if (createCus) {
batchProductUpdates.push(
createStripeCusIfNotExists({
@@ -172,6 +183,7 @@ export const checkStripeConnections = async ({
})
);
}
for (const product of products) {
batchProductUpdates.push(
checkStripeProductExists({
@@ -207,6 +219,9 @@ export const createStripePrices = async ({
const { prices, entitlements, products, org, stripeCli } = attachParams;
const batchPriceUpdates = [];
// const curCusProduct = attachParamsToCurCusProduct({ attachParams });
for (const price of prices) {
let product = getProductForPrice(price, products);

View File

@@ -77,13 +77,6 @@ export const checkToAttachParams = async ({
// Others
apiVersion,
// successUrl: attachBody.success_url,
// invoiceOnly: attachBody.invoice_only,
// billingAnchor: attachBody.billing_cycle_anchor,
// metadata: attachBody.metadata,
// disableFreeTrial: attachBody.free_trial === false || false,
// checkoutSessionParams: attachBody.checkout_session_params,
// isCustom: attachBody.is_custom,
};
return attachParams;

View File

@@ -66,8 +66,9 @@ export const getAttachParams = async ({
// Others
apiVersion,
successUrl: attachBody.success_url,
invoiceOnly: attachBody.invoice_only || attachBody.invoice,
finalizeInvoice: attachBody.invoice,
invoiceOnly: attachBody.invoice,
// || attachBody.invoice_only
billingAnchor: attachBody.billing_cycle_anchor,
metadata: attachBody.metadata,
disableFreeTrial: attachBody.free_trial === false || false,

View File

@@ -10,7 +10,7 @@ export const attachParamsToCurCusProduct = ({
const { curMainProduct, curSameProduct, curScheduledProduct } =
attachParamToCusProducts({ attachParams });
return curMainProduct || curSameProduct;
return curSameProduct || curMainProduct;
};
export const attachParamToCusProducts = ({

View File

@@ -135,9 +135,6 @@ const checkSameCustom = async ({
features: attachParams.features,
});
// console.log("Attach params free trial:", attachParams.freeTrial);
// console.log("Cur same product free trial:", curSameProduct.free_trial);
if (itemsSame && freeTrialsSame) {
throw new RecaseError({
message: `Items specified for ${product.name} are the same as the existing product, can't attach again`,
@@ -145,6 +142,11 @@ const checkSameCustom = async ({
});
}
// const curPrices = cusProductToPrices({ cusProduct: curSameProduct });
// if (isFreeProduct(curPrices) && !isFreeProduct(attachParams.prices)) {
// return AttachBranch.MainIsFree;
// }
if (onlyEntsChanged) {
return AttachBranch.SameCustomEnts;
}

View File

@@ -23,7 +23,7 @@ export const intervalsAreSame = ({
attachParams,
});
let curCusProduct = curMainProduct || curSameProduct;
let curCusProduct = curSameProduct || curMainProduct;
if (!curCusProduct) {
return false;
@@ -143,8 +143,17 @@ export const getAttachConfig = async ({
branch == AttachBranch.MainIsTrial ||
org.config.merge_billing_cycles === false;
const invoiceAndEnable =
attachParams.invoiceOnly && attachBody.enable_product_immediately;
const invoiceCheckout =
attachParams.invoiceOnly === true && !attachBody.enable_product_immediately;
const checkoutFlow =
isPublic || forceCheckout || (noPaymentMethod && !invoiceOnly);
isPublic ||
forceCheckout ||
invoiceCheckout ||
(noPaymentMethod && !invoiceAndEnable);
const onlyCheckout = !isFree && checkoutFlow;
@@ -155,6 +164,7 @@ export const getAttachConfig = async ({
proration,
disableTrial,
invoiceOnly: flags.invoiceOnly,
invoiceCheckout,
disableMerge,
sameIntervals,
carryTrial,
@@ -174,6 +184,7 @@ export const getDefaultAttachConfig = () => {
disableMerge: false,
sameIntervals: false,
carryTrial: false,
invoiceCheckout: false,
};
return config;

View File

@@ -25,6 +25,7 @@ import { deleteCurrentScheduledProduct } from "./deleteCurrentScheduledProduct.j
import { handleOneOffFunction } from "../attachFunctions/addProductFlow/handleOneOffFunction.js";
import { handleUpgradeSameInterval } from "../attachFunctions/upgradeSameIntFlow/handleUpgradeSameInt.js";
import { CusProductService } from "../../cusProducts/CusProductService.js";
import { handleCreateInvoiceCheckout } from "../../add-product/handleCreateInvoiceCheckout.js";
/*
1. If from new version, free trial should just carry over
@@ -217,6 +218,14 @@ export const runAttachFunction = async ({
}
if (attachFunction == AttachFunction.CreateCheckout) {
if (config.invoiceCheckout) {
return await handleCreateInvoiceCheckout({
req,
res,
attachParams,
config,
});
}
return await handleCreateCheckout({
req,
res,

View File

@@ -2,9 +2,15 @@ import RecaseError from "@/utils/errorUtils.js";
import { ErrCode } from "@/errors/errCodes.js";
import { StatusCodes } from "http-status-codes";
import { AttachParams } from "../../cusProducts/AttachParams.js";
import { AttachBranch, AttachErrCode, UsagePriceConfig } from "@autumn/shared";
import {
AttachBranch,
AttachConfig,
AttachErrCode,
UsagePriceConfig,
} from "@autumn/shared";
import { AttachBody } from "@autumn/shared";
import { AttachConfig, AttachFlags } from "../models/AttachFlags.js";
import { AttachFlags } from "../models/AttachFlags.js";
import {
getEntOptions,
priceIsOneOffAndTiered,
@@ -25,8 +31,10 @@ import { Decimal } from "decimal.js";
const handleNonCheckoutErrors = ({
flags,
action,
config,
}: {
flags: AttachFlags;
config: AttachConfig;
action: string;
}) => {
const { isPublic, forceCheckout, noPaymentMethod } = flags;
@@ -47,14 +55,22 @@ const handleNonCheckoutErrors = ({
message: `Not allowed to ${action} because customer has no payment method on file`,
code: ErrCode.InvalidRequest,
});
} else if (config.invoiceCheckout) {
throw new RecaseError({
message: `Not allowed to ${action} when using 'invoice': true`,
code: ErrCode.InvalidRequest,
statusCode: StatusCodes.BAD_REQUEST,
});
}
};
const handlePrepaidErrors = async ({
attachParams,
config,
useCheckout = false,
}: {
attachParams: AttachParams;
config: AttachConfig;
useCheckout?: boolean;
}) => {
const { prices, entitlements, optionsList } = attachParams;
@@ -69,7 +85,10 @@ const handlePrepaidErrors = async ({
let options = getEntOptions(optionsList, priceEnt);
// 1. If not checkout, quantity should be defined
if (!useCheckout && nullOrUndefined(options?.quantity)) {
const regularCheckout = useCheckout && !config.invoiceCheckout;
if (!regularCheckout && nullOrUndefined(options?.quantity)) {
throw new RecaseError({
message: `Pass in 'quantity' for feature ${priceEnt.feature_id} in options`,
code: ErrCode.InvalidOptions,
@@ -197,6 +216,8 @@ export const handleAttachErrors = async ({
}) => {
const { onlyCheckout } = config;
// Invoice no payment enabled: onlyCheckout
if (onlyCheckout || flags.isPublic) {
let upgradeDowngradeFlows = [
AttachBranch.Upgrade,
@@ -206,6 +227,7 @@ export const handleAttachErrors = async ({
if (upgradeDowngradeFlows.includes(branch)) {
handleNonCheckoutErrors({
flags,
config,
action: "perform upgrade or downgrade",
});
}
@@ -218,6 +240,7 @@ export const handleAttachErrors = async ({
handleNonCheckoutErrors({
flags,
action: "update current product",
config,
});
}
}
@@ -236,6 +259,7 @@ export const handleAttachErrors = async ({
await handlePrepaidErrors({
attachParams,
config,
useCheckout: onlyCheckout,
});

View File

@@ -14,7 +14,10 @@ import { getAttachBranch } from "../attachUtils/getAttachBranch.js";
import { getAttachConfig } from "../attachUtils/getAttachConfig.js";
import { getAttachFunction } from "../attachUtils/getAttachFunction.js";
import { handleCreateCheckout } from "../../add-product/handleCreateCheckout.js";
import { checkStripeConnections } from "../attachRouter.js";
import {
checkStripeConnections,
handlePrepaidErrors,
} from "../attachRouter.js";
import { attachParamsToPreview } from "../handleAttachPreview/attachParamsToPreview.js";
import { previewToCheckoutRes } from "./previewToCheckoutRes.js";
import { getProductResponse } from "@/internal/products/productUtils/productResponseUtils/getProductResponse.js";
@@ -24,6 +27,7 @@ import { isPrepaidPrice } from "@/internal/products/prices/priceUtils/usagePrice
import { priceToFeature } from "@/internal/products/prices/priceUtils/convertPrice.js";
import { getPriceOptions } from "@/internal/products/prices/priceUtils.js";
import { getHasProrations } from "./getHasProrations.js";
import { handleCreateInvoiceCheckout } from "../../add-product/handleCreateInvoiceCheckout.js";
const getAttachVars = async ({
req,
@@ -108,12 +112,19 @@ export const handleCheckout = (req: any, res: any) =>
const { logger, features } = req;
const attachBody = AttachBodySchema.parse(req.body);
const { attachParams, branch, func } = await getAttachVars({
const { attachParams, branch, func, config } = await getAttachVars({
req,
attachBody,
});
let checkoutUrl = null;
await handlePrepaidErrors({
attachParams,
config,
useCheckout: config.onlyCheckout,
});
if (func == AttachFunction.CreateCheckout) {
await checkStripeConnections({
req,
@@ -122,6 +133,15 @@ export const handleCheckout = (req: any, res: any) =>
useCheckout: true,
});
if (config.invoiceCheckout) {
const result = await handleCreateInvoiceCheckout({
req,
attachParams,
config,
});
checkoutUrl = result?.invoices?.[0]?.hosted_invoice_url;
} else {
const checkout = await handleCreateCheckout({
req,
res,
@@ -130,23 +150,7 @@ export const handleCheckout = (req: any, res: any) =>
});
checkoutUrl = checkout?.url;
// const customer = attachParams.customer;
// res.status(200).json(
// CheckoutResponseSchema.parse({
// url: checkout?.url,
// customer_id: customer.id || customer.internal_id,
// scenario: AttachScenario.New,
// lines: [],
// product: await getProductResponse({
// product: attachParams.products[0],
// features: features,
// withDisplay: false,
// options: attachParams.optionsList,
// }),
// })
// );
// return;
}
}
await getCheckoutOptions({

View File

@@ -30,6 +30,9 @@ export const handleAttachPreview = (req: any, res: any) =>
logger,
});
console.log("Branch:", attachPreview.branch);
console.log("Func:", attachPreview.func);
res.status(200).json(attachPreview);
return;

View File

@@ -58,9 +58,7 @@ export const getCusWithCache = async ({
if (!skipCache && !skipGet) {
try {
const start = Date.now();
const cached = await upstash!.get(cacheKey);
const end = Date.now();
if (cached) {
return cached as FullCustomer;
} else {
@@ -88,8 +86,9 @@ export const getCusWithCache = async ({
if (!skipCache && notNullish(customer)) {
try {
await upstash!.set(cacheKey, customer);
await upstash!.expire(cacheKey, 300); // Expire after 5 minutes...
await upstash!.set(cacheKey, customer, {
ex: 300,
});
} catch (error) {
logger.error(`Failed to set cache: ${cacheKey}`, { error });
}

View File

@@ -16,10 +16,11 @@ import {
import Stripe from "stripe";
import { DrizzleCli } from "@/db/initDrizzle.js";
import { getBillingType } from "@/internal/products/prices/priceUtils.js";
import { ACTIVE_STATUSES } from "../CusProductService.js";
export const cusProductsToCusPrices = ({
cusProducts,
inStatuses = [CusProductStatus.Active],
inStatuses,
billingType,
}: {
cusProducts: FullCusProduct[];
@@ -29,7 +30,7 @@ export const cusProductsToCusPrices = ({
const cusPrices: FullCustomerPrice[] = [];
for (const cusProduct of cusProducts) {
if (!inStatuses.includes(cusProduct.status)) {
if (inStatuses && !inStatuses.includes(cusProduct.status)) {
continue;
}

View File

@@ -10,6 +10,7 @@ import {
cusProductsToCusPrices,
} from "../../cusProducts/cusProductUtils/convertCusProduct.js";
import { getCusBalances } from "./getCusBalances.js";
import { ACTIVE_STATUSES } from "../../cusProducts/CusProductService.js";
export const getCusFeaturesResponse = async ({
cusProducts,
@@ -26,7 +27,9 @@ export const getCusFeaturesResponse = async ({
const balances = await getCusBalances({
cusEntsWithCusProduct: cusEnts,
cusPrices: cusProductsToCusPrices({ cusProducts }),
cusPrices: cusProductsToCusPrices({
cusProducts,
}),
org,
entity,
apiVersion,

View File

@@ -23,6 +23,7 @@ import {
cusProductToPrices,
} from "../customers/cusProducts/cusProductUtils/convertCusProduct.js";
import { priceToFeature } from "../products/prices/priceUtils/convertPrice.js";
import { ACTIVE_STATUSES } from "../customers/cusProducts/CusProductService.js";
export const validateFeatureId = (featureId: string) => {
if (!featureId.match(/^[a-zA-Z0-9_-]+$/)) {
@@ -227,7 +228,9 @@ export const isPaidContinuousUse = ({
let cusPrices = cusProductsToCusPrices({
cusProducts: fullCus.customer_products,
inStatuses: ACTIVE_STATUSES,
});
let hasPaid = cusPrices.some((cp) => {
let config = cp.price.config as UsagePriceConfig;
if (config.internal_feature_id == feature.internal_id) {

View File

@@ -36,7 +36,7 @@ export const createCheckoutMetadata = async ({
export const getMetadataFromCheckoutSession = async (
checkoutSession: Stripe.Checkout.Session,
db: DrizzleCli,
db: DrizzleCli
) => {
const metadataId = checkoutSession.metadata?.autumn_metadata_id;

View File

@@ -60,13 +60,6 @@ export const migrationToAttachParams = async ({
// Others
apiVersion,
// successUrl: attachBody.success_url,
// invoiceOnly: attachBody.invoice_only,
// billingAnchor: attachBody.billing_cycle_anchor,
// metadata: attachBody.metadata,
// disableFreeTrial: attachBody.free_trial === false || false,
// checkoutSessionParams: attachBody.checkout_session_params,
// isCustom: attachBody.is_custom,
};
return attachParams;

View File

@@ -696,6 +696,7 @@ export const runUpdateBalanceTask = async ({
entityId,
});
// console.time("refreshCusCache");
await refreshCusCache({
db,
customerId,
@@ -703,6 +704,7 @@ export const runUpdateBalanceTask = async ({
env,
entityId,
});
// console.timeEnd("refreshCusCache");
if (!cusEnts || cusEnts.length === 0) {
return;

View File

@@ -8,7 +8,7 @@ import {
} from "@autumn/shared";
import { and, eq, inArray } from "drizzle-orm";
const clearCustomersInBatches = async ({
export const clearCustomersInBatches = async ({
db,
org,
batchSize = 450,
@@ -38,6 +38,8 @@ const clearCustomersInBatches = async ({
.map((c) => c.internalId)
.filter((id) => id !== null);
console.log("Deleting customers:", customerIds);
await db
.delete(customers)
.where(inArray(customers.internal_id, customerIds));

View File

@@ -45,7 +45,7 @@ const testCase = "addOn1";
describe(`${chalk.yellowBright(`${testCase}: Testing free add on, and updating free add on`)}`, () => {
let customerId = testCase;
let autumn: AutumnInt = new AutumnInt({ version: APIVersion.v1_4 });
let autumn: AutumnInt = new AutumnInt({ version: APIVersion.v1_2 });
let testClockId: string;
let db: DrizzleCli, org: Organization, env: AppEnv;
let stripeCli: Stripe;
@@ -112,7 +112,7 @@ describe(`${chalk.yellowBright(`${testCase}: Testing free add on, and updating f
const customer = await autumn.customers.get(customerId);
expect(customer.products.length).to.equal(2);
expect(customer.products.length).to.equal(3);
expectProductAttached({
customer,
product: addOn,
@@ -124,7 +124,7 @@ describe(`${chalk.yellowBright(`${testCase}: Testing free add on, and updating f
});
const customItems = replaceItems({
items: pro.items,
items: addOn.items,
featureId: TestFeature.Messages,
newItem: constructFeatureItem({
featureId: TestFeature.Messages,
@@ -149,7 +149,7 @@ describe(`${chalk.yellowBright(`${testCase}: Testing free add on, and updating f
const customer = await autumn.customers.get(customerId);
expect(customer.products.length).to.equal(2);
expect(customer.products.length).to.equal(3);
expectProductAttached({
customer,
product: addOn,

View File

@@ -0,0 +1,118 @@
import { AutumnInt } from "@/external/autumn/autumnCli.js";
import { initCustomer } from "@/utils/scriptUtils/initCustomer.js";
import { APIVersion, AppEnv, Organization } from "@autumn/shared";
import chalk from "chalk";
import Stripe from "stripe";
import { DrizzleCli } from "@/db/initDrizzle.js";
import { setupBefore } from "tests/before.js";
import { createProducts, createReward } from "tests/utils/productUtils.js";
import { addPrefixToProducts } from "../utils.js";
import {
constructCoupon,
constructProduct,
} from "@/utils/scriptUtils/createTestProducts.js";
import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js";
import { TestFeature } from "tests/setup/v2Features.js";
import { completeCheckoutForm } from "tests/utils/stripeUtils.js";
import { timeout } from "@/utils/genUtils.js";
import { expectProductAttached } from "tests/utils/expectUtils/expectProductAttached.js";
import { expect } from "chai";
import { getBasePrice } from "tests/utils/testProductUtils/testProductUtils.js";
import { completeInvoiceCheckout } from "tests/utils/stripeUtils/completeInvoiceCheckout.js";
import { expectFeaturesCorrect } from "tests/utils/expectUtils/expectFeaturesCorrect.js";
export let pro = constructProduct({
items: [
constructFeatureItem({
featureId: TestFeature.Messages,
includedUsage: 100,
}),
],
type: "pro",
});
const testCase = "checkout5";
describe(`${chalk.yellowBright(`${testCase}: Testing invoice checkout, no product till paid`)}`, () => {
let customerId = testCase;
let autumn: AutumnInt = new AutumnInt({ version: APIVersion.v1_2 });
let testClockId: string;
let db: DrizzleCli, org: Organization, env: AppEnv;
let stripeCli: Stripe;
let curUnix = new Date().getTime();
before(async function () {
await setupBefore(this);
const { autumnJs } = this;
db = this.db;
org = this.org;
env = this.env;
stripeCli = this.stripeCli;
addPrefixToProducts({
products: [pro],
prefix: testCase,
});
await createProducts({
autumn,
products: [pro],
customerId,
db,
orgId: org.id,
env,
});
const { testClockId: testClockId1 } = await initCustomer({
autumn: autumnJs,
customerId,
db,
org,
env,
// attachPm: "success",
});
testClockId = testClockId1!;
});
it("should attach pro product", async function () {
const res = await autumn.attach({
customer_id: customerId,
product_id: pro.id,
invoice: true,
});
const customer = await autumn.customers.get(customerId);
const invoice = customer.invoices?.[0];
expect(invoice).to.exist;
expect(invoice.total).to.equal(getBasePrice({ product: pro }));
expect(invoice.status).to.equal("open");
const product = customer.products.find((p) => p.id === pro.id);
expect(product).to.not.exist;
await completeInvoiceCheckout({
url: res.checkout_url,
});
const customer2 = await autumn.customers.get(customerId);
const invoice2 = customer2.invoices?.[0];
expect(customer2.invoices.length).to.equal(1);
expect(invoice2).to.exist;
expect(invoice2.status).to.equal("paid");
expectProductAttached({
customer: customer2,
product: pro,
});
expectFeaturesCorrect({
customer: customer2,
product: pro,
});
});
});

View File

@@ -0,0 +1,158 @@
import chalk from "chalk";
import Stripe from "stripe";
import { AutumnInt } from "@/external/autumn/autumnCli.js";
import { initCustomer } from "@/utils/scriptUtils/initCustomer.js";
import { APIVersion, AppEnv, Organization } from "@autumn/shared";
import { DrizzleCli } from "@/db/initDrizzle.js";
import { setupBefore } from "tests/before.js";
import { createProducts } from "tests/utils/productUtils.js";
import { addPrefixToProducts } from "../utils.js";
import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js";
import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js";
import { TestFeature } from "tests/setup/v2Features.js";
import { completeInvoiceCheckout } from "tests/utils/stripeUtils/completeInvoiceCheckout.js";
import { expect } from "chai";
import { expectProductAttached } from "tests/utils/expectUtils/expectProductAttached.js";
import { expectFeaturesCorrect } from "tests/utils/expectUtils/expectFeaturesCorrect.js";
import { expectAutumnError } from "tests/utils/expectUtils/expectErrUtils.js";
import { getBasePrice } from "tests/utils/testProductUtils/testProductUtils.js";
export let pro = constructProduct({
items: [
constructFeatureItem({
featureId: TestFeature.Messages,
includedUsage: 100,
}),
],
type: "pro",
});
export let premium = constructProduct({
items: [
constructFeatureItem({
featureId: TestFeature.Messages,
includedUsage: 250,
}),
],
type: "premium",
});
const testCase = "checkout6";
describe(`${chalk.yellowBright(`${testCase}: Testing invoice checkout via checkout endpoint`)}`, () => {
let customerId = testCase;
let autumn: AutumnInt = new AutumnInt({ version: APIVersion.v1_2 });
let testClockId: string;
let db: DrizzleCli, org: Organization, env: AppEnv;
let stripeCli: Stripe;
let curUnix = new Date().getTime();
before(async function () {
await setupBefore(this);
const { autumnJs } = this;
db = this.db;
org = this.org;
env = this.env;
stripeCli = this.stripeCli;
addPrefixToProducts({
products: [pro, premium],
prefix: testCase,
});
await createProducts({
autumn,
products: [pro, premium],
customerId,
db,
orgId: org.id,
env,
});
const { testClockId: testClockId1 } = await initCustomer({
autumn: autumnJs,
customerId,
db,
org,
env,
attachPm: "success",
});
testClockId = testClockId1!;
});
it("should attach pro product via invoice checkout", async function () {
const res = await autumn.checkout({
customer_id: customerId,
product_id: pro.id,
invoice: true,
});
expect(res.url).to.exist;
await completeInvoiceCheckout({
url: res.url!,
});
const customer = await autumn.customers.get(customerId);
expectProductAttached({
customer,
product: pro,
});
expectFeaturesCorrect({
customer,
product: pro,
});
});
it("should have no URL returned if try to attach premium (with invoice true)", async function () {
await expectAutumnError({
func: async () => {
await autumn.attach({
customer_id: customerId,
product_id: premium.id,
invoice: true,
});
},
});
const res = await autumn.checkout({
customer_id: customerId,
product_id: premium.id,
invoice: true,
});
expect(res.url).to.not.exist;
});
it("should attach premium product via invoice enable immediately", async function () {
const res = await autumn.attach({
customer_id: customerId,
product_id: premium.id,
invoice: true,
enable_product_immediately: true,
});
const customer = await autumn.customers.get(customerId);
expectProductAttached({
customer,
product: premium,
});
expectFeaturesCorrect({
customer,
product: premium,
});
const invoices = customer.invoices;
expect(invoices.length).to.equal(2);
expect(invoices[0].status).to.equal("draft");
expect(invoices[0].total).to.equal(
getBasePrice({ product: premium }) - getBasePrice({ product: pro })
); // proration...
});
});

View File

@@ -0,0 +1,141 @@
import chalk from "chalk";
import Stripe from "stripe";
import { AutumnInt } from "@/external/autumn/autumnCli.js";
import { initCustomer } from "@/utils/scriptUtils/initCustomer.js";
import { APIVersion, AppEnv, Organization } from "@autumn/shared";
import { DrizzleCli } from "@/db/initDrizzle.js";
import { setupBefore } from "tests/before.js";
import { createProducts } from "tests/utils/productUtils.js";
import { addPrefixToProducts } from "../utils.js";
import {
constructProduct,
constructRawProduct,
} from "@/utils/scriptUtils/createTestProducts.js";
import {
constructFeatureItem,
constructPrepaidItem,
} from "@/utils/scriptUtils/constructItem.js";
import { TestFeature } from "tests/setup/v2Features.js";
import { completeInvoiceCheckout } from "tests/utils/stripeUtils/completeInvoiceCheckout.js";
import { expect } from "chai";
import { expectProductAttached } from "tests/utils/expectUtils/expectProductAttached.js";
import { expectFeaturesCorrect } from "tests/utils/expectUtils/expectFeaturesCorrect.js";
export let pro = constructProduct({
items: [
constructFeatureItem({
featureId: TestFeature.Messages,
includedUsage: 100,
}),
],
type: "pro",
});
export let addOn = constructRawProduct({
id: "addOn",
items: [
constructPrepaidItem({
featureId: TestFeature.Messages,
billingUnits: 100,
price: 10,
isOneOff: true,
}),
],
});
const testCase = "checkout7";
describe(`${chalk.yellowBright(`${testCase}: Testing invoice checkout with one off product`)}`, () => {
let customerId = testCase;
let autumn: AutumnInt = new AutumnInt({ version: APIVersion.v1_2 });
let testClockId: string;
let db: DrizzleCli, org: Organization, env: AppEnv;
let stripeCli: Stripe;
let curUnix = new Date().getTime();
before(async function () {
await setupBefore(this);
const { autumnJs } = this;
db = this.db;
org = this.org;
env = this.env;
stripeCli = this.stripeCli;
addPrefixToProducts({
products: [pro, addOn],
prefix: testCase,
});
await createProducts({
autumn,
products: [pro, addOn],
customerId,
db,
orgId: org.id,
env,
});
const { testClockId: testClockId1 } = await initCustomer({
autumn: autumnJs,
customerId,
db,
org,
env,
attachPm: "success",
});
testClockId = testClockId1!;
});
it("should attach pro product, then add on product via invoice checkout", async function () {
await autumn.attach({
customer_id: customerId,
product_id: pro.id,
});
const options = [
{
quantity: 200,
feature_id: TestFeature.Messages,
},
];
const res2 = await autumn.checkout({
customer_id: customerId,
product_id: addOn.id,
invoice: true,
options,
});
expect(res2.url).to.exist;
await completeInvoiceCheckout({
url: res2.url!,
});
const customer = await autumn.customers.get(customerId);
expectProductAttached({
customer,
product: addOn,
});
expectFeaturesCorrect({
customer,
product: addOn,
otherProducts: [pro],
options,
});
});
// it("should have no URL returned if try to attach add on (with invoice true)", async function () {
// const res = await autumn.checkout({
// customer_id: customerId,
// product_id: addOn.id,
// invoice: true,
// });
// expect(res.url).to.not.exist;
// });
});

View File

@@ -12,12 +12,14 @@ const checkEntitledOnProduct = async ({
totalAllowance,
finish = false,
usageBased = false,
timeoutMs = 8000,
}: {
customerId: string;
product: any;
totalAllowance?: number;
finish?: boolean;
usageBased?: boolean;
timeoutMs?: number;
}) => {
// 1. Send events
const allowance = totalAllowance || product.entitlements.metered1.allowance;
@@ -35,7 +37,7 @@ const checkEntitledOnProduct = async ({
}
await Promise.all(batchUpdates);
await timeout(8000);
await timeout(timeoutMs);
let used = randomNum;
// 2. Check entitled
@@ -74,7 +76,7 @@ const checkEntitledOnProduct = async ({
);
}
await Promise.all(batchUpdates2);
await timeout(8000);
await timeout(timeoutMs);
used += allowance - randomNum;
// 3. Check entitled again
@@ -124,13 +126,13 @@ describe(`${chalk.yellowBright(
});
});
it("should have correct entitlements (free)", async function () {
await checkEntitledOnProduct({
customerId: customerId,
product: products.free,
finish: true,
});
});
// it("should have correct entitlements (free)", async function () {
// await checkEntitledOnProduct({
// customerId: customerId,
// product: products.free,
// finish: true,
// });
// });
it("should attach pro", async function () {
await AutumnCli.attach({
@@ -170,6 +172,7 @@ describe(`${chalk.yellowBright(
product: products.oneTimeAddOnMetered1,
finish: true,
totalAllowance: curAllowance + oneTimeQuantity,
timeoutMs: 15000,
});
});
});
@@ -231,7 +234,7 @@ describe(`${chalk.yellowBright(
}
await Promise.all(batchUpdates);
await timeout(14000);
await timeout(10000);
const { allowed: allowed2, balanceObj: balanceObj2 }: any =
await AutumnCli.entitled(customerId, features.metered1.id, true);

View File

@@ -93,7 +93,8 @@ describe(`${chalk.yellowBright(`${testCase}: Testing attach with customer ID and
customer_id: internalCustomerId,
entity_id: internalEntityId,
product_id: pro.id,
invoice_only: true,
invoice: true,
enable_product_immediately: true,
});
const customer = await autumn.customers.get(internalCustomerId);

View File

@@ -0,0 +1,144 @@
import "dotenv/config";
import { Stripe } from "stripe";
import puppeteer from "puppeteer-core";
import Browserbase from "@browserbasehq/sdk";
import { Hyperbrowser } from "@hyperbrowser/sdk";
import { timeout } from "../genUtils.js";
const client = new Hyperbrowser({
apiKey: process.env.HYPERBROWSER_API_KEY,
});
export const completeInvoiceCheckout = async ({
url,
isLocal = false,
}: {
url: string;
isLocal?: boolean;
}) => {
let browser;
if (process.env.NODE_ENV === "development" && !isLocal) {
const session = await client.sessions.create();
browser = await puppeteer.connect({
browserWSEndpoint: session!.wsEndpoint,
defaultViewport: null,
});
} else {
browser = await puppeteer.launch({
headless: false,
executablePath: "/Applications/Chromium.app/Contents/MacOS/Chromium",
args: ["--no-sandbox", "--disable-setuid-sandbox"],
});
}
try {
const page = await browser.newPage();
await page.setViewport({ width: 1280, height: 800 }); // Set standard desktop viewport size
await page.goto(url);
// Wait for the payment element to load
await page.waitForSelector("#payment-element", { timeout: 10000 });
// Wait a bit more for the iframe to fully load
await new Promise((resolve) => setTimeout(resolve, 3000));
// Try clicking on the payment element container to expand the accordion
await page.click("#payment-element");
await new Promise((resolve) => setTimeout(resolve, 3000));
// Get the iframe containing the Stripe elements
const stripeFrame = await page.$("#payment-element iframe");
if (!stripeFrame) {
throw new Error("Stripe iframe not found");
}
const frame = await stripeFrame.contentFrame();
if (!frame) {
throw new Error("Could not access iframe content");
}
// Enter card number - try different possible selectors
try {
await frame.waitForSelector(
'input[name="number"], input[data-elements-stable-field-name="cardNumber"], input[placeholder*="1234"], input[aria-label*="Card number"]',
{ timeout: 2000 }
);
const cardNumberInput = await frame.$(
'input[name="number"], input[data-elements-stable-field-name="cardNumber"], input[placeholder*="1234"], input[aria-label*="Card number"]'
);
if (cardNumberInput) {
await cardNumberInput.click();
await cardNumberInput.type("4242424242424242");
}
} catch (error) {
console.log("Could not find card number input:", error);
}
// Enter expiry date
try {
await frame.waitForSelector(
'input[name="expiry"], input[data-elements-stable-field-name="cardExpiry"], input[placeholder*="MM"], input[aria-label*="expir"]',
{ timeout: 2000 }
);
const expiryInput = await frame.$(
'input[name="expiry"], input[data-elements-stable-field-name="cardExpiry"], input[placeholder*="MM"], input[aria-label*="expir"]'
);
if (expiryInput) {
await expiryInput.click();
await expiryInput.type("1227");
}
} catch (error) {
console.log("Could not find expiry input:", error);
}
// Enter CVC
try {
await frame.waitForSelector(
'input[name="cvc"], input[data-elements-stable-field-name="cardCvc"], input[placeholder*="CVC"], input[aria-label*="CVC"]',
{ timeout: 2000 }
);
const cvcInput = await frame.$(
'input[name="cvc"], input[data-elements-stable-field-name="cardCvc"], input[placeholder*="CVC"], input[aria-label*="CVC"]'
);
if (cvcInput) {
await cvcInput.click();
await cvcInput.type("123");
}
} catch (error) {
console.log("Could not find CVC input:", error);
}
// Enter postal code
try {
await frame.waitForSelector(
'input[name="postalCode"], input[data-elements-stable-field-name="postalCode"], input[placeholder*="12345"], input[aria-label*="ZIP"]',
{ timeout: 2000 }
);
const postalInput = await frame.$(
'input[name="postalCode"], input[data-elements-stable-field-name="postalCode"], input[placeholder*="12345"], input[aria-label*="ZIP"]'
);
if (postalInput) {
await postalInput.click();
await postalInput.type("12345");
}
} catch (error) {
console.log("Could not find postal code input:", error);
}
// Wait a bit for all inputs to be processed
await new Promise((resolve) => setTimeout(resolve, 2000));
const submitButton = await page.$(".SubmitButton-IconContainer");
await submitButton?.evaluate((b: any) => (b as HTMLElement).click());
await timeout(20000);
} finally {
// always close browser
await browser.close();
}
};

View File

@@ -44,6 +44,7 @@ export const AttachBodySchema = z
checkout_session_params: z.any().optional(),
reward: z.string().optional(),
invoice: z.boolean().optional(),
enable_product_immediately: z.boolean().optional(),
})
.refine(
(data) => {

View File

@@ -8,6 +8,7 @@ export enum ProrationBehavior {
export interface AttachConfig {
onlyCheckout: boolean;
invoiceCheckout: boolean;
carryUsage: boolean; // Whether to carry over existing usages
branch: AttachBranch;
proration: ProrationBehavior;

View File

@@ -111,7 +111,7 @@ export const CustomerDetails = ({
>
<FontAwesomeIcon
icon={faStripe}
className="!h-6 text-t2"
className="!h-6 !w-6 text-t2"
/>
<ArrowUpRightFromSquare size={12} className="text-t2" />
</Button>

View File

@@ -31,6 +31,7 @@ import { cn } from "@/lib/utils";
import { Separator } from "@/components/ui/separator";
import { AttachInfo } from "./attach-preview/AttachInfo";
import { getAttachBody } from "./attachProductUtils";
import { InvoiceCustomerButton } from "./InvoiceCustomerButton";
export const AttachModal = ({
open,
@@ -118,8 +119,15 @@ export const AttachModal = ({
return "Charge Customer";
};
const handleAttachClicked = async (useInvoice: boolean) => {
const setLoading = useInvoice ? setInvoiceLoading : setCheckoutLoading;
const handleAttachClicked = async ({
useInvoice,
enableProductImmediately,
setLoading,
}: {
useInvoice: boolean;
enableProductImmediately?: boolean;
setLoading: (loading: boolean) => void;
}) => {
const cusId = getCusId();
for (const option of options) {
@@ -145,6 +153,7 @@ export const AttachModal = ({
optionsInput: options,
attachState,
useInvoice,
enableProductImmediately,
successUrl: `${import.meta.env.VITE_FRONTEND_URL}${redirectUrl}`,
version: version || product.version,
});
@@ -155,9 +164,8 @@ export const AttachModal = ({
window.open(data.checkout_url, "_blank");
} else if (data.invoice) {
window.open(getStripeInvoiceLink(data.invoice), "_blank");
} else {
navigateTo(`/customers/${cusId}`, navigation, env);
}
navigateTo(`/customers/${cusId}`, navigation, env);
toast.success(data.message || "Successfully attached product");
setOpen(false);
@@ -186,7 +194,7 @@ export const AttachModal = ({
const mainWidth = "w-lg";
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="gap-0 p-0 rounded-xs">
<DialogContent className="translate-y-[0%] top-[20%] max-h-[70vh] duration-0 p-0 overflow-y-auto">
<div className="flex transition-all duration-300 ease-in-out">
<div
className={`p-6 pb-2 flex flex-col gap-4 ${mainWidth} rounded-sm`}
@@ -234,19 +242,23 @@ export const AttachModal = ({
)}
>
{invoiceAllowed() && (
<Button
variant="add"
className="!h-full text-t2"
endIcon={<ArrowUpRightFromSquare size={12} />}
disableStartIcon={true}
tabIndex={-1}
tooltipContent="This will enable the product for the customer immediately, and redirect you to Stripe to finalize the invoice"
isLoading={invoiceLoading}
disabled={invoiceLoading || checkoutLoading}
onClick={() => handleAttachClicked(true)}
>
Invoice Customer
</Button>
<InvoiceCustomerButton
preview={preview}
handleAttachClicked={handleAttachClicked}
/>
// <Button
// variant="add"
// className="!h-full text-t2"
// endIcon={<ArrowUpRightFromSquare size={12} />}
// disableStartIcon={true}
// tabIndex={-1}
// tooltipContent="This will enable the product for the customer immediately, and redirect you to Stripe to finalize the invoice"
// isLoading={invoiceLoading}
// disabled={invoiceLoading || checkoutLoading}
// onClick={() => handleAttachClicked(true)}
// >
// Invoice Customer
// </Button>
)}
<Button
variant="add"
@@ -255,7 +267,12 @@ export const AttachModal = ({
endIcon={<ArrowUpRightFromSquare size={12} />}
isLoading={checkoutLoading}
disabled={invoiceLoading || checkoutLoading}
onClick={() => handleAttachClicked(false)}
onClick={() =>
handleAttachClicked({
useInvoice: false,
setLoading: setCheckoutLoading,
})
}
>
{getButtonText()}
</Button>

View File

@@ -0,0 +1,103 @@
import FieldLabel from "@/components/general/modal-components/FieldLabel";
import { Button } from "@/components/ui/button";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { AttachBranch, AttachFunction } from "@autumn/shared";
import { ArrowUpRightFromSquare } from "lucide-react";
import { useState } from "react";
export const InvoiceCustomerButton = ({
handleAttachClicked,
preview,
}: {
handleAttachClicked: any;
preview?: any;
}) => {
const [immediateLoading, setImmediateLoading] = useState(false);
const [afterPaymentLoading, setAfterPaymentLoading] = useState(false);
const buttonsDisabled = immediateLoading || afterPaymentLoading;
const allowedBranches = [
AttachBranch.New,
AttachBranch.MainIsTrial,
AttachBranch.MainIsFree,
AttachBranch.OneOff,
AttachBranch.AddOn,
];
// const immediateDisabled = !allowedBranches.includes(preview?.branch);
console.log("Preview:", preview);
return (
<Popover>
<PopoverTrigger asChild>
<Button
variant="add"
className="!h-full text-t2"
endIcon={<ArrowUpRightFromSquare size={12} />}
disableStartIcon={true}
tabIndex={-1}
tooltipContent="This will enable the product for the customer immediately, and redirect you to Stripe to finalize the invoice"
>
Invoice Customer
</Button>
</PopoverTrigger>
<PopoverContent className="w-fit flex p-0">
<div className="w-[300px]">
<div className="border-r p-4 text-sm flex flex-col gap-2">
<p>Enable Product Immediately</p>
<p className="text-t2">
This will enable the product for the customer immediately, and
redirect you to Stripe to finalize the invoice
</p>
<Button
isLoading={immediateLoading}
className="w-fit mt-2"
variant="outline"
disabled={buttonsDisabled}
onClick={() =>
handleAttachClicked({
useInvoice: true,
enableProductImmediately: true,
setLoading: setImmediateLoading,
})
}
>
Invoice and enable immediately
</Button>
</div>
</div>
{preview?.func == AttachFunction.CreateCheckout && (
<div className="w-[300px]">
<div className="p-4 text-sm flex flex-col gap-2">
<p>Enable Product After Payment</p>
<p className="text-t2">
This will generate an invoice link for the customer, and enable
the product after they pay the invoice
</p>
<Button
isLoading={afterPaymentLoading}
className="w-fit mt-2"
variant="outline"
disabled={buttonsDisabled}
onClick={() =>
handleAttachClicked({
useInvoice: true,
enableProductImmediately: false,
setLoading: setAfterPaymentLoading,
})
}
>
Invoice and enable after payment
</Button>
</div>
</div>
)}
</PopoverContent>
</Popover>
);
};

View File

@@ -13,6 +13,7 @@ export const getAttachBody = ({
entityId,
optionsInput,
useInvoice,
enableProductImmediately = true,
successUrl,
version,
}: {
@@ -22,6 +23,7 @@ export const getAttachBody = ({
entityId: string;
optionsInput?: FeatureOptions[];
useInvoice?: boolean;
enableProductImmediately?: boolean;
successUrl?: string;
version?: number;
}) => {
@@ -47,7 +49,14 @@ export const getAttachBody = ({
...customData,
free_trial: isCustom ? product.free_trial || undefined : undefined,
invoice_only: useInvoice,
invoice: useInvoice,
enable_product_immediately: useInvoice
? enableProductImmediately
: undefined,
force_checkout:
useInvoice && enableProductImmediately === false ? true : undefined,
success_url: successUrl,
version: version ? Number(version) : undefined,
};

View File

@@ -11,31 +11,31 @@ export const useMemberships = () => {
url: "/organization/members",
});
const { data: session } = useSession();
const { data: orgs } = useListOrganizations();
// const { data: session } = useSession();
// const { data: orgs } = useListOrganizations();
const handleRemovedFromOrg = async () => {
const inOrg = orgs?.find(
(org: any) => org.id === session?.session?.activeOrganizationId,
);
// const handleRemovedFromOrg = async () => {
// const inOrg = orgs?.find(
// (org: any) => org.id === session?.session?.activeOrganizationId,
// );
if (!inOrg) {
if (orgs && orgs.length > 0) {
await authClient.organization.setActive({
organizationId: orgs[0].id,
});
} else {
const { data, error } = await authClient.revokeSessions();
console.log("Revoked sessions", data, error);
}
window.location.reload();
}
};
// if (!inOrg) {
// if (orgs && orgs.length > 0) {
// await authClient.organization.setActive({
// organizationId: orgs[0].id,
// });
// } else {
// const { data, error } = await authClient.revokeSessions();
// console.log("Revoked sessions", data, error);
// }
// window.location.reload();
// }
// };
useEffect(() => {
if (!orgs) return;
handleRemovedFromOrg();
}, [orgs]);
// useEffect(() => {
// if (!orgs) return;
// handleRemovedFromOrg();
// }, [orgs]);
return {
memberships: data?.memberships || [],