Files
cfw-autumn/server/tests/scenarios/agent/email-platform.ts
2026-06-14 14:59:36 +01:00

633 lines
16 KiB
TypeScript

import {
AppEnv,
FeatureUsageType,
type FreeTrial,
FreeTrialDuration,
type ProductItem,
type ProductV2,
TierBehavior,
} from "@autumn/shared";
import { items } from "@tests/utils/fixtures/items";
import { products } from "@tests/utils/fixtures/products";
import { clearOrg } from "@tests/utils/setup/clearOrg.js";
import defaultCtx, {
createTestContext,
type TestContext,
} from "@tests/utils/testInitUtils/createTestContext.js";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
import { FeatureService } from "@/internal/features/FeatureService";
import {
constructBooleanFeature,
constructCreditSystem,
constructMeteredFeature,
} from "@/internal/features/utils/constructFeatureUtils";
import {
constructFeatureItem,
constructPrepaidItem,
} from "@/utils/scriptUtils/constructItem";
import {
buildRealisticCustomerSeed,
createScenarioAutumn,
seedCustomersWithEntities,
} from "../seedUtils";
// ── Email platform seed config ──
// Modeled loosely on Resend's pricing with deliberately tweaked numbers and
// renamed feature flags so it is NOT an accurate mirror. Transactional plans have
// NO base price: email volume is a prepaid item (volume-priced tiers) plus a
// consumable overage. AI credits are a credit system.
// Defaults for `bun scenario ep`; CLI flags override (e.g. `--count 1000`).
const EP_SEED = {
customerCount: 0, // 0 = catalog only (features + plans, no customers)
concurrency: 10,
attachPlan: null as "free" | "enterprise" | null,
clear: true, // wipe org before seeding; --skip-clear keeps existing
};
export const emailPlatformFeatureIds = {
ai_actions: "ai_actions",
ai_credits: "ai_credits",
automation_runs: "automation_runs",
contacts: "contacts",
dedicated_ip: "dedicated_ip",
domains: "domains",
emails: "emails",
engagement_tracking: "engagement_tracking",
inbound_routing: "inbound_routing",
multi_region: "multi_region",
no_daily_limit: "no_daily_limit",
priority_support: "priority_support",
projects: "projects",
scoped_api_keys: "scoped_api_keys",
sending_receiving: "sending_receiving",
slack_support: "slack_support",
smtp_relay: "smtp_relay",
soc2_reports: "soc2_reports",
sso_saml: "sso_saml",
ticket_support: "ticket_support",
webhooks: "webhooks",
} as const;
export const emailPlatformPlanIds = {
dedicatedIpPack: "dedicated_ip_pack",
enterprise: "enterprise",
free: "free",
pro: "pro",
proMarketing: "pro_marketing",
scale: "scale",
} as const;
// Boolean flags shared by every plan ("all plans include ...").
const sharedBooleanFeatureIds = [
emailPlatformFeatureIds.sending_receiving,
emailPlatformFeatureIds.smtp_relay,
emailPlatformFeatureIds.inbound_routing,
emailPlatformFeatureIds.engagement_tracking,
emailPlatformFeatureIds.multi_region,
emailPlatformFeatureIds.webhooks,
emailPlatformFeatureIds.soc2_reports,
emailPlatformFeatureIds.scoped_api_keys,
] as const;
// Tier-specific boolean flags.
const tieredBooleanFeatureIds = [
emailPlatformFeatureIds.ticket_support,
emailPlatformFeatureIds.slack_support,
emailPlatformFeatureIds.priority_support,
emailPlatformFeatureIds.no_daily_limit,
emailPlatformFeatureIds.dedicated_ip,
emailPlatformFeatureIds.sso_saml,
] as const;
export const emailPlatformBooleanFeatureIds = [
...sharedBooleanFeatureIds,
...tieredBooleanFeatureIds,
] as const;
type EmailPlatformFeatureId =
(typeof emailPlatformFeatureIds)[keyof typeof emailPlatformFeatureIds];
const featureNames: Record<EmailPlatformFeatureId, string> = {
ai_actions: "AI Actions",
ai_credits: "AI Credits",
automation_runs: "Automation Runs",
contacts: "Contacts",
dedicated_ip: "Dedicated IP",
domains: "Domains",
emails: "Emails",
engagement_tracking: "Engagement Tracking",
inbound_routing: "Inbound Routing",
multi_region: "Multi-Region Sending",
no_daily_limit: "No Daily Limit",
priority_support: "Priority Support",
projects: "Projects",
scoped_api_keys: "Scoped API Keys",
sending_receiving: "Sending & Receiving",
slack_support: "Slack Support",
smtp_relay: "SMTP Relay",
soc2_reports: "SOC 2 Reports",
sso_saml: "SSO / SAML",
ticket_support: "Ticket Support",
webhooks: "Webhooks",
};
type EmailPlatformPlanMap = ReturnType<
typeof buildEmailPlatformProducts
>["plans"];
export type EmailPlatformPlanKey = keyof EmailPlatformPlanMap;
const booleanItem = (featureId: EmailPlatformFeatureId): ProductItem =>
constructFeatureItem({
featureId,
isBoolean: true,
}) as ProductItem;
const booleanItems = (featureIds: readonly EmailPlatformFeatureId[]) =>
featureIds.map(booleanItem);
type VolumeTier = { amount: number; to: number | "inf"; flat_amount: number };
// Prepaid email volume (whole purchased quantity billed at the tier flat amount)
// plus a per-1k consumable overage for sends beyond the purchased volume.
const emailItems = ({
tiers,
overagePerThousand,
}: {
tiers: VolumeTier[];
overagePerThousand: number;
}) => [
constructPrepaidItem({
featureId: emailPlatformFeatureIds.emails,
billingUnits: 1,
includedUsage: 0,
tierBehaviour: TierBehavior.VolumeBased,
tiers,
}) as ProductItem,
items.consumable({
featureId: emailPlatformFeatureIds.emails,
billingUnits: 1_000,
price: overagePerThousand,
}),
];
// 10k automation runs included, then per-run overage.
const automationItems = () => [
items.consumable({
featureId: emailPlatformFeatureIds.automation_runs,
includedUsage: 10_000,
billingUnits: 1,
price: 0.0015,
}),
];
const proEmailTiers: VolumeTier[] = [
{ amount: 0, to: 50_000, flat_amount: 20 },
{ amount: 0, to: 100_000, flat_amount: 35 },
{ amount: 0, to: "inf", flat_amount: 60 },
];
const scaleEmailTiers: VolumeTier[] = [
{ amount: 0, to: 100_000, flat_amount: 90 },
{ amount: 0, to: 200_000, flat_amount: 160 },
{ amount: 0, to: 500_000, flat_amount: 350 },
{ amount: 0, to: 1_000_000, flat_amount: 650 },
{ amount: 0, to: 1_500_000, flat_amount: 825 },
{ amount: 0, to: 2_500_000, flat_amount: 1_150 },
{ amount: 0, to: "inf", flat_amount: 1_500 },
];
// Marketing is capped by contacts, not emails: prepaid contact tiers, no overage.
const marketingContactTiers: VolumeTier[] = [
{ amount: 0, to: 5_000, flat_amount: 40 },
{ amount: 0, to: 10_000, flat_amount: 80 },
{ amount: 0, to: 15_000, flat_amount: 120 },
{ amount: 0, to: 25_000, flat_amount: 180 },
{ amount: 0, to: 50_000, flat_amount: 250 },
{ amount: 0, to: 100_000, flat_amount: 450 },
{ amount: 0, to: 150_000, flat_amount: 650 },
{ amount: 0, to: "inf", flat_amount: 900 },
];
export const buildEmailPlatformFeatures = ({ ctx }: { ctx: TestContext }) => {
const f = emailPlatformFeatureIds;
const orgId = ctx.org.id;
const env = ctx.env;
return [
constructMeteredFeature({
featureId: f.emails,
name: featureNames[f.emails],
orgId,
env,
usageType: FeatureUsageType.Single,
eventNames: ["emails"],
}),
constructMeteredFeature({
featureId: f.automation_runs,
name: featureNames[f.automation_runs],
orgId,
env,
usageType: FeatureUsageType.Single,
eventNames: ["automation_runs"],
}),
constructMeteredFeature({
featureId: f.ai_actions,
name: featureNames[f.ai_actions],
orgId,
env,
usageType: FeatureUsageType.Single,
eventNames: ["ai_actions"],
}),
constructCreditSystem({
featureId: f.ai_credits,
orgId,
env,
schema: [{ metered_feature_id: f.ai_actions, credit_cost: 1 }],
}),
constructMeteredFeature({
featureId: f.contacts,
name: featureNames[f.contacts],
orgId,
env,
usageType: FeatureUsageType.Continuous,
}),
constructMeteredFeature({
featureId: f.domains,
name: featureNames[f.domains],
orgId,
env,
usageType: FeatureUsageType.Continuous,
}),
constructMeteredFeature({
featureId: f.projects,
name: featureNames[f.projects],
orgId,
env,
usageType: FeatureUsageType.Continuous,
}),
...emailPlatformBooleanFeatureIds.map((featureId) =>
constructBooleanFeature({
featureId,
name: featureNames[featureId],
orgId,
env,
}),
),
];
};
export const ensureEmailPlatformFeatures = async ({
ctx = defaultCtx,
}: {
ctx?: TestContext;
} = {}) => {
const desiredFeatures = buildEmailPlatformFeatures({ ctx });
const existingFeatures = await FeatureService.list({
db: ctx.db,
orgId: ctx.org.id,
env: ctx.env,
});
const existingById = new Map(
existingFeatures.map((feature) => [feature.id, feature]),
);
const featuresToInsert = desiredFeatures.filter(
(feature) => !existingById.has(feature.id),
);
const featuresToUpdate = desiredFeatures.filter((feature) =>
existingById.has(feature.id),
);
if (featuresToInsert.length > 0) {
await FeatureService.insert({
db: ctx.db,
data: featuresToInsert,
logger: console,
});
}
await Promise.all(
featuresToUpdate.map((feature) =>
FeatureService.update({
db: ctx.db,
id: feature.id,
orgId: ctx.org.id,
env: ctx.env,
updates: {
name: feature.name,
type: feature.type,
config: feature.config,
event_names: feature.event_names,
model_markups: feature.model_markups,
archived: false,
},
}),
),
);
ctx.features = await FeatureService.list({
db: ctx.db,
orgId: ctx.org.id,
env: ctx.env,
});
return ctx.features.filter((feature) =>
desiredFeatures.some((desired) => desired.id === feature.id),
);
};
export const buildEmailPlatformProducts = () => {
const f = emailPlatformFeatureIds;
const p = emailPlatformPlanIds;
const freeBooleans = [...sharedBooleanFeatureIds, f.ticket_support] as const;
const proBooleans = [
...sharedBooleanFeatureIds,
f.ticket_support,
f.no_daily_limit,
] as const;
const scaleBooleans = [
...sharedBooleanFeatureIds,
f.slack_support,
f.no_daily_limit,
] as const;
const enterpriseBooleans = [
...sharedBooleanFeatureIds,
f.priority_support,
f.no_daily_limit,
f.sso_saml,
f.dedicated_ip,
] as const;
const plans = {
// Free: fixed allowances, daily-limited, no overage on automations.
free: products.base({
id: p.free,
items: [
items.free({ featureId: f.emails, includedUsage: 3_000 }),
items.free({ featureId: f.automation_runs, includedUsage: 10_000 }),
items.free({ featureId: f.ai_credits, includedUsage: 5 }),
items.free({ featureId: f.domains, includedUsage: 1 }),
...booleanItems(freeBooleans),
],
}),
// Pro/Scale: NO base price — email volume is the prepaid item.
pro: products.base({
id: p.pro,
items: [
...emailItems({ tiers: proEmailTiers, overagePerThousand: 0.9 }),
...automationItems(),
items.free({ featureId: f.ai_credits, includedUsage: 100 }),
items.free({ featureId: f.domains, includedUsage: 10 }),
...booleanItems(proBooleans),
],
}),
scale: products.base({
id: p.scale,
items: [
...emailItems({ tiers: scaleEmailTiers, overagePerThousand: 0.5 }),
...automationItems(),
items.free({ featureId: f.ai_credits, includedUsage: 500 }),
items.free({ featureId: f.domains, includedUsage: 1_000 }),
...booleanItems(scaleBooleans),
],
}),
// Marketing: capped by contacts (prepaid tiers), not email volume.
proMarketing: products.base({
id: p.proMarketing,
items: [
constructPrepaidItem({
featureId: f.contacts,
billingUnits: 1,
includedUsage: 0,
tierBehaviour: TierBehavior.VolumeBased,
tiers: marketingContactTiers,
}) as ProductItem,
items.free({ featureId: f.ai_credits, includedUsage: 100 }),
...booleanItems([...sharedBooleanFeatureIds, f.no_daily_limit]),
],
}),
// Enterprise: custom, no base price, flexible everything.
enterprise: products.base({
id: p.enterprise,
items: [
items.free({ featureId: f.emails, includedUsage: 3_000_000 }),
...automationItems(),
items.free({ featureId: f.ai_credits, includedUsage: 5_000 }),
items.free({ featureId: f.domains, includedUsage: 5_000 }),
items.free({ featureId: f.contacts, includedUsage: 150_000 }),
...booleanItems(enterpriseBooleans),
],
}),
dedicatedIpPack: products.base({
id: p.dedicatedIpPack,
isAddOn: true,
items: [items.monthlyPrice({ price: 30 }), booleanItem(f.dedicated_ip)],
}),
} satisfies Record<string, ProductV2>;
return {
featureIds: emailPlatformFeatureIds,
planIds: emailPlatformPlanIds,
proBooleans,
scaleBooleans,
enterpriseBooleans,
plans,
};
};
const withProductGroup = ({
products,
group,
}: {
products: ProductV2[];
group: string;
}) =>
products.map((product) => ({
...product,
group,
}));
export const initEmailPlatformScenario = async ({
customerId = "agent-email-platform",
attachPlan = "free",
entityCount = 2,
paymentMethod = "success",
ctx = defaultCtx,
}: {
customerId?: string;
attachPlan?: EmailPlatformPlanKey | null;
entityCount?: number;
paymentMethod?: "success" | "fail" | "authenticate" | "alipay";
ctx?: TestContext;
} = {}) => {
await ensureEmailPlatformFeatures({ ctx });
const catalog = buildEmailPlatformProducts();
const setup = [
s.customer({ paymentMethod }),
s.products({ list: Object.values(catalog.plans) }),
...(entityCount > 0
? [
s.entities({
count: entityCount,
featureId: catalog.featureIds.projects,
}),
]
: []),
];
const actions = attachPlan
? [s.billing.attach({ productId: catalog.plans[attachPlan].id })]
: [];
const scenario = await initScenario({
customerId,
setup,
actions,
ctx,
});
return {
...scenario,
...catalog,
};
};
/** Return a copy of a plan with a card-required free trial, for trialing-state scenarios. */
export const withFreeTrial = ({
product,
trialDays,
}: {
product: ProductV2;
trialDays: number;
}): ProductV2 => ({
...product,
free_trial: {
length: trialDays,
duration: FreeTrialDuration.Day,
unique_fingerprint: false,
card_required: true,
} as unknown as FreeTrial,
});
export const seedEmailPlatformCustomers = async ({
customerCount = 1_000,
idPrefix = "ep-customer",
entityCountForCustomer,
productPrefix = "email-platform",
attachPlan = null,
concurrency = 10,
deleteExisting = true,
ctx = defaultCtx,
}: {
customerCount?: number;
idPrefix?: string;
entityCountForCustomer?: (index: number) => 1 | 2;
productPrefix?: string;
attachPlan?: Extract<EmailPlatformPlanKey, "free" | "enterprise"> | null;
concurrency?: number;
deleteExisting?: boolean;
ctx?: TestContext;
} = {}) => {
await ensureEmailPlatformFeatures({ ctx });
const catalog = buildEmailPlatformProducts();
await initScenario({
setup: [
s.products({
list: withProductGroup({
products: Object.values(catalog.plans),
group: productPrefix,
}),
prefix: "",
createInStripe: false,
}),
],
actions: [],
ctx,
});
const customers = Array.from({ length: customerCount }, (_, index) => {
return {
...buildRealisticCustomerSeed({
index,
idPrefix,
entityFeatureId: emailPlatformFeatureIds.projects,
entityCount: entityCountForCustomer?.(index),
}),
attachPlanId: attachPlan ? catalog.plans[attachPlan].id : null,
};
});
const seeded = await seedCustomersWithEntities({
autumn: createScenarioAutumn({ ctx }),
customers,
concurrency,
deleteExisting,
});
return {
...catalog,
productPrefix,
...seeded,
};
};
const getArgValue = (name: string) => {
const prefix = `${name}=`;
const inline = process.argv.find((arg) => arg.startsWith(prefix));
if (inline) return inline.slice(prefix.length);
const index = process.argv.indexOf(name);
return index === -1 ? undefined : process.argv[index + 1];
};
export const runEmailPlatformSeed = async () => {
const customerCount = Number(getArgValue("--count") ?? EP_SEED.customerCount);
const concurrency = Number(
getArgValue("--concurrency") ?? EP_SEED.concurrency,
);
const attachPlan = (getArgValue("--attach-plan") ?? EP_SEED.attachPlan) as
| Extract<EmailPlatformPlanKey, "free" | "enterprise">
| null;
if (!process.env.TESTS_ORG) {
throw new Error("TESTS_ORG is required to seed email platform data");
}
if (EP_SEED.clear && !process.argv.includes("--skip-clear")) {
await clearOrg({
orgSlug: process.env.TESTS_ORG,
env: AppEnv.Sandbox,
skipStripeReset: true,
});
}
const ctx = await createTestContext();
const result = await seedEmailPlatformCustomers({
ctx,
customerCount,
concurrency,
attachPlan: attachPlan ?? null,
deleteExisting: !process.argv.includes("--keep-existing"),
});
console.log("Email platform seed complete", {
customers: result.customerCount,
entities: result.entityCount,
productPrefix: result.productPrefix,
attachPlan: attachPlan ?? null,
});
};
if (import.meta.main) {
runEmailPlatformSeed()
.catch((error) => {
console.error("Email platform seed failed:", error);
process.exit(1);
})
.finally(() => {
process.exit(0);
});
}