feat: deploy to production flow

- Added 3-step deploy dialog (Connect Stripe, Copy Products, Create API Key)
- Implemented backend endpoints for copying products/features between environments
- Added localStorage caching for organization data and deploy button visibility
- Created useShowDeployButton hook to conditionally show deploy button
- Hide environment switch in CommandBar when organization not deployed
- Abstracted feature and product creation/update logic into reusable actions

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
John Yeo
2025-10-20 16:59:13 +01:00
parent 5a2902f630
commit 843536bab4
45 changed files with 1712 additions and 590 deletions

View File

@@ -2,10 +2,10 @@ import "dotenv/config";
import Stripe from "stripe";
const main = async () => {
const stripe = new Stripe(process.env.STRIPE_SANDBOX_SECRET_KEY || "");
const stripe = new Stripe(process.env.STRIPE_LIVE_SECRET_KEY || "");
const result = await stripe.webhookEndpoints.create({
url: "https://api.useautumn.com/webhooks/connect/sandbox",
url: "https://express.dev.useautumn.com/webhooks/connect/live",
enabled_events: [
"checkout.session.completed",
"customer.subscription.created",

View File

@@ -0,0 +1,54 @@
import type { Feature } from "@autumn/shared";
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
import { JobName } from "@/queue/JobName.js";
import { addTaskToQueue } from "@/queue/queueUtils.js";
import { generateId } from "@/utils/genUtils.js";
import { FeatureService } from "../FeatureService.js";
import { validateFeature } from "../internalFeatureRouter.js";
interface CreateFeatureParams {
ctx: AutumnContext;
data: {
id: string;
name: string;
type: string;
config?: any;
event_names?: string[];
};
}
/**
* Creates a new feature in the database
* Used by both the API handler and internal operations like product copying
*/
export const createFeature = async ({
ctx,
data,
}: CreateFeatureParams): Promise<Feature | null> => {
const parsedFeature = validateFeature(data);
const feature: Feature = {
archived: false,
internal_id: generateId("fe"),
org_id: ctx.org.id,
created_at: Date.now(),
env: ctx.env,
...parsedFeature,
};
const insertedData = await FeatureService.insert({
db: ctx.db,
data: feature,
logger: ctx.logger,
});
await addTaskToQueue({
jobName: JobName.GenerateFeatureDisplay,
payload: {
feature,
org: ctx.org,
},
});
return insertedData && insertedData.length > 0 ? insertedData[0] : null;
};

View File

@@ -0,0 +1,147 @@
import { ErrCode, type Feature, FeatureType, notNullish } from "@autumn/shared";
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
import { JobName } from "@/queue/JobName.js";
import { addTaskToQueue } from "@/queue/queueUtils.js";
import RecaseError from "@/utils/errorUtils.js";
import { FeatureService } from "../FeatureService.js";
import {
validateCreditSystem,
validateMeteredConfig,
} from "../featureUtils.js";
import { getObjectsUsingFeature } from "../handlers/handleUpdateFeature/getObjectsUsingFeature.js";
import { handleFeatureIdChanged } from "../handlers/handleUpdateFeature/handleFeatureIdChanged.js";
import { handleFeatureTypeChanged } from "../handlers/handleUpdateFeature/handleFeatureTypeChanged.js";
import { handleFeatureUsageTypeChanged } from "../handlers/handleUpdateFeature/handleFeatureUsageTypeChanged.js";
interface UpdateFeatureParams {
ctx: AutumnContext;
featureId: string;
updates: Partial<Feature>;
}
/**
* Updates an existing feature with full validation logic
*/
export const updateFeature = async ({
ctx,
featureId,
updates,
}: UpdateFeatureParams): Promise<Feature | null> => {
// 1. Get all features and find the one to update
const allFeatures = await FeatureService.list({
db: ctx.db,
orgId: ctx.org.id,
env: ctx.env,
});
const feature = allFeatures.find((f) => f.id === featureId);
if (!feature) {
throw new RecaseError({
message: `Feature ${featureId} not found`,
code: ErrCode.InvalidFeature,
statusCode: 404,
});
}
// Check if changing type, id, or usage type
const isChangingType =
notNullish(updates.type) && feature.type !== updates.type;
const isChangingId = notNullish(updates.id) && feature.id !== updates.id;
const isChangingUsageType =
feature.type !== FeatureType.Boolean &&
updates.type !== FeatureType.Boolean &&
feature.config?.usage_type !== updates.config?.usage_type;
const isChangingName = updates.name && feature.name !== updates.name;
if (isChangingType || isChangingId || isChangingUsageType) {
const objectsUsingFeature = await getObjectsUsingFeature({
db: ctx.db,
orgId: ctx.org.id,
env: ctx.env,
allFeatures,
feature,
});
// Handle type change
if (isChangingType && updates.type) {
await handleFeatureTypeChanged({
ctx,
objectsUsingFeature,
feature,
newType: updates.type,
});
}
const { linkedEntitlements, entitlements, prices, creditSystems } =
objectsUsingFeature;
// Handle ID change
if (isChangingId && updates.id) {
await handleFeatureIdChanged({
ctx,
feature,
linkedEntitlements,
entitlements,
prices,
creditSystems,
newId: updates.id,
});
}
// Handle usage type change
if (isChangingUsageType && updates.config?.usage_type) {
await handleFeatureUsageTypeChanged({
db: ctx.db,
feature,
linkedEntitlements,
entitlements,
prices,
creditSystems,
newUsageType: updates.config.usage_type,
});
}
}
// Validate config based on feature type
const newConfig =
updates.config !== undefined
? feature.type === FeatureType.CreditSystem
? validateCreditSystem(updates.config)
: feature.type === FeatureType.Metered
? validateMeteredConfig(updates.config)
: updates.config
: feature.config;
// Update the feature
const updatedFeature = await FeatureService.update({
db: ctx.db,
id: featureId,
orgId: ctx.org.id,
env: ctx.env,
updates: {
id: updates.id,
name: updates.name,
type: updates.type,
archived: updates.archived,
event_names: updates.event_names,
config: newConfig,
},
});
// Queue display generation if name changed
if (isChangingName && updatedFeature) {
await addTaskToQueue({
jobName: JobName.GenerateFeatureDisplay,
payload: {
feature: updatedFeature,
org: ctx.org,
},
});
}
return updatedFeature;
};

View File

@@ -40,29 +40,6 @@ export const validateMeteredConfig = (config: MeteredConfig) => {
});
}
// Event names are now stored in feature.event_names, not in config.filters
// if (config.aggregate?.type === AggregateType.Count) {
// newConfig.aggregate = {
// type: AggregateType.Count,
// property: null,
// }; // to continue testing support for count...
// } else {
// newConfig.aggregate = {
// type: AggregateType.Sum,
// property: "value",
// };
// }
// if (newConfig?.filters?.length === 0 || !newConfig?.filters) {
// newConfig.filters = [
// {
// property: "",
// operator: "",
// value: [],
// },
// ];
// }
return newConfig as MeteredConfig;
};
@@ -137,7 +114,7 @@ export const runSaveFeatureDisplayTask = async ({
await FeatureService.update({
db,
internalId: feature.internal_id!,
internalId: feature.internal_id,
updates: {
display,
},

View File

@@ -1,45 +1,16 @@
import type { Feature } from "@autumn/shared";
import { OrgService } from "@/internal/orgs/OrgService.js";
import { JobName } from "@/queue/JobName.js";
import { addTaskToQueue } from "@/queue/queueUtils.js";
import { handleFrontendReqError } from "@/utils/errorUtils.js";
import { generateId } from "@/utils/genUtils.js";
import { FeatureService } from "../FeatureService.js";
import { validateFeature } from "../internalFeatureRouter.js";
import { createFeature } from "../featureActions/createFeature.js";
export const handleCreateFeature = async (req: any, res: any) => {
try {
console.log("Trying to create feature");
const data = req.body;
const { db, orgId, env, logger } = req;
const parsedFeature = validateFeature(data);
const feature: Feature = {
archived: false,
internal_id: generateId("fe"),
org_id: orgId,
created_at: Date.now(),
env: env,
...parsedFeature,
};
const org = await OrgService.getFromReq(req);
const insertedData = await FeatureService.insert({
db,
data: feature,
logger,
const insertedFeature = await createFeature({
ctx: req,
data,
});
await addTaskToQueue({
jobName: JobName.GenerateFeatureDisplay,
payload: {
feature,
org: org,
},
});
const insertedFeature =
insertedData && insertedData.length > 0 ? insertedData[0] : null;
res.status(200).json(insertedFeature);
} catch (error) {
handleFrontendReqError({ req, error, res, action: "Create feature" });

View File

@@ -1,257 +1,11 @@
import {
type AppEnv,
EntInterval,
type Entitlement,
type EntitlementWithFeature,
ErrCode,
type Feature,
FeatureType,
FeatureUsageType,
notNullish,
type Price,
type UsagePriceConfig,
} from "@autumn/shared";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import { CusEntService } from "@/internal/customers/cusProducts/cusEnts/CusEntitlementService.js";
import { FeatureService } from "@/internal/features/FeatureService.js";
import {
validateCreditSystem,
validateMeteredConfig,
} from "@/internal/features/featureUtils.js";
import { EntitlementService } from "@/internal/products/entitlements/EntitlementService.js";
import { PriceService } from "@/internal/products/prices/PriceService.js";
import { JobName } from "@/queue/JobName.js";
import { addTaskToQueue } from "@/queue/queueUtils.js";
import RecaseError from "@/utils/errorUtils.js";
import { keyToTitle } from "@/utils/genUtils.js";
import { routeHandler } from "@/utils/routerUtils.js";
import { updateFeature } from "../featureActions/updateFeature.js";
import { toApiFeature } from "../utils/mapFeatureUtils.js";
import { getObjectsUsingFeature } from "./handleUpdateFeature/getObjectsUsingFeature.js";
import { handleFeatureTypeChanged } from "./handleUpdateFeature/handleFeatureTypeChanged.js";
const handleFeatureIdChanged = async ({
db,
orgId,
env,
feature,
linkedEntitlements,
entitlements,
prices,
creditSystems,
newId,
logger,
}: {
db: DrizzleCli;
orgId: string;
env: AppEnv;
feature: Feature;
linkedEntitlements: Entitlement[];
entitlements: Entitlement[];
prices: Price[];
creditSystems: Feature[];
newId: string;
logger: any;
}) => {
// 1. Check if any customer entitlement linked to this feature
const cusEnts = await CusEntService.getByFeature({
db,
internalFeatureId: feature.internal_id!,
});
if (cusEnts.length > 0) {
throw new RecaseError({
message: `Cannot change id of feature ${feature.id} because a customer is using it`,
code: ErrCode.InvalidFeature,
statusCode: 400,
});
}
// 2. Update all linked objects
const batchUpdate = [];
for (const entitlement of linkedEntitlements) {
batchUpdate.push(
EntitlementService.update({
db,
id: entitlement.id!,
updates: {
entity_feature_id: newId,
},
}),
);
}
await Promise.all(batchUpdate);
// 3. Update all linked prices
const priceUpdate = [];
for (const price of prices) {
priceUpdate.push(
PriceService.update({
db,
id: price.id!,
update: {
config: {
...price.config,
feature_id: newId,
} as UsagePriceConfig,
},
}),
);
}
await Promise.all(priceUpdate);
// 4. Update all linked credit systems
const creditSystemUpdate = [];
for (const creditSystem of creditSystems) {
const newSchema = structuredClone(creditSystem.config.schema);
for (let i = 0; i < newSchema.length; i++) {
if (newSchema[i].metered_feature_id === feature.id) {
newSchema[i].metered_feature_id = newId;
}
}
creditSystemUpdate.push(
FeatureService.update({
db,
id: creditSystem.id!,
orgId,
env,
updates: {
config: {
...creditSystem.config,
schema: newSchema,
},
},
}),
);
}
await Promise.all(creditSystemUpdate);
// 5. Update all linked entitlements
const entitlementUpdate = [];
for (const entitlement of entitlements) {
entitlementUpdate.push(
EntitlementService.update({
db,
id: entitlement.id!,
updates: {
feature_id: newId,
},
}),
);
}
await Promise.all(entitlementUpdate);
};
const handleFeatureUsageTypeChanged = async ({
db,
feature,
newUsageType,
linkedEntitlements,
entitlements,
prices,
creditSystems,
}: {
db: DrizzleCli;
feature: Feature;
newUsageType: FeatureUsageType;
linkedEntitlements: EntitlementWithFeature[];
entitlements: EntitlementWithFeature[];
prices: Price[];
creditSystems: Feature[];
}) => {
const usageTypeTitle = keyToTitle(newUsageType).toLowerCase();
if (creditSystems.length > 0) {
throw new RecaseError({
message: `Cannot set to ${usageTypeTitle} because it is used in credit system ${creditSystems[0].id}`,
code: ErrCode.InvalidFeature,
statusCode: 400,
});
}
if (linkedEntitlements.length > 0) {
throw new RecaseError({
message: `Cannot set to ${usageTypeTitle} because it is used as an entity by ${linkedEntitlements[0].feature.name}`,
code: ErrCode.InvalidFeature,
statusCode: 400,
});
}
// Get cus product using feature...
const cusEnts = await CusEntService.getByFeature({
db,
internalFeatureId: feature.internal_id!,
});
if (cusEnts && cusEnts.length > 0) {
throw new RecaseError({
message: `Cannot set to ${usageTypeTitle} because it is / was used by customers`,
code: ErrCode.InvalidFeature,
statusCode: 400,
});
}
if (entitlements.length > 0) {
console.log(
`Feature usage type changed to ${newUsageType}, updating entitlements and prices`,
);
if (newUsageType === FeatureUsageType.Continuous) {
const batchEntUpdate = [];
for (const entitlement of entitlements) {
batchEntUpdate.push(
EntitlementService.update({
db,
id: entitlement.id!,
updates: {
interval: EntInterval.Lifetime,
},
}),
);
}
await Promise.all(batchEntUpdate);
console.log(`Updated ${entitlements.length} entitlements`);
}
}
if (prices.length > 0) {
const batchPriceUpdate = [];
for (const price of prices) {
const priceConfig = price.config as UsagePriceConfig;
batchPriceUpdate.push(
PriceService.update({
db,
id: price.id!,
update: {
config: {
...priceConfig,
should_prorate:
newUsageType === FeatureUsageType.Continuous ? false : true, // if continuous, don't prorate -> get usage_in_arrear type...
stripe_price_id: null,
},
},
}),
);
}
await Promise.all(batchPriceUpdate);
console.log(`Updated ${prices.length} prices`);
}
// // Allow update for entitlement / price?
// if (entitlements.length > 0) {
// }
};
export const handleUpdateFeature = async (
req: any,
res: any,
fromApi: boolean = false,
_fromApi: boolean = false,
) =>
routeHandler({
req,
@@ -260,143 +14,13 @@ export const handleUpdateFeature = async (
handler: async (req: any, res: any) => {
const featureId = req.params.feature_id;
const data = req.body;
const { db, orgId, env, logger } = req;
// 1. Get feature by ID
const features = await FeatureService.getFromReq(req);
const feature = features.find((f) => f.id === featureId);
if (!feature) {
throw new RecaseError({
message: `Feature ${featureId} not found`,
code: ErrCode.InvalidFeature,
statusCode: 404,
});
}
// If only archiving, skip other checks and just update
if (data.archived !== undefined && Object.keys(data).length === 1) {
console.log("Updating feature archived to: ", data.archived);
const updatedFeature = await FeatureService.update({
db: req.db,
id: featureId,
orgId: req.orgId,
env: req.env,
updates: {
archived: data.archived,
},
});
if (res) {
res
.status(200)
.json(
updatedFeature
? toApiFeature({ feature: updatedFeature })
: undefined,
);
}
return;
}
// 1. Check if changing type...
const isChangingType =
notNullish(data.type) && feature.type !== data.type;
const isChangingId = notNullish(data.id) && feature.id !== data.id;
const isChangingUsageType =
feature.type !== FeatureType.Boolean &&
data.type !== FeatureType.Boolean &&
feature.config?.usage_type !== data.config?.usage_type;
const isChangingName = feature.name !== data.name;
if (isChangingType || isChangingId || isChangingUsageType) {
const objectsUsingFeature = await getObjectsUsingFeature({
db,
orgId: req.orgId,
env: req.env,
allFeatures: features,
feature,
});
// 1. Can't change type if any objects are linked to it
if (isChangingType) {
await handleFeatureTypeChanged({
// Use the abstracted updateFeature function
const updatedFeature = await updateFeature({
ctx: req,
objectsUsingFeature,
feature,
newType: data.type,
featureId,
updates: data,
});
}
const { linkedEntitlements, entitlements, prices, creditSystems } =
objectsUsingFeature;
if (isChangingId) {
await handleFeatureIdChanged({
db,
orgId,
env,
feature,
linkedEntitlements,
entitlements,
prices,
creditSystems,
newId: data.id,
logger,
});
}
if (isChangingUsageType && data.config?.usage_type) {
await handleFeatureUsageTypeChanged({
db,
feature,
linkedEntitlements,
entitlements,
prices,
creditSystems,
newUsageType: data.config.usage_type,
});
}
}
const newConfig =
data.config !== undefined
? feature.type === FeatureType.CreditSystem
? validateCreditSystem(data.config)
: feature.type === FeatureType.Metered
? validateMeteredConfig(data.config)
: data.config
: feature.config;
const updatedFeature = await FeatureService.update({
db: req.db,
id: featureId,
orgId: req.orgId,
env: req.env,
updates: {
id: data.id !== undefined ? data.id : feature.id,
name: data.name !== undefined ? data.name : feature.name,
type: data.type !== undefined ? data.type : feature.type,
archived:
data.archived !== undefined ? data.archived : feature.archived,
event_names: data.event_names,
config: newConfig,
},
});
if (isChangingName) {
await addTaskToQueue({
jobName: JobName.GenerateFeatureDisplay,
payload: {
feature: updatedFeature,
org: req.org,
},
});
}
res
.status(200)

View File

@@ -0,0 +1,127 @@
import {
type Entitlement,
ErrCode,
type Feature,
type Price,
RecaseError,
type UsagePriceConfig,
} from "@autumn/shared";
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
import { CusEntService } from "@/internal/customers/cusProducts/cusEnts/CusEntitlementService.js";
import { EntitlementService } from "@/internal/products/entitlements/EntitlementService.js";
import { PriceService } from "@/internal/products/prices/PriceService.js";
import { FeatureService } from "../../FeatureService.js";
export const handleFeatureIdChanged = async ({
ctx,
feature,
linkedEntitlements,
entitlements,
prices,
creditSystems,
newId,
}: {
ctx: AutumnContext;
feature: Feature;
linkedEntitlements: Entitlement[];
entitlements: Entitlement[];
prices: Price[];
creditSystems: Feature[];
newId: string;
}) => {
const { db, org, env } = ctx;
// 1. Check if any customer entitlement linked to this feature
const cusEnts = await CusEntService.getByFeature({
db,
internalFeatureId: feature.internal_id,
});
if (cusEnts.length > 0) {
throw new RecaseError({
message: `Cannot change id of feature ${feature.id} because a customer is using it`,
code: ErrCode.InvalidFeature,
statusCode: 400,
});
}
// 2. Update all linked objects
const batchUpdate = [];
for (const entitlement of linkedEntitlements) {
batchUpdate.push(
EntitlementService.update({
db,
id: entitlement.id!,
updates: {
entity_feature_id: newId,
},
}),
);
}
await Promise.all(batchUpdate);
// 3. Update all linked prices
const priceUpdate = [];
for (const price of prices) {
priceUpdate.push(
PriceService.update({
db,
id: price.id!,
update: {
config: {
...price.config,
feature_id: newId,
} as UsagePriceConfig,
},
}),
);
}
await Promise.all(priceUpdate);
// 4. Update all linked credit systems
const creditSystemUpdate = [];
for (const creditSystem of creditSystems) {
const newSchema = structuredClone(creditSystem.config.schema);
for (let i = 0; i < newSchema.length; i++) {
if (newSchema[i].metered_feature_id === feature.id) {
newSchema[i].metered_feature_id = newId;
}
}
creditSystemUpdate.push(
FeatureService.update({
db,
id: creditSystem.id!,
orgId: org.id,
env,
updates: {
config: {
...creditSystem.config,
schema: newSchema,
},
},
}),
);
}
await Promise.all(creditSystemUpdate);
// 5. Update all linked entitlements
const entitlementUpdate = [];
for (const entitlement of entitlements) {
entitlementUpdate.push(
EntitlementService.update({
db,
id: entitlement.id!,
updates: {
feature_id: newId,
},
}),
);
}
await Promise.all(entitlementUpdate);
};

View File

@@ -7,8 +7,8 @@ import {
RecaseError,
} from "@autumn/shared";
import { db } from "@/db/initDrizzle.js";
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
import { EntitlementService } from "@/internal/products/entitlements/EntitlementService.js";
import type { ExtendedRequest } from "@/utils/models/Request.js";
import type { ObjectsUsingFeature } from "./getObjectsUsingFeature.js";
export const handleFeatureTypeChanged = async ({
@@ -16,7 +16,7 @@ export const handleFeatureTypeChanged = async ({
feature,
newType,
}: {
ctx: ExtendedRequest;
ctx: AutumnContext;
objectsUsingFeature: ObjectsUsingFeature;
feature: Feature;
newType: FeatureType;

View File

@@ -0,0 +1,111 @@
import {
EntInterval,
type EntitlementWithFeature,
ErrCode,
type Feature,
FeatureUsageType,
keyToTitle,
type Price,
RecaseError,
type UsagePriceConfig,
} from "@autumn/shared";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import { CusEntService } from "@/internal/customers/cusProducts/cusEnts/CusEntitlementService.js";
import { EntitlementService } from "@/internal/products/entitlements/EntitlementService.js";
import { PriceService } from "@/internal/products/prices/PriceService.js";
export const handleFeatureUsageTypeChanged = async ({
db,
feature,
newUsageType,
linkedEntitlements,
entitlements,
prices,
creditSystems,
}: {
db: DrizzleCli;
feature: Feature;
newUsageType: FeatureUsageType;
linkedEntitlements: EntitlementWithFeature[];
entitlements: EntitlementWithFeature[];
prices: Price[];
creditSystems: Feature[];
}) => {
const usageTypeTitle = keyToTitle(newUsageType).toLowerCase();
if (creditSystems.length > 0) {
throw new RecaseError({
message: `Cannot set to ${usageTypeTitle} because it is used in credit system ${creditSystems[0].id}`,
code: ErrCode.InvalidFeature,
statusCode: 400,
});
}
if (linkedEntitlements.length > 0) {
throw new RecaseError({
message: `Cannot set to ${usageTypeTitle} because it is used as an entity by ${linkedEntitlements[0].feature.name}`,
code: ErrCode.InvalidFeature,
statusCode: 400,
});
}
// Get cus product using feature...
const cusEnts = await CusEntService.getByFeature({
db,
internalFeatureId: feature.internal_id,
});
if (cusEnts && cusEnts.length > 0) {
throw new RecaseError({
message: `Cannot set to ${usageTypeTitle} because it is / was used by customers`,
code: ErrCode.InvalidFeature,
statusCode: 400,
});
}
if (entitlements.length > 0) {
console.log(
`Feature usage type changed to ${newUsageType}, updating entitlements and prices`,
);
if (newUsageType === FeatureUsageType.Continuous) {
const batchEntUpdate = [];
for (const entitlement of entitlements) {
batchEntUpdate.push(
EntitlementService.update({
db,
id: entitlement.id,
updates: {
interval: EntInterval.Lifetime,
},
}),
);
}
await Promise.all(batchEntUpdate);
console.log(`Updated ${entitlements.length} entitlements`);
}
}
if (prices.length > 0) {
const batchPriceUpdate = [];
for (const price of prices) {
const priceConfig = price.config as UsagePriceConfig;
batchPriceUpdate.push(
PriceService.update({
db,
id: price.id,
update: {
config: {
...priceConfig,
should_prorate: newUsageType === FeatureUsageType.Continuous,
stripe_price_id: null,
},
},
}),
);
}
await Promise.all(batchPriceUpdate);
console.log(`Updated ${prices.length} prices`);
}
};

View File

@@ -5,17 +5,18 @@ import { OrgService } from "../OrgService.js";
export const handleUpdateOrg = createRoute({
body: z.object({
onboarded: z.boolean().optional(),
deployed: z.boolean().optional(),
}),
handler: async (c) => {
const ctx = c.get("ctx");
const { db, org } = ctx;
const { onboarded } = c.req.valid("json");
const { onboarded, deployed } = c.req.valid("json");
await OrgService.update({
db,
orgId: org.id,
updates: { onboarded },
updates: { onboarded, deployed },
});
return c.json({ success: true });

View File

@@ -1,9 +1,14 @@
import { AppEnv, ErrCode, RecaseError } from "@autumn/shared";
import { z } from "zod/v4";
import { createRoute } from "@/honoMiddlewares/routeHandler.js";
import { generateOAuthState } from "@/internal/platform/platformBeta/utils/oauthStateUtils.js";
export const handleGetOAuthUrl = createRoute({
query: z.object({
redirect_url: z.string().optional(),
}),
handler: async (c) => {
const { redirect_url } = c.req.query();
const ctx = c.get("ctx");
const { org, env } = ctx;
@@ -23,7 +28,7 @@ export const handleGetOAuthUrl = createRoute({
// Generate OAuth state and store in Redis
const frontendUrl = process.env.CLIENT_URL || "http://localhost:5173";
const redirectUri = `${frontendUrl}/dev?tab=stripe`;
const redirectUri = redirect_url || `${frontendUrl}/dev?tab=stripe`;
const stateKey = await generateOAuthState({
organizationSlug: org.slug,

View File

@@ -58,7 +58,9 @@ export const handleOAuthCallback = async (c: Context<HonoEnv>) => {
if (isPlatformFlow) {
redirectUrl = new URL(redirect_uri);
} else {
redirectUrl = new URL(
redirectUrl = redirect_uri
? new URL(redirect_uri)
: new URL(
`${frontendUrl}${env === AppEnv.Sandbox ? "/sandbox" : ""}/dev?tab=stripe`,
);
}

View File

@@ -234,6 +234,7 @@ export const createOrgResponse = ({
test_pkey: org.test_pkey,
live_pkey: org.live_pkey,
onboarded: org.onboarded ?? true,
deployed: org.deployed ?? true,
};
};

View File

@@ -0,0 +1,52 @@
import { AppEnv } from "@autumn/shared";
import { createRoute } from "@/honoMiddlewares/routeHandler.js";
import { FeatureService } from "@/internal/features/FeatureService.js";
import { handleCopyFeatures } from "./handleCopyEnvironment/handleCopyFeatures.js";
import { handleCopyProducts } from "./handleCopyEnvironment/handleCopyProducts.js";
/**
* POST /copy_to_production
* Copies all products and features from sandbox to production
*/
export const handleCopyEnvironment = createRoute({
handler: async (c) => {
const ctx = c.get("ctx");
const { db, org, logger } = ctx;
// Always copy from sandbox to live
const fromEnv = AppEnv.Sandbox;
const toEnv = AppEnv.Live;
// 1. Get all sandbox products and features
const [sandboxFeatures, liveFeatures] = await Promise.all([
FeatureService.list({
db,
orgId: org.id,
env: fromEnv,
}),
FeatureService.list({
db,
orgId: org.id,
env: toEnv,
}),
]);
// 2. Copy features first
await handleCopyFeatures({
ctx,
sandboxFeatures,
liveFeatures,
});
await handleCopyProducts({
ctx,
fromEnv,
toEnv,
});
return c.json({
message: "Products copied to production",
});
},
});

View File

@@ -0,0 +1,77 @@
import { AppEnv, type Feature, FeatureType } from "@autumn/shared";
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
import { createFeature } from "@/internal/features/featureActions/createFeature.js";
import { updateFeature } from "@/internal/features/featureActions/updateFeature.js";
export const handleCopyFeatures = async ({
ctx,
sandboxFeatures,
liveFeatures,
}: {
ctx: AutumnContext;
sandboxFeatures: Feature[];
liveFeatures: Feature[];
}) => {
const newContext = {
...ctx,
features: liveFeatures,
env: AppEnv.Live,
};
// Separate features by type: Boolean/Metered must be created before CreditSystem
// since credit systems can reference metered features in their credit_schema
const booleanAndMeteredFeatures = sandboxFeatures.filter(
(f) => f.type === FeatureType.Boolean || f.type === FeatureType.Metered,
);
const creditSystemFeatures = sandboxFeatures.filter(
(f) => f.type === FeatureType.CreditSystem,
);
// First, process boolean and metered features
const firstBatchPromises = [];
for (const sandboxFeature of booleanAndMeteredFeatures) {
const liveFeature = liveFeatures.find((f) => f.id === sandboxFeature.id);
if (liveFeature) {
firstBatchPromises.push(
updateFeature({
ctx: newContext,
featureId: sandboxFeature.id,
updates: sandboxFeature,
}),
);
} else {
firstBatchPromises.push(
createFeature({
ctx: newContext,
data: sandboxFeature,
}),
);
}
}
await Promise.all(firstBatchPromises);
// Then, process credit system features
const secondBatchPromises = [];
for (const sandboxFeature of creditSystemFeatures) {
const liveFeature = liveFeatures.find((f) => f.id === sandboxFeature.id);
if (liveFeature) {
secondBatchPromises.push(
updateFeature({
ctx: newContext,
featureId: sandboxFeature.id,
updates: sandboxFeature,
}),
);
} else {
secondBatchPromises.push(
createFeature({
ctx: newContext,
data: sandboxFeature,
}),
);
}
}
await Promise.all(secondBatchPromises);
};

View File

@@ -0,0 +1,106 @@
import {
type AppEnv,
type CreateProductV2Params,
mapToProductV2,
type ProductV2,
type UpdateProductV2Params,
} from "@autumn/shared";
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
import { FeatureService } from "@/internal/features/FeatureService.js";
import { ProductService } from "../../ProductService.js";
import { createProduct } from "../productActions/createProduct.js";
import { updateProduct } from "../productActions/updateProduct.js";
const conformProductToSchema = (
product: ProductV2,
): UpdateProductV2Params & Omit<CreateProductV2Params, "version"> => {
return {
id: product.id,
name: product.name,
is_add_on: product.is_add_on,
is_default: product.is_default,
group: product.group ?? undefined,
archived: product.archived ?? undefined,
items: product.items,
free_trial: product.free_trial
? {
length: product.free_trial.length,
unique_fingerprint: product.free_trial.unique_fingerprint,
duration: product.free_trial.duration,
card_required: product.free_trial.card_required,
}
: null,
};
};
export const handleCopyProducts = async ({
ctx,
fromEnv,
toEnv,
}: {
ctx: AutumnContext;
fromEnv: AppEnv;
toEnv: AppEnv;
}) => {
const { db, org } = ctx;
const [sandboxFeatures, liveFeatures, sandboxProducts, liveProducts] =
await Promise.all([
FeatureService.list({ db, orgId: org.id, env: fromEnv }),
FeatureService.list({ db, orgId: org.id, env: toEnv }),
ProductService.listFull({ db, orgId: org.id, env: fromEnv }),
ProductService.listFull({ db, orgId: org.id, env: toEnv }),
]);
const liveProductsV2 = liveProducts.map((p) =>
mapToProductV2({ product: p, features: liveFeatures }),
);
const sandboxProductsV2 = sandboxProducts.map((p) => {
const productV2 = mapToProductV2({
product: p,
features: sandboxFeatures,
});
productV2.items = productV2.items.map((i) => {
const {
price_id: _price_id,
entitlement_id: _ent_id,
price_config: _price_config,
...rest
} = i;
return rest;
});
return productV2;
});
const newContext = {
...ctx,
features: liveFeatures,
env: toEnv,
};
const operations = sandboxProductsV2.map((sandboxProductV2) => {
const liveProductV2 = liveProductsV2.find(
(p) => p.id === sandboxProductV2.id,
);
const conformedProduct = conformProductToSchema(sandboxProductV2);
if (liveProductV2) {
return updateProduct({
ctx: newContext,
productId: sandboxProductV2.id,
query: { disable_version: true },
updates: conformedProduct,
});
} else {
return createProduct({
ctx: newContext,
data: conformedProduct,
});
}
});
await Promise.all(operations);
};

View File

@@ -0,0 +1,121 @@
import type {
CreateProductV2Params,
Entitlement,
FreeTrial,
FullProduct,
Price,
} from "@autumn/shared";
import { ProductAlreadyExistsError } from "@autumn/shared";
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
import { JobName } from "@/queue/JobName.js";
import { addTaskToQueue } from "@/queue/queueUtils.js";
import { getEntsWithFeature } from "../../entitlements/entitlementUtils.js";
import {
handleNewFreeTrial,
validateOneOffTrial,
} from "../../free-trials/freeTrialUtils.js";
import { ProductService } from "../../ProductService.js";
import { handleNewProductItems } from "../../product-items/productItemUtils/handleNewProductItems.js";
import { getProductResponse } from "../../productUtils/productResponseUtils/getProductResponse.js";
import { constructProduct, initProductInStripe } from "../../productUtils.js";
import { disableCurrentDefault } from "../handleCreateProduct.js";
export const createProduct = async ({
ctx,
data,
}: {
ctx: AutumnContext;
data: CreateProductV2Params;
}) => {
const { logger, org, features, env, db } = ctx;
const existing = await ProductService.get({
db,
orgId: org.id,
env,
id: data.id,
});
// 1. If existing product, throw error
if (existing) throw new ProductAlreadyExistsError({ productId: data.id });
await disableCurrentDefault({
req: ctx,
newProduct: data,
});
const product = await ProductService.insert({
db,
product: constructProduct({
productData: data,
orgId: org.id,
env,
}),
});
const { items, free_trial } = data;
let prices: Price[] = [];
let entitlements: Entitlement[] = [];
if (items) {
const res = await handleNewProductItems({
db,
product,
features,
curPrices: [],
curEnts: [],
newItems: items,
logger,
isCustom: false,
newVersion: false,
});
prices = res.prices;
entitlements = res.entitlements;
}
await validateOneOffTrial({
prices,
freeTrial: free_trial || null,
});
let newFreeTrial: FreeTrial | null = null;
if (free_trial) {
newFreeTrial =
(await handleNewFreeTrial({
db,
newFreeTrial: free_trial,
curFreeTrial: null,
internalProductId: product.internal_id,
isCustom: false,
})) || null;
}
const newFullProduct: FullProduct = {
...product,
prices,
entitlements: getEntsWithFeature({ ents: entitlements, features }),
free_trial: newFreeTrial,
};
await initProductInStripe({
db,
product: newFullProduct,
org,
env,
logger,
});
await addTaskToQueue({
jobName: JobName.DetectBaseVariant,
payload: {
curProduct: newFullProduct,
},
});
const productResponse = await getProductResponse({
product: newFullProduct,
features,
});
return productResponse;
};

View File

@@ -0,0 +1,214 @@
import {
type FreeTrial,
mapToProductV2,
notNullish,
ProductNotFoundError,
type ProductV2,
productsAreSame,
RecaseError,
UpdateProductSchema,
type UpdateProductV2Params,
} from "@autumn/shared";
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
import { CusProductService } from "@/internal/customers/cusProducts/CusProductService.js";
import { RewardProgramService } from "@/internal/rewards/RewardProgramService.js";
import { JobName } from "@/queue/JobName.js";
import { addTaskToQueue } from "@/queue/queueUtils.js";
import {
handleNewFreeTrial,
validateOneOffTrial,
} from "../../free-trials/freeTrialUtils.js";
import { ProductService } from "../../ProductService.js";
import { handleNewProductItems } from "../../product-items/productItemUtils/handleNewProductItems.js";
import { getProductResponse } from "../../productUtils/productResponseUtils/getProductResponse.js";
import { initProductInStripe } from "../../productUtils.js";
import { disableCurrentDefault } from "../handleCreateProduct.js";
import { handleUpdateProductDetails } from "../handleUpdateProduct/updateProductDetails.js";
import { handleVersionProductV2 } from "../handleVersionProduct.js";
export interface UpdateProductParams {
ctx: AutumnContext;
productId: string;
query: {
upsert?: boolean;
version?: number;
disable_version?: boolean;
};
updates: UpdateProductV2Params;
}
export const updateProduct = async ({
ctx,
query,
productId,
updates,
}: UpdateProductParams) => {
const { db, org, env, features, logger } = ctx;
const { version, upsert, disable_version } = query;
const [fullProduct, rewardPrograms, _defaultProds] = await Promise.all([
ProductService.getFull({
db,
idOrInternalId: productId,
orgId: org.id,
env,
version: version,
allowNotFound: upsert === true,
}),
RewardProgramService.getByProductId({
db,
productIds: [productId],
orgId: org.id,
env,
}),
ProductService.listDefault({
db,
orgId: org.id,
env,
}),
]);
if (!fullProduct) throw new ProductNotFoundError({ productId: productId });
const cusProductsCurVersion = await CusProductService.getByInternalProductId({
db,
internalProductId: fullProduct.internal_id,
});
const curProductV2 = mapToProductV2({
product: fullProduct,
features,
});
const newFreeTrial = updates.free_trial as FreeTrial | undefined;
const newProductV2: ProductV2 = {
...curProductV2,
...updates,
group: updates.group || curProductV2.group || "",
items: updates.items || [],
free_trial: newFreeTrial || curProductV2.free_trial || undefined,
};
await disableCurrentDefault({
req: ctx,
newProduct: newProductV2,
});
await handleUpdateProductDetails({
db,
curProduct: fullProduct,
newProduct: UpdateProductSchema.parse(updates),
newFreeTrial: updates.free_trial || curProductV2.free_trial || undefined,
items: updates.items || curProductV2.items,
org,
rewardPrograms,
logger: ctx.logger,
});
const itemsExist = notNullish(updates.items);
const cusProductExists = cusProductsCurVersion.length > 0;
if (cusProductExists && itemsExist) {
if (disable_version) {
throw new RecaseError({
message: "Cannot auto save product as there are existing customers",
});
}
const { itemsSame, freeTrialsSame } = productsAreSame({
newProductV2: newProductV2,
curProductV1: fullProduct,
features,
});
const productSame = itemsSame && freeTrialsSame;
if (!productSame) {
const newProduct = await handleVersionProductV2({
ctx,
newProductV2: newProductV2,
latestProduct: fullProduct,
org,
env,
});
return newProduct;
}
return fullProduct;
}
const { free_trial } = updates;
if (updates.items) {
await handleNewProductItems({
db,
curPrices: fullProduct.prices,
curEnts: fullProduct.entitlements,
newItems: updates.items,
features,
product: fullProduct,
logger: ctx.logger,
isCustom: false,
});
}
// New full product
const newFullProduct = await ProductService.getFull({
db,
idOrInternalId: fullProduct.id,
orgId: org.id,
env,
});
if (free_trial !== undefined) {
await validateOneOffTrial({
prices: newFullProduct.prices,
freeTrial: free_trial,
});
await handleNewFreeTrial({
db,
curFreeTrial: fullProduct.free_trial,
newFreeTrial: free_trial,
internalProductId: fullProduct.internal_id,
isCustom: false,
product: fullProduct,
});
}
// New full product
await initProductInStripe({
db,
product: newFullProduct,
org,
env,
logger,
});
logger.info("Adding task to queue to detect base variant");
await addTaskToQueue({
jobName: JobName.DetectBaseVariant,
payload: {
curProduct: newFullProduct,
},
});
await addTaskToQueue({
jobName: JobName.RewardMigration,
payload: {
oldPrices: fullProduct.prices,
productId: fullProduct.id,
orgId: org.id,
env,
},
});
const productResponse = await getProductResponse({
product: newFullProduct,
features,
});
return productResponse;
};

View File

@@ -518,6 +518,7 @@ expressProductRouter.get(
import { Hono } from "hono";
import type { HonoEnv } from "@/honoUtils/HonoEnv.js";
import { handleCopyEnvironment } from "./handlers/handleCopyEnvironment.js";
import { handleGetProductCount } from "./internalHandlers/handleGetProductCount.js";
import { handleGetProductInternal } from "./internalHandlers/handleGetProductInternal.js";
@@ -526,3 +527,4 @@ export const internalProductRouter = new Hono<HonoEnv>();
internalProductRouter.get("/:productId/count", ...handleGetProductCount);
internalProductRouter.get("/:productId/data", ...handleGetProductInternal);
internalProductRouter.post("/copy_to_production", ...handleCopyEnvironment);

View File

@@ -218,8 +218,8 @@ export const handleNewProductItems = async ({
const { newPrice, newEnt, updatedPrice, updatedEnt, samePrice, sameEnt } =
itemToPriceAndEnt({
item,
orgId: product.org_id!,
internalProductId: product.internal_id!,
orgId: product.org_id,
internalProductId: product.internal_id,
feature: feature,
curPrice,
curEnt,

View File

@@ -118,7 +118,7 @@ export const toFeature = ({
newVersion?: boolean;
feature?: Feature;
}) => {
const isBoolean = feature?.type == FeatureType.Boolean;
const isBoolean = feature?.type === FeatureType.Boolean;
const resetUsage = getResetUsage({ item, feature });
@@ -132,10 +132,10 @@ export const toFeature = ({
internal_feature_id: internalFeatureId,
feature_id: item.feature_id!,
allowance: item.included_usage == Infinite ? null : item.included_usage!,
allowance: item.included_usage === Infinite ? null : item.included_usage!,
allowance_type: isBoolean
? null
: item.included_usage == Infinite
: item.included_usage === Infinite
? AllowanceType.Unlimited
: AllowanceType.Fixed,

View File

@@ -74,9 +74,6 @@ export const CustomerDataSchema = z.object({
});
export const CustomerResponseSchema = CustomerSchema.omit({
// created_at: true,
// env: true,
// processor: true,
org_id: true,
});

View File

@@ -1,9 +1,14 @@
import { FullCusProduct } from "../cusProductModels/cusProductModels.js";
import { Event } from "../eventModels/eventTable.js";
import { Subscription } from "../subModels/subModels.js";
import { Customer } from "./cusModels.js";
import { Entity } from "./entityModels/entityModels.js";
import { Invoice } from "./invoiceModels/invoiceModels.js";
import { ProductSchema } from "@models/productModels/productModels.js";
import { z } from "zod/v4";
import {
CusProductSchema,
type FullCusProduct,
} from "../cusProductModels/cusProductModels.js";
import type { Event } from "../eventModels/eventTable.js";
import type { Subscription } from "../subModels/subModels.js";
import { type Customer, CustomerSchema } from "./cusModels.js";
import type { Entity } from "./entityModels/entityModels.js";
import type { Invoice } from "./invoiceModels/invoiceModels.js";
export type FullCustomer = Customer & {
customer_products: FullCusProduct[];
@@ -18,3 +23,11 @@ export type FullCustomer = Customer & {
subscriptions?: Subscription[];
events?: Event[];
};
export const CustomerWithProductsSchema = CustomerSchema.extend({
customer_products: z.array(
CusProductSchema.extend({ product: ProductSchema }),
),
});
export type CustomerWithProducts = z.infer<typeof CustomerWithProductsSchema>;

View File

@@ -22,6 +22,7 @@ export const FrontendOrgSchema = z.object({
.nullable(),
through_master: z.boolean(),
onboarded: z.boolean(),
deployed: z.boolean(),
});
export type FrontendOrg = z.infer<typeof FrontendOrgSchema>;

View File

@@ -86,6 +86,7 @@ export const organizations = pgTable(
config: jsonb().default({}).notNull().$type<OrgConfig>(),
created_by: text("created_by"),
onboarded: boolean("onboarded").default(false),
deployed: boolean("deployed").default(false),
},
(table) => [
unique("organizations_test_pkey_key").on(table.test_pkey),

View File

@@ -18,6 +18,7 @@ import CustomerView from "./views/customers/customer/CustomerView";
import CustomerProductView from "./views/customers/customer/product/CustomerProductView";
import { DefaultView } from "./views/DefaultView";
import DevScreen from "./views/developer/DevView";
import { CloseScreen } from "./views/general/CloseScreen";
import OnboardingView3 from "./views/onboarding3/OnboardingView3";
import ProductsView from "./views/products/ProductsView";
import PlanEditorView from "./views/products/plan/PlanEditorView";
@@ -45,6 +46,7 @@ export default function App() {
<Route path="/sign-in" element={<SignIn />} />
<Route path="/pw-sign-in" element={<PasswordSignIn />} />
<Route path="/accept" element={<AcceptInvitation />} />
<Route path="/close" element={<CloseScreen />} />
{/* Onboarding routes without sidebar */}
<Route element={<OnboardingLayout />}>

View File

@@ -46,8 +46,16 @@ export function MainLayout() {
useEffect(() => {
// Only redirect if org is loaded and user is not onboarded
if (!orgLoading && org && !org.onboarded) {
if (!orgLoading && org) {
if (!org.onboarded) {
navigate("/sandbox/onboarding");
} else if (!org.deployed) {
const pathname = window.location.pathname;
if (!pathname.startsWith("/sandbox")) {
const search = window.location.search;
navigate(`/sandbox${pathname}${search}`);
}
}
}
}, [org, orgLoading, navigate]);

View File

@@ -1,30 +1,52 @@
import type { FrontendOrg } from "@autumn/shared";
import type { AppEnv, FrontendOrg } from "@autumn/shared";
import { useQuery } from "@tanstack/react-query";
import { useEffect } from "react";
import { authClient, useListOrganizations } from "@/lib/auth-client";
import { useAxiosInstance } from "@/services/useAxiosInstance";
export const useOrg = () => {
const axiosInstance = useAxiosInstance();
const ORG_STORAGE_KEY = "autumn_org";
export const useOrg = (params?: { env?: AppEnv }) => {
const axiosInstance = useAxiosInstance({ env: params?.env });
const { data: orgList } = useListOrganizations();
const fetcher = async () => {
try {
const { data } = await axiosInstance.get("/organization");
// Store in local storage
if (data) {
const storageKey = params?.env
? `${ORG_STORAGE_KEY}_${params.env}`
: ORG_STORAGE_KEY;
localStorage.setItem(storageKey, JSON.stringify(data));
}
return data;
} catch {
return null;
}
};
const getInitialData = () => {
try {
const storageKey = params?.env
? `${ORG_STORAGE_KEY}_${params.env}`
: ORG_STORAGE_KEY;
const stored = localStorage.getItem(storageKey);
return stored ? JSON.parse(stored) : undefined;
} catch {
return undefined;
}
};
const {
data: org,
isLoading,
error,
refetch,
} = useQuery({
queryKey: ["org"],
queryKey: params?.env ? ["org", params.env] : ["org"],
queryFn: fetcher,
initialData: getInitialData(),
});
useEffect(() => {

View File

@@ -0,0 +1,88 @@
import type { CustomerWithProducts } from "@autumn/shared";
import { useCallback, useEffect, useState } from "react";
import { useProductsQuery } from "@/hooks/queries/useProductsQuery";
import { useGeneralQuery } from "../queries/useGeneralQuery";
const DEPLOY_BUTTON_STORAGE_KEY = "autumn_show_deploy_button";
export const useShowDeployButton = () => {
const { products } = useProductsQuery();
const { data: customersData, isLoading: isLoadingCustomers } =
useGeneralQuery({
url: "/customers/all/search",
method: "POST",
queryKey: ["customers", "all", "search"],
});
const getStoredValue = useCallback((): boolean | null => {
try {
const stored = localStorage.getItem(DEPLOY_BUTTON_STORAGE_KEY);
return stored ? JSON.parse(stored) : null;
} catch {
return null;
}
}, []);
const [showDeployButton, setShowDeployButton] = useState<boolean>(() => {
const stored = getStoredValue();
return stored !== null ? stored : false;
});
// Only show checking state if we don't have a stored value AND data is still loading
const isChecking = getStoredValue() === null && isLoadingCustomers;
useEffect(() => {
// Wait for data to be available before checking
if (!customersData) return;
const checkConditions = () => {
try {
// Check if at least 1 product exists
const hasProduct = products.length > 0;
if (!hasProduct) {
const newValue = false;
// Only update if different
setShowDeployButton((prev) => {
if (prev !== newValue) {
localStorage.setItem(
DEPLOY_BUTTON_STORAGE_KEY,
JSON.stringify(newValue),
);
return newValue;
}
return prev;
});
return;
}
// Check if at least 1 non-demo customer exists
const hasNonDemoCustomer = customersData.customers.some(
(customer: CustomerWithProducts) =>
customer.id !== "onboarding_demo_user",
);
const shouldShow = hasProduct && hasNonDemoCustomer;
// Only update if the value changed
setShowDeployButton((prev) => {
if (prev !== shouldShow) {
localStorage.setItem(
DEPLOY_BUTTON_STORAGE_KEY,
JSON.stringify(shouldShow),
);
return shouldShow;
}
return prev;
});
} catch (error) {
console.error("Error checking deploy button conditions:", error);
setShowDeployButton(false);
}
};
checkConditions();
}, [products.length, customersData]);
return { showDeployButton, isChecking };
};

View File

@@ -1,5 +1,5 @@
import { useAxiosInstance } from "@/services/useAxiosInstance";
import { useQuery } from "@tanstack/react-query";
import { useAxiosInstance } from "@/services/useAxiosInstance";
export const useDevQuery = () => {
const axiosInstance = useAxiosInstance();

View File

@@ -1,19 +1,24 @@
import { useAxiosInstance } from "@/services/useAxiosInstance";
import { useQuery } from "@tanstack/react-query";
import { useAxiosInstance } from "@/services/useAxiosInstance";
export const useGeneralQuery = ({
url,
method,
queryKey,
enabled,
}: {
url: string;
method: "GET" | "POST" | "PUT" | "DELETE";
queryKey?: string[];
enabled?: boolean;
}) => {
const axiosInstance = useAxiosInstance();
const fetcher = async () => {
const { data } = await axiosInstance.get(url);
const { data } = await axiosInstance.request({
method,
url,
});
return data;
};

View File

@@ -9,12 +9,8 @@ const defaultParams = {
};
export function useAxiosInstance(params?: { env?: AppEnv; isAuth?: boolean }) {
const finalParams: any = {
...defaultParams,
...(params || {}),
};
const trueEnv = useEnv();
const currentEnv = useEnv();
const envToUse = params?.env ?? currentEnv;
const axiosInstance = axios.create({
baseURL: import.meta.env.VITE_BACKEND_URL,
@@ -23,7 +19,7 @@ export function useAxiosInstance(params?: { env?: AppEnv; isAuth?: boolean }) {
axiosInstance.interceptors.request.use(
async (config: any) => {
config.headers.app_env = trueEnv;
config.headers.app_env = envToUse;
config.headers["x-api-version"] = "1.2";
config.headers["x-client-type"] = "dashboard";
@@ -56,7 +52,7 @@ export function useAxiosInstance(params?: { env?: AppEnv; isAuth?: boolean }) {
organizationId: nextOrg.id,
});
// Redirect to products page of the new organization
window.location.href = `/${trueEnv === AppEnv.Sandbox ? "sandbox" : "production"}/products`;
window.location.href = `/${currentEnv === AppEnv.Sandbox ? "sandbox" : "production"}/products`;
return Promise.reject(
new Error("Redirecting to available organization"),
);

View File

@@ -18,18 +18,22 @@ export const SignIn = () => {
const [googleLoading, setGoogleLoading] = useState(false);
const [sendOtpLoading, setSendOtpLoading] = useState(false);
const [otpSent, setOtpSent] = useState(false);
// const { data: session } = useSession();
const { org, isLoading: orgLoading } = useOrg();
const { org } = useOrg();
const navigate = useNavigate();
// const [searchParams] = useSearchParams();
// const token = searchParams.get("token");
const newPath = "/sandbox/onboarding";
const callbackPath = "/customers";
const callbackPath = "/sandbox/products?tab=products";
useEffect(() => {
if (org?.onboarded) {
navigate(callbackPath);
if (org) {
if (org.deployed) {
navigate("/products?tab=products");
} else if (org.onboarded) {
navigate("/sandbox/products?tab=products");
} else {
navigate("/sandbox/onboarding");
}
}
}, [org, navigate]);

View File

@@ -20,6 +20,7 @@ import {
CommandList,
} from "@/components/ui/command";
import { Skeleton } from "@/components/ui/skeleton";
import { useOrg } from "@/hooks/common/useOrg";
import { useProductsQuery } from "@/hooks/queries/useProductsQuery";
import { useListOrganizations } from "@/lib/auth-client";
import { useAxiosInstance } from "@/services/useAxiosInstance";
@@ -69,6 +70,7 @@ const CommandBar = () => {
const { data: orgs, isPending: isLoadingOrgs } = useListOrganizations();
const axiosInstance = useAxiosInstance();
const { isAdmin } = useAdmin();
const { org } = useOrg();
// Improved close dialog function with proper timing
const closeDialog = useCallback(() => {
@@ -320,6 +322,8 @@ const CommandBar = () => {
closeDialog();
},
},
...(org?.deployed
? [
{
title: `Go to ${env === AppEnv.Sandbox ? "Production" : "Sandbox"}`,
icon: <ArrowsClockwiseIcon />,
@@ -332,6 +336,8 @@ const CommandBar = () => {
closeDialog();
},
},
]
: []),
...(!isLoadingOrgs && orgs && orgs.length > 1
? [
{

View File

@@ -1,19 +1,8 @@
import { useAxiosInstance } from "@/services/useAxiosInstance";
import { useQuery, keepPreviousData } from "@tanstack/react-query";
import { useCustomersQueryStates } from "./useCustomersQueryStates";
import {
CusProductSchema,
CustomerSchema,
ProductSchema,
} from "@autumn/shared";
import { z } from "zod";
import type { CustomerWithProducts } from "@autumn/shared";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
const CustomerWithProductsSchema = CustomerSchema.extend({
customer_products: z.array(
CusProductSchema.extend({ product: ProductSchema }),
),
});
type CustomerWithProducts = z.infer<typeof CustomerWithProductsSchema>;
import { useAxiosInstance } from "@/services/useAxiosInstance";
import { useCustomersQueryStates } from "./useCustomersQueryStates";
export const useCusSearchQuery = () => {
const { queryStates } = useCustomersQueryStates();
@@ -72,22 +61,3 @@ export const useCusSearchQuery = () => {
isFetchingUncached,
};
};
// const { data, isLoading, error, mutate } = useAxiosPostSWR({
// url: `/v1/customers/all/search`,
// env,
// data: {
// search: queryStates.q || "",
// filters: {
// status: queryStates.status,
// product_id: queryStates.product_id,
// version: queryStates.version,
// none: queryStates.none,
// },
// page: queryStates.page,
// page_size: pageSize,
// last_item: queryStates.lastItemId
// ? { internal_id: queryStates.lastItemId }
// : null,
// },
// });

View File

@@ -1,15 +1,12 @@
import { debounce } from "lodash";
import {
parseAsArrayOf,
parseAsBoolean,
parseAsInteger,
parseAsString,
useQueryStates,
} from "nuqs";
import { useQueryStates } from "nuqs";
import { useLocation } from "react-router";
import { useCallback, useEffect, useState } from "react";
import { debounce } from "lodash";
import { useEffect, useState } from "react";
export const useCustomersQueryStates = () => {
const [queryStates, setQueryStates] = useQueryStates(
{
@@ -25,6 +22,8 @@ export const useCustomersQueryStates = () => {
},
);
// return { queryStates, setQueryStates };
const [stableStates, setStableStates] = useState(queryStates);
useEffect(() => {

View File

@@ -0,0 +1,31 @@
import { useEffect } from "react";
import LoadingScreen from "./LoadingScreen";
export const CloseScreen = () => {
useEffect(() => {
// Attempt to close immediately
window.close();
// If still open after 1 second, it means close() was blocked
const timeout = setTimeout(() => {
// Show the fallback UI
const root = document.getElementById("root");
if (root) {
root.innerHTML = `
<div style="display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100vh; gap: 16px;">
<p style="font-size: 18px; color: #10b981;">✓ Connection successful!</p>
<p style="color: #6b7280;">You can close this window now.</p>
</div>
`;
}
}, 1000);
return () => clearTimeout(timeout);
}, []);
return (
<div className="w-screen h-screen flex items-center justify-center">
<LoadingScreen />
</div>
);
};

View File

@@ -9,8 +9,10 @@ import { useHotkeys } from "react-hotkeys-hook";
import { Button } from "@/components/ui/button";
import { useAutumnFlags } from "@/hooks/common/useAutumnFlags";
import { useLocalStorage } from "@/hooks/common/useLocalStorage";
import { useOrg } from "@/hooks/common/useOrg";
import { cn } from "@/lib/utils";
import { useEnv } from "@/utils/envUtils";
import { DeployToProdButton } from "./components/deploy-button/DeployToProdButton";
import { OrgDropdown } from "./components/OrgDropdown";
import { EnvDropdown } from "./EnvDropdown";
import { NavButton } from "./NavButton";
@@ -20,6 +22,7 @@ import { SidebarGroup } from "./SidebarGroup";
export const MainSidebar = () => {
const env = useEnv();
const { org } = useOrg();
const { webhooks } = useAutumnFlags();
@@ -74,7 +77,11 @@ export const MainSidebar = () => {
</Button>
<OrgDropdown />
{org?.deployed ? (
<EnvDropdown env={env} />
) : (
<DeployToProdButton expanded={expanded} />
)}
<div className="flex flex-col px-2 gap-1">
<div>
<NavButton
@@ -160,43 +167,3 @@ export const MainSidebar = () => {
</SidebarContext.Provider>
);
};
{
/* <div
className={cn(
"grid transition-[grid-template-rows] duration-150 ease-in-out",
showProductTab ? "grid-rows-[1fr]" : "grid-rows-[0fr]"
)}
>
{expanded && (
<div
className={cn(
"overflow-hidden flex flex-col my-0 gap-0.5 border-l border-zinc-300 ml-4 -translate-x-[1px] pl-0 transition-opacity duration-150",
showProductTab ? "opacity-100" : "opacity-0"
)}
>
<NavButton
value="products"
subValue="products"
title="Plans"
env={env}
isSubNav
/>
<NavButton
value="products"
subValue="features"
title="Features"
env={env}
isSubNav
/>
<NavButton
value="products"
subValue="rewards"
title="Rewards"
env={env}
isSubNav
/>
</div>
)}
</div> */
}

View File

@@ -36,7 +36,7 @@ export const OrgDropdown = () => {
const { org, isLoading, error } = useOrg();
const { expanded, setExpanded } = useSidebarContext();
let { data: orgs, isPending } = useListOrganizations();
let { data: orgs } = useListOrganizations();
const { data: activeOrganization } = authClient.useActiveOrganization();
// Exclude the active organization from the orgs list (this makes it easier for users to understand which org is active)
@@ -50,12 +50,6 @@ export const OrgDropdown = () => {
const { data: session } = useSession();
// //remove the currect active org from the orgs data
// const inactiveOrgs = useMemo(() => {
// if (!orgs || !org) return [];
// return orgs.filter((orgItem: any) => orgItem.id !== org.id);
// }, [org, orgs]);
// To pre-fetch data
useMemberships();
const [dropdownOpen, setDropdownOpen] = useState(false);

View File

@@ -0,0 +1,37 @@
import { ArrowRightIcon } from "@phosphor-icons/react";
import { useState } from "react";
import { IconButton } from "@/components/v2/buttons/IconButton";
import { cn } from "@/lib/utils";
import { DeployToProdDialog } from "./DeployToProdDialog";
interface DeployToProdButtonProps {
expanded: boolean;
}
export const DeployToProdButton = ({ expanded }: DeployToProdButtonProps) => {
const [showDeployDialog, setShowDeployDialog] = useState(false);
return (
<>
<div className={cn("flex text-t2 text-xs gap-1 pl-3")}>
{expanded && (
<IconButton
variant="primary"
size="sm"
icon={<ArrowRightIcon />}
iconOrientation="right"
onClick={() => setShowDeployDialog(true)}
className="w-fit"
>
Go to Production
</IconButton>
)}
</div>
<DeployToProdDialog
open={showDeployDialog}
onOpenChange={setShowDeployDialog}
/>
</>
);
};

View File

@@ -0,0 +1,94 @@
import { ArrowRightIcon } from "@phosphor-icons/react";
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { IconButton } from "@/components/v2/buttons/IconButton";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/v2/dialogs/Dialog";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/v2/tooltips/Tooltip";
import { useOrg } from "@/hooks/common/useOrg";
import { useAxiosInstance } from "@/services/useAxiosInstance";
import { Step1ConnectStripe } from "../../deploy-dialog/Step1ConnectStripe";
import { Step2CopyProducts } from "../../deploy-dialog/Step2CopyProducts";
import { Step3CreateApiKey } from "../../deploy-dialog/Step3CreateApiKey";
interface DeployToProdDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export const DeployToProdDialog = ({
open,
onOpenChange,
}: DeployToProdDialogProps) => {
const [loading, setLoading] = useState(false);
const axiosInstance = useAxiosInstance();
const { mutate: mutateOrg } = useOrg();
const navigate = useNavigate();
const handleGoToProduction = async () => {
setLoading(true);
try {
await axiosInstance.patch("/v1/organization", {
deployed: true,
});
await mutateOrg();
window.location.href = "/products?tab=products";
} catch (error) {
console.error("Failed to deploy to production:", error);
} finally {
setLoading(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Deploy to Production</DialogTitle>
<DialogDescription>
If you've set up your products and integrated Autumn into your
codebase, you can follow the steps below to deploy to Production
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-10 mt-4 [>&_.atmn-sep]:ml-[32px]">
<Step1ConnectStripe isDialogOpen={open} />
<Step2CopyProducts />
<Step3CreateApiKey />
</div>
<DialogFooter>
<Tooltip>
<TooltipTrigger>
<IconButton
variant="primary"
icon={<ArrowRightIcon />}
iconOrientation="right"
onClick={handleGoToProduction}
isLoading={loading}
>
Go to Production
</IconButton>
</TooltipTrigger>
<TooltipContent side="bottom" className="max-w-xs">
Make sure you've completed all the steps above before clicking
this button. You won't be able to see this dialog again after
doing so.
</TooltipContent>
</Tooltip>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

@@ -0,0 +1,120 @@
import { AppEnv } from "@autumn/shared";
import { Check } from "lucide-react";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/v2/buttons/Button";
import { IconButton } from "@/components/v2/buttons/IconButton";
import { useOrg } from "@/hooks/common/useOrg";
import { useAxiosInstance } from "@/services/useAxiosInstance";
import { getBackendErr } from "@/utils/genUtils";
import { SectionHeader } from "@/views/onboarding3/components/integration-step/SectionHeader";
interface Step1ConnectStripeProps {
isDialogOpen: boolean;
}
export const Step1ConnectStripe = ({
isDialogOpen,
}: Step1ConnectStripeProps) => {
const { org, mutate } = useOrg({ env: AppEnv.Live });
const axiosInstance = useAxiosInstance({ env: AppEnv.Live });
const [isPolling, setIsPolling] = useState(false);
const [isConnectingStripe, setIsConnectingStripe] = useState(false);
// Check if Stripe is connected for production
const isStripeConnected =
org?.stripe_connection === "oauth" ||
org?.stripe_connection === "secret_key";
useEffect(() => {
if (!isPolling || !isDialogOpen) return;
const pollInterval = setInterval(async () => {
await mutate();
// Check if stripe is now connected
if (
org?.stripe_connection === "oauth" ||
org?.stripe_connection === "secret_key"
) {
setIsPolling(false);
setIsConnectingStripe(false);
}
}, 2000);
return () => clearInterval(pollInterval);
}, [isPolling, isDialogOpen, mutate, org?.stripe_connection]);
// Reset polling state when dialog opens
useEffect(() => {
if (isDialogOpen) {
setIsPolling(false);
setIsConnectingStripe(false);
}
}, [isDialogOpen]);
const handleConnectStripe = async () => {
setIsConnectingStripe(true);
setIsPolling(true);
try {
const { data } = await axiosInstance.get(
`/v1/organization/stripe/oauth_url`,
{
params: {
redirect_url: `${import.meta.env.VITE_FRONTEND_URL}/close`,
},
},
);
// Open in a popup window (not "_blank") so window.close() will work
window.open(
data.oauth_url,
"stripe_oauth",
"width=600,height=800,popup=yes",
);
} catch (error) {
console.error(error);
toast.error(getBackendErr(error, "Failed to get OAuth URL"));
setIsPolling(false);
setIsConnectingStripe(false);
}
};
return (
<div className="flex flex-col gap-3">
<div className="flex items-center gap-2">
<SectionHeader
stepNumber={1}
title="Connect your Stripe account"
description="Connect your Stripe production account via OAuth to accept live payments"
className="gap-0 flex-1"
/>
</div>
<div className="pl-[32px] flex flex-col gap-2">
{isStripeConnected ? (
<div className="flex items-center gap-2">
<IconButton
variant="secondary"
disabled
icon={<Check size={16} className="text-green-600" />}
className="!opacity-100"
>
Stripe Connected
</IconButton>
</div>
) : (
<div>
<Button
variant="secondary"
onClick={handleConnectStripe}
isLoading={isConnectingStripe}
>
Connect Stripe
</Button>
</div>
)}
</div>
</div>
);
};

View File

@@ -0,0 +1,65 @@
import { AppEnv } from "@autumn/shared";
import { Check } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/v2/buttons/Button";
import { IconButton } from "@/components/v2/buttons/IconButton";
import { useAxiosInstance } from "@/services/useAxiosInstance";
import { getBackendErr } from "@/utils/genUtils";
import { SectionHeader } from "@/views/onboarding3/components/integration-step/SectionHeader";
export const Step2CopyProducts = () => {
const sandboxAxios = useAxiosInstance({ env: AppEnv.Sandbox });
const [isCopying, setIsCopying] = useState(false);
const [isCopied, setIsCopied] = useState(false);
const handleCopyProducts = async () => {
setIsCopying(true);
try {
const { data } = await sandboxAxios.post("/products/copy_to_production");
console.log("Data:", data);
setIsCopied(true);
toast.success(`Successfully copied products to production`);
} catch (error) {
toast.error(getBackendErr(error, "Failed to copy products"));
} finally {
setIsCopying(false);
}
};
return (
<div className="flex flex-col gap-3">
<div className="flex items-center gap-2">
<SectionHeader
stepNumber={2}
title="Copy your products to production"
description="Sync all your configured products and features from sandbox to production"
className="gap-0 flex-1"
/>
</div>
<div className="pl-[32px] flex flex-col gap-2">
{isCopied ? (
<IconButton
variant="secondary"
disabled
icon={<Check size={16} className="text-green-600" />}
className="!opacity-100"
>
Copied Products
</IconButton>
) : (
<div>
<Button
variant="secondary"
onClick={handleCopyProducts}
isLoading={isCopying}
>
Copy Products
</Button>
</div>
)}
</div>
</div>
);
};

View File

@@ -0,0 +1,112 @@
import { AppEnv } from "@autumn/shared";
import { Check, Copy } from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/v2/buttons/Button";
import { useDevQuery } from "@/hooks/queries/useDevQuery";
import { DevService } from "@/services/DevService";
import { useAxiosInstance } from "@/services/useAxiosInstance";
import { SectionHeader } from "@/views/onboarding3/components/integration-step/SectionHeader";
export const Step3CreateApiKey = () => {
const { refetch } = useDevQuery();
const axiosInstance = useAxiosInstance({ env: AppEnv.Live });
const [loading, setLoading] = useState(false);
const [apiKey, setApiKey] = useState("");
const [copied, setCopied] = useState(false);
useEffect(() => {
if (copied) {
setTimeout(() => setCopied(false), 1000);
}
}, [copied]);
const handleCreate = async () => {
setLoading(true);
try {
const { api_key } = await DevService.createAPIKey(axiosInstance, {
name: "Production Secret Key",
});
setApiKey(api_key);
refetch();
} catch (error) {
console.log("Error:", error);
toast.error("Failed to create API key");
}
setLoading(false);
};
return (
<div className="flex flex-col gap-3">
<SectionHeader
stepNumber={3}
title="Create Production Secret Key"
description="Generate a live secret key for use in your production environment"
className="gap-0"
/>
<div className="pl-[32px] flex flex-col gap-3 min-h-[70px]">
<AnimatePresence mode="wait" initial={false}>
{apiKey ? (
<motion.div
key="api-key-display"
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 10 }}
transition={{
type: "spring",
bounce: 0.15,
duration: 0.3,
}}
className="flex flex-col gap-2"
>
<div className="flex justify-between bg-zinc-100 p-2 px-3 text-t2 rounded-md items-center w-fit">
<p className="text-sm font-mono">{apiKey}</p>
<button
type="button"
className="text-t2 hover:text-t2/80 ml-4"
onClick={() => {
setCopied(true);
navigator.clipboard.writeText(apiKey);
}}
>
{copied ? <Check size={15} /> : <Copy size={15} />}
</button>
</div>
<p className="text-xs text-t3">
You won't be able to view this key anymore after closing the
dialog.
</p>
</motion.div>
) : (
<motion.div
key="name-input"
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 10 }}
transition={{
type: "spring",
bounce: 0.15,
duration: 0.3,
}}
className="flex flex-col gap-2"
>
<Button
isLoading={loading}
onClick={handleCreate}
variant="secondary"
className="w-fit"
>
Generate Key
</Button>
</motion.div>
)}
</AnimatePresence>
</div>
</div>
);
};

View File

@@ -30,8 +30,6 @@ export const PlanCardToolbar = ({
const isEditingPlan = useIsEditingPlan();
const navigate = useNavigate();
console.log("deleteOpen", deleteOpen);
return (
<>
<DeletePlanDialog