feat(medusa): Payment Collection endpoints (#2525)

This commit is contained in:
Carlos R. L. Rodrigues authored and GitHub committed 2022-11-11 18:28:45 +01:00
1 parent 27184f3878
commit 755ba90c05
58 files changed
+1190 -484

No files matched your search

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