Merge branch 'staging'

This commit is contained in:
John Yeo
2025-07-23 10:24:01 +01:00
14 changed files with 387 additions and 167 deletions

View File

@@ -5,7 +5,6 @@ on:
push:
branches:
- main
- test/cicd
env:
DATABASE_URL: ${{ secrets.SUPABASE_URL }}
UNIT_TEST_AUTUMN_SECRET_KEY: ${{ secrets.AUTUMN_KEY }}
@@ -18,6 +17,7 @@ env:
STRIPE_WEBHOOK_URL: ${{ secrets.STRIPE_WEBHOOK_URL }}
BETTER_AUTH_SECRET: ${{ secrets.BETTER_AUTH_SECRET }}
HYPERBROWSER_API_KEY: ${{ secrets.HYPERBROWSER_API_KEY }}
STRIPE_TEST_KEY: ${{ secrets.STRIPE_TEST_KEY }}
jobs:
test:
@@ -28,13 +28,12 @@ jobs:
uses: actions/checkout@v4.2.2
- name: Set up bun
- uses: oven-sh/setup-bun@v2
uses: oven-sh/setup-bun@v2
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'bun'
- name: Install dependencies
# run: bun install --no-frozen-lockfile
@@ -102,8 +101,9 @@ jobs:
echo "TESTS_ORG_ID=${{ env.TESTS_ORG_ID }}" >> .env
echo "LOCALTUNNEL_RESERVED_KEY=${{ env.LOCALTUNNEL_RESERVED_KEY }}" >> .env
echo "BETTER_AUTH_SECRET=${{ env.BETTER_AUTH_SECRET }}" >> .env
echo "STRIPE_WEBHOOK_URL=${{ env.STRIPE_WEBHOOK_URL }}" >> .env
echo "HYPERBROWSER_API_KEY=${{ env.HYPERBROWSER_API_KEY }}" >> .env
echo "STRIPE_TEST_KEY=${{ env.STRIPE_TEST_KEY }}" >> .env
echo "STRIPE_WEBHOOK_URL=${{ env.STRIPE_WEBHOOK_URL }}" >> .env
cat .env
- name: Build shared
@@ -112,7 +112,7 @@ jobs:
bun run build
- name: Startup server
run: sudo docker compose -f docker/docker-compose.ci.yml up --detach
run: sudo docker compose -f docker-compose.ci.yml up --detach
- name: Wait for server to be ready
run: |
@@ -139,20 +139,23 @@ jobs:
chmod +x ./shell/g3.sh
./shell/g3.sh
- name: Run G4 tests
run: |
cd server/
chmod +x ./shell/g4.sh
./shell/g4.sh
# - name: Run G4 tests
# run: |
# cd server/
# chmod +x ./shell/g4.sh
# ./shell/g4.sh
- name: Message
run:
echo "We're all setup!"
# - name: Message
# run:
# echo "We're all setup!"
# - name: Check logs
# if: always()
# run: sudo docker compose -f docker-compose.unix.yml logs server
# run: sudo docker compose -f docker-compose.ci.yml logs server
- name: Check logs
if: always()
run: sudo docker compose -f docker-compose.ci.yml logs localtunnel
- name: Close Docker Containers
if: always()
run: sudo docker compose -f docker/docker-compose.unix.yml down
run: sudo docker compose -f docker-compose.ci.yml down

View File

@@ -18,7 +18,7 @@ services:
shared:
build:
dockerfile: ./dev.dockerfile
dockerfile: docker/dev.dockerfile
context: .
target: shared
volumes:
@@ -33,7 +33,7 @@ services:
# Vite frontend
vite:
build:
dockerfile: ./dev.dockerfile
dockerfile: docker/dev.dockerfile
context: .
target: vite
ports:
@@ -52,7 +52,7 @@ services:
# Main Express server
server:
build:
dockerfile: ./dev.dockerfile
dockerfile: docker/dev.dockerfile
context: .
target: server
ports:
@@ -82,7 +82,7 @@ services:
# BullMQ Workers
workers:
build:
dockerfile: ./dev.dockerfile
dockerfile: docker/dev.dockerfile
context: .
target: workers
volumes:
@@ -102,11 +102,13 @@ services:
# Run localtunnel
localtunnel:
build:
dockerfile: ./dev.dockerfile
dockerfile: docker/dev.dockerfile
context: .
target: localtunnel
volumes:
- ./server:/app/server
environment:
- LOCALTUNNEL_RESERVED_KEY=${LOCALTUNNEL_RESERVED_KEY}
depends_on:
- server
restart: unless-stopped

View File

@@ -10,4 +10,6 @@ MOCHA_PARALLEL=true $MOCHA_SETUP \
'tests/attach/downgrade/*.ts' \
'tests/attach/free/*.ts'
$MOCHA_CMD 'tests/attach/entities/*.ts'
# 'tests/attach/basic/basic2.ts' \

View File

@@ -12,10 +12,10 @@ $MOCHA_CMD \
'tests/attach/migrations/*.ts' \
'tests/attach/newVersion/*.ts' \
'tests/attach/upgradeOld/*.ts' \
'tests/attach/entities/*.ts' \
'tests/attach/others/*.ts' \
'tests/attach/updateEnts/*.ts' \
'tests/advanced/check/*.ts' \
'tests/attach/others/*.ts'
# 'tests/attach/entities/*.ts' \
$MOCHA_CMD 'tests/attach/prepaid/*.ts'

View File

@@ -398,6 +398,23 @@ export class AutumnInt {
},
};
stripe = {
connect: async (params: {
testApiKey: string;
liveApiKey: string;
successUrl: string;
defaultCurrency: string;
}) => {
const data = await this.post(`/org/stripe`, params);
return data;
},
delete: async () => {
const data = await this.delete(`/org/stripe`);
return data;
},
};
track = async (params: TrackParams) => {
const data = await this.post(`/track`, params);
return data;

View File

@@ -23,6 +23,8 @@ import expireRouter from "../customers/expire/expireRouter.js";
import { handleSetupPayment } from "../customers/attach/handleSetupPayment.js";
import { internalFeatureRouter } from "../features/internalFeatureRouter.js";
import { analyticsRouter } from "../analytics/analyticsRouter.js";
import { handleConnectStripe } from "../orgs/handlers/handleConnectStripe.js";
import { handleDeleteStripe } from "../orgs/handlers/handleDeleteStripe.js";
const apiRouter: Router = Router();
@@ -59,5 +61,7 @@ apiRouter.post("/setup_payment", handleSetupPayment);
// Analytics
apiRouter.use("/query", analyticsRouter);
apiRouter.post("/org/stripe", handleConnectStripe);
apiRouter.delete("/org/stripe", handleDeleteStripe);
export { apiRouter };

View File

@@ -0,0 +1,112 @@
import { routeHandler } from "@/utils/routerUtils.js";
import express, { Router } from "express";
import Stripe from "stripe";
import RecaseError, { handleRequestError } from "@/utils/errorUtils.js";
import { encryptData } from "@/utils/encryptUtils.js";
import { ErrCode } from "@/errors/errCodes.js";
import {
checkKeyValid,
createWebhookEndpoint,
} from "@/external/stripe/stripeOnboardingUtils.js";
import { OrgService } from "../OrgService.js";
import { AppEnv } from "@autumn/shared";
import { nullish } from "@/utils/genUtils.js";
import { clearOrgCache } from "../orgUtils/clearOrgCache.js";
export const handleConnectStripe = async (req: any, res: any) =>
routeHandler({
req,
res,
action: "connect stripe",
handler: async (req: any, res: any) => {
let { testApiKey, liveApiKey, successUrl, defaultCurrency } = req.body;
let { db, orgId, logtail: logger } = req;
if (!testApiKey || !liveApiKey || !successUrl) {
throw new RecaseError({
message: "Missing required fields",
code: ErrCode.StripeKeyInvalid,
statusCode: 400,
});
}
// 1. Check if API keys are valid
try {
await clearOrgCache({
db,
orgId,
logger,
});
await checkKeyValid(testApiKey);
await checkKeyValid(liveApiKey);
// Get default currency from Stripe
let stripe = new Stripe(testApiKey);
let account = await stripe.accounts.retrieve();
if (nullish(defaultCurrency) && nullish(account.default_currency)) {
throw new RecaseError({
message: "Default currency not set",
code: ErrCode.StripeKeyInvalid,
statusCode: 500,
});
} else if (nullish(defaultCurrency)) {
defaultCurrency = account.default_currency;
}
} catch (error: any) {
console.error("Error checking stripe keys", error);
throw new RecaseError({
message: error.message || "Invalid Stripe API keys",
code: ErrCode.StripeKeyInvalid,
statusCode: 500,
data: error,
});
}
// 2. Create webhook endpoint
let testWebhook: Stripe.WebhookEndpoint;
let liveWebhook: Stripe.WebhookEndpoint;
try {
testWebhook = await createWebhookEndpoint(
testApiKey,
AppEnv.Sandbox,
req.orgId
);
liveWebhook = await createWebhookEndpoint(
liveApiKey,
AppEnv.Live,
req.orgId
);
} catch (error) {
throw new RecaseError({
message: "Error creating stripe webhook",
code: ErrCode.StripeKeyInvalid,
statusCode: 500,
data: error,
});
}
// 1. Update org in Supabase
await OrgService.update({
db,
orgId: req.orgId,
updates: {
stripe_connected: true,
default_currency: defaultCurrency,
stripe_config: {
test_api_key: encryptData(testApiKey),
live_api_key: encryptData(liveApiKey),
test_webhook_secret: encryptData(testWebhook.secret as string),
live_webhook_secret: encryptData(liveWebhook.secret as string),
success_url: successUrl,
},
},
});
res.status(200).json({
message: "Stripe connected",
});
},
});

View File

@@ -0,0 +1,59 @@
import { routeHandler } from "@/utils/routerUtils.js";
import { OrgService } from "../OrgService.js";
import { createStripeCli } from "@/external/stripe/utils.js";
import { clearOrgCache } from "../orgUtils/clearOrgCache.js";
import { AppEnv } from "@autumn/shared";
export const handleDeleteStripe = async (req: any, res: any) =>
routeHandler({
req,
res,
action: "delete stripe",
handler: async (req: any, res: any) => {
const org = await OrgService.getFromReq(req);
let { db, orgId, logtail: logger } = req;
await clearOrgCache({
db,
orgId,
logger,
});
// 2. Delete webhook endpoint
try {
const testStripeCli = createStripeCli({ org, env: AppEnv.Sandbox });
const liveStripeCli = createStripeCli({ org, env: AppEnv.Live });
const testWebhooks = await testStripeCli.webhookEndpoints.list();
for (const webhook of testWebhooks.data) {
if (webhook.url.includes(org.id)) {
await testStripeCli.webhookEndpoints.del(webhook.id);
}
}
const liveWebhooks = await liveStripeCli.webhookEndpoints.list();
for (const webhook of liveWebhooks.data) {
if (webhook.url.includes(org.id)) {
await liveStripeCli.webhookEndpoints.del(webhook.id);
}
}
} catch (error: any) {
console.error("Error deleting stripe webhook(s)");
console.error(error.message);
}
await OrgService.update({
db,
orgId: req.orgId,
updates: {
stripe_connected: false,
stripe_config: null,
default_currency: undefined,
},
});
res.status(200).json({
message: "Stripe disconnected",
});
},
});

View File

@@ -29,7 +29,6 @@ describe("Initialize org for tests", () => {
this.timeout(1000000000);
this.org = await clearOrg({ orgSlug: ORG_SLUG, env: DEFAULT_ENV });
this.env = DEFAULT_ENV;
// this.sb = createSupabaseClient();
let { db, client } = initDrizzle();
this.db = db;

View File

@@ -174,6 +174,7 @@ describe(`${chalk.yellowBright(`${testCase}: Testing attach with new version`)}`
stripeCli,
testClockId,
advanceTo: addMonths(curUnix, 1).getTime(),
waitForSeconds: 30,
});
await advanceTestClock({
@@ -187,7 +188,7 @@ describe(`${chalk.yellowBright(`${testCase}: Testing attach with new version`)}`
const invoice = customer.invoices[0];
expect(invoice.total).to.equal(
invoiceTotal,
"invoice total after 1 cycle should be correct",
"invoice total after 1 cycle should be correct"
);
});
});

View File

@@ -1,161 +1,161 @@
import { assert } from "chai";
import { features, products } from "tests/global.js";
import { initCustomer } from "@/utils/scriptUtils/initCustomer.js";
import { getPublicAxiosInstance } from "tests/utils/setup.js";
import { completeCheckoutForm } from "tests/utils/stripeUtils.js";
import { timeout } from "tests/utils/genUtils.js";
import { ErrCode } from "@autumn/shared";
import { compareMainProduct } from "tests/utils/compare.js";
import { AutumnCli } from "tests/cli/AutumnCli.js";
import chalk from "chalk";
import { setupBefore } from "tests/before.js";
// import { assert } from "chai";
// import { features, products } from "tests/global.js";
// import { initCustomer } from "@/utils/scriptUtils/initCustomer.js";
// import { getPublicAxiosInstance } from "tests/utils/setup.js";
// import { completeCheckoutForm } from "tests/utils/stripeUtils.js";
// import { timeout } from "tests/utils/genUtils.js";
// import { ErrCode } from "@autumn/shared";
// import { compareMainProduct } from "tests/utils/compare.js";
// import { AutumnCli } from "tests/cli/AutumnCli.js";
// import chalk from "chalk";
// import { setupBefore } from "tests/before.js";
const testCase = "others4";
describe(`${chalk.yellowBright("others4: Testing publishable key")}`, () => {
// 1. Initialize customer with card
let customerId = testCase;
const bearerPublicAxios = getPublicAxiosInstance({
withBearer: true,
});
// const testCase = "others4";
// describe(`${chalk.yellowBright("others4: Testing publishable key")}`, () => {
// // 1. Initialize customer with card
// let customerId = testCase;
// const bearerPublicAxios = getPublicAxiosInstance({
// withBearer: true,
// });
before(async function () {
await setupBefore(this);
await initCustomer({
customerId,
db: this.db,
org: this.org,
env: this.env,
autumn: this.autumnJs,
});
});
// before(async function () {
// await setupBefore(this);
// await initCustomer({
// customerId,
// db: this.db,
// org: this.org,
// env: this.env,
// autumn: this.autumnJs,
// });
// });
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",
});
// 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,
});
// 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);
}
});
// 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,
});
// 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,
});
// // 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);
// assert.exists(data.checkout_url);
await completeCheckoutForm(data.checkout_url);
// await completeCheckoutForm(data.checkout_url);
await timeout(5000);
});
// 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 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,
});
// 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,
});
// 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);
}
});
// 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,
});
// 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);
}
});
// 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,
});
// // 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,
);
});
// 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,
});
// 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,
);
});
// 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,
},
});
// 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);
}
});
});
// 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

@@ -120,7 +120,7 @@ describe(`${chalk.yellowBright(`attach/${testCase}: upgrade quantity, prorate im
stripeCli,
testClockId,
advanceTo: addWeeks(new Date(), 2).getTime(),
waitForSeconds: 10,
waitForSeconds: 30,
});
await attachAndExpectCorrect({

View File

@@ -125,7 +125,7 @@ describe(`${chalk.yellowBright(`attach/${testCase}: Testing prepaid reset`)}`, (
const customer = await autumn.customers.get(customerId);
const newBalance = options[0].quantity - usage;
expect(customer.features[TestFeature.Messages].balance).to.equal(
newBalance,
newBalance
);
});
@@ -135,14 +135,14 @@ describe(`${chalk.yellowBright(`attach/${testCase}: Testing prepaid reset`)}`, (
testClockId,
advanceTo: addHours(
addMonths(new Date(), 1),
hoursToFinalizeInvoice,
hoursToFinalizeInvoice
).getTime(),
waitForSeconds: 25,
waitForSeconds: 30,
});
const customer = await autumn.customers.get(customerId);
expect(customer.features[TestFeature.Messages].balance).to.equal(
options[0].quantity,
options[0].quantity
);
});
});

View File

@@ -76,6 +76,27 @@ export const clearOrg = async ({
process.exit(1);
}
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({
testApiKey: process.env.STRIPE_TEST_KEY!,
liveApiKey: process.env.STRIPE_TEST_KEY!,
successUrl: "https://useautumn.com",
defaultCurrency: "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 });