diff --git a/server/src/external/stripe/stripeCusUtils.ts b/server/src/external/stripe/stripeCusUtils.ts index 2e2a48e90..73ce9d58b 100644 --- a/server/src/external/stripe/stripeCusUtils.ts +++ b/server/src/external/stripe/stripeCusUtils.ts @@ -111,7 +111,6 @@ export const createStripeCustomer = async ({ return stripeCustomer; } catch (error: any) { - console.log("error", error); throw new RecaseError({ message: `Error creating customer in Stripe. ${error.message}`, code: ErrCode.StripeCreateCustomerFailed, diff --git a/server/src/external/stripe/stripeSubUtils/createStripeSub.ts b/server/src/external/stripe/stripeSubUtils/createStripeSub.ts index fe6cc70cd..cc314ca1f 100644 --- a/server/src/external/stripe/stripeSubUtils/createStripeSub.ts +++ b/server/src/external/stripe/stripeSubUtils/createStripeSub.ts @@ -66,12 +66,12 @@ export const createStripeSub = async ({ let subItems = items.filter( (i: any, index: number) => - prices[index].config!.interval !== BillingInterval.OneOff, + prices[index].config!.interval !== BillingInterval.OneOff ); let invoiceItems = items.filter( (i: any, index: number) => - prices[index].config!.interval === BillingInterval.OneOff, + prices[index].config!.interval === BillingInterval.OneOff ); try { diff --git a/server/src/external/stripe/utils.ts b/server/src/external/stripe/utils.ts index ebbc7f64b..834fa9d2c 100644 --- a/server/src/external/stripe/utils.ts +++ b/server/src/external/stripe/utils.ts @@ -20,16 +20,18 @@ export const createStripeCli = ({ org: Organization; env: AppEnv; }) => { - if (!org.stripe_config) { - throw new RecaseError({ - message: "Stripe config not found", - code: ErrCode.StripeConfigNotFound, - }); - } let encrypted = env == AppEnv.Sandbox - ? org.stripe_config.test_api_key - : org.stripe_config.live_api_key; + ? 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"} keys. You can find them here: https://dashboard.stripe.com${env == AppEnv.Sandbox ? "/test" : ""}/apikeys`, + code: ErrCode.StripeConfigNotFound, + statusCode: 400, + }); + } let decrypted = decryptData(encrypted); return new Stripe(decrypted); diff --git a/server/src/internal/customers/attach/attachFunctions/addProductFlow/handlePaidProduct.ts b/server/src/internal/customers/attach/attachFunctions/addProductFlow/handlePaidProduct.ts index f1901a93a..938d04e12 100644 --- a/server/src/internal/customers/attach/attachFunctions/addProductFlow/handlePaidProduct.ts +++ b/server/src/internal/customers/attach/attachFunctions/addProductFlow/handlePaidProduct.ts @@ -63,7 +63,7 @@ export const handlePaidProduct = async ({ let mergeCusProduct = undefined; if (!config.disableMerge && !freeTrial) { mergeCusProduct = cusProducts?.find((cp) => - products.some((p) => p.group == cp.product.group), + products.some((p) => p.group == cp.product.group) ); } @@ -79,7 +79,7 @@ export const handlePaidProduct = async ({ } let mergeWithSub = mergeSubs.find( - (sub) => subToAutumnInterval(sub) == itemSet.interval, + (sub) => subToAutumnInterval(sub) == itemSet.interval ); let subscription; @@ -150,7 +150,7 @@ export const handlePaidProduct = async ({ carryExistingUsages: config.carryUsage, scenario: AttachScenario.New, logger, - }), + }) ); } await Promise.all(batchInsert); @@ -163,7 +163,7 @@ export const handlePaidProduct = async ({ invoiceId: sub.latest_invoice as string, attachParams, logger, - }), + }) ); } const invoices = await Promise.all(batchInsertInvoice); @@ -180,7 +180,7 @@ export const handlePaidProduct = async ({ product_ids: products.map((p) => p.id), customer_id: customer.id || customer.internal_id, invoice: invoiceOnly ? invoices?.[0] : undefined, - }), + }) ); } else { res.status(200).json({ diff --git a/server/src/internal/customers/attach/attachUtils/attachParams/attachParamsUtils/getPricesAndEnts.ts b/server/src/internal/customers/attach/attachUtils/attachParams/attachParamsUtils/getPricesAndEnts.ts index 931056ad9..5922e5fef 100644 --- a/server/src/internal/customers/attach/attachUtils/attachParams/attachParamsUtils/getPricesAndEnts.ts +++ b/server/src/internal/customers/attach/attachUtils/attachParams/attachParamsUtils/getPricesAndEnts.ts @@ -49,6 +49,7 @@ export const getPricesAndEnts = async ({ let freeTrial = null; let freeTrialProduct = products.find((p) => notNullish(p.free_trial)); + // freeTrial = freeTrialProduct?.free_trial; if (freeTrialProduct) { freeTrial = await getFreeTrialAfterFingerprint({ db, @@ -60,8 +61,6 @@ export const getPricesAndEnts = async ({ }); } - const prodIsMain = isMainProduct({ product: products[0], prices }); - return { optionsList: mapOptionsList({ optionsInput: optionsInput || [], diff --git a/server/src/internal/customers/attach/attachUtils/attachParams/processAttachBody.ts b/server/src/internal/customers/attach/attachUtils/attachParams/processAttachBody.ts index b023d2104..1257c9e0b 100644 --- a/server/src/internal/customers/attach/attachUtils/attachParams/processAttachBody.ts +++ b/server/src/internal/customers/attach/attachUtils/attachParams/processAttachBody.ts @@ -57,20 +57,13 @@ export const processAttachBody = async ({ // 1. Get customer and products const { org, env } = req; - if (!org.stripe_connected) { - throw new RecaseError({ - message: "Please connect to Stripe to add products", - code: ErrCode.StripeConfigNotFound, - statusCode: 400, - }); - } + const stripeCli = createStripeCli({ org, env }); const { customer, products } = await getCustomerAndProducts({ req, attachBody, }); - const stripeCli = createStripeCli({ org, env }); const [stripeCusData, rewardData] = await Promise.all([ getStripeCusData({ stripeCli, diff --git a/server/src/internal/customers/attach/handleAttachPreview/attachParamsToPreview.ts b/server/src/internal/customers/attach/handleAttachPreview/attachParamsToPreview.ts index 616796346..6ca46107f 100644 --- a/server/src/internal/customers/attach/handleAttachPreview/attachParamsToPreview.ts +++ b/server/src/internal/customers/attach/handleAttachPreview/attachParamsToPreview.ts @@ -88,6 +88,7 @@ export const attachParamsToPreview = async ({ branch, now, withPrepaid, + config, }); } diff --git a/server/src/internal/customers/attach/handleAttachPreview/getUpgradeProductPreview.ts b/server/src/internal/customers/attach/handleAttachPreview/getUpgradeProductPreview.ts index 1d61970c2..7d3fdaae0 100644 --- a/server/src/internal/customers/attach/handleAttachPreview/getUpgradeProductPreview.ts +++ b/server/src/internal/customers/attach/handleAttachPreview/getUpgradeProductPreview.ts @@ -5,10 +5,7 @@ import { } from "../attachUtils/convertAttachParams.js"; import { getStripeSubs } from "@/external/stripe/stripeSubUtils.js"; import { ExtendedRequest } from "@/utils/models/Request.js"; -import { - getFirstInterval, - getLastInterval, -} from "@/internal/products/prices/priceUtils/priceIntervalUtils.js"; +import { getFirstInterval } from "@/internal/products/prices/priceUtils/priceIntervalUtils.js"; import { getItemsForNewProduct } from "@/internal/invoices/previewItemUtils/getItemsForNewProduct.js"; import { getItemsForCurProduct } from "@/internal/invoices/previewItemUtils/getItemsForCurProduct.js"; import { getOptions } from "@/internal/api/entitled/checkUtils.js"; @@ -18,9 +15,11 @@ import { AttachBranch, BillingInterval, FreeTrial, + FullCusProduct, PreviewLineItem, Price, UsageModel, + AttachConfig, } from "@autumn/shared"; import { addBillingIntervalUnix, @@ -39,6 +38,8 @@ const getNextCycleAt = ({ interval, now, freeTrial, + branch, + curCusProduct, }: { prices: Price[]; stripeSubs: Stripe.Subscription[]; @@ -46,9 +47,17 @@ const getNextCycleAt = ({ interval: BillingInterval; now?: number; freeTrial?: FreeTrial | null; + branch: AttachBranch; + curCusProduct?: FullCusProduct; }) => { now = now || Date.now(); + if (branch == AttachBranch.NewVersion && curCusProduct?.free_trial) { + return { + next_cycle_at: curCusProduct.trial_ends_at, + }; + } + if (freeTrial) { return { next_cycle_at: @@ -85,12 +94,14 @@ export const getUpgradeProductPreview = async ({ branch, now, withPrepaid = false, + config, }: { req: ExtendedRequest; attachParams: AttachParams; branch: AttachBranch; now: number; withPrepaid?: boolean; + config?: AttachConfig; }) => { const { logtail: logger } = req; @@ -123,18 +134,22 @@ export const getUpgradeProductPreview = async ({ ? stripeSubs[0].current_period_end * 1000 : undefined; + let freeTrial = attachParams.freeTrial; + if (config?.carryTrial && curCusProduct?.free_trial) { + freeTrial = curCusProduct.free_trial; + } + const newPreviewItems = await getItemsForNewProduct({ newProduct, attachParams, now, anchorToUnix, - freeTrial: attachParams.freeTrial, + freeTrial, stripeSubs, logger, withPrepaid, }); - // const lastInterval = getLastInterval({ prices: newProduct.prices }); const lastInterval = getFirstInterval({ prices: newProduct.prices }); let dueNextCycle = undefined; @@ -146,6 +161,8 @@ export const getUpgradeProductPreview = async ({ interval: lastInterval, now, freeTrial: attachParams.freeTrial, + branch, + curCusProduct, }); let nextCycleItems = await getItemsForNewProduct({ diff --git a/server/src/internal/customers/cusUtils/getCustomerDetails.ts b/server/src/internal/customers/cusUtils/getCustomerDetails.ts index 40a034e92..ea8411a18 100644 --- a/server/src/internal/customers/cusUtils/getCustomerDetails.ts +++ b/server/src/internal/customers/cusUtils/getCustomerDetails.ts @@ -78,18 +78,6 @@ export const getCustomerDetails = async ({ (cp: FullCusProduct) => cp.subscription_ids || [] ); - // if (org.config.api_version >= BREAK_API_VERSION && org.stripe_connected) { - // let stripeCli = createStripeCli({ - // org, - // env, - // }); - - // subs = await getStripeSubs({ - // stripeCli, - // subIds, - // expand: withRewards ? ["discounts"] : undefined, - // }); - // } const subs = customer.subscriptions || []; const { main, addOns } = await processFullCusProducts({ fullCusProducts: cusProducts, diff --git a/server/src/internal/customers/expire/expireRouter.ts b/server/src/internal/customers/expire/expireRouter.ts index 6ee067a5c..4f42ed0f9 100644 --- a/server/src/internal/customers/expire/expireRouter.ts +++ b/server/src/internal/customers/expire/expireRouter.ts @@ -1,8 +1,7 @@ import { CusService } from "@/internal/customers/CusService.js"; -import { OrgService } from "@/internal/orgs/OrgService.js"; import RecaseError from "@/utils/errorUtils.js"; import { routeHandler } from "@/utils/routerUtils.js"; -import { CusProductStatus, ErrCode, FullCusProduct } from "@autumn/shared"; +import { ErrCode, FullCusProduct } from "@autumn/shared"; import { Router } from "express"; import { expireCusProduct } from "../handlers/handleCusProductExpired.js"; import { RELEVANT_STATUSES } from "../cusProducts/CusProductService.js"; @@ -55,6 +54,8 @@ expireRouter.post("", async (req, res) => }); } + // Handle case if there are two products to expire... + for (const cusProduct of cusProductsToExpire) { await expireCusProduct({ req, diff --git a/server/src/internal/dev/ApiKeyService.ts b/server/src/internal/dev/ApiKeyService.ts index 8ff77b85a..4b4a17f4e 100644 --- a/server/src/internal/dev/ApiKeyService.ts +++ b/server/src/internal/dev/ApiKeyService.ts @@ -60,6 +60,7 @@ export class ApiKeyService { org, features: (data.org.features || []) as Feature[], env, + userId: data.user_id, }; // console.log("result", result); diff --git a/server/src/internal/dev/api-keys/apiKeyUtils.ts b/server/src/internal/dev/api-keys/apiKeyUtils.ts index 1d30dba05..e7ef26a50 100644 --- a/server/src/internal/dev/api-keys/apiKeyUtils.ts +++ b/server/src/internal/dev/api-keys/apiKeyUtils.ts @@ -35,6 +35,7 @@ export const createKey = async ({ db, env, name, + userId, orgId, prefix, meta, @@ -45,6 +46,7 @@ export const createKey = async ({ orgId: string; prefix: string; meta: any; + userId?: string; }) => { const apiKey = generateApiKey(42, prefix); const hashedKey = hashApiKey(apiKey); @@ -52,7 +54,7 @@ export const createKey = async ({ const apiKeyData: ApiKey = { id: generateId("key"), org_id: orgId, - user_id: "", + user_id: userId || null, name, prefix: apiKey.substring(0, 14), created_at: Date.now(), diff --git a/server/src/internal/dev/devRouter.ts b/server/src/internal/dev/devRouter.ts index 651a4c18d..44502dc21 100644 --- a/server/src/internal/dev/devRouter.ts +++ b/server/src/internal/dev/devRouter.ts @@ -17,6 +17,7 @@ import { } from "@/external/stripe/stripeOnboardingUtils.js"; import { clearOrgCache } from "../orgs/orgUtils/clearOrgCache.js"; import * as crypto from "crypto"; +import { isStripeConnected } from "../orgs/orgUtils.js"; export const devRouter: Router = Router(); @@ -64,6 +65,7 @@ devRouter.post("/api_key", withOrgAuth, async (req: any, res) => env, name, orgId, + userId: req.user?.id, prefix, meta: {}, }); @@ -222,6 +224,7 @@ export const handleGetOtp = async (req: any, res: any) => fromCli: true, generatedAt: new Date().toISOString(), }, + userId: req.user?.id, }); const prodKey = await createKey({ @@ -234,6 +237,7 @@ export const handleGetOtp = async (req: any, res: any) => fromCli: true, generatedAt: new Date().toISOString(), }, + userId: req.user?.id, }); // console.log("New keys created:"); @@ -245,7 +249,7 @@ export const handleGetOtp = async (req: any, res: any) => orgId: cacheData.orgId, }); - let stripeConnected = org.stripe_connected; + let stripeConnected = isStripeConnected({ org }); let responseData = { ...cacheData, diff --git a/server/src/internal/orgs/handlers/handleConnectStripe.ts b/server/src/internal/orgs/handlers/handleConnectStripe.ts index 858d9d971..4d46b8494 100644 --- a/server/src/internal/orgs/handlers/handleConnectStripe.ts +++ b/server/src/internal/orgs/handlers/handleConnectStripe.ts @@ -16,8 +16,57 @@ import { OrgService } from "../OrgService.js"; import { AppEnv } from "@autumn/shared"; import { nullish } from "@/utils/genUtils.js"; import { clearOrgCache } from "../orgUtils/clearOrgCache.js"; +import { disconnectStripe } from "./handleDeleteStripe.js"; export const connectStripe = async ({ + db, + orgId, + logger, + apiKey, + env, +}: { + db: any; + orgId: string; + logger: any; + apiKey: string; + env: AppEnv; +}) => { + // 1. Check if key is valid + await checkKeyValid(apiKey); + + let stripe = new Stripe(apiKey); + let account = await stripe.accounts.retrieve(); + + // 2. Disconnect existing webhook endpoints + const curWebhooks = await stripe.webhookEndpoints.list(); + for (const webhook of curWebhooks.data) { + if (webhook.url.includes(orgId)) { + await stripe.webhookEndpoints.del(webhook.id); + } + } + + // 3. Create webhook endpoint + let webhook = await createWebhookEndpoint(apiKey, env, orgId); + + // 3. Return encrypted + if (env === AppEnv.Sandbox) { + return { + test_api_key: encryptData(apiKey), + test_webhook_secret: encryptData(webhook.secret as string), + env, + defaultCurrency: account.default_currency, + }; + } else { + return { + live_api_key: encryptData(apiKey), + live_webhook_secret: encryptData(webhook.secret as string), + env, + stripeCurrency: account.default_currency, + }; + } +}; + +export const connectAllStripe = async ({ db, orgId, logger, @@ -114,7 +163,7 @@ export const handleConnectStripe = async (req: any, res: any) => } let { defaultCurrency: finalDefaultCurrency, stripeConfig } = - await connectStripe({ + await connectAllStripe({ db, orgId, logger, diff --git a/server/src/internal/orgs/handlers/handleDeleteStripe.ts b/server/src/internal/orgs/handlers/handleDeleteStripe.ts index 295038231..90314a0a2 100644 --- a/server/src/internal/orgs/handlers/handleDeleteStripe.ts +++ b/server/src/internal/orgs/handlers/handleDeleteStripe.ts @@ -3,22 +3,26 @@ import { OrgService } from "../OrgService.js"; import { createStripeCli } from "@/external/stripe/utils.js"; import { clearOrgCache } from "../orgUtils/clearOrgCache.js"; import { AppEnv, Organization } from "@autumn/shared"; +import { isStripeConnected } from "../orgUtils.js"; export const disconnectStripe = async (org: Organization) => { - const testStripeCli = createStripeCli({ org, env: AppEnv.Sandbox }); - const liveStripeCli = createStripeCli({ org, env: AppEnv.Live }); - - const testWebhooks = await testStripeCli.webhookEndpoints.list(); - for (const webhook of testWebhooks.data) { - if (webhook.url.includes(org.id)) { - await testStripeCli.webhookEndpoints.del(webhook.id); + if (isStripeConnected({ org, env: AppEnv.Sandbox })) { + const testStripeCli = createStripeCli({ org, env: AppEnv.Sandbox }); + const testWebhooks = await testStripeCli.webhookEndpoints.list(); + for (const webhook of testWebhooks.data) { + if (webhook.url.includes(org.id)) { + await testStripeCli.webhookEndpoints.del(webhook.id); + } } } - const liveWebhooks = await liveStripeCli.webhookEndpoints.list(); - for (const webhook of liveWebhooks.data) { - if (webhook.url.includes(org.id)) { - await liveStripeCli.webhookEndpoints.del(webhook.id); + if (isStripeConnected({ org, env: AppEnv.Live })) { + const liveStripeCli = createStripeCli({ org, env: AppEnv.Live }); + const liveWebhooks = await liveStripeCli.webhookEndpoints.list(); + for (const webhook of liveWebhooks.data) { + if (webhook.url.includes(org.id)) { + await liveStripeCli.webhookEndpoints.del(webhook.id); + } } } }; diff --git a/server/src/internal/orgs/orgUtils.ts b/server/src/internal/orgs/orgUtils.ts index 6baba03c1..352130795 100644 --- a/server/src/internal/orgs/orgUtils.ts +++ b/server/src/internal/orgs/orgUtils.ts @@ -4,7 +4,26 @@ import { AppEnv, ErrCode, FrontendOrg, Organization } from "@autumn/shared"; import { createStripeCli } from "@/external/stripe/utils.js"; import { OrgService } from "./OrgService.js"; import { FeatureService } from "../features/FeatureService.js"; -import { createSvixApp } from "@/external/svix/svixHelpers.js"; +import { notNullish } from "@/utils/genUtils.js"; + +export const isStripeConnected = ({ + org, + env, +}: { + org: Organization; + env?: AppEnv; +}) => { + if (env === AppEnv.Sandbox) { + return notNullish(org.stripe_config?.test_api_key); + } else if (env === AppEnv.Live) { + return notNullish(org.stripe_config?.live_api_key); + } else { + return ( + notNullish(org.stripe_config?.test_api_key) && + notNullish(org.stripe_config?.live_api_key) + ); + } +}; export const constructOrg = ({ id, slug }: { id: string; slug: string }) => { return { @@ -49,19 +68,19 @@ export const deleteStripeWebhook = async ({ }; export const getStripeWebhookSecret = (org: Organization, env: AppEnv) => { - if (!org.stripe_config) { + const webhookSecret = + env === AppEnv.Sandbox + ? org.stripe_config?.test_webhook_secret + : org.stripe_config?.live_webhook_secret; + + if (!webhookSecret) { throw new RecaseError({ code: ErrCode.StripeConfigNotFound, - message: `Stripe config not found for org ${org.id}`, + message: `Stripe webhook secret not found for org ${org.id}`, statusCode: 400, }); } - const webhookSecret = - env === AppEnv.Sandbox - ? org.stripe_config.test_webhook_secret - : org.stripe_config!.live_webhook_secret; - return decryptData(webhookSecret); }; diff --git a/server/src/internal/platform/platformRouter.ts b/server/src/internal/platform/platformRouter.ts index 8320b34ba..83c75b9b5 100644 --- a/server/src/internal/platform/platformRouter.ts +++ b/server/src/internal/platform/platformRouter.ts @@ -1,26 +1,68 @@ import { generateId } from "better-auth"; import { NextFunction, Router } from "express"; -import { member, organizations, user as userTable } from "@autumn/shared"; + +import { + AppEnv, + member, + Organization, + organizations, + StripeConfig, + user as userTable, +} from "@autumn/shared"; + import { ExtendedRequest } from "@/utils/models/Request.js"; import { routeHandler } from "@/utils/routerUtils.js"; import { slugify } from "@/utils/genUtils.js"; -import { eq } from "drizzle-orm"; +import { and, eq } from "drizzle-orm"; import { connectStripe } from "../orgs/handlers/handleConnectStripe.js"; import { z } from "zod"; +import { createKey } from "../dev/api-keys/apiKeyUtils.js"; +import { afterOrgCreated } from "@/utils/authUtils/afterOrgCreated.js"; +import { Autumn } from "autumn-js"; const platformRouter = Router(); -const platformAuthMiddleware = (req: any, res: any, next: NextFunction) => { - next(); +const platformAuthMiddleware = async ( + req: any, + res: any, + next: NextFunction +) => { + if (!process.env.AUTUMN_SECRET_KEY) next(); + + try { + let autumn = new Autumn(); + const { data, error } = await autumn.check({ + customer_id: req.org.id, + feature_id: "platform", + }); + + if (error) { + throw error; + } + + if (!data?.allowed) { + res.status(403).json({ + message: + "You're not allowed to access the platform API. Please contact hey@useautumn.com to request access!", + code: "not_allowed", + }); + return; + } + next(); + } catch (error) { + res.status(500).json({ + message: "Failed to check if org is allowed to access platform", + code: "internal_error", + }); + } }; platformRouter.use(platformAuthMiddleware); const ExchangeSchema = z.object({ - organization: z.string(), - email: z.string(), - stripe_test_key: z.string().nonempty(), - stripe_live_key: z.string().nonempty(), + email: z.string().regex(/^[^\s@]+@[^\s@]+\.[^\s@]+$/), + stripe_test_key: z.string().nonempty().optional(), + stripe_live_key: z.string().nonempty().optional(), }); platformRouter.post("/exchange", (req: any, res: any) => @@ -31,57 +73,172 @@ platformRouter.post("/exchange", (req: any, res: any) => handler: async (req: ExtendedRequest, res: any) => { let { organization, email, stripe_test_key, stripe_live_key } = req.body; - // 1. Create user with email - const { db } = req; + const { db, logger } = req; - // let user = await db.insert(userTable).values({ - // id: generateId(), - // name: "", - // email, - // emailVerified: true, - // createdAt: new Date(), - // updatedAt: new Date(), - // role: "user", - // banned: false, - // banReason: null, - // banExpires: null, - // }); + ExchangeSchema.parse({ + organization, + email, + stripe_test_key, + stripe_live_key, + }); - // let { defaultCurrency, stripeConfig } = await connectStripe({ - // db, - // orgId: generateId(), - // logger: req.logtail, - // testApiKey: stripe_test_key, - // liveApiKey: stripe_live_key, - // successUrl: "https://useautumn.com", - // }); + // 1. Check if user with this email already exists + let user = await db.query.user.findFirst({ + where: eq(userTable.email, email), + }); - // // 2. Create org - // let orgId = generateId(); - // await db.insert(organizations).values({ - // id: orgId, - // slug: `${slugify(organization)}_${Math.floor(10000000 + Math.random() * 90000000)}`, - // name: organization, - // logo: "", - // createdAt: new Date(), - // metadata: "", - // stripe_connected: true, - // default_currency: defaultCurrency, - // stripe_config: stripeConfig, - // }); + if (!user) { + [user] = await db + .insert(userTable) + .values({ + id: generateId(), + name: "", + email, + emailVerified: true, + createdAt: new Date(), + updatedAt: new Date(), + role: "user", + banned: false, + banReason: null, + banExpires: null, + createdBy: req.org.id, + }) + .returning(); + } - // // 3. Create membership - // await db.insert(member).values({ - // id: generateId(), - // organizationId: orgId, - // userId: user!.id, - // role: "owner", - // createdAt: new Date(), - // }); + logger.info(`User found / created: ${user.id} (${email})`); + let org: Organization; + + let membership = await db.query.member.findFirst({ + where: and(eq(member.userId, user.id!), eq(member.role, "owner")), + }); + + if (!membership) { + logger.info(`Connected to Stripe`); + + // 2. Create org + let orgId = generateId(); + + [org] = (await db + .insert(organizations) + .values({ + id: orgId, + slug: `platform_org_${Math.floor(10000000 + Math.random() * 90000000)}`, + name: `Platform Org`, + logo: "", + createdAt: new Date(), + metadata: "", + }) + .returning()) as [Organization]; + + await db.insert(member).values({ + id: generateId(), + organizationId: orgId, + userId: user.id!, + role: "owner", + createdAt: new Date(), + }); + + await afterOrgCreated({ org }); + } else { + org = (await db.query.organizations.findFirst({ + where: eq(organizations.id, membership.organizationId), + })) as Organization; + } + + let sandboxKey, prodKey; + + let finalStripeConfig: any = {}; + let defaultCurrency = org.default_currency || "usd"; + + if (stripe_test_key) { + let { test_api_key, test_webhook_secret, stripeCurrency } = + await connectStripe({ + db, + orgId: org.id, + logger: req.logtail, + apiKey: stripe_test_key, + env: AppEnv.Sandbox, + }); + sandboxKey = await createKey({ + db, + orgId: org.id, + env: AppEnv.Sandbox, + name: "Platform API Key", + prefix: "am_sk_test", + meta: {}, + }); + finalStripeConfig = { + ...finalStripeConfig, + test_api_key, + test_webhook_secret, + }; + + if (!defaultCurrency) { + defaultCurrency = stripeCurrency || "usd"; + } + } + + if (stripe_live_key) { + let { live_api_key, live_webhook_secret, stripeCurrency } = + await connectStripe({ + db, + orgId: org.id, + logger: req.logtail, + apiKey: stripe_live_key, + env: AppEnv.Live, + }); + + prodKey = await createKey({ + db, + orgId: org.id, + env: AppEnv.Live, + name: "Platform API Key", + prefix: "am_sk_live", + meta: {}, + }); + + finalStripeConfig = { + ...finalStripeConfig, + live_api_key, + live_webhook_secret, + }; + + if (!defaultCurrency) { + defaultCurrency = stripeCurrency || "usd"; + } + } + + if (!org.stripe_config?.success_url) { + finalStripeConfig.success_url = `https://useautumn.com`; + } + + await db + .update(organizations) + .set({ + default_currency: defaultCurrency, + stripe_connected: true, + stripe_config: { + ...org.stripe_config, + ...finalStripeConfig, + } as StripeConfig, + }) + .where(eq(organizations.id, org.id)); res.status(200).json({ - // message: "User created", - // user, + // org: { + // id: org.id, + // slug: org.slug, + // name: org.name, + // }, + // user: { + // id: user.id!, + // email, + // }, + api_keys: { + sandbox: sandboxKey, + production: prodKey, + }, }); }, }) diff --git a/server/src/internal/products/handlers/handleUpdateProduct/updateProductDetails.ts b/server/src/internal/products/handlers/handleUpdateProduct/updateProductDetails.ts index 33c433d93..66eb86c63 100644 --- a/server/src/internal/products/handlers/handleUpdateProduct/updateProductDetails.ts +++ b/server/src/internal/products/handlers/handleUpdateProduct/updateProductDetails.ts @@ -21,6 +21,7 @@ import { isPriceItem, } from "../../product-items/productItemUtils/getItemType.js"; import { isFreeProduct } from "../../productUtils.js"; +import { isStripeConnected } from "@/internal/orgs/orgUtils.js"; const productDetailsSame = (prod1: Product, prod2: UpdateProduct) => { if (notNullish(prod2.id) && prod1.id != prod2.id) { @@ -59,7 +60,7 @@ const updateStripeProductNames = async ({ newName: string; logger: any; }) => { - if (!org.stripe_connected) return; + if (!isStripeConnected({ org, env: curProduct.env as AppEnv })) return; const stripeCli = createStripeCli({ org, env: curProduct.env as AppEnv, @@ -81,7 +82,7 @@ const updateStripeProductNames = async ({ error, stripeProdId, newName, - }, + } ); } @@ -103,7 +104,7 @@ const updateStripeProductNames = async ({ }); } catch (error: any) { logger.error( - `Error updating price ${price.id} name in Stripe: ${error.message}`, + `Error updating price ${price.id} name in Stripe: ${error.message}` ); } } @@ -198,7 +199,7 @@ export const handleUpdateProductDetails = async ({ // Update product name in Stripe if (curProduct.name !== newProduct.name && notNullish(newProduct.name)) { logger.info( - `Updating product (${curProduct.id}) name in Stripe to ${newProduct.name}`, + `Updating product (${curProduct.id}) name in Stripe to ${newProduct.name}` ); await updateStripeProductNames({ db, diff --git a/server/src/middleware/analyticsMiddleware.ts b/server/src/middleware/analyticsMiddleware.ts index 01266d179..b7c163630 100644 --- a/server/src/middleware/analyticsMiddleware.ts +++ b/server/src/middleware/analyticsMiddleware.ts @@ -12,7 +12,7 @@ const handleResFinish = (req: any, res: any) => { { statusCode: res.statusCode, res: res.locals.responseBody, - }, + } ); } } catch (error) { @@ -29,7 +29,7 @@ const parseCustomerIdFromUrl = (url: string): string | undefined => { const cleanUrl = url.split("?")[0].replace(/^\/+|\/+$/g, ""); const segments = cleanUrl.split("/"); const customersIndex = segments.findIndex( - (segment) => segment === "customers", + (segment) => segment === "customers" ); if (customersIndex !== -1 && segments[customersIndex + 1]) { @@ -48,6 +48,7 @@ export const analyticsMiddleware = async (req: any, res: any, next: any) => { body: req.body, customer_id: req?.body?.customer_id || parseCustomerIdFromUrl(req.originalUrl), + user_id: req.userId || null, }; if (req.span) { diff --git a/server/src/middleware/apiAuthMiddleware.ts b/server/src/middleware/apiAuthMiddleware.ts index dffd72d1b..a0477986c 100644 --- a/server/src/middleware/apiAuthMiddleware.ts +++ b/server/src/middleware/apiAuthMiddleware.ts @@ -80,7 +80,7 @@ export const verifySecretKey = async (req: any, res: any, next: any) => { }); } - let { org, features, env } = data; + let { org, features, env, userId } = data; req.orgId = org.id; req.env = env; req.minOrg = { @@ -90,6 +90,7 @@ export const verifySecretKey = async (req: any, res: any, next: any) => { req.org = org; req.features = features; req.authType = AuthType.SecretKey; + req.userId = userId; next(); }; diff --git a/server/src/utils/scriptUtils/initCustomer.ts b/server/src/utils/scriptUtils/initCustomer.ts index 9ef0e7656..2826044e8 100644 --- a/server/src/utils/scriptUtils/initCustomer.ts +++ b/server/src/utils/scriptUtils/initCustomer.ts @@ -8,6 +8,7 @@ import { } from "../../external/stripe/stripeCusUtils.js"; import { CusService } from "@/internal/customers/CusService.js"; import Stripe from "stripe"; +import { deleteCusCache } from "@/internal/customers/cusCache/updateCachedCus.js"; export const createCusInStripe = async ({ customer, @@ -83,6 +84,12 @@ export const initCustomer = async ({ if (customer) { await autumn.customers.delete(customerId); + await deleteCusCache({ + db, + customerId: customerId, + orgId: org.id, + env: env, + }); } try { @@ -95,6 +102,9 @@ export const initCustomer = async ({ env: env, })) as Customer; + // console.log("customer id", customerId); + // console.log("org id", org.id); + // console.log("env", env); // console.log("customer", customer); const stripeCli = createStripeCli({ org: org, env: env }); diff --git a/server/tests/attach/entities/entity2.ts b/server/tests/attach/entities/entity2.ts index 615a9b937..b371ec458 100644 --- a/server/tests/attach/entities/entity2.ts +++ b/server/tests/attach/entities/entity2.ts @@ -130,8 +130,9 @@ describe(`${chalk.yellowBright(`attach/${testCase}: Testing attach pro annual to testClockId, advanceTo: addHours( addMonths(curUnix, 1), - hoursToFinalizeInvoice, + hoursToFinalizeInvoice ).getTime(), + waitForSeconds: 30, }); await expectInvoiceAfterUsage({ diff --git a/server/tests/attach/newVersion/newVersion2.ts b/server/tests/attach/newVersion/newVersion2.ts new file mode 100644 index 000000000..9fe9d7041 --- /dev/null +++ b/server/tests/attach/newVersion/newVersion2.ts @@ -0,0 +1,168 @@ +import { expect } from "chai"; +import { AutumnInt } from "@/external/autumn/autumnCli.js"; +import { initCustomer } from "@/utils/scriptUtils/initCustomer.js"; +import { + APIVersion, + AppEnv, + BillingInterval, + Organization, + ProductV2, +} from "@autumn/shared"; +import chalk from "chalk"; +import Stripe from "stripe"; +import { DrizzleCli } from "@/db/initDrizzle.js"; +import { setupBefore } from "tests/before.js"; +import { createProducts } from "tests/utils/productUtils.js"; +import { addPrefixToProducts, runAttachTest } from "../utils.js"; +import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; +import { constructArrearItem } from "@/utils/scriptUtils/constructItem.js"; +import { TestFeature } from "tests/setup/v2Features.js"; +import { replaceItems } from "../utils.js"; +import { constructPriceItem } from "@/internal/products/product-items/productItemUtils.js"; +import runUpdateEntsTest from "../updateEnts/expectUpdateEnts.js"; +import { timeout } from "@/utils/genUtils.js"; +import { advanceTestClock } from "tests/utils/stripeUtils.js"; +import { addHours, addMonths, addWeeks } from "date-fns"; +import { hoursToFinalizeInvoice } from "tests/utils/constants.js"; +import { getExpectedInvoiceTotal } from "tests/utils/expectUtils/expectInvoiceUtils.js"; + +export let pro = constructProduct({ + items: [constructArrearItem({ featureId: TestFeature.Words })], + type: "pro", + trial: true, +}); + +const testCase = "newVersion2"; + +describe(`${chalk.yellowBright(`${testCase}: Testing attach new version for trial product`)}`, () => { + let customerId = testCase; + let autumn: AutumnInt = new AutumnInt({ version: APIVersion.v1_4 }); + let testClockId: string; + let db: DrizzleCli, org: Organization, env: AppEnv; + let stripeCli: Stripe; + + let curUnix = new Date().getTime(); + + before(async function () { + await setupBefore(this); + const { autumnJs } = this; + db = this.db; + org = this.org; + env = this.env; + + stripeCli = this.stripeCli; + + addPrefixToProducts({ + products: [pro], + prefix: testCase, + }); + + await createProducts({ + db, + orgId: org.id, + env, + autumn, + products: [pro], + customerId, + }); + + const { testClockId: testClockId1 } = await initCustomer({ + autumn: autumnJs, + customerId, + db, + org, + env, + attachPm: "success", + }); + + testClockId = testClockId1!; + }); + + it("should attach pro product", async function () { + await runAttachTest({ + autumn, + customerId, + product: pro, + stripeCli, + db, + org, + env, + }); + }); + + let usage = 50000; + let newPro: ProductV2; + it("should update product to new version", async function () { + newPro = structuredClone(pro); + let newItems = replaceItems({ + items: pro.items, + interval: BillingInterval.Month, + newItem: constructPriceItem({ + price: 100, + interval: BillingInterval.Month, + }), + }); + + newPro.version = 2; + newPro.items = newItems; + + await autumn.products.update(pro.id, { + items: newItems, + }); + }); + + return; + + it("should attach pro v2", async function () { + await runUpdateEntsTest({ + autumn, + stripeCli, + customerId, + customProduct: newPro, + newVersion: 2, + db, + org, + env, + }); + }); + + // it("should have correct invoice total on next cycle", async function () { + // const invoiceTotal = await getExpectedInvoiceTotal({ + // org, + // env, + // customerId, + // productId: pro.id, + // stripeCli, + // db, + // usage: [ + // { + // featureId: TestFeature.Words, + // value: usage, + // }, + // ], + // onlyIncludeMonthly: true, + // }); + + // let curUnix = Date.now(); + // curUnix = await advanceTestClock({ + // stripeCli, + // testClockId, + // advanceTo: addMonths(curUnix, 1).getTime(), + // waitForSeconds: 30, + // }); + + // await advanceTestClock({ + // stripeCli, + // testClockId, + // advanceTo: addHours(curUnix, hoursToFinalizeInvoice).getTime(), + // waitForSeconds: 10, + // }); + + // const customer = await autumn.customers.get(customerId); + // const invoice = customer.invoices[0]; + // expect(invoice.total).to.equal( + // invoiceTotal, + // "invoice total after 1 cycle should be correct" + // ); + // }); +}); diff --git a/shared/db/auth-schema.ts b/shared/db/auth-schema.ts index bc6dbe3a8..a420a5837 100644 --- a/shared/db/auth-schema.ts +++ b/shared/db/auth-schema.ts @@ -4,28 +4,40 @@ import { timestamp, boolean, integer, + foreignKey, } from "drizzle-orm/pg-core"; import { organizations } from "./schema.js"; -export const user = pgTable("user", { - id: text("id").primaryKey(), - name: text("name").notNull(), - email: text("email").notNull().unique(), - emailVerified: boolean("email_verified") - .$defaultFn(() => false) - .notNull(), - image: text("image"), - createdAt: timestamp("created_at", { withTimezone: true }) - .$defaultFn(() => /* @__PURE__ */ new Date()) - .notNull(), - updatedAt: timestamp("updated_at", { withTimezone: true }) - .$defaultFn(() => /* @__PURE__ */ new Date()) - .notNull(), - role: text("role"), - banned: boolean("banned"), - banReason: text("ban_reason"), - banExpires: timestamp("ban_expires"), -}); +export const user = pgTable( + "user", + { + id: text("id").primaryKey(), + name: text("name").notNull(), + email: text("email").notNull().unique(), + emailVerified: boolean("email_verified") + .$defaultFn(() => false) + .notNull(), + image: text("image"), + createdAt: timestamp("created_at", { withTimezone: true }) + .$defaultFn(() => /* @__PURE__ */ new Date()) + .notNull(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .$defaultFn(() => /* @__PURE__ */ new Date()) + .notNull(), + role: text("role"), + banned: boolean("banned"), + banReason: text("ban_reason"), + banExpires: timestamp("ban_expires"), + createdBy: text("created_by"), + }, + (table) => [ + foreignKey({ + columns: [table.createdBy], + foreignColumns: [organizations.id], + name: "user_created_by_fkey", + }), + ] +); export const session = pgTable("session", { id: text("id").primaryKey(), @@ -66,10 +78,10 @@ export const verification = pgTable("verification", { value: text("value").notNull(), expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(), createdAt: timestamp("created_at", { withTimezone: true }).$defaultFn( - () => /* @__PURE__ */ new Date(), + () => /* @__PURE__ */ new Date() ), updatedAt: timestamp("updated_at", { withTimezone: true }).$defaultFn( - () => /* @__PURE__ */ new Date(), + () => /* @__PURE__ */ new Date() ), }).enableRLS(); diff --git a/shared/models/devModels/apiKeyModels.ts b/shared/models/devModels/apiKeyModels.ts index 02edc0dc9..32fc8fbe7 100644 --- a/shared/models/devModels/apiKeyModels.ts +++ b/shared/models/devModels/apiKeyModels.ts @@ -3,7 +3,7 @@ import { AppEnv } from "../genModels/genEnums.js"; export type ApiKey = { id: string; org_id: string; - user_id: string; + user_id: string | null; name: string; prefix: string; created_at: number; diff --git a/shared/models/orgModels/orgTable.ts b/shared/models/orgModels/orgTable.ts index 680edf020..13ac25da4 100644 --- a/shared/models/orgModels/orgTable.ts +++ b/shared/models/orgModels/orgTable.ts @@ -17,11 +17,11 @@ export type SvixConfig = { }; export type StripeConfig = { - test_api_key: string; - live_api_key: string; - test_webhook_secret: string; - live_webhook_secret: string; - success_url: string; + test_api_key?: string; + live_api_key?: string; + test_webhook_secret?: string; + live_webhook_secret?: string; + success_url?: string; }; // logo: text("logo"), @@ -56,7 +56,7 @@ export const organizations = pgTable( (table) => [ unique("organizations_test_pkey_key").on(table.test_pkey), unique("organizations_live_pkey_key").on(table.live_pkey), - ], + ] ); export type Organization = typeof organizations.$inferSelect & { diff --git a/vite/src/views/customers/customer/customer-product-list/CusProductStatus.tsx b/vite/src/views/customers/customer/customer-product-list/CusProductStatus.tsx index c087fd87d..5f238cf0a 100644 --- a/vite/src/views/customers/customer/customer-product-list/CusProductStatus.tsx +++ b/vite/src/views/customers/customer/customer-product-list/CusProductStatus.tsx @@ -5,6 +5,7 @@ import { CusProductStripeLink } from "./CusProductStripeLink"; import { keyToTitle } from "@/utils/formatUtils/formatTextUtils"; import { formatUnixToDateTime } from "@/utils/formatUtils/formatDateUtils"; import { notNullish } from "@/utils/genUtils"; +import { differenceInDays, subDays } from "date-fns"; export const CusProductStatusItem = ({ cusProduct, @@ -12,12 +13,14 @@ export const CusProductStatusItem = ({ cusProduct: FullCusProduct; }) => { const getStatus = () => { + if (cusProduct.status == CusProductStatus.Expired) { + return CusProductStatus.Expired; + } + const trialing = cusProduct.trial_ends_at && cusProduct.trial_ends_at > Date.now(); - const canceled = - notNullish(cusProduct.canceled_at) && - cusProduct.status !== CusProductStatus.Expired; + const canceled = notNullish(cusProduct.canceled_at); if (canceled) return "canceled"; if (trialing) { @@ -27,14 +30,24 @@ export const CusProductStatusItem = ({ return cusProduct.status; }; - const isCanceled = notNullish(cusProduct.canceled_at); + const getTitle = () => { + const status = getStatus(); + if (status == CusProductStatus.Trialing) { + const daysTillEnd = differenceInDays( + new Date(cusProduct.trial_ends_at!), + new Date() + ); + return `trial (${daysTillEnd}d)`; + } + return keyToTitle(getStatus()).toLowerCase(); + }; const statusToColor: Record = { [CusProductStatus.Active]: "bg-lime-500", [CusProductStatus.Expired]: "bg-stone-800", [CusProductStatus.PastDue]: "bg-red-500", [CusProductStatus.Scheduled]: "bg-blue-500", - [CusProductStatus.Trialing]: "bg-yellow-400", + [CusProductStatus.Trialing]: "bg-blue-400", canceled: "bg-gray-500", [CusProductStatus.Unknown]: "bg-gray-500", }; @@ -45,7 +58,7 @@ export const CusProductStatusItem = ({ variant="status" className={cn("h-fit", statusToColor[getStatus()])} > - {keyToTitle(getStatus()).toLowerCase()} + {getTitle()} {/* {isCanceled && ( diff --git a/vite/src/views/customers/customer/product/components/AttachModal.tsx b/vite/src/views/customers/customer/product/components/AttachModal.tsx index 74876bbb4..b5890aca5 100644 --- a/vite/src/views/customers/customer/product/components/AttachModal.tsx +++ b/vite/src/views/customers/customer/product/components/AttachModal.tsx @@ -56,7 +56,7 @@ export const AttachModal = ({ if (entityId) { const entity = entities.find( - (e: Entity) => e.id === entityId || e.internal_id === entityId, + (e: Entity) => e.id === entityId || e.internal_id === entityId ); const entityName = entity?.name || entity?.id || entity?.internal_id; return `${cusName} (${entityName})`; @@ -110,6 +110,7 @@ export const AttachModal = ({ } const dueToday = preview?.due_today; + if (dueToday && dueToday.total == 0) { return "Confirm"; } @@ -169,7 +170,7 @@ export const AttachModal = ({ navigateTo( `/integrations/stripe?redirect=${redirectUrl}`, navigation, - env, + env ); } else { toast.error(getBackendErr(error, "Error creating product")); @@ -228,7 +229,7 @@ export const AttachModal = ({ {invoiceAllowed() && ( diff --git a/vite/src/views/customers/customer/product/components/attach-preview/AttachInfo.tsx b/vite/src/views/customers/customer/product/components/attach-preview/AttachInfo.tsx index bf18f9ae5..8cbacdb9f 100644 --- a/vite/src/views/customers/customer/product/components/attach-preview/AttachInfo.tsx +++ b/vite/src/views/customers/customer/product/components/attach-preview/AttachInfo.tsx @@ -8,6 +8,7 @@ import { ProductItem, ProductItemFeatureType, } from "@autumn/shared"; +import { format } from "date-fns"; import { InfoIcon } from "lucide-react"; export const AttachInfo = () => { @@ -44,13 +45,27 @@ export const AttachInfo = () => { ); }); - let text = `The customer is currently on ${currentProduct.name} v${currentProduct.version}. Switching to v${product.version} will update the customer's features immediately, and from ${formatUnixToDate(preview.due_next_cycle.due_at)} onwards they will pay any new prices`; + return ( + <> + + You are switching this customer to version {product.version} of{" "} + {product.name}. Their features will update immediately and from{" "} + {format(preview.due_next_cycle.due_at, "d MMM")} onwards, they will + pay any new prices + {usagePriceExists ? " (including usage from the last cycle)" : ""}. + + + ); - if (usagePriceExists) { - text += ` (including usage from the last cycle).`; - } else { - text += `.`; - } + const text = `You are switching this customer to version ${product.version} of ${product.name}.`; + + // let text = `The customer is currently on ${currentProduct.name} v${currentProduct.version}. Switching to v${product.version} will update the customer's features immediately, and from ${formatUnixToDate(preview.due_next_cycle.due_at)} onwards they will pay any new prices`; + + // if (usagePriceExists) { + // text += ` (including usage from the last cycle).`; + // } else { + // text += `.`; + // } return text; } diff --git a/vite/src/views/developer/CreateAPIKey.tsx b/vite/src/views/developer/CreateAPIKey.tsx index 84f1bd28a..c17e92db9 100644 --- a/vite/src/views/developer/CreateAPIKey.tsx +++ b/vite/src/views/developer/CreateAPIKey.tsx @@ -40,7 +40,6 @@ const CreateAPIKey = () => { }, [copied]); const handleCreate = async () => { - console.log("creating api key", apiKeyName ? apiKeyName : name); setLoading(true); try { const { api_key } = await DevService.createAPIKey(axiosInstance, {