diff --git a/client/modules/core/helpers/apps.js b/client/modules/core/helpers/apps.js index 72ab1829b47..94f34af1c73 100644 --- a/client/modules/core/helpers/apps.js +++ b/client/modules/core/helpers/apps.js @@ -3,7 +3,7 @@ import { Template } from "meteor/templating"; import { Meteor } from "meteor/meteor"; import { Roles } from "meteor/alanning:roles"; import { Reaction } from "/client/api"; -import { Packages } from "/lib/collections"; +import { Packages, Shops } from "/lib/collections"; /** @@ -49,6 +49,7 @@ export function Apps(optionHash) { let key; const reactionApps = []; let options = {}; + let shopType; // allow for object or option.hash if (optionHash) { @@ -64,6 +65,13 @@ export function Apps(optionHash) { options.shopId = Reaction.getShopId(); } + // Get the shop to determine shopType + const shop = Shops.findOne({ _id: options.shopId }); + if (shop) { + shopType = shop.shopType; + } + + // remove audience permissions for owner (still needed here for older/legacy calls) if (Reaction.hasOwnerAccess() && options.audience) { delete options.audience; @@ -130,6 +138,20 @@ export function Apps(optionHash) { delete itemFilter.audience; } + // Check that shopType matches showForShopType if option is present + if (item.showForShopTypes && + Array.isArray(item.showForShopTypes) && + item.showForShopTypes.indexOf(shopType) === -1) { + return false; + } + + // Check that shopType does not match hideForShopType if option is present + if (item.hideForShopTypes && + Array.isArray(item.hideForShopTypes) && + item.hideForShopTypes.indexOf(shopType) !== -1) { + return false; + } + return _.isMatch(item, itemFilter); }); diff --git a/client/modules/core/main.js b/client/modules/core/main.js index 949a0f111b7..8c467e5b5e8 100644 --- a/client/modules/core/main.js +++ b/client/modules/core/main.js @@ -30,13 +30,14 @@ export default { init() { Tracker.autorun(() => { - // marketplaceSettings come over on the PrimarySHopPackages subscription + // marketplaceSettings come over on the PrimaryShopPackages subscription if (this.Subscriptions.PrimaryShopPackages.ready()) { if (!this.marketplace._ready) { const marketplacePkgSettings = this.getMarketplaceSettings(); if (marketplacePkgSettings && marketplacePkgSettings.public) { - marketplacePkgSettings._ready = true; + this.marketplace._ready = true; this.marketplace = marketplacePkgSettings.public; + this.marketplace.enabled = true; } } } @@ -487,6 +488,11 @@ export default { return Packages.findOne(query); }, + getPackageSettingsWithOptions(options) { + const query = options; + return Packages.findOne(query); + }, + allowGuestCheckout() { let allowGuest = false; const settings = this.getShopSettings(); diff --git a/client/modules/core/subscriptions.js b/client/modules/core/subscriptions.js index b88325e2131..8bda12b640a 100644 --- a/client/modules/core/subscriptions.js +++ b/client/modules/core/subscriptions.js @@ -37,8 +37,14 @@ Subscriptions.PrimaryShop = Subscriptions.Manager.subscribe("PrimaryShop"); // Additional shop subscriptions Subscriptions.MerchantShops = Subscriptions.Manager.subscribe("MerchantShops"); -// Init Packages sub so we have a "ready" state +// This Packages subscription is used for the Active shop's packages +// // Init sub here so we have a "ready" state Subscriptions.Packages = Subscriptions.Manager.subscribe("Packages"); + +// This packages subscription is used for the Primary Shop's packages +// The Packages publication defaults to returning the primaryShopId's packages, +// so this subscription shouldn't ever need to be changed +// TODO: Consider how to handle routes for several shops which are all active at once Subscriptions.PrimaryShopPackages = Subscriptions.Manager.subscribe("Packages"); Subscriptions.Tags = Subscriptions.Manager.subscribe("Tags"); @@ -82,14 +88,9 @@ Tracker.autorun(() => { Tracker.autorun(() => { // Reload Packages sub if shopId changes - if (Reaction.getShopId()) { + // We have a persistent subscription to the primary shop's packages, + // so don't refresh sub if we're updating to primaryShopId sub + if (Reaction.getShopId() && Reaction.getShopId() !== Reaction.getPrimaryShopId()) { Subscriptions.Packages = Subscriptions.Manager.subscribe("Packages", Reaction.getShopId()); } }); - -Tracker.autorun(() => { - // Reload Packages sub if primaryShopId changes - if (Reaction.getPrimaryShopId()) { - Subscriptions.PrimaryShopPackages = Subscriptions.Manager.subscribe("Packages", Reaction.getPrimaryShopId()); - } -}); diff --git a/imports/plugins/core/checkout/client/methods/cart.js b/imports/plugins/core/checkout/client/methods/cart.js index e2f2b6d8c4b..23ce2b81d65 100644 --- a/imports/plugins/core/checkout/client/methods/cart.js +++ b/imports/plugins/core/checkout/client/methods/cart.js @@ -7,6 +7,9 @@ import { Cart } from "/lib/collections"; // Client Cart Methods // Stubs with matching server methods. Meteor.methods({ + // Not used for stripe connect integration + // Under consideration for deprecation and migrating other payment Packages + // to payments-stripe style methods "cart/submitPayment": function (paymentMethod) { check(paymentMethod, Reaction.Schemas.PaymentMethod); const checkoutCart = Cart.findOne({ diff --git a/imports/plugins/core/dashboard/client/templates/shop/settings/settings.html b/imports/plugins/core/dashboard/client/templates/shop/settings/settings.html index 0960df77fe9..40a50baa4f5 100644 --- a/imports/plugins/core/dashboard/client/templates/shop/settings/settings.html +++ b/imports/plugins/core/dashboard/client/templates/shop/settings/settings.html @@ -114,8 +114,8 @@
To accept payment and publish your products to the marketplace, you'll need + to connect your stripe account. If you don't have a stripe account, you can set + one up for free as part of this process.
+ data-i18n="marketplace.stripeConnectSignup">Start Accepting Payment diff --git a/imports/plugins/included/marketplace/client/templates/stripeConnectSignupButton/stripeConnectSignupButton.js b/imports/plugins/included/marketplace/client/templates/stripeConnectSignupButton/stripeConnectSignupButton.js index 2ecbf9ffde8..ae3235b3c69 100644 --- a/imports/plugins/included/marketplace/client/templates/stripeConnectSignupButton/stripeConnectSignupButton.js +++ b/imports/plugins/included/marketplace/client/templates/stripeConnectSignupButton/stripeConnectSignupButton.js @@ -1,48 +1,53 @@ -import { Meteor } from "meteor/meteor"; import { Template } from "meteor/templating"; import { Reaction } from "/lib/api"; +import { i18next } from "/client/api"; +import { Shops } from "/lib/collections"; -// TODO: This button should be a React component. +Template.stripeConnectSignupButton.events({ + "click [data-event-action='button-click-stripe-signup']": function () { + const shopId = Reaction.getShopId(); + const primaryShopId = Reaction.getPrimaryShopId(); + const primaryStripePackage = Reaction.getPackageSettingsWithOptions({ + shopId: primaryShopId, + name: "reaction-stripe", + enabled: true + }); -Template.stripeConnectSignupButton.onCreated(function () { - this.autorun(() => { - // TODO: this should probably be managed by a subscription? - // Seems inefficient to do it at the button component level - Meteor.subscribe("SellerShops"); - }); -}); + //eslint-disable-next-line + // debugger; + let clientId; -// Button -Template.stripeConnectSignupButton.helpers({ - /** - * Give it a size and style - * @return {String} The classes - */ - classes() { - const classes = [ - (this.type || "btn-info"), - (this.size || "") - ]; - - return classes.join(" "); - } -}); + if (primaryStripePackage && + primaryStripePackage.settings && + primaryStripePackage.settings.public && + typeof primaryStripePackage.settings.public.client_id === "string") { + // If the primaryshop has stripe enabled and set the client_id + clientId = primaryStripePackage.settings.public.client_id; + } else { + return Alerts.toast(`${i18next.t("admin.connect.stripeConnectNotEnabled")}`, "error"); + } -Template.stripeConnectSignupButton.events({ - "click [data-event-action='button-click-stripe-signup']": function () { - const sellerShop = Reaction.getSellerShop(); + const shop = Shops.findOne({ _id: shopId }); + + if (!shop.emails || !Array.isArray(shop.emails) || shop.emails.length === 0) { + return Alerts.toast(`${i18next.t("admin.connect.shopEmailNotConfigured")}`, "error"); + } + + if (!shop.addressBook || !Array.isArray(shop.addressBook) || shop.addressBook.length === 0) { + return Alerts.toast(`${i18next.t("admin.connect.shopAddressNotConfigured")}`, "error"); + } - const email = sellerShop.emails[0].address; - const country = sellerShop.addressBook[0].country; - const phoneNumber = sellerShop.addressBook[0].phone; - const businessName = sellerShop.addressBook[0].company; - const streetAddress = sellerShop.addressBook[0].address1; - const city = sellerShop.addressBook[0].city; - const state = sellerShop.addressBook[0].state; - const zip = sellerShop.addressBook[0].postal; + const email = shop.emails[0].address; + const country = shop.addressBook[0].country; + const phoneNumber = shop.addressBook[0].phone; + const businessName = shop.addressBook[0].company; + const streetAddress = shop.addressBook[0].address1; + const city = shop.addressBook[0].city; + const state = shop.addressBook[0].state; + const zip = shop.addressBook[0].postal; + const stripeConnectAuthorizeUrl = `https://connect.stripe.com/oauth/authorize?response_type=code&state=${shopId}&client_id=${clientId}&scope=read_write`; const autofillParams = `&stripe_user[email]=${email}&stripe_user[country]=${country}&stripe_user[phone_number]=${phoneNumber}&stripe_user[business_name]=${businessName}&stripe_user[street_address]=${streetAddress}&stripe_user[city]=${city}&stripe_user[state]=${state}&stripe_user[zip]=${zip}`; // eslint-disable-line max-len - // TODO: Should client_id be hardcoded in here? - window.location.href = "https://connect.stripe.com/oauth/authorize?response_type=code&client_id=ca_32D88BD1qLklliziD7gYQvctJIhWBSQ7&scope=read_write" + autofillParams; + window.open(stripeConnectAuthorizeUrl + autofillParams, "_blank"); } }); diff --git a/imports/plugins/included/marketplace/register.js b/imports/plugins/included/marketplace/register.js index 8e115cfd638..ac76e605788 100644 --- a/imports/plugins/included/marketplace/register.js +++ b/imports/plugins/included/marketplace/register.js @@ -29,11 +29,13 @@ Reaction.registerPackage({ "reaction-product-simple", "reaction-product-variant", "reaction-notification", + "reaction-marketplace", "reaction-analytics", "reaction-inventory", "reaction-sms", "reaction-social", - "reaction-stripe-connect", + "reaction-stripe", + "reaction-taxes", "discount-codes"] }, { shopType: "affiliate", @@ -64,7 +66,8 @@ Reaction.registerPackage({ icon: "fa fa-globe", provides: "shopSettings", container: "dashboard", - template: "marketplaceShopSettings" + template: "marketplaceShopSettings", + showForShopTypes: ["primary"] }, { route: "shop/:shopId", name: "shop", @@ -83,5 +86,13 @@ Reaction.registerPackage({ container: "dashboard", audience: ["seller"], priority: 1 + }, { + // This provides the settings container for marketplaceMerchantSettings + label: "My Shop Settings", + icon: "fa fa-briefcase", + provides: "shopSettings", + container: "dashboard", + template: "marketplaceMerchantSettings", + hideForShopTypes: ["primary"] }] }); diff --git a/imports/plugins/included/payments-stripe-connect/client/index.js b/imports/plugins/included/payments-stripe-connect/client/index.js deleted file mode 100644 index 151b499f63e..00000000000 --- a/imports/plugins/included/payments-stripe-connect/client/index.js +++ /dev/null @@ -1 +0,0 @@ -import "./settings/settings"; diff --git a/imports/plugins/included/payments-stripe-connect/client/settings/settings.html b/imports/plugins/included/payments-stripe-connect/client/settings/settings.html deleted file mode 100644 index b8f1e5756db..00000000000 --- a/imports/plugins/included/payments-stripe-connect/client/settings/settings.html +++ /dev/null @@ -1,65 +0,0 @@ - - {{#unless packageData.settings.api_key}} -Stripe Connect Redirect
+ diff --git a/imports/plugins/included/payments-stripe/client/settings/stripe.js b/imports/plugins/included/payments-stripe/client/settings/stripe.js index a3be99041dd..cfbda7c46db 100644 --- a/imports/plugins/included/payments-stripe/client/settings/stripe.js +++ b/imports/plugins/included/payments-stripe/client/settings/stripe.js @@ -4,8 +4,6 @@ import { Reaction, i18next } from "/client/api"; import { Packages } from "/lib/collections"; import { StripePackageConfig } from "../../lib/collections/schemas"; -import "./stripe.html"; - Template.stripeSettings.helpers({ StripePackageConfig() { return StripePackageConfig; diff --git a/imports/plugins/included/payments-stripe/lib/api/index.js b/imports/plugins/included/payments-stripe/lib/api/index.js deleted file mode 100644 index c68b90e230e..00000000000 --- a/imports/plugins/included/payments-stripe/lib/api/index.js +++ /dev/null @@ -1 +0,0 @@ -export { Stripe } from "./stripe"; diff --git a/imports/plugins/included/payments-stripe/lib/api/stripe.js b/imports/plugins/included/payments-stripe/lib/api/stripe.js deleted file mode 100644 index b2529efe444..00000000000 --- a/imports/plugins/included/payments-stripe/lib/api/stripe.js +++ /dev/null @@ -1,9 +0,0 @@ -import { Meteor } from "meteor/meteor"; - -export const Stripe = { - authorize: function (cardData, paymentInfo, callback) { - Meteor.call("stripeSubmit", "authorize", cardData, paymentInfo, (error, result) => { - callback(error, result); - }); - } -}; diff --git a/imports/plugins/included/payments-stripe/lib/collections/schemas/stripe.js b/imports/plugins/included/payments-stripe/lib/collections/schemas/stripe.js index 75a2210b6b9..5e87dbe07ee 100644 --- a/imports/plugins/included/payments-stripe/lib/collections/schemas/stripe.js +++ b/imports/plugins/included/payments-stripe/lib/collections/schemas/stripe.js @@ -7,6 +7,30 @@ import { PackageConfig } from "/lib/collections/schemas/registry"; * see: https://stripe.com/docs/api */ +const StripeConnectAuthorizationCredentials = new SimpleSchema({ + token_type: { // eslint-disable-line camelcase + type: String + }, + stripe_publishable_key: { // eslint-disable-line camelcase + type: String + }, + scope: { + type: String + }, + livemode: { + type: Boolean + }, + stripe_user_id: { // eslint-disable-line camelcase + type: String + }, + refresh_token: { // eslint-disable-line camelcase + type: String + }, + access_token: { // eslint-disable-line camelcase + type: String + } +}); + export const StripePackageConfig = new SimpleSchema([ PackageConfig, { "settings.mode": { @@ -15,7 +39,12 @@ export const StripePackageConfig = new SimpleSchema([ }, "settings.api_key": { type: String, - label: "API Client ID" + label: "API Secret Key" + }, + "settings.connectAuth": { + type: StripeConnectAuthorizationCredentials, + label: "Connect Authorization Credentials", + optional: true }, "settings.reaction-stripe.support": { type: Array, @@ -24,6 +53,13 @@ export const StripePackageConfig = new SimpleSchema([ "settings.reaction-stripe.support.$": { type: String, allowedValues: ["Authorize", "De-authorize", "Capture", "Refund"] + }, + + // Public Settings + "settings.public.client_id": { + type: String, + label: "Public Client ID", + optional: true } } ]); diff --git a/imports/plugins/included/payments-stripe/register.js b/imports/plugins/included/payments-stripe/register.js index cb213e2fb5f..d67a0231cf7 100644 --- a/imports/plugins/included/payments-stripe/register.js +++ b/imports/plugins/included/payments-stripe/register.js @@ -16,7 +16,11 @@ Reaction.registerPackage({ "Capture", "Refund" ] - } + }, + "public": { + client_id: "" + }, + "connectAuth": {} }, registry: [ // Settings panel @@ -24,7 +28,8 @@ Reaction.registerPackage({ label: "Stripe", provides: "paymentSettings", container: "dashboard", - template: "stripeSettings" + template: "stripeSettings", + hideForShopTypes: ["merchant", "affiliate"] }, // Payment form for checkout @@ -32,6 +37,22 @@ Reaction.registerPackage({ template: "stripePaymentForm", provides: "paymentMethod", icon: "fa fa-cc-stripe" + }, + + // Redirect for Stripe Connect Sign-In + { + route: "/stripe/connect/authorize", + template: "stripeConnectAuthorize" + }, + + // Payment Signup for Merchants + { + label: "Stripe Merchant Account", + icon: "fa fa-cc-stripe", + container: "dashboard", + provides: "marketplaceMerchantSettings", + template: "stripeConnectMerchantSignup", + hideForShopTypes: ["primary"] } ] }); diff --git a/imports/plugins/included/payments-stripe/server/i18n/en.json b/imports/plugins/included/payments-stripe/server/i18n/en.json index 26350359abf..b5544eb8972 100644 --- a/imports/plugins/included/payments-stripe/server/i18n/en.json +++ b/imports/plugins/included/payments-stripe/server/i18n/en.json @@ -16,7 +16,17 @@ "stripeLabel": "Stripe", "stripeSettingsLabel": "Stripe", "stripeSettingsDescription": "Don't have a Stripe API Client ID?", - "stripeSettingsGetItHere": "Get it here" + "stripeSettingsGetItHere": "Get it here", + "stripeClientIdWarning": "The Client ID is a PUBLIC field. Please ensure you're using the client_id found on the Stripe Connect settings page.", + "stripeClientIdLink": "Stripe Connect Settings" + }, + "connect": { + "stripeConnectNotEnabled": "Stripe Connect is not enabled for this application. Please contact the adminstrator.", + "shopEmailNotConfigured": "Please configure your shop email first.", + "shopAddressNotConfigured": "Please configure your shop address first" + }, + "redirect": { + "stripeConnectWaitingNote": "Will auto redirect in a couple seconds or click here." } } } diff --git a/imports/plugins/included/payments-stripe/server/index.js b/imports/plugins/included/payments-stripe/server/index.js index 8f5303791ae..8f056a015a2 100644 --- a/imports/plugins/included/payments-stripe/server/index.js +++ b/imports/plugins/included/payments-stripe/server/index.js @@ -1,4 +1,6 @@ import "./i18n"; import "./methods/stripe"; +import "./methods/stripe-connect"; +import "./startup/startup"; export * from "./methods/stripeapi"; diff --git a/imports/plugins/included/payments-stripe/server/methods/stripe-connect.js b/imports/plugins/included/payments-stripe/server/methods/stripe-connect.js new file mode 100644 index 00000000000..b1bb8b66a69 --- /dev/null +++ b/imports/plugins/included/payments-stripe/server/methods/stripe-connect.js @@ -0,0 +1,75 @@ +import { Meteor } from "meteor/meteor"; +import { HTTP } from "meteor/http"; +import { check } from "meteor/check"; +import { Reaction, Logger } from "/server/api"; +import { Packages } from "/lib/collections"; + +export const methods = { + // separate url into params + // save params into sellerShop collection + "stripe/connect/authorizeMerchant": function (shopId, authCode) { + check(shopId, String); + check(authCode, String); + + if (!Reaction.hasPermission(["owner", "admin", "reaction-stripe"], Meteor.userId(), shopId)) { + Logger.warn(`user: ${Meteor.userId()} attempted to authorize merchant account + for shopId ${shopId} but was denied access due to insufficient privileges.`); + throw new Meteor.Error("access-denied", "Access Denied"); + } + + let result; + const primaryShopId = Reaction.getPrimaryShopId(); + const stripePkg = Reaction.getPackageSettingsWithOptions({ + shopId: primaryShopId, + name: "reaction-stripe" + }); + + if (!stripePkg || !stripePkg.settings || !stripePkg.settings.api_key) { + throw new Meteor.Error("cannot-authorize", "Cannot authorize stripe connect merchant. Primary shop stripe must be configured."); + } + + const merchantStripePkg = Reaction.getPackageSettingsWithOptions({ + shopId: shopId, + name: "reaction-stripe" + }); + + if (merchantStripePkg && + merchantStripePkg.settings && + merchantStripePkg.settings.connectAuth && + typeof merchantStripePkg.settings.connectAuth.stripe_user_id === "string") { + return true; + } + + + const apiKey = stripePkg.settings.api_key; + const stripeAuthUrl = "https://connect.stripe.com/oauth/token"; + try { + result = HTTP.call("POST", stripeAuthUrl, { + params: { + client_secret: apiKey, // eslint-disable-line camelcase + code: authCode, + grant_type: "authorization_code" // eslint-disable-line camelcase + } + }); + + if (result.error) { + throw new Meteor.Error("There was a problem authorizing stripe connect", result.error, result.error_description); + } + + if (result && result.data) { + // Setup connectAuth settings for this merchant + Packages.update({ _id: merchantStripePkg._id }, { + $set: { + "settings.connectAuth": result.data + } + }); + } + } catch (error) { + Logger.error(error); + result = { error }; + } + return result; + } +}; + +Meteor.methods(methods); diff --git a/imports/plugins/included/payments-stripe/server/methods/stripe-payment-create-charges.app-test.js b/imports/plugins/included/payments-stripe/server/methods/stripe-payment-create-charges.app-test.js new file mode 100644 index 00000000000..c6b70b06e57 --- /dev/null +++ b/imports/plugins/included/payments-stripe/server/methods/stripe-payment-create-charges.app-test.js @@ -0,0 +1,502 @@ +/* eslint camelcase: 0 */ +import nock from "nock"; + +import { Meteor } from "meteor/meteor"; +import { expect } from "meteor/practicalmeteor:chai"; +import { sinon } from "meteor/practicalmeteor:sinon"; +import { Factory } from "meteor/dburles:factory"; +import { check, Match } from "meteor/check"; + +import { Reaction } from "/server/api"; +import { methods } from "./stripe.js"; + + +const testStripePkg = { + _id: "dLubvXeAciAY3ECD9", + name: "reaction-stripe", + shopId: "DFzmTo27eFS97tfSW", + enabled: true, + settings: { + "mode": false, + "api_key": "sk_test_testkey", + "reaction-stripe": { + enabled: false, + support: [ + "Authorize", + "Capture", + "Refund" + ] + }, + "public": { + client_id: "" + }, + "connectAuth": { + stripe_user_id: "ca_testconnectid" + }, + "client_id": "ca_testclientid" + } +}; + +const stripeCustomerResponse = { + id: "cus_testcust", + object: "customer", + account_balance: 0, + created: 1503200959, + currency: "usd", + default_source: null, + delinquent: false, + description: null, + discount: null, + email: null, + livemode: false, + metadata: { + }, + shipping: null, + sources: { + object: "list", + data: [ + + ], + has_more: false, + total_count: 0, + url: "/v1/customers/cus_testcust/sources" + }, + subscriptions: { + object: "list", + data: [], + has_more: false, + total_count: 0, + url: "/v1/customers/cus_testcust/subscriptions" + } +}; + +const stripeCustomerResponseWithSource = { + id: "card_testcard", + object: "card", + address_city: null, + address_country: null, + address_line1: null, + address_line1_check: null, + address_line2: null, + address_state: null, + address_zip: null, + address_zip_check: null, + brand: "Visa", + country: "US", + customer: "cus_testcust", + cvc_check: "pass", + dynamic_last4: null, + exp_month: 4, + exp_year: 2019, + fingerprint: "0tSZC0FAG4yYkbXM", + funding: "credit", + last4: "4242", + metadata: {}, + name: "Test User", + tokenization_method: null +}; + +// We'll need this when we test multiple charges +// const stripeTokenResponse = { +// id: "tok_1AskR8BXXkbZQs3xdsjQ9Fmp", +// object: "token", +// card: { +// id: "card_1AskR8BXXkbZQs3xpeBlqTiF", +// object: "card", +// address_city: null, +// address_country: null, +// address_line1: null, +// address_line1_check: null, +// address_line2: null, +// address_state: null, +// address_zip: null, +// address_zip_check: null, +// brand: "Visa", +// country: "US", +// cvc_check: null, +// dynamic_last4: null, +// exp_month: 8, +// exp_year: 2018, +// fingerprint: "sMf9T3BK8Si2Nqme", +// funding: "credit", +// last4: "4242", +// metadata: { +// }, +// name: null, +// tokenization_method: null +// }, +// client_ip: null, +// created: 1503200958, +// livemode: false, +// type: "card", +// used: false +// }; + +const stripeChargeResult = { + id: "ch_testcharge", + object: "charge", + amount: 2298, + amount_refunded: 0, + captured: false, + created: 1456110785, + currency: "usd", + refunded: false, + shipping: null, + source: { + id: "card_17hA8DBXXkbZQs3xclGesDrp", + object: "card", + address_city: null, + address_country: null, + address_line1: null, + address_line1_check: null, + address_line2: null, + address_state: null, + address_zip: null, + address_zip_check: null, + brand: "Visa", + country: "US", + customer: null, + cvc_check: "pass", + dynamic_last4: null, + exp_month: 3, + exp_year: 2019, + fingerprint: "sMf9T3BK8Si2Nqme", + funding: "credit", + last4: "4242", + metadata: {}, + name: "Test User", + tokenization_method: null + }, + statement_descriptor: null, + status: "succeeded" +}; + + +describe("stripe/payment/createCharges", function () { + let sandbox; + + beforeEach(function () { + sandbox = sinon.sandbox.create(); + // See below for deeper description of the nock lib used here. + // This method cleans up nocks that might have failed for any reason. + nock.cleanAll(); + }); + + afterEach(function () { + sandbox.restore(); + }); + + it("should call stripe/payment/createCharges with the proper parameters and create an order", function (done) { + this.timeout(10000); + // This is a pretty full payment => order integration test currently. + // This test should probably be split into multiple parts + // Each part should probably isolate downstream methods that get called + // such as copyCartToOrder, etc + const cart = Factory.create("cartToOrder"); + Factory.create("account", { + _id: cart.userId, + emails: [{ address: "test@example.com" }] + }); + + // Set Meteor userId to the cart userId + sandbox.stub(Meteor, "userId", function () { + return cart.userId; + }); + + sandbox.stub(Meteor.server.method_handlers, "cart/createCart", function () { + check(arguments, [Match.Any]); + }); + + sandbox.stub(Meteor.server.method_handlers, "orders/sendNotification", function () { + check(arguments, [Match.Any]); + }); + + // This stub causes the the charge to go through as the primary shop charge + // and skip the application_fee and customer tokenization that is required + // for charging multiple shops + sandbox.stub(Reaction, "getPrimaryShopId", function () { + return cart.shopId; + }); + + sandbox.stub(Reaction, "getPackageSettingsWithOptions", function () { + return testStripePkg; + }); + + const cardData = { + cvv2: "345", + expire_month: "4", + expire_year: "2022", + name: "Test User", + number: "4242424242424242", + type: "visa" + }; + + // create a charge result object that has the cart total in stripe format (cents) + const chargeResult = Object.assign({}, stripeChargeResult, { amount: cart.cartTotal() * 100 }); + + // Testing stripe using the npm Nock lib available here: + // NPM: https://www.npmjs.com/package/nock + // Docs / Github: https://github.com/node-nock/nock + + // The nock package permits mocking of HTTP calls and responses. + // Sinon struggles to mock the stripe node package well, this does a much + // better job. + // + // To extend or add additional tests, it's best to run + // nock.recorder.rec(); + // in your test and view the output. This will give you a basic nock structure + // that you can copy as the exact url and params are somewhat obscured by + // the stripe lib + // + // You can also append .log(console.log) to your nock chain to see which + // of your nocks are handling correctly. + // + // I'm leaving the commented `.log`s below as an example for how to build + // nock http mocks. + // + // This Stack Overflow answer was helpful to me when I was getting started with nock. + // https://stackoverflow.com/questions/22645216/stubbing-stripe-with-sinon-using-stub-yields/22662511#22662511 + + // Disable any HTTP connection during test + // nock.disableNetConnect(); + + // Stripe Create Customer Nock + nock("https://api.stripe.com:443", { encodedQueryParams: true }) + .post("/v1/customers", "email=test%40example.com") + .reply(200, stripeCustomerResponse); // .log(console.log); + + // Card data for adding a card source to a customer + const number = "source%5Bnumber%5D=4242424242424242"; + const name = "source%5Bname%5D=Test%20User"; + const cvc = "source%5Bcvc%5D=345"; + const expiry = "source%5Bexp_month%5D=4&source%5Bexp_year%5D=2022"; + const source = "source%5Bobject%5D=card"; + + // Stripe Add Source To Customer Nock + nock("https://api.stripe.com:443", { encodedQueryParams: true }) + .post(`/v1/customers/${stripeCustomerResponse.id}/sources`, `${number}&${name}&${cvc}&${expiry}&${source}`) + .reply(200, stripeCustomerResponseWithSource); // .log(console.log); + + // If we were testing a multi-shop order, we'd need to nock the tokens API + // and update our /v1/charges nock to use a source (token) instead of the + // customer=customerId as used in the charge nock below + // Stripe Token Nock + // nock("https://api.stripe.com:443", { encodedQueryParams: true }) + // .post("/v1/tokens", `customer=${stripeCustomerResponse.id}`) + // .reply(200, chargeResult).log(console.log); + + // Stripe Charge Nock + nock("https://api.stripe.com:443", { encodedQueryParams: true }) + .post("/v1/charges", `amount=${cart.cartTotal() * 100}&capture=false¤cy=USD&customer=${stripeCustomerResponse.id}`) + .reply(200, chargeResult); // .log(console.log); + + methods["stripe/payment/createCharges"]("authorize", cardData, cart._id).then((res) => { + const transactionIds = Object.keys(res.transactions); + const txId = transactionIds[0]; + expect(res.success).to.equal(true); + expect(res.transactions[txId].amount).to.equal(cart.cartTotal() * 100); + }).then(() => done(), done); + }); +}); + + +// TODO: Rebuild the tests below for the new Stripe integration +// describe("Stripe.authorize", function () { +// let sandbox; +// +// beforeEach(function () { +// sandbox = sinon.sandbox.create(); +// }); +// +// afterEach(function () { +// sandbox.restore(); +// }); +// +// it("should properly charge a card when using a currency besides USD", function (done) { +// const form = { +// cvv2: "345", +// expire_month: "4", +// expire_year: "2019", +// name: "Test User", +// number: "4242424242424242", +// type: "visa" +// }; +// const total = "22.98"; +// const currency = "EUR"; +// +// sandbox.stub(StripeApi.methods.createCharge, "call", function () { +// return stripeChargeResult; +// }); +// // spyOn(StripeApi.methods.createCharge, "call").and.returnValue(stripeChargeResult); +// let chargeResult = null; +// Stripe.authorize(form, { total: total, currency: currency }, function (error, result) { +// chargeResult = result; +// expect(chargeResult).to.not.be.undefined; +// expect(chargeResult.saved).to.be.true; +// expect(StripeApi.methods.createCharge.call).to.have.been.calledWith({ +// chargeObj: { +// amount: 2298, +// currency: "EUR", +// card: { +// number: "4242424242424242", +// name: "Test User", +// cvc: "345", +// exp_month: "4", +// exp_year: "2019" +// }, capture: false +// } +// }); +// done(); +// }); +// }); +// }); +// +// describe("Stripe.authorize", function () { +// let sandbox; +// +// beforeEach(function () { +// sandbox = sinon.sandbox.create(); +// }); +// +// afterEach(function () { +// sandbox.restore(); +// }); +// +// it("should return saved = false when card is declined", function (done) { +// const form = { +// cvv2: "345", +// expire_month: "4", +// expire_year: "2019", +// name: "Test User", +// number: "4000000000000002", +// type: "visa" +// }; +// const total = "22.98"; +// const currency = "EUR"; +// +// const stripeDeclineResult = +// { +// result: null, +// error: { +// type: "StripeCardError", +// rawType: "card_error", +// code: "card_declined", +// param: undefined, +// message: "Your card was declined.", +// detail: undefined, +// raw: { +// message: "Your card was declined.", +// type: "card_error", +// code: "card_declined", +// charge: "ch_17hXeXBXXkbZQs3x3lpNoH9l", +// statusCode: 402, +// requestId: "req_7xSZItk9XdVUIJ" +// }, +// requestId: "req_7xSZItk9XdVUIJ", +// statusCode: 402 +// } +// }; +// sandbox.stub(StripeApi.methods.createCharge, "call", function () { +// return stripeDeclineResult; +// }); +// // spyOn(StripeApi.methods.createCharge, "call").and.returnValue(stripeDeclineResult); +// +// let chargeResult = null; +// Stripe.authorize(form, { total: total, currency: currency }, function (error, result) { +// chargeResult = result; +// +// expect(chargeResult).to.not.be.undefined; +// expect(chargeResult.saved).to.be.false; +// expect(chargeResult.error.message).to.equal("Your card was declined."); +// expect(StripeApi.methods.createCharge.call).to.have.been.calledWith({ +// chargeObj: { +// amount: 2298, +// currency: "EUR", +// card: { +// number: "4000000000000002", +// name: "Test User", +// cvc: "345", +// exp_month: "4", +// exp_year: "2019" +// }, capture: false +// } +// }); +// done(); +// }); +// }); +// }); +// +// describe("Stripe.authorize", function () { +// let sandbox; +// +// beforeEach(function () { +// sandbox = sinon.sandbox.create(); +// }); +// +// afterEach(function () { +// sandbox.restore(); +// }); +// +// it("should return saved = false when an expired card is returned", function (done) { +// // Note that this test number makes the Stripe API return this error, it is +// // not looking at the actual expiration date. +// const form = { +// cvv2: "345", +// expire_month: "4", +// expire_year: "2019", +// name: "Test User", +// number: "4000000000000069", +// type: "visa" +// }; +// const total = "22.98"; +// const currency = "USD"; +// +// const stripeExpiredCardResult = +// { +// result: null, +// error: { +// type: "StripeCardError", +// rawType: "card_error", +// code: "expired_card", +// param: "exp_month", +// message: "Your card has expired.", +// raw: { +// message: "Your card has expired.", +// type: "card_error", +// param: "exp_month", +// code: "expired_card", +// charge: "ch_17iBsDBXXkbZQs3xfZArVPEd", +// statusCode: 402, +// requestId: "req_7y88CojR2UJYOd" +// }, +// requestId: "req_7y88CojR2UJYOd", +// statusCode: 402 +// } +// }; +// sandbox.stub(StripeApi.methods.createCharge, "call", function () { +// return stripeExpiredCardResult; +// }); +// +// let chargeResult = null; +// Stripe.authorize(form, { total: total, currency: currency }, function (error, result) { +// chargeResult = result; +// expect(chargeResult).to.not.be.undefined; +// expect(chargeResult.saved).to.be.false; +// expect(chargeResult.error.message).to.equal("Your card has expired."); +// expect(StripeApi.methods.createCharge.call).to.have.been.calledWith({ +// chargeObj: { +// amount: 2298, +// currency: "USD", +// card: { +// number: "4000000000000069", +// name: "Test User", +// cvc: "345", +// exp_month: "4", +// exp_year: "2019" +// }, capture: false +// } +// }); +// done(); +// }); +// }); +// }); diff --git a/imports/plugins/included/payments-stripe/server/methods/stripe.js b/imports/plugins/included/payments-stripe/server/methods/stripe.js index 1374d10eb20..87866c660b9 100644 --- a/imports/plugins/included/payments-stripe/server/methods/stripe.js +++ b/imports/plugins/included/payments-stripe/server/methods/stripe.js @@ -1,44 +1,22 @@ import accounting from "accounting-js"; -/* eslint camelcase: 0 */ -// meteor modules +import stripeNpm from "stripe"; + import { Meteor } from "meteor/meteor"; -import { check, Match } from "meteor/check"; -// reaction modules +import { check } from "meteor/check"; +import { Random } from "meteor/random"; + import { Reaction, Logger } from "/server/api"; import { StripeApi } from "./stripeapi"; -function luhnValid(x) { - return [...x].reverse().reduce((sum, c, i) => { - let d = parseInt(c, 10); - if (i % 2 !== 0) { d *= 2; } - if (d > 9) { d -= 9; } - return sum + d; - }, 0) % 10 === 0; -} - -const ValidCardNumber = Match.Where(function (x) { - return /^[0-9]{13,16}$/.test(x) && luhnValid(x); -}); - -const ValidExpireMonth = Match.Where(function (x) { - return /^[0-9]{1,2}$/.test(x); -}); - -const ValidExpireYear = Match.Where(function (x) { - return /^[0-9]{4}$/.test(x); -}); - -const ValidCVV = Match.Where(function (x) { - return /^[0-9]{3,4}$/.test(x); -}); +import { Cart, Shops, Accounts, Packages } from "/lib/collections"; function parseCardData(data) { return { number: data.number, name: data.name, cvc: data.cvv2, - exp_month: data.expire_month, - exp_year: data.expire_year + exp_month: data.expire_month, // eslint-disable-line camelcase + exp_year: data.expire_year // eslint-disable-line camelcase }; } @@ -83,95 +61,287 @@ function stripeCaptureCharge(paymentMethod) { return result; } +/** + * normalizes the status of a transaction + * @method normalizeStatus + * @param {object} transaction - The transaction that we need to normalize + * @return {string} normalized status string - either failed, settled, or created + */ +function normalizeStatus(transaction) { + if (!transaction) { + throw new Meteor.Error("normalizeStatus requires a transaction"); + } + + // if this transaction failed, mode is "failed" + if (transaction.failure_code) { + return "failed"; + } -Meteor.methods({ - "stripeSubmit": function (transactionType, cardData, paymentData) { + // if this transaction was captured, status is "settled" + if (transaction.captured) { // Transaction was authorized but not captured + return "settled"; + } + + // Otherwise status is "created" + return "created"; +} + +/** + * normalizes the mode of a transaction + * @method normalizeMode + * @param {object} transaction The transaction that we need to normalize + * @return {string} normalized status string - either failed, capture, or authorize + */ +function normalizeMode(transaction) { + if (!transaction) { + throw new Meteor.Error("normalizeMode requires a transaction"); + } + + // if this transaction failed, mode is "failed" + if (transaction.failure_code) { + return "failed"; + } + + // If this transaction was captured, mode is "capture" + if (transaction.captured) { + return "capture"; + } + + // Anything else, mode is "authorize" + return "authorize"; +} + + +function buildPaymentMethods(options) { + const { cardData, cartItemsByShop, transactionsByShopId } = options; + if (!transactionsByShopId) { + throw new Meteor.Error("Creating a payment method log requries transaction data"); + } + + const shopIds = Object.keys(transactionsByShopId); + const storedCard = cardData.type.charAt(0).toUpperCase() + cardData.type.slice(1) + " " + cardData.number.slice(-4); + const packageData = Packages.findOne({ + name: "reaction-stripe", + shopId: Reaction.getPrimaryShopId() + }); + + const paymentMethods = []; + + + shopIds.forEach((shopId) => { + if (transactionsByShopId[shopId]) { + const cartItems = cartItemsByShop[shopId].map((item) => { + return { + _id: item._id, + productId: item.productId, + variantId: item.variants._id, + shopId: shopId, + quantity: item.quantity + }; + }); + + const paymentMethod = { + processor: "Stripe", + storedCard: storedCard, + method: "credit", + paymentPackageId: packageData._id, + // TODO: REVIEW WITH AARON - why is paymentSettings key important + // and why is it just defined on the client? + paymentSettingsKey: packageData.name.split("/").splice(-1)[0], + transactionId: transactionsByShopId[shopId].id, + amount: transactionsByShopId[shopId].amount * 0.01, + status: normalizeStatus(transactionsByShopId[shopId]), + mode: normalizeMode(transactionsByShopId[shopId]), + createdAt: new Date(transactionsByShopId[shopId].created), + transactions: [], + items: cartItems, + shopId: shopId + }; + paymentMethod.transactions.push(transactionsByShopId[shopId]); + paymentMethods.push(paymentMethod); + } + }); + + return paymentMethods; +} + +export const methods = { + "stripe/payment/createCharges": async function (transactionType, cardData, cartId) { check(transactionType, String); check(cardData, { name: String, - number: ValidCardNumber, - expire_month: ValidExpireMonth, - expire_year: ValidExpireYear, - cvv2: ValidCVV, + number: String, + expire_month: String, // eslint-disable-line camelcase + expire_year: String, // eslint-disable-line camelcase + cvv2: String, type: String }); - check(paymentData, { - total: String, - currency: String - // Commenting this out because it causes tests to fail and isn't fully implemented. - // shopId: String // TODO: Implement Marketplace Payment - perhaps including shopId + check(cartId, String); + + const primaryShopId = Reaction.getPrimaryShopId(); + + const stripePkg = Reaction.getPackageSettingsWithOptions({ + shopId: primaryShopId, + name: "reaction-stripe" }); - const chargeObj = { - amount: "", - currency: "", - card: {}, - capture: true - }; + const card = parseCardData(cardData); - // check if this is a seller shop for destination and transaction fee logic - // TODO: Add transaction fee to Stripe chargeObj when stripeConnect is in use. - - // Where is sellerShops coming from here? - // const sellerShop = sellerShops.findOne(paymentData.shopId); - // - // if (sellerShop && sellerShop.stripeConnectSettings) { - // const chargeObj = { - // chargeData: { - // amount: "", - // currency: "", - // transactionFee: 0, - // card: {}, - // capture: true - // }, - // stripe_account: sellerShop.stripeConnectSettings.stripe_user_id - // }; - // } else { - // const chargeObj = { - // amount: "", - // currency: "", - // card: {}, - // capture: true - // }; - // } - - if (transactionType === "authorize") { - chargeObj.capture = false; + if (!stripePkg || !stripePkg.settings || !stripePkg.settings.api_key) { + // Fail if we can't find a Stripe API key + throw new Meteor.Error("Attempted to create multiple stripe charges, but stripe was not configured properly."); } - chargeObj.card = parseCardData(cardData); - chargeObj.amount = formatForStripe(paymentData.total); - chargeObj.currency = paymentData.currency; - // TODO: Check for a transaction fee and apply - // const stripeConnectSettings = Reaction.getPackageSettings("reaction-stripe-connect").settings; - // if (sellerShop.stripeConnectSettings && stripeConnectSettings.transactionFee.enabled) { - // chargeObj.transactionFee = chargeObj.amount * stripeConnectSettings.transactionFee.percentage; - // } + const capture = transactionType === "capture"; - let result; - let chargeResult; + // Must have an email + const cart = Cart.findOne({ _id: cartId }); + const customerAccount = Accounts.findOne({ _id: cart.userId }); + let customerEmail; + + if (!customerAccount || !Array.isArray(customerAccount.emails)) { + // TODO: Is it okay to create random email here if anonymous? + Logger.Error("cart email missing!"); + throw new Meteor.Error("Email is required for marketplace checkouts."); + } + + const defaultEmail = customerAccount.emails.find((email) => email.provides === "default"); + if (defaultEmail) { + customerEmail = defaultEmail.address; + } else { + throw new Meteor.Error("Customer does not have default email"); + } + + // Initialize stripe api lib + const stripeApiKey = stripePkg.settings.api_key; + const stripe = stripeNpm(stripeApiKey); + + // get array of shopIds that exist in this cart + const shopIds = cart.items.reduce((uniqueShopIds, item) => { + if (uniqueShopIds.indexOf(item.shopId) === -1) { + uniqueShopIds.push(item.shopId); + } + return uniqueShopIds; + }, []); + + const transactionsByShopId = {}; + + // TODO: If there is only one transactionsByShopId and the shopId is primaryShopId - + // Create a standard charge and bypass creating a customer for this charge + const primaryShop = Shops.findOne({ _id: primaryShopId }); + const currency = primaryShop.currency; try { - chargeResult = StripeApi.methods.createCharge.call({ chargeObj }); - if (chargeResult && chargeResult.status && chargeResult.status === "succeeded") { - result = { - saved: true, - response: chargeResult + // Creates a customer object, adds a source via the card data + // and waits for the promise to resolve + const customer = Promise.await(stripe.customers.create({ + email: customerEmail + }).then(function (cust) { + const customerCard = stripe.customers.createSource(cust.id, { source: { ...card, object: "card" } }); + return customerCard; + })); + + // Get cart totals for each Shop + const cartTotals = cart.cartTotalByShop(); + + // Loop through all shopIds represented in cart + shopIds.forEach((shopId) => { + // TODO: If shopId is primaryShopId - create a non-connect charge with the + // stripe customer object + + const isPrimaryShop = shopId === primaryShopId; + + let merchantStripePkg; + // Initialize options - this is where idempotency_key + // and, if using connect, stripe_account go + const stripeOptions = {}; + const stripeCharge = { + amount: formatForStripe(cartTotals[shopId]), + capture: capture, + currency: currency + // TODO: add product metadata to stripe charge }; - } else { - Logger.error("Stripe Call succeeded but charge failed"); - result = { - saved: false, - error: chargeResult.error + + if (isPrimaryShop) { + // If this is the primary shop, we can make a direct charge to the + // customer object we just created. + stripeCharge.customer = customer.customer; + } else { + // If this is a merchant shop, we need to tokenize the customer + // and charge the token with the merchant id + merchantStripePkg = Reaction.getPackageSettingsWithOptions({ + shopId: shopId, + name: "reaction-stripe" + }); + + // If this merchant doesn't have stripe setup, fail. + // We should _never_ get to this point, because + // this will not roll back the entire transaction + if (!merchantStripePkg || + !merchantStripePkg.settings || + !merchantStripePkg.settings.connectAuth || + !merchantStripePkg.settings.connectAuth.stripe_user_id) { + throw new Meteor.Error(`Error processing payment for merchant with shopId ${shopId}`); + } + + // get stripe account for this shop + const stripeUserId = merchantStripePkg.settings.connectAuth.stripe_user_id; + stripeOptions.stripe_account = stripeUserId; // eslint-disable-line camelcase + + // Create token from our customer object to use with merchant shop + const token = Promise.await(stripe.tokens.create({ + customer: customer.customer + }, stripeOptions)); + + // TODO: Add description to charge in Stripe + stripeCharge.source = token.id; + + // Demo 20% application fee + stripeCharge.application_fee = formatForStripe(cartTotals[shopId] * 0.2); // eslint-disable-line camelcase + } + + // We should only do this once per shop per cart + stripeOptions.idempotency_key = `${shopId}${cart._id}${Random.id()}`; // eslint-disable-line camelcase + + // Create a charge with the options set above + const charge = Promise.await(stripe.charges.create(stripeCharge, stripeOptions)); + + transactionsByShopId[shopId] = charge; + }); + + + // get cartItemsByShop to build paymentMethods + const cartItemsByShop = cart.cartItemsByShop(); + + // Build paymentMethods from transactions, card data and cart items + const paymentMethods = buildPaymentMethods({ cardData, cartItemsByShop, transactionsByShopId }); + + // If successful, call cart/submitPayment and return success back to client. + Meteor.call("cart/submitPayment", paymentMethods); + return { success: true, transactions: transactionsByShopId }; + } catch (error) { + // If unsuccessful + // return failure back to client if error is a standard stripe card error + if (error.rawType === "card_error") { + return { + success: false, + error: { + message: error.message, + code: error.code, + type: error.type, + rawType: error.rawType, + detail: error.detail + } }; } - return result; - } catch (e) { - Logger.error(e); - throw new Meteor.Error("error", e.message); + // If we get an unexpected error, log and return a censored error message + Logger.error("Received unexpected error type: " + error.rawType); + Logger.error(error); + throw new Meteor.Error("Error creating multiple stripe charges", "An unexpected error occurred"); } }, + // TODO: Update this method to support connect captures /** * Capture a Stripe charge * @see https://stripe.com/docs/api#capture_charge @@ -196,6 +366,7 @@ Meteor.methods({ return stripeCaptureCharge(paymentMethod); }, + // TODO: Update this method to support connect /** * Issue a refund against a previously captured transaction * @see https://stripe.com/docs/api#refunds @@ -242,6 +413,7 @@ Meteor.methods({ return result; }, + // Update this method to support connect /** * List refunds * @param {Object} paymentMethod object @@ -268,4 +440,6 @@ Meteor.methods({ } return result; } -}); +}; + +Meteor.methods(methods); diff --git a/imports/plugins/included/payments-stripe/server/methods/stripeapi-methods-charge.app-test.js b/imports/plugins/included/payments-stripe/server/methods/stripeapi-methods-charge.app-test.js deleted file mode 100644 index 9f8d587d3f9..00000000000 --- a/imports/plugins/included/payments-stripe/server/methods/stripeapi-methods-charge.app-test.js +++ /dev/null @@ -1,280 +0,0 @@ -/* eslint camelcase: 0 */ -import { expect } from "meteor/practicalmeteor:chai"; -import { sinon } from "meteor/practicalmeteor:sinon"; -import { StripeApi } from "./stripeapi"; -import { Stripe } from "../../lib/api"; - -const stripeChargeResult = { - id: "ch_17hA8DBXXkbZQs3xENUmN9bZ", - object: "charge", - amount: 2298, - amount_refunded: 0, - captured: false, - created: 1456110785, - currency: "usd", - refunded: false, - shipping: null, - source: { - id: "card_17hA8DBXXkbZQs3xclGesDrp", - object: "card", - address_city: null, - address_country: null, - address_line1: null, - address_line1_check: null, - address_line2: null, - address_state: null, - address_zip: null, - address_zip_check: null, - brand: "Visa", - country: "US", - customer: null, - cvc_check: "pass", - dynamic_last4: null, - exp_month: 3, - exp_year: 2019, - fingerprint: "sMf9T3BK8Si2Nqme", - funding: "credit", - last4: "4242", - metadata: {}, - name: "Test User", - tokenization_method: null - }, - statement_descriptor: null, - status: "succeeded" -}; - - -describe("Stripe.authorize", function () { - let sandbox; - - beforeEach(function () { - sandbox = sinon.sandbox.create(); - }); - - afterEach(function () { - sandbox.restore(); - }); - - it("should call StripeApi.methods.createCharge with the proper parameters and return saved = true", function (done) { - sandbox.stub(StripeApi.methods.createCharge, "call", function () { - return stripeChargeResult; - }); - const cardData = { - cvv2: "345", - expire_month: "4", - expire_year: "2019", - name: "Test User", - number: "4242424242424242", - type: "visa" - }; - const total = "22.98"; - const currency = "USD"; - let chargeResult = null; - Stripe.authorize(cardData, { total: total, currency: currency }, function (error, result) { - chargeResult = result; - expect(chargeResult).to.not.be.undefined; - expect(chargeResult.saved).to.be.true; - done(); - }); - }); -}); - -describe("Stripe.authorize", function () { - let sandbox; - - beforeEach(function () { - sandbox = sinon.sandbox.create(); - }); - - afterEach(function () { - sandbox.restore(); - }); - - it("should properly charge a card when using a currency besides USD", function (done) { - const form = { - cvv2: "345", - expire_month: "4", - expire_year: "2019", - name: "Test User", - number: "4242424242424242", - type: "visa" - }; - const total = "22.98"; - const currency = "EUR"; - - sandbox.stub(StripeApi.methods.createCharge, "call", function () { - return stripeChargeResult; - }); - // spyOn(StripeApi.methods.createCharge, "call").and.returnValue(stripeChargeResult); - let chargeResult = null; - Stripe.authorize(form, { total: total, currency: currency }, function (error, result) { - chargeResult = result; - expect(chargeResult).to.not.be.undefined; - expect(chargeResult.saved).to.be.true; - expect(StripeApi.methods.createCharge.call).to.have.been.calledWith({ - chargeObj: { - amount: 2298, - currency: "EUR", - card: { - number: "4242424242424242", - name: "Test User", - cvc: "345", - exp_month: "4", - exp_year: "2019" - }, capture: false - } - }); - done(); - }); - }); -}); - -describe("Stripe.authorize", function () { - let sandbox; - - beforeEach(function () { - sandbox = sinon.sandbox.create(); - }); - - afterEach(function () { - sandbox.restore(); - }); - - it("should return saved = false when card is declined", function (done) { - const form = { - cvv2: "345", - expire_month: "4", - expire_year: "2019", - name: "Test User", - number: "4000000000000002", - type: "visa" - }; - const total = "22.98"; - const currency = "EUR"; - - const stripeDeclineResult = - { - result: null, - error: { - type: "StripeCardError", - rawType: "card_error", - code: "card_declined", - param: undefined, - message: "Your card was declined.", - detail: undefined, - raw: { - message: "Your card was declined.", - type: "card_error", - code: "card_declined", - charge: "ch_17hXeXBXXkbZQs3x3lpNoH9l", - statusCode: 402, - requestId: "req_7xSZItk9XdVUIJ" - }, - requestId: "req_7xSZItk9XdVUIJ", - statusCode: 402 - } - }; - sandbox.stub(StripeApi.methods.createCharge, "call", function () { - return stripeDeclineResult; - }); - // spyOn(StripeApi.methods.createCharge, "call").and.returnValue(stripeDeclineResult); - - let chargeResult = null; - Stripe.authorize(form, { total: total, currency: currency }, function (error, result) { - chargeResult = result; - - expect(chargeResult).to.not.be.undefined; - expect(chargeResult.saved).to.be.false; - expect(chargeResult.error.message).to.equal("Your card was declined."); - expect(StripeApi.methods.createCharge.call).to.have.been.calledWith({ - chargeObj: { - amount: 2298, - currency: "EUR", - card: { - number: "4000000000000002", - name: "Test User", - cvc: "345", - exp_month: "4", - exp_year: "2019" - }, capture: false - } - }); - done(); - }); - }); -}); - -describe("Stripe.authorize", function () { - let sandbox; - - beforeEach(function () { - sandbox = sinon.sandbox.create(); - }); - - afterEach(function () { - sandbox.restore(); - }); - - it("should return saved = false when an expired card is returned", function (done) { - // Note that this test number makes the Stripe API return this error, it is - // not looking at the actual expiration date. - const form = { - cvv2: "345", - expire_month: "4", - expire_year: "2019", - name: "Test User", - number: "4000000000000069", - type: "visa" - }; - const total = "22.98"; - const currency = "USD"; - - const stripeExpiredCardResult = - { - result: null, - error: { - type: "StripeCardError", - rawType: "card_error", - code: "expired_card", - param: "exp_month", - message: "Your card has expired.", - raw: { - message: "Your card has expired.", - type: "card_error", - param: "exp_month", - code: "expired_card", - charge: "ch_17iBsDBXXkbZQs3xfZArVPEd", - statusCode: 402, - requestId: "req_7y88CojR2UJYOd" - }, - requestId: "req_7y88CojR2UJYOd", - statusCode: 402 - } - }; - sandbox.stub(StripeApi.methods.createCharge, "call", function () { - return stripeExpiredCardResult; - }); - - let chargeResult = null; - Stripe.authorize(form, { total: total, currency: currency }, function (error, result) { - chargeResult = result; - expect(chargeResult).to.not.be.undefined; - expect(chargeResult.saved).to.be.false; - expect(chargeResult.error.message).to.equal("Your card has expired."); - expect(StripeApi.methods.createCharge.call).to.have.been.calledWith({ - chargeObj: { - amount: 2298, - currency: "USD", - card: { - number: "4000000000000069", - name: "Test User", - cvc: "345", - exp_month: "4", - exp_year: "2019" - }, capture: false - } - }); - done(); - }); - }); -}); diff --git a/imports/plugins/included/payments-stripe/server/methods/stripeapi.js b/imports/plugins/included/payments-stripe/server/methods/stripeapi.js index 7a0b3e42e2d..61c7c2995ab 100644 --- a/imports/plugins/included/payments-stripe/server/methods/stripeapi.js +++ b/imports/plugins/included/payments-stripe/server/methods/stripeapi.js @@ -47,15 +47,14 @@ StripeApi.methods.getApiKey = new ValidatedMethod({ name: "StripeApi.methods.getApiKey", validate: null, run() { - const settings = Reaction.getPackageSettings("reaction-stripe").settings; - // TODO: We should merge Stripe Connect plugin with reaction-stripe - // or fully separate them, but we shouldn't be checking package settings for a package - // we don't require. - const stripeConnectSettings = Reaction.getPackageSettings("reaction-stripe-connect").settings; - if (!settings.api_key && !stripeConnectSettings.api_key) { - throw new Meteor.Error("403", "Invalid Stripe Credentials"); + const stripePkg = Reaction.getPackageSettingsWithOptions({ + shopId: Reaction.getPrimaryShopId(), + name: "reaction-stripe" + }); + if (stripePkg || stripePkg.settings && stripePkg.settings.api_key) { + return stripePkg.settings.api_key; } - return stripeConnectSettings.api_key || settings.api_key; + throw new Meteor.Error("access-denied", "Invalid Stripe Credentials"); } }); diff --git a/imports/plugins/included/payments-stripe/server/startup/startup.js b/imports/plugins/included/payments-stripe/server/startup/startup.js new file mode 100644 index 00000000000..3fe1de96480 --- /dev/null +++ b/imports/plugins/included/payments-stripe/server/startup/startup.js @@ -0,0 +1,9 @@ +import { Reaction, Hooks } from "/server/api"; + +Hooks.Events.add("afterCoreInit", () => { + Reaction.addRolesToGroups({ + allShops: true, + groups: ["customer", "guest"], + roles: ["stripe/connect/authorize"] + }); +}); diff --git a/imports/plugins/included/taxes-taxcloud/server/hooks/hooks.js b/imports/plugins/included/taxes-taxcloud/server/hooks/hooks.js index d341613ae7c..62dfad4f84f 100644 --- a/imports/plugins/included/taxes-taxcloud/server/hooks/hooks.js +++ b/imports/plugins/included/taxes-taxcloud/server/hooks/hooks.js @@ -48,7 +48,7 @@ MethodHooks.after("taxes/calculate", function (options) { if (!apiKey || !apiLoginId) { Logger.warn("TaxCloud API Key is required."); } - if (typeof cartToCalc.shipping !== "undefined" && cartToCalc.items) { + if (Array.isArray(cartToCalc.shipping) && cartToCalc.shipping.length > 0 && cartToCalc.items) { const shippingAddress = cartToCalc.shipping[0].address; if (shippingAddress) { diff --git a/lib/collections/schemas/cart.js b/lib/collections/schemas/cart.js index b441ddcdd37..dcb08a080fb 100644 --- a/lib/collections/schemas/cart.js +++ b/lib/collections/schemas/cart.js @@ -62,6 +62,16 @@ export const CartItem = new SimpleSchema({ optional: true, blackbox: true }, + taxData: { + type: Object, + optional: true, + blackbox: true + }, + taxRate: { + type: Number, + decimal: true, + optional: true + }, shippingMethod: { // Shipping Method associated with this item type: Object, optional: true, @@ -133,6 +143,7 @@ export const Cart = new SimpleSchema({ optional: true, blackbox: true }, + // This is the taxRate tax: { type: Number, decimal: true, @@ -143,6 +154,11 @@ export const Cart = new SimpleSchema({ optional: true, blackbox: true }, + taxRatesByShop: { + type: Object, + optional: true, + blackbox: true + }, discount: { type: Number, decimal: true, diff --git a/lib/collections/schemas/payments.js b/lib/collections/schemas/payments.js index 2a89e97f505..f34265bbe75 100644 --- a/lib/collections/schemas/payments.js +++ b/lib/collections/schemas/payments.js @@ -3,10 +3,43 @@ import { schemaIdAutoValue } from "./helpers"; import { Address } from "./address"; import { Workflow } from "./workflow"; + /** - * PaymentMethod Schema + * Schema for items we're inserting into our Payments to keep track of what items + * were paid for with a given paymentMethod + * @type {SimpleSchema} */ +export const PaymentItem = new SimpleSchema({ + _id: { + type: String, + label: "Shipment Line Item", + optional: true, + autoValue: schemaIdAutoValue + }, + productId: { + type: String, + index: 1 + }, + shopId: { + type: String, + index: 1, + label: "Shipment Item ShopId", + optional: true + }, + quantity: { + label: "Quantity", + type: Number, + min: 0 + }, + variantId: { + type: String + } +}); + +/** + * PaymentMethod Schema + */ export const PaymentMethod = new SimpleSchema({ processor: { type: String @@ -79,6 +112,16 @@ export const PaymentMethod = new SimpleSchema({ type: [Object], optional: true, blackbox: true + }, + // TODO: Build a migration to add payment items to payment methods + items: { + type: [PaymentItem], + optional: true + }, + // TODO: Build migration to add shopIds to payment methods + shopId: { + type: String, + optional: true } }); @@ -158,6 +201,12 @@ export const Payment = new SimpleSchema({ currency: { type: Currency, optional: true + }, + // TODO: REVIEW Not sure if shopId should be optional on the Payment Schema + // TODO: If we make shopId on the payment schema required, we need to build this into the migration for old orders + shopId: { + type: String, + optional: true } }); diff --git a/lib/collections/schemas/registry.js b/lib/collections/schemas/registry.js index b1578baa286..bd6287e3b12 100644 --- a/lib/collections/schemas/registry.js +++ b/lib/collections/schemas/registry.js @@ -103,6 +103,16 @@ export const Registry = new SimpleSchema({ type: Object, optional: true, blackbox: true + }, + showForShopTypes: { + label: "Shop Types this plugin should show for", + type: [String], + optional: true + }, + hideForShopTypes: { + label: "Shop Types this plugin should not show for", + type: [String], + optional: true } }); diff --git a/lib/collections/transform/cart.js b/lib/collections/transform/cart.js index 6965a922d80..b2fce07d58c 100644 --- a/lib/collections/transform/cart.js +++ b/lib/collections/transform/cart.js @@ -6,17 +6,29 @@ import accounting from "accounting-js"; * @param {Array} items - cart.items array * @param {Array} prop - path to item property represented by array * @param {Array} [prop2] - path to another item property represented by array + * @param {String} [shopId] - shopId * @return {Number} - computations result */ -function getSummary(items, prop, prop2) { +function getSummary(items, prop, prop2, shopId) { try { if (Array.isArray(items)) { return items.reduce((sum, item) => { if (prop2) { + if (shopId) { + if (shopId === item.shopId) { + // if we're looking for a specific shop's items and this item does match + return sum + item[prop[0]] * (prop2.length === 1 ? item[prop2[0]] : + item[prop2[0]][prop2[1]]); + } + // If we're looking for a specific shop's items and this item doesn't match + return sum; + } + // No shopId param // S + a * b, where b could be b1 or b2 return sum + item[prop[0]] * (prop2.length === 1 ? item[prop2[0]] : item[prop2[0]][prop2[1]]); } + // No prop2 param // S + b, where b could be b1 or b2 return sum + (prop.length === 1 ? item[prop[0]] : item[prop[0]][prop[1]]); @@ -56,6 +68,23 @@ export const cartTransform = { const subTotal = getSummary(this.items, ["quantity"], ["variants", "price"]); return accounting.toFixed(subTotal, 2); }, + + /** + * Aggregates the subtotals by shopId + * @method cartSubTotalByShop + * @return {object} An Object with a key for each shopId in the cart where the value is the subtotal for that shop + */ + cartSubTotalByShop() { + return this.items.reduce((uniqueShopSubTotals, item) => { + if (!uniqueShopSubTotals[item.shopId]) { + const subTotal = getSummary(this.items, ["quantity"], ["variants", "price"], item.shopId); + uniqueShopSubTotals[item.shopId] = accounting.toFixed(subTotal, 2); + return uniqueShopSubTotals; + } + return uniqueShopSubTotals; + }, {}); + }, + cartTaxes() { // taxes are calculated in a Cart.after.update hooks // the tax value stored with the cart is the effective tax rate @@ -66,10 +95,32 @@ export const cartTransform = { const taxTotal = subTotal * tax; return accounting.toFixed(taxTotal, 2); }, + + /** + * Aggregates the taxes by shopId + * @method cartTaxesByShop + * @return {[type]} An object with a key for each shopId in the cart where the value is the tax total for that shop + */ + cartTaxesByShop() { + const subtotals = this.cartSubTotalByShop(); + const taxRates = this.taxRatesByShop; + + return Object.keys(subtotals).reduce((shopTaxTotals, shopId) => { + if (!shopTaxTotals[shopId]) { + const shopSubtotal = parseFloat(subtotals[shopId]); + const shopTaxRate = taxRates && taxRates[shopId] || 0; + const shopTaxTotal = shopSubtotal * shopTaxRate; + shopTaxTotals[shopId] = accounting.toFixed(shopTaxTotal, 2); + } + return shopTaxTotals; + }, {}); + }, + cartDiscounts() { const discount = this.discount || 0; return accounting.toFixed(discount, 2); }, + cartTotal() { const subTotal = parseFloat(this.cartSubTotal()); const shipping = parseFloat(this.cartShipping()); @@ -78,5 +129,43 @@ export const cartTransform = { const discountTotal = Math.max(0, subTotal - discount); const total = discountTotal + shipping + taxes; return accounting.toFixed(total, 2); + }, + + /** + * Aggregates the cart total by shopId + * @method cartTotalByShop + * @return {object} An object with a key for each shopId in the cart where the value is the total for that shop + */ + cartTotalByShop() { + const subtotals = this.cartSubTotalByShop(); + const taxes = this.cartTaxesByShop(); + const shipping = parseFloat(this.cartShipping()); + // no discounts right now because that will need to support multi-shop + + return Object.keys(subtotals).reduce((shopTotals, shopId) => { + if (!shopTotals[shopId]) { + const shopSubtotal = parseFloat(subtotals[shopId]); + const shopTaxes = parseFloat(taxes[shopId]); + const shopTotal = shopSubtotal + shopTaxes + shipping; + shopTotals[shopId] = accounting.toFixed(shopTotal, 2); + } + return shopTotals; + }, {}); + }, + + /** + * cart items organized by shopId + * @method cartItemsByShop + * @return {Object} Dict of shopIds with an array of items from that shop that are present in the cart + */ + cartItemsByShop() { + return this.items.reduce((itemsByShop, item) => { + if (!itemsByShop[item.shopId]) { + itemsByShop[item.shopId] = [item]; + } else { + itemsByShop[item.shopId].push(item); + } + return itemsByShop; + }, {}); } }; diff --git a/package.json b/package.json index abec2763ad3..dbc86e6e060 100644 --- a/package.json +++ b/package.json @@ -59,6 +59,7 @@ "moment": "^2.18.1", "moment-timezone": "^0.5.13", "nexmo": "^2.0.2", + "nock": "^9.0.14", "node-geocoder": "^3.18.0", "node-loggly-bulk": "^2.0.0", "nodemailer-wellknown": "^0.2.3", diff --git a/server/api/core/core.js b/server/api/core/core.js index 6af45958cea..91bb7f66937 100644 --- a/server/api/core/core.js +++ b/server/api/core/core.js @@ -413,6 +413,17 @@ export default { return Packages.findOne({ name: name, shopId: this.getShopId() }) || null; }, + /** + * Takes options in the form of a query object. Returns a package that matches. + * @method getPackageSettingsWithOptions + * @param {object} options Options object, forms the query for Packages.findOne + * @return {object} Returns the first package found with the provided options + */ + getPackageSettingsWithOptions(options) { + const query = options; + return Packages.findOne(query); + }, + /** * getMarketplaceSettings finds the enabled `reaction-marketplace` package for * the primary shop and returns the settings @@ -509,6 +520,11 @@ export default { if (enabledPackages && Array.isArray(enabledPackages)) { if (enabledPackages.indexOf(pkg.name) === -1) { pkg.enabled = false; + } else { + // Enable "soft switch" for package. + if (pkg.settings && pkg.settings[packageName]) { + pkg.settings[packageName].enabled = true; + } } } Packages.insert(pkg); diff --git a/server/methods/core/cart.js b/server/methods/core/cart.js index 24f7091287a..268602d828c 100644 --- a/server/methods/core/cart.js +++ b/server/methods/core/cart.js @@ -553,7 +553,7 @@ Meteor.methods({ const cart = Collections.Cart.findOne(cartId); // security check - method can only be called on own cart - if (cart.userId !== this.userId) { + if (cart.userId !== Meteor.userId()) { throw new Meteor.Error(403, "Access Denied"); } @@ -1094,74 +1094,94 @@ Meteor.methods({ * @summary saves a submitted payment to cart, triggers workflow * and adds "paymentSubmitted" to cart workflow * Note: this method also has a client stub, that forwards to cartCompleted - * @param {Object} paymentMethod - paymentMethod object - * directly within this method, just throw down though hooks + * @param {Object|Array} paymentMethods - an array of paymentMethods or (deprecated) a single paymentMethod object * @return {String} returns update result */ - "cart/submitPayment": function (paymentMethod) { - check(paymentMethod, Reaction.Schemas.PaymentMethod); + "cart/submitPayment": function (paymentMethods) { + if (Array.isArray((paymentMethods))) { + check(paymentMethods, [Reaction.Schemas.PaymentMethod]); + } else { + check(paymentMethods, Reaction.Schemas.PaymentMethod); + } + - const checkoutCart = Collections.Cart.findOne({ + const cart = Collections.Cart.findOne({ userId: Meteor.userId() }); - const cart = _.clone(checkoutCart); const cartId = cart._id; - const invoice = { - shipping: cart.cartShipping(), - subtotal: cart.cartSubTotal(), - taxes: cart.cartTaxes(), - discounts: cart.cartDiscounts(), - total: cart.cartTotal() - }; + + const cartShipping = cart.cartShipping(); + const cartSubTotal = cart.cartSubTotal(); + const cartSubTotalByShop = cart.cartSubTotalByShop(); + const cartTaxes = cart.cartTaxes(); + const cartTaxesByShop = cart.cartTaxesByShop(); + const cartDiscounts = cart.cartDiscounts(); + const cartTotal = cart.cartTotal(); + const cartTotalByShop = cart.cartTotalByShop(); // we won't actually close the order at this stage. // we'll just update the workflow and billing data where // method-hooks can process the workflow update. - let selector; - let update; - // temp hack until we build out multiple billing handlers - // if we have an existing item update it, otherwise add to set. + const payments = []; + let paymentAddress; - // TODO: Marketplace Payments - Add support for multiple billing handlers here - if (cart.items) { - // TODO: Needs to be improved to consider which transaction goes with which item - // For now just attach the transaction to each item in the cart - const cartItemsWithPayment = cart.items.map(item => { - item.transaction = paymentMethod.transactions[paymentMethod.transactions.length - 1]; - return item; - }); - cart.items = cartItemsWithPayment; + // Find the payment address associated that the user input during the + // checkout process + if (Array.isArray(cart.billing) && cart.billing[0]) { + paymentAddress = cart.billing[0].address; } - if (cart.billing) { - selector = { - "_id": cartId, - "billing._id": cart.billing[0]._id - }; - update = { - $set: { - "billing.$.paymentMethod": paymentMethod, - "billing.$.invoice": invoice, - "items": cart.items - } - }; + // Payment plugins which have been updated for marketplace are passing an array as paymentMethods + if (Array.isArray(paymentMethods)) { + paymentMethods.forEach((paymentMethod) => { + const shopId = paymentMethod.shopId; + const invoice = { + shipping: parseFloat(cartShipping), + subtotal: parseFloat(cartSubTotalByShop[shopId]), + taxes: parseFloat(cartTaxesByShop[shopId]), + discounts: parseFloat(cartDiscounts), + total: parseFloat(cartTotalByShop[shopId]) + }; + + payments.push({ + paymentMethod: paymentMethod, + invoice: invoice, + address: paymentAddress, + shopId: shopId + }); + }); } else { - selector = { - _id: cartId - }; - update = { - $addToSet: { - "billing.paymentMethod": paymentMethod, - "billing.invoice": invoice - }, - $set: { - items: cart.items - } + // Legacy payment integration - transactions are not split by shop + // Create an invoice based on cart totals. + const invoice = { + shipping: cartShipping, + subtotal: cartSubTotal, + taxes: cartTaxes, + discounts: cartDiscounts, + total: cartTotal }; + + // Legacy payment plugins are passing in a single paymentMethod object + payments.push({ + paymentMethod: paymentMethods, + invoice: invoice, + address: paymentAddress, + shopId: Reaction.getPrimaryShopId() + }); } + const selector = { + _id: cartId + }; + + const update = { + $set: { + billing: payments + } + }; + try { Collections.Cart.update(selector, update); } catch (e) { diff --git a/server/methods/core/hooks/cart.js b/server/methods/core/hooks/cart.js index c1fe009b886..943c262bd26 100644 --- a/server/methods/core/hooks/cart.js +++ b/server/methods/core/hooks/cart.js @@ -6,6 +6,7 @@ import "../cart"; // // Meteor.after to call after MethodHooks.after("cart/submitPayment", function (options) { + // TODO: REVIEW WITH AARON - this is too late to fail. We need to copy cart to order either way at this point // if cart/submit had an error we won't copy cart to Order // and we'll throw an error. Logger.debug("MethodHooks after cart/submitPayment", options); @@ -37,6 +38,8 @@ MethodHooks.after("cart/submitPayment", function (options) { ); } } + } else { + throw new Meteor.Error("Error after submitting payment", options.error); } return result; }); diff --git a/server/methods/core/orders.js b/server/methods/core/orders.js index 5b8ab41f286..071cb8d55d4 100644 --- a/server/methods/core/orders.js +++ b/server/methods/core/orders.js @@ -412,7 +412,7 @@ export const methods = { check(order, Object); check(action, Match.OneOf(String, undefined)); - // REVIEW: SECURITY this only checks to see if a userId exists + // TODO: REVIEW: SECURITY this only checks to see if a userId exists if (!this.userId) { Logger.error("orders/sendNotification: Access denied"); throw new Meteor.Error("access-denied", "Access Denied"); @@ -434,12 +434,14 @@ export const methods = { } const billing = orderCreditMethod(order); - const refundResult = Meteor.call("orders/refunds/list", order); - let refundTotal = 0; - - _.each(refundResult, function (item) { - refundTotal += parseFloat(item.amount); - }); + // TODO: Update */refunds/list for marketplace + // const refundResult = Meteor.call("orders/refunds/list", order); + const refundTotal = 0; + + // TODO: We should use reduce here + // _.each(refundResult, function (item) { + // refundTotal += parseFloat(item.amount); + // }); // Get user currency formatting from shops collection, remove saved rate const userCurrencyFormatting = _.omit(shop.currencies[billing.currency.userCurrency], ["enabled", "rate"]); diff --git a/server/methods/core/shop.js b/server/methods/core/shop.js index 881506d25c5..0cb25b00fb6 100644 --- a/server/methods/core/shop.js +++ b/server/methods/core/shop.js @@ -32,6 +32,7 @@ Meteor.methods({ // this.unblock(); const count = Collections.Shops.find().count() || ""; const currentUser = Meteor.user(); + const currentAccount = Collections.Accounts.findOne({ _id: currentUser._id }); if (!currentUser) { throw new Meteor.Error("Unable to create shop with specified user"); @@ -48,7 +49,6 @@ Meteor.methods({ // identify a shop owner const userId = shopAdminUserId || currentUser._id; - const shopOwner = Meteor.users.findOne(userId); // ensure unique id and shop name shop._id = Random.id(); @@ -62,13 +62,20 @@ Meteor.methods({ // admin or marketplace needs to be on and guests allowed to create shops if (currentUser && Reaction.hasMarketplaceAccess("guest")) { // add user info for new shop - shop.emails = shopOwner.emails; - // TODO: Review source of default address for shop from user + shop.emails = currentUser.emails; + + // Reaction currently stores addressBook in Accounts collection not users - if (shopOwner.profile && shopOwner.profile.addressBook) { - shop.addressBook = [shopOwner.profile && shopOwner.profile.addressBook]; + if (currentAccount && currentAccount.addressBook && Array.isArray(currentAccount.addressBook)) { + shop.addressBook = currentAccount.addressBook; } + // TODO: SEUN REVIEW. Changed to above from below + // if (currentUser.profile && currentUser.profile.addressBook) { + // shop.addressBook = [currentUser.profile && currentUser.profile.addressBook]; + // } + + // clean up new shop delete shop.createdAt; delete shop.updatedAt; diff --git a/server/publications/collections/packages.js b/server/publications/collections/packages.js index 7a2f7de8b45..1cb492df5cc 100644 --- a/server/publications/collections/packages.js +++ b/server/publications/collections/packages.js @@ -97,7 +97,7 @@ Meteor.publish("Packages", function (shopId) { }; if (!shopId) { - myShopId = Reaction.getShopId(); + myShopId = Reaction.getPrimaryShopId(); } // we should always have a shop