Pass Billing Checkout var in url to bypass credit card (#9283)

This commit is contained in:
Félix Malfait
2024-12-31 14:48:00 +01:00
committed by GitHub
parent 45f14c8020
commit 97f5a5b8a5
123 changed files with 524 additions and 173 deletions

View File

@ -8,9 +8,10 @@ import {
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Response } from 'express';
import { Repository } from 'typeorm';
import { AuthException } from 'src/engine/core-modules/auth/auth.exception';
import { AuthOAuthExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-oauth-exception.filter';
import { AuthRestApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-rest-api-exception.filter';
import { GoogleOauthGuard } from 'src/engine/core-modules/auth/guards/google-oauth.guard';
@ -18,9 +19,8 @@ import { GoogleProviderEnabledGuard } from 'src/engine/core-modules/auth/guards/
import { AuthService } from 'src/engine/core-modules/auth/services/auth.service';
import { GoogleRequest } from 'src/engine/core-modules/auth/strategies/google.auth.strategy';
import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service';
import { AuthException } from 'src/engine/core-modules/auth/auth.exception';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/service/domain-manager.service';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
@Controller('auth/google')
@ -55,6 +55,7 @@ export class GoogleAuthController {
workspaceInviteHash,
workspacePersonalInviteToken,
targetWorkspaceSubdomain,
billingCheckoutSessionState,
} = req.user;
const signInUpParams = {
@ -106,6 +107,7 @@ export class GoogleAuthController {
this.authService.computeRedirectURI(
loginToken.token,
workspace.subdomain,
billingCheckoutSessionState,
),
);
} catch (err) {

View File

@ -11,15 +11,15 @@ import { InjectRepository } from '@nestjs/typeorm';
import { Response } from 'express';
import { Repository } from 'typeorm';
import { AuthException } from 'src/engine/core-modules/auth/auth.exception';
import { AuthRestApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-rest-api-exception.filter';
import { MicrosoftOAuthGuard } from 'src/engine/core-modules/auth/guards/microsoft-oauth.guard';
import { MicrosoftProviderEnabledGuard } from 'src/engine/core-modules/auth/guards/microsoft-provider-enabled.guard';
import { AuthService } from 'src/engine/core-modules/auth/services/auth.service';
import { MicrosoftRequest } from 'src/engine/core-modules/auth/strategies/microsoft.auth.strategy';
import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service';
import { AuthException } from 'src/engine/core-modules/auth/auth.exception';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/service/domain-manager.service';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
@Controller('auth/microsoft')
@ -90,6 +90,7 @@ export class MicrosoftAuthController {
this.authService.computeRedirectURI(
loginToken.token,
workspace.subdomain,
signInUpParams.billingCheckoutSessionState,
),
);
} catch (err) {

View File

@ -45,6 +45,14 @@ export class GoogleOauthGuard extends AuthGuard('google') {
request.params.workspaceSubdomain = request.query.workspaceSubdomain;
}
if (
request.query.billingCheckoutSessionState &&
typeof request.query.billingCheckoutSessionState === 'string'
) {
request.params.billingCheckoutSessionState =
request.query.billingCheckoutSessionState;
}
return (await super.canActivate(context)) as boolean;
}
}

View File

@ -33,6 +33,14 @@ export class MicrosoftOAuthGuard extends AuthGuard('microsoft') {
request.params.workspaceSubdomain = request.query.workspaceSubdomain;
}
if (
request.query.billingCheckoutSessionState &&
typeof request.query.billingCheckoutSessionState === 'string'
) {
request.params.billingCheckoutSessionState =
request.query.billingCheckoutSessionState;
}
return (await super.canActivate(context)) as boolean;
}
}

View File

@ -28,6 +28,7 @@ import { AuthorizeApp } from 'src/engine/core-modules/auth/dto/authorize-app.ent
import { AuthorizeAppInput } from 'src/engine/core-modules/auth/dto/authorize-app.input';
import { AvailableWorkspaceOutput } from 'src/engine/core-modules/auth/dto/available-workspaces.output';
import { ChallengeInput } from 'src/engine/core-modules/auth/dto/challenge.input';
import { AuthTokens } from 'src/engine/core-modules/auth/dto/token.entity';
import { UpdatePassword } from 'src/engine/core-modules/auth/dto/update-password.entity';
import {
UserExists,
@ -46,7 +47,6 @@ import { userValidator } from 'src/engine/core-modules/user/user.validate';
import { WorkspaceInvitationService } from 'src/engine/core-modules/workspace-invitation/services/workspace-invitation.service';
import { WorkspaceAuthProvider } from 'src/engine/core-modules/workspace/types/workspace.type';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthTokens } from 'src/engine/core-modules/auth/dto/token.entity';
@Injectable()
// eslint-disable-next-line @nx/workspace-inject-workspace-repository
@ -171,6 +171,7 @@ export class AuthService {
fromSSO: boolean;
targetWorkspaceSubdomain?: string;
authProvider?: WorkspaceAuthProvider;
billingCheckoutSessionState?: string;
}) {
return await this.signInUpService.signInUp({
email,
@ -413,11 +414,18 @@ export class AuthService {
return workspace;
}
computeRedirectURI(loginToken: string, subdomain?: string) {
computeRedirectURI(
loginToken: string,
subdomain?: string,
billingCheckoutSessionState?: string,
) {
const url = this.domainManagerService.buildWorkspaceURL({
subdomain,
pathname: '/verify',
searchParams: { loginToken },
searchParams: {
loginToken,
...(billingCheckoutSessionState ? { billingCheckoutSessionState } : {}),
},
});
return url.toString();

View File

@ -35,8 +35,8 @@ import {
import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.validate';
import { getDomainNameByEmail } from 'src/utils/get-domain-name-by-email';
import { getImageBufferFromUrl } from 'src/utils/image';
import { isWorkEmail } from 'src/utils/is-work-email';
import { isDefined } from 'src/utils/is-defined';
import { isWorkEmail } from 'src/utils/is-work-email';
export type SignInUpServiceInput = {
email: string;

View File

@ -18,6 +18,7 @@ export type GoogleRequest = Omit<
workspaceInviteHash?: string;
workspacePersonalInviteToken?: string;
targetWorkspaceSubdomain?: string;
billingCheckoutSessionState?: string;
};
};
@ -39,6 +40,12 @@ export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
state: JSON.stringify({
workspaceInviteHash: req.params.workspaceInviteHash,
workspaceSubdomain: req.params.workspaceSubdomain,
...(req.params.billingCheckoutSessionState
? {
billingCheckoutSessionState:
req.params.billingCheckoutSessionState,
}
: {}),
...(req.params.workspacePersonalInviteToken
? {
workspacePersonalInviteToken:
@ -72,6 +79,7 @@ export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
workspaceInviteHash: state.workspaceInviteHash,
workspacePersonalInviteToken: state.workspacePersonalInviteToken,
targetWorkspaceSubdomain: state.workspaceSubdomain,
billingCheckoutSessionState: state.billingCheckoutSessionState,
};
done(null, user);

View File

@ -22,6 +22,7 @@ export type MicrosoftRequest = Omit<
workspaceInviteHash?: string;
workspacePersonalInviteToken?: string;
targetWorkspaceSubdomain?: string;
billingCheckoutSessionState?: string;
};
};
@ -43,6 +44,12 @@ export class MicrosoftStrategy extends PassportStrategy(Strategy, 'microsoft') {
state: JSON.stringify({
workspaceInviteHash: req.params.workspaceInviteHash,
workspaceSubdomain: req.params.workspaceSubdomain,
...(req.params.billingCheckoutSessionState
? {
billingCheckoutSessionState:
req.params.billingCheckoutSessionState,
}
: {}),
...(req.params.workspacePersonalInviteToken
? {
workspacePersonalInviteToken:
@ -86,6 +93,7 @@ export class MicrosoftStrategy extends PassportStrategy(Strategy, 'microsoft') {
workspaceInviteHash: state.workspaceInviteHash,
workspacePersonalInviteToken: state.workspacePersonalInviteToken,
targetWorkspaceSubdomain: state.workspaceSubdomain,
billingCheckoutSessionState: state.billingCheckoutSessionState,
};
done(null, user);

View File

@ -56,7 +56,13 @@ export class BillingResolver {
async checkoutSession(
@AuthWorkspace() workspace: Workspace,
@AuthUser() user: User,
@Args() { recurringInterval, successUrlPath }: CheckoutSessionInput,
@Args()
{
recurringInterval,
successUrlPath,
plan,
requirePaymentMethod,
}: CheckoutSessionInput,
) {
const productPrice = await this.stripeService.getStripePrice(
AvailableProduct.BasePlan,
@ -75,6 +81,8 @@ export class BillingResolver {
workspace,
productPrice.stripePriceId,
successUrlPath,
plan,
requirePaymentMethod,
),
};
}

View File

@ -1,16 +1,32 @@
import { ArgsType, Field } from '@nestjs/graphql';
import { IsNotEmpty, IsOptional, IsString } from 'class-validator';
import Stripe from 'stripe';
import {
IsBoolean,
IsEnum,
IsNotEmpty,
IsOptional,
IsString,
} from 'class-validator';
import { BillingPlanKey } from 'src/engine/core-modules/billing/enums/billing-plan-key.enum';
import { SubscriptionInterval } from 'src/engine/core-modules/billing/enums/billing-subscription-interval.enum';
@ArgsType()
export class CheckoutSessionInput {
@Field(() => SubscriptionInterval)
@IsString()
@IsEnum(SubscriptionInterval)
@IsNotEmpty()
recurringInterval: Stripe.Price.Recurring.Interval;
recurringInterval: SubscriptionInterval;
@Field(() => BillingPlanKey, { defaultValue: BillingPlanKey.PRO })
@IsEnum(BillingPlanKey)
@IsOptional()
plan?: BillingPlanKey;
@Field(() => Boolean, { defaultValue: true })
@IsBoolean()
@IsOptional()
requirePaymentMethod?: boolean;
@Field(() => String, { nullable: true })
@IsString()

View File

@ -1,4 +1,12 @@
import { registerEnumType } from '@nestjs/graphql';
export enum BillingPlanKey {
BASE_PLAN = 'BASE_PLAN',
PRO_PLAN = 'PRO_PLAN',
BASE = 'BASE',
PRO = 'PRO',
ENTERPRISE = 'ENTERPRISE',
}
registerEnumType(BillingPlanKey, {
name: 'BillingPlanKey',
description: 'The different billing plans available',
});

View File

@ -4,6 +4,7 @@ import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { BillingSubscription } from 'src/engine/core-modules/billing/entities/billing-subscription.entity';
import { BillingPlanKey } from 'src/engine/core-modules/billing/enums/billing-plan-key.enum';
import { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.service';
import { StripeService } from 'src/engine/core-modules/billing/stripe/stripe.service';
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/service/domain-manager.service';
@ -30,6 +31,8 @@ export class BillingPortalWorkspaceService {
workspace: Workspace,
priceId: string,
successUrlPath?: string,
plan?: BillingPlanKey,
requirePaymentMethod?: boolean,
): Promise<string> {
const frontBaseUrl = this.domainManagerService.getBaseUrl();
const cancelUrl = frontBaseUrl.toString();
@ -57,6 +60,8 @@ export class BillingPortalWorkspaceService {
successUrl,
cancelUrl,
stripeCustomerId,
plan,
requirePaymentMethod,
);
assert(session.url, 'Error: missing checkout.session.url');

View File

@ -52,9 +52,9 @@ export class BillingWebhookProductService {
isValidBillingPlanKey(planKey?: string) {
switch (planKey) {
case BillingPlanKey.BASE_PLAN:
case BillingPlanKey.BASE:
return true;
case BillingPlanKey.PRO_PLAN:
case BillingPlanKey.PRO:
return true;
default:
return false;

View File

@ -5,6 +5,7 @@ import Stripe from 'stripe';
import { ProductPriceEntity } from 'src/engine/core-modules/billing/dto/product-price.entity';
import { BillingSubscriptionItem } from 'src/engine/core-modules/billing/entities/billing-subscription-item.entity';
import { AvailableProduct } from 'src/engine/core-modules/billing/enums/billing-available-product.enum';
import { BillingPlanKey } from 'src/engine/core-modules/billing/enums/billing-plan-key.enum';
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/service/domain-manager.service';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
import { User } from 'src/engine/core-modules/user/user.entity';
@ -90,6 +91,8 @@ export class StripeService {
successUrl?: string,
cancelUrl?: string,
stripeCustomerId?: string,
plan: BillingPlanKey = BillingPlanKey.PRO,
requirePaymentMethod = true,
): Promise<Stripe.Checkout.Session> {
return await this.stripe.checkout.sessions.create({
line_items: [
@ -102,18 +105,22 @@ export class StripeService {
subscription_data: {
metadata: {
workspaceId,
plan,
},
trial_period_days: this.environmentService.get(
'BILLING_FREE_TRIAL_DURATION_IN_DAYS',
),
},
automatic_tax: { enabled: true },
tax_id_collection: { enabled: true },
automatic_tax: { enabled: !!requirePaymentMethod },
tax_id_collection: { enabled: !!requirePaymentMethod },
customer: stripeCustomerId,
customer_update: stripeCustomerId ? { name: 'auto' } : undefined,
customer_email: stripeCustomerId ? undefined : user.email,
success_url: successUrl,
cancel_url: cancelUrl,
payment_method_collection: requirePaymentMethod
? 'always'
: 'if_required',
});
}