Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support to webhook #37

Merged
merged 6 commits into from
Jul 29, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
# compiled output
*/dist
/dist
*/node_modules
/node_modules
*/build

# Logs
Expand Down
1 change: 1 addition & 0 deletions snack-bar-api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
"@nestjs/platform-express": "^10.3.10",
"@nestjs/swagger": "^7.4.0",
"@prisma/client": "^5.14.0",
"axios": "^1.7.2",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"dotenv-cli": "^7.4.2",
Expand Down
2 changes: 1 addition & 1 deletion snack-bar-api/src/config/modules/client.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { FindClientByCpfUseCases } from '@/core/interactor/usecases/client/find-
import { FindClientByIdUseCases } from '@/core/interactor/usecases/client/find-client-by-id.use-cases';
import { UpdateClientUseCases } from '@/core/interactor/usecases/client/update-client.use-cases';
import { IClientRepository } from '@/core/repository/client/client.repository';
import { ClientPostgresAdapter } from '@/datasource/adapter/client/client-postgres.adapter';
import { ClientPostgresAdapter } from '@/datasource/database/adapter/client/client-postgres.adapter';
import { ClientController } from '@/transport/controller/client.controller';

@Module({
Expand Down
2 changes: 1 addition & 1 deletion snack-bar-api/src/config/modules/order.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { FindAllOrdersUseCases } from '@/core/interactor/usecases/order/find-all
import { FindOrderByIdUseCases } from '@/core/interactor/usecases/order/find-order-by-id.use-cases';
import { UpdateOrderUseCases } from '@/core/interactor/usecases/order/update-order.use-cases';
import { IOrderRepository } from '@/core/repository/order/order.respository';
import { OrderPostgresAdapter } from '@/datasource/adapter/order/order-postgres.adapter';
import { OrderPostgresAdapter } from '@/datasource/database/adapter/order/order-postgres.adapter';
import { OrderController } from '@/transport/controller/order.controller';

@Module({
Expand Down
22 changes: 20 additions & 2 deletions snack-bar-api/src/config/modules/payment.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,17 @@ import { PrismaService } from '@/config/prisma.config';
import { UpdateOrderUseCasesPort } from '@/core/interactor/port/order/update-order-use-cases.port';
import { CreatePaymentUseCasesPort } from '@/core/interactor/port/payment/create-payment-use-cases.port';
import { FindPaymentByIdUseCasesPort } from '@/core/interactor/port/payment/find-payment-by-id-use-cases.port';
import { UpdatePaymentServicePort } from '@/core/interactor/port/payment/update-payment-service.port';
import { UpdateOrderUseCases } from '@/core/interactor/usecases/order/update-order.use-cases';
import { CreatePaymentUseCases } from '@/core/interactor/usecases/payment/create-payment.use-cases';
import { FindPaymentByIdUseCases } from '@/core/interactor/usecases/payment/find-payment-by-id.use-cases';
import { UpdatePaymentUseCase } from '@/core/interactor/usecases/payment/update-payment.use-cases';
import { IOrderRepository } from '@/core/repository/order/order.respository';
import { IPaymentRepository } from '@/core/repository/payment/payment.repository';
import { OrderPostgresAdapter } from '@/datasource/adapter/order/order-postgres.adapter';
import { PaymentPostgresAdapter } from '@/datasource/adapter/payment/payment-postgres.adapter';
import { OrderPostgresAdapter } from '@/datasource/database/adapter/order/order-postgres.adapter';
import { PaymentPostgresAdapter } from '@/datasource/database/adapter/payment/payment-postgres.adapter';
import { MercadoPagoAdapter } from '@/datasource/mercado-pago/adapter/mercado-pago-adapter.service';
import { MercadoPagoServicePort } from '@/datasource/mercado-pago/port/mercado-pago-service.port';
import { PaymentController } from '@/transport/controller/payment.controller';

@Module({
Expand All @@ -29,6 +33,20 @@ import { PaymentController } from '@/transport/controller/payment.controller';
inject: [IPaymentRepository, UpdateOrderUseCasesPort],
},

{
provide: MercadoPagoServicePort,
useClass: MercadoPagoAdapter,
},
{
provide: UpdatePaymentServicePort,
useFactory: (
paymentRepository: IPaymentRepository,
mercadoPagoAdapter: MercadoPagoServicePort,
) => {
return new UpdatePaymentUseCase(paymentRepository, mercadoPagoAdapter);
},
inject: [IPaymentRepository, MercadoPagoServicePort],
},
{
provide: FindPaymentByIdUseCasesPort,
useFactory: (paymentRepository: IPaymentRepository) => {
Expand Down
2 changes: 1 addition & 1 deletion snack-bar-api/src/config/modules/product.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { FindAllProductsUseCases } from '@/core/interactor/usecases/product/find
import { FindProductByIdUseCases } from '@/core/interactor/usecases/product/find-product-by-id.use-cases';
import { FindProductsByCategoryUseCases } from '@/core/interactor/usecases/product/find-products-by-category.use-cases';
import { IProductRepository } from '@/core/repository/product/product.repository';
import { ProductPostgresAdapter } from '@/datasource/adapter/product/product-postgres.adapter';
import { ProductPostgresAdapter } from '@/datasource/database/adapter/product/product-postgres.adapter';
import { ProductController } from '@/transport/controller/product.controller';

@Module({
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { UpdatePaymentResponseDto } from '@/transport/dto/payment/response/update-success-response.dto';

export abstract class UpdatePaymentServicePort {
abstract execute(id: string): Promise<UpdatePaymentResponseDto>;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { Payment } from '@/core/domain/payment/payment.entity';
import { InternalServerErrorException } from '@/core/exceptions/custom-exceptions/internal-server-error.exception';
import { UpdatePaymentServicePort } from '@/core/interactor/port/payment/update-payment-service.port';
import { IPaymentRepository } from '@/core/repository/payment/payment.repository';
import { MercadoPagoServicePort } from '@/datasource/mercado-pago/port/mercado-pago-service.port';
import { UpdatePaymentResponseDto } from '@/transport/dto/payment/response/update-success-response.dto';

export class UpdatePaymentUseCase implements UpdatePaymentServicePort {
constructor(
private readonly paymentRepository: IPaymentRepository,
private readonly mercadoPagoAdapterService: MercadoPagoServicePort,
) {}

async execute(id: string): Promise<UpdatePaymentResponseDto> {
const payment = await this.fetchPayment(id);
return await this.updatePaymentRegister(payment);
}

private async fetchPayment(id: string): Promise<Payment> {
return await this.mercadoPagoAdapterService.getPaymentById(id);
}

private async updatePaymentRegister(
payment: Payment,
): Promise<UpdatePaymentResponseDto> {
try {
await this.paymentRepository.updateById(payment.id, payment);
} catch (error) {
throw new InternalServerErrorException();
}
return { message: 'Payment register was updated successfully' };
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,5 @@ import { Payment } from '@/core/domain/payment/payment.entity';
export abstract class IPaymentRepository {
abstract create(payment: Payment): Promise<Payment>;
abstract findById(id: string): Promise<Payment>;
abstract updateById(id: string, payment: Payment): Promise<Payment>;
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,15 @@ export class PaymentPostgresAdapter implements IPaymentRepository {
},
});
}

async updateById(id: string, payment: Payment): Promise<Payment> {
return await this.prisma.payment.update({
where: {
id,
},
data: {
...payment,
},
});
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { Payment } from '@/core/domain/payment/payment.entity';
import { InternalServerErrorException } from '@/core/exceptions/custom-exceptions/internal-server-error.exception';
import {
MercadoPagoPaymentDto,
toDomain,
} from '@/datasource/mercado-pago/dto/payment.dto';
import { MercadoPagoServicePort } from '@/datasource/mercado-pago/port/mercado-pago-service.port';
import axios from 'axios';

export class MercadoPagoAdapter implements MercadoPagoServicePort {
constructor() {}

//TODO: update bearer token with valid one for testing
api = axios.create({
baseURL: 'https://api.mercadopago.com/v1',
timeout: 1000,
headers: {
Authorization: 'Bearer f8f50e3e20d15-391569826',
},
});

async getPaymentById(id: string): Promise<Payment> {
const payment = await this.getById(id);
return this.mercadoPagoPaymentToDomainPayment(payment);
}

private mercadoPagoPaymentToDomainPayment(payment: MercadoPagoPaymentDto): Payment {
return toDomain(payment);
}

private async getById(id: string): Promise<MercadoPagoPaymentDto> {
try {
return await this.api.get(`/payments/${id}`);
} catch (error) {
console.log(error);
throw new InternalServerErrorException({
description: 'Third party API is out of service',
});
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export class MercadoPagoIdentificationDto {
readonly type: string;
readonly number: number;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { MercadoPagoIdentificationDto } from '@/datasource/mercado-pago/dto/nested/identification.dto';

export class MercadoPagoPayerDto {
readonly id: string;
readonly email: string;
readonly identification: MercadoPagoIdentificationDto;
readonly type: string;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export class MercadoPagoTransactionDetailsDto {
readonly net_received_amount: number;
readonly total_paid_amount: number;
readonly overpaid_amount: number;
readonly installment_amount: number;
}
34 changes: 34 additions & 0 deletions snack-bar-api/src/datasource/mercado-pago/dto/payment.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { Payment } from '@/core/domain/payment/payment.entity';
import { MercadoPagoPayerDto } from '@/datasource/mercado-pago/dto/nested/payer.dto';
import { MercadoPagoTransactionDetailsDto } from '@/datasource/mercado-pago/dto/nested/transaction-details.dto';

export class MercadoPagoPaymentDto {
readonly id: string;
readonly date_created: string;
readonly date_approved: string;
readonly date_last_updated: string;
readonly money_release_date: string;
readonly payment_method_id: string;
readonly payment_type_id: string;
readonly status: string;
readonly status_detail: string;
readonly currency_id: string;
readonly description: string;
readonly collector_id: string;
readonly payer: MercadoPagoPayerDto;
readonly metadata: Object;
readonly additional_info: Object;
readonly external_reference: string;
readonly transaction_amount: number;
readonly transaction_amount_refunded: number;
readonly coupon_amount: number;
readonly transaction_details: MercadoPagoTransactionDetailsDto;
readonly installments: number;
readonly car: Object;
}

export const toDomain = (dto: MercadoPagoPaymentDto): Payment => {
return dto.status === 'approved'
? new Payment(dto.id, dto.transaction_amount, 'PIX')
: null;
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { Payment } from '@/core/domain/payment/payment.entity';

export abstract class MercadoPagoServicePort {
abstract getPaymentById(identifier: string): Promise<Payment>;
}
4 changes: 4 additions & 0 deletions snack-bar-api/src/transport/constant/payment.constant.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ export const PAYMENT = {
SUMMARY: 'Gets payment status by identifier',
DESC: 'Fetches payment status from database register by identifier',
},
UPDATE_BY_ID: {
SUMMARY: 'Updates payment register by identifier',
DESC: 'Fetches updated data from third party API and updates existing payment register by identifier',
},
PAYMENT: {
ID: {
DESC: 'Payment identifier in database',
Expand Down
23 changes: 21 additions & 2 deletions snack-bar-api/src/transport/controller/payment.controller.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,30 @@
import { Body, Controller, Get, HttpStatus, Param, Post } from '@nestjs/common';
import { Body, Controller, Get, HttpStatus, Param, Patch, Post } from '@nestjs/common';
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';

import { Payment } from '@/core/domain/payment/payment.entity';
import { InternalServerErrorException } from '@/core/exceptions/custom-exceptions/internal-server-error.exception';
import { NotFoundException } from '@/core/exceptions/custom-exceptions/not-found.exception';
import { CreatePaymentUseCasesPort } from '@/core/interactor/port/payment/create-payment-use-cases.port';
import { FindPaymentByIdUseCasesPort } from '@/core/interactor/port/payment/find-payment-by-id-use-cases.port';
import { UpdatePaymentServicePort } from '@/core/interactor/port/payment/update-payment-service.port';
import { API_RESPONSE } from '@/transport/constant/api-response.constant';
import { PAYMENT } from '@/transport/constant/payment.constant';
import { CreatePaymentDto, toDomain } from '@/transport/dto/payment/request/payment.dto';
import {
GetPaymentStatusResponseDto,
toDTO,
} from '@/transport/dto/payment/response/get-payment-status.dto';
import { UpdatePaymentResponseDto } from '@/transport/dto/payment/response/update-success-response.dto';

const { CREATE, GET_BY_ID } = PAYMENT.API_PROPERTY;
const { CREATE, GET_BY_ID, UPDATE_BY_ID } = PAYMENT.API_PROPERTY;
const { CREATED_DESC, OK_DESC, INTERNAL_SERVER_EXCEPTION_DESC, NOT_FOUND_DESC } =
API_RESPONSE;

@Controller('payments')
@ApiTags('payments')
export class PaymentController {
constructor(
private readonly updatePaymentUseCaseService: UpdatePaymentServicePort,
private readonly createPaymentUseCases: CreatePaymentUseCasesPort,
private readonly findPaymentByIdUseCases: FindPaymentByIdUseCasesPort,
) {}
Expand Down Expand Up @@ -67,4 +70,20 @@ export class PaymentController {
async getStatusById(@Param('id') id: string): Promise<any> {
return toDTO(await this.findPaymentByIdUseCases.execute(id));
}

@Patch(':id')
@ApiOperation({ summary: UPDATE_BY_ID.SUMMARY, description: UPDATE_BY_ID.DESC })
@ApiResponse({
status: HttpStatus.OK,
description: OK_DESC,
type: () => UpdatePaymentResponseDto,
})
@ApiResponse({
status: HttpStatus.INTERNAL_SERVER_ERROR,
description: INTERNAL_SERVER_EXCEPTION_DESC,
type: () => InternalServerErrorException,
})
async updateById(@Param('id') id: string): Promise<UpdatePaymentResponseDto> {
return await this.updatePaymentUseCaseService.execute(id);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export class UpdatePaymentResponseDto {
message: string;
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ describe('CreatePaymentUseCases', () => {
Promise.resolve({ ...payment, id: 'some-id', createdAt: new Date() }),
),
findById: jest.fn(),
updateById: jest.fn(),
};

updateOrderUseCases = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ describe('FindPaymentByIdUseCases', () => {
const paymentRepository: IPaymentRepository = {
create: jest.fn(),
findById: jest.fn(),
updateById: jest.fn(),
};
const useCase: FindPaymentByIdUseCasesPort = new FindPaymentByIdUseCases(
paymentRepository,
Expand Down
61 changes: 61 additions & 0 deletions snack-bar-api/test/payment/update-usecase.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { Payment } from '@/core/domain/payment/payment.entity';
import { UpdatePaymentUseCase } from '@/core/interactor/usecases/payment/update-payment.use-cases';
import { IPaymentRepository } from '@/core/repository/payment/payment.repository';
import { MercadoPagoServicePort } from '@/datasource/mercado-pago/port/mercado-pago-service.port';

describe('UpdatePaymentUseCase', () => {
let service: UpdatePaymentUseCase;
let paymentRepository: IPaymentRepository;
let mercadoPagoAdapterService: MercadoPagoServicePort;

beforeEach(async () => {
paymentRepository = {
create: jest.fn(),
findById: jest.fn(),
updateById: jest.fn(),
};

mercadoPagoAdapterService = {
getPaymentById: jest.fn(),
};

service = new UpdatePaymentUseCase(
paymentRepository,
mercadoPagoAdapterService,
);
});

it('should fetch payment data from mercado pago API and update database register', async () => {
const payment: Payment = {
id: '122',
value: 100,
method: 'PIX',
createdAt: new Date(),
};

const adapterSpy = jest
.spyOn(mercadoPagoAdapterService, 'getPaymentById')
.mockResolvedValue(payment);
const repositorySpy = jest.spyOn(paymentRepository, 'updateById');

const result = await service.execute('122');

expect(result).toMatchObject({
message: 'Payment register was updated successfully',
});
expect(repositorySpy).toHaveBeenCalled();
expect(adapterSpy).toHaveBeenCalledWith('122');
});

it('should handle errors when mercado pago API is not responsive', async () => {
jest
.spyOn(mercadoPagoAdapterService, 'getPaymentById')
.mockRejectedValueOnce(new Error('Third party API is out of service'));

try {
return await service.execute('122');
} catch (error) {
expect(error).toEqual(new Error('Third party API is out of service'));
}
});
});