This commit is contained in:
John Yeo
2026-01-07 10:25:01 +00:00
parent 55b884437d
commit 6f21ec4a07
60 changed files with 832 additions and 3706 deletions

7
bunfig.toml Normal file
View File

@@ -0,0 +1,7 @@
[test]
preload = ["./server/tests/setup-integration-tests.ts"]
timeout = 0
[test.env]
NODE_ENV = "test"

View File

@@ -1,9 +0,0 @@
[phases.install]
onlyIncludeFiles = [
"package.json",
"bun.lock",
"./server/package.json",
"./shared/package.json",
"./vite/package.json",
]

View File

@@ -0,0 +1,32 @@
import { readFileSync } from "fs";
const file = process.argv[2];
const lineNum = parseInt(process.argv[3], 10);
const content = readFileSync(file, "utf-8");
const lines = content.split("\n");
// Walk backwards from cursor to find enclosing describe or test.concurrent
for (let i = lineNum - 1; i >= 0; i--) {
const line = lines[i];
// Match describe/test.concurrent with chalk.yellowBright or similar
const chalkMatch = line.match(
/(?:describe|test\.concurrent)\s*\(\s*`\$\{chalk\.\w+\(["'](.*?)["']\)\}`/,
);
if (chalkMatch) {
console.log(chalkMatch[1].replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
process.exit(0);
}
// Match simple describe/test.concurrent: ("name", ...) or ('name', ...) or (`name`, ...)
const simpleMatch = line.match(
/(?:describe|test\.concurrent)\s*\(\s*["'`](.*?)["'`]/,
);
if (simpleMatch) {
console.log(simpleMatch[1].replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
process.exit(0);
}
}
console.log(".*"); // fallback: run all

View File

@@ -628,7 +628,10 @@ export class AutumnInt {
track = async (
params: TrackParams,
{ skipCache = false }: { skipCache?: boolean } = {},
{
skipCache = false,
timeout,
}: { skipCache?: boolean; timeout?: number } = {},
) => {
const queryParams = new URLSearchParams();
if (skipCache) {
@@ -636,6 +639,10 @@ export class AutumnInt {
}
const data = await this.post(`/track?${queryParams.toString()}`, params);
if (timeout) {
await new Promise((resolve) => setTimeout(resolve, timeout));
}
return data;
};

View File

@@ -1,5 +1,5 @@
import { Hono } from "hono";
import { handleUpdateSubscriptionPreview } from "@/internal/billing/v2/subscriptionUpdate/handleUpdateSubscriptionPreview.js";
import { handlePreviewUpdateSubscription } from "@/internal/billing/v2/subscriptionUpdate/handlePreviewUpdateSubscription.js";
import type { HonoEnv } from "../../honoUtils/HonoEnv.js";
import { handleAttach } from "./attach/handleAttach.js";
import { handleCheckoutV2 } from "./checkout/handleCheckoutV2.js";
@@ -16,6 +16,6 @@ billingRouter.post("/attach_v2", ...handleAttachV2);
billingRouter.post("/subscriptions/update", ...handleUpdateSubscription);
billingRouter.post(
"/subscriptions/preview/update",
...handleUpdateSubscriptionPreview,
"/subscriptions/preview_update",
...handlePreviewUpdateSubscription,
);

View File

@@ -35,7 +35,7 @@ export const computeAttachPlan = async ({
// When to build checkout action?
// 2. Build autumn line items
const autumnLineItems = buildAutumnLineItems({
const lineItems = buildAutumnLineItems({
ctx,
newCusProducts,
ongoingCustomerProduct: ongoingCusProductAction?.cusProduct,
@@ -56,13 +56,13 @@ export const computeAttachPlan = async ({
// 6. Build stripe invoice action
// const stripeInvoiceAction = buildStripeInvoiceAction({
// attachContext,
// autumnLineItems,
// lineItems,
// stripeSubAction,
// newCusProducts,
// });
return {
autumnLineItems,
lineItems,
ongoingCusProductAction,
scheduledCusProductAction,

View File

@@ -30,14 +30,14 @@ export const evaluateStripeBillingPlan = ({
finalCustomerProducts: finalFullCustomer.customer_products,
});
const { autumnLineItems } = autumnBillingPlan;
const { lineItems } = autumnBillingPlan;
const stripeInvoiceAction = buildStripeInvoiceAction({
lineItems: autumnLineItems,
lineItems,
});
const stripeInvoiceItemsAction = buildStripeInvoiceItemsAction({
lineItems: autumnLineItems,
lineItems,
billingContext,
});

View File

@@ -101,7 +101,7 @@ export const computeQuantityUpdateDetails = ({
});
}
const autumnLineItems = buildQuantityUpdateLineItems({
const lineItems = buildQuantityUpdateLineItems({
ctx,
customerProduct,
feature,
@@ -115,6 +115,6 @@ export const computeQuantityUpdateDetails = ({
featureId,
customerEntitlementId,
customerEntitlementBalanceChange,
autumnLineItems,
lineItems,
};
};

View File

@@ -53,7 +53,7 @@ export const computeInvoiceAction = ({
// If subscription action is update, we need to create an invoice
const stripeSubscriptionActionType = stripeSubscriptionAction?.type;
if (stripeSubscriptionActionType === "update") {
const autumnLineItems = buildAutumnLineItems({
const lineItems = buildAutumnLineItems({
ctx,
newCusProducts: [toCustomerProduct],
ongoingCustomerProduct: fromCustomerProduct,
@@ -62,7 +62,7 @@ export const computeInvoiceAction = ({
});
const addLineParams = lineItemsToInvoiceAddLinesParams({
lineItems: autumnLineItems,
lineItems,
});
return {

View File

@@ -60,7 +60,7 @@ export const computeSubscriptionUpdateCustomPlan = async ({
freeTrialPlan,
});
const autumnLineItems = buildAutumnLineItems({
const lineItems = buildAutumnLineItems({
ctx,
newCustomerProducts: [newFullCustomerProduct],
deletedCustomerProduct: customerProduct,
@@ -76,6 +76,6 @@ export const computeSubscriptionUpdateCustomPlan = async ({
customPrices: customPrices,
customEntitlements: customEnts,
customFreeTrial: customFreeTrial,
autumnLineItems,
lineItems,
} satisfies AutumnBillingPlan;
};

View File

@@ -31,8 +31,8 @@ export const computeSubscriptionUpdateQuantityPlan = ({
}),
);
const autumnLineItems = quantityUpdateDetails.flatMap(
(detail) => detail.autumnLineItems,
const lineItems = quantityUpdateDetails.flatMap(
(detail) => detail.lineItems,
);
return {
@@ -54,7 +54,7 @@ export const computeSubscriptionUpdateQuantityPlan = ({
balanceChange: detail.customerEntitlementBalanceChange,
})),
autumnLineItems,
lineItems,
// quantityUpdateDetails,
};
};

View File

@@ -4,7 +4,7 @@ import { createRoute } from "../../../../honoMiddlewares/routeHandler";
import { computeSubscriptionUpdatePlan } from "./compute/computeSubscriptionUpdatePlan";
import { fetchUpdateSubscriptionBillingContext } from "./fetch/fetchUpdateSubscriptionBillingContext";
export const handleUpdateSubscriptionPreview = createRoute({
export const handlePreviewUpdateSubscription = createRoute({
body: UpdateSubscriptionV0ParamsSchema,
handler: async (c) => {
const ctx = c.get("ctx");
@@ -28,6 +28,8 @@ export const handleUpdateSubscriptionPreview = createRoute({
autumnBillingPlan,
});
// Convert to preview response
return c.json(
{
autumn: autumnBillingPlan,

View File

@@ -33,7 +33,7 @@ export const AutumnBillingPlanSchema = z.object({
customEntitlements: z.array(EntitlementSchema), // Custom entitlements to insert
customFreeTrial: FreeTrialSchema.optional(), // Custom free trial to insert
autumnLineItems: z.array(LineItemSchema),
lineItems: z.array(LineItemSchema),
updateCustomerEntitlements: z
.array(UpdateCustomerEntitlementSchema)

View File

@@ -48,7 +48,7 @@ export type UpdateOneOffAction = {
};
export type AttachPlan = {
autumnLineItems: LineItem[];
lineItems: LineItem[];
// 1. Autumn actions
@@ -69,7 +69,7 @@ export const QuantityUpdateDetailsSchema = z.object({
featureId: z.string(),
customerEntitlementId: z.string(),
customerEntitlementBalanceChange: z.number(),
autumnLineItems: z.array(LineItemSchema),
lineItems: z.array(LineItemSchema),
});
export type QuantityUpdateDetails = z.infer<typeof QuantityUpdateDetailsSchema>;

View File

@@ -0,0 +1,25 @@
import type { BillingPreviewResponse } from "@autumn/shared";
import type { BillingPlan } from "@/internal/billing/v2/types/billingPlan";
export const billingPlanToPreviewResponse = ({
billingPlan,
}: {
billingPlan: BillingPlan;
}): BillingPreviewResponse => {
// 1. Get lines
const autumnBillingPlan = billingPlan.autumn;
// const previewLineItems = autumnBillingPlan.lineItems.map((line) => ({
// description: line.description,
// amount: line.amount,
// }));
// const total = autumnBillingPlanLines.reduce(
// (acc, line) => acc + line.amount,
// 0,
// );
// return {
// customer_id: billingPlan.customer_id,
// };
};

View File

@@ -1,29 +0,0 @@
import dotenv from "dotenv";
dotenv.config();
import { AppEnv } from "@autumn/shared";
import { clearOrg, setupOrg } from "@tests/utils/setup.js";
import { initDrizzle } from "@/db/initDrizzle.js";
const ORG_SLUG = process.env.TESTS_ORG!;
const DEFAULT_ENV = AppEnv.Sandbox;
describe("Initialize org for tests", () => {
it("should initialize org", async function () {
this.timeout(1000000000);
this.org = await clearOrg({ orgSlug: ORG_SLUG, env: DEFAULT_ENV });
this.env = DEFAULT_ENV;
const { db, client } = initDrizzle();
this.db = db;
this.client = client;
await setupOrg({
orgId: this.org.id,
env: DEFAULT_ENV,
});
console.log("--------------------------------");
});
});

View File

@@ -35,8 +35,8 @@ Tests are organized into logical groups that can be run via shell scripts in `se
- **setupMain.ts** - Main setup script that clears and initializes the test organization
- **global.ts** - Shared test data (features, products, rewards, etc.)
- **utils/** - Test utilities and helper functions
- `setupUtils/clearOrg.ts` - Clears test org data
- `setupUtils/setupOrg.ts` - Sets up test org with features and products
- `setup/clearOrg.ts` - Clears test org data
- `setup/setupOrg.ts` - Sets up test org with features and products
- `setup.ts` - Re-exports setup utilities
- `init.ts` - Test initialization helpers
- `stripeUtils.ts` - Stripe-specific test utilities

View File

@@ -1,31 +1,165 @@
# Test Writing Guide
## Initial Notes (To be organized later)
## Quick Start
### Customer Initialization
Use `initTestScenario` for the fastest test setup:
#### Default Products
- For tests involving default products, use the `withDefault: true` flag in `initCustomerV3()`
- This ensures the customer is created with the default product attached
- Example:
```typescript
await initCustomerV3({
ctx,
customerId,
customerData: { fingerprint: "test" },
withTestClock: false,
withDefault: true, // Attach default product on creation
```typescript
import { test } from "bun:test";
import { TestFeature } from "@tests/setup/v2Features.js";
import { items } from "@tests/utils/fixtures/items.js";
import { products } from "@tests/utils/fixtures/products.js";
import { initTestScenario } from "@tests/utils/testInitUtils/initTestScenario.js";
test.concurrent("my test", async () => {
const messagesItem = items.monthlyMessages({ includedUsage: 500 });
const free = products.base({ items: [messagesItem] });
const { customerId, autumnV1 } = await initTestScenario({
customerId: "my-unique-test-id",
products: [free],
attachProducts: [free.id], // Pass original IDs - auto-prefixed
customerOptions: {
withTestClock: true,
attachPm: "success",
},
});
```
#### Fingerprint
- For tests involving fingerprint, pass in `fingerprint` through `customerData` in `initCustomerV3()`
- Example:
```typescript
await initCustomerV3({
ctx,
customerId,
customerData: { fingerprint: "test" }, // Pass fingerprint here
withTestClock: false,
// Your test logic here
await autumnV1.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
value: 100,
});
```
});
```
---
## Fixtures
### Item Fixtures (`@tests/utils/fixtures/items`)
Pre-configured product items for common feature types:
```typescript
import { items } from "@tests/utils/fixtures/items.js";
```
| Item | Description | Default |
|------|-------------|---------|
| `items.dashboard()` | Boolean feature (on/off) | - |
| `items.monthlyMessages({ includedUsage })` | Resets monthly | 100 |
| `items.monthlyWords({ includedUsage })` | Resets monthly | 100 |
| `items.monthlyCredits({ includedUsage })` | Resets monthly | 100 |
| `items.unlimitedMessages()` | No usage cap | - |
| `items.lifetimeMessages({ includedUsage })` | Never resets | 100 |
| `items.prepaidMessages({ includedUsage })` | Buy upfront ($10/unit) | 0 |
| `items.consumableMessages({ includedUsage })` | Pay-per-use ($0.10/unit) | 0 |
| `items.allocatedUsers({ includedUsage })` | Prorated seats ($10/seat) | 0 |
### Product Fixtures (`@tests/utils/fixtures/products`)
```typescript
import { products } from "@tests/utils/fixtures/products.js";
```
| Product | Description |
|---------|-------------|
| `products.base({ items, id?, isDefault? })` | No base price, `id` defaults to "base", `isDefault` defaults to `false` |
**Example:**
```typescript
// Simple product
const free = products.base({ items: [items.monthlyMessages()] });
// With custom ID
const addon = products.base({
id: "messages-addon",
items: [items.prepaidMessages()]
});
// As default product
const defaultProd = products.base({
items: [items.dashboard()],
isDefault: true
});
```
---
## Test Scenario Initialization
### `initTestScenario`
Combines customer creation, product creation, and attachment into one call.
```typescript
import { initTestScenario } from "@tests/utils/testInitUtils/initTestScenario.js";
const { customerId, products, autumnV1, autumnV2, testClockId, customer, ctx } =
await initTestScenario({
customerId: "unique-test-id", // Used as customer ID AND product prefix
products: [free, addon], // Products to create
attachProducts: [free.id], // Original IDs (auto-prefixed with customerId_)
customerOptions: {
withTestClock: true, // Default: true
attachPm: "success", // "success" | "fail" | "authenticate"
withDefault: false, // Default: false
customerData: { fingerprint }, // Optional customer data
},
});
```
**Important:** Product IDs are prefixed with `customerId_` for test isolation.
- You pass: `attachProducts: ["base"]`
- Actual product ID becomes: `"my-test-id_base"`
---
## Legacy Patterns
### Customer Initialization (Direct)
For cases where `initTestScenario` doesn't fit:
```typescript
import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js";
await initCustomerV3({
ctx,
customerId,
customerData: { fingerprint: "test" },
withTestClock: true,
withDefault: true, // Attach default product on creation
attachPm: "success",
});
```
### Product Initialization (Direct)
```typescript
import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js";
await initProductsV0({
ctx,
products: [free, pro],
prefix: customerId, // Prefix product IDs for isolation
});
```
---
## Running Tests
### Run specific test block
Place cursor inside a `test.concurrent()` or `describe()` block and press `Cmd+T`.
### Rerun last test
`Cmd+Shift+P` → "Rerun Last Task"
### Run entire file
```bash
bun test path/to/file.test.ts
```

View File

@@ -1,18 +0,0 @@
import { AppEnv } from "@autumn/shared";
import { clearOrg, setupOrg } from "@tests/utils/setup.js";
const ORG_SLUG = process.env.TESTS_ORG!;
const DEFAULT_ENV = AppEnv.Sandbox;
describe("Initialize alex org for tests", () => {
it("should initialize org", async function () {
this.org = await clearOrg({ orgSlug: ORG_SLUG, env: DEFAULT_ENV });
this.env = DEFAULT_ENV;
await setupOrg({
orgId: this.org.id,
env: DEFAULT_ENV,
});
console.log("--------------------------------");
});
});

View File

@@ -1,41 +0,0 @@
import chalk from "chalk";
import { AutumnCli } from "@tests/cli/AutumnCli.js";
import { compareMainProduct } from "@tests/utils/compare.js";
import { initCustomer } from "@tests/utils/init.js";
import { alexProducts } from "./init.js";
import { runEventsAndCheckBalances } from "./utils.js";
describe(chalk.yellowBright("Free customer"), () => {
const customerId = "alex-free-customer";
before("initializing customer", async function () {
await initCustomer({
customer_data: {
id: customerId,
// name: null,
// email: null,
},
db: this.db,
org: this.org,
env: this.env,
});
});
// 1. Check that customer has correct product & entitlements
it("GET customer has correct product & entitlements", async () => {
const customer = await AutumnCli.getCustomer(customerId);
compareMainProduct({
sent: alexProducts.free,
cusRes: customer,
});
});
// 3. Run /events for each feature, and check that the balance is updated correctly
it("should run /events for each feature and have correct balance afterwards", async () => {
const entitlements = Object.values(alexProducts.free.entitlements);
await runEventsAndCheckBalances({
customerId,
entitlements,
});
});
});

View File

@@ -1,58 +0,0 @@
import { CusProductStatus, InvoiceStatus } from "@autumn/shared";
import { expect } from "chai";
import { timeout } from "@tests/utils/genUtils.js";
import { AutumnCli } from "@tests/cli/AutumnCli.js";
import { compareMainProduct } from "@tests/utils/compare.js";
import { initCustomer } from "@tests/utils/init.js";
import { completeCheckoutForm } from "@tests/utils/stripeUtils.js";
import { alexProducts } from "./init.js";
import { runEventsAndCheckBalances } from "./utils.js";
import chalk from "chalk";
describe(chalk.yellowBright("Pro entitlements"), () => {
let customerId = "alex-pro-customer";
before("initializing customer", async function () {
await initCustomer({
customer_data: {
id: customerId,
// name: "Alex Pro Customer",
email: "alex-pro-customer@test.com",
},
db: this.db,
org: this.org,
env: this.env,
});
});
it("should upgrade to Pro after calling /attach", async function () {
const res = await AutumnCli.attach({
customerId,
productId: alexProducts.pro.id,
});
await completeCheckoutForm(res.checkout_url);
await timeout(20000);
const cusRes = await AutumnCli.getCustomer(customerId);
expect(typeof cusRes.customer.name).to.equal("string");
compareMainProduct({
sent: alexProducts.pro,
cusRes,
status: CusProductStatus.Trialing,
});
// Check invoice is correct
const invoices = cusRes.invoices;
expect(invoices.length).to.equal(1);
expect(invoices[0].total).to.equal(0);
expect(invoices[0].status).to.equal(InvoiceStatus.Paid);
});
it("should run event sending and check balances for each entitlement", async function () {
await runEventsAndCheckBalances({
customerId,
entitlements: Object.values(alexProducts.pro.entitlements),
});
});
});

View File

@@ -1,51 +0,0 @@
import { compareMainProduct } from "@tests/utils/compare.js";
import { AutumnCli } from "@tests/cli/AutumnCli.js";
import { initCustomer } from "@tests/utils/init.js";
import { alexProducts } from "./init.js";
import { CusProductStatus } from "@autumn/shared";
import { completeCheckoutForm } from "@tests/utils/stripeUtils.js";
import { timeout } from "@tests/utils/genUtils.js";
import { runEventsAndCheckBalances } from "./utils.js";
import chalk from "chalk";
describe(chalk.yellowBright("Premium plan"), () => {
let customerId = "alex-premium-customer";
before("initializing customer", async function () {
await initCustomer({
customer_data: {
id: customerId,
name: "Alex Premium Customer",
email: "alex-premium-customer@test.com",
},
db: this.db,
org: this.org,
env: this.env,
// attachPm: true,
});
});
it("should attach premium product", async function () {
const res = await AutumnCli.attach({
customerId,
productId: alexProducts.premium.id,
});
await completeCheckoutForm(res.checkout_url);
await timeout(10000);
const cusRes = await AutumnCli.getCustomer(customerId);
compareMainProduct({
sent: alexProducts.premium,
cusRes,
status: CusProductStatus.Trialing,
});
});
it("should send events and check balances", async function () {
await runEventsAndCheckBalances({
customerId,
entitlements: Object.values(alexProducts.premium.entitlements),
});
});
});

View File

@@ -1,219 +0,0 @@
import {
AllowanceType,
ApiVersion,
CusProductStatus,
EntInterval,
type Entitlement,
} from "@autumn/shared";
import { expect } from "chai";
import chalk from "chalk";
import { AutumnCli } from "@tests/cli/AutumnCli.js";
import { compareMainProduct } from "@tests/utils/compare.js";
import { timeout } from "@tests/utils/genUtils.js";
import { completeCheckoutForm } from "@tests/utils/stripeUtils.js";
import { AutumnInt } from "@/external/autumn/autumnCli.js";
import { initCustomerV2 } from "@/utils/scriptUtils/initCustomer.js";
import { alexFeatures, alexProducts } from "./init.js";
import {
checkFeatureHasCorrectBalance,
runEventsAndCheckBalances,
} from "./utils.js";
const autumn = new AutumnInt({ version: ApiVersion.V1_2 });
describe(chalk.yellowBright("Top ups"), () => {
const customerId = "alex-top-up-customer";
before("initializing customer", async function () {
await initCustomerV2({
autumn,
customerId,
org: this.org,
env: this.env,
db: this.db,
attachPm: "success",
});
// await initCustomer({
// customer_data: {
// id: customerId,
// name: "Alex Top Up Customer",
// email: "alex-top-up-customer@test.com",
// },
// db: this.db,
// org: this.org,
// env: this.env,
// attachPm: true,
// });
});
it("should attach pro product", async () => {
await timeout(5000);
await AutumnCli.attach({
customerId,
productId: alexProducts.pro.id,
});
const cusRes = await AutumnCli.getCustomer(customerId);
compareMainProduct({
sent: alexProducts.pro,
cusRes,
status: CusProductStatus.Trialing,
});
});
const overrideQuantity = 5;
const billingUnits =
alexProducts.topUpMessages.prices[0].config.billing_units;
const prodEnt = alexProducts.topUpMessages.entitlements.topUpMessage;
let leftoverBalance = 0;
it("should attach top up messages through force checkout", async () => {
const res = await AutumnCli.attach({
customerId,
productId: alexProducts.topUpMessages.id,
forceCheckout: true,
});
await completeCheckoutForm(res.checkout_url, overrideQuantity);
await timeout(10000);
const cusRes = await AutumnCli.getCustomer(customerId);
// Get product
const product = cusRes.add_ons.find(
(p: any) => p.id === alexProducts.topUpMessages.id,
);
expect(product).to.exist;
expect(product.status).to.equal(CusProductStatus.Active);
// Check quantity is correct
const cusEnt = cusRes.entitlements.find(
(e: any) => e.feature_id === alexFeatures.topUpMessage.id,
);
expect(cusEnt).to.exist;
expect(cusEnt.balance).to.equal(overrideQuantity * billingUnits);
expect(cusEnt.interval).to.equal(prodEnt.interval);
});
// Try buy again
it("should buy top ups again and have correct balance", async () => {
// 1. Update leftover balance
const { allowed, balanceObj }: any = await AutumnCli.entitled(
customerId,
alexFeatures.topUpMessage.id,
true,
);
leftoverBalance = balanceObj.balance;
const res = await AutumnCli.attach({
customerId,
productId: alexProducts.topUpMessages.id,
forceCheckout: true,
});
await completeCheckoutForm(res.checkout_url, overrideQuantity);
await timeout(10000);
const cusRes = await AutumnCli.getCustomer(customerId);
// Get product
const product = cusRes.add_ons.find(
(p: any) => p.id === alexProducts.topUpMessages.id,
);
expect(product).to.exist;
expect(product.status).to.equal(CusProductStatus.Active);
// Check quantity is correct
const cusEnt = cusRes.entitlements.find(
(e: any) => e.feature_id === alexFeatures.topUpMessage.id,
);
expect(cusEnt).to.exist;
expect(cusEnt.balance).to.equal(
leftoverBalance + overrideQuantity * billingUnits,
);
expect(cusEnt.interval).to.equal(prodEnt.interval);
});
});
describe(chalk.yellowBright("Testing o1 message top up"), () => {
const customerId = "alex-o1-top-up-customer";
const o1TopUpQuantity = Math.floor(Math.random() * 15);
const billingUnits = alexProducts.o1TopUps.prices[0].config.billing_units;
const proAllowance = alexProducts.pro.entitlements.o1Message.allowance!;
before("initializing customer", async function () {
await initCustomerV2({
autumn,
customerId,
org: this.org,
env: this.env,
db: this.db,
attachPm: "success",
});
// await initCustomer({
// customer_data: {
// id: customerId,
// name: "Alex O1 Top Up Customer",
// email: "alex-o1-top-up-customer@test.com",
// },
// db: this.db,
// org: this.org,
// env: this.env,
// attachPm: true,
// });
});
it("should attach pro product", async () => {
await AutumnCli.attach({
customerId,
productId: alexProducts.pro.id,
});
const cusRes = await AutumnCli.getCustomer(customerId);
compareMainProduct({
sent: alexProducts.pro,
cusRes,
status: CusProductStatus.Trialing,
});
});
it("should buy o1 messages and have correct balance", async () => {
const res = await AutumnCli.attach({
customerId,
productId: alexProducts.o1TopUps.id,
forceCheckout: true,
});
await completeCheckoutForm(res.checkout_url, o1TopUpQuantity);
await timeout(14000);
const cusRes = await AutumnCli.getCustomer(customerId);
// Get product
const product = cusRes.add_ons.find(
(p: any) => p.id === alexProducts.o1TopUps.id,
);
expect(product).to.exist;
expect(product.status).to.equal(CusProductStatus.Active);
// Check quantity is correct
await checkFeatureHasCorrectBalance({
customerId,
feature: alexFeatures.o1Message,
entitlement: alexProducts.o1TopUps.entitlements.o1Message,
expectedBalance: o1TopUpQuantity * billingUnits + proAllowance,
});
});
it("should send events and check balances", async () => {
const allowance = o1TopUpQuantity * billingUnits + proAllowance;
await runEventsAndCheckBalances({
customerId,
entitlements: [
{
interval: EntInterval.Lifetime,
feature_id: alexFeatures.o1Message.id,
allowance,
allowance_type: AllowanceType.Fixed,
} as Entitlement, // manual entitlement because in advance pricing
],
});
});
});

View File

@@ -1,210 +0,0 @@
import { ApiVersion, CusProductStatus, type Customer } from "@autumn/shared";
import { expect } from "chai";
import chalk from "chalk";
import { addDays, addHours } from "date-fns";
import type Stripe from "stripe";
import { AutumnCli } from "@tests/cli/AutumnCli.js";
import { compareMainProduct } from "@tests/utils/compare.js";
import { hoursToFinalizeInvoice } from "@tests/utils/constants.js";
import { timeout } from "@tests/utils/genUtils.js";
import { advanceTestClock } from "@tests/utils/stripeUtils.js";
import { AutumnInt } from "@/external/autumn/autumnCli.js";
import { createStripeCli } from "@/external/connect/createStripeCli.js";
import { attachFailedPaymentMethod } from "@/external/stripe/stripeCusUtils.js";
import { initCustomerV2 } from "@/utils/scriptUtils/initCustomer.js";
import { alexProducts } from "./init.js";
// CANCEL AT
const getProductFromCusRes = ({
cusRes,
productId,
}: {
cusRes: any;
productId: string;
}) => {
return cusRes.products.find((p: any) => p.id === productId);
};
describe(chalk.yellowBright("05_cancel"), () => {
const autumn = new AutumnInt({ version: ApiVersion.V1_2 });
describe("Testing cancel_at_period_end and cancel now", () => {
let stripeCli: Stripe;
const customerId = "alex-cancel-customer";
const autumn: AutumnInt = new AutumnInt({ version: ApiVersion.V1_2 });
before("initializing customer", async function () {
stripeCli = createStripeCli({
org: this.org,
env: this.env,
});
await initCustomerV2({
autumn,
customerId,
org: this.org,
env: this.env,
db: this.db,
attachPm: "success",
});
});
it("should attach pro product ", async () => {
const result = await AutumnCli.attach({
customerId,
productId: alexProducts.pro.id,
});
const cusRes = await AutumnCli.getCustomer(customerId);
compareMainProduct({
sent: alexProducts.pro,
cusRes,
status: CusProductStatus.Trialing,
});
});
it("should cancel at period end", async () => {
// 1. Get pro product
const cusRes = await AutumnCli.getCustomer(customerId);
const proProduct = getProductFromCusRes({
cusRes,
productId: alexProducts.pro.id,
});
for (const subId of proProduct.subscription_ids) {
await stripeCli.subscriptions.update(subId, {
cancel_at_period_end: true,
});
}
await timeout(5000);
const newCusRes = await AutumnCli.getCustomer(customerId);
const newProProduct = getProductFromCusRes({
cusRes: newCusRes,
productId: alexProducts.pro.id,
});
expect(newProProduct).to.exist;
expect(newProProduct.canceled_at).to.not.equal(null);
expect(newProProduct.status).to.equal(CusProductStatus.Trialing);
});
it("should cancel now", async () => {
const cusRes = await AutumnCli.getCustomer(customerId);
const proProduct = getProductFromCusRes({
cusRes,
productId: alexProducts.pro.id,
});
for (const subId of proProduct.subscription_ids) {
await stripeCli.subscriptions.cancel(subId);
}
await timeout(5000);
const newCusRes = await AutumnCli.getCustomer(customerId);
const newProProduct = getProductFromCusRes({
cusRes: newCusRes,
productId: alexProducts.pro.id,
});
expect(newProProduct).to.not.exist;
const freeProduct = getProductFromCusRes({
cusRes: newCusRes,
productId: alexProducts.free.id,
});
expect(freeProduct).to.exist;
expect(freeProduct.status).to.equal(CusProductStatus.Active);
});
});
describe("Testing past due", () => {
const customerId = "alex-past-due-customer";
let customer: Customer;
let testClockId: string;
let stripeCli: Stripe;
before("initializing customer", async function () {
stripeCli = createStripeCli({
org: this.org,
env: this.env,
});
const res = await initCustomerV2({
autumn,
customerId,
org: this.org,
env: this.env,
db: this.db,
attachPm: "success",
});
testClockId = res.testClockId;
customer = res.customer;
});
it("should attach pro product ", async () => {
await AutumnCli.attach({
customerId,
productId: alexProducts.pro.id,
});
await timeout(5000);
const cusRes = await AutumnCli.getCustomer(customerId);
compareMainProduct({
sent: alexProducts.pro,
cusRes,
status: CusProductStatus.Trialing,
});
});
it("should attach failed card", async () => {
await attachFailedPaymentMethod({ stripeCli, customer });
});
it("should advance clock to next cycle", async () => {
await advanceTestClock({
stripeCli,
testClockId,
advanceTo: addHours(
addDays(new Date(), 7),
hoursToFinalizeInvoice,
).getTime(),
});
await timeout(5000);
});
// // TODO: Edit so that it doesn't auto cancel for unit test org
// it("should have expired / past_dued pro product", async function () {
// const cusRes = await AutumnCli.getCustomer(customerId);
// let org = this.org;
// console.log("Cancel on past due:", org.config.cancel_on_past_due);
// if (org.config.cancel_on_past_due) {
// const proProduct = getProductFromCusRes({
// cusRes,
// productId: alexProducts.pro.id,
// });
// expect(proProduct).to.not.exist;
// const freeProduct = getProductFromCusRes({
// cusRes,
// productId: alexProducts.free.id,
// });
// expect(freeProduct).to.exist;
// expect(freeProduct.status).to.equal(CusProductStatus.Active);
// } else {
// compareMainProduct({
// sent: alexProducts.pro,
// cusRes,
// status: CusProductStatus.PastDue,
// });
// }
// });
});
});

View File

@@ -1,232 +0,0 @@
import { ApiVersion, CusProductStatus } from "@autumn/shared";
import { expect } from "chai";
import chalk from "chalk";
import { addDays, addHours } from "date-fns";
import type Stripe from "stripe";
import { AutumnCli } from "@tests/cli/AutumnCli.js";
import { compareMainProduct } from "@tests/utils/compare.js";
import { timeout } from "@tests/utils/genUtils.js";
import { advanceTestClock } from "@tests/utils/stripeUtils.js";
import { AutumnInt } from "@/external/autumn/autumnCli.js";
import { createStripeCli } from "@/external/connect/createStripeCli.js";
import { initCustomerV2 } from "@/utils/scriptUtils/initCustomer.js";
import { alexProducts } from "./init.js";
describe(
chalk.yellowBright(
"06_switch: Testing upgrades / downgrades from pro <-> premium",
),
() => {
const customerId = "alex-upgrade-downgrade-customer";
let testClockId = "";
const fingerprint = "fp1";
let stripeCli: Stripe;
const autumn = new AutumnInt({ version: ApiVersion.V1_2 });
before("initializing customer", async function () {
stripeCli = createStripeCli({
org: this.org,
env: this.env,
});
// const { testClockId: newTestClockId } = await initCustomerWithTestClock({
// customerId,
// db: this.db,
// org: this.org,
// env: this.env,
// fingerprint,
// });
const { testClockId: newTestClockId } = await initCustomerV2({
autumn,
customerId,
customerData: { fingerprint },
org: this.org,
env: this.env,
db: this.db,
attachPm: "success",
});
testClockId = newTestClockId;
});
describe("First upgrade from pro to premium (trial to trial)", () => {
it("should attach pro product", async () => {
await timeout(10000);
await AutumnCli.attach({
customerId,
productId: alexProducts.pro.id,
});
const cusRes = await AutumnCli.getCustomer(customerId);
compareMainProduct({
sent: alexProducts.pro,
cusRes,
status: CusProductStatus.Trialing,
});
});
it("should upgrade to premium", async () => {
await AutumnCli.attach({
customerId,
productId: alexProducts.premium.id,
});
const cusRes = await AutumnCli.getCustomer(customerId);
compareMainProduct({
sent: alexProducts.premium,
cusRes,
status: CusProductStatus.Trialing,
});
// Should have 2 invoices
expect(cusRes.invoices.length).to.equal(2);
expect(cusRes.invoices[0].total).to.equal(0);
});
});
// return;
describe("Downgrade from premium to pro (trial to paid)", () => {
it("should attach pro product (downgrade from premium trial)", async () => {
await AutumnCli.attach({
customerId,
productId: alexProducts.pro.id,
});
const cusRes = await AutumnCli.getCustomer(customerId);
compareMainProduct({
sent: alexProducts.premium,
cusRes,
status: CusProductStatus.Trialing,
});
const proProduct = cusRes.products.find(
(p: any) => p.id === alexProducts.pro.id,
);
expect(proProduct.status).to.equal(CusProductStatus.Scheduled);
expect(proProduct.starts_at).to.exist;
expect(proProduct.starts_at).to.be.greaterThan(Date.now());
await advanceTestClock({
stripeCli,
testClockId,
advanceTo: addHours(new Date(proProduct.starts_at), 10).getTime(),
});
});
it("should have pro product and last invoice for $20", async () => {
const cusRes = await AutumnCli.getCustomer(customerId);
compareMainProduct({
sent: alexProducts.pro,
cusRes,
status: CusProductStatus.Active,
});
const lastInvoice = cusRes.invoices[0];
expect(lastInvoice.total).to.equal(20);
});
});
describe("Upgrade from pro to premium (paid to paid)", () => {
// Now, advance 15 days and upgrade again, and check that the new invoice is for between 25 and 35 (because of the prorated amount)
it("should advance clock by 15 days and attach premium", async () => {
await advanceTestClock({
stripeCli,
testClockId,
advanceTo: addDays(new Date(), 7 + 15).getTime(),
});
await AutumnCli.attach({
customerId,
productId: alexProducts.premium.id,
});
await timeout(10000);
const cusRes = await AutumnCli.getCustomer(customerId);
compareMainProduct({
sent: alexProducts.premium,
cusRes,
});
});
it("should have new invoice for roughly 15 days of premium (due to prorated)", async () => {
const premiumPrice = alexProducts.premium.prices[0].config.amount;
const proPrice = alexProducts.pro.prices[0].config.amount;
const proratedAmount = ((premiumPrice - proPrice) * 15) / 30;
const cusRes = await AutumnCli.getCustomer(customerId);
const lastInvoice = cusRes.invoices[0];
// Expect invoice to be prorated amount +/- 10%
expect(lastInvoice.product_ids[0]).to.equal(alexProducts.premium.id);
expect(lastInvoice.total).to.be.greaterThan(proratedAmount * 0.9);
expect(lastInvoice.total).to.be.lessThan(proratedAmount * 1.1);
});
});
},
);
// Also, downgrade and cancel pro
describe(chalk.yellowBright("06_switch: Testing fingerprint"), () => {
const customerId = "alex-fingerprint-test";
const fingerprint = "fp1";
const autumn = new AutumnInt({ version: ApiVersion.V1_2 });
before("initializing customer", async function () {
await initCustomerV2({
autumn,
customerId,
customerData: { fingerprint },
org: this.org,
env: this.env,
db: this.db,
attachPm: "success",
});
// await initCustomer({
// customer_data: {
// id: customerId,
// name: "Alex Fingerprint Test",
// email: "alex-fingerprint-test@test.com",
// fingerprint,
// },
// attachPm: true,
// db: this.db,
// org: this.org,
// env: this.env,
// });
});
it("should attach pro product and have invoice for $20", async () => {
await AutumnCli.attach({
customerId,
productId: alexProducts.pro.id,
});
const cusRes = await AutumnCli.getCustomer(customerId);
compareMainProduct({
sent: alexProducts.pro,
cusRes,
status: CusProductStatus.Active,
});
const invoices = cusRes.invoices;
expect(invoices[0].total).to.equal(20);
});
it("should attach premium product and have invoice for $30", async () => {
await AutumnCli.attach({
customerId,
productId: alexProducts.premium.id,
});
const cusRes = await AutumnCli.getCustomer(customerId);
compareMainProduct({
sent: alexProducts.premium,
cusRes,
status: CusProductStatus.Active,
});
const invoices = cusRes.invoices;
expect(invoices[0].total).to.equal(30);
});
});

View File

@@ -1,455 +0,0 @@
import dotenv from "dotenv";
dotenv.config();
import {
AggregateType,
AllowanceType,
AppEnv,
BillingInterval,
EntInterval,
FeatureType,
} from "@autumn/shared";
import { initDrizzle } from "@/db/initDrizzle.js";
import { FeatureService } from "@/internal/features/FeatureService.js";
import { OrgService } from "@/internal/orgs/OrgService.js";
import {
initEntitlement,
initFeature,
initPrice,
initProduct,
} from "../utils/init.js";
export const alexFeatures = {
chatMessage: initFeature({
id: "chatMessage",
type: FeatureType.Metered,
aggregateType: AggregateType.Count,
eventName: "chat_message",
}),
deepseekMessage: initFeature({
id: "deepseekMessage",
type: FeatureType.Metered,
aggregateType: AggregateType.Count,
eventName: "deepseek_message",
}),
o1Message: initFeature({
id: "o1Message",
type: FeatureType.Metered,
aggregateType: AggregateType.Count,
eventName: "o1_message",
}),
applyCode: initFeature({
id: "applyCode",
type: FeatureType.Metered,
aggregateType: AggregateType.Count,
eventName: "apply",
}),
gitCommit: initFeature({
id: "gitCommit",
type: FeatureType.Metered,
aggregateType: AggregateType.Count,
eventName: "git_commit",
}),
tabToComplete: initFeature({
id: "tabToComplete",
type: FeatureType.Metered,
aggregateType: AggregateType.Count,
eventName: "suggestion",
}),
topUpMessage: initFeature({
id: "topUpMessage",
type: FeatureType.Metered,
aggregateType: AggregateType.Count,
eventName: "top_up_message",
}),
voiceInput: initFeature({
id: "voiceInput",
type: FeatureType.Metered,
aggregateType: AggregateType.Count,
eventName: "voice_input",
}),
figmaIntegration: initFeature({
id: "figmaIntegration",
type: FeatureType.Boolean,
eventName: "figma_integration",
}),
githubIssuesIntegration: initFeature({
id: "githubIssuesIntegration",
type: FeatureType.Boolean,
eventName: "github_issues_integration",
}),
linearIntegration: initFeature({
id: "linearIntegration",
type: FeatureType.Boolean,
eventName: "linear_integration",
}),
canAddSeats: initFeature({
id: "canAddSeats",
type: FeatureType.Boolean,
eventName: "can_add_seats",
}),
seats: initFeature({
id: "seats",
type: FeatureType.Metered,
aggregateType: AggregateType.Count,
eventName: "seats",
}),
};
export const alexProducts = {
free: initProduct({
id: "free",
isDefault: true,
entitlements: {
chatMessage: initEntitlement({
feature: alexFeatures.chatMessage,
allowance: 50,
interval: EntInterval.Month,
}),
applyCode: initEntitlement({
feature: alexFeatures.applyCode,
allowance: 5,
interval: EntInterval.Month,
}),
tabToComplete: initEntitlement({
feature: alexFeatures.tabToComplete,
allowance: 50,
interval: EntInterval.Month,
}),
voiceInput: initEntitlement({
feature: alexFeatures.voiceInput,
allowance: 5,
interval: EntInterval.Month,
}),
gitCommit: initEntitlement({
feature: alexFeatures.gitCommit,
allowance: 5,
interval: EntInterval.Month,
}),
topUpMessage: initEntitlement({
feature: alexFeatures.topUpMessage,
allowance: 0,
interval: EntInterval.Lifetime,
}),
o1Message: initEntitlement({
feature: alexFeatures.o1Message,
allowance: 5,
interval: EntInterval.Lifetime,
}),
deepseekMessage: initEntitlement({
feature: alexFeatures.deepseekMessage,
allowance: 50,
interval: EntInterval.Month,
}),
},
prices: [],
freeTrial: null,
}),
pro: initProduct({
id: "pro",
isDefault: false,
entitlements: {
chatMessage: initEntitlement({
feature: alexFeatures.chatMessage,
allowance: 500,
interval: EntInterval.Month,
}),
deepseekMessage: initEntitlement({
feature: alexFeatures.deepseekMessage,
allowanceType: AllowanceType.Unlimited,
}),
applyCode: initEntitlement({
feature: alexFeatures.applyCode,
allowanceType: AllowanceType.Unlimited,
}),
gitCommit: initEntitlement({
feature: alexFeatures.gitCommit,
allowanceType: AllowanceType.Unlimited,
}),
voiceInput: initEntitlement({
feature: alexFeatures.voiceInput,
allowanceType: AllowanceType.Unlimited,
}),
tabToComplete: initEntitlement({
feature: alexFeatures.tabToComplete,
allowanceType: AllowanceType.Unlimited,
}),
o1Message: initEntitlement({
feature: alexFeatures.o1Message,
allowance: 5,
interval: EntInterval.Lifetime,
}),
topUpMessage: initEntitlement({
feature: alexFeatures.topUpMessage,
allowance: 0,
interval: EntInterval.Lifetime,
}),
},
freeTrial: null,
prices: [
initPrice({
amount: 20.0, // $20.00
billingInterval: BillingInterval.Month,
type: "monthly",
}),
],
}),
topUpMessages: initProduct({
id: "topUpMessages",
isDefault: false,
isAddOn: true,
entitlements: {
topUpMessage: initEntitlement({
feature: alexFeatures.topUpMessage,
allowance: 0,
interval: EntInterval.Lifetime,
}),
},
prices: [
initPrice({
type: "in_advance",
amount: 9,
billingUnits: 250,
billingInterval: BillingInterval.OneOff,
feature: alexFeatures.topUpMessage,
}),
],
freeTrial: null,
}),
o1TopUps: initProduct({
id: "o1TopUps",
isDefault: false,
isAddOn: true,
entitlements: {
o1Message: initEntitlement({
feature: alexFeatures.o1Message,
allowance: 0,
interval: EntInterval.Lifetime,
}),
},
prices: [
initPrice({
type: "in_advance",
amount: 9,
billingUnits: 25,
billingInterval: BillingInterval.OneOff,
feature: alexFeatures.o1Message,
}),
],
freeTrial: null,
}),
premium: initProduct({
id: "premium",
isDefault: false,
entitlements: {
chatMessage: initEntitlement({
feature: alexFeatures.chatMessage,
allowance: 1000,
interval: EntInterval.Month,
}),
deepseekMessage: initEntitlement({
feature: alexFeatures.deepseekMessage,
allowanceType: AllowanceType.Unlimited,
}),
o1Message: initEntitlement({
feature: alexFeatures.o1Message,
allowance: 5,
interval: EntInterval.Month,
}),
applyCode: initEntitlement({
feature: alexFeatures.applyCode,
allowanceType: AllowanceType.Unlimited,
}),
gitCommit: initEntitlement({
feature: alexFeatures.gitCommit,
allowanceType: AllowanceType.Unlimited,
}),
voiceInput: initEntitlement({
feature: alexFeatures.voiceInput,
allowanceType: AllowanceType.Unlimited,
}),
tabToComplete: initEntitlement({
feature: alexFeatures.tabToComplete,
allowanceType: AllowanceType.Unlimited,
}),
topUpMessage: initEntitlement({
feature: alexFeatures.topUpMessage,
allowance: 0,
interval: EntInterval.Lifetime,
}),
figmaIntegration: initEntitlement({
feature: alexFeatures.figmaIntegration,
allowanceType: AllowanceType.Unlimited,
}),
githubIssuesIntegration: initEntitlement({
feature: alexFeatures.githubIssuesIntegration,
allowanceType: AllowanceType.Unlimited,
}),
linearIntegration: initEntitlement({
feature: alexFeatures.linearIntegration,
allowanceType: AllowanceType.Unlimited,
}),
},
prices: [
initPrice({
type: "monthly",
amount: 50,
}),
],
freeTrial: null,
}),
proTeam: initProduct({
id: "proTeam",
isDefault: false,
isAddOn: true,
entitlements: {
topUpMessage: initEntitlement({
feature: alexFeatures.topUpMessage,
allowance: 0,
interval: EntInterval.Lifetime,
}),
tabToComplete: initEntitlement({
feature: alexFeatures.tabToComplete,
allowanceType: AllowanceType.Unlimited,
}),
o1Message: initEntitlement({
feature: alexFeatures.o1Message,
allowance: 5,
interval: EntInterval.Lifetime,
}),
chatMessage: initEntitlement({
feature: alexFeatures.chatMessage,
allowance: 500,
interval: EntInterval.Month,
}),
deepseekMessage: initEntitlement({
feature: alexFeatures.deepseekMessage,
allowanceType: AllowanceType.Unlimited,
}),
applyCode: initEntitlement({
feature: alexFeatures.applyCode,
allowanceType: AllowanceType.Unlimited,
}),
gitCommit: initEntitlement({
feature: alexFeatures.gitCommit,
allowanceType: AllowanceType.Unlimited,
}),
voiceInput: initEntitlement({
feature: alexFeatures.voiceInput,
allowanceType: AllowanceType.Unlimited,
}),
},
prices: [],
freeTrial: null,
}),
teamManager: initProduct({
id: "teamManager",
isDefault: false,
isAddOn: true,
entitlements: {
topUpMessage: initEntitlement({
feature: alexFeatures.topUpMessage,
allowance: 0,
interval: EntInterval.Lifetime,
}),
chatMessage: initEntitlement({
feature: alexFeatures.chatMessage,
allowance: 1000,
interval: EntInterval.Month,
}),
deepseekMessage: initEntitlement({
feature: alexFeatures.deepseekMessage,
allowanceType: AllowanceType.Unlimited,
}),
o1Message: initEntitlement({
feature: alexFeatures.o1Message,
allowance: 5,
interval: EntInterval.Month,
}),
applyCode: initEntitlement({
feature: alexFeatures.applyCode,
allowanceType: AllowanceType.Unlimited,
}),
gitCommit: initEntitlement({
feature: alexFeatures.gitCommit,
allowanceType: AllowanceType.Unlimited,
}),
voiceInput: initEntitlement({
feature: alexFeatures.voiceInput,
allowanceType: AllowanceType.Unlimited,
}),
tabToComplete: initEntitlement({
feature: alexFeatures.tabToComplete,
allowanceType: AllowanceType.Unlimited,
}),
canAddSeats: initEntitlement({
feature: alexFeatures.canAddSeats,
}),
seats: initEntitlement({
feature: alexFeatures.seats,
allowance: 0,
interval: EntInterval.Lifetime,
}),
},
prices: [
initPrice({
type: "in_advance",
amount: 50,
billingUnits: 1,
billingInterval: BillingInterval.Month,
feature: alexFeatures.seats,
}),
],
freeTrial: null,
}),
};
const orgSlug = process.env.TESTS_ORG!;
before(async function () {
try {
this.env = AppEnv.Sandbox;
const { db, client } = initDrizzle();
this.db = db;
this.client = client;
this.org = await OrgService.getBySlug({
db: this.db,
slug: orgSlug,
});
const dbFeatures = await FeatureService.list({
db: this.db,
orgId: this.org.id,
env: this.env,
});
for (const featureId in alexFeatures) {
const feature = alexFeatures[featureId as keyof typeof alexFeatures];
const dbFeature = dbFeatures.find((f: any) => f.id === feature.id);
if (!dbFeature) {
continue;
throw new Error(`Feature ${feature.id} not found`);
}
alexFeatures[featureId as keyof typeof alexFeatures].internal_id =
dbFeature.internal_id;
if (feature.type === FeatureType.Metered) {
// Ignore this for now
alexFeatures[featureId as keyof typeof alexFeatures].eventName =
dbFeature.event_names?.[0] || dbFeature.id;
}
}
} catch (error) {
console.error(error);
}
});
after(async function () {
await this.client.end();
});

View File

@@ -1,241 +0,0 @@
import {
Entitlement,
FeatureType,
AllowanceType,
Feature,
} from "@autumn/shared";
import { expect } from "chai";
import { timeout } from "@tests/utils/genUtils.js";
import { AutumnCli } from "@tests/cli/AutumnCli.js";
import { alexFeatures } from "./init.js";
const checkEntitledOnFeatures = async (customerId: string, product: any) => {
const entitlements: Entitlement[] = Object.values(product.entitlements);
for (const entitlement of entitlements) {
const { allowed, balanceObj }: any = await AutumnCli.entitled(
customerId,
entitlement.feature_id!,
true,
);
// Type assertion to tell TypeScript that feature_id is a key of alexFeatures
let feature =
alexFeatures[entitlement.feature_id! as keyof typeof alexFeatures];
try {
if (feature.type === FeatureType.Boolean) {
expect(allowed).to.equal(true);
return;
}
if (entitlement.allowance === 0) {
expect(allowed).to.equal(false);
expect(balanceObj?.balance).to.equal(0);
return;
} else if (entitlement.allowance_type === AllowanceType.Unlimited) {
expect(allowed).to.equal(true);
expect(balanceObj?.balance).to.equal(null);
expect(balanceObj?.unlimited).to.equal(true);
return;
}
expect(allowed).to.equal(true);
expect(balanceObj?.balance).to.equal(entitlement.allowance);
} catch (error) {
console.log("Checking entitlement: ", entitlement);
console.log("Expected balance: ", entitlement.allowance);
console.log("Entitled response: ", { allowed, balanceObj });
throw error;
}
}
};
const getFeatureFromEntitlement = (entitlement: Entitlement) => {
return alexFeatures[
entitlement.feature_id! as keyof typeof alexFeatures
] as Feature & { eventName: string };
};
export const checkFeatureHasCorrectBalance = async ({
customerId,
feature,
entitlement,
expectedBalance,
}: {
customerId: string;
feature: Feature;
entitlement: Entitlement;
expectedBalance: number;
}) => {
const [entitledRes, cusRes] = await Promise.all([
AutumnCli.entitled(customerId, feature.id, true),
AutumnCli.getCustomer(customerId),
]);
if (feature.type === FeatureType.Boolean) {
console.log(" - Checking boolean feature: ", feature.id);
const { allowed, balanceObj }: any = entitledRes;
expect(allowed).to.equal(true);
return;
}
console.log(
` - Checking entitlement ${feature.id} has ${
entitlement.allowance_type == AllowanceType.Unlimited
? "unlimited balance"
: `balance of ${expectedBalance}`
}`,
);
// Get ent from cusRes
const { entitlements: cusEnts }: any = cusRes;
const { allowed, balanceObj }: any = entitledRes;
const cusEnt = cusEnts.find(
(e: any) =>
e.feature_id === feature.id && e.interval == entitlement.interval,
);
try {
expect(cusEnt).to.exist;
} catch (error) {
console.log(
`Expected cus ent ${feature.id}, interval ${entitlement.interval} to exist`,
);
throw error;
}
if (entitlement.allowance_type === AllowanceType.Unlimited) {
// Cus ent
expect(cusEnt.balance).to.equal(null);
expect(cusEnt.used).to.equal(null);
expect(cusEnt.unlimited).to.equal(true);
// Entitled res
expect(allowed).to.equal(true);
expect(balanceObj?.balance).to.equal(null);
expect(balanceObj?.unlimited).to.equal(true);
return;
}
if (expectedBalance === 0) {
expect(allowed).to.equal(false);
expect(balanceObj?.balance).to.equal(0);
expect(cusEnt.balance).to.equal(0);
return;
}
expect(balanceObj?.balance).to.equal(expectedBalance);
expect(cusEnt.balance).to.equal(expectedBalance);
};
export const runEventsAndCheckBalances = async ({
customerId,
entitlements,
}: {
customerId: string;
entitlements: Entitlement[];
}) => {
for (const entitlement of entitlements) {
if (
entitlement.allowance === 0 &&
entitlement.allowance_type !== AllowanceType.Unlimited
) {
continue;
}
let feature = getFeatureFromEntitlement(entitlement);
if (
entitlement.allowance_type === AllowanceType.Unlimited ||
feature.type === FeatureType.Boolean
) {
await checkFeatureHasCorrectBalance({
customerId,
feature,
entitlement,
expectedBalance: entitlement.allowance!,
});
continue;
}
// 1. Check that feature has full balance
await checkFeatureHasCorrectBalance({
customerId,
feature,
entitlement,
expectedBalance: entitlement.allowance!,
});
// console.log(" - Running events & entitled check for:", feature.id);
let firstHalf = Math.min(Math.floor(entitlement.allowance! / 2), 50);
let secondHalf = entitlement.allowance! - firstHalf;
// 1. Send first half
let batchUpdate = [];
for (let i = 0; i < firstHalf; i++) {
batchUpdate.push(
AutumnCli.sendEvent({
customerId,
eventName: feature.eventName!,
}),
);
}
let timeoutMilli = Math.max(Math.floor(firstHalf / 2.5), 2) * 2500;
await timeout(timeoutMilli);
await Promise.all(batchUpdate);
await checkFeatureHasCorrectBalance({
customerId,
feature,
entitlement,
expectedBalance: entitlement.allowance! - firstHalf,
});
if (secondHalf > 50) {
continue;
// TODO: Make balance 0 and check that it's blocked...
await AutumnCli.sendEvent({
customerId,
eventName: feature.eventName!,
properties: {
value: secondHalf,
},
});
await timeout(1000);
await checkFeatureHasCorrectBalance({
customerId,
feature,
entitlement,
expectedBalance: 0,
});
return;
}
for (let i = 0; i < secondHalf; i++) {
batchUpdate.push(
AutumnCli.sendEvent({
customerId,
eventName: feature.eventName!,
}),
);
}
await timeout(timeoutMilli);
await Promise.all(batchUpdate);
await checkFeatureHasCorrectBalance({
customerId,
feature,
entitlement,
expectedBalance: 0,
});
}
};

View File

@@ -1,341 +0,0 @@
import { compareMainProduct } from "../utils/compare.js";
import { initCustomer } from "../utils/init.js";
import { features, products } from "../global.js";
import { AutumnCli } from "../cli/AutumnCli.js";
import { assert, expect } from "chai";
import { timeout } from "../utils/genUtils.js";
import { completeCheckoutForm } from "../utils/stripeUtils.js";
import { getAxiosInstance } from "../utils/setup.js";
import chalk from "chalk";
const oneTimeQuantity = 2;
const oneTimePurchaseCount = 2;
const oneTimeOverrideQuantity = 4;
const monthlyQuantity = 2;
// UNCOMMENT FROM HERE
describe(`${chalk.yellowBright(
"01_product: Testing attach -- free, pro & one-time / monthly add on",
)}`, () => {
let customerId = "attach1";
before(async function () {
await initCustomer({
customerId,
db: this.db,
org: this.org,
env: this.env,
});
});
describe("Create customer -- check free is active", () => {
it("GET /customers/:id -- checking default product & entitlements", async function () {
const axiosInstance = getAxiosInstance();
const { data } = await axiosInstance.get(`/v1/customers/${customerId}`);
compareMainProduct({
sent: products.free,
cusRes: data,
});
});
it("GET /entitled -- metered1", async function () {
// Checking metered1 entitlement
const expectedEntitlement = products.free.entitlements.metered1;
const entitled: any = await AutumnCli.entitled(
customerId,
features.metered1.id,
);
const metered1Balance = entitled!.balances.find(
(balance: any) => balance.feature_id === features.metered1.id,
);
try {
expect(entitled!.allowed).to.be.true;
expect(metered1Balance).to.exist;
expect(metered1Balance!.balance).to.equal(
expectedEntitlement.allowance,
);
expect(metered1Balance!.unlimited).to.not.exist;
} catch (error) {
console.group();
console.group();
console.log("Looking for: ", expectedEntitlement);
console.log("Received (entitled res): ", entitled);
console.groupEnd();
console.groupEnd();
throw error;
}
});
it("GET /entitled -- boolean1", async function () {
const entitled = await AutumnCli.entitled(
customerId,
features.boolean1.id,
);
expect(entitled!.allowed).to.be.false;
});
});
describe("Attach pro -- check products & entitlements", () => {
it("POST /attach -- attaching pro (force checkout)", async function () {
const res = await AutumnCli.attach({
customerId: customerId,
productId: products.pro.id,
});
assert.exists(res.checkout_url);
await completeCheckoutForm(res.checkout_url);
await timeout(10000); // for webhook to be processed
console.log(` ${chalk.greenBright("Attached pro")}`);
});
it("GET /customers/:id -- checking product & entitlements (pro)", async function () {
const res = await AutumnCli.getCustomer(customerId);
// console.log("Res: ", res);
compareMainProduct({
sent: products.pro,
cusRes: res,
});
assert.isTrue(res.invoices.length > 0);
});
// return;
it("GET /entitled -- checking entitlements for metered1 && boolean1", async function () {
const proEntitlements = products.pro.entitlements;
for (const entitlement of Object.values(proEntitlements)) {
const allowance = entitlement.allowance;
const res: any = await AutumnCli.entitled(
customerId,
entitlement.feature_id!,
);
const entBalance = res!.balances.find(
(b: any) => b.feature_id === entitlement.feature_id,
);
try {
expect(res!.allowed).to.be.true;
expect(entBalance).to.exist;
if (entitlement.allowance) {
expect(entBalance!.balance).to.equal(allowance);
}
// console.log(` - ${entitlement.feature_id} -- Passed`);
} catch (error) {
console.group();
console.group();
console.log("Looking for: ", entitlement);
console.log("Received: ", res);
console.groupEnd();
console.groupEnd();
throw error;
}
}
});
});
const oneTimeBillingUnits =
products.oneTimeAddOnMetered1.prices[0].config.billing_units;
const monthlyBillingUnits =
products.monthlyAddOnMetered1.prices[0].config.billing_units;
describe("One time add on (force checkout)", () => {
// PURCHASE ONE TIME ADD ON
it("POST /attach -- attaching one time add on (force checkout) [no quantity passed in]", async function () {
try {
for (let i = 0; i < oneTimePurchaseCount; i++) {
const res = await AutumnCli.attach({
customerId: customerId,
productId: products.oneTimeAddOnMetered1.id,
forceCheckout: true,
});
await completeCheckoutForm(res.checkout_url, oneTimeOverrideQuantity);
await timeout(10000); // for webhook to be processed
console.log(` ${chalk.greenBright("Attached one time add on")}`);
}
} catch (error) {
console.group();
console.group();
console.log("Failed to attach one time add on");
console.log("Error data:", error);
console.groupEnd();
console.groupEnd();
process.exit(1);
}
});
// TODO: Attach one time add on again (with quantity?)
it("GET /customers/:id -- checking product & entitlements (one time add on)", async function () {
const cusRes = await AutumnCli.getCustomer(customerId);
// 1. Metered1 balance should be pro + one time add on
// Fetch balance
const addOnBalance = cusRes.entitlements.find(
(e: any) =>
e.feature_id === features.metered1.id &&
e.interval ==
products.oneTimeAddOnMetered1.entitlements.metered1.interval,
);
const expectedAmt =
(oneTimeOverrideQuantity || oneTimeQuantity) *
oneTimeBillingUnits *
oneTimePurchaseCount;
try {
assert.equal(addOnBalance!.balance, expectedAmt);
assert.equal(cusRes.add_ons.length, 1);
assert.equal(cusRes.add_ons[0].id, products.oneTimeAddOnMetered1.id);
assert.equal(cusRes.invoices.length, 1 + oneTimePurchaseCount);
} catch (error) {
console.group();
console.group();
console.log("GET customer, balances failed");
console.log(
"Add on entitlement:",
products.oneTimeAddOnMetered1.entitlements.metered1,
);
console.log("Customer entitlements:", cusRes.entitlements);
console.groupEnd();
console.groupEnd();
throw error;
}
});
it("GET /entitled -- checking entitled for metered1", async function () {
const res: any = await AutumnCli.entitled(
customerId,
features.metered1.id,
);
expect(res!.allowed).to.be.true;
// pro metered1
const proMetered1Amt = products.pro.entitlements.metered1.allowance;
const addOnBalance = res!.balances.find(
(b: any) => b.feature_id === features.metered1.id,
);
expect(res!.allowed).to.be.true;
expect(addOnBalance!.balance).to.equal(
proMetered1Amt! +
(oneTimeOverrideQuantity || oneTimeQuantity) *
oneTimeBillingUnits *
oneTimePurchaseCount,
);
});
});
// PURCHASE MONTHLY ADD ON
describe("Monthly add on", () => {
it("POST /attach -- attaching monthly add on", async function () {
await AutumnCli.attach({
customerId: customerId,
productId: products.monthlyAddOnMetered1.id,
forceCheckout: false,
options: [
{
feature_id: features.metered1.id,
quantity: monthlyQuantity * monthlyBillingUnits,
},
],
});
await timeout(10000);
console.log(` ${chalk.greenBright("Attached monthly top up")}`);
});
it("GET /customers/:id -- checking product & entitlements (monthly add on)", async function () {
const cusRes = await AutumnCli.getCustomer(customerId);
// 1. Metered1 balance should be pro + one time add on
const proMetered1 = products.pro.entitlements.metered1.allowance;
// Fetch balance
const monthlyMetered1Balance = cusRes.entitlements.find(
(e: any) =>
e.feature_id === features.metered1.id &&
e.interval ==
products.monthlyAddOnMetered1.entitlements.metered1.interval,
);
try {
assert.equal(
monthlyMetered1Balance!.balance,
proMetered1! + monthlyQuantity * monthlyBillingUnits,
);
assert.equal(cusRes.add_ons.length, 2);
const monthlyAddOnId = cusRes.add_ons.find(
(a: any) => a.id === products.monthlyAddOnMetered1.id,
);
assert.exists(monthlyAddOnId);
expect(cusRes.invoices.length).to.equal(2 + oneTimePurchaseCount);
} catch (error) {
console.group();
console.group();
console.log("GET customer, balances failed");
console.log(
"Add on entitlement:",
products.monthlyAddOnMetered1.entitlements.metered1,
);
console.log("Customer entitlements:", cusRes.entitlements);
console.groupEnd();
console.groupEnd();
throw error;
}
});
it("GET /entitled -- checking entitlements (monthly add on)", async function () {
const res: any = await AutumnCli.entitled(
customerId,
features.metered1.id,
);
const metered1Balance = res!.balances.find(
(b: any) => b.feature_id === features.metered1.id,
);
const proMetered1Amt = products.pro.entitlements.metered1.allowance;
const monthlyAddOnMetered1Amt = monthlyQuantity * monthlyBillingUnits;
const oneTimeAddOnMetered1Amt =
(oneTimeOverrideQuantity || oneTimeQuantity) *
oneTimeBillingUnits *
oneTimePurchaseCount;
try {
expect(metered1Balance!.balance).to.equal(
proMetered1Amt! + monthlyAddOnMetered1Amt + oneTimeAddOnMetered1Amt,
);
} catch (error) {
console.group();
console.group();
console.log("GET entitled, balances failed");
console.log("/entitled response:", res);
console.log("Pro metered1 amt:", proMetered1Amt);
console.log("Monthly add on metered1 amt:", monthlyAddOnMetered1Amt);
console.log("One time add on metered1 amt:", oneTimeAddOnMetered1Amt);
console.groupEnd();
console.groupEnd();
throw error;
}
});
});
});

View File

@@ -1,245 +0,0 @@
import chalk from "chalk";
import { initCustomer } from "../utils/init.js";
import { features, products } from "../global.js";
import { AutumnCli } from "../cli/AutumnCli.js";
import { timeout } from "../utils/genUtils.js";
import { expect } from "chai";
const checkEntitledOnProduct = async ({
customerId,
product,
totalAllowance,
finish = false,
usageBased = false,
}: {
customerId: string;
product: any;
totalAllowance?: number;
finish?: boolean;
usageBased?: boolean;
}) => {
// 1. Send events
const allowance = totalAllowance || product.entitlements.metered1.allowance;
// const randomNum = Math.floor(Math.random() * (allowance - 1));
const randomNum = 3;
const batchUpdates = [];
for (let i = 0; i < randomNum; i++) {
batchUpdates.push(
AutumnCli.sendEvent({
customerId: customerId,
eventName: features.metered1.eventName,
}),
);
}
await Promise.all(batchUpdates);
await timeout(8000);
let used = randomNum;
// 2. Check entitled
const { allowed, balanceObj }: any = await AutumnCli.entitled(
customerId,
features.metered1.id,
true,
);
try {
expect(allowed).to.be.true;
expect(balanceObj!.balance).to.equal(allowance - randomNum);
if (!finish) {
return used;
}
} catch (error) {
console.group();
console.group();
console.log("Allowance: ", allowance, "Random num: ", randomNum);
console.log("Expected balance to be: ", allowance - randomNum);
console.log("Entitled res: ", { allowed, balanceObj });
console.groupEnd();
console.groupEnd();
throw error;
}
// Finish up
const batchUpdates2 = [];
for (let i = 0; i < allowance - randomNum; i++) {
batchUpdates2.push(
AutumnCli.sendEvent({
customerId: customerId,
eventName: features.metered1.eventName,
}),
);
}
await Promise.all(batchUpdates2);
await timeout(8000);
used += allowance - randomNum;
// 3. Check entitled again
const { allowed: allowed2, balanceObj: balanceObj2 }: any =
await AutumnCli.entitled(customerId, features.metered1.id, true);
try {
if (usageBased) {
expect(allowed2).to.be.true;
} else {
expect(allowed2).to.be.false;
}
expect(balanceObj2!.balance).to.equal(0);
return used;
} catch (error) {
console.group();
console.group();
console.log("Expected balance to be: ", 0);
console.log("Entitled res: ", { allowed2, balanceObj2 });
console.groupEnd();
console.groupEnd();
throw error;
}
};
// TODO: Add test case for unlimited feature
describe(`${chalk.yellowBright(
"04_entitled: Testing /events and /entitled, for pro, one time top up",
)}`, () => {
const customerId = "entitledCustomer";
let curAllowance = 0;
const oneTimeBillingUnits =
products.oneTimeAddOnMetered1.prices[0].config.billing_units!;
let oneTimeQuantity = 2 * oneTimeBillingUnits;
before(async function () {
await initCustomer({
customer_data: {
id: customerId,
name: customerId,
email: `test@test.com`,
},
db: this.db,
org: this.org,
env: this.env,
attachPm: true,
});
});
it("should have correct entitlements (free)", async function () {
await checkEntitledOnProduct({
customerId: customerId,
product: products.free,
finish: true,
});
});
it("should attach pro", async function () {
await AutumnCli.attach({
customerId: customerId,
productId: products.pro.id,
});
});
it("should have correct entitlements (pro)", async function () {
const used = await checkEntitledOnProduct({
customerId: customerId,
product: products.pro,
finish: false,
});
curAllowance = products.pro.entitlements.metered1.allowance! - used;
});
it("should attach one time top up", async function () {
await AutumnCli.attach({
customerId: customerId,
productId: products.oneTimeAddOnMetered1.id,
options: [
{
feature_id: features.metered1.id,
quantity: oneTimeQuantity,
},
],
});
});
it("should have correct entitlements (one time top up)", async function () {
// const oneTimeAmt = oneTimeBillingUnits * oneTimeQuantity;
await checkEntitledOnProduct({
customerId: customerId,
product: products.oneTimeAddOnMetered1,
finish: true,
totalAllowance: curAllowance + oneTimeQuantity,
});
});
});
describe(`${chalk.yellowBright(
"04_entitled: Testing /entitled & /events, for pro with overage",
)}`, () => {
const customerId = "entitledCustomerUsageBased";
before(async function () {
await initCustomer({
customer_data: {
id: customerId,
name: customerId,
email: "test@test.com",
},
db: this.db,
org: this.org,
env: this.env,
attachPm: true,
});
});
// PRO WITH OVERAGE
it("should attach pro (with overage)", async function () {
await AutumnCli.attach({
customerId: customerId,
productId: products.proWithOverage.id,
});
});
it("should have correct entitlements (pro with overage)", async function () {
await checkEntitledOnProduct({
customerId: customerId,
product: products.proWithOverage,
finish: true,
totalAllowance: products.proWithOverage.entitlements.metered1.allowance!,
usageBased: true,
});
});
it("should have correct usage-based balance (balance < 0)", async function () {
const { allowed, balanceObj }: any = await AutumnCli.entitled(
customerId,
features.metered1.id,
true,
);
expect(allowed).to.be.true;
expect(balanceObj!.balance).to.equal(0);
// Sent 5 events
const batchUpdates = [];
for (let i = 0; i < 5; i++) {
batchUpdates.push(
AutumnCli.sendEvent({
customerId: customerId,
eventName: features.metered1.eventName,
}),
);
}
await Promise.all(batchUpdates);
await timeout(5000);
const { allowed: allowed2, balanceObj: balanceObj2 }: any =
await AutumnCli.entitled(customerId, features.metered1.id, true);
expect(allowed2).to.be.true;
expect(balanceObj2!.balance).to.equal(-5);
expect(balanceObj2!.usage_allowed).to.be.true;
});
});

View File

@@ -1,164 +0,0 @@
import { assert } from "chai";
import { features, products } from "../global.js";
import { initCustomer } from "../utils/init.js";
import { getPublicAxiosInstance } from "../utils/setup.js";
import { completeCheckoutForm } from "../utils/stripeUtils.js";
import { timeout } from "../utils/genUtils.js";
import { ErrCode } from "@autumn/shared";
import { compareMainProduct } from "../utils/compare.js";
import { AutumnCli } from "../cli/AutumnCli.js";
import chalk from "chalk";
describe(`${chalk.yellowBright("08_pkey: Testing publishable key")}`, () => {
// 1. Initialize customer with card
let customerId = "pkeyTestCustomer";
const bearerPublicAxios = getPublicAxiosInstance({
withBearer: true,
});
before(async function () {
this.timeout(30000);
await initCustomer({
customer_data: {
id: customerId,
name: customerId,
email: "test@test.com",
fingerprint: "fp1",
},
db: this.db,
org: this.org,
env: this.env,
attachPm: true,
});
});
it("should return a 401 if the pkey is invalid", async function () {
this.timeout(30000);
const axiosInstance = getPublicAxiosInstance({
withBearer: true,
pkey: "am_pk_test_invalid",
});
try {
const { data } = await axiosInstance.post("/v1/attach", {
customer_id: customerId,
product_id: products.pro.id,
});
throw new Error("Should not be able to attach");
} catch (error: any) {
assert.equal(error.response.status, 401);
}
});
it("should return checkout URL for both bearer key", async function () {
this.timeout(30000);
const axiosInstanceBearer = getPublicAxiosInstance({
withBearer: true,
});
// 1. Should be able to upgrade to pro
const { data } = await axiosInstanceBearer.post("/v1/attach", {
customer_id: customerId,
product_id: products.pro.id,
});
assert.exists(data.checkout_url);
await completeCheckoutForm(data.checkout_url);
await timeout(5000);
});
it("should have customer with product", async function () {
this.timeout(30000);
const res = await AutumnCli.getCustomer(customerId);
compareMainProduct({
sent: products.pro,
cusRes: res,
});
});
it("should return error if try to upgrade or downgrade without pkey", async function () {
this.timeout(30000);
const axiosInstance = getPublicAxiosInstance({
withBearer: true,
});
try {
await axiosInstance.post("/v1/attach", {
customer_id: customerId,
product_id: products.premium.id,
});
throw new Error("Should not be able to attach");
} catch (error: any) {
assert.equal(error.response.status, 400);
assert.equal(error.response.data.code, ErrCode.InvalidRequest);
}
});
it("should return error if try to downgrade to free", async function () {
try {
await bearerPublicAxios.post("/v1/attach", {
customer_id: customerId,
product_id: products.free.id,
});
throw new Error("Should not be able to attach");
} catch (error: any) {
assert.equal(error.response.status, 400);
assert.equal(error.response.data.code, ErrCode.InvalidRequest);
}
});
// Next, check entitled for pro
it("should return correct metered1 amount for pro", async function () {
const { data } = await bearerPublicAxios.post("/v1/entitled", {
customer_id: customerId,
feature_id: features.metered1.id,
});
assert.equal(data.allowed, true);
const metered1Balance = data.balances.find(
(b: any) => b.feature_id === features.metered1.id,
);
assert.equal(
metered1Balance.balance,
products.pro.entitlements.metered1.allowance,
);
});
it("should return same balance for entitled with bearer and x-publishable-key", async function () {
const { data } = await bearerPublicAxios.post("/v1/entitled", {
customer_id: customerId,
feature_id: features.metered1.id,
});
assert.equal(data.allowed, true);
const metered1Balance = data.balances.find(
(b: any) => b.feature_id === features.metered1.id,
);
assert.equal(
metered1Balance.balance,
products.pro.entitlements.metered1.allowance,
);
});
it("should return error when try to send event", async function () {
try {
await bearerPublicAxios.post("/v1/events", {
customer_id: customerId,
event_name: features.metered1.id,
properties: {
value: 10,
},
});
throw new Error("Should not be able to send event");
} catch (error: any) {
assert.equal(error.response.status, 401);
assert.equal(error.response.data.code, ErrCode.EndpointNotPublic);
}
});
});

View File

@@ -1,298 +0,0 @@
// import chalk from "chalk";
// import Stripe from "stripe";
// import { expect } from "chai";
// import { AutumnCli } from "@tests/cli/AutumnCli.js";
// import { advanceProducts, features } from "@tests/global.js";
// import { compareMainProduct } from "@tests/utils/compare.js";
// import { advanceTestClock } from "@tests/utils/stripeUtils.js";
// import { timeout } from "@tests/utils/genUtils.js";
// import { addDays, addMonths, format } from "date-fns";
// import { initCustomerWithTestClock } from "@tests/utils/testInitUtils.js";
// import { checkSubscriptionContainsProducts } from "@tests/utils/scheduleCheckUtils.js";
// import { CacheManager } from "@/external/caching/CacheManager.js";
// import { CacheType } from "@/external/caching/cacheActions.js";
// import { hashApiKey } from "@/internal/dev/api-keys/apiKeyUtils.js";
// const advanceAPThroughBalances = async ({
// stripeSub,
// stripeCli,
// testClockId,
// customerId,
// billingUnits,
// startingFrom,
// startingBalance,
// }: {
// stripeSub: Stripe.Subscription;
// stripeCli: Stripe;
// testClockId: string;
// customerId: string;
// billingUnits: number;
// startingFrom?: number;
// startingBalance?: number;
// }) => {
// // 1. Get total period
// let totalPeriod =
// (stripeSub.current_period_end - stripeSub.current_period_start) * 1000;
// // 2. Get allowance
// let allowance =
// advanceProducts.proratedArrearSeats.entitlements.seats.allowance!;
// // 3. Get starting balance
// let balance = startingBalance || allowance;
// // 4. Get price per seat
// let pricePerSeat =
// advanceProducts.proratedArrearSeats.prices[1].config.usage_tiers[0].amount;
// let skipDays = 2;
// // 5. Get accrued price
// let accruedPrice = 0;
// if (startingBalance) {
// let proratedPrice =
// (-startingBalance *
// pricePerSeat *
// (startingFrom! - stripeSub.current_period_start * 1000)) /
// totalPeriod;
// let previouslyPaid = pricePerSeat * -startingBalance;
// let priceToPay = proratedPrice - previouslyPaid;
// accruedPrice = priceToPay;
// // accruedPrice = Math.max(accruedPrice, 0);
// console.log(" 🔍 Starting balance: ", startingBalance);
// console.log(" 🔍 Starting price: ", accruedPrice);
// }
// let curTime = startingFrom || stripeSub.current_period_start * 1000;
// let numberOfEvents = 2;
// console.group();
// console.group();
// for (let i = 0; i < numberOfEvents; i++) {
// let sign = balance > 0 ? 1 : Math.random() > 0.7 ? 1 : -1;
// let currentUsage = allowance - balance;
// let nextBoundary =
// Math.ceil((currentUsage + 1) / billingUnits) * billingUnits;
// let prevBoundary = nextBoundary - billingUnits;
// let valueNeeded = 0;
// if (sign > 0) {
// // Add random amount to push above next boundary
// const valueToGetToNegative = balance + 1;
// valueNeeded =
// Math.floor(Math.random() * 10) + (nextBoundary - currentUsage + 1);
// valueNeeded = Math.max(valueNeeded, valueToGetToNegative);
// } else {
// valueNeeded = -(
// Math.floor(Math.random() * 10) +
// (currentUsage - prevBoundary + 1)
// );
// }
// let newBalance = balance - valueNeeded;
// await AutumnCli.updateBalances({
// customerId,
// balances: [
// {
// feature_id: features.seats.id,
// balance: newBalance,
// },
// ],
// });
// await timeout(2000);
// let prevBalance = balance;
// balance = newBalance;
// // Calculate prorated price only when crossing boundary
// let newPrice = Math.max(0, -balance * pricePerSeat);
// let prevCurTime = curTime;
// curTime = addDays(curTime, 2).getTime();
// if (i === numberOfEvents - 1) {
// curTime = stripeSub.current_period_end * 1000;
// }
// let proratedPrice = (newPrice * (curTime - prevCurTime)) / totalPeriod;
// accruedPrice += Number(proratedPrice.toFixed(2));
// console.log(`Event ${i + 1}:`);
// console.log(` - Value added: ${valueNeeded}`);
// console.log(` - Balance: ${prevBalance} -> ${balance}`);
// console.log(` - Prorated price: ${proratedPrice.toFixed(2)}`);
// console.log(` - Accrued price: ${accruedPrice.toFixed(2)}`);
// await advanceTestClock({
// stripeCli,
// testClockId,
// numberOfDays: 2,
// startingFrom: new Date(prevCurTime),
// });
// }
// console.groupEnd();
// console.groupEnd();
// // Advance test clock to end of period
// let advanceTo = addDays(addMonths(new Date(), 1), 2);
// let advanceToStart = startingFrom ? new Date(startingFrom) : new Date();
// await advanceTestClock({
// stripeCli,
// testClockId,
// numberOfDays: 2,
// startingFrom: addMonths(advanceToStart, 1),
// });
// // Check invoice amount
// const res = await AutumnCli.getCustomer(customerId);
// let invoice = res.invoices[0];
// let basePrice = advanceProducts.proratedArrearSeats.prices[0].config.amount;
// let nextMonthUsagePrice = Math.max(-balance * pricePerSeat, 0);
// console.log(" 🔍 Next month usage price: ", nextMonthUsagePrice);
// let expectedTotal = invoice.total;
// expect(Number(expectedTotal.toFixed(2))).to.lessThan(invoice.total + 0.1);
// expect(Number(expectedTotal.toFixed(2))).to.greaterThan(invoice.total - 0.1);
// return {
// balance,
// advancedTo: advanceTo.getTime(),
// };
// };
// describe(`${chalk.yellowBright(
// "arrear_prorated2: testing update in arrear prorated through /balances",
// )}`, () => {
// const customerId = "arrear-prorated-balances";
// let testClockId = "";
// let stripeCli: Stripe;
// let subId = "";
// let stripeSub: Stripe.Subscription;
// let billingUnits =
// advanceProducts.proratedArrearSeats.prices[1].config.billing_units || 1;
// before(async function () {
// const { testClockId: createdTestClockId } = await initCustomerWithTestClock(
// {
// customerId,
// org: this.org,
// env: this.env,
// db: this.db,
// },
// );
// stripeCli = createStripeCli({
// org: this.org,
// env: this.env,
// });
// testClockId = createdTestClockId;
// await this.sb
// .from("organizations")
// .update({
// config: {
// ...this.org.config,
// bill_upgrade_immediately: false,
// },
// })
// .eq("id", this.org.id);
// await CacheManager.invalidate({
// action: CacheType.SecretKey,
// value: hashApiKey(process.env.UNIT_TEST_AUTUMN_SECRET_KEY!),
// });
// await CacheManager.disconnect();
// });
// it("should attach in arrear prorated seats", async () => {
// await timeout(5000);
// await AutumnCli.attach({
// customerId,
// productId: advanceProducts.proratedArrearSeats.id,
// });
// });
// it("should have correct product", async function () {
// const res = await AutumnCli.getCustomer(customerId);
// compareMainProduct({
// sent: advanceProducts.proratedArrearSeats,
// cusRes: res,
// });
// // 2. Get subscription period start and period end
// subId = res.products[0].subscription_ids[0];
// stripeSub = await stripeCli.subscriptions.retrieve(subId);
// await checkSubscriptionContainsProducts({
// db: this.db,
// org: this.org,
// env: this.env,
// subscriptionId: subId,
// productIds: [advanceProducts.proratedArrearSeats.id],
// });
// });
// let advancedTo: number;
// let balance: number;
// it("should run first cycles and have correct invoice / balance", async () => {
// // Do it again
// let { advancedTo: advancedTo1, balance: balance1 } =
// await advanceAPThroughBalances({
// stripeSub,
// stripeCli,
// testClockId,
// customerId,
// billingUnits,
// });
// advancedTo = advancedTo1;
// balance = balance1;
// });
// it("should run second cycle and have correct invoice / balance", async () => {
// console.log(` Advanced to ${format(new Date(advancedTo), "yyyy-MM-dd")}`);
// let newStripeSub = await stripeCli.subscriptions.retrieve(subId);
// await advanceAPThroughBalances({
// stripeSub: newStripeSub,
// stripeCli,
// testClockId,
// customerId,
// billingUnits,
// startingFrom: advancedTo,
// startingBalance: balance,
// });
// });
// after(async function () {
// await this.sb
// .from("organizations")
// .update({
// config: {
// ...this.org.config,
// bill_upgrade_immediately: true,
// },
// })
// .eq("id", this.org.id);
// void CacheManager.invalidate({
// action: CacheType.SecretKey,
// value: hashApiKey(process.env.UNIT_TEST_AUTUMN_SECRET_KEY!),
// });
// });
// // TODO: Test reset at for in arrear prorated with Ent Interval = Lifetime
// // TODO: Test in arrear prorated for entitlements with billing units > 1
// });

View File

@@ -1,276 +0,0 @@
import { expect } from "chai";
import chalk from "chalk";
import { addDays, addMonths, format } from "date-fns";
import { Decimal } from "decimal.js";
import type Stripe from "stripe";
import { AutumnCli } from "@tests/cli/AutumnCli.js";
import { advanceProducts, features } from "@tests/global.js";
import { compareMainProduct } from "@tests/utils/compare.js";
import { timeout } from "@tests/utils/genUtils.js";
import { checkSubscriptionContainsProducts } from "@tests/utils/scheduleCheckUtils.js";
import { advanceTestClock } from "@tests/utils/stripeUtils.js";
import { initCustomerWithTestClock } from "@tests/utils/testInitUtils.js";
import { createStripeCli } from "@/external/connect/createStripeCli.js";
const advanceAPThroughBalances = async ({
stripeSub,
stripeCli,
testClockId,
customerId,
billingUnits,
startingFrom,
startingBalance,
}: {
stripeSub: Stripe.Subscription;
stripeCli: Stripe;
testClockId: string;
customerId: string;
billingUnits: number;
startingFrom?: number;
startingBalance?: number;
}) => {
// 1. Get total period
const totalPeriod =
(stripeSub.current_period_end - stripeSub.current_period_start) * 1000;
// 2. Get allowance
const allowance =
advanceProducts.proratedArrearSeats.entitlements.seats.allowance!;
// 3. Get starting balance
let balance = startingBalance || allowance;
// 4. Get price per seat
const pricePerSeat =
advanceProducts.proratedArrearSeats.prices[1].config.usage_tiers[0].amount;
const skipDays = 2;
// 5. Get accrued price
let accruedPrice = 0;
if (startingBalance && startingBalance < 0) {
const proratedPrice =
(-startingBalance *
pricePerSeat *
(startingFrom! - stripeSub.current_period_start * 1000)) /
totalPeriod;
const previouslyPaid = pricePerSeat * -startingBalance;
const priceToPay = proratedPrice - previouslyPaid;
accruedPrice = priceToPay;
// accruedPrice = Math.max(accruedPrice, 0);
console.log(" 🔍 Starting balance: ", startingBalance);
console.log(" 🔍 Starting price: ", accruedPrice);
}
let curTime = startingFrom || stripeSub.current_period_start * 1000;
const numberOfEvents = 2;
console.group();
console.group();
for (let i = 0; i < numberOfEvents; i++) {
const sign = balance > 0 ? 1 : Math.random() > 0.7 ? 1 : -1;
const currentUsage = allowance - balance;
const nextBoundary =
Math.ceil((currentUsage + 1) / billingUnits) * billingUnits;
const prevBoundary = nextBoundary - billingUnits;
let valueNeeded = 0;
if (sign > 0) {
// Add random amount to push above next boundary
const valueToGetToNegative = balance + 1;
valueNeeded =
Math.floor(Math.random() * 10) + (nextBoundary - currentUsage + 1);
valueNeeded = Math.max(valueNeeded, valueToGetToNegative);
} else {
valueNeeded = -(
Math.floor(Math.random() * 10) +
(currentUsage - prevBoundary + 1)
);
}
const newBalance = balance - valueNeeded;
const totalUsage = allowance - newBalance;
await AutumnCli.usage({
customerId,
featureId: features.seats.id,
value: totalUsage,
});
await timeout(2000);
const prevBalance = balance;
balance = newBalance;
// Calculate prorated price only when crossing boundary
const newPrice = Math.max(0, -balance * pricePerSeat);
const prevCurTime = curTime;
curTime = addDays(curTime, 2).getTime();
if (i === numberOfEvents - 1) {
curTime = stripeSub.current_period_end * 1000;
}
const proratedPrice = new Decimal(newPrice)
.mul(curTime - prevCurTime)
.div(totalPeriod);
accruedPrice = new Decimal(accruedPrice)
.plus(proratedPrice)
.toDecimalPlaces(2)
.toNumber();
console.log(`Event ${i + 1}:`);
console.log(` - Value added: ${valueNeeded}`);
console.log(` - Balance: ${prevBalance} -> ${balance}`);
console.log(` - Prorated price: ${proratedPrice.toFixed(2)}`);
console.log(` - Accrued price: ${accruedPrice.toFixed(2)}`);
await advanceTestClock({
stripeCli,
testClockId,
numberOfDays: 2,
startingFrom: new Date(prevCurTime),
});
}
console.groupEnd();
console.groupEnd();
// Advance test clock to end of period
const advanceTo = addDays(addMonths(new Date(), 1), 2);
const advanceToStart = startingFrom ? new Date(startingFrom) : new Date();
await advanceTestClock({
stripeCli,
testClockId,
// numberOfHours: hoursToFinalizeInvoice,
numberOfDays: 2,
startingFrom: addMonths(advanceToStart, 1),
});
// Check invoice amount
const res = await AutumnCli.getCustomer(customerId);
const invoice = res.invoices[0];
const basePrice = advanceProducts.proratedArrearSeats.prices[0].config.amount;
const nextMonthUsagePrice = Math.max(-balance * pricePerSeat, 0);
const expectedInvoiceTotal = Number(
(accruedPrice + basePrice + nextMonthUsagePrice).toFixed(2),
);
console.log(
`Invoice total = ${accruedPrice} (Accrued) + ${nextMonthUsagePrice} (Next month usage) + ${basePrice} (Base) = ${expectedInvoiceTotal}`,
);
expect(expectedInvoiceTotal).to.lte(
new Decimal(invoice.total).plus(0.01).toNumber(),
);
expect(expectedInvoiceTotal).to.gte(
new Decimal(invoice.total).minus(0.01).toNumber(),
);
return {
balance,
advancedTo: advanceTo.getTime(),
};
};
describe(`${chalk.yellowBright(
"arrear_prorated3: Testing through /usage",
)}`, () => {
const customerId = "arrear_prorated3";
let testClockId = "";
let stripeCli: Stripe;
let subId = "";
let stripeSub: Stripe.Subscription;
const billingUnits =
advanceProducts.proratedArrearSeats.prices[1].config.billing_units || 1;
before(async function () {
const { testClockId: createdTestClockId } = await initCustomerWithTestClock(
{
customerId,
org: this.org,
env: this.env,
db: this.db,
},
);
stripeCli = createStripeCli({
org: this.org,
env: this.env,
});
testClockId = createdTestClockId;
});
it("arrear_prorated3: should attach in arrear prorated seats", async () => {
await AutumnCli.attach({
customerId,
productId: advanceProducts.proratedArrearSeats.id,
});
});
it("arrear_prorated3: should have correct product", async function () {
const res = await AutumnCli.getCustomer(customerId);
compareMainProduct({
sent: advanceProducts.proratedArrearSeats,
cusRes: res,
});
// 2. Get subscription period start and period end
subId = res.products[0].subscription_ids[0];
stripeSub = await stripeCli.subscriptions.retrieve(subId);
await checkSubscriptionContainsProducts({
db: this.db,
org: this.org,
env: this.env,
subscriptionId: subId,
productIds: [advanceProducts.proratedArrearSeats.id],
});
});
let advancedTo: number;
let balance: number;
it("arrear_prorated3: should run first cycles and have correct invoice / balance", async () => {
// Do it again
const { advancedTo: advancedTo1, balance: balance1 } =
await advanceAPThroughBalances({
stripeSub,
stripeCli,
testClockId,
customerId,
billingUnits,
});
advancedTo = advancedTo1;
balance = balance1;
});
it("arrear_prorated3: should run second cycle and have correct invoice / balance", async () => {
if (advancedTo) {
console.log(
` Advanced to ${format(new Date(advancedTo), "yyyy-MM-dd")}`,
);
}
const newStripeSub = await stripeCli.subscriptions.retrieve(subId);
await advanceAPThroughBalances({
stripeSub: newStripeSub,
stripeCli,
testClockId,
customerId,
billingUnits,
startingFrom: advancedTo,
startingBalance: balance,
});
});
});

View File

@@ -1,84 +0,0 @@
import { beforeAll, describe, expect, test } from "bun:test";
import chalk from "chalk";
import { Decimal } from "decimal.js";
import { AutumnCli } from "@tests/cli/AutumnCli.js";
import { features, oneTimeProducts } from "@tests/global.js";
import { compareMainProduct } from "@tests/utils/compare.js";
import {
getFixedPriceAmount,
getUsagePriceTiers,
timeout,
} from "@tests/utils/genUtils.js";
import { completeCheckoutForm } from "@tests/utils/stripeUtils.js";
import ctx from "@tests/utils/testInitUtils/createTestContext.js";
import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js";
const testCase = "basic10";
describe(`${chalk.yellowBright("basic10: Multi attach, all one off")}`, () => {
const customerId = testCase;
const quantity = 1000;
const options = [
{
feature_id: features.metered2.id,
quantity,
},
];
beforeAll(async () => {
await initCustomerV3({
ctx,
customerId,
withTestClock: true,
});
});
test("should attach monthly with one time", async () => {
const res = await AutumnCli.attach({
customerId,
productIds: [
oneTimeProducts.oneTimeMetered1.id,
oneTimeProducts.oneTimeMetered2.id,
],
options,
});
await completeCheckoutForm(res.checkout_url);
await timeout(20000);
});
test("should have correct main product and entitlements", async () => {
const cusRes = await AutumnCli.getCustomer(customerId);
compareMainProduct({
sent: oneTimeProducts.oneTimeMetered1,
cusRes,
});
compareMainProduct({
sent: oneTimeProducts.oneTimeMetered2,
cusRes,
optionsList: options,
});
const invoices = cusRes.invoices;
const metered1Amount = getFixedPriceAmount(oneTimeProducts.oneTimeMetered1);
const metered2Tiers = getUsagePriceTiers({
product: oneTimeProducts.oneTimeMetered2,
featureId: features.metered2.id,
});
const metered2Amount = metered2Tiers[0].amount;
const numBillingUnits = new Decimal(options[0].quantity).div(
oneTimeProducts.oneTimeMetered2.prices[0].config.billing_units,
);
const expectedTotal = new Decimal(metered2Amount)
.mul(numBillingUnits)
.add(metered1Amount)
.toNumber();
expect(invoices[0].total).toBe(expectedTotal);
});
});

View File

@@ -1,162 +0,0 @@
// import { beforeAll, describe, expect, test } from "bun:test";
// import { type AppEnv, LegacyVersion, type Organization } from "@autumn/shared";
// import chalk from "chalk";
// import type { Stripe } from "stripe";
// import { TestFeature } from "@tests/setup/v2Features.js";
// import ctx from "@tests/utils/testInitUtils/createTestContext.js";
// import type { DrizzleCli } from "@/db/initDrizzle.js";
// import { AutumnInt } from "@/external/autumn/autumnCli.js";
// import { constructArrearItem } 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 { getExpectedInvoiceTotal } from "../../utils/expectUtils/expectInvoiceUtils.js";
// import { timeout } from "../../utils/genUtils.js";
// import { advanceToNextInvoice } from "../../utils/testAttachUtils/testAttachUtils.js";
// import { getBasePrice } from "../../utils/testProductUtils/testProductUtils.js";
// import { expectSubToBeCorrect } from "../mergeUtils/expectSubCorrect.js";
// // UNCOMMENT FROM HERE
// const premium = constructProduct({
// id: "premium",
// items: [constructArrearItem({ featureId: TestFeature.Words })],
// type: "premium",
// });
// const pro = constructProduct({
// id: "pro",
// items: [constructArrearItem({ featureId: TestFeature.Words })],
// type: "pro",
// });
// const testCase = "mergedAdd2";
// describe(`${chalk.yellowBright(`${testCase}: Testing merged subs, downgrade`)}`, () => {
// const customerId = testCase;
// const autumn: AutumnInt = new AutumnInt({ version: LegacyVersion.v1_4 });
// let stripeCli: Stripe;
// let testClockId: string;
// let curUnix: number;
// let db: DrizzleCli;
// let org: Organization;
// let env: AppEnv;
// beforeAll(async () => {
// await initProductsV0({
// ctx,
// products: [premium, pro],
// prefix: testCase,
// customerId,
// });
// const res = await initCustomerV3({
// ctx,
// customerId,
// customerData: {},
// attachPm: "success",
// withTestClock: true,
// });
// stripeCli = ctx.stripeCli;
// db = ctx.db;
// org = ctx.org;
// env = ctx.env;
// testClockId = res.testClockId!;
// });
// const entities = [
// {
// id: "1",
// name: "Entity 1",
// feature_id: TestFeature.Users,
// },
// {
// id: "2",
// name: "Entity 2",
// feature_id: TestFeature.Users,
// },
// ];
// test("should attach premium, product", async () => {
// await autumn.entities.create(customerId, entities);
// await autumn.attach({
// customer_id: customerId,
// product_id: premium.id,
// entity_id: "1",
// });
// await autumn.attach({
// customer_id: customerId,
// product_id: premium.id,
// entity_id: "2",
// });
// await autumn.attach({
// customer_id: customerId,
// product_id: pro.id,
// entity_id: "2",
// });
// const customer = await autumn.customers.get(customerId);
// const invoice = customer.invoices;
// await expectSubToBeCorrect({
// db,
// customerId,
// org,
// env,
// });
// });
// test("should track usage and have correct invoice end of month", async () => {
// const value1 = 110000;
// const value2 = 310000;
// const values = [value1, value2];
// await autumn.track({
// customer_id: customerId,
// feature_id: TestFeature.Words,
// value: value1,
// entity_id: "1",
// });
// await autumn.track({
// customer_id: customerId,
// feature_id: TestFeature.Words,
// value: value2,
// entity_id: "2",
// });
// await timeout(3000);
// await advanceToNextInvoice({
// stripeCli,
// testClockId,
// });
// let total = 0;
// for (let i = 0; i < entities.length; i++) {
// const expectedTotal = await getExpectedInvoiceTotal({
// customerId,
// productId: pro.id,
// usage: [{ featureId: TestFeature.Words, value: values[i] }],
// onlyIncludeUsage: true,
// stripeCli,
// db,
// org,
// env,
// });
// total += expectedTotal;
// }
// const basePrice = getBasePrice({ product: pro });
// const customer = await autumn.customers.get(customerId);
// const invoice = customer.invoices;
// expect(invoice[0].total).toBe(basePrice * 2 + total);
// });
// });
// // const expectedTotal = await getAttachPreviewTotal({
// // customerId,
// // productId: pro.id,
// // entityId: "2",
// // });

View File

@@ -1,50 +0,0 @@
import dotenv from "dotenv";
dotenv.config();
import { AppEnv } from "@autumn/shared";
import { Autumn as AutumnJS } from "autumn-js";
import { after } from "mocha";
import { initDrizzle } from "@/db/initDrizzle.js";
import { AutumnInt } from "@/external/autumn/autumnCli.js";
import { createStripeCli } from "@/external/connect/createStripeCli.js";
import { OrgService } from "@/internal/orgs/OrgService.js";
const ORG_SLUG = process.env.TESTS_ORG!;
const DEFAULT_ENV = AppEnv.Sandbox;
export const setupBefore = async (instance: any) => {
try {
const { db, client } = initDrizzle();
const org = await OrgService.getBySlug({ db, slug: ORG_SLUG });
if (!org) {
throw new Error("Org not found");
}
const env = DEFAULT_ENV;
const autumnSecretKey = process.env.UNIT_TEST_AUTUMN_SECRET_KEY!;
const autumn = new AutumnInt({ apiKey: autumnSecretKey });
const autumnJs = new AutumnJS({
secretKey: autumnSecretKey,
url: "http://localhost:8080/v1",
});
const stripeCli = createStripeCli({ org, env });
instance.org = org;
instance.env = env;
instance.autumn = autumn;
instance.stripeCli = stripeCli;
instance.autumnJs = autumnJs;
instance.db = db;
instance.client = client;
} catch (error) {
console.log("Error setting up before", error);
throw error;
}
// Return a cleanup function
after(async () => {
await instance.client?.end();
});
};

View File

@@ -1,25 +1 @@
update boolean features
- add / remove boolean features
- no invoice created, subscription stays the same
### update included usage
Notes:
- included usage changes
- usage stays the same
Cases:
- update included usage on multiple features
- update included usage on paid features
-> prepaid feature price: nothing changes
-> allocated feature price: price in Stripe changes, proration created for difference in overage
-> consumable feature price (unsure of effects yet, but handle this case)
-> updating multiple paid features at once
update price
update-edge-cases
- update max purchase, etc.
Unit Tests:
custom-plan

View File

@@ -0,0 +1,74 @@
import { test } from "bun:test";
import { TestFeature } from "@tests/setup/v2Features.js";
import { items } from "@tests/utils/fixtures/items.js";
import { products } from "@tests/utils/fixtures/products.js";
import { initTestScenario } from "@tests/utils/testInitUtils/initTestScenario.js";
import chalk from "chalk";
test.concurrent(`${chalk.yellowBright("custom-plan: update free plan")}`, async () => {
const messagesItem = items.monthlyMessages({ includedUsage: 500 });
const dashboardItem = items.dashboard();
const wordsItem = items.monthlyWords({ includedUsage: 100 });
const free = products.base({ items: [messagesItem] });
const { customerId, autumnV1 } = await initTestScenario({
customerId: "update-sub-free1",
products: [free],
attachProducts: [free.id],
customerOptions: {
withTestClock: true,
attachPm: "success",
},
});
const messagesUsage = 100;
await autumnV1.track(
{
customer_id: customerId,
feature_id: TestFeature.Messages,
value: messagesUsage,
},
{ timeout: 2000 },
);
await autumnV1.subscriptions.update({
customer_id: customerId,
product_id: free.id,
items: [messagesItem, dashboardItem, wordsItem],
});
});
test.concurrent(`${chalk.yellowBright("custom-plan: something else")}`, async () => {
const messagesItem = items.monthlyMessages({ includedUsage: 500 });
const dashboardItem = items.dashboard();
const wordsItem = items.monthlyWords({ includedUsage: 100 });
const free = products.base({ items: [messagesItem] });
const { customerId, autumnV1 } = await initTestScenario({
customerId: "custom-plan-something-else",
products: [free],
attachProducts: [free.id],
customerOptions: {
withTestClock: true,
attachPm: "success",
},
});
const messagesUsage = 100;
await autumnV1.track(
{
customer_id: customerId,
feature_id: TestFeature.Messages,
value: messagesUsage,
},
{ timeout: 2000 },
);
await autumnV1.subscriptions.update({
customer_id: customerId,
product_id: free.id,
items: [messagesItem, dashboardItem, wordsItem],
});
});

View File

@@ -0,0 +1,141 @@
import { beforeAll, describe, 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 { 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";
describe(`${chalk.yellowBright("custom-plan: update free plan")}`, () => {
const testCase = "custom-plan-update-free-plan";
const messagesItem = constructFeatureItem({
featureId: TestFeature.Messages,
includedUsage: 500,
});
const dashboardItem = constructFeatureItem({
featureId: TestFeature.Dashboard,
isBoolean: true,
});
const wordsItem = constructFeatureItem({
featureId: TestFeature.Words,
includedUsage: 100,
});
const free = constructProduct({
type: "free",
items: [messagesItem],
});
const customerId = testCase;
const autumnV1: AutumnInt = new AutumnInt({ version: ApiVersion.V1_2 });
beforeAll(async () => {
console.log("FILE 2 - describe 1 started at:", new Date().toISOString());
await initCustomerV3({
ctx,
customerId,
withTestClock: true,
attachPm: "success",
});
await initProductsV0({
ctx,
products: [free],
prefix: testCase,
});
await autumnV1.attach({
customer_id: customerId,
product_id: free.id,
});
});
const messagesUsage = 100;
test("should add boolean and metered feature to free plan", async () => {
await autumnV1.track(
{
customer_id: customerId,
feature_id: TestFeature.Messages,
value: messagesUsage,
},
{ timeout: 2000 },
);
await autumnV1.subscriptions.update({
customer_id: customerId,
product_id: free.id,
items: [messagesItem, dashboardItem, wordsItem],
});
});
});
describe(`${chalk.yellowBright("custom-plan: something else")}`, () => {
const testCase = "custom-plan-update-free-plan";
const messagesItem = constructFeatureItem({
featureId: TestFeature.Messages,
includedUsage: 500,
});
const dashboardItem = constructFeatureItem({
featureId: TestFeature.Dashboard,
isBoolean: true,
});
const wordsItem = constructFeatureItem({
featureId: TestFeature.Words,
includedUsage: 100,
});
const free = constructProduct({
type: "free",
items: [messagesItem],
});
const customerId = testCase;
const autumnV1: AutumnInt = new AutumnInt({ version: ApiVersion.V1_2 });
beforeAll(async () => {
await initCustomerV3({
ctx,
customerId,
withTestClock: true,
attachPm: "success",
});
await initProductsV0({
ctx,
products: [free],
prefix: testCase,
});
await autumnV1.attach({
customer_id: customerId,
product_id: free.id,
});
});
const messagesUsage = 100;
test("should add boolean and metered feature to free plan", async () => {
await autumnV1.track(
{
customer_id: customerId,
feature_id: TestFeature.Messages,
value: messagesUsage,
},
{ timeout: 2000 },
);
await autumnV1.subscriptions.update({
customer_id: customerId,
product_id: free.id,
items: [messagesItem, dashboardItem, wordsItem],
});
});
});

View File

@@ -1,186 +0,0 @@
import { Hyperbrowser } from "@hyperbrowser/sdk";
import * as fs from "fs";
import * as path from "path";
const client = new Hyperbrowser({
apiKey: process.env.HYPERBROWSER_API_KEY,
});
let sessionPromise: Promise<any> | null = null;
let currentSession: any | null = null;
// File paths for coordination across processes
const TEMP_DIR = path.join(process.cwd(), ".tmp");
const SESSION_FILE = path.join(TEMP_DIR, "browser-session.json");
const LOCK_FILE = path.join(TEMP_DIR, "browser-session.lock");
// Ensure temp directory exists
if (!fs.existsSync(TEMP_DIR)) {
fs.mkdirSync(TEMP_DIR, { recursive: true });
}
export const getBrowserSession = async () => {
// If we already have a session in memory, return it
if (currentSession) {
return currentSession;
}
// Check if another process has already created a session
const existingSession = loadSessionFromFile();
if (existingSession) {
currentSession = existingSession;
console.log("Using existing browser session:", existingSession.id);
return existingSession;
}
// If session creation is already in progress, wait for it
if (sessionPromise) {
return await sessionPromise;
}
// Try to acquire lock and create session
sessionPromise = createSessionWithLock();
try {
currentSession = await sessionPromise;
return currentSession;
} catch (error) {
// Reset promise on failure so we can retry
sessionPromise = null;
throw error;
}
};
const createSessionWithLock = async (): Promise<any> => {
// Try to acquire lock
const lockAcquired = await acquireLock();
if (!lockAcquired) {
// Another process is creating the session, wait for it
console.log("Waiting for another process to create browser session...");
return await waitForSession();
}
try {
// Double-check if session was created while we were acquiring lock
const existingSession = loadSessionFromFile();
if (existingSession) {
console.log(
"Session was created by another process:",
existingSession.id,
);
return existingSession;
}
// Create new session
console.log("Creating new browser session...");
const session = await client.sessions.create();
console.log("Browser session created successfully:", session.id);
// Save session to file for other processes
saveSessionToFile(session);
return session;
} finally {
// Always release lock
releaseLock();
}
};
const acquireLock = async (): Promise<boolean> => {
try {
// Try to create lock file exclusively
fs.writeFileSync(LOCK_FILE, process.pid.toString(), { flag: "wx" });
return true;
} catch (error) {
// Lock file exists, another process has the lock
return false;
}
};
const releaseLock = () => {
try {
if (fs.existsSync(LOCK_FILE)) {
fs.unlinkSync(LOCK_FILE);
}
} catch (error) {
// Ignore errors when releasing lock
console.warn("Warning: Could not release lock file:", error);
}
};
const saveSessionToFile = (session: any) => {
try {
fs.writeFileSync(SESSION_FILE, JSON.stringify(session));
} catch (error) {
console.error("Failed to save session to file:", error);
}
};
const loadSessionFromFile = (): any | null => {
try {
if (fs.existsSync(SESSION_FILE)) {
const sessionData = fs.readFileSync(SESSION_FILE, "utf-8");
return JSON.parse(sessionData);
}
} catch (error) {
// If file is corrupted or doesn't exist, ignore
console.warn("Could not load session from file:", error);
}
return null;
};
const waitForSession = async (): Promise<any> => {
// Poll for session file to appear
for (let i = 0; i < 30; i++) {
// Wait up to 30 seconds
await new Promise((resolve) => setTimeout(resolve, 1000)); // Wait 1 second
const session = loadSessionFromFile();
if (session) {
console.log("Found session created by another process:", session.id);
return session;
}
}
throw new Error(
"Timeout waiting for browser session to be created by another process",
);
};
// Reset session state (call this between test suites if needed)
export const resetBrowserSession = () => {
console.log("Resetting browser session state...");
currentSession = null;
sessionPromise = null;
// Clean up files
try {
if (fs.existsSync(SESSION_FILE)) {
fs.unlinkSync(SESSION_FILE);
}
if (fs.existsSync(LOCK_FILE)) {
fs.unlinkSync(LOCK_FILE);
}
} catch (error) {
console.warn("Warning: Could not clean up session files:", error);
}
};
// Cleanup on process exit
process.on("exit", () => {
if (currentSession) {
console.log("Process exiting, cleaning up browser session files...");
releaseLock();
}
});
process.on("SIGINT", () => {
resetBrowserSession();
process.exit(0);
});
process.on("SIGTERM", () => {
resetBrowserSession();
process.exit(0);
});

View File

@@ -6,8 +6,8 @@ dotenv.config();
import { AppEnv } from "@autumn/shared";
import chalk from "chalk";
import { clearOrg } from "./utils/setupUtils/clearOrg.js";
import { setupOrg } from "./utils/setupUtils/setupOrg.js";
import { clearOrg } from "./utils/setup/clearOrg.js";
import { setupOrg } from "./utils/setup/setupOrg.js";
async function main() {
console.log(chalk.blue("\n🧹 Clearing Master Org...\n"));

View File

@@ -1,11 +1,11 @@
import { beforeAll, describe, expect, test } from "bun:test";
import { LegacyVersion } from "@autumn/shared";
import chalk from "chalk";
import { addWeeks, addYears } from "date-fns";
import { TestFeature } from "@tests/setup/v2Features.js";
import { attachAndExpectCorrect } from "@tests/utils/expectUtils/expectAttach.js";
import { advanceTestClock } from "@tests/utils/stripeUtils.js";
import ctx from "@tests/utils/testInitUtils/createTestContext.js";
import chalk from "chalk";
import { addWeeks, addYears } from "date-fns";
import { AutumnInt } from "@/external/autumn/autumnCli.js";
import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js";
import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js";
@@ -68,7 +68,6 @@ describe(`${chalk.yellowBright("interval1: Should upgrade from pro to pro annual
advanceTo: addWeeks(new Date(), 2).getTime(),
});
});
return;
test("should upgrade to pro annual and have correct next cycle at", async () => {
const checkoutRes = await autumn.checkout({
@@ -96,7 +95,7 @@ describe(`${chalk.yellowBright("interval1: Should upgrade from pro to pro annual
const subItem = sub!.items.data[0];
expect(subItem.current_period_end * 1000).toBeCloseTo(
checkoutRes.next_cycle?.starts_at!,
checkoutRes.next_cycle?.starts_at ?? 0,
-Math.log10(toMilliseconds.days(1)), // +- 1 day
);
});

View File

@@ -1,9 +1,32 @@
import { execSync } from "node:child_process";
import { loadLocalEnv } from "../src/utils/envUtils.js";
// ... rest of your existing setup code
const loadInfisicalSecrets = async () => {
// Load infisical secrets into process.env
try {
const secrets = execSync("infisical export --env=dev --format=dotenv", {
encoding: "utf-8",
});
for (const line of secrets.split("\n")) {
const match = line.match(/^([^=]+)=(.*)$/);
if (match) {
process.env[match[1]] = match[2].replace(/^["']|["']$/g, "");
}
}
} catch (e) {
console.warn("Failed to load infisical secrets:", e);
}
};
/**
* Bun test preload script for integration tests.
* Loads environment variables before any test file runs.
*/
import { loadLocalEnv } from "../src/utils/envUtils.js";
console.log("--- Setup integration tests ---");
await loadLocalEnv();
await loadInfisicalSecrets();
loadLocalEnv();
console.log("--- Setup integration tests complete ---");

View File

@@ -3,7 +3,7 @@ import { loadLocalEnv } from "../src/utils/envUtils";
loadLocalEnv();
import { AppEnv } from "@autumn/shared";
import { setupOrg } from "@tests/utils/setupUtils/setupOrg.js";
import { setupOrg } from "@tests/utils/setup/setupOrg.js";
import { initDrizzle } from "@/db/initDrizzle.js";
import { OrgService } from "@/internal/orgs/OrgService.js";

View File

@@ -1,81 +0,0 @@
// import { AutumnInt } from "@/external/autumn/autumnCli.js";
// import { initCustomer } from "@/utils/scriptUtils/initCustomer.js";
// import { LegacyVersion, AppEnv, Organization } from "@autumn/shared";
// import chalk from "chalk";
// import Stripe from "stripe";
// import { DrizzleCli } from "@/db/initDrizzle.js";
// import { setupBefore } from "@tests/before.js";
// import { createProducts } from "@tests/utils/productUtils.js";
// import { addPrefixToProducts } from "../utils.js";
// import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js";
// import {
// constructArrearProratedItem,
// constructFeatureItem,
// } from "@/utils/scriptUtils/constructItem.js";
// import { TestFeature } from "@tests/setup/v2Features.js";
// export let pro = constructProduct({
// items: [
// constructArrearProratedItem({
// featureId: TestFeature.Users,
// pricePerUnit: 50,
// }),
// constructFeatureItem({
// featureId: TestFeature.Messages,
// includedUsage: 100,
// entityFeatureId: TestFeature.Users,
// }),
// ],
// type: "pro",
// });
// const testCase = "entity1";
// describe(`${chalk.yellowBright(`${testCase}: Testing entities`)}`, () => {
// let customerId = testCase;
// let autumn: AutumnInt = new AutumnInt({ version: LegacyVersion.v1_4 });
// let testClockId: string;
// let db: DrizzleCli, org: Organization, env: AppEnv;
// let stripeCli: Stripe;
// let curUnix = new Date().getTime();
// before(async function () {
// await setupBefore(this);
// const { autumnJs } = this;
// db = this.db;
// org = this.org;
// env = this.env;
// stripeCli = this.stripeCli;
// addPrefixToProducts({
// products: [pro],
// prefix: testCase,
// });
// await createProducts({
// autumn,
// products: [pro],
// customerId,
// db,
// orgId: org.id,
// env,
// });
// const { testClockId: testClockId1 } = await initCustomer({
// autumn: autumnJs,
// customerId,
// db,
// org,
// env,
// attachPm: "success",
// });
// testClockId = testClockId1!;
// });
// it("should create entity, then attach pro product", async function () {
// console.log("Here!");
// });
// });

View File

@@ -0,0 +1,167 @@
import { TestFeature } from "@tests/setup/v2Features";
import {
constructArrearItem,
constructArrearProratedItem,
constructFeatureItem,
constructPrepaidItem,
} from "@/utils/scriptUtils/constructItem";
// ═══════════════════════════════════════════════════════════════════
// BOOLEAN FEATURES
// ═══════════════════════════════════════════════════════════════════
/**
* Boolean feature - on/off access (no usage tracking)
* @returns Dashboard feature item
*/
const dashboard = () =>
constructFeatureItem({
featureId: TestFeature.Dashboard,
isBoolean: true,
});
// ═══════════════════════════════════════════════════════════════════
// FREE METERED (included usage, resets monthly)
// ═══════════════════════════════════════════════════════════════════
/**
* Monthly messages - resets each billing cycle
* @param includedUsage - Free usage allowance (default: 100)
*/
const monthlyMessages = ({
includedUsage = 100,
}: {
includedUsage?: number;
} = {}) =>
constructFeatureItem({ featureId: TestFeature.Messages, includedUsage });
/**
* Monthly words - resets each billing cycle
* @param includedUsage - Free usage allowance (default: 100)
*/
const monthlyWords = ({
includedUsage = 100,
}: {
includedUsage?: number;
} = {}) =>
constructFeatureItem({ featureId: TestFeature.Words, includedUsage });
/**
* Monthly credits - resets each billing cycle
* @param includedUsage - Free usage allowance (default: 100)
*/
const monthlyCredits = ({
includedUsage = 100,
}: {
includedUsage?: number;
} = {}) =>
constructFeatureItem({ featureId: TestFeature.Credits, includedUsage });
/**
* Unlimited messages - no usage cap
* @returns Unlimited messages feature item
*/
const unlimitedMessages = () =>
constructFeatureItem({
featureId: TestFeature.Messages,
unlimited: true,
});
/**
* Lifetime messages - never resets (interval: null)
* @param includedUsage - One-time usage allowance (default: 100)
*/
const lifetimeMessages = ({
includedUsage = 100,
}: {
includedUsage?: number;
} = {}) =>
constructFeatureItem({
featureId: TestFeature.Messages,
includedUsage,
interval: null,
});
// ═══════════════════════════════════════════════════════════════════
// PREPAID (purchase units upfront)
// ═══════════════════════════════════════════════════════════════════
/**
* Prepaid messages - purchase units upfront ($10/unit)
* @param includedUsage - Free units before purchase required (default: 0)
*/
const prepaidMessages = ({
includedUsage = 0,
}: {
includedUsage?: number;
} = {}) =>
constructPrepaidItem({
featureId: TestFeature.Messages,
price: 10,
billingUnits: 1,
includedUsage,
});
// ═══════════════════════════════════════════════════════════════════
// CONSUMABLE / PAY-PER-USE (overage pricing)
// ═══════════════════════════════════════════════════════════════════
/**
* Consumable messages - pay-per-use overage ($0.10/unit)
* @param includedUsage - Free units before overage kicks in (default: 0)
*/
const consumableMessages = ({
includedUsage = 0,
}: {
includedUsage?: number;
} = {}) =>
constructArrearItem({
featureId: TestFeature.Messages,
includedUsage,
price: 0.1,
billingUnits: 1,
});
// ═══════════════════════════════════════════════════════════════════
// ALLOCATED / SEATS (prorated billing)
// ═══════════════════════════════════════════════════════════════════
/**
* Allocated users/seats - prorated billing on change ($10/seat)
* @param includedUsage - Free seats included (default: 0)
*/
const allocatedUsers = ({
includedUsage = 0,
}: {
includedUsage?: number;
} = {}) =>
constructArrearProratedItem({
featureId: TestFeature.Users,
pricePerUnit: 10,
includedUsage,
});
// ═══════════════════════════════════════════════════════════════════
// EXPORT
// ═══════════════════════════════════════════════════════════════════
export const items = {
// Boolean
dashboard,
// Free metered
monthlyMessages,
monthlyWords,
monthlyCredits,
unlimitedMessages,
lifetimeMessages,
// Prepaid
prepaidMessages,
// Consumable
consumableMessages,
// Allocated
allocatedUsers,
} as const;

View File

@@ -0,0 +1,25 @@
import type { ProductItem, ProductV2 } from "@autumn/shared";
import { constructRawProduct } from "@/utils/scriptUtils/createTestProducts.js";
/**
* Base product - no base price, customizable defaults
* @param items - Product items (features)
* @param id - Product ID (default: "base")
* @param isDefault - Whether this is a default product (default: false)
*/
const base = ({
items,
id = "base",
isDefault = false,
}: {
items: ProductItem[];
id?: string;
isDefault?: boolean;
}): ProductV2 => ({
...constructRawProduct({ id, items }),
is_default: isDefault,
});
export const products = {
base,
} as const;

View File

@@ -1,8 +0,0 @@
export const isValidNumber = (value: any) => {
const number = parseFloat(value);
return !Number.isNaN(number) && Number.isFinite(number);
};
export const numberWithCommas = (x: number) => {
return x.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",");
};

View File

@@ -1,56 +1,9 @@
import type { AppEnv, Organization } from "@autumn/shared";
import { expect } from "chai";
import type Stripe from "stripe";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import { createStripeCli } from "@/external/connect/createStripeCli.js";
import { ProductService } from "@/internal/products/ProductService.js";
export const checkScheduleContainsProducts = async ({
db,
org,
env,
scheduleId,
schedule,
productIds,
}: {
db: DrizzleCli;
org: Organization;
env: AppEnv;
scheduleId?: string;
schedule?: Stripe.SubscriptionSchedule;
productIds: string[];
}) => {
if (!schedule && !scheduleId) {
throw new Error("schedule or scheduleId must be provided");
}
if (scheduleId) {
const stripeCli = createStripeCli({ org: org, env: env });
schedule = await stripeCli.subscriptionSchedules.retrieve(scheduleId);
}
let priceCount = 0;
for (const productId of productIds) {
const product = await ProductService.getFull({
db,
idOrInternalId: productId,
orgId: org.id,
env: env,
});
for (const price of product.prices) {
expect(
schedule!.phases[0].items.some(
(item) => item.price === price.config!.stripe_price_id,
),
).to.be.true;
priceCount++;
}
}
expect(schedule!.phases[0].items.length).to.equal(priceCount);
};
export const checkSubscriptionContainsProducts = async ({
db,
org,

View File

@@ -1,6 +1,6 @@
// Re-export functions from setupUtils
export { clearOrg } from "./setupUtils/clearOrg.js";
export { getAxiosInstance, setupOrg } from "./setupUtils/setupOrg.js";
export { clearOrg } from "./setup/clearOrg.js";
export { getAxiosInstance, setupOrg } from "./setup/setupOrg.js";
import axios from "axios";

View File

@@ -24,24 +24,6 @@ export const clearOrg = async ({
const autumn = new AutumnInt();
// if (process.env.STRIPE_TEST_KEY) {
// console.log(`Reconnecting stripe...`);
// try {
// await autumn.stripe.delete();
// } catch (_error) {}
// try {
// await autumn.stripe.connect({
// secret_key: process.env.STRIPE_TEST_KEY!,
// success_url: "https://useautumn.com",
// default_currency: "usd",
// });
// } catch (error: any) {
// console.error("Error reconnecting stripe", error.message);
// process.exit(1);
// }
// }
const { db, client } = initDrizzle();
const org = await OrgService.getBySlug({ db, slug: orgSlug });

View File

@@ -0,0 +1,88 @@
import { ApiVersion, type ProductV2 } from "@autumn/shared";
import type { CustomerData } from "autumn-js";
import { AutumnInt } from "@/external/autumn/autumnCli.js";
import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js";
import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js";
import ctx from "./createTestContext.js";
/**
* Initialize a complete test scenario with customer, products, and optional attachment.
* Combines initCustomerV3, initProductsV0, and attach into a single call.
*
* @param customerId - Unique identifier used as customerId and product prefix
* @param products - Array of products to create (use constructProduct)
* @param attachProducts - Array of product IDs to attach to customer
* @param customerOptions - Customer initialization options
*/
export const initTestScenario = async ({
customerId,
products,
attachProducts,
customerOptions = {},
}: {
customerId: string;
products: ProductV2[];
attachProducts?: string[];
customerOptions?: {
customerData?: CustomerData;
attachPm?: "success" | "fail" | "authenticate";
withDefault?: boolean;
withTestClock?: boolean;
};
}) => {
const {
customerData,
attachPm,
withDefault = false,
withTestClock = true,
} = customerOptions;
// 1. Initialize customer
const { testClockId, customer } = await initCustomerV3({
ctx,
customerId,
customerData,
attachPm,
withTestClock,
withDefault,
});
// 2. Initialize products (prefix = customerId for isolation)
await initProductsV0({
ctx,
products,
prefix: customerId,
});
// 3. Create autumn clients
const autumnV1 = new AutumnInt({
version: ApiVersion.V1_2,
secretKey: ctx.orgSecretKey,
});
const autumnV2 = new AutumnInt({
version: ApiVersion.V2_0,
secretKey: ctx.orgSecretKey,
});
// 4. Attach products if requested (prefix IDs to match mutated products)
if (attachProducts && attachProducts.length > 0) {
for (const productId of attachProducts) {
const prefixedId = `${customerId}_${productId}`;
await autumnV1.attach({
customer_id: customerId,
product_id: prefixedId,
});
}
}
return {
customerId,
products,
autumnV1,
autumnV2,
testClockId,
customer,
ctx,
};
};

View File

@@ -0,0 +1,18 @@
import { z } from "zod/v4";
export const BillingPreviewResponseSchema = z.object({
customer_id: z.string(),
line_items: z.array(
z.object({
description: z.string(),
amount: z.number(),
}),
),
total: z.number(),
currency: z.string(),
});
export type BillingPreviewResponse = z.infer<
typeof BillingPreviewResponseSchema
>;

View File

@@ -0,0 +1,16 @@
// Attach
export * from "./attach/attachBodyV1.js";
export * from "./attach/prevVersions/attachBodyV0.js";
export * from "./attach/prevVersions/attachResponseV1.js";
// Checkout
export * from "./checkout/checkoutParamsV1.js";
export * from "./checkout/prevVersions/checkoutParamsV0.js";
export * from "./checkout/prevVersions/checkoutResponseV0.js";
// Common
export * from "./common/billingPreviewResponse.js";
export * from "./updateSubscription/previewUpdateSubscriptionResponse.js";
// Update Subscription
export * from "./updateSubscription/updateSubscriptionV0Params.js";

View File

@@ -1,59 +0,0 @@
import type { z } from "zod/v4";
import { ApiVersion } from "../../../versionUtils/ApiVersion";
import {
AffectedResource,
defineVersionChange,
} from "../../../versionUtils/versionChangeUtils/VersionChange";
import { SubscriptionUpdateV1ParamsSchema } from "../subscriptionUpdateV1Params";
import { UpdateSubscriptionV0ParamsSchema } from "../UpdateSubscriptionV0Params";
/**
* V2_0_SubscriptionUpdateChange: Transforms subscription update params from V2.0 to V2.1 format
*
* Applied when: sourceVersion <= V2.0
*
* Breaking changes introduced in V2.1:
*
* 1. Renamed field: `product_id` → `plan_id`
* 2. Removed fields: `entity_id`, `customer_data`, `entity_data`, `options`, invoice settings
* 3. Added field: `plan_override` for customizations
*
* Input: UpdateSubscriptionV0Params (V2.0 format)
* Output: SubscriptionUpdateV1Params (V2.1 format)
*/
export const V2_0_SubscriptionUpdateChange = defineVersionChange({
name: "V2.1 Subscription Update Change",
newVersion: ApiVersion.V2_1,
oldVersion: ApiVersion.V2_0,
description: [
"Transforms subscription update params from V2.0 to V2.1 format",
],
affectedResources: [AffectedResource.ApiSubscriptionUpdate],
newSchema: SubscriptionUpdateV1ParamsSchema,
oldSchema: UpdateSubscriptionV0ParamsSchema,
affectsRequest: true,
affectsResponse: false,
// Request: V0 (UpdateSubscriptionV0Params) → V1 (SubscriptionUpdateV1Params)
transformRequest: ({
input,
}: {
input: z.infer<typeof UpdateSubscriptionV0ParamsSchema>;
}): z.infer<typeof SubscriptionUpdateV1ParamsSchema> => {
const planId = input.product_id;
if (!planId) {
throw new Error("product_id is required");
}
// if (input.items) {
// const planFeatures = input.items.map((item) => productV2);
// }
return {
customer_id: input.customer_id,
plan_id: planId,
};
},
});

View File

@@ -1,28 +0,0 @@
import { z } from "zod/v4";
import { PlanOverrideSchema } from "../common/planOverride";
export const SubscriptionUpdateV1ParamsSchema = z.object({
// Customer / Entity Info
customer_id: z.string(),
plan_id: z.string(),
plan_override: PlanOverrideSchema.optional(),
// customer_data: CustomerDataSchema.optional(),
// entity_data: EntityDataSchema.optional(),
// options: z.array(FeatureOptionsSchema).nullish(),
// invoice: z.boolean().optional(),
// enable_product_immediately: z.boolean().optional(),
// finalize_invoice: z.boolean().optional(),
// // Reset billing cycle anchor?
// reset_billing_cycle_anchor: z.boolean().optional(),
// new_billing_subscription: z.boolean().optional(),
// prorate_billing: z.boolean().optional(),
});
export type SubscriptionUpdateV1Params = z.infer<
typeof SubscriptionUpdateV1ParamsSchema
>;

View File

@@ -0,0 +1,9 @@
import { BillingPreviewResponseSchema } from "@api/billing/common/billingPreviewResponse";
import type { z } from "zod/v4";
export const PreviewUpdateSubscriptionResponseSchema =
BillingPreviewResponseSchema;
export type PreviewUpdateSubscriptionResponse = z.infer<
typeof PreviewUpdateSubscriptionResponseSchema
>;

View File

@@ -69,15 +69,8 @@ export * from "./balances/track/trackParams.js";
export * from "./balances/track/trackResponseV2.js";
export * from "./balances/track/trackTypes/pgDeductionUpdate.js";
export * from "./balances/usageModels.js";
export * from "./billing/attach/attachBodyV1.js";
export * from "./billing/attach/prevVersions/attachBodyV0.js";
export * from "./billing/attach/prevVersions/attachResponseV1.js";
export * from "./billing/checkout/checkoutParamsV1.js";
export * from "./billing/checkout/prevVersions/checkoutParamsV0.js";
export * from "./billing/checkout/prevVersions/checkoutParamsV0.js";
export * from "./billing/checkout/prevVersions/checkoutResponseV0.js";
export * from "./billing/subscriptionUpdate/subscriptionUpdateV1Params.js";
export * from "./billing/subscriptionUpdate/updateSubscriptionV0Params.js";
// Billing
export * from "./billing/index.js";
export * from "./common/customerData.js";
export * from "./common/entityData.js";

View File

@@ -40,7 +40,7 @@ import { useCusQuery } from "@/views/customers/customer/hooks/useCusQuery";
*
* This is an isolated test sheet for testing the subscription update flow.
* It calls:
* - POST /v1/subscriptions/preview/update - to get a billing plan preview
* - POST /v1/subscriptions/preview_update - to get a billing plan preview
* - POST /v1/subscriptions/update - to execute the update
*
* Usage: Open this sheet with an itemId (cusProduct id) and optional customizedProduct in data
@@ -857,7 +857,7 @@ function useSubscriptionUpdatePreview({
queryFn: async () => {
if (!debouncedBody) return null;
const response = await axiosInstance.post(
"/v1/subscriptions/preview/update",
"/v1/subscriptions/preview_update",
debouncedBody,
);
return response.data;