feat: payment failure returns checkout_url

This commit is contained in:
John Yeo
2025-11-28 16:58:13 +00:00
parent 7258fc23e5
commit 34ed3857f0
38 changed files with 1017 additions and 349 deletions

View File

@@ -1,8 +1,15 @@
import { initializeDatabaseFunctions } from "@server/db/initializeDatabaseFunctions";
import inquirer from "inquirer";
loadLocalEnv();
import { loadLocalEnv } from "@server/utils/envUtils";
import inquirer from "inquirer";
export const migrateFunctions = async () => {
// Dynamic import to ensure env is loaded first
const { initializeDatabaseFunctions } = await import(
"@server/db/initializeDatabaseFunctions"
);
const databaseUrl = process.env.DATABASE_URL;
console.log("databaseUrl", databaseUrl);
if (databaseUrl?.includes("us-west-3")) {
const { confirm } = await inquirer.prompt([
{

View File

@@ -11,4 +11,5 @@ BUN_PARALLEL_COMPACT \
'server/tests/attach/addOn' \
'server/tests/attach/checkout' \
'server/tests/attach/misc' \
'server/tests/billing/invoice-action-required' \
--max=6 \

View File

@@ -4,13 +4,16 @@ import { UTCDate } from "@date-fns/utc";
import { CronJob } from "cron";
import { format } from "date-fns";
import { initDrizzle } from "../db/initDrizzle.js";
import { logger } from "../external/logtail/logtailUtils.js";
import { CusEntService } from "../internal/customers/cusProducts/cusEnts/CusEntitlementService.js";
import { notNullish } from "../utils/genUtils.js";
import {
clearCusEntsFromCache,
resetCustomerEntitlement,
} from "./cronUtils.js";
import { runInvoiceCron } from "./invoiceCron/runInvoiceCron.js";
import { runProductCron } from "./productCron/runProductCron.js";
import type { CronContext } from "./utils/CronContext.js";
const { db, client } = initDrizzle();
@@ -66,7 +69,17 @@ const main = async () => {
console.log(`Cron disabled!`);
return;
}
await Promise.all([cronTask(), runProductCron()]);
const ctx: CronContext = {
db,
logger,
};
await Promise.all([
cronTask(),
runProductCron(),
runInvoiceCron({ ctx }),
// TODO: Add runUsageCron({ ctx })
]);
};
new CronJob(

View File

@@ -0,0 +1,65 @@
import { type Metadata, MetadataType, metadata } from "@autumn/shared";
import { and, eq, lt } from "drizzle-orm";
import { createStripeCli } from "../../external/connect/createStripeCli";
import type { AttachParams } from "../../internal/customers/cusProducts/AttachParams";
import type { CronContext } from "../utils/CronContext";
export const handleVoidInvoiceCron = async ({
ctx,
metadata,
}: {
ctx: CronContext;
metadata: Metadata;
}) => {
const { logger } = ctx;
const data = metadata.data as AttachParams;
const { org, customer } = data;
const stripeCli = createStripeCli({ org, env: customer.env });
if (!metadata.stripe_invoice_id) {
return;
}
const invoice = await stripeCli.invoices.retrieve(metadata.stripe_invoice_id);
if (invoice.status === "open") {
try {
await stripeCli.invoices.voidInvoice(metadata.stripe_invoice_id);
logger.info(
`voided invoice ${metadata.stripe_invoice_id} for customer ${customer.id} (org: ${org.slug})`,
);
} catch (error) {
logger.error(`Error voiding invoice: ${error}`);
}
}
};
export const runInvoiceCron = async ({ ctx }: { ctx: CronContext }) => {
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()),
),
);
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 }));
}
await Promise.all(promises);
console.log(`Handled ${i + batch.length}/${invoices.length} invoices`);
console.log("----------------------------------\n");
}
console.log("FINISHED INVOICE CRON");
};

View File

@@ -0,0 +1,7 @@
import type { DrizzleCli } from "../../db/initDrizzle";
import type { Logger } from "../../external/logtail/logtailUtils";
export interface CronContext {
db: DrizzleCli;
logger: Logger;
}

View File

@@ -203,12 +203,9 @@ export const handleStripeWebhookEvent = async ({
case "invoice.paid": {
const invoice = event.data.object;
await handleInvoicePaid({
db,
org,
ctx,
invoiceData: invoice,
env,
event,
req: ctx as unknown as ExtendedRequest,
});
break;
}

View File

@@ -11,6 +11,7 @@ import type { DrizzleCli } from "@/db/initDrizzle.js";
import { createStripeCli } from "@/external/connect/createStripeCli.js";
import { CusService } from "@/internal/customers/CusService.js";
import RecaseError from "@/utils/errorUtils.js";
import type { TestContext } from "../../../tests/utils/testInitUtils/createTestContext";
export const getStripeCus = async ({
stripeCli,
@@ -313,6 +314,38 @@ export const attachFailedPaymentMethod = async ({
});
};
export const attachAuthenticatePaymentMethod = async ({
ctx,
customerId,
}: {
ctx: TestContext;
customerId: string;
}) => {
const { org, env, db } = ctx;
const stripeCli = createStripeCli({ org, env });
const autumnCustomer = await CusService.get({
db,
idOrInternalId: customerId,
orgId: org.id,
env: env,
});
const stripeCustomer = await stripeCli.customers.retrieve(
autumnCustomer!.processor?.id,
);
// Delete existing payment method
const paymentMethods = await stripeCli.paymentMethods.list({
customer: stripeCustomer.id,
});
for (const pm of paymentMethods.data) {
await stripeCli.paymentMethods.detach(pm.id);
}
await stripeCli.paymentMethods.attach("pm_card_authenticationRequired", {
customer: stripeCustomer.id,
});
};
export const deleteAllStripeCustomers = async ({
org,
env,

View File

@@ -114,7 +114,11 @@ export const payForInvoice = async ({
}
if (errorOnFail) {
throw error;
throw new RecaseError({
message: error?.message,
code: ErrCode.PayInvoiceFailed,
data: invoice,
});
} else {
return {
paid: false,
@@ -122,7 +126,7 @@ export const payForInvoice = async ({
message: `Failed to pay invoice: ${error?.message || error}`,
code: ErrCode.PayInvoiceFailed,
}),
invoice: null,
invoice: invoice,
};
}
}

View File

@@ -1,7 +1,10 @@
import { ErrCode } from "@autumn/shared";
import { InternalError, MetadataType } from "@autumn/shared";
import { addMinutes } from "date-fns";
import type Stripe from "stripe";
import type { AttachParams } from "@/internal/customers/cusProducts/AttachParams.js";
import RecaseError from "@/utils/errorUtils.js";
import type { AutumnContext } from "../../../../honoUtils/HonoEnv.js";
import { attachParamsToMetadata } from "../../../../internal/billing/attach/utils/attachParamsToMetadata.js";
import type { Logger } from "../../../logtail/logtailUtils.js";
import { payForInvoice } from "../../stripeInvoiceUtils.js";
export const undoSubUpdate = async ({
@@ -55,17 +58,19 @@ export const undoSubUpdate = async ({
};
export const createProrationInvoice = async ({
ctx,
attachParams,
invoiceOnly,
curSub,
updatedSub,
logger,
}: {
ctx: AutumnContext;
attachParams: AttachParams;
invoiceOnly: boolean;
curSub: Stripe.Subscription;
updatedSub: Stripe.Subscription;
logger: any;
logger: Logger;
}) => {
const { stripeCli, customer, paymentMethod } = attachParams;
@@ -77,49 +82,78 @@ export const createProrationInvoice = async ({
if (items.data.length === 0) {
logger.info(`No items to prorate, skipping invoice creation`);
return null;
return {
invoice: null,
url: null,
};
}
// const shouldMemo = attachParams.org.config.invoice_memos && invoiceOnly;
// const invoiceMemo = shouldMemo
// ? await buildInvoiceMemoFromEntitlements({
// org: attachParams.org,
// entitlements: attachParams.entitlements,
// features: attachParams.features,
// })
// : undefined;
const invoice = await stripeCli.invoices.create({
customer: customer.processor.id,
subscription: curSub.id,
// subscription: curSub.id,
auto_advance: false,
// ...(shouldMemo ? { description: invoiceMemo } : {}),
pending_invoice_items_behavior: "include",
});
if (invoiceOnly) return invoice;
if (invoiceOnly)
return {
invoice,
url: null,
};
await stripeCli.invoices.finalizeInvoice(invoice.id!, {
auto_advance: false,
});
try {
const { invoice: subInvoice } = await payForInvoice({
const {
paid,
error,
invoice: subInvoice,
} = await payForInvoice({
stripeCli,
paymentMethod: paymentMethod || null,
invoiceId: invoice.id!,
logger,
voidIfFailed: true,
voidIfFailed: false,
errorOnFail: false,
});
return subInvoice;
} catch (error: any) {
if (!paid) {
await undoSubUpdate({ stripeCli, curSub, updatedSub });
throw new RecaseError({
code: ErrCode.UpdateSubscriptionFailed,
message: `Failed to update subscription. ${error.message}`,
if (subInvoice && subInvoice.status === "open") {
logger.info(
`[update subscription] invoice action required: ${subInvoice.id}`,
);
const metadata = await attachParamsToMetadata({
db: ctx.db,
attachParams,
type: MetadataType.InvoiceActionRequired,
stripeInvoiceId: subInvoice.id,
expiresAt: addMinutes(Date.now(), 10).getTime(),
});
await stripeCli.invoices.update(subInvoice.id, {
metadata: {
autumn_metadata_id: metadata.id,
},
});
return {
invoice: subInvoice,
url: subInvoice?.hosted_invoice_url,
};
} else {
throw new InternalError({
message: `[update subscription] Failed to pay invoice: ${error?.message}`,
code: "update_subscription_failed",
statusCode: 500,
data: `Stripe error: ${error.message}`,
data: error,
});
}
}
return {
invoice: subInvoice,
url: null,
};
};

View File

@@ -46,7 +46,7 @@ export const handleCheckoutSessionCompleted = async ({
// Get options
const stripeCli = createStripeCli({ org, env });
const attachParams: AttachParams = metadata.data;
const attachParams: AttachParams = metadata.data as AttachParams;
const checkoutSession = await stripeCli.checkout.sessions.retrieve(data.id, {
expand: ["line_items", "subscription"],
});

View File

@@ -8,13 +8,13 @@ import type {
import type Stripe from "stripe";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import { createStripeCli } from "@/external/connect/createStripeCli.js";
import { handleInvoiceCheckoutPaid } from "@/internal/customers/attach/attachFunctions/invoiceCheckoutPaid/handleInvoiceCheckoutPaid.js";
import { CusProductService } from "@/internal/customers/cusProducts/CusProductService.js";
import { InvoiceService } from "@/internal/invoices/InvoiceService.js";
import { getInvoiceItems } from "@/internal/invoices/invoiceUtils.js";
import { JobName } from "@/queue/JobName.js";
import { addTaskToQueue } from "@/queue/queueUtils.js";
import { nullish } from "@/utils/genUtils.js";
import type { AutumnContext } from "../../../honoUtils/HonoEnv.js";
import {
getFullStripeInvoice,
getInvoiceDiscounts,
@@ -23,6 +23,7 @@ import {
} from "../stripeInvoiceUtils.js";
import { lineItemInCusProduct } from "../stripeSubUtils/stripeSubItemUtils.js";
import { getStripeSubs } from "../stripeSubUtils.js";
import { handleInvoicePaidMetadata } from "./handleInvoicePaid/handleInvoicePaidMetadata.js";
import { handleInvoicePaidDiscount } from "./handleInvoicePaidDiscount.js";
const handleOneOffInvoicePaid = async ({
@@ -137,21 +138,15 @@ const convertToChargeAutomatically = async ({
};
export const handleInvoicePaid = async ({
db,
req,
org,
ctx,
invoiceData,
env,
event,
}: {
db: DrizzleCli;
req: any;
org: Organization;
ctx: AutumnContext;
invoiceData: Stripe.Invoice;
env: AppEnv;
event: Stripe.Event;
}) => {
const logger = req.logger;
const { logger, org, env, db } = ctx;
const stripeCli = createStripeCli({ org, env });
const invoice = await getFullStripeInvoice({
stripeCli,
@@ -160,12 +155,8 @@ export const handleInvoicePaid = async ({
});
if (invoice.metadata?.autumn_metadata_id) {
await handleInvoiceCheckoutPaid({
req,
org,
env,
db,
stripeCli,
await handleInvoicePaidMetadata({
ctx,
invoice,
});
}

View File

@@ -0,0 +1,74 @@
import { AttachBranch, type Metadata, ProrationBehavior } from "@autumn/shared";
import type Stripe from "stripe";
import type { AutumnContext } from "../../../../honoUtils/HonoEnv";
import { resetUsageBalances } from "../../../../internal/customers/attach/attachFunctions/upgradeDiffIntFlow/createUsageInvoiceItems";
import { handleUpgradeFlow } from "../../../../internal/customers/attach/attachFunctions/upgradeFlow/handleUpgradeFlow";
import { attachParamToCusProducts } from "../../../../internal/customers/attach/attachUtils/convertAttachParams";
import { getDefaultAttachConfig } from "../../../../internal/customers/attach/attachUtils/getAttachConfig";
import type { AttachParams } from "../../../../internal/customers/cusProducts/AttachParams";
import { deleteCachedApiCustomer } from "../../../../internal/customers/cusUtils/apiCusCacheUtils/deleteCachedApiCustomer";
import { MetadataService } from "../../../../internal/metadata/MetadataService";
import { createStripeCli } from "../../../connect/createStripeCli";
import { getCusPaymentMethod } from "../../stripeCusUtils";
export const handleInvoiceActionRequiredCompleted = async ({
ctx,
invoice,
metadata,
}: {
ctx: AutumnContext;
invoice: Stripe.Invoice;
metadata: Metadata;
}) => {
const { logger, org, env } = ctx;
logger.info(`invoice.paid, handling action required`);
const stripeCli = createStripeCli({ org, env });
const paymentMethod = await getCusPaymentMethod({
stripeCli,
stripeId: invoice.customer as string,
});
const attachParams = {
...(metadata.data as AttachParams),
stripeCli,
req: ctx,
paymentMethod,
} as AttachParams;
const attachConfig = {
...getDefaultAttachConfig(),
proration: ProrationBehavior.None,
};
ctx.logger.info(`handling upgrade flow for invoice ${invoice.id}`);
const { curMainProduct } = attachParamToCusProducts({ attachParams });
await handleUpgradeFlow({
ctx,
attachParams,
config: attachConfig,
branch: AttachBranch.Upgrade,
});
if (attachParams.cusEntIds && curMainProduct) {
await resetUsageBalances({
db: ctx.db,
cusEntIds: attachParams.cusEntIds,
cusProduct: curMainProduct,
});
}
await MetadataService.delete({
db: ctx.db,
id: metadata.id,
});
await deleteCachedApiCustomer({
customerId: attachParams.customer.id || "",
orgId: attachParams.org.id,
env: attachParams.customer.env,
});
};

View File

@@ -0,0 +1,56 @@
import type { Metadata } from "@autumn/shared";
import { AttachScenario } from "@autumn/shared";
import { createFullCusProduct } from "@/internal/customers/add-product/createFullCusProduct.js";
import type { AttachParams } from "@/internal/customers/cusProducts/AttachParams.js";
import { attachToInsertParams } from "@/internal/products/productUtils.js";
import type { AutumnContext } from "../../../../honoUtils/HonoEnv.js";
import { deleteCachedApiCustomer } from "../../../../internal/customers/cusUtils/apiCusCacheUtils/deleteCachedApiCustomer.js";
export const handleInvoiceCheckoutPaid = async ({
ctx,
metadata,
}: {
ctx: AutumnContext;
metadata: Metadata;
}) => {
const { logger, org, env, db } = ctx;
const { subId, anchorToUnix, config, ...rest } =
metadata.data as AttachParams;
const attachParams = rest;
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: subId ? [subId] : undefined,
anchorToUnix,
carryExistingUsages: config?.carryUsage,
scenario: AttachScenario.New,
logger: logger,
}),
);
}
await Promise.all(batchInsert);
logger.info(
`✅ invoice.paid, successfully inserted cus products: ${attachParams.products.map((p) => p.id).join(", ")}`,
);
await deleteCachedApiCustomer({
customerId: attachParams.customer.id || "",
orgId: org.id,
env,
});
};

View File

@@ -0,0 +1,45 @@
import { MetadataType } from "@autumn/shared";
import type Stripe from "stripe";
import type { AutumnContext } from "../../../../honoUtils/HonoEnv";
import { MetadataService } from "../../../../internal/metadata/MetadataService";
import { handleInvoiceActionRequiredCompleted } from "./handleInvoiceActionRequiredCompleted";
import { handleInvoiceCheckoutPaid } from "./handleInvoiceCheckoutPaid";
export const handleInvoicePaidMetadata = async ({
ctx,
invoice,
}: {
ctx: AutumnContext;
invoice: Stripe.Invoice;
}) => {
const metadataId = invoice.metadata?.autumn_metadata_id;
if (!metadataId) return;
const metadata = await MetadataService.get({
db: ctx.db,
id: metadataId,
});
if (!metadata) return;
if (metadata.type === MetadataType.InvoiceActionRequired) {
await handleInvoiceActionRequiredCompleted({
ctx,
invoice,
metadata,
});
return;
}
await handleInvoiceCheckoutPaid({
ctx,
metadata,
});
await MetadataService.delete({
db: ctx.db,
id: metadata.id,
});
};

View File

@@ -38,11 +38,13 @@ const handleInvoiceCheckoutVoided = async ({
id: metadataId,
});
if (!metadata) return;
const {
anchorToUnix: _anchorToUnix,
config: _config,
...rest
} = metadata?.data || {};
} = metadata.data as AttachParams;
const attachParams = rest as AttachParams;

View File

@@ -0,0 +1,43 @@
import type { MetadataInsert, MetadataType } from "@autumn/shared";
import { addDays } from "date-fns";
import type { DrizzleCli } from "../../../../db/initDrizzle";
import { generateId } from "../../../../utils/genUtils";
import type { AttachParams } from "../../../customers/cusProducts/AttachParams";
import { MetadataService } from "../../../metadata/MetadataService";
export const attachParamsToMetadata = async ({
db,
attachParams,
type,
stripeInvoiceId,
expiresAt,
}: {
db: DrizzleCli;
attachParams: AttachParams;
type: MetadataType;
stripeInvoiceId?: string;
expiresAt?: number;
}) => {
const {
req: _req,
checkoutSessionParams: _checkoutSessionParams,
stripeCli: _stripeCli,
paymentMethod: _paymentMethod,
...rest
} = attachParams;
const attachClone = structuredClone(rest);
const metadata: MetadataInsert = {
id: generateId("meta"),
created_at: Date.now(),
expires_at: expiresAt ?? addDays(Date.now(), 10).getTime(),
data: attachClone,
type,
stripe_invoice_id: stripeInvoiceId,
};
await MetadataService.insert({ db, data: metadata });
return metadata;
};

View File

@@ -1,13 +1,13 @@
import {
type AttachConfig,
AttachFunctionResponseSchema,
MetadataType,
RecaseError,
SuccessCode,
} from "@autumn/shared";
import type Stripe from "stripe";
import { createStripeCli } from "@/external/connect/createStripeCli.js";
import { getStripeSubItems } from "@/external/stripe/stripeSubUtils/getStripeSubItems.js";
import { createCheckoutMetadata } from "@/internal/metadata/metadataUtils.js";
import { toSuccessUrl } from "@/internal/orgs/orgUtils/convertOrgUtils.js";
import { orgToCurrency } from "@/internal/orgs/orgUtils.js";
import { freeTrialToStripeTimestamp } from "@/internal/products/free-trials/freeTrialUtils.js";
@@ -15,6 +15,7 @@ import { getNextStartOfMonthUnix } from "@/internal/products/prices/billingInter
import { pricesContainRecurring } from "@/internal/products/prices/priceUtils.js";
import { notNullish } from "@/utils/genUtils.js";
import type { AutumnContext } from "../../../honoUtils/HonoEnv.js";
import { attachParamsToMetadata } from "../../billing/attach/utils/attachParamsToMetadata.js";
import type { AttachParams } from "../cusProducts/AttachParams.js";
export const handleCreateCheckout = async ({
@@ -57,9 +58,10 @@ export const handleCreateCheckout = async ({
const isRecurring = pricesContainRecurring(attachParams.prices);
// Insert metadata
const metaId = await createCheckoutMetadata({
const metadata = await attachParamsToMetadata({
db,
attachParams,
type: MetadataType.CheckoutSessionCompleted,
});
let billingCycleAnchorUnixSeconds = org.config.anchor_start_of_month
@@ -136,7 +138,7 @@ export const handleCreateCheckout = async ({
metadata: {
...(attachParams.metadata ? attachParams.metadata : {}),
...(checkoutParams?.metadata || {}),
autumn_metadata_id: metaId,
autumn_metadata_id: metadata.id,
},
payment_method_collection:
freeTrial &&
@@ -155,7 +157,7 @@ export const handleCreateCheckout = async ({
...checkoutParams,
metadata: {
...(checkoutParams?.metadata || {}),
autumn_metadata_id: metaId,
autumn_metadata_id: metadata.id,
},
};
}

View File

@@ -2,11 +2,12 @@ import {
type AttachConfig,
type AttachFunctionResponse,
AttachFunctionResponseSchema,
MetadataType,
SuccessCode,
} from "@autumn/shared";
import { createCheckoutMetadata } from "@/internal/metadata/metadataUtils.js";
import { isOneOff } from "@/internal/products/productUtils.js";
import type { AutumnContext } from "../../../honoUtils/HonoEnv.js";
import { attachParamsToMetadata } from "../../billing/attach/utils/attachParamsToMetadata.js";
import { handleOneOffFunction } from "../attach/attachFunctions/addProductFlow/handleOneOffFunction.js";
import { handlePaidProduct } from "../attach/attachFunctions/addProductFlow/handlePaidProduct.js";
import type { AttachParams } from "../cusProducts/AttachParams.js";
@@ -39,13 +40,9 @@ export const handleCreateInvoiceCheckout = async ({
});
}
// const { invoices, anchorToUnix, subs } = invoiceResult;
const { invoice, stripeSub, anchorToUnix } = invoiceResult;
// console.log("finalize invoice:", config.finalizeInvoice);
// console.log("invoice hosted url:", invoice?.hosted_invoice_url);
const metadataId = await createCheckoutMetadata({
const metadata = await attachParamsToMetadata({
db: ctx.db,
attachParams: {
...attachParams,
@@ -53,12 +50,13 @@ export const handleCreateInvoiceCheckout = async ({
subId: stripeSub?.id,
config,
},
type: MetadataType.InvoiceCheckout,
});
if (invoice) {
await stripeCli.invoices.update(invoice.id, {
metadata: {
autumn_metadata_id: metadataId,
autumn_metadata_id: metadata.id,
},
});
}
@@ -73,52 +71,5 @@ export const handleCreateInvoiceCheckout = async ({
message: `Successfully created invoice checkout for customer ${customerId}, product(s) ${productNames}`,
code: SuccessCode.CheckoutCreated,
invoice: config.finalizeInvoice ? undefined : invoice, // if finalizeInvoice, checkout_url is used
// invoice,
// stripeSub,
// anchorToUnix,
// config,
});
// if (res) {
// if (!config.finalizeInvoice) {
// res.status(200).json(
// AttachResultSchema.parse({
// invoice: invoices[0],
// code: SuccessCode.CheckoutCreated,
// message: `Successfully created invoice 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;
// }
// 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 };
};
// if (attachParams.productsList) {
// invoiceResult = await handleMultiAttachFlow({
// req,
// res,
// attachParams,
// attachBody,
// branch,
// config,
// });
// } else

View File

@@ -1,108 +0,0 @@
import { type AppEnv, AttachScenario, type Organization } from "@autumn/shared";
import type Stripe from "stripe";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import { createFullCusProduct } from "@/internal/customers/add-product/createFullCusProduct.js";
import type { AttachParams } from "@/internal/customers/cusProducts/AttachParams.js";
import { MetadataService } from "@/internal/metadata/MetadataService.js";
import { attachToInsertParams } from "@/internal/products/productUtils.js";
import type { ExtendedRequest } from "@/utils/models/Request.js";
import { deleteCachedApiCustomer } from "../../../cusUtils/apiCusCacheUtils/deleteCachedApiCustomer.js";
export const handleInvoiceCheckoutPaid = async ({
req,
org,
env,
db,
stripeCli,
invoice,
}: {
req: ExtendedRequest;
org: Organization;
env: AppEnv;
db: DrizzleCli;
stripeCli: Stripe;
invoice: Stripe.Invoice;
}) => {
const { logger } = req;
const metadataId = invoice.metadata?.autumn_metadata_id;
if (!metadataId) return;
const metadata = await MetadataService.get({
db,
id: metadataId,
});
const { subId, anchorToUnix, config, ...rest }: AttachParams =
metadata?.data ?? {};
const attachParams = rest;
if (!attachParams) return;
const reqMatch =
attachParams.org.id === org.id && attachParams.customer.env === env;
if (!reqMatch) return;
// if (attachParams.productsList) {
// console.log("Inserting products list");
// for (const productOptions of attachParams.productsList) {
// const product = attachParams.products.find(
// (p) => p.id === productOptions.product_id,
// );
// if (!product) {
// logger.error(
// `checkout.completed: product not found for productOptions: ${JSON.stringify(
// productOptions,
// )}`,
// );
// continue;
// }
// await createFullCusProduct({
// db,
// attachParams: attachToInsertParams(
// attachParams,
// product,
// productOptions.entity_id || undefined,
// ),
// subscriptionIds: subIds,
// anchorToUnix,
// scenario: AttachScenario.New,
// logger,
// productOptions,
// });
// }
// } else {
// }
const batchInsert = [];
for (const product of attachParams.products) {
batchInsert.push(
createFullCusProduct({
db,
attachParams: attachToInsertParams(attachParams, product),
subscriptionIds: subId ? [subId] : undefined,
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(", ")}`,
);
await deleteCachedApiCustomer({
customerId: attachParams.customer.id || "",
orgId: org.id,
env,
});
};

View File

@@ -14,7 +14,7 @@ import { getStripeSubItems2 } from "@/external/stripe/stripeSubUtils/getStripeSu
import { subIsCanceled } from "@/external/stripe/stripeSubUtils.js";
import { addProductsUpdatedWebhookTask } from "@/internal/analytics/handlers/handleProductsUpdated.js";
import { createFullCusProduct } from "@/internal/customers/add-product/createFullCusProduct.js";
import { type AttachParams } from "@/internal/customers/cusProducts/AttachParams.js";
import type { AttachParams } from "@/internal/customers/cusProducts/AttachParams.js";
import { CusProductService } from "@/internal/customers/cusProducts/CusProductService.js";
import { getExistingCusProducts } from "@/internal/customers/cusProducts/cusProductUtils/getExistingCusProducts.js";
import {
@@ -123,11 +123,6 @@ export const handleUpgradeFlow = async ({
logger.info(`UPGRADE FLOW, updating sub ${curSub.id}`);
itemSet.subItems = subItems;
// await logPhaseItems({
// db: req.db,
// items: itemSet.subItems,
// });
const res = await updateStripeSub2({
ctx,
attachParams,
@@ -147,6 +142,14 @@ export const handleUpgradeFlow = async ({
});
}
if (res?.url) {
return AttachFunctionResponseSchema.parse({
checkout_url: res.url,
code: SuccessCode.InvoiceActionRequired,
message: `Payment action required`,
});
}
const schedule = await paramsToCurSubSchedule({ attachParams });
if (schedule) {

View File

@@ -81,6 +81,7 @@ export const updateStripeSub2 = async ({
days_until_due: 30,
}),
payment_behavior: "error_if_incomplete",
expand: ["latest_invoice"],
});
@@ -120,8 +121,10 @@ export const updateStripeSub2 = async ({
logger,
});
let url = null;
if (proration === ProrationBehavior.Immediately) {
latestInvoice = await createProrationInvoice({
const res = await createProrationInvoice({
ctx,
attachParams,
invoiceOnly,
curSub,
@@ -129,20 +132,30 @@ export const updateStripeSub2 = async ({
logger,
});
latestInvoice = res.invoice;
url = res.url;
console.log(`FINALIZED INVOICE ${latestInvoice?.id}`);
console.log(latestInvoice?.lines.data.map((line) => line.description));
}
// If url is returned, it means invoice action is required, so don't reset balances.
if (!url) {
await resetUsageBalances({
db,
cusEntIds,
cusProduct: curMainProduct!,
});
} else {
// reset balances later when invoice is paid
attachParams.cusEntIds = cusEntIds;
}
return {
updatedSub,
latestInvoice: latestInvoice,
cusEntIds,
replaceables,
url,
};
};

View File

@@ -120,10 +120,10 @@ export const paramsToSubItems = async ({
? removeCusProducts!
: getCusProductsToRemove({ attachParams });
console.log(
"Cus products to remove:",
cusProductsToRemove.map((cp) => cp.product.name),
);
// console.log(
// "Cus products to remove:",
// cusProductsToRemove.map((cp) => cp.product.name),
// );
const newSubItems = mergeNewSubItems({
itemSet,

View File

@@ -82,6 +82,10 @@ export type AttachParams = {
anchorToUnix?: number;
subId?: string;
config?: AttachConfig;
// Invoice action required
stripeInvoiceId?: string;
cusEntIds?: string[];
};
export type InsertCusProductParams = {

View File

@@ -1,9 +1,14 @@
import { DrizzleCli } from "@/db/initDrizzle.js";
import { AutumnMetadata, metadata } from "@autumn/shared";
import { eq } from "drizzle-orm";
import {
type Metadata,
type MetadataInsert,
type MetadataType,
metadata,
} from "@autumn/shared";
import { and, eq } from "drizzle-orm";
import type { DrizzleCli } from "@/db/initDrizzle.js";
export class MetadataService {
static async insert({ db, data }: { db: DrizzleCli; data: AutumnMetadata }) {
static async insert({ db, data }: { db: DrizzleCli; data: MetadataInsert }) {
await db.insert(metadata).values(data);
}
@@ -18,6 +23,33 @@ export class MetadataService {
return null;
}
return data[0] as AutumnMetadata;
return data[0] as Metadata;
}
static async getByStripeInvoiceId({
db,
stripeInvoiceId,
type,
}: {
db: DrizzleCli;
stripeInvoiceId: string;
type?: MetadataType;
}) {
const meta = await db.query.metadata.findFirst({
where: and(
eq(metadata.stripe_invoice_id, stripeInvoiceId),
type ? eq(metadata.type, type) : undefined,
),
});
if (!meta) {
return null;
}
return meta as Metadata;
}
static async delete({ db, id }: { db: DrizzleCli; id: string }) {
await db.delete(metadata).where(eq(metadata.id, id));
}
}

View File

@@ -1,44 +1,7 @@
import type { AutumnMetadata } from "@autumn/shared";
import { addDays } from "date-fns";
import type Stripe from "stripe";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import { generateId } from "@/utils/genUtils.js";
import type { AttachParams } from "../customers/cusProducts/AttachParams.js";
import { MetadataService } from "./MetadataService.js";
export const createCheckoutMetadata = async ({
db,
attachParams,
}: {
db: DrizzleCli;
attachParams: AttachParams;
}) => {
const metaId = generateId("meta");
const {
req: _req,
checkoutSessionParams: _checkoutSessionParams,
stripeCli: _stripeCli,
paymentMethod: _paymentMethod,
...rest
} = attachParams;
const attachClone = structuredClone(rest);
const metadata: AutumnMetadata = {
id: metaId,
created_at: Date.now(),
expires_at: addDays(Date.now(), 10).getTime(), // 10 days
data: {
...attachClone,
},
};
await MetadataService.insert({ db, data: metadata });
return metaId;
};
export const getMetadataFromCheckoutSession = async (
checkoutSession: Stripe.Checkout.Session,
db: DrizzleCli,

View File

@@ -150,10 +150,27 @@ export const attachPaymentMethod = async ({
}: {
stripeCli: Stripe;
stripeCusId: string;
type: "success" | "fail";
type: "success" | "fail" | "authenticate";
}) => {
try {
const token = type === "fail" ? "tok_chargeCustomerFail" : "tok_visa";
if (type === "authenticate") {
await stripeCli.paymentMethods.attach("pm_card_authenticationRequired", {
customer: stripeCusId,
});
const pms = await stripeCli.paymentMethods.list({
customer: stripeCusId,
});
await stripeCli.customers.update(stripeCusId, {
invoice_settings: {
default_payment_method: pms.data[0].id,
},
});
return;
}
const pm = await stripeCli.paymentMethods.create({
type: "card",
card: {

View File

@@ -17,7 +17,7 @@ export const initCustomerV3 = async ({
}: {
ctx: TestContext;
customerId: string;
attachPm?: "success" | "fail";
attachPm?: "success" | "fail" | "authenticate";
customerData?: CustomerData;
withTestClock?: boolean;
withDefault?: boolean;

View File

@@ -1,22 +1,22 @@
import { beforeAll, describe, test } from "bun:test";
import { LegacyVersion } from "@autumn/shared";
import { ApiVersion } from "@autumn/shared";
import { TestFeature } from "@tests/setup/v2Features.js";
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,
constructRawProduct,
} from "@/utils/scriptUtils/createTestProducts.js";
import { constructPriceItem } from "../../src/internal/products/product-items/productItemUtils.js";
import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js";
import { attachAuthenticatePaymentMethod } from "../../src/external/stripe/stripeCusUtils.js";
import { initCustomerV3 } from "../../src/utils/scriptUtils/testUtils/initCustomerV3.js";
import { initProductsV0 } from "../../src/utils/scriptUtils/testUtils/initProductsV0.js";
import { expectProductAttached } from "../utils/expectUtils/expectProductAttached.js";
import { expectSubItemsCorrect } from "../utils/expectUtils/expectSubUtils.js";
import { completeInvoiceConfirmation } from "../utils/stripeUtils/completeInvoiceConfirmation.js";
// UNCOMMENT FROM HERE
const pro = constructProduct({
type: "pro",
isDefault: true,
isDefault: false,
items: [
constructFeatureItem({
@@ -27,13 +27,9 @@ const pro = constructProduct({
],
});
const oneOff = constructRawProduct({
id: "one-off",
const premium = constructProduct({
type: "premium",
items: [
constructPriceItem({
price: 10,
interval: null,
}),
constructFeatureItem({
featureId: TestFeature.Messages,
includedUsage: 100,
@@ -43,7 +39,7 @@ const oneOff = constructRawProduct({
describe(`${chalk.yellowBright("temp: Testing add ons")}`, () => {
const customerId = "temp";
const autumn: AutumnInt = new AutumnInt({ version: LegacyVersion.v1_4 });
const autumn: AutumnInt = new AutumnInt({ version: ApiVersion.V1_2 });
beforeAll(async () => {
await initCustomerV3({
@@ -56,40 +52,61 @@ describe(`${chalk.yellowBright("temp: Testing add ons")}`, () => {
await initProductsV0({
ctx,
products: [pro, oneOff],
products: [pro, premium],
prefix: customerId,
});
});
test("should attach pro product", async () => {
// await autumn.customers.get(customerId);
const res = await autumn.attach({
await autumn.attach({
customer_id: customerId,
product_id: pro.id,
});
await autumn.attach({
customer_id: customerId,
product_id: oneOff.id,
await attachAuthenticatePaymentMethod({
ctx,
customerId,
});
await autumn.attach({
const res = await autumn.attach({
customer_id: customerId,
product_id: oneOff.id,
product_id: premium.id,
});
const customer = await autumn.customers.get(customerId);
console.log("Customer:", customer);
expectProductAttached({
customer,
product: pro,
});
// await autumn.attach({
// customer_id: customerId,
// product_id: oneOff.id,
// });
// await autumn.attach({
// customer_id: customerId,
// product_id: oneOff.id,
// });
await expectSubItemsCorrect({
customerId,
product: pro,
stripeCli: ctx.stripeCli,
db: ctx.db,
org: ctx.org,
env: ctx.env,
});
// const customer = await autumn.customers.get(customerId);
// console.log("Customer:", customer);
await completeInvoiceConfirmation({
url: res.checkout_url,
});
});
test("should have premium product attached", async () => {
const customer = await autumn.customers.get(customerId);
expectProductAttached({
customer,
product: premium,
});
await expectSubItemsCorrect({
customerId,
product: premium,
stripeCli: ctx.stripeCli,
db: ctx.db,
org: ctx.org,
env: ctx.env,
});
});
});

View File

@@ -0,0 +1,128 @@
import { beforeAll, describe, expect, test } from "bun:test";
import { ApiVersion } from "@autumn/shared";
import { TestFeature } from "@tests/setup/v2Features.js";
import { expectProductAttached } from "@tests/utils/expectUtils/expectProductAttached.js";
import { expectSubItemsCorrect } from "@tests/utils/expectUtils/expectSubUtils.js";
import { completeInvoiceConfirmation } from "@tests/utils/stripeUtils/completeInvoiceConfirmation.js";
import ctx from "@tests/utils/testInitUtils/createTestContext.js";
import chalk from "chalk";
import { AutumnInt } from "@/external/autumn/autumnCli.js";
import { attachAuthenticatePaymentMethod } from "@/external/stripe/stripeCusUtils.js";
import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js";
import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js";
import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js";
import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js";
// UNCOMMENT FROM HERE
const pro = constructProduct({
type: "pro",
isDefault: false,
items: [
constructFeatureItem({
featureId: TestFeature.Credits,
includedUsage: 200,
// unlimited: true,
}),
],
});
const premium = constructProduct({
type: "premium",
items: [
constructFeatureItem({
featureId: TestFeature.Messages,
includedUsage: 100,
}),
],
});
describe(`${chalk.yellowBright("invoice-action-required1: Testing invoice action required")}`, () => {
const customerId = "invoice-action-required1";
const autumn: AutumnInt = new AutumnInt({ version: ApiVersion.V1_2 });
beforeAll(async () => {
await initCustomerV3({
ctx,
customerId,
customerData: {},
attachPm: "success",
withTestClock: true,
});
await initProductsV0({
ctx,
products: [pro, premium],
prefix: customerId,
});
});
let checkoutUrl: string;
test("should attach pro product, then upgrade to premium and get checkout_url", async () => {
await autumn.attach({
customer_id: customerId,
product_id: pro.id,
});
await attachAuthenticatePaymentMethod({
ctx,
customerId,
});
const res = await autumn.attach({
customer_id: customerId,
product_id: premium.id,
});
expect(res.checkout_url).toBeDefined();
const customer = await autumn.customers.get(customerId);
expectProductAttached({
customer,
product: pro,
});
await expectSubItemsCorrect({
customerId,
product: pro,
stripeCli: ctx.stripeCli,
db: ctx.db,
org: ctx.org,
env: ctx.env,
});
checkoutUrl = res.checkout_url;
});
test("should complete invoice action required and have premium product attached", async () => {
await completeInvoiceConfirmation({
url: checkoutUrl,
});
const customer = await autumn.customers.get(customerId);
expectProductAttached({
customer,
product: premium,
});
await expectSubItemsCorrect({
customerId,
product: premium,
stripeCli: ctx.stripeCli,
db: ctx.db,
org: ctx.org,
env: ctx.env,
});
// Cleared cache
const nonCachedCustomer = await autumn.customers.get(customerId, {
skip_cache: "true",
});
expect(nonCachedCustomer.invoices?.[0].status).toBe("paid");
expectProductAttached({
customer: nonCachedCustomer,
product: premium,
});
});
});

View File

@@ -0,0 +1,109 @@
import { beforeAll, describe, expect, test } from "bun:test";
import { ApiVersion } from "@autumn/shared";
import { TestFeature } from "@tests/setup/v2Features.js";
import ctx from "@tests/utils/testInitUtils/createTestContext.js";
import chalk from "chalk";
import { AutumnInt } from "@/external/autumn/autumnCli.js";
import { attachAuthenticatePaymentMethod } from "@/external/stripe/stripeCusUtils.js";
import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js";
import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js";
import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js";
import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js";
import { handleVoidInvoiceCron } from "../../../src/cron/invoiceCron/runInvoiceCron";
import { MetadataService } from "../../../src/internal/metadata/MetadataService";
import { timeout } from "../../utils/genUtils";
// UNCOMMENT FROM HERE
const pro = constructProduct({
type: "pro",
isDefault: false,
items: [
constructFeatureItem({
featureId: TestFeature.Credits,
includedUsage: 200,
// unlimited: true,
}),
],
});
const premium = constructProduct({
type: "premium",
items: [
constructFeatureItem({
featureId: TestFeature.Messages,
includedUsage: 100,
}),
],
});
describe(`${chalk.yellowBright("invoice-action-required2: Testing void invoice cron")}`, () => {
const customerId = "invoice-action-required2";
const autumn: AutumnInt = new AutumnInt({ version: ApiVersion.V1_2 });
beforeAll(async () => {
await initCustomerV3({
ctx,
customerId,
customerData: {},
attachPm: "success",
withTestClock: true,
});
await initProductsV0({
ctx,
products: [pro, premium],
prefix: customerId,
});
});
test("should attach pro product, then upgrade to premium and get checkout_url", async () => {
await autumn.attach({
customer_id: customerId,
product_id: pro.id,
});
await attachAuthenticatePaymentMethod({
ctx,
customerId,
});
await autumn.attach({
customer_id: customerId,
product_id: premium.id,
});
// Get latest invoice for this customer
const customer = await autumn.customers.get(customerId);
expect(customer.invoices?.[0].status).toBe("open");
const stripeInvoices = await ctx.stripeCli.invoices.list({
customer: customer.stripe_id!,
});
const latestInvoice = stripeInvoices.data[0];
expect(latestInvoice.metadata?.autumn_metadata_id).toBeDefined();
const metadata = await MetadataService.get({
db: ctx.db,
id: latestInvoice.metadata?.autumn_metadata_id ?? "",
});
await handleVoidInvoiceCron({
metadata: metadata!,
ctx: {
db: ctx.db,
logger: ctx.logger,
},
});
const voidedInvoice = await ctx.stripeCli.invoices.retrieve(
latestInvoice.id,
);
expect(voidedInvoice.status).toBe("void");
await timeout(3000);
const customer2 = await autumn.customers.get(customerId);
expect(customer2.invoices?.[0].status).toBe("void");
});
});

View File

@@ -1,8 +1,7 @@
import { beforeAll, describe, test } from "bun:test";
import { beforeAll, describe, expect, test } from "bun:test";
import { type AppEnv, LegacyVersion, type Organization } from "@autumn/shared";
import { TestFeature } from "@tests/setup/v2Features.js";
import { attachAndExpectCorrect } from "@tests/utils/expectUtils/expectAttach.js";
import { expectAutumnError } from "@tests/utils/expectUtils/expectErrUtils.js";
import { expectFeaturesCorrect } from "@tests/utils/expectUtils/expectFeaturesCorrect.js";
import { expectProductAttached } from "@tests/utils/expectUtils/expectProductAttached.js";
import { expectSubItemsCorrect } from "@tests/utils/expectUtils/expectSubUtils.js";
@@ -22,7 +21,9 @@ import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js";
import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js";
import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js";
const testCase = "upgrade6";
import { completeInvoiceCheckout } from "../../utils/stripeUtils/completeInvoiceCheckout";
const testCase = "invoice-action-required3";
export const pro = constructProduct({
items: [
@@ -46,7 +47,7 @@ export const premium = constructProduct({
type: "premium",
});
describe(`${chalk.yellowBright(`${testCase}: Testing failed upgrades`)}`, () => {
describe(`${chalk.yellowBright(`${testCase}: Testing upgrade, failed payment`)}`, () => {
const customerId = testCase;
const autumn: AutumnInt = new AutumnInt({ version: LegacyVersion.v1_4 });
let testClockId: string;
@@ -89,6 +90,8 @@ describe(`${chalk.yellowBright(`${testCase}: Testing failed upgrades`)}`, () =>
});
const usage = 100012;
let checkoutUrl: string;
test("should upgrade to premium product and fail", async () => {
await autumn.track({
customer_id: customerId,
@@ -106,22 +109,14 @@ describe(`${chalk.yellowBright(`${testCase}: Testing failed upgrades`)}`, () =>
await attachFailedPaymentMethod({ stripeCli, customer: cus! });
await timeout(2000);
await expectAutumnError({
func: async () => {
await attachAndExpectCorrect({
autumn,
customerId,
product: premium,
stripeCli,
db,
org,
env,
});
},
errMessage: "Failed to update subscription. Your card was declined.",
const res = await autumn.attach({
customer_id: customerId,
product_id: premium.id,
});
await timeout(4000);
checkoutUrl = res.checkout_url;
expect(res.checkout_url).toBeDefined();
const customer = await autumn.customers.get(customerId);
expectProductAttached({
@@ -149,4 +144,30 @@ describe(`${chalk.yellowBright(`${testCase}: Testing failed upgrades`)}`, () =>
env,
});
});
test("should complete invoice and have premium product attached", async () => {
await completeInvoiceCheckout({
url: checkoutUrl,
});
const customer = await autumn.customers.get(customerId);
expectProductAttached({
customer,
product: premium,
});
expectFeaturesCorrect({
customer,
product: premium,
});
await expectSubItemsCorrect({
customerId,
product: premium,
stripeCli,
db,
org,
env,
});
});
});

View File

@@ -122,7 +122,7 @@ export const attachAndExpectCorrect = async ({
await timeout(waitForInvoice);
}
let customer;
let customer: Customer;
if (entityId) {
customer = await autumn.entities.get(customerId, entityId);
} else {

View File

@@ -0,0 +1,128 @@
import "dotenv/config";
import puppeteer, { type Browser } from "puppeteer-core";
import { timeout } from "../genUtils.js";
// const client = new Hyperbrowser({
// apiKey: process.env.HYPERBROWSER_API_KEY,
// });
export const completeInvoiceConfirmation = async ({
url,
isLocal = false,
}: {
url: string;
isLocal?: boolean;
}) => {
let browser: 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 page to be ready
await page.waitForSelector("button", { timeout: 5000 });
// Find and click the "Confirm payment" button
const buttonClicked = await page.evaluate(() => {
const buttons = Array.from(document.querySelectorAll("button"));
const confirmBtn = buttons.find((b) =>
/confirm payment/i.test(b.textContent || ""),
);
if (confirmBtn) {
(confirmBtn as HTMLElement).click();
return true;
}
return false;
});
if (!buttonClicked) {
throw new Error("Could not find or click Confirm payment button");
}
// Wait for processing/navigation
await new Promise((resolve) => setTimeout(resolve, 3000));
// Wait for iframe with the three-ds-2-challenge URL
let threeDSFrame = null;
for (let i = 0; i < 15; i++) {
await new Promise((resolve) => setTimeout(resolve, 2000));
const frames = page.frames();
threeDSFrame = frames.find((f) =>
f.url().includes("three-ds-2-challenge"),
);
if (threeDSFrame) {
break;
}
}
if (!threeDSFrame) {
throw new Error("Could not find 3DS challenge frame");
}
// Wait for the 3DS frame content to load
await new Promise((resolve) => setTimeout(resolve, 2000));
// Check for nested iframes
const frameContent = await threeDSFrame.evaluate(() => {
return {
hasButton: !!document.querySelector("#test-source-authorize-3ds"),
iframes: document.querySelectorAll("iframe").length,
};
});
// If there's a nested iframe, find it
if (frameContent.iframes > 0) {
const childFrames = page.frames();
let challengeFrame = childFrames.find((f) =>
f.url().includes("3d_secure_2_test"),
);
if (!challengeFrame) {
challengeFrame = childFrames.find((f) => f.name() === "challengeFrame");
}
if (challengeFrame) {
threeDSFrame = challengeFrame;
}
}
// Wait for the button and click it
await threeDSFrame.waitForSelector("#test-source-authorize-3ds", {
timeout: 3000,
});
await threeDSFrame.evaluate(() => {
const button = document.querySelector(
"#test-source-authorize-3ds",
) as HTMLElement;
if (button) {
button.click();
}
});
// Wait for the 3DS authentication to complete
await timeout(10000);
} finally {
// always close browser
await browser.close();
}
};

View File

@@ -8,7 +8,10 @@ import { type DrizzleCli, initDrizzle } from "@/db/initDrizzle.js";
import { createStripeCli } from "@/external/connect/createStripeCli.js";
import { FeatureService } from "@/internal/features/FeatureService.js";
import { OrgService } from "@/internal/orgs/OrgService.js";
import { logger } from "../../../src/external/logtail/logtailUtils.js";
import {
type Logger,
logger,
} from "../../../src/external/logtail/logtailUtils.js";
const DEFAULT_ENV = AppEnv.Sandbox;
@@ -19,6 +22,7 @@ export type TestContext = {
db: DrizzleCli;
orgSecretKey: string;
features: Feature[];
logger: Logger;
};
export const createTestContext = async () => {

View File

@@ -1,4 +1,5 @@
export enum SuccessCode {
InvoiceActionRequired = "invoice_action_required",
// Track
SuccessfullyDeducted = "successfully_deducted",
EventReceived = "event_received",

View File

@@ -104,7 +104,6 @@ export * from "./models/orgModels/frontendOrg.js";
export * from "./models/orgModels/orgConfig.js";
export * from "./models/orgModels/orgConfig.js";
export * from "./models/orgModels/orgTable.js";
export * from "./models/otherModels/metadataModels.js";
export * from "./models/otherModels/metadataTable.js";
// Duration Types
export * from "./models/productModels/durationTypes/rolloverExpiryDurationType.js";

View File

@@ -1,10 +1,10 @@
import { z } from "zod/v4";
// import { z } from "zod/v4";
export const AutumnMetadataSchema = z.object({
id: z.string(),
created_at: z.number(),
expires_at: z.number(),
data: z.any(),
});
// export const AutumnMetadataSchema = z.object({
// id: z.string(),
// created_at: z.number(),
// expires_at: z.number(),
// data: z.any(),
// });
export type AutumnMetadata = z.infer<typeof AutumnMetadataSchema>;
// export type AutumnMetadata = z.infer<typeof AutumnMetadataSchema>;

View File

@@ -1,9 +1,21 @@
import { pgTable, text, numeric, jsonb } from "drizzle-orm/pg-core";
import type { InferInsertModel, InferSelectModel } from "drizzle-orm";
import { jsonb, numeric, pgTable, text } from "drizzle-orm/pg-core";
import { sqlNow } from "../../db/utils.js";
export enum MetadataType {
InvoiceActionRequired = "invoice_action_required",
InvoiceCheckout = "invoice_checkout",
CheckoutSessionCompleted = "checkout_session_completed",
}
export const metadata = pgTable("metadata", {
id: text().primaryKey().notNull(),
created_at: numeric({ mode: "number" }).notNull().default(sqlNow),
expires_at: numeric({ mode: "number" }),
data: jsonb(),
type: text("type").$type<MetadataType>(),
stripe_invoice_id: text("stripe_invoice_id"),
});
export type Metadata = InferSelectModel<typeof metadata>;
export type MetadataInsert = InferInsertModel<typeof metadata>;