Repository navigation
FEATURE: Add Autumn subscriptions and usage tracking #38
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
06422fe
c1dbc1f
df11b9a
45a39f1
6c84fe8
55ba3b1
75908c4
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,57 @@ | ||
| import { feature, item, plan } from 'atmn'; | ||
|
|
||
| // Plans and features as Autumn config. `bun run billing:push` deploys it; | ||
| // src/lib/server/plans.ts mirrors the limits for the local hard stops and | ||
| // plans.test.ts keeps the two in step. The customer id is the org id. | ||
|
|
||
| const GIB = 1024 ** 3; | ||
|
|
||
| export const storageBytes = feature({ | ||
| id: 'storage_bytes', | ||
| name: 'Storage', | ||
| type: 'metered', | ||
| consumable: false | ||
| }); | ||
|
|
||
| export const aiOps = feature({ | ||
| id: 'ai_ops', | ||
| name: 'AI operations', | ||
| type: 'metered', | ||
| consumable: true | ||
| }); | ||
|
|
||
| export const publicSharing = feature({ | ||
| id: 'public_sharing', | ||
| name: 'Public sharing', | ||
| type: 'boolean' | ||
| }); | ||
|
|
||
| export const free = plan({ | ||
| id: 'free', | ||
| name: 'Free', | ||
| autoEnable: true, | ||
| items: [ | ||
| item({ featureId: storageBytes.id, included: 2 * GIB }), | ||
| item({ | ||
| featureId: aiOps.id, | ||
| included: 500, | ||
| reset: { interval: 'month' } | ||
| }), | ||
| item({ featureId: publicSharing.id }) | ||
| ] | ||
| }); | ||
|
|
||
| export const pro = plan({ | ||
| id: 'pro', | ||
| name: 'Pro', | ||
| price: { amount: 8, interval: 'month' }, | ||
| items: [ | ||
| item({ featureId: storageBytes.id, included: 100 * GIB }), | ||
| item({ | ||
| featureId: aiOps.id, | ||
| included: 10_000, | ||
| reset: { interval: 'month' } | ||
| }), | ||
| item({ featureId: publicSharing.id }) | ||
| ] | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,13 @@ | ||
| -- migrate:up | ||
| -- Billing counters beside the storage meter. ai_ops_month is this | ||
| -- UTC calendar month's embedded chunk count for the billing page; it | ||
| -- resets on the next increment after ai_ops_month_reset_at. Sync sends | ||
| -- the absolute current total, so a failed acknowledgement is safe to retry. | ||
| ALTER TABLE org_usage | ||
| ADD COLUMN ai_ops_month integer NOT NULL DEFAULT 0 | ||
| CHECK (ai_ops_month >= 0); | ||
| ALTER TABLE org_usage ADD COLUMN ai_ops_month_reset_at timestamptz; | ||
|
|
||
| -- migrate:down | ||
| ALTER TABLE org_usage DROP COLUMN ai_ops_month_reset_at; | ||
| ALTER TABLE org_usage DROP COLUMN ai_ops_month; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,23 @@ | ||
| -- migrate:up | ||
| -- A periodic absolute snapshot repairs lost enqueues and acknowledgements. | ||
| ALTER TABLE org_usage | ||
| ADD COLUMN usage_sync_next_run_at timestamptz NOT NULL DEFAULT now(); | ||
|
|
||
| -- Reserve quota before calling the embedder. Only a successful semantic | ||
| -- commit consumes the reservation and records billable usage. Abandoned | ||
| -- reservations expire with the indexing lease. | ||
| CREATE TABLE ai_usage_reservations ( | ||
| token text PRIMARY KEY, | ||
| org_id text NOT NULL REFERENCES orgs(id) ON DELETE CASCADE, | ||
| value integer NOT NULL CHECK (value > 0), | ||
| expires_at timestamptz NOT NULL | ||
| ); | ||
| CREATE INDEX ai_usage_reservations_org ON ai_usage_reservations (org_id, expires_at); | ||
| ALTER TABLE ai_usage_reservations ENABLE ROW LEVEL SECURITY; | ||
| ALTER TABLE ai_usage_reservations FORCE ROW LEVEL SECURITY; | ||
| CREATE POLICY org_isolation ON ai_usage_reservations USING (app_org_visible(org_id)); | ||
|
|
||
| -- migrate:down | ||
| DROP TABLE ai_usage_reservations; | ||
| ALTER TABLE org_usage | ||
| DROP COLUMN usage_sync_next_run_at; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,53 @@ | ||
| import type { PgClient } from '@effect/sql-pg'; | ||
| import { Effect } from 'effect'; | ||
| import { StorageError } from './errors'; | ||
| import type { AutumnClientShape } from './services/autumn'; | ||
|
|
||
| // plan_changes contains only affected plans, not the complete subscription | ||
| // state. A verified customer-level event asks us to reconcile from Autumn. | ||
| export type BillingWebhookEvent = | ||
| | { readonly kind: 'reconcile'; readonly orgId: string } | ||
| | { readonly kind: 'ignored'; readonly type: string }; | ||
|
|
||
| const isRecord = (value: unknown): value is Record<string, unknown> => | ||
| typeof value === 'object' && value !== null && !Array.isArray(value); | ||
|
|
||
| const text = (value: unknown) => (typeof value === 'string' ? value : ''); | ||
|
|
||
| export const decodeBillingWebhook = (payload: unknown): BillingWebhookEvent => { | ||
| if (!isRecord(payload)) return { kind: 'ignored', type: '' }; | ||
| const type = text(payload.type); | ||
| const data = isRecord(payload.data) ? payload.data : null; | ||
| const orgId = data ? text(data.customer_id) : ''; | ||
| if (type !== 'billing.updated' || !orgId || !data || text(data.entity_id)) { | ||
| return { kind: 'ignored', type }; | ||
| } | ||
| return { kind: 'reconcile', orgId }; | ||
| }; | ||
|
|
||
| // Lock before the provider read, so concurrent/redelivered signals cannot | ||
| // finish an older read after a newer one and restore a stale local plan. | ||
| // Unknown customers are acknowledged without creating an Autumn customer. | ||
| export const reconcileOrgPlan = ( | ||
| sql: PgClient.PgClient, | ||
| autumn: AutumnClientShape, | ||
| orgId: string | ||
| ) => | ||
| sql | ||
| .withTransaction( | ||
| Effect.gen(function* () { | ||
| const rows = yield* sql<{ id: string }>` | ||
| SELECT id FROM orgs WHERE id = ${orgId} FOR UPDATE`; | ||
| if (rows.length === 0) return null; | ||
| const plan = yield* autumn.getPlan({ customerId: orgId }); | ||
| yield* sql`UPDATE orgs SET plan = ${plan} WHERE id = ${orgId}`; | ||
| return plan; | ||
| }) | ||
| ) | ||
| .pipe( | ||
| Effect.mapError((cause) => | ||
| cause instanceof StorageError | ||
| ? cause | ||
| : new StorageError({ operation: 'reconcile org plan', cause }) | ||
| ) | ||
| ); | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -26,6 +26,14 @@ export interface CloudflareZoneConfig { | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| readonly zoneId: string; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Autumn billing. secretKey is null when AUTUMN_SECRET_KEY is unset, in | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // which case every gate fails open; a `fake:` key picks the in-memory | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // fake only in development. See services/autumn.ts. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| export interface AutumnConfig { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| readonly secretKey: string | null; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| readonly webhookSecret: string; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| export interface AppConfigShape { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| readonly dashboardOrigin: string; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Tenant content is served from `<slug>.<contentDomain>` over the | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -41,6 +49,7 @@ export interface AppConfigShape { | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| readonly cloudflareZone: CloudflareZoneConfig | null; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // WorkOS user ids allowed on /admin (ADMIN_USER_IDS, comma-separated). | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| readonly adminUserIds: ReadonlySet<string>; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| readonly autumn: AutumnConfig; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| readonly semanticSearch: 'off' | 'auto' | 'required'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| readonly embeddingModel: '@cf/baai/bge-small-en-v1.5'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| readonly embeddingPooling: 'cls'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -123,6 +132,19 @@ const adminUserIdsFromEnv = (env: Env) => | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .map((id) => id.trim()) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .filter((id) => id.length > 0) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const autumnFromEnv = (env: Env): AutumnConfig => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const secretKey = optionalString(env.AUTUMN_SECRET_KEY).trim() || null; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const webhookSecret = optionalString(env.AUTUMN_WEBHOOK_SECRET).trim(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (secretKey?.startsWith('fake:') && !dev) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| throw new Error('Fake Autumn billing is only available in development'); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (secretKey && !secretKey.startsWith('fake:') && !webhookSecret) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| throw new Error( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 'AUTUMN_WEBHOOK_SECRET is required alongside AUTUMN_SECRET_KEY' | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return { secretKey, webhookSecret }; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+135
to
+147
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win Reject a webhook secret that has no Autumn API key.
This affects
Proposed fix if (secretKey && !secretKey.startsWith('fake:') && !webhookSecret) {
throw new Error(
'AUTUMN_WEBHOOK_SECRET is required alongside AUTUMN_SECRET_KEY'
);
}
+ if (!secretKey && webhookSecret) {
+ throw new Error(
+ 'AUTUMN_SECRET_KEY is required alongside AUTUMN_WEBHOOK_SECRET'
+ );
+ }
return { secretKey, webhookSecret };📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| export const configFromEnv = (env: Env) => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const origins = normalizeOrigins({ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -160,6 +182,7 @@ export const configFromEnv = (env: Env) => { | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| urlScanner: urlScannerFromEnv(env), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| cloudflareZone: cloudflareZoneFromEnv(env), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| adminUserIds: adminUserIdsFromEnv(env), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| autumn: autumnFromEnv(env), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| semanticSearch, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| embeddingModel: '@cf/baai/bge-small-en-v1.5', | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| embeddingPooling: 'cls', | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟠 High
server/billing-webhook.ts:42When a customer is in a Pro trial,
billing.updatedcausesreconcileOrgPlanto writefreetoorgs.plan, removing the customer's Pro limits and access.autumn.getPlanexcludes the documentedtrialingsubscription status, so the reconciliation must treattrialingas an eligible subscription status.🚀 Reply "fix it for me" or copy this AI Prompt for your agent: