Skip to content
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
25 changes: 19 additions & 6 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import CacheService from './services/cache.service';
import { PaymentService } from './services/payment.service';
import { StorageService } from './services/storage.service';
import { UsersService } from './services/users.service';
import webhook from './webhooks';
import { webhookHandler } from './webhooks';
import cryptoWebhook from './webhooks/providers/bit2me/index';
import { LicenseCodesService } from './services/licenseCodes.service';
import { ObjectStorageService } from './services/objectStorage.service';
Expand All @@ -26,6 +26,7 @@ import { gatewayController } from './controller/gateway.controller';
import { HealthService } from './services/health.service';
import healthController from './controller/health.controller';
import { PreCreatedUserService } from './services/preCreatedUser.service';
import { ConfirmationTokenService } from './services/confirmationToken.service';

interface AppDependencies {
paymentService: PaymentService;
Expand All @@ -41,6 +42,7 @@ interface AppDependencies {
config: AppConfig;
healthService: HealthService;
preCreatedUserService: PreCreatedUserService;
confirmationTokenService: ConfirmationTokenService;
}

export async function buildApp({
Expand All @@ -57,6 +59,7 @@ export async function buildApp({
config,
healthService,
preCreatedUserService,
confirmationTokenService,
}: AppDependencies): Promise<FastifyInstance> {
const fastify = Fastify({
loggerInstance: Logger.getPinoLogger(),
Expand All @@ -72,9 +75,17 @@ export async function buildApp({
fastify.register(productsController(productsService, cacheService, config), {
prefix: '/products',
});
fastify.register(checkoutController({ usersService, paymentsService: paymentService, preCreatedUserService }), {
prefix: '/checkout',
});
fastify.register(
checkoutController({
usersService,
paymentsService: paymentService,
preCreatedUserService,
confirmationTokenService,
}),
{
prefix: '/checkout',
},
);
fastify.register(customerController(usersService, paymentService, cacheService), { prefix: '/customer' });
fastify.register(
gatewayController({
Expand All @@ -92,7 +103,7 @@ export async function buildApp({
fastify.register(healthController(healthService));

fastify.register(
webhook(
webhookHandler({
stripe,
storageService,
usersService,
Expand All @@ -101,7 +112,8 @@ export async function buildApp({
cacheService,
objectStorageService,
tiersService,
),
preCreatedUserService,
}),
);

fastify.register(
Expand All @@ -113,6 +125,7 @@ export async function buildApp({
cacheService,
objectStorageService,
tiersService,
preCreatedUserService,
}),
);

Expand Down
1 change: 1 addition & 0 deletions src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,4 +35,5 @@ export const EU_COUNTRIES = [
'SE',
];
export const STRIPE_API_VERSION = '2025-02-24.acacia';
export const CONFIRMATION_TOKEN_MAX_AGE_IN_MINUTES = 15;
export const PAYMENTS_TOKEN_EXPIRATION = '6h';
82 changes: 71 additions & 11 deletions src/controller/checkout.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,23 +15,29 @@ import { setupAuth } from '../plugins/auth';
import { stripePaymentsAdapter } from '../infrastructure/adapters/stripe.adapter';
import Logger from '../Logger';
import { PreCreatedUserService } from '../services/preCreatedUser.service';
import { UserNotFoundError } from '../errors/PaymentErrors';
import { ConfirmationTokenService } from '../services/confirmationToken.service';

interface CheckoutControllerPayload {
usersService: UsersService;
paymentsService: PaymentService;
preCreatedUserService: PreCreatedUserService;
confirmationTokenService: ConfirmationTokenService;
}

export function checkoutController({
usersService,
paymentsService,
preCreatedUserService,
confirmationTokenService,
}: CheckoutControllerPayload) {
return async function (fastify: FastifyInstance) {
await setupAuth(fastify, { secret: config.JWT_SECRET });

fastify.post<{
Body: {
email?: string;
confirmationTokenId?: string;
customerName?: string;
lineAddress1?: string;
lineAddress2?: string;
Expand All @@ -50,6 +56,8 @@ export function checkoutController({
type: 'object',
required: ['country', 'captchaToken'],
properties: {
email: { type: 'string', format: 'email' },
confirmationTokenId: { type: 'string', minLength: 1 },
customerName: { type: 'string' },
lineAddress1: { type: 'string' },
lineAddress2: { type: 'string' },
Expand All @@ -66,6 +74,7 @@ export function checkoutController({
},
},
config: {
allowAnonymous: true,
rateLimit: {
max: 5,
timeWindow: '1 hour',
Expand All @@ -74,7 +83,11 @@ export function checkoutController({
},
async (req, res): Promise<{ customerId: string; token: string }> => {
let customerId: Stripe.Customer['id'];
let userUuid: string;

const {
email,
confirmationTokenId,
customerName,
lineAddress1,
lineAddress2,
Expand All @@ -85,20 +98,46 @@ export function checkoutController({
captchaToken,
metadata,
} = req.body;
const { uuid: userUuid, email } = req.user.payload;
const userToken = req.user?.payload;

const verifiedCaptcha = await verifyRecaptcha(captchaToken);

if (!verifiedCaptcha) {
throw new ForbiddenError('Token verification failed');
}

const userExists = await usersService.findUserByUuid(userUuid).catch(() => null);
if (!userToken) {
if (!email) {
throw new BadRequestError('Email is required when there is no user token');
}

if (!confirmationTokenId) {
throw new BadRequestError('Confirmation token is required when there is no user token');
}

await confirmationTokenService.validateAndClaim(confirmationTokenId);

const preCreatedUser = await preCreatedUserService.getOrCreateEligibleForPayment(email);

userUuid = preCreatedUser.uuid;
} else {
userUuid = userToken.uuid;
}

const customerEmail = email ?? userToken?.email;

const userExists = await usersService.findUserByUuid(userUuid).catch((error) => {
if (error instanceof UserNotFoundError) {
return null;
}

throw error;
});

if (userExists) {
await stripePaymentsAdapter.updateCustomer(userExists.customerId, {
name: customerName,
email,
email: customerEmail,
address: {
line1: lineAddress1,
line2: lineAddress2,
Expand All @@ -112,7 +151,7 @@ export function checkoutController({
} else {
const { id } = await stripePaymentsAdapter.createCustomer({
name: customerName,
email,
email: customerEmail,
address: {
line1: lineAddress1,
line2: lineAddress2,
Expand Down Expand Up @@ -177,6 +216,13 @@ export function checkoutController({
},
},
},
config: {
allowAnonymous: true,
rateLimit: {
max: 15,
timeWindow: '1 hour',
},
},
},
async (req, res) => {
const { customerId, priceId, currency, promoCodeId, captchaToken, token } = req.body;
Expand Down Expand Up @@ -262,15 +308,16 @@ export function checkoutController({
},
},
config: {
allowAnonymous: true,
rateLimit: {
max: 5,
timeWindow: '1 minute',
max: 15,
timeWindow: '1 hour',
},
},
},
async (req, res): Promise<PaymentIntent> => {
let tokenCustomerId: string;
const { uuid, email } = req.user.payload;
const userUuid = req.user?.payload?.uuid;
const { customerId, priceId, token, currency, userAddress, captchaToken, promoCodeId } = req.body;

const verifiedCaptcha = await verifyRecaptcha(captchaToken);
Expand Down Expand Up @@ -299,24 +346,33 @@ export function checkoutController({
throw new ForbiddenError();
}

const customer = await stripePaymentsAdapter.getCustomer(customerId);
const price = await stripePaymentsAdapter.getPriceById(priceId);

if (price.interval !== 'lifetime') {
throw new BadRequestError('Only lifetime plans are supported');
}

const { canExpand: isStorageUpgradeAllowed } = await fetchUserStorage(uuid, email, price.bytes.toString());
if (userUuid) {
const { canExpand: isStorageUpgradeAllowed } = await fetchUserStorage(
userUuid,
customer.email,
price.bytes.toString(),
);

if (!isStorageUpgradeAllowed) {
throw new BadRequestError('The user already has the maximum storage allowed');
if (!isStorageUpgradeAllowed) {
throw new BadRequestError('The user already has the maximum storage allowed');
}
} else {
await preCreatedUserService.getEligibleForPayment(customer.email);
}

const shouldCalculateTaxes = await stripePaymentsAdapter.shouldCalculateTaxForCustomer(customerId);

const result = await paymentsService.createInvoice({
customerId,
priceId,
userEmail: email,
userEmail: customer.email,
currency: currency.trim(),
promoCodeId,
userAddress,
Expand Down Expand Up @@ -368,6 +424,10 @@ export function checkoutController({
},
config: {
allowAnonymous: true,
rateLimit: {
max: 20,
timeWindow: '15 minute',
},
},
},
async (req, res) => {
Expand Down
1 change: 1 addition & 0 deletions src/errors/HttpError.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export class HttpError extends Error {
public statusCode: number;
public code?: string;

constructor(message: string, statusCode = 500) {
super(message);
Expand Down
8 changes: 7 additions & 1 deletion src/errors/PaymentErrors.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { BadRequestError, ConflictError, InternalServerError, NotFoundError } from './Errors';
import { BadRequestError, ConflictError, ForbiddenError, InternalServerError, NotFoundError } from './Errors';

export class NotFoundSubscriptionError extends NotFoundError {
constructor(message: string) {
Expand Down Expand Up @@ -90,3 +90,9 @@ export class SubscriptionNotEligibleForEarlyChargeError extends BadRequestError
super(message);
}
}

export class InvalidConfirmationTokenError extends ForbiddenError {
constructor(message = 'The payment confirmation token is invalid, expired or already used') {
super(message);
}
}
28 changes: 28 additions & 0 deletions src/errors/PreCreatedUsersErrors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { ConflictError, NotFoundError } from './Errors';

export class PreCreatedUserPendingSetupError extends ConflictError {
constructor(message = 'User needs to setup his account') {
super(message);
this.code = 'AccountSetupPending';
this.cause = 'PRE_CREATED_USER_NEEDS_TO_SETUP_ACCOUNT';
this.name = this.constructor.name;
Object.setPrototypeOf(this, PreCreatedUserPendingSetupError.prototype);
}
}

export class PreCreatedUserNotFoundError extends NotFoundError {
constructor(message = 'Pre-created user not found') {
super(message);
this.cause = 'PRE_CREATED_USER_NOT_FOUND';
this.name = this.constructor.name;
Object.setPrototypeOf(this, PreCreatedUserNotFoundError.prototype);
}
}

export class DriveAccountAlreadyExistsError extends ConflictError {
constructor(message = 'The email already has an account, log in to continue') {
super(message);
this.name = this.constructor.name;
Object.setPrototypeOf(this, DriveAccountAlreadyExistsError.prototype);
}
}
7 changes: 7 additions & 0 deletions src/errors/httpErrorCodes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
/**
* Error codes returned by the APIs we consume in the response body, as read from
* {@link HttpResponseError.code}.
*/
export enum HttpErrorCode {
UserNotFound = 'USER_NOT_FOUND',
}
16 changes: 15 additions & 1 deletion src/infrastructure/adapters/stripe.adapter.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import Stripe from 'stripe';

import { UserNotFoundError } from '../../errors/PaymentErrors';
import { InvalidConfirmationTokenError, UserNotFoundError } from '../../errors/PaymentErrors';
import { PaymentsAdapter } from '../domain/ports/payments.adapter';
import { Customer, CreateCustomerParams, UpdateCustomerParams } from '../domain/entities/customer';
import envVariablesConfig from '../../config';
Expand All @@ -12,6 +12,7 @@ import { Subscription } from '../domain/entities/subscription';
import { Invoice, InvoiceStatus } from '../domain/entities/invoice';
import { InvoiceItems } from '../domain/entities/invoiceItems';
import { EU_COUNTRIES } from '../../constants';
import { ConfirmationToken } from '../domain/entities/confirmationToken';

export class StripePaymentsAdapter implements PaymentsAdapter {
private _provider?: Stripe;
Expand Down Expand Up @@ -99,6 +100,19 @@ export class StripePaymentsAdapter implements PaymentsAdapter {
return customers.data.map((customer) => Customer.toDomain(customer));
}

async getConfirmationToken(confirmationTokenId: ConfirmationToken['id']): Promise<ConfirmationToken> {
try {
const confirmationToken = await this.provider.confirmationTokens.retrieve(confirmationTokenId);

return ConfirmationToken.toDomain(confirmationToken);
} catch (error) {
if (error instanceof Stripe.errors.StripeInvalidRequestError) {
throw new InvalidConfirmationTokenError();
}
throw error;
}
}

async retrievePaymentMethod(paymentMethodId: PaymentMethod['id']): Promise<PaymentMethod> {
const paymentMethods = await this.provider.paymentMethods.retrieve(paymentMethodId, {});
return PaymentMethod.toDomain(paymentMethods);
Expand Down
Loading
Loading