diff --git a/packages/medusa/src/models/payment-collection.ts b/packages/medusa/src/models/payment-collection.ts index 6d2cbbd390..1625a4872f 100644 --- a/packages/medusa/src/models/payment-collection.ts +++ b/packages/medusa/src/models/payment-collection.ts @@ -36,10 +36,10 @@ export enum PaymentCollectionType { @FeatureFlagEntity(OrderEditingFeatureFlag.key) export class PaymentCollection extends SoftDeletableEntity { @DbAwareColumn({ type: "enum", enum: PaymentCollectionType }) - type: string + type: PaymentCollectionType @DbAwareColumn({ type: "enum", enum: PaymentCollectionStatus }) - status: string + status: PaymentCollectionStatus @Column({ nullable: true }) description: string diff --git a/packages/medusa/src/services/__tests__/payment-collection.ts b/packages/medusa/src/services/__tests__/payment-collection.ts new file mode 100644 index 0000000000..d577e94fda --- /dev/null +++ b/packages/medusa/src/services/__tests__/payment-collection.ts @@ -0,0 +1,200 @@ +import { IdMap, MockManager, MockRepository } from "medusa-test-utils" +import { EventBusService, PaymentCollectionService } from "../index" +import { PaymentCollectionStatus, PaymentCollectionType } from "../../models" +import { EventBusServiceMock } from "../__mocks__/event-bus" + +describe("PaymentCollectionService", () => { + afterEach(() => { + jest.clearAllMocks() + }) + + const paymentCollectionSample = { + id: IdMap.getId("payment-collection-id1"), + region_id: IdMap.getId("region1"), + amount: 100, + created_at: new Date(), + metadata: { + pluginInfo: "xyz", + }, + status: PaymentCollectionStatus.NOT_PAID, + } + + const paymentCollectionAuthorizedSample = { + id: IdMap.getId("payment-collection-id2"), + region_id: IdMap.getId("region1"), + amount: 35000, + status: PaymentCollectionStatus.AUTHORIZED, + } + + const paymentCollectionRepository = MockRepository({ + findOne: (query) => { + const map = { + [IdMap.getId("payment-collection-id1")]: paymentCollectionSample, + [IdMap.getId("payment-collection-id2")]: + paymentCollectionAuthorizedSample, + } + + if (map[query?.where?.id]) { + return { ...map[query?.where?.id] } + } + return + }, + create: (data) => { + return { + ...paymentCollectionSample, + ...data, + } + }, + }) + + const paymentCollectionService = new PaymentCollectionService({ + manager: MockManager, + paymentCollectionRepository, + eventBusService: EventBusServiceMock as unknown as EventBusService, + }) + + it("should retrieve a payment collection", async () => { + await paymentCollectionService.retrieve( + IdMap.getId("payment-collection-id1") + ) + expect(paymentCollectionRepository.findOne).toHaveBeenCalledTimes(1) + expect(paymentCollectionRepository.findOne).toHaveBeenCalledWith({ + where: { id: IdMap.getId("payment-collection-id1") }, + }) + }) + + it("should throw error if payment collection is not found", async () => { + const payCol = paymentCollectionService.retrieve( + IdMap.getId("payment-collection-non-existing-id") + ) + + expect(paymentCollectionRepository.findOne).toHaveBeenCalledTimes(1) + expect(payCol).rejects.toThrow(Error) + }) + + it("should create a payment collection", async () => { + const entity = await paymentCollectionService.create({ + region_id: IdMap.getId("region2"), + type: PaymentCollectionType.ORDER_EDIT, + currency_code: "USD", + amount: 190, + created_by: IdMap.getId("user-id"), + description: "some description", + metadata: { + abc: 123, + }, + }) + expect(paymentCollectionRepository.save).toHaveBeenCalledTimes(1) + expect(paymentCollectionRepository.save).toHaveBeenCalledWith( + expect.objectContaining({ + id: IdMap.getId("payment-collection-id1"), + region_id: IdMap.getId("region2"), + amount: 190, + created_by: IdMap.getId("user-id"), + status: PaymentCollectionStatus.NOT_PAID, + description: "some description", + metadata: { + abc: 123, + }, + }) + ) + + expect(EventBusServiceMock.emit).toHaveBeenCalledTimes(1) + expect(EventBusServiceMock.emit).toHaveBeenCalledWith( + PaymentCollectionService.Events.CREATED, + entity + ) + }) + + it("should update a payment collection with the right arguments", async () => { + const submittedChanges = { + description: "updated description", + status: PaymentCollectionStatus.CAPTURED, + metadata: { + extra: 123, + arr: ["a", "b", "c"], + }, + } + const internalChanges = { + ...submittedChanges, + } + internalChanges.metadata = { + ...internalChanges.metadata, + ...{ + pluginInfo: "xyz", + }, + } + + const entity = await paymentCollectionService.update( + IdMap.getId("payment-collection-id1"), + submittedChanges + ) + expect(paymentCollectionRepository.save).toHaveBeenCalledTimes(1) + expect(paymentCollectionRepository.save).toHaveBeenCalledWith( + expect.objectContaining(internalChanges) + ) + + expect(EventBusServiceMock.emit).toHaveBeenCalledTimes(1) + expect(EventBusServiceMock.emit).toHaveBeenCalledWith( + PaymentCollectionService.Events.UPDATED, + entity + ) + }) + + it("should throw error to update a non-existing payment collection", async () => { + const submittedChanges = { + description: "updated description", + status: PaymentCollectionStatus.CAPTURED, + metadata: { + extra: 123, + arr: ["a", "b", "c"], + }, + } + + const payCol = paymentCollectionService.update( + IdMap.getId("payment-collection-non-existing"), + submittedChanges + ) + expect(paymentCollectionRepository.save).toHaveBeenCalledTimes(0) + expect(EventBusServiceMock.emit).toHaveBeenCalledTimes(0) + expect(payCol).rejects.toThrow(Error) + }) + + it("should delete a payment collection", async () => { + const entity = await paymentCollectionService.delete( + IdMap.getId("payment-collection-id1") + ) + expect(paymentCollectionRepository.remove).toHaveBeenCalledTimes(1) + expect(paymentCollectionRepository.remove).toHaveBeenCalledWith( + expect.objectContaining({ + id: IdMap.getId("payment-collection-id1"), + region_id: IdMap.getId("region1"), + amount: 100, + }) + ) + + expect(EventBusServiceMock.emit).toHaveBeenCalledTimes(1) + expect(EventBusServiceMock.emit).toHaveBeenCalledWith( + PaymentCollectionService.Events.DELETED, + entity + ) + }) + + it("should ignore to delete a non-existing payment collection", async () => { + const entity = await paymentCollectionService.delete( + IdMap.getId("payment-collection-non-existing") + ) + expect(paymentCollectionRepository.remove).toHaveBeenCalledTimes(0) + expect(EventBusServiceMock.emit).toHaveBeenCalledTimes(0) + expect(entity).toBe(undefined) + }) + + it("should throw and error when trying to delete an initialized payment collection", async () => { + const entity = paymentCollectionService.delete( + IdMap.getId("payment-collection-id2") + ) + expect(paymentCollectionRepository.remove).toHaveBeenCalledTimes(0) + + expect(entity).rejects.toThrow(Error) + }) +}) diff --git a/packages/medusa/src/services/index.ts b/packages/medusa/src/services/index.ts index ef26ce94e3..8278ecb849 100644 --- a/packages/medusa/src/services/index.ts +++ b/packages/medusa/src/services/index.ts @@ -25,6 +25,7 @@ export { default as OrderService } from "./order" export { default as OrderEditService } from "./order-edit" export { default as OrderEditItemChangeService } from "./order-edit-item-change" export { default as PaymentProviderService } from "./payment-provider" +export { default as PaymentCollectionService } from "./payment-collection" export { default as PricingService } from "./pricing" export { default as PriceListService } from "./price-list" export { default as ProductCollectionService } from "./product-collection" diff --git a/packages/medusa/src/services/payment-collection.ts b/packages/medusa/src/services/payment-collection.ts new file mode 100644 index 0000000000..44fa69fcb6 --- /dev/null +++ b/packages/medusa/src/services/payment-collection.ts @@ -0,0 +1,163 @@ +import { DeepPartial, EntityManager, IsNull } from "typeorm" +import { MedusaError } from "medusa-core-utils" + +import { FindConfig } from "../types/common" +import { buildQuery, isDefined, setMetadata } from "../utils" +import { PaymentCollectionRepository } from "../repositories/payment-collection" +import { PaymentCollection, PaymentCollectionStatus } from "../models" +import { TransactionBaseService } from "../interfaces" +import { EventBusService } from "./index" + +import { CreatePaymentCollectionInput } from "../types/payment-collection" + +type InjectedDependencies = { + manager: EntityManager + paymentCollectionRepository: typeof PaymentCollectionRepository + eventBusService: EventBusService +} + +export default class PaymentCollectionService extends TransactionBaseService { + static readonly Events = { + CREATED: "payment-collection.created", + UPDATED: "payment-collection.updated", + DELETED: "payment-collection.deleted", + } + + protected readonly manager_: EntityManager + protected transactionManager_: EntityManager | undefined + protected readonly eventBusService_: EventBusService + // eslint-disable-next-line max-len + protected readonly paymentCollectionRepository_: typeof PaymentCollectionRepository + + constructor({ + manager, + paymentCollectionRepository, + eventBusService, + }: InjectedDependencies) { + // eslint-disable-next-line prefer-rest-params + super(arguments[0]) + + this.manager_ = manager + this.paymentCollectionRepository_ = paymentCollectionRepository + this.eventBusService_ = eventBusService + } + + async retrieve( + paymentCollectionId: string, + config: FindConfig = {} + ): Promise { + const manager = this.transactionManager_ ?? this.manager_ + const paymentCollectionRepository = manager.getCustomRepository( + this.paymentCollectionRepository_ + ) + + const query = buildQuery({ id: paymentCollectionId }, config) + const paymentCollection = await paymentCollectionRepository.findOne(query) + + if (!paymentCollection) { + throw new MedusaError( + MedusaError.Types.NOT_FOUND, + `Payment collection with id ${paymentCollectionId} was not found` + ) + } + + return paymentCollection + } + + async create(data: CreatePaymentCollectionInput): Promise { + return await this.atomicPhase_(async (transactionManager) => { + const paymentCollectionRepository = + transactionManager.getCustomRepository( + this.paymentCollectionRepository_ + ) + + const paymentCollectionToCreate = paymentCollectionRepository.create({ + region_id: data.region_id, + type: data.type, + status: PaymentCollectionStatus.NOT_PAID, + currency_code: data.currency_code, + amount: data.amount, + metadata: data.metadata, + created_by: data.created_by, + description: data.description, + }) + + const paymentCollection = await paymentCollectionRepository.save( + paymentCollectionToCreate + ) + + await this.eventBusService_ + .withTransaction(transactionManager) + .emit(PaymentCollectionService.Events.CREATED, paymentCollection) + + return paymentCollection + }) + } + + async update( + paymentCollectionId: string, + data: DeepPartial + ): Promise { + return await this.atomicPhase_(async (manager) => { + const paymentCollectionRepo = manager.getCustomRepository( + this.paymentCollectionRepository_ + ) + + const paymentCollection = await this.retrieve(paymentCollectionId) + + for (const key of Object.keys(data)) { + if (key === "metadata" && data.metadata) { + paymentCollection[key] = setMetadata(paymentCollection, data.metadata) + } else if (isDefined(data[key])) { + paymentCollection[key] = data[key] + } + } + + const result = await paymentCollectionRepo.save(paymentCollection) + + await this.eventBusService_ + .withTransaction(manager) + .emit(PaymentCollectionService.Events.UPDATED, result) + + return result + }) + } + + async delete( + paymentCollectionId: string + ): Promise { + return await this.atomicPhase_(async (manager) => { + const paymentCollectionRepo = manager.getCustomRepository( + this.paymentCollectionRepository_ + ) + + const paymentCollection = await this.retrieve(paymentCollectionId).catch( + () => void 0 + ) + + if (!paymentCollection) { + return + } + + if ( + [ + PaymentCollectionStatus.CANCELED, + PaymentCollectionStatus.NOT_PAID, + ].includes(paymentCollection.status) === false + ) { + throw new MedusaError( + MedusaError.Types.NOT_ALLOWED, + `Cannot delete payment collection with status ${paymentCollection.status}` + ) + } + + await paymentCollectionRepo.remove(paymentCollection) + + await this.eventBusService_ + .withTransaction(manager) + .emit(PaymentCollectionService.Events.DELETED, paymentCollection) + + return paymentCollection + }) + } +} diff --git a/packages/medusa/src/types/payment-collection.ts b/packages/medusa/src/types/payment-collection.ts new file mode 100644 index 0000000000..a5afd5ceed --- /dev/null +++ b/packages/medusa/src/types/payment-collection.ts @@ -0,0 +1,46 @@ +import { PaymentCollection, PaymentCollectionType } from "../models" + +export type CreatePaymentCollectionInput = { + region_id: string + type: PaymentCollectionType + currency_code: string + amount: number + created_by: string + metadata?: any + description?: string +} + +export const defaultPaymentCollectionRelations = [ + "region", + "region.payment_providers", + "payment_sessions", +] + +export const defaultPaymentCollectionFields: (keyof PaymentCollection)[] = [ + "id", + "type", + "status", + "description", + "amount", + "authorized_amount", + "refunded_amount", + "currency_code", + "metadata", + "region", + "payment_sessions", + "payments", +] + +// eslint-disable-next-line max-len +export const storePaymentCollectionNotAllowedFieldsAndRelations = ["created_by"] + +export const defaultStorePaymentCollectionRelations = + defaultPaymentCollectionRelations.filter( + (field) => + !storePaymentCollectionNotAllowedFieldsAndRelations.includes(field) + ) +export const defaultStorePaymentCollectionFields = + defaultPaymentCollectionFields.filter( + (field) => + !storePaymentCollectionNotAllowedFieldsAndRelations.includes(field) + )