From b23501e747e219074ea51b38007f0c7d37689197 Mon Sep 17 00:00:00 2001 From: Xavier Abad <77491413+xabg2@users.noreply.github.com> Date: Thu, 1 Oct 2026 23:47:37 +0200 Subject: [PATCH 01/12] feat: pre create logic iin checkout flow --- src/controller/checkout.controller.ts | 67 ++++++++++++++++--- src/errors/PreCreatedUsersErrors.ts | 19 ++++++ src/services/preCreatedUser.service.ts | 33 +++++++++ .../controller/checkout.controller.test.ts | 6 +- .../services/preCreatedUser.service.test.ts | 3 +- 5 files changed, 117 insertions(+), 11 deletions(-) create mode 100644 src/errors/PreCreatedUsersErrors.ts diff --git a/src/controller/checkout.controller.ts b/src/controller/checkout.controller.ts index 4312d786..ee577a79 100644 --- a/src/controller/checkout.controller.ts +++ b/src/controller/checkout.controller.ts @@ -15,6 +15,7 @@ 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'; interface CheckoutControllerPayload { usersService: UsersService; @@ -32,6 +33,7 @@ export function checkoutController({ fastify.post<{ Body: { + email?: string; customerName?: string; lineAddress1?: string; lineAddress2?: string; @@ -50,6 +52,7 @@ export function checkoutController({ type: 'object', required: ['country', 'captchaToken'], properties: { + email: { type: 'string' }, customerName: { type: 'string' }, lineAddress1: { type: 'string' }, lineAddress2: { type: 'string' }, @@ -66,6 +69,7 @@ export function checkoutController({ }, }, config: { + allowAnonymous: true, rateLimit: { max: 5, timeWindow: '1 hour', @@ -74,7 +78,10 @@ export function checkoutController({ }, async (req, res): Promise<{ customerId: string; token: string }> => { let customerId: Stripe.Customer['id']; + let userUuid: string; + const { + email, customerName, lineAddress1, lineAddress2, @@ -85,7 +92,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 +100,32 @@ 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'); + } + + const preCreatedUser = await preCreatedUserService.getOrCreate(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 +139,7 @@ export function checkoutController({ } else { const { id } = await stripePaymentsAdapter.createCustomer({ name: customerName, - email, + email: customerEmail, address: { line1: lineAddress1, line2: lineAddress2, @@ -177,6 +204,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 +296,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; + let userUuid = req.user?.payload?.uuid; const { customerId, priceId, token, currency, userAddress, captchaToken, promoCodeId } = req.body; const verifiedCaptcha = await verifyRecaptcha(captchaToken); @@ -299,13 +334,23 @@ 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 preCreatedUser = await preCreatedUserService.getEligibleForPayment(customer.email); + userUuid = preCreatedUser.uuid; + } + + const { canExpand: isStorageUpgradeAllowed } = await fetchUserStorage( + userUuid, + customer.email, + price.bytes.toString(), + ); if (!isStorageUpgradeAllowed) { throw new BadRequestError('The user already has the maximum storage allowed'); @@ -316,7 +361,7 @@ export function checkoutController({ const result = await paymentsService.createInvoice({ customerId, priceId, - userEmail: email, + userEmail: customer.email, currency: currency.trim(), promoCodeId, userAddress, @@ -368,6 +413,10 @@ export function checkoutController({ }, config: { allowAnonymous: true, + rateLimit: { + max: 20, + timeWindow: '15 minute', + }, }, }, async (req, res) => { diff --git a/src/errors/PreCreatedUsersErrors.ts b/src/errors/PreCreatedUsersErrors.ts new file mode 100644 index 00000000..7a068d06 --- /dev/null +++ b/src/errors/PreCreatedUsersErrors.ts @@ -0,0 +1,19 @@ +import { BadRequestError, NotFoundError } from './Errors'; + +export class PreCreatedUserPendingSetupError extends BadRequestError { + constructor(message = 'User needs to setup his account') { + super(message); + 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); + } +} diff --git a/src/services/preCreatedUser.service.ts b/src/services/preCreatedUser.service.ts index 57b55b9d..289aaf99 100644 --- a/src/services/preCreatedUser.service.ts +++ b/src/services/preCreatedUser.service.ts @@ -2,6 +2,7 @@ import { AxiosInstance, AxiosRequestConfig } from 'axios'; import { signGatewayToken } from '../utils/signGatewayToken'; import { AppConfig } from '../config'; import { PreCreatedUser, PreCreatedUserStatus } from '../infrastructure/domain/entities/preCreatedUser'; +import { PreCreatedUserNotFoundError, PreCreatedUserPendingSetupError } from '../errors/PreCreatedUsersErrors'; export class PreCreatedUserService { constructor( @@ -60,6 +61,38 @@ export class PreCreatedUserService { ); } + async getOrCreate(email: string) { + const existsPreCreatedUser = await this.get(email).catch((error) => { + if (error.response.data.code === 'USER_NOT_FOUND') { + return null; + } + + throw error; + }); + + if (!existsPreCreatedUser) { + return await this.create(email); + } + + return existsPreCreatedUser; + } + + async getEligibleForPayment(email: string) { + const preCreatedUser = await this.get(email).catch((error) => { + if (error.response.data.code === 'USER_NOT_FOUND') { + throw new PreCreatedUserNotFoundError(); + } + + throw error; + }); + + if (preCreatedUser.isPendingStatus) { + throw new PreCreatedUserPendingSetupError(); + } + + return preCreatedUser; + } + private get gatewaySecret(): string { return this.config.DRIVE_NEW_GATEWAY_SECRET; } diff --git a/tests/src/controller/checkout.controller.test.ts b/tests/src/controller/checkout.controller.test.ts index 643920fb..66c8cb25 100644 --- a/tests/src/controller/checkout.controller.test.ts +++ b/tests/src/controller/checkout.controller.test.ts @@ -23,6 +23,7 @@ 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 { UserNotFoundError } from '../../../src/errors/PaymentErrors'; import { Customer } from '../../../src/infrastructure/domain/entities/customer'; import { UserType } from '../../../src/core/users/User'; import { getPriceEntity } from '../entity.fixtures'; @@ -134,7 +135,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)); @@ -490,6 +491,9 @@ 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 () => { diff --git a/tests/src/services/preCreatedUser.service.test.ts b/tests/src/services/preCreatedUser.service.test.ts index e929f9ef..af25c7df 100644 --- a/tests/src/services/preCreatedUser.service.test.ts +++ b/tests/src/services/preCreatedUser.service.test.ts @@ -1,7 +1,8 @@ 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'; jest.mock('jsonwebtoken', () => ({ sign: jest.fn().mockReturnValue('mocked-jwt-token'), From 10a459bf66b4dd5d7f00c0021ff324b5527680c1 Mon Sep 17 00:00:00 2001 From: Xavier Abad <77491413+xabg2@users.noreply.github.com> Date: Fri, 2 Oct 2026 12:38:06 +0200 Subject: [PATCH 02/12] feat: implement pre-created logict to the existing EPs --- src/errors/httpErrorCodes.ts | 7 ++ src/infrastructure/http/HttpResponseError.ts | 37 ++++++++++ src/infrastructure/http/httpClient.ts | 26 +++++++ src/server.ts | 3 +- src/services/preCreatedUser.service.ts | 63 ++++++++++------ .../invoices/InvoiceCompletedHandler.ts | 49 +++++++++++-- src/webhooks/utils/handleCancelPlan.ts | 21 ++++++ tests/src/helpers/services-factory.ts | 6 +- .../services/preCreatedUser.service.test.ts | 71 +++++++++++++++---- 9 files changed, 243 insertions(+), 40 deletions(-) create mode 100644 src/errors/httpErrorCodes.ts create mode 100644 src/infrastructure/http/HttpResponseError.ts create mode 100644 src/infrastructure/http/httpClient.ts 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/http/HttpResponseError.ts b/src/infrastructure/http/HttpResponseError.ts new file mode 100644 index 00000000..9b72f8d4 --- /dev/null +++ b/src/infrastructure/http/HttpResponseError.ts @@ -0,0 +1,37 @@ +import { AxiosError } from 'axios'; + +interface ErrorResponseBody { + code?: string; + message?: string; +} + +/** + * A failed HTTP response with the fields we care about flattened out, so callers read + * `status`, `code` and `message` instead of digging into the axios error shape. + * + * Only responses are normalized: requests that never got one (timeouts, DNS failures, + * connection refused) keep propagating as the original axios error. + */ +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/server.ts b/src/server.ts index a1318843..36c16b4f 100644 --- a/src/server.ts +++ b/src/server.ts @@ -42,6 +42,7 @@ 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'; const start = async (mongoTestClient?: MongoClient): Promise => { const mongoClient = mongoTestClient ?? (await new MongoClient(envVariablesConfig.MONGO_URI).connect()); @@ -97,7 +98,7 @@ 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 fastify = await buildApp({ paymentService, diff --git a/src/services/preCreatedUser.service.ts b/src/services/preCreatedUser.service.ts index 289aaf99..60bc42c5 100644 --- a/src/services/preCreatedUser.service.ts +++ b/src/services/preCreatedUser.service.ts @@ -3,6 +3,8 @@ import { signGatewayToken } from '../utils/signGatewayToken'; import { AppConfig } from '../config'; import { PreCreatedUser, PreCreatedUserStatus } from '../infrastructure/domain/entities/preCreatedUser'; import { PreCreatedUserNotFoundError, PreCreatedUserPendingSetupError } from '../errors/PreCreatedUsersErrors'; +import { HttpResponseError } from '../infrastructure/http/HttpResponseError'; +import { HttpErrorCode } from '../errors/httpErrorCodes'; export class PreCreatedUserService { constructor( @@ -15,11 +17,14 @@ export class PreCreatedUserService { const requestConfig: AxiosRequestConfig = this.basicRequestConfig(token); - const preCreatedUser = await this.axios.post(this.baseUrl, { email }, requestConfig); + const preCreatedUser = await this.axios.post(`${this.baseUrl}/pre-create`, { email }, requestConfig); return PreCreatedUser.toDomain(preCreatedUser.data); } + /** + * @throws {PreCreatedUserNotFoundError} When the gateway has no pre-created user for that email. + */ async get(email: string): Promise { const token = signGatewayToken('5m', this.gatewaySecret); @@ -29,9 +34,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); + return PreCreatedUser.toDomain(preCreatedUser.data); + } catch (error) { + if (error instanceof HttpResponseError && error.hasCode(HttpErrorCode.UserNotFound)) { + throw new PreCreatedUserNotFoundError(); + } + + throw error; + } } async update({ @@ -50,7 +63,7 @@ export class PreCreatedUserService { const requestConfig: AxiosRequestConfig = this.basicRequestConfig(token); await this.axios.patch( - this.baseUrl, + `${this.baseUrl}/pre-create`, { uuid, status, @@ -61,31 +74,39 @@ export class PreCreatedUserService { ); } - async getOrCreate(email: string) { - const existsPreCreatedUser = await this.get(email).catch((error) => { - if (error.response.data.code === 'USER_NOT_FOUND') { - return null; - } - - throw error; - }); + async sendSetupEmail(uuid: string, planName?: string): Promise { + const token = signGatewayToken('5m', this.gatewaySecret); - if (!existsPreCreatedUser) { - return await this.create(email); - } + const requestConfig: AxiosRequestConfig = this.basicRequestConfig(token); - return existsPreCreatedUser; + await this.axios.post( + `${this.baseUrl}/${uuid}/setup-email`, + { + planName, + }, + requestConfig, + ); } - async getEligibleForPayment(email: string) { - const preCreatedUser = await this.get(email).catch((error) => { - if (error.response.data.code === 'USER_NOT_FOUND') { - throw new PreCreatedUserNotFoundError(); + async getOrCreate(email: string): Promise { + const existingPreCreatedUser = await this.get(email).catch((error) => { + if (error instanceof PreCreatedUserNotFoundError) { + return null; } throw error; }); + return existingPreCreatedUser ?? this.create(email); + } + + /** + * @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(); } @@ -102,7 +123,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..13da37e4 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 { PreCreatedUserStatus } from '../../../infrastructure/domain/entities/preCreatedUser'; +import { PreCreatedUserNotFoundError } from '../../../errors/PreCreatedUsersErrors'; 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) { @@ -146,7 +153,6 @@ export class InvoiceCompletedHandler { productId, customer, tier, - totalQuantity: totalQuantity, }); await this.updateOrInsertUserTier({ @@ -167,6 +173,8 @@ export class InvoiceCompletedHandler { isLifetimePlan, }); + await this.sendSetupEmailIfNeeded(email, userUuid, tier?.label); + await this.clearUserRelatedCache(customerId, userUuid); Logger.info(`Invoice ${invoiceId} processed successfully for user ${userUuid}`); @@ -194,6 +202,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 }; + } + } Logger.warn(`Failed to find user by email ${customerEmail} and customer ID ${customerId}. Error: ${error}`); } } @@ -314,14 +338,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; @@ -493,6 +515,25 @@ export class InvoiceCompletedHandler { } } + /** + * Send the email to a pre-created user so he can complete the set up to use the purchased plan + * @param email - The email of the pre-created user + * @param uuid - The uuid of the user we want to send the email + */ + private async sendSetupEmailIfNeeded(email: string, uuid: string, planName?: string): Promise { + try { + const preCreatedUser = await this.preCreatedUserService.get(email); + + if (preCreatedUser.status === PreCreatedUserStatus.PendingSetup) return; + + await this.preCreatedUserService.sendSetupEmail(uuid, planName); + } catch (err) { + const error = err as Error; + Logger.error(`Error while sending setup email for user ${email}. Error: ${error.message}`); + throw error; + } + } + /** * Clears the cache for a user, both the subscription and the used promo codes. * @param customerId The Stripe customer Id diff --git a/src/webhooks/utils/handleCancelPlan.ts b/src/webhooks/utils/handleCancelPlan.ts index de637bc3..db0b7091 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,22 @@ export const handleCancelPlan = async ({ await tiersService.deleteTierFromUser(userId, tierToRemove.id); + try { + const preCreatedUser = await preCreatedUserService.getEligibleForPayment(customerEmail); + + await preCreatedUserService.update({ + uuid: preCreatedUser.uuid, + status: PreCreatedUserStatus.Cancelled, + }); + } catch (error) { + if (error instanceof PreCreatedUserNotFoundError) { + log.info(`[CANCEL PLAN HANDLER]: The preCreatedUser with email ${customerEmail} does not exist. Continuing...`); + return; + } + + throw error; + } + 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/helpers/services-factory.ts b/tests/src/helpers/services-factory.ts index 2eb17c18..d3c20ad0 100644 --- a/tests/src/helpers/services-factory.ts +++ b/tests/src/helpers/services-factory.ts @@ -27,6 +27,9 @@ 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 const gatewayHttpClient = createHttpClient(); export interface TestServices { stripe: Stripe; @@ -109,6 +112,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(gatewayHttpClient, config); const invoiceCompletedHandler = new InvoiceCompletedHandler({ determineLifetimeConditions, objectStorageWebhookHandler, @@ -117,6 +121,7 @@ export const createTestServices = (overrides: TestServiceOverrides = {}): TestSe tiersService, usersService, cacheService, + preCreatedUserService, }); const userFeaturesOverridesService = new UserFeaturesOverridesService( usersService, @@ -124,7 +129,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/services/preCreatedUser.service.test.ts b/tests/src/services/preCreatedUser.service.test.ts index af25c7df..131ab382 100644 --- a/tests/src/services/preCreatedUser.service.test.ts +++ b/tests/src/services/preCreatedUser.service.test.ts @@ -1,17 +1,25 @@ -import axios from 'axios'; import config from '../../../src/config'; import { PreCreatedUserService } from '../../../src/services/preCreatedUser.service'; 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 { PreCreatedUserNotFoundError } 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(); }); @@ -25,7 +33,7 @@ describe('Pre Created User Service tests', () => { 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); + const postSpy = jest.spyOn(httpClient, 'post').mockResolvedValue(gatewayResponse); const email = 'n2oK7@example.com'; await preCreatedUserService.create(email); @@ -39,7 +47,7 @@ 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); + jest.spyOn(httpClient, 'post').mockResolvedValue(gatewayResponse); const result = await preCreatedUserService.create('n2oK7@example.com'); @@ -48,7 +56,7 @@ 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, 'post').mockRejectedValue(requestError); + jest.spyOn(httpClient, 'post').mockRejectedValue(requestError); await expect(preCreatedUserService.create('n2oK7@example.com')).rejects.toThrow(requestError); }); @@ -56,7 +64,7 @@ describe('Pre Created User Service tests', () => { 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); @@ -72,7 +80,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'); @@ -81,7 +89,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'); @@ -89,17 +97,54 @@ 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', () => { + test('When the user already exists, then it is returned without creating a new one', async () => { + jest.spyOn(httpClient, 'get').mockResolvedValue(gatewayResponse); + const postSpy = jest.spyOn(httpClient, 'post'); + + const result = await preCreatedUserService.getOrCreate('n2oK7@example.com'); + + expect(result).toStrictEqual(PreCreatedUser.toDomain(gatewayResponse.data)); + expect(postSpy).not.toHaveBeenCalled(); + }); + + test('When the user does not exist yet, then it is created', async () => { + jest.spyOn(httpClient, 'get').mockRejectedValue(userNotFoundError); + const postSpy = jest.spyOn(httpClient, 'post').mockResolvedValue(gatewayResponse); + + const result = await preCreatedUserService.getOrCreate('n2oK7@example.com'); + + expect(postSpy).toHaveBeenCalledTimes(1); + expect(result).toStrictEqual(PreCreatedUser.toDomain(gatewayResponse.data)); + }); + + test('When the lookup fails for any other reason, then no user is created and the rejection propagates', async () => { + const requestError = new Error('Network error'); + jest.spyOn(httpClient, 'get').mockRejectedValue(requestError); + const postSpy = jest.spyOn(httpClient, 'post'); + + await expect(preCreatedUserService.getOrCreate('n2oK7@example.com')).rejects.toThrow(requestError); + expect(postSpy).not.toHaveBeenCalled(); + }); + }); + 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, @@ -118,7 +163,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' }); @@ -132,7 +177,7 @@ 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); }); From 539a48e87d2370a36d2983a64be1e678bf8215b1 Mon Sep 17 00:00:00 2001 From: Xavier Abad <77491413+xabg2@users.noreply.github.com> Date: Fri, 2 Oct 2026 12:41:25 +0200 Subject: [PATCH 03/12] fix: remove useless comment --- src/infrastructure/http/HttpResponseError.ts | 7 ------- 1 file changed, 7 deletions(-) diff --git a/src/infrastructure/http/HttpResponseError.ts b/src/infrastructure/http/HttpResponseError.ts index 9b72f8d4..f9f7f70e 100644 --- a/src/infrastructure/http/HttpResponseError.ts +++ b/src/infrastructure/http/HttpResponseError.ts @@ -5,13 +5,6 @@ interface ErrorResponseBody { message?: string; } -/** - * A failed HTTP response with the fields we care about flattened out, so callers read - * `status`, `code` and `message` instead of digging into the axios error shape. - * - * Only responses are normalized: requests that never got one (timeouts, DNS failures, - * connection refused) keep propagating as the original axios error. - */ export class HttpResponseError extends Error { constructor( message: string, From c07f167465f04d29eb3994c77b94051c29b41374 Mon Sep 17 00:00:00 2001 From: Xavier Abad <77491413+xabg2@users.noreply.github.com> Date: Fri, 2 Oct 2026 13:31:32 +0200 Subject: [PATCH 04/12] feat: add confirmation token when there is no user token --- src/app.ts | 17 +- src/constants.ts | 1 + src/controller/checkout.controller.ts | 14 +- src/errors/PaymentErrors.ts | 8 +- src/infrastructure/adapters/stripe.adapter.ts | 16 +- .../domain/entities/confirmationToken.ts | 45 ++++ .../domain/ports/payments.adapter.ts | 2 + src/services/cache.service.ts | 18 ++ src/services/confirmationToken.service.ts | 28 +++ .../controller/checkout.controller.test.ts | 220 +++++++++++++++++- tests/src/fixtures.ts | 22 ++ .../adapters/stripe.adapter.test.ts | 49 +++- .../domain/entities/confirmationToken.test.ts | 69 ++++++ tests/src/services/cache.service.test.ts | 12 + .../confirmationToken.service.test.ts | 76 ++++++ 15 files changed, 586 insertions(+), 11 deletions(-) create mode 100644 src/infrastructure/domain/entities/confirmationToken.ts create mode 100644 src/services/confirmationToken.service.ts create mode 100644 tests/src/infrastructure/domain/entities/confirmationToken.test.ts create mode 100644 tests/src/services/confirmationToken.service.test.ts diff --git a/src/app.ts b/src/app.ts index daad56ed..c8bedffb 100644 --- a/src/app.ts +++ b/src/app.ts @@ -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({ 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 ee577a79..7ddeb1aa 100644 --- a/src/controller/checkout.controller.ts +++ b/src/controller/checkout.controller.ts @@ -16,17 +16,20 @@ 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 }); @@ -34,6 +37,7 @@ export function checkoutController({ fastify.post<{ Body: { email?: string; + confirmationTokenId?: string; customerName?: string; lineAddress1?: string; lineAddress2?: string; @@ -52,7 +56,8 @@ export function checkoutController({ type: 'object', required: ['country', 'captchaToken'], properties: { - email: { type: 'string' }, + email: { type: 'string', format: 'email' }, + confirmationTokenId: { type: 'string', minLength: 1 }, customerName: { type: 'string' }, lineAddress1: { type: 'string' }, lineAddress2: { type: 'string' }, @@ -82,6 +87,7 @@ export function checkoutController({ const { email, + confirmationTokenId, customerName, lineAddress1, lineAddress2, @@ -105,6 +111,12 @@ export function checkoutController({ 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.getOrCreate(email); userUuid = preCreatedUser.uuid; 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/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/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..f8b3ab3c --- /dev/null +++ b/src/services/confirmationToken.service.ts @@ -0,0 +1,28 @@ +import { InvalidConfirmationTokenError } from '../errors/PaymentErrors'; +import { ConfirmationToken } from '../infrastructure/domain/entities/confirmationToken'; +import { PaymentsAdapter } from '../infrastructure/domain/ports/payments.adapter'; +import CacheService from './cache.service'; + +export class ConfirmationTokenService { + constructor( + private readonly paymentsAdapter: PaymentsAdapter, + 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 this.paymentsAdapter.getConfirmationToken(confirmationTokenId); + + if (!confirmationToken.canStartPaymentAttempt()) { + throw new InvalidConfirmationTokenError(); + } + + const isFirstUse = await this.cacheService.markConfirmationTokenAsUsed(confirmationToken.id); + + if (!isFirstUse) { + throw new InvalidConfirmationTokenError(); + } + } +} diff --git a/tests/src/controller/checkout.controller.test.ts b/tests/src/controller/checkout.controller.test.ts index 66c8cb25..1772ff8f 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,13 +23,18 @@ 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 { PreCreatedUserNotFoundError } from '../../../src/errors/PreCreatedUsersErrors'; +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'; jest.mock('../../../src/utils/fetchUserStorage'); +jest.mock('ioredis', () => require('ioredis-mock')); let app: FastifyInstance; @@ -282,6 +288,214 @@ 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 = () => { + jest.spyOn(PreCreatedUserService.prototype, 'get').mockRejectedValue(new PreCreatedUserNotFoundError()); + return jest + .spyOn(PreCreatedUserService.prototype, 'create') + .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 the buyer already has a pre-created user awaiting payment, then it is reused without creating another one', async () => { + const { stripeToken } = stripeKnowsToken(); + jest + .spyOn(PreCreatedUserService.prototype, 'get') + .mockResolvedValue( + new PreCreatedUser({ uuid: preCreatedUserUuid, status: PreCreatedUserStatus.AwaitingPayment }), + ); + const createPreCreatedUserSpy = jest.spyOn(PreCreatedUserService.prototype, 'create'); + 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(createPreCreatedUserSpy).not.toHaveBeenCalled(); + expect(getPreCreatedUserSpy).toHaveBeenCalledTimes(1); + 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 without consuming the confirmation token', async () => { + const { stripeToken } = stripeKnowsToken(); + const pendingEmail = 'pending@internxt.com'; + jest.spyOn(PreCreatedUserService.prototype, 'get').mockImplementation(async (email) => { + if (email === pendingEmail) { + return new PreCreatedUser({ uuid: 'pending-uuid', status: PreCreatedUserStatus.PendingSetup }); + } + throw new PreCreatedUserNotFoundError(); + }); + jest + .spyOn(PreCreatedUserService.prototype, 'create') + .mockResolvedValue( + new PreCreatedUser({ uuid: preCreatedUserUuid, status: PreCreatedUserStatus.AwaitingPayment }), + ); + const createCustomerSpy = buyerHasNoCustomer(); + + const rejectedResponse = await createCustomerAnonymously({ + email: pendingEmail, + confirmationTokenId: stripeToken.id, + }); + const retryResponse = await createCustomerAnonymously({ + email: buyerEmail, + confirmationTokenId: stripeToken.id, + }); + + expect(rejectedResponse.statusCode).toBe(400); + expect(createCustomerSpy).toHaveBeenCalledTimes(1); + expect(retryResponse.statusCode).toBe(200); + }); + + 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(); @@ -491,9 +705,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())); + 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 () => { 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/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/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..8fd02cb4 --- /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 { PaymentsAdapter } from '../../../src/infrastructure/domain/ports/payments.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.Mock; + let confirmationTokenService: ConfirmationTokenService; + + const paymentsKnowsToken = (params?: Parameters[0]) => { + const confirmationToken = ConfirmationToken.toDomain(getConfirmationToken(params)); + getConfirmationTokenFromPayments.mockResolvedValue(confirmationToken); + return confirmationToken; + }; + + beforeEach(() => { + getConfirmationTokenFromPayments = jest.fn(); + const paymentsAdapter = { getConfirmationToken: getConfirmationTokenFromPayments } as unknown as PaymentsAdapter; + confirmationTokenService = new ConfirmationTokenService(paymentsAdapter, 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, + ); + }); +}); From 9f722212fd67096098ec00705aac9c112d6de39a Mon Sep 17 00:00:00 2001 From: Xavier Abad <77491413+xabg2@users.noreply.github.com> Date: Fri, 2 Oct 2026 15:06:32 +0200 Subject: [PATCH 05/12] fix: tests and imports --- src/app.ts | 8 ++-- src/webhooks/handleDisputeResult.ts | 8 ++-- src/webhooks/handleLifetimeRefunded.ts | 5 ++- src/webhooks/handleSubscriptionCanceled.ts | 9 ++-- src/webhooks/index.ts | 41 +++++++++++++------ src/webhooks/providers/bit2me/index.ts | 4 ++ tests/src/entity.fixtures.ts | 8 ++++ tests/src/helpers/services-factory.ts | 7 ++-- tests/src/services/klaviyo.service.test.ts | 6 +-- .../invoices/InvoiceCompletedHandler.test.ts | 15 ++----- .../src/webhooks/handleDisputeResult.test.ts | 25 +++++++---- .../webhooks/handleLifetimeRefunded.test.ts | 11 ++--- .../handleSubscriptionCanceled.test.ts | 19 ++++++--- .../webhooks/utils/handleCancelPlan.test.ts | 8 +++- 14 files changed, 109 insertions(+), 65 deletions(-) diff --git a/src/app.ts b/src/app.ts index daad56ed..7c1b64fc 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'; @@ -92,7 +92,7 @@ export async function buildApp({ fastify.register(healthController(healthService)); fastify.register( - webhook( + webhookHandler({ stripe, storageService, usersService, @@ -101,7 +101,8 @@ export async function buildApp({ cacheService, objectStorageService, tiersService, - ), + preCreatedUserService, + }), ); fastify.register( @@ -113,6 +114,7 @@ export async function buildApp({ cacheService, objectStorageService, tiersService, + preCreatedUserService, }), ); 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/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/helpers/services-factory.ts b/tests/src/helpers/services-factory.ts index d3c20ad0..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'; @@ -29,8 +29,6 @@ import { KlaviyoTrackingService } from '../../../src/services/klaviyo.service'; import { PreCreatedUserService } from '../../../src/services/preCreatedUser.service'; import { createHttpClient } from '../../../src/infrastructure/http/httpClient'; -export const gatewayHttpClient = createHttpClient(); - export interface TestServices { stripe: Stripe; tiersService: TiersService; @@ -65,6 +63,7 @@ export interface TestRepositories { export interface TestServiceOverrides { stripe?: any; + httpClient?: AxiosInstance; } const createRepositories = (): TestRepositories => ({ @@ -112,7 +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(gatewayHttpClient, config); + const preCreatedUserService = new PreCreatedUserService(overrides.httpClient ?? createHttpClient(), config); const invoiceCompletedHandler = new InvoiceCompletedHandler({ determineLifetimeConditions, objectStorageWebhookHandler, 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/webhooks/events/invoices/InvoiceCompletedHandler.test.ts b/tests/src/webhooks/events/invoices/InvoiceCompletedHandler.test.ts index fd140ef5..10fbc0ae 100644 --- a/tests/src/webhooks/events/invoices/InvoiceCompletedHandler.test.ts +++ b/tests/src/webhooks/events/invoices/InvoiceCompletedHandler.test.ts @@ -12,6 +12,7 @@ import { TierNotFoundError, UsersTiersError } from '../../../../../src/services/ 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'; const { invoiceCompletedHandler, @@ -22,11 +23,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 +226,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, @@ -427,7 +430,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 +449,6 @@ describe('Testing the handler when an invoice is completed', () => { customer: Customer.toDomain(mockedCustomer), isLifetimePlan: mockedIsLifetimePlan, productId: mockedProductId, - totalQuantity, tier: mockedTier, }); @@ -477,7 +478,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 +495,6 @@ describe('Testing the handler when an invoice is completed', () => { customer: Customer.toDomain(mockedCustomer), isLifetimePlan: mockedIsLifetimePlan, productId: mockedProductId, - totalQuantity, tier: mockedTier, }); @@ -525,7 +524,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 +539,6 @@ describe('Testing the handler when an invoice is completed', () => { customer: Customer.toDomain(mockedCustomer), isLifetimePlan: mockedIsLifetimePlan, productId: mockedProductId, - totalQuantity, tier: mockedTier, }); @@ -577,7 +574,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 +588,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 +607,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 +623,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..18928a28 100644 --- a/tests/src/webhooks/utils/handleCancelPlan.test.ts +++ b/tests/src/webhooks/utils/handleCancelPlan.test.ts @@ -2,12 +2,15 @@ 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'; -const { usersService, tiersService } = createTestServices(); +const { usersService, tiersService, preCreatedUserService } = createTestServices(); beforeEach(() => { jest.resetAllMocks(); jest.clearAllMocks(); + jest.spyOn(preCreatedUserService, 'getEligibleForPayment').mockResolvedValue(getPreCreatedUserEntity()); + jest.spyOn(preCreatedUserService, 'update').mockImplementation(voidPromise); }); describe('Handling canceled plans and refunded lifetimes', () => { @@ -35,6 +38,7 @@ describe('Handling canceled plans and refunded lifetimes', () => { tiersService, usersService, log, + preCreatedUserService, }), ).rejects.toThrow(TierNotFoundError); @@ -68,6 +72,7 @@ describe('Handling canceled plans and refunded lifetimes', () => { tiersService, usersService, log, + preCreatedUserService, }); expect(updateUserSpy).toHaveBeenCalledWith(mockedCustomer.id, { lifetime: false }); @@ -105,6 +110,7 @@ describe('Handling canceled plans and refunded lifetimes', () => { tiersService, usersService, log, + preCreatedUserService, }); expect(updateUserSpy).toHaveBeenCalledWith(mockedCustomer.id, { lifetime: false }); From 04114eca628c5c45590aa8fa4e9f56438e3e12fa Mon Sep 17 00:00:00 2001 From: Xavier Abad <77491413+xabg2@users.noreply.github.com> Date: Fri, 2 Oct 2026 17:44:45 +0200 Subject: [PATCH 06/12] tests: add coverage --- .../services/preCreatedUser.service.test.ts | 58 ++++++++- .../invoices/InvoiceCompletedHandler.test.ts | 111 ++++++++++++++++++ .../webhooks/utils/handleCancelPlan.test.ts | 71 +++++++++++ 3 files changed, 239 insertions(+), 1 deletion(-) diff --git a/tests/src/services/preCreatedUser.service.test.ts b/tests/src/services/preCreatedUser.service.test.ts index 131ab382..b0b7e1cd 100644 --- a/tests/src/services/preCreatedUser.service.test.ts +++ b/tests/src/services/preCreatedUser.service.test.ts @@ -5,7 +5,7 @@ import { PreCreatedUserStatus } from '../../../src/infrastructure/domain/entitie import { createHttpClient } from '../../../src/infrastructure/http/httpClient'; import { HttpResponseError } from '../../../src/infrastructure/http/HttpResponseError'; import { HttpErrorCode } from '../../../src/errors/httpErrorCodes'; -import { PreCreatedUserNotFoundError } from '../../../src/errors/PreCreatedUsersErrors'; +import { PreCreatedUserNotFoundError, PreCreatedUserPendingSetupError } from '../../../src/errors/PreCreatedUsersErrors'; jest.mock('jsonwebtoken', () => ({ sign: jest.fn().mockReturnValue('mocked-jwt-token'), @@ -182,4 +182,60 @@ describe('Pre Created User Service tests', () => { 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/webhooks/events/invoices/InvoiceCompletedHandler.test.ts b/tests/src/webhooks/events/invoices/InvoiceCompletedHandler.test.ts index 10fbc0ae..645fbd65 100644 --- a/tests/src/webhooks/events/invoices/InvoiceCompletedHandler.test.ts +++ b/tests/src/webhooks/events/invoices/InvoiceCompletedHandler.test.ts @@ -1,12 +1,15 @@ 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'; @@ -14,6 +17,13 @@ import { getCustomer, getInvoice, getProduct, getUser, newTier, voidPromise } fr 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, paymentService, @@ -310,6 +320,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 }); + 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', 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', () => { @@ -1054,6 +1110,61 @@ describe('Testing the handler when an invoice is completed', () => { }); }); + describe('Sending the setup email to a pre-created user', () => { + test('When the pre-created user is already pending to set up the account, then no email is sent', async () => { + const mockedUser = getUser(); + jest + .spyOn(preCreatedUserService, 'get') + .mockResolvedValue(getPreCreatedUserEntity({ status: PreCreatedUserStatus.PendingSetup })); + const sendSetupEmailSpy = jest.spyOn(preCreatedUserService, 'sendSetupEmail'); + + const sendSetupEmailIfNeeded = invoiceCompletedHandler['sendSetupEmailIfNeeded'].bind(invoiceCompletedHandler); + await sendSetupEmailIfNeeded('test@inxt.com', mockedUser.uuid, 'Premium'); + + expect(sendSetupEmailSpy).not.toHaveBeenCalled(); + }); + + test('When the pre-created user still has not set up the account, then the setup email is sent', async () => { + const mockedUser = getUser(); + jest + .spyOn(preCreatedUserService, 'get') + .mockResolvedValue(getPreCreatedUserEntity({ status: PreCreatedUserStatus.AwaitingPayment })); + const sendSetupEmailSpy = jest.spyOn(preCreatedUserService, 'sendSetupEmail').mockImplementation(voidPromise); + + const sendSetupEmailIfNeeded = invoiceCompletedHandler['sendSetupEmailIfNeeded'].bind(invoiceCompletedHandler); + await sendSetupEmailIfNeeded('test@inxt.com', mockedUser.uuid, 'Premium'); + + expect(sendSetupEmailSpy).toHaveBeenCalledWith(mockedUser.uuid, 'Premium'); + }); + + test('When checking the pre-created user status fails, then an error is logged and the failure propagates', async () => { + const mockedUser = getUser(); + const unexpectedError = new Error('Gateway is down'); + jest.spyOn(preCreatedUserService, 'get').mockRejectedValue(unexpectedError); + const sendSetupEmailSpy = jest.spyOn(preCreatedUserService, 'sendSetupEmail'); + const loggerSpy = jest.spyOn(Logger, 'error'); + + const sendSetupEmailIfNeeded = invoiceCompletedHandler['sendSetupEmailIfNeeded'].bind(invoiceCompletedHandler); + + await expect(sendSetupEmailIfNeeded('test@inxt.com', mockedUser.uuid)).rejects.toThrow(unexpectedError); + expect(sendSetupEmailSpy).not.toHaveBeenCalled(); + }); + + test('When sending the setup email fails, then an error is logged and the failure propagates', async () => { + const mockedUser = getUser(); + const unexpectedError = new Error('Mail service is down'); + jest + .spyOn(preCreatedUserService, 'get') + .mockResolvedValue(getPreCreatedUserEntity({ status: PreCreatedUserStatus.AwaitingPayment })); + jest.spyOn(preCreatedUserService, 'sendSetupEmail').mockRejectedValue(unexpectedError); + const loggerSpy = jest.spyOn(Logger, 'error'); + + const sendSetupEmailIfNeeded = invoiceCompletedHandler['sendSetupEmailIfNeeded'].bind(invoiceCompletedHandler); + + await expect(sendSetupEmailIfNeeded('test@inxt.com', mockedUser.uuid)).rejects.toThrow(unexpectedError); + }); + }); + describe('Cache Clearing', () => { test('When cache clearing succeeds, then it should log success message', async () => { const { customerId, uuid: userUuid } = getUser(); diff --git a/tests/src/webhooks/utils/handleCancelPlan.test.ts b/tests/src/webhooks/utils/handleCancelPlan.test.ts index 18928a28..320017c4 100644 --- a/tests/src/webhooks/utils/handleCancelPlan.test.ts +++ b/tests/src/webhooks/utils/handleCancelPlan.test.ts @@ -3,6 +3,8 @@ import { getCustomer, getLogger, getUser, newTier, voidPromise } from '../../fix 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, preCreatedUserService } = createTestServices(); @@ -123,4 +125,73 @@ 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('When a pre-created user is eligible for payment, then it is marked as cancelled', async () => { + const { params } = setUpCancellation(); + const mockedPreCreatedUser = getPreCreatedUserEntity(); + + jest.spyOn(preCreatedUserService, 'getEligibleForPayment').mockResolvedValue(mockedPreCreatedUser); + const updateSpy = jest.spyOn(preCreatedUserService, 'update').mockImplementation(voidPromise); + + await handleCancelPlan(params); + + expect(updateSpy).toHaveBeenCalledWith({ + 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, 'getEligibleForPayment') + .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, 'getEligibleForPayment').mockRejectedValue(unexpectedError); + const updateSpy = jest.spyOn(preCreatedUserService, 'update').mockImplementation(voidPromise); + + await expect(handleCancelPlan(params)).rejects.toThrow(unexpectedError); + + expect(updateSpy).not.toHaveBeenCalled(); + }); + }); }); From 25c22f790ba09d8d6bf9cea15d4273187921c387 Mon Sep 17 00:00:00 2001 From: Xavier Abad <77491413+xabg2@users.noreply.github.com> Date: Fri, 2 Oct 2026 17:56:19 +0200 Subject: [PATCH 07/12] feat: add log when skipping setup email --- .../events/invoices/InvoiceCompletedHandler.ts | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/src/webhooks/events/invoices/InvoiceCompletedHandler.ts b/src/webhooks/events/invoices/InvoiceCompletedHandler.ts index 13da37e4..c959748e 100644 --- a/src/webhooks/events/invoices/InvoiceCompletedHandler.ts +++ b/src/webhooks/events/invoices/InvoiceCompletedHandler.ts @@ -19,7 +19,7 @@ import { ObjectStorageWebhookHandler } from '../ObjectStorageWebhookHandler'; import { AxiosError } from 'axios'; import { PreCreatedUserService } from '../../../services/preCreatedUser.service'; import { PreCreatedUserStatus } from '../../../infrastructure/domain/entities/preCreatedUser'; -import { PreCreatedUserNotFoundError } from '../../../errors/PreCreatedUsersErrors'; +import { PreCreatedUserNotFoundError, PreCreatedUserPendingSetupError } from '../../../errors/PreCreatedUsersErrors'; interface InvoiceCompletedHandlerAttributes { determineLifetimeConditions: DetermineLifetimeConditions; @@ -522,12 +522,23 @@ export class InvoiceCompletedHandler { */ private async sendSetupEmailIfNeeded(email: string, uuid: string, planName?: string): Promise { try { - const preCreatedUser = await this.preCreatedUserService.get(email); + const preCreatedUser = await this.preCreatedUserService.getEligibleForPayment(email); - if (preCreatedUser.status === PreCreatedUserStatus.PendingSetup) return; + if (preCreatedUser.status === PreCreatedUserStatus.PendingSetup) { + Logger.info( + `[SEND EMAIL SETUP] User with uuid ${preCreatedUser.uuid} is pending setup, skipping sending setup email...`, + ); + return; + } await this.preCreatedUserService.sendSetupEmail(uuid, planName); } catch (err) { + if (err instanceof PreCreatedUserNotFoundError || err instanceof PreCreatedUserPendingSetupError) { + Logger.info( + `[SEND EMAIL SETUP] Pre created user with email ${email} not found, skipping sending setup email...`, + ); + return; + } const error = err as Error; Logger.error(`Error while sending setup email for user ${email}. Error: ${error.message}`); throw error; From 848ec980111281e4512b0fa3ef8a4e8004a174e0 Mon Sep 17 00:00:00 2001 From: Xavier Abad <77491413+xabg2@users.noreply.github.com> Date: Fri, 2 Oct 2026 18:15:19 +0200 Subject: [PATCH 08/12] refactor: improve pre-created users --- src/server.ts | 3 +++ src/services/confirmationToken.service.ts | 9 +++---- src/services/preCreatedUser.service.ts | 6 ++++- .../controller/checkout.controller.test.ts | 27 +++++++++++++++++-- .../confirmationToken.service.test.ts | 10 +++---- 5 files changed, 41 insertions(+), 14 deletions(-) diff --git a/src/server.ts b/src/server.ts index 36c16b4f..b59f452b 100644 --- a/src/server.ts +++ b/src/server.ts @@ -43,6 +43,7 @@ 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()); @@ -99,6 +100,7 @@ const start = async (mongoTestClient?: MongoClient): Promise => const productsService = new ProductsService(tiersService, usersService, userFeaturesOverridesService); const healthService = new HealthService(mongoClient, cacheService); const preCreatedUserService = new PreCreatedUserService(createHttpClient(), envVariablesConfig); + const confirmationTokenService = new ConfirmationTokenService(cacheService); const fastify = await buildApp({ paymentService, @@ -114,6 +116,7 @@ const start = async (mongoTestClient?: MongoClient): Promise => config: envVariablesConfig, healthService, preCreatedUserService, + confirmationTokenService, }); fastify.addHook('onClose', async () => { diff --git a/src/services/confirmationToken.service.ts b/src/services/confirmationToken.service.ts index f8b3ab3c..bb709d42 100644 --- a/src/services/confirmationToken.service.ts +++ b/src/services/confirmationToken.service.ts @@ -1,19 +1,16 @@ import { InvalidConfirmationTokenError } from '../errors/PaymentErrors'; +import { stripePaymentsAdapter } from '../infrastructure/adapters/stripe.adapter'; import { ConfirmationToken } from '../infrastructure/domain/entities/confirmationToken'; -import { PaymentsAdapter } from '../infrastructure/domain/ports/payments.adapter'; import CacheService from './cache.service'; export class ConfirmationTokenService { - constructor( - private readonly paymentsAdapter: PaymentsAdapter, - private readonly cacheService: CacheService, - ) {} + 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 this.paymentsAdapter.getConfirmationToken(confirmationTokenId); + const confirmationToken = await stripePaymentsAdapter.getConfirmationToken(confirmationTokenId); if (!confirmationToken.canStartPaymentAttempt()) { throw new InvalidConfirmationTokenError(); diff --git a/src/services/preCreatedUser.service.ts b/src/services/preCreatedUser.service.ts index 60bc42c5..31f05323 100644 --- a/src/services/preCreatedUser.service.ts +++ b/src/services/preCreatedUser.service.ts @@ -88,8 +88,12 @@ export class PreCreatedUserService { ); } + /** + * @throws {PreCreatedUserPendingSetupError} When the user already paid and is pending to set up the account, + * so it must not start another purchase. + */ async getOrCreate(email: string): Promise { - const existingPreCreatedUser = await this.get(email).catch((error) => { + const existingPreCreatedUser = await this.getEligibleForPayment(email).catch((error) => { if (error instanceof PreCreatedUserNotFoundError) { return null; } diff --git a/tests/src/controller/checkout.controller.test.ts b/tests/src/controller/checkout.controller.test.ts index 1772ff8f..6d60ba97 100644 --- a/tests/src/controller/checkout.controller.test.ts +++ b/tests/src/controller/checkout.controller.test.ts @@ -467,14 +467,37 @@ describe('Checkout controller', () => { email: pendingEmail, confirmationTokenId: stripeToken.id, }); + + expect(rejectedResponse.statusCode).toBe(400); + 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'; + driveHasNoPreCreatedUser(); + jest.spyOn(PreCreatedUserService.prototype, 'get').mockImplementation(async (email) => { + if (email === pendingEmail) { + return new PreCreatedUser({ uuid: 'pending-uuid', status: PreCreatedUserStatus.PendingSetup }); + } + throw new PreCreatedUserNotFoundError(); + }); + const createCustomerSpy = buyerHasNoCustomer(); + + const rejectedResponse = await createCustomerAnonymously({ + email: pendingEmail, + confirmationTokenId: firstToken.id, + }); + + const { stripeToken: freshToken } = stripeKnowsToken(); const retryResponse = await createCustomerAnonymously({ email: buyerEmail, - confirmationTokenId: stripeToken.id, + confirmationTokenId: freshToken.id, }); expect(rejectedResponse.statusCode).toBe(400); - expect(createCustomerSpy).toHaveBeenCalledTimes(1); 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 () => { diff --git a/tests/src/services/confirmationToken.service.test.ts b/tests/src/services/confirmationToken.service.test.ts index 8fd02cb4..0caa9126 100644 --- a/tests/src/services/confirmationToken.service.test.ts +++ b/tests/src/services/confirmationToken.service.test.ts @@ -1,7 +1,7 @@ import config from '../../../src/config'; import { InvalidConfirmationTokenError } from '../../../src/errors/PaymentErrors'; import { ConfirmationToken } from '../../../src/infrastructure/domain/entities/confirmationToken'; -import { PaymentsAdapter } from '../../../src/infrastructure/domain/ports/payments.adapter'; +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'; @@ -12,7 +12,7 @@ jest.mock('ioredis', () => require('ioredis-mock')); const nowInSeconds = () => Math.floor(Date.now() / 1000); describe('Confirmation token service', () => { - let getConfirmationTokenFromPayments: jest.Mock; + let getConfirmationTokenFromPayments: jest.SpyInstance; let confirmationTokenService: ConfirmationTokenService; const paymentsKnowsToken = (params?: Parameters[0]) => { @@ -22,9 +22,9 @@ describe('Confirmation token service', () => { }; beforeEach(() => { - getConfirmationTokenFromPayments = jest.fn(); - const paymentsAdapter = { getConfirmationToken: getConfirmationTokenFromPayments } as unknown as PaymentsAdapter; - confirmationTokenService = new ConfirmationTokenService(paymentsAdapter, new CacheService(config)); + 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 () => { From 109b273416dec26cf00149757380bb24b80ba035 Mon Sep 17 00:00:00 2001 From: Xavier Abad <77491413+xabg2@users.noreply.github.com> Date: Mon, 5 Oct 2026 20:59:11 +0200 Subject: [PATCH 09/12] fix: mark pre-created user as cancelled correctly --- src/webhooks/utils/handleCancelPlan.ts | 19 ++++---- .../webhooks/utils/handleCancelPlan.test.ts | 44 +++++++++++++++---- 2 files changed, 45 insertions(+), 18 deletions(-) diff --git a/src/webhooks/utils/handleCancelPlan.ts b/src/webhooks/utils/handleCancelPlan.ts index db0b7091..454e8f04 100644 --- a/src/webhooks/utils/handleCancelPlan.ts +++ b/src/webhooks/utils/handleCancelPlan.ts @@ -54,20 +54,21 @@ export const handleCancelPlan = async ({ await tiersService.deleteTierFromUser(userId, tierToRemove.id); - try { - const preCreatedUser = await preCreatedUserService.getEligibleForPayment(customerEmail); + 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, }); - } catch (error) { - if (error instanceof PreCreatedUserNotFoundError) { - log.info(`[CANCEL PLAN HANDLER]: The preCreatedUser with email ${customerEmail} does not exist. Continuing...`); - return; - } - - throw error; + } else { + log.info(`[CANCEL PLAN HANDLER]: The preCreatedUser with email ${customerEmail} does not exist. Continuing...`); } log.info( diff --git a/tests/src/webhooks/utils/handleCancelPlan.test.ts b/tests/src/webhooks/utils/handleCancelPlan.test.ts index 320017c4..50b787c6 100644 --- a/tests/src/webhooks/utils/handleCancelPlan.test.ts +++ b/tests/src/webhooks/utils/handleCancelPlan.test.ts @@ -11,7 +11,7 @@ const { usersService, tiersService, preCreatedUserService } = createTestServices beforeEach(() => { jest.resetAllMocks(); jest.clearAllMocks(); - jest.spyOn(preCreatedUserService, 'getEligibleForPayment').mockResolvedValue(getPreCreatedUserEntity()); + jest.spyOn(preCreatedUserService, 'get').mockResolvedValue(getPreCreatedUserEntity()); jest.spyOn(preCreatedUserService, 'update').mockImplementation(voidPromise); }); @@ -154,16 +154,44 @@ describe('Handling canceled plans and refunded lifetimes', () => { }; }; - it('When a pre-created user is eligible for payment, then it is marked as cancelled', async () => { + 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(); + const mockedPreCreatedUser = getPreCreatedUserEntity({ status: PreCreatedUserStatus.PendingSetup }); - jest.spyOn(preCreatedUserService, 'getEligibleForPayment').mockResolvedValue(mockedPreCreatedUser); + 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).toHaveBeenCalledWith({ + expect(updateSpy).toHaveBeenNthCalledWith(1, { + uuid: mockedPreCreatedUser.uuid, + status: PreCreatedUserStatus.Cancelled, + }); + expect(updateSpy).toHaveBeenNthCalledWith(2, { uuid: mockedPreCreatedUser.uuid, status: PreCreatedUserStatus.Cancelled, }); @@ -172,9 +200,7 @@ describe('Handling canceled plans and refunded lifetimes', () => { 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, 'getEligibleForPayment') - .mockRejectedValue(new PreCreatedUserNotFoundError()); + jest.spyOn(preCreatedUserService, 'get').mockRejectedValue(new PreCreatedUserNotFoundError()); const updateSpy = jest.spyOn(preCreatedUserService, 'update').mockImplementation(voidPromise); await expect(handleCancelPlan(params)).resolves.toBeUndefined(); @@ -186,7 +212,7 @@ describe('Handling canceled plans and refunded lifetimes', () => { const { params } = setUpCancellation(); const unexpectedError = new Error('Gateway is down'); - jest.spyOn(preCreatedUserService, 'getEligibleForPayment').mockRejectedValue(unexpectedError); + jest.spyOn(preCreatedUserService, 'get').mockRejectedValue(unexpectedError); const updateSpy = jest.spyOn(preCreatedUserService, 'update').mockImplementation(voidPromise); await expect(handleCancelPlan(params)).rejects.toThrow(unexpectedError); From 3dc467cd77b993108ff4eb5244e46ef30db27422 Mon Sep 17 00:00:00 2001 From: Xavier Abad <77491413+xabg2@users.noreply.github.com> Date: Mon, 5 Oct 2026 22:47:59 +0200 Subject: [PATCH 10/12] fix: check storage for existing users --- src/controller/checkout.controller.ts | 27 ++++--- src/errors/HttpError.ts | 1 + src/errors/PreCreatedUsersErrors.ts | 13 +++- src/plugins/auth.ts | 4 +- src/plugins/error-handler.ts | 1 + src/services/preCreatedUser.service.ts | 41 +++++++---- .../controller/checkout.controller.test.ts | 66 +++++++++++++++-- tests/src/plugins/error-handler.test.ts | 25 +++++++ .../services/preCreatedUser.service.test.ts | 70 ++++++++++++------- tests/src/utils/error-test-routes.ts | 12 ++++ 10 files changed, 198 insertions(+), 62 deletions(-) diff --git a/src/controller/checkout.controller.ts b/src/controller/checkout.controller.ts index ee577a79..105f0cd2 100644 --- a/src/controller/checkout.controller.ts +++ b/src/controller/checkout.controller.ts @@ -105,7 +105,7 @@ export function checkoutController({ throw new BadRequestError('Email is required when there is no user token'); } - const preCreatedUser = await preCreatedUserService.getOrCreate(email); + const preCreatedUser = await preCreatedUserService.getOrCreateEligibleForPayment(email); userUuid = preCreatedUser.uuid; } else { @@ -305,7 +305,7 @@ export function checkoutController({ }, async (req, res): Promise => { let tokenCustomerId: string; - let userUuid = req.user?.payload?.uuid; + const userUuid = req.user?.payload?.uuid; const { customerId, priceId, token, currency, userAddress, captchaToken, promoCodeId } = req.body; const verifiedCaptcha = await verifyRecaptcha(captchaToken); @@ -341,19 +341,18 @@ export function checkoutController({ throw new BadRequestError('Only lifetime plans are supported'); } - if (!userUuid) { - const preCreatedUser = await preCreatedUserService.getEligibleForPayment(customer.email); - userUuid = preCreatedUser.uuid; - } - - const { canExpand: isStorageUpgradeAllowed } = await fetchUserStorage( - userUuid, - customer.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); 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/PreCreatedUsersErrors.ts b/src/errors/PreCreatedUsersErrors.ts index 7a068d06..77aba01d 100644 --- a/src/errors/PreCreatedUsersErrors.ts +++ b/src/errors/PreCreatedUsersErrors.ts @@ -1,8 +1,9 @@ -import { BadRequestError, NotFoundError } from './Errors'; +import { ConflictError, NotFoundError } from './Errors'; -export class PreCreatedUserPendingSetupError extends BadRequestError { +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); @@ -17,3 +18,11 @@ export class PreCreatedUserNotFoundError extends NotFoundError { 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/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/services/preCreatedUser.service.ts b/src/services/preCreatedUser.service.ts index 60bc42c5..45629377 100644 --- a/src/services/preCreatedUser.service.ts +++ b/src/services/preCreatedUser.service.ts @@ -1,8 +1,12 @@ -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 { PreCreatedUserNotFoundError, PreCreatedUserPendingSetupError } from '../errors/PreCreatedUsersErrors'; +import { + DriveAccountAlreadyExistsError, + PreCreatedUserNotFoundError, + PreCreatedUserPendingSetupError, +} from '../errors/PreCreatedUsersErrors'; import { HttpResponseError } from '../infrastructure/http/HttpResponseError'; import { HttpErrorCode } from '../errors/httpErrorCodes'; @@ -12,14 +16,22 @@ 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}/pre-create`, { 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; + } } /** @@ -88,16 +100,19 @@ export class PreCreatedUserService { ); } - async getOrCreate(email: string): Promise { - const existingPreCreatedUser = await this.get(email).catch((error) => { - if (error instanceof PreCreatedUserNotFoundError) { - return null; - } + /** + * @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); - throw error; - }); + if (preCreatedUser.isPendingStatus) { + throw new PreCreatedUserPendingSetupError(); + } - return existingPreCreatedUser ?? this.create(email); + return preCreatedUser; } /** diff --git a/tests/src/controller/checkout.controller.test.ts b/tests/src/controller/checkout.controller.test.ts index 66c8cb25..07521fb4 100644 --- a/tests/src/controller/checkout.controller.test.ts +++ b/tests/src/controller/checkout.controller.test.ts @@ -26,7 +26,8 @@ import { StripePaymentsAdapter } from '../../../src/infrastructure/adapters/stri import { UserNotFoundError } from '../../../src/errors/PaymentErrors'; 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'; +import { PreCreatedUserService } from '../../../src/services/preCreatedUser.service'; jest.mock('../../../src/utils/fetchUserStorage'); @@ -491,9 +492,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())); + 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 () => { @@ -539,6 +538,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/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/preCreatedUser.service.test.ts b/tests/src/services/preCreatedUser.service.test.ts index b0b7e1cd..c04d8879 100644 --- a/tests/src/services/preCreatedUser.service.test.ts +++ b/tests/src/services/preCreatedUser.service.test.ts @@ -5,7 +5,11 @@ import { PreCreatedUserStatus } from '../../../src/infrastructure/domain/entitie import { createHttpClient } from '../../../src/infrastructure/http/httpClient'; import { HttpResponseError } from '../../../src/infrastructure/http/HttpResponseError'; import { HttpErrorCode } from '../../../src/errors/httpErrorCodes'; -import { PreCreatedUserNotFoundError, PreCreatedUserPendingSetupError } from '../../../src/errors/PreCreatedUsersErrors'; +import { + DriveAccountAlreadyExistsError, + PreCreatedUserNotFoundError, + PreCreatedUserPendingSetupError, +} from '../../../src/errors/PreCreatedUsersErrors'; jest.mock('jsonwebtoken', () => ({ sign: jest.fn().mockReturnValue('mocked-jwt-token'), @@ -31,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 () => { + 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( @@ -46,19 +52,27 @@ describe('Pre Created User Service tests', () => { ); }); - test('When called, then it resolves with the created pre-created user', async () => { + 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(httpClient, 'post').mockRejectedValue(requestError); - await expect(preCreatedUserService.create('n2oK7@example.com')).rejects.toThrow(requestError); + await expect(preCreatedUserService.getOrCreate('n2oK7@example.com')).rejects.toThrow(requestError); }); }); @@ -111,34 +125,36 @@ describe('Pre Created User Service tests', () => { }); }); - describe('Getting or creating a pre-created user', () => { - test('When the user already exists, then it is returned without creating a new one', async () => { - jest.spyOn(httpClient, 'get').mockResolvedValue(gatewayResponse); - const postSpy = jest.spyOn(httpClient, 'post'); + 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.getOrCreate('n2oK7@example.com'); + const result = await preCreatedUserService.getOrCreateEligibleForPayment('n2oK7@example.com'); + expect(postSpy).toHaveBeenCalledTimes(1); + expect(getSpy).not.toHaveBeenCalled(); expect(result).toStrictEqual(PreCreatedUser.toDomain(gatewayResponse.data)); - expect(postSpy).not.toHaveBeenCalled(); }); - test('When the user does not exist yet, then it is created', async () => { - jest.spyOn(httpClient, 'get').mockRejectedValue(userNotFoundError); - const postSpy = jest.spyOn(httpClient, 'post').mockResolvedValue(gatewayResponse); - - const result = await preCreatedUserService.getOrCreate('n2oK7@example.com'); + 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 } }); - expect(postSpy).toHaveBeenCalledTimes(1); - expect(result).toStrictEqual(PreCreatedUser.toDomain(gatewayResponse.data)); + await expect(preCreatedUserService.getOrCreateEligibleForPayment('n2oK7@example.com')).rejects.toThrow( + PreCreatedUserPendingSetupError, + ); }); - test('When the lookup fails for any other reason, then no user is created and the rejection propagates', async () => { - const requestError = new Error('Network error'); - jest.spyOn(httpClient, 'get').mockRejectedValue(requestError); - const postSpy = jest.spyOn(httpClient, 'post'); + 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.getOrCreate('n2oK7@example.com')).rejects.toThrow(requestError); - expect(postSpy).not.toHaveBeenCalled(); + await expect(preCreatedUserService.getOrCreateEligibleForPayment('n2oK7@example.com')).rejects.toThrow( + DriveAccountAlreadyExistsError, + ); }); }); 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'); }); From 7a8d1d456fd3665a408b233274df3a2d36de6d67 Mon Sep 17 00:00:00 2001 From: Xavier Abad <77491413+xabg2@users.noreply.github.com> Date: Mon, 5 Oct 2026 23:24:33 +0200 Subject: [PATCH 11/12] fix: send pre-create email only if needed --- .../invoices/InvoiceCompletedHandler.ts | 43 ++----- .../invoices/InvoiceCompletedHandler.test.ts | 106 ++++++++---------- 2 files changed, 57 insertions(+), 92 deletions(-) diff --git a/src/webhooks/events/invoices/InvoiceCompletedHandler.ts b/src/webhooks/events/invoices/InvoiceCompletedHandler.ts index c959748e..190756a3 100644 --- a/src/webhooks/events/invoices/InvoiceCompletedHandler.ts +++ b/src/webhooks/events/invoices/InvoiceCompletedHandler.ts @@ -18,8 +18,8 @@ import { PriceMetadata } from '../../../types/stripe'; import { ObjectStorageWebhookHandler } from '../ObjectStorageWebhookHandler'; import { AxiosError } from 'axios'; import { PreCreatedUserService } from '../../../services/preCreatedUser.service'; -import { PreCreatedUserStatus } from '../../../infrastructure/domain/entities/preCreatedUser'; -import { PreCreatedUserNotFoundError, PreCreatedUserPendingSetupError } from '../../../errors/PreCreatedUsersErrors'; +import { PreCreatedUserNotFoundError } from '../../../errors/PreCreatedUsersErrors'; +import { PreCreatedUser } from '../../../infrastructure/domain/entities/preCreatedUser'; interface InvoiceCompletedHandlerAttributes { determineLifetimeConditions: DetermineLifetimeConditions; @@ -127,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}`, @@ -173,7 +173,9 @@ export class InvoiceCompletedHandler { isLifetimePlan, }); - await this.sendSetupEmailIfNeeded(email, userUuid, tier?.label); + if (preCreatedUser) { + await this.preCreatedUserService.sendSetupEmail(preCreatedUser.uuid, tier?.label); + } await this.clearUserRelatedCache(customerId, userUuid); @@ -193,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) { @@ -215,7 +218,7 @@ export class InvoiceCompletedHandler { }); if (preCreatedUser) { - return { uuid: preCreatedUser.uuid }; + return { uuid: preCreatedUser.uuid, preCreatedUser }; } } Logger.warn(`Failed to find user by email ${customerEmail} and customer ID ${customerId}. Error: ${error}`); @@ -515,36 +518,6 @@ export class InvoiceCompletedHandler { } } - /** - * Send the email to a pre-created user so he can complete the set up to use the purchased plan - * @param email - The email of the pre-created user - * @param uuid - The uuid of the user we want to send the email - */ - private async sendSetupEmailIfNeeded(email: string, uuid: string, planName?: string): Promise { - try { - const preCreatedUser = await this.preCreatedUserService.getEligibleForPayment(email); - - if (preCreatedUser.status === PreCreatedUserStatus.PendingSetup) { - Logger.info( - `[SEND EMAIL SETUP] User with uuid ${preCreatedUser.uuid} is pending setup, skipping sending setup email...`, - ); - return; - } - - await this.preCreatedUserService.sendSetupEmail(uuid, planName); - } catch (err) { - if (err instanceof PreCreatedUserNotFoundError || err instanceof PreCreatedUserPendingSetupError) { - Logger.info( - `[SEND EMAIL SETUP] Pre created user with email ${email} not found, skipping sending setup email...`, - ); - return; - } - const error = err as Error; - Logger.error(`Error while sending setup email for user ${email}. Error: ${error.message}`); - throw error; - } - } - /** * Clears the cache for a user, both the subscription and the used promo codes. * @param customerId The Stripe customer Id diff --git a/tests/src/webhooks/events/invoices/InvoiceCompletedHandler.test.ts b/tests/src/webhooks/events/invoices/InvoiceCompletedHandler.test.ts index 645fbd65..cd7bd4da 100644 --- a/tests/src/webhooks/events/invoices/InvoiceCompletedHandler.test.ts +++ b/tests/src/webhooks/events/invoices/InvoiceCompletedHandler.test.ts @@ -245,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({ @@ -333,7 +380,7 @@ describe('Testing the handler when an invoice is completed', () => { const getUserUuid = invoiceCompletedHandler['getUserUuid'].bind(invoiceCompletedHandler); const result = await getUserUuid(mockedCustomer.id, mockedCustomer.email as string); - expect(result).toStrictEqual({ uuid: mockedPreCreatedUser.uuid }); + expect(result).toStrictEqual({ uuid: mockedPreCreatedUser.uuid, preCreatedUser: mockedPreCreatedUser }); expect(findByCustomerIdSpy).not.toHaveBeenCalled(); }); @@ -352,7 +399,7 @@ describe('Testing the handler when an invoice is completed', () => { expect(result).toStrictEqual({ uuid: mockedUser.uuid }); }); - test('When looking up the pre-created user fails for any other reason, then the error propagates', async () => { + 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', }); @@ -1110,61 +1157,6 @@ describe('Testing the handler when an invoice is completed', () => { }); }); - describe('Sending the setup email to a pre-created user', () => { - test('When the pre-created user is already pending to set up the account, then no email is sent', async () => { - const mockedUser = getUser(); - jest - .spyOn(preCreatedUserService, 'get') - .mockResolvedValue(getPreCreatedUserEntity({ status: PreCreatedUserStatus.PendingSetup })); - const sendSetupEmailSpy = jest.spyOn(preCreatedUserService, 'sendSetupEmail'); - - const sendSetupEmailIfNeeded = invoiceCompletedHandler['sendSetupEmailIfNeeded'].bind(invoiceCompletedHandler); - await sendSetupEmailIfNeeded('test@inxt.com', mockedUser.uuid, 'Premium'); - - expect(sendSetupEmailSpy).not.toHaveBeenCalled(); - }); - - test('When the pre-created user still has not set up the account, then the setup email is sent', async () => { - const mockedUser = getUser(); - jest - .spyOn(preCreatedUserService, 'get') - .mockResolvedValue(getPreCreatedUserEntity({ status: PreCreatedUserStatus.AwaitingPayment })); - const sendSetupEmailSpy = jest.spyOn(preCreatedUserService, 'sendSetupEmail').mockImplementation(voidPromise); - - const sendSetupEmailIfNeeded = invoiceCompletedHandler['sendSetupEmailIfNeeded'].bind(invoiceCompletedHandler); - await sendSetupEmailIfNeeded('test@inxt.com', mockedUser.uuid, 'Premium'); - - expect(sendSetupEmailSpy).toHaveBeenCalledWith(mockedUser.uuid, 'Premium'); - }); - - test('When checking the pre-created user status fails, then an error is logged and the failure propagates', async () => { - const mockedUser = getUser(); - const unexpectedError = new Error('Gateway is down'); - jest.spyOn(preCreatedUserService, 'get').mockRejectedValue(unexpectedError); - const sendSetupEmailSpy = jest.spyOn(preCreatedUserService, 'sendSetupEmail'); - const loggerSpy = jest.spyOn(Logger, 'error'); - - const sendSetupEmailIfNeeded = invoiceCompletedHandler['sendSetupEmailIfNeeded'].bind(invoiceCompletedHandler); - - await expect(sendSetupEmailIfNeeded('test@inxt.com', mockedUser.uuid)).rejects.toThrow(unexpectedError); - expect(sendSetupEmailSpy).not.toHaveBeenCalled(); - }); - - test('When sending the setup email fails, then an error is logged and the failure propagates', async () => { - const mockedUser = getUser(); - const unexpectedError = new Error('Mail service is down'); - jest - .spyOn(preCreatedUserService, 'get') - .mockResolvedValue(getPreCreatedUserEntity({ status: PreCreatedUserStatus.AwaitingPayment })); - jest.spyOn(preCreatedUserService, 'sendSetupEmail').mockRejectedValue(unexpectedError); - const loggerSpy = jest.spyOn(Logger, 'error'); - - const sendSetupEmailIfNeeded = invoiceCompletedHandler['sendSetupEmailIfNeeded'].bind(invoiceCompletedHandler); - - await expect(sendSetupEmailIfNeeded('test@inxt.com', mockedUser.uuid)).rejects.toThrow(unexpectedError); - }); - }); - describe('Cache Clearing', () => { test('When cache clearing succeeds, then it should log success message', async () => { const { customerId, uuid: userUuid } = getUser(); From e21f9af1ad7f44086dbb777698ca8fafbe88e09f Mon Sep 17 00:00:00 2001 From: Xavier Abad <77491413+xabg2@users.noreply.github.com> Date: Mon, 5 Oct 2026 23:58:09 +0200 Subject: [PATCH 12/12] fix: remove duplicated code --- src/controller/checkout.controller.ts | 6 --- .../controller/checkout.controller.test.ts | 52 ++++++++----------- 2 files changed, 23 insertions(+), 35 deletions(-) diff --git a/src/controller/checkout.controller.ts b/src/controller/checkout.controller.ts index 28b4e19d..e7342c8c 100644 --- a/src/controller/checkout.controller.ts +++ b/src/controller/checkout.controller.ts @@ -117,12 +117,6 @@ export function checkoutController({ await confirmationTokenService.validateAndClaim(confirmationTokenId); - 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; diff --git a/tests/src/controller/checkout.controller.test.ts b/tests/src/controller/checkout.controller.test.ts index fad0eb3f..aa3c2ba2 100644 --- a/tests/src/controller/checkout.controller.test.ts +++ b/tests/src/controller/checkout.controller.test.ts @@ -27,12 +27,10 @@ import { StripePaymentsAdapter, stripePaymentsAdapter } from '../../../src/infra import { UserNotFoundError } from '../../../src/errors/PaymentErrors'; import { PreCreatedUserService } from '../../../src/services/preCreatedUser.service'; import { PreCreatedUser, PreCreatedUserStatus } from '../../../src/infrastructure/domain/entities/preCreatedUser'; -import { PreCreatedUserNotFoundError } from '../../../src/errors/PreCreatedUsersErrors'; 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 { getPreCreatedUserEntity, getPriceEntity } from '../entity.fixtures'; -import { PreCreatedUserService } from '../../../src/services/preCreatedUser.service'; jest.mock('../../../src/utils/fetchUserStorage'); jest.mock('ioredis', () => require('ioredis-mock')); @@ -303,9 +301,8 @@ describe('Checkout controller', () => { }; const driveHasNoPreCreatedUser = () => { - jest.spyOn(PreCreatedUserService.prototype, 'get').mockRejectedValue(new PreCreatedUserNotFoundError()); return jest - .spyOn(PreCreatedUserService.prototype, 'create') + .spyOn(PreCreatedUserService.prototype, 'getOrCreate') .mockResolvedValue( new PreCreatedUser({ uuid: preCreatedUserUuid, status: PreCreatedUserStatus.AwaitingPayment }), ); @@ -346,14 +343,13 @@ describe('Checkout controller', () => { expect(createCustomerSpy).toHaveBeenCalledTimes(1); }); - test('When the buyer already has a pre-created user awaiting payment, then it is reused without creating another one', async () => { + 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(); - jest - .spyOn(PreCreatedUserService.prototype, 'get') + const getOrCreatePreCreatedUserSpy = jest + .spyOn(PreCreatedUserService.prototype, 'getOrCreate') .mockResolvedValue( new PreCreatedUser({ uuid: preCreatedUserUuid, status: PreCreatedUserStatus.AwaitingPayment }), ); - const createPreCreatedUserSpy = jest.spyOn(PreCreatedUserService.prototype, 'create'); const getPreCreatedUserSpy = jest.spyOn(PreCreatedUserService.prototype, 'get'); const findUserSpy = jest .spyOn(UsersService.prototype, 'findUserByUuid') @@ -366,8 +362,9 @@ describe('Checkout controller', () => { const response = await createCustomerAnonymously({ email: buyerEmail, confirmationTokenId: stripeToken.id }); expect(response.statusCode).toBe(200); - expect(createPreCreatedUserSpy).not.toHaveBeenCalled(); - expect(getPreCreatedUserSpy).toHaveBeenCalledTimes(1); + expect(getOrCreatePreCreatedUserSpy).toHaveBeenCalledTimes(1); + expect(getOrCreatePreCreatedUserSpy).toHaveBeenCalledWith(buyerEmail); + expect(getPreCreatedUserSpy).not.toHaveBeenCalled(); expect(findUserSpy).toHaveBeenCalledWith(preCreatedUserUuid); }); @@ -448,19 +445,15 @@ describe('Checkout controller', () => { expect(createCustomerSpy).not.toHaveBeenCalled(); }); - test('When the buyer has a pre-created user pending setup, then the request is rejected without consuming the confirmation token', async () => { + 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, 'get').mockImplementation(async (email) => { - if (email === pendingEmail) { - return new PreCreatedUser({ uuid: 'pending-uuid', status: PreCreatedUserStatus.PendingSetup }); - } - throw new PreCreatedUserNotFoundError(); - }); jest - .spyOn(PreCreatedUserService.prototype, 'create') - .mockResolvedValue( - new PreCreatedUser({ uuid: preCreatedUserUuid, status: PreCreatedUserStatus.AwaitingPayment }), + .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(); @@ -469,20 +462,21 @@ describe('Checkout controller', () => { confirmationTokenId: stripeToken.id, }); - expect(rejectedResponse.statusCode).toBe(400); + 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'; - driveHasNoPreCreatedUser(); - jest.spyOn(PreCreatedUserService.prototype, 'get').mockImplementation(async (email) => { - if (email === pendingEmail) { - return new PreCreatedUser({ uuid: 'pending-uuid', status: PreCreatedUserStatus.PendingSetup }); - } - throw new PreCreatedUserNotFoundError(); - }); + 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({ @@ -496,7 +490,7 @@ describe('Checkout controller', () => { confirmationTokenId: freshToken.id, }); - expect(rejectedResponse.statusCode).toBe(400); + expect(rejectedResponse.statusCode).toBe(409); expect(retryResponse.statusCode).toBe(200); expect(createCustomerSpy).toHaveBeenCalledTimes(1); });