Skip to content

skip stripe checkout for trial + fix indexing in progress UI + additional schema validation #214

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 8 commits into from
Feb 27, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 46 additions & 46 deletions packages/web/src/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,11 @@ import { getUser } from "@/data/user";
import { Session } from "next-auth";
import { STRIPE_PRODUCT_ID, CONFIG_MAX_REPOS_NO_TOKEN, EMAIL_FROM, SMTP_CONNECTION_URL, AUTH_URL } from "@/lib/environment";
import Stripe from "stripe";
import { OnboardingSteps } from "./lib/constants";
import { render } from "@react-email/components";
import InviteUserEmail from "./emails/inviteUserEmail";
import { createTransport } from "nodemailer";
import { repositoryQuerySchema } from "./lib/schemas";
import { RepositoryQuery } from "./lib/types";

const ajv = new Ajv({
validateFormats: false,
Expand Down Expand Up @@ -115,7 +116,7 @@ export const createOrg = (name: string, domain: string): Promise<{ id: number }
}
});

export const completeOnboarding = async (stripeCheckoutSessionId: string, domain: string): Promise<{ success: boolean } | ServiceError> =>
export const completeOnboarding = async (domain: string): Promise<{ success: boolean } | ServiceError> =>
withAuth((session) =>
withOrgMembership(session, domain, async ({ orgId }) => {
const org = await prisma.org.findUnique({
Expand All @@ -126,25 +127,9 @@ export const completeOnboarding = async (stripeCheckoutSessionId: string, domain
return notFound();
}

const stripe = getStripe();
const stripeSession = await stripe.checkout.sessions.retrieve(stripeCheckoutSessionId);
const stripeCustomerId = stripeSession.customer as string;

// Catch the case where the customer ID doesn't match the org's customer ID
if (org.stripeCustomerId !== stripeCustomerId) {
return {
statusCode: StatusCodes.BAD_REQUEST,
errorCode: ErrorCode.STRIPE_CHECKOUT_ERROR,
message: "Invalid Stripe customer ID",
} satisfies ServiceError;
}

if (stripeSession.payment_status !== 'paid') {
return {
statusCode: StatusCodes.BAD_REQUEST,
errorCode: ErrorCode.STRIPE_CHECKOUT_ERROR,
message: "Payment failed",
} satisfies ServiceError;
const subscription = await fetchSubscription(domain);
if (isServiceError(subscription)) {
return subscription;
}

await prisma.org.update({
Expand All @@ -161,7 +146,7 @@ export const completeOnboarding = async (stripeCheckoutSessionId: string, domain
}
})
);

export const getSecrets = (domain: string): Promise<{ createdAt: Date; key: string; }[] | ServiceError> =>
withAuth((session) =>
withOrgMembership(session, domain, async ({ orgId }) => {
Expand Down Expand Up @@ -317,7 +302,7 @@ export const getConnectionInfo = async (connectionId: number, domain: string) =>
})
)

export const getRepos = async (domain: string, filter: { status?: RepoIndexingStatus[], connectionId?: number } = {}) =>
export const getRepos = async (domain: string, filter: { status?: RepoIndexingStatus[], connectionId?: number } = {}): Promise<RepositoryQuery[] | ServiceError> =>
withAuth((session) =>
withOrgMembership(session, domain, async ({ orgId }) => {
const repos = await prisma.repo.findMany({
Expand All @@ -339,9 +324,11 @@ export const getRepos = async (domain: string, filter: { status?: RepoIndexingSt
}
});

return repos.map((repo) => ({
return repos.map((repo) => repositoryQuerySchema.parse({
codeHostType: repo.external_codeHostType,
repoId: repo.id,
repoName: repo.name,
repoCloneUrl: repo.cloneUrl,
linkedConnections: repo.connections.map((connection) => connection.connectionId),
imageUrl: repo.imageUrl ?? undefined,
indexedAt: repo.indexedAt ?? undefined,
Expand Down Expand Up @@ -814,7 +801,7 @@ export const transferOwnership = async (newOwnerId: string, domain: string): Pro
}, /* minRequiredRole = */ OrgRole.OWNER)
);

export const createOnboardingStripeCheckoutSession = async (domain: string) =>
export const createOnboardingSubscription = async (domain: string) =>
withAuth(async (session) =>
withOrgMembership(session, domain, async ({ orgId }) => {
const org = await prisma.org.findUnique({
Expand All @@ -833,7 +820,6 @@ export const createOnboardingStripeCheckoutSession = async (domain: string) =>
}

const stripe = getStripe();
const origin = (await headers()).get('origin');

// @nocheckin
const test_clock = await stripe.testHelpers.testClocks.create({
Expand Down Expand Up @@ -865,45 +851,59 @@ export const createOnboardingStripeCheckoutSession = async (domain: string) =>
return customer.id;
})();

const existingSubscription = await fetchSubscription(domain);
if (existingSubscription && !isServiceError(existingSubscription)) {
return {
statusCode: StatusCodes.BAD_REQUEST,
errorCode: ErrorCode.SUBSCRIPTION_ALREADY_EXISTS,
message: "Attemped to create a trial subscription for an organization that already has an active subscription",
} satisfies ServiceError;
}


const prices = await stripe.prices.list({
product: STRIPE_PRODUCT_ID,
expand: ['data.product'],
});

const stripeSession = await stripe.checkout.sessions.create({
customer: customerId,
line_items: [
{
try {
const subscription = await stripe.subscriptions.create({
customer: customerId,
items: [{
price: prices.data[0].id,
quantity: 1
}
],
mode: 'subscription',
subscription_data: {
trial_period_days: 7,
}],
trial_period_days: 14,
trial_settings: {
end_behavior: {
missing_payment_method: 'cancel',
},
},
},
payment_method_collection: 'if_required',
success_url: `${origin}/${domain}/onboard?step=${OnboardingSteps.Complete}&stripe_session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${origin}/${domain}/onboard?step=${OnboardingSteps.Checkout}`,
});
payment_settings: {
save_default_payment_method: 'on_subscription',
},
});

if (!subscription) {
return {
statusCode: StatusCodes.INTERNAL_SERVER_ERROR,
errorCode: ErrorCode.STRIPE_CHECKOUT_ERROR,
message: "Failed to create subscription",
} satisfies ServiceError;
}

if (!stripeSession.url) {
return {
subscriptionId: subscription.id,
}
} catch (e) {
console.error(e);
return {
statusCode: StatusCodes.INTERNAL_SERVER_ERROR,
errorCode: ErrorCode.STRIPE_CHECKOUT_ERROR,
message: "Failed to create checkout session",
message: "Failed to create subscription",
} satisfies ServiceError;
}

return {
url: stripeSession.url,
}

}, /* minRequiredRole = */ OrgRole.OWNER)
);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,22 @@ interface GerritConnectionCreationFormProps {
onCreated?: (id: number) => void;
}

const additionalConfigValidation = (config: GerritConnectionConfig): { message: string, isValid: boolean } => {
const hasProjects = config.projects && config.projects.length > 0;

if (!hasProjects) {
return {
message: "At least one project must be specified",
isValid: false,
}
}

return {
message: "Valid",
isValid: true,
}
}

export const GerritConnectionCreationForm = ({ onCreated }: GerritConnectionCreationFormProps) => {
const defaultConfig: GerritConnectionConfig = {
type: 'gerrit',
Expand All @@ -24,6 +40,7 @@ export const GerritConnectionCreationForm = ({ onCreated }: GerritConnectionCrea
}}
schema={gerritSchema}
quickActions={gerritQuickActions}
additionalConfigValidation={additionalConfigValidation}
onCreated={onCreated}
/>
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,24 @@ interface GiteaConnectionCreationFormProps {
onCreated?: (id: number) => void;
}

const additionalConfigValidation = (config: GiteaConnectionConfig): { message: string, isValid: boolean } => {
const hasOrgs = config.orgs && config.orgs.length > 0 && config.orgs.some(o => o.trim().length > 0);
const hasUsers = config.users && config.users.length > 0 && config.users.some(u => u.trim().length > 0);
const hasRepos = config.repos && config.repos.length > 0 && config.repos.some(r => r.trim().length > 0);

if (!hasOrgs && !hasUsers && !hasRepos) {
return {
message: "At least one organization, user, or repository must be specified",
isValid: false,
}
}

return {
message: "Valid",
isValid: true,
}
}

export const GiteaConnectionCreationForm = ({ onCreated }: GiteaConnectionCreationFormProps) => {
const defaultConfig: GiteaConnectionConfig = {
type: 'gitea',
Expand All @@ -23,6 +41,7 @@ export const GiteaConnectionCreationForm = ({ onCreated }: GiteaConnectionCreati
}}
schema={giteaSchema}
quickActions={giteaQuickActions}
additionalConfigValidation={additionalConfigValidation}
onCreated={onCreated}
/>
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,24 @@ interface GitHubConnectionCreationFormProps {
onCreated?: (id: number) => void;
}

const additionalConfigValidation = (config: GithubConnectionConfig): { message: string, isValid: boolean } => {
const hasRepos = config.repos && config.repos.length > 0 && config.repos.some(r => r.trim().length > 0);
const hasOrgs = config.orgs && config.orgs.length > 0 && config.orgs.some(o => o.trim().length > 0);
const hasUsers = config.users && config.users.length > 0 && config.users.some(u => u.trim().length > 0);

if (!hasRepos && !hasOrgs && !hasUsers) {
return {
message: "At least one repository, organization, or user must be specified",
isValid: false,
}
}

return {
message: "Valid",
isValid: true,
}
};

export const GitHubConnectionCreationForm = ({ onCreated }: GitHubConnectionCreationFormProps) => {
const defaultConfig: GithubConnectionConfig = {
type: 'github',
Expand All @@ -22,6 +40,7 @@ export const GitHubConnectionCreationForm = ({ onCreated }: GitHubConnectionCrea
config: JSON.stringify(defaultConfig, null, 2),
}}
schema={githubSchema}
additionalConfigValidation={additionalConfigValidation}
quickActions={githubQuickActions}
onCreated={onCreated}
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,24 @@ interface GitLabConnectionCreationFormProps {
onCreated?: (id: number) => void;
}

const additionalConfigValidation = (config: GitlabConnectionConfig): { message: string, isValid: boolean } => {
const hasProjects = config.projects && config.projects.length > 0 && config.projects.some(p => p.trim().length > 0);
const hasUsers = config.users && config.users.length > 0 && config.users.some(u => u.trim().length > 0);
const hasGroups = config.groups && config.groups.length > 0 && config.groups.some(g => g.trim().length > 0);

if (!hasProjects && !hasUsers && !hasGroups) {
return {
message: "At least one project, user, or group must be specified",
isValid: false,
}
}

return {
message: "Valid",
isValid: true,
}
}

export const GitLabConnectionCreationForm = ({ onCreated }: GitLabConnectionCreationFormProps) => {
const defaultConfig: GitlabConnectionConfig = {
type: 'gitlab',
Expand All @@ -23,6 +41,7 @@ export const GitLabConnectionCreationForm = ({ onCreated }: GitLabConnectionCrea
}}
schema={gitlabSchema}
quickActions={gitlabQuickActions}
additionalConfigValidation={additionalConfigValidation}
onCreated={onCreated}
/>
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ interface SharedConnectionCreationFormProps<T> {
}[],
className?: string;
onCreated?: (id: number) => void;
additionalConfigValidation?: (config: T) => { message: string, isValid: boolean };
}


Expand All @@ -48,6 +49,7 @@ export default function SharedConnectionCreationForm<T>({
quickActions,
className,
onCreated,
additionalConfigValidation
}: SharedConnectionCreationFormProps<T>) {
const { toast } = useToast();
const domain = useDomain();
Expand All @@ -56,7 +58,7 @@ export default function SharedConnectionCreationForm<T>({
const formSchema = useMemo(() => {
return z.object({
name: z.string().min(1),
config: createZodConnectionConfigValidator(schema),
config: createZodConnectionConfigValidator(schema, additionalConfigValidation),
secretKey: z.string().optional().refine(async (secretKey) => {
if (!secretKey) {
return true;
Expand Down
12 changes: 6 additions & 6 deletions packages/web/src/app/[domain]/components/repositoryCarousel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,14 @@ import {
CarouselItem,
} from "@/components/ui/carousel";
import Autoscroll from "embla-carousel-auto-scroll";
import { getRepoCodeHostInfo } from "@/lib/utils";
import { getRepoQueryCodeHostInfo } from "@/lib/utils";
import Image from "next/image";
import { FileIcon } from "@radix-ui/react-icons";
import clsx from "clsx";
import { Repository } from "@/lib/types";
import { RepositoryQuery } from "@/lib/types";

interface RepositoryCarouselProps {
repos: Repository[];
repos: RepositoryQuery[];
}

export const RepositoryCarousel = ({
Expand Down Expand Up @@ -50,14 +50,14 @@ export const RepositoryCarousel = ({
};

interface RepositoryBadgeProps {
repo: Repository;
repo: RepositoryQuery;
}

const RepositoryBadge = ({
repo
}: RepositoryBadgeProps) => {
const { repoIcon, displayName, repoLink } = (() => {
const info = getRepoCodeHostInfo(repo);
const info = getRepoQueryCodeHostInfo(repo);

if (info) {
return {
Expand All @@ -73,7 +73,7 @@ const RepositoryBadge = ({

return {
repoIcon: <FileIcon className="w-4 h-4" />,
displayName: repo.Name,
displayName: repo.repoName.split('/').slice(-2).join('/'),
repoLink: undefined,
}
})();
Expand Down
Loading