/** * @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); } }); }
/** * @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 }; }
/** * @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 }; }