From 5d759eba8d8fd16a62b17c538b5e4e731a1c7cae Mon Sep 17 00:00:00 2001 From: John Yeo Date: Sat, 18 Oct 2025 18:10:57 +0100 Subject: [PATCH] feat: new connect flow --- AGENTS.md | 30 ++ CLAUDE.md | 30 ++ bun.lock | 6 +- server/package.json | 2 +- server/register.ts | 31 ++ server/src/external/connect/connectUtils.ts | 112 +++++- .../src/external/connect/createStripeCli.ts | 68 ++-- .../src/external/connect/initMasterStripe.ts | 25 -- server/src/external/connect/initStripeCli.ts | 119 ++++++ .../connect/registerConnectWebhook.ts | 44 +++ .../stripe/handleStripeWebhookEvent.ts | 272 ++++++++++++++ .../external/stripe/stripeOnboardingUtils.ts | 4 +- server/src/external/stripe/stripeWebhooks.ts | 262 +------------ .../external/webhooks/connectWebhookRouter.ts | 288 ++------------ server/src/initHono.ts | 7 +- server/src/internal/api/apiRouter.ts | 2 +- server/src/internal/auth/UserService.ts | 11 + server/src/internal/dev/ApiKeyService.ts | 1 + server/src/internal/orgs/AuthService.ts | 34 -- server/src/internal/orgs/OrgService.ts | 121 +++++- .../orgs/handlers/handleConnectStripe_old.ts | 2 + .../internal/orgs/handlers/handleDeleteOrg.ts | 56 ++- .../orgs/handlers/handleGetInvites.ts | 5 +- .../orgs/handlers/handleGetUploadUrl.ts | 4 +- .../internal/orgs/handlers/handlePostOrg.ts | 5 +- .../stripeHandlers/handleDeleteStripe.ts | 68 +++- .../stripeHandlers/handleGetOAuthUrl.ts | 149 ++------ .../stripeHandlers/handleGetStripeAccount.ts | 19 + .../stripeHandlers/handleOAuthCallback.ts | 145 ++++++++ .../onboarding/createOnboardingProducts.ts | 17 +- .../orgs/onboarding/parseChatFeatures.ts | 22 +- server/src/internal/orgs/orgRouter.ts | 5 +- server/src/internal/orgs/orgUtils.ts | 16 +- .../internal/orgs/orgUtils/clearOrgCache.ts | 18 +- .../internal/orgs/orgUtils/convertOrgUtils.ts | 2 +- .../orgs/orgUtils/createConnectAccount.ts | 54 ++- .../internal/platform/honoPlatformRouter.ts | 3 +- .../platform/platformBeta/PLATFORM_API.md | 224 +++++++++++ .../handlers/handleCreatePlatformOrg.ts | 159 ++++++++ .../handlers/handleGetPlatformOAuth.ts | 74 ++++ .../handleUpdateOrganizationStripe.ts | 125 +++++++ .../platformBeta/platformBetaRouter.ts | 83 +++++ .../platformBeta/utils/oauthStateUtils.ts | 104 ++++++ .../platformBeta/utils/platformUtils.ts | 9 + .../platformBeta/utils/validatePlatformOrg.ts | 49 +++ .../handlers/handleListPlatformUsers.ts | 9 +- .../{ => platformLegacy}/platformRouter.ts | 14 +- server/src/utils/authUtils/afterOrgCreated.ts | 33 +- server/src/utils/constants.ts | 15 + server/test.ts | 60 +-- shared/models/orgModels/frontendOrg.ts | 8 + shared/models/orgModels/orgRelations.ts | 17 +- shared/models/orgModels/orgTable.ts | 7 +- vite/src/components/autumn/pricing-table.tsx | 22 +- vite/src/hooks/common/useAutumnFlags.tsx | 14 +- vite/src/hooks/queries/useOrgStripeQuery.tsx | 2 +- vite/src/services/OrgService.tsx | 6 +- vite/src/utils/linkUtils.ts | 13 + .../configure-stripe/ConfigureStripe.tsx | 350 +++++++++++------- .../DisconnectStripePopover.tsx | 10 +- .../views/onboarding2/ConnectStripeDialog.tsx | 46 ++- .../onboarding2/integrate/IntegrateAutumn.tsx | 2 - .../integration-steps/AddAutumnProvider.tsx | 2 +- .../integration-steps/AutumnHandler.tsx | 2 +- .../CheckoutPricingTable.tsx | 2 +- .../integrate/integration-steps/EnvStep.tsx | 2 +- .../integrate/integration-steps/Install.tsx | 2 +- 67 files changed, 2439 insertions(+), 1085 deletions(-) create mode 100644 server/register.ts delete mode 100644 server/src/external/connect/initMasterStripe.ts create mode 100644 server/src/external/connect/initStripeCli.ts create mode 100644 server/src/external/connect/registerConnectWebhook.ts create mode 100644 server/src/external/stripe/handleStripeWebhookEvent.ts create mode 100644 server/src/internal/auth/UserService.ts create mode 100644 server/src/internal/orgs/handlers/stripeHandlers/handleGetStripeAccount.ts create mode 100644 server/src/internal/orgs/handlers/stripeHandlers/handleOAuthCallback.ts create mode 100644 server/src/internal/platform/platformBeta/PLATFORM_API.md create mode 100644 server/src/internal/platform/platformBeta/handlers/handleCreatePlatformOrg.ts create mode 100644 server/src/internal/platform/platformBeta/handlers/handleGetPlatformOAuth.ts create mode 100644 server/src/internal/platform/platformBeta/handlers/handleUpdateOrganizationStripe.ts create mode 100644 server/src/internal/platform/platformBeta/platformBetaRouter.ts create mode 100644 server/src/internal/platform/platformBeta/utils/oauthStateUtils.ts create mode 100644 server/src/internal/platform/platformBeta/utils/platformUtils.ts create mode 100644 server/src/internal/platform/platformBeta/utils/validatePlatformOrg.ts rename server/src/internal/platform/{ => platformLegacy}/handlers/handleListPlatformUsers.ts (88%) rename server/src/internal/platform/{ => platformLegacy}/platformRouter.ts (94%) diff --git a/AGENTS.md b/AGENTS.md index b0243813d..5147e8811 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,6 +18,36 @@ - Functions (unless there's a very good reason) should always take in objects as arguments. Object params are named and easy to understand. +## Error Handling in API Routes +- NEVER use `c.json({ message: "...", code: "..." }, statusCode)` pattern for input validation or expected errors in Hono routes +- ALWAYS throw `RecaseError` from `@autumn/shared` for all validation errors, not found errors, forbidden errors, etc. +- For internal/unexpected errors (like missing configuration, database errors, etc.), throw `InternalError` from `@autumn/shared` +- The onError middleware automatically converts these errors to appropriate HTTP responses +- Examples: + ```typescript + // ❌ BAD - Don't do this + if (!org) { + return c.json({ message: "Org not found", code: "not_found" }, 404); + } + + // ✅ GOOD - Validation/expected errors use RecaseError + if (!org) { + throw new RecaseError({ + message: "Org not found", + code: ErrCode.NotFound, + statusCode: 404, + }); + } + + // ✅ GOOD - Internal/unexpected errors use InternalError + if (!upstash) { + throw new InternalError({ + message: "Upstash not configured", + code: "upstash_not_configured", + }); + } + ``` + ## Bad example / root -> components diff --git a/CLAUDE.md b/CLAUDE.md index f6947b667..61103c2bd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,6 +17,36 @@ - Functions (unless there's a very good reason) should always take in objects as arguments. Object params are named and easy to understand. +## Error Handling in API Routes +- NEVER use `c.json({ message: "...", code: "..." }, statusCode)` pattern for input validation or expected errors in Hono routes +- ALWAYS throw `RecaseError` from `@autumn/shared` for all validation errors, not found errors, forbidden errors, etc. +- For internal/unexpected errors (like missing configuration, database errors, etc.), throw `InternalError` from `@autumn/shared` +- The onError middleware automatically converts these errors to appropriate HTTP responses +- Examples: + ```typescript + // ❌ BAD - Don't do this + if (!org) { + return c.json({ message: "Org not found", code: "not_found" }, 404); + } + + // ✅ GOOD - Validation/expected errors use RecaseError + if (!org) { + throw new RecaseError({ + message: "Org not found", + code: ErrCode.NotFound, + statusCode: 404, + }); + } + + // ✅ GOOD - Internal/unexpected errors use InternalError + if (!upstash) { + throw new InternalError({ + message: "Upstash not configured", + code: "upstash_not_configured", + }); + } + ``` + ## Bad example / root -> components diff --git a/bun.lock b/bun.lock index cbe419e59..373e17cbb 100644 --- a/bun.lock +++ b/bun.lock @@ -92,7 +92,7 @@ "recaseai": "^0.0.37", "resend": "^4.1.1", "semver": "^7.7.2", - "stripe": "18.4.0", + "stripe": "18.4.0-beta.2", "svix": "^1.45.1", "tsc-alias": "^1.8.16", "ws": "^8.18.0", @@ -2504,7 +2504,7 @@ "strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="], - "stripe": ["stripe@18.4.0", "", { "dependencies": { "qs": "^6.11.0" }, "peerDependencies": { "@types/node": ">=12.x.x" }, "optionalPeers": ["@types/node"] }, "sha512-LKFeDnDYo4U/YzNgx2Lc9PT9XgKN0JNF1iQwZxgkS4lOw5NunWCnzyH5RhTlD3clIZnf54h7nyMWkS8VXPmtTQ=="], + "stripe": ["stripe@18.4.0-beta.2", "", { "dependencies": { "qs": "^6.11.0" }, "peerDependencies": { "@types/node": ">=12.x.x" }, "optionalPeers": ["@types/node"] }, "sha512-4MCaxGkwZcCpMpgiE+Wb4hWwKTlnZgUf4pTlvIolxdrYAA5gb6MnIEJJAowrcrSnl41FvjQP0xV4QvdN2Fq8Zw=="], "styled-jsx": ["styled-jsx@5.1.6", "", { "dependencies": { "client-only": "0.0.1" }, "peerDependencies": { "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" } }, "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA=="], @@ -2732,6 +2732,8 @@ "@better-auth/core/zod": ["zod@4.1.12", "", {}, "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ=="], + "@better-auth/stripe/stripe": ["stripe@18.5.0", "", { "dependencies": { "qs": "^6.11.0" }, "peerDependencies": { "@types/node": ">=12.x.x" }, "optionalPeers": ["@types/node"] }, "sha512-Hp+wFiEQtCB0LlNgcFh5uVyKznpDjzyUZ+CNVEf+I3fhlYvh7rZruIg+jOwzJRCpy0ZTPMjlzm7J2/M2N6d+DA=="], + "@better-auth/stripe/zod": ["zod@4.1.12", "", {}, "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ=="], "@browserbasehq/sdk/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="], diff --git a/server/package.json b/server/package.json index 0fe16b811..822447a04 100644 --- a/server/package.json +++ b/server/package.json @@ -94,7 +94,7 @@ "recaseai": "^0.0.37", "resend": "^4.1.1", "semver": "^7.7.2", - "stripe": "18.4.0", + "stripe": "18.4.0-beta.2", "svix": "^1.45.1", "tsc-alias": "^1.8.16", "ws": "^8.18.0", diff --git a/server/register.ts b/server/register.ts new file mode 100644 index 000000000..13c15dd04 --- /dev/null +++ b/server/register.ts @@ -0,0 +1,31 @@ +import "dotenv/config"; +import Stripe from "stripe"; + +const main = async () => { + const stripe = new Stripe(process.env.STRIPE_SANDBOX_SECRET_KEY || ""); + + const result = await stripe.webhookEndpoints.create({ + url: "https://api.useautumn.com/webhooks/connect/sandbox", + enabled_events: [ + "checkout.session.completed", + "customer.subscription.created", + "customer.subscription.updated", + "customer.subscription.deleted", + "customer.discount.deleted", + "invoice.paid", + "invoice.upcoming", + "invoice.created", + "invoice.finalized", + "invoice.updated", + "subscription_schedule.canceled", + "subscription_schedule.updated", + ], + connect: true, + }); + + console.log(result); +}; + +main() + .catch(console.error) + .then(() => process.exit(0)); diff --git a/server/src/external/connect/connectUtils.ts b/server/src/external/connect/connectUtils.ts index 888859f6a..fbdff6b27 100644 --- a/server/src/external/connect/connectUtils.ts +++ b/server/src/external/connect/connectUtils.ts @@ -1,4 +1,9 @@ -import { AppEnv, type Organization } from "@autumn/shared"; +import { AppEnv, InternalError, type Organization } from "@autumn/shared"; +import type { DrizzleCli } from "@/db/initDrizzle.js"; +import { OrgService } from "@/internal/orgs/OrgService.js"; +import { decryptData } from "@/utils/encryptUtils.js"; +import type { Logger } from "../logtail/logtailUtils.js"; +import { initMasterStripe } from "./initStripeCli.js"; export const orgToAccountId = ({ org, @@ -10,14 +15,107 @@ export const orgToAccountId = ({ noDefaultAccount?: boolean; }): string | undefined => { if (env === AppEnv.Sandbox) { + const config = org.test_stripe_connect; if (noDefaultAccount) { - return org.stripe_connect?.test_account_id; + return config?.account_id; } - return ( - org.stripe_connect?.test_account_id || - org.stripe_connect?.default_account_id - ); + return config?.account_id || config?.default_account_id; } else { - return org.stripe_connect?.live_account_id; + return org.live_stripe_connect?.account_id; } }; + +export const deauthorizeAccount = async ({ + accountId, + env, + logger, +}: { + accountId: string; + env: AppEnv; + logger: Logger; +}) => { + // OAuth-connected accounts must be deauthorized, not deleted + // Platform-managed accounts can be deleted + + const masterStripe = initMasterStripe({ env }); + try { + await masterStripe.oauth.deauthorize({ + client_id: + env === AppEnv.Live + ? process.env.STRIPE_LIVE_CLIENT_ID || "" + : process.env.STRIPE_SANDBOX_CLIENT_ID || "", + stripe_user_id: accountId, + }); + logger.info(`Deauthorized account ${accountId} for ${env}`); + } catch (error) { + // If deauthorization fails, the account might have already been disconnected + // or it's a platform-managed account that needs to be deleted + logger.error("Failed to deauthorize account, attempting deletion:", error); + } +}; + +export const deleteConnectedAccount = async ({ + accountId, + env, + logger, +}: { + accountId: string; + env: AppEnv; + logger: Logger; +}) => { + const masterStripe = initMasterStripe({ env }); + try { + await masterStripe.accounts.del(accountId); + logger.info(`Deleted account ${accountId} for ${env}`); + } catch (error) { + logger.error(`Failed to delete account ${accountId} for ${env}`, error); + } +}; + +export const shouldUseMaster = ({ + org, + env, +}: { + org: Organization; + env: AppEnv; +}) => { + const useMasterOrg = + env === AppEnv.Sandbox + ? Boolean(org.test_stripe_connect?.master_org_id) && + Boolean(org.test_stripe_connect?.account_id) + : Boolean(org.live_stripe_connect?.master_org_id) && + Boolean(org.live_stripe_connect?.account_id); + + if (useMasterOrg && !org.master) { + throw new InternalError({ + message: `Master organization not found for ${env} org ${org.id}`, + }); + } + + if (!useMasterOrg) return false; + + return useMasterOrg; +}; + +export const getConnectWebhookSecret = async ({ + db, + orgId, + env, +}: { + db: DrizzleCli; + orgId: string; + env: AppEnv; +}) => { + const org = await OrgService.get({ db, orgId }); + const prefix = env === AppEnv.Sandbox ? "test" : "live"; + const secret = org.stripe_config?.[`${prefix}_connect_webhook_secret`]; + + if (!secret) { + throw new InternalError({ + message: `Connect webhook secret not found for ${env} org ${orgId}`, + }); + } + + const decrypted = decryptData(secret); + return decrypted; +}; diff --git a/server/src/external/connect/createStripeCli.ts b/server/src/external/connect/createStripeCli.ts index d83355618..93fd1ec7c 100644 --- a/server/src/external/connect/createStripeCli.ts +++ b/server/src/external/connect/createStripeCli.ts @@ -1,47 +1,71 @@ import { AppEnv, ErrCode, + InternalError, type Organization, RecaseError, } from "@autumn/shared"; import Stripe from "stripe"; +import { isStripeConnected } from "@/internal/orgs/orgUtils.js"; import { decryptData } from "@/utils/encryptUtils.js"; -import { orgToAccountId } from "./connectUtils.js"; -import { initMasterStripe } from "./initMasterStripe.js"; +import { orgToAccountId, shouldUseMaster } from "./connectUtils.js"; +import { initMasterStripe, initPlatformStripe } from "./initStripeCli.js"; export const createStripeCli = ({ org, env, - // apiVersion, legacyVersion, + throughSecretKey = false, }: { org: Organization; env: AppEnv; - // apiVersion?: string; legacyVersion?: boolean; + throughSecretKey?: boolean; }) => { - // Look at test account flow first - const accountId = orgToAccountId({ org, env }); + // Try secret key first. + if (isStripeConnected({ org, env, throughSecretKey: true })) { + // Secret key flow + const encrypted = + env === AppEnv.Sandbox + ? org.stripe_config?.test_api_key + : org.stripe_config?.live_api_key; - if (accountId) return initMasterStripe({ accountId, legacyVersion }); + if (!encrypted) { + throw new RecaseError({ + message: `Please connect your Stripe ${env === AppEnv.Sandbox ? "test" : "live"} secret key. You can find it here: https://dashboard.stripe.com${env === AppEnv.Sandbox ? "/test" : ""}/apikeys`, + code: ErrCode.StripeConfigNotFound, + statusCode: 400, + }); + } - const encrypted = - env === AppEnv.Sandbox - ? org.stripe_config?.test_api_key - : org.stripe_config?.live_api_key; - - if (!encrypted) { - throw new RecaseError({ - message: `Please connect your Stripe ${env === AppEnv.Sandbox ? "test" : "live"} secret key. You can find it here: https://dashboard.stripe.com${env === AppEnv.Sandbox ? "/test" : ""}/apikeys`, - code: ErrCode.StripeConfigNotFound, - statusCode: 400, + const decrypted = decryptData(encrypted); + return new Stripe(decrypted, { + apiVersion: legacyVersion + ? ("2025-02-24.acacia" as any) + : "2025-07-30.basil", }); } - const decrypted = decryptData(encrypted); - return new Stripe(decrypted, { - apiVersion: legacyVersion - ? ("2025-02-24.acacia" as any) - : "2025-07-30.basil", + // Then try account ID + const accountId = orgToAccountId({ org, env }); + + if (accountId && !throughSecretKey) { + // Check if this org has a master_org_id (platform flow) + const useMaster = shouldUseMaster({ org, env }); + if (useMaster) { + return initPlatformStripe({ + masterOrg: org.master, + env, + accountId, + legacyVersion, + }); + } + + // Standard flow - use Autumn's master Stripe keys + return initMasterStripe({ accountId, legacyVersion, env }); + } + + throw new InternalError({ + message: `No stripe account linked to organization ${org.id}`, }); }; diff --git a/server/src/external/connect/initMasterStripe.ts b/server/src/external/connect/initMasterStripe.ts deleted file mode 100644 index 759d1d6df..000000000 --- a/server/src/external/connect/initMasterStripe.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { InternalError } from "@autumn/shared"; -import "dotenv/config"; -import Stripe from "stripe"; - -export const initMasterStripe = (params?: { - accountId?: string; - legacyVersion?: boolean; -}) => { - if (!process.env.STRIPE_SECRET_KEY) { - throw new InternalError({ - message: "STRIPE_SECRET_KEY env variable is not set", - }); - } - - if (!params) { - return new Stripe(process.env.STRIPE_SECRET_KEY || ""); - } - - return new Stripe(process.env.STRIPE_SECRET_KEY || "", { - stripeAccount: params?.accountId, - apiVersion: params?.legacyVersion - ? ("2025-02-24.acacia" as any) - : "2025-07-30.basil", - }); -}; diff --git a/server/src/external/connect/initStripeCli.ts b/server/src/external/connect/initStripeCli.ts new file mode 100644 index 000000000..ee62a5edc --- /dev/null +++ b/server/src/external/connect/initStripeCli.ts @@ -0,0 +1,119 @@ +import { + AppEnv, + InternalError, + type Organization, + RecaseError, +} from "@autumn/shared"; +import { decryptData } from "@/utils/encryptUtils.js"; +import "dotenv/config"; +import Stripe from "stripe"; +import type { DrizzleCli } from "@/db/initDrizzle.js"; +import { getConnectWebhookSecret } from "./connectUtils.js"; + +export const initMasterStripe = (params?: { + accountId?: string; + legacyVersion?: boolean; + env?: AppEnv; +}) => { + let secretKey: string; + + if (params?.env === AppEnv.Live) { + if (!process.env.STRIPE_LIVE_SECRET_KEY) { + throw new InternalError({ + message: "STRIPE_LIVE_SECRET_KEY env variable is not set", + }); + } + secretKey = process.env.STRIPE_LIVE_SECRET_KEY; + } else { + if (!process.env.STRIPE_SANDBOX_SECRET_KEY) { + throw new InternalError({ + message: "STRIPE_SANDBOX_SECRET_KEY env variable is not set", + }); + } + secretKey = process.env.STRIPE_SANDBOX_SECRET_KEY; + } + + // if (!params) { + // return new Stripe(secretKey); + // } + + return new Stripe(secretKey, { + stripeAccount: params?.accountId, + apiVersion: params?.legacyVersion + ? ("2025-02-24.acacia" as any) + : undefined, + }); +}; + +export const initPlatformStripe = ({ + masterOrg, + env, + accountId, + legacyVersion, +}: { + masterOrg: Organization | null; + env: AppEnv; + accountId?: string; + legacyVersion?: boolean; +}) => { + if (!masterOrg) { + throw new InternalError({ + message: "Master organization is undefined in initPlatformStripe", + }); + } + + // Get master org's secret key and validate access to the account + const encrypted = + env === AppEnv.Sandbox + ? masterOrg.stripe_config?.test_api_key + : masterOrg.stripe_config?.live_api_key; + + if (!encrypted) { + const envLabel = env === AppEnv.Sandbox ? "test" : "live"; + throw new RecaseError({ + message: `Master organization must have Stripe ${envLabel} secret key connected`, + }); + } + + const decrypted = decryptData(encrypted); + if (!decrypted) { + throw new InternalError({ + message: `Failed to decrypt master organization's Stripe secret key`, + }); + } + + return new Stripe(decrypted, { + stripeAccount: accountId || undefined, + apiVersion: legacyVersion ? ("2025-02-24.acacia" as any) : undefined, + }); +}; + +export const getStripeWebhookSecret = async ({ + db, + orgId, + env, +}: { + db: DrizzleCli; + orgId?: string; + env: AppEnv; +}) => { + // If org ID... + if (orgId) { + return await getConnectWebhookSecret({ db, orgId, env }); + } + + let secret: string; + if (env === AppEnv.Live) { + secret = process.env.STRIPE_LIVE_WEBHOOK_SECRET || ""; + } else { + secret = process.env.STRIPE_SANDBOX_WEBHOOK_SECRET || ""; + } + + if (!secret) { + throw new InternalError({ + message: `STRIPE_WEBHOOK_SECRET env variable is not set (${env})`, + }); + } + + return secret; +}; diff --git a/server/src/external/connect/registerConnectWebhook.ts b/server/src/external/connect/registerConnectWebhook.ts new file mode 100644 index 000000000..1b794e733 --- /dev/null +++ b/server/src/external/connect/registerConnectWebhook.ts @@ -0,0 +1,44 @@ +import type Stripe from "stripe"; +import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; +import { OrgService } from "@/internal/orgs/OrgService.js"; +import { WEBHOOK_EVENTS } from "@/utils/constants.js"; +import { encryptData } from "@/utils/encryptUtils.js"; +import { initPlatformStripe } from "./initStripeCli.js"; + +export const registerConnectWebhook = async ({ + ctx, +}: { + ctx: AutumnContext; +}) => { + const { db, org, env, logger } = ctx; + // Init master stripe + const stripeCli = initPlatformStripe({ masterOrg: org, env }); + + const curWebhookEndpoints = await stripeCli.webhookEndpoints.list(); + const backendUrl = process.env.SERVER_URL || process.env.STRIPE_WEBHOOK_URL; + + const webhookUrl = `${backendUrl}/webhooks/connect/${env}?org_id=${org.id}`; + + if (curWebhookEndpoints.data.some((webhook) => webhook.url === webhookUrl)) + return; + + const webhook = await stripeCli.webhookEndpoints.create({ + url: webhookUrl, + enabled_events: + WEBHOOK_EVENTS as Stripe.WebhookEndpointCreateParams.EnabledEvent[], + connect: true, + }); + + logger.info(`Registered connect webhook for ${org.slug} ${env}`); + + await OrgService.updateConnectWebhookSecret({ + db, + orgId: org.id, + env, + secret: encryptData(webhook.secret as string), + }); + + logger.info(`Updated connect webhook secret for ${org.slug} ${env}`); + + return webhook; +}; diff --git a/server/src/external/stripe/handleStripeWebhookEvent.ts b/server/src/external/stripe/handleStripeWebhookEvent.ts new file mode 100644 index 000000000..8f9501597 --- /dev/null +++ b/server/src/external/stripe/handleStripeWebhookEvent.ts @@ -0,0 +1,272 @@ +import { type AppEnv, type Organization } from "@autumn/shared"; +import chalk from "chalk"; +import { Stripe } from "stripe"; +import type { DrizzleCli } from "@/db/initDrizzle.js"; +import { createStripeCli } from "@/external/connect/createStripeCli.js"; +import { CusService } from "@/internal/customers/CusService.js"; +import { deleteCusCache } from "@/internal/customers/cusCache/updateCachedCus.js"; +import { unsetOrgStripeKeys } from "@/internal/orgs/orgUtils.js"; +import type { ExtendedRequest } from "@/utils/models/Request.js"; +import type { Logger } from "../logtail/logtailUtils.js"; +import { handleCheckoutSessionCompleted } from "./webhookHandlers/handleCheckoutCompleted.js"; +import { handleCusDiscountDeleted } from "./webhookHandlers/handleCusDiscountDeleted.js"; +import { handleInvoiceCreated } from "./webhookHandlers/handleInvoiceCreated/handleInvoiceCreated.js"; +import { handleInvoiceFinalized } from "./webhookHandlers/handleInvoiceFinalized.js"; +import { handleInvoicePaid } from "./webhookHandlers/handleInvoicePaid.js"; +import { handleInvoiceUpdated } from "./webhookHandlers/handleInvoiceUpdated.js"; +import { handleSubCreated } from "./webhookHandlers/handleSubCreated.js"; +import { handleSubDeleted } from "./webhookHandlers/handleSubDeleted.js"; +import { handleSubscriptionScheduleCanceled } from "./webhookHandlers/handleSubScheduleCanceled.js"; +import { handleSubscriptionUpdated } from "./webhookHandlers/handleSubUpdated.js"; + +const logStripeWebhook = ({ + logger, + org, + event, +}: { + logger: Logger; + org: Organization; + event: Stripe.Event; +}) => { + logger.info( + `${chalk.yellow("STRIPE").padEnd(18)} ${event.type.padEnd(30)} ${org.slug} | ${event.id}`, + ); +}; + +const coreEvents = [ + "customer.subscription.created", + "customer.subscription.updated", + "customer.subscription.deleted", + "invoice.paid", + "invoice.created", + "invoice.finalized", + "subscription_schedule.canceled", + "checkout.session.completed", +]; + +const handleStripeWebhookRefresh = async ({ + eventType, + data, + db, + org, + env, + logger, +}: { + eventType: string; + data: any; + db: DrizzleCli; + org: Organization; + env: AppEnv; + logger: any; +}) => { + if (coreEvents.includes(eventType)) { + const stripeCusId = data.object.customer; + if (!stripeCusId) { + logger.warn( + `stripe webhook cache refresh, object doesn't contain customer id`, + { + data: { + eventType, + object: data.object, + }, + }, + ); + return; + } + + const cus = await CusService.getByStripeId({ + db, + stripeId: stripeCusId, + }); + + if (!cus) { + logger.warn( + `Searched for customer by stripe id, but not found: ${stripeCusId}`, + ); + return; + } + + await deleteCusCache({ + db, + customerId: cus.id!, + org, + env, + }); + } +}; + +/** + * Handles Stripe webhook events after org/env extraction + */ +export const handleStripeWebhookEvent = async ({ + event, + db, + org, + env, + logger, + req, +}: { + event: Stripe.Event; + db: DrizzleCli; + org: Organization; + env: AppEnv; + logger: Logger; + req: ExtendedRequest; +}) => { + logStripeWebhook({ logger, org, event }); + + try { + const stripeCli = createStripeCli({ org, env }); + switch (event.type) { + case "customer.subscription.created": + await handleSubCreated({ + db, + org, + subData: event.data.object, + env, + logger, + }); + break; + + case "customer.subscription.updated": { + const subscription = event.data.object; + await handleSubscriptionUpdated({ + req, + db, + org, + subscription, + previousAttributes: event.data.previous_attributes, + env, + logger, + }); + break; + } + + case "customer.subscription.deleted": + await handleSubDeleted({ + req, + stripeCli, + data: event.data.object, + logger, + }); + break; + + case "checkout.session.completed": { + const checkoutSession = event.data.object; + await handleCheckoutSessionCompleted({ + req, + db, + data: checkoutSession, + org, + env, + logger, + }); + break; + } + + case "invoice.paid": { + const invoice = event.data.object; + await handleInvoicePaid({ + db, + org, + invoiceData: invoice, + env, + event, + req, + }); + break; + } + + case "invoice.updated": + await handleInvoiceUpdated({ + stripeCli, + env, + event, + req, + }); + break; + + case "invoice.created": { + const createdInvoice = event.data.object; + await handleInvoiceCreated({ + db, + org, + data: createdInvoice, + env, + logger, + }); + break; + } + + case "invoice.finalized": { + const finalizedInvoice = event.data.object; + await handleInvoiceFinalized({ + db, + org, + data: finalizedInvoice, + env, + logger, + }); + break; + } + + case "subscription_schedule.canceled": { + const canceledSchedule = event.data.object; + await handleSubscriptionScheduleCanceled({ + db, + org, + env, + schedule: canceledSchedule, + logger, + }); + break; + } + + case "customer.discount.deleted": + await handleCusDiscountDeleted({ + db, + org, + discount: event.data.object, + env, + logger, + res: req, + }); + break; + } + } catch (error) { + if (error instanceof Stripe.errors.StripeError) { + if (error.message.includes("No such customer")) { + logger.warn(`stripe customer missing: ${error.message}`); + return { success: true }; + } + + if (error.message.includes("Expired API Key provided")) { + await unsetOrgStripeKeys({ + db, + org, + env, + }); + + return { success: true }; + } + } + + logger.error(`Stripe webhook, error: ${error}`, { error }); + throw error; + } + + try { + await handleStripeWebhookRefresh({ + eventType: event.type, + data: event.data, + db, + org, + env, + logger, + }); + } catch (error) { + logger.error(`Stripe webhook, error refreshing cache!`, { error }); + } + + return { success: true }; +}; diff --git a/server/src/external/stripe/stripeOnboardingUtils.ts b/server/src/external/stripe/stripeOnboardingUtils.ts index 59a628f4f..84aa34981 100644 --- a/server/src/external/stripe/stripeOnboardingUtils.ts +++ b/server/src/external/stripe/stripeOnboardingUtils.ts @@ -1,6 +1,6 @@ -import RecaseError from "@/utils/errorUtils.js"; -import { AppEnv, ErrCode } from "@autumn/shared"; +import { type AppEnv, ErrCode } from "@autumn/shared"; import Stripe from "stripe"; +import RecaseError from "@/utils/errorUtils.js"; export const checkKeyValid = async (apiKey: string) => { const stripe = new Stripe(apiKey); diff --git a/server/src/external/stripe/stripeWebhooks.ts b/server/src/external/stripe/stripeWebhooks.ts index 0bf0bb14d..d6551f6a6 100644 --- a/server/src/external/stripe/stripeWebhooks.ts +++ b/server/src/external/stripe/stripeWebhooks.ts @@ -1,44 +1,16 @@ -import { type AppEnv, AuthType, type Organization } from "@autumn/shared"; -import chalk from "chalk"; +import { AuthType, type Organization } from "@autumn/shared"; import express, { type Router } from "express"; -import stripe, { Stripe } from "stripe"; -import type { DrizzleCli } from "@/db/initDrizzle.js"; -import { createStripeCli } from "@/external/connect/createStripeCli.js"; -import { CusService } from "@/internal/customers/CusService.js"; -import { deleteCusCache } from "@/internal/customers/cusCache/updateCachedCus.js"; +import stripe, { type Stripe } from "stripe"; import { OrgService } from "@/internal/orgs/OrgService.js"; import { getStripeWebhookSecret, isStripeConnected, - unsetOrgStripeKeys, } from "@/internal/orgs/orgUtils.js"; import { handleRequestError } from "@/utils/errorUtils.js"; -import type { ExtendedRequest } from "@/utils/models/Request.js"; -import { handleCheckoutSessionCompleted } from "./webhookHandlers/handleCheckoutCompleted.js"; -import { handleCusDiscountDeleted } from "./webhookHandlers/handleCusDiscountDeleted.js"; -import { handleInvoiceCreated } from "./webhookHandlers/handleInvoiceCreated/handleInvoiceCreated.js"; -import { handleInvoiceFinalized } from "./webhookHandlers/handleInvoiceFinalized.js"; -import { handleInvoicePaid } from "./webhookHandlers/handleInvoicePaid.js"; -import { handleInvoiceUpdated } from "./webhookHandlers/handleInvoiceUpdated.js"; -import { handleSubCreated } from "./webhookHandlers/handleSubCreated.js"; -import { handleSubDeleted } from "./webhookHandlers/handleSubDeleted.js"; -import { handleSubscriptionScheduleCanceled } from "./webhookHandlers/handleSubScheduleCanceled.js"; -import { handleSubscriptionUpdated } from "./webhookHandlers/handleSubUpdated.js"; +import { handleStripeWebhookEvent } from "./handleStripeWebhookEvent.js"; export const stripeWebhookRouter: Router = express.Router(); -const logStripeWebhook = ({ - req, - event, -}: { - req: ExtendedRequest; - event: Stripe.Event; -}) => { - req.logger.info( - `${chalk.yellow("STRIPE").padEnd(18)} ${event.type.padEnd(30)} ${req.org.slug} | ${event.id}`, - ); -}; - stripeWebhookRouter.post( "/:orgId/:env", express.raw({ type: "application/json" }), @@ -117,234 +89,24 @@ stripeWebhookRouter.post( }); const logger = request.logger; - logStripeWebhook({ req: request, event }); try { - const stripeCli = createStripeCli({ org, env }); - switch (event.type) { - case "customer.subscription.created": - await handleSubCreated({ - db, - org, - subData: event.data.object, - env, - logger, - }); - break; - - case "customer.subscription.updated": { - const subscription = event.data.object; - await handleSubscriptionUpdated({ - req: request, - db, - org, - subscription, - previousAttributes: event.data.previous_attributes, - env, - logger, - }); - break; - } - - case "customer.subscription.deleted": - await handleSubDeleted({ - req: request, - stripeCli, - data: event.data.object, - logger, - }); - break; - - case "checkout.session.completed": { - const checkoutSession = event.data.object; - await handleCheckoutSessionCompleted({ - req: request, - db, - data: checkoutSession, - org, - env, - logger, - }); - break; - } - - // Triggered when payment through Stripe is successful - case "invoice.paid": { - const invoice = event.data.object; - await handleInvoicePaid({ - db, - org, - invoiceData: invoice, - env, - event, - req: request, - }); - break; - } - - case "invoice.updated": - await handleInvoiceUpdated({ - stripeCli, - env, - event, - req: request, - }); - break; - - case "invoice.created": { - const createdInvoice = event.data.object; - await handleInvoiceCreated({ - db, - org, - data: createdInvoice, - env, - logger, - }); - break; - } - - case "invoice.finalized": { - const finalizedInvoice = event.data.object; - await handleInvoiceFinalized({ - db, - org, - data: finalizedInvoice, - env, - logger, - }); - break; - } - - case "subscription_schedule.canceled": { - const canceledSchedule = event.data.object; - await handleSubscriptionScheduleCanceled({ - db, - org, - env, - schedule: canceledSchedule, - logger, - }); - break; - } - - case "customer.discount.deleted": - await handleCusDiscountDeleted({ - db, - org, - discount: event.data.object, - env, - logger, - res: response, - }); - break; - } + await handleStripeWebhookEvent({ + event, + db, + org, + env, + logger, + req: request, + }); + response.status(200).send(); } catch (error) { - if (error instanceof Stripe.errors.StripeError) { - if (error.message.includes("No such customer")) { - logger.warn(`stripe customer missing: ${error.message}`); - response.status(200).json({ message: "ok" }); - return; - } - - if (error.message.includes("Expired API Key provided")) { - // Disconnect Stripe - await unsetOrgStripeKeys({ - db, - org, - env, - }); - - response.status(200).json({ message: "ok" }); - return; - } - } - handleRequestError({ req: request, error, res: response, action: "stripe webhook", }); - return; } - - try { - await handleStripeWebhookRefresh({ - eventType: event.type, - data: event.data, - db, - org, - env, - logger, - }); - } catch (error) { - logger.error(`Stripe webhook, error refreshing cache!`, { error }); - } - - // DO NOT DELETE -- RESPONSIBLE FOR SENDING SUCCESSFUL RESPONSE TO STRIPE... - response.status(200).send(); }, ); - -const coreEvents = [ - "customer.subscription.created", - "customer.subscription.updated", - "customer.subscription.deleted", - "invoice.paid", - "invoice.created", - "invoice.finalized", - "subscription_schedule.canceled", - "checkout.session.completed", -]; - -export const handleStripeWebhookRefresh = async ({ - eventType, - data, - db, - org, - env, - logger, -}: { - eventType: string; - data: any; - db: DrizzleCli; - org: Organization; - env: AppEnv; - logger: any; -}) => { - if (coreEvents.includes(eventType)) { - const stripeCusId = data.object.customer; - if (!stripeCusId) { - logger.warn( - `stripe webhook cache refresh, object doesn't contain customer id`, - { - data: { - eventType, - object: data.object, - }, - }, - ); - return; - } - - const cus = await CusService.getByStripeId({ - db, - stripeId: stripeCusId, - }); - - if (!cus) { - logger.warn( - `Searched for customer by stripe id, but not found: ${stripeCusId}`, - ); - return; - } - - // logger.info(`Deleting cache for customer ${cus.id}`); - await deleteCusCache({ - db, - customerId: cus.id!, - org, - env, - }); - } -}; diff --git a/server/src/external/webhooks/connectWebhookRouter.ts b/server/src/external/webhooks/connectWebhookRouter.ts index 449d81998..2bc4f5e50 100644 --- a/server/src/external/webhooks/connectWebhookRouter.ts +++ b/server/src/external/webhooks/connectWebhookRouter.ts @@ -1,54 +1,32 @@ -import { type AppEnv, AuthType, type Organization } from "@autumn/shared"; -import chalk from "chalk"; +import { type AppEnv, AuthType } from "@autumn/shared"; import express, { type Router } from "express"; import type { Context } from "hono"; -import { Stripe } from "stripe"; -import type { DrizzleCli } from "@/db/initDrizzle.js"; -import { createStripeCli } from "@/external/connect/createStripeCli.js"; -import { initMasterStripe } from "@/external/connect/initMasterStripe.js"; +import type { Stripe } from "stripe"; +import { + getStripeWebhookSecret, + initMasterStripe, +} from "@/external/connect/initStripeCli.js"; import type { HonoEnv } from "@/honoUtils/HonoEnv.js"; -import { CusService } from "@/internal/customers/CusService.js"; -import { deleteCusCache } from "@/internal/customers/cusCache/updateCachedCus.js"; import { OrgService } from "@/internal/orgs/OrgService.js"; -import { unsetOrgStripeKeys } from "@/internal/orgs/orgUtils.js"; import type { ExtendedRequest } from "@/utils/models/Request.js"; -import type { Logger } from "../logtail/logtailUtils.js"; -import { handleCheckoutSessionCompleted } from "../stripe/webhookHandlers/handleCheckoutCompleted.js"; -import { handleCusDiscountDeleted } from "../stripe/webhookHandlers/handleCusDiscountDeleted.js"; -import { handleInvoiceCreated } from "../stripe/webhookHandlers/handleInvoiceCreated/handleInvoiceCreated.js"; -import { handleInvoiceFinalized } from "../stripe/webhookHandlers/handleInvoiceFinalized.js"; -import { handleInvoicePaid } from "../stripe/webhookHandlers/handleInvoicePaid.js"; -import { handleInvoiceUpdated } from "../stripe/webhookHandlers/handleInvoiceUpdated.js"; -import { handleSubCreated } from "../stripe/webhookHandlers/handleSubCreated.js"; -import { handleSubDeleted } from "../stripe/webhookHandlers/handleSubDeleted.js"; -import { handleSubscriptionScheduleCanceled } from "../stripe/webhookHandlers/handleSubScheduleCanceled.js"; -import { handleSubscriptionUpdated } from "../stripe/webhookHandlers/handleSubUpdated.js"; +import { handleStripeWebhookEvent } from "../stripe/handleStripeWebhookEvent.js"; export const connectWebhookRouter: Router = express.Router(); -const logStripeWebhook = ({ - logger, - org, - event, -}: { - logger: Logger; - org: Organization; - event: Stripe.Event; -}) => { - logger.info( - `${chalk.yellow("STRIPE").padEnd(18)} ${event.type.padEnd(30)} ${org.slug} | ${event.id}`, - ); -}; - export const handleConnectWebhook = async (c: Context) => { const ctx = c.get("ctx"); - const { db, logger } = ctx; + const { env } = c.req.param() as { env: AppEnv }; const masterStripe = initMasterStripe(); let event: Stripe.Event; + const webhookSecret = await getStripeWebhookSecret({ + db, + orgId: c.req.query("org_id"), + env, + }); + try { - const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET || ""; const rawBody = await c.req.text(); const signature = c.req.header("stripe-signature") || ""; @@ -58,24 +36,21 @@ export const handleConnectWebhook = async (c: Context) => { webhookSecret, ); } catch (err: any) { - logger.error(`Webhook verification error: ${err.message}`, { error: err }); - return c.json({ error: err.message }, 400); + logger.error(`Webhook verification error: ${err.message}`); + return c.json({ error: err.message }, 200); } const accountId = event.account; + if (!accountId) return c.json({ error: "Account ID not found" }, 200); - if (!accountId) { - return c.json({ error: "Account ID not found" }, 400); - } - - const { org, features, env } = await OrgService.getByAccountId({ + const { org, features } = await OrgService.getByAccountId({ db, accountId, }); ctx.org = org; ctx.features = features; - ctx.env = env; + ctx.env = env as AppEnv; ctx.logger = ctx.logger.child({ context: { context: { @@ -91,225 +66,18 @@ export const handleConnectWebhook = async (c: Context) => { }, }); - logStripeWebhook({ logger, org, event }); - try { - const stripeCli = createStripeCli({ org, env }); - switch (event.type) { - case "customer.subscription.created": - await handleSubCreated({ - db, - org, - subData: event.data.object, - env, - logger, - }); - break; - - case "customer.subscription.updated": { - const subscription = event.data.object; - await handleSubscriptionUpdated({ - req: ctx as ExtendedRequest, - db, - org, - subscription, - previousAttributes: event.data.previous_attributes, - env, - logger, - }); - break; - } - - case "customer.subscription.deleted": - await handleSubDeleted({ - req: ctx as ExtendedRequest, - stripeCli, - data: event.data.object, - logger, - }); - break; - - case "checkout.session.completed": { - const checkoutSession = event.data.object; - await handleCheckoutSessionCompleted({ - req: ctx as ExtendedRequest, - db, - data: checkoutSession, - org, - env, - logger, - }); - break; - } - - // Triggered when payment through Stripe is successful - case "invoice.paid": { - const invoice = event.data.object; - await handleInvoicePaid({ - db, - org, - invoiceData: invoice, - env, - event, - req: ctx as ExtendedRequest, - }); - break; - } - - case "invoice.updated": - await handleInvoiceUpdated({ - stripeCli, - env, - event, - req: ctx as ExtendedRequest, - }); - break; - - case "invoice.created": { - const createdInvoice = event.data.object; - await handleInvoiceCreated({ - db, - org, - data: createdInvoice, - env, - logger, - }); - break; - } - - case "invoice.finalized": { - const finalizedInvoice = event.data.object; - await handleInvoiceFinalized({ - db, - org, - data: finalizedInvoice, - env, - logger, - }); - break; - } - - case "subscription_schedule.canceled": { - const canceledSchedule = event.data.object; - await handleSubscriptionScheduleCanceled({ - db, - org, - env, - schedule: canceledSchedule, - logger, - }); - break; - } - - case "customer.discount.deleted": - await handleCusDiscountDeleted({ - db, - org, - discount: event.data.object, - env, - logger, - res: ctx as ExtendedRequest, - }); - break; - } + await handleStripeWebhookEvent({ + event, + db, + org, + env: env as AppEnv, + logger, + req: ctx as ExtendedRequest, + }); + return c.json({ message: "Webhook received" }, 200); } catch (error) { - if (error instanceof Stripe.errors.StripeError) { - if (error.message.includes("No such customer")) { - logger.warn(`stripe customer missing: ${error.message}`); - return c.json({ message: "ok" }, 200); - } - - if (error.message.includes("Expired API Key provided")) { - // Disconnect Stripe - await unsetOrgStripeKeys({ - db, - org, - env, - }); - - return c.json({ message: "ok" }, 200); - } - } - logger.error(`Stripe webhook, error: ${error}`, { error }); return c.json({ message: "Internal server error" }, 500); } - - try { - await handleStripeWebhookRefresh({ - eventType: event.type, - data: event.data, - db, - org, - env, - logger, - }); - } catch (error) { - logger.error(`Stripe webhook, error refreshing cache!`, { error }); - } - - return c.json({ message: "Webhook received" }, 200); -}; - -const coreEvents = [ - "customer.subscription.created", - "customer.subscription.updated", - "customer.subscription.deleted", - "invoice.paid", - "invoice.created", - "invoice.finalized", - "subscription_schedule.canceled", - "checkout.session.completed", -]; - -export const handleStripeWebhookRefresh = async ({ - eventType, - data, - db, - org, - env, - logger, -}: { - eventType: string; - data: any; - db: DrizzleCli; - org: Organization; - env: AppEnv; - logger: any; -}) => { - if (coreEvents.includes(eventType)) { - const stripeCusId = data.object.customer; - if (!stripeCusId) { - logger.warn( - `stripe webhook cache refresh, object doesn't contain customer id`, - { - data: { - eventType, - object: data.object, - }, - }, - ); - return; - } - - const cus = await CusService.getByStripeId({ - db, - stripeId: stripeCusId, - }); - - if (!cus) { - logger.warn( - `Searched for customer by stripe id, but not found: ${stripeCusId}`, - ); - return; - } - - // logger.info(`Deleting cache for customer ${cus.id}`); - await deleteCusCache({ - db, - customerId: cus.id!, - org, - env, - }); - } }; diff --git a/server/src/initHono.ts b/server/src/initHono.ts index eca71b940..22f99cfca 100644 --- a/server/src/initHono.ts +++ b/server/src/initHono.ts @@ -13,9 +13,10 @@ import { secretKeyMiddleware } from "./honoMiddlewares/secretKeyMiddleware.js"; import { traceMiddleware } from "./honoMiddlewares/traceMiddleware.js"; import type { HonoEnv } from "./honoUtils/HonoEnv.js"; import { cusRouter } from "./internal/customers/cusRouter.js"; -import { handleOAuthCallback } from "./internal/orgs/handlers/stripeHandlers/handleGetOAuthUrl.js"; +import { handleOAuthCallback } from "./internal/orgs/handlers/stripeHandlers/handleOAuthCallback.js"; import { honoOrgRouter } from "./internal/orgs/orgRouter.js"; import { honoPlatformRouter } from "./internal/platform/honoPlatformRouter.js"; +import { platformBetaRouter } from "./internal/platform/platformBeta/platformBetaRouter.js"; import { honoProductRouter } from "./internal/products/productRouter.js"; import { auth } from "./utils/auth.js"; @@ -76,8 +77,7 @@ export const createHonoApp = () => { app.use("*", traceMiddleware); // Webhook routes (after baseMiddleware for logging, but baseMiddleware skips body parsing) - app.post("/webhooks/connect", handleConnectWebhook); - + app.post("/webhooks/connect/:env", handleConnectWebhook); app.use("/v1/*", secretKeyMiddleware); app.use("/v1/*", orgConfigMiddleware); app.use("/v1/*", apiVersionMiddleware); @@ -88,6 +88,7 @@ export const createHonoApp = () => { app.route("v1/customers", cusRouter); app.route("v1/products", honoProductRouter); app.route("v1/platform", honoPlatformRouter); + app.route("v1/platform/beta", platformBetaRouter); app.route("v1/organization", honoOrgRouter); // Error handler - must be defined after all routes and middleware diff --git a/server/src/internal/api/apiRouter.ts b/server/src/internal/api/apiRouter.ts index 0077bfa1b..c0b60695d 100644 --- a/server/src/internal/api/apiRouter.ts +++ b/server/src/internal/api/apiRouter.ts @@ -14,7 +14,7 @@ import { featureRouter } from "../features/featureRouter.js"; import { internalFeatureRouter } from "../features/internalFeatureRouter.js"; import { migrationRouter } from "../migrations/migrationRouter.js"; import { handleGetOrg } from "../orgs/handlers/handleGetOrg.js"; -import { platformRouter } from "../platform/platformRouter.js"; +import { platformRouter } from "../platform/platformLegacy/platformRouter.js"; import { productBetaRouter, productRouter } from "../products/productRouter.js"; import { componentRouter } from "./components/componentRouter.js"; import { entityRouter } from "./entities/entityRouter.js"; diff --git a/server/src/internal/auth/UserService.ts b/server/src/internal/auth/UserService.ts new file mode 100644 index 000000000..b9019f449 --- /dev/null +++ b/server/src/internal/auth/UserService.ts @@ -0,0 +1,11 @@ +import { user as userTable } from "@autumn/shared"; +import { eq } from "drizzle-orm"; +import type { DrizzleCli } from "@/db/initDrizzle.js"; + +export class UserService { + static async getByEmail({ db, email }: { db: DrizzleCli; email: string }) { + return await db.query.user.findFirst({ + where: eq(userTable.email, email), + }); + } +} diff --git a/server/src/internal/dev/ApiKeyService.ts b/server/src/internal/dev/ApiKeyService.ts index e9ae6d8df..89a7ec2ef 100644 --- a/server/src/internal/dev/ApiKeyService.ts +++ b/server/src/internal/dev/ApiKeyService.ts @@ -32,6 +32,7 @@ export class ApiKeyService { features: { where: eq(features.env, env), }, + master: true, }, }, }, diff --git a/server/src/internal/orgs/AuthService.ts b/server/src/internal/orgs/AuthService.ts index 0fddb7853..e69de29bb 100644 --- a/server/src/internal/orgs/AuthService.ts +++ b/server/src/internal/orgs/AuthService.ts @@ -1,34 +0,0 @@ -// import { db } from "@/db/initDrizzle.js"; -// import { OrgService } from "./OrgService.js"; -// import { DrizzleCli } from "@/db/initDrizzle.js"; -// import { member, organizations } from "@autumn/shared"; -// import { generateId } from "@/utils/genUtils.js"; - -// export class AuthService { -// static async createOrg({ -// db, -// name, -// slug, -// userId, -// }: { -// db: DrizzleCli; -// name: string; -// slug: string; -// userId: string; -// }) { -// // 1. Create org -// await db.insert(organizations).values({ -// id: generateId("org"), -// name, -// slug, -// createdAt: new Date(), -// }); - -// await db.insert(member).values({ -// id: generateId("mem"), -// organizationId: org.id, -// userId, -// createdAt: new Date(), -// }); -// } -// } diff --git a/server/src/internal/orgs/OrgService.ts b/server/src/internal/orgs/OrgService.ts index e34be8615..61491f055 100644 --- a/server/src/internal/orgs/OrgService.ts +++ b/server/src/internal/orgs/OrgService.ts @@ -176,9 +176,10 @@ export class OrgService { features: { where: eq(features.env, env), }, + master: true, }, })) as Organization & { - features: Feature[]; + features?: Feature[]; }; if (!result) { @@ -194,7 +195,8 @@ export class OrgService { } const org = structuredClone(result); - delete (org as any).features; + delete org.features; + return { org: { ...org, @@ -296,12 +298,15 @@ export class OrgService { const result = await db.query.organizations.findFirst({ where: or( eq( - sql`${organizations.stripe_connect}->>'default_account_id'`, + sql`${organizations.test_stripe_connect}->>'default_account_id'`, accountId, ), - eq(sql`${organizations.stripe_connect}->>'test_account_id'`, accountId), - eq(sql`${organizations.stripe_connect}->>'live_account_id'`, accountId), + eq(sql`${organizations.test_stripe_connect}->>'account_id'`, accountId), + eq(sql`${organizations.live_stripe_connect}->>'account_id'`, accountId), ), + with: { + master: true, + }, }); if (!result) { @@ -312,8 +317,8 @@ export class OrgService { }); } - const defaultAccountId = result?.stripe_connect?.default_account_id; - const testAccountId = result?.stripe_connect?.test_account_id; + const defaultAccountId = result?.test_stripe_connect?.default_account_id; + const testAccountId = result?.test_stripe_connect?.account_id; const env = defaultAccountId === accountId || testAccountId === accountId @@ -335,4 +340,106 @@ export class OrgService { env, }; } + + static async findByStripeAccountId({ + db, + accountId, + env, + }: { + db: DrizzleCli; + accountId: string; + env: AppEnv; + }): Promise { + const result = await db.query.organizations.findFirst({ + where: or( + eq(sql`${organizations.test_stripe_connect}->>'account_id'`, accountId), + eq(sql`${organizations.live_stripe_connect}->>'account_id'`, accountId), + ), + }); + + return result as Organization; + } + + /** + * Update Stripe Connect account ID for an organization + */ + static async updateStripeConnect({ + db, + orgId, + accountId, + env, + }: { + db: DrizzleCli; + orgId: string; + accountId: string; + env: AppEnv; + }): Promise { + const [org] = await db + .select() + .from(organizations) + .where(eq(organizations.id, orgId)) + .limit(1); + + if (!org) { + throw new RecaseError({ + message: "Organization not found", + code: ErrCode.OrgNotFound, + statusCode: 404, + }); + } + + if (env === AppEnv.Sandbox) { + const currentConnect = org.test_stripe_connect || {}; + await db + .update(organizations) + .set({ + test_stripe_connect: { + ...currentConnect, + account_id: accountId, + }, + }) + .where(eq(organizations.id, orgId)); + } else { + const currentConnect = org.live_stripe_connect || {}; + await db + .update(organizations) + .set({ + live_stripe_connect: { + ...currentConnect, + account_id: accountId, + }, + }) + .where(eq(organizations.id, orgId)); + } + + await clearOrgCache({ db, orgId }); + } + + static async updateConnectWebhookSecret({ + db, + orgId, + env, + secret, + }: { + db: DrizzleCli; + orgId: string; + env: AppEnv; + secret: string; + }) { + const prefix = env === AppEnv.Sandbox ? "test" : "live"; + const org = await OrgService.get({ db, orgId }); + console.info(`Updating connect webhook secret for ${env} org ${orgId}`); + console.info(`Secret: ${secret}`); + await db + .update(organizations) + .set({ + stripe_config: { + ...(org.stripe_config || {}), + [`${prefix}_connect_webhook_secret`]: secret, + }, + }) + .where(eq(organizations.id, orgId)); + + await clearOrgCache({ db, orgId }); + } } diff --git a/server/src/internal/orgs/handlers/handleConnectStripe_old.ts b/server/src/internal/orgs/handlers/handleConnectStripe_old.ts index 71a252bb1..d472b1cc1 100644 --- a/server/src/internal/orgs/handlers/handleConnectStripe_old.ts +++ b/server/src/internal/orgs/handlers/handleConnectStripe_old.ts @@ -225,6 +225,8 @@ export const handleGetStripe = async (req: any, res: any) => { const stripeCli = createStripeCli({ org, env: req.env }); const account_details = await stripeCli.accounts.retrieve(); + // console.log("Account details: ", account_details); + res.status(200).json(account_details); } catch (error) { handleRequestError({ req, error, res, action: "Get invoice" }); diff --git a/server/src/internal/orgs/handlers/handleDeleteOrg.ts b/server/src/internal/orgs/handlers/handleDeleteOrg.ts index 592bdcbdf..7dee8008e 100644 --- a/server/src/internal/orgs/handlers/handleDeleteOrg.ts +++ b/server/src/internal/orgs/handlers/handleDeleteOrg.ts @@ -1,8 +1,10 @@ import { AppEnv, customers, ErrCode, type Organization } from "@autumn/shared"; import { and, eq } from "drizzle-orm"; import type { Response } from "express"; -import Stripe from "stripe"; -import { initMasterStripe } from "@/external/connect/initMasterStripe.js"; +import { + deauthorizeAccount, + deleteConnectedAccount, +} from "@/external/connect/connectUtils.js"; import type { Logger } from "@/external/logtail/logtailUtils.js"; import { deleteSvixApp } from "@/external/svix/svixHelpers.js"; import RecaseError, { handleFrontendReqError } from "@/utils/errorUtils.js"; @@ -71,42 +73,28 @@ const deleteStripeAccounts = async ({ org: Organization; logger: Logger; }) => { - const stripe = initMasterStripe(); - - if (org.stripe_connect.test_account_id) { - try { - await stripe.accounts.del(org.stripe_connect.test_account_id); - } catch (error) { - if (error instanceof Stripe.errors.StripeError) { - logger.error( - `Failed to delete stripe test acocunt ID for ${org.id}, ${org.slug}. ${error.message})`, - ); - } - } + if (org.test_stripe_connect?.account_id) { + await deauthorizeAccount({ + accountId: org.test_stripe_connect.account_id, + env: AppEnv.Sandbox, + logger, + }); } - if (org.stripe_connect.live_account_id) { - try { - await stripe.accounts.del(org.stripe_connect.live_account_id); - } catch (error) { - if (error instanceof Stripe.errors.StripeError) { - logger.error( - `Failed to delete stripe live account ID for ${org.id}, ${org.slug}. ${error.message})`, - ); - } - } + if (org.live_stripe_connect?.account_id) { + await deauthorizeAccount({ + accountId: org.live_stripe_connect.account_id, + env: AppEnv.Live, + logger, + }); } - if (org.stripe_connect.default_account_id) { - try { - await stripe.accounts.del(org.stripe_connect.default_account_id); - } catch (error) { - if (error instanceof Stripe.errors.StripeError) { - logger.error( - `Failed to delete stripe default account ID for ${org.id}, ${org.slug}. ${error.message})`, - ); - } - } + if (org.test_stripe_connect?.default_account_id) { + await deleteConnectedAccount({ + accountId: org.test_stripe_connect.default_account_id, + env: AppEnv.Sandbox, + logger, + }); } }; diff --git a/server/src/internal/orgs/handlers/handleGetInvites.ts b/server/src/internal/orgs/handlers/handleGetInvites.ts index bdf9c5c4c..958b11a00 100644 --- a/server/src/internal/orgs/handlers/handleGetInvites.ts +++ b/server/src/internal/orgs/handlers/handleGetInvites.ts @@ -1,6 +1,9 @@ -import { ExtendedRequest, ExtendedResponse } from "@/utils/models/Request.js"; import { invitation, user as userTable } from "@autumn/shared"; import { and, eq, gt } from "drizzle-orm"; +import type { + ExtendedRequest, + ExtendedResponse, +} from "@/utils/models/Request.js"; export const handleGetInvites = async ( req: ExtendedRequest, diff --git a/server/src/internal/orgs/handlers/handleGetUploadUrl.ts b/server/src/internal/orgs/handlers/handleGetUploadUrl.ts index 73683f28c..cc96bfb54 100644 --- a/server/src/internal/orgs/handlers/handleGetUploadUrl.ts +++ b/server/src/internal/orgs/handlers/handleGetUploadUrl.ts @@ -1,13 +1,13 @@ +import { ErrCode } from "@autumn/shared"; import { logger } from "@/external/logtail/logtailUtils.js"; import { getUploadUrl } from "@/external/supabase/storageUtils.js"; import RecaseError, { handleFrontendReqError } from "@/utils/errorUtils.js"; -import { ErrCode } from "@autumn/shared"; export const handleGetUploadUrl = async (req: any, res: any) => { try { const { org } = req; - let path = `logo/${org.id}`; + const path = `logo/${org.id}`; if (!process.env.SUPABASE_URL || !process.env.SUPABASE_SERVICE_KEY) { logger.warn("Supabase storage not set up"); diff --git a/server/src/internal/orgs/handlers/handlePostOrg.ts b/server/src/internal/orgs/handlers/handlePostOrg.ts index 4518092ac..3a40725f2 100644 --- a/server/src/internal/orgs/handlers/handlePostOrg.ts +++ b/server/src/internal/orgs/handlers/handlePostOrg.ts @@ -1,4 +1,7 @@ -import { ExtendedRequest, ExtendedResponse } from "@/utils/models/Request.js"; +import type { + ExtendedRequest, + ExtendedResponse, +} from "@/utils/models/Request.js"; import { routeHandler } from "@/utils/routerUtils.js"; export const handlePostOrg = async (req: any, res: any) => diff --git a/server/src/internal/orgs/handlers/stripeHandlers/handleDeleteStripe.ts b/server/src/internal/orgs/handlers/stripeHandlers/handleDeleteStripe.ts index 18557f641..a1de40d14 100644 --- a/server/src/internal/orgs/handlers/stripeHandlers/handleDeleteStripe.ts +++ b/server/src/internal/orgs/handlers/stripeHandlers/handleDeleteStripe.ts @@ -6,7 +6,8 @@ import { import type { DrizzleCli } from "@/db/initDrizzle.js"; import { orgToAccountId } from "@/external/connect/connectUtils.js"; import { createStripeCli } from "@/external/connect/createStripeCli.js"; -import { initMasterStripe } from "@/external/connect/initMasterStripe.js"; +import { initMasterStripe } from "@/external/connect/initStripeCli.js"; +import type { Logger } from "@/external/logtail/logtailUtils.js"; import { createRoute } from "@/honoMiddlewares/routeHandler.js"; import { OrgService } from "../../OrgService.js"; import { clearOrgCache } from "../../orgUtils/clearOrgCache.js"; @@ -15,12 +16,14 @@ import { isStripeConnected } from "../../orgUtils.js"; export const disconnectStripe = async ({ org, env, + logger, }: { org: Organization; env: AppEnv; + logger: Logger; }) => { if (isStripeConnected({ org, env, throughSecretKey: true })) { - const stripeCli = createStripeCli({ org, env }); + const stripeCli = createStripeCli({ org, env, throughSecretKey: true }); const webhooks = await stripeCli.webhookEndpoints.list(); for (const webhook of webhooks.data) { if (webhook.url.includes(org.id) && webhook.url.includes(env)) { @@ -32,8 +35,26 @@ export const disconnectStripe = async ({ const accountId = orgToAccountId({ org, env, noDefaultAccount: true }); if (accountId) { - const masterStripe = initMasterStripe(); - await masterStripe.accounts.del(accountId); + const masterStripe = initMasterStripe({ env }); + + // OAuth-connected accounts must be deauthorized, not deleted + // Platform-managed accounts can be deleted + try { + await masterStripe.oauth.deauthorize({ + client_id: + env === AppEnv.Live + ? process.env.STRIPE_LIVE_CLIENT_ID || "" + : process.env.STRIPE_SANDBOX_CLIENT_ID || "", + stripe_user_id: accountId, + }); + } catch (error) { + // If deauthorization fails, the account might have already been disconnected + // or it's a platform-managed account that needs to be deleted + logger.error( + "Failed to deauthorize account, attempting deletion:", + error, + ); + } } }; @@ -75,7 +96,7 @@ export const handleDeleteStripe = createRoute({ }); try { - await disconnectStripe({ org, env }); + await disconnectStripe({ org, env, logger }); } catch (error) { logger.error(`Failed to disconnect stripe for ${org.id}, ${org.slug}`, { error, @@ -87,22 +108,31 @@ export const handleDeleteStripe = createRoute({ if (isStripeConnected({ org, env, throughSecretKey: true })) { await clearStripeConfig({ db, org, env }); } else if (orgToAccountId({ org, env, noDefaultAccount: true })) { - const newStripeConnect: StripeConnectConfig = - structuredClone(org.stripe_connect) || {}; - if (env === AppEnv.Sandbox) { - delete newStripeConnect.test_account_id; - } else { - delete newStripeConnect.live_account_id; - } + const newStripeConnect: StripeConnectConfig = + structuredClone(org.test_stripe_connect) || {}; + delete newStripeConnect.account_id; - await OrgService.update({ - db, - orgId: org.id, - updates: { - stripe_connect: newStripeConnect, - }, - }); + await OrgService.update({ + db, + orgId: org.id, + updates: { + test_stripe_connect: newStripeConnect, + }, + }); + } else { + const newStripeConnect: StripeConnectConfig = + structuredClone(org.live_stripe_connect) || {}; + delete newStripeConnect.account_id; + + await OrgService.update({ + db, + orgId: org.id, + updates: { + live_stripe_connect: newStripeConnect, + }, + }); + } } return c.json({}); diff --git a/server/src/internal/orgs/handlers/stripeHandlers/handleGetOAuthUrl.ts b/server/src/internal/orgs/handlers/stripeHandlers/handleGetOAuthUrl.ts index a95e097a5..5a66f69ec 100644 --- a/server/src/internal/orgs/handlers/stripeHandlers/handleGetOAuthUrl.ts +++ b/server/src/internal/orgs/handlers/stripeHandlers/handleGetOAuthUrl.ts @@ -1,30 +1,51 @@ -import { AppEnv, organizations } from "@autumn/shared"; -import { eq } from "drizzle-orm"; -import type { Context } from "hono"; -import { initDrizzle } from "@/db/initDrizzle.js"; -import { initMasterStripe } from "@/external/connect/initMasterStripe.js"; +import { AppEnv, ErrCode, RecaseError } from "@autumn/shared"; import { createRoute } from "@/honoMiddlewares/routeHandler.js"; -import type { HonoEnv } from "@/honoUtils/HonoEnv.js"; -import { clearOrgCache } from "../../orgUtils/clearOrgCache.js"; +import { generateOAuthState } from "@/internal/platform/platformBeta/utils/oauthStateUtils.js"; export const handleGetOAuthUrl = createRoute({ handler: async (c) => { const ctx = c.get("ctx"); - const { org } = ctx; + const { org, env } = ctx; + + const clientId = + env === AppEnv.Live + ? process.env.STRIPE_LIVE_CLIENT_ID + : process.env.STRIPE_SANDBOX_CLIENT_ID; + + if (!clientId) { + throw new RecaseError({ + message: `Stripe ${env === AppEnv.Live ? "live" : "test"} client ID not configured`, + code: ErrCode.InternalError, + statusCode: 500, + }); + } + + // Generate OAuth state and store in Redis + const frontendUrl = process.env.CLIENT_URL || "http://localhost:5173"; + + const redirectUri = `${frontendUrl}/dev?tab=stripe`; + + const stateKey = await generateOAuthState({ + organizationSlug: org.slug, + env: env === AppEnv.Live ? "live" : "test", + redirectUri, + masterOrgId: null, // null for standard flow + }); const baseUrl = new URL( - `https://connect.stripe.com/oauth/v2/authorize?response_type=code&client_id=${process.env.STRIPE_CLIENT_ID}&scope=read_write`, + `https://connect.stripe.com/oauth/v2/authorize?response_type=code&client_id=${clientId}&scope=read_write`, ); + let serverUrl = process.env.BETTER_AUTH_URL; + if (env === AppEnv.Live && serverUrl?.includes("localhost")) { + serverUrl = `https://express.dev.useautumn.com`; + } + // Add state + redirect_uri - baseUrl.searchParams.set("state", `${org.id}|${ctx.env}`); - // baseUrl.searchParams.set( - // "redirect_uri", - // `${process.env.BETTER_AUTH_URL}/stripe/oauth_callback`, - // ); + baseUrl.searchParams.set("state", stateKey); baseUrl.searchParams.set( "redirect_uri", - `https://express.dev.useautumn.com/stripe/oauth_callback`, + `${serverUrl}/stripe/oauth_callback`, ); return c.json({ @@ -32,101 +53,3 @@ export const handleGetOAuthUrl = createRoute({ }); }, }); - -export const handleOAuthCallback = async (c: Context) => { - const query = c.req.query(); - const { code, state, error } = query; - - // Build frontend redirect URL - const frontendUrl = process.env.CLIENT_URL || "http://localhost:5173"; - const redirectUrl = new URL(`${frontendUrl}/developer/configure-stripe`); - - // Handle OAuth error from Stripe - if (error) { - console.error("Stripe OAuth error:", error); - redirectUrl.searchParams.set("error", error); - return c.redirect(redirectUrl.toString()); - } - - // Validate required parameters - if (!code || !state) { - console.error("Missing code or state parameter"); - redirectUrl.searchParams.set("error", "missing_parameters"); - return c.redirect(redirectUrl.toString()); - } - - // Parse state to get orgId and env - const [orgId, env] = state.split("|"); - - if (!orgId || !env) { - console.error("Invalid state format"); - redirectUrl.searchParams.set("error", "invalid_state"); - return c.redirect(redirectUrl.toString()); - } - - console.log(`Org ID: ${orgId}, Env: ${env}, Code: ${code}`); - - try { - const stripe = initMasterStripe(); - - // Exchange authorization code for access token - const response = await stripe.oauth.token({ - grant_type: "authorization_code", - code, - }); - - const accountId = response.stripe_user_id; - console.log("Connected Stripe account:", accountId); - - // Get database connection - const { db } = initDrizzle(); - - // Fetch the organization - const [org] = await db - .select() - .from(organizations) - .where(eq(organizations.id, orgId)); - - if (!org) { - console.error("Organization not found:", orgId); - redirectUrl.searchParams.set("error", "org_not_found"); - return c.redirect(redirectUrl.toString()); - } - - // Update organization with connected account based on environment - const currentConnect = org.stripe_connect || { - default_account_id: "", - test_account_id: undefined, - live_account_id: undefined, - }; - - const updatedStripeConnect = { - ...currentConnect, - [env === AppEnv.Sandbox ? "test_account_id" : "live_account_id"]: - accountId, - }; - - await db - .update(organizations) - .set({ - stripe_connect: updatedStripeConnect, - }) - .where(eq(organizations.id, orgId)); - - // Clear organization cache - await clearOrgCache({ db, orgId }); - - console.log(`Successfully connected Stripe account for org ${orgId}`); - - // Redirect to success - redirectUrl.searchParams.set("success", "true"); - return c.redirect(redirectUrl.toString()); - } catch (error: unknown) { - console.error("Error in OAuth callback:", error); - redirectUrl.searchParams.set( - "error", - error instanceof Error ? error.message : "unknown_error", - ); - return c.redirect(redirectUrl.toString()); - } -}; diff --git a/server/src/internal/orgs/handlers/stripeHandlers/handleGetStripeAccount.ts b/server/src/internal/orgs/handlers/stripeHandlers/handleGetStripeAccount.ts new file mode 100644 index 000000000..1c9630769 --- /dev/null +++ b/server/src/internal/orgs/handlers/stripeHandlers/handleGetStripeAccount.ts @@ -0,0 +1,19 @@ +import { createStripeCli } from "@/external/connect/createStripeCli.js"; +import { createRoute } from "@/honoMiddlewares/routeHandler.js"; +import { isStripeConnected } from "../../orgUtils.js"; + +export const handleGetStripeAccount = createRoute({ + handler: async (c) => { + const ctx = c.get("ctx"); + const { org, env } = ctx; + + if (!isStripeConnected({ org, env })) { + return c.json(null); + } + + const stripeCli = createStripeCli({ org, env }); + const account_details = await stripeCli.accounts.retrieve(); + + return c.json(account_details); + }, +}); diff --git a/server/src/internal/orgs/handlers/stripeHandlers/handleOAuthCallback.ts b/server/src/internal/orgs/handlers/stripeHandlers/handleOAuthCallback.ts new file mode 100644 index 000000000..fd55f9603 --- /dev/null +++ b/server/src/internal/orgs/handlers/stripeHandlers/handleOAuthCallback.ts @@ -0,0 +1,145 @@ +import { AppEnv } from "@autumn/shared"; +import type { Context } from "hono"; +import { initDrizzle } from "@/db/initDrizzle.js"; +import { createStripeCli } from "@/external/connect/createStripeCli.js"; +import { initMasterStripe } from "@/external/connect/initStripeCli.js"; +import type { HonoEnv } from "@/honoUtils/HonoEnv.js"; +import { OrgService } from "@/internal/orgs/OrgService.js"; +import { consumeOAuthState } from "@/internal/platform/platformBeta/utils/oauthStateUtils.js"; + +/** + * Handles Stripe OAuth callback + * Uses Redis state for both standard and platform flows + */ +export const handleOAuthCallback = async (c: Context) => { + const query = c.req.query(); + const { code, state, error } = query; + + // Get database connection + const { db } = initDrizzle(); + + // Build frontend redirect URL (default) + const frontendUrl = process.env.CLIENT_URL || "http://localhost:5173"; + let redirectUrl = new URL(`${frontendUrl}`); + redirectUrl.searchParams.set("tab", "stripe"); + + // Handle OAuth error from Stripe + if (error) { + redirectUrl.searchParams.set("error", error); + return c.redirect(redirectUrl.toString()); + } + + // Validate required parameters + if (!code || !state) { + redirectUrl.searchParams.set("error", "missing_parameters"); + return c.redirect(redirectUrl.toString()); + } + + try { + // Consume OAuth state from Redis + const redisState = await consumeOAuthState({ stateKey: state }); + + if (!redisState) { + redirectUrl.searchParams.set("error", "invalid_state"); + return c.redirect(redirectUrl.toString()); + } + + // Extract state data + const { + organization_slug, + env: envStr, + redirect_uri, + master_org_id, + } = redisState; + const env = envStr === "live" ? AppEnv.Live : AppEnv.Sandbox; + const isPlatformFlow = master_org_id !== null; + + // Use custom redirect URI if provided (platform flow) + if (isPlatformFlow) { + redirectUrl = new URL(redirect_uri); + } else { + redirectUrl = new URL( + `${frontendUrl}${env === AppEnv.Sandbox ? "/sandbox" : ""}/dev?tab=stripe`, + ); + } + + // Fetch the organization by slug + const org = await OrgService.getBySlug({ db, slug: organization_slug }); + + if (!org) { + console.error("Organization not found:", organization_slug); + redirectUrl.searchParams.set("error", "org_not_found"); + return c.redirect(redirectUrl.toString()); + } + + const stripe = initMasterStripe({ env }); + const response = await stripe.oauth.token({ + grant_type: "authorization_code", + code, + }); + + const accountId = response.stripe_user_id; + + if (!accountId) { + console.error("Account ID not found"); + redirectUrl.searchParams.set("error", "account_id_not_found"); + return c.redirect(redirectUrl.toString()); + } + + // Check if account ID is already connected to another organization + const existingOrg = await OrgService.findByStripeAccountId({ + db, + accountId, + env, + }); + + if (existingOrg) { + console.error( + `Account ${accountId} is already connected to org ${existingOrg.id}`, + ); + + // Platform flow just returns error code + if (isPlatformFlow) { + redirectUrl.searchParams.set("error", "account_already_connected"); + return c.redirect(redirectUrl.toString()); + } + + // Standard flow returns detailed error + const master = createStripeCli({ org: existingOrg, env }); + const account = await master.accounts.retrieve(accountId); + redirectUrl.searchParams.set("error", "account_already_connected"); + redirectUrl.searchParams.set("account_id", accountId); + redirectUrl.searchParams.set("account_name", account.company?.name || ""); + redirectUrl.searchParams.set( + "connected_org_name", + existingOrg.name || "", + ); + redirectUrl.searchParams.set( + "connected_org_slug", + existingOrg.slug || "", + ); + return c.redirect(redirectUrl.toString()); + } + + // Update organization with Stripe Connect account + await OrgService.updateStripeConnect({ + db, + orgId: org.id, + accountId, + env, + }); + + console.log(`Successfully connected Stripe account for org ${org.id}`); + + // Redirect to success + redirectUrl.searchParams.set("success", "true"); + return c.redirect(redirectUrl.toString()); + } catch (error: unknown) { + console.error("Error in OAuth callback:", error); + redirectUrl.searchParams.set( + "error", + error instanceof Error ? error.message : "unknown_error", + ); + return c.redirect(redirectUrl.toString()); + } +}; diff --git a/server/src/internal/orgs/onboarding/createOnboardingProducts.ts b/server/src/internal/orgs/onboarding/createOnboardingProducts.ts index 4a7a3be12..34de26265 100644 --- a/server/src/internal/orgs/onboarding/createOnboardingProducts.ts +++ b/server/src/internal/orgs/onboarding/createOnboardingProducts.ts @@ -1,21 +1,20 @@ -import { DrizzleCli } from "@/db/initDrizzle.js"; -import { generateId, keyToTitle } from "@/utils/genUtils.js"; import { - FeatureType, AggregateType, - FeatureUsageType, - EntInterval, AllowanceType, - PriceType, BillingInterval, + EntInterval, // DB Models entitlements, - prices, + FeatureType, + FeatureUsageType, features, + PriceType, + prices, products, } from "@autumn/shared"; - import { AppEnv } from "autumn-js"; +import type { DrizzleCli } from "@/db/initDrizzle.js"; +import { generateId, keyToTitle } from "@/utils/genUtils.js"; const defaultFeatures = [ { @@ -119,7 +118,7 @@ export const createOnboardingProducts = async ({ const batchInsert = []; for (const product of defaultProducts) { const insertProduct = async (product: any) => { - let internalProductId = generateId("pr"); + const internalProductId = generateId("pr"); await db.insert(products).values({ ...product, diff --git a/server/src/internal/orgs/onboarding/parseChatFeatures.ts b/server/src/internal/orgs/onboarding/parseChatFeatures.ts index 6a0259055..9acbf0ac4 100644 --- a/server/src/internal/orgs/onboarding/parseChatFeatures.ts +++ b/server/src/internal/orgs/onboarding/parseChatFeatures.ts @@ -1,17 +1,17 @@ -import { validateMeteredConfig } from "@/internal/features/featureUtils.js"; -import { constructFeature } from "@/internal/features/utils/constructFeatureUtils.js"; -import RecaseError from "@/utils/errorUtils.js"; -import { keyToTitle } from "@/utils/genUtils.js"; import { AggregateType, AppEnv, ChatFeatureCreditSchema, - ChatResultFeature, + type ChatResultFeature, + type CreditSystemConfig, FeatureType, FeatureUsageType, - MeteredConfig, + type MeteredConfig, } from "@autumn/shared"; -import { CreditSystemConfig } from "@autumn/shared"; +import { validateMeteredConfig } from "@/internal/features/featureUtils.js"; +import { constructFeature } from "@/internal/features/utils/constructFeatureUtils.js"; +import RecaseError from "@/utils/errorUtils.js"; +import { keyToTitle } from "@/utils/genUtils.js"; const validateFeatures = (features: ChatResultFeature[]) => { features.forEach((feature) => { @@ -32,7 +32,7 @@ const validateFeatures = (features: ChatResultFeature[]) => { }); } - let meteredFeature = features.some( + const meteredFeature = features.some( (m) => m.id == item.metered_feature_id && m.id != feature.id, ); if (!meteredFeature) { @@ -58,14 +58,14 @@ export const parseChatResultFeatures = ({ validateFeatures(features); return features.map((feature) => { - let type = + const type = feature.type == "boolean" ? FeatureType.Boolean : feature.type == "credit_system" ? FeatureType.CreditSystem : FeatureType.Metered; - let config: CreditSystemConfig | MeteredConfig | undefined = undefined; + let config: CreditSystemConfig | MeteredConfig | undefined; if (type == FeatureType.CreditSystem) { config = { schema: feature.credit_schema!.map((item) => ({ @@ -89,7 +89,7 @@ export const parseChatResultFeatures = ({ }); } - let backendFeat = constructFeature({ + const backendFeat = constructFeature({ id: feature.id, name: keyToTitle(feature.id), type, diff --git a/server/src/internal/orgs/orgRouter.ts b/server/src/internal/orgs/orgRouter.ts index 9f28b15fe..8713251e1 100644 --- a/server/src/internal/orgs/orgRouter.ts +++ b/server/src/internal/orgs/orgRouter.ts @@ -1,7 +1,6 @@ import express, { type Router } from "express"; import { Hono } from "hono"; import type { HonoEnv } from "@/honoUtils/HonoEnv.js"; -import { handleGetStripe } from "./handlers/handleConnectStripe_old.js"; import { handleDeleteOrg } from "./handlers/handleDeleteOrg.js"; import { handleGetInvites } from "./handlers/handleGetInvites.js"; import { handleGetOrg } from "./handlers/handleGetOrg.js"; @@ -13,6 +12,7 @@ import { handleGetUploadUrl } from "./handlers/handleGetUploadUrl.js"; import { handleConnectStripe } from "./handlers/stripeHandlers/handleConnectStripe.js"; import { handleDeleteStripe } from "./handlers/stripeHandlers/handleDeleteStripe.js"; import { handleGetOAuthUrl } from "./handlers/stripeHandlers/handleGetOAuthUrl.js"; +import { handleGetStripeAccount } from "./handlers/stripeHandlers/handleGetStripeAccount.js"; export const orgRouter: Router = express.Router(); orgRouter.get("/members", handleGetOrgMembers); @@ -29,12 +29,11 @@ orgRouter.delete("/delete-user", async (req: any, res) => { orgRouter.get("", handleGetOrg); -orgRouter.get("/stripe", handleGetStripe); - // orgRouter.post("/stripe", handleConnectStripe); export const honoOrgRouter = new Hono(); +honoOrgRouter.get("/stripe", ...handleGetStripeAccount); honoOrgRouter.delete("/stripe", ...handleDeleteStripe); honoOrgRouter.post("/stripe", ...handleConnectStripe); honoOrgRouter.get("/stripe/oauth_url", ...handleGetOAuthUrl); diff --git a/server/src/internal/orgs/orgUtils.ts b/server/src/internal/orgs/orgUtils.ts index 9b54d72ca..96140911f 100644 --- a/server/src/internal/orgs/orgUtils.ts +++ b/server/src/internal/orgs/orgUtils.ts @@ -10,7 +10,10 @@ import { eq } from "drizzle-orm"; import Stripe from "stripe"; import type { DrizzleCli } from "@/db/initDrizzle.js"; import { CacheManager } from "@/external/caching/CacheManager.js"; -import { orgToAccountId } from "@/external/connect/connectUtils.js"; +import { + orgToAccountId, + shouldUseMaster, +} from "@/external/connect/connectUtils.js"; import { createStripeCli } from "@/external/connect/createStripeCli.js"; import { decryptData, generatePublishableKey } from "@/utils/encryptUtils.js"; import RecaseError from "@/utils/errorUtils.js"; @@ -131,7 +134,7 @@ export const deleteStripeWebhook = async ({ }) => { if (!isStripeConnected({ org, env, throughSecretKey: true })) return; - const stripeCli = createStripeCli({ org, env }); + const stripeCli = createStripeCli({ org, env, throughSecretKey: true }); const webhookEndpoints = await stripeCli.webhookEndpoints.list({ limit: 100, }); @@ -198,11 +201,19 @@ export const createOrgResponse = ({ ? "oauth" : "default"; + const throughMaster = shouldUseMaster({ org, env }); return { id: org.id, name: org.name, logo: org.logo, slug: org.slug, + master: org.master + ? { + id: org.master.id, + name: org.master.name, + slug: org.master.slug, + } + : null, // sandbox_config: { // stripe_connected: isStripeConnected({ org, env: AppEnv.Sandbox }), // default_currency: org.default_currency || "USD", @@ -217,6 +228,7 @@ export const createOrgResponse = ({ success_url: toSuccessUrl({ org, env }) || "", default_currency: org.default_currency || "usd", stripe_connection: stripeConnection, + through_master: throughMaster, created_at: new Date(org.createdAt).getTime(), test_pkey: org.test_pkey, diff --git a/server/src/internal/orgs/orgUtils/clearOrgCache.ts b/server/src/internal/orgs/orgUtils/clearOrgCache.ts index aa8eb6491..2471be677 100644 --- a/server/src/internal/orgs/orgUtils/clearOrgCache.ts +++ b/server/src/internal/orgs/orgUtils/clearOrgCache.ts @@ -1,8 +1,8 @@ -import { AppEnv } from "@autumn/shared"; -import { OrgService } from "../OrgService.js"; +import type { AppEnv } from "@autumn/shared"; +import type { DrizzleCli } from "@/db/initDrizzle.js"; import { CacheManager } from "@/external/caching/CacheManager.js"; import { CacheType } from "@/external/caching/cacheActions.js"; -import { DrizzleCli } from "@/db/initDrizzle.js"; +import { OrgService } from "../OrgService.js"; export const clearOrgCache = async ({ db, @@ -17,7 +17,7 @@ export const clearOrgCache = async ({ }) => { // 1. Get all hashed secret key and public key for org try { - let org = await OrgService.getWithKeys({ + const org = await OrgService.getWithKeys({ db, orgId, env, @@ -27,11 +27,11 @@ export const clearOrgCache = async ({ return; } - let secretKeys = org.api_keys.map((key: any) => key.hashed_key); - let publicKeys = [org.test_pkey, org.live_pkey]; + const secretKeys = org.api_keys.map((key: any) => key.hashed_key); + const publicKeys = [org.test_pkey, org.live_pkey]; - let batchDelete = []; - for (let key of secretKeys) { + const batchDelete = []; + for (const key of secretKeys) { batchDelete.push( CacheManager.invalidate({ action: CacheType.SecretKey, @@ -40,7 +40,7 @@ export const clearOrgCache = async ({ ); } - for (let key of publicKeys) { + for (const key of publicKeys) { batchDelete.push( CacheManager.invalidate({ action: CacheType.PublicKey, diff --git a/server/src/internal/orgs/orgUtils/convertOrgUtils.ts b/server/src/internal/orgs/orgUtils/convertOrgUtils.ts index 025a5cfe8..e9c735a9d 100644 --- a/server/src/internal/orgs/orgUtils/convertOrgUtils.ts +++ b/server/src/internal/orgs/orgUtils/convertOrgUtils.ts @@ -1,4 +1,4 @@ -import { AppEnv, Organization } from "@autumn/shared"; +import { AppEnv, type Organization } from "@autumn/shared"; export const toSuccessUrl = ({ org, diff --git a/server/src/internal/orgs/orgUtils/createConnectAccount.ts b/server/src/internal/orgs/orgUtils/createConnectAccount.ts index a2291c817..89cd72c4a 100644 --- a/server/src/internal/orgs/orgUtils/createConnectAccount.ts +++ b/server/src/internal/orgs/orgUtils/createConnectAccount.ts @@ -1,7 +1,8 @@ import "dotenv/config"; +import { AppEnv } from "@autumn/shared"; import type { User } from "better-auth"; import type { Organization } from "better-auth/plugins"; -import { initMasterStripe } from "@/external/connect/initMasterStripe.js"; +import { initMasterStripe } from "@/external/connect/initStripeCli.js"; export const createConnectAccount = async ({ org, @@ -10,37 +11,34 @@ export const createConnectAccount = async ({ org: Organization; user: User; }) => { - const stripe = initMasterStripe(); + // For v2 API, need to use specific API version + const stripe = initMasterStripe({ + env: AppEnv.Sandbox, + legacyVersion: false, // Ensure using latest API version + }); - const account = await stripe.accounts.create({ - business_type: "company", - email: user.email, - country: "us", - company: { - name: org.name, + console.log("Creating connect account for org:", org.name); + + // Stripe v2 API for connected accounts + const account = await stripe.v2.core.accounts.create({ + contact_email: user.email, + display_name: org.name, + dashboard: "full", + identity: { + country: "us", + }, + configuration: { + merchant: {}, + }, + defaults: { + responsibilities: { + losses_collector: "stripe", + fees_collector: "stripe", + }, }, }); - // const account = await stripe.v2.core.accounts.create({ - // contact_email: user.email, - // display_name: org.name, - - // dashboard: "full", - - // identity: { - // country: "us", - // }, - - // configuration: { - // merchant: {}, - // }, - // defaults: { - // responsibilities: { - // losses_collector: "stripe", - // fees_collector: "stripe", - // }, - // }, - // }); + console.log("Created connected account:", account.id); return account; }; diff --git a/server/src/internal/platform/honoPlatformRouter.ts b/server/src/internal/platform/honoPlatformRouter.ts index 669a80783..ff8c85497 100644 --- a/server/src/internal/platform/honoPlatformRouter.ts +++ b/server/src/internal/platform/honoPlatformRouter.ts @@ -1,6 +1,5 @@ import { Hono } from "hono"; import type { HonoEnv } from "@/honoUtils/HonoEnv.js"; -import { listPlatformUsers } from "./handlers/handleListPlatformUsers.js"; /** * Hono router for platform API endpoints @@ -8,4 +7,4 @@ import { listPlatformUsers } from "./handlers/handleListPlatformUsers.js"; export const honoPlatformRouter = new Hono(); // GET /platform/users - List users created by master org -honoPlatformRouter.get("/users", ...listPlatformUsers); +// honoPlatformRouter.get("/users", ...listPlatformUsers); diff --git a/server/src/internal/platform/platformBeta/PLATFORM_API.md b/server/src/internal/platform/platformBeta/PLATFORM_API.md new file mode 100644 index 000000000..69c365487 --- /dev/null +++ b/server/src/internal/platform/platformBeta/PLATFORM_API.md @@ -0,0 +1,224 @@ +# Platform API Reference + +The Platform API allows you to manage organizations and Stripe Connect accounts on behalf of your tenants. All endpoints require platform feature access. + +## Authentication + +All Platform API endpoints require: +- Valid Autumn API key in the `Authorization` header +- Platform feature enabled for your organization + +```bash +Authorization: Bearer am_sk_test_... +``` + +--- + +## Endpoints + +### POST /v1/platform/beta/organization + +Creates a new organization for a platform tenant. Reuses existing users and organizations if they already exist. + +**Request Body:** + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `user_email` | string | Yes | Email address of the organization owner. User will be created if it doesn't exist. | +| `name` | string | Yes | Display name for the organization. | +| `slug` | string | Yes | Unique slug for the organization (will be prefixed with your org ID). | +| `env` | enum | No | Environment(s) to create API keys for: `"test"`, `"live"`, or `"both"`. Defaults to `"both"`. | + +**Response:** + +| Field | Type | Description | +|-------|------|-------------| +| `test_secret_key` | string? | Autumn test API key for the organization (if `env` is `"test"` or `"both"`). | +| `live_secret_key` | string? | Autumn live API key for the organization (if `env` is `"live"` or `"both"`). | + +**Example:** + +```bash +curl -X POST https://api.useautumn.com/v1/platform/beta/organization \ + -H "Authorization: Bearer am_sk_test_..." \ + -H "Content-Type: application/json" \ + -d '{ + "user_email": "tenant@example.com", + "name": "Tenant Organization", + "slug": "tenant-org", + "env": "both" + }' +``` + +**Response:** +```json +{ + "test_secret_key": "am_sk_test_abc123...", + "live_secret_key": "am_sk_live_xyz789..." +} +``` + +**Notes:** +- If a user with the email already exists, it will be reused +- If an organization with the slug already exists for this user, it will be reused +- The actual organization slug stored will be `{slug}_{your_org_id}` to ensure uniqueness +- Returns Autumn API keys that your tenant can use to interact with Autumn + +--- + +### POST /v1/platform/beta/oauth_url + +Generates a Stripe Connect OAuth URL for a platform organization. Use this to allow your tenants to connect their Stripe accounts. + +**Request Body:** + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `organization_slug` | string | Yes | The slug of the organization (without the org ID prefix). | +| `env` | enum | Yes | Environment: `"test"` or `"live"`. | +| `redirect_url` | string | Yes | URL to redirect to after OAuth completion. | + +**Response:** + +| Field | Type | Description | +|-------|------|-------------| +| `oauth_url` | string | Stripe Connect OAuth URL to redirect the user to. | + +**Example:** + +```bash +curl -X POST https://api.useautumn.com/v1/platform/beta/oauth_url \ + -H "Authorization: Bearer am_sk_test_..." \ + -H "Content-Type: application/json" \ + -d '{ + "organization_slug": "tenant-org", + "env": "test", + "redirect_url": "https://yourapp.com/stripe/callback" + }' +``` + +**Response:** +```json +{ + "oauth_url": "https://connect.stripe.com/oauth/v2/authorize?response_type=code&client_id=ca_xxx&scope=read_write&state=abc123&redirect_uri=https://express.dev.useautumn.com/stripe/oauth_callback" +} +``` + +**OAuth Flow:** +1. Call this endpoint to get the OAuth URL +2. Redirect your tenant to the `oauth_url` +3. User authorizes their Stripe account +4. Stripe redirects to Autumn's callback URL +5. Autumn processes the authorization and redirects to your `redirect_url` +6. Your `redirect_url` will receive query parameters: + - `success=true` or `success=false` + - `message=...` (if error occurred) + +**Notes:** +- OAuth state is stored in Upstash with 10-minute expiry +- The organization must have been created via the platform API +- After successful OAuth, the Stripe account is automatically linked to the tenant organization + +--- + +### POST /v1/platform/beta/organization/stripe + +Updates a platform organization's Stripe Connect configuration. Associates a Stripe account ID with the organization using your master Stripe credentials. + +**Request Body:** + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `organization_slug` | string | Yes | The slug of the organization (without the org ID prefix). | +| `test_account_id` | string | No* | Stripe account ID for test environment (e.g., `acct_xxx`). | +| `live_account_id` | string | No* | Stripe account ID for live environment (e.g., `acct_xxx`). | + +*At least one of `test_account_id` or `live_account_id` must be provided. + +**Response:** + +| Field | Type | Description | +|-------|------|-------------| +| `message` | string | Success message. | +| `organization.id` | string | Internal organization ID. | +| `organization.slug` | string | Organization slug (without prefix). | + +**Example:** + +```bash +curl -X POST https://api.useautumn.com/v1/platform/beta/organization/stripe \ + -H "Authorization: Bearer am_sk_test_..." \ + -H "Content-Type: application/json" \ + -d '{ + "organization_slug": "tenant-org", + "test_account_id": "acct_1234567890", + "live_account_id": "acct_0987654321" + }' +``` + +**Response:** +```json +{ + "message": "Stripe Connect configuration updated successfully", + "organization": { + "id": "org_abc123", + "slug": "tenant-org" + } +} +``` + +**Validation:** +- Your organization must have the corresponding Stripe secret key connected (test/live) +- The endpoint validates that your master Stripe account can access the provided account ID +- If validation fails, you'll receive a descriptive error message + +**Notes:** +- Use this endpoint when you want to manage Stripe accounts on behalf of your tenants using your own Stripe Connect credentials +- The `master_org_id` is automatically set to your organization ID +- All Stripe operations for the tenant will use your master Stripe credentials with the tenant's account ID +- This is an alternative to the OAuth flow for cases where you have direct access to the tenant's Stripe account ID + +--- + +## Error Responses + +All endpoints return standard error responses: + +```json +{ + "message": "Error description", + "code": "error_code" +} +``` + +### Common Error Codes: + +| Code | Status | Description | +|------|--------|-------------| +| `not_found` | 404 | Organization not found or doesn't exist. | +| `forbidden` | 403 | You don't have permission to manage this organization. | +| `invalid_input` | 400 | Invalid request parameters or missing required fields. | +| `internal_error` | 500 | Internal server error. | +| `not_allowed` | 403 | Platform feature not enabled for your organization. | + +**Example Error Response:** +```json +{ + "message": "Organization with slug 'tenant-org' not found", + "code": "not_found" +} +``` + +--- + +## Rate Limits + +Platform API endpoints share the same rate limits as other Autumn API endpoints. Contact support if you need higher rate limits. + +--- + +## Support + +For questions or issues with the Platform API, contact: +- Email: hey@useautumn.com +- Documentation: https://docs.useautumn.com diff --git a/server/src/internal/platform/platformBeta/handlers/handleCreatePlatformOrg.ts b/server/src/internal/platform/platformBeta/handlers/handleCreatePlatformOrg.ts new file mode 100644 index 000000000..0ef4a5fe8 --- /dev/null +++ b/server/src/internal/platform/platformBeta/handlers/handleCreatePlatformOrg.ts @@ -0,0 +1,159 @@ +import { + AppEnv, + member, + type Organization, + organizations, + user as userTable, +} from "@autumn/shared"; +import { generateId } from "better-auth"; +import { and, eq } from "drizzle-orm"; +import { z } from "zod/v4"; +import { createRoute } from "@/honoMiddlewares/routeHandler.js"; +import { UserService } from "@/internal/auth/UserService.js"; +import { OrgService } from "@/internal/orgs/OrgService.js"; +import { afterOrgCreated } from "@/utils/authUtils/afterOrgCreated.js"; +import { createKey } from "../../../dev/api-keys/apiKeyUtils.js"; + +const CreateOrganizationSchema = z.object({ + user_email: z.email(), + name: z.string().min(1), + slug: z.string().min(1), + env: z.enum(["test", "live", "both"]).default("both"), +}); + +/** + * Creates an organization for platform users + * - Reuses existing users and organizations + * - Creates test account via Stripe Connect + * - Returns Autumn secret keys + */ +export const handleCreatePlatformOrg = createRoute({ + body: CreateOrganizationSchema, + handler: async (c) => { + const ctx = c.get("ctx"); + const { db, org: masterOrg, logger } = ctx; + + const { user_email, name, slug, env } = c.req.valid("json"); + + // 1. Check if user with this email already exists, otherwise create + let user = await UserService.getByEmail({ + db, + email: user_email, + }); + + if (!user) { + [user] = await db + .insert(userTable) + .values({ + id: generateId(), + name: "", + email: user_email, + emailVerified: true, + createdAt: new Date(), + updatedAt: new Date(), + role: "user", + banned: false, + banReason: null, + banExpires: null, + createdBy: masterOrg.id, + }) + .returning(); + + logger.info(`Created new user: ${user.id} (${user_email})`); + } else { + logger.info( + `[Platform Beta] Found existing user with email: (${user_email})`, + ); + } + + // 2. Check if organization with this slug exists (scoped to master org) + const orgSlug = `${slug}|${masterOrg.id}`; + const existingMembership = await db + .select() + .from(member) + .innerJoin(organizations, eq(member.organizationId, organizations.id)) + .where( + and( + eq(member.userId, user.id), + eq(member.role, "owner"), + eq(organizations.slug, orgSlug), + eq(organizations.created_by, masterOrg.id), + ), + ) + .limit(1); + + const orgExists = OrgService.getBySlug({ + db, + slug: orgSlug, + }); + + let org: Organization; + if (existingMembership.length === 0) { + // Create new organization + const orgId = generateId(); + + console.log(`Creating new organization: ${orgId} (${orgSlug})`); + + [org] = await db + .insert(organizations) + .values({ + id: orgId, + slug: orgSlug, + name, + logo: "", + createdAt: new Date(), + metadata: "", + created_by: masterOrg.id, + }) + .returning(); + + // Create membership + await db.insert(member).values({ + id: generateId(), + organizationId: orgId, + userId: user.id, + role: "owner", + createdAt: new Date(), + }); + + // Initialize org (creates default Stripe test account, svix apps, etc.) + await afterOrgCreated({ org, user }); + + logger.info(`Created new organization: ${org.id} (${orgSlug})`); + } else { + org = existingMembership[0].organizations; + logger.info(`Found existing organization: ${org.id} (${orgSlug})`); + } + + // 3. Generate Autumn secret keys based on env + let test_secret_key: string | undefined; + let live_secret_key: string | undefined; + + if (env === "test" || env === "both") { + test_secret_key = await createKey({ + db, + orgId: org.id, + env: AppEnv.Sandbox, + name: "Platform API Key", + prefix: "am_sk_test", + meta: {}, + }); + } + + if (env === "live" || env === "both") { + live_secret_key = await createKey({ + db, + orgId: org.id, + env: AppEnv.Live, + name: "Platform API Key", + prefix: "am_sk_live", + meta: {}, + }); + } + + return c.json({ + test_secret_key, + live_secret_key, + }); + }, +}); diff --git a/server/src/internal/platform/platformBeta/handlers/handleGetPlatformOAuth.ts b/server/src/internal/platform/platformBeta/handlers/handleGetPlatformOAuth.ts new file mode 100644 index 000000000..2b63c9d48 --- /dev/null +++ b/server/src/internal/platform/platformBeta/handlers/handleGetPlatformOAuth.ts @@ -0,0 +1,74 @@ +import { ErrCode, RecaseError } from "@autumn/shared"; +import { z } from "zod/v4"; +import { createRoute } from "@/honoMiddlewares/routeHandler.js"; +import { generateOAuthState } from "../utils/oauthStateUtils.js"; +import { validatePlatformOrg } from "../utils/validatePlatformOrg.js"; + +const GetOAuthUrlSchema = z.object({ + organization_slug: z.string().min(1), + env: z.enum(["test", "live"]), + redirect_url: z.string(), +}); + +/** + * POST /oauth_url + * Generates Stripe OAuth URL for platform organizations + * - Validates organization ownership + * - Generates secure state key stored in Redis + * - Returns OAuth URL with state + */ +export const handleGetPlatformOAuth = createRoute({ + body: GetOAuthUrlSchema, + handler: async (c) => { + const ctx = c.get("ctx"); + const { db, org: masterOrg, logger } = ctx; + + const { organization_slug, env, redirect_url } = c.req.valid("json"); + + // Verify the organization exists and was created by this master org + const org = await validatePlatformOrg({ + db, + organizationSlug: organization_slug, + masterOrg, + }); + + // Generate OAuth state and store in Redis + const stateKey = await generateOAuthState({ + organizationSlug: org.slug, + env, + redirectUri: redirect_url, + masterOrgId: masterOrg.id, + }); + + // Get appropriate Stripe client ID based on environment + const clientId = + env === "live" + ? process.env.STRIPE_LIVE_CLIENT_ID + : process.env.STRIPE_SANDBOX_CLIENT_ID; + + if (!clientId) { + throw new RecaseError({ + message: `Stripe ${env} client ID not configured`, + code: ErrCode.InternalError, + statusCode: 500, + }); + } + + // Build OAuth URL + const oauthUrl = new URL("https://connect.stripe.com/oauth/v2/authorize"); + oauthUrl.searchParams.set("response_type", "code"); + oauthUrl.searchParams.set("client_id", clientId); + oauthUrl.searchParams.set("scope", "read_write"); + oauthUrl.searchParams.set("state", stateKey); + oauthUrl.searchParams.set( + "redirect_uri", + `${process.env.BETTER_AUTH_URL || "https://express.dev.useautumn.com"}/stripe/oauth_callback`, + ); + + logger.info(`Generated OAuth URL for platform org ${org.slug} (${env})`); + + return c.json({ + oauth_url: oauthUrl.toString(), + }); + }, +}); diff --git a/server/src/internal/platform/platformBeta/handlers/handleUpdateOrganizationStripe.ts b/server/src/internal/platform/platformBeta/handlers/handleUpdateOrganizationStripe.ts new file mode 100644 index 000000000..56c1f2e08 --- /dev/null +++ b/server/src/internal/platform/platformBeta/handlers/handleUpdateOrganizationStripe.ts @@ -0,0 +1,125 @@ +import { AppEnv, type Organization, organizations } from "@autumn/shared"; +import { eq } from "drizzle-orm"; +import { z } from "zod/v4"; +import { initPlatformStripe } from "@/external/connect/initStripeCli.js"; +import { registerConnectWebhook } from "@/external/connect/registerConnectWebhook.js"; +import { logger } from "@/external/logtail/logtailUtils.js"; +import { createRoute } from "@/honoMiddlewares/routeHandler.js"; +import { clearOrgCache } from "@/internal/orgs/orgUtils/clearOrgCache.js"; +import { validatePlatformOrg } from "../utils/validatePlatformOrg.js"; + +const UpdateOrganizationStripeSchema = z + .object({ + organization_slug: z.string().min(1), + test_account_id: z.string().optional(), + live_account_id: z.string().optional(), + }) + .refine( + (data) => data.test_account_id || data.live_account_id, + "At least one of test_account_id or live_account_id is required", + ); + +/** + * Validates that master org can access the Stripe account and updates the org's Stripe Connect config + */ +const validateAndUpdateStripeAccount = async ({ + accountId, + env, + masterOrg, + org, +}: { + accountId: string; + env: AppEnv; + masterOrg: Organization; + org: Organization; +}) => { + const stripeCli = initPlatformStripe({ + masterOrg, + env, + accountId, + }); + + const account = await stripeCli.accounts.retrieve(accountId); + logger.info(`Stripe account ${account?.id} retrieved successfully`); + + // Update the organization's Stripe Connect configuration + const currentConnect = + env === AppEnv.Sandbox ? org.test_stripe_connect : org.live_stripe_connect; + + return { + ...currentConnect, + account_id: accountId, + master_org_id: masterOrg.id, + }; +}; + +/** + * POST /organization/stripe + * Updates Stripe Connect account for a platform organization + * - Requires master org to have Stripe secret key connected + * - Validates master org can access the account + * - Stores master_org_id in the tenant org's stripe_connect config + */ +export const handleUpdateOrganizationStripe = createRoute({ + body: UpdateOrganizationStripeSchema, + handler: async (c) => { + const ctx = c.get("ctx"); + const { db, org: masterOrg, logger } = ctx; + + const { organization_slug, test_account_id, live_account_id } = + c.req.valid("json"); + + // Verify the organization exists and was created by this master org + const org = await validatePlatformOrg({ + db, + organizationSlug: organization_slug, + masterOrg, + }); + + // Validate and update Stripe accounts + const updates: { + test_stripe_connect?: any; + live_stripe_connect?: any; + } = {}; + + if (test_account_id) { + updates.test_stripe_connect = await validateAndUpdateStripeAccount({ + accountId: test_account_id, + env: AppEnv.Sandbox, + masterOrg, + org, + }); + } + + if (live_account_id) { + updates.live_stripe_connect = await validateAndUpdateStripeAccount({ + accountId: live_account_id, + env: AppEnv.Live, + masterOrg, + org, + }); + } + + await db + .update(organizations) + .set(updates) + .where(eq(organizations.id, org.id)); + + // Clear organization cache + await clearOrgCache({ db, orgId: org.id }); + + logger.info( + `Updated Stripe Connect for platform org ${org.slug}: test=${test_account_id}, live=${live_account_id}`, + ); + + await registerConnectWebhook({ ctx }); + + return c.json({ + message: "Stripe Connect configuration updated successfully", + organization: { + id: org.id, + slug: organization_slug, + }, + }); + }, +}); diff --git a/server/src/internal/platform/platformBeta/platformBetaRouter.ts b/server/src/internal/platform/platformBeta/platformBetaRouter.ts new file mode 100644 index 000000000..9fde2f9d6 --- /dev/null +++ b/server/src/internal/platform/platformBeta/platformBetaRouter.ts @@ -0,0 +1,83 @@ +import { Autumn } from "autumn-js"; +import { Hono } from "hono"; +import type { HonoEnv } from "@/honoUtils/HonoEnv.js"; +import { listPlatformUsers } from "../platformLegacy/handlers/handleListPlatformUsers.js"; +import { handleCreatePlatformOrg } from "./handlers/handleCreatePlatformOrg.js"; +import { handleGetPlatformOAuth } from "./handlers/handleGetPlatformOAuth.js"; +import { handleUpdateOrganizationStripe } from "./handlers/handleUpdateOrganizationStripe.js"; + +const platformBetaRouter = new Hono(); + +/** + * Platform authentication middleware + * Checks if the requesting organization has access to platform API + */ +platformBetaRouter.use("*", async (c, next) => { + const ctx = c.get("ctx"); + const { org, logger } = ctx; + + if (!process.env.AUTUMN_SECRET_KEY) { + return next(); + } + + try { + const autumn = new Autumn(); + const { data, error } = await autumn.check({ + customer_id: org.id, + feature_id: "platform", + }); + + if (error) { + throw error; + } + + if (!data?.allowed) { + return c.json( + { + message: + "You're not allowed to access the platform API. Please contact hey@useautumn.com to request access!", + code: "not_allowed", + }, + 403, + ); + } + + await next(); + } catch (error) { + logger.error(`Failed to check if org is allowed to access platform`, { + error, + }); + return c.json( + { + message: "Failed to check if org is allowed to access platform", + code: "internal_error", + }, + 500, + ); + } +}); + +/** + * POST /organization + * Creates a new organization for platform users + */ +platformBetaRouter.post("/organization", ...handleCreatePlatformOrg); + +/** + * POST /oauth_url + * Generates Stripe OAuth URL for platform organizations + */ +platformBetaRouter.post("/oauth_url", ...handleGetPlatformOAuth); + +/** + * POST /organization/stripe + * Updates Stripe Connect configuration for platform organization + */ +platformBetaRouter.post( + "/organization/stripe", + ...handleUpdateOrganizationStripe, +); + +platformBetaRouter.get("/users", ...listPlatformUsers); + +export { platformBetaRouter }; diff --git a/server/src/internal/platform/platformBeta/utils/oauthStateUtils.ts b/server/src/internal/platform/platformBeta/utils/oauthStateUtils.ts new file mode 100644 index 000000000..5e40d127a --- /dev/null +++ b/server/src/internal/platform/platformBeta/utils/oauthStateUtils.ts @@ -0,0 +1,104 @@ +import { randomBytes } from "node:crypto"; +import { InternalError } from "@autumn/shared"; +import { initUpstash } from "@/internal/customers/cusCache/upstashUtils.js"; + +const STATE_KEY_PREFIX = "oauth_state:"; +const STATE_EXPIRY_SECONDS = 10 * 60; // 10 minutes + +export type OAuthState = { + organization_slug: string; + env: "test" | "live"; + redirect_uri: string; + master_org_id: string | null; // null for standard flow, string for platform flow +}; + +/** + * Generates a unique OAuth state key and stores it in Upstash + * Retries up to 3 times if key already exists (race condition prevention) + */ +export const generateOAuthState = async ({ + organizationSlug, + env, + redirectUri, + masterOrgId, +}: { + organizationSlug: string; + env: "test" | "live"; + redirectUri: string; + masterOrgId: string | null; +}): Promise => { + const upstash = await initUpstash(); + if (!upstash) { + throw new InternalError({ + message: "Upstash not configured", + code: "upstash_not_configured", + }); + } + + const maxAttempts = 3; + + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + // Generate random state key + const stateKey = randomBytes(32).toString("hex"); + const redisKey = `${STATE_KEY_PREFIX}${stateKey}`; + + // Try to set the key + const stateData: OAuthState = { + organization_slug: organizationSlug, + env, + redirect_uri: redirectUri, + master_org_id: masterOrgId, + }; + + // Check if key exists first + const existing = await upstash.get(redisKey); + if (!existing) { + // Key doesn't exist, set it with expiry + await upstash.set(redisKey, stateData, { ex: STATE_EXPIRY_SECONDS }); + return stateKey; + } + + // Key already exists, retry + if (attempt < maxAttempts) { + await new Promise((resolve) => setTimeout(resolve, 50)); // Wait 50ms before retry + } + } + + throw new InternalError({ + message: + "Failed to generate unique OAuth state after 3 attempts. Please try again.", + code: "oauth_state_generation_failed", + }); +}; + +/** + * Retrieves and deletes OAuth state from Upstash + * Returns null if state doesn't exist or has expired + */ +export const consumeOAuthState = async ({ + stateKey, +}: { + stateKey: string; +}): Promise => { + const upstash = await initUpstash(); + if (!upstash) { + throw new InternalError({ + message: "Upstash not configured", + code: "upstash_not_configured", + }); + } + + const redisKey = `${STATE_KEY_PREFIX}${stateKey}`; + + // Get the data + const stateData = await upstash.get(redisKey); + + if (!stateData) { + return null; + } + + // Delete the key + await upstash.del(redisKey); + + return stateData; +}; diff --git a/server/src/internal/platform/platformBeta/utils/platformUtils.ts b/server/src/internal/platform/platformBeta/utils/platformUtils.ts new file mode 100644 index 000000000..8ce089fe7 --- /dev/null +++ b/server/src/internal/platform/platformBeta/utils/platformUtils.ts @@ -0,0 +1,9 @@ +export const getConnectedOrgSlug = ({ + orgSlug, + masterOrgId, +}: { + orgSlug: string; + masterOrgId: string; +}) => { + return `${orgSlug}|${masterOrgId}`; +}; diff --git a/server/src/internal/platform/platformBeta/utils/validatePlatformOrg.ts b/server/src/internal/platform/platformBeta/utils/validatePlatformOrg.ts new file mode 100644 index 000000000..86864d3e9 --- /dev/null +++ b/server/src/internal/platform/platformBeta/utils/validatePlatformOrg.ts @@ -0,0 +1,49 @@ +import { type Organization, organizations, RecaseError } from "@autumn/shared"; +import { and, eq } from "drizzle-orm"; +import type { DrizzleCli } from "@/db/initDrizzle.js"; +import { getConnectedOrgSlug } from "./platformUtils.js"; + +/** + * Validates that a platform organization exists and is owned by the master org + * @returns The organization if valid + * @throws RecaseError if org not found or not owned by master + */ +export const validatePlatformOrg = async ({ + db, + organizationSlug, + masterOrg, +}: { + db: DrizzleCli; + organizationSlug: string; + masterOrg: Organization; +}): Promise => { + const orgSlug = getConnectedOrgSlug({ + orgSlug: organizationSlug, + masterOrgId: masterOrg.id, + }); + + const [org] = await db + .select() + .from(organizations) + .where( + and( + eq(organizations.slug, orgSlug), + eq(organizations.created_by, masterOrg.id), + ), + ) + .limit(1); + + if (!org) { + throw new RecaseError({ + message: `Organization with slug '${organizationSlug}' not found`, + }); + } + + if (org.created_by !== masterOrg.id) { + throw new RecaseError({ + message: "You do not have permission to manage this organization", + }); + } + + return org; +}; diff --git a/server/src/internal/platform/handlers/handleListPlatformUsers.ts b/server/src/internal/platform/platformLegacy/handlers/handleListPlatformUsers.ts similarity index 88% rename from server/src/internal/platform/handlers/handleListPlatformUsers.ts rename to server/src/internal/platform/platformLegacy/handlers/handleListPlatformUsers.ts index 8045db43e..3dcd8cae7 100644 --- a/server/src/internal/platform/handlers/handleListPlatformUsers.ts +++ b/server/src/internal/platform/platformLegacy/handlers/handleListPlatformUsers.ts @@ -81,9 +81,12 @@ function cleanOrgSlug(slug: string, orgId: string): string { cleanedSlug = cleanedSlug.slice(prefix.length); } // Handle the case where slug is prepended with "slug_orgId" - const altPrefix = `_${orgId}`; - if (cleanedSlug.endsWith(altPrefix)) { - cleanedSlug = cleanedSlug.slice(0, -altPrefix.length); + const altPrefix1 = `_${orgId}`; + const altPrefix2 = `|${orgId}`; + if (cleanedSlug.endsWith(altPrefix1)) { + cleanedSlug = cleanedSlug.slice(0, -altPrefix1.length); + } else if (cleanedSlug.endsWith(altPrefix2)) { + cleanedSlug = cleanedSlug = cleanedSlug.slice(0, -altPrefix2.length); } return cleanedSlug; } diff --git a/server/src/internal/platform/platformRouter.ts b/server/src/internal/platform/platformLegacy/platformRouter.ts similarity index 94% rename from server/src/internal/platform/platformRouter.ts rename to server/src/internal/platform/platformLegacy/platformRouter.ts index ee8ec7746..c583555a5 100644 --- a/server/src/internal/platform/platformRouter.ts +++ b/server/src/internal/platform/platformLegacy/platformRouter.ts @@ -11,13 +11,12 @@ import { generateId } from "better-auth"; import { and, eq } from "drizzle-orm"; import { type NextFunction, Router } from "express"; import { z } from "zod"; +import { createKey } from "@/internal/dev/api-keys/apiKeyUtils.js"; +import { connectStripe } from "@/internal/orgs/handlers/handleConnectStripe_old.js"; +import { shouldReconnectStripe } from "@/internal/orgs/orgUtils.js"; import { afterOrgCreated } from "@/utils/authUtils/afterOrgCreated.js"; import type { ExtendedRequest } from "@/utils/models/Request.js"; import { routeHandler } from "@/utils/routerUtils.js"; -import { createKey } from "../dev/api-keys/apiKeyUtils.js"; -import { connectStripe } from "../orgs/handlers/handleConnectStripe_old.js"; - -import { shouldReconnectStripe } from "../orgs/orgUtils.js"; const platformRouter = Router(); @@ -190,7 +189,7 @@ platformRouter.post("/exchange", (req: any, res: any) => createdAt: new Date(), }); - await afterOrgCreated({ org }); + await afterOrgCreated({ org, user, createStripeAccount: false }); } else { // org = (await db.query.organizations.findFirst({ // where: eq(organizations.id, membership.organizationId), @@ -198,9 +197,10 @@ platformRouter.post("/exchange", (req: any, res: any) => org = membership.organizations as Organization; } - let sandboxKey, prodKey; + let sandboxKey: string | undefined; + let prodKey: string | undefined; - let finalStripeConfig: any = {}; + let finalStripeConfig: StripeConfig = {}; let defaultCurrency = org.default_currency || "usd"; // Connect stripe if not exists... diff --git a/server/src/utils/authUtils/afterOrgCreated.ts b/server/src/utils/authUtils/afterOrgCreated.ts index add5533ae..aa3f7a6ca 100644 --- a/server/src/utils/authUtils/afterOrgCreated.ts +++ b/server/src/utils/authUtils/afterOrgCreated.ts @@ -39,9 +39,11 @@ export const initOrgSvixApps = async ({ export const afterOrgCreated = async ({ org, user, + createStripeAccount = true, }: { org: Organization; user: User; + createStripeAccount?: boolean; }) => { logger.info(`Org created: ${org.id} (${org.slug})`); const { id, slug, createdAt } = org; @@ -56,23 +58,24 @@ export const afterOrgCreated = async ({ }); // 1. Add stripe connect config - console.log("Creating stripe connect account"); - const stripeConnectAccount = await createConnectAccount({ - org: org, - user, - }); + if (createStripeAccount) { + console.log("Creating stripe connect account"); + const stripeConnectAccount = await createConnectAccount({ + org: org, + user, + }); - console.log("Stripe connect account:", stripeConnectAccount); - await OrgService.update({ - db, - orgId: org.id, - updates: { - default_currency: "usd", - stripe_connect: { - default_account_id: stripeConnectAccount.id, + await OrgService.update({ + db, + orgId: org.id, + updates: { + default_currency: "usd", + test_stripe_connect: { + default_account_id: stripeConnectAccount.id, + }, }, - }, - }); + }); + } // 1. Create svix webhoooks const { sandboxApp, liveApp } = await initOrgSvixApps({ diff --git a/server/src/utils/constants.ts b/server/src/utils/constants.ts index 1ff42dc33..4c1471e68 100644 --- a/server/src/utils/constants.ts +++ b/server/src/utils/constants.ts @@ -23,3 +23,18 @@ export const dashboardOrigins = [ "https://staging.useautumn.com", process.env.CLIENT_URL!, ]; + +export const WEBHOOK_EVENTS = [ + "checkout.session.completed", + "customer.subscription.created", + "customer.subscription.updated", + "customer.subscription.deleted", + "customer.discount.deleted", + "invoice.paid", + "invoice.upcoming", + "invoice.created", + "invoice.finalized", + "invoice.updated", + "subscription_schedule.canceled", + "subscription_schedule.updated", +]; diff --git a/server/test.ts b/server/test.ts index 73e5dc85a..08c12af00 100644 --- a/server/test.ts +++ b/server/test.ts @@ -2,35 +2,47 @@ import "dotenv/config"; import Stripe from "stripe"; const main = async () => { - const stripe = new Stripe(process.env.STRIPE_SECRET_KEY || ""); + const stripe = new Stripe(process.env.STRIPE_SANDBOX_SECRET_KEY || ""); - const accountLink = await stripe.accountLinks.create({ - account: "acct_1SJBNiIS0TxMMCJn", - refresh_url: "https://useautumn.com/refresh", - return_url: "https://useautumn.com/return", - type: "account_onboarding", + const result = await stripe.webhookEndpoints.create({ + url: "https://express.dev.useautumn.com/webhooks/connect/sandbox", + enabled_events: [ + "checkout.session.completed", + "customer.subscription.created", + "customer.subscription.updated", + "customer.subscription.deleted", + "customer.discount.deleted", + "invoice.paid", + "invoice.upcoming", + "invoice.created", + "invoice.finalized", + "invoice.updated", + "subscription_schedule.canceled", + "subscription_schedule.updated", + ], + connect: true, }); - console.log(accountLink); + console.log(result); - // const result = await stripe.webhookEndpoints.create({ - // url: "https://express.dev.useautumn.com/webhooks/connect", - // enabled_events: [ - // "customer.subscription.created", - // "customer.subscription.updated", - // "customer.subscription.deleted", - // "checkout.session.completed", - // "invoice.paid", - // "invoice.upcoming", - // "invoice.created", - // "invoice.finalized", - // "invoice.updated", - // "subscription_schedule.canceled", - // "subscription_schedule.updated", - // "customer.discount.deleted", - // ], - // connect: true, + // const account = await stripe.v2.core.accounts.create({ + // contact_email: "johnyeo10@gmail.com", + // display_name: "John Yeo", + // dashboard: "full", + // identity: { + // country: "us", + // }, + // configuration: { + // merchant: {}, + // }, + // defaults: { + // responsibilities: { + // losses_collector: "stripe", + // fees_collector: "stripe", + // }, + // }, // }); + // console.log(account); // console.log(result); diff --git a/shared/models/orgModels/frontendOrg.ts b/shared/models/orgModels/frontendOrg.ts index 4d9569a0b..6b9d0d38f 100644 --- a/shared/models/orgModels/frontendOrg.ts +++ b/shared/models/orgModels/frontendOrg.ts @@ -13,6 +13,14 @@ export const FrontendOrgSchema = z.object({ live_pkey: z.string().nullable(), stripe_connection: z.string(), + master: z + .object({ + id: z.string(), + name: z.string(), + slug: z.string(), + }) + .nullable(), + through_master: z.boolean(), }); export type FrontendOrg = z.infer; diff --git a/shared/models/orgModels/orgRelations.ts b/shared/models/orgModels/orgRelations.ts index 9955da110..a2dbac475 100644 --- a/shared/models/orgModels/orgRelations.ts +++ b/shared/models/orgModels/orgRelations.ts @@ -4,8 +4,15 @@ import { apiKeys } from "../devModels/apiKeyTable.js"; import { features } from "../featureModels/featureTable.js"; import { organizations } from "./orgTable.js"; -export const organizationsRelations = relations(organizations, ({ many }) => ({ - api_keys: many(apiKeys), - features: many(features), - members: many(member), -})); +export const organizationsRelations = relations( + organizations, + ({ many, one }) => ({ + api_keys: many(apiKeys), + features: many(features), + members: many(member), + master: one(organizations, { + fields: [organizations.created_by], + references: [organizations.id], + }), + }), +); diff --git a/shared/models/orgModels/orgTable.ts b/shared/models/orgModels/orgTable.ts index 190a079c8..5482f266b 100644 --- a/shared/models/orgModels/orgTable.ts +++ b/shared/models/orgModels/orgTable.ts @@ -22,6 +22,9 @@ export type StripeConfig = { live_webhook_secret?: string; sandbox_success_url?: string; success_url?: string; + + test_connect_webhook_secret?: string; + live_connect_webhook_secret?: string; }; export type OrgProcessorConfig = { @@ -89,7 +92,9 @@ export const organizations = pgTable( ], ); -export type Organization = typeof organizations.$inferSelect; +export type Organization = typeof organizations.$inferSelect & { + master: Organization | null; +}; // Multi tenancy flow <-> stripe connect... // Create org in Autumn, don't need stripe connect key, we create an Autumn connect account for them. diff --git a/vite/src/components/autumn/pricing-table.tsx b/vite/src/components/autumn/pricing-table.tsx index 84522ef67..47691ecec 100644 --- a/vite/src/components/autumn/pricing-table.tsx +++ b/vite/src/components/autumn/pricing-table.tsx @@ -1,16 +1,13 @@ -import React from "react"; -import { Loader2 } from "lucide-react"; - -import { createContext, useContext, useState } from "react"; -import { cn } from "@/lib/utils"; -import { Switch } from "@/components/ui/switch"; -import { Button } from "@/components/ui/button"; -import CheckoutDialog from "@/components/autumn/checkout-dialog"; -import { getPricingTableContent } from "@/lib/autumn/pricing-table-content"; import type { Product, ProductItem } from "autumn-js"; - import { useCustomer } from "autumn-js/react"; +import { Loader2 } from "lucide-react"; +import React, { createContext, useContext, useState } from "react"; +import CheckoutDialog from "@/components/autumn/checkout-dialog"; +import { Button } from "@/components/ui/button"; +import { Switch } from "@/components/ui/switch"; import { useOrg } from "@/hooks/common/useOrg"; +import { getPricingTableContent } from "@/lib/autumn/pricing-table-content"; +import { cn } from "@/lib/utils"; export default function PricingTable({ products, @@ -68,11 +65,6 @@ export default function PricingTable({ product.scenario === "scheduled", onClick: async () => { - if (!org.stripe_connected) { - setConnectStripeOpen(true); - return; - } - if (product.id) { const result = await checkout({ productId: product.id, diff --git a/vite/src/hooks/common/useAutumnFlags.tsx b/vite/src/hooks/common/useAutumnFlags.tsx index 1d72a3ad4..6287360cd 100644 --- a/vite/src/hooks/common/useAutumnFlags.tsx +++ b/vite/src/hooks/common/useAutumnFlags.tsx @@ -1,7 +1,7 @@ -import { useEffect } from "react"; -import { notNullish } from "@/utils/genUtils"; import { useCustomer } from "autumn-js/react"; +import { useEffect } from "react"; import { useLocalStorage } from "@/hooks/common/useLocalStorage"; +import { notNullish } from "@/utils/genUtils"; export const useAutumnFlags = () => { const { customer } = useCustomer(); @@ -9,6 +9,8 @@ export const useAutumnFlags = () => { const [flags, setFlags] = useLocalStorage("autumn.flags", { pkey: false, webhooks: false, + stripe_key: false, + platform: false, }); useEffect(() => { @@ -17,16 +19,20 @@ export const useAutumnFlags = () => { const nextFlags = { pkey: notNullish(customer.features.pkey), webhooks: notNullish(customer.features.webhooks), + stripe_key: notNullish(customer.features.stripe_key), + platform: notNullish(customer.features.platform), }; // Only update storage/state when values actually change if ( flags.pkey !== nextFlags.pkey || - flags.webhooks !== nextFlags.webhooks + flags.webhooks !== nextFlags.webhooks || + flags.stripe_key !== nextFlags.stripe_key || + flags.platform !== nextFlags.platform ) { setFlags(nextFlags); } - }, [customer?.features?.pkey, customer?.features?.webhooks]); + }, [customer?.features]); return flags; }; diff --git a/vite/src/hooks/queries/useOrgStripeQuery.tsx b/vite/src/hooks/queries/useOrgStripeQuery.tsx index a0ebea4d4..a71cea7d3 100644 --- a/vite/src/hooks/queries/useOrgStripeQuery.tsx +++ b/vite/src/hooks/queries/useOrgStripeQuery.tsx @@ -11,7 +11,7 @@ export const useOrgStripeQuery = () => { const fetchStripeAccount = async () => { const { data } = await axiosInstance.get( - "/organization/stripe", + "/v1/organization/stripe", ); return data; }; diff --git a/vite/src/services/OrgService.tsx b/vite/src/services/OrgService.tsx index b14fb5543..fc00b9274 100644 --- a/vite/src/services/OrgService.tsx +++ b/vite/src/services/OrgService.tsx @@ -1,4 +1,4 @@ -import { AxiosInstance } from "axios"; +import type { AxiosInstance } from "axios"; export class OrgService { static async get(axiosInstance: AxiosInstance) { @@ -10,10 +10,10 @@ export class OrgService { } static async connectStripe(axiosInstance: AxiosInstance, data: any) { - return await axiosInstance.post(`/organization/stripe`, data); + return await axiosInstance.post(`/v1/organization/stripe`, data); } static async disconnectStripe(axiosInstance: AxiosInstance) { - return await axiosInstance.delete(`/organization/stripe`); + return await axiosInstance.delete(`/v1/organization/stripe`); } } diff --git a/vite/src/utils/linkUtils.ts b/vite/src/utils/linkUtils.ts index edd2068f2..d53996431 100644 --- a/vite/src/utils/linkUtils.ts +++ b/vite/src/utils/linkUtils.ts @@ -61,3 +61,16 @@ export const getStripeInvoiceLink = ({ const withTest = env === AppEnv.Live ? "" : "/test"; return `${baseUrl}${accountPath}${withTest}/invoices/${stripeInvoice.id || stripeInvoice.stripe_id}`; }; + +export const getStripeDashboardLink = ({ + env, + accountId, +}: { + env: AppEnv; + accountId?: string; +}) => { + const baseUrl = `https://dashboard.stripe.com`; + const accountPath = accountId ? `/${accountId}` : ""; + const withTest = env === AppEnv.Live ? "" : "/test"; + return `${baseUrl}${accountPath}${withTest}/dashboard`; +}; diff --git a/vite/src/views/developer/configure-stripe/ConfigureStripe.tsx b/vite/src/views/developer/configure-stripe/ConfigureStripe.tsx index 5734a6c81..76b11043c 100644 --- a/vite/src/views/developer/configure-stripe/ConfigureStripe.tsx +++ b/vite/src/views/developer/configure-stripe/ConfigureStripe.tsx @@ -1,39 +1,74 @@ +import { AppEnv } from "@autumn/shared"; import { useEffect, useState } from "react"; +import { useSearchParams } from "react-router"; import { toast } from "sonner"; import FieldLabel from "@/components/general/modal-components/FieldLabel"; import { PageSectionHeader } from "@/components/general/PageSectionHeader"; import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; -import { Separator } from "@/components/ui/separator"; +import { Skeleton } from "@/components/ui/skeleton"; +import { useAutumnFlags } from "@/hooks/common/useAutumnFlags"; import { useOrg } from "@/hooks/common/useOrg"; import { useOrgStripeQuery } from "@/hooks/queries/useOrgStripeQuery"; import { OrgService } from "@/services/OrgService"; import { useAxiosInstance } from "@/services/useAxiosInstance"; +import { useEnv } from "@/utils/envUtils"; import { getBackendErr } from "@/utils/genUtils"; +import { getStripeDashboardLink } from "@/utils/linkUtils"; import { CurrencySelect } from "@/views/onboarding/ConnectStripe"; +import ConnectStripeDialog from "@/views/onboarding2/ConnectStripeDialog"; +import { DisconnectStripePopover } from "./DisconnectStripePopover"; export const ConfigureStripe = () => { const { org, mutate } = useOrg(); - const { stripeAccount } = useOrgStripeQuery(); + const { stripeAccount, isLoading: isLoadingStripeAccount } = + useOrgStripeQuery(); const axiosInstance = useAxiosInstance(); + const [searchParams, setSearchParams] = useSearchParams(); + const flags = useAutumnFlags(); const [newStripeConfig, setNewStripeConfig] = useState({ success_url: org?.success_url, default_currency: org?.default_currency, - // secret_key: org?.stripe_connected ? "Stripe connected" : "", }); const [connecting, setConnecting] = useState(false); - const [disconnecting, setDisconnecting] = useState(false); + const [showConnectDialog, setShowConnectDialog] = useState(false); + const [showDuplicateDialog, setShowDuplicateDialog] = useState(false); + const env = useEnv(); + + // Check if user can paste secret keys (feature flagged) + const canPasteSecretKey = + flags.stripe_key === true || flags.platform === true; useEffect(() => { setNewStripeConfig({ success_url: org?.success_url, default_currency: org?.default_currency, - // stripe_connected: org?.stripe_connected, }); }, [org]); + useEffect(() => { + const error = searchParams.get("error"); + if (error === "account_already_connected") { + setShowDuplicateDialog(true); + } + }, [searchParams]); + const allowSave = () => { return ( newStripeConfig.success_url !== org?.success_url || @@ -71,66 +106,145 @@ export const ConfigureStripe = () => { } }; - const handleVisitDashboard = () => { - window.open( - `https://dashboard.stripe.com/${stripeAccount?.id}/test/dashboard`, - "_blank", - ); + const getConnectionStatus = () => { + const connection = org?.stripe_connection; + const accountName = + stripeAccount?.business_profile?.name || + stripeAccount?.settings?.dashboard?.display_name; + const accountId = stripeAccount?.id; + + if (connection === "secret_key") { + return { + description: `You have connected the Stripe account ${accountId}${accountName ? ` (${accountName})` : ""} via secret key.`, // Will show dashboard link in the same line + showDisconnect: true, + showConnectButtons: false, + showDefaultAccountLink: true, + }; + } + + if (connection === "oauth") { + const accountName = + stripeAccount?.business_profile?.name || + stripeAccount?.settings?.dashboard?.display_name; + const accountId = stripeAccount?.id; + return { + description: `You have connected the Stripe account ${accountId}${accountName ? ` (${accountName})` : ""} via OAuth.`, + showDisconnect: true, + showConnectButtons: false, + showDefaultAccountLink: false, + }; + } + + if (connection === "default") { + return { + description: + env === AppEnv.Live + ? "To start taking payments in Production, connect your Stripe live account below:" + : "You are using Autumn's default test account. To connect your own, click the button below", + showDisconnect: false, + showConnectButtons: true, + showDefaultAccountLink: false, // Don't show for default accounts + }; + } + + return { + description: + env === AppEnv.Live + ? "To start taking payments in Production, connect your Stripe live account below:" + : "No Stripe account connected", + showDisconnect: false, + showConnectButtons: true, + showDefaultAccountLink: false, + }; }; - const handleDisconnectStripe = async () => { - setDisconnecting(true); - try { - await axiosInstance.delete("/v1/organization/stripe"); - await mutate(); - toast.success("Successfully disconnected account from Stripe"); - } catch (error) { - toast.error(getBackendErr(error, "Failed to disconnect Stripe")); - } finally { - setDisconnecting(false); + const getDashboardUrl = () => { + const connection = org?.stripe_connection; + + if (connection === "oauth" && stripeAccount?.id) { + return getStripeDashboardLink({ + env, + accountId: stripeAccount?.id, + }); } + + // For secret_key, link to main dashboard (no account ID) + if (connection === "secret_key") { + return getStripeDashboardLink({ + env, + accountId: stripeAccount?.id, + }); + } + + return null; }; + const status = getConnectionStatus(); + const dashboardUrl = getDashboardUrl(); + return (
-
- {org.stripe_connection !== "default" ? ( -
- - + + + Connect your Stripe account + {isLoadingStripeAccount ? ( +
+ + +
+ ) : ( + status.description && ( + + {status.description} + {dashboardUrl && ( + + {" "} + Visit the Stripe dashboard{" "} + + here + + + )} + + ) + )} +
+ + +
+ {status.showConnectButtons && ( + <> + + {canPasteSecretKey && ( + + )} + + )} + + {status.showDisconnect && ( + { + await mutate(); + }} + /> + )}
- ) : ( - <> -
- - -
-
- {org.stripe_connection && ( -

- Connection: {org.stripe_connection} -

- )} - -
- - )} -
- + +
@@ -160,7 +274,6 @@ export const ConfigureStripe = () => { This currency that your prices will be created in. This setting is shared between your sandbox and production environment.

- {/* */} @@ -181,79 +294,62 @@ export const ConfigureStripe = () => { > Save - {/* {org.stripe_connected ? ( - { - await mutate(); - setNewStripeConfig({ - ...newStripeConfig, - secret_key: "", - }); - }} - /> - ) : ( -
- )} */}
+ + + + { + setShowDuplicateDialog(open); + if (!open) { + // Clear query params when closing dialog + searchParams.delete("error"); + searchParams.delete("account_id"); + searchParams.delete("account_name"); + searchParams.delete("connected_org_name"); + searchParams.delete("connected_org_slug"); + setSearchParams(searchParams); + } + }} + > + + + Account Already Connected + + The Stripe account{" "} + {searchParams.get("account_id")} + {searchParams.get("account_name") && ( + <> ({searchParams.get("account_name")}) + )}{" "} + is already connected to the Autumn organization{" "} + {searchParams.get("connected_org_name")} + {searchParams.get("connected_org_slug") && ( + <> ({searchParams.get("connected_org_slug")}) + )} + . Please disconnect it from there first before connecting to this + organization. + + + + +
); }; - -//

-// You can retrieve this from your Stripe dashboard{" "} -// -// here -// -// . -//

-// {env === AppEnv.Live && ( -//
-// -// If you want to use a restricted key -// -// -//
-//

The following scopes are needed:

-//
    -//
  • Core (read & write)
  • -//
  • Checkout (read & write)
  • -//
  • Billing (read & write)
  • -//
  • All webhooks (write)
  • -//
  • Connect → Account Links (write)
  • -//
- -//

-// In your Stripe dashboard, go to{" "} -// Developers → API keys, click{" "} -// Create restricted key, and enable the -// scopes above with the listed permissions. -//

-//
-//
-//
-// )} - -// {org.stripe_connected ? ( -// } -// /> -// ) : ( -// -// setNewStripeConfig({ -// ...newStripeConfig, -// secret_key: e.target.value, -// }) -// } -// /> -// )} diff --git a/vite/src/views/developer/configure-stripe/DisconnectStripePopover.tsx b/vite/src/views/developer/configure-stripe/DisconnectStripePopover.tsx index 664a2b5a7..16d96dd49 100644 --- a/vite/src/views/developer/configure-stripe/DisconnectStripePopover.tsx +++ b/vite/src/views/developer/configure-stripe/DisconnectStripePopover.tsx @@ -1,3 +1,5 @@ +import { useState } from "react"; +import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { @@ -5,13 +7,9 @@ import { PopoverContent, PopoverTrigger, } from "@/components/ui/popover"; -import { useOrg } from "@/hooks/common/useOrg"; -import { useListOrganizations } from "@/lib/auth-client"; import { OrgService } from "@/services/OrgService"; import { useAxiosInstance } from "@/services/useAxiosInstance"; import { getBackendErr } from "@/utils/genUtils"; -import { useState } from "react"; -import { toast } from "sonner"; export const DisconnectStripePopover = ({ onSuccess, @@ -46,9 +44,7 @@ export const DisconnectStripePopover = ({ return ( - +
diff --git a/vite/src/views/onboarding2/ConnectStripeDialog.tsx b/vite/src/views/onboarding2/ConnectStripeDialog.tsx index b7baef266..15d18fb76 100644 --- a/vite/src/views/onboarding2/ConnectStripeDialog.tsx +++ b/vite/src/views/onboarding2/ConnectStripeDialog.tsx @@ -1,5 +1,5 @@ -import { Dialog, DialogHeader, DialogTitle } from "@/components/ui/dialog"; -import { useModelPricingContext } from "./model-pricing/ModelPricingContext"; +import { useState } from "react"; +import { InfoTooltip } from "@/components/general/modal-components/InfoTooltip"; import { CustomDialogBody, @@ -7,11 +7,11 @@ import { CustomDialogFooter, } from "@/components/general/modal-components/DialogContentWrapper"; import { Button } from "@/components/ui/button"; -import { useState } from "react"; +import { Dialog, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; -import { connectStripe } from "./utils/connectStripe"; -import { useAxiosInstance } from "@/services/useAxiosInstance"; import { useOrg } from "@/hooks/common/useOrg"; +import { useAxiosInstance } from "@/services/useAxiosInstance"; +import { connectStripe } from "./utils/connectStripe"; export default function ConnectStripeDialog({ open, @@ -40,18 +40,30 @@ export default function ConnectStripeDialog({ Connect your Stripe account -

- To add a product to a customer, first connect your Stripe account. - Grab your secret key{" "} - - here - -

- {/* */} +
+ + If you want to use a restricted key + + +
+

The following scopes are needed:

+
    +
  • Core (read & write)
  • +
  • Checkout (read & write)
  • +
  • Billing (read & write)
  • +
  • All webhooks (write)
  • +
  • Connect → Account Links (write)
  • +
+ +

+ In your Stripe dashboard, go to{" "} + Developers → API keys, click{" "} + Create restricted key, and enable the scopes + above with the listed permissions. +

+
+
+
{stackSelected && queryStates.reactTypescript && ( <> - diff --git a/vite/src/views/onboarding2/integrate/integration-steps/AddAutumnProvider.tsx b/vite/src/views/onboarding2/integrate/integration-steps/AddAutumnProvider.tsx index bbb08b438..25af76fb1 100644 --- a/vite/src/views/onboarding2/integrate/integration-steps/AddAutumnProvider.tsx +++ b/vite/src/views/onboarding2/integrate/integration-steps/AddAutumnProvider.tsx @@ -113,7 +113,7 @@ export const AddAutumnProvider = () => { return (
Wrap your React app in {""} diff --git a/vite/src/views/onboarding2/integrate/integration-steps/AutumnHandler.tsx b/vite/src/views/onboarding2/integrate/integration-steps/AutumnHandler.tsx index 7d8330d31..5a1b85c18 100644 --- a/vite/src/views/onboarding2/integrate/integration-steps/AutumnHandler.tsx +++ b/vite/src/views/onboarding2/integrate/integration-steps/AutumnHandler.tsx @@ -154,7 +154,7 @@ export const AutumnHandler = () => { return (
Mount autumnHandler to your backend diff --git a/vite/src/views/onboarding2/integrate/integration-steps/CheckoutPricingTable.tsx b/vite/src/views/onboarding2/integrate/integration-steps/CheckoutPricingTable.tsx index 31c4648cd..9154af68f 100644 --- a/vite/src/views/onboarding2/integrate/integration-steps/CheckoutPricingTable.tsx +++ b/vite/src/views/onboarding2/integrate/integration-steps/CheckoutPricingTable.tsx @@ -28,7 +28,7 @@ export const CheckoutPricingTable = () => { return (
Drop in {""} diff --git a/vite/src/views/onboarding2/integrate/integration-steps/EnvStep.tsx b/vite/src/views/onboarding2/integrate/integration-steps/EnvStep.tsx index b9934ecbd..60214406d 100644 --- a/vite/src/views/onboarding2/integrate/integration-steps/EnvStep.tsx +++ b/vite/src/views/onboarding2/integrate/integration-steps/EnvStep.tsx @@ -10,7 +10,7 @@ export const EnvStep = () => { <>
Add the Autumn secret key to your {".env"}{" "} diff --git a/vite/src/views/onboarding2/integrate/integration-steps/Install.tsx b/vite/src/views/onboarding2/integrate/integration-steps/Install.tsx index db4e53cfd..0d1ed15e9 100644 --- a/vite/src/views/onboarding2/integrate/integration-steps/Install.tsx +++ b/vite/src/views/onboarding2/integrate/integration-steps/Install.tsx @@ -9,7 +9,7 @@ const installCodeBun = `bun add autumn-js`; export const Install = () => { return (
- +