feat(medusa): Update payment session management (#2937)

This commit is contained in:
Adrien de Peretti
2023-01-10 16:33:24 +01:00
committed by GitHub
parent 71fa60892c
commit cac81749ea
11 changed files with 464 additions and 241 deletions

View File

@@ -0,0 +1,5 @@
---
"@medusajs/medusa": patch
---
chore: Update cart payment session management

View File

@@ -1,6 +1,6 @@
import { EntityManager } from "typeorm"
import { AbstractCartCompletionStrategy } from "../../../../interfaces"
import { IdempotencyKey } from "../../../../models/idempotency-key"
import { IdempotencyKey } from "../../../../models"
import { IdempotencyKeyService } from "../../../../services"
/**

View File

@@ -195,11 +195,12 @@ export abstract class AbstractPaymentService
* @param paymentSessionData
* @param context The type of this argument is meant to be temporary and once the previous method signature
* will be removed, the type will only be PaymentContext instead of Cart & PaymentContext
* @return it return either a PaymentSessionResponse or PaymentSessionResponse["session_data"] to maintain backward compatibility
*/
public abstract updatePayment(
paymentSessionData: PaymentSessionData,
context: Cart & PaymentContext
): Promise<PaymentSessionResponse>
): Promise<PaymentSessionResponse | PaymentSessionResponse["session_data"]>
/**
* @deprecated use updatePayment(paymentSessionData: PaymentSessionData, context: Cart & PaymentContext): Promise<PaymentSessionResponse> instead

View File

@@ -0,0 +1,24 @@
import { MigrationInterface, QueryRunner } from "typeorm"
export class PaymentSessionIsInitiated1672906846560 implements MigrationInterface {
name = "paymentSessionIsInitiated1672906846560"
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE payment_session ADD COLUMN is_initiated BOOLEAN NOT NULL DEFAULT false
`)
// Set is_initiated to true if there is more that 0 key in the data. We assume that if data contains any key
// A payment has been initiated to the payment provider
await queryRunner.query(`
UPDATE payment_session SET is_initiated = true WHERE (
SELECT coalesce(json_array_length(json_agg(keys)), 0)
FROM jsonb_object_keys(data) AS keys (keys)
) > 0
`)
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE payment_session DROP COLUMN is_initiated`)
}
}

View File

@@ -1,12 +1,4 @@
import {
BeforeInsert,
Column,
Entity,
Index,
JoinColumn,
ManyToOne,
Unique,
} from "typeorm"
import { BeforeInsert, Column, Entity, Index, JoinColumn, ManyToOne, Unique, } from "typeorm"
import { BaseEntity } from "../interfaces"
import { Cart } from "./cart"
@@ -43,6 +35,9 @@ export class PaymentSession extends BaseEntity {
@Column({ type: "boolean", nullable: true })
is_selected: boolean | null
@Column({ type: "boolean", default: false })
is_initiated: boolean
@DbAwareColumn({ type: "enum", enum: PaymentSessionStatus })
status: string
@@ -97,6 +92,11 @@ export class PaymentSession extends BaseEntity {
* description: "A flag to indicate if the Payment Session has been selected as the method that will be used to complete the purchase."
* type: boolean
* example: true
* is_initiated:
* description: "A flag to indicate if a communication with the third party provider has been initiated."
* type: boolean
* example: true
* default: false
* status:
* description: "Indicates the status of the Payment Session. Will default to `pending`, and will eventually become `authorized`. Payment Sessions may have the status of `requires_more` to indicate that further actions are to be completed by the Customer."
* type: string

View File

@@ -7,6 +7,7 @@ import { ProductVariantInventoryServiceMock } from "../__mocks__/product-variant
import { LineItemAdjustmentServiceMock } from "../__mocks__/line-item-adjustment"
import { newTotalsServiceMock } from "../__mocks__/new-totals"
import { taxProviderServiceMock } from "../__mocks__/tax-provider"
import { PaymentSessionStatus } from "../../models"
const eventBusService = {
emit: jest.fn(),
@@ -1359,32 +1360,71 @@ describe("CartService", () => {
describe("setPaymentSession", () => {
const cartRepository = MockRepository({
findOneWithRelations: () => {
return Promise.resolve({
region: {
payment_providers: [
findOneWithRelations: (rels, q) => {
if (q.where.id === IdMap.getId("cartWithLine")) {
return Promise.resolve({
total: 100,
customer: {},
region: {
currency_code: "usd",
payment_providers: [
{
id: "test-provider",
},
],
},
items: [],
shipping_methods: [],
payment_sessions: [
{
id: "test-provider",
id: IdMap.getId("test-session"),
provider_id: "test-provider",
},
],
},
items: [],
shipping_methods: [],
payment_sessions: [
{
id: IdMap.getId("test-session"),
provider_id: "test-provider",
})
} else if (q.where.id === IdMap.getId("cartWithLine2")) {
return Promise.resolve({
total: 100,
customer: {},
region: {
currency_code: "usd",
payment_providers: [
{
id: "test-provider",
},
],
},
],
})
items: [],
shipping_methods: [],
payment_sessions: [
{
id: IdMap.getId("test-session"),
provider_id: "test-provider",
is_selected: true,
},
],
})
}
},
})
const paymentSessionRepository = MockRepository({})
const paymentProviderService = {
deleteSession: jest.fn(),
updateSession: jest.fn(),
createSession: jest.fn().mockImplementation(() => {
return { id: IdMap.getId("test-session") }
}),
withTransaction: function () {
return this
},
}
const cartService = new CartService({
manager: MockManager,
paymentSessionRepository,
paymentProviderService,
totalsService,
cartRepository,
eventBusService,
@@ -1397,22 +1437,63 @@ describe("CartService", () => {
jest.clearAllMocks()
})
it("successfully sets a payment method", async () => {
it("successfully sets a payment method and create it remotely", async () => {
const providerId = "test-provider"
await cartService.setPaymentSession(
IdMap.getId("cartWithLine"),
"test-provider"
providerId
)
expect(eventBusService.emit).toHaveBeenCalledTimes(1)
expect(eventBusService.emit).toHaveBeenCalledWith(
"cart.updated",
CartService.Events.UPDATED,
expect.any(Object)
)
expect(paymentSessionRepository.save).toHaveBeenCalledWith({
id: IdMap.getId("test-session"),
provider_id: "test-provider",
is_selected: true,
expect(paymentProviderService.createSession).toHaveBeenCalledWith({
cart: expect.any(Object),
customer: expect.any(Object),
amount: expect.any(Number),
currency_code: expect.any(String),
provider_id: providerId,
payment_session_id: IdMap.getId("test-session"),
})
expect(paymentSessionRepository.update).toHaveBeenCalledWith(
IdMap.getId("test-session"),
{
is_selected: true,
}
)
})
it("successfully sets a payment method and update it remotely", async () => {
const providerId = "test-provider"
await cartService.setPaymentSession(
IdMap.getId("cartWithLine2"),
providerId
)
expect(eventBusService.emit).toHaveBeenCalledTimes(1)
expect(eventBusService.emit).toHaveBeenCalledWith(
CartService.Events.UPDATED,
expect.any(Object)
)
expect(paymentProviderService.updateSession).toHaveBeenCalledWith(
expect.objectContaining({
id: IdMap.getId("test-session"),
}),
{
cart: expect.any(Object),
customer: expect.any(Object),
amount: expect.any(Number),
currency_code: expect.any(String),
provider_id: providerId,
payment_session_id: IdMap.getId("test-session"),
}
)
})
it("fails if the region does not contain the provider_id", async () => {
@@ -1423,13 +1504,16 @@ describe("CartService", () => {
})
describe("setPaymentSessions", () => {
const provider1Id = "provider_1"
const provider2Id = "provider_2"
const cart1 = {
total: 100,
items: [{ subtotal: 100 }],
shipping_methods: [],
payment_sessions: [],
region: {
payment_providers: [{ id: "provider_1" }, { id: "provider_2" }],
payment_providers: [{ id: provider1Id }, { id: provider2Id }],
},
}
@@ -1437,9 +1521,9 @@ describe("CartService", () => {
total: 100,
items: [],
shipping_methods: [],
payment_sessions: [{ provider_id: "provider_1" }],
payment_sessions: [{ provider_id: provider1Id }],
region: {
payment_providers: [{ id: "provider_1" }, { id: "provider_2" }],
payment_providers: [{ id: provider1Id }, { id: provider2Id }],
},
}
@@ -1448,11 +1532,11 @@ describe("CartService", () => {
items: [{ subtotal: 100 }],
shipping_methods: [{ subtotal: 100 }],
payment_sessions: [
{ provider_id: "provider_1" },
{ provider_id: provider1Id },
{ provider_id: "not_in_region" },
],
region: {
payment_providers: [{ id: "provider_1" }, { id: "provider_2" }],
payment_providers: [{ id: provider1Id }, { id: provider2Id }],
},
}
@@ -1461,22 +1545,24 @@ describe("CartService", () => {
items: [{ total: 0 }],
shipping_methods: [],
payment_sessions: [
{ provider_id: "provider_1" },
{ provider_id: "provider_2" },
{ provider_id: provider1Id },
{ provider_id: provider2Id },
],
region: {
payment_providers: [{ id: "provider_1" }, { id: "provider_2" }],
payment_providers: [{ id: provider1Id }, { id: provider2Id }],
},
}
const cart5 = {
total: -1,
total: 100,
items: [{ subtotal: 100 }],
shipping_methods: [],
payment_sessions: [
{ provider_id: "provider_1" },
{ provider_id: "provider_2" },
{ provider_id: provider1Id, is_initiated: true },
{ provider_id: provider2Id, is_selected: true },
],
region: {
payment_providers: [{ id: "provider_1" }, { id: "provider_2" }],
payment_providers: [{ id: provider1Id }, { id: provider2Id }],
},
}
@@ -1494,6 +1580,11 @@ describe("CartService", () => {
if (q.where.id === IdMap.getId("cart-negative")) {
return Promise.resolve(cart4)
}
if (
q.where.id === IdMap.getId("cartWithMixedSelectedInitiatedSessions")
) {
return Promise.resolve(cart5)
}
return Promise.resolve(cart1)
},
})
@@ -1507,8 +1598,11 @@ describe("CartService", () => {
},
}
const paymentSessionRepositoryMock = MockRepository({})
const cartService = new CartService({
manager: MockManager,
paymentSessionRepository: paymentSessionRepositoryMock,
totalsService,
cartRepository,
paymentProviderService,
@@ -1525,30 +1619,58 @@ describe("CartService", () => {
it("initializes payment sessions for each of the providers", async () => {
await cartService.setPaymentSessions(IdMap.getId("cartWithLine"))
expect(paymentProviderService.createSession).toHaveBeenCalledTimes(2)
expect(paymentProviderService.createSession).toHaveBeenCalledWith({
cart: cart1,
customer: cart1.customer,
expect(paymentSessionRepositoryMock.create).toHaveBeenCalledTimes(2)
expect(paymentSessionRepositoryMock.save).toHaveBeenCalledTimes(2)
expect(paymentSessionRepositoryMock.create).toHaveBeenCalledWith({
cart_id: IdMap.getId("cartWithLine"),
status: PaymentSessionStatus.PENDING,
amount: cart1.total,
currency_code: cart1.region.currency_code,
provider_id: "provider_1",
provider_id: provider1Id,
data: {},
})
expect(paymentProviderService.createSession).toHaveBeenCalledWith({
cart: cart1,
customer: cart1.customer,
expect(paymentSessionRepositoryMock.create).toHaveBeenCalledWith({
cart_id: IdMap.getId("cartWithLine"),
status: PaymentSessionStatus.PENDING,
amount: cart1.total,
currency_code: cart1.region.currency_code,
provider_id: "provider_2",
provider_id: provider2Id,
data: {},
})
})
it("delete or update payment sessions remotely depending if they are selected and/or initiated", async () => {
await cartService.setPaymentSessions(
IdMap.getId("cartWithMixedSelectedInitiatedSessions")
)
// Selected, update
expect(paymentProviderService.updateSession).toHaveBeenCalledTimes(1)
expect(paymentProviderService.updateSession).toHaveBeenCalledWith(
expect.any(Object),
expect.objectContaining({
provider_id: provider2Id,
})
)
// Not selected, but initiated, delete
expect(paymentProviderService.deleteSession).toHaveBeenCalledTimes(1)
expect(paymentProviderService.deleteSession).toHaveBeenCalledWith(
expect.objectContaining({
provider_id: provider1Id,
})
)
expect(paymentSessionRepositoryMock.save).toHaveBeenCalledTimes(1)
})
it("filters sessions not available in the region", async () => {
await cartService.setPaymentSessions(IdMap.getId("cart-to-filter"))
expect(paymentProviderService.createSession).toHaveBeenCalledTimes(1)
expect(paymentProviderService.updateSession).toHaveBeenCalledTimes(1)
expect(paymentProviderService.deleteSession).toHaveBeenCalledTimes(1)
expect(paymentProviderService.deleteSession).toHaveBeenCalledWith({
expect(paymentSessionRepositoryMock.create).toHaveBeenCalledTimes(1)
expect(paymentSessionRepositoryMock.save).toHaveBeenCalledTimes(2) // create and update
expect(paymentSessionRepositoryMock.delete).toHaveBeenCalledTimes(1)
expect(paymentSessionRepositoryMock.delete).toHaveBeenCalledWith({
provider_id: "not_in_region",
})
})
@@ -1556,28 +1678,26 @@ describe("CartService", () => {
it("removes if cart total === 0", async () => {
await cartService.setPaymentSessions(IdMap.getId("cart-remove"))
expect(paymentProviderService.updateSession).toHaveBeenCalledTimes(0)
expect(paymentProviderService.createSession).toHaveBeenCalledTimes(0)
expect(paymentProviderService.deleteSession).toHaveBeenCalledTimes(2)
expect(paymentProviderService.deleteSession).toHaveBeenCalledWith({
provider_id: "provider_1",
expect(paymentSessionRepositoryMock.delete).toHaveBeenCalledTimes(2)
expect(paymentSessionRepositoryMock.delete).toHaveBeenCalledWith({
provider_id: provider1Id,
})
expect(paymentProviderService.deleteSession).toHaveBeenCalledWith({
provider_id: "provider_2",
expect(paymentSessionRepositoryMock.delete).toHaveBeenCalledWith({
provider_id: provider2Id,
})
})
it("removes if cart total < 0", async () => {
await cartService.setPaymentSessions(IdMap.getId("cart-negative"))
expect(paymentProviderService.updateSession).toHaveBeenCalledTimes(0)
expect(paymentProviderService.createSession).toHaveBeenCalledTimes(0)
expect(paymentProviderService.deleteSession).toHaveBeenCalledTimes(2)
expect(paymentProviderService.deleteSession).toHaveBeenCalledWith({
provider_id: "provider_1",
expect(paymentSessionRepositoryMock.delete).toHaveBeenCalledTimes(2)
expect(paymentSessionRepositoryMock.delete).toHaveBeenCalledWith({
provider_id: provider1Id,
})
expect(paymentProviderService.deleteSession).toHaveBeenCalledWith({
provider_id: "provider_2",
expect(paymentSessionRepositoryMock.delete).toHaveBeenCalledWith({
provider_id: provider2Id,
})
})
})

View File

@@ -95,7 +95,7 @@ describe("PaymentProviderService", () => {
withTransaction: function () {
return this
},
updatePayment: jest.fn().mockReturnValue(Promise.resolve()),
updatePayment: jest.fn().mockReturnValue(Promise.resolve({})),
})
)

View File

@@ -12,6 +12,7 @@ import {
DiscountRuleType,
LineItem,
PaymentSession,
PaymentSessionStatus,
SalesChannel,
ShippingMethod,
} from "../models"
@@ -39,24 +40,24 @@ import { FlagRouter } from "../utils/flag-router"
import { validateEmail } from "../utils/is-email"
import { PaymentSessionInput } from "../types/payment"
import {
CustomShippingOptionService,
CustomerService,
CustomShippingOptionService,
DiscountService,
EventBusService,
GiftCardService,
LineItemService,
LineItemAdjustmentService,
LineItemService,
NewTotalsService,
PaymentProviderService,
ProductService,
ProductVariantService,
ProductVariantInventoryService,
ProductVariantService,
RegionService,
SalesChannelService,
ShippingOptionService,
StoreService,
TaxProviderService,
TotalsService,
SalesChannelService,
} from "."
type InjectedDependencies = {
@@ -1631,12 +1632,11 @@ class CartService extends TransactionBaseService {
}
/**
* Sets a payment method for a cart.
* Selects a payment session for a cart and creates a payment object in the external provider system
* @param cartId - the id of the cart to add payment method to
* @param providerId - the id of the provider to be set to the cart
* @return result of update operation
*/
async setPaymentSession(cartId: string, providerId: string): Promise<Cart> {
async setPaymentSession(cartId: string, providerId: string): Promise<void> {
return await this.atomicPhase_(
async (transactionManager: EntityManager) => {
const psRepo = transactionManager.getCustomRepository(
@@ -1644,30 +1644,34 @@ class CartService extends TransactionBaseService {
)
const cart = await this.retrieveWithTotals(cartId, {
relations: ["region", "region.payment_providers", "payment_sessions"],
relations: [
"customer",
"region",
"region.payment_providers",
"payment_sessions",
],
})
// The region must have the provider id in its providers array
if (
providerId !== "system" &&
!(
cart.region.payment_providers.length &&
cart.region.payment_providers.find(({ id }) => providerId === id)
)
) {
const isProviderPresent = cart.region.payment_providers.find(
({ id }) => providerId === id
)
if (providerId !== "system" && !isProviderPresent) {
throw new MedusaError(
MedusaError.Types.NOT_ALLOWED,
`The payment method is not available in this region`
)
}
await Promise.all(
cart.payment_sessions.map(async (paymentSession) => {
return psRepo.save({ ...paymentSession, is_selected: null })
})
const cartPaymentSessionIds = cart.payment_sessions.map((p) => p.id)
await psRepo.update(
{ id: In(cartPaymentSessionIds) },
{
is_selected: null,
}
)
const paymentSession = cart.payment_sessions.find(
let paymentSession = cart.payment_sessions.find(
(ps) => ps.provider_id === providerId
)
@@ -1678,16 +1682,34 @@ class CartService extends TransactionBaseService {
)
}
paymentSession.is_selected = true
const sessionInput: PaymentSessionInput = {
cart,
customer: cart.customer,
amount: cart.total!,
currency_code: cart.region.currency_code,
provider_id: providerId,
payment_session_id: paymentSession.id,
}
await psRepo.save(paymentSession)
if (paymentSession.is_selected) {
// update the session remotely
await this.paymentProviderService_
.withTransaction(transactionManager)
.updateSession(paymentSession, sessionInput)
}
const updatedCart = await this.retrieve(cartId)
if (!paymentSession.is_initiated) {
// Create the session remotely
paymentSession = await this.paymentProviderService_
.withTransaction(transactionManager)
.createSession(sessionInput)
}
await psRepo.update(paymentSession.id, { is_selected: true })
await this.eventBus_
.withTransaction(transactionManager)
.emit(CartService.Events.UPDATED, updatedCart)
return updatedCart
.emit(CartService.Events.UPDATED, { id: cartId })
},
"SERIALIZABLE"
)
@@ -1709,6 +1731,9 @@ class CartService extends TransactionBaseService {
this.paymentSessionRepository_
)
const paymentProviderServiceTx =
this.paymentProviderService_.withTransaction(transactionManager)
const cartId =
typeof cartOrCartId === `string` ? cartOrCartId : cartOrCartId.id
@@ -1735,77 +1760,135 @@ class CartService extends TransactionBaseService {
)
const { total, region } = cart
// Helpers that either delete a session locally or remotely. Will be used in multiple places below.
const deleteSessionAppropriately = async (session) => {
if (session.is_selected || session.is_initiated) {
return paymentProviderServiceTx.deleteSession(session)
}
return psRepo.delete(session)
}
// In the case of a cart that has a total <= 0 we can return prematurely.
// we are deleting the sessions, and we don't need to create or update anything from now on.
if (total <= 0) {
await Promise.all(
cart.payment_sessions.map(async (session) => {
return deleteSessionAppropriately(session)
})
)
return
}
const providerSet = new Set(region.payment_providers.map((p) => p.id))
const alreadyConsumedProviderIds: Set<string> = new Set()
const partialSessionInput: Omit<PaymentSessionInput, "provider_id"> = {
cart: cart as Cart,
customer: cart.customer,
amount: cart.total,
amount: total,
currency_code: cart.region.currency_code,
}
// If there are existing payment sessions ensure that these are up to date
const seen: string[] = []
if (cart.payment_sessions?.length) {
await Promise.all(
cart.payment_sessions.map(async (paymentSession) => {
if (
total <= 0 ||
!region.payment_providers.find(
({ id }) => id === paymentSession.provider_id
)
) {
return this.paymentProviderService_
.withTransaction(transactionManager)
.deleteSession(paymentSession)
} else {
seen.push(paymentSession.provider_id)
const paymentSessionInput = {
...partialSessionInput,
provider_id: paymentSession.provider_id,
}
return this.paymentProviderService_
.withTransaction(transactionManager)
.updateSession(paymentSession, paymentSessionInput)
}
})
)
const partialPaymentSessionData = {
cart_id: cartId,
data: {},
status: PaymentSessionStatus.PENDING,
amount: total,
}
if (total > 0) {
// If only one payment session exists, we preselect it
if (region.payment_providers.length === 1 && !cart.payment_session) {
const paymentProvider = region.payment_providers[0]
const paymentSessionInput = {
...partialSessionInput,
provider_id: paymentProvider.id,
await Promise.all(
cart.payment_sessions.map(async (session) => {
if (!providerSet.has(session.provider_id)) {
/**
* if the provider does not belong to the region then delete the session.
* The deletion occurs locally if there is no external data or if it is not selected
* otherwise the deletion will also occur remotely through the external provider.
*/
return await deleteSessionAppropriately(session)
}
const paymentSession = await this.paymentProviderService_
.withTransaction(transactionManager)
.createSession(paymentSessionInput)
/**
* if the provider belongs to the region then update or delete the session.
* The update occurs locally if it is not selected
* otherwise the update will also occur remotely through the external provider.
* In case the session is not selected but contains an external provider data, we delete the external provider
* session to be in a clean state.
*/
paymentSession.is_selected = true
// We are saving the provider id on which the work below will be done. That way,
// when handling the providers from the cart region at a later point below, we do not double the work on the sessions that already
// exists for the same provider.
alreadyConsumedProviderIds.add(session.provider_id)
await psRepo.save(paymentSession)
} else {
await Promise.all(
region.payment_providers.map(async (paymentProvider) => {
if (!seen.includes(paymentProvider.id)) {
const paymentSessionInput = {
...partialSessionInput,
provider_id: paymentProvider.id,
}
// Update remotely
if (session.is_selected) {
const paymentSessionInput = {
...partialSessionInput,
provider_id: session.provider_id,
}
return this.paymentProviderService_
.withTransaction(transactionManager)
.createSession(paymentSessionInput)
}
return
return paymentProviderServiceTx.updateSession(
session,
paymentSessionInput
)
}
let updatedSession: PaymentSession
// At this stage the session is not selected. Delete it remotely if there is some
// external provider data and create the session locally only. Otherwise, update the existing local session.
if (session.is_initiated) {
await paymentProviderServiceTx.deleteSession(session)
updatedSession = psRepo.create({
...partialPaymentSessionData,
provider_id: session.provider_id,
})
)
} else {
updatedSession = { ...session, amount: total } as PaymentSession
}
return psRepo.save(updatedSession)
})
)
/**
* From now on, the sessions have been cleanup. We can now
* - Set the provider session as selected if it is the only one existing and there is no payment session on the cart
* - Create a session per provider locally if it does not already exists on the cart as per the previous step
*/
// If only one provider exists and there is no session on the cart, create the session and select it.
if (region.payment_providers.length === 1 && !cart.payment_session) {
const paymentProvider = region.payment_providers[0]
const paymentSessionInput = {
...partialSessionInput,
provider_id: paymentProvider.id,
}
const paymentSession = await this.paymentProviderService_
.withTransaction(transactionManager)
.createSession(paymentSessionInput)
await psRepo.update(paymentSession.id, { is_selected: true })
return
}
await Promise.all(
region.payment_providers.map(async (paymentProvider) => {
if (alreadyConsumedProviderIds.has(paymentProvider.id)) {
return
}
const paymentSession = psRepo.create({
...partialPaymentSessionData,
provider_id: paymentProvider.id,
})
return psRepo.save(paymentSession)
})
)
}
)
}
@@ -1820,7 +1903,7 @@ class CartService extends TransactionBaseService {
async deletePaymentSession(
cartId: string,
providerId: string
): Promise<Cart> {
): Promise<void> {
return await this.atomicPhase_(
async (transactionManager: EntityManager) => {
const cart = await this.retrieve(cartId, {
@@ -1840,11 +1923,18 @@ class CartService extends TransactionBaseService {
({ provider_id }) => provider_id !== providerId
)
const psRepo = transactionManager.getCustomRepository(
this.paymentSessionRepository_
)
if (paymentSession) {
// Delete the session with the provider
await this.paymentProviderService_
.withTransaction(transactionManager)
.deleteSession(paymentSession)
if (paymentSession.is_selected || paymentSession.is_initiated) {
await this.paymentProviderService_
.withTransaction(transactionManager)
.deleteSession(paymentSession)
} else {
await psRepo.delete(paymentSession)
}
}
}
@@ -1852,8 +1942,7 @@ class CartService extends TransactionBaseService {
await this.eventBus_
.withTransaction(transactionManager)
.emit(CartService.Events.UPDATED, cart)
return cart
.emit(CartService.Events.UPDATED, { id: cart.id })
}
)
}
@@ -1863,12 +1952,12 @@ class CartService extends TransactionBaseService {
* @param cartId - the id of the cart to remove from
* @param providerId - the id of the provider whoose payment session
* should be removed.
* @return {Promise<Cart>} the resulting cart.
* @return {Promise<void>} the resulting cart.
*/
async refreshPaymentSession(
cartId: string,
providerId: string
): Promise<Cart> {
): Promise<void> {
return await this.atomicPhase_(
async (transactionManager: EntityManager) => {
const cart = await this.retrieveWithTotals(cartId, {
@@ -1881,25 +1970,30 @@ class CartService extends TransactionBaseService {
)
if (paymentSession) {
// Delete the session with the provider
await this.paymentProviderService_
.withTransaction(transactionManager)
.refreshSession(paymentSession, {
cart: cart as Cart,
customer: cart.customer,
if (paymentSession.is_selected) {
await this.paymentProviderService_
.withTransaction(transactionManager)
.refreshSession(paymentSession, {
cart: cart as Cart,
customer: cart.customer,
amount: cart.total,
currency_code: cart.region.currency_code,
provider_id: providerId,
})
} else {
const psRepo = transactionManager.getCustomRepository(
this.paymentSessionRepository_
)
await psRepo.update(paymentSession.id, {
amount: cart.total,
currency_code: cart.region.currency_code,
provider_id: providerId,
})
}
}
}
const updatedCart = await this.retrieve(cartId)
await this.eventBus_
.withTransaction(transactionManager)
.emit(CartService.Events.UPDATED, updatedCart)
return updatedCart
.emit(CartService.Events.UPDATED, { id: cartId })
}
)
}

View File

@@ -215,17 +215,15 @@ export default class PaymentProviderService extends TransactionBaseService {
paymentResponse
)
const amount = this.featureFlagRouter_.isFeatureEnabled(
OrderEditingFeatureFlag.key
)
? context.amount
: undefined
return await this.saveSession(providerId, {
payment_session_id: !isString(providerIdOrSessionInput)
? providerIdOrSessionInput.payment_session_id
: undefined,
cartId: context.id,
sessionData,
status: PaymentSessionStatus.PENDING,
amount,
isInitiated: true,
amount: context.amount,
})
})
}
@@ -281,20 +279,24 @@ export default class PaymentProviderService extends TransactionBaseService {
const context = this.buildPaymentProcessorContext(sessionInput)
const sessionData = await provider
const paymentResponse = await provider
.withTransaction(transactionManager)
.updatePayment(paymentSession.data, context)
const amount = this.featureFlagRouter_.isFeatureEnabled(
OrderEditingFeatureFlag.key
const sessionData = paymentResponse.session_data ?? paymentResponse
await this.processUpdateRequestsData(
{
customer: { id: context.customer?.id },
},
paymentResponse
)
? context.amount
: undefined
return await this.saveSession(paymentSession.provider_id, {
payment_session_id: paymentSession.id,
sessionData,
amount,
isInitiated: true,
amount: context.amount,
})
})
}
@@ -687,44 +689,40 @@ export default class PaymentProviderService extends TransactionBaseService {
amount?: number
sessionData: Record<string, unknown>
isSelected?: boolean
isInitiated?: boolean
status?: PaymentSessionStatus
}
): Promise<PaymentSession> {
const manager = this.transactionManager_ ?? this.manager_
if (
data.amount != null &&
!this.featureFlagRouter_.isFeatureEnabled(OrderEditingFeatureFlag.key)
) {
throw new MedusaError(
MedusaError.Types.INVALID_ARGUMENT,
"Amount on payment sessions is only available with the OrderEditing API currently guarded by feature flag `MEDUSA_FF_ORDER_EDITING`. Read more about feature flags here: https://docs.medusajs.com/advanced/backend/feature-flags/toggle/"
)
}
const sessionRepo = manager.getCustomRepository(
this.paymentSessionRepository_
)
// Update an existing session
if (data.payment_session_id) {
const session = await this.retrieveSession(data.payment_session_id)
session.data = data.sessionData ?? session.data
session.status = data.status ?? session.status
session.amount = data.amount ?? session.amount
session.is_initiated = data.isInitiated ?? session.is_initiated
session.is_selected = data.isSelected ?? session.is_selected
return await sessionRepo.save(session)
} else {
const toCreate: Partial<PaymentSession> = {
cart_id: data.cartId || null,
provider_id: providerId,
data: data.sessionData,
is_selected: data.isSelected,
status: data.status,
amount: data.amount,
}
const created = sessionRepo.create(toCreate)
return await sessionRepo.save(created)
}
// Create a new session
const toCreate: Partial<PaymentSession> = {
cart_id: data.cartId || null,
provider_id: providerId,
data: data.sessionData,
is_selected: data.isSelected,
is_initiated: data.isInitiated,
status: data.status,
amount: data.amount,
}
const created = sessionRepo.create(toCreate)
return await sessionRepo.save(created)
}
/**

View File

@@ -1,28 +1,20 @@
import EventBusService from "../services/event-bus"
import { CartService, PaymentProviderService } from "../services"
import { CartService } from "../services"
import { EntityManager } from "typeorm"
type InjectedDependencies = {
eventBusService: EventBusService
cartService: CartService
paymentProviderService: PaymentProviderService
manager: EntityManager
}
class CartSubscriber {
protected readonly manager_: EntityManager
protected readonly cartService_: CartService
protected readonly paymentProviderService_: PaymentProviderService
protected readonly eventBus_: EventBusService
constructor({
manager,
cartService,
paymentProviderService,
eventBusService,
}: InjectedDependencies) {
constructor({ manager, cartService, eventBusService }: InjectedDependencies) {
this.cartService_ = cartService
this.paymentProviderService_ = paymentProviderService
this.eventBus_ = eventBusService
this.manager_ = manager
@@ -38,30 +30,18 @@ class CartSubscriber {
await this.manager_.transaction(
"SERIALIZABLE",
async (transactionManager) => {
const cart = await this.cartService_
.withTransaction(transactionManager)
.retrieveWithTotals(cartId, {
relations: [
"billing_address",
"region",
"region.payment_providers",
"payment_sessions",
"customer",
],
})
const cartServiceTx =
this.cartService_.withTransaction(transactionManager)
const cart = await cartServiceTx.retrieve(cartId, {
relations: ["payment_sessions"],
})
if (!cart.payment_sessions?.length) {
return
}
const paymentProviderServiceTx =
this.paymentProviderService_.withTransaction(transactionManager)
return await Promise.all(
cart.payment_sessions.map(async (paymentSession) => {
return paymentProviderServiceTx.updateSession(paymentSession, cart)
})
)
return await cartServiceTx.setPaymentSessions(cart.id)
}
)
}

View File

@@ -7,6 +7,7 @@ import {
} from "../models"
export type PaymentSessionInput = {
payment_session_id?: string
provider_id: string
// TODO: Support legacy payment provider API> Once we are ready to break the api then we can remove the Cart type
cart: