diff --git a/docs-util/fixture-gen/src/services/test-pay.js b/docs-util/fixture-gen/src/services/test-pay.js index 481261b3a0..7f5c3a0781 100644 --- a/docs-util/fixture-gen/src/services/test-pay.js +++ b/docs-util/fixture-gen/src/services/test-pay.js @@ -1,10 +1,10 @@ -import { PaymentService } from "medusa-interfaces"; +import { AbstractPaymentService } from "@medusajs/medusa"; -class TestPayService extends PaymentService { +class TestPayService extends AbstractPaymentService { static identifier = "test-pay"; - constructor() { - super(); + constructor(_) { + super(_); } async getStatus(paymentData) { diff --git a/docs/content/advanced/backend/payment/how-to-create-payment-provider.md b/docs/content/advanced/backend/payment/how-to-create-payment-provider.md index e867ffe91c..2c781c854a 100644 --- a/docs/content/advanced/backend/payment/how-to-create-payment-provider.md +++ b/docs/content/advanced/backend/payment/how-to-create-payment-provider.md @@ -47,9 +47,9 @@ These methods are used at different points in the Checkout flow as well as when The first step to create a payment provider is to create a file in `src/services` with the following content: ```jsx -import { PaymentService } from "medusa-interfaces" +import { AbstractPaymentService } from "@medusajs/medusa" -class MyPaymentService extends PaymentService { +class MyPaymentService extends AbstractPaymentService { } diff --git a/integration-tests/api/__tests__/taxes/manual-taxes.js b/integration-tests/api/__tests__/taxes/manual-taxes.js index 79c53033a5..89831bc9b6 100644 --- a/integration-tests/api/__tests__/taxes/manual-taxes.js +++ b/integration-tests/api/__tests__/taxes/manual-taxes.js @@ -6,8 +6,6 @@ const { initDb, useDb } = require("../../../helpers/use-db") const { simpleProductTaxRateFactory, - simpleShippingTaxRateFactory, - simpleShippingOptionFactory, simpleCartFactory, simpleRegionFactory, simpleProductFactory, diff --git a/integration-tests/api/src/services/test-pay.js b/integration-tests/api/src/services/test-pay.js index eed8aa80e5..eb202c01c2 100644 --- a/integration-tests/api/src/services/test-pay.js +++ b/integration-tests/api/src/services/test-pay.js @@ -1,10 +1,10 @@ -import { PaymentService } from "medusa-interfaces" +import { AbstractPaymentService } from "@medusajs/medusa" -class TestPayService extends PaymentService { +class TestPayService extends AbstractPaymentService { static identifier = "test-pay" - constructor() { - super() + constructor(_) { + super(_) } async getStatus(paymentData) { diff --git a/integration-tests/plugins/src/services/test-pay.js b/integration-tests/plugins/src/services/test-pay.js index eed8aa80e5..eb202c01c2 100644 --- a/integration-tests/plugins/src/services/test-pay.js +++ b/integration-tests/plugins/src/services/test-pay.js @@ -1,10 +1,10 @@ -import { PaymentService } from "medusa-interfaces" +import { AbstractPaymentService } from "@medusajs/medusa" -class TestPayService extends PaymentService { +class TestPayService extends AbstractPaymentService { static identifier = "test-pay" - constructor() { - super() + constructor(_) { + super(_) } async getStatus(paymentData) { diff --git a/packages/medusa-interfaces/src/index.js b/packages/medusa-interfaces/src/index.js index 10cf36af65..9cf865cf57 100644 --- a/packages/medusa-interfaces/src/index.js +++ b/packages/medusa-interfaces/src/index.js @@ -1,7 +1,7 @@ export { default as BaseService } from "./base-service" -export { default as FileService } from "./file-service" +export { default as PaymentService } from "./payment-service" export { default as FulfillmentService } from "./fulfillment-service" +export { default as FileService } from "./file-service" export { default as NotificationService } from "./notification-service" export { default as OauthService } from "./oauth-service" -export { default as PaymentService } from "./payment-service" export { default as SearchService } from "./search-service" diff --git a/packages/medusa/src/api/routes/store/carts/create-payment-sessions.ts b/packages/medusa/src/api/routes/store/carts/create-payment-sessions.ts index 15d5a8dbac..537268ab2e 100644 --- a/packages/medusa/src/api/routes/store/carts/create-payment-sessions.ts +++ b/packages/medusa/src/api/routes/store/carts/create-payment-sessions.ts @@ -2,6 +2,7 @@ import { defaultStoreCartFields, defaultStoreCartRelations } from "." import { CartService } from "../../../../services" import { decorateLineItemsWithTotals } from "./decorate-line-items-with-totals" import { EntityManager } from "typeorm"; +import IdempotencyKeyService from "../../../../services/idempotency-key"; /** * @oas [post] /carts/{id}/payment-sessions @@ -26,17 +27,97 @@ export default async (req, res) => { const { id } = req.params const cartService: CartService = req.scope.resolve("cartService") - + const idempotencyKeyService: IdempotencyKeyService = req.scope.resolve( + "idempotencyKeyService" + ) const manager: EntityManager = req.scope.resolve("manager") - await manager.transaction(async (transactionManager) => { - return await cartService.withTransaction(transactionManager).setPaymentSessions(id) - }) - const cart = await cartService.retrieve(id, { - select: defaultStoreCartFields, - relations: defaultStoreCartRelations, - }) + const headerKey = req.get("Idempotency-Key") || "" - const data = await decorateLineItemsWithTotals(cart, req) - res.status(200).json({ cart: data }) + let idempotencyKey + try { + await manager.transaction(async (transactionManager) => { + idempotencyKey = await idempotencyKeyService.withTransaction(transactionManager).initializeRequest( + headerKey, + req.method, + req.params, + req.path + ) + }) + } catch (error) { + res.status(409).send("Failed to create idempotency key") + return + } + + res.setHeader("Access-Control-Expose-Headers", "Idempotency-Key") + res.setHeader("Idempotency-Key", idempotencyKey.idempotency_key) + + try { + let inProgress = true + let err: unknown = false + + while (inProgress) { + switch (idempotencyKey.recovery_point) { + case "started": { + await manager.transaction(async (transactionManager) => { + const { key, error } = await idempotencyKeyService + .withTransaction(transactionManager) + .workStage( + idempotencyKey.idempotency_key, + async (stageManager) => { + await cartService.withTransaction(stageManager).setPaymentSessions(id) + + const cart = await cartService.withTransaction(stageManager).retrieve(id, { + select: defaultStoreCartFields, + relations: defaultStoreCartRelations, + }) + + const data = await decorateLineItemsWithTotals(cart, req, { + force_taxes: false, + transactionManager: stageManager + }) + + return { + response_code: 200, + response_body: { cart: data }, + } + }) + + if (error) { + inProgress = false + err = error + } else { + idempotencyKey = key + } + }) + break + } + + case "finished": { + inProgress = false + break + } + + default: + await manager.transaction(async (transactionManager) => { + idempotencyKey = await idempotencyKeyService + .withTransaction(transactionManager) + .update( + idempotencyKey.idempotency_key, + { + recovery_point: "finished", + response_code: 500, + response_body: { message: "Unknown recovery point" }, + } + ) + }) + break + } + } + + res.status(idempotencyKey.response_code).json(idempotencyKey.response_body) + } catch (e) { + console.log(e) + throw e + } } diff --git a/packages/medusa/src/api/routes/store/carts/decorate-line-items-with-totals.ts b/packages/medusa/src/api/routes/store/carts/decorate-line-items-with-totals.ts index 8707770db8..ffea29618f 100644 --- a/packages/medusa/src/api/routes/store/carts/decorate-line-items-with-totals.ts +++ b/packages/medusa/src/api/routes/store/carts/decorate-line-items-with-totals.ts @@ -6,17 +6,16 @@ import { EntityManager } from "typeorm"; export const decorateLineItemsWithTotals = async ( cart: Cart, req: Request, - options: { force_taxes: boolean } = { force_taxes: false } + options: { force_taxes: boolean, transactionManager?: EntityManager } = { force_taxes: false } ): Promise => { const totalsService: TotalsService = req.scope.resolve("totalsService") if (cart.items && cart.region) { - const manager: EntityManager = req.scope.resolve("manager") - const items = await manager.transaction(async (transactionManager) => { + const getItems = async (manager) => { + const totalsServiceTx = totalsService.withTransaction(manager) return await Promise.all( cart.items.map(async (item: LineItem) => { - const itemTotals = await totalsService - .withTransaction(transactionManager) + const itemTotals = await totalsServiceTx .getLineItemTotals(item, cart, { include_tax: options.force_taxes || cart.region.automatic_taxes, }) @@ -24,7 +23,17 @@ export const decorateLineItemsWithTotals = async ( return Object.assign(item, itemTotals) }) ) - }) + } + + let items + if (options.transactionManager) { + items = await getItems(options.transactionManager) + } else { + const manager: EntityManager = options.transactionManager ?? req.scope.resolve("manager") + items = await manager.transaction(async (transactionManager) => { + return await getItems(transactionManager) + }) + } return Object.assign(cart, { items }) } diff --git a/packages/medusa/src/api/routes/store/carts/update-payment-session.ts b/packages/medusa/src/api/routes/store/carts/update-payment-session.ts index 279e6512c9..67b9ca8aed 100644 --- a/packages/medusa/src/api/routes/store/carts/update-payment-session.ts +++ b/packages/medusa/src/api/routes/store/carts/update-payment-session.ts @@ -53,5 +53,5 @@ export default async (req, res) => { export class StorePostCartsCartPaymentSessionUpdateReq { @IsObject() - data: object + data: Record } diff --git a/packages/medusa/src/interfaces/index.ts b/packages/medusa/src/interfaces/index.ts index b19351aeef..38c2dfccc7 100644 --- a/packages/medusa/src/interfaces/index.ts +++ b/packages/medusa/src/interfaces/index.ts @@ -9,3 +9,4 @@ export * from "./price-selection-strategy" export * from "./models/base-entity" export * from "./models/soft-deletable-entity" export * from "./search-service" +export * from "./payment-service" diff --git a/packages/medusa/src/interfaces/payment-service.ts b/packages/medusa/src/interfaces/payment-service.ts new file mode 100644 index 0000000000..03a068f675 --- /dev/null +++ b/packages/medusa/src/interfaces/payment-service.ts @@ -0,0 +1,116 @@ +import { TransactionBaseService } from "./transaction-base-service" +import { + Cart, + Customer, + Payment, + PaymentSession, + PaymentSessionStatus, +} from "../models" +import { PaymentService } from "medusa-interfaces" + +export type Data = Record +export type PaymentData = Data +export type PaymentSessionData = Data + +export interface PaymentService> + extends TransactionBaseService { + getIdentifier(): string + + getPaymentData(paymentSession: PaymentSession): Promise + + updatePaymentData( + paymentSessionData: PaymentSessionData, + data: Data + ): Promise + + createPayment(cart: Cart): Promise + + retrievePayment(paymentData: PaymentData): Promise + + updatePayment( + paymentSessionData: PaymentSessionData, + cart: Cart + ): Promise + + authorizePayment( + paymentSession: PaymentSession, + context: Data + ): Promise<{ data: PaymentSessionData; status: PaymentSessionStatus }> + + capturePayment(payment: Payment): Promise + + refundPayment(payment: Payment, refundAmount: number): Promise + + cancelPayment(payment: Payment): Promise + + deletePayment(paymentSession: PaymentSession): Promise + + retrieveSavedMethods(customer: Customer): Promise + + getStatus(data: Data): Promise +} + +export abstract class AbstractPaymentService< + T extends TransactionBaseService + > + extends TransactionBaseService + implements PaymentService +{ + protected constructor(container: unknown, config?: Record) { + super(container, config) + } + + protected static identifier: string + + public getIdentifier(): string { + if (!(this.constructor).identifier) { + throw new Error('Missing static property "identifier".') + } + return (this.constructor).identifier + } + + public abstract getPaymentData( + paymentSession: PaymentSession + ): Promise + + public abstract updatePaymentData( + paymentSessionData: PaymentSessionData, + data: Data + ): Promise + + public abstract createPayment(cart: Cart): Promise + + public abstract retrievePayment(paymentData: PaymentData): Promise + + public abstract updatePayment( + paymentSessionData: PaymentSessionData, + cart: Cart + ): Promise + + public abstract authorizePayment( + paymentSession: PaymentSession, + context: Data + ): Promise<{ data: PaymentSessionData; status: PaymentSessionStatus }> + + public abstract capturePayment(payment: Payment): Promise + + public abstract refundPayment( + payment: Payment, + refundAmount: number + ): Promise + + public abstract cancelPayment(payment: Payment): Promise + + public abstract deletePayment(paymentSession: PaymentSession): Promise + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + public retrieveSavedMethods(customer: Customer): Promise { + return Promise.resolve([]) + } + + public abstract getStatus(data: Data): Promise +} + +export function isPaymentService(obj: unknown): boolean { + return obj instanceof AbstractPaymentService || obj instanceof PaymentService +} diff --git a/packages/medusa/src/loaders/defaults.ts b/packages/medusa/src/loaders/defaults.ts index 6d4d26ea43..25324790f6 100644 --- a/packages/medusa/src/loaders/defaults.ts +++ b/packages/medusa/src/loaders/defaults.ts @@ -1,4 +1,8 @@ -import { BasePaymentService, BaseNotificationService, BaseFulfillmentService } from 'medusa-interfaces' +import { + BaseNotificationService, + BaseFulfillmentService, + BasePaymentService, +} from "medusa-interfaces" import { currencies } from "../utils/currencies" import { countries } from "../utils/countries" import { AwilixContainer } from "awilix" @@ -15,11 +19,15 @@ import { TaxProviderService, } from "../services" import { CurrencyRepository } from "../repositories/currency" -import { AbstractTaxService } from "../interfaces" import { FlagRouter } from "../utils/flag-router"; import SalesChannelFeatureFlag from "./feature-flags/sales-channels"; +import { AbstractPaymentService, AbstractTaxService } from "../interfaces" -const silentResolution = (container: AwilixContainer, name: string, logger: Logger): T | never | undefined => { +const silentResolution = ( + container: AwilixContainer, + name: string, + logger: Logger +): T | never | undefined => { try { return container.resolve(name) } catch (err) { @@ -44,15 +52,23 @@ const silentResolution = (container: AwilixContainer, name: string, logger: L `You don't have any ${identifier} provider plugins installed. You may want to add one to your project.` ) } - return; + return } } -export default async ({ container }: { container: AwilixContainer }): Promise => { +export default async ({ + container, +}: { + container: AwilixContainer +}): Promise => { const storeService = container.resolve("storeService") - const currencyRepository = container.resolve("currencyRepository") - const countryRepository = container.resolve("countryRepository") - const profileService = container.resolve("shippingProfileService") + const currencyRepository = + container.resolve("currencyRepository") + const countryRepository = + container.resolve("countryRepository") + const profileService = container.resolve( + "shippingProfileService" + ) const salesChannelService = container.resolve("salesChannelService") const logger = container.resolve("logger") const featureFlagRouter = container.resolve("featureFlagRouter") @@ -104,32 +120,54 @@ export default async ({ container }: { container: AwilixContainer }): Promise(container, "paymentProviders", logger) || [] + silentResolution<(typeof BasePaymentService | AbstractPaymentService)[]>( + container, + "paymentProviders", + logger + ) || [] const payIds = payProviders.map((p) => p.getIdentifier()) - const pProviderService = container.resolve("paymentProviderService") + const pProviderService = container.resolve( + "paymentProviderService" + ) await pProviderService.registerInstalledProviders(payIds) const notiProviders = - silentResolution(container, "notificationProviders", logger) || [] + silentResolution( + container, + "notificationProviders", + logger + ) || [] const notiIds = notiProviders.map((p) => p.getIdentifier()) - const nProviderService = container.resolve("notificationService") + const nProviderService = container.resolve( + "notificationService" + ) await nProviderService.registerInstalledProviders(notiIds) - const fulfilProviders = - silentResolution(container, "fulfillmentProviders", logger) || [] + silentResolution( + container, + "fulfillmentProviders", + logger + ) || [] const fulfilIds = fulfilProviders.map((p) => p.getIdentifier()) - const fProviderService = container.resolve("fulfillmentProviderService") + const fProviderService = container.resolve( + "fulfillmentProviderService" + ) await fProviderService.registerInstalledProviders(fulfilIds) const taxProviders = - silentResolution(container, "taxProviders", logger) || [] + silentResolution( + container, + "taxProviders", + logger + ) || [] const taxIds = taxProviders.map((p) => p.getIdentifier()) - const tProviderService = container.resolve("taxProviderService") + const tProviderService = + container.resolve("taxProviderService") await tProviderService.registerInstalledProviders(taxIds) await profileService.withTransaction(manager).createDefault() diff --git a/packages/medusa/src/loaders/plugins.ts b/packages/medusa/src/loaders/plugins.ts index 98ecaf8ad4..aa16d7c041 100644 --- a/packages/medusa/src/loaders/plugins.ts +++ b/packages/medusa/src/loaders/plugins.ts @@ -10,7 +10,6 @@ import { FileService, FulfillmentService, OauthService, - PaymentService, } from "medusa-interfaces" import path from "path" import { EntitySchema } from "typeorm" @@ -20,6 +19,7 @@ import { isCartCompletionStrategy, isFileService, isNotificationService, + isPaymentService, isPriceSelectionStrategy, isSearchService, isTaxCalculationStrategy, @@ -366,7 +366,7 @@ export async function registerServices( throw new Error(message) } - if (loaded.prototype instanceof PaymentService) { + if (isPaymentService(loaded.prototype)) { // Register our payment providers to paymentProviders container.registerAdd( "paymentProviders", diff --git a/packages/medusa/src/migrations/1660040729000-payment_session_uniq_cartId_providerId.ts b/packages/medusa/src/migrations/1660040729000-payment_session_uniq_cartId_providerId.ts new file mode 100644 index 0000000000..d738af3d86 --- /dev/null +++ b/packages/medusa/src/migrations/1660040729000-payment_session_uniq_cartId_providerId.ts @@ -0,0 +1,13 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class paymentSessionUniqCartIdProviderId1660040729000 implements MigrationInterface { + name = "paymentSessionUniqCartIdProviderId1660040729000" + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE UNIQUE INDEX "UniqPaymentSessionCartIdProviderId" ON "payment_session" ("cart_id", "provider_id")`) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX "UniqPaymentSessionCartIdProviderId"`) + } +} diff --git a/packages/medusa/src/models/payment-session.ts b/packages/medusa/src/models/payment-session.ts index b458b26892..2945f11041 100644 --- a/packages/medusa/src/models/payment-session.ts +++ b/packages/medusa/src/models/payment-session.ts @@ -22,6 +22,7 @@ export enum PaymentSessionStatus { } @Unique("OneSelected", ["cart_id", "is_selected"]) +@Unique("UniqPaymentSessionCartIdProviderId", ["cart_id", "provider_id"]) @Entity() export class PaymentSession extends BaseEntity { @Index() diff --git a/packages/medusa/src/models/payment.ts b/packages/medusa/src/models/payment.ts index 607e9a0a40..1fe31488db 100644 --- a/packages/medusa/src/models/payment.ts +++ b/packages/medusa/src/models/payment.ts @@ -63,10 +63,10 @@ export class Payment extends BaseEntity { data: Record @Column({ type: resolveDbType("timestamptz"), nullable: true }) - captured_at: Date + captured_at: Date | string @Column({ type: resolveDbType("timestamptz"), nullable: true }) - canceled_at: Date + canceled_at: Date | string @DbAwareColumn({ type: "jsonb", nullable: true }) metadata: Record diff --git a/packages/medusa/src/services/__mocks__/test-pay.js b/packages/medusa/src/services/__mocks__/test-pay.js new file mode 100644 index 0000000000..34017ceca0 --- /dev/null +++ b/packages/medusa/src/services/__mocks__/test-pay.js @@ -0,0 +1,43 @@ +export const testPayServiceMock = { + identifier: "test-pay", + getIdentifier: "test-pay", + withTransaction: function () { + return this + }, + getStatus: jest.fn().mockResolvedValue(Promise.resolve("authorised")), + retrieveSavedMethods: jest.fn().mockResolvedValue(Promise.resolve([])), + getPaymentData: jest.fn().mockResolvedValue(Promise.resolve({})), + createPayment: jest.fn().mockImplementation(() => { + return {} + }), + retrievePayment: jest.fn().mockImplementation(() => { + return {} + }), + updatePayment: jest.fn().mockImplementation(() => { + return {} + }), + deletePayment: jest.fn().mockImplementation(() => { + return {} + }), + authorizePayment: jest.fn().mockImplementation(() => { + return {} + }), + updatePaymentData: jest.fn().mockImplementation(() => { + return {} + }), + cancelPayment: jest.fn().mockImplementation(() => { + return {} + }), + capturePayment: jest.fn().mockImplementation(() => { + return {} + }), + refundPayment: jest.fn().mockImplementation(() => { + return {} + }) +} + +const mock = jest.fn().mockImplementation(() => { + return testPayServiceMock +}) + +export default mock diff --git a/packages/medusa/src/services/__tests__/cart.js b/packages/medusa/src/services/__tests__/cart.js index b354f11c82..f6e9a5f179 100644 --- a/packages/medusa/src/services/__tests__/cart.js +++ b/packages/medusa/src/services/__tests__/cart.js @@ -61,6 +61,8 @@ describe("CartService", () => { undefined, { where: { id: IdMap.getId("emptyCart") }, + select: undefined, + relations: undefined, } ) }) diff --git a/packages/medusa/src/services/__tests__/payment-provider.js b/packages/medusa/src/services/__tests__/payment-provider.js index e071e2fb7f..17eec0ebab 100644 --- a/packages/medusa/src/services/__tests__/payment-provider.js +++ b/packages/medusa/src/services/__tests__/payment-provider.js @@ -1,7 +1,8 @@ import { MockManager, MockRepository } from "medusa-test-utils" import PaymentProviderService from "../payment-provider" +import { testPayServiceMock } from "../__mocks__/test-pay" -describe("ProductService", () => { +describe("PaymentProviderService", () => { describe("retrieveProvider", () => { const container = { manager: MockManager, @@ -33,6 +34,9 @@ describe("ProductService", () => { manager: MockManager, paymentSessionRepository: MockRepository(), pp_default_provider: { + withTransaction: function () { + return this + }, createPayment, }, } @@ -67,6 +71,9 @@ describe("ProductService", () => { }), }), pp_default_provider: { + withTransaction: function () { + return this + }, updatePayment, }, } @@ -97,3 +104,183 @@ describe("ProductService", () => { }) }) }) + +describe(`PaymentProviderService`, () => { + const container = { + manager: MockManager, + paymentSessionRepository: MockRepository({ + findOne: () => + Promise.resolve({ + id: "session", + provider_id: "default_provider", + data: { + id: "1234", + }, + }), + }), + paymentRepository: MockRepository({ + findOne: () => + Promise.resolve({ + id: "pay_jadazdjk", + provider_id: "default_provider", + data: { + id: "1234", + }, + }), + find: () => + Promise.resolve([{ + id: "pay_jadazdjk", + provider_id: "default_provider", + data: { + id: "1234", + }, + captured_at: new Date(), + amount: 100, + amount_refunded: 0 + }]), + }), + refundRepository: MockRepository(), + pp_default_provider: testPayServiceMock, + } + const providerService = new PaymentProviderService(container) + + afterEach(() => { + jest.clearAllMocks() + }) + + it("successfully retrieves payment provider", () => { + const provider = providerService.retrieveProvider("default_provider") + expect(provider.identifier).toEqual("test-pay") + }) + + it("successfully creates session", async () => { + await providerService.createSession("default_provider", { + total: 100, + }) + + expect(testPayServiceMock.createPayment).toBeCalledTimes(1) + expect(testPayServiceMock.createPayment).toBeCalledWith({ + total: 100, + }) + }) + + it("successfully update session", async () => { + await providerService.updateSession( + { + id: "session", + provider_id: "default_provider", + data: { + id: "1234", + }, + }, + { + total: 100, + } + ) + + expect(testPayServiceMock.updatePayment).toBeCalledTimes(1) + expect(testPayServiceMock.updatePayment).toBeCalledWith( + { id: "1234" }, + { + total: 100, + } + ) + }) + + it("successfully refresh session", async () => { + await providerService.refreshSession( + { + id: "session", + provider_id: "default_provider", + data: { + id: "1234", + }, + }, + { + total: 100, + } + ) + + expect(testPayServiceMock.deletePayment).toBeCalledTimes(1) + expect(testPayServiceMock.createPayment).toBeCalledTimes(1) + }) + + it("successfully delete session", async () => { + await providerService.deleteSession( + { + id: "session", + provider_id: "default_provider", + data: { + id: "1234", + }, + } + ) + + expect(testPayServiceMock.deletePayment).toBeCalledTimes(1) + }) + + it("successfully delete session", async () => { + await providerService.deleteSession( + { + id: "session", + provider_id: "default_provider", + data: { + id: "1234", + }, + } + ) + + expect(testPayServiceMock.deletePayment).toBeCalledTimes(1) + }) + + it("successfully authorize payment", async () => { + await providerService.authorizePayment( + { + id: "session", + provider_id: "default_provider", + data: { + id: "1234", + }, + }, + {} + ) + + expect(testPayServiceMock.authorizePayment).toBeCalledTimes(1) + }) + + it("successfully update session data", async () => { + await providerService.updateSessionData( + { + id: "session", + provider_id: "default_provider", + data: { + id: "1234", + }, + }, + {} + ) + + expect(testPayServiceMock.updatePaymentData).toBeCalledTimes(1) + }) + + it("successfully cancel payment", async () => { + await providerService.cancelPayment({ + id: "pay_jadazdjk" + }) + expect(testPayServiceMock.cancelPayment).toBeCalledTimes(1) + }) + + it("successfully capture payment", async () => { + await providerService.capturePayment({ + id: "pay_jadazdjk" + }) + expect(testPayServiceMock.capturePayment).toBeCalledTimes(1) + }) + + it("successfully refund payment", async () => { + await providerService.refundPayment([{ + id: "pay_jadazdjk" + }], 50) + expect(testPayServiceMock.refundPayment).toBeCalledTimes(1) + }) +}) diff --git a/packages/medusa/src/services/__tests__/swap.js b/packages/medusa/src/services/__tests__/swap.js index aaf1a4330e..ebf3df9733 100644 --- a/packages/medusa/src/services/__tests__/swap.js +++ b/packages/medusa/src/services/__tests__/swap.js @@ -1,4 +1,3 @@ -import paymentService from "medusa-interfaces/dist/payment-service" import { IdMap, MockRepository, MockManager } from "medusa-test-utils" import SwapService from "../swap" import { InventoryServiceMock } from "../__mocks__/inventory" diff --git a/packages/medusa/src/services/cart.ts b/packages/medusa/src/services/cart.ts index bc8ee5d24d..8caa2acdb4 100644 --- a/packages/medusa/src/services/cart.ts +++ b/packages/medusa/src/services/cart.ts @@ -4,7 +4,6 @@ import { DeepPartial, EntityManager, In } from "typeorm" import { TransactionBaseService } from "../interfaces" import { IPriceSelectionStrategy } from "../interfaces/price-selection-strategy" import { - DiscountRuleType, Address, Cart, CustomShippingOption, @@ -13,6 +12,8 @@ import { LineItem, ShippingMethod, SalesChannel, + DiscountRuleType, + PaymentSession, } from "../models" import { AddressRepository } from "../repositories/address" import { CartRepository } from "../repositories/cart" @@ -1310,7 +1311,10 @@ class CartService extends TransactionBaseService { * @param update - the data to update the payment session with * @return the resulting cart */ - async updatePaymentSession(cartId: string, update: object): Promise { + async updatePaymentSession( + cartId: string, + update: Record + ): Promise { return await this.atomicPhase_( async (transactionManager: EntityManager) => { const cart = await this.retrieve(cartId, { @@ -1385,14 +1389,14 @@ class CartService extends TransactionBaseService { ) } - const session = await this.paymentProviderService_ + const session = (await this.paymentProviderService_ .withTransaction(transactionManager) - .authorizePayment(cart.payment_session, context) + .authorizePayment(cart.payment_session, context)) as PaymentSession - const freshCart = await this.retrieve(cart.id, { + const freshCart = (await this.retrieve(cart.id, { select: ["total"], relations: ["payment_sessions", "items", "items.adjustments"], - }) + })) as Cart & { payment_session: PaymentSession } if (session.status === "authorized") { freshCart.payment = await this.paymentProviderService_ diff --git a/packages/medusa/src/services/order.ts b/packages/medusa/src/services/order.ts index c68bb4a4f9..f99105e0bc 100644 --- a/packages/medusa/src/services/order.ts +++ b/packages/medusa/src/services/order.ts @@ -30,6 +30,7 @@ import { Order, OrderStatus, Payment, + PaymentSession, PaymentStatus, Return, Swap, @@ -531,7 +532,6 @@ class OrderService extends TransactionBaseService { // Would be the case if a discount code is applied that covers the item // total if (total !== 0) { - // Throw if payment method does not exist if (!payment) { throw new MedusaError( MedusaError.Types.INVALID_ARGUMENT, @@ -543,7 +543,6 @@ class OrderService extends TransactionBaseService { .withTransaction(manager) .getStatus(payment) - // If payment status is not authorized, we throw if (paymentStatus !== "authorized") { throw new MedusaError( MedusaError.Types.INVALID_ARGUMENT, diff --git a/packages/medusa/src/services/payment-provider.js b/packages/medusa/src/services/payment-provider.js deleted file mode 100644 index 613811d59c..0000000000 --- a/packages/medusa/src/services/payment-provider.js +++ /dev/null @@ -1,438 +0,0 @@ -import { BaseService } from "medusa-interfaces" -import { MedusaError } from "medusa-core-utils" - -/** - * Helps retrive payment providers - */ -class PaymentProviderService extends BaseService { - constructor(container) { - super() - - /** @private {logger} */ - this.container_ = container - - this.manager_ = container.manager - - this.paymentSessionRepository_ = container.paymentSessionRepository - - this.paymentRepository_ = container.paymentRepository - - this.refundRepository_ = container.refundRepository - } - - withTransaction(manager) { - if (!manager) { - return this - } - - const cloned = new PaymentProviderService(this.container_) - cloned.transactionManager_ = manager - cloned.manager_ = manager - - return cloned - } - - async registerInstalledProviders(providers) { - const { manager, paymentProviderRepository } = this.container_ - - const model = manager.getCustomRepository(paymentProviderRepository) - await model.update({}, { is_installed: false }) - - for (const p of providers) { - const n = model.create({ id: p, is_installed: true }) - await model.save(n) - } - } - - async list() { - const { manager, paymentProviderRepository } = this.container_ - const ppRepo = manager.getCustomRepository(paymentProviderRepository) - - return await ppRepo.find({}) - } - - async retrievePayment(id, relations = []) { - const paymentRepo = this.manager_.getCustomRepository( - this.paymentRepository_ - ) - const validatedId = this.validateId_(id) - - const query = { - where: { id: validatedId }, - } - - if (relations.length) { - query.relations = relations - } - - const payment = await paymentRepo.findOne(query) - - if (!payment) { - throw new MedusaError( - MedusaError.Types.NOT_FOUND, - `Payment with ${id} was not found` - ) - } - - return payment - } - - listPayments( - selector, - config = { skip: 0, take: 50, order: { created_at: "DESC" } } - ) { - const payRepo = this.manager_.getCustomRepository(this.paymentRepository_) - const query = this.buildQuery_(selector, config) - return payRepo.find(query) - } - - async retrieveSession(id, relations = []) { - const sessionRepo = this.manager_.getCustomRepository( - this.paymentSessionRepository_ - ) - const validatedId = this.validateId_(id) - - const query = { - where: { id: validatedId }, - } - - if (relations.length) { - query.relations = relations - } - - const session = await sessionRepo.findOne(query) - - if (!session) { - throw new MedusaError( - MedusaError.Types.NOT_FOUND, - `Payment Session with ${id} was not found` - ) - } - - return session - } - - /** - * Creates a payment session with the given provider. - * @param {string} providerId - the id of the provider to create payment with - * @param {Cart} cart - a cart object used to calculate the amount, etc. from - * @return {Promise} the payment session - */ - async createSession(providerId, cart) { - return this.atomicPhase_(async (manager) => { - const provider = this.retrieveProvider(providerId) - const sessionData = await provider.createPayment(cart) - - const sessionRepo = manager.getCustomRepository( - this.paymentSessionRepository_ - ) - - const toCreate = { - cart_id: cart.id, - provider_id: providerId, - data: sessionData, - status: "pending", - } - - const created = sessionRepo.create(toCreate) - const result = await sessionRepo.save(created) - - return result - }) - } - - /** - * Refreshes a payment session with the given provider. - * This means, that we delete the current one and create a new. - * @param {PaymentSession} paymentSession - the payment session object to - * update - * @param {Cart} cart - a cart object used to calculate the amount, etc. from - * @return {Promise} the payment session - */ - async refreshSession(paymentSession, cart) { - return this.atomicPhase_(async (manager) => { - const session = await this.retrieveSession(paymentSession.id) - - const provider = this.retrieveProvider(paymentSession.provider_id) - await provider.deletePayment(session) - - const sessionRepo = manager.getCustomRepository( - this.paymentSessionRepository_ - ) - - await sessionRepo.remove(session) - - const sessionData = await provider.createPayment(cart) - const toCreate = { - cart_id: cart.id, - provider_id: session.provider_id, - data: sessionData, - is_selected: true, - status: "pending", - } - - const created = sessionRepo.create(toCreate) - const result = await sessionRepo.save(created) - - return result - }) - } - - /** - * Updates an existing payment session. - * @param {PaymentSession} paymentSession - the payment session object to - * update - * @param {Cart} cart - the cart object to update for - * @return {Promise} the updated payment session - */ - updateSession(paymentSession, cart) { - return this.atomicPhase_(async (manager) => { - const session = await this.retrieveSession(paymentSession.id) - - const provider = this.retrieveProvider(paymentSession.provider_id) - session.data = await provider.updatePayment(paymentSession.data, cart) - - const sessionRepo = manager.getCustomRepository( - this.paymentSessionRepository_ - ) - return sessionRepo.save(session) - }) - } - - deleteSession(paymentSession) { - return this.atomicPhase_(async (manager) => { - const session = await this.retrieveSession(paymentSession.id).catch( - (_) => undefined - ) - - if (!session) { - return Promise.resolve() - } - - const provider = this.retrieveProvider(paymentSession.provider_id) - await provider.deletePayment(paymentSession) - - const sessionRepo = manager.getCustomRepository( - this.paymentSessionRepository_ - ) - - return sessionRepo.remove(session) - }) - } - - /** - * Finds a provider given an id - * @param {string} providerId - the id of the provider to get - * @return {PaymentService} the payment provider - */ - retrieveProvider(providerId) { - try { - let provider - if (providerId === "system") { - provider = this.container_[`systemPaymentProviderService`] - } else { - provider = this.container_[`pp_${providerId}`] - } - - return provider - } catch (err) { - throw new MedusaError( - MedusaError.Types.NOT_FOUND, - `Could not find a payment provider with id: ${providerId}` - ) - } - } - - async createPayment(cart) { - return this.atomicPhase_(async (manager) => { - const { payment_session: paymentSession, region, total } = cart - const provider = this.retrieveProvider(paymentSession.provider_id) - const paymentData = await provider.getPaymentData(paymentSession) - - const paymentRepo = manager.getCustomRepository(this.paymentRepository_) - - const created = paymentRepo.create({ - provider_id: paymentSession.provider_id, - amount: total, - currency_code: region.currency_code, - data: paymentData, - cart_id: cart.id, - }) - - return paymentRepo.save(created) - }) - } - - async updatePayment(paymentId, update) { - return this.atomicPhase_(async (manager) => { - const payment = await this.retrievePayment(paymentId) - - if ("order_id" in update) { - payment.order_id = update.order_id - } - - if ("swap_id" in update) { - payment.swap_id = update.swap_id - } - - const payRepo = manager.getCustomRepository(this.paymentRepository_) - return payRepo.save(payment) - }) - } - - async authorizePayment(paymentSession, context) { - return this.atomicPhase_(async (manager) => { - const session = await this.retrieveSession(paymentSession.id).catch( - (_) => undefined - ) - - if (!session) { - return Promise.resolve() - } - - const provider = this.retrieveProvider(paymentSession.provider_id) - const { status, data } = await provider - .withTransaction(manager) - .authorizePayment(session, context) - - session.data = data - session.status = status - - const sessionRepo = manager.getCustomRepository( - this.paymentSessionRepository_ - ) - return sessionRepo.save(session) - }) - } - - async updateSessionData(paySession, update) { - return this.atomicPhase_(async (manager) => { - const session = await this.retrieveSession(paySession.id) - - const provider = this.retrieveProvider(paySession.provider_id) - - session.data = await provider.updatePaymentData(paySession.data, update) - session.status = paySession.status - - const sessionRepo = manager.getCustomRepository( - this.paymentSessionRepository_ - ) - return sessionRepo.save(session) - }) - } - - async cancelPayment(paymentObj) { - return this.atomicPhase_(async (manager) => { - const payment = await this.retrievePayment(paymentObj.id) - const provider = this.retrieveProvider(payment.provider_id) - payment.data = await provider.cancelPayment(payment) - - const now = new Date() - payment.canceled_at = now.toISOString() - - const paymentRepo = manager.getCustomRepository(this.paymentRepository_) - return await paymentRepo.save(payment) - }) - } - - async getStatus(payment) { - const provider = this.retrieveProvider(payment.provider_id) - return provider.getStatus(payment.data) - } - - async capturePayment(paymentObj) { - return this.atomicPhase_(async (manager) => { - const payment = await this.retrievePayment(paymentObj.id) - - const provider = this.retrieveProvider(payment.provider_id) - payment.data = await provider.capturePayment(payment) - - const now = new Date() - payment.captured_at = now.toISOString() - - const paymentRepo = manager.getCustomRepository(this.paymentRepository_) - return paymentRepo.save(payment) - }) - } - - async refundPayment(payObjs, amount, reason, note) { - return this.atomicPhase_(async (manager) => { - const payments = await this.listPayments({ id: payObjs.map((p) => p.id) }) - - let order_id - const refundable = payments.reduce((acc, next) => { - order_id = next.order_id - if (next.captured_at) { - return (acc += next.amount - next.amount_refunded) - } - - return acc - }, 0) - - if (refundable < amount) { - throw new MedusaError( - MedusaError.Types.NOT_ALLOWED, - "Refund amount is too high" - ) - } - - let balance = amount - - const used = [] - - const paymentRepo = manager.getCustomRepository(this.paymentRepository_) - let toRefund = payments.find((p) => p.amount - p.amount_refunded > 0) - while (toRefund) { - const currentRefundable = toRefund.amount - toRefund.amount_refunded - - const refundAmount = Math.min(currentRefundable, balance) - - const provider = this.retrieveProvider(toRefund.provider_id) - toRefund.data = await provider.refundPayment(toRefund, refundAmount) - toRefund.amount_refunded += refundAmount - await paymentRepo.save(toRefund) - - balance -= refundAmount - - used.push(toRefund.id) - - if (balance > 0) { - toRefund = payments.find( - (p) => p.amount - p.amount_refunded > 0 && !used.includes(p.id) - ) - } else { - toRefund = null - } - } - - const refundRepo = manager.getCustomRepository(this.refundRepository_) - - const toCreate = { - order_id, - amount, - reason, - note, - } - - const created = refundRepo.create(toCreate) - return refundRepo.save(created) - }) - } - - async retrieveRefund(id, config = {}) { - const refRepo = this.manager_.getCustomRepository(this.refundRepository_) - const query = this.buildQuery_({ id }, config) - const refund = await refRepo.findOne(query) - - if (!refund) { - throw new MedusaError( - MedusaError.Types.NOT_FOUND, - `A refund with ${id} was not found` - ) - } - - return refund - } -} - -export default PaymentProviderService diff --git a/packages/medusa/src/services/payment-provider.ts b/packages/medusa/src/services/payment-provider.ts new file mode 100644 index 0000000000..b3f5de7cb3 --- /dev/null +++ b/packages/medusa/src/services/payment-provider.ts @@ -0,0 +1,544 @@ +import { MedusaError } from "medusa-core-utils" +import { BasePaymentService } from "medusa-interfaces" +import { AbstractPaymentService, TransactionBaseService } from "../interfaces" +import { EntityManager } from "typeorm" +import { PaymentSessionRepository } from "../repositories/payment-session" +import { PaymentRepository } from "../repositories/payment" +import { RefundRepository } from "../repositories/refund" +import { PaymentProviderRepository } from "../repositories/payment-provider" +import { buildQuery } from "../utils" +import { FindConfig, Selector } from "../types/common" +import { + Cart, + Payment, + PaymentProvider, + PaymentSession, + PaymentSessionStatus, + Refund, +} from "../models" + +type PaymentProviderKey = `pp_${string}` | "systemPaymentProviderService" +type InjectedDependencies = { + manager: EntityManager + paymentSessionRepository: typeof PaymentSessionRepository + paymentProviderRepository: typeof PaymentProviderRepository + paymentRepository: typeof PaymentRepository + refundRepository: typeof RefundRepository +} & { + [key in `${PaymentProviderKey}`]: + | AbstractPaymentService + | typeof BasePaymentService +} + +/** + * Helps retrieve payment providers + */ +export default class PaymentProviderService extends TransactionBaseService { + protected manager_: EntityManager + protected transactionManager_: EntityManager | undefined + protected readonly container_: InjectedDependencies + protected readonly paymentSessionRepository_: typeof PaymentSessionRepository + protected readonly paymentProviderRepository_: typeof PaymentProviderRepository + protected readonly paymentRepository_: typeof PaymentRepository + protected readonly refundRepository_: typeof RefundRepository + + constructor(container: InjectedDependencies) { + super(container) + + this.container_ = container + this.manager_ = container.manager + this.paymentSessionRepository_ = container.paymentSessionRepository + this.paymentProviderRepository_ = container.paymentProviderRepository + this.paymentRepository_ = container.paymentRepository + this.refundRepository_ = container.refundRepository + } + + async registerInstalledProviders(providerIds: string[]): Promise { + return await this.atomicPhase_(async (transactionManager) => { + const model = transactionManager.getCustomRepository( + this.paymentProviderRepository_ + ) + await model.update({}, { is_installed: false }) + + await Promise.all( + providerIds.map(async (providerId) => { + const provider = model.create({ + id: providerId, + is_installed: true, + }) + return await model.save(provider) + }) + ) + }) + } + + async list(): Promise { + const ppRepo = this.manager_.getCustomRepository( + this.paymentProviderRepository_ + ) + return await ppRepo.find() + } + + async retrievePayment( + id: string, + relations: string[] = [] + ): Promise { + const paymentRepo = this.manager_.getCustomRepository( + this.paymentRepository_ + ) + const query = { + where: { id }, + relations: [] as string[], + } + + if (relations.length) { + query.relations = relations + } + + const payment = await paymentRepo.findOne(query) + + if (!payment) { + throw new MedusaError( + MedusaError.Types.NOT_FOUND, + `Payment with ${id} was not found` + ) + } + + return payment + } + + async listPayments( + selector: Selector, + config: FindConfig = { + skip: 0, + take: 50, + order: { created_at: "DESC" }, + } + ): Promise { + const payRepo = this.manager_.getCustomRepository(this.paymentRepository_) + const query = buildQuery(selector, config) + return await payRepo.find(query) + } + + async retrieveSession( + id: string, + relations: string[] = [] + ): Promise { + const sessionRepo = this.manager_.getCustomRepository( + this.paymentSessionRepository_ + ) + + const query = { + where: { id }, + relations: [] as string[], + } + + if (relations.length) { + query.relations = relations + } + + const session = await sessionRepo.findOne(query) + + if (!session) { + throw new MedusaError( + MedusaError.Types.NOT_FOUND, + `Payment Session with ${id} was not found` + ) + } + + return session + } + + /** + * Creates a payment session with the given provider. + * @param providerId - the id of the provider to create payment with + * @param cart - a cart object used to calculate the amount, etc. from + * @return the payment session + */ + async createSession(providerId: string, cart: Cart): Promise { + return await this.atomicPhase_(async (transactionManager) => { + const provider = this.retrieveProvider(providerId) + const sessionData = await provider + .withTransaction(transactionManager) + .createPayment(cart) + + const sessionRepo = transactionManager.getCustomRepository( + this.paymentSessionRepository_ + ) + + const toCreate = { + cart_id: cart.id, + provider_id: providerId, + data: sessionData, + status: "pending", + } + + 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. + * @param paymentSession - the payment session object to + * update + * @param cart - a cart object used to calculate the amount, etc. from + * @return the payment session + */ + async refreshSession( + paymentSession: PaymentSession, + cart: Cart + ): 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) + + const sessionData = await provider + .withTransaction(transactionManager) + .createPayment(cart) + + const toCreate = { + cart_id: cart.id, + provider_id: session.provider_id, + data: sessionData, + is_selected: true, + status: "pending", + } + + const created = sessionRepo.create(toCreate) + return await sessionRepo.save(created) + }) + } + + /** + * Updates an existing payment session. + * @param paymentSession - the payment session object to + * update + * @param cart - the cart object to update for + * @return the updated payment session + */ + async updateSession( + paymentSession: PaymentSession, + cart: Cart + ): Promise { + return await this.atomicPhase_(async (transactionManager) => { + const session = await this.retrieveSession(paymentSession.id) + const provider = this.retrieveProvider(paymentSession.provider_id) + session.data = await provider + .withTransaction(transactionManager) + .updatePayment(paymentSession.data, cart) + + const sessionRepo = transactionManager.getCustomRepository( + this.paymentSessionRepository_ + ) + return sessionRepo.save(session) + }) + } + + async deleteSession( + paymentSession: PaymentSession + ): Promise { + return await this.atomicPhase_(async (transactionManager) => { + const session = await this.retrieveSession(paymentSession.id).catch( + () => void 0 + ) + + if (!session) { + return + } + + const provider = this.retrieveProvider(paymentSession.provider_id) + await provider + .withTransaction(transactionManager) + .deletePayment(paymentSession) + + const sessionRepo = transactionManager.getCustomRepository( + this.paymentSessionRepository_ + ) + + return sessionRepo.remove(session) + }) + } + + /** + * Finds a provider given an id + * @param {string} providerId - the id of the provider to get + * @return {PaymentService} the payment provider + */ + retrieveProvider< + TProvider extends AbstractPaymentService | typeof BasePaymentService + >( + providerId: string + ): TProvider extends AbstractPaymentService + ? AbstractPaymentService + : typeof BasePaymentService { + try { + let provider + if (providerId === "system") { + provider = this.container_[`systemPaymentProviderService`] + } else { + provider = this.container_[`pp_${providerId}`] + } + + return provider + } catch (err) { + throw new MedusaError( + MedusaError.Types.NOT_FOUND, + `Could not find a payment provider with id: ${providerId}` + ) + } + } + + async createPayment( + cart: Cart & { payment_session: PaymentSession } + ): Promise { + return await this.atomicPhase_(async (transactionManager) => { + const { payment_session: paymentSession, region, total } = cart + + const provider = this.retrieveProvider(paymentSession.provider_id) + const paymentData = await provider + .withTransaction(transactionManager) + .getPaymentData(paymentSession) + + const paymentRepo = transactionManager.getCustomRepository( + this.paymentRepository_ + ) + + const created = paymentRepo.create({ + provider_id: paymentSession.provider_id, + amount: total, + currency_code: region.currency_code, + data: paymentData, + cart_id: cart.id, + }) + + return paymentRepo.save(created) + }) + } + + async updatePayment( + paymentId: string, + data: { order_id?: string; swap_id?: string } + ): Promise { + return await this.atomicPhase_(async (transactionManager) => { + const payment = await this.retrievePayment(paymentId) + + if (data?.order_id) { + payment.order_id = data.order_id + } + + if (data?.swap_id) { + payment.swap_id = data.swap_id + } + + const payRepo = transactionManager.getCustomRepository( + this.paymentRepository_ + ) + return payRepo.save(payment) + }) + } + + async authorizePayment( + paymentSession: PaymentSession, + context: Record + ): Promise { + return await this.atomicPhase_(async (transactionManager) => { + const session = await this.retrieveSession(paymentSession.id).catch( + () => void 0 + ) + + if (!session) { + return + } + + const provider = this.retrieveProvider(paymentSession.provider_id) + const { status, data } = await provider + .withTransaction(transactionManager) + .authorizePayment(session, context) + + session.data = data + session.status = status + + const sessionRepo = transactionManager.getCustomRepository( + this.paymentSessionRepository_ + ) + return sessionRepo.save(session) + }) + } + + async updateSessionData( + paymentSession: PaymentSession, + data: Record + ): Promise { + return await this.atomicPhase_(async (transactionManager) => { + const session = await this.retrieveSession(paymentSession.id) + + const provider = this.retrieveProvider(paymentSession.provider_id) + + session.data = await provider + .withTransaction(transactionManager) + .updatePaymentData(paymentSession.data, data) + session.status = paymentSession.status + + const sessionRepo = transactionManager.getCustomRepository( + this.paymentSessionRepository_ + ) + return sessionRepo.save(session) + }) + } + + async cancelPayment( + paymentObj: Partial & { id: string } + ): Promise { + return await this.atomicPhase_(async (transactionManager) => { + const payment = await this.retrievePayment(paymentObj.id) + const provider = this.retrieveProvider(payment.provider_id) + payment.data = await provider + .withTransaction(transactionManager) + .cancelPayment(payment) + + const now = new Date() + payment.canceled_at = now.toISOString() + + const paymentRepo = transactionManager.getCustomRepository( + this.paymentRepository_ + ) + return await paymentRepo.save(payment) + }) + } + + async getStatus(payment: Payment): Promise { + const provider = this.retrieveProvider(payment.provider_id) + return await provider.withTransaction(this.manager_).getStatus(payment.data) + } + + async capturePayment( + paymentObj: Partial & { id: string } + ): Promise { + return await this.atomicPhase_(async (transactionManager) => { + const payment = await this.retrievePayment(paymentObj.id) + const provider = this.retrieveProvider(payment.provider_id) + payment.data = await provider + .withTransaction(transactionManager) + .capturePayment(payment) + + const now = new Date() + payment.captured_at = now.toISOString() + + const paymentRepo = transactionManager.getCustomRepository( + this.paymentRepository_ + ) + return paymentRepo.save(payment) + }) + } + + async refundPayment( + payObjs: Payment[], + amount: number, + reason: string, + note?: string + ): Promise { + return await this.atomicPhase_(async (transactionManager) => { + const payments = await this.listPayments({ + id: payObjs.map((p) => p.id), + }) + + let order_id!: string + const refundable = payments.reduce((acc, next) => { + order_id = next.order_id + if (next.captured_at) { + return (acc += next.amount - next.amount_refunded) + } + + return acc + }, 0) + + if (refundable < amount) { + throw new MedusaError( + MedusaError.Types.NOT_ALLOWED, + "Refund amount is higher that the refundable amount" + ) + } + + let balance = amount + + const used: string[] = [] + + const paymentRepo = transactionManager.getCustomRepository( + this.paymentRepository_ + ) + let paymentToRefund = payments.find( + (payment) => payment.amount - payment.amount_refunded > 0 + ) + + while (paymentToRefund) { + const currentRefundable = + paymentToRefund.amount - paymentToRefund.amount_refunded + + const refundAmount = Math.min(currentRefundable, balance) + + const provider = this.retrieveProvider(paymentToRefund.provider_id) + paymentToRefund.data = await provider + .withTransaction(transactionManager) + .refundPayment(paymentToRefund, refundAmount) + + paymentToRefund.amount_refunded += refundAmount + await paymentRepo.save(paymentToRefund) + + balance -= refundAmount + + used.push(paymentToRefund.id) + + if (balance > 0) { + paymentToRefund = payments.find( + (payment) => + payment.amount - payment.amount_refunded > 0 && + !used.includes(payment.id) + ) + } else { + paymentToRefund = undefined + } + } + + const refundRepo = transactionManager.getCustomRepository( + this.refundRepository_ + ) + + const toCreate = { + order_id, + amount, + reason, + note, + } + + const created = refundRepo.create(toCreate) + return refundRepo.save(created) + }) + } + + async retrieveRefund( + id: string, + config: FindConfig = {} + ): Promise { + const refRepo = this.manager_.getCustomRepository(this.refundRepository_) + const query = buildQuery({ id }, config) + const refund = await refRepo.findOne(query) + + if (!refund) { + throw new MedusaError( + MedusaError.Types.NOT_FOUND, + `A refund with ${id} was not found` + ) + } + + return refund + } +} diff --git a/packages/medusa/src/services/system-payment-provider.js b/packages/medusa/src/services/system-payment-provider.js index f8254b99dc..737c7490a0 100644 --- a/packages/medusa/src/services/system-payment-provider.js +++ b/packages/medusa/src/services/system-payment-provider.js @@ -1,10 +1,10 @@ -import { BaseService } from "medusa-interfaces" +import { TransactionBaseService } from "../interfaces" -class SystemProviderService extends BaseService { +class SystemProviderService extends TransactionBaseService { static identifier = "system" constructor(_) { - super() + super(_) } async createPayment(_) { diff --git a/packages/medusa/tsconfig.json b/packages/medusa/tsconfig.json index c54526fd0d..0fc6130e78 100644 --- a/packages/medusa/tsconfig.json +++ b/packages/medusa/tsconfig.json @@ -22,14 +22,11 @@ "skipLibCheck": true, "downlevelIteration": true // to use ES5 specific tooling }, - "include": [ - "./src/**/*", - "index.d.ts" - ], + "include": ["./src/**/*", "index.d.ts"], "exclude": [ "./dist/**/*", "./src/**/__tests__", "./src/**/__mocks__", "node_modules" ] -} +} \ No newline at end of file