npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2024 – Pkg Stats / Ryan Hefner

@chantelle/sales

v2.0.5

Published

Library for sales calculations: cart, order, invoice, refund, etc

Downloads

30

Readme

Sales calculations

NPM version Build Status Code Coverage

Manage order calculations based on invoices, refunds, cancellations

See full documentation on GitHub

Motivation

Many e-commerce platforms need to deal with order management: creating invoices, cancellations, refunds. In every sales document we need the right amounts: shipping, total, price for each item.

In case of refunds and cancellations, this library is able to cancel (re-calculate) the promotion, based on subset of finally acquired items.

Many implementations have rounding issues on calculations and do not give flexibility to cancel promotions.

By introducing the Order model this library covers business scenarios with promotions cancellation in sales documents.

Enjoy using it without having any number rounding issues :tada:

Installation

npm: npm i @chantelle/sales

yarn: yarn add @chantelle/sales

Usage

Disclaimer: Examples below are written without TypeScript for wide auditory.

Three same items cost 10€ in total

Source

Let's create this order and see how the library split 10€ into 3 same items:

const theOrder = {
    total: 10,
    shipping: 0,
    items: [{ id: 'a', price: 4, total: 10, qty: 3 }],
    invoiced: [],
    refunded: [],
    canceled: [] }

console.log(divideTotal(theOrder.items[0]).map(({ total }) => total))
// [ 3.33, 3.34, 3.33 ]

When we take 2 of 3 items, we should receive 6.67€. Let's create the invoice with quantity 2:

const invoice = orderCart(theOrder)
    .invoice({ items: [{ id: 'a', price: 4, qty: 2 }], shipping: 0 })
console.log(invoice.total, invoice.items[0].total)
// 6.67 6.67
theOrder.invoiced.push(invoice)

When we take just one item, we should receive either 3.33€ or 3.34€ - depending on already invoiced, refunded and canceled. The first refunded item in our invoiced order would be 3.33€ and then the second refunded item should be 3.34€. Let's check:

const refund = orderCart(theOrder)
    .refund({ items: [{ id: 'a', price: 4, qty: 1 }], shipping: 0 })
console.log(refund.total, refund.items[0].total)
// 3.33 3.33
theOrder.refunded.push(refund)
const refund2 = orderCart(theOrder)
    .refund({ items: [{ id: 'a', price: 4, qty: 1 }], shipping: 0 })
console.log(refund2.total, refund2.items[0].total)
// 3.34 3.34

Order scopes

Source

Based on invoiced, refunded and canceled data, you can calculate different parts of the order:

  • invoiced and not refunded (current income)
  • not canceled and not invoiced (potential for invoices and cancellations)
  • not canceled and not refunded (potential income)

You can calculate these values for the total amount, for items and for shipping. Let's take the order of 4 items with 2 items invoiced, 1 canceled and 1 refunded (from the invoiced). The shipping amount would be also partially invoiced, refunded and canceled.

  • Order total amount: 16€
  • Order shipping amount: 4€
  • Order items:
    • item a: quantity 4, total price 16€ (item price 4€)
  • Order has 2 invoices:
    • First invoice total 3€, shipping in this invoice 1€, items in this invoice:
      • item a: quantity 1, total price 5€ (item price 4€)
    • Second invoice total 5€, shipping in this invoice 1€, items in this invoice:
      • item a: quantity 1, total price 2€ (item price 4€)
  • Order has 1 refund:
    • Refund total amount 4€, shipping refund amount 1€, items refunded:
      • item a: quantity 1, total price 3€ (item price 4€)
  • Order has 1 cancellation:
    • Canceled total amount 3€, canceled shipping amount 1€, items canceled:
      • item a: quantity 1, total price 4€ (item price 4€)
const theOrder = {
    total: 16,
    shipping: 4,
    items: [{ id: 'a', price: 4, total: 16, qty: 4 }],
    invoiced: [
        { items: [{ id: 'a', price: 4, total: 5, qty: 1 }],
            shipping: 1,
            total: 3 },
        { items: [{ id: 'a', price: 4, total: 2, qty: 1 }],
            shipping: 1,
            total: 5 }],
    refunded: [{ items: [{ id: 'a', price: 4, total: 3, qty: 1 }],
        shipping: 1,
        total: 4 }],
    canceled: [{ items: [{ id: 'a', price: 4, total: 4, qty: 1 }],
        shipping: 1,
        total: 3 }] }

The current income for this order is 4€. In the scope of this income 1 item with 4€ total. Shipping costs income is 1€:

// Invoiced and not refunded amount, items, shipping
console.log(
    order.sales.total(theOrder).ir,
    order.sales.items(theOrder).ir,
    order.sales.shipping(theOrder).ir)
// 4 [ { id: 'a', price: 4, total: 4, qty: 1 } ] 1

Potentially we can invoice or cancel 5€. In scope of this part of the order, we have 1 item with 5€ total. Potentially invoiced or canceled shipping costs is 1€:

// Not canceled and not invoiced amount, items, shipping
console.log(
    order.sales.total(theOrder).ci,
    order.sales.items(theOrder).ci,
    order.sales.shipping(theOrder).ci)
// 5 [ { id: 'a', price: 4, total: 5, qty: 1 } ] 1

Potential income (in case if we invoice the rest) would be 9€. In scope of this part of the order, we have 2 items with 9€ total. Potential shipping costs income is 2€:

// Not canceled and not refunded amount, items
console.log(
    order.sales.total(theOrder).cr,
    order.sales.items(theOrder).cr,
    order.sales.shipping(theOrder).cr)
// 9 [ { id: 'a', price: 4, total: 9, qty: 2 } ] 2

Invariants

Source

Imagine, we have the order:

  • Order total amount: 10€
  • Order shipping amount: 4€
  • Order items:
    • item a: quantity 4, total price 10€ (item price 4€)
  • Order has 1 invoice:
    • Invoice total amount 5€, shipping in this invoice 2€, items in this invoice:
      • item a: quantity 2, total price 8€ (item price 4€)
  • Order has 1 refund:
    • Refund total amount 6€, shipping refund amount 3€, items refunded:
      • item a: quantity 3, total price 9€ (item price 4€)
  • Order has 1 cancellation:
    • Canceled total amount 7€, canceled shipping amount 3€, items canceled:
      • item a: quantity 3, total price 7€ (item price 4€)
const theOrder = {
    total: 10,
    shipping: 4,
    items: [{ id: 'a', price: 4, total: 10, qty: 4 }],
    invoiced: [{ items: [{ id: 'a', price: 4, total: 8, qty: 2 }],
        shipping: 2,
        total: 5 }],
    refunded: [{ items: [{ id: 'a', price: 4, total: 9, qty: 3 }],
        shipping: 3,
        total: 6 }],
    canceled: [{ items: [{ id: 'a', price: 4, total: 5, qty: 3 }],
        shipping: 3,
        total: 7 }] }

What is wrong with this order? The library has functions to check invariants:

  • You can't refund more than invoiced (items, shipping, amount)
  • Invoiced and canceled together shouldn't be more than we have in the order

Let's see the invoiced and not refunded scope - for order amount, shipping amount, items quantity and items amount:

console.log(invariants.total(theOrder).ir)
// -1

console.log(invariants.shipping(theOrder).total.ir)
// -1

console.log(invariants.items.qty(theOrder).ir[0].qty)
// -1

console.log(invariants.items.total(theOrder).ir[0].total)
// -1

Here we can see, that we refunded 1€ more than we have invoiced. We refunded 1€ more of shipping costs than we have invoiced for shipping. We refunded 1 more item than invoiced. Finally, we refunded 1€ more for this item, than invoiced.

Let's see the not canceled and not invoiced scope - for order amount, shipping amount, items quantity and items amount:

console.log(invariants.total(theOrder).ci)
// -2

console.log(invariants.shipping(theOrder).total.ci)
// -1

console.log(invariants.items.qty(theOrder).ci[0].qty)
// -1

console.log(invariants.items.total(theOrder).ci[0].total)
// -3

Here we can see, that we canceled and invoiced 2€ more than the order total amount. We canceled and invoiced 1€ more of shipping costs than the shipping costs of the order. We canceled and invoiced 1 more item than we have in the order. Finally, we canceled and invoiced 3€ more for this item, than we paid for this item in the order.

For correct order data, all above values should be non-negative. Functions in this library follow these invariants.

Promotions cancellation

Source

Assume we have the promotion: every 3rd cheapest item discounted - reduce product price to 1€. The next function implements this promo calculation:

const discountEvery3rdItem = cart => {
    const sorted = [...cart.items]
        .sort(({ price: a }, { price: b }) => a - b)
    const result = {
        c: Math.floor(cart.items
            .reduce((acc, { qty }) => acc + qty, 0) / 3),
        items: [] }
    for (const item of sorted) {
        if (result.c <= 0) {
            result.items.push({ ...item, total: itemPrice(item) })
        } else if (result.c >= item.qty) {
            result.items.push({ ...item, total: item.qty })
        } else {
            result.items.push({ ...item,
                total: addPrices(result.c,
                    itemPrice({ ...item,
                        qty: item.qty - result.c })) })
        }
        result.c -= item.qty
    }
    return { ...cart,
        items: result.items,
        total: addPrices(cart.shipping,
            ...result.items.map(({ total }) => total)) }
}

Let's have some simple checks:

  • We order one item - no promo, the total amount is just item price
  • We order 2 items - no promo, the total amount is the sum of items prices
  • We order 3 items - cheapest item discounted. For example, items prices are 5€, 5€ and 6€ - we have total amount 12€ (5€ + 1€ + 6€)
  • We order 6 items - 2 cheapest items discounted. For example, items prices are 5€, 5€, 5€, 4€, 4€ and 4€ - we have total amount 21€ (5€ + 5€ + 5€ + 4€ + 1€ + 1€)
// 1 item - no promo
console.log(discountEvery3rdItem({
    shipping: 0,
    items: [{ id: 'a', qty: 1, price: 5 }] }).total)
// 5

// 2 items - no promo
console.log(discountEvery3rdItem({
    shipping: 0,
    items: [{ id: 'a', qty: 2, price: 5 }] }).total)
// 10
console.log(discountEvery3rdItem({ shipping: 0, items: [
    { id: 'a', qty: 1, price: 5 },
    { id: 'b', qty: 1, price: 6 }] }).total)
// 11

// 3 items - cheapest item discounted
console.log(discountEvery3rdItem({
    shipping: 0,
    items: [{ id: 'a', qty: 3, price: 5 }] }).total)
// 11
console.log(discountEvery3rdItem({ shipping: 0, items: [
    { id: 'a', qty: 1, price: 5 },
    { id: 'b', qty: 1, price: 5 },
    { id: 'c', qty: 1, price: 6 }] }).total)
// 12
console.log(discountEvery3rdItem({ shipping: 0, items: [
    { id: 'a', qty: 2, price: 5 },
    { id: 'c', qty: 1, price: 6 }] }).total)
// 12

// 6 items - 2 cheapest items discounted
console.log(discountEvery3rdItem({
    shipping: 0,
    items: [{ id: 'a', qty: 6, price: 5 }] }).total)
// 22
console.log(discountEvery3rdItem({ shipping: 0, items: [
    { id: 'a', qty: 1, price: 5 },
    { id: 'b', qty: 2, price: 5 },
    { id: 'c', qty: 3, price: 4 }] }))
// 21

Potentially, your promo calculations could be based on your own systems, even use the database or call 3rd-party API to know applied promotions and total costs. Simply write cart calculation adapter to the library interfaces (similar to discountEvery3rdItem) - after this you will be able to calculate costs for invoices, refunds and cancellations.

Let's create an order with promotion applied, the cheapest item discounted, and we have 1€ total costs for that item:

const theOrder = discountEvery3rdItem({ shipping: 0, items: [
    { id: 'a', qty: 1, price: 4 },
    { id: 'b', qty: 1, price: 5 },
    { id: 'c', qty: 1, price: 6 }] })
console.log(theOrder)

// prepare order object for sales calculations
theOrder.invoiced = []
theOrder.refunded = []
theOrder.canceled = []

We have 3€ discount on item a in this order. We can see how totals are calculated for this order and for each item separately:

  • Order items:
    • item a: quantity 1, total price 1€ (item price 4€)
    • item b: quantity 1, total price 5€ (item price 5€)
    • item c: quantity 1, total price 6€ (item price 6€)
  • Order shipping amount: 0€
  • Order total amount: 12€

How should we calculate if we cancel item b? In this case, promotion would not be applicable anymore. So, we can keep the fee for promotion cancellation and cancel only 2€ (5€ - 3€). First, we request the "cancellation cart" from the library:

const cartForCancelation = order.total(theOrder)
    .cancel({ shipping: 0,
        items: [{ id: 'b', qty: 1, price: 5 }] })
console.log(cartForCancelation)

Here library gives you cart items (and shipping) for your cart calculations. As mentioned above, you can use the database or call 3rd-party API, async functions, etc. When you calculate all totals for the given cart - proceed by calling total, pass the totals you have calculated. Let's see, if we use our discountEvery3rdItem here:

// your calculations here - could be async
const cartForCancelationWithTotals =
    discountEvery3rdItem(cartForCancelation)
// end of your calculations, proceed with the library
const cancelDocument = cartForCancelation
    .total(cartForCancelationWithTotals)
console.log(cancelDocument)
theOrder.canceled.push(cancelDocument)

Here we have our cancel document calculated:

  • Cancellation total amount: 2€
  • Canceled shipping amount: 0€
  • Canceled items:
    • item b: quantity 1, total price 5€ (item price 5€)

Here we can see the difference. Even the cancellation amount for the item is 5€, the cancel document has total amount 2€. You can inform the customer, that this happened because of the fee for promotion cancellation.

Now the same way let's invoice items a and c - request "invoice cart" from the library, use discountEvery3rdItem to calculate totals for the "invoice cart" and then proceed by calling total to receive the invoice document:

const cartForInvoice = order.total(theOrder)
    .invoice({ shipping: 0, items: [
        { id: 'a', qty: 1, price: 4 },
        { id: 'c', qty: 1, price: 6 }] })
// your calculations here - could be async
const cartForInvoiceWithTotals = discountEvery3rdItem(cartForInvoice)
// end of your calculations, proceed with the library
const invoiceDocument = cartForInvoice.total(cartForInvoiceWithTotals)
console.log(invoiceDocument)

Here we have our invoice document calculated:

  • Invoice total amount: 10€
  • Invoiced shipping amount: 0€
  • Invoiced items:
    • item a: quantity 1, total price 1€ (item price 4€)
    • item c: quantity 1, total price 6€ (item price 6€)

Again, we can see this difference here. Even we invoiced only 1€ for item a, we have invoice total amount of 10€, because it includes the fee. You can inform customer, that this happened because of promotion cancellation.

The same will happen when you refund invoiced items. For more use-cases you can read at business scenarios. For more details read other parts of the documentation. For visual diagrams and calculation formulas you can check Order model

Order model

Sales API

For all documented functions you can also check the unit-tests to see the usage examples (100% coverage). All interfaces are extendable in functions, so you can use more specific types in your applications.