From 3debd7e1081473ec17ab4848bde29f4dff1252b7 Mon Sep 17 00:00:00 2001 From: Oliver Windall Juhl <59018053+olivermrbl@users.noreply.github.com> Date: Mon, 25 May 2020 21:08:16 +0200 Subject: [PATCH] Stripe payment provider plugin (#65) Adds Stripe payment provider plugin Closes #44 --- packages/medusa-payment-stripe/.babelrc | 3 +- packages/medusa-payment-stripe/.gitignore | 5 +- packages/medusa-payment-stripe/package.json | 18 +- .../src/__mocks__/cart.js | 212 +++++++++++++++ .../src/__mocks__/customer.js | 36 +++ .../src/__mocks__/eventbus.js | 10 + .../src/__mocks__/stripe.js | 85 ++++++ .../src/__mocks__/totals.js | 9 + .../medusa-payment-stripe/src/api/index.js | 10 +- .../src/api/middlewares/await-middleware.js | 1 + .../src/api/middlewares/index.js | 5 + .../src/api/routes/hooks/index.js | 17 ++ .../src/api/routes/hooks/stripe.js | 35 +++ .../src/services/__mocks__/stripe-provider.js | 52 ++++ .../src/services/__tests__/stripe-provider.js | 243 ++++++++++++++++++ .../src/services/stripe-provider.js | 206 ++++++++++++++- .../src/subscribers/__tests__/cart.js | 90 +++++++ .../src/subscribers/cart.js | 62 ++++- 18 files changed, 1078 insertions(+), 21 deletions(-) create mode 100644 packages/medusa-payment-stripe/src/__mocks__/cart.js create mode 100644 packages/medusa-payment-stripe/src/__mocks__/customer.js create mode 100644 packages/medusa-payment-stripe/src/__mocks__/eventbus.js create mode 100644 packages/medusa-payment-stripe/src/__mocks__/stripe.js create mode 100644 packages/medusa-payment-stripe/src/__mocks__/totals.js create mode 100644 packages/medusa-payment-stripe/src/api/middlewares/await-middleware.js create mode 100644 packages/medusa-payment-stripe/src/api/middlewares/index.js create mode 100644 packages/medusa-payment-stripe/src/api/routes/hooks/index.js create mode 100644 packages/medusa-payment-stripe/src/api/routes/hooks/stripe.js create mode 100644 packages/medusa-payment-stripe/src/services/__mocks__/stripe-provider.js create mode 100644 packages/medusa-payment-stripe/src/services/__tests__/stripe-provider.js create mode 100644 packages/medusa-payment-stripe/src/subscribers/__tests__/cart.js diff --git a/packages/medusa-payment-stripe/.babelrc b/packages/medusa-payment-stripe/.babelrc index 5c12149795..4d2dfe8f09 100644 --- a/packages/medusa-payment-stripe/.babelrc +++ b/packages/medusa-payment-stripe/.babelrc @@ -1,7 +1,8 @@ { "plugins": [ "@babel/plugin-proposal-class-properties", - "@babel/plugin-transform-instanceof" + "@babel/plugin-transform-instanceof", + "@babel/plugin-transform-classes" ], "presets": ["@babel/preset-env"], "env": { diff --git a/packages/medusa-payment-stripe/.gitignore b/packages/medusa-payment-stripe/.gitignore index 0693b7de13..ff2317dd07 100644 --- a/packages/medusa-payment-stripe/.gitignore +++ b/packages/medusa-payment-stripe/.gitignore @@ -6,8 +6,5 @@ node_modules !index.js yarn.lock -/api -/services -/models -/subscribers +/dist diff --git a/packages/medusa-payment-stripe/package.json b/packages/medusa-payment-stripe/package.json index b938cfefbd..2b56b01d41 100644 --- a/packages/medusa-payment-stripe/package.json +++ b/packages/medusa-payment-stripe/package.json @@ -13,11 +13,12 @@ "devDependencies": { "@babel/cli": "^7.7.5", "@babel/core": "^7.7.5", + "@babel/node": "^7.7.4", "@babel/plugin-proposal-class-properties": "^7.7.4", - "@babel/plugin-transform-runtime": "^7.7.6", - "@babel/plugin-transform-classes": "^7.9.5", "@babel/plugin-transform-instanceof": "^7.8.3", + "@babel/plugin-transform-runtime": "^7.7.6", "@babel/preset-env": "^7.7.5", + "@babel/register": "^7.7.4", "@babel/runtime": "^7.9.6", "client-sessions": "^0.8.0", "cross-env": "^5.2.1", @@ -25,14 +26,19 @@ "jest": "^25.5.2" }, "scripts": { - "build": "babel src --out-dir . --ignore **/__tests__", + "build": "babel src -d dist", "prepare": "cross-env NODE_ENV=production npm run build", - "watch": "babel -w src --out-dir . --ignore **/__tests__" + "watch": "babel -w src --out-dir . --ignore **/__tests__", + "test": "jest" }, "dependencies": { + "@babel/plugin-transform-classes": "^7.9.5", "express": "^4.17.1", "medusa-core-utils": "^0.3.0", - "medusa-interfaces": "^0.3.0" + "medusa-interfaces": "^0.3.0", + "medusa-test-utils": "^0.3.0", + "stripe": "^8.50.0", + "body-parser": "^1.19.0" }, "gitHead": "35e0930650d5f4aedf2610749cd131ae8b7e17cc" -} +} \ No newline at end of file diff --git a/packages/medusa-payment-stripe/src/__mocks__/cart.js b/packages/medusa-payment-stripe/src/__mocks__/cart.js new file mode 100644 index 0000000000..0f58f9f24e --- /dev/null +++ b/packages/medusa-payment-stripe/src/__mocks__/cart.js @@ -0,0 +1,212 @@ +import { IdMap } from "medusa-test-utils" + +export const carts = { + emptyCart: { + _id: IdMap.getId("emptyCart"), + items: [], + region_id: IdMap.getId("testRegion"), + shipping_options: [ + { + _id: IdMap.getId("freeShipping"), + profile_id: "default_profile", + data: { + some_data: "yes", + }, + }, + ], + }, + frCart: { + _id: IdMap.getId("fr-cart"), + email: "lebron@james.com", + title: "test", + region_id: IdMap.getId("region-france"), + items: [ + { + _id: IdMap.getId("line"), + title: "merge line", + description: "This is a new line", + thumbnail: "test-img-yeah.com/thumb", + content: [ + { + unit_price: 8, + variant: { + _id: IdMap.getId("eur-8-us-10"), + }, + product: { + _id: IdMap.getId("product"), + }, + quantity: 1, + }, + { + unit_price: 10, + variant: { + _id: IdMap.getId("eur-10-us-12"), + }, + product: { + _id: IdMap.getId("product"), + }, + quantity: 1, + }, + ], + quantity: 10, + }, + { + _id: IdMap.getId("existingLine"), + title: "merge line", + description: "This is a new line", + thumbnail: "test-img-yeah.com/thumb", + content: { + unit_price: 10, + variant: { + _id: IdMap.getId("eur-10-us-12"), + }, + product: { + _id: IdMap.getId("product"), + }, + quantity: 1, + }, + quantity: 10, + }, + ], + shipping_methods: [ + { + _id: IdMap.getId("freeShipping"), + profile_id: "default_profile", + }, + ], + shipping_options: [ + { + _id: IdMap.getId("freeShipping"), + profile_id: "default_profile", + }, + ], + payment_sessions: [ + { + provider_id: "stripe", + data: { + id: "pi_123456789", + customer: IdMap.getId("not-lebron"), + }, + }, + ], + payment_method: { + provider_id: "stripe", + data: { + id: "pi_123456789", + customer: IdMap.getId("not-lebron"), + }, + }, + shipping_address: {}, + billing_address: {}, + discounts: [], + customer_id: IdMap.getId("lebron"), + }, + frCartNoStripeCustomer: { + _id: IdMap.getId("fr-cart-no-customer"), + title: "test", + region_id: IdMap.getId("region-france"), + items: [ + { + _id: IdMap.getId("line"), + title: "merge line", + description: "This is a new line", + thumbnail: "test-img-yeah.com/thumb", + content: [ + { + unit_price: 8, + variant: { + _id: IdMap.getId("eur-8-us-10"), + }, + product: { + _id: IdMap.getId("product"), + }, + quantity: 1, + }, + { + unit_price: 10, + variant: { + _id: IdMap.getId("eur-10-us-12"), + }, + product: { + _id: IdMap.getId("product"), + }, + quantity: 1, + }, + ], + quantity: 10, + }, + { + _id: IdMap.getId("existingLine"), + title: "merge line", + description: "This is a new line", + thumbnail: "test-img-yeah.com/thumb", + content: { + unit_price: 10, + variant: { + _id: IdMap.getId("eur-10-us-12"), + }, + product: { + _id: IdMap.getId("product"), + }, + quantity: 1, + }, + quantity: 10, + }, + ], + shipping_methods: [ + { + _id: IdMap.getId("freeShipping"), + profile_id: "default_profile", + }, + ], + shipping_options: [ + { + _id: IdMap.getId("freeShipping"), + profile_id: "default_profile", + }, + ], + payment_sessions: [ + { + provider_id: "stripe", + data: { + id: "pi_123456789", + customer: IdMap.getId("not-lebron"), + }, + }, + ], + payment_method: { + provider_id: "stripe", + data: { + id: "pi_123456789", + customer: IdMap.getId("not-lebron"), + }, + }, + shipping_address: {}, + billing_address: {}, + discounts: [], + customer_id: IdMap.getId("vvd"), + }, +} + +export const CartServiceMock = { + retrieve: jest.fn().mockImplementation((cartId) => { + if (cartId === IdMap.getId("fr-cart")) { + return Promise.resolve(carts.frCart) + } + if (cartId === IdMap.getId("emptyCart")) { + return Promise.resolve(carts.emptyCart) + } + return Promise.resolve(undefined) + }), + updatePaymentSession: jest + .fn() + .mockImplementation((cartId, stripe, paymentIntent) => { + return Promise.resolve() + }), +} + +const mock = jest.fn().mockImplementation(() => { + return CartServiceMock +}) + +export default mock diff --git a/packages/medusa-payment-stripe/src/__mocks__/customer.js b/packages/medusa-payment-stripe/src/__mocks__/customer.js new file mode 100644 index 0000000000..8d197e14d7 --- /dev/null +++ b/packages/medusa-payment-stripe/src/__mocks__/customer.js @@ -0,0 +1,36 @@ +import { IdMap } from "medusa-test-utils" + +export const CustomerServiceMock = { + retrieve: jest.fn().mockImplementation((id) => { + if (id === IdMap.getId("lebron")) { + return Promise.resolve({ + _id: IdMap.getId("lebron"), + first_name: "LeBron", + last_name: "James", + email: "lebron@james.com", + password_hash: "1234", + metadata: { + stripe_id: "cus_123456789_new", + }, + }) + } + if (id === IdMap.getId("vvd")) { + return Promise.resolve({ + _id: IdMap.getId("vvd"), + first_name: "Virgil", + last_name: "Van Dijk", + email: "virg@vvd.com", + password_hash: "1234", + metadata: {}, + }) + } + return Promise.resolve(undefined) + }), + setMetadata: jest.fn().mockReturnValue(Promise.resolve()), +} + +const mock = jest.fn().mockImplementation(() => { + return CustomerServiceMock +}) + +export default mock diff --git a/packages/medusa-payment-stripe/src/__mocks__/eventbus.js b/packages/medusa-payment-stripe/src/__mocks__/eventbus.js new file mode 100644 index 0000000000..e9031d9428 --- /dev/null +++ b/packages/medusa-payment-stripe/src/__mocks__/eventbus.js @@ -0,0 +1,10 @@ +export const EventBusServiceMock = { + emit: jest.fn(), + subscribe: jest.fn(), +} + +const mock = jest.fn().mockImplementation(() => { + return EventBusServiceMock +}) + +export default mock diff --git a/packages/medusa-payment-stripe/src/__mocks__/stripe.js b/packages/medusa-payment-stripe/src/__mocks__/stripe.js new file mode 100644 index 0000000000..7aa040b8f6 --- /dev/null +++ b/packages/medusa-payment-stripe/src/__mocks__/stripe.js @@ -0,0 +1,85 @@ +export const StripeMock = { + customers: { + create: jest.fn().mockImplementation((data) => { + if (data.email === "virg@vvd.com") { + return Promise.resolve({ + id: "cus_vvd", + email: "virg@vvd.com", + }) + } + if (data.email === "lebron@james.com") { + return Promise.resolve({ + id: "cus_lebron", + email: "lebron@james.com", + }) + } + }), + }, + paymentIntents: { + create: jest.fn().mockImplementation((data) => { + if (data.customer === "cus_123456789_new") { + return Promise.resolve({ + id: "pi_lebron", + amount: 100, + customer: "cus_123456789_new", + }) + } + if (data.customer === "cus_lebron") { + return Promise.resolve({ + id: "pi_lebron", + amount: 100, + customer: "cus_lebron", + }) + } + }), + retrieve: jest.fn().mockImplementation((data) => { + return Promise.resolve({ + id: "pi_lebron", + customer: "cus_lebron", + }) + }), + update: jest.fn().mockImplementation((pi, data) => { + if (data.customer === "cus_lebron_2") { + return Promise.resolve({ + id: "pi_lebron", + customer: "cus_lebron_2", + amount: 1000, + }) + } + return Promise.resolve({ + id: "pi_lebron", + customer: "cus_lebron", + amount: 1000, + }) + }), + capture: jest.fn().mockImplementation((data) => { + return Promise.resolve({ + id: "pi_lebron", + customer: "cus_lebron", + amount: 1000, + status: "succeeded", + }) + }), + cancel: jest.fn().mockImplementation((data) => { + return Promise.resolve({ + id: "pi_lebron", + customer: "cus_lebron", + status: "cancelled", + }) + }), + }, + refunds: { + create: jest.fn().mockImplementation((data) => { + return Promise.resolve({ + id: "re_123", + payment_intent: "pi_lebron", + amount: 1000, + status: "succeeded", + }) + }), + }, +} + +const stripe = jest.fn(() => StripeMock) + +export default stripe diff --git a/packages/medusa-payment-stripe/src/__mocks__/totals.js b/packages/medusa-payment-stripe/src/__mocks__/totals.js new file mode 100644 index 0000000000..e4b1f117e3 --- /dev/null +++ b/packages/medusa-payment-stripe/src/__mocks__/totals.js @@ -0,0 +1,9 @@ +export const TotalsServiceMock = { + getTotal: jest.fn(), +} + +const mock = jest.fn().mockImplementation(() => { + return TotalsServiceMock +}) + +export default mock diff --git a/packages/medusa-payment-stripe/src/api/index.js b/packages/medusa-payment-stripe/src/api/index.js index 452cc7ee1d..50feeb7074 100644 --- a/packages/medusa-payment-stripe/src/api/index.js +++ b/packages/medusa-payment-stripe/src/api/index.js @@ -1,14 +1,10 @@ import { Router } from "express" +import hooks from "./routes/hooks" -export default () => { +export default (container) => { const app = Router() - app.get("/stripe", (req, res) => { - console.log("hi") - res.json({ - success: true - }) - }) + hooks(app) return app } diff --git a/packages/medusa-payment-stripe/src/api/middlewares/await-middleware.js b/packages/medusa-payment-stripe/src/api/middlewares/await-middleware.js new file mode 100644 index 0000000000..1c3692b377 --- /dev/null +++ b/packages/medusa-payment-stripe/src/api/middlewares/await-middleware.js @@ -0,0 +1 @@ +export default (fn) => (...args) => fn(...args).catch(args[2]) diff --git a/packages/medusa-payment-stripe/src/api/middlewares/index.js b/packages/medusa-payment-stripe/src/api/middlewares/index.js new file mode 100644 index 0000000000..c784e319a9 --- /dev/null +++ b/packages/medusa-payment-stripe/src/api/middlewares/index.js @@ -0,0 +1,5 @@ +import { default as wrap } from "./await-middleware" + +export default { + wrap, +} diff --git a/packages/medusa-payment-stripe/src/api/routes/hooks/index.js b/packages/medusa-payment-stripe/src/api/routes/hooks/index.js new file mode 100644 index 0000000000..9a0e535ae5 --- /dev/null +++ b/packages/medusa-payment-stripe/src/api/routes/hooks/index.js @@ -0,0 +1,17 @@ +import { Router } from "express" +import bodyParser from "body-parser" +import middlewares from "../../middlewares" + +const route = Router() + +export default (app) => { + app.use("/hooks", route) + + route.post( + "/stripe", + // stripe constructEvent fails without body-parser + bodyParser.raw({ type: "application/json" }), + middlewares.wrap(require("./stripe").default) + ) + return app +} diff --git a/packages/medusa-payment-stripe/src/api/routes/hooks/stripe.js b/packages/medusa-payment-stripe/src/api/routes/hooks/stripe.js new file mode 100644 index 0000000000..df6f45cc1c --- /dev/null +++ b/packages/medusa-payment-stripe/src/api/routes/hooks/stripe.js @@ -0,0 +1,35 @@ +export default async (req, res) => { + const signature = req.headers["stripe-signature"] + + let event + try { + const stripeProviderService = req.resolve("pp_stripe") + event = stripeProviderService.constructWebhookEvent(req.body, signature) + } catch (err) { + res.status(400).send(`Webhook Error: ${err.message}`) + return + } + + const paymentIntent = event.data.object + + // handle payment intent events + switch (event.type) { + case "payment_intent.succeeded": + break + case "payment_intent.canceled": + break + case "payment_intent.created": + break + case "payment_intent.payment_failed": + break + case "payment_intent.amount_capturable_updated": + break + case "payment_intent.processing": + break + default: + res.status(400) + return + } + + res.sendStatus(200) +} diff --git a/packages/medusa-payment-stripe/src/services/__mocks__/stripe-provider.js b/packages/medusa-payment-stripe/src/services/__mocks__/stripe-provider.js new file mode 100644 index 0000000000..ed3e4d955a --- /dev/null +++ b/packages/medusa-payment-stripe/src/services/__mocks__/stripe-provider.js @@ -0,0 +1,52 @@ +import { IdMap } from "medusa-test-utils" + +export const StripeProviderServiceMock = { + retrievePayment: jest.fn().mockImplementation((cart) => { + if (cart._id === IdMap.getId("fr-cart")) { + return Promise.resolve({ + id: "pi", + customer: "cus_123456789", + }) + } + if (cart._id === IdMap.getId("fr-cart-no-customer")) { + return Promise.resolve({ + id: "pi", + }) + } + return Promise.resolve(undefined) + }), + cancelPayment: jest.fn().mockImplementation((cart) => { + return Promise.resolve() + }), + updatePaymentIntentCustomer: jest.fn().mockImplementation((cart) => { + return Promise.resolve() + }), + retrieveCustomer: jest.fn().mockImplementation((customerId) => { + if (customerId === "cus_123456789_new") { + return Promise.resolve({ + id: "cus_123456789_new", + }) + } + return Promise.resolve(undefined) + }), + createCustomer: jest.fn().mockImplementation((customer) => { + if (customer._id === IdMap.getId("vvd")) { + return Promise.resolve({ + id: "cus_123456789_new_vvd", + }) + } + return Promise.resolve(undefined) + }), + createPayment: jest.fn().mockImplementation((cart) => { + return Promise.resolve({ + id: "pi_new", + customer: "cus_123456789_new", + }) + }), +} + +const mock = jest.fn().mockImplementation(() => { + return StripeProviderServiceMock +}) + +export default mock diff --git a/packages/medusa-payment-stripe/src/services/__tests__/stripe-provider.js b/packages/medusa-payment-stripe/src/services/__tests__/stripe-provider.js new file mode 100644 index 0000000000..0aedc1abf3 --- /dev/null +++ b/packages/medusa-payment-stripe/src/services/__tests__/stripe-provider.js @@ -0,0 +1,243 @@ +import { IdMap } from "medusa-test-utils" +import StripeProviderService from "../stripe-provider" +import { CustomerServiceMock } from "../../__mocks__/customer" +import { carts } from "../../__mocks__/cart" +import { TotalsServiceMock } from "../../__mocks__/totals" + +describe("StripeProviderService", () => { + describe("createCustomer", () => { + let result + beforeAll(async () => { + jest.clearAllMocks() + const stripeProviderService = new StripeProviderService( + { + customerService: CustomerServiceMock, + totalsService: TotalsServiceMock, + }, + { + api_key: "test", + } + ) + + result = await stripeProviderService.createCustomer({ + _id: IdMap.getId("vvd"), + first_name: "Virgil", + last_name: "Van Dijk", + email: "virg@vvd.com", + password_hash: "1234", + metadata: {}, + }) + }) + + it("returns created stripe customer", () => { + expect(result).toEqual({ + id: "cus_vvd", + email: "virg@vvd.com", + }) + }) + }) + + describe("createPayment", () => { + let result + const stripeProviderService = new StripeProviderService( + { + customerService: CustomerServiceMock, + totalsService: TotalsServiceMock, + }, + { + api_key: "test", + } + ) + + beforeEach(async () => { + jest.clearAllMocks() + }) + + it("returns created stripe payment intent for cart with existing customer", async () => { + result = await stripeProviderService.createPayment(carts.frCart) + expect(result).toEqual({ + id: "pi_lebron", + customer: "cus_123456789_new", + amount: 100, + }) + }) + + it("returns created stripe payment intent for cart with no customer", async () => { + carts.frCart.customer_id = "" + result = await stripeProviderService.createPayment(carts.frCart) + expect(result).toEqual({ + id: "pi_lebron", + customer: "cus_lebron", + amount: 100, + }) + }) + }) + + describe("retrievePayment", () => { + let result + beforeAll(async () => { + jest.clearAllMocks() + const stripeProviderService = new StripeProviderService( + { + customerService: CustomerServiceMock, + totalsService: TotalsServiceMock, + }, + { + api_key: "test", + } + ) + + result = await stripeProviderService.retrievePayment({ + payment_method: { + data: { + id: "pi_lebron", + }, + }, + }) + }) + + it("returns stripe payment intent", () => { + expect(result).toEqual({ + id: "pi_lebron", + customer: "cus_lebron", + }) + }) + }) + + describe("updatePayment", () => { + let result + beforeAll(async () => { + jest.clearAllMocks() + const stripeProviderService = new StripeProviderService( + { + customerService: CustomerServiceMock, + totalsService: TotalsServiceMock, + }, + { + api_key: "test", + } + ) + + result = await stripeProviderService.updatePayment( + { + payment_method: { + data: { + id: "pi_lebron", + }, + }, + }, + { + amount: 1000, + } + ) + }) + + it("returns cancelled stripe payment intent", () => { + expect(result).toEqual({ + id: "pi_lebron", + customer: "cus_lebron", + amount: 1000, + }) + }) + }) + + describe("updatePaymentIntentCustomer", () => { + let result + beforeAll(async () => { + jest.clearAllMocks() + const stripeProviderService = new StripeProviderService( + { + customerService: CustomerServiceMock, + totalsService: TotalsServiceMock, + }, + { + api_key: "test", + } + ) + + result = await stripeProviderService.updatePaymentIntentCustomer( + "pi_lebron", + "cus_lebron_2" + ) + }) + + it("returns update stripe payment intent", () => { + expect(result).toEqual({ + id: "pi_lebron", + customer: "cus_lebron_2", + amount: 1000, + }) + }) + }) + + describe("capturePayment", () => { + let result + beforeAll(async () => { + jest.clearAllMocks() + const stripeProviderService = new StripeProviderService( + {}, + { + api_key: "test", + } + ) + + result = await stripeProviderService.capturePayment("pi_lebron") + }) + + it("returns captured stripe payment intent", () => { + expect(result).toEqual({ + id: "pi_lebron", + customer: "cus_lebron", + amount: 1000, + status: "succeeded", + }) + }) + }) + + describe("refundPayment", () => { + let result + beforeAll(async () => { + jest.clearAllMocks() + const stripeProviderService = new StripeProviderService( + {}, + { + api_key: "test", + } + ) + + result = await stripeProviderService.refundPayment("pi_lebron", 1000) + }) + + it("returns refunded stripe payment intent", () => { + expect(result).toEqual({ + id: "re_123", + payment_intent: "pi_lebron", + amount: 1000, + status: "succeeded", + }) + }) + }) + + describe("cancelPayment", () => { + let result + beforeAll(async () => { + jest.clearAllMocks() + const stripeProviderService = new StripeProviderService( + {}, + { + api_key: "test", + } + ) + + result = await stripeProviderService.cancelPayment("pi_lebron") + }) + + it("returns cancelled stripe payment intent", () => { + expect(result).toEqual({ + id: "pi_lebron", + customer: "cus_lebron", + status: "cancelled", + }) + }) + }) +}) diff --git a/packages/medusa-payment-stripe/src/services/stripe-provider.js b/packages/medusa-payment-stripe/src/services/stripe-provider.js index b28309a7a3..1b6b0455b1 100644 --- a/packages/medusa-payment-stripe/src/services/stripe-provider.js +++ b/packages/medusa-payment-stripe/src/services/stripe-provider.js @@ -1,12 +1,214 @@ import _ from "lodash" +import Stripe from "stripe" import { PaymentService } from "medusa-interfaces" class StripeProviderService extends PaymentService { static identifier = "stripe" - constructor(appScope, options) { + constructor({ customerService, totalsService }, options) { super() - console.log(options) + + this.options_ = options + + this.stripe_ = Stripe(options.api_key) + + this.customerService_ = customerService + + this.totalsService_ = totalsService + } + + /** + * Status for Stripe PaymentIntent. + * @param {Object} paymentData - payment method data from cart + * @returns {string} the status of the payment intent + */ + async getStatus(paymentData) { + const { id } = paymentData + + const paymentIntent = await this.stripe_.paymentIntents.retrieve(id) + + let status = "initial" + + if (paymentIntent.status === "requires_payment_method") { + return status + } + + if (paymentIntent.status === "requires_action") { + status = "authorized" + } + + if (paymentIntent.status === "succeeded") { + status = "succeeded" + } + + if (paymentIntent.status === "cancelled") { + status = "cancelled" + } + + return status + } + + async retrieveCustomer(customerId) { + return this.stripe_.customers.retrieve(customerId) + } + + // customer metadata + async createCustomer(customer) { + try { + const stripeCustomer = await this.stripe_.customers.create({ + email: customer.email, + }) + await this.customerService_.setMetadata( + customer._id, + "stripe_id", + stripeCustomer.id + ) + return stripeCustomer + } catch (error) { + throw error + } + } + + /** + * Creates Stripe PaymentIntent. + * @param {string} cart - the cart to create a payment for + * @param {number} amount - the amount to create a payment for + * @returns {string} id of payment intent + */ + async createPayment(cart) { + const { customer_id } = cart + + let stripeCustomerId + + if (!customer_id) { + const { id } = await this.stripe_.customers.create({ + email: cart.email, + }) + stripeCustomerId = id + } else { + const customer = await this.customerService_.retrieve(customer_id) + if (!customer.metadata.stripe_id) { + const { id } = await this.stripe_.customers.create({ + email: customer.email, + }) + await this.customerService_.setMetadata(customer._id, "stripe_id", id) + } else { + stripeCustomerId = customer.metadata.stripe_id + } + } + + const amount = this.totalsService_.getTotal(cart) + const paymentIntent = await this.stripe_.paymentIntents.create({ + customer: stripeCustomerId, + amount, + }) + + return paymentIntent + } + + /** + * Retrieves Stripe PaymentIntent. + * @param {string} cart - the cart to retrieve payment intent for + * @returns {Object} Stripe PaymentIntent + */ + async retrievePayment(cart) { + try { + const { data } = cart.payment_method + return this.stripe_.paymentIntents.retrieve(data.id) + } catch (error) { + throw error + } + } + + /** + * Updates Stripe PaymentIntent. + * @param {string} cart - the cart to update payment intent for + * @param {Object} data - the update object for the payment intent + * @returns {Object} Stripe PaymentIntent + */ + async updatePayment(cart, update) { + try { + const { data } = cart.payment_method + return this.stripe_.paymentIntents.update(data.id, update) + } catch (error) { + throw error + } + } + + /** + * Updates customer of Stripe PaymentIntent. + * @param {string} cart - the cart to update payment intent for + * @param {Object} data - the update object for the payment intent + * @returns {Object} Stripe PaymentIntent + */ + async updatePaymentIntentCustomer(paymentIntent, id) { + try { + return this.stripe_.paymentIntents.update(paymentIntent, { + customer: id, + }) + } catch (error) { + throw error + } + } + + /** + * Captures payment for Stripe PaymentIntent. + * @param {Object} paymentData - payment method data from cart + * @returns {Object} Stripe PaymentIntent + */ + async capturePayment(paymentData) { + const { id } = paymentData + try { + return this.stripe_.paymentIntents.capture(id) + } catch (error) { + throw error + } + } + + /** + * Refunds payment for Stripe PaymentIntent. + * @param {Object} paymentData - payment method data from cart + * @returns {string} id of payment intent + */ + async refundPayment(paymentData, amount) { + const { id } = paymentData + try { + return this.stripe_.refunds.create({ + amount, + payment_intent: id, + }) + } catch (error) { + throw error + } + } + + /** + * Cancels payment for Stripe PaymentIntent. + * @param {Object} paymentData - payment method data from cart + * @returns {string} id of payment intent + */ + async cancelPayment(paymentData) { + const { id } = paymentData + try { + return this.stripe_.paymentIntents.cancel(id) + } catch (error) { + throw error + } + } + + /** + * Constructs Stripe Webhook event + * @param {Object} data - the data of the webhook request: req.body + * @param {Object} signature - the Stripe signature on the event, that + * ensures integrity of the webhook event + * @returns {Object} Stripe Webhook event + */ + constructWebhookEvent(data, signature) { + return this.stripe_.webhooks.constructEvent( + data, + signature, + this.options_.webhook_secret + ) } } diff --git a/packages/medusa-payment-stripe/src/subscribers/__tests__/cart.js b/packages/medusa-payment-stripe/src/subscribers/__tests__/cart.js new file mode 100644 index 0000000000..bd477fe6ac --- /dev/null +++ b/packages/medusa-payment-stripe/src/subscribers/__tests__/cart.js @@ -0,0 +1,90 @@ +import { IdMap } from "medusa-test-utils" +import { carts, CartServiceMock } from "../../__mocks__/cart" +import { CustomerServiceMock } from "../../__mocks__/customer" +import { StripeProviderServiceMock } from "../../services/__mocks__/stripe-provider" +import { EventBusServiceMock } from "../../__mocks__/eventbus" +import CartSubscriber from "../cart" + +describe("CartSubscriber", () => { + describe("onCustomerUpdated", () => { + let cartSubcriber = new CartSubscriber({ + eventBusService: EventBusServiceMock, + cartService: CartServiceMock, + stripeProviderService: StripeProviderServiceMock, + customerService: CustomerServiceMock, + }) + + beforeEach(async () => { + jest.clearAllMocks() + }) + + it("resolves on non-existing payment data", async () => { + await cartSubcriber.onCustomerUpdated(carts.emptyCart) + + expect(CustomerServiceMock.retrieve).toHaveBeenCalledTimes(0) + }) + + it("cancels old and creates new payment intent with the updated existing customer", async () => { + await cartSubcriber.onCustomerUpdated(carts.frCart) + + expect(CustomerServiceMock.retrieve).toHaveBeenCalledTimes(1) + expect(CustomerServiceMock.retrieve).toHaveBeenCalledWith( + IdMap.getId("lebron") + ) + + expect(StripeProviderServiceMock.retrievePayment).toHaveBeenCalledTimes(1) + expect(StripeProviderServiceMock.retrievePayment).toHaveBeenCalledWith( + carts.frCart + ) + + expect(StripeProviderServiceMock.cancelPayment).toHaveBeenCalledTimes(1) + expect(StripeProviderServiceMock.cancelPayment).toHaveBeenCalledWith("pi") + + expect(StripeProviderServiceMock.createPayment).toHaveBeenCalledTimes(1) + expect(StripeProviderServiceMock.createPayment).toHaveBeenCalledWith( + carts.frCart + ) + + expect(CartServiceMock.updatePaymentSession).toHaveBeenCalledTimes(1) + expect(CartServiceMock.updatePaymentSession).toHaveBeenCalledWith( + IdMap.getId("fr-cart"), + "stripe", + { + id: "pi_new", + customer: "cus_123456789_new", + } + ) + }) + + it("cancels old and creates new payment intent and creates new stripe customer", async () => { + await cartSubcriber.onCustomerUpdated(carts.frCartNoStripeCustomer) + + expect(CustomerServiceMock.retrieve).toHaveBeenCalledTimes(1) + expect(CustomerServiceMock.retrieve).toHaveBeenCalledWith( + IdMap.getId("vvd") + ) + + expect(StripeProviderServiceMock.retrievePayment).toHaveBeenCalledTimes(1) + expect(StripeProviderServiceMock.retrievePayment).toHaveBeenCalledWith( + carts.frCartNoStripeCustomer + ) + + expect(StripeProviderServiceMock.createCustomer).toHaveBeenCalledTimes(1) + expect(StripeProviderServiceMock.createCustomer).toHaveBeenCalledWith({ + _id: IdMap.getId("vvd"), + first_name: "Virgil", + last_name: "Van Dijk", + email: "virg@vvd.com", + password_hash: "1234", + metadata: {}, + }) + + expect( + StripeProviderServiceMock.updatePaymentIntentCustomer + ).toHaveBeenCalledTimes(1) + expect( + StripeProviderServiceMock.updatePaymentIntentCustomer + ).toHaveBeenCalledWith("cus_123456789_new_vvd") + }) + }) +}) diff --git a/packages/medusa-payment-stripe/src/subscribers/cart.js b/packages/medusa-payment-stripe/src/subscribers/cart.js index 045cc1b12e..e5a0af6c1e 100644 --- a/packages/medusa-payment-stripe/src/subscribers/cart.js +++ b/packages/medusa-payment-stripe/src/subscribers/cart.js @@ -1,11 +1,71 @@ class CartSubscriber { - constructor({ cartService, eventBusService }) { + constructor({ + cartService, + customerService, + stripeProviderService, + eventBusService, + }) { this.cartService_ = cartService + this.customerService_ = customerService + this.stripeProviderService_ = stripeProviderService this.eventBus_ = eventBusService this.eventBus_.subscribe("cart.created", (data) => { console.log(data) }) + + this.eventBus_.subscribe("cart.customer_updated", async (cart) => { + await this.onCustomerUpdated(cart) + }) + } + + async onCustomerUpdated(cart) { + const { customer_id, payment_sessions } = cart + + if (!payment_sessions) { + return Promise.resolve() + } + + const customer = await this.customerService_.retrieve(customer_id) + + const paymentIntent = await this.stripeProviderService_.retrievePayment( + cart + ) + + let stripeCustomer = await this.stripeProviderService_.retrieveCustomer( + customer.metadata.stripe_id + ) + + if (!stripeCustomer) { + stripeCustomer = await this.stripeProviderService_.createCustomer( + customer + ) + } + + if (stripeCustomer.id === paymentIntent.customer) { + return Promise.resolve() + } + + if (!paymentIntent.customer) { + return this.stripeProviderService_.updatePaymentIntentCustomer( + stripeCustomer.id + ) + } + + if (stripeCustomer.id !== paymentIntent.customer) { + await this.stripeProviderService_.cancelPayment(paymentIntent.id) + const newPaymentIntent = await this.stripeProviderService_.createPayment( + cart + ) + + await this.cartService_.updatePaymentSession( + cart._id, + "stripe", + newPaymentIntent + ) + } + + return Promise.resolve() } }