diff --git a/.changeset/swift-frogs-collect.md b/.changeset/swift-frogs-collect.md new file mode 100644 index 0000000000..dd1711553e --- /dev/null +++ b/.changeset/swift-frogs-collect.md @@ -0,0 +1,7 @@ +--- +"@medusajs/medusa": patch +"@medusajs/types": patch +"@medusajs/utils": patch +--- + +feat(payment-stripe): new Stripe payment provider diff --git a/packages/auth/src/providers/google.ts b/packages/auth/src/providers/google.ts index 557daa36cc..a66d21e73c 100644 --- a/packages/auth/src/providers/google.ts +++ b/packages/auth/src/providers/google.ts @@ -1,12 +1,12 @@ -import { AbstractAuthModuleProvider, MedusaError } from "@medusajs/utils" import { AuthenticationInput, AuthenticationResponse, ModulesSdkTypes, } from "@medusajs/types" +import { AbstractAuthModuleProvider, MedusaError } from "@medusajs/utils" +import { AuthUserService } from "@services" import jwt, { JwtPayload } from "jsonwebtoken" -import { AuthUserService } from "@services" import { AuthorizationCode } from "simple-oauth2" import url from "url" diff --git a/packages/medusa-payment-stripe/src/api/utils/utils.ts b/packages/medusa-payment-stripe/src/api/utils/utils.ts index 4b5eb43bc1..7bf2c200cf 100644 --- a/packages/medusa-payment-stripe/src/api/utils/utils.ts +++ b/packages/medusa-payment-stripe/src/api/utils/utils.ts @@ -4,12 +4,10 @@ import { IdempotencyKeyService, PostgresError, } from "@medusajs/medusa" -import { ConfigModule, MedusaContainer } from "@medusajs/types" import { MedusaError } from "@medusajs/utils" import { AwilixContainer } from "awilix" import { EOL } from "os" import Stripe from "stripe" -import { StripeOptions } from "../../types" const PAYMENT_PROVIDER_KEY = "pp_stripe" @@ -179,7 +177,7 @@ async function capturePaymenCollectiontIfNecessary({ await manager.transaction(async (manager) => { await paymentCollectionService .withTransaction(manager) - .capture(payment.id) + .capture(payment.id) // TODO: revisit - this method doesn't exists ATM }) } } @@ -257,4 +255,4 @@ async function completeCartIfNecessary({ ) } } -} \ No newline at end of file +} diff --git a/packages/medusa/src/api-v2/hooks/middlewares.ts b/packages/medusa/src/api-v2/hooks/middlewares.ts new file mode 100644 index 0000000000..109d7bb5cc --- /dev/null +++ b/packages/medusa/src/api-v2/hooks/middlewares.ts @@ -0,0 +1,9 @@ +import { MiddlewareRoute } from "../../types/middlewares" + +export const hooksRoutesMiddlewares: MiddlewareRoute[] = [ + { + method: ["POST"], + bodyParser: { preserveRawBody: true }, + matcher: "/hooks/payment/:provider", + }, +] diff --git a/packages/medusa/src/api-v2/hooks/payment/[provider]/route.ts b/packages/medusa/src/api-v2/hooks/payment/[provider]/route.ts new file mode 100644 index 0000000000..fb3a592200 --- /dev/null +++ b/packages/medusa/src/api-v2/hooks/payment/[provider]/route.ts @@ -0,0 +1,32 @@ +import { ModuleRegistrationName } from "@medusajs/modules-sdk" +import { PaymentWebhookEvents } from "@medusajs/utils" +import { PaymentModuleOptions } from "@medusajs/types" + +import { MedusaRequest, MedusaResponse } from "../../../../types/routing" + +export const POST = async (req: MedusaRequest, res: MedusaResponse) => { + try { + const { provider } = req.params + + const options: PaymentModuleOptions = + req.scope.resolve(ModuleRegistrationName.PAYMENT).options || {} + + const event = { + provider, + payload: { data: req.body, rawData: req.rawBody, headers: req.headers }, + } + + const eventBus = req.scope.resolve("eventBusService") + + // we delay the processing of the event to avoid a conflict caused by a race condition + await eventBus.emit(PaymentWebhookEvents.WebhookReceived, event, { + delay: options.webhook_delay || 5000, + attempts: options.webhook_retries || 3, + }) + } catch (err) { + res.status(400).send(`Webhook Error: ${err.message}`) + return + } + + res.sendStatus(200) +} diff --git a/packages/medusa/src/api-v2/middlewares.ts b/packages/medusa/src/api-v2/middlewares.ts index 60ba673a46..11b048d0e9 100644 --- a/packages/medusa/src/api-v2/middlewares.ts +++ b/packages/medusa/src/api-v2/middlewares.ts @@ -12,6 +12,7 @@ import { authRoutesMiddlewares } from "./auth/middlewares" import { storeCartRoutesMiddlewares } from "./store/carts/middlewares" import { storeCustomerRoutesMiddlewares } from "./store/customers/middlewares" import { storeRegionRoutesMiddlewares } from "./store/regions/middlewares" +import { hooksRoutesMiddlewares } from "./hooks/middlewares" export const config: MiddlewaresConfig = { routes: [ @@ -29,5 +30,6 @@ export const config: MiddlewaresConfig = { ...adminUserRoutesMiddlewares, ...adminInviteRoutesMiddlewares, ...adminApiKeyRoutesMiddlewares, + ...hooksRoutesMiddlewares, ], } diff --git a/packages/medusa/src/loaders/api.ts b/packages/medusa/src/loaders/api.ts index d5314b8142..478d6c2f47 100644 --- a/packages/medusa/src/loaders/api.ts +++ b/packages/medusa/src/loaders/api.ts @@ -33,8 +33,6 @@ export default async ({ next() }) - app.use(bodyParser.json()) - if (featureFlagRouter?.isFeatureEnabled(FeatureFlagUtils.MedusaV2Flag.key)) { // TODO: Figure out why this is causing issues with test when placed inside ./api.ts // Adding this here temporarily @@ -52,6 +50,7 @@ export default async ({ throw Error("An error occurred while registering Medusa Core API Routes") } } else { + app.use(bodyParser.json()) app.use("/", routes(container, configModule.projectConfig)) } diff --git a/packages/medusa/src/loaders/helpers/routing/index.ts b/packages/medusa/src/loaders/helpers/routing/index.ts index 0fea3ad87a..ab8d31749f 100644 --- a/packages/medusa/src/loaders/helpers/routing/index.ts +++ b/packages/medusa/src/loaders/helpers/routing/index.ts @@ -22,7 +22,9 @@ import { RouteConfig, RouteDescriptor, RouteVerb, + ParserConfigArgs, } from "./types" +import { MedusaRequest, MedusaResponse } from "../../../types/routing" const log = ({ activityId, @@ -150,9 +152,18 @@ function findMatch( * Returns an array of body parser middlewares that are applied on routes * out-of-the-box. */ -function getBodyParserMiddleware(sizeLimit?: string | number | undefined) { +function getBodyParserMiddleware(args?: ParserConfigArgs) { + const sizeLimit = args?.sizeLimit + const preserveRawBody = args?.preserveRawBody return [ - json({ limit: sizeLimit }), + json({ + limit: sizeLimit, + verify: preserveRawBody + ? (req: MedusaRequest, res: MedusaResponse, buf: Buffer) => { + req.rawBody = buf + } + : undefined, + }), text({ limit: sizeLimit }), urlencoded({ limit: sizeLimit, extended: true }), ] @@ -556,7 +567,7 @@ export class RoutesLoader { this.router[method.toLowerCase()]( path, - ...getBodyParserMiddleware(sizeLimit) + ...getBodyParserMiddleware(mostSpecificConfig?.bodyParser) ) return diff --git a/packages/medusa/src/loaders/helpers/routing/types.ts b/packages/medusa/src/loaders/helpers/routing/types.ts index d5521f9009..63d691d1bc 100644 --- a/packages/medusa/src/loaders/helpers/routing/types.ts +++ b/packages/medusa/src/loaders/helpers/routing/types.ts @@ -55,11 +55,12 @@ export type MedusaErrorHandlerFunction = ( next: MedusaNextFunction ) => Promise | void -type ParserConfig = - | false - | { - sizeLimit?: string | number | undefined - } +export type ParserConfigArgs = { + sizeLimit?: string | number | undefined + preserveRawBody?: boolean +} + +type ParserConfig = false | ParserConfigArgs export type MiddlewareRoute = { method?: MiddlewareVerb | MiddlewareVerb[] diff --git a/packages/medusa/src/subscribers/payment-webhook.ts b/packages/medusa/src/subscribers/payment-webhook.ts new file mode 100644 index 0000000000..f71d31c9c9 --- /dev/null +++ b/packages/medusa/src/subscribers/payment-webhook.ts @@ -0,0 +1,50 @@ +import { PaymentWebhookEvents } from "@medusajs/utils" + +import { + IEventBusService, + IPaymentModuleService, + ProviderWebhookPayload, + Subscriber, +} from "@medusajs/types" +import { EventBusService } from "../services" + +type SerializedBuffer = { + data: ArrayBuffer + type: "Buffer" +} + +type InjectedDependencies = { + paymentModuleService: IPaymentModuleService + eventBusService: EventBusService +} + +class PaymentWebhookSubscriber { + private readonly eventBusService_: IEventBusService + private readonly paymentModuleService_: IPaymentModuleService + + constructor({ eventBusService, paymentModuleService }: InjectedDependencies) { + this.eventBusService_ = eventBusService + this.paymentModuleService_ = paymentModuleService + + this.eventBusService_.subscribe( + PaymentWebhookEvents.WebhookReceived, + this.processEvent as Subscriber + ) + } + + /** + * TODO: consider moving this to a workflow + */ + processEvent = async (data: ProviderWebhookPayload): Promise => { + if ( + (data.payload.rawData as unknown as SerializedBuffer).type === "Buffer" + ) { + data.payload.rawData = Buffer.from( + (data.payload.rawData as unknown as SerializedBuffer).data + ) + } + await this.paymentModuleService_.processEvent(data) + } +} + +export default PaymentWebhookSubscriber diff --git a/packages/medusa/src/types/routing.ts b/packages/medusa/src/types/routing.ts index d507385f25..0e1550d057 100644 --- a/packages/medusa/src/types/routing.ts +++ b/packages/medusa/src/types/routing.ts @@ -7,6 +7,7 @@ export interface MedusaRequest extends Request { user?: (User | Customer) & { customer_id?: string; userId?: string } scope: MedusaContainer session?: any + rawBody?: any requestId?: string auth_user?: { id: string; app_metadata: Record; scope: string } } diff --git a/packages/payment-stripe/.gitignore b/packages/payment-stripe/.gitignore new file mode 100644 index 0000000000..83cb36a41e --- /dev/null +++ b/packages/payment-stripe/.gitignore @@ -0,0 +1,4 @@ +dist +node_modules +.DS_store +yarn.lock diff --git a/packages/payment-stripe/CHANGELOG.md b/packages/payment-stripe/CHANGELOG.md new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/payment-stripe/README.md b/packages/payment-stripe/README.md new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/payment-stripe/jest.config.js b/packages/payment-stripe/jest.config.js new file mode 100644 index 0000000000..e564d67c70 --- /dev/null +++ b/packages/payment-stripe/jest.config.js @@ -0,0 +1,13 @@ +module.exports = { + globals: { + "ts-jest": { + tsconfig: "tsconfig.spec.json", + isolatedModules: false, + }, + }, + transform: { + "^.+\\.[jt]s?$": "ts-jest", + }, + testEnvironment: `node`, + moduleFileExtensions: [`js`, `jsx`, `ts`, `tsx`, `json`], +} diff --git a/packages/payment-stripe/package.json b/packages/payment-stripe/package.json new file mode 100644 index 0000000000..e1614feec2 --- /dev/null +++ b/packages/payment-stripe/package.json @@ -0,0 +1,48 @@ +{ + "name": "@medusajs/payment-stripe", + "version": "0.0.1", + "description": "Stripe payment provider for Medusa", + "main": "dist/index.js", + "repository": { + "type": "git", + "url": "https://github.com/medusajs/medusa", + "directory": "packages/payment-stripe" + }, + "files": [ + "dist" + ], + "engines": { + "node": ">=16" + }, + "author": "Medusa", + "license": "MIT", + "scripts": { + "prepublishOnly": "cross-env NODE_ENV=production tsc --build", + "test": "jest --passWithNoTests src", + "build": "rimraf dist && tsc -p ./tsconfig.json", + "watch": "tsc --watch" + }, + "devDependencies": { + "@medusajs/medusa": "^1.19.1", + "@types/stripe": "^8.0.417", + "awilix": "^8.0.1", + "cross-env": "^5.2.1", + "jest": "^25.5.4", + "rimraf": "^5.0.1", + "typescript": "^4.9.5" + }, + "peerDependencies": { + "@medusajs/medusa": "^1.12.0" + }, + "dependencies": { + "@medusajs/utils": "^1.11.3", + "body-parser": "^1.19.0", + "express": "^4.17.1", + "stripe": "latest" + }, + "gitHead": "81a7ff73d012fda722f6e9ef0bd9ba0232d37808", + "keywords": [ + "medusa-plugin", + "medusa-plugin-payment" + ] +} diff --git a/packages/payment-stripe/src/core/stripe-base.ts b/packages/payment-stripe/src/core/stripe-base.ts new file mode 100644 index 0000000000..04fbb5f540 --- /dev/null +++ b/packages/payment-stripe/src/core/stripe-base.ts @@ -0,0 +1,382 @@ +import { EOL } from "os" + +import Stripe from "stripe" + +import { + MedusaContainer, + PaymentSessionStatus, + PaymentProviderContext, + PaymentProviderError, + PaymentProviderSessionResponse, + ProviderWebhookPayload, + WebhookActionResult, +} from "@medusajs/types" +import { + PaymentActions, + AbstractPaymentProvider, + isPaymentProviderError, + MedusaError, +} from "@medusajs/utils" +import { isDefined } from "medusa-core-utils" + +import { + ErrorCodes, + ErrorIntentStatus, + PaymentIntentOptions, + StripeCredentials, + StripeOptions, +} from "../types" + +abstract class StripeBase extends AbstractPaymentProvider { + protected readonly options_: StripeOptions + protected stripe_: Stripe + protected container_: MedusaContainer + + protected constructor(container: MedusaContainer, options: StripeOptions) { + // @ts-ignore + super(...arguments) + + this.container_ = container + this.options_ = options + + this.stripe_ = this.init() + } + + protected init() { + this.validateOptions(this.config) + + return new Stripe(this.config.apiKey) + } + + abstract get paymentIntentOptions(): PaymentIntentOptions + + private validateOptions(options: StripeCredentials): void { + if (!isDefined(options.apiKey)) { + throw new Error("Required option `apiKey` is missing in Stripe plugin") + } + } + + get options(): StripeOptions { + return this.options_ + } + + getPaymentIntentOptions(): PaymentIntentOptions { + const options: PaymentIntentOptions = {} + + if (this?.paymentIntentOptions?.capture_method) { + options.capture_method = this.paymentIntentOptions.capture_method + } + + if (this?.paymentIntentOptions?.setup_future_usage) { + options.setup_future_usage = this.paymentIntentOptions.setup_future_usage + } + + if (this?.paymentIntentOptions?.payment_method_types) { + options.payment_method_types = + this.paymentIntentOptions.payment_method_types + } + + return options + } + + async getPaymentStatus( + paymentSessionData: Record + ): Promise { + const id = paymentSessionData.id as string + const paymentIntent = await this.stripe_.paymentIntents.retrieve(id) + + switch (paymentIntent.status) { + case "requires_payment_method": + case "requires_confirmation": + case "processing": + return PaymentSessionStatus.PENDING + case "requires_action": + return PaymentSessionStatus.REQUIRES_MORE + case "canceled": + return PaymentSessionStatus.CANCELED + case "requires_capture": + case "succeeded": + return PaymentSessionStatus.AUTHORIZED + default: + return PaymentSessionStatus.PENDING + } + } + + async initiatePayment( + context: PaymentProviderContext + ): Promise { + const intentRequestData = this.getPaymentIntentOptions() + const { + email, + context: cart_context, + currency_code, + amount, + resource_id, + customer, + } = context + + const description = (cart_context.payment_description ?? + this.options_?.payment_description) as string + + const intentRequest: Stripe.PaymentIntentCreateParams = { + description, + amount: Math.round(amount), + currency: currency_code, + metadata: { resource_id }, + capture_method: this.options_.capture ? "automatic" : "manual", + ...intentRequestData, + } + + if (this.options_?.automatic_payment_methods) { + intentRequest.automatic_payment_methods = { enabled: true } + } + + if (customer?.metadata?.stripe_id) { + intentRequest.customer = customer.metadata.stripe_id as string + } else { + let stripeCustomer + try { + stripeCustomer = await this.stripe_.customers.create({ + email, + }) + } catch (e) { + return this.buildError( + "An error occurred in initiatePayment when creating a Stripe customer", + e + ) + } + + intentRequest.customer = stripeCustomer.id + } + + let session_data + try { + session_data = (await this.stripe_.paymentIntents.create( + intentRequest + )) as unknown as Record + } catch (e) { + return this.buildError( + "An error occurred in InitiatePayment during the creation of the stripe payment intent", + e + ) + } + + return { + data: session_data, + // TODO: REVISIT + // update_requests: customer?.metadata?.stripe_id + // ? undefined + // : { + // customer_metadata: { + // stripe_id: intentRequest.customer, + // }, + // }, + } + } + + async authorizePayment( + paymentSessionData: Record, + context: Record + ): Promise< + | PaymentProviderError + | { + status: PaymentSessionStatus + data: PaymentProviderSessionResponse["data"] + } + > { + const status = await this.getPaymentStatus(paymentSessionData) + return { data: paymentSessionData, status } + } + + async cancelPayment( + paymentSessionData: Record + ): Promise { + try { + const id = paymentSessionData.id as string + return (await this.stripe_.paymentIntents.cancel( + id + )) as unknown as PaymentProviderSessionResponse["data"] + } catch (error) { + if (error.payment_intent?.status === ErrorIntentStatus.CANCELED) { + return error.payment_intent + } + + return this.buildError("An error occurred in cancelPayment", error) + } + } + + async capturePayment( + paymentSessionData: Record + ): Promise { + const id = paymentSessionData.id as string + try { + const intent = await this.stripe_.paymentIntents.capture(id) + return intent as unknown as PaymentProviderSessionResponse["data"] + } catch (error) { + if (error.code === ErrorCodes.PAYMENT_INTENT_UNEXPECTED_STATE) { + if (error.payment_intent?.status === ErrorIntentStatus.SUCCEEDED) { + return error.payment_intent + } + } + + return this.buildError("An error occurred in capturePayment", error) + } + } + + async deletePayment( + paymentSessionData: Record + ): Promise { + return await this.cancelPayment(paymentSessionData) + } + + async refundPayment( + paymentSessionData: Record, + refundAmount: number + ): Promise { + const id = paymentSessionData.id as string + + try { + await this.stripe_.refunds.create({ + amount: Math.round(refundAmount), + payment_intent: id as string, + }) + } catch (e) { + return this.buildError("An error occurred in refundPayment", e) + } + + return paymentSessionData + } + + async retrievePayment( + paymentSessionData: Record + ): Promise { + try { + const id = paymentSessionData.id as string + const intent = await this.stripe_.paymentIntents.retrieve(id) + return intent as unknown as PaymentProviderSessionResponse["data"] + } catch (e) { + return this.buildError("An error occurred in retrievePayment", e) + } + } + + async updatePayment( + context: PaymentProviderContext + ): Promise { + const { amount, customer, payment_session_data } = context + const stripeId = customer?.metadata?.stripe_id + + if (stripeId !== payment_session_data.customer) { + const result = await this.initiatePayment(context) + if (isPaymentProviderError(result)) { + return this.buildError( + "An error occurred in updatePayment during the initiate of the new payment for the new customer", + result + ) + } + + return result + } else { + if (amount && payment_session_data.amount === Math.round(amount)) { + return { data: payment_session_data } + } + + try { + const id = payment_session_data.id as string + const sessionData = (await this.stripe_.paymentIntents.update(id, { + amount: Math.round(amount), + })) as unknown as PaymentProviderSessionResponse["data"] + + return { data: sessionData } + } catch (e) { + return this.buildError("An error occurred in updatePayment", e) + } + } + } + + async updatePaymentData(sessionId: string, data: Record) { + try { + // Prevent from updating the amount from here as it should go through + // the updatePayment method to perform the correct logic + if (data.amount) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + "Cannot update amount, use updatePayment instead" + ) + } + + return (await this.stripe_.paymentIntents.update(sessionId, { + ...data, + })) as unknown as PaymentProviderSessionResponse["data"] + } catch (e) { + return this.buildError("An error occurred in updatePaymentData", e) + } + } + + async getWebhookActionAndData( + webhookData: ProviderWebhookPayload["payload"] + ): Promise { + const event = this.constructWebhookEvent(webhookData) + const intent = event.data.object as Stripe.PaymentIntent + + switch (event.type) { + case "payment_intent.amount_capturable_updated": + return { + action: PaymentActions.AUTHORIZED, + data: { + resource_id: intent.metadata.resource_id, + amount: intent.amount_capturable, // NOTE: revisit when implementing multicapture + }, + } + case "payment_intent.succeeded": + return { + action: PaymentActions.SUCCESSFUL, + data: { + resource_id: intent.metadata.resource_id, + amount: intent.amount_received, + }, + } + case "payment_intent.payment_failed": + return { + action: PaymentActions.FAILED, + data: { + resource_id: intent.metadata.resource_id, + amount: intent.amount, + }, + } + default: + return { action: PaymentActions.NOT_SUPPORTED } + } + } + + /** + * Constructs Stripe Webhook event + * @param {object} data - the data of the webhook request: req.body + * ensures integrity of the webhook event + * @return {object} Stripe Webhook event + */ + constructWebhookEvent(data: ProviderWebhookPayload["payload"]): Stripe.Event { + const signature = data.headers["stripe-signature"] as string + + return this.stripe_.webhooks.constructEvent( + data.rawData as string | Buffer, + signature, + this.config.webhookSecret + ) + } + protected buildError( + message: string, + error: Stripe.StripeRawError | PaymentProviderError | Error + ): PaymentProviderError { + return { + error: message, + code: "code" in error ? error.code : "unknown", + detail: isPaymentProviderError(error) + ? `${error.error}${EOL}${error.detail ?? ""}` + : "detail" in error + ? error.detail + : error.message ?? "", + } + } +} + +export default StripeBase diff --git a/packages/payment-stripe/src/index.ts b/packages/payment-stripe/src/index.ts new file mode 100644 index 0000000000..28904bd1bb --- /dev/null +++ b/packages/payment-stripe/src/index.ts @@ -0,0 +1,24 @@ +import { ModuleProviderExports } from "@medusajs/types" +import { + StripeBancontactService, + StripeBlikService, + StripeGiropayService, + StripeIdealService, + StripeProviderService, + StripePrzelewy24Service, +} from "./services" + +const services = [ + StripeBancontactService, + StripeBlikService, + StripeGiropayService, + StripeIdealService, + StripeProviderService, + StripePrzelewy24Service, +] + +const providerExport: ModuleProviderExports = { + services, +} + +export default providerExport diff --git a/packages/payment-stripe/src/services/index.ts b/packages/payment-stripe/src/services/index.ts new file mode 100644 index 0000000000..517e1e5fa0 --- /dev/null +++ b/packages/payment-stripe/src/services/index.ts @@ -0,0 +1,7 @@ +export { default as StripeBancontactService } from "./stripe-bancontact" +export { default as StripeBlikService } from "./stripe-blik" +export { default as StripeGiropayService } from "./stripe-giropay" +export { default as StripeIdealService } from "./stripe-ideal" +export { default as StripeProviderService } from "./stripe-provider" +export { default as StripePrzelewy24Service } from "./stripe-przelewy24" + diff --git a/packages/payment-stripe/src/services/stripe-bancontact.ts b/packages/payment-stripe/src/services/stripe-bancontact.ts new file mode 100644 index 0000000000..dfa487ae47 --- /dev/null +++ b/packages/payment-stripe/src/services/stripe-bancontact.ts @@ -0,0 +1,19 @@ +import StripeBase from "../core/stripe-base" +import { PaymentIntentOptions, PaymentProviderKeys } from "../types" + +class BancontactProviderService extends StripeBase { + static PROVIDER = PaymentProviderKeys.BAN_CONTACT + + constructor(_, options) { + super(_, options) + } + + get paymentIntentOptions(): PaymentIntentOptions { + return { + payment_method_types: ["bancontact"], + capture_method: "automatic", + } + } +} + +export default BancontactProviderService diff --git a/packages/payment-stripe/src/services/stripe-blik.ts b/packages/payment-stripe/src/services/stripe-blik.ts new file mode 100644 index 0000000000..4f91c7b01d --- /dev/null +++ b/packages/payment-stripe/src/services/stripe-blik.ts @@ -0,0 +1,19 @@ +import StripeBase from "../core/stripe-base" +import { PaymentIntentOptions, PaymentProviderKeys } from "../types" + +class BlikProviderService extends StripeBase { + static PROVIDER = PaymentProviderKeys.BLIK + + constructor(_, options) { + super(_, options) + } + + get paymentIntentOptions(): PaymentIntentOptions { + return { + payment_method_types: ["blik"], + capture_method: "automatic", + } + } +} + +export default BlikProviderService diff --git a/packages/payment-stripe/src/services/stripe-giropay.ts b/packages/payment-stripe/src/services/stripe-giropay.ts new file mode 100644 index 0000000000..682aa5967d --- /dev/null +++ b/packages/payment-stripe/src/services/stripe-giropay.ts @@ -0,0 +1,19 @@ +import StripeBase from "../core/stripe-base" +import { PaymentIntentOptions, PaymentProviderKeys } from "../types" + +class GiropayProviderService extends StripeBase { + static PROVIDER = PaymentProviderKeys.GIROPAY + + constructor(_, options) { + super(_, options) + } + + get paymentIntentOptions(): PaymentIntentOptions { + return { + payment_method_types: ["giropay"], + capture_method: "automatic", + } + } +} + +export default GiropayProviderService diff --git a/packages/payment-stripe/src/services/stripe-ideal.ts b/packages/payment-stripe/src/services/stripe-ideal.ts new file mode 100644 index 0000000000..d1ff8ce03d --- /dev/null +++ b/packages/payment-stripe/src/services/stripe-ideal.ts @@ -0,0 +1,19 @@ +import StripeBase from "../core/stripe-base" +import { PaymentIntentOptions, PaymentProviderKeys } from "../types" + +class IdealProviderService extends StripeBase { + static PROVIDER = PaymentProviderKeys.IDEAL + + constructor(_, options) { + super(_, options) + } + + get paymentIntentOptions(): PaymentIntentOptions { + return { + payment_method_types: ["ideal"], + capture_method: "automatic", + } + } +} + +export default IdealProviderService diff --git a/packages/payment-stripe/src/services/stripe-provider.ts b/packages/payment-stripe/src/services/stripe-provider.ts new file mode 100644 index 0000000000..148681ac3f --- /dev/null +++ b/packages/payment-stripe/src/services/stripe-provider.ts @@ -0,0 +1,16 @@ +import StripeBase from "../core/stripe-base" +import { PaymentIntentOptions, PaymentProviderKeys } from "../types" + +class StripeProviderService extends StripeBase { + static PROVIDER = PaymentProviderKeys.STRIPE + + constructor(_, options) { + super(_, options) + } + + get paymentIntentOptions(): PaymentIntentOptions { + return {} + } +} + +export default StripeProviderService diff --git a/packages/payment-stripe/src/services/stripe-przelewy24.ts b/packages/payment-stripe/src/services/stripe-przelewy24.ts new file mode 100644 index 0000000000..2fa0c8e14f --- /dev/null +++ b/packages/payment-stripe/src/services/stripe-przelewy24.ts @@ -0,0 +1,19 @@ +import StripeBase from "../core/stripe-base" +import { PaymentIntentOptions, PaymentProviderKeys } from "../types" + +class Przelewy24ProviderService extends StripeBase { + static PROVIDER = PaymentProviderKeys.PRZELEWY_24 + + constructor(_, options) { + super(_, options) + } + + get paymentIntentOptions(): PaymentIntentOptions { + return { + payment_method_types: ["p24"], + capture_method: "automatic", + } + } +} + +export default Przelewy24ProviderService diff --git a/packages/payment-stripe/src/types/index.ts b/packages/payment-stripe/src/types/index.ts new file mode 100644 index 0000000000..1ef40c7999 --- /dev/null +++ b/packages/payment-stripe/src/types/index.ts @@ -0,0 +1,44 @@ +export interface StripeCredentials { + apiKey: string + webhookSecret: string +} + +export interface StripeOptions { + credentials: Record + /** + * Use this flag to capture payment immediately (default is false) + */ + capture?: boolean + /** + * set `automatic_payment_methods` to `{ enabled: true }` + */ + automatic_payment_methods?: boolean + /** + * Set a default description on the intent if the context does not provide one + */ + payment_description?: string +} + +export interface PaymentIntentOptions { + capture_method?: "automatic" | "manual" + setup_future_usage?: "on_session" | "off_session" + payment_method_types?: string[] +} + +export const ErrorCodes = { + PAYMENT_INTENT_UNEXPECTED_STATE: "payment_intent_unexpected_state", +} + +export const ErrorIntentStatus = { + SUCCEEDED: "succeeded", + CANCELED: "canceled", +} + +export const PaymentProviderKeys = { + STRIPE: "stripe", + BAN_CONTACT: "stripe-bancontact", + BLIK: "stripe-blik", + GIROPAY: "stripe-giropay", + IDEAL: "stripe-ideal", + PRZELEWY_24: "stripe-przelewy24", +} diff --git a/packages/payment-stripe/tsconfig.json b/packages/payment-stripe/tsconfig.json new file mode 100644 index 0000000000..65e5a4fd5b --- /dev/null +++ b/packages/payment-stripe/tsconfig.json @@ -0,0 +1,36 @@ +{ + "compilerOptions": { + "lib": [ + "es5", + "es6", + "es2019" + ], + "target": "es5", + "jsx": "react-jsx" /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */, + "outDir": "./dist", + "esModuleInterop": true, + "declaration": true, + "module": "commonjs", + "moduleResolution": "node", + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "noImplicitReturns": true, + "strictNullChecks": true, + "strictFunctionTypes": true, + "noImplicitThis": true, + "allowJs": true, + "skipLibCheck": true, + "downlevelIteration": true, // to use ES5 specific tooling + "inlineSourceMap": true /* Emit a single file with source maps instead of having a separate file. */ + }, + "include": ["src"], + "exclude": [ + "dist", + "build", + "src/**/__tests__", + "src/**/__mocks__", + "src/**/__fixtures__", + "node_modules", + ".eslintrc.js" + ] +} diff --git a/packages/payment-stripe/tsconfig.spec.json b/packages/payment-stripe/tsconfig.spec.json new file mode 100644 index 0000000000..9b62409191 --- /dev/null +++ b/packages/payment-stripe/tsconfig.spec.json @@ -0,0 +1,5 @@ +{ + "extends": "./tsconfig.json", + "include": ["src"], + "exclude": ["node_modules"] +} diff --git a/packages/payment/integration-tests/__fixtures__/data.ts b/packages/payment/integration-tests/__fixtures__/data.ts index 0eab65b782..4876195d13 100644 --- a/packages/payment/integration-tests/__fixtures__/data.ts +++ b/packages/payment/integration-tests/__fixtures__/data.ts @@ -24,21 +24,21 @@ export const defaultPaymentSessionData = [ id: "pay-sess-id-1", amount: 100, currency_code: "usd", - provider_id: "system", + provider_id: "pp_system_default", payment_collection: "pay-col-id-1", }, { id: "pay-sess-id-2", amount: 100, currency_code: "usd", - provider_id: "system", + provider_id: "pp_system_default", payment_collection: "pay-col-id-2", }, { id: "pay-sess-id-3", amount: 100, currency_code: "usd", - provider_id: "system", + provider_id: "pp_system_default", payment_collection: "pay-col-id-2", }, ] @@ -50,7 +50,7 @@ export const defaultPaymentData = [ currency_code: "usd", payment_collection: "pay-col-id-1", payment_session: "pay-sess-id-1", - provider_id: "system", + provider_id: "pp_system_default", authorized_amount: 100, data: {}, }, @@ -61,7 +61,7 @@ export const defaultPaymentData = [ currency_code: "usd", payment_collection: "pay-col-id-2", payment_session: "pay-sess-id-2", - provider_id: "system", + provider_id: "pp_system_default", data: {}, }, ] diff --git a/packages/payment/integration-tests/__tests__/loaders/providers.spec.ts b/packages/payment/integration-tests/__tests__/loaders/providers.spec.ts new file mode 100644 index 0000000000..64b0ff646e --- /dev/null +++ b/packages/payment/integration-tests/__tests__/loaders/providers.spec.ts @@ -0,0 +1,76 @@ +import { IPaymentModuleService } from "@medusajs/types" +import { SqlEntityManager } from "@mikro-orm/postgresql" + +import { Modules } from "@medusajs/modules-sdk" +import { initModules } from "medusa-test-utils" +import { MikroOrmWrapper } from "../../utils" +import { getInitModuleConfig } from "../../utils/get-init-module-config" +import { createPaymentCollections } from "../../__fixtures__" + +jest.setTimeout(30000) + +describe("Payment Module Service", () => { + let service: IPaymentModuleService + let repositoryManager: SqlEntityManager + let shutdownFunc: () => Promise + + beforeAll(async () => { + await MikroOrmWrapper.setupDatabase() + + const initModulesConfig = getInitModuleConfig() + const { medusaApp, shutdown } = await initModules(initModulesConfig) + service = medusaApp.modules[Modules.PAYMENT] + + shutdownFunc = shutdown + }) + + afterAll(async () => { + await shutdownFunc() + }) + + beforeEach(async () => { + await MikroOrmWrapper.setupDatabase() + repositoryManager = await MikroOrmWrapper.forkManager() + + await createPaymentCollections(repositoryManager) + }) + + afterEach(async () => { + await MikroOrmWrapper.clearDatabase() + }) + + describe("providers", () => { + it("should load payment plugins", async () => { + let error = await service + .createPaymentCollections([ + { + amount: 200, + region_id: "req_123", + } as any, + ]) + .catch((e) => e) + + expect(error.message).toContain( + "Value for PaymentCollection.currency_code is required, 'undefined' found" + ) + }) + + it("should create a payment collection successfully", async () => { + const [createdPaymentCollection] = await service.createPaymentCollections( + [{ currency_code: "USD", amount: 200, region_id: "reg_123" }] + ) + + expect(createdPaymentCollection).toEqual( + expect.objectContaining({ + id: expect.any(String), + status: "not_paid", + payment_providers: [], + payment_sessions: [], + payments: [], + currency_code: "USD", + amount: 200, + }) + ) + }) + }) +}) diff --git a/packages/payment/integration-tests/__tests__/services/payment-module/index.spec.ts b/packages/payment/integration-tests/__tests__/services/payment-module/index.spec.ts index 102845cd46..f014398c06 100644 --- a/packages/payment/integration-tests/__tests__/services/payment-module/index.spec.ts +++ b/packages/payment/integration-tests/__tests__/services/payment-module/index.spec.ts @@ -39,7 +39,7 @@ moduleIntegrationTestRunner({ const paymentSession = await service.createPaymentSession( paymentCollection.id, { - provider_id: "system", + provider_id: "pp_system_default", providerContext: { amount: 200, currency_code: "USD", @@ -48,7 +48,6 @@ moduleIntegrationTestRunner({ customer: {}, billing_address: {}, email: "test@test.test.com", - resource_id: "cart_test", }, } ) @@ -86,7 +85,7 @@ moduleIntegrationTestRunner({ id: expect.any(String), currency_code: "USD", amount: 200, - provider_id: "system", + provider_id: "pp_system_default", status: "authorized", authorized_at: expect.any(Date), }), @@ -96,7 +95,7 @@ moduleIntegrationTestRunner({ id: expect.any(String), amount: 200, currency_code: "USD", - provider_id: "system", + provider_id: "pp_system_default", captures: [ expect.objectContaining({ amount: 200, @@ -335,7 +334,7 @@ moduleIntegrationTestRunner({ describe("create", () => { it("should create a payment session successfully", async () => { await service.createPaymentSession("pay-col-id-1", { - provider_id: "system", + provider_id: "pp_system_default", providerContext: { amount: 200, currency_code: "usd", @@ -365,7 +364,7 @@ moduleIntegrationTestRunner({ authorized_at: null, currency_code: "usd", amount: 200, - provider_id: "system", + provider_id: "pp_system_default", }), ]), }) @@ -376,7 +375,7 @@ moduleIntegrationTestRunner({ describe("update", () => { it("should update a payment session successfully", async () => { let session = await service.createPaymentSession("pay-col-id-1", { - provider_id: "system", + provider_id: "pp_system_default", providerContext: { amount: 200, currency_code: "usd", @@ -423,7 +422,7 @@ moduleIntegrationTestRunner({ }) const session = await service.createPaymentSession(collection.id, { - provider_id: "system", + provider_id: "pp_system_default", providerContext: { amount: 100, currency_code: "usd", @@ -445,9 +444,8 @@ moduleIntegrationTestRunner({ expect.objectContaining({ id: expect.any(String), amount: 100, - authorized_amount: 100, currency_code: "usd", - provider_id: "system", + provider_id: "pp_system_default", refunds: [], captures: [], @@ -467,7 +465,7 @@ moduleIntegrationTestRunner({ currency_code: "usd", amount: 100, raw_amount: { value: "100", precision: 20 }, - provider_id: "system", + provider_id: "pp_system_default", data: {}, status: "authorized", authorized_at: expect.any(Date), @@ -475,7 +473,6 @@ moduleIntegrationTestRunner({ id: expect.any(String), }), payment: expect.objectContaining({ - authorized_amount: 100, cart_id: null, order_id: null, order_edit_id: null, @@ -488,7 +485,7 @@ moduleIntegrationTestRunner({ captures: [], amount: 100, currency_code: "usd", - provider_id: "system", + provider_id: "pp_system_default", }), }, }) diff --git a/packages/payment/integration-tests/utils/get-init-module-config.ts b/packages/payment/integration-tests/utils/get-init-module-config.ts index 6c6c667102..b39463f977 100644 --- a/packages/payment/integration-tests/utils/get-init-module-config.ts +++ b/packages/payment/integration-tests/utils/get-init-module-config.ts @@ -10,6 +10,21 @@ export function getInitModuleConfig() { schema: process.env.MEDUSA_PAYMENT_DB_SCHEMA, }, }, + providers: [ + { + resolve: "@medusajs/payment-stripe", + options: { + config: { + dkk: { + apiKey: "pk_test_123", + }, + usd: { + apiKey: "pk_test_456", + }, + }, + }, + }, + ], } const injectedDependencies = {} diff --git a/packages/payment/src/initialize/index.ts b/packages/payment/src/initialize/index.ts index 28208d3db3..c39148dee4 100644 --- a/packages/payment/src/initialize/index.ts +++ b/packages/payment/src/initialize/index.ts @@ -5,17 +5,23 @@ import { MODULE_PACKAGE_NAMES, Modules, } from "@medusajs/modules-sdk" -import { IPaymentModuleService, ModulesSdkTypes } from "@medusajs/types" +import { + IPaymentModuleService, + ModuleProvider, + ModulesSdkTypes, +} from "@medusajs/types" import { moduleDefinition } from "../module-definition" import { InitializeModuleInjectableDependencies } from "../types" export const initialize = async ( options?: - | ModulesSdkTypes.ModuleServiceInitializeOptions - | ModulesSdkTypes.ModuleServiceInitializeCustomDataLayerOptions - | ExternalModuleDeclaration - | InternalModuleDeclaration, + | ( + | ModulesSdkTypes.ModuleServiceInitializeOptions + | ModulesSdkTypes.ModuleServiceInitializeCustomDataLayerOptions + | ExternalModuleDeclaration + | InternalModuleDeclaration + ) & { providers: ModuleProvider[] }, injectedDependencies?: InitializeModuleInjectableDependencies ): Promise => { const loaded = await MedusaModule.bootstrap({ diff --git a/packages/payment/src/loaders/providers.ts b/packages/payment/src/loaders/providers.ts index 1366db249c..7c350a587c 100644 --- a/packages/payment/src/loaders/providers.ts +++ b/packages/payment/src/loaders/providers.ts @@ -1,26 +1,21 @@ import { moduleProviderLoader } from "@medusajs/modules-sdk" - import { LoaderOptions, ModuleProvider, ModulesSdkTypes } from "@medusajs/types" -import { Lifetime, asFunction } from "awilix" +import { Lifetime, asFunction, asValue } from "awilix" import * as providers from "../providers" const registrationFn = async (klass, container, pluginOptions) => { - container.register({ - [`pp_${klass.identifier}`]: asFunction( - (cradle) => new klass(cradle, pluginOptions), - { - lifetime: klass.LIFE_TIME || Lifetime.SINGLETON, - } - ), - }) + Object.entries(pluginOptions.config || []).map(([name, config]) => { + const key = `pp_${klass.PROVIDER}_${name}` - container.registerAdd( - "payment_providers", - asFunction((cradle) => new klass(cradle, pluginOptions), { - lifetime: klass.LIFE_TIME || Lifetime.SINGLETON, + container.register({ + [key]: asFunction((cradle) => new klass(cradle, config), { + lifetime: klass.LIFE_TIME || Lifetime.SINGLETON, + }), }) - ) + + container.registerAdd("payment_providers", asValue(key)) + }) } export default async ({ @@ -34,7 +29,7 @@ export default async ({ >): Promise => { // Local providers for (const provider of Object.values(providers)) { - await registrationFn(provider, container, {}) + await registrationFn(provider, container, { config: { default: {} } }) } await moduleProviderLoader({ diff --git a/packages/payment/src/models/payment.ts b/packages/payment/src/models/payment.ts index c8dfd2619b..50e5485c47 100644 --- a/packages/payment/src/models/payment.ts +++ b/packages/payment/src/models/payment.ts @@ -14,11 +14,7 @@ import { } from "@mikro-orm/core" import { DAL } from "@medusajs/types" -import { - DALUtils, - generateEntityId, - optionalNumericSerializer, -} from "@medusajs/utils" +import { DALUtils, generateEntityId } from "@medusajs/utils" import Refund from "./refund" import Capture from "./capture" import PaymentSession from "./payment-session" @@ -40,13 +36,6 @@ export default class Payment { }) amount: number - @Property({ - columnType: "numeric", - nullable: true, - serializer: optionalNumericSerializer, - }) - authorized_amount: number | null = null - @Property({ columnType: "text" }) currency_code: string @@ -119,7 +108,11 @@ export default class Payment { }) payment_collection!: PaymentCollection - @OneToOne({ owner: true, fieldName: "session_id" }) + @OneToOne({ + owner: true, + fieldName: "session_id", + index: "IDX_payment_payment_session_id", + }) payment_session!: PaymentSession /** COMPUTED PROPERTIES START **/ diff --git a/packages/payment/src/providers/system.ts b/packages/payment/src/providers/system.ts index 7694b6d488..2675dd74fd 100644 --- a/packages/payment/src/providers/system.ts +++ b/packages/payment/src/providers/system.ts @@ -3,11 +3,14 @@ import { PaymentProviderError, PaymentProviderSessionResponse, PaymentSessionStatus, + ProviderWebhookPayload, + WebhookActionResult, } from "@medusajs/types" -import { AbstractPaymentProvider } from "@medusajs/utils" +import { AbstractPaymentProvider, PaymentActions } from "@medusajs/utils" export class SystemProviderService extends AbstractPaymentProvider { static identifier = "system" + static PROVIDER = "system" async getStatus(_): Promise { return "authorized" @@ -66,6 +69,12 @@ export class SystemProviderService extends AbstractPaymentProvider { async cancelPayment(_): Promise> { return {} } + + async getWebhookActionAndData( + data: ProviderWebhookPayload["payload"] + ): Promise { + return { action: PaymentActions.NOT_SUPPORTED } + } } export default SystemProviderService diff --git a/packages/payment/src/services/index.ts b/packages/payment/src/services/index.ts index b331c37d29..dcd1312a42 100644 --- a/packages/payment/src/services/index.ts +++ b/packages/payment/src/services/index.ts @@ -1,2 +1,3 @@ export { default as PaymentModuleService } from "./payment-module" export { default as PaymentProviderService } from "./payment-provider" + diff --git a/packages/payment/src/services/payment-module.ts b/packages/payment/src/services/payment-module.ts index e6c036dbe3..c37932cac9 100644 --- a/packages/payment/src/services/payment-module.ts +++ b/packages/payment/src/services/payment-module.ts @@ -3,7 +3,6 @@ import { Context, CreateCaptureDTO, CreatePaymentCollectionDTO, - CreatePaymentDTO, CreatePaymentProviderDTO, CreatePaymentSessionDTO, CreateRefundDTO, @@ -16,12 +15,14 @@ import { PaymentDTO, PaymentSessionDTO, PaymentSessionStatus, + ProviderWebhookPayload, RefundDTO, UpdatePaymentCollectionDTO, UpdatePaymentDTO, UpdatePaymentSessionDTO, } from "@medusajs/types" import { + PaymentActions, InjectTransactionManager, MedusaContext, MedusaError, @@ -206,23 +207,38 @@ export default class PaymentModuleService< data: CreatePaymentSessionDTO, @MedusaContext() sharedContext?: Context ): Promise { - const sessionData = await this.paymentProviderService_.createSession( - data.provider_id, - data.providerContext - ) - const created = await this.paymentSessionService_.create( { provider_id: data.provider_id, amount: data.providerContext.amount, currency_code: data.providerContext.currency_code, payment_collection: paymentCollectionId, - data: sessionData, }, sharedContext ) - return await this.baseRepository_.serialize(created, { populate: true }) + try { + const sessionData = await this.paymentProviderService_.createSession( + data.provider_id, + { + ...data.providerContext, + resource_id: created.id, + } + ) + + await this.paymentSessionService_.update( + { + id: created.id, + data: sessionData, + }, + sharedContext + ) + + return await this.baseRepository_.serialize(created, { populate: true }) + } catch (e) { + await this.paymentSessionService_.delete([created.id], sharedContext) + throw e + } } @InjectTransactionManager("baseRepository_") @@ -288,6 +304,7 @@ export default class PaymentModuleService< sharedContext ) + // this method needs to be idempotent if (session.authorized_at) { const payment = await this.paymentService_.retrieve( { session_id: session.id }, @@ -330,7 +347,6 @@ export default class PaymentModuleService< { amount: session.amount, currency_code: session.currency_code, - authorized_amount: session.amount, payment_session: session.id, payment_collection: session.payment_collection!.id, provider_id: session.provider_id, @@ -374,15 +390,17 @@ export default class PaymentModuleService< ) } + // this method needs to be idempotent if (payment.captured_at) { - throw new MedusaError( - MedusaError.Types.INVALID_DATA, - `The payment: ${payment.id} is already fully captured.` + return this.retrievePayment( + data.payment_id, + { relations: ["captures"] }, + sharedContext ) } // TODO: revisit when https://github.com/medusajs/medusa/pull/6253 is merged - // if (payment.captured_amount + input.amount > payment.authorized_amount) { + // if (payment.captured_amount + input.amount > payment.amount) { // throw new MedusaError( // MedusaError.Types.INVALID_DATA, // `Total captured amount for payment: ${payment.id} exceeds authorized amount.` @@ -500,25 +518,60 @@ export default class PaymentModuleService< return await this.retrievePayment(payment.id, {}, sharedContext) } + @InjectTransactionManager("baseRepository_") + async processEvent( + eventData: ProviderWebhookPayload, + @MedusaContext() sharedContext?: Context + ): Promise { + const providerId = `pp_${eventData.provider}` + + const event = await this.paymentProviderService_.getWebhookActionAndData( + providerId, + eventData.payload + ) + + if (event.action === PaymentActions.NOT_SUPPORTED) { + return + } + + switch (event.action) { + case PaymentActions.SUCCESSFUL: { + const [payment] = await this.listPayments({ + session_id: event.data.resource_id, + }) + + await this.capturePayment( + { payment_id: payment.id, amount: event.data.amount }, + sharedContext + ) + break + } + case PaymentActions.AUTHORIZED: + await this.authorizePaymentSession( + event.data.resource_id as string, + {}, + sharedContext + ) + } + } + async createProvidersOnLoad() { const providersToLoad = this.__container__["payment_providers"] const providers = await this.paymentProviderService_.list({ // @ts-ignore TODO - id: providersToLoad.map((p) => p.getIdentifier()), + id: providersToLoad, }) const loadedProvidersMap = new Map(providers.map((p) => [p.id, p])) const providersToCreate: CreatePaymentProviderDTO[] = [] - for (const provider of providersToLoad) { - if (loadedProvidersMap.has(provider.getIdentifier())) { + for (const id of providersToLoad) { + if (loadedProvidersMap.has(id)) { continue } - providersToCreate.push({ - id: provider.getIdentifier(), - }) + providersToCreate.push({ id }) } await this.paymentProviderService_.create(providersToCreate) diff --git a/packages/payment/src/services/payment-provider.ts b/packages/payment/src/services/payment-provider.ts index 261ce54b10..af3a000ca9 100644 --- a/packages/payment/src/services/payment-provider.ts +++ b/packages/payment/src/services/payment-provider.ts @@ -12,6 +12,8 @@ import { PaymentProviderError, PaymentProviderSessionResponse, PaymentSessionStatus, + ProviderWebhookPayload, + WebhookActionResult, } from "@medusajs/types" import { InjectManager, @@ -57,7 +59,7 @@ export default class PaymentProviderService { retrieveProvider(providerId: string): IPaymentProvider { try { - return this.container_[`pp_${providerId}`] as IPaymentProvider + return this.container_[providerId] as IPaymentProvider } catch (e) { throw new MedusaError( MedusaError.Types.NOT_FOUND, @@ -173,6 +175,15 @@ export default class PaymentProviderService { return res as Record } + async getWebhookActionAndData( + providerId: string, + data: ProviderWebhookPayload["payload"] + ): Promise { + const provider = this.retrieveProvider(providerId) + + return await provider.getWebhookActionAndData(data) + } + private throwPaymentProviderError(errObj: PaymentProviderError) { throw new MedusaError( MedusaError.Types.INVALID_DATA, diff --git a/packages/types/src/modules-sdk/index.ts b/packages/types/src/modules-sdk/index.ts index dcfe7a8a0f..56c7a02469 100644 --- a/packages/types/src/modules-sdk/index.ts +++ b/packages/types/src/modules-sdk/index.ts @@ -11,6 +11,7 @@ import { Logger } from "../logger" export type Constructor = new (...args: any[]) => T export * from "../common/medusa-container" export * from "./internal-module-service" +export * from "./module-provider" export type LogLevel = | "query" @@ -290,13 +291,3 @@ export interface IModuleService { onApplicationStart?: () => Promise } } - -export type ModuleProviderExports = { - services: Constructor[] -} - -export type ModuleProvider = { - resolve: string | ModuleProviderExports - provider_name?: string - options: Record -} diff --git a/packages/types/src/modules-sdk/module-provider.ts b/packages/types/src/modules-sdk/module-provider.ts new file mode 100644 index 0000000000..5a418c14b3 --- /dev/null +++ b/packages/types/src/modules-sdk/module-provider.ts @@ -0,0 +1,11 @@ +import { Constructor } from "./index" + +export type ModuleProviderExports = { + services: Constructor[] +} + +export type ModuleProvider = { + resolve: string | ModuleProviderExports + provider_name?: string + options: Record +} diff --git a/packages/types/src/payment/common.ts b/packages/types/src/payment/common.ts index 63bcae95ed..590825e7ab 100644 --- a/packages/types/src/payment/common.ts +++ b/packages/types/src/payment/common.ts @@ -280,6 +280,23 @@ export interface PaymentDTO { payment_session?: PaymentSessionDTO } +export interface FilterablePaymentProps + extends BaseFilterable { + id?: string | string[] + + session_id?: string | string[] | OperatorMap + + customer_id?: string | string[] | OperatorMap + cart_id?: string | string[] | OperatorMap + order_id?: string | string[] | OperatorMap + order_edit_id?: string | string[] | OperatorMap + + created_at?: OperatorMap + updated_at?: OperatorMap + captured_at?: OperatorMap + canceled_at?: OperatorMap +} + /** * The capture details. */ diff --git a/packages/types/src/payment/mutations.ts b/packages/types/src/payment/mutations.ts index 633e8bde3b..bb1ad497c7 100644 --- a/packages/types/src/payment/mutations.ts +++ b/packages/types/src/payment/mutations.ts @@ -188,7 +188,7 @@ export interface CreatePaymentSessionDTO { /** * The provider's context. */ - providerContext: PaymentProviderContext + providerContext: Omit } /** @@ -218,3 +218,21 @@ export interface CreatePaymentProviderDTO { */ is_enabled?: boolean } + +/** + * Webhook + */ +export interface ProviderWebhookPayload { + provider: string + payload: { + /** + * Parsed webhook body + */ + data: Record + /** + * Raw request body + */ + rawData: string | Buffer + headers: Record + } +} diff --git a/packages/types/src/payment/provider.ts b/packages/types/src/payment/provider.ts index 0102d5d791..b48dcef76b 100644 --- a/packages/types/src/payment/provider.ts +++ b/packages/types/src/payment/provider.ts @@ -1,4 +1,33 @@ import { PaymentSessionStatus } from "./common" +import { CustomerDTO } from "../customer" +import { AddressDTO } from "../address" +import { ProviderWebhookPayload } from "./mutations" + +export type PaymentAddressDTO = Partial + +export type PaymentCustomerDTO = Partial + +/** + * Normalized events from payment provider to internal payment module events. + */ +export enum PaymentActions { + /** + * Payment session has been authorized and there are available funds for capture. + */ + AUTHORIZED = "authorized", + /** + * Payment was successful and the mount is captured. + */ + SUCCESSFUL = "captured", + /** + * Payment failed. + */ + FAILED = "failed", + /** + * Received an event that is not processable. + */ + NOT_SUPPORTED = "not_supported", +} /** * @interface @@ -9,7 +38,7 @@ export type PaymentProviderContext = { /** * The payment's billing address. */ - billing_address?: Record | null // TODO: revisit types + billing_address?: PaymentAddressDTO /** * The customer's email. */ @@ -23,17 +52,17 @@ export type PaymentProviderContext = { */ amount: number /** - * The ID of the resource the payment is associated with. For example, the cart's ID. + * The ID of the resource the payment is associated with i.e. the ID of the PaymentSession in Medusa */ resource_id: string /** * The customer associated with this payment. */ - customer?: Record // TODO: type + customer?: PaymentCustomerDTO /** * The context. */ - context: Record + context: { payment_description?: string } & Record /** * If the payment session hasn't been created or initiated yet, it'll be an empty object. * If the payment session exists, it'll be the value of the payment session's `data` field. @@ -88,6 +117,20 @@ export interface PaymentProviderError { detail?: any } +export type WebhookActionData = { + resource_id: string + amount: number +} + +export type WebhookActionResult = + | { + action: PaymentActions.NOT_SUPPORTED + } + | { + action: PaymentActions + data: WebhookActionData + } + export interface IPaymentProvider { /** * @ignore @@ -209,4 +252,15 @@ export interface IPaymentProvider { getPaymentStatus( paymentSessionData: Record ): Promise + + /** + * The method is called when å webhook call for this particular provider is received. + * + * The method is responsible for normalizing the received event and provide + * + * @param data - object containing provider id and data from the provider + */ + getWebhookActionAndData( + data: ProviderWebhookPayload["payload"] + ): Promise } diff --git a/packages/types/src/payment/service.ts b/packages/types/src/payment/service.ts index 3db5357838..9208a869c5 100644 --- a/packages/types/src/payment/service.ts +++ b/packages/types/src/payment/service.ts @@ -1,22 +1,23 @@ +import { FindConfig } from "../common" import { IModuleService } from "../modules-sdk" import { Context } from "../shared-context" -import { - CreateCaptureDTO, - CreatePaymentCollectionDTO, - CreatePaymentDTO, - CreatePaymentSessionDTO, - CreateRefundDTO, - UpdatePaymentCollectionDTO, - UpdatePaymentDTO, - UpdatePaymentSessionDTO, -} from "./mutations" import { FilterablePaymentCollectionProps, + FilterablePaymentProps, PaymentCollectionDTO, PaymentDTO, PaymentSessionDTO, } from "./common" -import { FindConfig } from "../common" +import { + CreateCaptureDTO, + CreatePaymentCollectionDTO, + CreatePaymentSessionDTO, + CreateRefundDTO, + ProviderWebhookPayload, + UpdatePaymentCollectionDTO, + UpdatePaymentDTO, + UpdatePaymentSessionDTO, +} from "./mutations" /** * The main service interface for the payment module. @@ -311,6 +312,24 @@ export interface IPaymentModuleService extends IModuleService { /* ********** PAYMENT ********** */ + /** + * This method retrieves a paginated list of payments based on optional filters and configuration. + * + * @param {FilterablePaymentProps} filters - The filters to apply on the retrieved payment. + * @param {FindConfig} config - The configurations determining how the payment is retrieved. Its properties, such as `select` or `relations`, accept the + * attributes or relations associated with a payment. + * @param {Context} sharedContext - A context used to share resources, such as transaction manager, between the application and the module. + * @returns {Promise} A list of payment. + * + * @example + * {example-code} + */ + listPayments( + filters?: FilterablePaymentProps, + config?: FindConfig, + sharedContext?: Context + ): Promise + /** * This method updates an existing payment. * @@ -375,4 +394,21 @@ export interface IPaymentModuleService extends IModuleService { * {example-code} */ createProvidersOnLoad(): Promise + + /* ********** HOOKS ********** */ + + processEvent(data: ProviderWebhookPayload): Promise +} + +export interface PaymentModuleOptions { + /** + * The delay in milliseconds before processing the webhook event. + * @defaultValue 5000 + */ + webhook_delay?: number + /** + * The number of times to retry the webhook event processing in case of an error. + * @defaultValue 3 + */ + webhook_retries?: number } diff --git a/packages/utils/src/payment/abstract-payment-provider.ts b/packages/utils/src/payment/abstract-payment-provider.ts index 32b3c04fe3..08e8d3e326 100644 --- a/packages/utils/src/payment/abstract-payment-provider.ts +++ b/packages/utils/src/payment/abstract-payment-provider.ts @@ -5,9 +5,13 @@ import { PaymentProviderError, PaymentProviderSessionResponse, PaymentSessionStatus, + ProviderWebhookPayload, + WebhookActionResult, } from "@medusajs/types" -export abstract class AbstractPaymentProvider implements IPaymentProvider { +export abstract class AbstractPaymentProvider> + implements IPaymentProvider +{ /** * You can use the `constructor` of your Payment Provider to have access to different services in Medusa through [dependency injection](https://docs.medusajs.com/development/fundamentals/dependency-injection). * @@ -38,7 +42,7 @@ export abstract class AbstractPaymentProvider implements IPaymentProvider { */ protected constructor( protected readonly container: MedusaContainer, - protected readonly config?: Record // eslint-disable-next-line @typescript-eslint/no-empty-function + protected readonly config: TConfig = {} as TConfig // eslint-disable-next-line @typescript-eslint/no-empty-function ) {} /** @@ -126,8 +130,18 @@ export abstract class AbstractPaymentProvider implements IPaymentProvider { abstract updatePayment( context: PaymentProviderContext ): Promise + + abstract getWebhookActionAndData( + data: ProviderWebhookPayload["payload"] + ): Promise } export function isPaymentProviderError(obj: any): obj is PaymentProviderError { - return obj && typeof obj === "object" && obj.error && obj.code && obj.detail + return ( + obj && + typeof obj === "object" && + "error" in obj && + "code" in obj && + "detail" in obj + ) } diff --git a/packages/utils/src/payment/index.ts b/packages/utils/src/payment/index.ts index 35e33262da..315d9f49a1 100644 --- a/packages/utils/src/payment/index.ts +++ b/packages/utils/src/payment/index.ts @@ -1,3 +1,4 @@ +export * from "./abstract-payment-provider" export * from "./payment-collection" export * from "./payment-session" -export * from "./abstract-payment-provider" +export * from "./webhook" diff --git a/packages/utils/src/payment/webhook.ts b/packages/utils/src/payment/webhook.ts new file mode 100644 index 0000000000..b175e1821d --- /dev/null +++ b/packages/utils/src/payment/webhook.ts @@ -0,0 +1,25 @@ +export enum PaymentWebhookEvents { + WebhookReceived = "payment.webhook_received", +} + +/** + * Normalized events from payment provider to internal payment module events. + */ +export enum PaymentActions { + /** + * Payment session has been authorized and there are available funds for capture. + */ + AUTHORIZED = "authorized", + /** + * Payment was successful and the mount is captured. + */ + SUCCESSFUL = "captured", + /** + * Payment failed. + */ + FAILED = "failed", + /** + * Received an event that is not processable. + */ + NOT_SUPPORTED = "not_supported", +} diff --git a/yarn.lock b/yarn.lock index c5e0a085fb..55e7e10386 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8550,6 +8550,26 @@ __metadata: languageName: unknown linkType: soft +"@medusajs/payment-stripe@workspace:packages/payment-stripe": + version: 0.0.0-use.local + resolution: "@medusajs/payment-stripe@workspace:packages/payment-stripe" + dependencies: + "@medusajs/medusa": ^1.19.1 + "@medusajs/utils": ^1.11.3 + "@types/stripe": ^8.0.417 + awilix: ^8.0.1 + body-parser: ^1.19.0 + cross-env: ^5.2.1 + express: ^4.17.1 + jest: ^25.5.4 + rimraf: ^5.0.1 + stripe: latest + typescript: ^4.9.5 + peerDependencies: + "@medusajs/medusa": ^1.12.0 + languageName: unknown + linkType: soft + "@medusajs/payment@workspace:packages/payment": version: 0.0.0-use.local resolution: "@medusajs/payment@workspace:packages/payment" @@ -47770,6 +47790,16 @@ __metadata: languageName: node linkType: hard +"stripe@npm:latest": + version: 14.16.0 + resolution: "stripe@npm:14.16.0" + dependencies: + "@types/node": ">=8.1.0" + qs: ^6.11.0 + checksum: bada06609592bae71094ba86fdf745d86945d6bb5b44482da0355235c01ca6f2c76f261fad31d9d367cee5cf6b8b5532fb66733d664d4bb497125809b61699bf + languageName: node + linkType: hard + "strnum@npm:^1.0.5": version: 1.0.5 resolution: "strnum@npm:1.0.5"