From 8b21278db1813cc003a51d4a3e64e888a83560cd Mon Sep 17 00:00:00 2001 From: Xavier Abad <77491413+xabg2@users.noreply.github.com> Date: Fri, 16 Jan 2026 12:07:02 +0100 Subject: [PATCH 1/5] feat(customer): use update customer stripe adapter --- src/controller/checkout.controller.ts | 27 +++++------- src/controller/payments.controller.ts | 3 +- src/infrastructure/adapters/stripe.adapter.ts | 7 +-- .../domain/entities/customer.ts | 8 ++++ src/services/payment.service.ts | 43 +------------------ src/webhooks/index.ts | 4 +- .../controller/checkout.controller.test.ts | 35 +++++++-------- 7 files changed, 43 insertions(+), 84 deletions(-) diff --git a/src/controller/checkout.controller.ts b/src/controller/checkout.controller.ts index 7502a480..ab46382f 100644 --- a/src/controller/checkout.controller.ts +++ b/src/controller/checkout.controller.ts @@ -70,24 +70,17 @@ export function checkoutController(usersService: UsersService, paymentsService: const userExists = await usersService.findUserByUuid(userUuid).catch(() => null); if (userExists) { - await paymentsService.updateCustomer( - userExists.customerId, - { - customer: { - name: customerName, - }, - }, - { - email, - address: { - line1: lineAddress1, - line2: lineAddress2, - city, - postal_code: postalCode, - country, - }, + await stripePaymentsAdapter.updateCustomer(userExists.customerId, { + name: customerName, + email, + address: { + line1: lineAddress1, + line2: lineAddress2, + city, + postalCode, + country, }, - ); + }); customerId = userExists.customerId; } else { const { id } = await stripePaymentsAdapter.createCustomer({ diff --git a/src/controller/payments.controller.ts b/src/controller/payments.controller.ts index daced634..ba017138 100644 --- a/src/controller/payments.controller.ts +++ b/src/controller/payments.controller.ts @@ -26,6 +26,7 @@ import { ForbiddenError } from '../errors/Errors'; import { VERIFICATION_CHARGE } from '../constants'; import { setupAuth } from '../plugins/auth'; import { PaymentService } from '../services/payment.service'; +import { stripePaymentsAdapter } from '../infrastructure/adapters/stripe.adapter'; const allowedCurrency = ['eur', 'usd']; @@ -337,7 +338,7 @@ export function paymentsController( async (req, rep) => { const user = await assertUser(req, rep, usersService); const { address, phoneNumber } = req.body; - await paymentService.updateCustomerBillingInfo(user.customerId, { + await stripePaymentsAdapter.updateCustomer(user.customerId, { address: { line1: address, }, diff --git a/src/infrastructure/adapters/stripe.adapter.ts b/src/infrastructure/adapters/stripe.adapter.ts index 1de77bbf..5dc13558 100644 --- a/src/infrastructure/adapters/stripe.adapter.ts +++ b/src/infrastructure/adapters/stripe.adapter.ts @@ -2,7 +2,7 @@ import Stripe from 'stripe'; import { UserNotFoundError } from '../../errors/PaymentErrors'; import { PaymentsAdapter } from '../domain/ports/payments.adapter'; -import { Customer, CreateCustomerParams } from '../domain/entities/customer'; +import { Customer, CreateCustomerParams, UpdateCustomerParams } from '../domain/entities/customer'; import envVariablesConfig from '../../config'; export class StripePaymentsAdapter implements PaymentsAdapter { @@ -20,7 +20,7 @@ export class StripePaymentsAdapter implements PaymentsAdapter { return Customer.toDomain(stripeCustomer); } - async updateCustomer(customerId: Customer['id'], params: Partial): Promise { + async updateCustomer(customerId: Customer['id'], params: Partial): Promise { const updatedCustomer = await this.provider.customers.update(customerId, this.toStripeCustomerParams(params)); return Customer.toDomain(updatedCustomer); @@ -49,10 +49,11 @@ export class StripePaymentsAdapter implements PaymentsAdapter { return customers.data.map((customer) => Customer.toDomain(customer)); } - private toStripeCustomerParams(params: Partial): Stripe.CustomerCreateParams { + private toStripeCustomerParams(params: Partial): Stripe.CustomerCreateParams { return { ...(params.name && { name: params.name }), ...(params.email && { email: params.email }), + ...(params.phone && { phone: params.phone }), ...(params.address && { address: { line1: params.address.line1, diff --git a/src/infrastructure/domain/entities/customer.ts b/src/infrastructure/domain/entities/customer.ts index bf994a8b..5d5b870c 100644 --- a/src/infrastructure/domain/entities/customer.ts +++ b/src/infrastructure/domain/entities/customer.ts @@ -16,6 +16,14 @@ export interface CreateCustomerParams { address: Partial; } +export interface UpdateCustomerParams extends Partial { + phone?: string; + tax?: { + id: string; + type: Stripe.TaxIdCreateParams.Type; + }; +} + export class Customer { constructor( public readonly id: string, diff --git a/src/services/payment.service.ts b/src/services/payment.service.ts index 3b579577..7a99f2d4 100644 --- a/src/services/payment.service.ts +++ b/src/services/payment.service.ts @@ -35,7 +35,7 @@ import { SetupIntent, PaymentMethod, CustomerSource, - Customer, + Customer as StripeCustomer, } from '../types/stripe'; import { PaymentIntent, PromotionCode, PriceByIdResponse, Reason } from '../types/payment'; import { @@ -427,15 +427,6 @@ export class PaymentService { }; } - async updateCustomerBillingInfo( - customerId: CustomerId, - payload: Pick, - ): Promise { - const customer = await this.provider.customers.update(customerId, payload); - - return customer; - } - async subscribe( customerId: CustomerId, priceId: PriceId, @@ -725,7 +716,7 @@ export class PaymentService { }); } - async getCustomersByEmail(customerEmail: CustomerEmail): Promise { + async getCustomersByEmail(customerEmail: CustomerEmail): Promise { const res = await this.provider.customers.list({ email: customerEmail as string }); return res.data; @@ -1553,36 +1544,6 @@ export class PaymentService { }); } - async updateCustomer( - customerId: Stripe.Customer['id'], - updatableAttributes: { - customer?: Partial>; - tax?: { - id: string; - type: Stripe.TaxIdCreateParams.Type; - }; - }, - additionalOptions?: Partial, - ): Promise { - if (updatableAttributes.customer && Object.keys(updatableAttributes.customer).length > 0) { - await this.provider.customers.update(customerId, { - name: updatableAttributes.customer.name, - ...additionalOptions, - }); - } - if (updatableAttributes.tax) { - await this.provider.taxIds.create({ - owner: { - customer: customerId, - type: 'customer', - }, - - type: updatableAttributes.tax.type, - value: updatableAttributes.tax.id, - }); - } - } - async getCryptoCurrencies() { const currencies = await this.bit2MeService.getCurrencies(); diff --git a/src/webhooks/index.ts b/src/webhooks/index.ts index 7ac2e5aa..d0d3537a 100644 --- a/src/webhooks/index.ts +++ b/src/webhooks/index.ts @@ -100,13 +100,13 @@ export default function ( const userAddressBillingDetails = paymentMethod.billing_details.address; if (userAddressBillingDetails) { - await stripe.customers.update(eventData.customer as string, { + await stripePaymentsAdapter.updateCustomer(eventData.customer as string, { address: { city: userAddressBillingDetails.city as string, line1: userAddressBillingDetails.line1 as string, line2: userAddressBillingDetails.line2 as string, country: userAddressBillingDetails.country as string, - postal_code: userAddressBillingDetails.postal_code as string, + postalCode: userAddressBillingDetails.postal_code as string, state: userAddressBillingDetails.state as string, }, }); diff --git a/tests/src/controller/checkout.controller.test.ts b/tests/src/controller/checkout.controller.test.ts index 7aa8ef76..c293cf16 100644 --- a/tests/src/controller/checkout.controller.test.ts +++ b/tests/src/controller/checkout.controller.test.ts @@ -76,7 +76,9 @@ describe('Checkout controller', () => { jest.spyOn(verifyRecaptcha, 'verifyRecaptcha').mockResolvedValue(true); jest.spyOn(UsersService.prototype, 'findUserByUuid').mockResolvedValue(mockedUser); - const updateCustomerSpy = jest.spyOn(PaymentService.prototype, 'updateCustomer').mockResolvedValue(); + const updateCustomerSpy = jest + .spyOn(StripePaymentsAdapter.prototype, 'updateCustomer') + .mockResolvedValue({} as any); const response = await app.inject({ path: '/checkout/customer', @@ -93,24 +95,17 @@ describe('Checkout controller', () => { customerId: mockedUser.customerId, token: userToken, }); - expect(updateCustomerSpy).toHaveBeenCalledWith( - mockedUser.customerId, - { - customer: { - name: customerData.customerName, - }, - }, - { - email: userEmail, - address: { - line1: customerData.lineAddress1, - line2: customerData.lineAddress2, - city: customerData.city, - postal_code: customerData.postalCode, - country: customerData.country, - }, + expect(updateCustomerSpy).toHaveBeenCalledWith(mockedUser.customerId, { + name: customerData.customerName, + email: userEmail, + address: { + line1: customerData.lineAddress1, + line2: customerData.lineAddress2, + city: customerData.city, + postalCode: customerData.postalCode, + country: customerData.country, }, - ); + }); }); test('when the user does not exist, then a new customer is created and saved in the database and the customer id and its token are returned', async () => { @@ -192,7 +187,7 @@ describe('Checkout controller', () => { jest.spyOn(verifyRecaptcha, 'verifyRecaptcha').mockResolvedValue(true); jest.spyOn(UsersService.prototype, 'findUserByUuid').mockResolvedValue(mockedUser); - jest.spyOn(PaymentService.prototype, 'updateCustomer').mockResolvedValue(); + jest.spyOn(StripePaymentsAdapter.prototype, 'updateCustomer').mockResolvedValue({} as any); const attachVatIdSpy = jest .spyOn(PaymentService.prototype, 'getVatIdAndAttachTaxIdToCustomer') .mockResolvedValue(); @@ -235,7 +230,7 @@ describe('Checkout controller', () => { jest.spyOn(verifyRecaptcha, 'verifyRecaptcha').mockResolvedValue(true); jest.spyOn(UsersService.prototype, 'findUserByUuid').mockResolvedValue(mockedUser); - jest.spyOn(PaymentService.prototype, 'updateCustomer').mockResolvedValue(); + jest.spyOn(StripePaymentsAdapter.prototype, 'updateCustomer').mockResolvedValue({} as any); const attachVatIdSpy = jest.spyOn(PaymentService.prototype, 'getVatIdAndAttachTaxIdToCustomer'); const response = await app.inject({ From 181e0f16f7fb1ff3ee8aa7808c6c471e866c36f7 Mon Sep 17 00:00:00 2001 From: Xavier Abad <77491413+xabg2@users.noreply.github.com> Date: Fri, 16 Jan 2026 12:52:18 +0100 Subject: [PATCH 2/5] fix: customer address and tests --- src/infrastructure/adapters/stripe.adapter.ts | 30 ++++++------ .../domain/entities/customer.ts | 32 ++++--------- .../domain/entities/paymentMethod.ts | 28 +++++++++++ .../domain/ports/payments.adapter.ts | 2 + src/infrastructure/domain/types.ts | 8 ++++ src/webhooks/index.ts | 17 +++---- .../adapters/stripe.adapter.test.ts | 17 ++++++- .../domain/entities/customer.test.ts | 8 ---- .../domain/entities/paymentMethod.test.ts | 43 +++++++++++++++++ tests/src/webhooks/webhook.test.ts | 46 ++++++++++++++++++- 10 files changed, 178 insertions(+), 53 deletions(-) create mode 100644 src/infrastructure/domain/entities/paymentMethod.ts create mode 100644 src/infrastructure/domain/types.ts create mode 100644 tests/src/infrastructure/domain/entities/paymentMethod.test.ts diff --git a/src/infrastructure/adapters/stripe.adapter.ts b/src/infrastructure/adapters/stripe.adapter.ts index 5dc13558..4120f425 100644 --- a/src/infrastructure/adapters/stripe.adapter.ts +++ b/src/infrastructure/adapters/stripe.adapter.ts @@ -4,6 +4,7 @@ import { UserNotFoundError } from '../../errors/PaymentErrors'; import { PaymentsAdapter } from '../domain/ports/payments.adapter'; import { Customer, CreateCustomerParams, UpdateCustomerParams } from '../domain/entities/customer'; import envVariablesConfig from '../../config'; +import { PaymentMethod } from '../domain/entities/paymentMethod'; export class StripePaymentsAdapter implements PaymentsAdapter { private readonly provider: Stripe = new Stripe(envVariablesConfig.STRIPE_SECRET_KEY, { @@ -49,21 +50,24 @@ export class StripePaymentsAdapter implements PaymentsAdapter { return customers.data.map((customer) => Customer.toDomain(customer)); } + async retrievePaymentMethod(paymentMethodId: PaymentMethod['id']): Promise { + const paymentMethods = await this.provider.paymentMethods.retrieve(paymentMethodId, {}); + return PaymentMethod.toDomain(paymentMethods); + } + private toStripeCustomerParams(params: Partial): Stripe.CustomerCreateParams { return { - ...(params.name && { name: params.name }), - ...(params.email && { email: params.email }), - ...(params.phone && { phone: params.phone }), - ...(params.address && { - address: { - line1: params.address.line1, - line2: params.address.line2, - city: params.address.city, - state: params.address.state, - country: params.address.country, - postal_code: params.address.postalCode, - }, - }), + name: params.name, + email: params.email, + phone: params.phone, + address: { + line1: params.address?.line1 ?? undefined, + line2: params.address?.line2 ?? undefined, + city: params.address?.city ?? undefined, + state: params.address?.state ?? undefined, + country: params.address?.country ?? undefined, + postal_code: params.address?.postalCode ?? undefined, + }, }; } } diff --git a/src/infrastructure/domain/entities/customer.ts b/src/infrastructure/domain/entities/customer.ts index 5d5b870c..7446fcfe 100644 --- a/src/infrastructure/domain/entities/customer.ts +++ b/src/infrastructure/domain/entities/customer.ts @@ -1,19 +1,11 @@ import Stripe from 'stripe'; import { BadRequestError } from '../../../errors/Errors'; - -export interface CustomerAddress { - line1: string; - line2: string; - city: string; - state: string; - country: string; - postalCode: string; -} +import { Address } from '../types'; export interface CreateCustomerParams { name: string; email: string; - address: Partial; + address: Partial
; } export interface UpdateCustomerParams extends Partial { @@ -29,7 +21,7 @@ export class Customer { public readonly id: string, public readonly name: string, public readonly email: string, - public readonly address: CustomerAddress, + public readonly address?: Address, public readonly phone?: string, ) {} @@ -42,21 +34,17 @@ export class Customer { throw new BadRequestError('Customer email is required'); } - if (!stripeCustomer.address) { - throw new BadRequestError('Customer address is required'); - } - return new Customer( stripeCustomer.id, stripeCustomer.name, stripeCustomer.email, { - line1: stripeCustomer.address.line1 ?? '', - line2: stripeCustomer.address.line2 ?? '', - city: stripeCustomer.address.city ?? '', - state: stripeCustomer.address.state ?? '', - country: stripeCustomer.address.country ?? '', - postalCode: stripeCustomer.address.postal_code ?? '', + line1: stripeCustomer.address?.line1, + line2: stripeCustomer.address?.line2, + city: stripeCustomer.address?.city, + state: stripeCustomer.address?.state, + country: stripeCustomer.address?.country, + postalCode: stripeCustomer.address?.postal_code, }, stripeCustomer.phone ?? undefined, ); @@ -70,7 +58,7 @@ export class Customer { return this.email; } - getAddress(): CustomerAddress { + getAddress(): Address | undefined { return this.address; } } diff --git a/src/infrastructure/domain/entities/paymentMethod.ts b/src/infrastructure/domain/entities/paymentMethod.ts new file mode 100644 index 00000000..e6f7236d --- /dev/null +++ b/src/infrastructure/domain/entities/paymentMethod.ts @@ -0,0 +1,28 @@ +import Stripe from 'stripe'; +import { Address } from '../types'; + +export class PaymentMethod { + constructor( + public readonly id: string, + public readonly address?: Address, + ) {} + + static toDomain(stripePaymentMethod: Stripe.PaymentMethod): PaymentMethod { + return new PaymentMethod(stripePaymentMethod.id, { + line1: stripePaymentMethod.billing_details.address?.line1, + line2: stripePaymentMethod.billing_details.address?.line2, + city: stripePaymentMethod.billing_details.address?.city, + state: stripePaymentMethod.billing_details.address?.state, + country: stripePaymentMethod.billing_details.address?.country, + postalCode: stripePaymentMethod.billing_details.address?.postal_code, + }); + } + + getId(): string { + return this.id; + } + + getAddress(): Address | undefined { + return this.address; + } +} diff --git a/src/infrastructure/domain/ports/payments.adapter.ts b/src/infrastructure/domain/ports/payments.adapter.ts index 4a2283ca..f1bd1b06 100644 --- a/src/infrastructure/domain/ports/payments.adapter.ts +++ b/src/infrastructure/domain/ports/payments.adapter.ts @@ -1,8 +1,10 @@ import { Customer, CreateCustomerParams } from '../entities/customer'; +import { PaymentMethod } from '../entities/paymentMethod'; export interface PaymentsAdapter { createCustomer: (params: CreateCustomerParams) => Promise; updateCustomer: (customerId: Customer['id'], params: Partial) => Promise; getCustomer: (customerId: Customer['id']) => Promise; searchCustomer: (email: Customer['email']) => Promise; + retrievePaymentMethod: (paymentMethodId: PaymentMethod['id']) => Promise; } diff --git a/src/infrastructure/domain/types.ts b/src/infrastructure/domain/types.ts new file mode 100644 index 00000000..34686f0e --- /dev/null +++ b/src/infrastructure/domain/types.ts @@ -0,0 +1,8 @@ +export interface Address { + line1?: string | null; + line2?: string | null; + city?: string | null; + state?: string | null; + country?: string | null; + postalCode?: string | null; +} diff --git a/src/webhooks/index.ts b/src/webhooks/index.ts index d0d3537a..3452c3fb 100644 --- a/src/webhooks/index.ts +++ b/src/webhooks/index.ts @@ -96,18 +96,19 @@ export default function ( case 'payment_intent.succeeded': { const eventData = event.data.object; - const paymentMethod = await stripe.paymentMethods.retrieve(eventData.payment_method as string); - const userAddressBillingDetails = paymentMethod.billing_details.address; + + const paymentMethod = await stripePaymentsAdapter.retrievePaymentMethod(eventData.payment_method as string); + const userAddressBillingDetails = paymentMethod.getAddress(); if (userAddressBillingDetails) { await stripePaymentsAdapter.updateCustomer(eventData.customer as string, { address: { - city: userAddressBillingDetails.city as string, - line1: userAddressBillingDetails.line1 as string, - line2: userAddressBillingDetails.line2 as string, - country: userAddressBillingDetails.country as string, - postalCode: userAddressBillingDetails.postal_code as string, - state: userAddressBillingDetails.state as string, + city: userAddressBillingDetails.city, + line1: userAddressBillingDetails.line1, + line2: userAddressBillingDetails.line2, + country: userAddressBillingDetails.country, + postalCode: userAddressBillingDetails.postalCode, + state: userAddressBillingDetails.state, }, }); } diff --git a/tests/src/infrastructure/adapters/stripe.adapter.test.ts b/tests/src/infrastructure/adapters/stripe.adapter.test.ts index 58738569..75a1f252 100644 --- a/tests/src/infrastructure/adapters/stripe.adapter.test.ts +++ b/tests/src/infrastructure/adapters/stripe.adapter.test.ts @@ -1,8 +1,9 @@ -import { getCustomer } from '../../fixtures'; +import { getCustomer, getPaymentMethod } from '../../fixtures'; 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 { PaymentMethod } from '../../../../src/infrastructure/domain/entities/paymentMethod'; describe('Stripe Adapter', () => { describe('Create customer', () => { @@ -97,4 +98,18 @@ describe('Stripe Adapter', () => { await expect(stripePaymentsAdapter.searchCustomer(mockedCustomer.email as string)).rejects.toThrow(mockedError); }); }); + + describe('Get Payment methods', () => { + test('When retrieving a payment method, then the payment method is returned', async () => { + const mockedPaymentMethod = getPaymentMethod(); + + jest + .spyOn(stripePaymentsAdapter.getInstance().paymentMethods, 'retrieve') + .mockResolvedValue(mockedPaymentMethod as Stripe.Response); + + const paymentMethod = await stripePaymentsAdapter.retrievePaymentMethod(mockedPaymentMethod.id); + + expect(paymentMethod).toStrictEqual(PaymentMethod.toDomain(mockedPaymentMethod)); + }); + }); }); diff --git a/tests/src/infrastructure/domain/entities/customer.test.ts b/tests/src/infrastructure/domain/entities/customer.test.ts index c465c27b..d7300afa 100644 --- a/tests/src/infrastructure/domain/entities/customer.test.ts +++ b/tests/src/infrastructure/domain/entities/customer.test.ts @@ -4,7 +4,6 @@ import { BadRequestError } from '../../../../../src/errors/Errors'; describe('Customer entity', () => { const mockedCustomer = getCustomer(); - const badRequestNotFoundError = new BadRequestError(); test('When converting the customer to domain, then the customer is created successfully', () => { const customer = Customer.toDomain(mockedCustomer); @@ -63,11 +62,4 @@ describe('Customer entity', () => { expect(() => Customer.toDomain(customerWithoutEmail)).toThrow(badRequestNotFoundError); }); - - test('When converting a customer without address, then an error is thrown', () => { - const badRequestNotFoundError = new BadRequestError('Customer address is required'); - const customerWithoutAddress = { ...mockedCustomer, address: null }; - - expect(() => Customer.toDomain(customerWithoutAddress)).toThrow(badRequestNotFoundError); - }); }); diff --git a/tests/src/infrastructure/domain/entities/paymentMethod.test.ts b/tests/src/infrastructure/domain/entities/paymentMethod.test.ts new file mode 100644 index 00000000..59a75f94 --- /dev/null +++ b/tests/src/infrastructure/domain/entities/paymentMethod.test.ts @@ -0,0 +1,43 @@ +import { PaymentMethod } from '../../../../../src/infrastructure/domain/entities/paymentMethod'; +import { getPaymentMethod } from '../../../fixtures'; + +describe('Payment method entity', () => { + test('When creating a payment method, then it is created successfully', () => { + const mockedPaymentMethod = getPaymentMethod(); + + const paymentMethod = PaymentMethod.toDomain(mockedPaymentMethod); + + expect(paymentMethod.getId()).toStrictEqual(mockedPaymentMethod.id); + expect(paymentMethod.getAddress()).toStrictEqual({ + line1: mockedPaymentMethod.billing_details.address?.line1, + line2: mockedPaymentMethod.billing_details.address?.line2, + city: mockedPaymentMethod.billing_details.address?.city, + state: mockedPaymentMethod.billing_details.address?.state, + country: mockedPaymentMethod.billing_details.address?.country, + postalCode: mockedPaymentMethod.billing_details.address?.postal_code, + }); + }); + + test('When requesting the payment method id, then the payment method id is returned', () => { + const mockedPaymentMethod = getPaymentMethod(); + + const paymentMethod = PaymentMethod.toDomain(mockedPaymentMethod); + + expect(paymentMethod.getId()).toStrictEqual(mockedPaymentMethod.id); + }); + + test('When requesting the payment method address, then the payment method address is returned', () => { + const mockedPaymentMethod = getPaymentMethod(); + + const paymentMethod = PaymentMethod.toDomain(mockedPaymentMethod); + + expect(paymentMethod.getAddress()).toStrictEqual({ + line1: mockedPaymentMethod.billing_details.address?.line1, + line2: mockedPaymentMethod.billing_details.address?.line2, + city: mockedPaymentMethod.billing_details.address?.city, + state: mockedPaymentMethod.billing_details.address?.state, + country: mockedPaymentMethod.billing_details.address?.country, + postalCode: mockedPaymentMethod.billing_details.address?.postal_code, + }); + }); +}); diff --git a/tests/src/webhooks/webhook.test.ts b/tests/src/webhooks/webhook.test.ts index d4a5b22f..0165a5e6 100644 --- a/tests/src/webhooks/webhook.test.ts +++ b/tests/src/webhooks/webhook.test.ts @@ -1,7 +1,7 @@ import { FastifyInstance } from 'fastify'; import { closeServerAndDatabase, initializeServerAndDatabase } from '../utils/initializeServer'; import Stripe from 'stripe'; -import { getCustomer, getInvoice, getPaymentIntent } from '../fixtures'; +import { getCustomer, getInvoice, getPaymentIntent, getPaymentMethod } from '../fixtures'; import handleFundsCaptured from '../../../src/webhooks/handleFundsCaptured'; import { PaymentService } from '../../../src/services/payment.service'; import { ObjectStorageService } from '../../../src/services/objectStorage.service'; @@ -10,6 +10,7 @@ import handleInvoicePaymentFailed from '../../../src/webhooks/handleInvoicePayme import { InvoiceCompletedHandler } from '../../../src/webhooks/events/invoices/InvoiceCompletedHandler'; import { StripePaymentsAdapter } from '../../../src/infrastructure/adapters/stripe.adapter'; import { Customer } from '../../../src/infrastructure/domain/entities/customer'; +import { PaymentMethod } from '../../../src/infrastructure/domain/entities/paymentMethod'; let app: FastifyInstance; @@ -99,6 +100,49 @@ describe('Webhook events', () => { ); }); + test('When the event payment_intent.succeeded is triggered, then the correct function is called', async () => { + const mockedPaymentIntent = getPaymentIntent(); + const mockedPaymentMethods = getPaymentMethod({ + id: mockedPaymentIntent.payment_method as string, + }); + const mockedPaymentMethodToDomain = PaymentMethod.toDomain(mockedPaymentMethods); + + const event = { + id: 'evt_3', + type: 'payment_intent.succeeded', + data: { object: mockedPaymentIntent }, + }; + const payloadToString = JSON.stringify(event); + + const getPaymentMethodSpy = jest + .spyOn(StripePaymentsAdapter.prototype, 'retrievePaymentMethod') + .mockResolvedValue(mockedPaymentMethodToDomain); + const updateCustomerSpy = jest + .spyOn(StripePaymentsAdapter.prototype, 'updateCustomer') + .mockResolvedValue({} as any); + + const header = Stripe.webhooks.generateTestHeaderString({ + payload: payloadToString, + secret, + }); + + const response = await app.inject({ + method: 'POST', + path: 'webhook', + body: Buffer.from(payloadToString), + headers: { + 'stripe-signature': header, + 'content-type': 'application/json', + }, + }); + + expect(response.statusCode).toBe(204); + expect(getPaymentMethodSpy).toHaveBeenCalledWith(mockedPaymentIntent.payment_method as string); + expect(updateCustomerSpy).toHaveBeenCalledWith(mockedPaymentIntent.customer as string, { + address: mockedPaymentMethodToDomain.address, + }); + }); + describe('Invoice Payment completed', () => { test('When the event invoice.paid is triggered, then the correct function is called', async () => { const mockedInvoice = getInvoice({ From 9549a45cf5b9fb587de5ed9077ecfd2bdc688fdf Mon Sep 17 00:00:00 2001 From: Xavier Abad <77491413+xabg2@users.noreply.github.com> Date: Fri, 16 Jan 2026 14:39:09 +0100 Subject: [PATCH 3/5] fix(adapter): use the correct interface for a function --- src/infrastructure/domain/ports/payments.adapter.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/infrastructure/domain/ports/payments.adapter.ts b/src/infrastructure/domain/ports/payments.adapter.ts index f1bd1b06..592e66f4 100644 --- a/src/infrastructure/domain/ports/payments.adapter.ts +++ b/src/infrastructure/domain/ports/payments.adapter.ts @@ -1,9 +1,9 @@ -import { Customer, CreateCustomerParams } from '../entities/customer'; +import { Customer, CreateCustomerParams, UpdateCustomerParams } from '../entities/customer'; import { PaymentMethod } from '../entities/paymentMethod'; export interface PaymentsAdapter { createCustomer: (params: CreateCustomerParams) => Promise; - updateCustomer: (customerId: Customer['id'], params: Partial) => Promise; + updateCustomer: (customerId: Customer['id'], params: Partial) => Promise; getCustomer: (customerId: Customer['id']) => Promise; searchCustomer: (email: Customer['email']) => Promise; retrievePaymentMethod: (paymentMethodId: PaymentMethod['id']) => Promise; From 74b391e708a3b4bfc9015cd420cf49288460dc04 Mon Sep 17 00:00:00 2001 From: Xavier Abad <77491413+xabg2@users.noreply.github.com> Date: Fri, 16 Jan 2026 14:43:32 +0100 Subject: [PATCH 4/5] fix: update param only when it is provided to avoid updating unwanted params --- src/infrastructure/adapters/stripe.adapter.ts | 24 ++++++++++--------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/src/infrastructure/adapters/stripe.adapter.ts b/src/infrastructure/adapters/stripe.adapter.ts index 4120f425..e86a6a00 100644 --- a/src/infrastructure/adapters/stripe.adapter.ts +++ b/src/infrastructure/adapters/stripe.adapter.ts @@ -57,17 +57,19 @@ export class StripePaymentsAdapter implements PaymentsAdapter { private toStripeCustomerParams(params: Partial): Stripe.CustomerCreateParams { return { - name: params.name, - email: params.email, - phone: params.phone, - address: { - line1: params.address?.line1 ?? undefined, - line2: params.address?.line2 ?? undefined, - city: params.address?.city ?? undefined, - state: params.address?.state ?? undefined, - country: params.address?.country ?? undefined, - postal_code: params.address?.postalCode ?? undefined, - }, + ...(params.name && { name: params.name }), + ...(params.email && { email: params.email }), + ...(params.phone && { phone: params.phone }), + ...(params.address && { + address: { + line1: params.address.line1 ?? undefined, + line2: params.address.line2 ?? undefined, + city: params.address.city ?? undefined, + state: params.address.state ?? undefined, + country: params.address.country ?? undefined, + postal_code: params.address.postalCode ?? undefined, + }, + }), }; } } From fe70d602400b03ed56bd8617cc6ce954deeda30b Mon Sep 17 00:00:00 2001 From: Xavier Abad <77491413+xabg2@users.noreply.github.com> Date: Fri, 16 Jan 2026 14:58:04 +0100 Subject: [PATCH 5/5] test: check that some fields are not updated if not provided --- .../adapters/stripe.adapter.test.ts | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/tests/src/infrastructure/adapters/stripe.adapter.test.ts b/tests/src/infrastructure/adapters/stripe.adapter.test.ts index 75a1f252..97e9bbaa 100644 --- a/tests/src/infrastructure/adapters/stripe.adapter.test.ts +++ b/tests/src/infrastructure/adapters/stripe.adapter.test.ts @@ -42,10 +42,68 @@ describe('Stripe Adapter', () => { const updatedCustomer = await stripePaymentsAdapter.updateCustomer(mockedCustomer.id, { email: mockedCustomer.email as string, name: mockedCustomer.name as string, + address: { + line1: mockedCustomer.address?.line1 ?? '', + line2: mockedCustomer.address?.line2 ?? '', + city: mockedCustomer.address?.city ?? '', + state: mockedCustomer.address?.state ?? '', + country: mockedCustomer.address?.country ?? '', + postalCode: mockedCustomer.address?.postal_code ?? '', + }, }); expect(updatedCustomer).toStrictEqual(Customer.toDomain(mockedCustomer)); }); + + test('When address is not provided, then the existing address should be preserved', async () => { + const originalAddress = { + postal_code: '08001', + country: 'ES', + city: 'Barcelona', + line1: 'Carrer Major 1', + line2: 'Piso 2', + state: 'Catalunya', + }; + + const initialCustomer = getCustomer({ + name: 'Original Name', + email: 'original@internxt.com', + address: originalAddress, + }); + + const updatedCustomer = getCustomer({ + id: initialCustomer.id, + name: 'Updated Name', + email: 'original@internxt.com', + address: originalAddress, + }); + + const updateSpy = jest + .spyOn(stripePaymentsAdapter.getInstance().customers, 'update') + .mockResolvedValue(updatedCustomer as Stripe.Response); + + const result = await stripePaymentsAdapter.updateCustomer(initialCustomer.id, { + name: 'Updated Name', + }); + + expect(updateSpy).toHaveBeenCalledWith(initialCustomer.id, { + name: 'Updated Name', + }); + expect(updateSpy).toHaveBeenCalledWith( + expect.any(String), + expect.not.objectContaining({ address: expect.anything() }), + ); + + expect(result.name).toBe('Updated Name'); + expect(result.address).toStrictEqual({ + line1: originalAddress.line1, + line2: originalAddress.line2, + city: originalAddress.city, + state: originalAddress.state, + country: originalAddress.country, + postalCode: originalAddress.postal_code, + }); + }); }); describe('Get customer', () => {