From c3f82975993f5cc591197a6861c030ffd3a963f7 Mon Sep 17 00:00:00 2001 From: "Carlos R. L. Rodrigues" <37986729+carlos-r-l-rodrigues@users.noreply.github.com> Date: Wed, 2 Nov 2022 13:34:29 -0300 Subject: [PATCH] feat(medusa): manage payment sessions from payment collection (#2402) * feat: manage payment sessions from payment collection --- .../fixture-gen/src/services/test-pay.js | 40 +- .../api/src/services/test-pay.js | 8 + integration-tests/development/server.js | 9 + .../src/services/adyen.js | 8 + .../src/api/routes/hooks/push.js | 20 +- .../src/services/klarna-provider.js | 127 +++- .../src/services/manual-payment.js | 8 + .../src/api/routes/hooks/paypal.js | 54 +- .../src/services/paypal-provider.js | 57 ++ .../src/api/routes/hooks/stripe.js | 77 +-- .../src/helpers/stripe-base.js | 235 ++++++++ .../src/services/stripe-bancontact.js | 245 +------- .../src/services/stripe-blik.js | 242 +------- .../src/services/stripe-giropay.js | 245 +------- .../src/services/stripe-ideal.js | 242 +------- .../src/services/stripe-provider.js | 66 ++- .../src/services/stripe-przelewy24.js | 239 +------- .../medusa/src/interfaces/payment-service.ts | 9 + .../1664880666982-payment-collection.ts | 20 +- .../medusa/src/models/payment-collection.ts | 3 + packages/medusa/src/models/payment-session.ts | 7 +- packages/medusa/src/models/refund.ts | 18 +- .../src/repositories/payment-collection.ts | 68 ++- .../services/__mocks__/payment-provider.js | 36 +- .../services/__tests__/payment-collection.ts | 506 +++++++++++++++- .../medusa/src/services/payment-collection.ts | 544 +++++++++++++++++- .../medusa/src/services/payment-provider.ts | 153 ++++- .../medusa/src/types/payment-collection.ts | 27 +- 28 files changed, 2000 insertions(+), 1313 deletions(-) create mode 100644 packages/medusa-payment-stripe/src/helpers/stripe-base.js diff --git a/docs-util/fixture-gen/src/services/test-pay.js b/docs-util/fixture-gen/src/services/test-pay.js index 7f5c3a07817b8..6e929aeeb2bca 100644 --- a/docs-util/fixture-gen/src/services/test-pay.js +++ b/docs-util/fixture-gen/src/services/test-pay.js @@ -1,59 +1,67 @@ -import { AbstractPaymentService } from "@medusajs/medusa"; +import { AbstractPaymentService } from "@medusajs/medusa" class TestPayService extends AbstractPaymentService { - static identifier = "test-pay"; + static identifier = "test-pay" constructor(_) { - super(_); + super(_) } async getStatus(paymentData) { - return "authorized"; + return "authorized" } async retrieveSavedMethods(customer) { - return Promise.resolve([]); + return Promise.resolve([]) } async createPayment() { - return {}; + return {} + } + + async createPaymentNew() { + return {} } async retrievePayment(data) { - return {}; + return {} } async getPaymentData(sessionData) { - return {}; + return {} } async authorizePayment(sessionData, context = {}) { - return {}; + return {} } async updatePaymentData(sessionData, update) { - return {}; + return {} } async updatePayment(sessionData, cart) { - return {}; + return {} + } + + async updatePaymentNew(sessionData, paymentInput) { + return {} } async deletePayment(payment) { - return {}; + return {} } async capturePayment(payment) { - return {}; + return {} } async refundPayment(payment, amountToRefund) { - return {}; + return {} } async cancelPayment(payment) { - return {}; + return {} } } -export default TestPayService; +export default TestPayService diff --git a/integration-tests/api/src/services/test-pay.js b/integration-tests/api/src/services/test-pay.js index d8ff3ca494c69..9724be4e06808 100644 --- a/integration-tests/api/src/services/test-pay.js +++ b/integration-tests/api/src/services/test-pay.js @@ -33,6 +33,10 @@ class TestPayService extends AbstractPaymentService { return data } + async createPaymentNew(inputData) { + return inputData + } + async retrievePayment(data) { return {} } @@ -59,6 +63,10 @@ class TestPayService extends AbstractPaymentService { return {} } + async updatePaymentNew(sessionData) { + return sessionData + } + async deletePayment(payment) { return {} } diff --git a/integration-tests/development/server.js b/integration-tests/development/server.js index f4ed9d1e674aa..04eccbe23592d 100644 --- a/integration-tests/development/server.js +++ b/integration-tests/development/server.js @@ -93,6 +93,15 @@ const bootstrapApp = async () => { } const app = express() + app.use((req, res, next) => { + res.header("Access-Control-Allow-Origin", req.headers.origin) + res.header("Access-Control-Allow-Methods", "*") + res.header( + "Access-Control-Allow-Headers", + "Origin, X-Requested-With, Content-Type, Accept" + ) + next() + }) const dir = path.resolve( path.join(__dirname, "../../packages/medusa/src/loaders") diff --git a/packages/medusa-payment-adyen/src/services/adyen.js b/packages/medusa-payment-adyen/src/services/adyen.js index 54b6f172dcf2d..a3c74a3442cd1 100644 --- a/packages/medusa-payment-adyen/src/services/adyen.js +++ b/packages/medusa-payment-adyen/src/services/adyen.js @@ -201,6 +201,10 @@ class AdyenService extends BaseService { return { cart_id: cart.id } } + async createPaymentNew(paymentInput) { + return { resource_id: paymentInput.resource_id } + } + /** * Retrieves Adyen payment. This is not supported by adyen, so we simply * return the current payment method data @@ -322,6 +326,10 @@ class AdyenService extends BaseService { return paymentData } + async updatePaymentNew(paymentData, details) { + return paymentData + } + /** * Additional details * @param {object} paymentData - payment data diff --git a/packages/medusa-payment-klarna/src/api/routes/hooks/push.js b/packages/medusa-payment-klarna/src/api/routes/hooks/push.js index f6fb1429515ea..80b7baadf6254 100644 --- a/packages/medusa-payment-klarna/src/api/routes/hooks/push.js +++ b/packages/medusa-payment-klarna/src/api/routes/hooks/push.js @@ -1,8 +1,10 @@ -import { MedusaError } from "medusa-core-utils" - export default async (req, res) => { const { klarna_order_id } = req.query + function isPaymentCollection(id) { + return id && id.startsWith("paycol") + } + try { const orderService = req.scope.resolve("orderService") const klarnaProviderService = req.scope.resolve("pp_klarna") @@ -11,10 +13,18 @@ export default async (req, res) => { klarna_order_id ) - const cartId = klarnaOrder.merchant_data - const order = await orderService.retrieveByCartId(cartId) + const resourceId = klarnaOrder.merchant_data + + if (isPaymentCollection(resourceId)) { + await klarnaProviderService.acknowledgeOrder(klarnaOrder.order_id) + } else { + const order = await orderService.retrieveByCartId(resourceId) - await klarnaProviderService.acknowledgeOrder(klarnaOrder.order_id, order.id) + await klarnaProviderService.acknowledgeOrder( + klarnaOrder.order_id, + order.id + ) + } res.sendStatus(200) } catch (error) { diff --git a/packages/medusa-payment-klarna/src/services/klarna-provider.js b/packages/medusa-payment-klarna/src/services/klarna-provider.js index 5907c1da00e94..f9acd742fed3f 100644 --- a/packages/medusa-payment-klarna/src/services/klarna-provider.js +++ b/packages/medusa-payment-klarna/src/services/klarna-provider.js @@ -230,6 +230,84 @@ class KlarnaProviderService extends PaymentService { return order } + validateKlarnaOrderUrls(property) { + const required = ["terms", "checkout", "confirmation"] + + const isMissing = required.some((prop) => !this.options_[property]?.[prop]) + + if (isMissing) { + throw new Error( + `options.${property} is required to create a Klarna Order.\n` + + `medusa-config.js file has to contain ${property} { ${required.join( + ", " + )}}` + ) + } + } + + replaceStringWithPropertyValue(string, obj) { + const keys = Object.keys(obj) + for (const key of keys) { + if (string.includes(`{${key}}`)) { + string = string.replace(`{${key}}`, obj[key]) + } + } + return string + } + + async paymentInputToKlarnaOrder(paymentInput) { + if (paymentInput.cart) { + this.validateKlarnaOrderUrls("merchant_urls") + return this.cartToKlarnaOrder(paymentInput.cart) + } + + this.validateKlarnaOrderUrls("payment_collection_urls") + + let order = { + // Custom id is stored, such that we can use it for hooks + merchant_data: paymentInput.resource_id, + locale: "en-US", + } + + const { currency_code, amount } = paymentInput + + order.order_lines = [ + { + name: "Payment Collection", + quantity: 1, + unit_price: amount, + tax_rate: 0, + total_amount: amount, + total_tax_amount: 0, + }, + ] + + // 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 + } + /** * Status for Klarna order. * @param {Object} paymentData - payment method data from cart @@ -251,7 +329,7 @@ class KlarnaProviderService extends PaymentService { } /** - * Creates Stripe PaymentIntent. + * Creates Klarna PaymentIntent. * @param {string} cart - the cart to create a payment for * @param {number} amount - the amount to create a payment for * @returns {string} id of payment intent @@ -271,6 +349,21 @@ class KlarnaProviderService extends PaymentService { } } + async createPaymentNew(paymentInput) { + try { + const order = await this.paymentInputToKlarnaOrder(paymentInput) + + const klarnaPayment = await this.klarna_ + .post(this.klarnaOrderUrl_, order) + .then(({ data }) => data) + + return klarnaPayment + } catch (error) { + this.logger_.error(error) + throw error + } + } + /** * Retrieves Klarna Order. * @param {string} cart - the cart to retrieve order for @@ -338,18 +431,20 @@ class KlarnaProviderService extends PaymentService { * @param {string} klarnaOrderId - id of the order to acknowledge * @returns {string} id of acknowledged order */ - async acknowledgeOrder(klarnaOrderId, orderId) { + async acknowledgeOrder(klarnaOrderId, orderId = null) { try { await this.klarna_.post( `${this.klarnaOrderManagementUrl_}/${klarnaOrderId}/acknowledge` ) - await this.klarna_.patch( - `${this.klarnaOrderManagementUrl_}/${klarnaOrderId}/merchant-references`, - { - merchant_reference1: orderId, - } - ) + if (orderId !== null) { + await this.klarna_.patch( + `${this.klarnaOrderManagementUrl_}/${klarnaOrderId}/merchant-references`, + { + merchant_reference1: orderId, + } + ) + } return klarnaOrderId } catch (error) { @@ -408,6 +503,22 @@ class KlarnaProviderService extends PaymentService { return paymentData } + async updatePaymentNew(paymentData, paymentInput) { + if (paymentInput.amount !== paymentData.order_amount) { + const order = await this.paymentInputToKlarnaOrder(paymentInput) + return this.klarna_ + .post(`${this.klarnaOrderUrl_}/${paymentData.order_id}`, order) + .then(({ data }) => data) + .catch(async (_) => { + return this.klarna_ + .post(this.klarnaOrderUrl_, order) + .then(({ data }) => data) + }) + } + + return paymentData + } + /** * Captures Klarna order. * @param {Object} paymentData - payment method data from cart diff --git a/packages/medusa-payment-manual/src/services/manual-payment.js b/packages/medusa-payment-manual/src/services/manual-payment.js index 9afc5797e7a00..139bff887c2c1 100644 --- a/packages/medusa-payment-manual/src/services/manual-payment.js +++ b/packages/medusa-payment-manual/src/services/manual-payment.js @@ -26,6 +26,10 @@ class ManualPaymentService extends PaymentService { return { status: "pending" } } + async createPaymentNew() { + return { status: "pending" } + } + /** * Retrieves payment * @param {object} data - the data of the payment to retrieve @@ -52,6 +56,10 @@ class ManualPaymentService extends PaymentService { return sessionData.data } + async updatePaymentNew(sessionData) { + return sessionData.data + } + /** . * @param {object} sessionData - payment session data. 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 8803ccb6b56d4..cee5b3a9b1c1d 100644 --- a/packages/medusa-payment-paypal/src/api/routes/hooks/paypal.js +++ b/packages/medusa-payment-paypal/src/api/routes/hooks/paypal.js @@ -21,21 +21,11 @@ export default async (req, res) => { return } - try { - const body = req.body - const authId = body.resource.id - const auth = await paypalService.retrieveAuthorization(authId) - - const order = await paypalService.retrieveOrderFromAuth(auth) - - const purchaseUnit = order.purchase_units[0] - const cartId = purchaseUnit.custom_id - - if (!cartId) { - res.sendStatus(200) - return - } + function isPaymentCollection(id) { + return id && id.startsWith("paycol") + } + async function autorizeCart(req, cartId) { const manager = req.scope.resolve("manager") const cartService = req.scope.resolve("cartService") const swapService = req.scope.resolve("swapService") @@ -78,6 +68,42 @@ export default async (req, res) => { } } }) + } + + async function autorizePaymentCollection(req, payColId) { + 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) + }) + // TODO: complete authorization + } + + try { + const body = req.body + const authId = body.resource.id + const auth = await paypalService.retrieveAuthorization(authId) + + const order = await paypalService.retrieveOrderFromAuth(auth) + + const purchaseUnit = order.purchase_units[0] + const customId = purchaseUnit.custom_id + + if (!customId) { + res.sendStatus(200) + return + } + + if (isPaymentCollection(customId)) { + await autorizePaymentCollection(req, customId) + } else { + await autorizeCart(req, customId) + } res.sendStatus(200) } catch (err) { diff --git a/packages/medusa-payment-paypal/src/services/paypal-provider.js b/packages/medusa-payment-paypal/src/services/paypal-provider.js index de0aa5c0c1416..8a147ae681d6f 100644 --- a/packages/medusa-payment-paypal/src/services/paypal-provider.js +++ b/packages/medusa-payment-paypal/src/services/paypal-provider.js @@ -118,6 +118,34 @@ class PayPalProviderService extends PaymentService { return { id: res.result.id } } + async createPaymentNew(paymentInput) { + const { resource_id, currency_code, amount } = paymentInput + + const request = new PayPal.orders.OrdersCreateRequest() + request.requestBody({ + intent: "AUTHORIZE", + application_context: { + shipping_preference: "NO_SHIPPING", + }, + purchase_units: [ + { + custom_id: resource_id, + amount: { + currency_code: currency_code.toUpperCase(), + value: roundToTwo( + humanizeAmount(amount, currency_code), + currency_code + ), + }, + }, + ], + }) + + const res = await this.paypal_.execute(request) + + return { id: res.result.id } + } + /** * Retrieves a PayPal order. * @param {object} data - the data stored with the payment @@ -216,6 +244,35 @@ class PayPalProviderService extends PaymentService { } } + async updatePaymentNew(sessionData, paymentInput) { + try { + const { currency_code, amount } = paymentInput + + const request = new PayPal.orders.OrdersPatchRequest(sessionData.id) + request.requestBody([ + { + op: "replace", + path: "/purchase_units/@reference_id=='default'", + value: { + amount: { + currency_code: currency_code.toUpperCase(), + value: roundToTwo( + humanizeAmount(amount, currency_code), + currency_code + ), + }, + }, + }, + ]) + + await this.paypal_.execute(request) + + return sessionData + } catch (error) { + return this.createPaymentNew(paymentInput) + } + } + /** * Not suported */ 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 f83ebac942f6c..90252b51020ee 100644 --- a/packages/medusa-payment-stripe/src/api/routes/hooks/stripe.js +++ b/packages/medusa-payment-stripe/src/api/routes/hooks/stripe.js @@ -10,40 +10,53 @@ export default async (req, res) => { return } - const paymentIntent = event.data.object + function isPaymentCollection(id) { + return id && id.startsWith("paycol") + } - const manager = req.scope.resolve("manager") - const cartService = req.scope.resolve("cartService") - const orderService = req.scope.resolve("orderService") + async function handleCartPayments(event, req, res, cartId) { + const manager = req.scope.resolve("manager") + const cartService = req.scope.resolve("cartService") + const orderService = req.scope.resolve("orderService") - const cartId = paymentIntent.metadata.cart_id - const order = await orderService - .retrieveByCartId(cartId) - .catch(() => undefined) - - // handle payment intent events - switch (event.type) { - case "payment_intent.succeeded": - if (order && order.payment_status !== "captured") { - await manager.transaction(async (manager) => { - await orderService.withTransaction(manager).capturePayment(order.id) - }) - } - break - case "payment_intent.amount_capturable_updated": - if (!order) { - await manager.transaction(async (manager) => { - const cartServiceTx = cartService.withTransaction(manager) - await cartServiceTx.setPaymentSession(cartId, "stripe") - await cartServiceTx.authorizePayment(cartId) - await orderService.withTransaction(manager).createFromCart(cartId) - }) - } - break - default: - res.sendStatus(204) - return + const order = await orderService + .retrieveByCartId(cartId) + .catch(() => undefined) + + // handle payment intent events + switch (event.type) { + case "payment_intent.succeeded": + if (order && order.payment_status !== "captured") { + await manager.transaction(async (manager) => { + await orderService.withTransaction(manager).capturePayment(order.id) + }) + } + break + case "payment_intent.amount_capturable_updated": + if (!order) { + await manager.transaction(async (manager) => { + const cartServiceTx = cartService.withTransaction(manager) + await cartServiceTx.setPaymentSession(cartId, "stripe") + await cartServiceTx.authorizePayment(cartId) + await orderService.withTransaction(manager).createFromCart(cartId) + }) + } + break + default: + res.sendStatus(204) + return + } + + res.sendStatus(200) } - res.sendStatus(200) + const paymentIntent = event.data.object + const cartId = paymentIntent.metadata.cart_id + const resourceId = paymentIntent.metadata.resource_id + + if (isPaymentCollection(resourceId)) { + // TODO: handle payment collection + } else { + await handleCartPayments(event, req, res, resourceId ?? cartId) + } } diff --git a/packages/medusa-payment-stripe/src/helpers/stripe-base.js b/packages/medusa-payment-stripe/src/helpers/stripe-base.js new file mode 100644 index 0000000000000..20d7c5212f05f --- /dev/null +++ b/packages/medusa-payment-stripe/src/helpers/stripe-base.js @@ -0,0 +1,235 @@ +import Stripe from "stripe" +import { AbstractPaymentService, PaymentSessionData } from "@medusajs/medusa" + +class StripeBase extends AbstractPaymentService { + static identifier = null + + constructor( + { + stripeProviderService, + customerService, + totalsService, + regionService, + manager, + }, + options, + paymentMethodTypes + ) { + super( + { + stripeProviderService, + customerService, + totalsService, + regionService, + manager, + }, + options + ) + /** @private @const {string[]} */ + this.paymentMethodTypes = paymentMethodTypes + + /** + * Required Stripe options: + * { + * api_key: "stripe_secret_key", REQUIRED + * webhook_secret: "stripe_webhook_secret", REQUIRED + * // Use this flag to capture payment immediately (default is false) + * capture: true + * } + */ + this.options_ = options + + /** @private @const {Stripe} */ + this.stripe_ = Stripe(options.api_key) + + /** @private @const {CustomerService} */ + this.stripeProviderService_ = stripeProviderService + + /** @private @const {CustomerService} */ + this.customerService_ = customerService + + /** @private @const {RegionService} */ + this.regionService_ = regionService + + /** @private @const {TotalsService} */ + this.totalsService_ = totalsService + + /** @private @const {EntityManager} */ + this.manager_ = manager + } + + /** + * Fetches Stripe payment intent. Check its status and returns the + * corresponding Medusa status. + * @param {PaymentSessionData} paymentSessionData - payment method data from cart + * @return {Promise} the status of the payment intent + */ + async getStatus(paymentSessionData) { + return await this.stripeProviderService_.getStatus(paymentSessionData) + } + + /** + * Fetches a customers saved payment methods if registered in Stripe. + * @param {object} customer - customer to fetch saved cards for + * @return {Promise} saved payments methods + */ + async retrieveSavedMethods(customer) { + return Promise.resolve([]) + } + + /** + * Fetches a Stripe customer + * @param {string} customerId - Stripe customer id + * @return {Promise} Stripe customer + */ + async retrieveCustomer(customerId) { + return await this.stripeProviderService_.retrieveCustomer(customerId) + } + + /** + * Creates a Stripe customer using a Medusa customer. + * @param {object} customer - Customer data from Medusa + * @return {Promise} Stripe customer + */ + async createCustomer(customer) { + return await this.stripeProviderService_ + .withTransaction(this.manager_) + .createCustomer(customer) + } + + /** + * Creates a Stripe payment intent. + * If customer is not registered in Stripe, we do so. + * @param {Cart} cart - cart to create a payment for + * @return {Promise} Stripe payment intent + */ + async createPayment(cart) { + const intentRequest = { + payment_method_types: this.paymentMethodTypes, + capture_method: "automatic", + } + + return await this.stripeProviderService_.createPayment(cart, intentRequest) + } + + async createPaymentNew(paymentInput) { + const intentRequest = { + payment_method_types: this.paymentMethodTypes, + capture_method: "automatic", + } + + return await this.stripeProviderService_.createPaymentNew( + paymentInput, + intentRequest + ) + } + + /** + * Retrieves Stripe payment intent. + * @param {PaymentData} paymentData - the data of the payment to retrieve + * @return {Promise} Stripe payment intent + */ + async retrievePayment(paymentData) { + return await this.stripeProviderService_.retrievePayment(paymentData) + } + + /** + * Gets a Stripe payment intent and returns it. + * @param {PaymentSession} paymentSession - the data of the payment to retrieve + * @return {Promise} Stripe payment intent + */ + async getPaymentData(paymentSession) { + return await this.stripeProviderService_.getPaymentData(paymentSession) + } + + /** + * Authorizes Stripe payment intent by simply returning + * the status for the payment intent in use. + * @param {PaymentSession} paymentSession - payment session data + * @param {object} context - properties relevant to current context + * @return {Promise<{data: PaymentSessionData; status: PaymentSessionStatus}>} result with data and status + */ + async authorizePayment(paymentSession, context = {}) { + return await this.stripeProviderService_.authorizePayment( + paymentSession, + context + ) + } + + async updatePaymentData(paymentSessionData, data) { + return await this.stripeProviderService_.updatePaymentData( + paymentSessionData, + data + ) + } + + /** + * Updates Stripe payment intent. + * @param {PaymentSessionData} paymentSessionData - payment session data. + * @param {Cart} cart + * @return {Promise} Stripe payment intent + */ + async updatePayment(paymentSessionData, cart) { + return await this.stripeProviderService_.updatePayment( + paymentSessionData, + cart + ) + } + + async updatePaymentNew(paymentSessionData, paymentInput) { + return await this.stripeProviderService_.updatePaymentNew( + paymentSessionData, + paymentInput + ) + } + + async deletePayment(paymentSession) { + return await this.stripeProviderService_.deletePayment(paymentSession) + } + + /** + * Updates customer of Stripe payment intent. + * @param {string} paymentIntentId - id of payment intent to update + * @param {string} customerId - id of new Stripe customer + * @return {object} Stripe payment intent + */ + async updatePaymentIntentCustomer(paymentIntentId, customerId) { + return await this.stripeProviderService_.updatePaymentIntentCustomer( + paymentIntentId, + customerId + ) + } + + /** + * Captures payment for Stripe payment intent. + * @param {Payment} payment - payment method data from cart + * @return {Promise} Stripe payment intent + */ + async capturePayment(payment) { + return await this.stripeProviderService_.capturePayment(payment) + } + + /** + * Refunds payment for Stripe payment intent. + * @param {Payment} payment - payment method data from cart + * @param {number} refundAmount - amount to refund + * @return {Promise} refunded payment intent + */ + async refundPayment(payment, refundAmount) { + return await this.stripeProviderService_.refundPayment( + payment, + refundAmount + ) + } + + /** + * Cancels payment for Stripe payment intent. + * @param {Payment} payment - payment method data from cart + * @return {Promise} canceled payment intent + */ + async cancelPayment(payment) { + return await this.stripeProviderService_.cancelPayment(payment) + } +} + +export default StripeBase diff --git a/packages/medusa-payment-stripe/src/services/stripe-bancontact.js b/packages/medusa-payment-stripe/src/services/stripe-bancontact.js index 3516023252e18..8169b2201cc6b 100644 --- a/packages/medusa-payment-stripe/src/services/stripe-bancontact.js +++ b/packages/medusa-payment-stripe/src/services/stripe-bancontact.js @@ -1,7 +1,6 @@ -import Stripe from "stripe" -import { AbstractPaymentService, PaymentSessionData } from "@medusajs/medusa" +import StripeBase from "../helpers/stripe-base" -class BancontactProviderService extends AbstractPaymentService { +class BancontactProviderService extends StripeBase { static identifier = "stripe-bancontact" constructor( @@ -22,245 +21,9 @@ class BancontactProviderService extends AbstractPaymentService { regionService, manager, }, - options + options, + ["bancontact"] ) - - /** - * Required Stripe options: - * { - * api_key: "stripe_secret_key", REQUIRED - * webhook_secret: "stripe_webhook_secret", REQUIRED - * // Use this flag to capture payment immediately (default is false) - * capture: true - * } - */ - this.options_ = options - - /** @private @const {Stripe} */ - this.stripe_ = Stripe(options.api_key) - - /** @private @const {CustomerService} */ - this.stripeProviderService_ = stripeProviderService - - /** @private @const {CustomerService} */ - this.customerService_ = customerService - - /** @private @const {RegionService} */ - this.regionService_ = regionService - - /** @private @const {TotalsService} */ - this.totalsService_ = totalsService - - /** @private @const {EntityManager} */ - this.manager_ = manager - } - - /** - * Fetches Stripe payment intent. Check its status and returns the - * corresponding Medusa status. - * @param {PaymentSessionData} paymentSessionData - payment method data from cart - * @return {Promise} the status of the payment intent - */ - async getStatus(paymentSessionData) { - return await this.stripeProviderService_.getStatus(paymentSessionData) - } - - /** - * Fetches a customers saved payment methods if registered in Stripe. - * @param {object} customer - customer to fetch saved cards for - * @return {Promise} saved payments methods - */ - async retrieveSavedMethods(customer) { - return Promise.resolve([]) - } - - /** - * Fetches a Stripe customer - * @param {string} customerId - Stripe customer id - * @return {Promise} Stripe customer - */ - async retrieveCustomer(customerId) { - return await this.stripeProviderService_.retrieveCustomer(customerId) - } - - /** - * Creates a Stripe customer using a Medusa customer. - * @param {object} customer - Customer data from Medusa - * @return {Promise} Stripe customer - */ - async createCustomer(customer) { - return await this.stripeProviderService_ - .withTransaction(this.manager_) - .createCustomer(customer) - } - - /** - * Creates a Stripe payment intent. - * If customer is not registered in Stripe, we do so. - * @param {Cart} cart - cart to create a payment for - * @return {Promise} Stripe payment intent - */ - async createPayment(cart) { - const { customer_id, region_id, email } = cart - const region = await this.regionService_ - .withTransaction(this.manager_) - .retrieve(region_id) - const { currency_code } = region - - const amount = await this.totalsService_ - .withTransaction(this.manager_) - .getTotal(cart) - - const intentRequest = { - amount: Math.round(amount), - description: - cart?.context?.payment_description ?? this.options?.payment_description, - currency: currency_code, - payment_method_types: ["bancontact"], - capture_method: "automatic", - metadata: { cart_id: `${cart.id}` }, - } - - if (customer_id) { - const customer = await this.customerService_ - .withTransaction(this.manager_) - .retrieve(customer_id) - - if (customer.metadata?.stripe_id) { - intentRequest.customer = customer.metadata.stripe_id - } else { - const stripeCustomer = await this.createCustomer({ - email, - id: customer_id, - }) - - intentRequest.customer = stripeCustomer.id - } - } else { - const stripeCustomer = await this.createCustomer({ - email, - }) - - intentRequest.customer = stripeCustomer.id - } - - return await this.stripe_.paymentIntents.create(intentRequest) - } - - /** - * Retrieves Stripe payment intent. - * @param {PaymentData} paymentData - the data of the payment to retrieve - * @return {Promise} Stripe payment intent - */ - async retrievePayment(paymentData) { - return await this.stripeProviderService_.retrievePayment(paymentData) - } - - /** - * Gets a Stripe payment intent and returns it. - * @param {PaymentSession} paymentSession - the data of the payment to retrieve - * @return {Promise} Stripe payment intent - */ - async getPaymentData(paymentSession) { - return await this.stripeProviderService_.getPaymentData(paymentSession) - } - - /** - * Authorizes Stripe payment intent by simply returning - * the status for the payment intent in use. - * @param {PaymentSession} paymentSession - payment session data - * @param {object} context - properties relevant to current context - * @return {Promise<{data: PaymentSessionData; status: PaymentSessionStatus}>} result with data and status - */ - async authorizePayment(paymentSession, context = {}) { - return await this.stripeProviderService_.authorizePayment( - paymentSession, - context - ) - } - - async updatePaymentData(paymentSessionData, data) { - return await this.stripeProviderService_.updatePaymentData( - paymentSessionData, - data - ) - } - - /** - * Updates Stripe payment intent. - * @param {PaymentSessionData} paymentSessionData - payment session data. - * @param {Cart} cart - * @return {Promise} Stripe payment intent - */ - async updatePayment(paymentSessionData, cart) { - try { - const stripeId = cart.customer?.metadata?.stripe_id || undefined - - if (stripeId !== paymentSessionData.customer) { - return this.createPayment(cart) - } else { - if ( - cart.total && - paymentSessionData.amount === Math.round(cart.total) - ) { - return sessionData - } - - return this.stripe_.paymentIntents.update(paymentSessionData.id, { - amount: Math.round(cart.total), - }) - } - } catch (error) { - throw error - } - } - - async deletePayment(paymentSession) { - return await this.stripeProviderService_.deletePayment(paymentSession) - } - - /** - * Updates customer of Stripe payment intent. - * @param {string} paymentIntentId - id of payment intent to update - * @param {string} customerId - id of new Stripe customer - * @return {object} Stripe payment intent - */ - async updatePaymentIntentCustomer(paymentIntentId, customerId) { - return await this.stripeProviderService_.updatePaymentIntentCustomer( - paymentIntentId, - customerId - ) - } - - /** - * Captures payment for Stripe payment intent. - * @param {Payment} payment - payment method data from cart - * @return {Promise} Stripe payment intent - */ - async capturePayment(payment) { - return await this.stripeProviderService_.capturePayment(payment) - } - - /** - * Refunds payment for Stripe payment intent. - * @param {Payment} payment - payment method data from cart - * @param {number} refundAmount - amount to refund - * @return {Promise} refunded payment intent - */ - async refundPayment(payment, refundAmount) { - return await this.stripeProviderService_.refundPayment( - payment, - refundAmount - ) - } - - /** - * Cancels payment for Stripe payment intent. - * @param {Payment} payment - payment method data from cart - * @return {Promise} canceled payment intent - */ - async cancelPayment(payment) { - return await this.stripeProviderService_.cancelPayment(payment) } } diff --git a/packages/medusa-payment-stripe/src/services/stripe-blik.js b/packages/medusa-payment-stripe/src/services/stripe-blik.js index d7d5c4bb71880..20febdc417397 100644 --- a/packages/medusa-payment-stripe/src/services/stripe-blik.js +++ b/packages/medusa-payment-stripe/src/services/stripe-blik.js @@ -1,7 +1,6 @@ -import Stripe from "stripe" -import { AbstractPaymentService, PaymentSessionStatus } from "@medusajs/medusa" +import StripeBase from "../helpers/stripe-base" -class BlikProviderService extends AbstractPaymentService { +class BlikProviderService extends StripeBase { static identifier = "stripe-blik" constructor( @@ -22,242 +21,9 @@ class BlikProviderService extends AbstractPaymentService { regionService, manager, }, - options + options, + ["blik"] ) - - /** - * Required Stripe options: - * { - * api_key: "stripe_secret_key", REQUIRED - * webhook_secret: "stripe_webhook_secret", REQUIRED - * // Use this flag to capture payment immediately (default is false) - * capture: true - * } - */ - this.options_ = options - - /** @private @const {Stripe} */ - this.stripe_ = Stripe(options.api_key) - - /** @private @const {CustomerService} */ - this.stripeProviderService_ = stripeProviderService - - /** @private @const {CustomerService} */ - this.customerService_ = customerService - - /** @private @const {RegionService} */ - this.regionService_ = regionService - - /** @private @const {TotalsService} */ - this.totalsService_ = totalsService - - this.manager_ = manager - } - - /** - * Fetches Stripe payment intent. Check its status and returns the - * corresponding Medusa status. - * @param {PaymentSessionData} paymentSessionData - payment method data from cart - * @return {Promise} the status of the payment intent - */ - async getStatus(paymentSessionData) { - return await this.stripeProviderService_.getStatus(paymentSessionData) - } - - /** - * Fetches a customers saved payment methods if registered in Stripe. - * @param {object} customer - customer to fetch saved cards for - * @return {Promise} saved payments methods - */ - async retrieveSavedMethods(customer) { - return Promise.resolve([]) - } - - /** - * Fetches a Stripe customer - * @param {string} customerId - Stripe customer id - * @return {Promise} Stripe customer - */ - async retrieveCustomer(customerId) { - return await this.stripeProviderService_.retrieveCustomer(customerId) - } - - /** - * Creates a Stripe customer using a Medusa customer. - * @param {object} customer - Customer data from Medusa - * @return {Promise} Stripe customer - */ - async createCustomer(customer) { - return await this.stripeProviderService_ - .withTransaction(this.manager_) - .createCustomer(customer) - } - - /** - * Creates a Stripe payment intent. - * If customer is not registered in Stripe, we do so. - * @param {Cart} cart - cart to create a payment for - * @returns {PaymentSessionData} Stripe payment intent - */ - async createPayment(cart) { - const { customer_id, region_id, email } = cart - const region = await this.regionService_ - .withTransaction(this.manager_) - .retrieve(region_id) - const { currency_code } = region - - const amount = await this.totalsService_ - .withTransaction(this.manager_) - .getTotal(cart) - - const intentRequest = { - amount: Math.round(amount), - currency: currency_code, - payment_method_types: ["blik"], - capture_method: "automatic", - metadata: { cart_id: `${cart.id}` }, - } - - if (customer_id) { - const customer = await this.customerService_ - .withTransaction(this.manager_) - .retrieve(customer_id) - - if (customer.metadata?.stripe_id) { - intentRequest.customer = customer.metadata.stripe_id - } else { - const stripeCustomer = await this.createCustomer({ - email, - id: customer_id, - }) - - intentRequest.customer = stripeCustomer.id - } - } else { - const stripeCustomer = await this.createCustomer({ - email, - }) - - intentRequest.customer = stripeCustomer.id - } - - return await this.stripe_.paymentIntents.create(intentRequest) - } - - /** - * Retrieves Stripe payment intent. - * @param {PaymentData} paymentData - the data of the payment to retrieve - * @return {Promise} Stripe payment intent - */ - async retrievePayment(paymentData) { - return await this.stripeProviderService_.retrievePayment(paymentData) - } - - /** - * Gets a Stripe payment intent and returns it. - * @param {PaymentSession} paymentSession - the data of the payment to retrieve - * @return {Promise} Stripe payment intent - */ - async getPaymentData(paymentSession) { - return await this.stripeProviderService_.getPaymentData(paymentSession) - } - - /** - * Authorizes Stripe payment intent by simply returning - * the status for the payment intent in use. - * @param {PaymentSession} paymentSession - payment session data - * @param {object} context - properties relevant to current context - * @return {Promise<{data: PaymentSessionData; status: PaymentSessionStatus}>} result with data and status - */ - async authorizePayment(paymentSession, context = {}) { - return await this.stripeProviderService_.authorizePayment( - paymentSession, - context - ) - } - - async updatePaymentData(paymentSessionData, data) { - return await this.stripeProviderService_.updatePaymentData( - paymentSessionData, - data - ) - } - - /** - * Updates Stripe payment intent. - * @param {PaymentSessionData} paymentSessionData - payment session data. - * @param {Cart} cart - * @return {Promise} Stripe payment intent - */ - async updatePayment(paymentSessionData, cart) { - try { - const stripeId = cart.customer?.metadata?.stripe_id || undefined - - if (stripeId !== paymentSessionData.customer) { - return this.createPayment(cart) - } else { - if ( - cart.total && - paymentSessionData.amount === Math.round(cart.total) - ) { - return sessionData - } - - return this.stripe_.paymentIntents.update(paymentSessionData.id, { - amount: Math.round(cart.total), - }) - } - } catch (error) { - throw error - } - } - - async deletePayment(paymentSession) { - return await this.stripeProviderService_.deletePayment(paymentSession) - } - - /** - * Updates customer of Stripe payment intent. - * @param {string} paymentIntentId - id of payment intent to update - * @param {string} customerId - id of new Stripe customer - * @return {object} Stripe payment intent - */ - async updatePaymentIntentCustomer(paymentIntentId, customerId) { - return await this.stripeProviderService_.updatePaymentIntentCustomer( - paymentIntentId, - customerId - ) - } - - /** - * Captures payment for Stripe payment intent. - * @param {Payment} payment - payment method data from cart - * @return {Promise} Stripe payment intent - */ - async capturePayment(payment) { - return await this.stripeProviderService_.capturePayment(payment) - } - - /** - * Refunds payment for Stripe payment intent. - * @param {Payment} payment - payment method data from cart - * @param {number} refundAmount - amount to refund - * @return {Promise} refunded payment intent - */ - async refundPayment(payment, refundAmount) { - return await this.stripeProviderService_.refundPayment( - payment, - refundAmount - ) - } - - /** - * Cancels payment for Stripe payment intent. - * @param {Payment} payment - payment method data from cart - * @return {Promise} canceled payment intent - */ - async cancelPayment(payment) { - return await this.stripeProviderService_.cancelPayment(payment) } } diff --git a/packages/medusa-payment-stripe/src/services/stripe-giropay.js b/packages/medusa-payment-stripe/src/services/stripe-giropay.js index 1f61f1b9ea8d3..b5425f44bc9e4 100644 --- a/packages/medusa-payment-stripe/src/services/stripe-giropay.js +++ b/packages/medusa-payment-stripe/src/services/stripe-giropay.js @@ -1,7 +1,6 @@ -import Stripe from "stripe" -import { AbstractPaymentService, PaymentSessionStatus } from "@medusajs/medusa" +import StripeBase from "../helpers/stripe-base" -class GiropayProviderService extends AbstractPaymentService { +class GiropayProviderService extends StripeBase { static identifier = "stripe-giropay" constructor( @@ -22,245 +21,9 @@ class GiropayProviderService extends AbstractPaymentService { regionService, manager, }, - options + options, + ["giropay"] ) - - /** - * Required Stripe options: - * { - * api_key: "stripe_secret_key", REQUIRED - * webhook_secret: "stripe_webhook_secret", REQUIRED - * // Use this flag to capture payment immediately (default is false) - * capture: true - * } - */ - this.options_ = options - - /** @private @const {Stripe} */ - this.stripe_ = Stripe(options.api_key) - - /** @private @const {CustomerService} */ - this.stripeProviderService_ = stripeProviderService - - /** @private @const {CustomerService} */ - this.customerService_ = customerService - - /** @private @const {RegionService} */ - this.regionService_ = regionService - - /** @private @const {TotalsService} */ - this.totalsService_ = totalsService - - /** @private @const {EntityManager} */ - this.manager_ = manager - } - - /** - * Fetches Stripe payment intent. Check its status and returns the - * corresponding Medusa status. - * @param {PaymentSessionData} paymentSessionData - payment method data from cart - * @return {Promise} the status of the payment intent - */ - async getStatus(paymentSessionData) { - return await this.stripeProviderService_.getStatus(paymentSessionData) - } - - /** - * Fetches a customers saved payment methods if registered in Stripe. - * @param {Customer} customer - customer to fetch saved cards for - * @return {Promise} saved payments methods - */ - async retrieveSavedMethods(customer) { - return Promise.resolve([]) - } - - /** - * Fetches a Stripe customer - * @param {string} customerId - Stripe customer id - * @return {Promise} Stripe customer - */ - async retrieveCustomer(customerId) { - return await this.stripeProviderService_.retrieveCustomer(customerId) - } - - /** - * Creates a Stripe customer using a Medusa customer. - * @param {object} customer - Customer data from Medusa - * @return {Promise} Stripe customer - */ - async createCustomer(customer) { - return await this.stripeProviderService_ - .withTransaction(this.manager_) - .createCustomer(customer) - } - - /** - * Creates a Stripe payment intent. - * If customer is not registered in Stripe, we do so. - * @param {Cart} cart - cart to create a payment for - * @return {Promise} Stripe payment intent - */ - async createPayment(cart) { - const { customer_id, region_id, email } = cart - const region = await this.regionService_ - .withTransaction(this.manager_) - .retrieve(region_id) - const { currency_code } = region - - const amount = await this.totalsService_ - .withTransaction(this.manager_) - .getTotal(cart) - - const intentRequest = { - amount: Math.round(amount), - description: - cart?.context?.payment_description ?? this.options?.payment_description, - currency: currency_code, - payment_method_types: ["giropay"], - capture_method: "automatic", - metadata: { cart_id: `${cart.id}` }, - } - - if (customer_id) { - const customer = await this.customerService_ - .withTransaction(this.manager_) - .retrieve(customer_id) - - if (customer.metadata?.stripe_id) { - intentRequest.customer = customer.metadata.stripe_id - } else { - const stripeCustomer = await this.createCustomer({ - email, - id: customer_id, - }) - - intentRequest.customer = stripeCustomer.id - } - } else { - const stripeCustomer = await this.createCustomer({ - email, - }) - - intentRequest.customer = stripeCustomer.id - } - - return await this.stripe_.paymentIntents.create(intentRequest) - } - - /** - * Retrieves Stripe payment intent. - * @param {PaymentData} paymentData - the data of the payment to retrieve - * @return {Promise} Stripe payment intent - */ - async retrievePayment(paymentData) { - return await this.stripeProviderService_.retrievePayment(paymentData) - } - - /** - * Gets a Stripe payment intent and returns it. - * @param {PaymentSession} paymentSession - the data of the payment to retrieve - * @return {Promise} Stripe payment intent - */ - async getPaymentData(paymentSession) { - return await this.stripeProviderService_.getPaymentData(paymentSession) - } - - /** - * Authorizes Stripe payment intent by simply returning - * the status for the payment intent in use. - * @param {PaymentSession} paymentSession - payment session data - * @param {Data} context - properties relevant to current context - * @return {Promise<{data: PaymentSessionData; status: PaymentSessionStatus}>} result with data and status - */ - async authorizePayment(paymentSession, context = {}) { - return await this.stripeProviderService_.authorizePayment( - paymentSession, - context - ) - } - - async updatePaymentData(paymentSessionData, data) { - return await this.stripeProviderService_.updatePaymentData( - paymentSessionData, - data - ) - } - - /** - * Updates Stripe payment intent. - * @param {PaymentSessionData} paymentSessionData - payment session data. - * @param {Cart} cart - * @return {Promise} Stripe payment intent - */ - async updatePayment(paymentSessionData, cart) { - try { - const stripeId = cart.customer?.metadata?.stripe_id || undefined - - if (stripeId !== paymentSessionData.customer) { - return this.createPayment(cart) - } else { - if ( - cart.total && - paymentSessionData.amount === Math.round(cart.total) - ) { - return paymentSessionData - } - - return this.stripe_.paymentIntents.update(paymentSessionData.id, { - amount: Math.round(cart.total), - }) - } - } catch (error) { - throw error - } - } - - async deletePayment(paymentSession) { - return await this.stripeProviderService_.deletePayment(paymentSession) - } - - /** - * Updates customer of Stripe payment intent. - * @param {string} paymentIntentId - id of payment intent to update - * @param {string} customerId - id of new Stripe customer - * @return {object} Stripe payment intent - */ - async updatePaymentIntentCustomer(paymentIntentId, customerId) { - return await this.stripeProviderService_.updatePaymentIntentCustomer( - paymentIntentId, - customerId - ) - } - - /** - * Captures payment for Stripe payment intent. - * @param {Payment} payment - payment method data from cart - * @return {Promise} Stripe payment intent - */ - async capturePayment(payment) { - return await this.stripeProviderService_.capturePayment(payment) - } - - /** - * Refunds payment for Stripe payment intent. - * @param {Payment} payment - payment method data from cart - * @param {number} refundAmount - amount to refund - * @return {Promise} refunded payment intent - */ - async refundPayment(payment, refundAmount) { - return await this.stripeProviderService_.refundPayment( - payment, - refundAmount - ) - } - - /** - * Cancels payment for Stripe payment intent. - * @param {Payment} payment - payment method data from cart - * @return {Promise} canceled payment intent - */ - async cancelPayment(payment) { - return await this.stripeProviderService_.cancelPayment(payment) } } diff --git a/packages/medusa-payment-stripe/src/services/stripe-ideal.js b/packages/medusa-payment-stripe/src/services/stripe-ideal.js index 2ed1712335cf7..8da5a17c973b2 100644 --- a/packages/medusa-payment-stripe/src/services/stripe-ideal.js +++ b/packages/medusa-payment-stripe/src/services/stripe-ideal.js @@ -1,7 +1,6 @@ -import Stripe from "stripe" -import { PaymentService } from "medusa-interfaces" +import StripeBase from "../helpers/stripe-base" -class IdealProviderService extends PaymentService { +class IdealProviderService extends StripeBase { static identifier = "stripe-ideal" constructor( @@ -22,242 +21,9 @@ class IdealProviderService extends PaymentService { regionService, manager, }, - options + options, + ["ideal"] ) - - /** - * Required Stripe options: - * { - * api_key: "stripe_secret_key", REQUIRED - * webhook_secret: "stripe_webhook_secret", REQUIRED - * // Use this flag to capture payment immediately (default is false) - * capture: true - * } - */ - this.options_ = options - - /** @private @const {Stripe} */ - this.stripe_ = Stripe(options.api_key) - - /** @private @const {CustomerService} */ - this.stripeProviderService_ = stripeProviderService - - /** @private @const {CustomerService} */ - this.customerService_ = customerService - - /** @private @const {RegionService} */ - this.regionService_ = regionService - - /** @private @const {TotalsService} */ - this.totalsService_ = totalsService - - this.manager_ = manager - } - - /** - * Fetches Stripe payment intent. Check its status and returns the - * corresponding Medusa status. - * @param {PaymentSessionData} paymentSessionData - payment method data from cart - * @return {Promise} the status of the payment intent - */ - async getStatus(paymentData) { - return await this.stripeProviderService_.getStatus(paymentData) - } - - /** - * Fetches a customers saved payment methods if registered in Stripe. - * @param {object} customer - customer to fetch saved cards for - * @return {Promise} saved payments methods - */ - async retrieveSavedMethods(customer) { - return Promise.resolve([]) - } - - /** - * Fetches a Stripe customer - * @param {string} customerId - Stripe customer id - * @return {Promise} Stripe customer - */ - async retrieveCustomer(customerId) { - return await this.stripeProviderService_.retrieveCustomer(customerId) - } - - /** - * Creates a Stripe customer using a Medusa customer. - * @param {object} customer - Customer data from Medusa - * @return {Promise} Stripe customer - */ - async createCustomer(customer) { - return await this.stripeProviderService_ - .withTransaction(this.manager_) - .createCustomer(customer) - } - - /** - * Creates a Stripe payment intent. - * If customer is not registered in Stripe, we do so. - * @param {Cart} cart - cart to create a payment for - * @return {Promise} Stripe payment intent - */ - async createPayment(cart) { - const { customer_id, region_id, email } = cart - const region = await this.regionService_ - .withTransaction(this.manager_) - .retrieve(region_id) - const { currency_code } = region - - const amount = await this.totalsService_ - .withTransaction(this.manager_) - .getTotal(cart) - - const intentRequest = { - amount: Math.round(amount), - description: - cart?.context?.payment_description ?? - this.options_?.payment_description, - currency: currency_code, - payment_method_types: ["ideal"], - capture_method: "automatic", - metadata: { cart_id: `${cart.id}` }, - } - - if (customer_id) { - const customer = await this.customerService_ - .withTransaction(this.manager_) - .retrieve(customer_id) - - if (customer.metadata?.stripe_id) { - intentRequest.customer = customer.metadata.stripe_id - } else { - const stripeCustomer = await this.createCustomer({ - email, - id: customer_id, - }) - - intentRequest.customer = stripeCustomer.id - } - } else { - const stripeCustomer = await this.createCustomer({ - email, - }) - - intentRequest.customer = stripeCustomer.id - } - - return await this.stripe_.paymentIntents.create(intentRequest) - } - - /** - * Retrieves Stripe payment intent. - * @param {PaymentData} paymentData - the data of the payment to retrieve - * @return {Promise} Stripe payment intent - */ - async retrievePayment(paymentData) { - return await this.stripeProviderService_.retrievePayment(paymentData) - } - - /** - * Gets a Stripe payment intent and returns it. - * @param {PaymentSession} paymentSession - the data of the payment to retrieve - * @return {Promise} Stripe payment intent - */ - async getPaymentData(paymentSession) { - return await this.stripeProviderService_.getPaymentData(paymentSession) - } - - /** - * Authorizes Stripe payment intent by simply returning - * the status for the payment intent in use. - * @param {PaymentSession} paymentSession - payment session data - * @param {object} context - properties relevant to current context - * @return {Promise<{data: PaymentSessionData; status: PaymentSessionStatus}>} result with data and status - */ - async authorizePayment(paymentSession, context = {}) { - return await this.stripeProviderService_.authorizePayment( - paymentSession, - context - ) - } - - async updatePaymentData(paymentSessionData, data) { - return await this.stripeProviderService_.updatePaymentData( - paymentSessionData, - data - ) - } - - /** - * Updates Stripe payment intent. - * @param {PaymentSessionData} paymentSessionData - payment session data. - * @param {Cart} cart - * @return {Promise} Stripe payment intent - */ - async updatePayment(sessionData, cart) { - try { - const stripeId = cart.customer?.metadata?.stripe_id || undefined - - if (stripeId !== sessionData.customer) { - return this.createPayment(cart) - } else { - if (cart.total && sessionData.amount === Math.round(cart.total)) { - return sessionData - } - - return this.stripe_.paymentIntents.update(sessionData.id, { - amount: Math.round(cart.total), - }) - } - } catch (error) { - throw error - } - } - - async deletePayment(paymentSession) { - return await this.stripeProviderService_.deletePayment(paymentSession) - } - - /** - * Updates customer of Stripe payment intent. - * @param {string} paymentIntentId - id of payment intent to update - * @param {string} customerId - id of new Stripe customer - * @return {object} Stripe payment intent - */ - async updatePaymentIntentCustomer(paymentIntentId, customerId) { - return await this.stripeProviderService_.updatePaymentIntentCustomer( - paymentIntentId, - customerId - ) - } - - /** - * Captures payment for Stripe payment intent. - * @param {Payment} payment - payment method data from cart - * @return {Promise} Stripe payment intent - */ - async capturePayment(payment) { - return await this.stripeProviderService_.capturePayment(payment) - } - - /** - * Refunds payment for Stripe payment intent. - * @param {Payment} payment - payment method data from cart - * @param {number} refundAmount - amount to refund - * @return {Promise} refunded payment intent - */ - async refundPayment(payment, refundAmount) { - return await this.stripeProviderService_.refundPayment( - payment, - refundAmount - ) - } - - /** - * Cancels payment for Stripe payment intent. - * @param {Payment} payment - payment method data from cart - * @return {Promise} canceled payment intent - */ - async cancelPayment(payment) { - return await this.stripeProviderService_.cancelPayment(payment) } } diff --git a/packages/medusa-payment-stripe/src/services/stripe-provider.js b/packages/medusa-payment-stripe/src/services/stripe-provider.js index b3f96e1803ba5..8a04867a6bf4b 100644 --- a/packages/medusa-payment-stripe/src/services/stripe-provider.js +++ b/packages/medusa-payment-stripe/src/services/stripe-provider.js @@ -129,7 +129,7 @@ class StripeProviderService extends AbstractPaymentService { * @param {Cart} cart - cart to create a payment for * @return {Promise} Stripe payment intent */ - async createPayment(cart) { + async createPayment(cart, intentRequestData = {}) { const { customer_id, region_id, email } = cart const { currency_code } = await this.regionService_ .withTransaction(this.manager_) @@ -143,9 +143,10 @@ class StripeProviderService extends AbstractPaymentService { this.options_?.payment_description, amount: Math.round(amount), currency: currency_code, + metadata: { cart_id: `${cart.id}` }, setup_future_usage: "on_session", capture_method: this.options_.capture ? "automatic" : "manual", - metadata: { cart_id: `${cart.id}` }, + ...intentRequestData, } if (this.options_?.automatic_payment_methods) { @@ -178,6 +179,44 @@ class StripeProviderService extends AbstractPaymentService { return await this.stripe_.paymentIntents.create(intentRequest) } + async createPaymentNew(paymentInput, intentRequestData = {}) { + const { customer, currency_code, amount, resource_id, cart } = paymentInput + const { id: customer_id, email } = customer + + let intentRequest = { + description: + cart?.context?.payment_description ?? + this.options_?.payment_description, + amount: Math.round(amount), + currency: currency_code, + metadata: { resource_id }, + setup_future_usage: "on_session", + capture_method: this.options_.capture ? "automatic" : "manual", + ...intentRequestData, + } + + if (customer_id) { + if (customer.metadata?.stripe_id) { + intentRequest.customer = customer.metadata.stripe_id + } else { + const stripeCustomer = await this.createCustomer({ + email, + id: customer_id, + }) + + intentRequest.customer = stripeCustomer.id + } + } else { + const stripeCustomer = await this.createCustomer({ + email, + }) + + intentRequest.customer = stripeCustomer.id + } + + return await this.stripe_.paymentIntents.create(intentRequest) + } + /** * Retrieves Stripe payment intent. * @param {PaymentData} paymentData - the data of the payment to retrieve @@ -213,7 +252,6 @@ class StripeProviderService extends AbstractPaymentService { */ async authorizePayment(paymentSession, context = {}) { const stat = await this.getStatus(paymentSession.data) - try { return { data: paymentSession.data, status: stat } } catch (error) { @@ -242,7 +280,7 @@ class StripeProviderService extends AbstractPaymentService { const stripeId = cart.customer?.metadata?.stripe_id || undefined if (stripeId !== sessionData.customer) { - return this.createPayment(cart) + return await this.createPayment(cart) } else { if (cart.total && sessionData.amount === Math.round(cart.total)) { return sessionData @@ -257,6 +295,26 @@ class StripeProviderService extends AbstractPaymentService { } } + async updatePaymentNew(paymentSessionData, paymentInput) { + try { + const stripeId = paymentInput.customer?.metadata?.stripe_id + + if (stripeId !== paymentInput.customer_id) { + return await this.createPaymentNew(paymentInput) + } else { + if (paymentSessionData.amount === Math.round(paymentInput.amount)) { + return sessionData + } + + return this.stripe_.paymentIntents.update(paymentSessionData.id, { + amount: Math.round(paymentInput.amount), + }) + } + } catch (error) { + throw error + } + } + async deletePayment(payment) { try { const { id } = payment.data diff --git a/packages/medusa-payment-stripe/src/services/stripe-przelewy24.js b/packages/medusa-payment-stripe/src/services/stripe-przelewy24.js index c63535769021b..ad30da4baf86f 100644 --- a/packages/medusa-payment-stripe/src/services/stripe-przelewy24.js +++ b/packages/medusa-payment-stripe/src/services/stripe-przelewy24.js @@ -1,7 +1,6 @@ -import Stripe from "stripe" -import { AbstractPaymentService, PaymentSessionStatus } from "@medusajs/medusa" +import StripeBase from "../helpers/stripe-base" -class Przelewy24ProviderService extends AbstractPaymentService { +class Przelewy24ProviderService extends StripeBase { static identifier = "stripe-przelewy24" constructor( @@ -22,239 +21,9 @@ class Przelewy24ProviderService extends AbstractPaymentService { regionService, manager, }, - options + options, + ["p24"] ) - - /** - * Required Stripe options: - * { - * api_key: "stripe_secret_key", REQUIRED - * webhook_secret: "stripe_webhook_secret", REQUIRED - * // Use this flag to capture payment immediately (default is false) - * capture: true - * } - */ - this.options_ = options - - /** @private @const {Stripe} */ - this.stripe_ = Stripe(options.api_key) - - /** @private @const {CustomerService} */ - this.stripeProviderService_ = stripeProviderService - - /** @private @const {CustomerService} */ - this.customerService_ = customerService - - /** @private @const {RegionService} */ - this.regionService_ = regionService - - /** @private @const {TotalsService} */ - this.totalsService_ = totalsService - - this.manager_ = manager - } - - /** - * Fetches Stripe payment intent. Check its status and returns the - * corresponding Medusa status. - * @param {PaymentSessionData} paymentSessionData - payment method data from cart - * @return {Promise} the status of the payment intent - */ - async getStatus(paymentData) { - return await this.stripeProviderService_.getStatus(paymentData) - } - - /** - * Fetches a customers saved payment methods if registered in Stripe. - * @param {object} customer - customer to fetch saved cards for - * @return {Promise} saved payments methods - */ - async retrieveSavedMethods(customer) { - return Promise.resolve([]) - } - - /** - * Fetches a Stripe customer - * @param {string} customerId - Stripe customer id - * @return {Promise} Stripe customer - */ - async retrieveCustomer(customerId) { - return await this.stripeProviderService_.retrieveCustomer(customerId) - } - - /** - * Creates a Stripe customer using a Medusa customer. - * @param {object} customer - Customer data from Medusa - * @return {Promise} Stripe customer - */ - async createCustomer(customer) { - return await this.stripeProviderService_ - .withTransaction(this.manager_) - .createCustomer(customer) - } - - /** - * Creates a Stripe payment intent. - * If customer is not registered in Stripe, we do so. - * @param {object} cart - cart to create a payment for - * @returns {object} Stripe payment intent - */ - async createPayment(cart) { - const { customer_id, region_id, email } = cart - const region = await this.regionService_ - .withTransaction(this.manager_) - .retrieve(region_id) - const { currency_code } = region - - const amount = await this.totalsService_ - .withTransaction(this.manager_) - .getTotal(cart) - - const intentRequest = { - amount: Math.round(amount), - currency: currency_code, - payment_method_types: ["p24"], - capture_method: "automatic", - metadata: { cart_id: `${cart.id}` }, - } - - if (customer_id) { - const customer = await this.customerService_ - .withTransaction(this.manager_) - .retrieve(customer_id) - - if (customer.metadata?.stripe_id) { - intentRequest.customer = customer.metadata.stripe_id - } else { - const stripeCustomer = await this.createCustomer({ - email, - id: customer_id, - }) - - intentRequest.customer = stripeCustomer.id - } - } else { - const stripeCustomer = await this.createCustomer({ - email, - }) - - intentRequest.customer = stripeCustomer.id - } - - return await this.stripe_.paymentIntents.create(intentRequest) - } - - /** - * Retrieves Stripe payment intent. - * @param {PaymentData} paymentData - the data of the payment to retrieve - * @return {Promise} Stripe payment intent - */ - async retrievePayment(paymentData) { - return await this.stripeProviderService_.retrievePayment(paymentData) - } - - /** - * Gets a Stripe payment intent and returns it. - * @param {PaymentSession} paymentSession - the data of the payment to retrieve - * @return {Promise} Stripe payment intent - */ - async getPaymentData(paymentSession) { - return await this.stripeProviderService_.getPaymentData(paymentSession) - } - - /** - * Authorizes Stripe payment intent by simply returning - * the status for the payment intent in use. - * @param {PaymentSession} paymentSession - payment session data - * @param {object} context - properties relevant to current context - * @return {Promise<{data: PaymentSessionData; status: PaymentSessionStatus}>} result with data and status - */ - async authorizePayment(paymentSession, context = {}) { - return await this.stripeProviderService_.authorizePayment( - paymentSession, - context - ) - } - - async updatePaymentData(paymentSessionData, data) { - return await this.stripeProviderService_.updatePaymentData( - paymentSessionData, - data - ) - } - - /** - * Updates Stripe payment intent. - * @param {PaymentSessionData} paymentSessionData - payment session data. - * @param {Cart} cart - * @return {Promise} Stripe payment intent - */ - async updatePayment(sessionData, cart) { - try { - const stripeId = cart.customer?.metadata?.stripe_id || undefined - - if (stripeId !== sessionData.customer) { - return this.createPayment(cart) - } else { - if (cart.total && sessionData.amount === Math.round(cart.total)) { - return sessionData - } - - return this.stripe_.paymentIntents.update(sessionData.id, { - amount: Math.round(cart.total), - }) - } - } catch (error) { - throw error - } - } - - async deletePayment(paymentSession) { - return await this.stripeProviderService_.deletePayment(paymentSession) - } - - /** - * Updates customer of Stripe payment intent. - * @param {string} paymentIntentId - id of payment intent to update - * @param {string} customerId - id of new Stripe customer - * @return {object} Stripe payment intent - */ - async updatePaymentIntentCustomer(paymentIntentId, customerId) { - return await this.stripeProviderService_.updatePaymentIntentCustomer( - paymentIntentId, - customerId - ) - } - - /** - * Captures payment for Stripe payment intent. - * @param {Payment} payment - payment method data from cart - * @return {Promise} Stripe payment intent - */ - async capturePayment(payment) { - return await this.stripeProviderService_.capturePayment(payment) - } - - /** - * Refunds payment for Stripe payment intent. - * @param {Payment} payment - payment method data from cart - * @param {number} refundAmount - amount to refund - * @return {Promise} refunded payment intent - */ - async refundPayment(payment, refundAmount) { - return await this.stripeProviderService_.refundPayment( - payment, - refundAmount - ) - } - - /** - * Cancels payment for Stripe payment intent. - * @param {Payment} payment - payment method data from cart - * @return {Promise} canceled payment intent - */ - async cancelPayment(payment) { - return await this.stripeProviderService_.cancelPayment(payment) } } diff --git a/packages/medusa/src/interfaces/payment-service.ts b/packages/medusa/src/interfaces/payment-service.ts index 64c1b24b8a190..6ac3676a65664 100644 --- a/packages/medusa/src/interfaces/payment-service.ts +++ b/packages/medusa/src/interfaces/payment-service.ts @@ -7,6 +7,7 @@ import { PaymentSessionStatus, } from "../models" import { PaymentService } from "medusa-interfaces" +import { PaymentProviderDataInput } from "../types/payment-collection" export type Data = Record export type PaymentData = Data @@ -76,6 +77,9 @@ export abstract class AbstractPaymentService ): Promise public abstract createPayment(cart: Cart): Promise + public abstract createPaymentNew( + paymentInput: PaymentProviderDataInput + ): Promise public abstract retrievePayment(paymentData: PaymentData): Promise @@ -84,6 +88,11 @@ export abstract class AbstractPaymentService cart: Cart ): Promise + public abstract updatePaymentNew( + paymentSessionData: PaymentSessionData, + paymentInput: PaymentProviderDataInput + ): Promise + public abstract authorizePayment( paymentSession: PaymentSession, context: Data diff --git a/packages/medusa/src/migrations/1664880666982-payment-collection.ts b/packages/medusa/src/migrations/1664880666982-payment-collection.ts index c66fec1dd9d97..dc3c7d3603aa8 100644 --- a/packages/medusa/src/migrations/1664880666982-payment-collection.ts +++ b/packages/medusa/src/migrations/1664880666982-payment-collection.ts @@ -27,6 +27,7 @@ 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, @@ -71,6 +72,12 @@ export class paymentCollection1664880666982 implements MigrationInterface { ALTER TABLE "order_edit" ADD CONSTRAINT "FK_order_edit_payment_collection_id" FOREIGN KEY ("payment_collection_id") REFERENCES "payment_collection"("id") ON DELETE NO ACTION ON UPDATE NO ACTION; ALTER TABLE payment_session ADD COLUMN payment_authorized_at timestamp WITH time zone NULL; + ALTER TABLE payment_session ADD COLUMN amount integer NULL; + ALTER TABLE payment_session ALTER COLUMN cart_id DROP NOT NULL; + + 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; `) // Add missing indexes @@ -85,6 +92,12 @@ export class paymentCollection1664880666982 implements MigrationInterface { public async down(queryRunner: QueryRunner): Promise { await queryRunner.query(` + DROP INDEX "IDX_order_edit_payment_collection_id"; + ALTER TABLE order_edit DROP CONSTRAINT "FK_order_edit_payment_collection_id"; + + DROP INDEX "IDX_refund_payment_id"; + ALTER TABLE refund DROP CONSTRAINT "FK_refund_payment_id"; + 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"; ALTER TABLE payment_collection_sessions DROP CONSTRAINT "FK_payment_collection_sessions_payment_session_id"; @@ -92,6 +105,9 @@ export class paymentCollection1664880666982 implements MigrationInterface { ALTER TABLE payment_collection_payments DROP CONSTRAINT "FK_payment_collection_payments_payment_id"; ALTER TABLE order_edit DROP COLUMN payment_collection_id; ALTER TABLE payment_session DROP COLUMN payment_authorized_at; + ALTER TABLE payment_session DROP COLUMN amount; + ALTER TABLE payment_session ALTER COLUMN cart_id SET NOT NULL; + ALTER TABLE refund DROP COLUMN payment_id; DROP TABLE payment_collection; DROP TABLE payment_collection_sessions; @@ -99,10 +115,6 @@ export class paymentCollection1664880666982 implements MigrationInterface { DROP TYPE "PAYMENT_COLLECTION_TYPE_ENUM"; DROP TYPE "PAYMENT_COLLECTION_STATUS_ENUM"; - - DROP INDEX "IDX_order_edit_payment_collection_id"; - ALTER TABLE order_edit DROP CONSTRAINT "FK_order_edit_payment_collection_id"; - `) await queryRunner.query(` diff --git a/packages/medusa/src/models/payment-collection.ts b/packages/medusa/src/models/payment-collection.ts index 1625a4872f54b..f58708a0afd7f 100644 --- a/packages/medusa/src/models/payment-collection.ts +++ b/packages/medusa/src/models/payment-collection.ts @@ -50,6 +50,9 @@ export class PaymentCollection extends SoftDeletableEntity { @Column({ type: "int", nullable: true }) authorized_amount: number + @Column({ type: "int", nullable: true }) + captured_amount: number + @Column({ type: "int", nullable: true }) refunded_amount: number diff --git a/packages/medusa/src/models/payment-session.ts b/packages/medusa/src/models/payment-session.ts index 41630a86abf1c..bbfda3a07c65d 100644 --- a/packages/medusa/src/models/payment-session.ts +++ b/packages/medusa/src/models/payment-session.ts @@ -28,7 +28,7 @@ export enum PaymentSessionStatus { @Entity() export class PaymentSession extends BaseEntity { @Index() - @Column() + @Column({ nullable: true }) cart_id: string @ManyToOne(() => Cart, (cart) => cart.payment_sessions) @@ -51,6 +51,11 @@ export class PaymentSession extends BaseEntity { @Column({ nullable: true }) idempotency_key: string + @FeatureFlagDecorators(OrderEditingFeatureFlag.key, [ + Column({ type: "integer", nullable: true }), + ]) + amount: number + @FeatureFlagDecorators(OrderEditingFeatureFlag.key, [ Column({ type: resolveDbType("timestamptz"), nullable: true }), ]) diff --git a/packages/medusa/src/models/refund.ts b/packages/medusa/src/models/refund.ts index 1d50b608b752c..fbc311a5820c1 100644 --- a/packages/medusa/src/models/refund.ts +++ b/packages/medusa/src/models/refund.ts @@ -5,12 +5,16 @@ import { Index, JoinColumn, ManyToOne, + OneToOne, } from "typeorm" import { BaseEntity } from "../interfaces/models/base-entity" import { DbAwareColumn } from "../utils/db-aware-column" import { Order } from "./order" import { generateEntityId } from "../utils/generate-entity-id" +import { Payment } from "./payment" +import { FeatureFlagDecorators } from "../utils/feature-flag-decorators" +import OrderEditingFeatureFlag from "../loaders/feature-flags/order-editing" export enum RefundReason { DISCOUNT = "discount", @@ -23,13 +27,25 @@ export enum RefundReason { @Entity() export class Refund extends BaseEntity { @Index() - @Column() + @Column({ nullable: true }) order_id: string + @FeatureFlagDecorators(OrderEditingFeatureFlag.key, [ + Index(), + Column({ nullable: true }), + ]) + payment_id: string + @ManyToOne(() => Order, (order) => order.payments) @JoinColumn({ name: "order_id" }) order: Order + @FeatureFlagDecorators(OrderEditingFeatureFlag.key, [ + OneToOne(() => Payment, { nullable: true }), + JoinColumn({ name: "payment_id" }), + ]) + payment: Payment + @Column({ type: "int" }) amount: number diff --git a/packages/medusa/src/repositories/payment-collection.ts b/packages/medusa/src/repositories/payment-collection.ts index 6e58146cd6a31..382181f9dc5ac 100644 --- a/packages/medusa/src/repositories/payment-collection.ts +++ b/packages/medusa/src/repositories/payment-collection.ts @@ -1,6 +1,72 @@ +import { MedusaError } from "medusa-core-utils" import { PaymentCollection } from "./../models/payment-collection" import { EntityRepository, Repository } from "typeorm" +import { FindConfig } from "../types/common" +import { PaymentSession } from "../models" @EntityRepository(PaymentCollection) // eslint-disable-next-line max-len -export class PaymentCollectionRepository extends Repository {} +export class PaymentCollectionRepository extends Repository { + async getPaymentCollectionIdBySessionId( + sessionId: string, + config: FindConfig = {} + ): Promise { + const paymentCollection = await this.find({ + join: { + alias: "payment_col", + innerJoin: { payment_sessions: "payment_col.payment_sessions" }, + }, + where: (qb) => { + qb.where( + "payment_col_payment_sessions.payment_session_id = :sessionId", + { sessionId } + ) + }, + relations: config.relations, + select: config.select, + }) + + if (!paymentCollection.length) { + throw new MedusaError( + MedusaError.Types.NOT_FOUND, + `Payment collection related to Payment Session id ${sessionId} was not found` + ) + } + + return paymentCollection[0] + } + + async getPaymentCollectionIdByPaymentId( + paymentId: string, + config: FindConfig = {} + ): Promise { + const paymentCollection = await this.find({ + join: { + alias: "payment_col", + innerJoin: { payments: "payment_col.payments" }, + }, + where: (qb) => { + qb.where("payment_col_payments.payment_id = :paymentId", { paymentId }) + }, + relations: config.relations, + select: config.select, + }) + + if (!paymentCollection.length) { + throw new MedusaError( + MedusaError.Types.NOT_FOUND, + `Payment collection related to Payment id ${paymentId} was not found` + ) + } + + return paymentCollection[0] + } + + async deleteMultiple(ids: string[]): Promise { + await this.createQueryBuilder() + .delete() + .from(PaymentSession) + .where("id IN (:...ids)", { ids }) + .execute() + } +} diff --git a/packages/medusa/src/services/__mocks__/payment-provider.js b/packages/medusa/src/services/__mocks__/payment-provider.js index f5ca20cd6bed6..d32c1e3a31dd6 100644 --- a/packages/medusa/src/services/__mocks__/payment-provider.js +++ b/packages/medusa/src/services/__mocks__/payment-provider.js @@ -1,5 +1,5 @@ export const DefaultProviderMock = { - getStatus: jest.fn().mockImplementation(data => { + getStatus: jest.fn().mockImplementation((data) => { if (data.money_id === "success") { return Promise.resolve("authorized") } @@ -10,7 +10,7 @@ export const DefaultProviderMock = { return Promise.resolve("initial") }), - retrievePayment: jest.fn().mockImplementation(data => { + retrievePayment: jest.fn().mockImplementation((data) => { return Promise.resolve(data) }), list: jest.fn().mockImplementation(() => { @@ -19,15 +19,26 @@ export const DefaultProviderMock = { capturePayment: jest.fn().mockReturnValue(Promise.resolve()), refundPayment: jest.fn().mockReturnValue(Promise.resolve()), cancelPayment: jest.fn().mockReturnValue(Promise.resolve({})), + deletePayment: jest.fn().mockReturnValue(Promise.resolve({})), + authorizePayment: jest.fn().mockReturnValue(Promise.resolve({})), } export const PaymentProviderServiceMock = { + withTransaction: function () { + return this + }, updateSession: jest.fn().mockImplementation((session, cart) => { return Promise.resolve({ ...session.data, id: `${session.data.id}_updated`, }) }), + updateSessionNew: jest.fn().mockImplementation((session, sessionInput) => { + return Promise.resolve({ + ...session, + id: `${session.id}_updated`, + }) + }), list: jest.fn().mockImplementation(() => { return Promise.resolve() }), @@ -40,12 +51,31 @@ export const PaymentProviderServiceMock = { cartId: cart._id, }) }), - retrieveProvider: jest.fn().mockImplementation(providerId => { + createSessionNew: jest.fn().mockImplementation((sessionInput) => { + return Promise.resolve({ + id: `${sessionInput.providerId}_session`, + }) + }), + retrieveProvider: jest.fn().mockImplementation((providerId) => { if (providerId === "default_provider") { return DefaultProviderMock } throw new Error("Provider Not Found") }), + refreshSessionNew: jest.fn().mockImplementation((session, inputData) => { + DefaultProviderMock.deletePayment() + PaymentProviderServiceMock.createSessionNew(inputData) + return Promise.resolve({ + ...session, + id: `${session.id}_refreshed`, + }) + }), + authorizePayment: jest + .fn() + .mockReturnValue(Promise.resolve({ status: "authorized" })), + createPaymentNew: jest.fn().mockImplementation((session, inputData) => { + Promise.resolve(inputData) + }), } const mock = jest.fn().mockImplementation(() => { diff --git a/packages/medusa/src/services/__tests__/payment-collection.ts b/packages/medusa/src/services/__tests__/payment-collection.ts index d577e94fdac06..cc105b57c0393 100644 --- a/packages/medusa/src/services/__tests__/payment-collection.ts +++ b/packages/medusa/src/services/__tests__/payment-collection.ts @@ -1,7 +1,22 @@ import { IdMap, MockManager, MockRepository } from "medusa-test-utils" -import { EventBusService, PaymentCollectionService } from "../index" -import { PaymentCollectionStatus, PaymentCollectionType } from "../../models" +import { + CustomerService, + EventBusService, + PaymentCollectionService, + PaymentProviderService, +} from "../index" +import { + PaymentCollectionStatus, + PaymentCollectionType, + PaymentCollection, +} from "../../models" import { EventBusServiceMock } from "../__mocks__/event-bus" +import { + DefaultProviderMock, + PaymentProviderServiceMock, +} from "../__mocks__/payment-provider" +import { CustomerServiceMock } from "../__mocks__/customer" +import { PaymentCollectionSessionInput } from "../../types/payment-collection" describe("PaymentCollectionService", () => { afterEach(() => { @@ -11,6 +26,23 @@ describe("PaymentCollectionService", () => { const paymentCollectionSample = { id: IdMap.getId("payment-collection-id1"), region_id: IdMap.getId("region1"), + region: { + payment_providers: [ + { + id: IdMap.getId("region1_provider1"), + }, + { + id: IdMap.getId("region1_provider2"), + }, + ], + }, + payment_sessions: [ + { + id: IdMap.getId("payCol_session1"), + provider_id: IdMap.getId("region1_provider1"), + amount: 100, + }, + ], amount: 100, created_at: new Date(), metadata: { @@ -19,6 +51,28 @@ describe("PaymentCollectionService", () => { status: PaymentCollectionStatus.NOT_PAID, } + const paymentCollectionWithSessions = { + id: IdMap.getId("payment-collection-session"), + region_id: IdMap.getId("region1"), + region: { + payment_providers: [ + { + id: IdMap.getId("region1_provider1"), + }, + ], + }, + payment_sessions: [ + { + id: IdMap.getId("payCol_session1"), + provider_id: IdMap.getId("region1_provider1"), + amount: 100, + }, + ], + amount: 100, + created_at: new Date(), + status: PaymentCollectionStatus.NOT_PAID, + } as PaymentCollection + const paymentCollectionAuthorizedSample = { id: IdMap.getId("payment-collection-id2"), region_id: IdMap.getId("region1"), @@ -26,16 +80,123 @@ describe("PaymentCollectionService", () => { status: PaymentCollectionStatus.AUTHORIZED, } + const zeroSample = { + id: IdMap.getId("payment-collection-zero"), + region_id: IdMap.getId("region1"), + amount: 0, + status: PaymentCollectionStatus.NOT_PAID, + } + + const noSessionSample = { + id: IdMap.getId("payment-collection-zero"), + region_id: IdMap.getId("region1"), + amount: 10000, + status: PaymentCollectionStatus.NOT_PAID, + } + + const fullyAuthorizedSample = { + id: IdMap.getId("payment-collection-fully"), + region_id: IdMap.getId("region1"), + amount: 35000, + authorized_amount: 35000, + region: { + payment_providers: [ + { + id: IdMap.getId("region1_provider1"), + }, + ], + }, + payment_sessions: [ + { + id: IdMap.getId("payCol_session1"), + payment_authorized_at: Date.now(), + provider_id: IdMap.getId("region1_provider1"), + amount: 35000, + }, + ], + payments: [ + { + id: IdMap.getId("payment-123"), + amount: 35000, + captured_amount: 0, + }, + ], + status: PaymentCollectionStatus.AUTHORIZED, + } as unknown as PaymentCollection + + const partiallyAuthorizedSample = { + id: IdMap.getId("payment-collection-partial"), + region_id: IdMap.getId("region1"), + amount: 70000, + authorized_amount: 35000, + region: { + payment_providers: [ + { + id: IdMap.getId("region1_provider1"), + }, + ], + }, + payment_sessions: [ + { + id: IdMap.getId("payCol_session1"), + provider_id: IdMap.getId("region1_provider1"), + amount: 35000, + }, + { + id: IdMap.getId("payCol_session2"), + payment_authorized_at: Date.now(), + provider_id: IdMap.getId("region1_provider1"), + amount: 35000, + }, + ], + payments: [], + status: PaymentCollectionStatus.PARTIALLY_AUTHORIZED, + } + + const notAuthorizedSample = { + id: IdMap.getId("payment-collection-not-authorized"), + region_id: IdMap.getId("region1"), + amount: 70000, + region: { + payment_providers: [ + { + id: IdMap.getId("region1_provider1"), + }, + ], + }, + payment_sessions: [ + { + id: IdMap.getId("payCol_session1"), + provider_id: IdMap.getId("region1_provider1"), + amount: 35000, + }, + { + id: IdMap.getId("payCol_session2"), + provider_id: IdMap.getId("region1_provider1"), + amount: 35000, + }, + ], + payments: [], + status: PaymentCollectionStatus.PARTIALLY_AUTHORIZED, + } as unknown as PaymentCollection + const paymentCollectionRepository = MockRepository({ - findOne: (query) => { + find: (query) => { const map = { [IdMap.getId("payment-collection-id1")]: paymentCollectionSample, [IdMap.getId("payment-collection-id2")]: paymentCollectionAuthorizedSample, + [IdMap.getId("payment-collection-session")]: + paymentCollectionWithSessions, + [IdMap.getId("payment-collection-zero")]: zeroSample, + [IdMap.getId("payment-collection-no-session")]: noSessionSample, + [IdMap.getId("payment-collection-fully")]: fullyAuthorizedSample, + [IdMap.getId("payment-collection-partial")]: partiallyAuthorizedSample, + [IdMap.getId("payment-collection-not-authorized")]: notAuthorizedSample, } if (map[query?.where?.id]) { - return { ...map[query?.where?.id] } + return [{ ...map[query?.where?.id] }] } return }, @@ -45,20 +206,38 @@ describe("PaymentCollectionService", () => { ...data, } }, + save: (data) => { + return data + }, }) + paymentCollectionRepository.deleteMultiple = jest + .fn() + .mockImplementation(() => { + return Promise.resolve() + }) + + paymentCollectionRepository.getPaymentCollectionIdBySessionId = jest + .fn() + .mockImplementation(async () => { + return paymentCollectionWithSessions + }) + const paymentCollectionService = new PaymentCollectionService({ manager: MockManager, paymentCollectionRepository, eventBusService: EventBusServiceMock as unknown as EventBusService, + paymentProviderService: + PaymentProviderServiceMock as unknown as PaymentProviderService, + customerService: CustomerServiceMock as unknown as CustomerService, }) it("should retrieve a payment collection", async () => { await paymentCollectionService.retrieve( IdMap.getId("payment-collection-id1") ) - expect(paymentCollectionRepository.findOne).toHaveBeenCalledTimes(1) - expect(paymentCollectionRepository.findOne).toHaveBeenCalledWith({ + expect(paymentCollectionRepository.find).toHaveBeenCalledTimes(1) + expect(paymentCollectionRepository.find).toHaveBeenCalledWith({ where: { id: IdMap.getId("payment-collection-id1") }, }) }) @@ -68,8 +247,8 @@ describe("PaymentCollectionService", () => { IdMap.getId("payment-collection-non-existing-id") ) - expect(paymentCollectionRepository.findOne).toHaveBeenCalledTimes(1) expect(payCol).rejects.toThrow(Error) + expect(paymentCollectionRepository.find).toBeCalledTimes(1) }) it("should create a payment collection", async () => { @@ -155,9 +334,10 @@ describe("PaymentCollectionService", () => { IdMap.getId("payment-collection-non-existing"), submittedChanges ) - expect(paymentCollectionRepository.save).toHaveBeenCalledTimes(0) - expect(EventBusServiceMock.emit).toHaveBeenCalledTimes(0) + expect(payCol).rejects.toThrow(Error) + expect(paymentCollectionRepository.save).toBeCalledTimes(0) + expect(EventBusServiceMock.emit).toBeCalledTimes(0) }) it("should delete a payment collection", async () => { @@ -197,4 +377,312 @@ describe("PaymentCollectionService", () => { expect(entity).rejects.toThrow(Error) }) + + describe("Manage Payment Sessions", () => { + afterEach(() => { + jest.clearAllMocks() + }) + + it("should throw error if payment collection doesn't have the correct status", async () => { + const inp: PaymentCollectionSessionInput = { + amount: 100, + provider_id: IdMap.getId("region1_provider1"), + customer_id: "customer1", + } + const ret = paymentCollectionService.setPaymentSessions( + IdMap.getId("payment-collection-id2"), + inp + ) + + expect(ret).rejects.toThrowError( + new Error( + `Cannot set payment sessions for a payment collection with status ${PaymentCollectionStatus.AUTHORIZED}` + ) + ) + expect(PaymentProviderServiceMock.createSessionNew).toBeCalledTimes(0) + }) + + it("should throw error if amount is different than requested", async () => { + const inp: PaymentCollectionSessionInput = { + amount: 101, + provider_id: IdMap.getId("region1_provider1"), + customer_id: "customer1", + } + const ret = paymentCollectionService.setPaymentSessions( + IdMap.getId("payment-collection-id1"), + inp + ) + + expect(PaymentProviderServiceMock.createSessionNew).toHaveBeenCalledTimes( + 0 + ) + expect(ret).rejects.toThrow( + `The sum of sessions is not equal to 100 on Payment Collection` + ) + + const multInp: PaymentCollectionSessionInput[] = [ + { + amount: 51, + provider_id: IdMap.getId("region1_provider1"), + customer_id: "customer1", + }, + { + amount: 50, + provider_id: IdMap.getId("region1_provider2"), + customer_id: "customer1", + }, + ] + const multiRet = paymentCollectionService.setPaymentSessions( + IdMap.getId("payment-collection-id1"), + multInp + ) + + expect(PaymentProviderServiceMock.createSessionNew).toHaveBeenCalledTimes( + 0 + ) + expect(multiRet).rejects.toThrow( + `The sum of sessions is not equal to 100 on Payment Collection` + ) + }) + + it("should ignore sessions where provider doesn't belong to the region", async () => { + const multInp: PaymentCollectionSessionInput[] = [ + { + amount: 50, + provider_id: IdMap.getId("region1_provider1"), + customer_id: "customer1", + }, + { + amount: 50, + provider_id: IdMap.getId("region1_invalid_provider"), + customer_id: "customer1", + }, + ] + const multiRet = paymentCollectionService.setPaymentSessions( + IdMap.getId("payment-collection-id1"), + multInp + ) + + expect(multiRet).rejects.toThrow( + `The sum of sessions is not equal to 100 on Payment Collection` + ) + expect(PaymentProviderServiceMock.createSessionNew).toBeCalledTimes(0) + }) + + it("should add a new session and update existing one", async () => { + const inp: PaymentCollectionSessionInput[] = [ + { + session_id: IdMap.getId("payCol_session1"), + amount: 50, + provider_id: IdMap.getId("region1_provider1"), + customer_id: IdMap.getId("lebron"), + }, + { + amount: 50, + provider_id: IdMap.getId("region1_provider1"), + customer_id: IdMap.getId("lebron"), + }, + ] + await paymentCollectionService.setPaymentSessions( + IdMap.getId("payment-collection-session"), + inp + ) + + expect(PaymentProviderServiceMock.createSessionNew).toHaveBeenCalledTimes( + 1 + ) + expect(PaymentProviderServiceMock.updateSessionNew).toHaveBeenCalledTimes( + 1 + ) + expect(CustomerServiceMock.retrieve).toHaveBeenCalledTimes(1) + expect(paymentCollectionRepository.save).toHaveBeenCalledTimes(1) + }) + + it("should add a new session and delete existing one", async () => { + const inp: PaymentCollectionSessionInput[] = [ + { + amount: 100, + provider_id: IdMap.getId("region1_provider1"), + customer_id: IdMap.getId("lebron"), + }, + ] + await paymentCollectionService.setPaymentSessions( + IdMap.getId("payment-collection-session"), + inp + ) + + expect(PaymentProviderServiceMock.createSessionNew).toHaveBeenCalledTimes( + 1 + ) + expect(PaymentProviderServiceMock.updateSessionNew).toHaveBeenCalledTimes( + 0 + ) + expect(paymentCollectionRepository.deleteMultiple).toHaveBeenCalledTimes( + 1 + ) + + expect(paymentCollectionRepository.save).toHaveBeenCalledTimes(1) + }) + + it("should refresh a payment session", async () => { + await paymentCollectionService.refreshPaymentSession( + IdMap.getId("payment-collection-session"), + IdMap.getId("payCol_session1"), + { + customer_id: "customer1", + amount: 100, + provider_id: IdMap.getId("region1_provider1"), + } + ) + + expect( + PaymentProviderServiceMock.refreshSessionNew + ).toHaveBeenCalledTimes(1) + expect(DefaultProviderMock.deletePayment).toHaveBeenCalledTimes(1) + expect(PaymentProviderServiceMock.createSessionNew).toHaveBeenCalledTimes( + 1 + ) + }) + + it("should fail to refresh a payment session if the amount is different", async () => { + const sess = paymentCollectionService.refreshPaymentSession( + IdMap.getId("payment-collection-session"), + IdMap.getId("payCol_session1"), + { + 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" + ) + expect(PaymentProviderServiceMock.refreshSessionNew).toBeCalledTimes(0) + expect(DefaultProviderMock.deletePayment).toBeCalledTimes(0) + expect(PaymentProviderServiceMock.createSessionNew).toBeCalledTimes(0) + }) + }) + + describe("Authorize Payments", () => { + afterEach(() => { + jest.clearAllMocks() + }) + + it("should mark as paid if amount is 0", async () => { + await paymentCollectionService.authorize( + IdMap.getId("payment-collection-zero") + ) + + expect(PaymentProviderServiceMock.authorizePayment).toHaveBeenCalledTimes( + 0 + ) + }) + + it("should reject payment collection without payment sessions", async () => { + const ret = paymentCollectionService.authorize( + IdMap.getId("payment-collection-no-session") + ) + + expect(ret).rejects.toThrowError( + new Error( + "You cannot complete a Payment Collection without a payment session." + ) + ) + }) + + it("should call authorizePayments for all sessions", async () => { + await paymentCollectionService.authorize( + IdMap.getId("payment-collection-not-authorized") + ) + + expect(PaymentProviderServiceMock.authorizePayment).toHaveBeenCalledTimes( + 2 + ) + expect(PaymentProviderServiceMock.createPaymentNew).toHaveBeenCalledTimes( + 2 + ) + expect(EventBusServiceMock.emit).toHaveBeenCalledTimes(1) + }) + + it("should skip authorized sessions - partially authorized", async () => { + await paymentCollectionService.authorize( + IdMap.getId("payment-collection-partial") + ) + + expect(PaymentProviderServiceMock.authorizePayment).toHaveBeenCalledTimes( + 1 + ) + expect(PaymentProviderServiceMock.createPaymentNew).toHaveBeenCalledTimes( + 1 + ) + expect(EventBusServiceMock.emit).toHaveBeenCalledTimes(1) + }) + + it("should skip authorized sessions - fully authorized", async () => { + await paymentCollectionService.authorize( + IdMap.getId("payment-collection-fully") + ) + + expect(PaymentProviderServiceMock.authorizePayment).toHaveBeenCalledTimes( + 0 + ) + expect(PaymentProviderServiceMock.createPaymentNew).toHaveBeenCalledTimes( + 0 + ) + 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/payment-collection.ts b/packages/medusa/src/services/payment-collection.ts index 44fa69fcb64f8..2ecd0c244ac1d 100644 --- a/packages/medusa/src/services/payment-collection.ts +++ b/packages/medusa/src/services/payment-collection.ts @@ -1,19 +1,37 @@ -import { DeepPartial, EntityManager, IsNull } from "typeorm" +import { DeepPartial, EntityManager, Equal } from "typeorm" import { MedusaError } from "medusa-core-utils" import { FindConfig } from "../types/common" import { buildQuery, isDefined, setMetadata } from "../utils" import { PaymentCollectionRepository } from "../repositories/payment-collection" -import { PaymentCollection, PaymentCollectionStatus } from "../models" +import { + Customer, + Payment, + PaymentCollection, + PaymentCollectionStatus, + PaymentSession, + PaymentSessionStatus, + Refund, +} from "../models" import { TransactionBaseService } from "../interfaces" -import { EventBusService } from "./index" +import { + CustomerService, + EventBusService, + PaymentProviderService, +} from "./index" -import { CreatePaymentCollectionInput } from "../types/payment-collection" +import { + CreatePaymentCollectionInput, + PaymentCollectionSessionInput, + PaymentProviderDataInput, +} from "../types/payment-collection" type InjectedDependencies = { manager: EntityManager paymentCollectionRepository: typeof PaymentCollectionRepository + paymentProviderService: PaymentProviderService eventBusService: EventBusService + customerService: CustomerService } export default class PaymentCollectionService extends TransactionBaseService { @@ -21,17 +39,26 @@ export default class PaymentCollectionService extends TransactionBaseService { CREATED: "payment-collection.created", 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 protected transactionManager_: EntityManager | undefined protected readonly eventBusService_: EventBusService + protected readonly paymentProviderService_: PaymentProviderService + protected readonly customerService_: CustomerService // eslint-disable-next-line max-len protected readonly paymentCollectionRepository_: typeof PaymentCollectionRepository constructor({ manager, paymentCollectionRepository, + paymentProviderService, + customerService, eventBusService, }: InjectedDependencies) { // eslint-disable-next-line prefer-rest-params @@ -39,7 +66,9 @@ export default class PaymentCollectionService extends TransactionBaseService { this.manager_ = manager this.paymentCollectionRepository_ = paymentCollectionRepository + this.paymentProviderService_ = paymentProviderService this.eventBusService_ = eventBusService + this.customerService_ = customerService } async retrieve( @@ -52,24 +81,24 @@ export default class PaymentCollectionService extends TransactionBaseService { ) const query = buildQuery({ id: paymentCollectionId }, config) - const paymentCollection = await paymentCollectionRepository.findOne(query) - if (!paymentCollection) { + const paymentCollection = await paymentCollectionRepository.find(query) + + if (!paymentCollection.length) { throw new MedusaError( MedusaError.Types.NOT_FOUND, `Payment collection with id ${paymentCollectionId} was not found` ) } - return paymentCollection + return paymentCollection[0] } async create(data: CreatePaymentCollectionInput): Promise { - return await this.atomicPhase_(async (transactionManager) => { - const paymentCollectionRepository = - transactionManager.getCustomRepository( - this.paymentCollectionRepository_ - ) + return await this.atomicPhase_(async (manager) => { + const paymentCollectionRepository = manager.getCustomRepository( + this.paymentCollectionRepository_ + ) const paymentCollectionToCreate = paymentCollectionRepository.create({ region_id: data.region_id, @@ -87,7 +116,7 @@ export default class PaymentCollectionService extends TransactionBaseService { ) await this.eventBusService_ - .withTransaction(transactionManager) + .withTransaction(manager) .emit(PaymentCollectionService.Events.CREATED, paymentCollection) return paymentCollection @@ -160,4 +189,493 @@ export default class PaymentCollectionService extends TransactionBaseService { return paymentCollection }) } + + private isValidTotalAmount( + total: number, + sessionsInput: PaymentCollectionSessionInput[] + ): boolean { + const sum = sessionsInput.reduce((cur, sess) => cur + sess.amount, 0) + return total === sum + } + + async setPaymentSessions( + paymentCollectionId: string, + sessions: PaymentCollectionSessionInput[] | PaymentCollectionSessionInput + ): Promise { + let sessionsInput = Array.isArray(sessions) ? sessions : [sessions] + + return await this.atomicPhase_(async (manager: EntityManager) => { + const paymentCollectionRepository = manager.getCustomRepository( + this.paymentCollectionRepository_ + ) + + const payCol = await this.retrieve(paymentCollectionId, { + relations: ["region", "region.payment_providers", "payment_sessions"], + }) + + if (payCol.status !== PaymentCollectionStatus.NOT_PAID) { + throw new MedusaError( + MedusaError.Types.NOT_ALLOWED, + `Cannot set payment sessions for a payment collection with status ${payCol.status}` + ) + } + + sessionsInput = sessionsInput.filter((session) => { + return !!payCol.region.payment_providers.find(({ id }) => { + return id === session.provider_id + }) + }) + + if (!this.isValidTotalAmount(payCol.amount, sessionsInput)) { + throw new MedusaError( + MedusaError.Types.UNEXPECTED_STATE, + `The sum of sessions is not equal to ${payCol.amount} on Payment Collection` + ) + } + + let customer: Customer | undefined = undefined + + const selectedSessionIds: string[] = [] + const paymentSessions: PaymentSession[] = [] + + for (const session of sessionsInput) { + if (!customer) { + customer = await this.customerService_ + .withTransaction(manager) + .retrieve(session.customer_id, { + select: ["id", "email", "metadata"], + }) + } + + const existingSession = payCol.payment_sessions?.find( + (sess) => session.session_id === sess?.id + ) + + const inputData: PaymentProviderDataInput = { + resource_id: payCol.id, + currency_code: payCol.currency_code, + amount: session.amount, + provider_id: session.provider_id, + customer, + metadata: { + resource_id: payCol.id, + }, + } + + if (existingSession) { + const paymentSession = await this.paymentProviderService_ + .withTransaction(manager) + .updateSessionNew(existingSession, inputData) + + selectedSessionIds.push(existingSession.id) + paymentSessions.push(paymentSession) + } else { + const paymentSession = await this.paymentProviderService_ + .withTransaction(manager) + .createSessionNew(inputData) + + selectedSessionIds.push(paymentSession.id) + paymentSessions.push(paymentSession) + } + } + + if (payCol.payment_sessions?.length) { + const removeIds: string[] = payCol.payment_sessions + .map((sess) => sess.id) + .filter((id) => !selectedSessionIds.includes(id)) + + if (removeIds.length) { + await paymentCollectionRepository.deleteMultiple(removeIds) + } + } + + payCol.payment_sessions = paymentSessions + + return await paymentCollectionRepository.save(payCol) + }) + } + + async refreshPaymentSession( + paymentCollectionId: string, + sessionId: string, + sessionInput: PaymentCollectionSessionInput + ): Promise { + return await this.atomicPhase_(async (manager: EntityManager) => { + const paymentCollectionRepository = manager.getCustomRepository( + this.paymentCollectionRepository_ + ) + + const payCol = + await paymentCollectionRepository.getPaymentCollectionIdBySessionId( + sessionId, + { + relations: [ + "region", + "region.payment_providers", + "payment_sessions", + ], + } + ) + + if (paymentCollectionId !== payCol.id) { + throw new MedusaError( + MedusaError.Types.NOT_ALLOWED, + `Payment Session ${sessionId} does not belong to Payment Collection ${paymentCollectionId}` + ) + } + + const session = payCol.payment_sessions.find( + (sess) => sessionId === sess?.id + ) + + if (session?.amount !== sessionInput.amount) { + throw new MedusaError( + MedusaError.Types.NOT_ALLOWED, + "The amount has to be the same as the existing payment session" + ) + } + + const customer = await this.customerService_ + .withTransaction(manager) + .retrieve(sessionInput.customer_id, { + select: ["id", "email", "metadata"], + }) + + const inputData: PaymentProviderDataInput = { + resource_id: payCol.id, + currency_code: payCol.currency_code, + amount: session.amount, + provider_id: session.provider_id, + customer, + } + + const sessionRefreshed = await this.paymentProviderService_ + .withTransaction(manager) + .refreshSessionNew(session, inputData) + + payCol.payment_sessions = payCol.payment_sessions.map((sess) => { + if (sess.id === sessionId) { + return sessionRefreshed + } + return sess + }) + + if (session.payment_authorized_at) { + payCol.authorized_amount -= session.amount + } + + await paymentCollectionRepository.save(payCol) + + return sessionRefreshed + }) + } + + async authorize( + paymentCollectionId: string, + context: Record = {} + ): Promise { + return await this.atomicPhase_(async (manager: EntityManager) => { + const paymentCollectionRepository = manager.getCustomRepository( + this.paymentCollectionRepository_ + ) + + const payCol = await this.retrieve(paymentCollectionId, { + relations: ["payment_sessions", "payments"], + }) + + if (payCol.authorized_amount === payCol.amount) { + return payCol + } + + // If cart total is 0, we don't perform anything payment related + if (payCol.amount <= 0) { + payCol.authorized_amount = 0 + return await paymentCollectionRepository.save(payCol) + } + + if (!payCol.payment_sessions?.length) { + throw new MedusaError( + MedusaError.Types.NOT_ALLOWED, + "You cannot complete a Payment Collection without a payment session." + ) + } + + let authorizedAmount = 0 + for (const session of payCol.payment_sessions) { + if (session.payment_authorized_at) { + authorizedAmount += session.amount + continue + } + + const auth = await this.paymentProviderService_ + .withTransaction(manager) + .authorizePayment(session, context) + + if (auth?.status === PaymentSessionStatus.AUTHORIZED) { + authorizedAmount += session.amount + + const inputData: Omit = { + amount: session.amount, + currency_code: payCol.currency_code, + provider_id: session.provider_id, + resource_id: payCol.id, + } + + payCol.payments.push( + await this.paymentProviderService_ + .withTransaction(manager) + .createPaymentNew(inputData) + ) + } + } + + if (authorizedAmount === 0) { + payCol.status = PaymentCollectionStatus.AWAITING + } else if (authorizedAmount < payCol.amount) { + payCol.status = PaymentCollectionStatus.PARTIALLY_AUTHORIZED + } else if (authorizedAmount === payCol.amount) { + payCol.status = PaymentCollectionStatus.AUTHORIZED + } + + payCol.authorized_amount = authorizedAmount + const payColCopy = await paymentCollectionRepository.save(payCol) + + await this.eventBusService_ + .withTransaction(manager) + .emit(PaymentCollectionService.Events.PAYMENT_AUTHORIZED, payColCopy) + + 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 dd85dcc99493e..c5304a7ad1693 100644 --- a/packages/medusa/src/services/payment-provider.ts +++ b/packages/medusa/src/services/payment-provider.ts @@ -16,6 +16,7 @@ import { PaymentSessionStatus, Refund, } from "../models" +import { PaymentProviderDataInput } from "../types/payment-collection" type PaymentProviderKey = `pp_${string}` | "systemPaymentProviderService" type InjectedDependencies = { @@ -179,6 +180,33 @@ export default class PaymentProviderService extends TransactionBaseService { }) } + async createSessionNew( + sessionInput: PaymentProviderDataInput + ): Promise { + return await this.atomicPhase_(async (transactionManager) => { + const provider: AbstractPaymentService = this.retrieveProvider( + sessionInput.provider_id + ) + const sessionData = await provider + .withTransaction(transactionManager) + .createPaymentNew(sessionInput) + + const sessionRepo = transactionManager.getCustomRepository( + this.paymentSessionRepository_ + ) + + const toCreate = { + provider_id: sessionInput.provider_id, + data: sessionData, + status: "pending", + amount: sessionInput.amount, + } as PaymentSession + + const created = sessionRepo.create(toCreate) + return await sessionRepo.save(created) + }) + } + /** * Refreshes a payment session with the given provider. * This means, that we delete the current one and create a new. @@ -219,6 +247,26 @@ export default class PaymentProviderService extends TransactionBaseService { }) } + async refreshSessionNew( + paymentSession: PaymentSession, + sessionInput: PaymentProviderDataInput + ): Promise { + return this.atomicPhase_(async (transactionManager) => { + const session = await this.retrieveSession(paymentSession.id) + const provider = this.retrieveProvider(paymentSession.provider_id) + + await provider.withTransaction(transactionManager).deletePayment(session) + + const sessionRepo = transactionManager.getCustomRepository( + this.paymentSessionRepository_ + ) + + await sessionRepo.remove(session) + + return await this.createSessionNew(sessionInput) + }) + } + /** * Updates an existing payment session. * @param paymentSession - the payment session object to @@ -240,7 +288,29 @@ export default class PaymentProviderService extends TransactionBaseService { const sessionRepo = transactionManager.getCustomRepository( this.paymentSessionRepository_ ) - return sessionRepo.save(session) + return await sessionRepo.save(session) + }) + } + + async updateSessionNew( + paymentSession: PaymentSession, + sessionInput: PaymentProviderDataInput + ): Promise { + return await this.atomicPhase_(async (transactionManager) => { + const session = await this.retrieveSession(paymentSession.id) + const provider = this.retrieveProvider(paymentSession.provider_id) + + session.amount = sessionInput.amount + paymentSession.data.amount = sessionInput.amount + session.data = await provider + .withTransaction(transactionManager) + .updatePaymentNew(paymentSession.data, sessionInput) + + const sessionRepo = transactionManager.getCustomRepository( + this.paymentSessionRepository_ + ) + + return await sessionRepo.save(session) }) } @@ -265,7 +335,7 @@ export default class PaymentProviderService extends TransactionBaseService { this.paymentSessionRepository_ ) - return sessionRepo.remove(session) + return await sessionRepo.remove(session) }) } @@ -321,7 +391,34 @@ export default class PaymentProviderService extends TransactionBaseService { cart_id: cart.id, }) - return paymentRepo.save(created) + return await paymentRepo.save(created) + }) + } + + async createPaymentNew( + paymentInput: Omit + ): Promise { + return await this.atomicPhase_(async (transactionManager) => { + const { payment_session, currency_code, amount, provider_id } = + paymentInput + + const provider = this.retrieveProvider(provider_id) + const paymentData = await provider + .withTransaction(transactionManager) + .getPaymentData(payment_session) + + const paymentRepo = transactionManager.getCustomRepository( + this.paymentRepository_ + ) + + const created = paymentRepo.create({ + provider_id, + amount, + currency_code, + data: paymentData, + }) + + return await paymentRepo.save(created) }) } @@ -343,7 +440,7 @@ export default class PaymentProviderService extends TransactionBaseService { const payRepo = transactionManager.getCustomRepository( this.paymentRepository_ ) - return payRepo.save(payment) + return await payRepo.save(payment) }) } @@ -371,7 +468,7 @@ export default class PaymentProviderService extends TransactionBaseService { const sessionRepo = transactionManager.getCustomRepository( this.paymentSessionRepository_ ) - return sessionRepo.save(session) + return await sessionRepo.save(session) }) } @@ -392,7 +489,7 @@ export default class PaymentProviderService extends TransactionBaseService { const sessionRepo = transactionManager.getCustomRepository( this.paymentSessionRepository_ ) - return sessionRepo.save(session) + return await sessionRepo.save(session) }) } @@ -437,7 +534,7 @@ export default class PaymentProviderService extends TransactionBaseService { const paymentRepo = transactionManager.getCustomRepository( this.paymentRepository_ ) - return paymentRepo.save(payment) + return await paymentRepo.save(payment) }) } @@ -521,7 +618,47 @@ export default class PaymentProviderService extends TransactionBaseService { } const created = refundRepo.create(toCreate) - return refundRepo.save(created) + return await refundRepo.save(created) + }) + } + + async refundFromPayment( + payment: Payment, + amount: number, + reason: string, + note?: string + ): Promise { + return await this.atomicPhase_(async (manager) => { + const refundable = payment.amount - payment.amount_refunded + + if (refundable < amount) { + throw new MedusaError( + MedusaError.Types.NOT_ALLOWED, + "Refund amount is higher that the refundable amount" + ) + } + + const provider = this.retrieveProvider(payment.provider_id) + payment.data = await provider + .withTransaction(manager) + .refundPayment(payment, amount) + + payment.amount_refunded += amount + + const paymentRepo = manager.getCustomRepository(this.paymentRepository_) + await paymentRepo.save(payment) + + const refundRepo = manager.getCustomRepository(this.refundRepository_) + + const toCreate = { + payment_id: payment.id, + amount, + reason, + note, + } + + const created = refundRepo.create(toCreate) + return await refundRepo.save(created) }) } diff --git a/packages/medusa/src/types/payment-collection.ts b/packages/medusa/src/types/payment-collection.ts index a5afd5ceed7cb..3652e253caf2a 100644 --- a/packages/medusa/src/types/payment-collection.ts +++ b/packages/medusa/src/types/payment-collection.ts @@ -1,4 +1,10 @@ -import { PaymentCollection, PaymentCollectionType } from "../models" +import { + Cart, + Customer, + PaymentCollection, + PaymentCollectionType, + PaymentSession, +} from "../models" export type CreatePaymentCollectionInput = { region_id: string @@ -10,6 +16,25 @@ export type CreatePaymentCollectionInput = { description?: string } +export type PaymentCollectionSessionInput = { + provider_id: string + amount: number + session_id?: string + customer_id: string +} + +export type PaymentProviderDataInput = { + resource_id: string + customer: Partial + currency_code: string + provider_id: string + amount: number + payment_session?: PaymentSession + payment_description?: string + cart_id?: string + cart?: Cart + metadata?: any +} export const defaultPaymentCollectionRelations = [ "region", "region.payment_providers",