Example #1
0
/**
 * @method updateSurchargeMutation
 * @summary updates a surcharge
 * @param {Object} context - an object containing the per-request state
 * @param {Object} input - Input (see SimpleSchema)
 * @return {Promise<Object>} An object with a `surcharge` property containing the updated surcharge
 */
export default async function updateSurchargeMutation(context, input) {
  const cleanedInput = inputSchema.clean(input); // add default values and such
  inputSchema.validate(cleanedInput);

  const { surcharge, surchargeId, shopId } = cleanedInput;
  const { collections, userHasPermission } = context;
  const { Surcharges } = collections;

  if (!userHasPermission(["admin", "owner", "shipping"], shopId)) {
    throw new ReactionError("access-denied", "Access Denied");
  }

  const { matchedCount } = await Surcharges.updateOne({
    _id: surchargeId,
    shopId
  }, {
    $set: {
      updatedAt: new Date(),
      ...surcharge
    }
  });
  if (matchedCount === 0) throw new ReactionError("not-found", "Not found");

  return { surcharge };
}
/**
 * @method createFlatRateFulfillmentMethodMutation
 * @summary Creates a flat rate fulfillment method
 * @param {Object} context - an object containing the per-request state
 * @param {Object} input - Input (see SimpleSchema)
 * @return {Promise<Object>} An object with a `method` property containing the created method
 */
export default async function createFlatRateFulfillmentMethodMutation(context, input) {
  const cleanedInput = inputSchema.clean(input); // add default values and such
  inputSchema.validate(cleanedInput);

  const { method, shopId } = cleanedInput;
  const { collections, userHasPermission } = context;
  const { Shipping } = collections;

  if (!userHasPermission(["admin", "owner", "shipping"], shopId)) {
    throw new ReactionError("access-denied", "Access Denied");
  }

  const shippingRecord = await Shipping.findOne({ "provider.name": "flatRates", shopId });
  if (!shippingRecord) {
    await Shipping.insertOne({
      name: "Default Shipping Provider",
      shopId,
      provider: {
        name: "flatRates",
        label: "Flat Rate"
      }
    });
  }

  method._id = Random.id();

  // MongoDB schema still uses `enabled` rather than `isEnabled`
  method.enabled = method.isEnabled;
  delete method.isEnabled;

  const { matchedCount } = await Shipping.updateOne({
    shopId,
    "provider.name": "flatRates"
  }, {
    $addToSet: {
      methods: method
    }
  });
  if (matchedCount === 0) throw new ReactionError("server-error", "Unable to create fulfillment method");

  return { method };
}
/**
 * @method setShippingAddressOnCart
 * @summary Sets the shippingAddress data for all fulfillment groups on a cart that have
 *   a type of "shipping"
 * @param {Object} context - an object containing the per-request state
 * @param {Object} input - Input (see SimpleSchema)
 * @return {Promise<Object>} An object with a `cart` property containing the updated cart
 */
export default async function setShippingAddressOnCart(context, input) {
  const cleanedInput = inputSchema.clean(input); // add default values and such
  inputSchema.validate(cleanedInput);

  const { address, addressId, cartId, cartToken } = cleanedInput;
  address._id = addressId || Random.id();

  const cart = await getCartById(context, cartId, { cartToken, throwIfNotFound: true });

  let didModify = false;
  const updatedFulfillmentGroups = (cart.shipping || []).map((group) => {
    if (group.type === "shipping") {
      didModify = true;
      return { ...group, address };
    }
    return group;
  });

  if (!didModify) return { cart };

  const { appEvents, collections, userId } = context;
  const { Cart } = collections;

  const updatedAt = new Date();
  const { matchedCount } = await Cart.updateOne({ _id: cartId }, {
    $set: {
      shipping: updatedFulfillmentGroups,
      updatedAt
    }
  });
  if (matchedCount === 0) throw new ReactionError("server-error", "Failed to update cart");

  const updatedCart = { ...cart, shipping: updatedFulfillmentGroups, updatedAt };

  await appEvents.emit("afterCartUpdate", {
    cart: updatedCart,
    updatedBy: userId
  });

  return { cart: updatedCart };
}
/**
 * @method selectFulfillmentOptionForGroup
 * @summary Selects a fulfillment option for a fulfillment group
 * @param {Object} context -  an object containing the per-request state
 * @param {Object} input - an object of all mutation arguments that were sent by the client
 * @param {String} input.cartId - The ID of the cart to select a fulfillment option for
 * @param {String} [input.cartToken] - The token for the cart, required if it is an anonymous cart
 * @param {String} input.fulfillmentGroupId - The group to select a fulfillment option for
 * @param {String} input.fulfillmentMethodId - The fulfillment method ID from the option the shopper selected
 * @return {Promise<Object>} An object with a `cart` property containing the updated cart
 */
export default async function selectFulfillmentOptionForGroup(context, input) {
  const cleanedInput = inputSchema.clean(input || {});
  inputSchema.validate(cleanedInput);

  const { cartId, cartToken, fulfillmentGroupId, fulfillmentMethodId } = cleanedInput;
  const { appEvents, collections, userId } = context;
  const { Cart } = collections;

  const cart = await getCartById(context, cartId, { cartToken, throwIfNotFound: true });

  const fulfillmentGroup = (cart.shipping || []).find((group) => group._id === fulfillmentGroupId);
  if (!fulfillmentGroup) throw new ReactionError("not-found", `Fulfillment group with ID ${fulfillmentGroupId} not found in cart with ID ${cartId}`);

  // Make sure there is an option for this group that has the requested ID
  const option = (fulfillmentGroup.shipmentQuotes || []).find((quote) => quote.method._id === fulfillmentMethodId);
  if (!option) throw new ReactionError("not-found", `Fulfillment option with method ID ${fulfillmentMethodId} not found in cart with ID ${cartId}`);

  const { matchedCount } = await Cart.updateOne({
    "_id": cartId,
    "shipping._id": fulfillmentGroupId
  }, {
    $set: {
      "shipping.$.shipmentMethod": option.method
    }
  });
  if (matchedCount !== 1) throw new ReactionError("server-error", "Unable to update cart");

  // We can do the same update locally to avoid a db find
  cart.shipping.forEach((group) => {
    if (group._id === fulfillmentGroupId) {
      group.shipmentMethod = option.method;
    }
  });

  await appEvents.emit("afterCartUpdate", {
    cart,
    updatedBy: userId
  });

  return { cart };
}
/**
 * @method placeMarketplaceOrderWithStripeCardPayment
 * @summary Authorizes a credit card, given its Stripe-created token, and then
 *   creates an order with that payment attached.
 * @param {Object} context - an object containing the per-request state
 * @param {Object} input - Necessary input. See SimpleSchema
 * @return {Promise<Object>} Object with `order` property containing the created order
 */
export default async function placeMarketplaceOrderWithStripeCardPayment(context, input) {
  const cleanedInput = inputSchema.clean(input); // add default values and such
  inputSchema.validate(cleanedInput);

  const { order: orderInput, payment: paymentInput } = cleanedInput;
  const { currencyCode, email, shopId } = orderInput;
  const { billingAddress, billingAddressId, stripeTokenId } = paymentInput;
  const { accountId, mutations } = context;

  billingAddress._id = billingAddressId || Random.id();

  let applicationFee;
  let stripe;
  let stripeCustomerId;
  const stripeIdsByShopId = {};
  return mutations.createOrder(context, {
    order: orderInput,
    async afterValidate() {
      const result = await getStripeInstanceForShop(context, shopId);
      ({ applicationFee, stripe } = result);

      // For orders with only a single fulfillment group, we could create a charge directly from the card token, and skip
      // creating a customer. However, to keep the code simple and because we always have an email address and tracking
      // payments by customer in Stripe can be useful, we create a customer no matter what.
      const stripeCustomer = await stripe.customers.create({ email, metadata: { accountId }, source: stripeTokenId });
      stripeCustomerId = stripeCustomer.id;

      await Promise.all(orderInput.fulfillmentGroups.map(async (group) => {
        if (group.shopId === shopId) return null; // We use Stripe Connect only for merchants
        if (!stripeIdsByShopId[group.shopId]) {
          stripeIdsByShopId[group.shopId] = await getMerchantStripeId(context, shopId);
        }
      }));
    },
    async createPaymentForFulfillmentGroup(group) {
      return createSingleCharge(stripe, group, stripeCustomerId, currencyCode, billingAddress, stripeIdsByShopId[group.shopId], applicationFee);
    }
  });
}
Example #6
0
/**
 * @method createSurchargeMutation
 * @summary Creates a surcharge
 * @param {Object} context - an object containing the per-request state
 * @param {Object} input - Input (see SimpleSchema)
 * @return {Promise<Object>} An object with a `surcharge` property containing the created surcharge
 */
export default async function createSurchargeMutation(context, input) {
  const cleanedInput = inputSchema.clean(input); // add default values and such
  inputSchema.validate(cleanedInput);

  const { surcharge, shopId } = cleanedInput;
  const { collections, userHasPermission } = context;
  const { Surcharges } = collections;

  if (!userHasPermission(["admin", "owner", "shipping"], shopId)) {
    throw new ReactionError("access-denied", "Access Denied");
  }

  surcharge._id = Random.id();

  const { insertedCount } = await Surcharges.insertOne({
    shopId,
    createdAt: new Date(),
    ...surcharge
  });
  if (insertedCount === 0) throw new ReactionError("server-error", "Unable to create surcharge");

  return { surcharge };
}
Example #7
0
/**
 * @method createOrder
 * @summary Creates an order
 * @param {Object} context - an object containing the per-request state
 * @param {Object} input - Necessary input. See SimpleSchema
 * @return {Promise<Object>} Object with `order` property containing the created order
 */
export default async function createOrder(context, input) {
  const cleanedInput = inputSchema.clean(input); // add default values and such
  inputSchema.validate(cleanedInput);

  const { afterValidate, createPaymentForFulfillmentGroup, order: orderInput } = cleanedInput;
  const { cartId, currencyCode, email, fulfillmentGroups, shopId } = orderInput;
  const { accountId, account, collections } = context;
  const { Orders } = collections;

  // We are mixing concerns a bit here for now. This is for backwards compatibility with current
  // discount codes feature. We are planning to revamp discounts soon, but until then, we'll look up
  // any discounts on the related cart here.
  const { discounts, total: discountTotal } = await getDiscountsTotalForCart(context, cartId);

  // Add more props to each fulfillment group, and validate/build the items in each group
  const finalFulfillmentGroups = await Promise.all(fulfillmentGroups.map(async (groupInput) => {
    const finalGroup = {
      _id: Random.id(),
      address: groupInput.data ? getShippingAddressWithId(groupInput.data.shippingAddress, groupInput.data.shippingAddressId) : null,
      items: groupInput.items,
      shopId: groupInput.shopId,
      type: groupInput.type,
      workflow: { status: "new", workflow: ["coreOrderWorkflow/notStarted"] }
    };

    // Verify that the price for the chosen shipment method on each group matches between what the client
    // provided and what the current quote is.
    const rates = await context.queries.getFulfillmentMethodsWithQuotes(finalGroup, context);
    const selectedFulfillmentMethod = rates.find((rate) => groupInput.selectedFulfillmentMethodId === rate.method._id);
    if (!selectedFulfillmentMethod) {
      throw new ReactionError("invalid", "The selected fulfillment method is no longer available." +
        " Fetch updated fulfillment options and try creating the order again with a valid method.");
    }
    finalGroup.shipmentMethod = {
      _id: selectedFulfillmentMethod.method._id,
      carrier: selectedFulfillmentMethod.method.carrier,
      currencyCode,
      label: selectedFulfillmentMethod.method.label,
      group: selectedFulfillmentMethod.method.group,
      name: selectedFulfillmentMethod.method.name,
      handling: selectedFulfillmentMethod.handlingPrice,
      rate: selectedFulfillmentMethod.shippingPrice
    };

    // Build the final order item objects. As part of this, we look up the variant in the system and make sure that
    // the price is what the shopper expects it to be.
    finalGroup.items = await Promise.all(finalGroup.items.map((item) => buildOrderItem(item, currencyCode, context)));

    finalGroup.items = await getFulfillmentGroupItemsWithTaxAdded(collections, finalGroup, true);

    // Add some more properties for convenience
    finalGroup.itemIds = finalGroup.items.map((item) => item._id);
    finalGroup.totalItemQuantity = finalGroup.items.reduce((sum, item) => sum + item.quantity, 0);

    // Error if we calculate total price differently from what the client has shown as the preview.
    // It's important to keep this after adding and verifying the shipmentMethod and order item prices.
    finalGroup.invoice = getInvoiceForFulfillmentGroup(finalGroup, discountTotal);

    // For now we expect that the client has NOT included discounts in the expected total it sent.
    // Note that we don't currently know which parts of `discountTotal` go with which fulfillment groups.
    // This needs to be rewritten soon for discounts to work when there are multiple fulfillment groups.
    // Probably the client should be sending all applied discount IDs and amounts in the order input (by group),
    // and include total discount in `groupInput.totalPrice`, and then we simply verify that they are valid here.
    const expectedTotal = Math.max(groupInput.totalPrice - discountTotal, 0);

    // In order to prevent mismatch due to rounding, we convert these to strings before comparing. What we really
    // care about is, do these match to the specificity that the shopper will see (i.e. to the scale of the currency)?
    // No currencies have greater than 3 decimal places, so we'll use 3.
    const expectedTotalString = accounting.toFixed(expectedTotal, 3);
    const actualTotalString = accounting.toFixed(finalGroup.invoice.total, 3);

    if (expectedTotalString !== actualTotalString) {
      throw new ReactionError(
        "invalid",
        `Client provided total price ${expectedTotalString} for order group, but actual total price is ${actualTotalString}`
      );
    }

    return finalGroup;
  }));

  const currencyExchangeInfo = await getCurrencyExchangeObject(collections, currencyCode, shopId, account);

  if (afterValidate) {
    await afterValidate();
  }

  // Create one charge per fulfillment group. This is necessary so that each group charge can be captured as it is
  // fulfilled, and so that each can be refunded or canceled separately.
  let chargedFulfillmentGroups;
  try {
    chargedFulfillmentGroups = await Promise.all(finalFulfillmentGroups.map(async (group) => {
      const payment = await createPaymentForFulfillmentGroup(group);
      const paymentWithCurrency = {
        ...payment,
        currency: currencyExchangeInfo,
        currencyCode
      };
      PaymentSchema.validate(paymentWithCurrency);
      return {
        ...group,
        payment: paymentWithCurrency
      };
    }));
  } catch (error) {
    Logger.error("createOrder: error creating payments", error.message);
    throw new ReactionError("payment-failed", "There was a problem authorizing this payment");
  }

  // Create anonymousAccessToken if no account ID
  let anonymousAccessToken = null;
  if (!accountId) {
    anonymousAccessToken = Random.secret();
  }

  const now = new Date();

  const order = {
    _id: Random.id(),
    accountId,
    anonymousAccessToken: anonymousAccessToken && hashLoginToken(anonymousAccessToken),
    cartId,
    createdAt: now,
    currencyCode,
    discounts,
    email,
    shipping: chargedFulfillmentGroups,
    shopId,
    totalItemQuantity: chargedFulfillmentGroups.reduce((sum, group) => sum + group.totalItemQuantity, 0),
    updatedAt: now,
    workflow: {
      status: "new",
      workflow: ["coreOrderWorkflow/created"]
    }
  };

  // Validate and save
  OrderSchema.validate(order);
  await Orders.insertOne(order);

  appEvents.emit("afterOrderCreate", order).catch((error) => {
    Logger.error("Error emitting afterOrderCreate", error);
  });

  return {
    orders: [order],
    token: anonymousAccessToken
  };
}