feat(medusa:) Convert PaymentProvider + PaymentProviderInterface to TS + (#1773)

* feat(payments): Refactor core Payment related

* fix(medusa): typings

* test(unit): fix suite

* test(unit): fix suite

* feat(medusa): Improve payment provider container typings

* fix(medusa): typings

* styles(medusa): renove comments

* feat(medusa): cleanup

* feat(medusa): Add uniq constraint on payment session and idem key on create-payment-session end point

* fix(medusa): migration

* fix(medusa): create payment session

* feat(medusa): cleanup
This commit is contained in:
Adrien de Peretti
2022-08-10 17:26:16 +02:00
committed by GitHub
parent 987ce2ab6d
commit bd031ef7ad
26 changed files with 1106 additions and 512 deletions

View File

@@ -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) {

View File

@@ -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 {
}

View File

@@ -6,8 +6,6 @@ const { initDb, useDb } = require("../../../helpers/use-db")
const {
simpleProductTaxRateFactory,
simpleShippingTaxRateFactory,
simpleShippingOptionFactory,
simpleCartFactory,
simpleRegionFactory,
simpleProductFactory,

View File

@@ -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) {

View File

@@ -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) {

View File

@@ -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"

View File

@@ -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
}
}

View File

@@ -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<Cart> => {
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 })
}

View File

@@ -53,5 +53,5 @@ export default async (req, res) => {
export class StorePostCartsCartPaymentSessionUpdateReq {
@IsObject()
data: object
data: Record<string, unknown>
}

View File

@@ -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"

View File

@@ -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<string, unknown>
export type PaymentData = Data
export type PaymentSessionData = Data
export interface PaymentService<T extends TransactionBaseService<never>>
extends TransactionBaseService<T> {
getIdentifier(): string
getPaymentData(paymentSession: PaymentSession): Promise<PaymentData>
updatePaymentData(
paymentSessionData: PaymentSessionData,
data: Data
): Promise<PaymentSessionData>
createPayment(cart: Cart): Promise<PaymentSessionData>
retrievePayment(paymentData: PaymentData): Promise<Data>
updatePayment(
paymentSessionData: PaymentSessionData,
cart: Cart
): Promise<PaymentSessionData>
authorizePayment(
paymentSession: PaymentSession,
context: Data
): Promise<{ data: PaymentSessionData; status: PaymentSessionStatus }>
capturePayment(payment: Payment): Promise<PaymentData>
refundPayment(payment: Payment, refundAmount: number): Promise<PaymentData>
cancelPayment(payment: Payment): Promise<PaymentData>
deletePayment(paymentSession: PaymentSession): Promise<void>
retrieveSavedMethods(customer: Customer): Promise<Data[]>
getStatus(data: Data): Promise<PaymentSessionStatus>
}
export abstract class AbstractPaymentService<
T extends TransactionBaseService<never>
>
extends TransactionBaseService<T>
implements PaymentService<T>
{
protected constructor(container: unknown, config?: Record<string, unknown>) {
super(container, config)
}
protected static identifier: string
public getIdentifier(): string {
if (!(<typeof AbstractPaymentService>this.constructor).identifier) {
throw new Error('Missing static property "identifier".')
}
return (<typeof AbstractPaymentService>this.constructor).identifier
}
public abstract getPaymentData(
paymentSession: PaymentSession
): Promise<PaymentData>
public abstract updatePaymentData(
paymentSessionData: PaymentSessionData,
data: Data
): Promise<PaymentSessionData>
public abstract createPayment(cart: Cart): Promise<PaymentSessionData>
public abstract retrievePayment(paymentData: PaymentData): Promise<Data>
public abstract updatePayment(
paymentSessionData: PaymentSessionData,
cart: Cart
): Promise<PaymentSessionData>
public abstract authorizePayment(
paymentSession: PaymentSession,
context: Data
): Promise<{ data: PaymentSessionData; status: PaymentSessionStatus }>
public abstract capturePayment(payment: Payment): Promise<PaymentData>
public abstract refundPayment(
payment: Payment,
refundAmount: number
): Promise<PaymentData>
public abstract cancelPayment(payment: Payment): Promise<PaymentData>
public abstract deletePayment(paymentSession: PaymentSession): Promise<void>
// eslint-disable-next-line @typescript-eslint/no-unused-vars
public retrieveSavedMethods(customer: Customer): Promise<Data[]> {
return Promise.resolve([])
}
public abstract getStatus(data: Data): Promise<PaymentSessionStatus>
}
export function isPaymentService(obj: unknown): boolean {
return obj instanceof AbstractPaymentService || obj instanceof PaymentService
}

View File

@@ -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 = <T>(container: AwilixContainer, name: string, logger: Logger): T | never | undefined => {
const silentResolution = <T>(
container: AwilixContainer,
name: string,
logger: Logger
): T | never | undefined => {
try {
return container.resolve<T>(name)
} catch (err) {
@@ -44,15 +52,23 @@ const silentResolution = <T>(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<void> => {
export default async ({
container,
}: {
container: AwilixContainer
}): Promise<void> => {
const storeService = container.resolve<StoreService>("storeService")
const currencyRepository = container.resolve<typeof CurrencyRepository>("currencyRepository")
const countryRepository = container.resolve<typeof CountryRepository>("countryRepository")
const profileService = container.resolve<ShippingProfileService>("shippingProfileService")
const currencyRepository =
container.resolve<typeof CurrencyRepository>("currencyRepository")
const countryRepository =
container.resolve<typeof CountryRepository>("countryRepository")
const profileService = container.resolve<ShippingProfileService>(
"shippingProfileService"
)
const salesChannelService = container.resolve<SalesChannelService>("salesChannelService")
const logger = container.resolve<Logger>("logger")
const featureFlagRouter = container.resolve<FlagRouter>("featureFlagRouter")
@@ -104,32 +120,54 @@ export default async ({ container }: { container: AwilixContainer }): Promise<vo
await storeService.withTransaction(manager).create()
const payProviders =
silentResolution<typeof BasePaymentService[]>(container, "paymentProviders", logger) || []
silentResolution<(typeof BasePaymentService | AbstractPaymentService<never>)[]>(
container,
"paymentProviders",
logger
) || []
const payIds = payProviders.map((p) => p.getIdentifier())
const pProviderService = container.resolve<PaymentProviderService>("paymentProviderService")
const pProviderService = container.resolve<PaymentProviderService>(
"paymentProviderService"
)
await pProviderService.registerInstalledProviders(payIds)
const notiProviders =
silentResolution<typeof BaseNotificationService[]>(container, "notificationProviders", logger) || []
silentResolution<typeof BaseNotificationService[]>(
container,
"notificationProviders",
logger
) || []
const notiIds = notiProviders.map((p) => p.getIdentifier())
const nProviderService = container.resolve<NotificationService>("notificationService")
const nProviderService = container.resolve<NotificationService>(
"notificationService"
)
await nProviderService.registerInstalledProviders(notiIds)
const fulfilProviders =
silentResolution<typeof BaseFulfillmentService[]>(container, "fulfillmentProviders", logger) || []
silentResolution<typeof BaseFulfillmentService[]>(
container,
"fulfillmentProviders",
logger
) || []
const fulfilIds = fulfilProviders.map((p) => p.getIdentifier())
const fProviderService = container.resolve<FulfillmentProviderService>("fulfillmentProviderService")
const fProviderService = container.resolve<FulfillmentProviderService>(
"fulfillmentProviderService"
)
await fProviderService.registerInstalledProviders(fulfilIds)
const taxProviders =
silentResolution<AbstractTaxService[]>(container, "taxProviders", logger) || []
silentResolution<AbstractTaxService[]>(
container,
"taxProviders",
logger
) || []
const taxIds = taxProviders.map((p) => p.getIdentifier())
const tProviderService = container.resolve<TaxProviderService>("taxProviderService")
const tProviderService =
container.resolve<TaxProviderService>("taxProviderService")
await tProviderService.registerInstalledProviders(taxIds)
await profileService.withTransaction(manager).createDefault()

View File

@@ -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",

View File

@@ -0,0 +1,13 @@
import { MigrationInterface, QueryRunner } from "typeorm"
export class paymentSessionUniqCartIdProviderId1660040729000 implements MigrationInterface {
name = "paymentSessionUniqCartIdProviderId1660040729000"
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`CREATE UNIQUE INDEX "UniqPaymentSessionCartIdProviderId" ON "payment_session" ("cart_id", "provider_id")`)
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP INDEX "UniqPaymentSessionCartIdProviderId"`)
}
}

View File

@@ -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()

View File

@@ -63,10 +63,10 @@ export class Payment extends BaseEntity {
data: Record<string, unknown>
@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<string, unknown>

View File

@@ -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

View File

@@ -61,6 +61,8 @@ describe("CartService", () => {
undefined,
{
where: { id: IdMap.getId("emptyCart") },
select: undefined,
relations: undefined,
}
)
})

View File

@@ -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)
})
})

View File

@@ -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"

View File

@@ -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<CartService> {
* @param update - the data to update the payment session with
* @return the resulting cart
*/
async updatePaymentSession(cartId: string, update: object): Promise<Cart> {
async updatePaymentSession(
cartId: string,
update: Record<string, unknown>
): Promise<Cart> {
return await this.atomicPhase_(
async (transactionManager: EntityManager) => {
const cart = await this.retrieve(cartId, {
@@ -1385,14 +1389,14 @@ class CartService extends TransactionBaseService<CartService> {
)
}
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_

View File

@@ -30,6 +30,7 @@ import {
Order,
OrderStatus,
Payment,
PaymentSession,
PaymentStatus,
Return,
Swap,
@@ -531,7 +532,6 @@ class OrderService extends TransactionBaseService<OrderService> {
// 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<OrderService> {
.withTransaction(manager)
.getStatus(payment)
// If payment status is not authorized, we throw
if (paymentStatus !== "authorized") {
throw new MedusaError(
MedusaError.Types.INVALID_ARGUMENT,

View File

@@ -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

View File

@@ -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<never>
| typeof BasePaymentService
}
/**
* Helps retrieve payment providers
*/
export default class PaymentProviderService extends TransactionBaseService<PaymentProviderService> {
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<void> {
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<PaymentProvider[]> {
const ppRepo = this.manager_.getCustomRepository(
this.paymentProviderRepository_
)
return await ppRepo.find()
}
async retrievePayment(
id: string,
relations: string[] = []
): Promise<Payment | never> {
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<Payment>,
config: FindConfig<Payment> = {
skip: 0,
take: 50,
order: { created_at: "DESC" },
}
): Promise<Payment[]> {
const payRepo = this.manager_.getCustomRepository(this.paymentRepository_)
const query = buildQuery(selector, config)
return await payRepo.find(query)
}
async retrieveSession(
id: string,
relations: string[] = []
): Promise<PaymentSession | never> {
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<PaymentSession> {
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<PaymentSession> {
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<PaymentSession> {
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<PaymentSession | undefined> {
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<never> | typeof BasePaymentService
>(
providerId: string
): TProvider extends AbstractPaymentService<never>
? AbstractPaymentService<never>
: 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<Payment> {
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<Payment> {
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<string, unknown>
): Promise<PaymentSession | undefined> {
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<string, unknown>
): Promise<PaymentSession> {
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<Payment> & { id: string }
): Promise<Payment> {
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<PaymentSessionStatus> {
const provider = this.retrieveProvider(payment.provider_id)
return await provider.withTransaction(this.manager_).getStatus(payment.data)
}
async capturePayment(
paymentObj: Partial<Payment> & { id: string }
): Promise<Payment> {
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<Refund> {
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<Refund> = {}
): Promise<Refund | never> {
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
}
}

View File

@@ -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(_) {

View File

@@ -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"
]
}
}