docs: fixed readme.md

This commit is contained in:
John Yeo
2025-06-18 12:46:16 +01:00
parent 3b1cff357b
commit 9f3514c83c
18 changed files with 173 additions and 234 deletions

6
.gitignore vendored
View File

@@ -81,4 +81,8 @@ shared/drizzle
run.sh
commands.sh
commands.sh
env.sh
server/env.sh
server/run.sh
server/test.sh

135
README.md
View File

@@ -3,7 +3,7 @@
![Autumn](assets/github_hero.png)
![Discord](https://img.shields.io/badge/Join%20Community-5865F2?logo=discord&logoColor=white)
![Follow](https://img.shields.io/twitter/follow/autumn_pricing?style=social)
[![Follow](https://img.shields.io/twitter/follow/autumnpricing?style=social)](https://x.com/autumnpricing)
![Y Combinator](https://img.shields.io/badge/Y%20Combinator-F24-orange)
[![Cloud](https://img.shields.io/badge/Cloud-☁️-blue)](https://app.useautumn.com)
[![Documentation](https://img.shields.io/badge/Documentation-📕-blue)](https://docs.useautumn.com)
@@ -17,85 +17,96 @@
All this without having to handle webhooks, upgrades/downgrades, cancellations or payment fails.
**Docs**: https://docs.useautumn.com
## Getting Started
**Cloud**: The quickest way to start using Autum is through our cloud service [here](https://app.useautumn.com).
**Self Hosted**: If you'd like to self-host Autumn:
1. Make sure you have `node.js` and `pnpm` installed
2. Run our set up script:
```bash
pnpm run setup
```
3. Run Autumn:
```bash
docker compose -f docker-compose.prod.yml up
```
That's it! You should be able to see the Autumn dashboard on `http://localhost:3000`
> Our set up script initializes the required env vars and (optionally) a Supabase instance. If you'd like to use your own Postgres instance, you can do so -- just paste the connection string in the `DATABASE_URL` env variable at `server/.env`
## Why Autumn
**1⃣ Billing infra gets complex fast**
More than payments: it's building permission management, metering, usage limits with cron jobs, and connecting it to upgrade, downgrade, cancellation and failed payments states. Race conditions, edge cases, and other bugs will slow you down.
**2⃣ Billing and app logic should be decoupled**
Growing companies iterate on pricing often: raising prices, experimenting with credits or charging for a new feature. DB migrations, rebuilding in-app flows, internal dashboards for custom pricing and grandfathering users on old pricing is a nightmare.
## How it works
First, create your products and plans on the dashboard. We support **any** pricing model. Some popular ones we've seen include:
### 1. Define your products and plans in Autumn
1. **Usage & Overage**
- Set real-time usage limits and choose when they reset. Charge users if they go over.
2. **Credits** 💰
- Users can access monetary or arbitrary credits that many features can draw from
3. **Seat-based with per-seat limits** 👥:
- Bill customers for their users (or other entities). Set usage-limits per user.
4. **Pay upfront** 💳
- let users purchase a fixed quantity of a feature upfront, which is used over time
Create your products in Autumn's dashboard, and set the available features for each product.
![Autumn Product](assets/products.png)
Next, all your billing logic can be implemented through just 3 functions:
Features can be `boolean`, which are used to enable/disable features. Or, they can be `metered`, for tracking usage-based aspects of your product.
1. `/attach`: One function call for all purchase flows. We return a Stripe Checkout URL, or handle an upgrade/downgrade.
![Autumn Feature](assets/features.png)
```tsx
const { attach } = useAutumn();
<button
onClick={async () => {
await attach({ productId: "pro" });
}}
>
Upgrade to Pro
</button>
```
You define the level of granularity you want: whether that's 1 single boolean feature flag (eg: `pro-features`) to enable all your paid features, or splitting them each into their own feature.
2. `/check`: Check whether a customer has access to a product, feature or remaining usage.
```ts
const { check } = useAutumn();
Once defined, you can check if a user has access to any of the defined features with a simple API call from your codebase.
### 2. Embed the products in your application
There are only 2 functions you need to call from your codebase:
1. `/entitled` - check if a user has access to a feature
2. `/events` - *(optional)* track the usage of metered features
```javascript
// 1. Check if a user has access to a feature
const response = await fetch('https://api.useautumn.com/v1/entitled', {
method: "POST",
headers: {Authorization: 'Bearer <token>', 'Content-Type': 'application/json'},
body: JSON.stringify({
"customer_id": "my_personal_id",
"feature_id": "premium_msg"
})
const { data } = await check({
productId: "ai_tokens"
})
// 2. Check if user is allowed
let data = await response.json()
if (!data.allowed) {
throw new Error(`feature not allowed`)
}
!data.allowed && alert("AI limit reached")
```
// 3. Send event if user accesses feature
await fetch('https://api.useautumn.com/v1/events', {
method: "POST",
headers: {Authorization: 'Bearer <token>', 'Content-Type': 'application/json'},
body: JSON.stringify({
"customer_id": "my_personal_id",
"event_name": "premium_msg"
})
3. `/track`: When a customer uses a usage-based feature, record a usage event.
```ts
const { track } = useAutumn();
await track({
featureId: "ai_tokens",
value: 1312
})
```
No need to handle any customer logic. You can use your own customer IDs, and if the customer doesn't exist in Autumn, they'll be created automatically (and assigned any default plans).
## Others
### 3. Attach a product to a customer on purchase
**Contributing** 🤝: If you're interested in contributing, you can check out our guide [here](/.github/CONTRIBUTING.md). All types of help are appreciated :)
Whenever a customer attempts to purchase a product, you can assign it to them using the `/attach` function. If the card is not on file, Autumn will return a Stripe checkout URL to collect payment.
**Support** 💬: If you need any type of support, we're typically most responsive on our [Discord channel](https://discord.gg/STqxY92zuS), but feel free to email us `hey@useautumn.com` too!
Otherwise, it will automatically handle any upgrade, downgrade or pro-ration logic.
```javascript
const response = await fetch('https://api.useautumn.com/v1/attach', {
method: "POST",
headers: {Authorization: 'Bearer <token>', 'Content-Type': 'application/json'},
body: JSON.stringify({
"customer_id": "my_personal_id",
"product_id": "prod_2rwydCAcuUp913PqoZwOjpy4aDM"
})
})
const data = await response.json()
if (data.checkout_url) {
// Redirect user to checkout_url
}
```
## Congratulations!
<!-- ## Congratulations!
You've embedded a full billing system into your application within a few minutes. You can make any pricing model changes you need, or handle custom plans without needing to alter your codebase.
Feel free to self-host Autumn, or use our hosted version at https://useautumn.com. And let us know any questions, thoughts or feedback at hey@useautumn.com.
Feel free to self-host Autumn, or use our hosted version at https://useautumn.com. And let us know any questions, thoughts or feedback at hey@useautumn.com. -->

View File

@@ -1,4 +1,4 @@
version: "3.8"
services:
valkey:
image: docker.io/bitnami/valkey:8.0
@@ -32,10 +32,24 @@ services:
context: .
dockerfile: docker/prod.dockerfile
target: server-prod
volumes:
- ./server:/app/server
ports:
- "8080:8080"
restart: always
localtunnel:
image: node:20-alpine
build:
dockerfile: docker/prod.dockerfile
context: .
target: localtunnel
volumes:
- ./server:/app/server
depends_on:
- server
restart: unless-stopped
workers:
environment:
- REDIS_URL=redis://valkey:6379
@@ -43,4 +57,10 @@ services:
context: .
dockerfile: docker/prod.dockerfile
target: workers-prod
restart: always
restart: always
volumes:
valkey-data:
shared-dist:
shared-node-modules:
root-node-modules:

View File

@@ -10,11 +10,10 @@ COPY vite/package*.json ./vite/
RUN pnpm install
# # ---- Build shared ----
# FROM base AS shared-build
# COPY shared/ ./shared/
# RUN pnpm -F shared build
FROM base AS localtunnel
WORKDIR /app
COPY localtunnel-start.sh ./
CMD ["sh", "localtunnel-start.sh"]
# # ---- Build frontend (vite) ----
FROM base AS vite-build
@@ -44,17 +43,3 @@ EXPOSE 8080
WORKDIR /app
CMD ["pnpm", "run", "server:workers"]
# # ---- Production backend image ----
# FROM node:18-alpine AS server-prod
# COPY --from=server-build /app/server/dist ./dist
# COPY --from=server-build /app/server/package.json ./
# COPY --from=server-build /app/server/node_modules ./node_modules
# EXPOSE 8080
# CMD ["pnpm", "run", "server:start"]
# # ---- Production workers image ----
# FROM node:18-alpine AS workers-prod
# COPY --from=server-build /app/server/dist ./dist
# COPY --from=server-build /app/server/package.json ./
# COPY --from=server-build /app/server/node_modules ./node_modules
# CMD ["pnpm", "run", "server:workers"]

View File

@@ -1,112 +0,0 @@
import {
pgTable,
text,
timestamp,
boolean,
integer,
} from "drizzle-orm/pg-core";
export const user = pgTable("user", {
id: text("id").primaryKey(),
name: text("name").notNull(),
email: text("email").notNull().unique(),
emailVerified: boolean("email_verified")
.$defaultFn(() => false)
.notNull(),
image: text("image"),
createdAt: timestamp("created_at", { withTimezone: true })
.$defaultFn(() => /* @__PURE__ */ new Date())
.notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true })
.$defaultFn(() => /* @__PURE__ */ new Date())
.notNull(),
role: text("role"),
banned: boolean("banned"),
banReason: text("ban_reason"),
banExpires: timestamp("ban_expires"),
}).enableRLS();
export const session = pgTable("session", {
id: text("id").primaryKey(),
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
token: text("token").notNull().unique(),
createdAt: timestamp("created_at", { withTimezone: true }).notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull(),
ipAddress: text("ip_address"),
userAgent: text("user_agent"),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
impersonatedBy: text("impersonated_by"),
activeOrganizationId: text("active_organization_id"),
}).enableRLS();
export const account = pgTable("account", {
id: text("id").primaryKey(),
accountId: text("account_id").notNull(),
providerId: text("provider_id").notNull(),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
accessToken: text("access_token"),
refreshToken: text("refresh_token"),
idToken: text("id_token"),
accessTokenExpiresAt: timestamp("access_token_expires_at", {
withTimezone: true,
}),
refreshTokenExpiresAt: timestamp("refresh_token_expires_at", {
withTimezone: true,
}),
scope: text("scope"),
password: text("password"),
createdAt: timestamp("created_at").notNull(),
updatedAt: timestamp("updated_at").notNull(),
}).enableRLS();
export const verification = pgTable("verification", {
id: text("id").primaryKey(),
identifier: text("identifier").notNull(),
value: text("value").notNull(),
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
createdAt: timestamp("created_at", { withTimezone: true }).$defaultFn(
() => /* @__PURE__ */ new Date(),
),
updatedAt: timestamp("updated_at", { withTimezone: true }).$defaultFn(
() => /* @__PURE__ */ new Date(),
),
}).enableRLS();
export const organization = pgTable("organization", {
id: text("id").primaryKey(),
name: text("name").notNull(),
slug: text("slug").unique(),
logo: text("logo"),
createdAt: timestamp("created_at", { withTimezone: true }).notNull(),
metadata: text("metadata"),
}).enableRLS();
export const member = pgTable("member", {
id: text("id").primaryKey(),
organizationId: text("organization_id")
.notNull()
.references(() => organization.id, { onDelete: "cascade" }),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
role: text("role").default("member").notNull(),
createdAt: timestamp("created_at", { withTimezone: true }).notNull(),
}).enableRLS();
export const invitation = pgTable("invitation", {
id: text("id").primaryKey(),
organizationId: text("organization_id")
.notNull()
.references(() => organization.id, { onDelete: "cascade" }),
email: text("email").notNull(),
role: text("role"),
status: text("status").default("pending").notNull(),
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
inviterId: text("inviter_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
}).enableRLS();

View File

@@ -9,6 +9,8 @@ const pinoLogger = initLogger();
const createLogMethod = (pinoMethod: any, logtailMethod: any) => {
function rewriteAppPath(str: string) {
if (typeof str !== "string") return str;
// Replace file:///app/ with ./
str = str.replace("file:///app/", "./");
return str.replace(/\/app\//g, "./");
}

View File

@@ -110,3 +110,9 @@ export const stripeToAutumnInterval = ({
return BillingInterval.Year;
}
};
export const subItemToAutumnInterval = (item: Stripe.SubscriptionItem) => {
return stripeToAutumnInterval({
interval: item.price.recurring?.interval!,
intervalCount: item.price.recurring?.interval_count!,
});
};

View File

@@ -1,4 +1,4 @@
import { apiAuthMiddleware } from "@/middleware/apiMiddleware.js";
import { apiAuthMiddleware } from "@/middleware/apiAuthMiddleware.js";
import { Router } from "express";
import { eventsRouter } from "./events/eventRouter.js";
import { cusRouter } from "./cusRouter.js";

View File

@@ -94,7 +94,7 @@ export class FeatureService {
env: updatedFeatures[0].env as AppEnv,
});
return updatedFeatures as Feature[];
return updatedFeatures.length > 0 ? updatedFeatures[0] : null;
}
static async insert({

View File

@@ -149,12 +149,12 @@ export const getObjectsUsingFeature = async ({
export const runSaveFeatureDisplayTask = async ({
db,
feature,
org,
logger,
}: {
db: DrizzleCli;
feature: Feature;
org: Organization;
logger: any;
}) => {
let display;
@@ -166,9 +166,7 @@ export const runSaveFeatureDisplayTask = async ({
return;
}
logger.info(
`Generating feature display for ${feature.id} (org: ${org.slug})`,
);
logger.info(`Generating feature display for ${feature.id}`);
display = await generateFeatureDisplay(feature);
logger.info(`Result: ${JSON.stringify(display)}`);

View File

@@ -10,7 +10,7 @@ const BillingIntervalOrder = [
];
const ReversedBillingIntervalOrder = [
BillingInterval.OneOff,
// BillingInterval.OneOff,
BillingInterval.Month,
BillingInterval.Quarter,
BillingInterval.SemiAnnual,

View File

@@ -53,7 +53,6 @@ const initWorker = ({
await runSaveFeatureDisplayTask({
db,
feature: job.data.feature,
org: job.data.org,
logger: logtail,
});
return;

View File

@@ -1,8 +1,5 @@
import { subItemToAutumnInterval } from "@/external/stripe/utils.js";
import Stripe from "stripe";
import {
stripeToAutumnInterval,
subItemToAutumnInterval,
} from "tests/utils/stripeUtils.js";
export const logSubItems = (sub: Stripe.Subscription) => {
for (const item of sub.items.data) {

View File

@@ -243,6 +243,17 @@ button:focus-visible {
--color-focus: rgba(139, 92, 246, 0.95);
}
/* Hide number input spinners globally */
input[type="number"] {
-moz-appearance: textfield; /* Firefox */
}
input[type="number"]::-webkit-outer-spin-button,
input[type="number"]::-webkit-inner-spin-button {
-webkit-appearance: none; /* Chrome, Safari, Edge */
margin: 0;
}
/*
---break--- */

View File

@@ -6,6 +6,7 @@ import { PriceItem } from "@/components/pricing/attach-pricing-dialog";
import { formatAmount } from "@/utils/product/productItemUtils";
import { AttachBranch } from "@autumn/shared";
import { Decimal } from "decimal.js";
import { Input } from "@/components/ui/input";
export const DueToday = () => {
const { attachState, product, org } = useProductContext();
@@ -67,7 +68,7 @@ export const DueToday = () => {
<span>
{product.name} - {feature_name}
</span>
<QuantityInput
{/* <QuantityInput
key={feature_name}
value={quantity ? quantity / billing_units : ""}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
@@ -77,7 +78,22 @@ export const DueToday = () => {
setOptions(newOptions);
}}
>
<span className="text-muted-foreground">
</QuantityInput> */}
<div className="flex items-center gap-2 ">
<Input
type="number"
value={quantity ? quantity / billing_units : ""}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
const newOptions = [...options];
newOptions[index].quantity =
parseInt(e.target.value) * billing_units;
setOptions(newOptions);
}}
className="w-12 h-7"
/>
<span className="text-muted-foreground truncate max-w-40">
×{" "}
{formatAmount({
defaultCurrency: currency,
@@ -86,7 +102,7 @@ export const DueToday = () => {
})}{" "}
per {billing_units === 1 ? " " : billing_units} {feature_name}
</span>
</QuantityInput>
</div>
</PriceItem>
);
})}

View File

@@ -55,17 +55,21 @@ const ConfigureWebhookSection = ({ dashboardUrl }: any) => {
<div className="bg-white">
<PageSectionHeader title="Webhooks" />
<AppPortal
url={dashboardUrl}
style={{
height: "100%",
borderRadius: "none",
marginTop: "0.5rem",
// paddingLeft: "1rem",
// paddingRight: "1rem",
}}
fullSize
/>
{dashboardUrl ? (
<AppPortal
url={dashboardUrl}
style={{
height: "100%",
borderRadius: "none",
marginTop: "0.5rem",
// paddingLeft: "1rem",
// paddingRight: "1rem",
}}
fullSize
/>
) : (
<div className="text-muted-foreground">Dashboard URL not found.</div>
)}
</div>
);
};

View File

@@ -1,14 +1,12 @@
import FieldLabel from "@/components/general/modal-components/FieldLabel";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { useEffect, useState } from "react";
import { useState } from "react";
import { Select, SelectContent, SelectItem } from "@/components/ui/select";
import { SelectTrigger, SelectValue } from "@/components/ui/select";
import { keyToTitle, slugify } from "@/utils/formatUtils/formatTextUtils";
import { keyToTitle } from "@/utils/formatUtils/formatTextUtils";
import {
Reward,
CouponDurationType,
DiscountType,
RewardType,
ProductItem,
} from "@autumn/shared";
@@ -156,11 +154,11 @@ const ProductPriceSelector = ({
// Handle selection/deselection of a price
const handlePriceToggle = (priceId: string) => {
let newPriceIds = [...config.price_ids];
if (config.price_ids.includes(priceId)) {
newPriceIds = config.price_ids.filter((id) => id !== priceId);
let newPriceIds = [...(config.price_ids || [])];
if (config.price_ids?.includes(priceId)) {
newPriceIds = config.price_ids?.filter((id) => id !== priceId) || [];
} else {
newPriceIds = [...config.price_ids, priceId];
newPriceIds = [...(config.price_ids || []), priceId];
}
setConfig("price_ids", newPriceIds);
};
@@ -190,11 +188,11 @@ const ProductPriceSelector = ({
>
{config.apply_to_all ? (
"All Products"
) : config.price_ids.length == 0 ? (
) : config.price_ids?.length == 0 ? (
"Select Products"
) : (
<>
{config.price_ids.map((priceId) => (
{config.price_ids?.map((priceId) => (
<div
key={priceId}
className="py-1 px-3 text-xs text-t3 border-zinc-300 bg-zinc-100 rounded-full w-fit flex items-center gap-2 h-fit max-w-full"
@@ -270,7 +268,7 @@ const ProductPriceSelector = ({
})}
</span>
{config.price_ids.includes(item.price_id) && (
{config.price_ids?.includes(item.price_id) && (
<Check size={12} className="text-t3" />
)}
</CommandItem>