fix: Minor fixes and cleanup to the payments setup (#11356)
This PR adds a couple new statuses to the payment collection and payment webhook results. The payment collection will now be marked as "completed" once the captured amount is the full amount of the payment collection. There are several things left to improve the payment setup, so non-happy-path cases are handled correctly. 1. Currently the payment session and payment models serve a very similar purpose. Part of the information is found on one, and the other part on the other model, without any clear reason for doing so. We can simplify the payment module and the data models simply by merging the two. 2. We need to handle failures more gracefully, such as setting the payment session status to failed when such a webhook comes in. 3. We should convert the payment collection status and the different amounts to calculated fields from the payment session, captures, and refunds, as they can easily be a source of inconsistencies.
This commit is contained in:
@@ -833,7 +833,7 @@ medusaIntegrationTestRunner({
|
||||
expect.objectContaining({
|
||||
currency_code: "usd",
|
||||
amount: paymentDelta,
|
||||
status: "authorized",
|
||||
status: "completed",
|
||||
authorized_amount: paymentDelta,
|
||||
captured_amount: paymentDelta,
|
||||
refunded_amount: 0,
|
||||
|
||||
@@ -346,8 +346,7 @@ medusaIntegrationTestRunner({
|
||||
expect(paymentCollection).toEqual(
|
||||
expect.objectContaining({
|
||||
amount: 200,
|
||||
// Q: Shouldn't this be paid?
|
||||
status: "authorized",
|
||||
status: "completed",
|
||||
payment_sessions: [
|
||||
expect.objectContaining({
|
||||
status: "authorized",
|
||||
@@ -639,8 +638,7 @@ medusaIntegrationTestRunner({
|
||||
expect(paymentCollection).toEqual(
|
||||
expect.objectContaining({
|
||||
amount: 115.9,
|
||||
// Q: Shouldn't this be paid?
|
||||
status: "authorized",
|
||||
status: "completed",
|
||||
payment_sessions: [
|
||||
expect.objectContaining({
|
||||
status: "authorized",
|
||||
|
||||
@@ -38,4 +38,6 @@ export const capturePaymentStep = createStep(
|
||||
|
||||
return new StepResponse(payment)
|
||||
}
|
||||
// We don't want to compensate a capture automatically as the actual funds have already been taken.
|
||||
// The only want to compensate here is to issue a refund, but it's better to leave that as a manual operation for now.
|
||||
)
|
||||
|
||||
@@ -38,4 +38,6 @@ export const refundPaymentStep = createStep(
|
||||
|
||||
return new StepResponse(payment)
|
||||
}
|
||||
// We don't want to compensate a refund automatically as the actual funds have already been sent
|
||||
// And in most cases we can't simply do another capture/authorization
|
||||
)
|
||||
|
||||
@@ -9,7 +9,8 @@ export type BasePaymentCollectionStatus =
|
||||
| "authorized"
|
||||
| "partially_authorized"
|
||||
| "canceled"
|
||||
|
||||
| "completed"
|
||||
| "failed"
|
||||
/**
|
||||
*
|
||||
* The status of a payment session.
|
||||
|
||||
@@ -10,6 +10,8 @@ export type PaymentCollectionStatus =
|
||||
| "authorized"
|
||||
| "partially_authorized"
|
||||
| "canceled"
|
||||
| "failed"
|
||||
| "completed"
|
||||
|
||||
export type PaymentSessionStatus =
|
||||
| "authorized"
|
||||
|
||||
@@ -56,6 +56,9 @@ export type PaymentActions =
|
||||
| "authorized"
|
||||
| "captured"
|
||||
| "failed"
|
||||
| "pending"
|
||||
| "requires_more"
|
||||
| "canceled"
|
||||
| "not_supported"
|
||||
|
||||
/**
|
||||
@@ -123,28 +126,28 @@ export type UpdatePaymentInput = PaymentProviderInput & {
|
||||
|
||||
/**
|
||||
* @interface
|
||||
*
|
||||
*
|
||||
* The data to delete a payment.
|
||||
*/
|
||||
export type DeletePaymentInput = PaymentProviderInput
|
||||
|
||||
/**
|
||||
* @interface
|
||||
*
|
||||
*
|
||||
* The data to authorize a payment.
|
||||
*/
|
||||
export type AuthorizePaymentInput = PaymentProviderInput
|
||||
|
||||
/**
|
||||
* @interface
|
||||
*
|
||||
*
|
||||
* The data to capture a payment.
|
||||
*/
|
||||
export type CapturePaymentInput = PaymentProviderInput
|
||||
|
||||
/**
|
||||
* @interface
|
||||
*
|
||||
*
|
||||
* The data to refund a payment.
|
||||
*/
|
||||
export type RefundPaymentInput = PaymentProviderInput & {
|
||||
@@ -156,21 +159,21 @@ export type RefundPaymentInput = PaymentProviderInput & {
|
||||
|
||||
/**
|
||||
* @interface
|
||||
*
|
||||
*
|
||||
* The data to retrieve a payment.
|
||||
*/
|
||||
export type RetrievePaymentInput = PaymentProviderInput
|
||||
|
||||
/**
|
||||
* @interface
|
||||
*
|
||||
*
|
||||
* The data to cancel a payment.
|
||||
*/
|
||||
export type CancelPaymentInput = PaymentProviderInput
|
||||
|
||||
/**
|
||||
* @interface
|
||||
*
|
||||
*
|
||||
* The data to create an account holder.
|
||||
*/
|
||||
export type CreateAccountHolderInput = PaymentProviderInput & {
|
||||
@@ -187,7 +190,7 @@ export type CreateAccountHolderInput = PaymentProviderInput & {
|
||||
|
||||
/**
|
||||
* @interface
|
||||
*
|
||||
*
|
||||
* The data to delete an account holder.
|
||||
*/
|
||||
export type DeleteAccountHolderInput = PaymentProviderInput & {
|
||||
@@ -204,21 +207,21 @@ export type DeleteAccountHolderInput = PaymentProviderInput & {
|
||||
|
||||
/**
|
||||
* @interface
|
||||
*
|
||||
*
|
||||
* The data to list payment methods.
|
||||
*/
|
||||
export type ListPaymentMethodsInput = PaymentProviderInput
|
||||
|
||||
/**
|
||||
* @interface
|
||||
*
|
||||
*
|
||||
* The data to save a payment method.
|
||||
*/
|
||||
export type SavePaymentMethodInput = PaymentProviderInput
|
||||
|
||||
/**
|
||||
* @interface
|
||||
*
|
||||
*
|
||||
* The data to get the payment status.
|
||||
*/
|
||||
export type GetPaymentStatusInput = PaymentProviderInput
|
||||
@@ -237,7 +240,7 @@ export type PaymentProviderOutput = {
|
||||
|
||||
/**
|
||||
* @interface
|
||||
*
|
||||
*
|
||||
* The successful result of initiating a payment session using a third-party payment provider.
|
||||
*/
|
||||
export type InitiatePaymentOutput = PaymentProviderOutput & {
|
||||
@@ -261,49 +264,49 @@ export type AuthorizePaymentOutput = PaymentProviderOutput & {
|
||||
|
||||
/**
|
||||
* @interface
|
||||
*
|
||||
*
|
||||
* The result of updating a payment.
|
||||
*/
|
||||
export type UpdatePaymentOutput = PaymentProviderOutput
|
||||
|
||||
/**
|
||||
* @interface
|
||||
*
|
||||
*
|
||||
* The result of deleting a payment.
|
||||
*/
|
||||
export type DeletePaymentOutput = PaymentProviderOutput
|
||||
|
||||
/**
|
||||
* @interface
|
||||
*
|
||||
*
|
||||
* The result of capturing the payment.
|
||||
*/
|
||||
export type CapturePaymentOutput = PaymentProviderOutput
|
||||
|
||||
/**
|
||||
* @interface
|
||||
*
|
||||
*
|
||||
* The result of refunding the payment.
|
||||
*/
|
||||
export type RefundPaymentOutput = PaymentProviderOutput
|
||||
|
||||
/**
|
||||
* @interface
|
||||
*
|
||||
*
|
||||
* The result of retrieving the payment.
|
||||
*/
|
||||
export type RetrievePaymentOutput = PaymentProviderOutput
|
||||
|
||||
/**
|
||||
* @interface
|
||||
*
|
||||
*
|
||||
* The result of canceling the payment.
|
||||
*/
|
||||
export type CancelPaymentOutput = PaymentProviderOutput
|
||||
|
||||
/**
|
||||
* @interface
|
||||
*
|
||||
*
|
||||
* The result of creating an account holder in the third-party payment provider. The `data`
|
||||
* property is stored as-is in Medusa's account holder's `data` property.
|
||||
*/
|
||||
@@ -326,7 +329,7 @@ export type ListPaymentMethodsOutput = (PaymentProviderOutput & {
|
||||
|
||||
/**
|
||||
* @interface
|
||||
*
|
||||
*
|
||||
* The result of saving a payment method.
|
||||
*/
|
||||
export type SavePaymentMethodOutput = PaymentProviderOutput & {
|
||||
@@ -338,7 +341,7 @@ export type SavePaymentMethodOutput = PaymentProviderOutput & {
|
||||
|
||||
/**
|
||||
* @interface
|
||||
*
|
||||
*
|
||||
* The result of getting the payment status.
|
||||
*/
|
||||
export type GetPaymentStatusOutput = PaymentProviderOutput & {
|
||||
@@ -408,46 +411,46 @@ export interface IPaymentProvider {
|
||||
|
||||
/**
|
||||
* This method is used when creating an account holder in Medusa, allowing you to create
|
||||
* the equivalent account in the third-party service. An account holder is useful to
|
||||
* later save payment methods, such as credit cards, for a customer in the
|
||||
* the equivalent account in the third-party service. An account holder is useful to
|
||||
* later save payment methods, such as credit cards, for a customer in the
|
||||
* third-party payment provider using the {@link savePaymentMethod} method.
|
||||
*
|
||||
*
|
||||
* The returned data will be stored in the account holder created in Medusa. For example,
|
||||
* the returned `id` property will be stored in the account holder's `external_id` property.
|
||||
*
|
||||
*
|
||||
* Medusa creates an account holder when a payment session initialized for a registered customer.
|
||||
*
|
||||
*
|
||||
* @param data - Input data including the details of the account holder to create.
|
||||
* @returns The result of creating the account holder. If an error occurs, throw it.
|
||||
*
|
||||
*
|
||||
* @version 2.5.0
|
||||
*
|
||||
*
|
||||
* @example
|
||||
* import { MedusaError } from "@medusajs/framework/utils"
|
||||
*
|
||||
*
|
||||
* class MyPaymentProviderService extends AbstractPaymentProvider<
|
||||
* Options
|
||||
* > {
|
||||
* async createAccountHolder({ context, data }: CreateAccountHolderInput) {
|
||||
* const { account_holder, customer } = context
|
||||
*
|
||||
*
|
||||
* if (account_holder?.data?.id) {
|
||||
* return { id: account_holder.data.id as string }
|
||||
* }
|
||||
*
|
||||
*
|
||||
* if (!customer) {
|
||||
* throw new MedusaError(
|
||||
* MedusaError.Types.INVALID_DATA,
|
||||
* "Missing customer data."
|
||||
* )
|
||||
* }
|
||||
*
|
||||
*
|
||||
* // assuming you have a client that creates the account holder
|
||||
* const providerAccountHolder = await this.client.createAccountHolder({
|
||||
* email: customer.email,
|
||||
* ...data
|
||||
* })
|
||||
*
|
||||
*
|
||||
* return {
|
||||
* id: providerAccountHolder.id,
|
||||
* data: providerAccountHolder as unknown as Record<string, unknown>
|
||||
@@ -461,15 +464,15 @@ export interface IPaymentProvider {
|
||||
/**
|
||||
* This method is used when an account holder is deleted in Medusa, allowing you
|
||||
* to also delete the equivalent account holder in the third-party service.
|
||||
*
|
||||
*
|
||||
* @param data - Input data including the details of the account holder to delete.
|
||||
* @returns The result of deleting the account holder. If an error occurs, throw it.
|
||||
*
|
||||
*
|
||||
* @version 2.5.0
|
||||
*
|
||||
*
|
||||
* @example
|
||||
* import { MedusaError } from "@medusajs/framework/utils"
|
||||
*
|
||||
*
|
||||
* class MyPaymentProviderService extends AbstractPaymentProvider<
|
||||
* Options
|
||||
* > {
|
||||
@@ -482,12 +485,12 @@ export interface IPaymentProvider {
|
||||
* "Missing account holder ID."
|
||||
* )
|
||||
* }
|
||||
*
|
||||
*
|
||||
* // assuming you have a client that deletes the account holder
|
||||
* await this.client.deleteAccountHolder({
|
||||
* id: accountHolderId
|
||||
* })
|
||||
*
|
||||
*
|
||||
* return {}
|
||||
* }
|
||||
* }
|
||||
@@ -500,34 +503,34 @@ export interface IPaymentProvider {
|
||||
* This method is used to retrieve the list of saved payment methods for an account holder
|
||||
* in the third-party payment provider. A payment provider that supports saving payment methods
|
||||
* must implement this method.
|
||||
*
|
||||
*
|
||||
* @version 2.5.0
|
||||
*
|
||||
*
|
||||
* @param data - Input data including the details of the account holder to list payment methods for.
|
||||
* @returns The list of payment methods saved for the account holder. If an error occurs, throw it.
|
||||
*
|
||||
*
|
||||
* @example
|
||||
* import { MedusaError } from "@medusajs/framework/utils"
|
||||
*
|
||||
*
|
||||
* class MyPaymentProviderService extends AbstractPaymentProvider<
|
||||
* Options
|
||||
* > {
|
||||
* async listPaymentMethods({ context }: ListPaymentMethodsInput) {
|
||||
* const { account_holder } = context
|
||||
* const accountHolderId = account_holder?.data?.id as string | undefined
|
||||
*
|
||||
*
|
||||
* if (!accountHolderId) {
|
||||
* throw new MedusaError(
|
||||
* MedusaError.Types.INVALID_DATA,
|
||||
* "Missing account holder ID."
|
||||
* )
|
||||
* }
|
||||
*
|
||||
*
|
||||
* // assuming you have a client that lists the payment methods
|
||||
* const paymentMethods = await this.client.listPaymentMethods({
|
||||
* customer_id: accountHolderId
|
||||
* })
|
||||
*
|
||||
*
|
||||
* return paymentMethods.map((pm) => ({
|
||||
* id: pm.id,
|
||||
* data: pm as unknown as Record<string, unknown>
|
||||
@@ -543,36 +546,36 @@ export interface IPaymentProvider {
|
||||
* This method is used to save a customer's payment method, such as a credit card, in the
|
||||
* third-party payment provider. A payment provider that supports saving payment methods
|
||||
* must implement this method.
|
||||
*
|
||||
*
|
||||
* @version 2.5.0
|
||||
*
|
||||
*
|
||||
* @param data - The details of the payment method to save.
|
||||
* @returns The result of saving the payment method. If an error occurs, throw it.
|
||||
*
|
||||
*
|
||||
* @example
|
||||
* import { MedusaError } from "@medusajs/framework/utils"
|
||||
*
|
||||
*
|
||||
* class MyPaymentProviderService extends AbstractPaymentProvider<
|
||||
* Options
|
||||
* > {
|
||||
* async savePaymentMethod({ context, data }: SavePaymentMethodInput) { *
|
||||
* async savePaymentMethod({ context, data }: SavePaymentMethodInput) { *
|
||||
* const accountHolderId = context?.account_holder?.data?.id as
|
||||
* | string
|
||||
* | undefined
|
||||
*
|
||||
*
|
||||
* if (!accountHolderId) {
|
||||
* throw new MedusaError(
|
||||
* MedusaError.Types.INVALID_DATA,
|
||||
* "Missing account holder ID."
|
||||
* )
|
||||
* }
|
||||
*
|
||||
*
|
||||
* // assuming you have a client that saves the payment method
|
||||
* const paymentMethod = await this.client.savePaymentMethod({
|
||||
* customer_id: accountHolderId,
|
||||
* ...data
|
||||
* })
|
||||
*
|
||||
*
|
||||
* return {
|
||||
* id: paymentMethod.id,
|
||||
* data: paymentMethod as unknown as Record<string, unknown>
|
||||
|
||||
@@ -1003,6 +1003,21 @@ export interface IPaymentModuleService extends IModuleService {
|
||||
sharedContext?: Context
|
||||
): Promise<CaptureDTO[]>
|
||||
|
||||
/**
|
||||
* This method deletes a capture by its ID.
|
||||
*
|
||||
* @param {string[]} captureId - The capture's ID.
|
||||
* @param {Context} sharedContext - A context used to share resources, such as transaction manager, between the application and the module.
|
||||
* @returns {Promise<void>} Resolves when the capture is deleted successfully.
|
||||
*
|
||||
* @example
|
||||
* await paymentModuleService.deleteCaptures([
|
||||
* "capt_123",
|
||||
* "capt_321",
|
||||
* ])
|
||||
*/
|
||||
deleteCaptures(ids: string[], sharedContext?: Context): Promise<void>
|
||||
|
||||
/**
|
||||
* This method retrieves a paginated list of refunds based on optional filters and configuration.
|
||||
*
|
||||
@@ -1055,6 +1070,21 @@ export interface IPaymentModuleService extends IModuleService {
|
||||
sharedContext?: Context
|
||||
): Promise<RefundDTO[]>
|
||||
|
||||
/**
|
||||
* This method deletes a refund by its ID.
|
||||
*
|
||||
* @param {string[]} refundId - The refund's ID.
|
||||
* @param {Context} sharedContext - A context used to share resources, such as transaction manager, between the application and the module.
|
||||
* @returns {Promise<void>} Resolves when the refund is deleted successfully.
|
||||
*
|
||||
* @example
|
||||
* await paymentModuleService.deleteRefunds([
|
||||
* "ref_123",
|
||||
* "ref_321",
|
||||
* ])
|
||||
*/
|
||||
deleteRefunds(ids: string[], sharedContext?: Context): Promise<void>
|
||||
|
||||
/**
|
||||
* This method creates refund reasons.
|
||||
*
|
||||
|
||||
@@ -24,4 +24,12 @@ export enum PaymentCollectionStatus {
|
||||
* The payment collection is canceled.
|
||||
*/
|
||||
CANCELED = "canceled",
|
||||
/**
|
||||
* The payment collection is failed.
|
||||
*/
|
||||
FAILED = "failed",
|
||||
/**
|
||||
* The payment collection is completed.
|
||||
*/
|
||||
COMPLETED = "completed",
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ export enum PaymentWebhookEvents {
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalized events from payment provider to internal payment module events.
|
||||
* Normalized events from payment provider to internal payment module events. In principle, these should match the payment status.
|
||||
*/
|
||||
export enum PaymentActions {
|
||||
/**
|
||||
@@ -18,6 +18,18 @@ export enum PaymentActions {
|
||||
* Payment failed.
|
||||
*/
|
||||
FAILED = "failed",
|
||||
/**
|
||||
* Payment is pending.
|
||||
*/
|
||||
PENDING = "pending",
|
||||
/**
|
||||
* Payment requires more information.
|
||||
*/
|
||||
REQUIRES_MORE = "requires_more",
|
||||
/**
|
||||
* Payment was canceled.
|
||||
*/
|
||||
CANCELED = "canceled",
|
||||
/**
|
||||
* Received an event that is not processable.
|
||||
*/
|
||||
|
||||
@@ -35,7 +35,14 @@ export default async function paymentWebhookhandler({
|
||||
|
||||
const processedEvent = await paymentService.getWebhookActionAndData(input)
|
||||
|
||||
if (processedEvent?.action === PaymentActions.NOT_SUPPORTED) {
|
||||
if (
|
||||
processedEvent?.action === PaymentActions.NOT_SUPPORTED ||
|
||||
// Currently none of these are handled by the processPaymentWorkflow, so we ignore them.
|
||||
// Remove once the processPaymentWorkflow is handling them.
|
||||
processedEvent?.action === PaymentActions.CANCELED ||
|
||||
processedEvent?.action === PaymentActions.FAILED ||
|
||||
processedEvent?.action === PaymentActions.REQUIRES_MORE
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -139,7 +139,7 @@ moduleIntegrationTestRunner<IPaymentModuleService>({
|
||||
amount: 200,
|
||||
authorized_amount: 200,
|
||||
captured_amount: 200,
|
||||
status: "authorized",
|
||||
status: "completed",
|
||||
deleted_at: null,
|
||||
completed_at: expect.any(Date),
|
||||
payment_sessions: [
|
||||
|
||||
@@ -209,7 +209,9 @@
|
||||
"awaiting",
|
||||
"authorized",
|
||||
"partially_authorized",
|
||||
"canceled"
|
||||
"canceled",
|
||||
"failed",
|
||||
"completed"
|
||||
],
|
||||
"mappedType": "enum"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Migration } from '@mikro-orm/migrations';
|
||||
|
||||
export class Migration20250207132723 extends Migration {
|
||||
|
||||
override async up(): Promise<void> {
|
||||
this.addSql(`alter table if exists "payment_collection" drop constraint if exists "payment_collection_status_check";`);
|
||||
|
||||
this.addSql(`alter table if exists "payment_collection" add constraint "payment_collection_status_check" check("status" in ('not_paid', 'awaiting', 'authorized', 'partially_authorized', 'canceled', 'failed', 'completed'));`);
|
||||
}
|
||||
|
||||
override async down(): Promise<void> {
|
||||
this.addSql(`alter table if exists "payment_collection" drop constraint if exists "payment_collection_status_check";`);
|
||||
|
||||
this.addSql(`alter table if exists "payment_collection" add constraint "payment_collection_status_check" check("status" in ('not_paid', 'awaiting', 'authorized', 'partially_authorized', 'canceled'));`);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import PaymentCollection from "./payment-collection"
|
||||
import PaymentSession from "./payment-session"
|
||||
import Refund from "./refund"
|
||||
|
||||
// TODO: We should remove the `Payment` model and use the `PaymentSession` model instead.
|
||||
// We just need to move the refunds, captures, canceled_at, and captured_at to it.
|
||||
const Payment = model
|
||||
.define("Payment", {
|
||||
id: model.id({ prefix: "pay" }).primaryKey(),
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
FilterablePaymentCollectionProps,
|
||||
FilterablePaymentMethodProps,
|
||||
FilterablePaymentProviderProps,
|
||||
FilterablePaymentSessionProps,
|
||||
FindConfig,
|
||||
InferEntityType,
|
||||
InternalModuleDeclaration,
|
||||
@@ -303,6 +302,7 @@ export default class PaymentModuleService
|
||||
sharedContext?: Context
|
||||
): Promise<PaymentCollectionDTO[]>
|
||||
|
||||
// Should we remove this and use `updatePaymentCollections` instead?
|
||||
@InjectManager()
|
||||
async completePaymentCollections(
|
||||
paymentCollectionId: string | string[],
|
||||
@@ -415,6 +415,12 @@ export default class PaymentModuleService
|
||||
sharedContext
|
||||
)
|
||||
|
||||
await this.paymentProviderService_.updateSession(session.provider_id, {
|
||||
data: data.data,
|
||||
amount: data.amount,
|
||||
currency_code: data.currency_code,
|
||||
})
|
||||
|
||||
const updated = await this.paymentSessionService_.update(
|
||||
{
|
||||
id: session.id,
|
||||
@@ -491,7 +497,7 @@ export default class PaymentModuleService
|
||||
) {
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.NOT_ALLOWED,
|
||||
`Session: ${session.id} is not authorized with the provider.`
|
||||
`Session: ${session.id} was not authorized with the provider.`
|
||||
)
|
||||
}
|
||||
|
||||
@@ -516,11 +522,9 @@ export default class PaymentModuleService
|
||||
sharedContext
|
||||
)
|
||||
|
||||
return await this.retrievePayment(
|
||||
payment.id,
|
||||
{ relations: ["payment_collection"] },
|
||||
sharedContext
|
||||
)
|
||||
return await this.baseRepository_.serialize(payment, {
|
||||
populate: true,
|
||||
})
|
||||
}
|
||||
|
||||
@InjectTransactionManager()
|
||||
@@ -541,8 +545,11 @@ export default class PaymentModuleService
|
||||
id: session.id,
|
||||
data,
|
||||
status,
|
||||
authorized_at:
|
||||
status === PaymentSessionStatus.AUTHORIZED ? new Date() : null,
|
||||
...(session.authorized_at === null
|
||||
? {
|
||||
authorized_at: new Date(),
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
sharedContext
|
||||
)
|
||||
@@ -569,38 +576,6 @@ export default class PaymentModuleService
|
||||
return payment
|
||||
}
|
||||
|
||||
@InjectManager()
|
||||
// @ts-expect-error
|
||||
async retrievePaymentSession(
|
||||
id: string,
|
||||
config: FindConfig<PaymentSessionDTO> = {},
|
||||
@MedusaContext() sharedContext?: Context
|
||||
): Promise<PaymentSessionDTO> {
|
||||
const session = await this.paymentSessionService_.retrieve(
|
||||
id,
|
||||
config,
|
||||
sharedContext
|
||||
)
|
||||
|
||||
return await this.baseRepository_.serialize(session)
|
||||
}
|
||||
|
||||
@InjectManager()
|
||||
// @ts-expect-error
|
||||
async listPaymentSessions(
|
||||
filters?: FilterablePaymentSessionProps,
|
||||
config?: FindConfig<PaymentSessionDTO>,
|
||||
sharedContext?: Context
|
||||
): Promise<PaymentSessionDTO[]> {
|
||||
const sessions = await this.paymentSessionService_.list(
|
||||
filters,
|
||||
config,
|
||||
sharedContext
|
||||
)
|
||||
|
||||
return await this.baseRepository_.serialize<PaymentSessionDTO[]>(sessions)
|
||||
}
|
||||
|
||||
@InjectManager()
|
||||
async updatePayment(
|
||||
data: UpdatePaymentDTO,
|
||||
@@ -612,13 +587,33 @@ export default class PaymentModuleService
|
||||
return await this.baseRepository_.serialize<PaymentDTO>(result[0])
|
||||
}
|
||||
|
||||
// TODO: This method should return a capture, not a payment
|
||||
@InjectManager()
|
||||
async capturePayment(
|
||||
data: CreateCaptureDTO,
|
||||
@MedusaContext() sharedContext: Context = {}
|
||||
): Promise<PaymentDTO> {
|
||||
const { payment, isFullyCaptured, capture } = await this.capturePayment_(
|
||||
const payment = await this.paymentService_.retrieve(
|
||||
data.payment_id,
|
||||
{
|
||||
select: [
|
||||
"id",
|
||||
"data",
|
||||
"provider_id",
|
||||
"payment_collection_id",
|
||||
"amount",
|
||||
"raw_amount",
|
||||
"captured_at",
|
||||
"canceled_at",
|
||||
],
|
||||
relations: ["captures.raw_amount"],
|
||||
},
|
||||
sharedContext
|
||||
)
|
||||
|
||||
const { isFullyCaptured, capture } = await this.capturePayment_(
|
||||
data,
|
||||
payment,
|
||||
sharedContext
|
||||
)
|
||||
|
||||
@@ -640,45 +635,20 @@ export default class PaymentModuleService
|
||||
sharedContext
|
||||
)
|
||||
|
||||
return await this.retrievePayment(
|
||||
payment.id,
|
||||
{ relations: ["captures"] },
|
||||
sharedContext
|
||||
)
|
||||
return await this.baseRepository_.serialize(payment, {
|
||||
populate: true,
|
||||
})
|
||||
}
|
||||
|
||||
@InjectTransactionManager()
|
||||
private async capturePayment_(
|
||||
data: CreateCaptureDTO,
|
||||
payment: InferEntityType<typeof Payment>,
|
||||
@MedusaContext() sharedContext: Context = {}
|
||||
): Promise<{
|
||||
payment: InferEntityType<typeof Payment>
|
||||
isFullyCaptured: boolean
|
||||
capture?: InferEntityType<typeof Capture>
|
||||
}> {
|
||||
const payment = await this.paymentService_.retrieve(
|
||||
data.payment_id,
|
||||
{
|
||||
select: [
|
||||
"id",
|
||||
"data",
|
||||
"provider_id",
|
||||
"payment_collection_id",
|
||||
"amount",
|
||||
"raw_amount",
|
||||
"captured_at",
|
||||
"canceled_at",
|
||||
],
|
||||
relations: ["captures.raw_amount"],
|
||||
},
|
||||
sharedContext
|
||||
)
|
||||
|
||||
// If no custom amount is passed, we assume the full amount needs to be captured
|
||||
if (!data.amount) {
|
||||
data.amount = payment.amount as number
|
||||
}
|
||||
|
||||
if (payment.canceled_at) {
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.INVALID_DATA,
|
||||
@@ -687,7 +657,12 @@ export default class PaymentModuleService
|
||||
}
|
||||
|
||||
if (payment.captured_at) {
|
||||
return { payment, isFullyCaptured: true }
|
||||
return { isFullyCaptured: true }
|
||||
}
|
||||
|
||||
// If no custom amount is passed, we assume the full amount needs to be captured
|
||||
if (!data.amount) {
|
||||
data.amount = payment.amount as number
|
||||
}
|
||||
|
||||
const capturedAmount = payment.captures.reduce((captureAmount, next) => {
|
||||
@@ -720,7 +695,7 @@ export default class PaymentModuleService
|
||||
sharedContext
|
||||
)
|
||||
|
||||
return { payment, isFullyCaptured, capture }
|
||||
return { isFullyCaptured, capture }
|
||||
}
|
||||
@InjectManager()
|
||||
private async capturePaymentFromProvider_(
|
||||
@@ -875,15 +850,79 @@ export default class PaymentModuleService
|
||||
}
|
||||
|
||||
@InjectManager()
|
||||
async getWebhookActionAndData(
|
||||
eventData: ProviderWebhookPayload,
|
||||
@MedusaContext() sharedContext?: Context
|
||||
): Promise<WebhookActionResult> {
|
||||
const providerId = `pp_${eventData.provider}`
|
||||
private async maybeUpdatePaymentCollection_(
|
||||
paymentCollectionId: string,
|
||||
sharedContext?: Context
|
||||
) {
|
||||
const paymentCollection = await this.paymentCollectionService_.retrieve(
|
||||
paymentCollectionId,
|
||||
{
|
||||
select: ["amount", "raw_amount", "status"],
|
||||
relations: [
|
||||
"payment_sessions.amount",
|
||||
"payment_sessions.raw_amount",
|
||||
"payments.captures.amount",
|
||||
"payments.captures.raw_amount",
|
||||
"payments.refunds.amount",
|
||||
"payments.refunds.raw_amount",
|
||||
],
|
||||
},
|
||||
sharedContext
|
||||
)
|
||||
|
||||
return await this.paymentProviderService_.getWebhookActionAndData(
|
||||
providerId,
|
||||
eventData.payload
|
||||
const paymentSessions = paymentCollection.payment_sessions
|
||||
const captures = paymentCollection.payments
|
||||
.map((pay) => [...pay.captures])
|
||||
.flat()
|
||||
const refunds = paymentCollection.payments
|
||||
.map((pay) => [...pay.refunds])
|
||||
.flat()
|
||||
|
||||
let authorizedAmount = MathBN.convert(0)
|
||||
let capturedAmount = MathBN.convert(0)
|
||||
let refundedAmount = MathBN.convert(0)
|
||||
let completedAt: Date | undefined
|
||||
|
||||
for (const ps of paymentSessions) {
|
||||
if (ps.status === PaymentSessionStatus.AUTHORIZED) {
|
||||
authorizedAmount = MathBN.add(authorizedAmount, ps.amount)
|
||||
}
|
||||
}
|
||||
|
||||
for (const capture of captures) {
|
||||
capturedAmount = MathBN.add(capturedAmount, capture.amount)
|
||||
}
|
||||
|
||||
for (const refund of refunds) {
|
||||
refundedAmount = MathBN.add(refundedAmount, refund.amount)
|
||||
}
|
||||
|
||||
let status =
|
||||
paymentSessions.length === 0
|
||||
? PaymentCollectionStatus.NOT_PAID
|
||||
: PaymentCollectionStatus.AWAITING
|
||||
|
||||
if (MathBN.gt(authorizedAmount, 0)) {
|
||||
status = MathBN.gte(authorizedAmount, paymentCollection.amount)
|
||||
? PaymentCollectionStatus.AUTHORIZED
|
||||
: PaymentCollectionStatus.PARTIALLY_AUTHORIZED
|
||||
}
|
||||
|
||||
if (MathBN.eq(paymentCollection.amount, capturedAmount)) {
|
||||
status = PaymentCollectionStatus.COMPLETED
|
||||
completedAt = new Date()
|
||||
}
|
||||
|
||||
await this.paymentCollectionService_.update(
|
||||
{
|
||||
id: paymentCollectionId,
|
||||
status,
|
||||
authorized_amount: authorizedAmount,
|
||||
captured_amount: capturedAmount,
|
||||
refunded_amount: refundedAmount,
|
||||
completed_at: completedAt,
|
||||
},
|
||||
sharedContext
|
||||
)
|
||||
}
|
||||
|
||||
@@ -939,44 +978,23 @@ export default class PaymentModuleService
|
||||
let accountHolder: InferEntityType<typeof AccountHolder> | undefined
|
||||
let providerAccountHolder: CreateAccountHolderOutput | undefined
|
||||
|
||||
try {
|
||||
providerAccountHolder =
|
||||
await this.paymentProviderService_.createAccountHolder(
|
||||
input.provider_id,
|
||||
{ context: input.context }
|
||||
)
|
||||
providerAccountHolder =
|
||||
await this.paymentProviderService_.createAccountHolder(
|
||||
input.provider_id,
|
||||
{ context: input.context }
|
||||
)
|
||||
|
||||
// This can be empty when either the method is not supported or an account holder wasn't created
|
||||
if (isPresent(providerAccountHolder)) {
|
||||
accountHolder = await this.accountHolderService_.create(
|
||||
{
|
||||
external_id: providerAccountHolder.id,
|
||||
email: input.context.customer?.email,
|
||||
data: providerAccountHolder.data,
|
||||
provider_id: input.provider_id,
|
||||
},
|
||||
sharedContext
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
if (providerAccountHolder) {
|
||||
await this.paymentProviderService_.deleteAccountHolder(
|
||||
input.provider_id,
|
||||
{
|
||||
context: {
|
||||
account_holder: providerAccountHolder as {
|
||||
data: Record<string, unknown>
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (accountHolder) {
|
||||
await this.accountHolderService_.delete(accountHolder.id, sharedContext)
|
||||
}
|
||||
|
||||
throw error
|
||||
// This can be empty when either the method is not supported or an account holder wasn't created
|
||||
if (isPresent(providerAccountHolder)) {
|
||||
accountHolder = await this.accountHolderService_.create(
|
||||
{
|
||||
external_id: providerAccountHolder.id,
|
||||
email: input.context.customer?.email,
|
||||
data: providerAccountHolder.data,
|
||||
provider_id: input.provider_id,
|
||||
},
|
||||
sharedContext
|
||||
)
|
||||
}
|
||||
|
||||
return await this.baseRepository_.serialize(accountHolder)
|
||||
@@ -1078,72 +1096,15 @@ export default class PaymentModuleService
|
||||
}
|
||||
|
||||
@InjectManager()
|
||||
private async maybeUpdatePaymentCollection_(
|
||||
paymentCollectionId: string,
|
||||
sharedContext?: Context
|
||||
) {
|
||||
const paymentCollection = await this.paymentCollectionService_.retrieve(
|
||||
paymentCollectionId,
|
||||
{
|
||||
select: ["amount", "raw_amount", "status"],
|
||||
relations: [
|
||||
"payment_sessions.amount",
|
||||
"payment_sessions.raw_amount",
|
||||
"payments.captures.amount",
|
||||
"payments.captures.raw_amount",
|
||||
"payments.refunds.amount",
|
||||
"payments.refunds.raw_amount",
|
||||
],
|
||||
},
|
||||
sharedContext
|
||||
)
|
||||
async getWebhookActionAndData(
|
||||
eventData: ProviderWebhookPayload,
|
||||
@MedusaContext() sharedContext?: Context
|
||||
): Promise<WebhookActionResult> {
|
||||
const providerId = `pp_${eventData.provider}`
|
||||
|
||||
const paymentSessions = paymentCollection.payment_sessions
|
||||
const captures = paymentCollection.payments
|
||||
.map((pay) => [...pay.captures])
|
||||
.flat()
|
||||
const refunds = paymentCollection.payments
|
||||
.map((pay) => [...pay.refunds])
|
||||
.flat()
|
||||
|
||||
let authorizedAmount = MathBN.convert(0)
|
||||
let capturedAmount = MathBN.convert(0)
|
||||
let refundedAmount = MathBN.convert(0)
|
||||
|
||||
for (const ps of paymentSessions) {
|
||||
if (ps.status === PaymentSessionStatus.AUTHORIZED) {
|
||||
authorizedAmount = MathBN.add(authorizedAmount, ps.amount)
|
||||
}
|
||||
}
|
||||
|
||||
for (const capture of captures) {
|
||||
capturedAmount = MathBN.add(capturedAmount, capture.amount)
|
||||
}
|
||||
|
||||
for (const refund of refunds) {
|
||||
refundedAmount = MathBN.add(refundedAmount, refund.amount)
|
||||
}
|
||||
|
||||
let status =
|
||||
paymentSessions.length === 0
|
||||
? PaymentCollectionStatus.NOT_PAID
|
||||
: PaymentCollectionStatus.AWAITING
|
||||
|
||||
if (MathBN.gt(authorizedAmount, 0)) {
|
||||
status = MathBN.gte(authorizedAmount, paymentCollection.amount)
|
||||
? PaymentCollectionStatus.AUTHORIZED
|
||||
: PaymentCollectionStatus.PARTIALLY_AUTHORIZED
|
||||
}
|
||||
|
||||
await this.paymentCollectionService_.update(
|
||||
{
|
||||
id: paymentCollectionId,
|
||||
status,
|
||||
authorized_amount: authorizedAmount,
|
||||
captured_amount: capturedAmount,
|
||||
refunded_amount: refundedAmount,
|
||||
},
|
||||
sharedContext
|
||||
return await this.paymentProviderService_.getWebhookActionAndData(
|
||||
providerId,
|
||||
eventData.payload
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,22 +125,30 @@ abstract class StripeBase extends AbstractPaymentProvider<StripeOptions> {
|
||||
}
|
||||
|
||||
const paymentIntent = await this.stripe_.paymentIntents.retrieve(id)
|
||||
const dataResponse = paymentIntent as unknown as Record<string, unknown>
|
||||
|
||||
switch (paymentIntent.status) {
|
||||
case "requires_payment_method":
|
||||
if (paymentIntent.last_payment_error) {
|
||||
return { status: PaymentSessionStatus.ERROR, data: dataResponse }
|
||||
}
|
||||
return { status: PaymentSessionStatus.PENDING, data: dataResponse }
|
||||
case "requires_confirmation":
|
||||
case "processing":
|
||||
return { status: PaymentSessionStatus.PENDING }
|
||||
return { status: PaymentSessionStatus.PENDING, data: dataResponse }
|
||||
case "requires_action":
|
||||
return { status: PaymentSessionStatus.REQUIRES_MORE }
|
||||
return {
|
||||
status: PaymentSessionStatus.REQUIRES_MORE,
|
||||
data: dataResponse,
|
||||
}
|
||||
case "canceled":
|
||||
return { status: PaymentSessionStatus.CANCELED }
|
||||
return { status: PaymentSessionStatus.CANCELED, data: dataResponse }
|
||||
case "requires_capture":
|
||||
return { status: PaymentSessionStatus.AUTHORIZED }
|
||||
return { status: PaymentSessionStatus.AUTHORIZED, data: dataResponse }
|
||||
case "succeeded":
|
||||
return { status: PaymentSessionStatus.CAPTURED }
|
||||
return { status: PaymentSessionStatus.CAPTURED, data: dataResponse }
|
||||
default:
|
||||
return { status: PaymentSessionStatus.PENDING }
|
||||
return { status: PaymentSessionStatus.PENDING, data: dataResponse }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -419,24 +427,23 @@ abstract class StripeBase extends AbstractPaymentProvider<StripeOptions> {
|
||||
const intent = event.data.object as Stripe.PaymentIntent
|
||||
|
||||
const { currency } = intent
|
||||
|
||||
switch (event.type) {
|
||||
case "payment_intent.amount_capturable_updated":
|
||||
case "payment_intent.created":
|
||||
case "payment_intent.processing":
|
||||
return {
|
||||
action: PaymentActions.AUTHORIZED,
|
||||
action: PaymentActions.PENDING,
|
||||
data: {
|
||||
session_id: intent.metadata.session_id,
|
||||
amount: getAmountFromSmallestUnit(
|
||||
intent.amount_capturable,
|
||||
currency
|
||||
), // NOTE: revisit when implementing multicapture
|
||||
amount: getAmountFromSmallestUnit(intent.amount, currency),
|
||||
},
|
||||
}
|
||||
case "payment_intent.succeeded":
|
||||
case "payment_intent.canceled":
|
||||
return {
|
||||
action: PaymentActions.SUCCESSFUL,
|
||||
action: PaymentActions.CANCELED,
|
||||
data: {
|
||||
session_id: intent.metadata.session_id,
|
||||
amount: getAmountFromSmallestUnit(intent.amount_received, currency),
|
||||
amount: getAmountFromSmallestUnit(intent.amount, currency),
|
||||
},
|
||||
}
|
||||
case "payment_intent.payment_failed":
|
||||
@@ -447,6 +454,34 @@ abstract class StripeBase extends AbstractPaymentProvider<StripeOptions> {
|
||||
amount: getAmountFromSmallestUnit(intent.amount, currency),
|
||||
},
|
||||
}
|
||||
case "payment_intent.requires_action":
|
||||
return {
|
||||
action: PaymentActions.REQUIRES_MORE,
|
||||
data: {
|
||||
session_id: intent.metadata.session_id,
|
||||
amount: getAmountFromSmallestUnit(intent.amount, currency),
|
||||
},
|
||||
}
|
||||
case "payment_intent.amount_capturable_updated":
|
||||
return {
|
||||
action: PaymentActions.AUTHORIZED,
|
||||
data: {
|
||||
session_id: intent.metadata.session_id,
|
||||
amount: getAmountFromSmallestUnit(
|
||||
intent.amount_capturable,
|
||||
currency
|
||||
),
|
||||
},
|
||||
}
|
||||
case "payment_intent.succeeded":
|
||||
return {
|
||||
action: PaymentActions.SUCCESSFUL,
|
||||
data: {
|
||||
session_id: intent.metadata.session_id,
|
||||
amount: getAmountFromSmallestUnit(intent.amount_received, currency),
|
||||
},
|
||||
}
|
||||
|
||||
default:
|
||||
return { action: PaymentActions.NOT_SUPPORTED }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user