diff --git a/src/app.ts b/src/app.ts index daad56ed..107724fe 100644 --- a/src/app.ts +++ b/src/app.ts @@ -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'; @@ -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; @@ -41,6 +42,7 @@ interface AppDependencies { config: AppConfig; healthService: HealthService; preCreatedUserService: PreCreatedUserService; + confirmationTokenService: ConfirmationTokenService; } export async function buildApp({ @@ -57,6 +59,7 @@ export async function buildApp({ config, healthService, preCreatedUserService, + confirmationTokenService, }: AppDependencies): Promise { const fastify = Fastify({ loggerInstance: Logger.getPinoLogger(), @@ -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({ @@ -92,7 +103,7 @@ export async function buildApp({ fastify.register(healthController(healthService)); fastify.register( - webhook( + webhookHandler({ stripe, storageService, usersService, @@ -101,7 +112,8 @@ export async function buildApp({ cacheService, objectStorageService, tiersService, - ), + preCreatedUserService, + }), ); fastify.register( @@ -113,6 +125,7 @@ export async function buildApp({ cacheService, objectStorageService, tiersService, + preCreatedUserService, }), ); diff --git a/src/constants.ts b/src/constants.ts index 48a5e322..be82ed13 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -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'; diff --git a/src/controller/checkout.controller.ts b/src/controller/checkout.controller.ts index 4312d786..e7342c8c 100644 --- a/src/controller/checkout.controller.ts +++ b/src/controller/checkout.controller.ts @@ -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; @@ -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' }, @@ -66,6 +74,7 @@ export function checkoutController({ }, }, config: { + allowAnonymous: true, rateLimit: { max: 5, timeWindow: '1 hour', @@ -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, @@ -85,7 +98,7 @@ export function checkoutController({ captchaToken, metadata, } = req.body; - const { uuid: userUuid, email } = req.user.payload; + const userToken = req.user?.payload; const verifiedCaptcha = await verifyRecaptcha(captchaToken); @@ -93,12 +106,38 @@ export function checkoutController({ 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, @@ -112,7 +151,7 @@ export function checkoutController({ } else { const { id } = await stripePaymentsAdapter.createCustomer({ name: customerName, - email, + email: customerEmail, address: { line1: lineAddress1, line2: lineAddress2, @@ -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; @@ -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 => { 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); @@ -299,16 +346,25 @@ 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); @@ -316,7 +372,7 @@ export function checkoutController({ const result = await paymentsService.createInvoice({ customerId, priceId, - userEmail: email, + userEmail: customer.email, currency: currency.trim(), promoCodeId, userAddress, @@ -368,6 +424,10 @@ export function checkoutController({ }, config: { allowAnonymous: true, + rateLimit: { + max: 20, + timeWindow: '15 minute', + }, }, }, async (req, res) => { diff --git a/src/errors/HttpError.ts b/src/errors/HttpError.ts index 1b3a0aa2..ad516927 100644 --- a/src/errors/HttpError.ts +++ b/src/errors/HttpError.ts @@ -1,5 +1,6 @@ export class HttpError extends Error { public statusCode: number; + public code?: string; constructor(message: string, statusCode = 500) { super(message); diff --git a/src/errors/PaymentErrors.ts b/src/errors/PaymentErrors.ts index c5b57c0e..39e0f5ac 100644 --- a/src/errors/PaymentErrors.ts +++ b/src/errors/PaymentErrors.ts @@ -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) { @@ -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); + } +} diff --git a/src/errors/PreCreatedUsersErrors.ts b/src/errors/PreCreatedUsersErrors.ts new file mode 100644 index 00000000..77aba01d --- /dev/null +++ b/src/errors/PreCreatedUsersErrors.ts @@ -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); + } +} diff --git a/src/errors/httpErrorCodes.ts b/src/errors/httpErrorCodes.ts new file mode 100644 index 00000000..913ac202 --- /dev/null +++ b/src/errors/httpErrorCodes.ts @@ -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', +} diff --git a/src/infrastructure/adapters/stripe.adapter.ts b/src/infrastructure/adapters/stripe.adapter.ts index 8bc0dc87..bbba9e6b 100644 --- a/src/infrastructure/adapters/stripe.adapter.ts +++ b/src/infrastructure/adapters/stripe.adapter.ts @@ -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'; @@ -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; @@ -99,6 +100,19 @@ export class StripePaymentsAdapter implements PaymentsAdapter { return customers.data.map((customer) => Customer.toDomain(customer)); } + async getConfirmationToken(confirmationTokenId: ConfirmationToken['id']): Promise { + 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 { const paymentMethods = await this.provider.paymentMethods.retrieve(paymentMethodId, {}); return PaymentMethod.toDomain(paymentMethods); diff --git a/src/infrastructure/domain/entities/confirmationToken.ts b/src/infrastructure/domain/entities/confirmationToken.ts new file mode 100644 index 00000000..a4d70dae --- /dev/null +++ b/src/infrastructure/domain/entities/confirmationToken.ts @@ -0,0 +1,45 @@ +import Stripe from 'stripe'; +import { CONFIRMATION_TOKEN_MAX_AGE_IN_MINUTES } from '../../../constants'; + +const MILLISECONDS_PER_SECOND = 1000; +const MILLISECONDS_PER_MINUTE = 60 * MILLISECONDS_PER_SECOND; + +interface ConfirmationTokenAttributes { + id: string; + createdAt: Date; + expiresAt?: Date; + isAlreadyUsed: boolean; +} + +export class ConfirmationToken implements ConfirmationTokenAttributes { + id: string; + createdAt: Date; + expiresAt?: Date; + isAlreadyUsed: boolean; + + constructor({ id, createdAt, expiresAt, isAlreadyUsed }: ConfirmationTokenAttributes) { + this.id = id; + this.createdAt = createdAt; + this.expiresAt = expiresAt; + this.isAlreadyUsed = isAlreadyUsed; + } + + static toDomain(stripeConfirmationToken: Stripe.ConfirmationToken): ConfirmationToken { + return new ConfirmationToken({ + id: stripeConfirmationToken.id, + createdAt: new Date(stripeConfirmationToken.created * MILLISECONDS_PER_SECOND), + expiresAt: stripeConfirmationToken.expires_at + ? new Date(stripeConfirmationToken.expires_at * MILLISECONDS_PER_SECOND) + : undefined, + isAlreadyUsed: !!stripeConfirmationToken.payment_intent || !!stripeConfirmationToken.setup_intent, + }); + } + + canStartPaymentAttempt(now: Date = new Date()): boolean { + const isExpired = !!this.expiresAt && this.expiresAt.getTime() <= now.getTime(); + const oldestAllowedCreationTime = now.getTime() - CONFIRMATION_TOKEN_MAX_AGE_IN_MINUTES * MILLISECONDS_PER_MINUTE; + const isRecent = this.createdAt.getTime() >= oldestAllowedCreationTime; + + return !this.isAlreadyUsed && !isExpired && isRecent; + } +} diff --git a/src/infrastructure/domain/ports/payments.adapter.ts b/src/infrastructure/domain/ports/payments.adapter.ts index d3bf6059..a5cf78b2 100644 --- a/src/infrastructure/domain/ports/payments.adapter.ts +++ b/src/infrastructure/domain/ports/payments.adapter.ts @@ -5,12 +5,14 @@ import { PaymentMethod } from '../entities/paymentMethod'; import { Price } from '../entities/price'; import { Subscription } from '../entities/subscription'; import { InvoiceItems } from '../entities/invoiceItems'; +import { ConfirmationToken } from '../entities/confirmationToken'; export interface PaymentsAdapter { createCustomer: (params: CreateCustomerParams) => Promise; updateCustomer: (customerId: Customer['id'], params: Partial) => Promise; getCustomer: (customerId: Customer['id']) => Promise; searchCustomer: (email: Customer['email']) => Promise; + getConfirmationToken: (confirmationTokenId: ConfirmationToken['id']) => Promise; retrievePaymentMethod: (paymentMethodId: PaymentMethod['id']) => Promise; getPrices: (currency: string) => Promise; getPriceById: (priceId: Price['id'], currency: string) => Promise; diff --git a/src/infrastructure/http/HttpResponseError.ts b/src/infrastructure/http/HttpResponseError.ts new file mode 100644 index 00000000..f9f7f70e --- /dev/null +++ b/src/infrastructure/http/HttpResponseError.ts @@ -0,0 +1,30 @@ +import { AxiosError } from 'axios'; + +interface ErrorResponseBody { + code?: string; + message?: string; +} + +export class HttpResponseError extends Error { + constructor( + message: string, + readonly status: number, + readonly code: string | undefined, + readonly body: unknown, + ) { + super(message); + this.name = this.constructor.name; + Object.setPrototypeOf(this, HttpResponseError.prototype); + } + + static from(error: AxiosError): HttpResponseError { + const response = error.response!; + const body = response.data as ErrorResponseBody | undefined; + + return new HttpResponseError(body?.message ?? error.message, response.status, body?.code, response.data); + } + + hasCode(code: string): boolean { + return this.code === code; + } +} diff --git a/src/infrastructure/http/httpClient.ts b/src/infrastructure/http/httpClient.ts new file mode 100644 index 00000000..7629794f --- /dev/null +++ b/src/infrastructure/http/httpClient.ts @@ -0,0 +1,26 @@ +import axios, { AxiosInstance, CreateAxiosDefaults, isAxiosError } from 'axios'; + +import { HttpResponseError } from './HttpResponseError'; + +/** + * Creates an axios instance whose failed responses are normalized into + * {@link HttpResponseError}, so callers read `status`/`code`/`message` directly instead + * of reaching into `error.response.data`. Errors without a response (network failures) + * are left untouched. + */ +export function createHttpClient(config?: CreateAxiosDefaults): AxiosInstance { + const client = axios.create(config); + + client.interceptors.response.use( + (response) => response, + (error: unknown) => { + if (isAxiosError(error) && error.response) { + throw HttpResponseError.from(error); + } + + throw error; + }, + ); + + return client; +} diff --git a/src/plugins/auth.ts b/src/plugins/auth.ts index c5181e64..1978fda1 100644 --- a/src/plugins/auth.ts +++ b/src/plugins/auth.ts @@ -42,8 +42,8 @@ export async function setupAuth(fastify: FastifyInstance, options: WithAuthOptio fastify.addHook('onRequest', async (request) => { const skipAuth = request.routeOptions?.config?.skipAuth; const allowAnonymous = request.routeOptions?.config?.allowAnonymous; - const userAuthorization = request.headers.authorization; - const shouldAvoidAuth = allowAnonymous && !userAuthorization; + const hasBearerToken = /^Bearer\s+\S+/i.test(request.headers.authorization ?? ''); + const shouldAvoidAuth = allowAnonymous && !hasBearerToken; if (skipAuth || shouldAvoidAuth) { return; diff --git a/src/plugins/error-handler.ts b/src/plugins/error-handler.ts index 26c7fbcb..bdfcff2a 100644 --- a/src/plugins/error-handler.ts +++ b/src/plugins/error-handler.ts @@ -7,6 +7,7 @@ export function registerErrorHandler(app: FastifyInstance) { if (error instanceof HttpError) { return reply.status(error.statusCode).send({ message: error.message, + ...(error.code && { code: error.code }), }); } diff --git a/src/server.ts b/src/server.ts index a1318843..b59f452b 100644 --- a/src/server.ts +++ b/src/server.ts @@ -42,6 +42,8 @@ import { MongoDBDynamicConfigRepository } from './core/infra/MongoDBDynamicConfi import { stripePaymentsAdapter } from './infrastructure/adapters/stripe.adapter'; import { initStripeNewVersion } from './services/stripe'; import { PreCreatedUserService } from './services/preCreatedUser.service'; +import { createHttpClient } from './infrastructure/http/httpClient'; +import { ConfirmationTokenService } from './services/confirmationToken.service'; const start = async (mongoTestClient?: MongoClient): Promise => { const mongoClient = mongoTestClient ?? (await new MongoClient(envVariablesConfig.MONGO_URI).connect()); @@ -97,7 +99,8 @@ const start = async (mongoTestClient?: MongoClient): Promise => const userFeaturesOverridesService = new UserFeaturesOverridesService(usersService, userFeatureOverridesRepository); const productsService = new ProductsService(tiersService, usersService, userFeaturesOverridesService); const healthService = new HealthService(mongoClient, cacheService); - const preCreatedUserService = new PreCreatedUserService(axios, envVariablesConfig); + const preCreatedUserService = new PreCreatedUserService(createHttpClient(), envVariablesConfig); + const confirmationTokenService = new ConfirmationTokenService(cacheService); const fastify = await buildApp({ paymentService, @@ -113,6 +116,7 @@ const start = async (mongoTestClient?: MongoClient): Promise => config: envVariablesConfig, healthService, preCreatedUserService, + confirmationTokenService, }); fastify.addHook('onClose', async () => { diff --git a/src/services/cache.service.ts b/src/services/cache.service.ts index 3bafb8f9..052cab54 100644 --- a/src/services/cache.service.ts +++ b/src/services/cache.service.ts @@ -3,9 +3,11 @@ import Redis from 'ioredis'; import { type AppConfig } from '../config'; import { Tier } from '../core/users/Tier'; import Logger from '../Logger'; +import { CONFIRMATION_TOKEN_MAX_AGE_IN_MINUTES } from '../constants'; const FIFTEEN_MINS_EXPIRATION_IN_SECONDS = 15 * 60; const FOUR_HOURS_EXPIRATION_IN_SECONDS = 4 * 60 * 60; +const CONFIRMATION_TOKEN_EXPIRATION_IN_SECONDS = CONFIRMATION_TOKEN_MAX_AGE_IN_MINUTES * 60; export default class CacheService { private readonly redis: Redis; @@ -43,6 +45,10 @@ export default class CacheService { return `user-tier-${userUuid}`; } + private buildUsedConfirmationTokenKey(confirmationTokenId: string): string { + return `used-confirmation-token-${confirmationTokenId}`; + } + private async safeAwait(promise: Promise): Promise { try { return await promise; @@ -118,6 +124,18 @@ export default class CacheService { ); } + async markConfirmationTokenAsUsed(confirmationTokenId: string): Promise { + const result = await this.redis.set( + this.buildUsedConfirmationTokenKey(confirmationTokenId), + '1', + 'EX', + CONFIRMATION_TOKEN_EXPIRATION_IN_SECONDS, + 'NX', + ); + + return result === 'OK'; + } + async clearSubscription(customerId: string, userType: UserType = UserType.Individual): Promise { await this.safeAwait(this.redis.del(this.buildSubscriptionKey(customerId, userType))); } diff --git a/src/services/confirmationToken.service.ts b/src/services/confirmationToken.service.ts new file mode 100644 index 00000000..bb709d42 --- /dev/null +++ b/src/services/confirmationToken.service.ts @@ -0,0 +1,25 @@ +import { InvalidConfirmationTokenError } from '../errors/PaymentErrors'; +import { stripePaymentsAdapter } from '../infrastructure/adapters/stripe.adapter'; +import { ConfirmationToken } from '../infrastructure/domain/entities/confirmationToken'; +import CacheService from './cache.service'; + +export class ConfirmationTokenService { + constructor(private readonly cacheService: CacheService) {} + + /** + * @throws {InvalidConfirmationTokenError} When the token is unknown, expired, too old or was already used. + */ + async validateAndClaim(confirmationTokenId: ConfirmationToken['id']): Promise { + const confirmationToken = await stripePaymentsAdapter.getConfirmationToken(confirmationTokenId); + + if (!confirmationToken.canStartPaymentAttempt()) { + throw new InvalidConfirmationTokenError(); + } + + const isFirstUse = await this.cacheService.markConfirmationTokenAsUsed(confirmationToken.id); + + if (!isFirstUse) { + throw new InvalidConfirmationTokenError(); + } + } +} diff --git a/src/services/preCreatedUser.service.ts b/src/services/preCreatedUser.service.ts index 57b55b9d..45629377 100644 --- a/src/services/preCreatedUser.service.ts +++ b/src/services/preCreatedUser.service.ts @@ -1,7 +1,14 @@ -import { AxiosInstance, AxiosRequestConfig } from 'axios'; +import { AxiosInstance, AxiosRequestConfig, HttpStatusCode } from 'axios'; import { signGatewayToken } from '../utils/signGatewayToken'; import { AppConfig } from '../config'; import { PreCreatedUser, PreCreatedUserStatus } from '../infrastructure/domain/entities/preCreatedUser'; +import { + DriveAccountAlreadyExistsError, + PreCreatedUserNotFoundError, + PreCreatedUserPendingSetupError, +} from '../errors/PreCreatedUsersErrors'; +import { HttpResponseError } from '../infrastructure/http/HttpResponseError'; +import { HttpErrorCode } from '../errors/httpErrorCodes'; export class PreCreatedUserService { constructor( @@ -9,16 +16,27 @@ export class PreCreatedUserService { private readonly config: AppConfig, ) {} - async create(email: string): Promise { + async getOrCreate(email: string): Promise { const token = signGatewayToken('5m', this.gatewaySecret); const requestConfig: AxiosRequestConfig = this.basicRequestConfig(token); - const preCreatedUser = await this.axios.post(this.baseUrl, { email }, requestConfig); + try { + const preCreatedUser = await this.axios.post(`${this.baseUrl}/pre-create`, { email }, requestConfig); - return PreCreatedUser.toDomain(preCreatedUser.data); + return PreCreatedUser.toDomain(preCreatedUser.data); + } catch (error) { + if (error instanceof HttpResponseError && error.status === HttpStatusCode.Conflict) { + throw new DriveAccountAlreadyExistsError(); + } + + throw error; + } } + /** + * @throws {PreCreatedUserNotFoundError} When the gateway has no pre-created user for that email. + */ async get(email: string): Promise { const token = signGatewayToken('5m', this.gatewaySecret); @@ -28,9 +46,17 @@ export class PreCreatedUserService { }, }); - const preCreatedUser = await this.axios.get(this.baseUrl, requestConfig); + try { + const preCreatedUser = await this.axios.get(`${this.baseUrl}/pre-create`, requestConfig); + + return PreCreatedUser.toDomain(preCreatedUser.data); + } catch (error) { + if (error instanceof HttpResponseError && error.hasCode(HttpErrorCode.UserNotFound)) { + throw new PreCreatedUserNotFoundError(); + } - return PreCreatedUser.toDomain(preCreatedUser.data); + throw error; + } } async update({ @@ -49,7 +75,7 @@ export class PreCreatedUserService { const requestConfig: AxiosRequestConfig = this.basicRequestConfig(token); await this.axios.patch( - this.baseUrl, + `${this.baseUrl}/pre-create`, { uuid, status, @@ -60,6 +86,49 @@ export class PreCreatedUserService { ); } + async sendSetupEmail(uuid: string, planName?: string): Promise { + const token = signGatewayToken('5m', this.gatewaySecret); + + const requestConfig: AxiosRequestConfig = this.basicRequestConfig(token); + + await this.axios.post( + `${this.baseUrl}/${uuid}/setup-email`, + { + planName, + }, + requestConfig, + ); + } + + /** + * @throws {PreCreatedUserPendingSetupError} When the user already paid and is pending to set up the account, + * so it must not start another purchase. + * @throws {DriveAccountAlreadyExistsError} When the email already has a Drive account. + */ + async getOrCreateEligibleForPayment(email: string): Promise { + const preCreatedUser = await this.getOrCreate(email); + + if (preCreatedUser.isPendingStatus) { + throw new PreCreatedUserPendingSetupError(); + } + + return preCreatedUser; + } + + /** + * @throws {PreCreatedUserNotFoundError} When the gateway has no pre-created user for that email. + * @throws {PreCreatedUserPendingSetupError} When the user already paid and is pending to set up the account. + */ + async getEligibleForPayment(email: string): Promise { + const preCreatedUser = await this.get(email); + + if (preCreatedUser.isPendingStatus) { + throw new PreCreatedUserPendingSetupError(); + } + + return preCreatedUser; + } + private get gatewaySecret(): string { return this.config.DRIVE_NEW_GATEWAY_SECRET; } @@ -69,7 +138,7 @@ export class PreCreatedUserService { } private get baseUrl(): string { - return `${this.apiHostname}/gateway/users/pre-create`; + return `${this.apiHostname}/gateway/users`; } private basicRequestConfig(token: string, args?: Partial): AxiosRequestConfig { diff --git a/src/webhooks/events/invoices/InvoiceCompletedHandler.ts b/src/webhooks/events/invoices/InvoiceCompletedHandler.ts index 853673f6..190756a3 100644 --- a/src/webhooks/events/invoices/InvoiceCompletedHandler.ts +++ b/src/webhooks/events/invoices/InvoiceCompletedHandler.ts @@ -16,6 +16,10 @@ import { TierNotFoundError, TiersService } from '../../../services/tiers.service import { UsersService } from '../../../services/users.service'; import { PriceMetadata } from '../../../types/stripe'; import { ObjectStorageWebhookHandler } from '../ObjectStorageWebhookHandler'; +import { AxiosError } from 'axios'; +import { PreCreatedUserService } from '../../../services/preCreatedUser.service'; +import { PreCreatedUserNotFoundError } from '../../../errors/PreCreatedUsersErrors'; +import { PreCreatedUser } from '../../../infrastructure/domain/entities/preCreatedUser'; interface InvoiceCompletedHandlerAttributes { determineLifetimeConditions: DetermineLifetimeConditions; @@ -24,6 +28,7 @@ interface InvoiceCompletedHandlerAttributes { storageService: StorageService; tiersService: TiersService; usersService: UsersService; + preCreatedUserService: PreCreatedUserService; cacheService: CacheService; } @@ -41,6 +46,7 @@ export class InvoiceCompletedHandler { private readonly tiersService: TiersService; private readonly usersService: UsersService; private readonly cacheService: CacheService; + private readonly preCreatedUserService: PreCreatedUserService; constructor({ determineLifetimeConditions, @@ -50,6 +56,7 @@ export class InvoiceCompletedHandler { tiersService, usersService, cacheService, + preCreatedUserService, }: InvoiceCompletedHandlerAttributes) { this.determineLifetimeConditions = determineLifetimeConditions; this.objectStorageWebhookHandler = objectStorageWebhookHandler; @@ -58,6 +65,7 @@ export class InvoiceCompletedHandler { this.tiersService = tiersService; this.usersService = usersService; this.cacheService = cacheService; + this.preCreatedUserService = preCreatedUserService; } /** @@ -90,7 +98,6 @@ export class InvoiceCompletedHandler { } const items = await this.paymentService.getInvoiceLineItems(invoiceId); - const totalQuantity = items.data[0].quantity ?? 1; const price = items.data?.[0].price; if (!price) { @@ -120,7 +127,7 @@ export class InvoiceCompletedHandler { const isOldProduct = !tier; const email = customer.email ?? customerEmail; - const { uuid: userUuid } = await this.getUserUuid(customerId, email); + const { uuid: userUuid, preCreatedUser } = await this.getUserUuid(customerId, email); Logger.info( `Tier with product ID ${tier?.productId} found to apply it to the user with customer ID: ${customerId} and User id: ${userUuid}`, @@ -146,7 +153,6 @@ export class InvoiceCompletedHandler { productId, customer, tier, - totalQuantity: totalQuantity, }); await this.updateOrInsertUserTier({ @@ -167,6 +173,10 @@ export class InvoiceCompletedHandler { isLifetimePlan, }); + if (preCreatedUser) { + await this.preCreatedUserService.sendSetupEmail(preCreatedUser.uuid, tier?.label); + } + await this.clearUserRelatedCache(customerId, userUuid); Logger.info(`Invoice ${invoiceId} processed successfully for user ${userUuid}`); @@ -185,6 +195,7 @@ export class InvoiceCompletedHandler { customerEmail: string | null, ): Promise<{ uuid: string; + preCreatedUser?: PreCreatedUser; }> { // Try to find the user by email from the Drive Server if (customerEmail) { @@ -194,6 +205,22 @@ export class InvoiceCompletedHandler { return { uuid: userResponse.data.uuid }; } } catch (error) { + const err = error as AxiosError; + const isNotFoundStatusCode = err.response?.status === 404; + + if (isNotFoundStatusCode) { + const preCreatedUser = await this.preCreatedUserService.get(customerEmail).catch((error) => { + if (error instanceof PreCreatedUserNotFoundError) { + return null; + } + + throw error; + }); + + if (preCreatedUser) { + return { uuid: preCreatedUser.uuid, preCreatedUser }; + } + } Logger.warn(`Failed to find user by email ${customerEmail} and customer ID ${customerId}. Error: ${error}`); } } @@ -314,14 +341,12 @@ export class InvoiceCompletedHandler { customer, isLifetimePlan, productId, - totalQuantity, tier, }: { user: User & { email: string }; customer: Customer; isLifetimePlan: boolean; productId: string; - totalQuantity: number; tier: Tier; }): Promise { let tierToApply = tier; diff --git a/src/webhooks/handleDisputeResult.ts b/src/webhooks/handleDisputeResult.ts index 33cea979..1d4d0307 100644 --- a/src/webhooks/handleDisputeResult.ts +++ b/src/webhooks/handleDisputeResult.ts @@ -2,11 +2,11 @@ import Stripe from 'stripe'; import { UsersService } from '../services/users.service'; import { StorageService } from '../services/storage.service'; import CacheService from '../services/cache.service'; -import { AppConfig } from '../config'; import handleLifetimeRefunded from './handleLifetimeRefunded'; import { PaymentService } from '../services/payment.service'; import { FastifyBaseLogger } from 'fastify'; import { TiersService } from '../services/tiers.service'; +import { PreCreatedUserService } from '../services/preCreatedUser.service'; interface HandleDisputeResultProps { dispute: Stripe.Dispute; @@ -17,7 +17,7 @@ interface HandleDisputeResultProps { cacheService: CacheService; tiersService: TiersService; log: FastifyBaseLogger; - config: AppConfig; + preCreatedUserService: PreCreatedUserService; } export async function handleDisputeResult({ @@ -29,7 +29,7 @@ export async function handleDisputeResult({ cacheService, tiersService, log, - config, + preCreatedUserService, }: HandleDisputeResultProps) { if (dispute.status !== 'lost') { return; @@ -53,7 +53,7 @@ export async function handleDisputeResult({ paymentService, log, tiersService, - config, + preCreatedUserService, ); } else { await paymentService.cancelSubscription(subscriptionId as string); diff --git a/src/webhooks/handleLifetimeRefunded.ts b/src/webhooks/handleLifetimeRefunded.ts index c54cccef..9d5323c6 100644 --- a/src/webhooks/handleLifetimeRefunded.ts +++ b/src/webhooks/handleLifetimeRefunded.ts @@ -2,12 +2,12 @@ import { FastifyLoggerInstance } from 'fastify'; import CacheService from '../services/cache.service'; import { StorageService } from '../services/storage.service'; import { UsersService } from '../services/users.service'; -import { AppConfig } from '../config'; import { TierNotFoundError, TiersService } from '../services/tiers.service'; import { handleCancelPlan } from './utils/handleCancelPlan'; import Stripe from 'stripe'; import { PaymentService } from '../services/payment.service'; import { Service } from '../core/users/Tier'; +import { PreCreatedUserService } from '../services/preCreatedUser.service'; export default async function handleLifetimeRefunded( storageService: StorageService, @@ -17,7 +17,7 @@ export default async function handleLifetimeRefunded( paymentsService: PaymentService, log: FastifyLoggerInstance, tiersService: TiersService, - config: AppConfig, + preCreatedUserService: PreCreatedUserService, ): Promise { const customerId = charge.customer as string; const userEmail = charge.receipt_email; @@ -56,6 +56,7 @@ export default async function handleLifetimeRefunded( usersService, tiersService, log, + preCreatedUserService, }); } catch (error) { const err = error as Error; diff --git a/src/webhooks/handleSubscriptionCanceled.ts b/src/webhooks/handleSubscriptionCanceled.ts index f4fd03e5..b58b2eba 100644 --- a/src/webhooks/handleSubscriptionCanceled.ts +++ b/src/webhooks/handleSubscriptionCanceled.ts @@ -4,7 +4,6 @@ import CacheService from '../services/cache.service'; import { StorageService } from '../services/storage.service'; import { UsersService } from '../services/users.service'; import { PaymentService } from '../services/payment.service'; -import { AppConfig } from '../config'; import Stripe from 'stripe'; import { ObjectStorageService } from '../services/objectStorage.service'; import { handleCancelPlan } from './utils/handleCancelPlan'; @@ -14,6 +13,7 @@ import { stripePaymentsAdapter } from '../infrastructure/adapters/stripe.adapter import { Customer } from '../infrastructure/domain/entities/customer'; import { klaviyoService } from '../services/klaviyo.service'; import Logger from '../Logger'; +import { PreCreatedUserService } from '../services/preCreatedUser.service'; function isObjectStorageProduct(meta: Stripe.Metadata): boolean { return !!meta && !!meta.type && meta.type === 'object-storage'; @@ -56,13 +56,13 @@ export default async function handleSubscriptionCanceled( objectStorageService: ObjectStorageService, tiersService: TiersService, log: FastifyBaseLogger, - config: AppConfig, + preCreatedUserService: PreCreatedUserService, ): Promise { const customerId = subscription.customer as string; const productId = subscription.items.data[0].price.product as string; const { metadata: productMetadata } = await paymentService.getProduct(productId); const customer = await stripePaymentsAdapter.getCustomer(customerId); - + if (isObjectStorageProduct(productMetadata)) { await handleObjectStorageSubscriptionCancelled(customer, subscription, objectStorageService, paymentService, log); return; @@ -99,11 +99,12 @@ export default async function handleSubscriptionCanceled( usersService, tiersService, log, + preCreatedUserService, }); } catch (error) { const err = error as Error; log.error(`[SUB CANCEL/ERROR]: Error canceling tier product. ERROR: ${err.stack ?? err.message}`); - + if (!(error instanceof TierNotFoundError)) { throw error; } diff --git a/src/webhooks/index.ts b/src/webhooks/index.ts index 5976f5da..26242b0a 100644 --- a/src/webhooks/index.ts +++ b/src/webhooks/index.ts @@ -19,17 +19,31 @@ import { ObjectStorageWebhookHandler } from './events/ObjectStorageWebhookHandle import { InvoiceCompletedHandler } from './events/invoices/InvoiceCompletedHandler'; import Logger from '../Logger'; import { stripePaymentsAdapter } from '../infrastructure/adapters/stripe.adapter'; +import { PreCreatedUserService } from '../services/preCreatedUser.service'; + +interface WebhookHandlerPayload { + stripe: Stripe; + storageService: StorageService; + usersService: UsersService; + paymentService: PaymentService; + config: AppConfig; + cacheService: CacheService; + objectStorageService: ObjectStorageService; + tiersService: TiersService; + preCreatedUserService: PreCreatedUserService; +} -export default function ( - stripe: Stripe, - storageService: StorageService, - usersService: UsersService, - paymentService: PaymentService, - config: AppConfig, - cacheService: CacheService, - objectStorageService: ObjectStorageService, - tiersService: TiersService, -) { +export function webhookHandler({ + stripe, + storageService, + usersService, + paymentService, + config, + cacheService, + objectStorageService, + tiersService, + preCreatedUserService, +}: WebhookHandlerPayload) { return async function (fastify: FastifyInstance) { fastify.addContentTypeParser('application/json', { parseAs: 'buffer' }, function (req, body, done) { done(null, body); @@ -77,7 +91,7 @@ export default function ( objectStorageService, tiersService, fastify.log, - config, + preCreatedUserService, ); break; @@ -129,6 +143,7 @@ export default function ( tiersService, usersService, cacheService, + preCreatedUserService, }); await handler.run({ @@ -159,7 +174,7 @@ export default function ( paymentService, fastify.log, tiersService, - config, + preCreatedUserService, ); } } @@ -176,7 +191,7 @@ export default function ( cacheService, tiersService, log: fastify.log, - config, + preCreatedUserService, }); break; diff --git a/src/webhooks/providers/bit2me/index.ts b/src/webhooks/providers/bit2me/index.ts index b479424d..29721a62 100644 --- a/src/webhooks/providers/bit2me/index.ts +++ b/src/webhooks/providers/bit2me/index.ts @@ -14,6 +14,7 @@ import { DetermineLifetimeConditions } from '../../../core/users/DetermineLifeti import { ObjectStorageWebhookHandler } from '../../events/ObjectStorageWebhookHandler'; import Logger from '../../../Logger'; import { stripePaymentsAdapter } from '../../../infrastructure/adapters/stripe.adapter'; +import { PreCreatedUserService } from '../../../services/preCreatedUser.service'; export interface CryptoWebhookDependencies { storageService: StorageService; @@ -23,6 +24,7 @@ export interface CryptoWebhookDependencies { paymentService: PaymentService; tiersService: TiersService; usersService: UsersService; + preCreatedUserService: PreCreatedUserService; } export interface CryptoWebhookTokenPayload { @@ -61,6 +63,7 @@ export default function ({ paymentService, tiersService, usersService, + preCreatedUserService, }: CryptoWebhookDependencies) { return async function (fastify: FastifyInstance) { const decodeToken = async (token: string): Promise => { @@ -114,6 +117,7 @@ export default function ({ tiersService, storageService, usersService, + preCreatedUserService, }); await handler.run({ diff --git a/src/webhooks/utils/handleCancelPlan.ts b/src/webhooks/utils/handleCancelPlan.ts index de637bc3..454e8f04 100644 --- a/src/webhooks/utils/handleCancelPlan.ts +++ b/src/webhooks/utils/handleCancelPlan.ts @@ -3,6 +3,9 @@ import { UsersService } from '../../services/users.service'; import { TierNotFoundError, TiersService } from '../../services/tiers.service'; import Stripe from 'stripe'; import { FastifyBaseLogger } from 'fastify'; +import { PreCreatedUserService } from '../../services/preCreatedUser.service'; +import { PreCreatedUserStatus } from '../../infrastructure/domain/entities/preCreatedUser'; +import { PreCreatedUserNotFoundError } from '../../errors/PreCreatedUsersErrors'; interface HandleCancelPlanProps { customerId: CustomerId; @@ -10,6 +13,7 @@ interface HandleCancelPlanProps { productId: Stripe.Product['id']; usersService: UsersService; tiersService: TiersService; + preCreatedUserService: PreCreatedUserService; log: FastifyBaseLogger; isLifetime?: boolean; } @@ -20,6 +24,7 @@ export const handleCancelPlan = async ({ productId, usersService, tiersService, + preCreatedUserService, isLifetime, log, }: HandleCancelPlanProps) => { @@ -49,6 +54,23 @@ export const handleCancelPlan = async ({ await tiersService.deleteTierFromUser(userId, tierToRemove.id); + const preCreatedUser = await preCreatedUserService.get(customerEmail).catch((error) => { + if (error instanceof PreCreatedUserNotFoundError) { + return null; + } + + throw error; + }); + + if (preCreatedUser) { + await preCreatedUserService.update({ + uuid: preCreatedUser.uuid, + status: PreCreatedUserStatus.Cancelled, + }); + } else { + log.info(`[CANCEL PLAN HANDLER]: The preCreatedUser with email ${customerEmail} does not exist. Continuing...`); + } + log.info( `[CANCEL PLAN HANDLER]: The user-tier relationship using the user id ${userId} and tier id ${tier.id} has been deleted`, ); diff --git a/tests/src/controller/checkout.controller.test.ts b/tests/src/controller/checkout.controller.test.ts index 643920fb..aa3c2ba2 100644 --- a/tests/src/controller/checkout.controller.test.ts +++ b/tests/src/controller/checkout.controller.test.ts @@ -1,5 +1,6 @@ import { FastifyInstance } from 'fastify'; import { + getConfirmationToken, getCreateSubscriptionResponse, getCryptoCurrency, getCustomer, @@ -22,12 +23,17 @@ import Stripe from 'stripe'; import { AllowedCryptoCurrencies } from '../../../src/utils/currency'; import { Bit2MeService } from '../../../src/services/bit2me.service'; import * as verifyRecaptcha from '../../../src/utils/verifyRecaptcha'; -import { StripePaymentsAdapter } from '../../../src/infrastructure/adapters/stripe.adapter'; +import { StripePaymentsAdapter, stripePaymentsAdapter } from '../../../src/infrastructure/adapters/stripe.adapter'; +import { UserNotFoundError } from '../../../src/errors/PaymentErrors'; +import { PreCreatedUserService } from '../../../src/services/preCreatedUser.service'; +import { PreCreatedUser, PreCreatedUserStatus } from '../../../src/infrastructure/domain/entities/preCreatedUser'; +import { CONFIRMATION_TOKEN_MAX_AGE_IN_MINUTES } from '../../../src/constants'; import { Customer } from '../../../src/infrastructure/domain/entities/customer'; import { UserType } from '../../../src/core/users/User'; -import { getPriceEntity } from '../entity.fixtures'; +import { getPreCreatedUserEntity, getPriceEntity } from '../entity.fixtures'; jest.mock('../../../src/utils/fetchUserStorage'); +jest.mock('ioredis', () => require('ioredis-mock')); let app: FastifyInstance; @@ -134,7 +140,7 @@ describe('Checkout controller', () => { }; jest.spyOn(verifyRecaptcha, 'verifyRecaptcha').mockResolvedValue(true); - jest.spyOn(UsersService.prototype, 'findUserByUuid').mockRejectedValue(new Error('User not found')); + jest.spyOn(UsersService.prototype, 'findUserByUuid').mockRejectedValue(new UserNotFoundError()); const createCustomerSpy = jest .spyOn(StripePaymentsAdapter.prototype, 'createCustomer') .mockResolvedValue(Customer.toDomain(mockedCustomer)); @@ -281,6 +287,233 @@ describe('Checkout controller', () => { }); }); + describe('Create customer without an account (POST method)', () => { + const buyerEmail = 'buyer@internxt.com'; + const preCreatedUserUuid = 'pre-created-uuid'; + const nowInSeconds = () => Math.floor(Date.now() / 1000); + + const stripeKnowsToken = (params?: Parameters[0]) => { + const stripeToken = getConfirmationToken(params); + const retrieveSpy = jest + .spyOn(stripePaymentsAdapter.provider.confirmationTokens, 'retrieve') + .mockResolvedValue(stripeToken as Stripe.Response); + return { stripeToken, retrieveSpy }; + }; + + const driveHasNoPreCreatedUser = () => { + return jest + .spyOn(PreCreatedUserService.prototype, 'getOrCreate') + .mockResolvedValue( + new PreCreatedUser({ uuid: preCreatedUserUuid, status: PreCreatedUserStatus.AwaitingPayment }), + ); + }; + + const buyerHasNoCustomer = () => { + jest.spyOn(UsersService.prototype, 'findUserByUuid').mockRejectedValue(new UserNotFoundError()); + jest.spyOn(UsersService.prototype, 'insertUser').mockResolvedValue(); + return jest + .spyOn(StripePaymentsAdapter.prototype, 'createCustomer') + .mockResolvedValue(Customer.toDomain(getCustomer({ id: 'new_customer_id', email: buyerEmail }))); + }; + + const createCustomerAnonymously = (body: Record) => + app.inject({ + path: '/checkout/customer', + method: 'POST', + body: { country: 'ES', captchaToken: 'valid_captcha_token', ...body }, + }); + + beforeEach(() => { + jest.spyOn(verifyRecaptcha, 'verifyRecaptcha').mockResolvedValue(true); + }); + + test('When the buyer has no account and the confirmation token is valid, then the customer and the pre-created user are created', async () => { + const { stripeToken } = stripeKnowsToken(); + const createPreCreatedUserSpy = driveHasNoPreCreatedUser(); + const createCustomerSpy = buyerHasNoCustomer(); + + const response = await createCustomerAnonymously({ email: buyerEmail, confirmationTokenId: stripeToken.id }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toStrictEqual({ + customerId: 'new_customer_id', + token: getValidUserToken({ customerId: 'new_customer_id' }), + }); + expect(createPreCreatedUserSpy).toHaveBeenCalledWith(buyerEmail); + expect(createCustomerSpy).toHaveBeenCalledTimes(1); + }); + + test('When Drive already has the buyer pre-created awaiting payment, then that user is the one the customer is linked to', async () => { + const { stripeToken } = stripeKnowsToken(); + const getOrCreatePreCreatedUserSpy = jest + .spyOn(PreCreatedUserService.prototype, 'getOrCreate') + .mockResolvedValue( + new PreCreatedUser({ uuid: preCreatedUserUuid, status: PreCreatedUserStatus.AwaitingPayment }), + ); + const getPreCreatedUserSpy = jest.spyOn(PreCreatedUserService.prototype, 'get'); + const findUserSpy = jest + .spyOn(UsersService.prototype, 'findUserByUuid') + .mockRejectedValue(new UserNotFoundError()); + jest.spyOn(UsersService.prototype, 'insertUser').mockResolvedValue(); + jest + .spyOn(StripePaymentsAdapter.prototype, 'createCustomer') + .mockResolvedValue(Customer.toDomain(getCustomer({ id: 'new_customer_id' }))); + + const response = await createCustomerAnonymously({ email: buyerEmail, confirmationTokenId: stripeToken.id }); + + expect(response.statusCode).toBe(200); + expect(getOrCreatePreCreatedUserSpy).toHaveBeenCalledTimes(1); + expect(getOrCreatePreCreatedUserSpy).toHaveBeenCalledWith(buyerEmail); + expect(getPreCreatedUserSpy).not.toHaveBeenCalled(); + expect(findUserSpy).toHaveBeenCalledWith(preCreatedUserUuid); + }); + + test('When the same confirmation token is used twice, then the second attempt is rejected and nothing else is created', async () => { + const { stripeToken } = stripeKnowsToken(); + const createPreCreatedUserSpy = driveHasNoPreCreatedUser(); + const createCustomerSpy = buyerHasNoCustomer(); + await createCustomerAnonymously({ email: buyerEmail, confirmationTokenId: stripeToken.id }); + + const response = await createCustomerAnonymously({ email: buyerEmail, confirmationTokenId: stripeToken.id }); + + expect(response.statusCode).toBe(403); + expect(createPreCreatedUserSpy).toHaveBeenCalledTimes(1); + expect(createCustomerSpy).toHaveBeenCalledTimes(1); + }); + + test.each([ + ['has expired', () => ({ expires_at: nowInSeconds() - 1 })], + ['was already used for a payment', () => ({ payment_intent: 'pi_already_used' })], + [ + 'is older than the maximum age', + () => ({ created: nowInSeconds() - (CONFIRMATION_TOKEN_MAX_AGE_IN_MINUTES + 1) * 60 }), + ], + ])('When the confirmation token %s, then the request is rejected and nothing is created', async (_, params) => { + const { stripeToken } = stripeKnowsToken(params()); + const createPreCreatedUserSpy = driveHasNoPreCreatedUser(); + const createCustomerSpy = buyerHasNoCustomer(); + + const response = await createCustomerAnonymously({ email: buyerEmail, confirmationTokenId: stripeToken.id }); + + expect(response.statusCode).toBe(403); + expect(createPreCreatedUserSpy).not.toHaveBeenCalled(); + expect(createCustomerSpy).not.toHaveBeenCalled(); + }); + + test('When Stripe does not know the confirmation token, then the request is rejected and nothing is created', async () => { + jest.spyOn(stripePaymentsAdapter.provider.confirmationTokens, 'retrieve').mockRejectedValue( + new Stripe.errors.StripeInvalidRequestError({ + type: 'invalid_request_error', + code: 'resource_missing', + message: 'No such confirmationtoken', + }), + ); + const createPreCreatedUserSpy = driveHasNoPreCreatedUser(); + const createCustomerSpy = buyerHasNoCustomer(); + + const response = await createCustomerAnonymously({ email: buyerEmail, confirmationTokenId: 'ctoken_unknown' }); + + expect(response.statusCode).toBe(403); + expect(createPreCreatedUserSpy).not.toHaveBeenCalled(); + expect(createCustomerSpy).not.toHaveBeenCalled(); + }); + + test('When the confirmation token is missing, then the request is rejected', async () => { + const createCustomerSpy = buyerHasNoCustomer(); + + const response = await createCustomerAnonymously({ email: buyerEmail }); + + expect(response.statusCode).toBe(400); + expect(createCustomerSpy).not.toHaveBeenCalled(); + }); + + test('When the email is missing, then the request is rejected', async () => { + const createCustomerSpy = buyerHasNoCustomer(); + + const response = await createCustomerAnonymously({ confirmationTokenId: 'ctoken_123' }); + + expect(response.statusCode).toBe(400); + expect(createCustomerSpy).not.toHaveBeenCalled(); + }); + + test('When the email has an invalid format, then the request is rejected', async () => { + const createCustomerSpy = buyerHasNoCustomer(); + + const response = await createCustomerAnonymously({ email: 'not-an-email', confirmationTokenId: 'ctoken_123' }); + + expect(response.statusCode).toBe(400); + expect(createCustomerSpy).not.toHaveBeenCalled(); + }); + + test('When the buyer has a pre-created user pending setup, then the request is rejected as a conflict and no customer is created', async () => { + const { stripeToken } = stripeKnowsToken(); + const pendingEmail = 'pending@internxt.com'; + jest + .spyOn(PreCreatedUserService.prototype, 'getOrCreate') + .mockImplementation(async (email) => + email === pendingEmail + ? new PreCreatedUser({ uuid: 'pending-uuid', status: PreCreatedUserStatus.PendingSetup }) + : new PreCreatedUser({ uuid: preCreatedUserUuid, status: PreCreatedUserStatus.AwaitingPayment }), + ); + const createCustomerSpy = buyerHasNoCustomer(); + + const rejectedResponse = await createCustomerAnonymously({ + email: pendingEmail, + confirmationTokenId: stripeToken.id, + }); + + expect(rejectedResponse.statusCode).toBe(409); + expect(rejectedResponse.json()).toMatchObject({ code: 'AccountSetupPending' }); + expect(createCustomerSpy).not.toHaveBeenCalled(); + }); + + test('When a buyer rejected for pending setup retries with a fresh confirmation token, then the purchase goes through', async () => { + const { stripeToken: firstToken } = stripeKnowsToken(); + const pendingEmail = 'pending@internxt.com'; + jest + .spyOn(PreCreatedUserService.prototype, 'getOrCreate') + .mockImplementation(async (email) => + email === pendingEmail + ? new PreCreatedUser({ uuid: 'pending-uuid', status: PreCreatedUserStatus.PendingSetup }) + : new PreCreatedUser({ uuid: preCreatedUserUuid, status: PreCreatedUserStatus.AwaitingPayment }), + ); + const createCustomerSpy = buyerHasNoCustomer(); + + const rejectedResponse = await createCustomerAnonymously({ + email: pendingEmail, + confirmationTokenId: firstToken.id, + }); + + const { stripeToken: freshToken } = stripeKnowsToken(); + const retryResponse = await createCustomerAnonymously({ + email: buyerEmail, + confirmationTokenId: freshToken.id, + }); + + expect(rejectedResponse.statusCode).toBe(409); + expect(retryResponse.statusCode).toBe(200); + expect(createCustomerSpy).toHaveBeenCalledTimes(1); + }); + + test('When the user is logged in, then no confirmation token is needed and Stripe is not asked about one', async () => { + const mockedUser = getUser(); + const userAuthToken = getValidAuthToken(mockedUser.uuid, undefined, { email: buyerEmail }); + const retrieveSpy = jest.spyOn(stripePaymentsAdapter.provider.confirmationTokens, 'retrieve'); + const createCustomerSpy = buyerHasNoCustomer(); + + const response = await app.inject({ + path: '/checkout/customer', + method: 'POST', + body: { country: 'ES', captchaToken: 'valid_captcha_token' }, + headers: { Authorization: `Bearer ${userAuthToken}` }, + }); + + expect(response.statusCode).toBe(200); + expect(createCustomerSpy).toHaveBeenCalledTimes(1); + expect(retrieveSpy).not.toHaveBeenCalled(); + }); + }); + describe('Creating a subscription', () => { test('When the user wants to create a subscription, test is created successfully', async () => { const mockedUser = getUser(); @@ -490,6 +723,7 @@ describe('Checkout controller', () => { describe('Create an invoice and returns the payment intent', () => { beforeEach(() => { jest.clearAllMocks(); + jest.spyOn(StripePaymentsAdapter.prototype, 'getCustomer').mockResolvedValue(Customer.toDomain(getCustomer())); }); test('When the user wants to pay a one time plan, then an invoice is created and the client secret is returned', async () => { @@ -535,6 +769,65 @@ describe('Checkout controller', () => { expect(responseBody).toStrictEqual(mockedPaymentIntent); }); + describe('When the buyer has no account', () => { + const payAnonymously = (headers: Record = {}) => { + const mockedUser = getUser(); + const mockedPrice = getPriceEntity({ interval: 'lifetime' }); + const mockedPaymentIntent: PaymentIntent = { + id: 'payment_intent_id', + clientSecret: 'client_secret', + type: 'fiat', + } as const; + + jest.spyOn(StripePaymentsAdapter.prototype, 'getPriceById').mockResolvedValue(mockedPrice); + jest.spyOn(PaymentService.prototype, 'createInvoice').mockResolvedValue(mockedPaymentIntent); + jest.spyOn(verifyRecaptcha, 'verifyRecaptcha').mockResolvedValue(true); + const eligibilitySpy = jest + .spyOn(PreCreatedUserService.prototype, 'getEligibleForPayment') + .mockResolvedValue(getPreCreatedUserEntity()); + + return { + mockedPaymentIntent, + eligibilitySpy, + response: app.inject({ + path: '/checkout/payment-intent', + method: 'POST', + body: { + customerId: mockedUser.customerId, + priceId: mockedPrice.id, + token: getValidUserToken({ customerId: mockedUser.customerId }), + currency: 'eur', + captchaToken: 'captcha_token', + }, + headers, + }), + }; + }; + + test('When it pays a one time plan, then it is checked as a buyer without account and its storage is not looked up', async () => { + const { response, mockedPaymentIntent, eligibilitySpy } = payAnonymously(); + + const { statusCode, json } = await response; + + expect(statusCode).toBe(200); + expect(json()).toStrictEqual(mockedPaymentIntent); + expect(eligibilitySpy).toHaveBeenCalledTimes(1); + expect(fetchUserStorage).not.toHaveBeenCalled(); + }); + + test('When the checkout sends an empty bearer token, then it is treated as a buyer without account instead of being rejected', async () => { + const { response } = payAnonymously({ authorization: 'Bearer ' }); + + expect((await response).statusCode).toBe(200); + }); + + test('When the checkout sends an invalid bearer token, then it is rejected', async () => { + const { response } = payAnonymously({ authorization: 'Bearer invalid-token' }); + + expect((await response).statusCode).toBe(401); + }); + }); + test('when the user want to pay a one time plan using crypto currencies, then an invoice is created and the specific payload containing the QR Link is returned', async () => { const mockedUser = getUser(); const mockedInvoice = getInvoice(); diff --git a/tests/src/entity.fixtures.ts b/tests/src/entity.fixtures.ts index a79d45f9..fa3747ca 100644 --- a/tests/src/entity.fixtures.ts +++ b/tests/src/entity.fixtures.ts @@ -9,6 +9,7 @@ import dayjs from 'dayjs'; import { Invoice, InvoiceStatus } from '../../src/infrastructure/domain/entities/invoice'; import { getInvoice, getInvoiceItems } from './fixtures'; import { InvoiceItems } from '../../src/infrastructure/domain/entities/invoiceItems'; +import { PreCreatedUser, PreCreatedUserStatus } from '../../src/infrastructure/domain/entities/preCreatedUser'; const randomGenerator = new Chance(); @@ -108,3 +109,10 @@ export const getInvoiceItemsEntity = (params?: Partial): Invoice = ...params, }); }; + +export const getPreCreatedUserEntity = (params?: Partial): PreCreatedUser => + PreCreatedUser.toDomain({ + uuid: randomGenerator.guid(), + status: PreCreatedUserStatus.AwaitingPayment, + ...params, + }); diff --git a/tests/src/fixtures.ts b/tests/src/fixtures.ts index 29677a6f..11a50c48 100644 --- a/tests/src/fixtures.ts +++ b/tests/src/fixtures.ts @@ -159,6 +159,28 @@ export const getCustomer = (params?: Partial): Stripe.Customer }; }; +export const getConfirmationToken = (params?: Partial): Stripe.ConfirmationToken => { + const nowInSeconds = Math.floor(Date.now() / 1000); + const twelveHoursInSeconds = 12 * 60 * 60; + + return { + id: `ctoken_${randomDataGenerator.string({ length: 24, alpha: true, numeric: true })}`, + object: 'confirmation_token', + created: nowInSeconds, + expires_at: nowInSeconds + twelveHoursInSeconds, + livemode: false, + payment_intent: null, + payment_method_options: null, + payment_method_preview: null, + return_url: null, + setup_future_usage: null, + setup_intent: null, + shipping: null, + use_stripe_sdk: true, + ...params, + }; +}; + export const getPromotionCodeResponse = (params?: Partial): PromotionCode => { return { codeId: 'promo_id', diff --git a/tests/src/helpers/services-factory.ts b/tests/src/helpers/services-factory.ts index 2eb17c18..2b5e9f85 100644 --- a/tests/src/helpers/services-factory.ts +++ b/tests/src/helpers/services-factory.ts @@ -1,5 +1,5 @@ import { Stripe } from 'stripe'; -import axios from 'axios'; +import axios, { AxiosInstance } from 'axios'; import { TiersService } from '../../../src/services/tiers.service'; import { PaymentService } from '../../../src/services/payment.service'; import { UsersService } from '../../../src/services/users.service'; @@ -27,6 +27,7 @@ import { UserFeatureOverridesRepository } from '../../../src/core/users/MongoDBU import { UserFeaturesOverridesService } from '../../../src/services/userFeaturesOverride.service'; import { KlaviyoTrackingService } from '../../../src/services/klaviyo.service'; import { PreCreatedUserService } from '../../../src/services/preCreatedUser.service'; +import { createHttpClient } from '../../../src/infrastructure/http/httpClient'; export interface TestServices { stripe: Stripe; @@ -62,6 +63,7 @@ export interface TestRepositories { export interface TestServiceOverrides { stripe?: any; + httpClient?: AxiosInstance; } const createRepositories = (): TestRepositories => ({ @@ -109,6 +111,7 @@ export const createTestServices = (overrides: TestServiceOverrides = {}): TestSe const objectStorageService = new ObjectStorageService(paymentService, config, axios); const determineLifetimeConditions = new DetermineLifetimeConditions(paymentService, tiersService); const objectStorageWebhookHandler = new ObjectStorageWebhookHandler(objectStorageService, paymentService); + const preCreatedUserService = new PreCreatedUserService(overrides.httpClient ?? createHttpClient(), config); const invoiceCompletedHandler = new InvoiceCompletedHandler({ determineLifetimeConditions, objectStorageWebhookHandler, @@ -117,6 +120,7 @@ export const createTestServices = (overrides: TestServiceOverrides = {}): TestSe tiersService, usersService, cacheService, + preCreatedUserService, }); const userFeaturesOverridesService = new UserFeaturesOverridesService( usersService, @@ -124,7 +128,6 @@ export const createTestServices = (overrides: TestServiceOverrides = {}): TestSe ); const productsService = new ProductsService(tiersService, usersService, userFeaturesOverridesService); const klaviyoTrackingService = new KlaviyoTrackingService(); - const preCreatedUserService = new PreCreatedUserService(axios, config); return { stripe, diff --git a/tests/src/infrastructure/adapters/stripe.adapter.test.ts b/tests/src/infrastructure/adapters/stripe.adapter.test.ts index 393656ca..5e1b46da 100644 --- a/tests/src/infrastructure/adapters/stripe.adapter.test.ts +++ b/tests/src/infrastructure/adapters/stripe.adapter.test.ts @@ -1,4 +1,5 @@ import { + getConfirmationToken, getCreatedSubscription, getCustomer, getInvoice, @@ -9,7 +10,8 @@ import { import { stripePaymentsAdapter } from '../../../../src/infrastructure/adapters/stripe.adapter'; import Stripe from 'stripe'; import { Customer } from '../../../../src/infrastructure/domain/entities/customer'; -import { UserNotFoundError } from '../../../../src/errors/PaymentErrors'; +import { InvalidConfirmationTokenError, UserNotFoundError } from '../../../../src/errors/PaymentErrors'; +import { ConfirmationToken } from '../../../../src/infrastructure/domain/entities/confirmationToken'; import { PaymentMethod } from '../../../../src/infrastructure/domain/entities/paymentMethod'; import { Price } from '../../../../src/infrastructure/domain/entities/price'; import { UserType } from '../../../../src/core/users/User'; @@ -174,6 +176,51 @@ describe('Stripe Adapter', () => { }); }); + describe('Get a payment confirmation token', () => { + test('When the token exists, then it is returned with its creation, expiration and usage', async () => { + const stripeConfirmationToken = getConfirmationToken({ + created: 1_700_000_000, + expires_at: 1_700_043_200, + payment_intent: 'pi_123', + }); + jest + .spyOn(stripePaymentsAdapter.provider.confirmationTokens, 'retrieve') + .mockResolvedValue(stripeConfirmationToken as Stripe.Response); + + const confirmationToken = await stripePaymentsAdapter.getConfirmationToken(stripeConfirmationToken.id); + + expect(confirmationToken).toStrictEqual( + new ConfirmationToken({ + id: stripeConfirmationToken.id, + createdAt: new Date(1_700_000_000_000), + expiresAt: new Date(1_700_043_200_000), + isAlreadyUsed: true, + }), + ); + }); + + test('When Stripe does not know the token, then an error indicating it is invalid is thrown', async () => { + jest.spyOn(stripePaymentsAdapter.provider.confirmationTokens, 'retrieve').mockRejectedValue( + new Stripe.errors.StripeInvalidRequestError({ + type: 'invalid_request_error', + code: 'resource_missing', + message: 'No such confirmationtoken', + }), + ); + + await expect(stripePaymentsAdapter.getConfirmationToken('ctoken_unknown')).rejects.toThrow( + InvalidConfirmationTokenError, + ); + }); + + test('When Stripe fails for another reason, then the error is not hidden', async () => { + const stripeOutage = new Stripe.errors.StripeAPIError({ type: 'api_error', message: 'Stripe is down' }); + jest.spyOn(stripePaymentsAdapter.provider.confirmationTokens, 'retrieve').mockRejectedValue(stripeOutage); + + await expect(stripePaymentsAdapter.getConfirmationToken('ctoken_123')).rejects.toBe(stripeOutage); + }); + }); + describe('Get Payment methods', () => { test('When retrieving a payment method, then the payment method is returned', async () => { const mockedPaymentMethod = getPaymentMethod(); diff --git a/tests/src/infrastructure/domain/entities/confirmationToken.test.ts b/tests/src/infrastructure/domain/entities/confirmationToken.test.ts new file mode 100644 index 00000000..fecdf9cb --- /dev/null +++ b/tests/src/infrastructure/domain/entities/confirmationToken.test.ts @@ -0,0 +1,69 @@ +import { getConfirmationToken } from '../../../fixtures'; +import { ConfirmationToken } from '../../../../../src/infrastructure/domain/entities/confirmationToken'; +import { CONFIRMATION_TOKEN_MAX_AGE_IN_MINUTES } from '../../../../../src/constants'; + +const nowInSeconds = () => Math.floor(Date.now() / 1000); + +describe('Confirmation token entity', () => { + test('When converting a Stripe token that has no payment attempt, then it is not marked as used', () => { + const stripeToken = getConfirmationToken(); + + const confirmationToken = ConfirmationToken.toDomain(stripeToken); + + expect(confirmationToken).toMatchObject({ + id: stripeToken.id, + createdAt: new Date(stripeToken.created * 1000), + expiresAt: new Date((stripeToken.expires_at as number) * 1000), + isAlreadyUsed: false, + }); + }); + + test.each([ + ['payment', { payment_intent: 'pi_123' }], + ['setup', { setup_intent: 'seti_123' }], + ])('When the Stripe token is attached to a %s, then it is marked as used', (_, params) => { + const confirmationToken = ConfirmationToken.toDomain(getConfirmationToken(params)); + + expect(confirmationToken.isAlreadyUsed).toBe(true); + }); + + test('When the Stripe token has no expiration, then the domain token has none either', () => { + const confirmationToken = ConfirmationToken.toDomain(getConfirmationToken({ expires_at: null })); + + expect(confirmationToken.expiresAt).toBeUndefined(); + }); + + test('When the token is recent, unused and not expired, then a payment attempt can start', () => { + const confirmationToken = ConfirmationToken.toDomain(getConfirmationToken()); + + expect(confirmationToken.canStartPaymentAttempt()).toBe(true); + }); + + test('When the token was already used, then a payment attempt cannot start', () => { + const confirmationToken = ConfirmationToken.toDomain(getConfirmationToken({ payment_intent: 'pi_123' })); + + expect(confirmationToken.canStartPaymentAttempt()).toBe(false); + }); + + test('When the token has expired, then a payment attempt cannot start', () => { + const confirmationToken = ConfirmationToken.toDomain(getConfirmationToken({ expires_at: nowInSeconds() - 1 })); + + expect(confirmationToken.canStartPaymentAttempt()).toBe(false); + }); + + test('When the token was created longer ago than the maximum age, then a payment attempt cannot start', () => { + const confirmationToken = ConfirmationToken.toDomain( + getConfirmationToken({ created: nowInSeconds() - (CONFIRMATION_TOKEN_MAX_AGE_IN_MINUTES + 1) * 60 }), + ); + + expect(confirmationToken.canStartPaymentAttempt()).toBe(false); + }); + + test('When the token was created just within the maximum age, then a payment attempt can start', () => { + const confirmationToken = ConfirmationToken.toDomain( + getConfirmationToken({ created: nowInSeconds() - (CONFIRMATION_TOKEN_MAX_AGE_IN_MINUTES - 1) * 60 }), + ); + + expect(confirmationToken.canStartPaymentAttempt()).toBe(true); + }); +}); diff --git a/tests/src/plugins/error-handler.test.ts b/tests/src/plugins/error-handler.test.ts index e69fe530..4c092edb 100644 --- a/tests/src/plugins/error-handler.test.ts +++ b/tests/src/plugins/error-handler.test.ts @@ -41,6 +41,31 @@ describe('Custom error handler', () => { }); }); + it('When the account setup of the buyer is pending, then returns a conflict with the code the checkout recognizes', async () => { + const res = await app.inject({ + method: 'GET', + url: '/account-setup-pending', + }); + + expect(res.statusCode).toBe(409); + expect(JSON.parse(res.body)).toEqual({ + message: 'User needs to setup his account', + code: 'AccountSetupPending', + }); + }); + + it('When the email already has a Drive account, then returns a conflict without code', async () => { + const res = await app.inject({ + method: 'GET', + url: '/account-already-exists', + }); + + expect(res.statusCode).toBe(409); + expect(JSON.parse(res.body)).toEqual({ + message: 'The email already has an account, log in to continue', + }); + }); + it('When an Internal Server Error is thrown, then returns the correct status code and message', async () => { const res = await app.inject({ method: 'GET', diff --git a/tests/src/services/cache.service.test.ts b/tests/src/services/cache.service.test.ts index 2c6d101d..5db1a9a6 100644 --- a/tests/src/services/cache.service.test.ts +++ b/tests/src/services/cache.service.test.ts @@ -187,4 +187,16 @@ describe('Cache Service', () => { expect(storedTier).toBeNull(); }); }); + + describe('Single use of payment confirmation tokens', () => { + test('When a confirmation token is used for the first time, then it is accepted', async () => { + await expect(cacheService.markConfirmationTokenAsUsed('ctoken_first_use')).resolves.toBe(true); + }); + + test('When a confirmation token was already used, then it is not accepted again', async () => { + await cacheService.markConfirmationTokenAsUsed('ctoken_used_twice'); + + await expect(cacheService.markConfirmationTokenAsUsed('ctoken_used_twice')).resolves.toBe(false); + }); + }); }); diff --git a/tests/src/services/confirmationToken.service.test.ts b/tests/src/services/confirmationToken.service.test.ts new file mode 100644 index 00000000..0caa9126 --- /dev/null +++ b/tests/src/services/confirmationToken.service.test.ts @@ -0,0 +1,76 @@ +import config from '../../../src/config'; +import { InvalidConfirmationTokenError } from '../../../src/errors/PaymentErrors'; +import { ConfirmationToken } from '../../../src/infrastructure/domain/entities/confirmationToken'; +import { stripePaymentsAdapter } from '../../../src/infrastructure/adapters/stripe.adapter'; +import CacheService from '../../../src/services/cache.service'; +import { ConfirmationTokenService } from '../../../src/services/confirmationToken.service'; +import { CONFIRMATION_TOKEN_MAX_AGE_IN_MINUTES } from '../../../src/constants'; +import { getConfirmationToken } from '../fixtures'; + +jest.mock('ioredis', () => require('ioredis-mock')); + +const nowInSeconds = () => Math.floor(Date.now() / 1000); + +describe('Confirmation token service', () => { + let getConfirmationTokenFromPayments: jest.SpyInstance; + let confirmationTokenService: ConfirmationTokenService; + + const paymentsKnowsToken = (params?: Parameters[0]) => { + const confirmationToken = ConfirmationToken.toDomain(getConfirmationToken(params)); + getConfirmationTokenFromPayments.mockResolvedValue(confirmationToken); + return confirmationToken; + }; + + beforeEach(() => { + jest.restoreAllMocks(); + getConfirmationTokenFromPayments = jest.spyOn(stripePaymentsAdapter, 'getConfirmationToken'); + confirmationTokenService = new ConfirmationTokenService(new CacheService(config)); + }); + + test('When the token is valid and unused, then it is accepted', async () => { + const confirmationToken = paymentsKnowsToken(); + + await expect(confirmationTokenService.validateAndClaim(confirmationToken.id)).resolves.toBeUndefined(); + }); + + test('When the same token is used twice, then the second use is rejected', async () => { + const confirmationToken = paymentsKnowsToken(); + await confirmationTokenService.validateAndClaim(confirmationToken.id); + + await expect(confirmationTokenService.validateAndClaim(confirmationToken.id)).rejects.toThrow( + InvalidConfirmationTokenError, + ); + }); + + test.each([ + ['has expired', () => ({ expires_at: nowInSeconds() - 1 })], + ['was already used for a payment', () => ({ payment_intent: 'pi_already_used' })], + ['was already used for a setup', () => ({ setup_intent: 'seti_already_used' })], + ['is not recent', () => ({ created: nowInSeconds() - (CONFIRMATION_TOKEN_MAX_AGE_IN_MINUTES + 1) * 60 })], + ])('When the token %s, then it is rejected', async (_, params) => { + const confirmationToken = paymentsKnowsToken(params()); + + await expect(confirmationTokenService.validateAndClaim(confirmationToken.id)).rejects.toThrow( + InvalidConfirmationTokenError, + ); + }); + + test('When a token is rejected, then it is not consumed and a later valid use is not affected', async () => { + const confirmationToken = paymentsKnowsToken({ expires_at: nowInSeconds() - 1 }); + await expect(confirmationTokenService.validateAndClaim(confirmationToken.id)).rejects.toThrow(); + + getConfirmationTokenFromPayments.mockResolvedValue( + new ConfirmationToken({ ...confirmationToken, expiresAt: undefined }), + ); + + await expect(confirmationTokenService.validateAndClaim(confirmationToken.id)).resolves.toBeUndefined(); + }); + + test('When the payment provider does not know the token, then it is rejected', async () => { + getConfirmationTokenFromPayments.mockRejectedValue(new InvalidConfirmationTokenError()); + + await expect(confirmationTokenService.validateAndClaim('ctoken_unknown')).rejects.toThrow( + InvalidConfirmationTokenError, + ); + }); +}); diff --git a/tests/src/services/klaviyo.service.test.ts b/tests/src/services/klaviyo.service.test.ts index e71af831..70b3a30e 100644 --- a/tests/src/services/klaviyo.service.test.ts +++ b/tests/src/services/klaviyo.service.test.ts @@ -31,19 +31,19 @@ describe('KlaviyoTrackingService', () => { (config as any).KLAVIYO_API_KEY = mockApiKey; (config as any).KLAVIYO_BASE_URL = mockBaseUrl; - service = createTestServices({ stripe: {} as any }).klaviyoTrackingService; + service = createTestServices({ stripe: {} as any, httpClient: {} as any }).klaviyoTrackingService; }); describe('Initialization', () => { test('When instantiated without an API Key in config, then it logs a warning', () => { const loggerWarnSpy = jest.spyOn(Logger, 'warn').mockImplementation(); (config as any).KLAVIYO_API_KEY = undefined; - createTestServices({ stripe: {} as any }); + createTestServices({ stripe: {} as any, httpClient: {} as any }); expect(loggerWarnSpy).toHaveBeenCalled(); }); test('When instantiated with valid config, then it initializes correctly', () => { - expect(() => createTestServices({ stripe: {} as any }).klaviyoTrackingService).not.toThrow(); + expect(() => createTestServices({ stripe: {} as any, httpClient: {} as any }).klaviyoTrackingService).not.toThrow(); }); }); diff --git a/tests/src/services/preCreatedUser.service.test.ts b/tests/src/services/preCreatedUser.service.test.ts index e929f9ef..c04d8879 100644 --- a/tests/src/services/preCreatedUser.service.test.ts +++ b/tests/src/services/preCreatedUser.service.test.ts @@ -1,16 +1,29 @@ -import axios from 'axios'; import config from '../../../src/config'; import { PreCreatedUserService } from '../../../src/services/preCreatedUser.service'; -import { PreCreatedUser, PreCreatedUserStatus } from '../../../src/infrastructure/domain/entities/preCreatedUser'; +import { PreCreatedUser } from '../../../src/infrastructure/domain/entities/preCreatedUser'; +import { PreCreatedUserStatus } from '../../../src/infrastructure/domain/entities/preCreatedUser'; +import { createHttpClient } from '../../../src/infrastructure/http/httpClient'; +import { HttpResponseError } from '../../../src/infrastructure/http/HttpResponseError'; +import { HttpErrorCode } from '../../../src/errors/httpErrorCodes'; +import { + DriveAccountAlreadyExistsError, + PreCreatedUserNotFoundError, + PreCreatedUserPendingSetupError, +} from '../../../src/errors/PreCreatedUsersErrors'; jest.mock('jsonwebtoken', () => ({ sign: jest.fn().mockReturnValue('mocked-jwt-token'), })); +const httpClient = createHttpClient(); let preCreatedUserService: PreCreatedUserService; +const userNotFoundError = new HttpResponseError('User not found', 404, HttpErrorCode.UserNotFound, { + code: HttpErrorCode.UserNotFound, +}); + beforeEach(() => { - preCreatedUserService = new PreCreatedUserService(axios, config); + preCreatedUserService = new PreCreatedUserService(httpClient, config); jest.restoreAllMocks(); }); @@ -22,12 +35,14 @@ describe('Pre Created User Service tests', () => { }; const gatewayResponse = { data: { uuid: 'pre-created-uuid', status: PreCreatedUserStatus.AwaitingPayment } }; - describe('Creating a pre-created user', () => { - test('When called, then it requests the creation of the pre-created user with the signed token', async () => { - const postSpy = jest.spyOn(axios, 'post').mockResolvedValue(gatewayResponse); + describe('Getting or creating a pre-created user in the gateway', () => { + const conflictError = new HttpResponseError('User already registered', 409, undefined, {}); + + test('When called, then it asks the gateway to get or create the pre-created user with the signed token', async () => { + const postSpy = jest.spyOn(httpClient, 'post').mockResolvedValue(gatewayResponse); const email = 'n2oK7@example.com'; - await preCreatedUserService.create(email); + await preCreatedUserService.getOrCreate(email); expect(postSpy).toHaveBeenCalledTimes(1); expect(postSpy).toHaveBeenCalledWith( @@ -37,25 +52,33 @@ describe('Pre Created User Service tests', () => { ); }); - test('When called, then it resolves with the created pre-created user', async () => { - jest.spyOn(axios, 'post').mockResolvedValue(gatewayResponse); + test('When called, then it resolves with the pre-created user returned by the gateway', async () => { + jest.spyOn(httpClient, 'post').mockResolvedValue(gatewayResponse); - const result = await preCreatedUserService.create('n2oK7@example.com'); + const result = await preCreatedUserService.getOrCreate('n2oK7@example.com'); expect(result).toStrictEqual(PreCreatedUser.toDomain(gatewayResponse.data)); }); - test('When the request fails, then the rejection propagates', async () => { + test('When the email already has a Drive account, then an error indicating so is thrown', async () => { + jest.spyOn(httpClient, 'post').mockRejectedValue(conflictError); + + await expect(preCreatedUserService.getOrCreate('n2oK7@example.com')).rejects.toThrow( + DriveAccountAlreadyExistsError, + ); + }); + + test('When the request fails for any other reason, then the rejection propagates', async () => { const requestError = new Error('Network error'); - jest.spyOn(axios, 'post').mockRejectedValue(requestError); + jest.spyOn(httpClient, 'post').mockRejectedValue(requestError); - await expect(preCreatedUserService.create('n2oK7@example.com')).rejects.toThrow(requestError); + await expect(preCreatedUserService.getOrCreate('n2oK7@example.com')).rejects.toThrow(requestError); }); }); describe('Retrieving a pre-created user', () => { test('When called, then it fetches the pre-created user by email with the signed token', async () => { - const getSpy = jest.spyOn(axios, 'get').mockResolvedValue(gatewayResponse); + const getSpy = jest.spyOn(httpClient, 'get').mockResolvedValue(gatewayResponse); const email = 'n2oK7@example.com'; await preCreatedUserService.get(email); @@ -71,7 +94,7 @@ describe('Pre Created User Service tests', () => { }); test('When called, then it resolves with the pre-created user returned by the gateway', async () => { - jest.spyOn(axios, 'get').mockResolvedValue(gatewayResponse); + jest.spyOn(httpClient, 'get').mockResolvedValue(gatewayResponse); const result = await preCreatedUserService.get('n2oK7@example.com'); @@ -80,7 +103,7 @@ describe('Pre Created User Service tests', () => { test('When the retrieved user is pending to set up the account, then the resulting user reflects it', async () => { jest - .spyOn(axios, 'get') + .spyOn(httpClient, 'get') .mockResolvedValue({ data: { uuid: 'pre-created-uuid', status: PreCreatedUserStatus.PendingSetup } }); const result = await preCreatedUserService.get('n2oK7@example.com'); @@ -88,17 +111,56 @@ describe('Pre Created User Service tests', () => { expect(result.isPendingStatus).toBe(true); }); - test('When the request fails, then the rejection propagates', async () => { + test('When the gateway reports the user as not found, then a domain error is thrown instead', async () => { + jest.spyOn(httpClient, 'get').mockRejectedValue(userNotFoundError); + + await expect(preCreatedUserService.get('n2oK7@example.com')).rejects.toThrow(PreCreatedUserNotFoundError); + }); + + test('When the request fails for any other reason, then the rejection propagates untouched', async () => { const requestError = new Error('Network error'); - jest.spyOn(axios, 'get').mockRejectedValue(requestError); + jest.spyOn(httpClient, 'get').mockRejectedValue(requestError); await expect(preCreatedUserService.get('n2oK7@example.com')).rejects.toThrow(requestError); }); }); + describe('Getting or creating a pre-created user that is eligible for payment', () => { + test('When called, then a single request to the gateway gets or creates the user, so it is never looked up first', async () => { + const getSpy = jest.spyOn(httpClient, 'get'); + const postSpy = jest.spyOn(httpClient, 'post').mockResolvedValue(gatewayResponse); + + const result = await preCreatedUserService.getOrCreateEligibleForPayment('n2oK7@example.com'); + + expect(postSpy).toHaveBeenCalledTimes(1); + expect(getSpy).not.toHaveBeenCalled(); + expect(result).toStrictEqual(PreCreatedUser.toDomain(gatewayResponse.data)); + }); + + test('When the user is pending to set up the account, then an error indicating so is thrown', async () => { + jest + .spyOn(httpClient, 'post') + .mockResolvedValue({ data: { uuid: 'pre-created-uuid', status: PreCreatedUserStatus.PendingSetup } }); + + await expect(preCreatedUserService.getOrCreateEligibleForPayment('n2oK7@example.com')).rejects.toThrow( + PreCreatedUserPendingSetupError, + ); + }); + + test('When the email already has a Drive account, then an error indicating so is thrown', async () => { + jest + .spyOn(httpClient, 'post') + .mockRejectedValue(new HttpResponseError('User already registered', 409, undefined, {})); + + await expect(preCreatedUserService.getOrCreateEligibleForPayment('n2oK7@example.com')).rejects.toThrow( + DriveAccountAlreadyExistsError, + ); + }); + }); + describe('Updating a pre-created user', () => { test('When called with every attribute, then all of them are sent with the signed token', async () => { - const patchSpy = jest.spyOn(axios, 'patch').mockResolvedValue(undefined); + const patchSpy = jest.spyOn(httpClient, 'patch').mockResolvedValue(undefined); const attributes = { uuid: 'pre-created-uuid', status: PreCreatedUserStatus.PendingSetup, @@ -117,7 +179,7 @@ describe('Pre Created User Service tests', () => { }); test('When called with only some attributes, then the omitted ones are sent as undefined', async () => { - const patchSpy = jest.spyOn(axios, 'patch').mockResolvedValue(undefined); + const patchSpy = jest.spyOn(httpClient, 'patch').mockResolvedValue(undefined); await preCreatedUserService.update({ uuid: 'pre-created-uuid' }); @@ -131,9 +193,65 @@ describe('Pre Created User Service tests', () => { test('When the request fails, then the rejection propagates', async () => { const requestError = new Error('Network error'); - jest.spyOn(axios, 'patch').mockRejectedValue(requestError); + jest.spyOn(httpClient, 'patch').mockRejectedValue(requestError); await expect(preCreatedUserService.update({ uuid: 'pre-created-uuid' })).rejects.toThrow(requestError); }); }); + + describe('Sending the setup email to a pre-created user', () => { + const setupEmailUrl = `${config.DRIVE_NEW_GATEWAY_URL}/gateway/users/pre-created-uuid/setup-email`; + + test('When called with a plan name, then it requests the setup email with the signed token', async () => { + const postSpy = jest.spyOn(httpClient, 'post').mockResolvedValue(undefined); + + await preCreatedUserService.sendSetupEmail('pre-created-uuid', 'Premium 2TB'); + + expect(postSpy).toHaveBeenCalledTimes(1); + expect(postSpy).toHaveBeenCalledWith( + setupEmailUrl, + { planName: 'Premium 2TB' }, + expect.objectContaining({ headers: expectedHeaders }), + ); + }); + + test('When called without a plan name, then it is sent as undefined', async () => { + const postSpy = jest.spyOn(httpClient, 'post').mockResolvedValue(undefined); + + await preCreatedUserService.sendSetupEmail('pre-created-uuid'); + + expect(postSpy).toHaveBeenCalledWith( + setupEmailUrl, + { planName: undefined }, + expect.objectContaining({ headers: expectedHeaders }), + ); + }); + + test('When the request fails, then the rejection propagates', async () => { + const requestError = new Error('Network error'); + jest.spyOn(httpClient, 'post').mockRejectedValue(requestError); + + await expect(preCreatedUserService.sendSetupEmail('pre-created-uuid')).rejects.toThrow(requestError); + }); + }); + + describe('Checking if a pre-created user is eligible for payment', () => { + test('When the pre-created user has not completed the account setup yet, then it is returned', async () => { + jest.spyOn(httpClient, 'get').mockResolvedValue(gatewayResponse); + + const result = await preCreatedUserService.getEligibleForPayment('n2oK7@example.com'); + + expect(result).toStrictEqual(PreCreatedUser.toDomain(gatewayResponse.data)); + }); + + test('When the pre-created user already paid and is pending to set up the account, then a domain error is thrown', async () => { + jest + .spyOn(httpClient, 'get') + .mockResolvedValue({ data: { uuid: 'pre-created-uuid', status: PreCreatedUserStatus.PendingSetup } }); + + await expect(preCreatedUserService.getEligibleForPayment('n2oK7@example.com')).rejects.toThrow( + PreCreatedUserPendingSetupError, + ); + }); + }); }); diff --git a/tests/src/utils/error-test-routes.ts b/tests/src/utils/error-test-routes.ts index 1734587a..ff0a3835 100644 --- a/tests/src/utils/error-test-routes.ts +++ b/tests/src/utils/error-test-routes.ts @@ -1,6 +1,10 @@ // routes/test-routes.ts import { FastifyInstance } from 'fastify'; import { BadRequestError, NotFoundError } from '../../../src/errors/Errors'; +import { + DriveAccountAlreadyExistsError, + PreCreatedUserPendingSetupError, +} from '../../../src/errors/PreCreatedUsersErrors'; export async function testRoutes(app: FastifyInstance) { app.get('/bad-request', async () => { @@ -11,6 +15,14 @@ export async function testRoutes(app: FastifyInstance) { throw new NotFoundError('User not found'); }); + app.get('/account-setup-pending', async () => { + throw new PreCreatedUserPendingSetupError(); + }); + + app.get('/account-already-exists', async () => { + throw new DriveAccountAlreadyExistsError(); + }); + app.get('/unhandled', async () => { throw new Error('Something went wrong'); }); diff --git a/tests/src/webhooks/events/invoices/InvoiceCompletedHandler.test.ts b/tests/src/webhooks/events/invoices/InvoiceCompletedHandler.test.ts index fd140ef5..cd7bd4da 100644 --- a/tests/src/webhooks/events/invoices/InvoiceCompletedHandler.test.ts +++ b/tests/src/webhooks/events/invoices/InvoiceCompletedHandler.test.ts @@ -1,17 +1,28 @@ import Stripe from 'stripe'; +import { AxiosError } from 'axios'; import { SUBSCRIPTION_EARLY_CANCELLATION_KEY } from '../../../../../src/constants'; import { Service } from '../../../../../src/core/users/Tier'; import { NotFoundError } from '../../../../../src/errors/Errors'; import { UserNotFoundError } from '../../../../../src/errors/PaymentErrors'; import { CouponNotBeingTrackedError } from '../../../../../src/errors/UsersErrors'; +import { PreCreatedUserNotFoundError } from '../../../../../src/errors/PreCreatedUsersErrors'; import { stripePaymentsAdapter } from '../../../../../src/infrastructure/adapters/stripe.adapter'; import { Customer } from '../../../../../src/infrastructure/domain/entities/customer'; +import { PreCreatedUserStatus } from '../../../../../src/infrastructure/domain/entities/preCreatedUser'; import Logger from '../../../../../src/Logger'; import { TierNotFoundError, UsersTiersError } from '../../../../../src/services/tiers.service'; import { InvoiceCompletedHandlerPayload } from '../../../../../src/webhooks/events/invoices/InvoiceCompletedHandler'; import { getCustomer, getInvoice, getProduct, getUser, newTier, voidPromise } from '../../../fixtures'; import { createTestServices } from '../../../helpers/services-factory'; +import { getPreCreatedUserEntity } from '../../../entity.fixtures'; + +function axiosErrorWithStatus(status: number): AxiosError { + const error = new AxiosError('Request failed'); + error.response = { status } as AxiosError['response']; + + return error; +} const { invoiceCompletedHandler, @@ -22,11 +33,14 @@ const { objectStorageWebhookHandler, cacheService, storageService, + preCreatedUserService, } = createTestServices(); beforeEach(() => { jest.clearAllMocks(); jest.restoreAllMocks(); + jest.spyOn(preCreatedUserService, 'get').mockResolvedValue(getPreCreatedUserEntity()); + jest.spyOn(preCreatedUserService, 'sendSetupEmail').mockImplementation(voidPromise); }); afterEach(() => jest.restoreAllMocks()); @@ -222,7 +236,6 @@ describe('Testing the handler when an invoice is completed', () => { productId: (mockedInvoice.lines.data[0].price!.product as Stripe.Product).id, customer: Customer.toDomain(mockedCustomer), tier: mockedTier, - totalQuantity: 1, }); expect(updateOrInsertUserTierSpy).toHaveBeenCalledWith({ isBusinessPlan: false, @@ -232,6 +245,53 @@ describe('Testing the handler when an invoice is completed', () => { }); }); + describe('Account setup email', () => { + const runNewProductPurchase = async (preCreatedUser?: ReturnType) => { + const mockedCustomer = getCustomer(); + const mockedInvoice = getInvoice({ customer: mockedCustomer.id, status: 'paid' }); + const mockedTier = newTier(); + const mockedUser = getUser(); + + jest + .spyOn(paymentService, 'getInvoiceLineItems') + .mockResolvedValue(mockedInvoice.lines as Stripe.Response>); + jest.spyOn(tiersService, 'getTierProductsByProductsId').mockResolvedValue(mockedTier); + jest + .spyOn(invoiceCompletedHandler as any, 'getUserUuid') + .mockResolvedValue({ uuid: preCreatedUser?.uuid ?? mockedUser.uuid, preCreatedUser }); + jest.spyOn(invoiceCompletedHandler as any, 'updateOrInsertUser').mockResolvedValue(voidPromise); + jest.spyOn(usersService, 'findUserByUuid').mockResolvedValue(mockedUser); + jest.spyOn(invoiceCompletedHandler as any, 'handleNewProduct').mockResolvedValue(voidPromise); + jest.spyOn(invoiceCompletedHandler as any, 'updateOrInsertUserTier').mockResolvedValue(voidPromise); + jest.spyOn(invoiceCompletedHandler as any, 'handleUserCouponRelationship').mockResolvedValue(voidPromise); + jest.spyOn(invoiceCompletedHandler as any, 'clearUserRelatedCache').mockResolvedValue(voidPromise); + const sendSetupEmailSpy = jest.spyOn(preCreatedUserService, 'sendSetupEmail').mockImplementation(voidPromise); + const getPreCreatedUserSpy = jest.spyOn(preCreatedUserService, 'get'); + + await invoiceCompletedHandler.run({ + customer: Customer.toDomain(mockedCustomer), + invoice: mockedInvoice, + status: mockedInvoice.status as string, + }); + + return { sendSetupEmailSpy, getPreCreatedUserSpy, mockedUser, mockedTier }; + }; + + test('When the buyer was found as a pre-created user, then the setup email is requested for it with the plan name', async () => { + const preCreatedUser = getPreCreatedUserEntity(); + const { sendSetupEmailSpy, mockedTier } = await runNewProductPurchase(preCreatedUser); + + expect(sendSetupEmailSpy).toHaveBeenCalledWith(preCreatedUser.uuid, mockedTier.label); + }); + + test('When the buyer is not a pre-created user, then the gateway is not asked about pre-created users nor setup emails', async () => { + const { sendSetupEmailSpy, getPreCreatedUserSpy } = await runNewProductPurchase(); + + expect(sendSetupEmailSpy).not.toHaveBeenCalled(); + expect(getPreCreatedUserSpy).not.toHaveBeenCalled(); + }); + }); + describe('Invoice for early cancellation', () => { test('When the invoices is for early cancellation, then the subscription is cancelled and early returned', async () => { const mockedInvoice = getInvoice({ @@ -307,6 +367,52 @@ describe('Testing the handler when an invoice is completed', () => { await expect(getUserUuid(mockedCustomer.id, mockedCustomer.email)).rejects.toThrow(NotFoundError); }); + + test('When the user is not found by email but exists as a pre-created user, then their unique Id is used', async () => { + const mockedCustomer = getCustomer({ + email: 'test@inxt.com', + }); + const mockedPreCreatedUser = getPreCreatedUserEntity(); + jest.spyOn(usersService, 'findUserByEmail').mockRejectedValue(axiosErrorWithStatus(404)); + jest.spyOn(preCreatedUserService, 'get').mockResolvedValue(mockedPreCreatedUser); + const findByCustomerIdSpy = jest.spyOn(usersService, 'findUserByCustomerID'); + + const getUserUuid = invoiceCompletedHandler['getUserUuid'].bind(invoiceCompletedHandler); + const result = await getUserUuid(mockedCustomer.id, mockedCustomer.email as string); + + expect(result).toStrictEqual({ uuid: mockedPreCreatedUser.uuid, preCreatedUser: mockedPreCreatedUser }); + expect(findByCustomerIdSpy).not.toHaveBeenCalled(); + }); + + test('When the user is not found by email and there is no pre-created user either, then the local lookup by customer ID is used', async () => { + const mockedCustomer = getCustomer({ + email: 'test@inxt.com', + }); + const mockedUser = getUser({ customerId: mockedCustomer.id }); + jest.spyOn(usersService, 'findUserByEmail').mockRejectedValue(axiosErrorWithStatus(404)); + jest.spyOn(preCreatedUserService, 'get').mockRejectedValue(new PreCreatedUserNotFoundError()); + jest.spyOn(usersService, 'findUserByCustomerID').mockResolvedValue(mockedUser); + + const getUserUuid = invoiceCompletedHandler['getUserUuid'].bind(invoiceCompletedHandler); + const result = await getUserUuid(mockedCustomer.id, mockedCustomer.email as string); + + expect(result).toStrictEqual({ uuid: mockedUser.uuid }); + }); + + test('When looking up the pre-created user fails for any other reason, then the error propagates so the webhook is retried', async () => { + const mockedCustomer = getCustomer({ + email: 'test@inxt.com', + }); + const unexpectedError = new Error('Gateway is down'); + jest.spyOn(usersService, 'findUserByEmail').mockRejectedValue(axiosErrorWithStatus(404)); + jest.spyOn(preCreatedUserService, 'get').mockRejectedValue(unexpectedError); + const findByCustomerIdSpy = jest.spyOn(usersService, 'findUserByCustomerID'); + + const getUserUuid = invoiceCompletedHandler['getUserUuid'].bind(invoiceCompletedHandler); + + await expect(getUserUuid(mockedCustomer.id, mockedCustomer.email as string)).rejects.toThrow(unexpectedError); + expect(findByCustomerIdSpy).not.toHaveBeenCalled(); + }); }); describe('Get price data', () => { @@ -427,7 +533,6 @@ describe('Testing the handler when an invoice is completed', () => { const lifetimeMockedMaxSpaceBytes = mockedMaxSpaceBytes * 5; const mockedIsLifetimePlan = true; const mockedProductId = getProduct({}).id; - const totalQuantity = 1; const mockedTier = newTier(); const mockedLifetimeTier = newTier({ billingType: 'lifetime' }); const determineLifetimeConditionsSpy = jest.spyOn(determineLifetimeConditions, 'determine').mockResolvedValue({ @@ -447,7 +552,6 @@ describe('Testing the handler when an invoice is completed', () => { customer: Customer.toDomain(mockedCustomer), isLifetimePlan: mockedIsLifetimePlan, productId: mockedProductId, - totalQuantity, tier: mockedTier, }); @@ -477,7 +581,6 @@ describe('Testing the handler when an invoice is completed', () => { }); const mockedIsLifetimePlan = true; const mockedProductId = getProduct({}).id; - const totalQuantity = 1; const mockedTier = newTier(); const determineLifetimeConditionsSpy = jest .spyOn(determineLifetimeConditions, 'determine') @@ -495,7 +598,6 @@ describe('Testing the handler when an invoice is completed', () => { customer: Customer.toDomain(mockedCustomer), isLifetimePlan: mockedIsLifetimePlan, productId: mockedProductId, - totalQuantity, tier: mockedTier, }); @@ -525,7 +627,6 @@ describe('Testing the handler when an invoice is completed', () => { }); const mockedIsLifetimePlan = false; const mockedProductId = getProduct({}).id; - const totalQuantity = 1; const mockedTier = newTier(); const applyDriveFeaturesSpy = jest.spyOn(tiersService, 'applyDriveFeatures').mockResolvedValue(); @@ -541,7 +642,6 @@ describe('Testing the handler when an invoice is completed', () => { customer: Customer.toDomain(mockedCustomer), isLifetimePlan: mockedIsLifetimePlan, productId: mockedProductId, - totalQuantity, tier: mockedTier, }); @@ -577,7 +677,6 @@ describe('Testing the handler when an invoice is completed', () => { }); const mockedIsLifetimePlan = false; const mockedProductId = getProduct({}).id; - const totalQuantity = 1; const mockedTier = newTier(); jest.spyOn(tiersService, 'applyDriveFeatures').mockRejectedValue(mockedError); const loggerSpy = jest.spyOn(Logger, 'error'); @@ -592,7 +691,6 @@ describe('Testing the handler when an invoice is completed', () => { customer: Customer.toDomain(mockedCustomer), isLifetimePlan: mockedIsLifetimePlan, productId: mockedProductId, - totalQuantity, tier: mockedTier, }), ).rejects.toThrow(mockedError); @@ -612,7 +710,6 @@ describe('Testing the handler when an invoice is completed', () => { }); const mockedIsLifetimePlan = false; const mockedProductId = getProduct({}).id; - const totalQuantity = 1; const mockedTier = newTier(); jest.spyOn(tiersService, 'applyDriveFeatures').mockResolvedValue(); jest.spyOn(tiersService, 'applyVpnFeatures').mockRejectedValue(mockedError); @@ -629,7 +726,6 @@ describe('Testing the handler when an invoice is completed', () => { customer: Customer.toDomain(mockedCustomer), isLifetimePlan: mockedIsLifetimePlan, productId: mockedProductId, - totalQuantity, tier: mockedTier, }), ).rejects.toThrow(mockedError); diff --git a/tests/src/webhooks/handleDisputeResult.test.ts b/tests/src/webhooks/handleDisputeResult.test.ts index 47a47caf..460bc54e 100644 --- a/tests/src/webhooks/handleDisputeResult.test.ts +++ b/tests/src/webhooks/handleDisputeResult.test.ts @@ -1,5 +1,4 @@ import axios from 'axios'; -import config from '../../../src/config'; import { handleDisputeResult } from '../../../src/webhooks/handleDisputeResult'; import handleLifetimeRefunded from '../../../src/webhooks/handleLifetimeRefunded'; import { getCharge, getDispute, getInvoice, getLogger, getUser, voidPromise } from '../fixtures'; @@ -19,10 +18,18 @@ const mockStripe = { }, }; -const { stripe, paymentService, usersRepository, cacheService, usersService, storageService, tiersService } = - createTestServices({ - stripe: mockStripe, - }); +const { + stripe, + paymentService, + usersRepository, + cacheService, + usersService, + storageService, + tiersService, + preCreatedUserService, +} = createTestServices({ + stripe: mockStripe, +}); const logger = getLogger(); describe('handleDisputeResult()', () => { @@ -49,7 +56,7 @@ describe('handleDisputeResult()', () => { await handleDisputeResult({ dispute: mockedDispute, cacheService: cacheService, - config, + preCreatedUserService, paymentService, usersService, stripe, @@ -89,7 +96,7 @@ describe('handleDisputeResult()', () => { await handleDisputeResult({ dispute: mockedDispute, cacheService, - config, + preCreatedUserService, paymentService, usersService, stripe, @@ -109,7 +116,7 @@ describe('handleDisputeResult()', () => { paymentService, logger, tiersService, - config, + preCreatedUserService, ); }); }); @@ -140,7 +147,7 @@ describe('handleDisputeResult()', () => { await handleDisputeResult({ dispute: mockedDispute, cacheService, - config, + preCreatedUserService, paymentService, usersService, stripe, diff --git a/tests/src/webhooks/handleLifetimeRefunded.test.ts b/tests/src/webhooks/handleLifetimeRefunded.test.ts index 30e5bf24..a2270ce7 100644 --- a/tests/src/webhooks/handleLifetimeRefunded.test.ts +++ b/tests/src/webhooks/handleLifetimeRefunded.test.ts @@ -1,7 +1,6 @@ import Stripe from 'stripe'; import { TierNotFoundError } from '../../../src/services/tiers.service'; import { getCharge, getInvoice, getLogger, getUser, newTier } from '../fixtures'; -import config from '../../../src/config'; import { handleCancelPlan } from '../../../src/webhooks/utils/handleCancelPlan'; import handleLifetimeRefunded from '../../../src/webhooks/handleLifetimeRefunded'; import { FREE_PLAN_BYTES_SPACE } from '../../../src/constants'; @@ -10,7 +9,8 @@ import { createTestServices } from '../helpers/services-factory'; jest.mock('../../../src/webhooks/utils/handleCancelPlan'); const logger = getLogger(); -const { paymentService, usersService, storageService, tiersService, cacheService } = createTestServices(); +const { paymentService, usersService, storageService, tiersService, cacheService, preCreatedUserService } = + createTestServices(); beforeEach(() => { jest.clearAllMocks(); @@ -39,7 +39,7 @@ describe('Process when a lifetime is refunded', () => { paymentService, logger, tiersService, - config, + preCreatedUserService, ); expect(findUserByCustomerIdSpy).toHaveBeenCalledWith(mockedCharge.customer); @@ -52,6 +52,7 @@ describe('Process when a lifetime is refunded', () => { usersService: usersService, tiersService: tiersService, log: logger, + preCreatedUserService, }); }); @@ -86,7 +87,7 @@ describe('Process when a lifetime is refunded', () => { paymentService, logger, tiersService, - config, + preCreatedUserService, ); expect(findUserByCustomerIdSpy).toHaveBeenCalledWith(mockedCharge.customer); @@ -119,7 +120,7 @@ describe('Process when a lifetime is refunded', () => { paymentService, logger, tiersService, - config, + preCreatedUserService, ), ).rejects.toThrow(randomError); }); diff --git a/tests/src/webhooks/handleSubscriptionCanceled.test.ts b/tests/src/webhooks/handleSubscriptionCanceled.test.ts index 0cc16688..4042ad67 100644 --- a/tests/src/webhooks/handleSubscriptionCanceled.test.ts +++ b/tests/src/webhooks/handleSubscriptionCanceled.test.ts @@ -1,7 +1,6 @@ import { TierNotFoundError } from '../../../src/services/tiers.service'; import { FastifyBaseLogger } from 'fastify'; import { getCreatedSubscription, getCustomer, getLogger, getProduct, getUser, newTier } from '../fixtures'; -import config from '../../../src/config'; import handleSubscriptionCanceled from '../../../src/webhooks/handleSubscriptionCanceled'; import { handleCancelPlan } from '../../../src/webhooks/utils/handleCancelPlan'; import { FREE_PLAN_BYTES_SPACE } from '../../../src/constants'; @@ -13,8 +12,15 @@ jest.mock('../../../src/webhooks/utils/handleCancelPlan'); const logger: jest.Mocked = getLogger(); -const { paymentService, usersService, storageService, cacheService, objectStorageService, tiersService } = - createTestServices(); +const { + paymentService, + usersService, + storageService, + cacheService, + objectStorageService, + tiersService, + preCreatedUserService, +} = createTestServices(); beforeEach(() => { jest.clearAllMocks(); @@ -42,7 +48,7 @@ describe('Process when a subscription is cancelled', () => { objectStorageService, tiersService, logger, - config, + preCreatedUserService, ); expect(getProductSpy).toHaveBeenCalledWith(mockedSubscription.items.data[0].price.product); @@ -55,6 +61,7 @@ describe('Process when a subscription is cancelled', () => { usersService, tiersService, log: logger, + preCreatedUserService, }); }); @@ -91,7 +98,7 @@ describe('Process when a subscription is cancelled', () => { objectStorageService, tiersService, logger, - config, + preCreatedUserService, ); expect(getProductSpy).toHaveBeenCalledWith(mockedSubscription.items.data[0].price.product); @@ -127,7 +134,7 @@ describe('Process when a subscription is cancelled', () => { objectStorageService, tiersService, logger, - config, + preCreatedUserService, ), ).rejects.toThrow(randomError); }); diff --git a/tests/src/webhooks/utils/handleCancelPlan.test.ts b/tests/src/webhooks/utils/handleCancelPlan.test.ts index 642dbefe..50b787c6 100644 --- a/tests/src/webhooks/utils/handleCancelPlan.test.ts +++ b/tests/src/webhooks/utils/handleCancelPlan.test.ts @@ -2,12 +2,17 @@ import { TierNotFoundError } from '../../../../src/services/tiers.service'; import { getCustomer, getLogger, getUser, newTier, voidPromise } from '../../fixtures'; import { handleCancelPlan } from '../../../../src/webhooks/utils/handleCancelPlan'; import { createTestServices } from '../../helpers/services-factory'; +import { getPreCreatedUserEntity } from '../../entity.fixtures'; +import { PreCreatedUserStatus } from '../../../../src/infrastructure/domain/entities/preCreatedUser'; +import { PreCreatedUserNotFoundError } from '../../../../src/errors/PreCreatedUsersErrors'; -const { usersService, tiersService } = createTestServices(); +const { usersService, tiersService, preCreatedUserService } = createTestServices(); beforeEach(() => { jest.resetAllMocks(); jest.clearAllMocks(); + jest.spyOn(preCreatedUserService, 'get').mockResolvedValue(getPreCreatedUserEntity()); + jest.spyOn(preCreatedUserService, 'update').mockImplementation(voidPromise); }); describe('Handling canceled plans and refunded lifetimes', () => { @@ -35,6 +40,7 @@ describe('Handling canceled plans and refunded lifetimes', () => { tiersService, usersService, log, + preCreatedUserService, }), ).rejects.toThrow(TierNotFoundError); @@ -68,6 +74,7 @@ describe('Handling canceled plans and refunded lifetimes', () => { tiersService, usersService, log, + preCreatedUserService, }); expect(updateUserSpy).toHaveBeenCalledWith(mockedCustomer.id, { lifetime: false }); @@ -105,6 +112,7 @@ describe('Handling canceled plans and refunded lifetimes', () => { tiersService, usersService, log, + preCreatedUserService, }); expect(updateUserSpy).toHaveBeenCalledWith(mockedCustomer.id, { lifetime: false }); @@ -117,4 +125,99 @@ describe('Handling canceled plans and refunded lifetimes', () => { expect(userTiersSpy).toHaveBeenCalledWith(mockedUser.id); expect(deleteTierFromUserSpy).toHaveBeenCalledWith(mockedUser.id, mockedTier.id); }); + + describe('Cancelling the pre-created user', () => { + const setUpCancellation = () => { + const mockedCustomer = getCustomer(); + const log = getLogger(); + const mockedUser = getUser({ customerId: mockedCustomer.id, lifetime: false }); + const mockedTier = newTier(); + + jest.spyOn(usersService, 'findUserByCustomerID').mockResolvedValue(mockedUser); + jest.spyOn(tiersService, 'getTierProductsByProductsId').mockResolvedValue(mockedTier); + jest.spyOn(usersService, 'updateUser').mockImplementation(voidPromise); + jest.spyOn(tiersService, 'removeTier').mockImplementation(voidPromise); + jest.spyOn(tiersService, 'getTiersProductsByUserId').mockResolvedValue([mockedTier]); + jest.spyOn(tiersService, 'deleteTierFromUser').mockImplementation(voidPromise); + + return { + log, + params: { + customerEmail: mockedCustomer.email as string, + customerId: mockedCustomer.id, + productId: mockedTier.productId, + tiersService, + usersService, + log, + preCreatedUserService, + }, + }; + }; + + it.each([PreCreatedUserStatus.AwaitingPayment, PreCreatedUserStatus.PendingSetup])( + 'When the pre-created user is %s, then it is marked as cancelled', + async (status) => { + const { params } = setUpCancellation(); + const mockedPreCreatedUser = getPreCreatedUserEntity({ status }); + + jest.spyOn(preCreatedUserService, 'get').mockResolvedValue(mockedPreCreatedUser); + const updateSpy = jest.spyOn(preCreatedUserService, 'update').mockImplementation(voidPromise); + + await handleCancelPlan(params); + + expect(updateSpy).toHaveBeenCalledWith({ + uuid: mockedPreCreatedUser.uuid, + status: PreCreatedUserStatus.Cancelled, + }); + }, + ); + + it('When the cancellation is notified twice, then the pre-created user ends up cancelled the same way both times', async () => { + const { params } = setUpCancellation(); + const mockedPreCreatedUser = getPreCreatedUserEntity({ status: PreCreatedUserStatus.PendingSetup }); + + jest.spyOn(preCreatedUserService, 'get').mockResolvedValueOnce(mockedPreCreatedUser); + jest + .spyOn(preCreatedUserService, 'get') + .mockResolvedValueOnce( + getPreCreatedUserEntity({ ...mockedPreCreatedUser, status: PreCreatedUserStatus.Cancelled }), + ); + const updateSpy = jest.spyOn(preCreatedUserService, 'update').mockImplementation(voidPromise); + + await handleCancelPlan(params); + await handleCancelPlan(params); + + expect(updateSpy).toHaveBeenNthCalledWith(1, { + uuid: mockedPreCreatedUser.uuid, + status: PreCreatedUserStatus.Cancelled, + }); + expect(updateSpy).toHaveBeenNthCalledWith(2, { + uuid: mockedPreCreatedUser.uuid, + status: PreCreatedUserStatus.Cancelled, + }); + }); + + it('When there is no pre-created user for that email, then nothing is updated and the handler completes', async () => { + const { params } = setUpCancellation(); + + jest.spyOn(preCreatedUserService, 'get').mockRejectedValue(new PreCreatedUserNotFoundError()); + const updateSpy = jest.spyOn(preCreatedUserService, 'update').mockImplementation(voidPromise); + + await expect(handleCancelPlan(params)).resolves.toBeUndefined(); + + expect(updateSpy).not.toHaveBeenCalled(); + }); + + it('When looking up the pre-created user fails for any other reason, then the error propagates and nothing is updated', async () => { + const { params } = setUpCancellation(); + const unexpectedError = new Error('Gateway is down'); + + jest.spyOn(preCreatedUserService, 'get').mockRejectedValue(unexpectedError); + const updateSpy = jest.spyOn(preCreatedUserService, 'update').mockImplementation(voidPromise); + + await expect(handleCancelPlan(params)).rejects.toThrow(unexpectedError); + + expect(updateSpy).not.toHaveBeenCalled(); + }); + }); });