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 d1a7c2ec95..a1cf523b0b 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"; @@ -86,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(); @@ -189,6 +201,20 @@ 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. + const signupGrantCents = + opts?.signupGrantCents ?? settings.signupGrantCents; + if (signupGrantCents > 0 && gatewayAdminConfigured()) { + try { + await grantGatewaySignupCredit({ + organizationId, + amountCents: 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..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 { @@ -33,6 +37,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..c523f98e07 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 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: "5000" }) + .settings.signupGrantCents, + ).toBe(5000); + }); + + 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..68845252d3 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, + // 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, + 2300, + 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..75bbac21c9 100644 --- a/apps/api/src/settings/types.ts +++ b/apps/api/src/settings/types.ts @@ -75,6 +75,12 @@ 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 + * 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 // 503s, no checkout. 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;