feat: checkout endpoint

This commit is contained in:
John Yeo
2025-07-18 10:36:07 +01:00
parent c715c1921d
commit b3dfc1babf
20 changed files with 262 additions and 120 deletions

3
pnpm-lock.yaml generated
View File

@@ -410,6 +410,9 @@ importers:
ag-charts-community:
specifier: ^12.0.2
version: 12.0.2
ag-grid-community:
specifier: ^34.0.2
version: 34.0.2
ag-grid-react:
specifier: ^34.0.2
version: 34.0.2(react-dom@18.2.0(react@18.2.0))(react@18.2.0)

View File

@@ -12,7 +12,10 @@ import {
} from "@autumn/shared";
import Stripe from "stripe";
import assert from "assert";
import { cusProductToPrices } from "@/internal/customers/cusProducts/cusProductUtils/convertCusProduct.js";
import {
cusProductsToCusEnts,
cusProductToPrices,
} from "@/internal/customers/cusProducts/cusProductUtils/convertCusProduct.js";
import {
findStripeItemForPrice,
isLicenseItem,
@@ -30,6 +33,7 @@ import { CusService } from "@/internal/customers/CusService.js";
import { getStripeSubs } from "@/external/stripe/stripeSubUtils.js";
import { createSupabaseClient } from "@/external/supabaseUtils.js";
import { isFreeProduct, isOneOff } from "@/internal/products/productUtils.js";
import { getRelatedCusPrice } from "./internal/customers/cusProducts/cusEnts/cusEntUtils.js";
const { db, client } = initDrizzle({ maxConnections: 5 });
let orgSlugs = process.env.ORG_SLUGS!.split(",");
@@ -183,12 +187,20 @@ const checkCustomerCorrect = async ({
}
}
// console.log("prices:", prices);
// console.log("subItems:", subItems);
assert(
prices.length - missingUsageCount === subItems.length,
`(${cusProduct.product.name}) number of sub items equivalent to number of prices`
);
for (const cusEnt of cusProduct.customer_entitlements) {
let cusPrice = getRelatedCusPrice(cusEnt, cusProduct.customer_prices);
if (cusEnt.usage_allowed && !cusPrice) {
assert.fail(
`Feature ${cusEnt.feature_id} has usage allowed but no related cus price`
);
}
}
}
// Other checks to perform

View File

@@ -72,11 +72,11 @@ const checkSubAnchor = async ({
console.log("Checking billing cycle anchor");
console.log(
"Next reset at ",
format(new UTCDate(nextResetAt), "dd MMM yyyy HH:mm:ss"),
format(new UTCDate(nextResetAt), "dd MMM yyyy HH:mm:ss")
);
console.log(
"Billing cycle anchor",
format(new UTCDate(billingCycleAnchor), "dd MMM yyyy HH:mm:ss"),
format(new UTCDate(billingCycleAnchor), "dd MMM yyyy HH:mm:ss")
);
const billingCycleDay = getDate(new UTCDate(billingCycleAnchor));
@@ -116,7 +116,7 @@ const resetCustomerEntitlement = async ({
const entOptions = getEntOptions(
cusEnt.customer_product.options,
cusEnt.entitlement,
cusEnt.entitlement
);
const resetBalance = getResetBalance({
@@ -140,10 +140,10 @@ const resetCustomerEntitlement = async ({
console.log(
`Reset ${cusEnt.id} | customer: ${chalk.yellow(
cusEnt.customer_id,
cusEnt.customer_id
)} | feature: ${chalk.yellow(
cusEnt.feature_id,
)} | new balance: unlimited`,
cusEnt.feature_id
)} | new balance: unlimited`
);
return;
}
@@ -159,16 +159,16 @@ const resetCustomerEntitlement = async ({
console.log(
`Reset ${cusEnt.id} | customer: ${chalk.yellow(
cusEnt.customer_id,
cusEnt.customer_id
)} | feature: ${chalk.yellow(
cusEnt.feature_id,
)} | reset to lifetime (next_reset_at: null)`,
cusEnt.feature_id
)} | reset to lifetime (next_reset_at: null)`
);
return;
}
let nextResetAt = getNextResetAt(
new UTCDate(cusEnt.next_reset_at!),
cusEnt.entitlement.interval as EntInterval,
cusEnt.entitlement.interval as EntInterval
);
let resetBalanceUpdate = getResetBalancesUpdate({
@@ -199,18 +199,18 @@ const resetCustomerEntitlement = async ({
console.log(
`Reset ${cusEnt.id} | customer: ${chalk.yellow(
cusEnt.customer_id,
cusEnt.customer_id
)} | feature: ${chalk.yellow(
cusEnt.feature_id,
cusEnt.feature_id
)} | new balance: ${chalk.green(
resetBalance,
resetBalance
)} | new next_reset_at: ${chalk.green(
format(new UTCDate(nextResetAt), "dd MMM yyyy HH:mm:ss"),
)}`,
format(new UTCDate(nextResetAt), "dd MMM yyyy HH:mm:ss")
)}`
);
} catch (error: any) {
console.log(
`Failed to reset ${cusEnt.id} | ${cusEnt.customer_id} | ${cusEnt.feature_id}, error: ${error}`,
`Failed to reset ${cusEnt.id} | ${cusEnt.customer_id} | ${cusEnt.feature_id}, error: ${error}`
);
}
};
@@ -218,16 +218,16 @@ const resetCustomerEntitlement = async ({
export const cronTask = async () => {
console.log(
"\n----------------------------------\nRUNNING RESET CRON:",
format(new UTCDate(), "yyyy-MM-dd HH:mm:ss"),
format(new UTCDate(), "yyyy-MM-dd HH:mm:ss")
);
const { db, client } = initDrizzle();
try {
let cusEnts: FullCusEntWithProduct[] =
await CusEntService.getActiveResetPassed({ db });
await CusEntService.getActiveResetPassed({ db, batchSize: 500 });
const batchSize = 20;
const batchSize = 50;
for (let i = 0; i < cusEnts.length; i += batchSize) {
const batch = cusEnts.slice(i, i + batchSize);
const batchResets = [];
@@ -236,7 +236,7 @@ export const cronTask = async () => {
resetCustomerEntitlement({
db,
cusEnt: cusEnt as FullCusEntWithProduct,
}),
})
);
}
@@ -245,7 +245,7 @@ export const cronTask = async () => {
console.log(
"FINISHED RESET CRON:",
format(new UTCDate(), "yyyy-MM-dd HH:mm:ss"),
format(new UTCDate(), "yyyy-MM-dd HH:mm:ss")
);
console.log("----------------------------------\n");
} catch (error) {
@@ -263,7 +263,7 @@ const job = new CronJob(
},
null, // onComplete
true, // start immediately
"UTC", // timezone (adjust as needed)
"UTC" // timezone (adjust as needed)
);
// job.start();

View File

@@ -68,8 +68,8 @@ stripeWebhookRouter.post(
return;
}
const webhookSecret = getStripeWebhookSecret(org, env);
try {
const webhookSecret = getStripeWebhookSecret(org, env);
event = stripe.webhooks.constructEvent(request.body, sig, webhookSecret);
} catch (err: any) {
response.status(400).send(`Webhook Error: ${err.message}`);

View File

@@ -68,7 +68,12 @@ export const mapOptionsList = ({
newOption.internal_feature_id === option.internal_feature_id
);
if (!inNewOptions) {
const prepaidPriceExists = findPrepaidPrice({
prices,
internalFeatureId: option.internal_feature_id!,
});
if (!inNewOptions && prepaidPriceExists) {
newOptionsList.push(option);
}
}

View File

@@ -0,0 +1,36 @@
import { ExtendedRequest } from "@/utils/models/Request.js";
import { AttachParams } from "../../cusProducts/AttachParams.js";
import { AttachBranch } from "@autumn/shared";
import {
attachParamsToCurCusProduct,
attachParamToCusProducts,
} from "../attachUtils/convertAttachParams.js";
import { cusProductToPrices } from "../../cusProducts/cusProductUtils/convertCusProduct.js";
import { isFreeProduct } from "@/internal/products/productUtils.js";
export const getHasProrations = async ({
req,
branch,
attachParams,
}: {
req: ExtendedRequest;
branch: AttachBranch;
attachParams: AttachParams;
}) => {
let hasProrations = false;
let { curMainProduct } = attachParamToCusProducts({ attachParams });
if (branch == AttachBranch.Upgrade) {
let curPrices = cusProductToPrices({ cusProduct: curMainProduct! });
if (!isFreeProduct(curPrices)) {
return true;
}
}
if (branch == AttachBranch.UpdatePrepaidQuantity) {
return true;
}
return false;
};

View File

@@ -1,12 +1,11 @@
import {
AttachBranch,
AttachFunction,
AttachScenario,
CheckoutResponseSchema,
FeatureOptions,
FreeTrialResponseSchema,
ProductItemResponseSchema,
ProductResponseSchema,
} from "@autumn/shared";
import { routeHandler } from "@/utils/routerUtils.js";
import { getAttachParams } from "../attachUtils/attachParams/getAttachParams.js";
import { AttachBody, AttachBodySchema } from "@autumn/shared";
@@ -16,7 +15,6 @@ 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 { z } from "zod";
import { checkStripeConnections } from "../attachRouter.js";
import { attachParamsToPreview } from "../handleAttachPreview/attachParamsToPreview.js";
import { previewToCheckoutRes } from "./previewToCheckoutRes.js";
@@ -26,6 +24,7 @@ import { attachParamsToProduct } from "../attachUtils/convertAttachParams.js";
import { isPrepaidPrice } from "@/internal/products/prices/priceUtils/usagePriceUtils/classifyUsagePrice.js";
import { priceToFeature } from "@/internal/products/prices/priceUtils/convertPrice.js";
import { getPriceOptions } from "@/internal/products/prices/priceUtils.js";
import { getHasProrations } from "./getHasProrations.js";
const getAttachVars = async ({
req,
@@ -167,12 +166,20 @@ export const handleCheckout = (req: any, res: any) =>
preview,
});
// Get has prorations
const hasProrations = await getHasProrations({
req,
branch,
attachParams,
});
res.status(200).json({
...checkoutRes,
options: attachParams.optionsList.map((o) => ({
quantity: o.quantity,
feature_id: o.feature_id,
})),
has_prorations: hasProrations,
});
return;

View File

@@ -2,12 +2,11 @@ import {
AttachPreview,
CheckoutLine,
CheckoutResponseSchema,
UsageModel,
} from "@autumn/shared";
import { AttachParams } from "../../cusProducts/AttachParams.js";
import { getAttachScenario } from "@/internal/api/entitled/handlers/attachToCheckPreview/getAttachScenario.js";
import {
attachParamsToCurCusProduct,
attachParamsToProduct,
attachParamToCusProducts,
} from "../attachUtils/convertAttachParams.js";
@@ -23,7 +22,9 @@ import {
} from "@/internal/products/productUtils/productResponseUtils/getProductResponse.js";
import { toProductItem } from "@/internal/products/product-items/mapToItem.js";
import { getPriceEntitlement } from "@/internal/products/prices/priceUtils.js";
import { notNullish } from "@/utils/genUtils.js";
import { formatUnixToDateTime, notNullish } from "@/utils/genUtils.js";
import { isPriceItem } from "@/internal/products/product-items/productItemUtils/getItemType.js";
import { Decimal } from "decimal.js";
export const previewToCheckoutRes = async ({
req,
@@ -36,7 +37,6 @@ export const previewToCheckoutRes = async ({
}) => {
const { logger, features, org } = req;
const product = attachParamsToProduct({ attachParams });
const scenario = await getAttachScenario({ preview, product });
const { curCusProduct } = attachParamToCusProducts({ attachParams });
let curPrices = curCusProduct
@@ -83,6 +83,7 @@ export const previewToCheckoutRes = async ({
features,
currency: org.default_currency,
options: attachParams.optionsList,
fullCus: attachParams.customer,
});
let curProduct = curCusProduct
@@ -96,17 +97,46 @@ export const previewToCheckoutRes = async ({
const total = lines.reduce((acc, line) => acc + line.amount, 0);
let nextCycle = undefined;
if (notNullish(preview.due_next_cycle)) {
nextCycle = {
starts_at: preview.due_next_cycle.due_at,
total: newProduct.items
.reduce((acc, item) => {
if (item.usage_model == UsageModel.PayPerUse) {
return acc;
}
// if (item.interval !== newProduct.properties?.interval_group) {
// return acc;
// }
if (isPriceItem(item)) {
return acc.plus(item.price || 0);
}
let prepaidQuantity =
attachParams.optionsList.find(
(o) => o.feature_id == item.feature_id
)?.quantity || 0;
return acc.plus(prepaidQuantity * (item.price || 0));
}, new Decimal(0))
.toNumber(),
};
}
return CheckoutResponseSchema.parse({
customer_id: attachParams.customer.id,
scenario,
lines,
product: newProduct,
current_product: curProduct,
total,
currency: org.default_currency || "usd",
// next_cycle: nextCycle,
next_cycle_at: notNullish(preview.due_next_cycle)
? preview.due_next_cycle.due_at
: null,
next_cycle: nextCycle,
});
};

View File

@@ -20,10 +20,10 @@ export const getDowngradeProductPreview = async ({
}) => {
const newProduct = attachParamsToProduct({ attachParams });
const { curMainProduct } = attachParamToCusProducts({ attachParams });
const { curCusProduct } = attachParamToCusProducts({ attachParams });
const stripeSubs = await getStripeSubs({
stripeCli: attachParams.stripeCli,
subIds: curMainProduct?.subscription_ids || [],
subIds: curCusProduct?.subscription_ids || [],
});
const anchorToUnix = stripeSubs[0].current_period_end * 1000;
@@ -47,11 +47,15 @@ export const getDowngradeProductPreview = async ({
// anchorToUnix,
});
let nextCycleAt = curCusProduct?.trial_ends_at
? curCusProduct.trial_ends_at
: anchorToUnix;
return {
currency: attachParams.org.default_currency,
due_next_cycle: {
line_items: items,
due_at: anchorToUnix,
due_at: nextCycleAt,
},
options,

View File

@@ -13,7 +13,12 @@ import { freeTrialToStripeTimestamp } from "@/internal/products/free-trials/free
import { getLastInterval } from "@/internal/products/prices/priceUtils/priceIntervalUtils.js";
import { isFreeProduct, isOneOff } from "@/internal/products/productUtils.js";
import { getMergeCusProduct } from "../attachFunctions/addProductFlow/getMergeCusProduct.js";
import { formatUnixToDate, notNullish, nullish } from "@/utils/genUtils.js";
import {
formatUnixToDate,
formatUnixToDateTime,
notNullish,
nullish,
} from "@/utils/genUtils.js";
export const getNewProductPreview = async ({
branch,

View File

@@ -30,7 +30,7 @@ import { freeTrialToStripeTimestamp } from "@/internal/products/free-trials/free
import { Decimal } from "decimal.js";
import { intervalsAreSame } from "../attachUtils/getAttachConfig.js";
import { isFreeProduct } from "@/internal/products/productUtils.js";
import { notNullish } from "@/utils/genUtils.js";
import { formatUnixToDateTime, notNullish } from "@/utils/genUtils.js";
const getNextCycleAt = ({
prices,
@@ -60,17 +60,18 @@ const getNextCycleAt = ({
}
if (willCycleReset) {
const minInterval = getLastInterval({ prices });
const firstInterval = getFirstInterval({ prices });
return {
next_cycle_at: addBillingIntervalUnix(now, minInterval),
next_cycle_at: addBillingIntervalUnix(now, firstInterval),
};
}
const minInterval = getLastInterval({ prices });
const firstInterval = getFirstInterval({ prices });
const nextCycleAt = getAlignedIntervalUnix({
alignWithUnix: stripeSubs[0].current_period_end * 1000,
interval: minInterval,
interval: firstInterval,
alwaysReturn: true,
now,
});
return {

View File

@@ -52,41 +52,62 @@ export class CusEntService {
static async getActiveResetPassed({
db,
customDateUnix,
batchSize = 1000,
}: {
db: DrizzleCli;
customDateUnix?: number;
batchSize?: number;
}) {
const data = await db
.select()
.from(customerEntitlements)
.innerJoin(
customerProducts,
eq(customerEntitlements.customer_product_id, customerProducts.id),
)
.innerJoin(
entitlements,
eq(customerEntitlements.entitlement_id, entitlements.id),
)
.innerJoin(
features,
eq(entitlements.internal_feature_id, features.internal_id),
)
.where(
and(
eq(customerProducts.status, CusProductStatus.Active),
lt(customerEntitlements.next_reset_at, customDateUnix ?? Date.now()),
),
);
const allResults: FullCusEntWithProduct[] = [];
let offset = 0;
let hasMore = true;
return data.map((item) => ({
...item.customer_entitlements,
entitlement: {
...item.entitlements,
feature: item.features,
},
customer_product: item.customer_products,
replaceables: [],
})) as FullCusEntWithProduct[];
while (hasMore) {
const data = await db
.select()
.from(customerEntitlements)
.innerJoin(
customerProducts,
eq(customerEntitlements.customer_product_id, customerProducts.id)
)
.innerJoin(
entitlements,
eq(customerEntitlements.entitlement_id, entitlements.id)
)
.innerJoin(
features,
eq(entitlements.internal_feature_id, features.internal_id)
)
.where(
and(
eq(customerProducts.status, CusProductStatus.Active),
lt(customerEntitlements.next_reset_at, customDateUnix ?? Date.now())
)
)
.limit(batchSize)
.offset(offset);
if (data.length === 0) {
hasMore = false;
} else {
const mappedData = data.map((item) => ({
...item.customer_entitlements,
entitlement: {
...item.entitlements,
feature: item.features,
},
customer_product: item.customer_products,
replaceables: [],
})) as FullCusEntWithProduct[];
allResults.push(...mappedData);
offset += batchSize;
hasMore = data.length === batchSize;
console.log(`Fetched ${allResults.length} entitlements to reset`);
}
}
return allResults;
}
static async update({

View File

@@ -8,7 +8,6 @@ import {
FreeTrial,
PreviewLineItem,
BillingType,
AttachConfig,
getFeatureInvoiceDescription,
UsagePriceConfig,
UsageModel,
@@ -232,7 +231,7 @@ export const getItemsForNewProduct = async ({
billingUnits: (price.config as UsagePriceConfig).billing_units,
prodName: newProduct.name,
isPrepaid: true,
fromUnix: finalProration?.start,
fromUnix: now,
}),
amount,
usage_model: UsageModel.Prepaid,

View File

@@ -1,6 +1,10 @@
import { cusProductToProduct } from "@/internal/customers/cusProducts/cusProductUtils/convertCusProduct.js";
import { AttachScenario, FullCustomer, FullProduct } from "@autumn/shared";
import { isFreeProduct, isProductUpgrade } from "../../productUtils.js";
import {
isFreeProduct,
isOneOff,
isProductUpgrade,
} from "../../productUtils.js";
import { getExistingCusProducts } from "@/internal/customers/cusProducts/cusProductUtils/getExistingCusProducts.js";
export const getAttachScenario = ({
@@ -19,6 +23,10 @@ export const getAttachScenario = ({
if (!curMainProduct || fullProduct.is_add_on) return AttachScenario.New;
if (isOneOff(fullProduct.prices)) {
return AttachScenario.New;
}
// 1. If current product is the same as the product, return active
if (curMainProduct?.product.id == fullProduct.id) {
if (curMainProduct.canceled_at != null) {

View File

@@ -6,18 +6,17 @@ import {
FreeTrialResponseSchema,
FullCustomer,
ProductItem,
Organization,
AttachScenario,
ProductPropertiesSchema,
BillingInterval,
FeatureOptions,
UsageModel,
FreeTrial,
FreeTrialResponse,
Price,
} from "@autumn/shared";
import { sortProductItems } from "../../pricecn/pricecnUtils.js";
import {
getItemType,
isPriceItem,
} from "../../product-items/productItemUtils/getItemType.js";
import { getItemType } from "../../product-items/productItemUtils/getItemType.js";
import { mapToProductItems } from "../../productV2Utils.js";
import { getProductItemDisplay } from "./getProductItemDisplay.js";
import { getAttachScenario } from "./getAttachScenario.js";
@@ -28,6 +27,7 @@ import { isFreeProduct, isOneOff } from "../../productUtils.js";
import { getFirstInterval } from "../../prices/priceUtils/priceIntervalUtils.js";
import { itemToPriceOrTiers } from "../../product-items/productItemUtils.js";
import { toAPIFeature } from "@/internal/features/utils/mapFeatureUtils.js";
import { isPrepaidPrice } from "../../prices/priceUtils/usagePriceUtils/classifyUsagePrice.js";
export const getProductItemResponse = ({
item,
@@ -123,16 +123,29 @@ export const getFreeTrialResponse = async ({
return null;
};
export const getProductProperties = ({ product }: { product: FullProduct }) => {
export const getProductProperties = ({
product,
freeTrial,
}: {
product: FullProduct;
freeTrial?: FreeTrialResponse | null;
}) => {
let firstInterval: any = getFirstInterval({ prices: product.prices });
if (firstInterval == BillingInterval.OneOff) {
firstInterval = null;
}
let hasFreeTrial =
notNullish(freeTrial) && freeTrial?.trial_available !== false;
return ProductPropertiesSchema.parse({
is_free: isFreeProduct(product.prices) || false,
is_one_off: isOneOff(product.prices) || false,
interval_group: firstInterval,
has_trial: hasFreeTrial,
has_prepaid: product.prices.some((p: Price) =>
isPrepaidPrice({ price: p })
),
});
};
@@ -180,12 +193,12 @@ export const getProductResponse = async ({
fullProduct: product,
});
let freeTrial = await getFreeTrialResponse({
let freeTrial = (await getFreeTrialResponse({
db: db as DrizzleCli,
product,
fullCus,
attachScenario,
});
})) as FreeTrialResponse;
return ProductResponseSchema.parse({
...product,
@@ -194,6 +207,6 @@ export const getProductResponse = async ({
items: items,
free_trial: freeTrial || null,
scenario: attachScenario,
properties: getProductProperties({ product }),
properties: getProductProperties({ product, freeTrial }),
});
};

View File

@@ -1,14 +1,7 @@
import puppeteer from "puppeteer";
import { timeout } from "./genUtils.js";
import { Stripe } from "stripe";
import {
AppEnv,
BillingInterval,
BillingType,
Customer,
FullProduct,
Organization,
} from "@autumn/shared";
import { BillingInterval, Customer, FullProduct } from "@autumn/shared";
import {
addDays,
addHours,
@@ -17,8 +10,6 @@ import {
addWeeks,
format,
} from "date-fns";
import { SupabaseClient } from "@supabase/supabase-js";
import { getBillingType } from "@/internal/products/prices/priceUtils.js";
const STRIPE_TEST_CLOCK_TIMING = 20000; // 30s
// const STRIPE_TEST_CLOCK_TIMING = 40000; // 30s
@@ -26,7 +17,7 @@ const STRIPE_TEST_CLOCK_TIMING = 20000; // 30s
export const completeCheckoutForm = async (
url: string,
overrideQuantity?: number,
promoCode?: string,
promoCode?: string
) => {
const browser = await puppeteer.launch({
headless: false,
@@ -106,7 +97,7 @@ export const deleteAllStripeProducts = async ({
active: false,
});
}
}),
})
);
console.log("Deleted", i, "of", stripeProds.data.length);
}
@@ -124,9 +115,7 @@ export const deleteAllStripeTestClocks = async ({
for (let i = 0; i < stripeTestClocks.data.length; i += batchSize) {
const batch = stripeTestClocks.data.slice(i, i + batchSize);
await Promise.all(
batch.map(async (clock) =>
stripeCli.testHelpers.testClocks.del(clock.id),
),
batch.map(async (clock) => stripeCli.testHelpers.testClocks.del(clock.id))
);
}
};
@@ -149,7 +138,7 @@ export const deleteStripeProduct = async ({
const config = price.config as any;
if (config.stripe_price_id) {
const stripePrice = await stripeCli.prices.retrieve(
config.stripe_price_id,
config.stripe_price_id
);
await stripeCli.prices.update(config.stripe_price_id, {
@@ -252,7 +241,7 @@ export const advanceTestClock = async ({
});
await timeout(
waitForSeconds ? waitForSeconds * 1000 : STRIPE_TEST_CLOCK_TIMING,
waitForSeconds ? waitForSeconds * 1000 : STRIPE_TEST_CLOCK_TIMING
);
return advanceTo;
@@ -297,7 +286,7 @@ export const advanceClockForInvoice = async ({
console.log(
" - advanceClockForInvoice (1): ",
format(advanceTo, "dd MMM yyyy HH:mm:ss"),
format(advanceTo, "dd MMM yyyy HH:mm:ss")
);
if (waitForMeterUpdate) {
@@ -318,7 +307,7 @@ export const advanceClockForInvoice = async ({
console.log(
" - advanceClockForInvoice (2): ",
format(advanceTo2, "dd MMM yyyy HH:mm:ss"),
format(advanceTo2, "dd MMM yyyy HH:mm:ss")
);
await timeout(STRIPE_TEST_CLOCK_TIMING);
@@ -337,10 +326,10 @@ export const advanceMonths = async ({
let advanceTo = new Date();
for (let i = 0; i < numberOfMonths; i += 1) {
// let numMonths = Math.min(numberOfMonths - i, 2);
(advanceTo = addMonths(advanceTo, 1)), 10;
((advanceTo = addMonths(advanceTo, 1)), 10);
console.log(
" - Advancing to: ",
format(advanceTo, "dd MMM yyyy HH:mm:ss"),
format(advanceTo, "dd MMM yyyy HH:mm:ss")
);
try {
@@ -379,7 +368,7 @@ export const checkBillingMeterEventSummary = async ({
start_time: Math.round(startTime.getTime() / 1000),
end_time: Math.round(endTime.getTime() / 1000),
customer: stripeCustomerId,
},
}
);
if (event.data.length === 0) {
@@ -402,7 +391,7 @@ export const getDiscount = async ({
stripeId || customer!.processor!.id,
{
expand: ["discount.coupon"],
},
}
);
return stripeCustomer.discount;

View File

@@ -13,14 +13,20 @@ export const CheckoutLineSchema = z.object({
export const CheckoutResponseSchema = z.object({
url: z.string().nullish(),
customer_id: z.string(),
scenario: z.nativeEnum(AttachScenario),
lines: z.array(CheckoutLineSchema),
product: ProductResponseSchema.nullish(),
current_product: ProductResponseSchema.nullish(),
options: z.array(FeatureOptionsSchema).nullish(),
total: z.number().nullish(),
currency: z.string().nullish(),
next_cycle_at: z.number().nullish(),
has_prorations: z.boolean().nullish(),
// next_cycle_at: z.number().nullish(),
next_cycle: z
.object({
starts_at: z.number().nullish(),
total: z.number().nullish(),
})
.nullish(),
});
export type CheckoutLine = z.infer<typeof CheckoutLineSchema>;

View File

@@ -8,6 +8,8 @@ export const ProductPropertiesSchema = z.object({
is_free: z.boolean(),
is_one_off: z.boolean(),
interval_group: z.string().nullish(),
has_trial: z.boolean().nullish(),
has_prepaid: z.boolean().nullish(),
});
export const ProductResponseSchema = z.object({

View File

@@ -36,6 +36,7 @@
"@types/recharts": "^2.0.1",
"@wooorm/starry-night": "^3.6.0",
"ag-charts-community": "^12.0.2",
"ag-grid-community": "^34.0.2",
"ag-grid-react": "^34.0.2",
"autumn-js": "^0.0.93",
"axios": "^1.8.3",

View File

@@ -32,8 +32,8 @@ export default function PricingTable({
const intervals = Array.from(
new Set(
products?.map((p) => p.properties?.interval_group).filter((i) => !!i),
),
products?.map((p) => p.properties?.interval_group).filter((i) => !!i)
)
);
const multiInterval = intervals.length > 1;
@@ -151,7 +151,7 @@ export const PricingTableContainer = ({
{multiInterval && (
<div
className={cn(
products.some((p) => p.display?.recommend_text) && "mb-8",
products.some((p) => p.display?.recommend_text) && "mb-8"
)}
>
<AnnualSwitch
@@ -163,7 +163,7 @@ export const PricingTableContainer = ({
<div
className={cn(
"grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-[repeat(auto-fit,minmax(200px,1fr))] w-full gap-2",
className,
className
)}
>
{children}
@@ -214,7 +214,7 @@ export const PricingCard = ({
" w-full h-full py-6 text-foreground border rounded-lg shadow-sm max-w-xl",
isRecommended &&
"lg:-translate-y-6 lg:shadow-lg dark:shadow-zinc-800/80 lg:h-[calc(100%+48px)] bg-secondary/40",
className,
className
)}
>
{productDisplay?.recommend_text && (
@@ -223,7 +223,7 @@ export const PricingCard = ({
<div
className={cn(
"flex flex-col h-full flex-grow",
isRecommended && "lg:translate-y-6",
isRecommended && "lg:translate-y-6"
)}
>
<div className="h-full">
@@ -339,7 +339,7 @@ export const PricingCardButton = React.forwardRef<
<Button
className={cn(
"w-full py-3 px-4 group overflow-hidden relative transition-all duration-300 hover:brightness-90 border rounded-lg",
className,
className
)}
{...props}
variant={recommended ? "default" : "secondary"}