From 755ba90c0564d7317dbce6a5272fc1cf9eeb1210 Mon Sep 17 00:00:00 2001 From: "Carlos R. L. Rodrigues" <37986729+carlos-r-l-rodrigues@users.noreply.github.com> Date: Fri, 11 Nov 2022 14:28:45 -0300 Subject: [PATCH] feat(medusa): Payment Collection endpoints (#2525) --- .../api/__tests__/admin/colllections.js | 2 +- .../api/__tests__/admin/order-edit.js | 96 ++++++- .../api/__tests__/store/collections.js | 2 +- integration-tests/api/factories/index.ts | 1 + .../api/factories/simple-line-item-factory.ts | 2 + .../factories/simple-order-edit-factory.ts | 2 + .../simple-payment-collection-factory.ts | 48 ++++ integration-tests/api/package.json | 2 +- .../api/src/services/test-pay.js | 2 +- integration-tests/development/dev-require.js | 11 +- .../development/src/services/test-pay.js | 2 +- .../plugins/src/services/test-pay.js | 2 +- package.json | 2 +- .../src/services/mobilepay-adyen.js | 10 + .../src/services/klarna-provider.js | 70 +++-- .../src/api/routes/hooks/paypal.js | 12 +- .../src/services/paypal-provider.js | 2 +- .../src/api/routes/hooks/stripe.js | 36 ++- .../src/helpers/stripe-base.js | 4 +- .../src/services/stripe-provider.js | 14 +- packages/medusa/src/api/index.js | 2 + packages/medusa/src/api/routes/admin/index.js | 4 + .../__tests__/request-confirmation.ts | 6 + .../src/api/routes/admin/order-edits/index.ts | 3 + .../admin/order-edits/request-confirmation.ts | 61 ++++- .../delete-payment-collection.ts | 13 + .../get-payment-collection.ts | 20 ++ .../routes/admin/payment-collections/index.ts | 66 +++++ .../update-payment-collection.ts | 34 +++ .../routes/admin/payments/capture-payment.ts | 11 + .../api/routes/admin/payments/get-payment.ts | 15 + .../src/api/routes/admin/payments/index.ts | 57 ++++ .../routes/admin/payments/refund-payment.ts | 41 +++ packages/medusa/src/api/routes/store/index.js | 2 + .../store/order-edits/complete-order-edit.ts | 53 +++- .../authorize-payment-collection.ts | 15 + .../get-payment-collection.ts | 20 ++ .../routes/store/payment-collections/index.ts | 70 +++++ .../manage-payment-sessions.ts | 51 ++++ .../refresh-payment-session.ts | 32 +++ .../medusa/src/interfaces/payment-service.ts | 2 +- .../1664880666982-payment-collection.ts | 8 +- .../medusa/src/models/payment-collection.ts | 17 +- .../medusa/src/models/publishable-api-key.ts | 2 +- .../src/services/__mocks__/order-edit.js | 18 +- .../medusa/src/services/__mocks__/order.js | 2 + .../services/__mocks__/payment-collection.js | 17 ++ .../medusa/src/services/__mocks__/payment.js | 15 + .../src/services/__tests__/order-edit.ts | 5 +- .../services/__tests__/payment-collection.ts | 66 +---- .../services/__tests__/payment-provider.js | 73 ++--- packages/medusa/src/services/index.ts | 1 + packages/medusa/src/services/order-edit.ts | 2 +- .../medusa/src/services/payment-collection.ts | 259 ++---------------- .../medusa/src/services/payment-provider.ts | 54 ++-- packages/medusa/src/services/payment.ts | 229 ++++++++++++++++ packages/medusa/src/types/order-edit.ts | 2 + .../medusa/src/types/payment-collection.ts | 4 - 58 files changed, 1190 insertions(+), 484 deletions(-) create mode 100644 integration-tests/api/factories/simple-payment-collection-factory.ts create mode 100644 packages/medusa/src/api/routes/admin/payment-collections/delete-payment-collection.ts create mode 100644 packages/medusa/src/api/routes/admin/payment-collections/get-payment-collection.ts create mode 100644 packages/medusa/src/api/routes/admin/payment-collections/index.ts create mode 100644 packages/medusa/src/api/routes/admin/payment-collections/update-payment-collection.ts create mode 100644 packages/medusa/src/api/routes/admin/payments/capture-payment.ts create mode 100644 packages/medusa/src/api/routes/admin/payments/get-payment.ts create mode 100644 packages/medusa/src/api/routes/admin/payments/index.ts create mode 100644 packages/medusa/src/api/routes/admin/payments/refund-payment.ts create mode 100644 packages/medusa/src/api/routes/store/payment-collections/authorize-payment-collection.ts create mode 100644 packages/medusa/src/api/routes/store/payment-collections/get-payment-collection.ts create mode 100644 packages/medusa/src/api/routes/store/payment-collections/index.ts create mode 100644 packages/medusa/src/api/routes/store/payment-collections/manage-payment-sessions.ts create mode 100644 packages/medusa/src/api/routes/store/payment-collections/refresh-payment-session.ts create mode 100644 packages/medusa/src/services/__mocks__/payment-collection.js create mode 100644 packages/medusa/src/services/__mocks__/payment.js create mode 100644 packages/medusa/src/services/payment.ts diff --git a/integration-tests/api/__tests__/admin/colllections.js b/integration-tests/api/__tests__/admin/colllections.js index c650c163e8..b3284f0ca8 100644 --- a/integration-tests/api/__tests__/admin/colllections.js +++ b/integration-tests/api/__tests__/admin/colllections.js @@ -29,7 +29,7 @@ describe("/admin/collections", () => { beforeAll(async () => { const cwd = path.resolve(path.join(__dirname, "..", "..")) dbConnection = await initDb({ cwd }) - medusaProcess = await setupServer({ cwd }) + medusaProcess = await setupServer({ cwd, verbose: true }) }) afterAll(async () => { diff --git a/integration-tests/api/__tests__/admin/order-edit.js b/integration-tests/api/__tests__/admin/order-edit.js index 4c6f333b82..ba4c71567a 100644 --- a/integration-tests/api/__tests__/admin/order-edit.js +++ b/integration-tests/api/__tests__/admin/order-edit.js @@ -22,7 +22,7 @@ const { } = require("../../factories") const { OrderEditItemChangeType, OrderEdit } = require("@medusajs/medusa") -jest.setTimeout(50000) +jest.setTimeout(30000) const adminHeaders = { headers: { @@ -869,23 +869,82 @@ describe("[MEDUSA_FF_ORDER_EDITING] /admin/order-edits", () => { await adminSeeder(dbConnection) const product1 = await simpleProductFactory(dbConnection) + const product2 = await simpleProductFactory(dbConnection) - const { id, order_id } = await simpleOrderEditFactory(dbConnection, { + const order = await simpleOrderFactory(dbConnection, { + id: IdMap.getId("order-test-2"), + email: "test@testson.com", + tax_rate: null, + fulfillment_status: "fulfilled", + payment_status: "captured", + region: { + id: "test-region", + name: "Test region", + tax_rate: 0, + }, + line_items: [ + { + id: "lineItemId1", + variant_id: product2.variants[0].id, + quantity: 1, + fulfilled_quantity: 1, + shipped_quantity: 1, + unit_price: 1000, + tax_lines: [ + { + rate: 10, + code: "code1", + name: "code1", + }, + ], + }, + ], + shipping_methods: [ + { + shipping_option: { + name: "random", + region_id: "test-region", + }, + price: 10, + tax_lines: [ + { + rate: 0, + code: "code1", + name: "code1", + }, + ], + }, + ], + }) + + const { id } = await simpleOrderEditFactory(dbConnection, { created_by: "admin_user", + order_id: order.id, }) const noChangesEdit = await simpleOrderEditFactory(dbConnection, { created_by: "admin_user", }) - await simpleLineItemFactory(dbConnection, { - order_id: order_id, + const lineItemAdded = await simpleLineItemFactory(dbConnection, { + order_id: null, + order_edit_id: id, variant_id: product1.variants[0].id, + unit_price: 2000, + quantity: 1, + tax_lines: [ + { + rate: 0, + code: "code1", + name: "code1", + }, + ], }) await simpleOrderItemChangeFactory(dbConnection, { order_edit_id: id, - type: "item_add", + type: OrderEditItemChangeType.ITEM_ADD, + line_item_id: lineItemAdded.id, }) orderEditId = id @@ -900,6 +959,28 @@ describe("[MEDUSA_FF_ORDER_EDITING] /admin/order-edits", () => { it("requests order edit", async () => { const api = useApi() + const result = await api.post( + `/admin/order-edits/${orderEditId}/request`, + { + payment_collection_description: "Payment collection description", + }, + adminHeaders + ) + + expect(result.status).toEqual(200) + expect(result.data.order_edit).toEqual( + expect.objectContaining({ + id: orderEditId, + requested_at: expect.any(String), + requested_by: "admin_user", + status: "requested", + }) + ) + }) + + it("creates payment collection if difference_due > 0", async () => { + const api = useApi() + const result = await api.post( `/admin/order-edits/${orderEditId}/request`, {}, @@ -913,6 +994,7 @@ describe("[MEDUSA_FF_ORDER_EDITING] /admin/order-edits", () => { requested_at: expect.any(String), requested_by: "admin_user", status: "requested", + payment_collection_id: expect.any(String), }) ) }) @@ -934,6 +1016,7 @@ describe("[MEDUSA_FF_ORDER_EDITING] /admin/order-edits", () => { ) } }) + it("requests order edit", async () => { const api = useApi() @@ -2635,7 +2718,6 @@ describe("[MEDUSA_FF_ORDER_EDITING] /admin/order-edits", () => { const lineItemId2Discount = IdMap.getId("line-item-2-discount") beforeEach(async () => { - const api = useApi() await adminSeeder(dbConnection) product = await simpleProductFactory(dbConnection, { @@ -2646,7 +2728,7 @@ describe("[MEDUSA_FF_ORDER_EDITING] /admin/order-edits", () => { id: prodId2, }) - const reagion = await simpleRegionFactory(dbConnection, { + const region = await simpleRegionFactory(dbConnection, { id: "test-region", name: "Test region", tax_rate: 12.5, diff --git a/integration-tests/api/__tests__/store/collections.js b/integration-tests/api/__tests__/store/collections.js index a630f6aca7..328b151d27 100644 --- a/integration-tests/api/__tests__/store/collections.js +++ b/integration-tests/api/__tests__/store/collections.js @@ -14,7 +14,7 @@ describe("/store/collections", () => { beforeAll(async () => { const cwd = path.resolve(path.join(__dirname, "..", "..")) dbConnection = await initDb({ cwd }) - medusaProcess = await setupServer({ cwd }) + medusaProcess = await setupServer({ cwd, verbose: true }) }) afterAll(async () => { diff --git a/integration-tests/api/factories/index.ts b/integration-tests/api/factories/index.ts index 16a6830152..1ed9578557 100644 --- a/integration-tests/api/factories/index.ts +++ b/integration-tests/api/factories/index.ts @@ -18,3 +18,4 @@ export * from "./simple-price-list-factory" export * from "./simple-batch-job-factory" export * from "./simple-sales-channel-factory" export * from "./simple-custom-shipping-option-factory" +export * from "./simple-payment-collection-factory" diff --git a/integration-tests/api/factories/simple-line-item-factory.ts b/integration-tests/api/factories/simple-line-item-factory.ts index 98d8ccedf2..aac65d870b 100644 --- a/integration-tests/api/factories/simple-line-item-factory.ts +++ b/integration-tests/api/factories/simple-line-item-factory.ts @@ -31,6 +31,7 @@ export type LineItemFactoryData = { tax_lines?: TaxLineFactoryData[] adjustments: LineItemAdjustmentFactoryData[] includes_tax?: boolean + order_edit_id?: string } export const simpleLineItemFactory = async ( @@ -72,6 +73,7 @@ export const simpleLineItemFactory = async ( returned_quantity: data.returned_quantity || null, adjustments: data.adjustments, includes_tax: data.includes_tax, + order_edit_id: data.order_edit_id, }) const line = await manager.save(toSave) diff --git a/integration-tests/api/factories/simple-order-edit-factory.ts b/integration-tests/api/factories/simple-order-edit-factory.ts index da89fb27b2..64d1ef2777 100644 --- a/integration-tests/api/factories/simple-order-edit-factory.ts +++ b/integration-tests/api/factories/simple-order-edit-factory.ts @@ -8,6 +8,7 @@ export type OrderEditFactoryData = { order_id?: string internal_note?: string declined_reason?: string + payment_collection_id?: string confirmed_at?: Date | string confirmed_by?: string created_at?: Date | string @@ -46,6 +47,7 @@ export const simpleOrderEditFactory = async ( created_by: data.created_by, confirmed_at: data.confirmed_at, confirmed_by: data.confirmed_by, + payment_collection_id: data.payment_collection_id, }) return await manager.save(orderEdit) diff --git a/integration-tests/api/factories/simple-payment-collection-factory.ts b/integration-tests/api/factories/simple-payment-collection-factory.ts new file mode 100644 index 0000000000..d86be9b355 --- /dev/null +++ b/integration-tests/api/factories/simple-payment-collection-factory.ts @@ -0,0 +1,48 @@ +import { Connection } from "typeorm" + +import { simpleRegionFactory } from "./simple-region-factory" +import { simplePaymentFactory } from "./simple-payment-factory" +import { Payment, PaymentCollection } from "@medusajs/medusa" + +export const simplePaymentCollectionFactory = async ( + connection: Connection, + data: Partial = {} +): Promise => { + const manager = connection.manager + + const defaultData = { + currency_code: data.currency_code ?? "usd", + } + + if (!data.region && !data.region_id) { + data.region = await simpleRegionFactory(connection, { + id: data.region_id || "test-region", + currency_code: defaultData.currency_code, + }) + data.region_id = data.region.id + } + + if (data.payments?.length) { + const payments: Payment[] = [] + for (const payment of data.payments) { + payment.currency_code = payment.currency_code ?? defaultData.currency_code + + const savedPayment = await simplePaymentFactory( + connection, + payment as any + ) + payments.push(savedPayment) + } + + data.payments = payments + } + + const obj = { + ...defaultData, + ...data, + } + + const payCol = manager.create(PaymentCollection, obj) + + return await manager.save(payCol) +} diff --git a/integration-tests/api/package.json b/integration-tests/api/package.json index b1ad5a6818..49381037db 100644 --- a/integration-tests/api/package.json +++ b/integration-tests/api/package.json @@ -4,7 +4,7 @@ "main": "index.js", "license": "MIT", "scripts": { - "test": "jest --silent=false --runInBand --bail", + "test": "jest --silent=false --runInBand --bail --detectOpenHandles --forceExit", "build": "babel src -d dist --extensions \".ts,.js\"" }, "dependencies": { diff --git a/integration-tests/api/src/services/test-pay.js b/integration-tests/api/src/services/test-pay.js index 9724be4e06..e37f3e9dab 100644 --- a/integration-tests/api/src/services/test-pay.js +++ b/integration-tests/api/src/services/test-pay.js @@ -12,7 +12,7 @@ class TestPayService extends AbstractPaymentService { } async retrieveSavedMethods(customer) { - return Promise.resolve([]) + return [] } async createPayment(cart) { diff --git a/integration-tests/development/dev-require.js b/integration-tests/development/dev-require.js index a795965a00..b47ffa20ce 100644 --- a/integration-tests/development/dev-require.js +++ b/integration-tests/development/dev-require.js @@ -12,17 +12,16 @@ function replacePath(requirePath, package, concatPackage = true) { const idx = requirePath.indexOf(package) const packPath = requirePath.substring(idx + package.length) - let newPath = path.resolve( + let newPath = medusaCore + - "/" + - (concatPackage ? package + "/" : "") + - packPath.replace("/dist", "/src").replace(".js", "") - ) + "/" + + (concatPackage ? package + "/" : "") + + packPath.replace("/dist", "/src").replace(".js", "") if (!newPath.includes("/src")) { newPath += "/src" } - return newPath + return path.resolve(newPath) } Module.prototype.require = function (...args) { diff --git a/integration-tests/development/src/services/test-pay.js b/integration-tests/development/src/services/test-pay.js index 9724be4e06..e37f3e9dab 100644 --- a/integration-tests/development/src/services/test-pay.js +++ b/integration-tests/development/src/services/test-pay.js @@ -12,7 +12,7 @@ class TestPayService extends AbstractPaymentService { } async retrieveSavedMethods(customer) { - return Promise.resolve([]) + return [] } async createPayment(cart) { diff --git a/integration-tests/plugins/src/services/test-pay.js b/integration-tests/plugins/src/services/test-pay.js index eb202c01c2..c678898453 100644 --- a/integration-tests/plugins/src/services/test-pay.js +++ b/integration-tests/plugins/src/services/test-pay.js @@ -12,7 +12,7 @@ class TestPayService extends AbstractPaymentService { } async retrieveSavedMethods(customer) { - return Promise.resolve([]) + return [] } async createPayment(cart) { diff --git a/package.json b/package.json index 0360cbe78d..b740fbe4ea 100644 --- a/package.json +++ b/package.json @@ -63,7 +63,7 @@ "jest": "jest", "test": "turbo run test", "test:integration": "NODE_ENV=test jest --runInBand --bail --config=integration-tests/jest.config.js", - "test:integration:api": "NODE_ENV=test jest --runInBand --bail --config=integration-tests/jest.config.js --projects=integration-tests/api", + "test:integration:api": "NODE_ENV=test jest --detectOpenHandles --forceExit --runInBand --bail --config=integration-tests/jest.config.js --projects=integration-tests/api", "test:integration:plugins": "NODE_ENV=test jest --runInBand --bail --config=integration-tests/jest.config.js --projects=integration-tests/plugins", "test:fixtures": "NODE_ENV=test jest --config=docs-util/jest.config.js --runInBand --bail", "openapi:generate": "node ./scripts/build-openapi.js", diff --git a/packages/medusa-payment-adyen/src/services/mobilepay-adyen.js b/packages/medusa-payment-adyen/src/services/mobilepay-adyen.js index 3cf851cd51..c139443c9a 100644 --- a/packages/medusa-payment-adyen/src/services/mobilepay-adyen.js +++ b/packages/medusa-payment-adyen/src/services/mobilepay-adyen.js @@ -20,6 +20,12 @@ class MobilePayAdyenService extends PaymentService { return raw } + async createPaymentNew(paymentInput) { + const raw = await this.adyenService_.createPaymentNew(paymentInput) + raw.type = "mobilepay" + return raw + } + async authorizePayment(sessionData, context) { return this.adyenService_.authorizePayment(sessionData, context) } @@ -32,6 +38,10 @@ class MobilePayAdyenService extends PaymentService { return this.adyenService_.updatePayment(data) } + async updatePaymentNew(data, _) { + return this.adyenService_.updatePaymentNew(data) + } + async updatePaymentData(sessionData, update) { return this.adyenService_.updatePaymentData(sessionData, update) } diff --git a/packages/medusa-payment-klarna/src/services/klarna-provider.js b/packages/medusa-payment-klarna/src/services/klarna-provider.js index f9acd742fe..0651111833 100644 --- a/packages/medusa-payment-klarna/src/services/klarna-provider.js +++ b/packages/medusa-payment-klarna/src/services/klarna-provider.js @@ -245,7 +245,7 @@ class KlarnaProviderService extends PaymentService { } } - replaceStringWithPropertyValue(string, obj) { + static replaceStringWithPropertyValue(string, obj) { const keys = Object.keys(obj) for (const key of keys) { if (string.includes(`{${key}}`)) { @@ -263,46 +263,44 @@ class KlarnaProviderService extends PaymentService { this.validateKlarnaOrderUrls("payment_collection_urls") - let order = { + const { currency_code, amount, resource_id } = paymentInput + + const order = { // Custom id is stored, such that we can use it for hooks - merchant_data: paymentInput.resource_id, + merchant_data: resource_id, locale: "en-US", - } + order_lines: [ + { + name: "Payment Collection", + quantity: 1, + unit_price: amount, + tax_rate: 0, + total_amount: amount, + total_tax_amount: 0, + }, + ], + // Defaults to Sweden + purchase_country: "SE", - const { currency_code, amount } = paymentInput + order_amount: amount, + order_tax_amount: 0, + purchase_currency: currency_code.toUpperCase(), - order.order_lines = [ - { - name: "Payment Collection", - quantity: 1, - unit_price: amount, - tax_rate: 0, - total_amount: amount, - total_tax_amount: 0, + merchant_urls: { + terms: KlarnaProviderService.replaceStringWithPropertyValue( + this.options_.payment_collection_urls.terms, + paymentInput + ), + checkout: KlarnaProviderService.replaceStringWithPropertyValue( + this.options_.payment_collection_urls.checkout, + paymentInput + ), + confirmation: KlarnaProviderService.replaceStringWithPropertyValue( + this.options_.payment_collection_urls.confirmation, + paymentInput + ), + push: `${this.backendUrl_}/klarna/push?klarna_order_id={checkout.order.id}`, }, - ] - - // Defaults to Sweden - order.purchase_country = "SE" - - order.order_amount = amount - order.order_tax_amount = 0 - order.purchase_currency = currency_code.toUpperCase() - - order.merchant_urls = { - terms: this.replaceStringWithPropertyValue( - this.options_.payment_collection_urls.terms, - paymentInput - ), - checkout: this.replaceStringWithPropertyValue( - this.options_.payment_collection_urls.checkout, - paymentInput - ), - confirmation: this.replaceStringWithPropertyValue( - this.options_.payment_collection_urls.confirmation, - paymentInput - ), - push: `${this.backendUrl_}/klarna/push?klarna_order_id={checkout.order.id}`, } return order diff --git a/packages/medusa-payment-paypal/src/api/routes/hooks/paypal.js b/packages/medusa-payment-paypal/src/api/routes/hooks/paypal.js index cee5b3a9b1..5fc48ea7c4 100644 --- a/packages/medusa-payment-paypal/src/api/routes/hooks/paypal.js +++ b/packages/medusa-payment-paypal/src/api/routes/hooks/paypal.js @@ -70,18 +70,15 @@ export default async (req, res) => { }) } - async function autorizePaymentCollection(req, payColId) { + async function autorizePaymentCollection(req, id, orderId) { const manager = req.scope.resolve("manager") const paymentCollectionService = req.scope.resolve( "paymentCollectonService" ) - await manager.transaction(async (m) => { - const payCol = await paymentCollectionService - .withTransaction(m) - .retrieve(payColId) + await manager.transaction(async (manager) => { + await paymentCollectionService.withTransaction(manager).authorize(id) }) - // TODO: complete authorization } try { @@ -100,7 +97,8 @@ export default async (req, res) => { } if (isPaymentCollection(customId)) { - await autorizePaymentCollection(req, customId) + const orderId = order.id + await autorizePaymentCollection(req, customId, orderId) } else { await autorizeCart(req, customId) } diff --git a/packages/medusa-payment-paypal/src/services/paypal-provider.js b/packages/medusa-payment-paypal/src/services/paypal-provider.js index 8a147ae681..be488f0ef7 100644 --- a/packages/medusa-payment-paypal/src/services/paypal-provider.js +++ b/packages/medusa-payment-paypal/src/services/paypal-provider.js @@ -77,7 +77,7 @@ class PayPalProviderService extends PaymentService { * Not supported */ async retrieveSavedMethods(customer) { - return Promise.resolve([]) + return [] } /** diff --git a/packages/medusa-payment-stripe/src/api/routes/hooks/stripe.js b/packages/medusa-payment-stripe/src/api/routes/hooks/stripe.js index 90252b5102..33936889dc 100644 --- a/packages/medusa-payment-stripe/src/api/routes/hooks/stripe.js +++ b/packages/medusa-payment-stripe/src/api/routes/hooks/stripe.js @@ -50,13 +50,43 @@ export default async (req, res) => { res.sendStatus(200) } + async function handlePaymentCollection(event, req, res, id, paymentIntentId) { + const manager = req.scope.resolve("manager") + const paymentCollectionService = req.scope.resolve( + "paymentCollectionService" + ) + + const paycol = await paymentCollectionService + .retrieve(id, { relations: ["payments"] }) + .catch(() => undefined) + + if (paycol?.payments?.length) { + if (event.type === "payment_intent.succeeded") { + const payment = paycol.payments.find( + (pay) => pay.data.id === paymentIntentId + ) + if (payment && !payment.captured_at) { + await manager.transaction(async (manager) => { + await paymentCollectionService + .withTransaction(manager) + .capture(payment.id) + }) + } + + res.sendStatus(200) + return + } + } + res.sendStatus(204) + } + const paymentIntent = event.data.object - const cartId = paymentIntent.metadata.cart_id + const cartId = paymentIntent.metadata.cart_id // Backward compatibility const resourceId = paymentIntent.metadata.resource_id if (isPaymentCollection(resourceId)) { - // TODO: handle payment collection + await handlePaymentCollection(event, req, res, resourceId, paymentIntentId) } else { - await handleCartPayments(event, req, res, resourceId ?? cartId) + await handleCartPayments(event, req, res, cartId ?? resourceId) } } diff --git a/packages/medusa-payment-stripe/src/helpers/stripe-base.js b/packages/medusa-payment-stripe/src/helpers/stripe-base.js index 676cb99f26..9919e763e8 100644 --- a/packages/medusa-payment-stripe/src/helpers/stripe-base.js +++ b/packages/medusa-payment-stripe/src/helpers/stripe-base.js @@ -26,7 +26,7 @@ class StripeBase extends AbstractPaymentService { options ) /** @private @const {string[]} */ - this.paymentMethodTypes = paymentMethodTypes + this.paymentMethodTypes_ = paymentMethodTypes /** * Required Stripe options: @@ -93,7 +93,7 @@ class StripeBase extends AbstractPaymentService { * @return {Promise} saved payments methods */ async retrieveSavedMethods(customer) { - return Promise.resolve([]) + return [] } /** diff --git a/packages/medusa-payment-stripe/src/services/stripe-provider.js b/packages/medusa-payment-stripe/src/services/stripe-provider.js index 4b5a3fdb37..7d26ec3915 100644 --- a/packages/medusa-payment-stripe/src/services/stripe-provider.js +++ b/packages/medusa-payment-stripe/src/services/stripe-provider.js @@ -83,7 +83,7 @@ class StripeProviderService extends AbstractPaymentService { return methods.data } - return Promise.resolve([]) + return [] } /** @@ -222,7 +222,7 @@ class StripeProviderService extends AbstractPaymentService { */ async retrievePayment(data) { try { - return this.stripe_.paymentIntents.retrieve(data.id) + return await this.stripe_.paymentIntents.retrieve(data.id) } catch (error) { throw error } @@ -235,7 +235,7 @@ class StripeProviderService extends AbstractPaymentService { */ async getPaymentData(paymentSession) { try { - return this.stripe_.paymentIntents.retrieve(paymentSession.data.id) + return await this.stripe_.paymentIntents.retrieve(paymentSession.data.id) } catch (error) { throw error } @@ -259,7 +259,7 @@ class StripeProviderService extends AbstractPaymentService { async updatePaymentData(sessionData, update) { try { - return this.stripe_.paymentIntents.update(sessionData.id, { + return await this.stripe_.paymentIntents.update(sessionData.id, { ...update.data, }) } catch (error) { @@ -284,7 +284,7 @@ class StripeProviderService extends AbstractPaymentService { return sessionData } - return this.stripe_.paymentIntents.update(sessionData.id, { + return await this.stripe_.paymentIntents.update(sessionData.id, { amount: Math.round(cart.total), }) } @@ -304,7 +304,7 @@ class StripeProviderService extends AbstractPaymentService { return sessionData } - return this.stripe_.paymentIntents.update(paymentSessionData.id, { + return await this.stripe_.paymentIntents.update(paymentSessionData.id, { amount: Math.round(paymentInput.amount), }) } @@ -335,7 +335,7 @@ class StripeProviderService extends AbstractPaymentService { */ async updatePaymentIntentCustomer(paymentIntentId, customerId) { try { - return this.stripe_.paymentIntents.update(paymentIntentId, { + return await this.stripe_.paymentIntents.update(paymentIntentId, { customer: customerId, }) } catch (error) { diff --git a/packages/medusa/src/api/index.js b/packages/medusa/src/api/index.js index 0cbfa9e7aa..73d74354d9 100644 --- a/packages/medusa/src/api/index.js +++ b/packages/medusa/src/api/index.js @@ -29,6 +29,8 @@ export * from "./routes/admin/gift-cards" export * from "./routes/admin/invites" export * from "./routes/admin/notes" export * from "./routes/admin/notifications" +export * from "./routes/admin/payment-collections" +export * from "./routes/admin/payments" export * from "./routes/admin/order-edits" export * from "./routes/admin/orders" export * from "./routes/admin/price-lists" diff --git a/packages/medusa/src/api/routes/admin/index.js b/packages/medusa/src/api/routes/admin/index.js index 57b32643e8..1ba1494d4a 100644 --- a/packages/medusa/src/api/routes/admin/index.js +++ b/packages/medusa/src/api/routes/admin/index.js @@ -33,6 +33,8 @@ import taxRateRoutes from "./tax-rates" import uploadRoutes from "./uploads" import userRoutes, { unauthenticatedUserRoutes } from "./users" import variantRoutes from "./variants" +import paymentCollectionRoutes from "./payment-collections" +import paymentRoutes from "./payments" const route = Router() @@ -99,6 +101,8 @@ export default (app, container, config) => { uploadRoutes(route) userRoutes(route) variantRoutes(route) + paymentCollectionRoutes(route) + paymentRoutes(route) return app } diff --git a/packages/medusa/src/api/routes/admin/order-edits/__tests__/request-confirmation.ts b/packages/medusa/src/api/routes/admin/order-edits/__tests__/request-confirmation.ts index bc874857a7..6d93bdef5d 100644 --- a/packages/medusa/src/api/routes/admin/order-edits/__tests__/request-confirmation.ts +++ b/packages/medusa/src/api/routes/admin/order-edits/__tests__/request-confirmation.ts @@ -1,6 +1,7 @@ import { IdMap } from "medusa-test-utils" import { request } from "../../../../../helpers/test-request" import { orderEditServiceMock } from "../../../../../services/__mocks__/order-edit" + import OrderEditingFeatureFlag from "../../../../../loaders/feature-flags/order-editing" describe("GET /admin/order-edits/:id", () => { @@ -19,6 +20,9 @@ describe("GET /admin/order-edits/:id", () => { }, }, flags: [OrderEditingFeatureFlag], + payload: { + payment_collection_description: "PayCol description", + }, } ) }) @@ -33,6 +37,8 @@ describe("GET /admin/order-edits/:id", () => { orderEditId, { loggedInUserId: IdMap.getId("admin_user") } ) + + expect(orderEditServiceMock.update).toHaveBeenCalledTimes(1) }) it("returns updated orderEdit", () => { diff --git a/packages/medusa/src/api/routes/admin/order-edits/index.ts b/packages/medusa/src/api/routes/admin/order-edits/index.ts index 34d97f34a8..7541184e0d 100644 --- a/packages/medusa/src/api/routes/admin/order-edits/index.ts +++ b/packages/medusa/src/api/routes/admin/order-edits/index.ts @@ -18,6 +18,7 @@ import { AdminPostOrderEditsEditLineItemsReq } from "./add-line-item" import { AdminPostOrderEditsEditLineItemsLineItemReq } from "./update-order-edit-line-item" import { GetOrderEditsParams } from "./list-order-edit" import { GetOrderEditsOrderEditParams } from "./get-order-edit" +import { AdminPostOrderEditsRequestConfirmationReq } from "./request-confirmation" const route = Router() @@ -85,6 +86,7 @@ export default (app) => { route.post( "/:id/request", + transformBody(AdminPostOrderEditsRequestConfirmationReq), middlewares.wrap(require("./request-confirmation").default) ) @@ -121,3 +123,4 @@ export * from "./create-order-edit" export * from "./get-order-edit" export * from "./list-order-edit" export * from "./add-line-item" +export * from "./request-confirmation" diff --git a/packages/medusa/src/api/routes/admin/order-edits/request-confirmation.ts b/packages/medusa/src/api/routes/admin/order-edits/request-confirmation.ts index 19f747ec84..2d0f159f2e 100644 --- a/packages/medusa/src/api/routes/admin/order-edits/request-confirmation.ts +++ b/packages/medusa/src/api/routes/admin/order-edits/request-confirmation.ts @@ -1,9 +1,15 @@ import { EntityManager } from "typeorm" -import { OrderEditService } from "../../../../services" +import { IsOptional, IsString, IsObject } from "class-validator" +import { + OrderEditService, + OrderService, + PaymentCollectionService, +} from "../../../../services" import { defaultOrderEditFields, defaultOrderEditRelations, } from "../../../../types/order-edit" +import { PaymentCollectionType } from "../../../../models" /** * @oas [post] /order-edits/{id}/request @@ -54,26 +60,71 @@ import { */ export default async (req, res) => { const { id } = req.params + const validatedBody = + req.validatedBody as AdminPostOrderEditsRequestConfirmationReq const orderEditService: OrderEditService = req.scope.resolve("orderEditService") + const orderService: OrderService = req.scope.resolve("orderService") + + const paymentCollectionService: PaymentCollectionService = req.scope.resolve( + "paymentCollectionService" + ) + const manager: EntityManager = req.scope.resolve("manager") const loggedInUser = (req.user?.id ?? req.user?.userId) as string await manager.transaction(async (transactionManager) => { - await orderEditService - .withTransaction(transactionManager) - .requestConfirmation(id, { loggedInUserId: loggedInUser }) + const orderEditServiceTx = + orderEditService.withTransaction(transactionManager) + + const orderEdit = await orderEditServiceTx.requestConfirmation(id, { + loggedInUserId: loggedInUser, + }) + + const total = await orderEditServiceTx.getTotals(orderEdit.id) + + if (total.difference_due > 0) { + const order = await orderService + .withTransaction(transactionManager) + .retrieve(orderEdit.order_id, { + select: ["currency_code", "region_id"], + }) + + const paymentCollection = await paymentCollectionService + .withTransaction(transactionManager) + .create({ + type: PaymentCollectionType.ORDER_EDIT, + amount: total.difference_due, + currency_code: order.currency_code, + region_id: order.region_id, + description: validatedBody.payment_collection_description, + created_by: loggedInUser, + }) + + orderEdit.payment_collection_id = paymentCollection.id + + await orderEditServiceTx.update(orderEdit.id, { + payment_collection_id: paymentCollection.id, + }) + } }) - const orderEdit = await orderEditService.retrieve(id, { + let orderEdit = await orderEditService.retrieve(id, { relations: defaultOrderEditRelations, select: defaultOrderEditFields, }) + orderEdit = await orderEditService.decorateTotals(orderEdit) res.status(200).send({ order_edit: orderEdit, }) } + +export class AdminPostOrderEditsRequestConfirmationReq { + @IsString() + @IsOptional() + payment_collection_description?: string | undefined +} diff --git a/packages/medusa/src/api/routes/admin/payment-collections/delete-payment-collection.ts b/packages/medusa/src/api/routes/admin/payment-collections/delete-payment-collection.ts new file mode 100644 index 0000000000..e64acd61e6 --- /dev/null +++ b/packages/medusa/src/api/routes/admin/payment-collections/delete-payment-collection.ts @@ -0,0 +1,13 @@ +import { PaymentCollectionService } from "../../../../services" + +export default async (req, res) => { + const { id } = req.params + + const paymentCollectionService: PaymentCollectionService = req.scope.resolve( + "paymentCollectionService" + ) + + await paymentCollectionService.delete(id) + + res.status(200).json({ id, deleted: true, object: "payment_collection" }) +} diff --git a/packages/medusa/src/api/routes/admin/payment-collections/get-payment-collection.ts b/packages/medusa/src/api/routes/admin/payment-collections/get-payment-collection.ts new file mode 100644 index 0000000000..a450ba8a7f --- /dev/null +++ b/packages/medusa/src/api/routes/admin/payment-collections/get-payment-collection.ts @@ -0,0 +1,20 @@ +import { PaymentCollectionService } from "../../../../services" +import { FindParams } from "../../../../types/common" + +export default async (req, res) => { + const { id } = req.params + const retrieveConfig = req.retrieveConfig + + const paymentCollectionService: PaymentCollectionService = req.scope.resolve( + "paymentCollectionService" + ) + + const paymentCollection = await paymentCollectionService.retrieve( + id, + retrieveConfig + ) + + res.status(200).json({ payment_collection: paymentCollection }) +} + +export class GetPaymentCollectionsParams extends FindParams {} diff --git a/packages/medusa/src/api/routes/admin/payment-collections/index.ts b/packages/medusa/src/api/routes/admin/payment-collections/index.ts new file mode 100644 index 0000000000..441fb5b3db --- /dev/null +++ b/packages/medusa/src/api/routes/admin/payment-collections/index.ts @@ -0,0 +1,66 @@ +import { Router } from "express" +import "reflect-metadata" +import middlewares, { + transformBody, + transformQuery, +} from "../../../middlewares" +import OrderEditingFeatureFlag from "../../../../loaders/feature-flags/order-editing" +import { isFeatureFlagEnabled } from "../../../middlewares/feature-flag-enabled" + +import { GetPaymentCollectionsParams } from "./get-payment-collection" +import { AdminUpdatePaymentCollectionRequest } from "./update-payment-collection" + +const route = Router() + +export default (app, container) => { + app.use( + "/payment-collections", + isFeatureFlagEnabled(OrderEditingFeatureFlag.key), + route + ) + + route.get( + "/:id", + transformQuery(GetPaymentCollectionsParams, { + defaultFields: defaultPaymentCollectionFields, + defaultRelations: defaulPaymentCollectionRelations, + isList: false, + }), + middlewares.wrap(require("./get-payment-collection").default) + ) + + route.post( + "/:id", + transformBody(AdminUpdatePaymentCollectionRequest), + middlewares.wrap(require("./update-payment-collection").default) + ) + + route.delete( + "/:id", + middlewares.wrap(require("./delete-payment-collection").default) + ) + + return app +} + +export const defaultPaymentCollectionFields = [ + "id", + "type", + "status", + "description", + "amount", + "authorized_amount", + "region", + "currency_code", + "currency", + "metadata", +] + +export const defaulPaymentCollectionRelations = [ + "region", + "payment_sessions", + "payments", +] + +export * from "./get-payment-collection" +export * from "./update-payment-collection" diff --git a/packages/medusa/src/api/routes/admin/payment-collections/update-payment-collection.ts b/packages/medusa/src/api/routes/admin/payment-collections/update-payment-collection.ts new file mode 100644 index 0000000000..9b06cbfded --- /dev/null +++ b/packages/medusa/src/api/routes/admin/payment-collections/update-payment-collection.ts @@ -0,0 +1,34 @@ +import { IsObject, IsOptional, IsString } from "class-validator" + +import { EntityManager } from "typeorm" +import { PaymentCollectionService } from "../../../../services" + +export default async (req, res) => { + const { id } = req.params + const data = req.validatedBody as AdminUpdatePaymentCollectionRequest + + const paymentCollectionService: PaymentCollectionService = req.scope.resolve( + "paymentCollectionService" + ) + + const manager: EntityManager = req.scope.resolve("manager") + const paymentCollection = await manager.transaction( + async (transactionManager) => { + return await paymentCollectionService + .withTransaction(transactionManager) + .update(id, data) + } + ) + + res.status(200).json({ payment_collection: paymentCollection }) +} + +export class AdminUpdatePaymentCollectionRequest { + @IsString() + @IsOptional() + description?: string + + @IsObject() + @IsOptional() + metadata?: Record +} diff --git a/packages/medusa/src/api/routes/admin/payments/capture-payment.ts b/packages/medusa/src/api/routes/admin/payments/capture-payment.ts new file mode 100644 index 0000000000..b5a95e10a9 --- /dev/null +++ b/packages/medusa/src/api/routes/admin/payments/capture-payment.ts @@ -0,0 +1,11 @@ +import { PaymentService } from "../../../../services" + +export default async (req, res) => { + const { id } = req.params + + const paymentService: PaymentService = req.scope.resolve("paymentService") + + const payment = await paymentService.capture(id) + + res.status(200).json({ payment }) +} diff --git a/packages/medusa/src/api/routes/admin/payments/get-payment.ts b/packages/medusa/src/api/routes/admin/payments/get-payment.ts new file mode 100644 index 0000000000..434894327e --- /dev/null +++ b/packages/medusa/src/api/routes/admin/payments/get-payment.ts @@ -0,0 +1,15 @@ +import { PaymentService } from "../../../../services" +import { FindParams } from "../../../../types/common" + +export default async (req, res) => { + const { id } = req.params + const retrieveConfig = req.retrieveConfig + + const paymentService: PaymentService = req.scope.resolve("paymentService") + + const payment = await paymentService.retrieve(id, retrieveConfig) + + res.status(200).json({ payment: payment }) +} + +export class GetPaymentsParams extends FindParams {} diff --git a/packages/medusa/src/api/routes/admin/payments/index.ts b/packages/medusa/src/api/routes/admin/payments/index.ts new file mode 100644 index 0000000000..d750505861 --- /dev/null +++ b/packages/medusa/src/api/routes/admin/payments/index.ts @@ -0,0 +1,57 @@ +import { Router } from "express" +import "reflect-metadata" +import middlewares, { + transformBody, + transformQuery, +} from "../../../middlewares" +import OrderEditingFeatureFlag from "../../../../loaders/feature-flags/order-editing" +import { isFeatureFlagEnabled } from "../../../middlewares/feature-flag-enabled" + +import { GetPaymentsParams } from "./get-payment" +import { AdminPostPaymentRefundsReq } from "./refund-payment" + +const route = Router() + +export default (app, container) => { + app.use("/payments", isFeatureFlagEnabled(OrderEditingFeatureFlag.key), route) + + route.get( + "/:id", + transformQuery(GetPaymentsParams, { + defaultFields: defaultPaymentFields, + isList: false, + }), + middlewares.wrap(require("./get-payment").default) + ) + + route.post( + "/:id/capture", + middlewares.wrap(require("./capture-payment").default) + ) + + route.post( + "/:id/refund", + transformBody(AdminPostPaymentRefundsReq), + middlewares.wrap(require("./refund-payment").default) + ) + + return app +} + +export const defaultPaymentFields = [ + "id", + "swap_id", + "cart_id", + "order_id", + "amount", + "currency_code", + "amount_refunded", + "provider_id", + "data", + "captured_at", + "canceled_at", + "metadata", +] + +export * from "./get-payment" +export * from "./refund-payment" diff --git a/packages/medusa/src/api/routes/admin/payments/refund-payment.ts b/packages/medusa/src/api/routes/admin/payments/refund-payment.ts new file mode 100644 index 0000000000..4c872a5b29 --- /dev/null +++ b/packages/medusa/src/api/routes/admin/payments/refund-payment.ts @@ -0,0 +1,41 @@ +import { + IsEnum, + IsInt, + IsNotEmpty, + IsOptional, + IsString, +} from "class-validator" +import { RefundReason } from "../../../../models" + +import { PaymentService } from "../../../../services" + +export default async (req, res) => { + const { id } = req.params + + const data = req.validatedBody as AdminPostPaymentRefundsReq + + const paymentService: PaymentService = req.scope.resolve("paymentService") + + const refund = await paymentService.refund( + id, + data.amount, + data.reason, + data.note + ) + + res.status(200).json({ refund }) +} + +export class AdminPostPaymentRefundsReq { + @IsInt() + @IsNotEmpty() + amount: number + + @IsEnum(RefundReason) + @IsNotEmpty() + reason: RefundReason + + @IsString() + @IsOptional() + note?: string +} diff --git a/packages/medusa/src/api/routes/store/index.js b/packages/medusa/src/api/routes/store/index.js index 926f1a7cfb..be9959090f 100644 --- a/packages/medusa/src/api/routes/store/index.js +++ b/packages/medusa/src/api/routes/store/index.js @@ -16,6 +16,7 @@ import returnRoutes from "./returns" import shippingOptionRoutes from "./shipping-options" import swapRoutes from "./swaps" import variantRoutes from "./variants" +import paymentCollectionRoutes from "./payment-collections" const route = Router() @@ -47,6 +48,7 @@ export default (app, container, config) => { returnRoutes(route) giftCardRoutes(route) returnReasonRoutes(route) + paymentCollectionRoutes(route) return app } diff --git a/packages/medusa/src/api/routes/store/order-edits/complete-order-edit.ts b/packages/medusa/src/api/routes/store/order-edits/complete-order-edit.ts index ed467bb35f..e9bbac2f7d 100644 --- a/packages/medusa/src/api/routes/store/order-edits/complete-order-edit.ts +++ b/packages/medusa/src/api/routes/store/order-edits/complete-order-edit.ts @@ -1,11 +1,15 @@ import { Request, Response } from "express" import { EntityManager } from "typeorm" -import { OrderEditService } from "../../../../services" +import { + OrderEditService, + OrderService, + PaymentProviderService, +} from "../../../../services" import { defaultStoreOrderEditFields, defaultStoreOrderEditRelations, } from "../../../../types/order-edit" -import { OrderEditStatus } from "../../../../models" +import { OrderEditStatus, PaymentCollectionStatus } from "../../../../models" import { MedusaError } from "medusa-core-utils" /** @@ -55,13 +59,25 @@ export default async (req: Request, res: Response) => { const orderEditService: OrderEditService = req.scope.resolve("orderEditService") + const orderService: OrderService = req.scope.resolve("orderService") + + const paymentProviderService: PaymentProviderService = req.scope.resolve( + "paymentProviderService" + ) + const manager: EntityManager = req.scope.resolve("manager") const userId = req.user?.customer_id ?? req.user?.id ?? req.user?.userId await manager.transaction(async (manager) => { const orderEditServiceTx = orderEditService.withTransaction(manager) - const orderEdit = await orderEditServiceTx.retrieve(id) + const orderServiceTx = orderService.withTransaction(manager) + const paymentProviderServiceTx = + paymentProviderService.withTransaction(manager) + + const orderEdit = await orderEditServiceTx.retrieve(id, { + relations: ["payment_collection"], + }) if (orderEdit.status === OrderEditStatus.CONFIRMED) { return orderEdit @@ -74,18 +90,31 @@ export default async (req: Request, res: Response) => { ) } - // TODO once payment collection is done - /*const paymentCollection = await this.paymentCollectionService_.withTransaction(manager).retrieve(orderEdit.payment_collection_id) - if (!paymentCollection.authorized_at) { - throw new MedusaError( - MedusaError.Types.NOT_ALLOWED, - "Unable to complete an order edit if the payment is not authorized" - ) - }*/ + if (orderEdit.payment_collection) { + if ( + orderEdit.payment_collection.status !== + PaymentCollectionStatus.AUTHORIZED + ) { + throw new MedusaError( + MedusaError.Types.NOT_ALLOWED, + "Unable to complete an order edit if the payment is not authorized" + ) + } + } - return await orderEditServiceTx.confirm(id, { + const returned = await orderEditServiceTx.confirm(id, { loggedInUserId: userId, }) + + if (orderEdit.payment_collection) { + for (const payment of orderEdit.payment_collection.payments) { + await paymentProviderServiceTx.updatePayment(payment.id, { + order_id: orderEdit.order_id, + }) + } + } + + return returned }) let orderEdit = await orderEditService.retrieve(id, { diff --git a/packages/medusa/src/api/routes/store/payment-collections/authorize-payment-collection.ts b/packages/medusa/src/api/routes/store/payment-collections/authorize-payment-collection.ts new file mode 100644 index 0000000000..4b662d5068 --- /dev/null +++ b/packages/medusa/src/api/routes/store/payment-collections/authorize-payment-collection.ts @@ -0,0 +1,15 @@ +import { PaymentCollectionService } from "../../../../services" + +export default async (req, res) => { + const { payment_id } = req.params + + const paymentCollectionService: PaymentCollectionService = req.scope.resolve( + "paymentCollectionService" + ) + + const payment_collection = await paymentCollectionService.authorize( + payment_id + ) + + res.status(200).json({ payment_collection }) +} diff --git a/packages/medusa/src/api/routes/store/payment-collections/get-payment-collection.ts b/packages/medusa/src/api/routes/store/payment-collections/get-payment-collection.ts new file mode 100644 index 0000000000..a450ba8a7f --- /dev/null +++ b/packages/medusa/src/api/routes/store/payment-collections/get-payment-collection.ts @@ -0,0 +1,20 @@ +import { PaymentCollectionService } from "../../../../services" +import { FindParams } from "../../../../types/common" + +export default async (req, res) => { + const { id } = req.params + const retrieveConfig = req.retrieveConfig + + const paymentCollectionService: PaymentCollectionService = req.scope.resolve( + "paymentCollectionService" + ) + + const paymentCollection = await paymentCollectionService.retrieve( + id, + retrieveConfig + ) + + res.status(200).json({ payment_collection: paymentCollection }) +} + +export class GetPaymentCollectionsParams extends FindParams {} diff --git a/packages/medusa/src/api/routes/store/payment-collections/index.ts b/packages/medusa/src/api/routes/store/payment-collections/index.ts new file mode 100644 index 0000000000..39deb89612 --- /dev/null +++ b/packages/medusa/src/api/routes/store/payment-collections/index.ts @@ -0,0 +1,70 @@ +import { Router } from "express" +import "reflect-metadata" +import middlewares, { + transformBody, + transformQuery, +} from "../../../middlewares" + +import OrderEditingFeatureFlag from "../../../../loaders/feature-flags/order-editing" +import { isFeatureFlagEnabled } from "../../../middlewares/feature-flag-enabled" + +import { AdminManagePaymentCollectionSessionRequest } from "./manage-payment-sessions" +import { AdminRefreshPaymentCollectionSessionRequest } from "./refresh-payment-session" +import { GetPaymentCollectionsParams } from "./get-payment-collection" + +const route = Router() + +export default (app, container) => { + app.use( + "/payment-collections", + isFeatureFlagEnabled(OrderEditingFeatureFlag.key), + route + ) + + route.get( + "/:id", + transformQuery(GetPaymentCollectionsParams, { + defaultFields: defaultPaymentCollectionFields, + defaultRelations: defaulPaymentCollectionRelations, + isList: false, + }), + middlewares.wrap(require("./get-payment-collection").default) + ) + + route.post( + "/:id/authorize", + middlewares.wrap(require("./authorize-payment-collection").default) + ) + + route.post( + "/:id/sessions", + transformBody(AdminManagePaymentCollectionSessionRequest), + middlewares.wrap(require("./manage-payment-sessions").default) + ) + + route.post( + "/:id/sessions/:session_id/refresh", + transformBody(AdminRefreshPaymentCollectionSessionRequest), + middlewares.wrap(require("./refresh-payment-session").default) + ) + + return app +} + +export const defaultPaymentCollectionFields = [ + "id", + "type", + "status", + "description", + "amount", + "region", + "currency_code", + "currency", + "metadata", +] + +export const defaulPaymentCollectionRelations = ["region", "payment_sessions"] + +export * from "./get-payment-collection" +export * from "./manage-payment-sessions" +export * from "./refresh-payment-session" diff --git a/packages/medusa/src/api/routes/store/payment-collections/manage-payment-sessions.ts b/packages/medusa/src/api/routes/store/payment-collections/manage-payment-sessions.ts new file mode 100644 index 0000000000..45f5d9f05f --- /dev/null +++ b/packages/medusa/src/api/routes/store/payment-collections/manage-payment-sessions.ts @@ -0,0 +1,51 @@ +import { IsInt, IsNotEmpty, IsOptional, IsString } from "class-validator" +import { IsType } from "../../../../utils/validators/is-type" + +import { EntityManager } from "typeorm" +import { PaymentCollectionService } from "../../../../services" + +export default async (req, res) => { + const data = req.validatedBody as AdminManagePaymentCollectionSessionRequest + const { id } = req.params + + const paymentCollectionService: PaymentCollectionService = req.scope.resolve( + "paymentCollectionService" + ) + + const manager: EntityManager = req.scope.resolve("manager") + const paymentCollection = await manager.transaction( + async (transactionManager) => { + return await paymentCollectionService + .withTransaction(transactionManager) + .setPaymentSessions(id, data.sessions) + } + ) + + res.status(200).json({ payment_collection: paymentCollection }) +} + +export class PaymentCollectionSessionInputRequest { + @IsString() + provider_id: string + + @IsString() + customer_id: string + + @IsInt() + @IsNotEmpty() + amount: number + + @IsString() + @IsOptional() + session_id?: string +} + +export class AdminManagePaymentCollectionSessionRequest { + @IsType([ + PaymentCollectionSessionInputRequest, + [PaymentCollectionSessionInputRequest], + ]) + sessions: + | PaymentCollectionSessionInputRequest + | PaymentCollectionSessionInputRequest[] +} diff --git a/packages/medusa/src/api/routes/store/payment-collections/refresh-payment-session.ts b/packages/medusa/src/api/routes/store/payment-collections/refresh-payment-session.ts new file mode 100644 index 0000000000..1544dabbb3 --- /dev/null +++ b/packages/medusa/src/api/routes/store/payment-collections/refresh-payment-session.ts @@ -0,0 +1,32 @@ +import { IsInt, IsNotEmpty, IsString } from "class-validator" + +import { EntityManager } from "typeorm" +import { PaymentCollectionService } from "../../../../services" + +export default async (req, res) => { + const data = req.validatedBody as AdminRefreshPaymentCollectionSessionRequest + const { id, session_id } = req.params + + const paymentCollectionService: PaymentCollectionService = req.scope.resolve( + "paymentCollectionService" + ) + + const manager: EntityManager = req.scope.resolve("manager") + const paymentCollection = await manager.transaction( + async (transactionManager) => { + return await paymentCollectionService + .withTransaction(transactionManager) + .refreshPaymentSession(id, session_id, data) + } + ) + + res.status(200).json({ payment_collection: paymentCollection }) +} + +export class AdminRefreshPaymentCollectionSessionRequest { + @IsString() + provider_id: string + + @IsString() + customer_id: string +} diff --git a/packages/medusa/src/interfaces/payment-service.ts b/packages/medusa/src/interfaces/payment-service.ts index 6ac3676a65..6094ef8a2a 100644 --- a/packages/medusa/src/interfaces/payment-service.ts +++ b/packages/medusa/src/interfaces/payment-service.ts @@ -111,7 +111,7 @@ export abstract class AbstractPaymentService // eslint-disable-next-line @typescript-eslint/no-unused-vars public async retrieveSavedMethods(customer: Customer): Promise { - return Promise.resolve([]) + return [] } public abstract getStatus(data: Data): Promise diff --git a/packages/medusa/src/migrations/1664880666982-payment-collection.ts b/packages/medusa/src/migrations/1664880666982-payment-collection.ts index dc3c7d3603..e08ce1b331 100644 --- a/packages/medusa/src/migrations/1664880666982-payment-collection.ts +++ b/packages/medusa/src/migrations/1664880666982-payment-collection.ts @@ -12,8 +12,8 @@ export class paymentCollection1664880666982 implements MigrationInterface { CREATE TYPE "PAYMENT_COLLECTION_TYPE_ENUM" AS ENUM ('order_edit'); CREATE TYPE "PAYMENT_COLLECTION_STATUS_ENUM" AS ENUM ( - 'not_paid', 'awaiting', 'authorized', 'partially_authorized', 'captured', - 'partially_captured', 'refunded', 'partially_refunded', 'canceled', 'requires_action' + 'not_paid', 'awaiting', 'authorized', + 'partially_authorized', 'canceled' ); CREATE TABLE IF NOT EXISTS payment_collection @@ -27,8 +27,6 @@ export class paymentCollection1664880666982 implements MigrationInterface { description text NULL, amount integer NOT NULL, authorized_amount integer NULL, - captured_amount integer NULL, - refunded_amount integer NULL, region_id character varying NOT NULL, currency_code character varying NOT NULL, metadata jsonb NULL, @@ -78,6 +76,7 @@ export class paymentCollection1664880666982 implements MigrationInterface { ALTER TABLE refund ADD COLUMN payment_id character varying NULL; CREATE INDEX "IDX_refund_payment_id" ON "refund" ("payment_id"); ALTER TABLE "refund" ADD CONSTRAINT "FK_refund_payment_id" FOREIGN KEY ("payment_id") REFERENCES "payment"("id") ON DELETE NO ACTION ON UPDATE NO ACTION; + ALTER TABLE refund ALTER COLUMN order_id DROP NOT NULL; `) // Add missing indexes @@ -97,6 +96,7 @@ export class paymentCollection1664880666982 implements MigrationInterface { DROP INDEX "IDX_refund_payment_id"; ALTER TABLE refund DROP CONSTRAINT "FK_refund_payment_id"; + ALTER TABLE refund ALTER COLUMN order_id SET NOT NULL; ALTER TABLE payment_collection DROP CONSTRAINT "FK_payment_collection_region_id"; ALTER TABLE payment_collection_sessions DROP CONSTRAINT "FK_payment_collection_sessions_payment_collection_id"; diff --git a/packages/medusa/src/models/payment-collection.ts b/packages/medusa/src/models/payment-collection.ts index a1a7778645..57fc9adb98 100644 --- a/packages/medusa/src/models/payment-collection.ts +++ b/packages/medusa/src/models/payment-collection.ts @@ -21,12 +21,7 @@ export enum PaymentCollectionStatus { AWAITING = "awaiting", AUTHORIZED = "authorized", PARTIALLY_AUTHORIZED = "partially_authorized", - CAPTURED = "captured", - PARTIALLY_CAPTURED = "partially_captured", - REFUNDED = "refunded", - PARTIALLY_REFUNDED = "partially_refunded", CANCELED = "canceled", - REQUIRES_ACTION = "requires_action", } export enum PaymentCollectionType { @@ -41,20 +36,14 @@ export class PaymentCollection extends SoftDeletableEntity { @DbAwareColumn({ type: "enum", enum: PaymentCollectionStatus }) status: PaymentCollectionStatus - @Column({ nullable: true }) - description: string + @Column({ type: "varchar", nullable: true }) + description: string | null @Column({ type: "int" }) amount: number @Column({ type: "int", nullable: true }) - authorized_amount: number - - @Column({ type: "int", nullable: true }) - captured_amount: number - - @Column({ type: "int", nullable: true }) - refunded_amount: number + authorized_amount: number | null @Index() @Column() diff --git a/packages/medusa/src/models/publishable-api-key.ts b/packages/medusa/src/models/publishable-api-key.ts index 829ebf7cd4..e5a9b413c5 100644 --- a/packages/medusa/src/models/publishable-api-key.ts +++ b/packages/medusa/src/models/publishable-api-key.ts @@ -33,7 +33,7 @@ export class PublishableApiKey extends BaseEntity { * type: string * description: The key's ID * example: pak_01G1G5V27GYX4QXNARRQCW1N8T - * created_by: + * created_by: * type: string * description: "The unique identifier of the user that created the key." * example: usr_01G1G5V26F5TB3GPAPNJ8X1S3V diff --git a/packages/medusa/src/services/__mocks__/order-edit.js b/packages/medusa/src/services/__mocks__/order-edit.js index 2cba2e71e8..0aa055bd41 100644 --- a/packages/medusa/src/services/__mocks__/order-edit.js +++ b/packages/medusa/src/services/__mocks__/order-edit.js @@ -2,7 +2,7 @@ import { IdMap } from "medusa-test-utils" export const orderEdit = { id: IdMap.getId("testCreatedOrder"), - order_id: "empty-id", + order_id: IdMap.getId("test-order"), internal_note: "internal note", declined_reason: null, declined_at: null, @@ -106,6 +106,9 @@ export const orderEditServiceMock = { created_by: context.loggedInUserId, }) }), + update: jest.fn().mockImplementation((id, data) => { + return Promise.resolve(data) + }), decline: jest.fn().mockImplementation((id, reason, userId) => { return Promise.resolve({ id, @@ -114,8 +117,17 @@ export const orderEditServiceMock = { declined_at: new Date(), }) }), - getTotals: jest.fn().mockImplementation(() => { - return Promise.resolve({}) + getTotals: jest.fn().mockImplementation((id) => { + return Promise.resolve({ + shipping_total: 10, + gift_card_total: 0, + gift_card_tax_total: 0, + discount_total: 0, + tax_total: 1, + subtotal: 2000, + difference_due: 1000, + total: 1000, + }) }), delete: jest.fn().mockImplementation((_) => { return Promise.resolve() diff --git a/packages/medusa/src/services/__mocks__/order.js b/packages/medusa/src/services/__mocks__/order.js index f5b8dd22bd..804c6864fa 100644 --- a/packages/medusa/src/services/__mocks__/order.js +++ b/packages/medusa/src/services/__mocks__/order.js @@ -42,6 +42,7 @@ export const orders = { }, ], regionid: IdMap.getId("testRegion"), + currency_code: "USD", customerid: IdMap.getId("testCustomer"), payment_method: { providerid: "default_provider", @@ -96,6 +97,7 @@ export const orders = { }, ], regionid: IdMap.getId("region-france"), + currency_code: "EUR", customerid: IdMap.getId("test-customer"), payment_method: { providerid: "default_provider", diff --git a/packages/medusa/src/services/__mocks__/payment-collection.js b/packages/medusa/src/services/__mocks__/payment-collection.js new file mode 100644 index 0000000000..6a5845613d --- /dev/null +++ b/packages/medusa/src/services/__mocks__/payment-collection.js @@ -0,0 +1,17 @@ +import { IdMap } from "medusa-test-utils" + +export const PaymentCollectionServiceMock = { + withTransaction: function () { + return this + }, + create: jest.fn().mockImplementation((data) => { + const id = data.id ?? IdMap.getId("paycol_1") + return Promise.resolve({ ...data, id }) + }), +} + +const mock = jest.fn().mockImplementation(() => { + return PaymentCollectionServiceMock +}) + +export default mock diff --git a/packages/medusa/src/services/__mocks__/payment.js b/packages/medusa/src/services/__mocks__/payment.js new file mode 100644 index 0000000000..dd53e15436 --- /dev/null +++ b/packages/medusa/src/services/__mocks__/payment.js @@ -0,0 +1,15 @@ +export const PaymentServiceMock = { + withTransaction: function () { + return this + }, + create: jest.fn().mockImplementation((data) => { + const id = data.id ?? IdMap.getId("pay_1") + return Promise.resolve({ ...data, id }) + }), +} + +const mock = jest.fn().mockImplementation(() => { + return PaymentServiceMock +}) + +export default mock diff --git a/packages/medusa/src/services/__tests__/order-edit.ts b/packages/medusa/src/services/__tests__/order-edit.ts index 44927c27a0..34dd0f523d 100644 --- a/packages/medusa/src/services/__tests__/order-edit.ts +++ b/packages/medusa/src/services/__tests__/order-edit.ts @@ -182,7 +182,6 @@ describe("OrderEditService", () => { } }, }) - const orderEditService = new OrderEditService({ manager: MockManager, orderEditRepository, @@ -331,6 +330,10 @@ describe("OrderEditService", () => { let result beforeEach(async () => { + jest.spyOn(orderEditService, "getTotals").mockResolvedValue({ + difference_due: 1500, + } as any) + result = await orderEditService.requestConfirmation(orderEditId, { loggedInUserId: userId, }) diff --git a/packages/medusa/src/services/__tests__/payment-collection.ts b/packages/medusa/src/services/__tests__/payment-collection.ts index cc105b57c0..169bebaaf7 100644 --- a/packages/medusa/src/services/__tests__/payment-collection.ts +++ b/packages/medusa/src/services/__tests__/payment-collection.ts @@ -4,6 +4,7 @@ import { EventBusService, PaymentCollectionService, PaymentProviderService, + PaymentService, } from "../index" import { PaymentCollectionStatus, @@ -118,7 +119,6 @@ describe("PaymentCollectionService", () => { { id: IdMap.getId("payment-123"), amount: 35000, - captured_amount: 0, }, ], status: PaymentCollectionStatus.AUTHORIZED, @@ -288,7 +288,6 @@ describe("PaymentCollectionService", () => { 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"], @@ -323,7 +322,6 @@ describe("PaymentCollectionService", () => { 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"], @@ -530,7 +528,6 @@ describe("PaymentCollectionService", () => { IdMap.getId("payCol_session1"), { customer_id: "customer1", - amount: 100, provider_id: IdMap.getId("region1_provider1"), } ) @@ -544,19 +541,20 @@ describe("PaymentCollectionService", () => { ) }) - it("should fail to refresh a payment session if the amount is different", async () => { + it("should throw to refresh a payment session that doesn't exist", async () => { const sess = paymentCollectionService.refreshPaymentSession( IdMap.getId("payment-collection-session"), - IdMap.getId("payCol_session1"), + IdMap.getId("payCol_session-not-found"), { customer_id: "customer1", - amount: 80, provider_id: IdMap.getId("region1_provider1"), } ) expect(sess).rejects.toThrow( - "The amount has to be the same as the existing payment session" + `Session with id ${IdMap.getId( + "payCol_session-not-found" + )} was not found` ) expect(PaymentProviderServiceMock.refreshSessionNew).toBeCalledTimes(0) expect(DefaultProviderMock.deletePayment).toBeCalledTimes(0) @@ -633,56 +631,4 @@ describe("PaymentCollectionService", () => { expect(EventBusServiceMock.emit).toHaveBeenCalledTimes(0) }) }) - - describe("Capture Payments", () => { - afterEach(() => { - jest.clearAllMocks() - }) - - it("should throw error if the status is not authorized", async () => { - paymentCollectionRepository.getPaymentCollectionIdByPaymentId = jest - .fn() - .mockReturnValue(Promise.resolve(notAuthorizedSample)) - - PaymentProviderServiceMock.capturePayment = jest - .fn() - .mockReturnValue(Promise.resolve()) - - const ret = paymentCollectionService.capture( - IdMap.getId("payment-collection-not-authorized") - ) - - expect(ret).rejects.toThrowError( - new Error( - `A Payment Collection with status ${PaymentCollectionStatus.PARTIALLY_AUTHORIZED} cannot capture payment` - ) - ) - - expect(PaymentProviderServiceMock.capturePayment).toBeCalledTimes(0) - }) - - it("should emit PAYMENT_CAPTURE_FAILED if payment capture has failed", async () => { - paymentCollectionRepository.getPaymentCollectionIdByPaymentId = jest - .fn() - .mockReturnValue(Promise.resolve(fullyAuthorizedSample)) - - PaymentProviderServiceMock.retrievePayment = jest.fn().mockReturnValue( - Promise.resolve({ - id: IdMap.getId("payment-123"), - amount: 35000, - captured_amount: 0, - }) - ) - - PaymentProviderServiceMock.capturePayment = jest - .fn() - .mockRejectedValue("capture failed") - - const ret = paymentCollectionService.capture(IdMap.getId("payment-123")) - - expect(ret).rejects.toThrowError( - new Error(`Failed to capture Payment ${IdMap.getId("payment-123")}`) - ) - }) - }) }) diff --git a/packages/medusa/src/services/__tests__/payment-provider.js b/packages/medusa/src/services/__tests__/payment-provider.js index 17eec0ebab..12ef0f32bd 100644 --- a/packages/medusa/src/services/__tests__/payment-provider.js +++ b/packages/medusa/src/services/__tests__/payment-provider.js @@ -1,6 +1,7 @@ import { MockManager, MockRepository } from "medusa-test-utils" import PaymentProviderService from "../payment-provider" import { testPayServiceMock } from "../__mocks__/test-pay" +import { FlagRouter } from "../../utils/flag-router" describe("PaymentProviderService", () => { describe("retrieveProvider", () => { @@ -106,6 +107,10 @@ describe("PaymentProviderService", () => { }) describe(`PaymentProviderService`, () => { + const featureFlagRouter = new FlagRouter({ + order_editing: false, + }) + const container = { manager: MockManager, paymentSessionRepository: MockRepository({ @@ -128,19 +133,22 @@ describe(`PaymentProviderService`, () => { }, }), find: () => - Promise.resolve([{ - id: "pay_jadazdjk", - provider_id: "default_provider", - data: { - id: "1234", + Promise.resolve([ + { + id: "pay_jadazdjk", + provider_id: "default_provider", + data: { + id: "1234", + }, + captured_at: new Date(), + amount: 100, + amount_refunded: 0, }, - captured_at: new Date(), - amount: 100, - amount_refunded: 0 - }]), + ]), }), refundRepository: MockRepository(), pp_default_provider: testPayServiceMock, + featureFlagRouter, } const providerService = new PaymentProviderService(container) @@ -206,29 +214,25 @@ describe(`PaymentProviderService`, () => { }) it("successfully delete session", async () => { - await providerService.deleteSession( - { - id: "session", - provider_id: "default_provider", - data: { - id: "1234", - }, - } - ) + await providerService.deleteSession({ + id: "session", + provider_id: "default_provider", + data: { + id: "1234", + }, + }) expect(testPayServiceMock.deletePayment).toBeCalledTimes(1) }) it("successfully delete session", async () => { - await providerService.deleteSession( - { - id: "session", - provider_id: "default_provider", - data: { - id: "1234", - }, - } - ) + await providerService.deleteSession({ + id: "session", + provider_id: "default_provider", + data: { + id: "1234", + }, + }) expect(testPayServiceMock.deletePayment).toBeCalledTimes(1) }) @@ -265,22 +269,27 @@ describe(`PaymentProviderService`, () => { it("successfully cancel payment", async () => { await providerService.cancelPayment({ - id: "pay_jadazdjk" + id: "pay_jadazdjk", }) expect(testPayServiceMock.cancelPayment).toBeCalledTimes(1) }) it("successfully capture payment", async () => { await providerService.capturePayment({ - id: "pay_jadazdjk" + id: "pay_jadazdjk", }) expect(testPayServiceMock.capturePayment).toBeCalledTimes(1) }) it("successfully refund payment", async () => { - await providerService.refundPayment([{ - id: "pay_jadazdjk" - }], 50) + await providerService.refundPayment( + [ + { + id: "pay_jadazdjk", + }, + ], + 50 + ) expect(testPayServiceMock.refundPayment).toBeCalledTimes(1) }) }) diff --git a/packages/medusa/src/services/index.ts b/packages/medusa/src/services/index.ts index 3eed30e274..2d4fbf2137 100644 --- a/packages/medusa/src/services/index.ts +++ b/packages/medusa/src/services/index.ts @@ -28,6 +28,7 @@ export { default as OrderEditService } from "./order-edit" export { default as OrderEditItemChangeService } from "./order-edit-item-change" export { default as PaymentCollectionService } from "./payment-collection" export { default as PaymentProviderService } from "./payment-provider" +export { default as PaymentService } from "./payment" export { default as PriceListService } from "./price-list" export { default as PricingService } from "./pricing" export { default as ProductService } from "./product" diff --git a/packages/medusa/src/services/order-edit.ts b/packages/medusa/src/services/order-edit.ts index f9e681a1a7..ba3324d66d 100644 --- a/packages/medusa/src/services/order-edit.ts +++ b/packages/medusa/src/services/order-edit.ts @@ -661,7 +661,7 @@ export default class OrderEditService extends TransactionBaseService { let orderEdit = await this.retrieve(orderEditId, { relations: ["changes"], - select: ["id", "requested_at"], + select: ["id", "order_id", "requested_at"], }) if (!orderEdit.changes?.length) { diff --git a/packages/medusa/src/services/payment-collection.ts b/packages/medusa/src/services/payment-collection.ts index 2ecd0c244a..5177e9d514 100644 --- a/packages/medusa/src/services/payment-collection.ts +++ b/packages/medusa/src/services/payment-collection.ts @@ -6,18 +6,17 @@ import { buildQuery, isDefined, setMetadata } from "../utils" import { PaymentCollectionRepository } from "../repositories/payment-collection" import { Customer, - Payment, PaymentCollection, PaymentCollectionStatus, PaymentSession, PaymentSessionStatus, - Refund, } from "../models" import { TransactionBaseService } from "../interfaces" import { CustomerService, EventBusService, PaymentProviderService, + PaymentService, } from "./index" import { @@ -40,10 +39,6 @@ export default class PaymentCollectionService extends TransactionBaseService { UPDATED: "payment-collection.updated", DELETED: "payment-collection.deleted", PAYMENT_AUTHORIZED: "payment-collection.payment_authorized", - PAYMENT_CAPTURED: "payment-collection.payment_captured", - PAYMENT_CAPTURE_FAILED: "payment-collection.payment_capture_failed", - REFUND_CREATED: "payment-collection.payment_refund_created", - REFUND_FAILED: "payment-collection.payment_refund_failed", } protected readonly manager_: EntityManager @@ -298,7 +293,7 @@ export default class PaymentCollectionService extends TransactionBaseService { async refreshPaymentSession( paymentCollectionId: string, sessionId: string, - sessionInput: PaymentCollectionSessionInput + sessionInput: Omit ): Promise { return await this.atomicPhase_(async (manager: EntityManager) => { const paymentCollectionRepository = manager.getCustomRepository( @@ -328,10 +323,10 @@ export default class PaymentCollectionService extends TransactionBaseService { (sess) => sessionId === sess?.id ) - if (session?.amount !== sessionInput.amount) { + if (!session) { throw new MedusaError( MedusaError.Types.NOT_ALLOWED, - "The amount has to be the same as the existing payment session" + `Session with id ${sessionId} was not found` ) } @@ -360,7 +355,7 @@ export default class PaymentCollectionService extends TransactionBaseService { return sess }) - if (session.payment_authorized_at) { + if (session.payment_authorized_at && payCol.authorized_amount) { payCol.authorized_amount -= session.amount } @@ -401,7 +396,9 @@ export default class PaymentCollectionService extends TransactionBaseService { } let authorizedAmount = 0 - for (const session of payCol.payment_sessions) { + for (let i = 0; i < payCol.payment_sessions.length; i++) { + const session = payCol.payment_sessions[i] + if (session.payment_authorized_at) { authorizedAmount += session.amount continue @@ -411,14 +408,21 @@ export default class PaymentCollectionService extends TransactionBaseService { .withTransaction(manager) .authorizePayment(session, context) + if (auth) { + payCol.payment_sessions[i] = auth + } + if (auth?.status === PaymentSessionStatus.AUTHORIZED) { authorizedAmount += session.amount - const inputData: Omit = { + const inputData: Omit & { + payment_session: PaymentSession + } = { amount: session.amount, currency_code: payCol.currency_code, provider_id: session.provider_id, resource_id: payCol.id, + payment_session: auth, } payCol.payments.push( @@ -447,235 +451,4 @@ export default class PaymentCollectionService extends TransactionBaseService { return payCol }) } - - private async capturePayment( - payCol: PaymentCollection, - payment: Payment - ): Promise { - if (payment?.captured_at) { - return payment - } - - return await this.atomicPhase_(async (manager: EntityManager) => { - const allowedStatuses = [ - PaymentCollectionStatus.AUTHORIZED, - PaymentCollectionStatus.PARTIALLY_CAPTURED, - PaymentCollectionStatus.REQUIRES_ACTION, - ] - - if (!allowedStatuses.includes(payCol.status)) { - throw new MedusaError( - MedusaError.Types.NOT_ALLOWED, - `A Payment Collection with status ${payCol.status} cannot capture payment` - ) - } - - let captureError: Error | null = null - const capturedPayment = await this.paymentProviderService_ - .withTransaction(manager) - .capturePayment(payment) - .catch((err) => { - captureError = err - }) - - payCol.captured_amount = payCol.captured_amount ?? 0 - if (capturedPayment) { - payCol.captured_amount += payment.amount - } - - if (payCol.captured_amount === 0) { - payCol.status = PaymentCollectionStatus.REQUIRES_ACTION - } else if (payCol.captured_amount === payCol.amount) { - payCol.status = PaymentCollectionStatus.CAPTURED - } else { - payCol.status = PaymentCollectionStatus.PARTIALLY_CAPTURED - } - - const paymentCollectionRepository = manager.getCustomRepository( - this.paymentCollectionRepository_ - ) - - await paymentCollectionRepository.save(payCol) - - if (!capturedPayment) { - await this.eventBusService_ - .withTransaction(manager) - .emit(PaymentCollectionService.Events.PAYMENT_CAPTURE_FAILED, { - ...payment, - error: captureError, - }) - - throw new MedusaError( - MedusaError.Types.UNEXPECTED_STATE, - `Failed to capture Payment ${payment.id}` - ) - } - - await this.eventBusService_ - .withTransaction(manager) - .emit(PaymentCollectionService.Events.PAYMENT_CAPTURED, capturedPayment) - - return capturedPayment - }) - } - - async capture(paymentId: string): Promise { - const manager = this.transactionManager_ ?? this.manager_ - const paymentCollectionRepository = manager.getCustomRepository( - this.paymentCollectionRepository_ - ) - - const payCol = - await paymentCollectionRepository.getPaymentCollectionIdByPaymentId( - paymentId, - { - relations: ["payments"], - } - ) - - const payment = payCol.payments.find((payment) => paymentId === payment?.id) - - return await this.capturePayment(payCol, payment!) - } - - async captureAll(paymentCollectionId: string): Promise { - const payCol = await this.retrieve(paymentCollectionId, { - relations: ["payments"], - }) - - const allPayments: Payment[] = [] - for (const payment of payCol.payments) { - const captured = await this.capturePayment(payCol, payment).catch( - () => void 0 - ) - - if (captured) { - allPayments.push(captured) - } - } - - return allPayments - } - - private async refundPayment( - payCol: PaymentCollection, - payment: Payment, - amount: number, - reason: string, - note?: string - ): Promise { - return await this.atomicPhase_(async (manager: EntityManager) => { - if (!payment.captured_at) { - throw new MedusaError( - MedusaError.Types.NOT_ALLOWED, - `Payment ${payment.id} is not captured` - ) - } - - const refundable = payment.amount - payment.amount_refunded - if (amount > refundable) { - throw new MedusaError( - MedusaError.Types.NOT_ALLOWED, - `Only ${refundable} can be refunded from Payment ${payment.id}` - ) - } - - let refundError: Error | null = null - const refund = await this.paymentProviderService_ - .withTransaction(manager) - .refundFromPayment(payment, amount, reason, note) - .catch((err) => { - refundError = err - }) - - payCol.refunded_amount = payCol.refunded_amount ?? 0 - if (refund) { - payCol.refunded_amount += refund.amount - } - - if (payCol.refunded_amount === 0) { - payCol.status = PaymentCollectionStatus.REQUIRES_ACTION - } else if (payCol.refunded_amount === payCol.amount) { - payCol.status = PaymentCollectionStatus.REFUNDED - } else { - payCol.status = PaymentCollectionStatus.PARTIALLY_REFUNDED - } - - const paymentCollectionRepository = manager.getCustomRepository( - this.paymentCollectionRepository_ - ) - - await paymentCollectionRepository.save(payCol) - - if (!refund) { - await this.eventBusService_ - .withTransaction(manager) - .emit(PaymentCollectionService.Events.REFUND_FAILED, { - ...payment, - error: refundError, - }) - - throw new MedusaError( - MedusaError.Types.UNEXPECTED_STATE, - `Failed to refund Payment ${payment.id}` - ) - } - - await this.eventBusService_ - .withTransaction(manager) - .emit(PaymentCollectionService.Events.REFUND_CREATED, refund) - - return refund - }) - } - - async refund( - paymentId: string, - amount: number, - reason: string, - note?: string - ): Promise { - const manager = this.transactionManager_ ?? this.manager_ - const paymentCollectionRepository = manager.getCustomRepository( - this.paymentCollectionRepository_ - ) - - const payCol = - await paymentCollectionRepository.getPaymentCollectionIdByPaymentId( - paymentId - ) - - const payment = await this.paymentProviderService_.retrievePayment( - paymentId - ) - - return await this.refundPayment(payCol, payment, amount, reason, note) - } - - async refundAll( - paymentCollectionId: string, - reason: string, - note?: string - ): Promise { - const payCol = await this.retrieve(paymentCollectionId, { - relations: ["payments"], - }) - - const allRefunds: Refund[] = [] - for (const payment of payCol.payments) { - const refunded = await this.refundPayment( - payCol, - payment, - payment.amount, - reason, - note - ).catch(() => void 0) - - if (refunded) { - allRefunds.push(refunded) - } - } - - return allRefunds - } } diff --git a/packages/medusa/src/services/payment-provider.ts b/packages/medusa/src/services/payment-provider.ts index c5304a7ad1..deb1deb532 100644 --- a/packages/medusa/src/services/payment-provider.ts +++ b/packages/medusa/src/services/payment-provider.ts @@ -17,6 +17,9 @@ import { Refund, } from "../models" import { PaymentProviderDataInput } from "../types/payment-collection" +import { FlagRouter } from "../utils/flag-router" +import OrderEditingFeatureFlag from "../loaders/feature-flags/order-editing" +import PaymentService from "./payment" type PaymentProviderKey = `pp_${string}` | "systemPaymentProviderService" type InjectedDependencies = { @@ -25,6 +28,8 @@ type InjectedDependencies = { paymentProviderRepository: typeof PaymentProviderRepository paymentRepository: typeof PaymentRepository refundRepository: typeof RefundRepository + paymentService: PaymentService + featureFlagRouter: FlagRouter } & { [key in `${PaymentProviderKey}`]: | AbstractPaymentService @@ -44,6 +49,8 @@ export default class PaymentProviderService extends TransactionBaseService { protected readonly paymentRepository_: typeof PaymentRepository protected readonly refundRepository_: typeof RefundRepository + protected readonly featureFlagRouter_: FlagRouter + constructor(container: InjectedDependencies) { super(container) @@ -53,6 +60,7 @@ export default class PaymentProviderService extends TransactionBaseService { this.paymentProviderRepository_ = container.paymentProviderRepository this.paymentRepository_ = container.paymentRepository this.refundRepository_ = container.refundRepository + this.featureFlagRouter_ = container.featureFlagRouter } async registerInstalledProviders(providerIds: string[]): Promise { @@ -184,9 +192,7 @@ export default class PaymentProviderService extends TransactionBaseService { sessionInput: PaymentProviderDataInput ): Promise { return await this.atomicPhase_(async (transactionManager) => { - const provider: AbstractPaymentService = this.retrieveProvider( - sessionInput.provider_id - ) + const provider = this.retrieveProvider(sessionInput.provider_id) const sessionData = await provider .withTransaction(transactionManager) .createPaymentNew(sessionInput) @@ -396,7 +402,9 @@ export default class PaymentProviderService extends TransactionBaseService { } async createPaymentNew( - paymentInput: Omit + paymentInput: Omit & { + payment_session: PaymentSession + } ): Promise { return await this.atomicPhase_(async (transactionManager) => { const { payment_session, currency_code, amount, provider_id } = @@ -407,18 +415,13 @@ export default class PaymentProviderService extends TransactionBaseService { .withTransaction(transactionManager) .getPaymentData(payment_session) - const paymentRepo = transactionManager.getCustomRepository( - this.paymentRepository_ - ) - - const created = paymentRepo.create({ + const paymentService = this.container_.paymentService + return await paymentService.withTransaction(transactionManager).create({ provider_id, amount, currency_code, data: paymentData, }) - - return await paymentRepo.save(created) }) } @@ -427,20 +430,10 @@ export default class PaymentProviderService extends TransactionBaseService { data: { order_id?: string; swap_id?: string } ): Promise { return await this.atomicPhase_(async (transactionManager) => { - const payment = await this.retrievePayment(paymentId) - - if (data?.order_id) { - payment.order_id = data.order_id - } - - if (data?.swap_id) { - payment.swap_id = data.swap_id - } - - const payRepo = transactionManager.getCustomRepository( - this.paymentRepository_ - ) - return await payRepo.save(payment) + const paymentService = this.container_.paymentService + return await paymentService + .withTransaction(transactionManager) + .update(paymentId, data) }) } @@ -465,6 +458,13 @@ export default class PaymentProviderService extends TransactionBaseService { session.data = data session.status = status + if ( + this.featureFlagRouter_.isFeatureEnabled(OrderEditingFeatureFlag.key) && + status === PaymentSessionStatus.AUTHORIZED + ) { + session.payment_authorized_at = new Date() + } + const sessionRepo = transactionManager.getCustomRepository( this.paymentSessionRepository_ ) @@ -562,7 +562,7 @@ export default class PaymentProviderService extends TransactionBaseService { if (refundable < amount) { throw new MedusaError( MedusaError.Types.NOT_ALLOWED, - "Refund amount is higher that the refundable amount" + "Refund amount is greater that the refundable amount" ) } @@ -634,7 +634,7 @@ export default class PaymentProviderService extends TransactionBaseService { if (refundable < amount) { throw new MedusaError( MedusaError.Types.NOT_ALLOWED, - "Refund amount is higher that the refundable amount" + "Refund amount is greater that the refundable amount" ) } diff --git a/packages/medusa/src/services/payment.ts b/packages/medusa/src/services/payment.ts new file mode 100644 index 0000000000..056f8f4a5c --- /dev/null +++ b/packages/medusa/src/services/payment.ts @@ -0,0 +1,229 @@ +import { PaymentRepository } from "./../repositories/payment" +import { EntityManager } from "typeorm" +import { MedusaError } from "medusa-core-utils" + +import { Payment, Refund } from "../models" +import { TransactionBaseService } from "../interfaces" +import { EventBusService, PaymentProviderService } from "./index" +import { buildQuery } from "../utils" +import { FindConfig } from "../types/common" + +type InjectedDependencies = { + manager: EntityManager + paymentProviderService: PaymentProviderService + eventBusService: EventBusService + paymentRepository: typeof PaymentRepository +} + +export type PaymentDataInput = { + currency_code: string + provider_id: string + amount: number + data: Record +} + +export default class PaymentService extends TransactionBaseService { + protected readonly manager_: EntityManager + protected transactionManager_: EntityManager | undefined + protected readonly eventBusService_: EventBusService + protected readonly paymentProviderService_: PaymentProviderService + protected readonly paymentRepository_: typeof PaymentRepository + static readonly Events = { + CREATED: "payment.created", + UPDATED: "payment.updated", + PAYMENT_CAPTURED: "payment.payment_captured", + PAYMENT_CAPTURE_FAILED: "payment.payment_capture_failed", + REFUND_CREATED: "payment.payment_refund_created", + REFUND_FAILED: "payment.payment_refund_failed", + } + + constructor({ + manager, + paymentRepository, + paymentProviderService, + eventBusService, + }: InjectedDependencies) { + // eslint-disable-next-line prefer-rest-params + super(arguments[0]) + + this.manager_ = manager + this.paymentRepository_ = paymentRepository + this.paymentProviderService_ = paymentProviderService + this.eventBusService_ = eventBusService + } + + async retrieve( + paymentId: string, + config: FindConfig = {} + ): Promise { + const manager = this.transactionManager_ ?? this.manager_ + const paymentRepository = manager.getCustomRepository( + this.paymentRepository_ + ) + + const query = buildQuery({ id: paymentId }, config) + + const payment = await paymentRepository.find(query) + + if (!payment.length) { + throw new MedusaError( + MedusaError.Types.NOT_FOUND, + `Payment with id ${paymentId} was not found` + ) + } + + return payment[0] + } + + async create(paymentInput: PaymentDataInput): Promise { + return await this.atomicPhase_(async (manager: EntityManager) => { + const { data, currency_code, amount, provider_id } = paymentInput + + const paymentRepository = manager.getCustomRepository( + this.paymentRepository_ + ) + + const created = paymentRepository.create({ + provider_id, + amount, + currency_code, + data, + }) + + const saved = await paymentRepository.save(created) + + await this.eventBusService_ + .withTransaction(manager) + .emit(PaymentService.Events.CREATED, saved) + + return saved + }) + } + + async update( + paymentId: string, + data: { order_id?: string; swap_id?: string } + ): Promise { + return await this.atomicPhase_(async (manager: EntityManager) => { + const payment = await this.retrieve(paymentId) + + const paymentRepository = manager.getCustomRepository( + this.paymentRepository_ + ) + + if (data?.order_id) { + payment.order_id = data.order_id + } + + if (data?.swap_id) { + payment.swap_id = data.swap_id + } + + const updated = await paymentRepository.save(payment) + + await this.eventBusService_ + .withTransaction(manager) + .emit(PaymentService.Events.UPDATED, updated) + + return updated + }) + } + + async capture(paymentOrId: string | Payment): Promise { + const payment = + typeof paymentOrId === "string" + ? await this.retrieve(paymentOrId) + : paymentOrId + + if (payment?.captured_at) { + return payment + } + + return await this.atomicPhase_(async (manager: EntityManager) => { + let captureError: Error | null = null + const capturedPayment = await this.paymentProviderService_ + .withTransaction(manager) + .capturePayment(payment) + .catch((err) => { + captureError = err + }) + + if (!capturedPayment) { + await this.eventBusService_ + .withTransaction(manager) + .emit(PaymentService.Events.PAYMENT_CAPTURE_FAILED, { + ...payment, + error: captureError, + }) + + throw new MedusaError( + MedusaError.Types.UNEXPECTED_STATE, + `Failed to capture Payment ${payment.id}` + ) + } + + await this.eventBusService_ + .withTransaction(manager) + .emit(PaymentService.Events.PAYMENT_CAPTURED, capturedPayment) + + return capturedPayment + }) + } + + async refund( + paymentOrId: string | Payment, + amount: number, + reason: string, + note?: string + ): Promise { + const payment = + typeof paymentOrId === "string" + ? await this.retrieve(paymentOrId) + : paymentOrId + + return await this.atomicPhase_(async (manager: EntityManager) => { + if (!payment.captured_at) { + throw new MedusaError( + MedusaError.Types.NOT_ALLOWED, + `Payment ${payment.id} is not captured` + ) + } + + const refundable = payment.amount - payment.amount_refunded + if (amount > refundable) { + throw new MedusaError( + MedusaError.Types.NOT_ALLOWED, + `Only ${refundable} can be refunded from Payment ${payment.id}` + ) + } + + let refundError: Error | null = null + const refund = await this.paymentProviderService_ + .withTransaction(manager) + .refundFromPayment(payment, amount, reason, note) + .catch((err) => { + refundError = err + }) + + if (!refund) { + await this.eventBusService_ + .withTransaction(manager) + .emit(PaymentService.Events.REFUND_FAILED, { + ...payment, + error: refundError, + }) + + throw new MedusaError( + MedusaError.Types.UNEXPECTED_STATE, + `Failed to refund Payment ${payment.id}` + ) + } + + await this.eventBusService_ + .withTransaction(manager) + .emit(PaymentService.Events.REFUND_CREATED, refund) + + return refund + }) + } +} diff --git a/packages/medusa/src/types/order-edit.ts b/packages/medusa/src/types/order-edit.ts index 732cf9418d..684e667095 100644 --- a/packages/medusa/src/types/order-edit.ts +++ b/packages/medusa/src/types/order-edit.ts @@ -26,6 +26,7 @@ export const defaultOrderEditRelations: string[] = [ "items", "items.adjustments", "items.tax_lines", + "payment_collection", ] export const defaultOrderEditFields: (keyof OrderEdit)[] = [ @@ -45,6 +46,7 @@ export const defaultOrderEditFields: (keyof OrderEdit)[] = [ "canceled_by", "canceled_at", "internal_note", + "payment_collection_id", ] export const storeOrderEditNotAllowedFieldsAndRelations = [ diff --git a/packages/medusa/src/types/payment-collection.ts b/packages/medusa/src/types/payment-collection.ts index 3652e253ca..549469c7fa 100644 --- a/packages/medusa/src/types/payment-collection.ts +++ b/packages/medusa/src/types/payment-collection.ts @@ -3,7 +3,6 @@ import { Customer, PaymentCollection, PaymentCollectionType, - PaymentSession, } from "../models" export type CreatePaymentCollectionInput = { @@ -29,8 +28,6 @@ export type PaymentProviderDataInput = { currency_code: string provider_id: string amount: number - payment_session?: PaymentSession - payment_description?: string cart_id?: string cart?: Cart metadata?: any @@ -48,7 +45,6 @@ export const defaultPaymentCollectionFields: (keyof PaymentCollection)[] = [ "description", "amount", "authorized_amount", - "refunded_amount", "currency_code", "metadata", "region",