feat: Implement medusa payments provider (#13772)

* feat: Implement medusa payments provider

* chore: Improvements after testing

* chore: Add typings to medusa payments

* fix: Final changes to complete medusa payment provider

* update package

* fix: Final changes to complete medusa payment provider

---------

Co-authored-by: adrien2p <adrien.deperetti@gmail.com>
Co-authored-by: Oli Juhl <59018053+olivermrbl@users.noreply.github.com>
This commit is contained in:
Stevche Radevski
2025-10-29 15:07:33 +01:00
committed by GitHub
parent 1defb3c29b
commit ef7b9b9375
19 changed files with 1182 additions and 23 deletions

View File

@@ -7,9 +7,50 @@ jest.setTimeout(30000)
moduleIntegrationTestRunner<IPaymentModuleService>({
moduleName: Modules.PAYMENT,
moduleOptions: {
cloud: {
api_key: "test",
environment_handle: "test",
webhook_secret: "test",
endpoint: "test",
},
},
testSuite: ({ service }) => {
describe("Payment Module Service", () => {
describe("providers", () => {
it("should load the system and medusa payments providers by default", async () => {
const paymentProviders = await service.listPaymentProviders()
expect(paymentProviders).toHaveLength(2)
expect(paymentProviders).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: "pp_system_default",
}),
expect.objectContaining({
id: "pp_medusa-payments_default",
}),
])
)
})
it("should create a payment session successfully", async () => {
const paymentCollection = await service.createPaymentCollections({
currency_code: "USD",
amount: 200,
})
const paymentSession = await service.createPaymentSession(
paymentCollection.id,
{
provider_id: "pp_system_default",
amount: 200,
currency_code: "USD",
data: {},
}
)
})
it("should load payment plugins", async () => {
let error = await service
.createPaymentCollections([
@@ -46,3 +87,16 @@ moduleIntegrationTestRunner<IPaymentModuleService>({
})
},
})
moduleIntegrationTestRunner<IPaymentModuleService>({
moduleName: Modules.PAYMENT,
moduleOptions: {},
testSuite: ({ service }) =>
describe("providers", () => {
it("should not load the medusa payments provider if the cloud options are not provided", async () => {
const paymentProviders = await service.listPaymentProviders()
expect(paymentProviders).toHaveLength(1)
expect(paymentProviders[0].id).toBe("pp_system_default")
})
}),
})

View File

@@ -47,5 +47,8 @@
},
"peerDependencies": {
"@medusajs/framework": "2.11.1"
},
"dependencies": {
"stripe": "19.1.0"
}
}

View File

@@ -41,11 +41,46 @@ export default async ({
(
| ModulesSdkTypes.ModuleServiceInitializeOptions
| ModulesSdkTypes.ModuleServiceInitializeCustomDataLayerOptions
) & { providers: ModuleProvider[] }
) & {
providers: ModuleProvider[]
cloud: {
api_key?: string
endpoint?: string
environment_handle?: string
sandbox_handle?: string
webhook_secret?: string
}
}
>): Promise<void> => {
// Local providers
for (const provider of Object.values(providers)) {
await registrationFn(provider, container, { id: "default" })
await registrationFn(providers.SystemPaymentProvider, container, {
id: "default",
})
// We only want to register medusa payments if the options for it have been provided.
const {
api_key,
endpoint,
environment_handle,
sandbox_handle,
webhook_secret,
} = options?.cloud ?? {}
if (
api_key &&
endpoint &&
webhook_secret &&
(environment_handle || sandbox_handle)
) {
await registrationFn(providers.MedusaPaymentsProvider, container, {
options: {
api_key,
endpoint,
environment_handle,
sandbox_handle,
webhook_secret,
},
id: "default",
})
}
await moduleProviderLoader({

View File

@@ -1 +1,2 @@
export { default as SystemPaymentProvider } from "./system"
export { SystemPaymentProvider } from "./system"
export { MedusaPaymentsProvider } from "./payment-medusa"

View File

@@ -0,0 +1 @@
export { MedusaPaymentsProvider } from "./services/medusa-payments"

View File

@@ -0,0 +1,758 @@
import { setTimeout } from "timers/promises"
import stripe from "stripe"
import {
AuthorizePaymentInput,
AuthorizePaymentOutput,
CancelPaymentInput,
CancelPaymentOutput,
CapturePaymentInput,
CapturePaymentOutput,
RetrieveAccountHolderInput,
RetrieveAccountHolderOutput,
CreateAccountHolderInput,
CreateAccountHolderOutput,
DeleteAccountHolderInput,
DeleteAccountHolderOutput,
DeletePaymentInput,
DeletePaymentOutput,
GetPaymentStatusInput,
GetPaymentStatusOutput,
InitiatePaymentInput,
InitiatePaymentOutput,
ListPaymentMethodsInput,
ListPaymentMethodsOutput,
ProviderWebhookPayload,
RefundPaymentInput,
RefundPaymentOutput,
RetrievePaymentInput,
RetrievePaymentOutput,
SavePaymentMethodInput,
SavePaymentMethodOutput,
UpdateAccountHolderInput,
UpdateAccountHolderOutput,
UpdatePaymentInput,
UpdatePaymentOutput,
WebhookActionResult,
} from "@medusajs/framework/types"
import {
AbstractPaymentProvider,
isDefined,
isPresent,
PaymentActions,
PaymentSessionStatus,
} from "@medusajs/framework/utils"
import { MedusaPaymentsOptions } from "../types"
import {
getAmountFromSmallestUnit,
getSmallestUnit,
} from "../utils/get-smallest-unit"
import {
CreateAccountHolderRequest,
CreatePaymentRequest,
MedusaAccountHolder,
MedusaPayment,
MedusaPaymentMethod,
MedusaPaymentMethodSession,
MedusaRefund,
RefundPaymentRequest,
UpdateAccountHolderRequest,
} from "../types/medusa-payments"
type HandledErrorType = { retry: true } | { retry: false; data: any }
class CloudServiceError extends Error {
type: string
originalType: string
data: any
message: string
constructor(type: string, originalType: string, data: any, message: string) {
super(message)
this.type = type
this.originalType = originalType
this.data = data
this.message = message
}
}
export class MedusaPaymentsProvider extends AbstractPaymentProvider<MedusaPaymentsOptions> {
static identifier = "medusa-payments"
protected readonly options_: MedusaPaymentsOptions
protected container_: Record<string, unknown>
// The stripe client is used to construct the webhook event, since we construct it the same way as Stripe does.
protected readonly stripeClient: stripe
// The provider is loaded in a different a bit differently - it is not passed as a provider but the options are passed to the module's configuration.
// Due to that, the validation needs to happen in the constructor
static validateOptions(options: MedusaPaymentsOptions): void {
return validateOptions(options)
}
constructor(cradle: Record<string, unknown>, options: MedusaPaymentsOptions) {
super(cradle, options)
validateOptions(options ?? {})
this.options_ = options
this.stripeClient = new stripe(options.api_key)
}
request<T>(
url: string,
options: Omit<RequestInit, "body"> & { body?: object }
): Promise<T> {
const headers = {
"Content-Type": "application/json",
Authorization: `Basic ${this.options_.api_key}`,
}
if (this.options_.environment_handle) {
headers["x-medusa-environment-handle"] = this.options_.environment_handle
}
if (this.options_.sandbox_handle) {
headers["x-medusa-sandbox-handle"] = this.options_.sandbox_handle
}
return fetch(`${this.options_.endpoint}${url}`, {
...options,
body: options.body ? JSON.stringify(options.body) : undefined,
headers: {
...options.headers,
...headers,
},
}).then(async (res) => {
const body = await res.json().catch(() => ({}))
if (!res.ok) {
throw new CloudServiceError(
body.type,
body.originalType,
body.data,
body.message
)
}
return body
})
}
normalizePaymentParameters(
extra?: Record<string, string>
): Partial<CreatePaymentRequest> {
const res = {
description: extra?.payment_description ?? "",
capture_method: extra?.capture_method as "automatic" | "manual",
setup_future_usage: extra?.setup_future_usage,
payment_method_types: extra?.payment_method_types,
payment_method_data: extra?.payment_method_data,
payment_method_options: extra?.payment_method_options,
automatic_payment_methods: extra?.automatic_payment_methods,
off_session: extra?.off_session,
confirm: extra?.confirm,
payment_method: extra?.payment_method,
return_url: extra?.return_url,
shared_payment_token: extra?.shared_payment_token,
} as Partial<CreatePaymentRequest>
return res
}
handleStripeError(error: CloudServiceError): HandledErrorType {
switch (error.type) {
case "MedusaCardError":
// Medusa has created a payment but it failed
// Extract and return payment object to be stored in payment_session
// Allows for reference to the failed intent and potential webhook reconciliation
const medusaPayment = error.data as MedusaPayment | undefined
if (medusaPayment) {
return {
retry: false,
data: medusaPayment,
}
} else {
throw error
}
case "MedusaConnectionError":
case "MedusaRateLimitError":
// Connection or rate limit errors indicate an uncertain result
// Retry the operation
return {
retry: true,
}
case "MedusaAPIError": {
// API errors should be treated as indeterminate per Stripe documentation
// Rely on webhooks rather than assuming failure
return {
retry: false,
data: {
indeterminate_due_to: "medusa_api_error",
},
}
}
default:
throw error
}
}
async executeWithRetry<T>(
apiCall: () => Promise<T>,
maxRetries: number = 3,
baseDelay: number = 1000,
currentAttempt: number = 1
): Promise<T> {
try {
return await apiCall()
} catch (error) {
const handledError = this.handleStripeError(error)
if (!handledError.retry) {
// If retry is false, we know data exists per the type definition
return handledError.data
}
if (handledError.retry && currentAttempt <= maxRetries) {
// Logic for retrying
const delay =
baseDelay *
Math.pow(2, currentAttempt - 1) *
(0.5 + Math.random() * 0.5)
await setTimeout(delay)
return this.executeWithRetry(
apiCall,
maxRetries,
baseDelay,
currentAttempt + 1
)
}
// Retries are exhausted
throw error
}
}
async getPaymentStatus(
input: GetPaymentStatusInput
): Promise<GetPaymentStatusOutput> {
const id = input?.data?.id as string
if (!id) {
throw new Error(
"No payment intent ID provided while getting payment status"
)
}
const payment = await this.retrievePayment({ data: { id } })
return {
status: payment.data?.status as PaymentSessionStatus,
data: payment.data,
}
}
async initiatePayment({
currency_code,
amount,
data,
context,
}: InitiatePaymentInput): Promise<InitiatePaymentOutput> {
const additionalParameters = this.normalizePaymentParameters(
data as Record<string, string>
)
const intentRequest = {
amount: getSmallestUnit(amount, currency_code),
currency: currency_code,
metadata: {
session_id: data?.session_id as string,
},
account_holder_id: context?.account_holder?.data?.id as
| string
| undefined,
idempotency_key: context?.idempotency_key,
...additionalParameters,
} as CreatePaymentRequest
const payment = (await this.executeWithRetry(() => {
return this.request<{ payment: any }>("/payments", {
method: "POST",
body: intentRequest,
}).then((data) => data.payment)
})) as MedusaPayment
return {
id: payment.id,
...this.getStatus(payment),
}
}
async authorizePayment(
input: AuthorizePaymentInput
): Promise<AuthorizePaymentOutput> {
return this.getPaymentStatus(input)
}
async cancelPayment({
data,
context,
}: CancelPaymentInput): Promise<CancelPaymentOutput> {
const id = data?.id as string
if (!id) {
return { data: data }
}
const intent = (await this.executeWithRetry(() => {
return this.request<{ payment: any }>(`/payments/${id}/cancel`, {
method: "POST",
body: {
idempotency_key: context?.idempotency_key,
},
}).then((data) => data.payment)
})) as MedusaPayment
return { data: intent as unknown as Record<string, unknown> }
}
async capturePayment({
data,
context,
}: CapturePaymentInput): Promise<CapturePaymentOutput> {
const id = data?.id as string
const intent = (await this.executeWithRetry(() => {
return this.request<{ payment: any }>(`/payments/${id}/capture`, {
method: "POST",
body: {
idempotency_key: context?.idempotency_key,
},
}).then((data) => data.payment)
})) as MedusaPayment
return { data: intent as unknown as Record<string, unknown> }
}
async deletePayment(input: DeletePaymentInput): Promise<DeletePaymentOutput> {
return await this.cancelPayment(input)
}
async refundPayment({
amount,
data,
context,
}: RefundPaymentInput): Promise<RefundPaymentOutput> {
const id = data?.id as string
if (!id) {
throw new Error("No payment intent ID provided while refunding payment")
}
const currencyCode = data?.currency as string
const response = (await this.executeWithRetry(() => {
return this.request<{ refund: any }>(`/payments/${id}/refund`, {
method: "POST",
body: {
amount: getSmallestUnit(amount, currencyCode),
idempotency_key: context?.idempotency_key,
} as RefundPaymentRequest,
}).then((data) => data.refund)
})) as MedusaRefund
return { data: response as unknown as Record<string, unknown> }
}
async retrievePayment({
data,
}: RetrievePaymentInput): Promise<RetrievePaymentOutput> {
const id = data?.id as string
const intent = (await this.executeWithRetry(() => {
return this.request<{ payment: any }>(`/payments/${id}`, {
method: "GET",
}).then((data) => data.payment)
})) as MedusaPayment
intent.amount = getAmountFromSmallestUnit(intent.amount, intent.currency)
return { data: intent as unknown as Record<string, unknown> }
}
async updatePayment({
data,
currency_code,
amount,
context,
}: UpdatePaymentInput): Promise<UpdatePaymentOutput> {
const amountNumeric = getSmallestUnit(amount, currency_code)
if (isPresent(amount) && data?.amount === amountNumeric) {
return this.getStatus(
data as unknown as MedusaPayment
) as unknown as UpdatePaymentOutput
}
const id = data?.id as string
const sessionData = (await this.executeWithRetry(() => {
return this.request<{ payment: any }>(`/payments/${id}`, {
method: "POST",
body: {
amount: amountNumeric,
idempotency_key: context?.idempotency_key,
},
}).then((data) => data.payment)
})) as MedusaPayment
return this.getStatus(sessionData)
}
async retrieveAccountHolder({
id,
}: RetrieveAccountHolderInput): Promise<RetrieveAccountHolderOutput> {
if (!id) {
throw new Error(
"No account holder ID provided while getting account holder"
)
}
const res = (await this.executeWithRetry(() => {
return this.request<{ account_holder: any }>(`/account-holders/${id}`, {
method: "GET",
}).then((data) => data.account_holder)
})) as MedusaAccountHolder
return {
id: res.id,
data: res as unknown as Record<string, unknown>,
}
}
async createAccountHolder({
context,
}: CreateAccountHolderInput): Promise<CreateAccountHolderOutput> {
const { account_holder, customer, idempotency_key } = context
if (account_holder?.data?.id) {
return { id: account_holder.data.id as string }
}
if (!customer) {
throw new Error("No customer provided while creating account holder")
}
const shipping = customer.billing_address
? {
address: {
city: customer.billing_address.city,
country: customer.billing_address.country_code,
line1: customer.billing_address.address_1,
line2: customer.billing_address.address_2,
postal_code: customer.billing_address.postal_code,
state: customer.billing_address.province,
},
}
: undefined
const accountHolder = (await this.executeWithRetry(() => {
return this.request<{ account_holder: any }>(`/account-holders`, {
method: "POST",
body: {
email: customer.email,
name:
customer.company_name ||
`${customer.first_name ?? ""} ${customer.last_name ?? ""}`.trim() ||
undefined,
phone: customer.phone as string | undefined,
...shipping,
idempotency_key: idempotency_key,
} as CreateAccountHolderRequest,
}).then((data) => data.account_holder)
})) as MedusaAccountHolder
return {
id: accountHolder.id,
data: accountHolder as unknown as Record<string, unknown>,
}
}
async updateAccountHolder({
context,
}: UpdateAccountHolderInput): Promise<UpdateAccountHolderOutput> {
const { account_holder, customer, idempotency_key } = context
if (!account_holder?.data?.id) {
throw new Error(
"No account holder in context while updating account holder"
)
}
// If no customer context was provided, we simply don't update anything within the provider
if (!customer) {
return {}
}
const accountHolderId = account_holder.data.id as string
const shipping = customer.billing_address
? {
address: {
city: customer.billing_address.city,
country: customer.billing_address.country_code,
line1: customer.billing_address.address_1,
line2: customer.billing_address.address_2,
postal_code: customer.billing_address.postal_code,
state: customer.billing_address.province,
},
}
: undefined
const accountHolder = (await this.executeWithRetry(() => {
return this.request<{ account_holder: any }>(
`/account-holders/${accountHolderId}`,
{
method: "POST",
body: {
email: customer.email,
name:
customer.company_name ||
`${customer.first_name ?? ""} ${
customer.last_name ?? ""
}`.trim() ||
undefined,
phone: customer.phone as string | undefined,
...shipping,
idempotency_key: idempotency_key,
} as UpdateAccountHolderRequest,
}
).then((data) => data.account_holder)
})) as MedusaAccountHolder
return {
data: accountHolder as unknown as Record<string, unknown>,
}
}
async deleteAccountHolder({
context,
}: DeleteAccountHolderInput): Promise<DeleteAccountHolderOutput> {
const { account_holder } = context
const accountHolderId = account_holder?.data?.id as string | undefined
if (!accountHolderId) {
throw new Error(
"No account holder in context while deleting account holder"
)
}
await this.executeWithRetry(() => {
return this.request<{ account_holder: any }>(
`/account-holders/${accountHolderId}`,
{
method: "DELETE",
}
)
})
return {}
}
async listPaymentMethods({
context,
}: ListPaymentMethodsInput): Promise<ListPaymentMethodsOutput> {
const accountHolderId = context?.account_holder?.data?.id as
| string
| undefined
if (!accountHolderId) {
return []
}
const paymentMethods = (await this.executeWithRetry(() => {
return this.request<{ payment_methods: any[] }>(
`/payment-methods?account_holder_id=${accountHolderId}`,
{
method: "GET",
}
).then((data) => data.payment_methods)
})) as MedusaPaymentMethod[]
return paymentMethods.map((method) => ({
id: method.id,
data: method as unknown as Record<string, unknown>,
}))
}
async savePaymentMethod({
context,
data,
}: SavePaymentMethodInput): Promise<SavePaymentMethodOutput> {
const accountHolderId = context?.account_holder?.data?.id as
| string
| undefined
if (!accountHolderId) {
throw new Error("Account holder not set while saving a payment method")
}
const paymentMethodSession = (await this.executeWithRetry(() => {
return this.request<{ payment_method_session: any }>(`/payment-methods`, {
method: "POST",
body: {
account_holder_id: accountHolderId,
...data,
idempotency_key: context?.idempotency_key,
},
}).then((data) => data.payment_method_session)
})) as MedusaPaymentMethodSession
return {
id: paymentMethodSession.id,
data: paymentMethodSession as unknown as Record<string, unknown>,
}
}
private getStatus(payment: MedusaPayment) {
const paymenAsRecord = payment as unknown as Record<string, unknown>
switch (payment.status) {
case "requires_payment_method":
if (payment.last_payment_error) {
return { status: PaymentSessionStatus.ERROR, data: paymenAsRecord }
}
return { status: PaymentSessionStatus.PENDING, data: paymenAsRecord }
case "requires_confirmation":
case "processing":
return { status: PaymentSessionStatus.PENDING, data: paymenAsRecord }
case "requires_action":
return {
status: PaymentSessionStatus.REQUIRES_MORE,
data: paymenAsRecord,
}
case "canceled":
return { status: PaymentSessionStatus.CANCELED, data: paymenAsRecord }
case "requires_capture":
return { status: PaymentSessionStatus.AUTHORIZED, data: paymenAsRecord }
case "succeeded":
return { status: PaymentSessionStatus.CAPTURED, data: paymenAsRecord }
default:
return { status: PaymentSessionStatus.PENDING, data: paymenAsRecord }
}
}
async getWebhookActionAndData(
webhookData: ProviderWebhookPayload["payload"]
): Promise<WebhookActionResult> {
const event = this.constructWebhookEvent(webhookData)
const intent = event.data.object as stripe.PaymentIntent
const { currency } = intent
switch (event.type) {
case "payment_intent.created":
case "payment_intent.processing":
return {
action: PaymentActions.PENDING,
data: {
session_id: intent.metadata.session_id,
amount: getAmountFromSmallestUnit(intent.amount, currency),
},
}
case "payment_intent.canceled":
return {
action: PaymentActions.CANCELED,
data: {
session_id: intent.metadata.session_id,
amount: getAmountFromSmallestUnit(intent.amount, currency),
},
}
case "payment_intent.payment_failed":
return {
action: PaymentActions.FAILED,
data: {
session_id: intent.metadata.session_id,
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.partially_funded":
return {
action: PaymentActions.REQUIRES_MORE,
data: {
session_id: intent.metadata.session_id,
amount: getAmountFromSmallestUnit(
intent.next_action?.display_bank_transfer_instructions
?.amount_remaining ?? intent.amount,
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 }
}
}
/**
* Constructs Medusa Payments Webhook event
* @param {object} data - the data of the webhook request: req.body
* ensures integrity of the webhook event
* @return {object} Medusa Payments Webhook event
*/
constructWebhookEvent(data: ProviderWebhookPayload["payload"]) {
const signature = data.headers["medusa-payments-signature"] as string
const stripeEvent = this.stripeClient.webhooks.constructEvent(
data.rawData as string | Buffer,
signature,
this.options_.webhook_secret
)
return stripeEvent
}
}
const validateOptions = (options: MedusaPaymentsOptions): void => {
if (!isDefined(options.endpoint)) {
throw new Error(
"Required option `endpoint` is missing in Medusa payments plugin"
)
}
if (!isDefined(options.webhook_secret)) {
throw new Error(
"Required option `webhook_secret` is missing in Medusa payments plugin"
)
}
if (!isDefined(options.api_key)) {
throw new Error(
"Required option `api_key` is missing in Medusa payments plugin"
)
}
if (
!isDefined(options.environment_handle) &&
!isDefined(options.sandbox_handle)
) {
throw new Error(
"Required option `environment_handle` or `sandbox_handle` is missing in Medusa payments plugin"
)
}
}

View File

@@ -0,0 +1,22 @@
export interface MedusaPaymentsOptions {
/**
* The API key for the Stripe account
*/
api_key: string
/**
* The webhook secret used to verify webhooks
*/
webhook_secret: string
/**
* The endpoint to use for the payments
*/
endpoint: string
/**
* The handle of the cloud environment
*/
environment_handle: string
/**
* The handle of the cloud sandbox
*/
sandbox_handle: string
}

View File

@@ -0,0 +1,30 @@
import stripe from "stripe"
export interface CreatePaymentRequest extends stripe.PaymentIntentCreateParams {
account_holder_id?: string
idempotency_key?: string
}
export interface MedusaPayment extends stripe.PaymentIntent {
account_holder_id?: string
}
export interface RefundPaymentRequest extends stripe.RefundCreateParams {
idempotency_key?: string
}
export interface MedusaRefund extends stripe.Refund {}
export interface CreateAccountHolderRequest
extends stripe.CustomerCreateParams {}
export interface UpdateAccountHolderRequest
extends stripe.CustomerUpdateParams {}
export interface MedusaAccountHolder extends stripe.Customer {}
export interface MedusaPaymentMethod extends stripe.PaymentMethod {
account_holder_id?: string
}
export interface MedusaPaymentMethodSession extends stripe.SetupIntent {}

View File

@@ -0,0 +1,21 @@
import { getSmallestUnit } from "../get-smallest-unit"
describe("getSmallestUnit", () => {
it("should convert an amount to the format required by Stripe based on currency", () => {
// 0 decimals
expect(getSmallestUnit(50098, "JPY")).toBe(50098)
// 3 decimals
expect(getSmallestUnit(5.124, "KWD")).toBe(5130)
// 2 decimals
expect(getSmallestUnit(2.675, "USD")).toBe(268)
expect(getSmallestUnit(100.54, "USD")).toBe(10054)
expect(getSmallestUnit(5.126, "KWD")).toBe(5130)
expect(getSmallestUnit(0.54, "USD")).toBe(54)
expect(getSmallestUnit(0.054, "USD")).toBe(5)
expect(getSmallestUnit(0.005104, "USD")).toBe(1)
expect(getSmallestUnit(0.004104, "USD")).toBe(0)
})
})

View File

@@ -0,0 +1,79 @@
import { BigNumberInput } from "@medusajs/framework/types"
import { BigNumber, MathBN } from "@medusajs/framework/utils"
function getCurrencyMultiplier(currency) {
const currencyMultipliers = {
0: [
"BIF",
"CLP",
"DJF",
"GNF",
"JPY",
"KMF",
"KRW",
"MGA",
"PYG",
"RWF",
"UGX",
"VND",
"VUV",
"XAF",
"XOF",
"XPF",
],
3: ["BHD", "IQD", "JOD", "KWD", "OMR", "TND"],
}
currency = currency.toUpperCase()
let power = 2
for (const [key, value] of Object.entries(currencyMultipliers)) {
if (value.includes(currency)) {
power = parseInt(key, 10)
break
}
}
return Math.pow(10, power)
}
/**
* Converts an amount to the format required by Stripe based on currency.
* https://docs.stripe.com/currencies
* @param {BigNumberInput} amount - The amount to be converted.
* @param {string} currency - The currency code (e.g., 'USD', 'JOD').
* @returns {number} - The converted amount in the smallest currency unit.
*/
export function getSmallestUnit(
amount: BigNumberInput,
currency: string
): number {
const multiplier = getCurrencyMultiplier(currency)
let amount_ =
Math.round(new BigNumber(MathBN.mult(amount, multiplier)).numeric) /
multiplier
const smallestAmount = new BigNumber(MathBN.mult(amount_, multiplier))
let numeric = smallestAmount.numeric
// Check if the currency requires rounding to the nearest ten
if (multiplier === 1e3) {
numeric = Math.ceil(numeric / 10) * 10
}
return parseInt(numeric.toString().split(".").shift()!, 10)
}
/**
* Converts an amount from the smallest currency unit to the standard unit based on currency.
* @param {BigNumberInput} amount - The amount in the smallest currency unit.
* @param {string} currency - The currency code (e.g., 'USD', 'JOD').
* @returns {number} - The converted amount in the standard currency unit.
*/
export function getAmountFromSmallestUnit(
amount: BigNumberInput,
currency: string
): number {
const multiplier = getCurrencyMultiplier(currency)
const standardAmount = new BigNumber(MathBN.div(amount, multiplier))
return standardAmount.numeric
}

View File

@@ -7,6 +7,8 @@ import {
CancelPaymentOutput,
CapturePaymentInput,
CapturePaymentOutput,
RetrieveAccountHolderInput,
RetrieveAccountHolderOutput,
CreateAccountHolderInput,
CreateAccountHolderOutput,
DeleteAccountHolderInput,
@@ -32,7 +34,7 @@ import {
PaymentSessionStatus,
} from "@medusajs/framework/utils"
export class SystemProviderService extends AbstractPaymentProvider {
export class SystemPaymentProvider extends AbstractPaymentProvider {
static identifier = "system"
async getStatus(_): Promise<string> {
@@ -81,6 +83,12 @@ export class SystemProviderService extends AbstractPaymentProvider {
return { data: {} }
}
async retrieveAccountHolder(
input: RetrieveAccountHolderInput
): Promise<RetrieveAccountHolderOutput> {
return { id: input.id }
}
async createAccountHolder(
input: CreateAccountHolderInput
): Promise<CreateAccountHolderOutput> {
@@ -108,4 +116,4 @@ export class SystemProviderService extends AbstractPaymentProvider {
}
}
export default SystemProviderService
export default SystemPaymentProvider

View File

@@ -5,6 +5,8 @@ import {
CancelPaymentOutput,
CapturePaymentInput,
CapturePaymentOutput,
RetrieveAccountHolderInput,
RetrieveAccountHolderOutput,
CreateAccountHolderInput,
CreateAccountHolderOutput,
DAL,
@@ -140,6 +142,21 @@ Please make sure that the provider is registered in the container and it is conf
return await provider.refundPayment(input)
}
async retrieveAccountHolder(
providerId: string,
input: RetrieveAccountHolderInput
): Promise<RetrieveAccountHolderOutput> {
const provider = this.retrieveProvider(providerId)
if (!provider.retrieveAccountHolder) {
this.#logger.warn(
`Provider ${providerId} does not support retrieving account holders`
)
return {} as unknown as RetrieveAccountHolderOutput
}
return await provider.retrieveAccountHolder(input)
}
async createAccountHolder(
providerId: string,
input: CreateAccountHolderInput

View File

@@ -53,6 +53,7 @@ import {
type StripeIndeterminateState = {
indeterminate_due_to: string
}
type StripeErrorData = Stripe.PaymentIntent | StripeIndeterminateState
type HandledErrorType =
| { retry: true }
@@ -128,9 +129,7 @@ abstract class StripeBase extends AbstractPaymentProvider<StripeOptions> {
res.return_url = extra?.return_url as string | undefined
// @ts-expect-error - Need to update Stripe SDK
res.shared_payment_token = extra?.shared_payment_token as
| string
| undefined
res.shared_payment_token = extra?.shared_payment_token as string | undefined
return res
}
@@ -248,7 +247,10 @@ abstract class StripeBase extends AbstractPaymentProvider<StripeOptions> {
const intentRequest: Stripe.PaymentIntentCreateParams = {
amount: getSmallestUnit(amount, currency_code),
currency: currency_code,
metadata: { ...(data?.metadata ?? {}), session_id: data?.session_id as string },
metadata: {
...(data?.metadata ?? {}),
session_id: data?.session_id as string,
},
...additionalParameters,
}