feat: new connect flow
This commit is contained in:
30
AGENTS.md
30
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
|
||||
|
||||
30
CLAUDE.md
30
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
|
||||
|
||||
6
bun.lock
6
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=="],
|
||||
|
||||
@@ -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",
|
||||
|
||||
31
server/register.ts
Normal file
31
server/register.ts
Normal file
@@ -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));
|
||||
112
server/src/external/connect/connectUtils.ts
vendored
112
server/src/external/connect/connectUtils.ts
vendored
@@ -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;
|
||||
};
|
||||
|
||||
68
server/src/external/connect/createStripeCli.ts
vendored
68
server/src/external/connect/createStripeCli.ts
vendored
@@ -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}`,
|
||||
});
|
||||
};
|
||||
|
||||
25
server/src/external/connect/initMasterStripe.ts
vendored
25
server/src/external/connect/initMasterStripe.ts
vendored
@@ -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",
|
||||
});
|
||||
};
|
||||
119
server/src/external/connect/initStripeCli.ts
vendored
Normal file
119
server/src/external/connect/initStripeCli.ts
vendored
Normal file
@@ -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;
|
||||
};
|
||||
44
server/src/external/connect/registerConnectWebhook.ts
vendored
Normal file
44
server/src/external/connect/registerConnectWebhook.ts
vendored
Normal file
@@ -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;
|
||||
};
|
||||
272
server/src/external/stripe/handleStripeWebhookEvent.ts
vendored
Normal file
272
server/src/external/stripe/handleStripeWebhookEvent.ts
vendored
Normal file
@@ -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 };
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
262
server/src/external/stripe/stripeWebhooks.ts
vendored
262
server/src/external/stripe/stripeWebhooks.ts
vendored
@@ -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,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
288
server/src/external/webhooks/connectWebhookRouter.ts
vendored
288
server/src/external/webhooks/connectWebhookRouter.ts
vendored
@@ -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<HonoEnv>) => {
|
||||
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<HonoEnv>) => {
|
||||
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<HonoEnv>) => {
|
||||
},
|
||||
});
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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";
|
||||
|
||||
11
server/src/internal/auth/UserService.ts
Normal file
11
server/src/internal/auth/UserService.ts
Normal file
@@ -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),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,7 @@ export class ApiKeyService {
|
||||
features: {
|
||||
where: eq(features.env, env),
|
||||
},
|
||||
master: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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(),
|
||||
// });
|
||||
// }
|
||||
// }
|
||||
|
||||
@@ -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<Organization | undefined> {
|
||||
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<void> {
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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({});
|
||||
|
||||
@@ -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<HonoEnv>) => {
|
||||
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());
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
@@ -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<HonoEnv>) => {
|
||||
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());
|
||||
}
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<HonoEnv>();
|
||||
|
||||
honoOrgRouter.get("/stripe", ...handleGetStripeAccount);
|
||||
honoOrgRouter.delete("/stripe", ...handleDeleteStripe);
|
||||
honoOrgRouter.post("/stripe", ...handleConnectStripe);
|
||||
honoOrgRouter.get("/stripe/oauth_url", ...handleGetOAuthUrl);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { AppEnv, Organization } from "@autumn/shared";
|
||||
import { AppEnv, type Organization } from "@autumn/shared";
|
||||
|
||||
export const toSuccessUrl = ({
|
||||
org,
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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<HonoEnv>();
|
||||
|
||||
// GET /platform/users - List users created by master org
|
||||
honoPlatformRouter.get("/users", ...listPlatformUsers);
|
||||
// honoPlatformRouter.get("/users", ...listPlatformUsers);
|
||||
|
||||
224
server/src/internal/platform/platformBeta/PLATFORM_API.md
Normal file
224
server/src/internal/platform/platformBeta/PLATFORM_API.md
Normal file
@@ -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
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -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(),
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -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<HonoEnv>();
|
||||
|
||||
/**
|
||||
* 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 };
|
||||
@@ -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<string> => {
|
||||
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<OAuthState | null> => {
|
||||
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<OAuthState>(redisKey);
|
||||
|
||||
if (!stateData) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Delete the key
|
||||
await upstash.del(redisKey);
|
||||
|
||||
return stateData;
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
export const getConnectedOrgSlug = ({
|
||||
orgSlug,
|
||||
masterOrgId,
|
||||
}: {
|
||||
orgSlug: string;
|
||||
masterOrgId: string;
|
||||
}) => {
|
||||
return `${orgSlug}|${masterOrgId}`;
|
||||
};
|
||||
@@ -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<Organization> => {
|
||||
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;
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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...
|
||||
@@ -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({
|
||||
|
||||
@@ -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",
|
||||
];
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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<typeof FrontendOrgSchema>;
|
||||
|
||||
@@ -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],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -11,7 +11,7 @@ export const useOrgStripeQuery = () => {
|
||||
|
||||
const fetchStripeAccount = async () => {
|
||||
const { data } = await axiosInstance.get<Stripe.Account>(
|
||||
"/organization/stripe",
|
||||
"/v1/organization/stripe",
|
||||
);
|
||||
return data;
|
||||
};
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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`;
|
||||
};
|
||||
|
||||
@@ -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 (
|
||||
<div className="flex flex-col gap-4">
|
||||
<PageSectionHeader title="Stripe Settings" />
|
||||
<div className="px-10 max-w-[600px] flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
{org.stripe_connection !== "default" ? (
|
||||
<div>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleDisconnectStripe}
|
||||
isLoading={disconnecting}
|
||||
>
|
||||
Disconnect Stripe
|
||||
</Button>
|
||||
<Button variant="outline" onClick={handleVisitDashboard}>
|
||||
{stripeAccount?.id}
|
||||
</Button>
|
||||
<Card className="!p-4 shadow-none">
|
||||
<CardHeader className="px-0">
|
||||
<CardTitle>Connect your Stripe account</CardTitle>
|
||||
{isLoadingStripeAccount ? (
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
</div>
|
||||
) : (
|
||||
status.description && (
|
||||
<CardDescription>
|
||||
{status.description}
|
||||
{dashboardUrl && (
|
||||
<span className="text-muted-foreground">
|
||||
{" "}
|
||||
Visit the Stripe dashboard{" "}
|
||||
<a
|
||||
href={dashboardUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline text-primary"
|
||||
>
|
||||
here
|
||||
</a>
|
||||
</span>
|
||||
)}
|
||||
</CardDescription>
|
||||
)
|
||||
)}
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="flex flex-col gap-2 px-0">
|
||||
<div className="flex gap-2">
|
||||
{status.showConnectButtons && (
|
||||
<>
|
||||
<Button variant="outline" onClick={handleRedirectToOAuth}>
|
||||
Connect via OAuth
|
||||
</Button>
|
||||
{canPasteSecretKey && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowConnectDialog(true)}
|
||||
>
|
||||
Paste secret key
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{status.showDisconnect && (
|
||||
<DisconnectStripePopover
|
||||
onSuccess={async () => {
|
||||
await mutate();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={handleRedirectToOAuth}>
|
||||
Connect via OAuth
|
||||
</Button>
|
||||
<Button variant="outline">Paste your Stripe secret key</Button>
|
||||
</div>
|
||||
<div>
|
||||
{org.stripe_connection && (
|
||||
<p className="text-t3 text-sm mb-2">
|
||||
Connection: {org.stripe_connection}
|
||||
</p>
|
||||
)}
|
||||
<Button variant="outline" onClick={handleVisitDashboard}>
|
||||
{stripeAccount?.id}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<Separator />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div>
|
||||
<FieldLabel className="mb-1">
|
||||
@@ -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.
|
||||
</p>
|
||||
{/* <Input value={org.default_currency} /> */}
|
||||
<CurrencySelect
|
||||
defaultCurrency={newStripeConfig.default_currency.toUpperCase()}
|
||||
setDefaultCurrency={(currency) =>
|
||||
@@ -181,79 +294,62 @@ export const ConfigureStripe = () => {
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
{/* {org.stripe_connected ? (
|
||||
<DisconnectStripePopover
|
||||
onSuccess={async () => {
|
||||
await mutate();
|
||||
setNewStripeConfig({
|
||||
...newStripeConfig,
|
||||
secret_key: "",
|
||||
});
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-6/12" />
|
||||
)} */}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ConnectStripeDialog
|
||||
open={showConnectDialog}
|
||||
setOpen={setShowConnectDialog}
|
||||
/>
|
||||
|
||||
<Dialog
|
||||
open={showDuplicateDialog}
|
||||
onOpenChange={(open) => {
|
||||
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);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Account Already Connected</DialogTitle>
|
||||
<DialogDescription>
|
||||
The Stripe account{" "}
|
||||
<strong>{searchParams.get("account_id")}</strong>
|
||||
{searchParams.get("account_name") && (
|
||||
<> ({searchParams.get("account_name")})</>
|
||||
)}{" "}
|
||||
is already connected to the Autumn organization{" "}
|
||||
<strong>{searchParams.get("connected_org_name")}</strong>
|
||||
{searchParams.get("connected_org_slug") && (
|
||||
<> ({searchParams.get("connected_org_slug")})</>
|
||||
)}
|
||||
. Please disconnect it from there first before connecting to this
|
||||
organization.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setShowDuplicateDialog(false);
|
||||
searchParams.delete("error");
|
||||
searchParams.delete("account_id");
|
||||
searchParams.delete("account_name");
|
||||
searchParams.delete("connected_org_name");
|
||||
searchParams.delete("connected_org_slug");
|
||||
setSearchParams(searchParams);
|
||||
}}
|
||||
>
|
||||
OK
|
||||
</Button>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// <p className="text-t3 text-sm mb-2">
|
||||
// You can retrieve this from your Stripe dashboard{" "}
|
||||
// <a
|
||||
// href="https://dashboard.stripe.com/apikeys"
|
||||
// target="_blank"
|
||||
// rel="noopener noreferrer"
|
||||
// className="text-primary underline"
|
||||
// >
|
||||
// here
|
||||
// </a>
|
||||
// .
|
||||
// </p>
|
||||
// {env === AppEnv.Live && (
|
||||
// <div className="flex items-center gap-2 mb-2">
|
||||
// <span className="text-t3 text-sm">
|
||||
// If you want to use a restricted key
|
||||
// </span>
|
||||
// <InfoTooltip>
|
||||
// <div className="max-w-xs">
|
||||
// <p className="mb-2">The following scopes are needed:</p>
|
||||
// <ul className="list-disc list-inside space-y-0.5">
|
||||
// <li>Core (read & write)</li>
|
||||
// <li>Checkout (read & write)</li>
|
||||
// <li>Billing (read & write)</li>
|
||||
// <li>All webhooks (write)</li>
|
||||
// <li>Connect → Account Links (write)</li>
|
||||
// </ul>
|
||||
|
||||
// <p className="mt-2 mb-2 text-xs">
|
||||
// In your Stripe dashboard, go to{" "}
|
||||
// <strong>Developers → API keys</strong>, click{" "}
|
||||
// <strong>Create restricted key</strong>, and enable the
|
||||
// scopes above with the listed permissions.
|
||||
// </p>
|
||||
// </div>
|
||||
// </InfoTooltip>
|
||||
// </div>
|
||||
// )}
|
||||
|
||||
// {org.stripe_connected ? (
|
||||
// <Input
|
||||
// disabled
|
||||
// value="Stripe connected"
|
||||
// endContent={<Check size={14} className="text-t3" />}
|
||||
// />
|
||||
// ) : (
|
||||
// <Input
|
||||
// placeholder={env === AppEnv.Live ? "sk_live_..." : "sk_test_..."}
|
||||
// value={newStripeConfig.secret_key}
|
||||
// onChange={(e) =>
|
||||
// setNewStripeConfig({
|
||||
// ...newStripeConfig,
|
||||
// secret_key: e.target.value,
|
||||
// })
|
||||
// }
|
||||
// />
|
||||
// )}
|
||||
|
||||
@@ -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 (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="destructive" className="w-6/12">
|
||||
Disconnect Stripe
|
||||
</Button>
|
||||
<Button variant="destructive">Disconnect Stripe</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" className="border border-zinc-200">
|
||||
<div className="flex flex-col gap-4 text-sm w-fit">
|
||||
|
||||
@@ -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({
|
||||
<DialogHeader>
|
||||
<DialogTitle>Connect your Stripe account</DialogTitle>
|
||||
</DialogHeader>
|
||||
<p className="text-t2 text-sm">
|
||||
To add a product to a customer, first connect your Stripe account.
|
||||
Grab your secret key{" "}
|
||||
<a
|
||||
href="https://dashboard.stripe.com/test/apikeys"
|
||||
target="_blank"
|
||||
className="underline"
|
||||
>
|
||||
here
|
||||
</a>
|
||||
</p>
|
||||
{/* <ConnectStripeStep mutate={mutate} productData={data} /> */}
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-t3 text-sm">
|
||||
If you want to use a restricted key
|
||||
</span>
|
||||
<InfoTooltip>
|
||||
<div className="max-w-xs">
|
||||
<p className="mb-2">The following scopes are needed:</p>
|
||||
<ul className="list-disc list-inside space-y-0.5">
|
||||
<li>Core (read & write)</li>
|
||||
<li>Checkout (read & write)</li>
|
||||
<li>Billing (read & write)</li>
|
||||
<li>All webhooks (write)</li>
|
||||
<li>Connect → Account Links (write)</li>
|
||||
</ul>
|
||||
|
||||
<p className="mt-2 mb-2 text-xs">
|
||||
In your Stripe dashboard, go to{" "}
|
||||
<strong>Developers → API keys</strong>, click{" "}
|
||||
<strong>Create restricted key</strong>, and enable the scopes
|
||||
above with the listed permissions.
|
||||
</p>
|
||||
</div>
|
||||
</InfoTooltip>
|
||||
</div>
|
||||
<Input
|
||||
className="w-full"
|
||||
placeholder="Stripe secret key (sk_test_...)"
|
||||
|
||||
@@ -13,7 +13,6 @@ import { ArrowLeftIcon } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { NextSteps } from "./NextSteps";
|
||||
import { AutumnProvider } from "autumn-js/react";
|
||||
import { ConnectStripeStep } from "./ConnectStripeStep";
|
||||
import { useOnboardingQueryState } from "../hooks/useOnboardingQueryState";
|
||||
|
||||
export default function IntegrateAutumn() {
|
||||
@@ -50,7 +49,6 @@ export default function IntegrateAutumn() {
|
||||
<SelectStack />
|
||||
{stackSelected && queryStates.reactTypescript && (
|
||||
<>
|
||||
<ConnectStripeStep />
|
||||
<EnvStep />
|
||||
<Install />
|
||||
<AutumnHandler />
|
||||
|
||||
@@ -113,7 +113,7 @@ export const AddAutumnProvider = () => {
|
||||
return (
|
||||
<div className="flex flex-col gap-4 w-full">
|
||||
<StepHeader
|
||||
number={6}
|
||||
number={5}
|
||||
title={
|
||||
<p>
|
||||
Wrap your React app in <CodeSpan>{"<AutumnProvider />"}</CodeSpan>
|
||||
|
||||
@@ -154,7 +154,7 @@ export const AutumnHandler = () => {
|
||||
return (
|
||||
<div className="flex flex-col gap-4 w-full">
|
||||
<StepHeader
|
||||
number={5}
|
||||
number={4}
|
||||
title={
|
||||
<p>
|
||||
Mount <CodeSpan>autumnHandler</CodeSpan> to your backend
|
||||
|
||||
@@ -28,7 +28,7 @@ export const CheckoutPricingTable = () => {
|
||||
return (
|
||||
<div className="flex flex-col gap-4 w-full">
|
||||
<StepHeader
|
||||
number={7}
|
||||
number={6}
|
||||
title={
|
||||
<p>
|
||||
Drop in <CodeSpan>{"<PricingTable />"}</CodeSpan>
|
||||
|
||||
@@ -10,7 +10,7 @@ export const EnvStep = () => {
|
||||
<>
|
||||
<div className="flex flex-col gap-4 w-full">
|
||||
<StepHeader
|
||||
number={3}
|
||||
number={2}
|
||||
title={
|
||||
<p>
|
||||
Add the Autumn secret key to your <CodeSpan>{".env"}</CodeSpan>{" "}
|
||||
|
||||
@@ -9,7 +9,7 @@ const installCodeBun = `bun add autumn-js`;
|
||||
export const Install = () => {
|
||||
return (
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
<StepHeader number={4} title="Install autumn-js" />
|
||||
<StepHeader number={3} title="Install autumn-js" />
|
||||
|
||||
<CodeBlock
|
||||
snippets={[
|
||||
|
||||
Reference in New Issue
Block a user