From 0f66524a599f7ea26dfbc17ee19438d9c81a4cf9 Mon Sep 17 00:00:00 2001 From: Nicacio Oliveira Date: Thu, 3 Sep 2026 15:26:22 -0300 Subject: [PATCH 1/2] feat(billing): grant $25 AI gateway signup credit to new orgs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit seedOrgDb now grants a one-time signup credit to every new org's Deco AI Gateway ledger via POST /api/admin/credits, idempotent at the gateway per `signup-credit:` so a re-run never double-grants. Amount is configurable (DECO_AI_GATEWAY_SIGNUP_GRANT_CENTS, default 2500 = $25, 0 disables). Gated on gatewayAdminConfigured() (hosted only) and applied fail-soft — a grant error never fails org creation, mirroring the AI-key auto-provision path. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/api/src/auth/org.ts | 16 +++++++++ apps/api/src/billing/gateway-admin.ts | 32 ++++++++++++++++++ apps/api/src/settings/resolve-config.test.ts | 34 ++++++++++++++++++++ apps/api/src/settings/resolve-config.ts | 7 ++++ apps/api/src/settings/types.ts | 5 +++ 5 files changed, 94 insertions(+) diff --git a/apps/api/src/auth/org.ts b/apps/api/src/auth/org.ts index d1a7c2ec95..0b14180fe1 100644 --- a/apps/api/src/auth/org.ts +++ b/apps/api/src/auth/org.ts @@ -4,6 +4,10 @@ import { getWellKnownSelfConnection, } from "@decocms/shared/sdk"; import { decoAiGatewayAdapter } from "@/ai-providers/adapters/deco-ai-gateway"; +import { + gatewayAdminConfigured, + grantGatewaySignupCredit, +} from "@/billing/gateway-admin"; import { getBaseUrl } from "@/core/server-constants"; import { getDb } from "@/database"; import { CredentialVault } from "@/encryption/credential-vault"; @@ -189,6 +193,18 @@ export async function seedOrgDb(organizationId: string, createdBy: string) { console.error("Failed to auto-provision Deco AI Gateway key:", err); } } + + // Idempotent at the gateway per `signup-credit:`; fail-soft. + if (settings.signupGrantCents > 0 && gatewayAdminConfigured()) { + try { + await grantGatewaySignupCredit({ + organizationId, + amountCents: settings.signupGrantCents, + }); + } catch (err) { + console.error("Failed to grant signup AI credit:", err); + } + } } catch (err) { console.error("Error creating default MCP connections:", err); } diff --git a/apps/api/src/billing/gateway-admin.ts b/apps/api/src/billing/gateway-admin.ts index f126b41e08..8806ef98a3 100644 --- a/apps/api/src/billing/gateway-admin.ts +++ b/apps/api/src/billing/gateway-admin.ts @@ -33,6 +33,38 @@ async function postGatewayAdmin( } } +/** + * Grant the one-time signup credit to a new org's gateway ledger. Idempotent + * at the gateway per referenceId (unique ledger index): the deterministic + * `signup-credit:` reference collapses any replay — a re-run of + * seedOrgDb, a retry — to a no-op, so the org is credited exactly once without + * Studio holding any local "already granted" state. + * + * Fail-soft is the CALLER's job: org creation must never fail on a grant error + * (see seedOrgDb), mirroring the auto-provision path. + */ +export async function grantGatewaySignupCredit(input: { + organizationId: string; + amountCents: number; +}): Promise { + if (!gatewayAdminConfigured()) { + // Config was removed mid-flight; throw so the fail-soft caller logs it. + throw new Error( + "gateway admin not configured — cannot grant signup credit", + ); + } + await postGatewayAdmin( + "/api/admin/credits", + { + orgId: input.organizationId, + amountCents: input.amountCents, + description: "Studio signup credit", + referenceId: `signup-credit:${input.organizationId}`, + }, + "gateway signup credit grant", + ); +} + /** * Credit purchased AI credits to the org's gateway ledger. The top-up webhook * THROWS on failure and lets Stripe's redelivery be the retry queue — the diff --git a/apps/api/src/settings/resolve-config.test.ts b/apps/api/src/settings/resolve-config.test.ts index 3086f19b9e..4b4e46d88b 100644 --- a/apps/api/src/settings/resolve-config.test.ts +++ b/apps/api/src/settings/resolve-config.test.ts @@ -498,6 +498,40 @@ describe("resolveConfig topup fee percent", () => { }); }); +describe("resolveConfig signup grant cents", () => { + it("defaults to 2500 ($25)", () => { + expect(resolveConfig(flags, {}).settings.signupGrantCents).toBe(2500); + }); + + it("honors an override", () => { + expect( + resolveConfig(flags, { DECO_AI_GATEWAY_SIGNUP_GRANT_CENTS: "2300" }) + .settings.signupGrantCents, + ).toBe(2300); + }); + + it("allows 0 to disable the signup grant", () => { + expect( + resolveConfig(flags, { DECO_AI_GATEWAY_SIGNUP_GRANT_CENTS: "0" }).settings + .signupGrantCents, + ).toBe(0); + }); + + it("rejects a non-numeric value at boot (fail-fast, not a silent default)", () => { + expect(() => + resolveConfig(flags, { DECO_AI_GATEWAY_SIGNUP_GRANT_CENTS: "lots" }), + ).toThrow( + "DECO_AI_GATEWAY_SIGNUP_GRANT_CENTS must be a non-negative integer", + ); + }); + + it("rejects a value above the $1,000 fat-finger cap", () => { + expect(() => + resolveConfig(flags, { DECO_AI_GATEWAY_SIGNUP_GRANT_CENTS: "250000" }), + ).toThrow("DECO_AI_GATEWAY_SIGNUP_GRANT_CENTS must be at most 100000"); + }); +}); + describe("resolveConfig decopilot max concurrent subagents", () => { it("defaults to 4", () => { expect( diff --git a/apps/api/src/settings/resolve-config.ts b/apps/api/src/settings/resolve-config.ts index b23a029469..200640b0d5 100644 --- a/apps/api/src/settings/resolve-config.ts +++ b/apps/api/src/settings/resolve-config.ts @@ -261,6 +261,13 @@ export function resolveConfig( aiGatewayEnabled: toBool(envVars.DECO_AI_GATEWAY_ENABLED), aiGatewayUrl: envVars.DECO_AI_GATEWAY_URL || "https://ai-site.deco.site", aiGatewayAdminToken: envVars.DECO_AI_GATEWAY_ADMIN_TOKEN, + // Capped at $1,000 (fat-finger guard); 0 disables the signup grant. + signupGrantCents: toNonNegativeIntegerOrDefault( + "DECO_AI_GATEWAY_SIGNUP_GRANT_CENTS", + envVars.DECO_AI_GATEWAY_SIGNUP_GRANT_CENTS, + 2500, + 100_000, + ), stripeWebhookSecret: envVars.STRIPE_WEBHOOK_SECRET, stripeSecretKey: envVars.STRIPE_SECRET_KEY, stripeOrgPriceId: envVars.STRIPE_ORG_PRICE_ID, diff --git a/apps/api/src/settings/types.ts b/apps/api/src/settings/types.ts index 27afc13934..40da9195cd 100644 --- a/apps/api/src/settings/types.ts +++ b/apps/api/src/settings/types.ts @@ -75,6 +75,11 @@ export interface Settings { /** Bearer for the gateway's /api/admin/* (top-up credits). Absent → the * top-up tool falls back to the gateway's own checkout. */ aiGatewayAdminToken: string | undefined; + /** One-time AI credit granted to every new org on signup, in cents (default + * 2500 = $25). 0 disables the grant. Only applied when the gateway admin is + * configured (hosted deployments); self-hosted deployments can't reach the + * admin API and skip it. */ + signupGrantCents: number; // Stripe (per-org subscription + AI-credit top-ups). Absent → webhook // 503s, no checkout. From 20c81de09beae491171533876ef32d3209b1d2ff Mon Sep 17 00:00:00 2001 From: Nicacio Oliveira Date: Thu, 3 Sep 2026 15:40:25 -0300 Subject: [PATCH 2/2] feat(billing): make signup AI credit per-org configurable at org creation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The control plane creates orgs via ORGANIZATION_CREATE; it can now pass `initialCreditCents` to set that org's welcome credit, overriding the deployment default. The value rides in org metadata so the afterCreate seed hook (the canonical creation boundary, covering every entry point) reads it via readInitialCreditCents — a defensive parser that accepts metadata as object or JSON string and ignores out-of-range/garbage, falling back to the default. Default grant lowered 2500 -> 2300 so it nets ~$25 with the gateway's own $2 provision credit. Env DECO_AI_GATEWAY_SIGNUP_GRANT_CENTS still sets the deployment default (0 disables), capped at $1,000 (MAX_SIGNUP_GRANT_CENTS). Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/api/src/auth/index.ts | 5 +- apps/api/src/auth/initial-credit.test.ts | 58 ++++++++++++++++++++ apps/api/src/auth/initial-credit.ts | 30 ++++++++++ apps/api/src/auth/org.ts | 16 +++++- apps/api/src/billing/gateway-admin.ts | 4 ++ apps/api/src/settings/resolve-config.test.ts | 8 +-- apps/api/src/settings/resolve-config.ts | 4 +- apps/api/src/settings/types.ts | 7 ++- apps/api/src/tools/organization/create.ts | 21 +++++-- packages/shared/src/tools/tool-io.ts | 7 ++- 10 files changed, 142 insertions(+), 18 deletions(-) create mode 100644 apps/api/src/auth/initial-credit.test.ts create mode 100644 apps/api/src/auth/initial-credit.ts diff --git a/apps/api/src/auth/index.ts b/apps/api/src/auth/index.ts index 786e636bfc..65b441b1c6 100644 --- a/apps/api/src/auth/index.ts +++ b/apps/api/src/auth/index.ts @@ -41,6 +41,7 @@ import { createEmailOtpConfig } from "./email-otp"; import { createEmailSender, findEmailProvider } from "./email-providers"; import { emailButton, emailParagraph, emailTemplate } from "./email-template"; import { createMagicLinkConfig } from "./magic-link"; +import { readInitialCreditCents } from "./initial-credit"; import { seedOrgDb } from "./org"; import { hoistOrgLogo } from "./hoist-org-logo"; import { identifyAuthenticatedUser } from "./posthog-identify"; @@ -240,7 +241,9 @@ const plugins = [ organization({ organizationCreation: { afterCreate: async (data) => { - await seedOrgDb(data.organization.id, data.member.userId); + await seedOrgDb(data.organization.id, data.member.userId, { + signupGrantCents: readInitialCreditCents(data.organization.metadata), + }); }, }, organizationHooks: { diff --git a/apps/api/src/auth/initial-credit.test.ts b/apps/api/src/auth/initial-credit.test.ts new file mode 100644 index 0000000000..f9602e73da --- /dev/null +++ b/apps/api/src/auth/initial-credit.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "bun:test"; +import { readInitialCreditCents } from "./initial-credit"; + +describe("readInitialCreditCents", () => { + it("reads a valid amount from an object", () => { + expect(readInitialCreditCents({ initialCreditCents: 2500 })).toBe(2500); + }); + + it("reads a valid amount from a JSON string (as Better Auth may pass it)", () => { + expect( + readInitialCreditCents(JSON.stringify({ initialCreditCents: 1000 })), + ).toBe(1000); + }); + + it("accepts 0 (explicit no-grant)", () => { + expect(readInitialCreditCents({ initialCreditCents: 0 })).toBe(0); + }); + + it("preserves other metadata keys without interfering", () => { + expect( + readInitialCreditCents({ description: "x", initialCreditCents: 500 }), + ).toBe(500); + }); + + it("falls back to undefined for non-object / missing / malformed input", () => { + for (const input of [ + undefined, + null, + "", + "not json", + 42, + [], + { other: 1 }, + ]) { + expect(readInitialCreditCents(input)).toBeUndefined(); + } + }); + + it("rejects out-of-range or non-integer amounts", () => { + for (const value of [ + -1, + 1.5, + Number.NaN, + Number.POSITIVE_INFINITY, + 100_001, + ]) { + expect( + readInitialCreditCents({ initialCreditCents: value }), + ).toBeUndefined(); + } + }); + + it("accepts exactly the cap", () => { + expect(readInitialCreditCents({ initialCreditCents: 100_000 })).toBe( + 100_000, + ); + }); +}); diff --git a/apps/api/src/auth/initial-credit.ts b/apps/api/src/auth/initial-credit.ts new file mode 100644 index 0000000000..9d08203d18 --- /dev/null +++ b/apps/api/src/auth/initial-credit.ts @@ -0,0 +1,30 @@ +import { MAX_SIGNUP_GRANT_CENTS } from "@/billing/gateway-admin"; + +/** + * Extract a control-plane-supplied initial AI-credit override (in cents) from + * an org's Better Auth metadata, which reaches the afterCreate hook as either + * an object or a raw JSON string. Returns undefined for anything that isn't a + * finite non-negative integer within the cap, so a malformed value falls back + * to the deployment default rather than granting a garbage amount. + */ +export function readInitialCreditCents(metadata: unknown): number | undefined { + let bag: unknown = metadata; + if (typeof bag === "string") { + try { + bag = JSON.parse(bag); + } catch { + return undefined; + } + } + if (typeof bag !== "object" || bag === null) return undefined; + const value = (bag as Record).initialCreditCents; + if ( + typeof value !== "number" || + !Number.isInteger(value) || + value < 0 || + value > MAX_SIGNUP_GRANT_CENTS + ) { + return undefined; + } + return value; +} diff --git a/apps/api/src/auth/org.ts b/apps/api/src/auth/org.ts index 0b14180fe1..a1cf523b0b 100644 --- a/apps/api/src/auth/org.ts +++ b/apps/api/src/auth/org.ts @@ -90,8 +90,16 @@ function getDefaultOrgMcps(organizationId: string): MCPCreationSpec[] { * Create default MCP connections for a new organization * This is deferred to run after the Better Auth request completes * to avoid deadlocks when issuing tokens + * + * `opts.signupGrantCents` overrides the deployment-default AI-credit grant for + * this org (the control-plane passes it per-org at creation); omitted → the + * `signupGrantCents` setting applies. */ -export async function seedOrgDb(organizationId: string, createdBy: string) { +export async function seedOrgDb( + organizationId: string, + createdBy: string, + opts?: { signupGrantCents?: number }, +) { try { const database = getDb(); const settings = getSettings(); @@ -195,11 +203,13 @@ export async function seedOrgDb(organizationId: string, createdBy: string) { } // Idempotent at the gateway per `signup-credit:`; fail-soft. - if (settings.signupGrantCents > 0 && gatewayAdminConfigured()) { + const signupGrantCents = + opts?.signupGrantCents ?? settings.signupGrantCents; + if (signupGrantCents > 0 && gatewayAdminConfigured()) { try { await grantGatewaySignupCredit({ organizationId, - amountCents: settings.signupGrantCents, + amountCents: signupGrantCents, }); } catch (err) { console.error("Failed to grant signup AI credit:", err); diff --git a/apps/api/src/billing/gateway-admin.ts b/apps/api/src/billing/gateway-admin.ts index 8806ef98a3..c02e73314f 100644 --- a/apps/api/src/billing/gateway-admin.ts +++ b/apps/api/src/billing/gateway-admin.ts @@ -5,6 +5,10 @@ import { getSettings } from "../settings"; +/** Fat-finger cap on any signup/initial-credit grant ($1,000). Keep in sync + * with the DECO_AI_GATEWAY_SIGNUP_GRANT_CENTS cap in resolve-config.ts. */ +export const MAX_SIGNUP_GRANT_CENTS = 100_000; + /** Whether this deployment can reach the gateway admin API at all * (self-hosted deployments can't). */ export function gatewayAdminConfigured(): boolean { diff --git a/apps/api/src/settings/resolve-config.test.ts b/apps/api/src/settings/resolve-config.test.ts index 4b4e46d88b..c523f98e07 100644 --- a/apps/api/src/settings/resolve-config.test.ts +++ b/apps/api/src/settings/resolve-config.test.ts @@ -499,15 +499,15 @@ describe("resolveConfig topup fee percent", () => { }); describe("resolveConfig signup grant cents", () => { - it("defaults to 2500 ($25)", () => { - expect(resolveConfig(flags, {}).settings.signupGrantCents).toBe(2500); + it("defaults to 2300 (nets ~$25 with the gateway's $2)", () => { + expect(resolveConfig(flags, {}).settings.signupGrantCents).toBe(2300); }); it("honors an override", () => { expect( - resolveConfig(flags, { DECO_AI_GATEWAY_SIGNUP_GRANT_CENTS: "2300" }) + resolveConfig(flags, { DECO_AI_GATEWAY_SIGNUP_GRANT_CENTS: "5000" }) .settings.signupGrantCents, - ).toBe(2300); + ).toBe(5000); }); it("allows 0 to disable the signup grant", () => { diff --git a/apps/api/src/settings/resolve-config.ts b/apps/api/src/settings/resolve-config.ts index 200640b0d5..68845252d3 100644 --- a/apps/api/src/settings/resolve-config.ts +++ b/apps/api/src/settings/resolve-config.ts @@ -261,11 +261,11 @@ export function resolveConfig( aiGatewayEnabled: toBool(envVars.DECO_AI_GATEWAY_ENABLED), aiGatewayUrl: envVars.DECO_AI_GATEWAY_URL || "https://ai-site.deco.site", aiGatewayAdminToken: envVars.DECO_AI_GATEWAY_ADMIN_TOKEN, - // Capped at $1,000 (fat-finger guard); 0 disables the signup grant. + // Default 2300 nets ~$25 with the gateway's $2; cap $1,000; 0 disables. signupGrantCents: toNonNegativeIntegerOrDefault( "DECO_AI_GATEWAY_SIGNUP_GRANT_CENTS", envVars.DECO_AI_GATEWAY_SIGNUP_GRANT_CENTS, - 2500, + 2300, 100_000, ), stripeWebhookSecret: envVars.STRIPE_WEBHOOK_SECRET, diff --git a/apps/api/src/settings/types.ts b/apps/api/src/settings/types.ts index 40da9195cd..75bbac21c9 100644 --- a/apps/api/src/settings/types.ts +++ b/apps/api/src/settings/types.ts @@ -76,9 +76,10 @@ export interface Settings { * top-up tool falls back to the gateway's own checkout. */ aiGatewayAdminToken: string | undefined; /** One-time AI credit granted to every new org on signup, in cents (default - * 2500 = $25). 0 disables the grant. Only applied when the gateway admin is - * configured (hosted deployments); self-hosted deployments can't reach the - * admin API and skip it. */ + * 2300, which nets ~$25 with the gateway's own $2 provision credit). A + * per-org override may be passed at creation (see readInitialCreditCents). + * 0 disables the grant. Only applied when the gateway admin is configured + * (hosted deployments); self-hosted can't reach the admin API and skip it. */ signupGrantCents: number; // Stripe (per-org subscription + AI-credit top-ups). Absent → webhook diff --git a/apps/api/src/tools/organization/create.ts b/apps/api/src/tools/organization/create.ts index 87a08c4c8c..9c5ab4b20f 100644 --- a/apps/api/src/tools/organization/create.ts +++ b/apps/api/src/tools/organization/create.ts @@ -8,6 +8,7 @@ import { z } from "zod"; import { defineTool } from "../../core/define-tool"; import { getUserId, requireAuth } from "../../core/studio-context"; import { isReservedOrganizationSlug } from "@decocms/shared/organization-slugs"; +import { MAX_SIGNUP_GRANT_CENTS } from "../../billing/gateway-admin"; export const ORGANIZATION_CREATE = defineTool({ name: "ORGANIZATION_CREATE" as const, @@ -32,6 +33,15 @@ export const ORGANIZATION_CREATE = defineTool({ ), name: z.string().min(1).max(255), description: z.string().optional(), + initialCreditCents: z + .number() + .int() + .min(0) + .max(MAX_SIGNUP_GRANT_CENTS) + .optional() + .describe( + "Initial Deco AI Gateway credit to grant this org, in cents (e.g. 2500 = $25). Overrides the deployment default; omit to use it. 0 grants nothing.", + ), }), outputSchema: z.object({ @@ -57,13 +67,16 @@ export const ORGANIZATION_CREATE = defineTool({ throw new Error("User ID required to create organization"); } - // Create organization via bound auth client + // initialCreditCents rides in metadata for the afterCreate seed hook. + const metadata: Record = {}; + if (input.description) metadata.description = input.description; + if (input.initialCreditCents !== undefined) { + metadata.initialCreditCents = input.initialCreditCents; + } const result = await ctx.boundAuth.organization.create({ name: input.name, slug: input.slug, - metadata: input.description - ? { description: input.description } - : undefined, + metadata: Object.keys(metadata).length > 0 ? metadata : undefined, userId, // Server-side creation }); diff --git a/packages/shared/src/tools/tool-io.ts b/packages/shared/src/tools/tool-io.ts index 309d818b6a..f173ca6391 100644 --- a/packages/shared/src/tools/tool-io.ts +++ b/packages/shared/src/tools/tool-io.ts @@ -7,7 +7,12 @@ */ export interface StudioToolIO { ORGANIZATION_CREATE: { - input: { slug: string; name: string; description?: string | undefined }; + input: { + slug: string; + name: string; + description?: string | undefined; + initialCreditCents?: number | undefined; + }; output: { id: string; name: string;