feat(medusa): manage payment sessions from payment collection (#2402)
* feat: manage payment sessions from payment collection
This commit is contained in:
1 parent
a014e3623c
commit
c3f8297599
28 files changed
+2002
-1315
No files matched your search
@@ -7,6 +7,7 @@ import {
|
||||
PaymentSessionStatus,
|
||||
} from "../models"
|
||||
import { PaymentService } from "medusa-interfaces"
|
||||
import { PaymentProviderDataInput } from "../types/payment-collection"
|
||||
|
||||
export type Data = Record<string, unknown>
|
||||
export type PaymentData = Data
|
||||
@@ -76,6 +77,9 @@ export abstract class AbstractPaymentService
|
||||
): Promise<PaymentSessionData>
|
||||
|
||||
public abstract createPayment(cart: Cart): Promise<PaymentSessionData>
|
||||
public abstract createPaymentNew(
|
||||
paymentInput: PaymentProviderDataInput
|
||||
): Promise<PaymentSessionData>
|
||||
|
||||
public abstract retrievePayment(paymentData: PaymentData): Promise<Data>
|
||||
|
||||
@@ -84,6 +88,11 @@ export abstract class AbstractPaymentService
|
||||
cart: Cart
|
||||
): Promise<PaymentSessionData>
|
||||
|
||||
public abstract updatePaymentNew(
|
||||
paymentSessionData: PaymentSessionData,
|
||||
paymentInput: PaymentProviderDataInput
|
||||
): Promise<PaymentSessionData>
|
||||
|
||||
public abstract authorizePayment(
|
||||
paymentSession: PaymentSession,
|
||||
context: Data
|
||||
|
||||
@@ -27,6 +27,7 @@ export class paymentCollection1664880666982 implements MigrationInterface {
|
||||
description text NULL,
|
||||
amount integer NOT NULL,
|
||||
authorized_amount integer NULL,
|
||||
captured_amount integer NULL,
|
||||
refunded_amount integer NULL,
|
||||
region_id character varying NOT NULL,
|
||||
currency_code character varying NOT NULL,
|
||||
@@ -71,6 +72,12 @@ export class paymentCollection1664880666982 implements MigrationInterface {
|
||||
ALTER TABLE "order_edit" ADD CONSTRAINT "FK_order_edit_payment_collection_id" FOREIGN KEY ("payment_collection_id") REFERENCES "payment_collection"("id") ON DELETE NO ACTION ON UPDATE NO ACTION;
|
||||
|
||||
ALTER TABLE payment_session ADD COLUMN payment_authorized_at timestamp WITH time zone NULL;
|
||||
ALTER TABLE payment_session ADD COLUMN amount integer NULL;
|
||||
ALTER TABLE payment_session ALTER COLUMN cart_id DROP NOT NULL;
|
||||
|
||||
ALTER TABLE refund ADD COLUMN payment_id character varying NULL;
|
||||
CREATE INDEX "IDX_refund_payment_id" ON "refund" ("payment_id");
|
||||
ALTER TABLE "refund" ADD CONSTRAINT "FK_refund_payment_id" FOREIGN KEY ("payment_id") REFERENCES "payment"("id") ON DELETE NO ACTION ON UPDATE NO ACTION;
|
||||
`)
|
||||
|
||||
// Add missing indexes
|
||||
@@ -85,6 +92,12 @@ export class paymentCollection1664880666982 implements MigrationInterface {
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
DROP INDEX "IDX_order_edit_payment_collection_id";
|
||||
ALTER TABLE order_edit DROP CONSTRAINT "FK_order_edit_payment_collection_id";
|
||||
|
||||
DROP INDEX "IDX_refund_payment_id";
|
||||
ALTER TABLE refund DROP CONSTRAINT "FK_refund_payment_id";
|
||||
|
||||
ALTER TABLE payment_collection DROP CONSTRAINT "FK_payment_collection_region_id";
|
||||
ALTER TABLE payment_collection_sessions DROP CONSTRAINT "FK_payment_collection_sessions_payment_collection_id";
|
||||
ALTER TABLE payment_collection_sessions DROP CONSTRAINT "FK_payment_collection_sessions_payment_session_id";
|
||||
@@ -92,6 +105,9 @@ export class paymentCollection1664880666982 implements MigrationInterface {
|
||||
ALTER TABLE payment_collection_payments DROP CONSTRAINT "FK_payment_collection_payments_payment_id";
|
||||
ALTER TABLE order_edit DROP COLUMN payment_collection_id;
|
||||
ALTER TABLE payment_session DROP COLUMN payment_authorized_at;
|
||||
ALTER TABLE payment_session DROP COLUMN amount;
|
||||
ALTER TABLE payment_session ALTER COLUMN cart_id SET NOT NULL;
|
||||
ALTER TABLE refund DROP COLUMN payment_id;
|
||||
|
||||
DROP TABLE payment_collection;
|
||||
DROP TABLE payment_collection_sessions;
|
||||
@@ -99,10 +115,6 @@ export class paymentCollection1664880666982 implements MigrationInterface {
|
||||
|
||||
DROP TYPE "PAYMENT_COLLECTION_TYPE_ENUM";
|
||||
DROP TYPE "PAYMENT_COLLECTION_STATUS_ENUM";
|
||||
|
||||
DROP INDEX "IDX_order_edit_payment_collection_id";
|
||||
ALTER TABLE order_edit DROP CONSTRAINT "FK_order_edit_payment_collection_id";
|
||||
|
||||
`)
|
||||
|
||||
await queryRunner.query(`
|
||||
|
||||
@@ -50,6 +50,9 @@ export class PaymentCollection extends SoftDeletableEntity {
|
||||
@Column({ type: "int", nullable: true })
|
||||
authorized_amount: number
|
||||
|
||||
@Column({ type: "int", nullable: true })
|
||||
captured_amount: number
|
||||
|
||||
@Column({ type: "int", nullable: true })
|
||||
refunded_amount: number
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ export enum PaymentSessionStatus {
|
||||
@Entity()
|
||||
export class PaymentSession extends BaseEntity {
|
||||
@Index()
|
||||
@Column()
|
||||
@Column({ nullable: true })
|
||||
cart_id: string
|
||||
|
||||
@ManyToOne(() => Cart, (cart) => cart.payment_sessions)
|
||||
@@ -51,6 +51,11 @@ export class PaymentSession extends BaseEntity {
|
||||
@Column({ nullable: true })
|
||||
idempotency_key: string
|
||||
|
||||
@FeatureFlagDecorators(OrderEditingFeatureFlag.key, [
|
||||
Column({ type: "integer", nullable: true }),
|
||||
])
|
||||
amount: number
|
||||
|
||||
@FeatureFlagDecorators(OrderEditingFeatureFlag.key, [
|
||||
Column({ type: resolveDbType("timestamptz"), nullable: true }),
|
||||
])
|
||||
|
||||
@@ -5,12 +5,16 @@ import {
|
||||
Index,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
OneToOne,
|
||||
} from "typeorm"
|
||||
|
||||
import { BaseEntity } from "../interfaces/models/base-entity"
|
||||
import { DbAwareColumn } from "../utils/db-aware-column"
|
||||
import { Order } from "./order"
|
||||
import { generateEntityId } from "../utils/generate-entity-id"
|
||||
import { Payment } from "./payment"
|
||||
import { FeatureFlagDecorators } from "../utils/feature-flag-decorators"
|
||||
import OrderEditingFeatureFlag from "../loaders/feature-flags/order-editing"
|
||||
|
||||
export enum RefundReason {
|
||||
DISCOUNT = "discount",
|
||||
@@ -23,13 +27,25 @@ export enum RefundReason {
|
||||
@Entity()
|
||||
export class Refund extends BaseEntity {
|
||||
@Index()
|
||||
@Column()
|
||||
@Column({ nullable: true })
|
||||
order_id: string
|
||||
|
||||
@FeatureFlagDecorators(OrderEditingFeatureFlag.key, [
|
||||
Index(),
|
||||
Column({ nullable: true }),
|
||||
])
|
||||
payment_id: string
|
||||
|
||||
@ManyToOne(() => Order, (order) => order.payments)
|
||||
@JoinColumn({ name: "order_id" })
|
||||
order: Order
|
||||
|
||||
@FeatureFlagDecorators(OrderEditingFeatureFlag.key, [
|
||||
OneToOne(() => Payment, { nullable: true }),
|
||||
JoinColumn({ name: "payment_id" }),
|
||||
])
|
||||
payment: Payment
|
||||
|
||||
@Column({ type: "int" })
|
||||
amount: number
|
||||
|
||||
|
||||
@@ -1,6 +1,72 @@
|
||||
import { MedusaError } from "medusa-core-utils"
|
||||
import { PaymentCollection } from "./../models/payment-collection"
|
||||
import { EntityRepository, Repository } from "typeorm"
|
||||
import { FindConfig } from "../types/common"
|
||||
import { PaymentSession } from "../models"
|
||||
|
||||
@EntityRepository(PaymentCollection)
|
||||
// eslint-disable-next-line max-len
|
||||
export class PaymentCollectionRepository extends Repository<PaymentCollection> {}
|
||||
export class PaymentCollectionRepository extends Repository<PaymentCollection> {
|
||||
async getPaymentCollectionIdBySessionId(
|
||||
sessionId: string,
|
||||
config: FindConfig<PaymentCollection> = {}
|
||||
): Promise<PaymentCollection> {
|
||||
const paymentCollection = await this.find({
|
||||
join: {
|
||||
alias: "payment_col",
|
||||
innerJoin: { payment_sessions: "payment_col.payment_sessions" },
|
||||
},
|
||||
where: (qb) => {
|
||||
qb.where(
|
||||
"payment_col_payment_sessions.payment_session_id = :sessionId",
|
||||
{ sessionId }
|
||||
)
|
||||
},
|
||||
relations: config.relations,
|
||||
select: config.select,
|
||||
})
|
||||
|
||||
if (!paymentCollection.length) {
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.NOT_FOUND,
|
||||
`Payment collection related to Payment Session id ${sessionId} was not found`
|
||||
)
|
||||
}
|
||||
|
||||
return paymentCollection[0]
|
||||
}
|
||||
|
||||
async getPaymentCollectionIdByPaymentId(
|
||||
paymentId: string,
|
||||
config: FindConfig<PaymentCollection> = {}
|
||||
): Promise<PaymentCollection> {
|
||||
const paymentCollection = await this.find({
|
||||
join: {
|
||||
alias: "payment_col",
|
||||
innerJoin: { payments: "payment_col.payments" },
|
||||
},
|
||||
where: (qb) => {
|
||||
qb.where("payment_col_payments.payment_id = :paymentId", { paymentId })
|
||||
},
|
||||
relations: config.relations,
|
||||
select: config.select,
|
||||
})
|
||||
|
||||
if (!paymentCollection.length) {
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.NOT_FOUND,
|
||||
`Payment collection related to Payment id ${paymentId} was not found`
|
||||
)
|
||||
}
|
||||
|
||||
return paymentCollection[0]
|
||||
}
|
||||
|
||||
async deleteMultiple(ids: string[]): Promise<void> {
|
||||
await this.createQueryBuilder()
|
||||
.delete()
|
||||
.from(PaymentSession)
|
||||
.where("id IN (:...ids)", { ids })
|
||||
.execute()
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
export const DefaultProviderMock = {
|
||||
getStatus: jest.fn().mockImplementation(data => {
|
||||
getStatus: jest.fn().mockImplementation((data) => {
|
||||
if (data.money_id === "success") {
|
||||
return Promise.resolve("authorized")
|
||||
}
|
||||
@@ -10,7 +10,7 @@ export const DefaultProviderMock = {
|
||||
|
||||
return Promise.resolve("initial")
|
||||
}),
|
||||
retrievePayment: jest.fn().mockImplementation(data => {
|
||||
retrievePayment: jest.fn().mockImplementation((data) => {
|
||||
return Promise.resolve(data)
|
||||
}),
|
||||
list: jest.fn().mockImplementation(() => {
|
||||
@@ -19,15 +19,26 @@ export const DefaultProviderMock = {
|
||||
capturePayment: jest.fn().mockReturnValue(Promise.resolve()),
|
||||
refundPayment: jest.fn().mockReturnValue(Promise.resolve()),
|
||||
cancelPayment: jest.fn().mockReturnValue(Promise.resolve({})),
|
||||
deletePayment: jest.fn().mockReturnValue(Promise.resolve({})),
|
||||
authorizePayment: jest.fn().mockReturnValue(Promise.resolve({})),
|
||||
}
|
||||
|
||||
export const PaymentProviderServiceMock = {
|
||||
withTransaction: function () {
|
||||
return this
|
||||
},
|
||||
updateSession: jest.fn().mockImplementation((session, cart) => {
|
||||
return Promise.resolve({
|
||||
...session.data,
|
||||
id: `${session.data.id}_updated`,
|
||||
})
|
||||
}),
|
||||
updateSessionNew: jest.fn().mockImplementation((session, sessionInput) => {
|
||||
return Promise.resolve({
|
||||
...session,
|
||||
id: `${session.id}_updated`,
|
||||
})
|
||||
}),
|
||||
list: jest.fn().mockImplementation(() => {
|
||||
return Promise.resolve()
|
||||
}),
|
||||
@@ -40,12 +51,31 @@ export const PaymentProviderServiceMock = {
|
||||
cartId: cart._id,
|
||||
})
|
||||
}),
|
||||
retrieveProvider: jest.fn().mockImplementation(providerId => {
|
||||
createSessionNew: jest.fn().mockImplementation((sessionInput) => {
|
||||
return Promise.resolve({
|
||||
id: `${sessionInput.providerId}_session`,
|
||||
})
|
||||
}),
|
||||
retrieveProvider: jest.fn().mockImplementation((providerId) => {
|
||||
if (providerId === "default_provider") {
|
||||
return DefaultProviderMock
|
||||
}
|
||||
throw new Error("Provider Not Found")
|
||||
}),
|
||||
refreshSessionNew: jest.fn().mockImplementation((session, inputData) => {
|
||||
DefaultProviderMock.deletePayment()
|
||||
PaymentProviderServiceMock.createSessionNew(inputData)
|
||||
return Promise.resolve({
|
||||
...session,
|
||||
id: `${session.id}_refreshed`,
|
||||
})
|
||||
}),
|
||||
authorizePayment: jest
|
||||
.fn()
|
||||
.mockReturnValue(Promise.resolve({ status: "authorized" })),
|
||||
createPaymentNew: jest.fn().mockImplementation((session, inputData) => {
|
||||
Promise.resolve(inputData)
|
||||
}),
|
||||
}
|
||||
|
||||
const mock = jest.fn().mockImplementation(() => {
|
||||
|
||||
@@ -1,7 +1,22 @@
|
||||
import { IdMap, MockManager, MockRepository } from "medusa-test-utils"
|
||||
import { EventBusService, PaymentCollectionService } from "../index"
|
||||
import { PaymentCollectionStatus, PaymentCollectionType } from "../../models"
|
||||
import {
|
||||
CustomerService,
|
||||
EventBusService,
|
||||
PaymentCollectionService,
|
||||
PaymentProviderService,
|
||||
} from "../index"
|
||||
import {
|
||||
PaymentCollectionStatus,
|
||||
PaymentCollectionType,
|
||||
PaymentCollection,
|
||||
} from "../../models"
|
||||
import { EventBusServiceMock } from "../__mocks__/event-bus"
|
||||
import {
|
||||
DefaultProviderMock,
|
||||
PaymentProviderServiceMock,
|
||||
} from "../__mocks__/payment-provider"
|
||||
import { CustomerServiceMock } from "../__mocks__/customer"
|
||||
import { PaymentCollectionSessionInput } from "../../types/payment-collection"
|
||||
|
||||
describe("PaymentCollectionService", () => {
|
||||
afterEach(() => {
|
||||
@@ -11,6 +26,23 @@ describe("PaymentCollectionService", () => {
|
||||
const paymentCollectionSample = {
|
||||
id: IdMap.getId("payment-collection-id1"),
|
||||
region_id: IdMap.getId("region1"),
|
||||
region: {
|
||||
payment_providers: [
|
||||
{
|
||||
id: IdMap.getId("region1_provider1"),
|
||||
},
|
||||
{
|
||||
id: IdMap.getId("region1_provider2"),
|
||||
},
|
||||
],
|
||||
},
|
||||
payment_sessions: [
|
||||
{
|
||||
id: IdMap.getId("payCol_session1"),
|
||||
provider_id: IdMap.getId("region1_provider1"),
|
||||
amount: 100,
|
||||
},
|
||||
],
|
||||
amount: 100,
|
||||
created_at: new Date(),
|
||||
metadata: {
|
||||
@@ -19,6 +51,28 @@ describe("PaymentCollectionService", () => {
|
||||
status: PaymentCollectionStatus.NOT_PAID,
|
||||
}
|
||||
|
||||
const paymentCollectionWithSessions = {
|
||||
id: IdMap.getId("payment-collection-session"),
|
||||
region_id: IdMap.getId("region1"),
|
||||
region: {
|
||||
payment_providers: [
|
||||
{
|
||||
id: IdMap.getId("region1_provider1"),
|
||||
},
|
||||
],
|
||||
},
|
||||
payment_sessions: [
|
||||
{
|
||||
id: IdMap.getId("payCol_session1"),
|
||||
provider_id: IdMap.getId("region1_provider1"),
|
||||
amount: 100,
|
||||
},
|
||||
],
|
||||
amount: 100,
|
||||
created_at: new Date(),
|
||||
status: PaymentCollectionStatus.NOT_PAID,
|
||||
} as PaymentCollection
|
||||
|
||||
const paymentCollectionAuthorizedSample = {
|
||||
id: IdMap.getId("payment-collection-id2"),
|
||||
region_id: IdMap.getId("region1"),
|
||||
@@ -26,16 +80,123 @@ describe("PaymentCollectionService", () => {
|
||||
status: PaymentCollectionStatus.AUTHORIZED,
|
||||
}
|
||||
|
||||
const zeroSample = {
|
||||
id: IdMap.getId("payment-collection-zero"),
|
||||
region_id: IdMap.getId("region1"),
|
||||
amount: 0,
|
||||
status: PaymentCollectionStatus.NOT_PAID,
|
||||
}
|
||||
|
||||
const noSessionSample = {
|
||||
id: IdMap.getId("payment-collection-zero"),
|
||||
region_id: IdMap.getId("region1"),
|
||||
amount: 10000,
|
||||
status: PaymentCollectionStatus.NOT_PAID,
|
||||
}
|
||||
|
||||
const fullyAuthorizedSample = {
|
||||
id: IdMap.getId("payment-collection-fully"),
|
||||
region_id: IdMap.getId("region1"),
|
||||
amount: 35000,
|
||||
authorized_amount: 35000,
|
||||
region: {
|
||||
payment_providers: [
|
||||
{
|
||||
id: IdMap.getId("region1_provider1"),
|
||||
},
|
||||
],
|
||||
},
|
||||
payment_sessions: [
|
||||
{
|
||||
id: IdMap.getId("payCol_session1"),
|
||||
payment_authorized_at: Date.now(),
|
||||
provider_id: IdMap.getId("region1_provider1"),
|
||||
amount: 35000,
|
||||
},
|
||||
],
|
||||
payments: [
|
||||
{
|
||||
id: IdMap.getId("payment-123"),
|
||||
amount: 35000,
|
||||
captured_amount: 0,
|
||||
},
|
||||
],
|
||||
status: PaymentCollectionStatus.AUTHORIZED,
|
||||
} as unknown as PaymentCollection
|
||||
|
||||
const partiallyAuthorizedSample = {
|
||||
id: IdMap.getId("payment-collection-partial"),
|
||||
region_id: IdMap.getId("region1"),
|
||||
amount: 70000,
|
||||
authorized_amount: 35000,
|
||||
region: {
|
||||
payment_providers: [
|
||||
{
|
||||
id: IdMap.getId("region1_provider1"),
|
||||
},
|
||||
],
|
||||
},
|
||||
payment_sessions: [
|
||||
{
|
||||
id: IdMap.getId("payCol_session1"),
|
||||
provider_id: IdMap.getId("region1_provider1"),
|
||||
amount: 35000,
|
||||
},
|
||||
{
|
||||
id: IdMap.getId("payCol_session2"),
|
||||
payment_authorized_at: Date.now(),
|
||||
provider_id: IdMap.getId("region1_provider1"),
|
||||
amount: 35000,
|
||||
},
|
||||
],
|
||||
payments: [],
|
||||
status: PaymentCollectionStatus.PARTIALLY_AUTHORIZED,
|
||||
}
|
||||
|
||||
const notAuthorizedSample = {
|
||||
id: IdMap.getId("payment-collection-not-authorized"),
|
||||
region_id: IdMap.getId("region1"),
|
||||
amount: 70000,
|
||||
region: {
|
||||
payment_providers: [
|
||||
{
|
||||
id: IdMap.getId("region1_provider1"),
|
||||
},
|
||||
],
|
||||
},
|
||||
payment_sessions: [
|
||||
{
|
||||
id: IdMap.getId("payCol_session1"),
|
||||
provider_id: IdMap.getId("region1_provider1"),
|
||||
amount: 35000,
|
||||
},
|
||||
{
|
||||
id: IdMap.getId("payCol_session2"),
|
||||
provider_id: IdMap.getId("region1_provider1"),
|
||||
amount: 35000,
|
||||
},
|
||||
],
|
||||
payments: [],
|
||||
status: PaymentCollectionStatus.PARTIALLY_AUTHORIZED,
|
||||
} as unknown as PaymentCollection
|
||||
|
||||
const paymentCollectionRepository = MockRepository({
|
||||
findOne: (query) => {
|
||||
find: (query) => {
|
||||
const map = {
|
||||
[IdMap.getId("payment-collection-id1")]: paymentCollectionSample,
|
||||
[IdMap.getId("payment-collection-id2")]:
|
||||
paymentCollectionAuthorizedSample,
|
||||
[IdMap.getId("payment-collection-session")]:
|
||||
paymentCollectionWithSessions,
|
||||
[IdMap.getId("payment-collection-zero")]: zeroSample,
|
||||
[IdMap.getId("payment-collection-no-session")]: noSessionSample,
|
||||
[IdMap.getId("payment-collection-fully")]: fullyAuthorizedSample,
|
||||
[IdMap.getId("payment-collection-partial")]: partiallyAuthorizedSample,
|
||||
[IdMap.getId("payment-collection-not-authorized")]: notAuthorizedSample,
|
||||
}
|
||||
|
||||
if (map[query?.where?.id]) {
|
||||
return { ...map[query?.where?.id] }
|
||||
return [{ ...map[query?.where?.id] }]
|
||||
}
|
||||
return
|
||||
},
|
||||
@@ -45,20 +206,38 @@ describe("PaymentCollectionService", () => {
|
||||
...data,
|
||||
}
|
||||
},
|
||||
save: (data) => {
|
||||
return data
|
||||
},
|
||||
})
|
||||
|
||||
paymentCollectionRepository.deleteMultiple = jest
|
||||
.fn()
|
||||
.mockImplementation(() => {
|
||||
return Promise.resolve()
|
||||
})
|
||||
|
||||
paymentCollectionRepository.getPaymentCollectionIdBySessionId = jest
|
||||
.fn()
|
||||
.mockImplementation(async () => {
|
||||
return paymentCollectionWithSessions
|
||||
})
|
||||
|
||||
const paymentCollectionService = new PaymentCollectionService({
|
||||
manager: MockManager,
|
||||
paymentCollectionRepository,
|
||||
eventBusService: EventBusServiceMock as unknown as EventBusService,
|
||||
paymentProviderService:
|
||||
PaymentProviderServiceMock as unknown as PaymentProviderService,
|
||||
customerService: CustomerServiceMock as unknown as CustomerService,
|
||||
})
|
||||
|
||||
it("should retrieve a payment collection", async () => {
|
||||
await paymentCollectionService.retrieve(
|
||||
IdMap.getId("payment-collection-id1")
|
||||
)
|
||||
expect(paymentCollectionRepository.findOne).toHaveBeenCalledTimes(1)
|
||||
expect(paymentCollectionRepository.findOne).toHaveBeenCalledWith({
|
||||
expect(paymentCollectionRepository.find).toHaveBeenCalledTimes(1)
|
||||
expect(paymentCollectionRepository.find).toHaveBeenCalledWith({
|
||||
where: { id: IdMap.getId("payment-collection-id1") },
|
||||
})
|
||||
})
|
||||
@@ -68,8 +247,8 @@ describe("PaymentCollectionService", () => {
|
||||
IdMap.getId("payment-collection-non-existing-id")
|
||||
)
|
||||
|
||||
expect(paymentCollectionRepository.findOne).toHaveBeenCalledTimes(1)
|
||||
expect(payCol).rejects.toThrow(Error)
|
||||
expect(paymentCollectionRepository.find).toBeCalledTimes(1)
|
||||
})
|
||||
|
||||
it("should create a payment collection", async () => {
|
||||
@@ -155,9 +334,10 @@ describe("PaymentCollectionService", () => {
|
||||
IdMap.getId("payment-collection-non-existing"),
|
||||
submittedChanges
|
||||
)
|
||||
expect(paymentCollectionRepository.save).toHaveBeenCalledTimes(0)
|
||||
expect(EventBusServiceMock.emit).toHaveBeenCalledTimes(0)
|
||||
|
||||
expect(payCol).rejects.toThrow(Error)
|
||||
expect(paymentCollectionRepository.save).toBeCalledTimes(0)
|
||||
expect(EventBusServiceMock.emit).toBeCalledTimes(0)
|
||||
})
|
||||
|
||||
it("should delete a payment collection", async () => {
|
||||
@@ -197,4 +377,312 @@ describe("PaymentCollectionService", () => {
|
||||
|
||||
expect(entity).rejects.toThrow(Error)
|
||||
})
|
||||
|
||||
describe("Manage Payment Sessions", () => {
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
it("should throw error if payment collection doesn't have the correct status", async () => {
|
||||
const inp: PaymentCollectionSessionInput = {
|
||||
amount: 100,
|
||||
provider_id: IdMap.getId("region1_provider1"),
|
||||
customer_id: "customer1",
|
||||
}
|
||||
const ret = paymentCollectionService.setPaymentSessions(
|
||||
IdMap.getId("payment-collection-id2"),
|
||||
inp
|
||||
)
|
||||
|
||||
expect(ret).rejects.toThrowError(
|
||||
new Error(
|
||||
`Cannot set payment sessions for a payment collection with status ${PaymentCollectionStatus.AUTHORIZED}`
|
||||
)
|
||||
)
|
||||
expect(PaymentProviderServiceMock.createSessionNew).toBeCalledTimes(0)
|
||||
})
|
||||
|
||||
it("should throw error if amount is different than requested", async () => {
|
||||
const inp: PaymentCollectionSessionInput = {
|
||||
amount: 101,
|
||||
provider_id: IdMap.getId("region1_provider1"),
|
||||
customer_id: "customer1",
|
||||
}
|
||||
const ret = paymentCollectionService.setPaymentSessions(
|
||||
IdMap.getId("payment-collection-id1"),
|
||||
inp
|
||||
)
|
||||
|
||||
expect(PaymentProviderServiceMock.createSessionNew).toHaveBeenCalledTimes(
|
||||
0
|
||||
)
|
||||
expect(ret).rejects.toThrow(
|
||||
`The sum of sessions is not equal to 100 on Payment Collection`
|
||||
)
|
||||
|
||||
const multInp: PaymentCollectionSessionInput[] = [
|
||||
{
|
||||
amount: 51,
|
||||
provider_id: IdMap.getId("region1_provider1"),
|
||||
customer_id: "customer1",
|
||||
},
|
||||
{
|
||||
amount: 50,
|
||||
provider_id: IdMap.getId("region1_provider2"),
|
||||
customer_id: "customer1",
|
||||
},
|
||||
]
|
||||
const multiRet = paymentCollectionService.setPaymentSessions(
|
||||
IdMap.getId("payment-collection-id1"),
|
||||
multInp
|
||||
)
|
||||
|
||||
expect(PaymentProviderServiceMock.createSessionNew).toHaveBeenCalledTimes(
|
||||
0
|
||||
)
|
||||
expect(multiRet).rejects.toThrow(
|
||||
`The sum of sessions is not equal to 100 on Payment Collection`
|
||||
)
|
||||
})
|
||||
|
||||
it("should ignore sessions where provider doesn't belong to the region", async () => {
|
||||
const multInp: PaymentCollectionSessionInput[] = [
|
||||
{
|
||||
amount: 50,
|
||||
provider_id: IdMap.getId("region1_provider1"),
|
||||
customer_id: "customer1",
|
||||
},
|
||||
{
|
||||
amount: 50,
|
||||
provider_id: IdMap.getId("region1_invalid_provider"),
|
||||
customer_id: "customer1",
|
||||
},
|
||||
]
|
||||
const multiRet = paymentCollectionService.setPaymentSessions(
|
||||
IdMap.getId("payment-collection-id1"),
|
||||
multInp
|
||||
)
|
||||
|
||||
expect(multiRet).rejects.toThrow(
|
||||
`The sum of sessions is not equal to 100 on Payment Collection`
|
||||
)
|
||||
expect(PaymentProviderServiceMock.createSessionNew).toBeCalledTimes(0)
|
||||
})
|
||||
|
||||
it("should add a new session and update existing one", async () => {
|
||||
const inp: PaymentCollectionSessionInput[] = [
|
||||
{
|
||||
session_id: IdMap.getId("payCol_session1"),
|
||||
amount: 50,
|
||||
provider_id: IdMap.getId("region1_provider1"),
|
||||
customer_id: IdMap.getId("lebron"),
|
||||
},
|
||||
{
|
||||
amount: 50,
|
||||
provider_id: IdMap.getId("region1_provider1"),
|
||||
customer_id: IdMap.getId("lebron"),
|
||||
},
|
||||
]
|
||||
await paymentCollectionService.setPaymentSessions(
|
||||
IdMap.getId("payment-collection-session"),
|
||||
inp
|
||||
)
|
||||
|
||||
expect(PaymentProviderServiceMock.createSessionNew).toHaveBeenCalledTimes(
|
||||
1
|
||||
)
|
||||
expect(PaymentProviderServiceMock.updateSessionNew).toHaveBeenCalledTimes(
|
||||
1
|
||||
)
|
||||
expect(CustomerServiceMock.retrieve).toHaveBeenCalledTimes(1)
|
||||
expect(paymentCollectionRepository.save).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it("should add a new session and delete existing one", async () => {
|
||||
const inp: PaymentCollectionSessionInput[] = [
|
||||
{
|
||||
amount: 100,
|
||||
provider_id: IdMap.getId("region1_provider1"),
|
||||
customer_id: IdMap.getId("lebron"),
|
||||
},
|
||||
]
|
||||
await paymentCollectionService.setPaymentSessions(
|
||||
IdMap.getId("payment-collection-session"),
|
||||
inp
|
||||
)
|
||||
|
||||
expect(PaymentProviderServiceMock.createSessionNew).toHaveBeenCalledTimes(
|
||||
1
|
||||
)
|
||||
expect(PaymentProviderServiceMock.updateSessionNew).toHaveBeenCalledTimes(
|
||||
0
|
||||
)
|
||||
expect(paymentCollectionRepository.deleteMultiple).toHaveBeenCalledTimes(
|
||||
1
|
||||
)
|
||||
|
||||
expect(paymentCollectionRepository.save).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it("should refresh a payment session", async () => {
|
||||
await paymentCollectionService.refreshPaymentSession(
|
||||
IdMap.getId("payment-collection-session"),
|
||||
IdMap.getId("payCol_session1"),
|
||||
{
|
||||
customer_id: "customer1",
|
||||
amount: 100,
|
||||
provider_id: IdMap.getId("region1_provider1"),
|
||||
}
|
||||
)
|
||||
|
||||
expect(
|
||||
PaymentProviderServiceMock.refreshSessionNew
|
||||
).toHaveBeenCalledTimes(1)
|
||||
expect(DefaultProviderMock.deletePayment).toHaveBeenCalledTimes(1)
|
||||
expect(PaymentProviderServiceMock.createSessionNew).toHaveBeenCalledTimes(
|
||||
1
|
||||
)
|
||||
})
|
||||
|
||||
it("should fail to refresh a payment session if the amount is different", async () => {
|
||||
const sess = paymentCollectionService.refreshPaymentSession(
|
||||
IdMap.getId("payment-collection-session"),
|
||||
IdMap.getId("payCol_session1"),
|
||||
{
|
||||
customer_id: "customer1",
|
||||
amount: 80,
|
||||
provider_id: IdMap.getId("region1_provider1"),
|
||||
}
|
||||
)
|
||||
|
||||
expect(sess).rejects.toThrow(
|
||||
"The amount has to be the same as the existing payment session"
|
||||
)
|
||||
expect(PaymentProviderServiceMock.refreshSessionNew).toBeCalledTimes(0)
|
||||
expect(DefaultProviderMock.deletePayment).toBeCalledTimes(0)
|
||||
expect(PaymentProviderServiceMock.createSessionNew).toBeCalledTimes(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe("Authorize Payments", () => {
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
it("should mark as paid if amount is 0", async () => {
|
||||
await paymentCollectionService.authorize(
|
||||
IdMap.getId("payment-collection-zero")
|
||||
)
|
||||
|
||||
expect(PaymentProviderServiceMock.authorizePayment).toHaveBeenCalledTimes(
|
||||
0
|
||||
)
|
||||
})
|
||||
|
||||
it("should reject payment collection without payment sessions", async () => {
|
||||
const ret = paymentCollectionService.authorize(
|
||||
IdMap.getId("payment-collection-no-session")
|
||||
)
|
||||
|
||||
expect(ret).rejects.toThrowError(
|
||||
new Error(
|
||||
"You cannot complete a Payment Collection without a payment session."
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
it("should call authorizePayments for all sessions", async () => {
|
||||
await paymentCollectionService.authorize(
|
||||
IdMap.getId("payment-collection-not-authorized")
|
||||
)
|
||||
|
||||
expect(PaymentProviderServiceMock.authorizePayment).toHaveBeenCalledTimes(
|
||||
2
|
||||
)
|
||||
expect(PaymentProviderServiceMock.createPaymentNew).toHaveBeenCalledTimes(
|
||||
2
|
||||
)
|
||||
expect(EventBusServiceMock.emit).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it("should skip authorized sessions - partially authorized", async () => {
|
||||
await paymentCollectionService.authorize(
|
||||
IdMap.getId("payment-collection-partial")
|
||||
)
|
||||
|
||||
expect(PaymentProviderServiceMock.authorizePayment).toHaveBeenCalledTimes(
|
||||
1
|
||||
)
|
||||
expect(PaymentProviderServiceMock.createPaymentNew).toHaveBeenCalledTimes(
|
||||
1
|
||||
)
|
||||
expect(EventBusServiceMock.emit).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it("should skip authorized sessions - fully authorized", async () => {
|
||||
await paymentCollectionService.authorize(
|
||||
IdMap.getId("payment-collection-fully")
|
||||
)
|
||||
|
||||
expect(PaymentProviderServiceMock.authorizePayment).toHaveBeenCalledTimes(
|
||||
0
|
||||
)
|
||||
expect(PaymentProviderServiceMock.createPaymentNew).toHaveBeenCalledTimes(
|
||||
0
|
||||
)
|
||||
expect(EventBusServiceMock.emit).toHaveBeenCalledTimes(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe("Capture Payments", () => {
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
it("should throw error if the status is not authorized", async () => {
|
||||
paymentCollectionRepository.getPaymentCollectionIdByPaymentId = jest
|
||||
.fn()
|
||||
.mockReturnValue(Promise.resolve(notAuthorizedSample))
|
||||
|
||||
PaymentProviderServiceMock.capturePayment = jest
|
||||
.fn()
|
||||
.mockReturnValue(Promise.resolve())
|
||||
|
||||
const ret = paymentCollectionService.capture(
|
||||
IdMap.getId("payment-collection-not-authorized")
|
||||
)
|
||||
|
||||
expect(ret).rejects.toThrowError(
|
||||
new Error(
|
||||
`A Payment Collection with status ${PaymentCollectionStatus.PARTIALLY_AUTHORIZED} cannot capture payment`
|
||||
)
|
||||
)
|
||||
|
||||
expect(PaymentProviderServiceMock.capturePayment).toBeCalledTimes(0)
|
||||
})
|
||||
|
||||
it("should emit PAYMENT_CAPTURE_FAILED if payment capture has failed", async () => {
|
||||
paymentCollectionRepository.getPaymentCollectionIdByPaymentId = jest
|
||||
.fn()
|
||||
.mockReturnValue(Promise.resolve(fullyAuthorizedSample))
|
||||
|
||||
PaymentProviderServiceMock.retrievePayment = jest.fn().mockReturnValue(
|
||||
Promise.resolve({
|
||||
id: IdMap.getId("payment-123"),
|
||||
amount: 35000,
|
||||
captured_amount: 0,
|
||||
})
|
||||
)
|
||||
|
||||
PaymentProviderServiceMock.capturePayment = jest
|
||||
.fn()
|
||||
.mockRejectedValue("capture failed")
|
||||
|
||||
const ret = paymentCollectionService.capture(IdMap.getId("payment-123"))
|
||||
|
||||
expect(ret).rejects.toThrowError(
|
||||
new Error(`Failed to capture Payment ${IdMap.getId("payment-123")}`)
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,19 +1,37 @@
|
||||
import { DeepPartial, EntityManager, IsNull } from "typeorm"
|
||||
import { DeepPartial, EntityManager, Equal } from "typeorm"
|
||||
import { MedusaError } from "medusa-core-utils"
|
||||
|
||||
import { FindConfig } from "../types/common"
|
||||
import { buildQuery, isDefined, setMetadata } from "../utils"
|
||||
import { PaymentCollectionRepository } from "../repositories/payment-collection"
|
||||
import { PaymentCollection, PaymentCollectionStatus } from "../models"
|
||||
import {
|
||||
Customer,
|
||||
Payment,
|
||||
PaymentCollection,
|
||||
PaymentCollectionStatus,
|
||||
PaymentSession,
|
||||
PaymentSessionStatus,
|
||||
Refund,
|
||||
} from "../models"
|
||||
import { TransactionBaseService } from "../interfaces"
|
||||
import { EventBusService } from "./index"
|
||||
import {
|
||||
CustomerService,
|
||||
EventBusService,
|
||||
PaymentProviderService,
|
||||
} from "./index"
|
||||
|
||||
import { CreatePaymentCollectionInput } from "../types/payment-collection"
|
||||
import {
|
||||
CreatePaymentCollectionInput,
|
||||
PaymentCollectionSessionInput,
|
||||
PaymentProviderDataInput,
|
||||
} from "../types/payment-collection"
|
||||
|
||||
type InjectedDependencies = {
|
||||
manager: EntityManager
|
||||
paymentCollectionRepository: typeof PaymentCollectionRepository
|
||||
paymentProviderService: PaymentProviderService
|
||||
eventBusService: EventBusService
|
||||
customerService: CustomerService
|
||||
}
|
||||
|
||||
export default class PaymentCollectionService extends TransactionBaseService {
|
||||
@@ -21,17 +39,26 @@ export default class PaymentCollectionService extends TransactionBaseService {
|
||||
CREATED: "payment-collection.created",
|
||||
UPDATED: "payment-collection.updated",
|
||||
DELETED: "payment-collection.deleted",
|
||||
PAYMENT_AUTHORIZED: "payment-collection.payment_authorized",
|
||||
PAYMENT_CAPTURED: "payment-collection.payment_captured",
|
||||
PAYMENT_CAPTURE_FAILED: "payment-collection.payment_capture_failed",
|
||||
REFUND_CREATED: "payment-collection.payment_refund_created",
|
||||
REFUND_FAILED: "payment-collection.payment_refund_failed",
|
||||
}
|
||||
|
||||
protected readonly manager_: EntityManager
|
||||
protected transactionManager_: EntityManager | undefined
|
||||
protected readonly eventBusService_: EventBusService
|
||||
protected readonly paymentProviderService_: PaymentProviderService
|
||||
protected readonly customerService_: CustomerService
|
||||
// eslint-disable-next-line max-len
|
||||
protected readonly paymentCollectionRepository_: typeof PaymentCollectionRepository
|
||||
|
||||
constructor({
|
||||
manager,
|
||||
paymentCollectionRepository,
|
||||
paymentProviderService,
|
||||
customerService,
|
||||
eventBusService,
|
||||
}: InjectedDependencies) {
|
||||
// eslint-disable-next-line prefer-rest-params
|
||||
@@ -39,7 +66,9 @@ export default class PaymentCollectionService extends TransactionBaseService {
|
||||
|
||||
this.manager_ = manager
|
||||
this.paymentCollectionRepository_ = paymentCollectionRepository
|
||||
this.paymentProviderService_ = paymentProviderService
|
||||
this.eventBusService_ = eventBusService
|
||||
this.customerService_ = customerService
|
||||
}
|
||||
|
||||
async retrieve(
|
||||
@@ -52,24 +81,24 @@ export default class PaymentCollectionService extends TransactionBaseService {
|
||||
)
|
||||
|
||||
const query = buildQuery({ id: paymentCollectionId }, config)
|
||||
const paymentCollection = await paymentCollectionRepository.findOne(query)
|
||||
|
||||
if (!paymentCollection) {
|
||||
const paymentCollection = await paymentCollectionRepository.find(query)
|
||||
|
||||
if (!paymentCollection.length) {
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.NOT_FOUND,
|
||||
`Payment collection with id ${paymentCollectionId} was not found`
|
||||
)
|
||||
}
|
||||
|
||||
return paymentCollection
|
||||
return paymentCollection[0]
|
||||
}
|
||||
|
||||
async create(data: CreatePaymentCollectionInput): Promise<PaymentCollection> {
|
||||
return await this.atomicPhase_(async (transactionManager) => {
|
||||
const paymentCollectionRepository =
|
||||
transactionManager.getCustomRepository(
|
||||
this.paymentCollectionRepository_
|
||||
)
|
||||
return await this.atomicPhase_(async (manager) => {
|
||||
const paymentCollectionRepository = manager.getCustomRepository(
|
||||
this.paymentCollectionRepository_
|
||||
)
|
||||
|
||||
const paymentCollectionToCreate = paymentCollectionRepository.create({
|
||||
region_id: data.region_id,
|
||||
@@ -87,7 +116,7 @@ export default class PaymentCollectionService extends TransactionBaseService {
|
||||
)
|
||||
|
||||
await this.eventBusService_
|
||||
.withTransaction(transactionManager)
|
||||
.withTransaction(manager)
|
||||
.emit(PaymentCollectionService.Events.CREATED, paymentCollection)
|
||||
|
||||
return paymentCollection
|
||||
@@ -160,4 +189,493 @@ export default class PaymentCollectionService extends TransactionBaseService {
|
||||
return paymentCollection
|
||||
})
|
||||
}
|
||||
|
||||
private isValidTotalAmount(
|
||||
total: number,
|
||||
sessionsInput: PaymentCollectionSessionInput[]
|
||||
): boolean {
|
||||
const sum = sessionsInput.reduce((cur, sess) => cur + sess.amount, 0)
|
||||
return total === sum
|
||||
}
|
||||
|
||||
async setPaymentSessions(
|
||||
paymentCollectionId: string,
|
||||
sessions: PaymentCollectionSessionInput[] | PaymentCollectionSessionInput
|
||||
): Promise<PaymentCollection> {
|
||||
let sessionsInput = Array.isArray(sessions) ? sessions : [sessions]
|
||||
|
||||
return await this.atomicPhase_(async (manager: EntityManager) => {
|
||||
const paymentCollectionRepository = manager.getCustomRepository(
|
||||
this.paymentCollectionRepository_
|
||||
)
|
||||
|
||||
const payCol = await this.retrieve(paymentCollectionId, {
|
||||
relations: ["region", "region.payment_providers", "payment_sessions"],
|
||||
})
|
||||
|
||||
if (payCol.status !== PaymentCollectionStatus.NOT_PAID) {
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.NOT_ALLOWED,
|
||||
`Cannot set payment sessions for a payment collection with status ${payCol.status}`
|
||||
)
|
||||
}
|
||||
|
||||
sessionsInput = sessionsInput.filter((session) => {
|
||||
return !!payCol.region.payment_providers.find(({ id }) => {
|
||||
return id === session.provider_id
|
||||
})
|
||||
})
|
||||
|
||||
if (!this.isValidTotalAmount(payCol.amount, sessionsInput)) {
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.UNEXPECTED_STATE,
|
||||
`The sum of sessions is not equal to ${payCol.amount} on Payment Collection`
|
||||
)
|
||||
}
|
||||
|
||||
let customer: Customer | undefined = undefined
|
||||
|
||||
const selectedSessionIds: string[] = []
|
||||
const paymentSessions: PaymentSession[] = []
|
||||
|
||||
for (const session of sessionsInput) {
|
||||
if (!customer) {
|
||||
customer = await this.customerService_
|
||||
.withTransaction(manager)
|
||||
.retrieve(session.customer_id, {
|
||||
select: ["id", "email", "metadata"],
|
||||
})
|
||||
}
|
||||
|
||||
const existingSession = payCol.payment_sessions?.find(
|
||||
(sess) => session.session_id === sess?.id
|
||||
)
|
||||
|
||||
const inputData: PaymentProviderDataInput = {
|
||||
resource_id: payCol.id,
|
||||
currency_code: payCol.currency_code,
|
||||
amount: session.amount,
|
||||
provider_id: session.provider_id,
|
||||
customer,
|
||||
metadata: {
|
||||
resource_id: payCol.id,
|
||||
},
|
||||
}
|
||||
|
||||
if (existingSession) {
|
||||
const paymentSession = await this.paymentProviderService_
|
||||
.withTransaction(manager)
|
||||
.updateSessionNew(existingSession, inputData)
|
||||
|
||||
selectedSessionIds.push(existingSession.id)
|
||||
paymentSessions.push(paymentSession)
|
||||
} else {
|
||||
const paymentSession = await this.paymentProviderService_
|
||||
.withTransaction(manager)
|
||||
.createSessionNew(inputData)
|
||||
|
||||
selectedSessionIds.push(paymentSession.id)
|
||||
paymentSessions.push(paymentSession)
|
||||
}
|
||||
}
|
||||
|
||||
if (payCol.payment_sessions?.length) {
|
||||
const removeIds: string[] = payCol.payment_sessions
|
||||
.map((sess) => sess.id)
|
||||
.filter((id) => !selectedSessionIds.includes(id))
|
||||
|
||||
if (removeIds.length) {
|
||||
await paymentCollectionRepository.deleteMultiple(removeIds)
|
||||
}
|
||||
}
|
||||
|
||||
payCol.payment_sessions = paymentSessions
|
||||
|
||||
return await paymentCollectionRepository.save(payCol)
|
||||
})
|
||||
}
|
||||
|
||||
async refreshPaymentSession(
|
||||
paymentCollectionId: string,
|
||||
sessionId: string,
|
||||
sessionInput: PaymentCollectionSessionInput
|
||||
): Promise<PaymentSession> {
|
||||
return await this.atomicPhase_(async (manager: EntityManager) => {
|
||||
const paymentCollectionRepository = manager.getCustomRepository(
|
||||
this.paymentCollectionRepository_
|
||||
)
|
||||
|
||||
const payCol =
|
||||
await paymentCollectionRepository.getPaymentCollectionIdBySessionId(
|
||||
sessionId,
|
||||
{
|
||||
relations: [
|
||||
"region",
|
||||
"region.payment_providers",
|
||||
"payment_sessions",
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
if (paymentCollectionId !== payCol.id) {
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.NOT_ALLOWED,
|
||||
`Payment Session ${sessionId} does not belong to Payment Collection ${paymentCollectionId}`
|
||||
)
|
||||
}
|
||||
|
||||
const session = payCol.payment_sessions.find(
|
||||
(sess) => sessionId === sess?.id
|
||||
)
|
||||
|
||||
if (session?.amount !== sessionInput.amount) {
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.NOT_ALLOWED,
|
||||
"The amount has to be the same as the existing payment session"
|
||||
)
|
||||
}
|
||||
|
||||
const customer = await this.customerService_
|
||||
.withTransaction(manager)
|
||||
.retrieve(sessionInput.customer_id, {
|
||||
select: ["id", "email", "metadata"],
|
||||
})
|
||||
|
||||
const inputData: PaymentProviderDataInput = {
|
||||
resource_id: payCol.id,
|
||||
currency_code: payCol.currency_code,
|
||||
amount: session.amount,
|
||||
provider_id: session.provider_id,
|
||||
customer,
|
||||
}
|
||||
|
||||
const sessionRefreshed = await this.paymentProviderService_
|
||||
.withTransaction(manager)
|
||||
.refreshSessionNew(session, inputData)
|
||||
|
||||
payCol.payment_sessions = payCol.payment_sessions.map((sess) => {
|
||||
if (sess.id === sessionId) {
|
||||
return sessionRefreshed
|
||||
}
|
||||
return sess
|
||||
})
|
||||
|
||||
if (session.payment_authorized_at) {
|
||||
payCol.authorized_amount -= session.amount
|
||||
}
|
||||
|
||||
await paymentCollectionRepository.save(payCol)
|
||||
|
||||
return sessionRefreshed
|
||||
})
|
||||
}
|
||||
|
||||
async authorize(
|
||||
paymentCollectionId: string,
|
||||
context: Record<string, unknown> = {}
|
||||
): Promise<PaymentCollection> {
|
||||
return await this.atomicPhase_(async (manager: EntityManager) => {
|
||||
const paymentCollectionRepository = manager.getCustomRepository(
|
||||
this.paymentCollectionRepository_
|
||||
)
|
||||
|
||||
const payCol = await this.retrieve(paymentCollectionId, {
|
||||
relations: ["payment_sessions", "payments"],
|
||||
})
|
||||
|
||||
if (payCol.authorized_amount === payCol.amount) {
|
||||
return payCol
|
||||
}
|
||||
|
||||
// If cart total is 0, we don't perform anything payment related
|
||||
if (payCol.amount <= 0) {
|
||||
payCol.authorized_amount = 0
|
||||
return await paymentCollectionRepository.save(payCol)
|
||||
}
|
||||
|
||||
if (!payCol.payment_sessions?.length) {
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.NOT_ALLOWED,
|
||||
"You cannot complete a Payment Collection without a payment session."
|
||||
)
|
||||
}
|
||||
|
||||
let authorizedAmount = 0
|
||||
for (const session of payCol.payment_sessions) {
|
||||
if (session.payment_authorized_at) {
|
||||
authorizedAmount += session.amount
|
||||
continue
|
||||
}
|
||||
|
||||
const auth = await this.paymentProviderService_
|
||||
.withTransaction(manager)
|
||||
.authorizePayment(session, context)
|
||||
|
||||
if (auth?.status === PaymentSessionStatus.AUTHORIZED) {
|
||||
authorizedAmount += session.amount
|
||||
|
||||
const inputData: Omit<PaymentProviderDataInput, "customer"> = {
|
||||
amount: session.amount,
|
||||
currency_code: payCol.currency_code,
|
||||
provider_id: session.provider_id,
|
||||
resource_id: payCol.id,
|
||||
}
|
||||
|
||||
payCol.payments.push(
|
||||
await this.paymentProviderService_
|
||||
.withTransaction(manager)
|
||||
.createPaymentNew(inputData)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (authorizedAmount === 0) {
|
||||
payCol.status = PaymentCollectionStatus.AWAITING
|
||||
} else if (authorizedAmount < payCol.amount) {
|
||||
payCol.status = PaymentCollectionStatus.PARTIALLY_AUTHORIZED
|
||||
} else if (authorizedAmount === payCol.amount) {
|
||||
payCol.status = PaymentCollectionStatus.AUTHORIZED
|
||||
}
|
||||
|
||||
payCol.authorized_amount = authorizedAmount
|
||||
const payColCopy = await paymentCollectionRepository.save(payCol)
|
||||
|
||||
await this.eventBusService_
|
||||
.withTransaction(manager)
|
||||
.emit(PaymentCollectionService.Events.PAYMENT_AUTHORIZED, payColCopy)
|
||||
|
||||
return payCol
|
||||
})
|
||||
}
|
||||
|
||||
private async capturePayment(
|
||||
payCol: PaymentCollection,
|
||||
payment: Payment
|
||||
): Promise<Payment> {
|
||||
if (payment?.captured_at) {
|
||||
return payment
|
||||
}
|
||||
|
||||
return await this.atomicPhase_(async (manager: EntityManager) => {
|
||||
const allowedStatuses = [
|
||||
PaymentCollectionStatus.AUTHORIZED,
|
||||
PaymentCollectionStatus.PARTIALLY_CAPTURED,
|
||||
PaymentCollectionStatus.REQUIRES_ACTION,
|
||||
]
|
||||
|
||||
if (!allowedStatuses.includes(payCol.status)) {
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.NOT_ALLOWED,
|
||||
`A Payment Collection with status ${payCol.status} cannot capture payment`
|
||||
)
|
||||
}
|
||||
|
||||
let captureError: Error | null = null
|
||||
const capturedPayment = await this.paymentProviderService_
|
||||
.withTransaction(manager)
|
||||
.capturePayment(payment)
|
||||
.catch((err) => {
|
||||
captureError = err
|
||||
})
|
||||
|
||||
payCol.captured_amount = payCol.captured_amount ?? 0
|
||||
if (capturedPayment) {
|
||||
payCol.captured_amount += payment.amount
|
||||
}
|
||||
|
||||
if (payCol.captured_amount === 0) {
|
||||
payCol.status = PaymentCollectionStatus.REQUIRES_ACTION
|
||||
} else if (payCol.captured_amount === payCol.amount) {
|
||||
payCol.status = PaymentCollectionStatus.CAPTURED
|
||||
} else {
|
||||
payCol.status = PaymentCollectionStatus.PARTIALLY_CAPTURED
|
||||
}
|
||||
|
||||
const paymentCollectionRepository = manager.getCustomRepository(
|
||||
this.paymentCollectionRepository_
|
||||
)
|
||||
|
||||
await paymentCollectionRepository.save(payCol)
|
||||
|
||||
if (!capturedPayment) {
|
||||
await this.eventBusService_
|
||||
.withTransaction(manager)
|
||||
.emit(PaymentCollectionService.Events.PAYMENT_CAPTURE_FAILED, {
|
||||
...payment,
|
||||
error: captureError,
|
||||
})
|
||||
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.UNEXPECTED_STATE,
|
||||
`Failed to capture Payment ${payment.id}`
|
||||
)
|
||||
}
|
||||
|
||||
await this.eventBusService_
|
||||
.withTransaction(manager)
|
||||
.emit(PaymentCollectionService.Events.PAYMENT_CAPTURED, capturedPayment)
|
||||
|
||||
return capturedPayment
|
||||
})
|
||||
}
|
||||
|
||||
async capture(paymentId: string): Promise<Payment> {
|
||||
const manager = this.transactionManager_ ?? this.manager_
|
||||
const paymentCollectionRepository = manager.getCustomRepository(
|
||||
this.paymentCollectionRepository_
|
||||
)
|
||||
|
||||
const payCol =
|
||||
await paymentCollectionRepository.getPaymentCollectionIdByPaymentId(
|
||||
paymentId,
|
||||
{
|
||||
relations: ["payments"],
|
||||
}
|
||||
)
|
||||
|
||||
const payment = payCol.payments.find((payment) => paymentId === payment?.id)
|
||||
|
||||
return await this.capturePayment(payCol, payment!)
|
||||
}
|
||||
|
||||
async captureAll(paymentCollectionId: string): Promise<Payment[]> {
|
||||
const payCol = await this.retrieve(paymentCollectionId, {
|
||||
relations: ["payments"],
|
||||
})
|
||||
|
||||
const allPayments: Payment[] = []
|
||||
for (const payment of payCol.payments) {
|
||||
const captured = await this.capturePayment(payCol, payment).catch(
|
||||
() => void 0
|
||||
)
|
||||
|
||||
if (captured) {
|
||||
allPayments.push(captured)
|
||||
}
|
||||
}
|
||||
|
||||
return allPayments
|
||||
}
|
||||
|
||||
private async refundPayment(
|
||||
payCol: PaymentCollection,
|
||||
payment: Payment,
|
||||
amount: number,
|
||||
reason: string,
|
||||
note?: string
|
||||
): Promise<Refund> {
|
||||
return await this.atomicPhase_(async (manager: EntityManager) => {
|
||||
if (!payment.captured_at) {
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.NOT_ALLOWED,
|
||||
`Payment ${payment.id} is not captured`
|
||||
)
|
||||
}
|
||||
|
||||
const refundable = payment.amount - payment.amount_refunded
|
||||
if (amount > refundable) {
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.NOT_ALLOWED,
|
||||
`Only ${refundable} can be refunded from Payment ${payment.id}`
|
||||
)
|
||||
}
|
||||
|
||||
let refundError: Error | null = null
|
||||
const refund = await this.paymentProviderService_
|
||||
.withTransaction(manager)
|
||||
.refundFromPayment(payment, amount, reason, note)
|
||||
.catch((err) => {
|
||||
refundError = err
|
||||
})
|
||||
|
||||
payCol.refunded_amount = payCol.refunded_amount ?? 0
|
||||
if (refund) {
|
||||
payCol.refunded_amount += refund.amount
|
||||
}
|
||||
|
||||
if (payCol.refunded_amount === 0) {
|
||||
payCol.status = PaymentCollectionStatus.REQUIRES_ACTION
|
||||
} else if (payCol.refunded_amount === payCol.amount) {
|
||||
payCol.status = PaymentCollectionStatus.REFUNDED
|
||||
} else {
|
||||
payCol.status = PaymentCollectionStatus.PARTIALLY_REFUNDED
|
||||
}
|
||||
|
||||
const paymentCollectionRepository = manager.getCustomRepository(
|
||||
this.paymentCollectionRepository_
|
||||
)
|
||||
|
||||
await paymentCollectionRepository.save(payCol)
|
||||
|
||||
if (!refund) {
|
||||
await this.eventBusService_
|
||||
.withTransaction(manager)
|
||||
.emit(PaymentCollectionService.Events.REFUND_FAILED, {
|
||||
...payment,
|
||||
error: refundError,
|
||||
})
|
||||
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.UNEXPECTED_STATE,
|
||||
`Failed to refund Payment ${payment.id}`
|
||||
)
|
||||
}
|
||||
|
||||
await this.eventBusService_
|
||||
.withTransaction(manager)
|
||||
.emit(PaymentCollectionService.Events.REFUND_CREATED, refund)
|
||||
|
||||
return refund
|
||||
})
|
||||
}
|
||||
|
||||
async refund(
|
||||
paymentId: string,
|
||||
amount: number,
|
||||
reason: string,
|
||||
note?: string
|
||||
): Promise<Refund> {
|
||||
const manager = this.transactionManager_ ?? this.manager_
|
||||
const paymentCollectionRepository = manager.getCustomRepository(
|
||||
this.paymentCollectionRepository_
|
||||
)
|
||||
|
||||
const payCol =
|
||||
await paymentCollectionRepository.getPaymentCollectionIdByPaymentId(
|
||||
paymentId
|
||||
)
|
||||
|
||||
const payment = await this.paymentProviderService_.retrievePayment(
|
||||
paymentId
|
||||
)
|
||||
|
||||
return await this.refundPayment(payCol, payment, amount, reason, note)
|
||||
}
|
||||
|
||||
async refundAll(
|
||||
paymentCollectionId: string,
|
||||
reason: string,
|
||||
note?: string
|
||||
): Promise<Refund[]> {
|
||||
const payCol = await this.retrieve(paymentCollectionId, {
|
||||
relations: ["payments"],
|
||||
})
|
||||
|
||||
const allRefunds: Refund[] = []
|
||||
for (const payment of payCol.payments) {
|
||||
const refunded = await this.refundPayment(
|
||||
payCol,
|
||||
payment,
|
||||
payment.amount,
|
||||
reason,
|
||||
note
|
||||
).catch(() => void 0)
|
||||
|
||||
if (refunded) {
|
||||
allRefunds.push(refunded)
|
||||
}
|
||||
}
|
||||
|
||||
return allRefunds
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
PaymentSessionStatus,
|
||||
Refund,
|
||||
} from "../models"
|
||||
import { PaymentProviderDataInput } from "../types/payment-collection"
|
||||
|
||||
type PaymentProviderKey = `pp_${string}` | "systemPaymentProviderService"
|
||||
type InjectedDependencies = {
|
||||
@@ -179,6 +180,33 @@ export default class PaymentProviderService extends TransactionBaseService {
|
||||
})
|
||||
}
|
||||
|
||||
async createSessionNew(
|
||||
sessionInput: PaymentProviderDataInput
|
||||
): Promise<PaymentSession> {
|
||||
return await this.atomicPhase_(async (transactionManager) => {
|
||||
const provider: AbstractPaymentService = this.retrieveProvider(
|
||||
sessionInput.provider_id
|
||||
)
|
||||
const sessionData = await provider
|
||||
.withTransaction(transactionManager)
|
||||
.createPaymentNew(sessionInput)
|
||||
|
||||
const sessionRepo = transactionManager.getCustomRepository(
|
||||
this.paymentSessionRepository_
|
||||
)
|
||||
|
||||
const toCreate = {
|
||||
provider_id: sessionInput.provider_id,
|
||||
data: sessionData,
|
||||
status: "pending",
|
||||
amount: sessionInput.amount,
|
||||
} as PaymentSession
|
||||
|
||||
const created = sessionRepo.create(toCreate)
|
||||
return await sessionRepo.save(created)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Refreshes a payment session with the given provider.
|
||||
* This means, that we delete the current one and create a new.
|
||||
@@ -219,6 +247,26 @@ export default class PaymentProviderService extends TransactionBaseService {
|
||||
})
|
||||
}
|
||||
|
||||
async refreshSessionNew(
|
||||
paymentSession: PaymentSession,
|
||||
sessionInput: PaymentProviderDataInput
|
||||
): Promise<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)
|
||||
|
||||
return await this.createSessionNew(sessionInput)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates an existing payment session.
|
||||
* @param paymentSession - the payment session object to
|
||||
@@ -240,7 +288,29 @@ export default class PaymentProviderService extends TransactionBaseService {
|
||||
const sessionRepo = transactionManager.getCustomRepository(
|
||||
this.paymentSessionRepository_
|
||||
)
|
||||
return sessionRepo.save(session)
|
||||
return await sessionRepo.save(session)
|
||||
})
|
||||
}
|
||||
|
||||
async updateSessionNew(
|
||||
paymentSession: PaymentSession,
|
||||
sessionInput: PaymentProviderDataInput
|
||||
): Promise<PaymentSession> {
|
||||
return await this.atomicPhase_(async (transactionManager) => {
|
||||
const session = await this.retrieveSession(paymentSession.id)
|
||||
const provider = this.retrieveProvider(paymentSession.provider_id)
|
||||
|
||||
session.amount = sessionInput.amount
|
||||
paymentSession.data.amount = sessionInput.amount
|
||||
session.data = await provider
|
||||
.withTransaction(transactionManager)
|
||||
.updatePaymentNew(paymentSession.data, sessionInput)
|
||||
|
||||
const sessionRepo = transactionManager.getCustomRepository(
|
||||
this.paymentSessionRepository_
|
||||
)
|
||||
|
||||
return await sessionRepo.save(session)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -265,7 +335,7 @@ export default class PaymentProviderService extends TransactionBaseService {
|
||||
this.paymentSessionRepository_
|
||||
)
|
||||
|
||||
return sessionRepo.remove(session)
|
||||
return await sessionRepo.remove(session)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -321,7 +391,34 @@ export default class PaymentProviderService extends TransactionBaseService {
|
||||
cart_id: cart.id,
|
||||
})
|
||||
|
||||
return paymentRepo.save(created)
|
||||
return await paymentRepo.save(created)
|
||||
})
|
||||
}
|
||||
|
||||
async createPaymentNew(
|
||||
paymentInput: Omit<PaymentProviderDataInput, "customer">
|
||||
): Promise<Payment> {
|
||||
return await this.atomicPhase_(async (transactionManager) => {
|
||||
const { payment_session, currency_code, amount, provider_id } =
|
||||
paymentInput
|
||||
|
||||
const provider = this.retrieveProvider(provider_id)
|
||||
const paymentData = await provider
|
||||
.withTransaction(transactionManager)
|
||||
.getPaymentData(payment_session)
|
||||
|
||||
const paymentRepo = transactionManager.getCustomRepository(
|
||||
this.paymentRepository_
|
||||
)
|
||||
|
||||
const created = paymentRepo.create({
|
||||
provider_id,
|
||||
amount,
|
||||
currency_code,
|
||||
data: paymentData,
|
||||
})
|
||||
|
||||
return await paymentRepo.save(created)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -343,7 +440,7 @@ export default class PaymentProviderService extends TransactionBaseService {
|
||||
const payRepo = transactionManager.getCustomRepository(
|
||||
this.paymentRepository_
|
||||
)
|
||||
return payRepo.save(payment)
|
||||
return await payRepo.save(payment)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -371,7 +468,7 @@ export default class PaymentProviderService extends TransactionBaseService {
|
||||
const sessionRepo = transactionManager.getCustomRepository(
|
||||
this.paymentSessionRepository_
|
||||
)
|
||||
return sessionRepo.save(session)
|
||||
return await sessionRepo.save(session)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -392,7 +489,7 @@ export default class PaymentProviderService extends TransactionBaseService {
|
||||
const sessionRepo = transactionManager.getCustomRepository(
|
||||
this.paymentSessionRepository_
|
||||
)
|
||||
return sessionRepo.save(session)
|
||||
return await sessionRepo.save(session)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -437,7 +534,7 @@ export default class PaymentProviderService extends TransactionBaseService {
|
||||
const paymentRepo = transactionManager.getCustomRepository(
|
||||
this.paymentRepository_
|
||||
)
|
||||
return paymentRepo.save(payment)
|
||||
return await paymentRepo.save(payment)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -521,7 +618,47 @@ export default class PaymentProviderService extends TransactionBaseService {
|
||||
}
|
||||
|
||||
const created = refundRepo.create(toCreate)
|
||||
return refundRepo.save(created)
|
||||
return await refundRepo.save(created)
|
||||
})
|
||||
}
|
||||
|
||||
async refundFromPayment(
|
||||
payment: Payment,
|
||||
amount: number,
|
||||
reason: string,
|
||||
note?: string
|
||||
): Promise<Refund> {
|
||||
return await this.atomicPhase_(async (manager) => {
|
||||
const refundable = payment.amount - payment.amount_refunded
|
||||
|
||||
if (refundable < amount) {
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.NOT_ALLOWED,
|
||||
"Refund amount is higher that the refundable amount"
|
||||
)
|
||||
}
|
||||
|
||||
const provider = this.retrieveProvider(payment.provider_id)
|
||||
payment.data = await provider
|
||||
.withTransaction(manager)
|
||||
.refundPayment(payment, amount)
|
||||
|
||||
payment.amount_refunded += amount
|
||||
|
||||
const paymentRepo = manager.getCustomRepository(this.paymentRepository_)
|
||||
await paymentRepo.save(payment)
|
||||
|
||||
const refundRepo = manager.getCustomRepository(this.refundRepository_)
|
||||
|
||||
const toCreate = {
|
||||
payment_id: payment.id,
|
||||
amount,
|
||||
reason,
|
||||
note,
|
||||
}
|
||||
|
||||
const created = refundRepo.create(toCreate)
|
||||
return await refundRepo.save(created)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { PaymentCollection, PaymentCollectionType } from "../models"
|
||||
import {
|
||||
Cart,
|
||||
Customer,
|
||||
PaymentCollection,
|
||||
PaymentCollectionType,
|
||||
PaymentSession,
|
||||
} from "../models"
|
||||
|
||||
export type CreatePaymentCollectionInput = {
|
||||
region_id: string
|
||||
@@ -10,6 +16,25 @@ export type CreatePaymentCollectionInput = {
|
||||
description?: string
|
||||
}
|
||||
|
||||
export type PaymentCollectionSessionInput = {
|
||||
provider_id: string
|
||||
amount: number
|
||||
session_id?: string
|
||||
customer_id: string
|
||||
}
|
||||
|
||||
export type PaymentProviderDataInput = {
|
||||
resource_id: string
|
||||
customer: Partial<Customer>
|
||||
currency_code: string
|
||||
provider_id: string
|
||||
amount: number
|
||||
payment_session?: PaymentSession
|
||||
payment_description?: string
|
||||
cart_id?: string
|
||||
cart?: Cart
|
||||
metadata?: any
|
||||
}
|
||||
export const defaultPaymentCollectionRelations = [
|
||||
"region",
|
||||
"region.payment_providers",
|
||||
|
||||
Reference in new issue
Block a user