diff --git a/packages/payment/integration-tests/__fixtures__/data.ts b/packages/payment/integration-tests/__fixtures__/data.ts new file mode 100644 index 0000000000000..8dad208fded0d --- /dev/null +++ b/packages/payment/integration-tests/__fixtures__/data.ts @@ -0,0 +1,67 @@ +export const defaultPaymentCollectionData = [ + { + id: "pay-col-id-1", + amount: 100, + region_id: "region-id-1", + currency_code: "usd", + }, + { + id: "pay-col-id-2", + amount: 200, + region_id: "region-id-1", + currency_code: "usd", + }, + { + id: "pay-col-id-3", + amount: 300, + region_id: "region-id-2", + currency_code: "usd", + }, +] + +export const defaultPaymentSessionData = [ + { + id: "pay-sess-id-1", + amount: 100, + currency_code: "usd", + provider_id: "manual", + payment_collection: "pay-col-id-1", + }, + { + id: "pay-sess-id-2", + amount: 100, + currency_code: "usd", + provider_id: "manual", + payment_collection: "pay-col-id-2", + }, + { + id: "pay-sess-id-3", + amount: 100, + currency_code: "usd", + provider_id: "manual", + payment_collection: "pay-col-id-2", + }, +] + +export const defaultPaymentData = [ + { + id: "pay-id-1", + amount: 100, + currency_code: "usd", + payment_collection: "pay-col-id-1", + payment_session: "pay-sess-id-1", + provider_id: "manual", + authorized_amount: 100, + data: {}, + }, + { + id: "pay-id-2", + amount: 100, + authorized_amount: 100, + currency_code: "usd", + payment_collection: "pay-col-id-2", + payment_session: "pay-sess-id-2", + provider_id: "manual", + data: {}, + }, +] diff --git a/packages/payment/integration-tests/__fixtures__/index.ts b/packages/payment/integration-tests/__fixtures__/index.ts new file mode 100644 index 0000000000000..ee87a7d8a970b --- /dev/null +++ b/packages/payment/integration-tests/__fixtures__/index.ts @@ -0,0 +1,53 @@ +import { EntityName } from "@mikro-orm/core" +import { SqlEntityManager } from "@mikro-orm/postgresql" + +import { Payment, PaymentSession, PaymentCollection } from "@models" + +import { + defaultPaymentCollectionData, + defaultPaymentData, + defaultPaymentSessionData, +} from "./data" + +export * from "./data" + +async function createEntities< + T extends EntityName +>(manager: SqlEntityManager, entity: T, data: any[]) { + const created: T[] = [] + for (let record of data) { + created.push(manager.create(entity, record)) + } + + await manager.persistAndFlush(created) + return created +} + +export async function createPaymentCollections( + manager: SqlEntityManager, + paymentCollectionData = defaultPaymentCollectionData +): Promise { + return await createEntities( + manager, + PaymentCollection, + paymentCollectionData + ) +} + +export async function createPaymentSessions( + manager: SqlEntityManager, + paymentSessionData = defaultPaymentSessionData +): Promise { + return await createEntities( + manager, + PaymentSession, + paymentSessionData + ) +} + +export async function createPayments( + manager: SqlEntityManager, + paymentData = defaultPaymentData +): Promise { + return await createEntities(manager, Payment, paymentData) +} diff --git a/packages/payment/integration-tests/__fixtures__/payment-collection/data.ts b/packages/payment/integration-tests/__fixtures__/payment-collection/data.ts deleted file mode 100644 index 692d57bc3e49f..0000000000000 --- a/packages/payment/integration-tests/__fixtures__/payment-collection/data.ts +++ /dev/null @@ -1,20 +0,0 @@ -export const defaultPaymentCollectionData = [ - { - id: "pay-col-id-1", - amount: 100, - region_id: "region-id-1", - currency_code: "usd", - }, - { - id: "pay-col-id-2", - amount: 200, - region_id: "region-id-1", - currency_code: "usd", - }, - { - id: "pay-col-id-3", - amount: 300, - region_id: "region-id-2", - currency_code: "usd", - }, -] diff --git a/packages/payment/integration-tests/__fixtures__/payment-collection/index.ts b/packages/payment/integration-tests/__fixtures__/payment-collection/index.ts deleted file mode 100644 index 3700ffd5df4e0..0000000000000 --- a/packages/payment/integration-tests/__fixtures__/payment-collection/index.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { CreatePaymentCollectionDTO } from "@medusajs/types" -import { SqlEntityManager } from "@mikro-orm/postgresql" - -import { PaymentCollection } from "../../../src/models" -import { defaultPaymentCollectionData } from "./data" - -export * from "./data" - -export async function createPaymentCollections( - manager: SqlEntityManager, - paymentCollectionData: CreatePaymentCollectionDTO[] = defaultPaymentCollectionData -): Promise { - const collections: PaymentCollection[] = [] - - for (let data of paymentCollectionData) { - let collection = manager.create(PaymentCollection, data) - - await manager.persistAndFlush(collection) - } - - return collections -} diff --git a/packages/payment/integration-tests/__tests__/services/payment-module/index.spec.ts b/packages/payment/integration-tests/__tests__/services/payment-module/index.spec.ts index 4f13d0822f622..b671f78c42f85 100644 --- a/packages/payment/integration-tests/__tests__/services/payment-module/index.spec.ts +++ b/packages/payment/integration-tests/__tests__/services/payment-module/index.spec.ts @@ -1,230 +1,586 @@ import { IPaymentModuleService } from "@medusajs/types" import { SqlEntityManager } from "@mikro-orm/postgresql" -import { Modules } from "@medusajs/modules-sdk" -import { initModules } from "medusa-test-utils" -import { initialize } from "../../../../src/initialize" -import { createPaymentCollections } from "../../../__fixtures__/payment-collection" +import { initialize } from "../../../../src" import { DB_URL, MikroOrmWrapper } from "../../../utils" +import { + createPaymentCollections, + createPaymentSessions, + createPayments, +} from "../../../__fixtures__" import { getInitModuleConfig } from "../../../utils/get-init-module-config" +import { initModules } from "medusa-test-utils" +import { Modules } from "@medusajs/modules-sdk" jest.setTimeout(30000) describe("Payment Module Service", () => { let service: IPaymentModuleService - let repositoryManager: SqlEntityManager - let shutdownFunc: () => Promise - beforeAll(async () => { - const initModulesConfig = getInitModuleConfig() + describe("PaymentCollection", () => { + let repositoryManager: SqlEntityManager + let shutdownFunc: () => Promise - const { medusaApp, shutdown } = await initModules(initModulesConfig) + beforeAll(async () => { + const initModulesConfig = getInitModuleConfig() - service = medusaApp.modules[Modules.PAYMENT] + const { medusaApp, shutdown } = await initModules(initModulesConfig) - shutdownFunc = shutdown - }) + service = medusaApp.modules[Modules.PAYMENT] - afterAll(async () => { - await shutdownFunc() - }) - - beforeEach(async () => { - await MikroOrmWrapper.setupDatabase() - repositoryManager = await MikroOrmWrapper.forkManager() + shutdownFunc = shutdown + }) - service = await initialize({ - database: { - clientUrl: DB_URL, - schema: process.env.MEDUSA_PAYMNET_DB_SCHEMA, - }, + afterAll(async () => { + await shutdownFunc() }) - await createPaymentCollections(repositoryManager) - }) + beforeEach(async () => { + await MikroOrmWrapper.setupDatabase() + repositoryManager = await MikroOrmWrapper.forkManager() - afterEach(async () => { - await MikroOrmWrapper.clearDatabase() - }) + service = await initialize({ + database: { + clientUrl: DB_URL, + schema: process.env.MEDUSA_PAYMNET_DB_SCHEMA, + }, + }) - describe("create", () => { - it("should throw an error when required params are not passed", async () => { - let error = await service - .createPaymentCollection([ - { - amount: 200, - region_id: "req_123", - } as any, - ]) - .catch((e) => e) + await createPaymentCollections(repositoryManager) + await createPaymentSessions(repositoryManager) + await createPayments(repositoryManager) + }) - expect(error.message).toContain( - "Value for PaymentCollection.currency_code is required, 'undefined' found" - ) + afterEach(async () => { + await MikroOrmWrapper.clearDatabase() + }) - error = await service - .createPaymentCollection([ - { - currency_code: "USD", - region_id: "req_123", - } as any, - ]) - .catch((e) => e) + describe("create", () => { + it("should throw an error when required params are not passed", async () => { + let error = await service + .createPaymentCollection([ + { + amount: 200, + region_id: "req_123", + } as any, + ]) + .catch((e) => e) + + expect(error.message).toContain( + "Value for PaymentCollection.currency_code is required, 'undefined' found" + ) + + error = await service + .createPaymentCollection([ + { + currency_code: "USD", + region_id: "req_123", + } as any, + ]) + .catch((e) => e) + + expect(error.message).toContain( + "Value for PaymentCollection.amount is required, 'undefined' found" + ) + + error = await service + .createPaymentCollection([ + { + currency_code: "USD", + amount: 200, + } as any, + ]) + .catch((e) => e) + + expect(error.message).toContain( + "Value for PaymentCollection.region_id is required, 'undefined' found" + ) + }) - expect(error.message).toContain( - "Value for PaymentCollection.amount is required, 'undefined' found" - ) + it("should create a payment collection successfully", async () => { + const [createdPaymentCollection] = + await service.createPaymentCollection([ + { currency_code: "USD", amount: 200, region_id: "reg_123" }, + ]) - error = await service - .createPaymentCollection([ - { + expect(createdPaymentCollection).toEqual( + expect.objectContaining({ + id: expect.any(String), + status: "not_paid", + payment_providers: [], + payment_sessions: [], + payments: [], currency_code: "USD", amount: 200, - } as any, - ]) - .catch((e) => e) - - expect(error.message).toContain( - "Value for PaymentCollection.region_id is required, 'undefined' found" - ) - }) - - it("should create a payment collection successfully", async () => { - const [createdPaymentCollection] = await service.createPaymentCollection([ - { currency_code: "USD", amount: 200, region_id: "reg_123" }, - ]) - - expect(createdPaymentCollection).toEqual( - expect.objectContaining({ - id: expect.any(String), - status: "not_paid", - payment_providers: [], - payment_sessions: [], - payments: [], - currency_code: "USD", - amount: 200, + }) + ) + }) + }) + + describe("delete", () => { + it("should delete a Payment Collection", async () => { + let collection = await service.listPaymentCollections({ + id: ["pay-col-id-1"], + }) + + expect(collection.length).toEqual(1) + + await service.deletePaymentCollections(["pay-col-id-1"]) + + collection = await service.listPaymentCollections({ + id: ["pay-col-id-1"], }) - ) + + expect(collection.length).toEqual(0) + }) }) - }) - describe("delete", () => { - it("should delete a Payment Collection", async () => { - let collection = await service.listPaymentCollections({ - id: ["pay-col-id-1"], + describe("retrieve", () => { + it("should retrieve a Payment Collection", async () => { + let collection = await service.retrievePaymentCollection("pay-col-id-2") + + expect(collection).toEqual( + expect.objectContaining({ + id: "pay-col-id-2", + amount: 200, + region_id: "region-id-1", + currency_code: "usd", + }) + ) }) - expect(collection.length).toEqual(1) + it("should fail to retrieve a non existent Payment Collection", async () => { + let error = await service + .retrievePaymentCollection("pay-col-id-not-exists") + .catch((e) => e) - await service.deletePaymentCollections(["pay-col-id-1"]) + expect(error.message).toContain( + "PaymentCollection with id: pay-col-id-not-exists was not found" + ) + }) + }) - collection = await service.listPaymentCollections({ - id: ["pay-col-id-1"], + describe("list", () => { + it("should list and count Payment Collection", async () => { + let [collections, count] = + await service.listAndCountPaymentCollections() + + expect(count).toEqual(3) + + expect(collections).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: "pay-col-id-1", + amount: 100, + region_id: "region-id-1", + currency_code: "usd", + }), + expect.objectContaining({ + id: "pay-col-id-2", + amount: 200, + region_id: "region-id-1", + currency_code: "usd", + }), + expect.objectContaining({ + id: "pay-col-id-3", + amount: 300, + region_id: "region-id-2", + currency_code: "usd", + }), + ]) + ) }) - expect(collection.length).toEqual(0) + it("should list Payment Collections by region_id", async () => { + let collections = await service.listPaymentCollections( + { + region_id: "region-id-1", + }, + { select: ["id", "amount", "region_id"] } + ) + + expect(collections.length).toEqual(2) + + expect(collections).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: "pay-col-id-1", + amount: 100, + region_id: "region-id-1", + }), + expect.objectContaining({ + id: "pay-col-id-2", + amount: 200, + region_id: "region-id-1", + }), + ]) + ) + }) }) - }) - describe("retrieve", () => { - it("should retrieve a Payment Collection", async () => { - let collection = await service.retrievePaymentCollection("pay-col-id-2") - - expect(collection).toEqual( - expect.objectContaining({ + describe("update", () => { + it("should update a Payment Collection", async () => { + await service.updatePaymentCollection({ id: "pay-col-id-2", - amount: 200, - region_id: "region-id-1", - currency_code: "usd", + currency_code: "eur", + authorized_amount: 200, }) - ) - }) - it("should fail to retrieve a non existent Payment Collection", async () => { - let error = await service - .retrievePaymentCollection("pay-col-id-not-exists") - .catch((e) => e) + const collection = await service.retrievePaymentCollection( + "pay-col-id-2" + ) - expect(error.message).toContain( - "PaymentCollection with id: pay-col-id-not-exists was not found" - ) + expect(collection).toEqual( + expect.objectContaining({ + id: "pay-col-id-2", + authorized_amount: 200, + currency_code: "eur", + }) + ) + }) }) }) - describe("list", () => { - it("should list and count Payment Collection", async () => { - let [collections, count] = await service.listAndCountPaymentCollections() + describe("PaymentSession", () => { + let repositoryManager: SqlEntityManager - expect(count).toEqual(3) + beforeEach(async () => { + await MikroOrmWrapper.setupDatabase() + repositoryManager = await MikroOrmWrapper.forkManager() - expect(collections).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - id: "pay-col-id-1", - amount: 100, - region_id: "region-id-1", + service = await initialize({ + database: { + clientUrl: DB_URL, + schema: process.env.MEDUSA_PAYMNET_DB_SCHEMA, + }, + }) + + await createPaymentCollections(repositoryManager) + await createPaymentSessions(repositoryManager) + }) + + afterEach(async () => { + await MikroOrmWrapper.clearDatabase() + }) + + describe("create", () => { + it("should create a payment session successfully", async () => { + const paymentCollection = await service.createPaymentSession( + "pay-col-id-1", + { + amount: 200, + provider_id: "manual", currency_code: "usd", - }), + } + ) + + expect(paymentCollection).toEqual( expect.objectContaining({ - id: "pay-col-id-2", + id: "pay-col-id-1", + status: "not_paid", + payment_sessions: expect.arrayContaining([ + { + id: expect.any(String), + data: null, + status: "pending", + authorized_at: null, + currency_code: "usd", + amount: 200, + provider_id: "manual", + payment_collection: expect.objectContaining({ + id: paymentCollection.id, + }), + }, + ]), + }) + ) + }) + }) + }) + + describe("Payment", () => { + let repositoryManager: SqlEntityManager + + beforeEach(async () => { + await MikroOrmWrapper.setupDatabase() + repositoryManager = await MikroOrmWrapper.forkManager() + + service = await initialize({ + database: { + clientUrl: DB_URL, + schema: process.env.MEDUSA_PAYMNET_DB_SCHEMA, + }, + }) + + await createPaymentCollections(repositoryManager) + await createPaymentSessions(repositoryManager) + await createPayments(repositoryManager) + }) + + afterEach(async () => { + await MikroOrmWrapper.clearDatabase() + }) + + describe("create", () => { + it("should create a payment successfully", async () => { + let paymentCollection = await service.createPaymentCollection({ + currency_code: "usd", + amount: 200, + region_id: "reg", + }) + + paymentCollection = await service.createPaymentSession( + paymentCollection.id, + { amount: 200, - region_id: "region-id-1", + provider_id: "manual", currency_code: "usd", - }), + } + ) + + const createdPayment = await service.createPayment({ + data: {}, + amount: 200, + provider_id: "manual", + currency_code: "usd", + payment_collection_id: paymentCollection.id, + payment_session_id: paymentCollection.payment_sessions![0].id, + }) + + expect(createdPayment).toEqual( expect.objectContaining({ - id: "pay-col-id-3", - amount: 300, - region_id: "region-id-2", + id: expect.any(String), + authorized_amount: null, + cart_id: null, + order_id: null, + order_edit_id: null, + customer_id: null, + data: {}, + deleted_at: null, + captured_at: null, + canceled_at: null, + refunds: [], + captures: [], + amount: 200, currency_code: "usd", - }), - ]) - ) + provider_id: "manual", + payment_collection: expect.objectContaining({ + id: paymentCollection.id, + }), + payment_session: expect.objectContaining({ + id: paymentCollection.payment_sessions![0].id, + }), + }) + ) + }) }) - it("should list Payment Collections by region_id", async () => { - let collections = await service.listPaymentCollections( - { - region_id: "region-id-1", - }, - { select: ["id", "amount", "region_id"] } - ) + describe("update", () => { + it("should update a payment successfully", async () => { + const updatedPayment = await service.updatePayment({ + id: "pay-id-1", + cart_id: "new-cart", + }) - expect(collections.length).toEqual(2) + expect(updatedPayment).toEqual( + expect.objectContaining({ + id: "pay-id-1", + cart_id: "new-cart", + }) + ) + }) + }) + + describe("capture", () => { + it("should capture a payment successfully", async () => { + const capturedPayment = await service.capturePayment({ + amount: 100, + payment_id: "pay-id-1", + }) - expect(collections).toEqual( - expect.arrayContaining([ + expect(capturedPayment).toEqual( expect.objectContaining({ - id: "pay-col-id-1", + id: "pay-id-1", amount: 100, - region_id: "region-id-1", - }), - expect.objectContaining({ - id: "pay-col-id-2", - amount: 200, - region_id: "region-id-1", - }), + + captures: [ + expect.objectContaining({ + created_by: null, + amount: 100, + }), + ], + + // TODO: uncomment when totals calculations are implemented + // captured_amount: 100, + // captured_at: expect.any(Date), + }) + ) + }) + + it("should capture payments in bulk successfully", async () => { + const capturedPayments = await service.capturePayment([ + { + amount: 50, // partially captured + payment_id: "pay-id-1", + }, + { + amount: 100, // fully captured + payment_id: "pay-id-2", + }, ]) - ) - }) - }) - describe("update", () => { - it("should update a Payment Collection", async () => { - await service.updatePaymentCollection({ - id: "pay-col-id-2", - currency_code: "eur", - authorized_amount: 200, + expect(capturedPayments).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: "pay-id-1", + amount: 100, + authorized_amount: 100, + captured_at: null, + captures: [ + expect.objectContaining({ + created_by: null, + amount: 50, + }), + ], + // captured_amount: 50, + }), + expect.objectContaining({ + id: "pay-id-2", + amount: 100, + authorized_amount: 100, + // captured_at: expect.any(Date), + captures: [ + expect.objectContaining({ + created_by: null, + amount: 100, + }), + ], + // captured_amount: 100, + }), + ]) + ) }) - const collection = await service.retrievePaymentCollection("pay-col-id-2") + // TODO: uncomment when totals are implemented + // it("should fail to capture payments in bulk if one of the captures fail", async () => { + // const error = await service + // .capturePayment([ + // { + // amount: 50, + // payment_id: "pay-id-1", + // }, + // { + // amount: 200, // exceeds authorized amount + // payment_id: "pay-id-2", + // }, + // ]) + // .catch((e) => e) + // + // expect(error.message).toEqual( + // "Total captured amount for payment: pay-id-2 exceeds authorized amount." + // ) + // }) + + // it("should fail to capture amount greater than authorized", async () => { + // const error = await service + // .capturePayment({ + // amount: 200, + // payment_id: "pay-id-1", + // }) + // .catch((e) => e) + // + // expect(error.message).toEqual( + // "Total captured amount for payment: pay-id-1 exceeds authorized amount." + // ) + // }) + + // it("should fail to capture already captured payment", async () => { + // await service.capturePayment({ + // amount: 100, + // payment_id: "pay-id-1", + // }) + // + // const error = await service + // .capturePayment({ + // amount: 100, + // payment_id: "pay-id-1", + // }) + // .catch((e) => e) + // + // expect(error.message).toEqual("The payment is already fully captured.") + // }) + }) - expect(collection).toEqual( - expect.objectContaining({ - id: "pay-col-id-2", - authorized_amount: 200, - currency_code: "eur", + describe("refund", () => { + it("should refund a payments in bulk successfully", async () => { + await service.capturePayment({ + amount: 100, + payment_id: "pay-id-1", + }) + + await service.capturePayment({ + amount: 100, + payment_id: "pay-id-2", }) - ) + + const refundedPayment = await service.refundPayment([ + { + amount: 100, + payment_id: "pay-id-1", + }, + { + amount: 100, + payment_id: "pay-id-2", + }, + ]) + + expect(refundedPayment).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: "pay-id-1", + amount: 100, + refunds: [ + expect.objectContaining({ + created_by: null, + amount: 100, + }), + ], + // captured_amount: 100, + // refunded_amount: 100, + }), + expect.objectContaining({ + id: "pay-id-2", + amount: 100, + refunds: [ + expect.objectContaining({ + created_by: null, + amount: 100, + }), + ], + // captured_amount: 100, + // refunded_amount: 100, + }), + ]) + ) + }) + + // it("should throw if refund is greater than captured amount", async () => { + // await service.capturePayment({ + // amount: 50, + // payment_id: "pay-id-1", + // }) + // + // const error = await service + // .refundPayment({ + // amount: 100, + // payment_id: "pay-id-1", + // }) + // .catch((e) => e) + // + // expect(error.message).toEqual( + // "Refund amount for payment: pay-id-1 cannot be greater than the amount captured on the payment." + // ) + // }) }) }) }) diff --git a/packages/payment/src/models/capture.ts b/packages/payment/src/models/capture.ts index 5c8d1eb53a7e8..f0d5be2a25cf8 100644 --- a/packages/payment/src/models/capture.ts +++ b/packages/payment/src/models/capture.ts @@ -31,7 +31,7 @@ export default class Capture { index: "IDX_capture_payment_id", fieldName: "payment_id", }) - payment: Payment + payment!: Payment @Property({ onCreate: () => new Date(), diff --git a/packages/payment/src/models/payment-session.ts b/packages/payment/src/models/payment-session.ts index f001852af5098..1e11878ae12e0 100644 --- a/packages/payment/src/models/payment-session.ts +++ b/packages/payment/src/models/payment-session.ts @@ -5,6 +5,7 @@ import { ManyToOne, OneToOne, OnInit, + OptionalProps, PrimaryKey, Property, } from "@mikro-orm/core" @@ -15,6 +16,8 @@ import Payment from "./payment" @Entity({ tableName: "payment_session" }) export default class PaymentSession { + [OptionalProps]?: "status" + @PrimaryKey({ columnType: "text" }) id: string @@ -36,7 +39,7 @@ export default class PaymentSession { @Enum({ items: () => PaymentSessionStatus, }) - status: PaymentSessionStatus + status: PaymentSessionStatus = PaymentSessionStatus.PENDING @Property({ columnType: "timestamptz", @@ -47,15 +50,17 @@ export default class PaymentSession { @ManyToOne({ index: "IDX_payment_session_payment_collection_id", fieldName: "payment_collection_id", + onDelete: "cascade", }) payment_collection!: PaymentCollection @OneToOne({ entity: () => Payment, - mappedBy: (payment) => payment.session, + mappedBy: (payment) => payment.payment_session, cascade: ["soft-remove"] as any, + nullable: true, }) - payment!: Payment + payment?: Payment | null @BeforeCreate() onCreate() { diff --git a/packages/payment/src/models/payment.ts b/packages/payment/src/models/payment.ts index 4647be64ac245..c8dfd2619b8ec 100644 --- a/packages/payment/src/models/payment.ts +++ b/packages/payment/src/models/payment.ts @@ -115,16 +115,17 @@ export default class Payment { @ManyToOne({ index: "IDX_payment_payment_collection_id", fieldName: "payment_collection_id", + onDelete: "cascade", }) - payment_collection: PaymentCollection + payment_collection!: PaymentCollection @OneToOne({ owner: true, fieldName: "session_id" }) - session: PaymentSession + payment_session!: PaymentSession /** COMPUTED PROPERTIES START **/ - // captured_amount: number // sum of the associated captures - // refunded_amount: number // sum of the associated refunds + captured_amount: number // sum of the associated captures + refunded_amount: number // sum of the associated refunds /** COMPUTED PROPERTIES END **/ diff --git a/packages/payment/src/models/refund.ts b/packages/payment/src/models/refund.ts index 4f99da250f72b..8213eff127f20 100644 --- a/packages/payment/src/models/refund.ts +++ b/packages/payment/src/models/refund.ts @@ -26,7 +26,7 @@ export default class Refund { index: "IDX_refund_payment_id", fieldName: "payment_id", }) - payment: Payment + payment!: Payment @Property({ onCreate: () => new Date(), diff --git a/packages/payment/src/services/payment-module.ts b/packages/payment/src/services/payment-module.ts index 1e62e664311eb..6f5b2b075af89 100644 --- a/packages/payment/src/services/payment-module.ts +++ b/packages/payment/src/services/payment-module.ts @@ -1,8 +1,11 @@ import { + CaptureDTO, Context, + CreateCaptureDTO, CreatePaymentCollectionDTO, CreatePaymentDTO, CreatePaymentSessionDTO, + CreateRefundDTO, DAL, InternalModuleDeclaration, IPaymentModuleService, @@ -10,6 +13,8 @@ import { ModulesSdkTypes, PaymentCollectionDTO, PaymentDTO, + PaymentSessionDTO, + RefundDTO, SetPaymentSessionsDTO, UpdatePaymentCollectionDTO, UpdatePaymentDTO, @@ -18,6 +23,8 @@ import { InjectTransactionManager, MedusaContext, ModulesSdkUtils, + MedusaError, + InjectManager, } from "@medusajs/utils" import { entityNameToLinkableKeysMap, joinerConfig } from "../joiner-config" @@ -25,49 +32,58 @@ import { Capture, Payment, PaymentCollection, - PaymentMethodToken, - PaymentProvider, PaymentSession, Refund, } from "@models" type InjectedDependencies = { baseRepository: DAL.RepositoryService + paymentService: ModulesSdkTypes.InternalModuleService + captureService: ModulesSdkTypes.InternalModuleService + refundService: ModulesSdkTypes.InternalModuleService + paymentSessionService: ModulesSdkTypes.InternalModuleService paymentCollectionService: ModulesSdkTypes.InternalModuleService } -const generateMethodForModels = [ - Capture, - PaymentCollection, - PaymentMethodToken, - PaymentProvider, - PaymentSession, - Refund, -] +const generateMethodForModels = [PaymentCollection, PaymentSession] export default class PaymentModuleService< - TPaymentCollection extends PaymentCollection = PaymentCollection + TPaymentCollection extends PaymentCollection = PaymentCollection, + TPayment extends Payment = Payment, + TCapture extends Capture = Capture, + TRefund extends Refund = Refund, + TPaymentSession extends PaymentSession = PaymentSession > extends ModulesSdkUtils.abstractModuleServiceFactory< - // TODO revisit when moving forward frane InjectedDependencies, - PaymentDTO, + PaymentCollectionDTO, { - Capture: { dto: any } - PaymentCollection: { dto: any } - PaymentMethodToken: { dto: any } - PaymentProvider: { dto: any } - PaymentSession: { dto: any } - Refund: { dto: any } + PaymentCollection: { dto: PaymentCollectionDTO } + PaymentSession: { dto: PaymentSessionDTO } + Payment: { dto: PaymentDTO } + Capture: { dto: CaptureDTO } + Refund: { dto: RefundDTO } } - >(Payment, generateMethodForModels, entityNameToLinkableKeysMap) + >(PaymentCollection, generateMethodForModels, entityNameToLinkableKeysMap) implements IPaymentModuleService { protected baseRepository_: DAL.RepositoryService + + protected paymentService_: ModulesSdkTypes.InternalModuleService + protected captureService_: ModulesSdkTypes.InternalModuleService + protected refundService_: ModulesSdkTypes.InternalModuleService + protected paymentSessionService_: ModulesSdkTypes.InternalModuleService protected paymentCollectionService_: ModulesSdkTypes.InternalModuleService constructor( - { baseRepository, paymentCollectionService }: InjectedDependencies, + { + baseRepository, + paymentService, + captureService, + refundService, + paymentSessionService, + paymentCollectionService, + }: InjectedDependencies, protected readonly moduleDeclaration: InternalModuleDeclaration ) { // @ts-ignore @@ -75,6 +91,10 @@ export default class PaymentModuleService< this.baseRepository_ = baseRepository + this.refundService_ = refundService + this.captureService_ = captureService + this.paymentService_ = paymentService + this.paymentSessionService_ = paymentSessionService this.paymentCollectionService_ = paymentCollectionService } @@ -140,41 +160,38 @@ export default class PaymentModuleService< ) } - /** - * TODO - */ + createPayment( + data: CreatePaymentDTO, + sharedContext?: Context + ): Promise + createPayment( + data: CreatePaymentDTO[], + sharedContext?: Context + ): Promise - authorizePaymentCollection( - paymentCollectionId: string, - sharedContext?: Context | undefined - ): Promise { - throw new Error("Method not implemented.") - } - completePaymentCollection( - paymentCollectionId: string, - sharedContext?: Context | undefined - ): Promise { - throw new Error("Method not implemented.") - } - createPayment(data: CreatePaymentDTO): Promise - createPayment(data: CreatePaymentDTO[]): Promise - createPayment(data: unknown): Promise { - throw new Error("Method not implemented.") - } - capturePayment( - paymentId: string, - amount: number, - sharedContext?: Context | undefined - ): Promise { - throw new Error("Method not implemented.") - } - refundPayment( - paymentId: string, - amount: number, - sharedContext?: Context | undefined - ): Promise { - throw new Error("Method not implemented.") + @InjectTransactionManager("baseRepository_") + async createPayment( + data: CreatePaymentDTO | CreatePaymentDTO[], + @MedusaContext() sharedContext?: Context + ): Promise { + let input = Array.isArray(data) ? data : [data] + + input = input.map((inputData) => ({ + payment_collection: inputData.payment_collection_id, + payment_session: inputData.payment_session_id, + ...inputData, + })) + + const payments = await this.paymentService_.create(input, sharedContext) + + return await this.baseRepository_.serialize( + Array.isArray(data) ? payments : payments[0], + { + populate: true, + } + ) } + updatePayment( data: UpdatePaymentDTO, sharedContext?: Context | undefined @@ -183,12 +200,181 @@ export default class PaymentModuleService< data: UpdatePaymentDTO[], sharedContext?: Context | undefined ): Promise - updatePayment( - data: unknown, - sharedContext?: unknown + + @InjectTransactionManager("baseRepository_") + async updatePayment( + data: UpdatePaymentDTO | UpdatePaymentDTO[], + @MedusaContext() sharedContext?: Context ): Promise { - throw new Error("Method not implemented.") + const input = Array.isArray(data) ? data : [data] + const result = await this.paymentService_.update(input, sharedContext) + + return await this.baseRepository_.serialize( + Array.isArray(data) ? result : result[0], + { + populate: true, + } + ) + } + + capturePayment( + data: CreateCaptureDTO, + sharedContext?: Context + ): Promise + capturePayment( + data: CreateCaptureDTO[], + sharedContext?: Context + ): Promise + + @InjectManager("baseRepository_") + async capturePayment( + data: CreateCaptureDTO | CreateCaptureDTO[], + @MedusaContext() sharedContext: Context = {} + ): Promise { + const input = Array.isArray(data) ? data : [data] + + const payments = await this.capturePaymentBulk_(input, sharedContext) + + return await this.baseRepository_.serialize( + Array.isArray(data) ? payments : payments[0], + { populate: true } + ) + } + + @InjectTransactionManager("baseRepository_") + protected async capturePaymentBulk_( + data: CreateCaptureDTO[], + @MedusaContext() sharedContext?: Context + ): Promise { + let payments = await this.paymentService_.list( + { id: data.map((d) => d.payment_id) }, + {}, + sharedContext + ) + const inputMap = new Map(data.map((d) => [d.payment_id, d])) + + for (const payment of payments) { + const input = inputMap.get(payment.id)! + + if (payment.captured_at) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + "The payment is already fully captured." + ) + } + + // TODO: revisit when https://github.com/medusajs/medusa/pull/6253 is merged + // if (payment.captured_amount + input.amount > payment.authorized_amount) { + // throw new MedusaError( + // MedusaError.Types.INVALID_DATA, + // `Total captured amount for payment: ${payment.id} exceeds authorized amount.` + // ) + // } + } + + await this.captureService_.create( + data.map((d) => ({ + payment: d.payment_id, + amount: d.amount, + captured_by: d.captured_by, + })), + sharedContext + ) + + let fullyCapturedPaymentsId: string[] = [] + for (const payment of payments) { + const input = inputMap.get(payment.id)! + + // TODO: revisit when https://github.com/medusajs/medusa/pull/6253 is merged + // if (payment.captured_amount + input.amount === payment.amount) { + // fullyCapturedPaymentsId.push(payment.id) + // } + } + + if (fullyCapturedPaymentsId.length) { + await this.paymentService_.update( + fullyCapturedPaymentsId.map((id) => ({ id, captured_at: new Date() })), + sharedContext + ) + } + + // TODO: set PaymentCollection status if fully captured + + return await this.paymentService_.list( + { id: data.map((d) => d.payment_id) }, + { + relations: ["captures"], + }, + sharedContext + ) } + + refundPayment( + data: CreateRefundDTO, + sharedContext?: Context + ): Promise + refundPayment( + data: CreateRefundDTO[], + sharedContext?: Context + ): Promise + + @InjectManager("baseRepository_") + async refundPayment( + data: CreateRefundDTO | CreateRefundDTO[], + @MedusaContext() sharedContext?: Context + ): Promise { + const input = Array.isArray(data) ? data : [data] + + const payments = await this.refundPaymentBulk_(input, sharedContext) + + return await this.baseRepository_.serialize( + Array.isArray(data) ? payments : payments[0], + { populate: true } + ) + } + + @InjectTransactionManager("baseRepository_") + async refundPaymentBulk_( + data: CreateRefundDTO[], + @MedusaContext() sharedContext?: Context + ): Promise { + const payments = await this.paymentService_.list( + { id: data.map(({ payment_id }) => payment_id) }, + {}, + sharedContext + ) + + const inputMap = new Map(data.map((d) => [d.payment_id, d])) + + // TODO: revisit when https://github.com/medusajs/medusa/pull/6253 is merged + // for (const payment of payments) { + // const input = inputMap.get(payment.id)! + // if (payment.captured_amount < input.amount) { + // throw new MedusaError( + // MedusaError.Types.INVALID_DATA, + // `Refund amount for payment: ${payment.id} cannot be greater than the amount captured on the payment.` + // ) + // } + // } + + await this.refundService_.create( + data.map((d) => ({ + payment: d.payment_id, + amount: d.amount, + captured_by: d.created_by, + })), + sharedContext + ) + + return await this.paymentService_.list( + { id: data.map(({ payment_id }) => payment_id) }, + { + relations: ["refunds"], + }, + sharedContext + ) + } + createPaymentSession( paymentCollectionId: string, data: CreatePaymentSessionDTO, @@ -199,13 +385,61 @@ export default class PaymentModuleService< data: CreatePaymentSessionDTO[], sharedContext?: Context | undefined ): Promise - createPaymentSession( - paymentCollectionId: unknown, - data: unknown, - sharedContext?: unknown + + @InjectTransactionManager("baseRepository_") + async createPaymentSession( + paymentCollectionId: string, + data: CreatePaymentSessionDTO | CreatePaymentSessionDTO[], + @MedusaContext() sharedContext?: Context + ): Promise { + let input = Array.isArray(data) ? data : [data] + + input = input.map((inputData) => ({ + payment_collection: paymentCollectionId, + ...inputData, + })) + + await this.paymentSessionService_.create(input, sharedContext) + + return await this.retrievePaymentCollection( + paymentCollectionId, + { + relations: ["payment_sessions"], + }, + sharedContext + ) + } + + /** + * TODO + */ + + authorizePaymentCollection( + paymentCollectionId: string, + sharedContext?: Context | undefined + ): Promise { + throw new Error("Method not implemented.") + } + completePaymentCollection( + paymentCollectionId: string, + sharedContext?: Context | undefined ): Promise { throw new Error("Method not implemented.") } + + cancelPayment(paymentId: string, sharedContext?: Context): Promise + cancelPayment( + paymentId: string[], + sharedContext?: Context + ): Promise + + cancelPayment( + paymentId: string | string[], + sharedContext?: Context + ): Promise { + throw new Error("Method not implemented.") + } + authorizePaymentSessions( paymentCollectionId: string, sessionIds: string[], diff --git a/packages/types/src/payment/common.ts b/packages/types/src/payment/common.ts index 5f4b303bff64f..021138cb461a8 100644 --- a/packages/types/src/payment/common.ts +++ b/packages/types/src/payment/common.ts @@ -36,6 +36,76 @@ export interface PaymentCollectionDTO { * The ID of the Payment Collection */ id: string + + /** + * The currency of the payments/sessions associated with payment collection + */ + currency_code: string + /** + * The id of the region + */ + region_id: string + + /** + * The amount + */ + amount: number + + /** + * The amount authorized within associated payment sessions + */ + authorized_amount?: number + + /** + * The amount refunded from associated payments + */ + refunded_amount?: number + + /** + * When the payment collection was completed + */ + completed_at?: string | Date + + /** + * When the payment collection was created + */ + created_at?: string | Date + + /** + * When the payment collection was updated + */ + updated_at?: string | Date + + /** + * Holds custom data in key-value pairs + */ + metadata?: Record | null + + /** + * The status of the payment collection + */ + status: PaymentCollectionStatus + + /** + * The payment provider for the payments + * + * @expandable + */ + payment_providers: PaymentProviderDTO[] + + /** + * The associated payment sessions + * + * @expandable + */ + payment_sessions?: PaymentSessionDTO[] + + /** + * The associated payments + * + * @expandable + */ + payments?: PaymentDTO[] } export interface FilterablePaymentCollectionProps @@ -52,7 +122,142 @@ export interface FilterablePaymentCollectionProps export interface PaymentDTO { /** - * The ID of the Payment Collection + * The ID of the Payment */ id: string + + /** + * The payment amount + */ + amount: number + + authorized_amount?: number + + /** + * Payment currency + */ + currency_code: string + + /** + * The ID of payment provider + */ + provider_id: string + + cart_id?: string + order_id?: string + order_edit_id?: string + customer_id?: string + + /** + * Payment provider data + */ + data?: Record + + /** + * When the payment collection was created + */ + created_at?: string | Date + + /** + * When the payment collection was updated + */ + updated_at?: string | Date + + /** + * When the payment was captured + */ + captured_at?: string | Date + + /** + * When the payment was canceled + */ + canceled_at?: string | Date + + /** + * The sum of the associated captures + */ + captured_amount?: number + + /** + * The sum of the associated refunds + */ + refunded_amount?: number + + /** + * The associated payment captures + * + * @expandable + */ + captures?: CaptureDTO[] + + /** + * The associated refunds of the payment + * + * @expandable + */ + refunds?: RefundDTO[] + + /** + * The payment collection the payment is associated with + * + * @expandable + */ + payment_collection?: PaymentCollectionDTO + + /** + * The payment session from which the payment is created + * + * @expandable + */ + payment_session?: PaymentSessionDTO +} + +export interface CaptureDTO { + /** + * The ID of the Capture + */ + id: string + + /** + * Captured amount + */ + amount: number + + created_at: Date + + created_by?: string + + payment: PaymentDTO +} + +export interface RefundDTO { + /** + * The ID of the Refund + */ + id: string + + /** + * Refunded amount + */ + amount: number + + created_at: Date + + created_by?: string + + payment: PaymentDTO +} + +/* ********** PAYMENT ********** */ + +export interface PaymentSessionDTO { + /** + * The ID of the Payment Session + */ + id: string +} + +export interface PaymentProviderDTO { + id: string + is_enabled: string } diff --git a/packages/types/src/payment/mutations.ts b/packages/types/src/payment/mutations.ts index c5cfa3e9b9bdc..92d6ed6487ef3 100644 --- a/packages/types/src/payment/mutations.ts +++ b/packages/types/src/payment/mutations.ts @@ -17,7 +17,6 @@ export interface UpdatePaymentCollectionDTO authorized_amount?: number refunded_amount?: number - completed_at?: number status?: PaymentCollectionStatus } @@ -31,6 +30,9 @@ export interface CreatePaymentDTO { provider_id: string data: Record + payment_session_id: string + payment_collection_id: string + cart_id?: string order_id?: string order_edit_id?: string @@ -38,10 +40,28 @@ export interface CreatePaymentDTO { } export interface UpdatePaymentDTO { + id: string + cart_id?: string order_id?: string order_edit_id?: string customer_id?: string + + data?: Record +} + +export interface CreateCaptureDTO { + amount: number + payment_id: string + + captured_by?: string +} + +export interface CreateRefundDTO { + amount: number + payment_id: string + + created_by?: string } /** diff --git a/packages/types/src/payment/service.ts b/packages/types/src/payment/service.ts index ed5c12d60151f..e038a590bf66e 100644 --- a/packages/types/src/payment/service.ts +++ b/packages/types/src/payment/service.ts @@ -1,9 +1,11 @@ import { IModuleService } from "../modules-sdk" import { Context } from "../shared-context" import { + CreateCaptureDTO, CreatePaymentCollectionDTO, CreatePaymentDTO, CreatePaymentSessionDTO, + CreateRefundDTO, SetPaymentSessionsDTO, UpdatePaymentCollectionDTO, UpdatePaymentDTO, @@ -75,19 +77,38 @@ export interface IPaymentModuleService extends IModuleService { /* ********** PAYMENT ********** */ - createPayment(data: CreatePaymentDTO): Promise - createPayment(data: CreatePaymentDTO[]): Promise + createPayment( + data: CreatePaymentDTO, + sharedContext?: Context + ): Promise + createPayment( + data: CreatePaymentDTO[], + sharedContext?: Context + ): Promise capturePayment( - paymentId: string, - amount: number, + data: CreateCaptureDTO, sharedContext?: Context ): Promise + capturePayment( + data: CreateCaptureDTO[], + sharedContext?: Context + ): Promise + refundPayment( - paymentId: string, - amount: number, + data: CreateRefundDTO, sharedContext?: Context ): Promise + refundPayment( + data: CreateRefundDTO[], + sharedContext?: Context + ): Promise + + cancelPayment(paymentId: string, sharedContext?: Context): Promise + cancelPayment( + paymentId: string[], + sharedContext?: Context + ): Promise updatePayment( data: UpdatePaymentDTO,