Skip to content

Commit

Permalink
Messed up previous commit. This commit adds discount support.
Browse files Browse the repository at this point in the history
* invoices now support a discounts array
* discount line items support a description and a percentage or amount
* renamed balance_due, total_paid, and remaining_balance to match
  invoice view
* mocha test written for discount total calculations
* changed roundToDecimal function to actually round instead of truncate
  • Loading branch information
akinsey committed Jul 8, 2014
1 parent 286141a commit 0e610bc
Show file tree
Hide file tree
Showing 11 changed files with 190 additions and 27 deletions.
22 changes: 19 additions & 3 deletions db/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -272,12 +272,28 @@ var createInvoice = function(invoice, callback) {
invoice.api_key = undefined;
invoice.created = new Date().getTime();
invoice.type = 'invoice';
var balanceDue = new BigNumber(0);
var invoiceTotal = new BigNumber(0);
invoice.line_items.forEach(function(item) {
var lineCost = new BigNumber(item.amount).times(item.quantity);
balanceDue = balanceDue.plus(lineCost);
invoiceTotal = invoiceTotal.plus(lineCost);
});
invoice.balance_due = Number(balanceDue.valueOf());
var isUSD = invoice.currency.toUpperCase() === 'USD';
var discountTotal = new BigNumber(0);
invoice.discounts.forEach(function(item) {
var roundedAmount = 0;
if (item.amount) {
roundedAmount = isUSD ? helper.roundToDecimal(item.amount, 2) : item.amount;
}
else if (item.percentage) {
var percentage = new BigNumber(item.percentage).dividedBy(100);
var discountAmount = Number(invoiceTotal.times(percentage).valueOf());
roundedAmount = isUSD ? helper.roundToDecimal(discountAmount, 2) : discountAmount;
}
discountTotal = discountTotal.plus(roundedAmount);
});
invoiceTotal = invoiceTotal.minus(discountTotal);
invoice.invoice_total = Number(invoiceTotal.valueOf());
invoice.invoice_total = isUSD ? helper.roundToDecimal(invoice.invoice_total, 2) : Number(invoice.invoice_total);
if (invoice.text) {
invoice.text = sanitizeHtml(invoice.text); // remove hostile elements
}
Expand Down
3 changes: 1 addition & 2 deletions lib/helper.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,7 @@ var getLastFourDecimals = function(number) {

// Round to decimal place
var roundToDecimal = function(number, decimalPlaces) {
var offset = Math.pow(10, decimalPlaces);
return (Math.round(number * offset) / offset).toFixed(decimalPlaces);
return Number(Math.round(number + 'e+' + decimalPlaces) + 'e-' + decimalPlaces).toFixed(decimalPlaces);
};

// Returns receiveDetail portion of transaction json from wallet notify
Expand Down
31 changes: 29 additions & 2 deletions lib/invoices.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ var helper = require(__dirname + '/helper');
// Calculates line totals for invoice line items
var calculateLineTotals = function(invoice) {
var isUSD = invoice.currency.toUpperCase() === 'USD';
invoice.line_items.forEach(function (item){
invoice.line_items.forEach(function (item) {
item.line_total = Number(new BigNumber(item.amount).times(item.quantity).valueOf());
if (isUSD) { // Round USD to two decimals
item.amount = helper.roundToDecimal(item.amount, 2);
Expand All @@ -21,6 +21,32 @@ var calculateLineTotals = function(invoice) {
});
};

// Calculates discount totals for invoice
var calculateDiscountTotals = function(invoice) {
if (invoice.discounts && invoice.discounts.length > 0) {
var isUSD = invoice.currency.toUpperCase() === 'USD';
var invoiceTotal = new BigNumber(0);
invoice.line_items.forEach(function(item) {
var lineCost = new BigNumber(item.amount).times(item.quantity);
invoiceTotal = invoiceTotal.plus(lineCost);
});
invoice.discounts.forEach(function (item) {
if (item.amount) {
return;
}
var percentage = new BigNumber(item.percentage).dividedBy(100);
item.amount = invoiceTotal.times(percentage);
if (isUSD) { // Round USD to two decimals
item.amount = helper.roundToDecimal(item.amount, 2);
}
// If our calculated discount total has more than 8 decimals round to 8
else if (helper.decimalPlaces(item.amount) > 8) {
item.amount = helper.roundToDecimal(item.amount, 8);
}
});
}
};

// Returns the latest payment object for invoice
var getActivePayment = function(paymentsArr) {
var activePayment;
Expand Down Expand Up @@ -86,7 +112,7 @@ var getAmountDue = function(balanceDue, totalPaid, currency) {
var getAmountDueBTC = function(invoice, paymentsArr, cb) {
var isUSD = invoice.currency.toUpperCase() === 'USD';
var totalPaid = getTotalPaid(invoice, paymentsArr);
var remainingBalance = new BigNumber(invoice.balance_due).minus(totalPaid);
var remainingBalance = new BigNumber(invoice.invoice_total).minus(totalPaid);
if (isUSD) {
var curTime = new Date().getTime();
tickerJob.getTicker(curTime, function(err, docs) {
Expand Down Expand Up @@ -123,6 +149,7 @@ var getPaymentHistory = function(paymentsArr) {

module.exports = {
calculateLineTotals: calculateLineTotals,
calculateDiscountTotals: calculateDiscountTotals,
getActivePayment: getActivePayment,
getTotalPaid: getTotalPaid,
getAmountDue: getAmountDue,
Expand Down
2 changes: 1 addition & 1 deletion lib/payments.js
Original file line number Diff line number Diff line change
Expand Up @@ -207,7 +207,7 @@ function createNewPaymentWithTransaction(invoiceId, transaction, cb) {
var tickerData = docs.rows[0].value;
var rate = new BigNumber(tickerData.vwap);
var totalPaid = new BigNumber(invoicesLib.getTotalPaid(invoice, paymentsArr));
var remainingBalance = new BigNumber(invoice.balance_due).minus(totalPaid);
var remainingBalance = new BigNumber(invoice.invoice_total).minus(totalPaid);
var isUSD = invoice.currency.toUpperCase() === 'USD';
if (isUSD) {
var actualPaid = helper.roundToDecimal(rate.times(transaction.amount).valueOf(), 2);
Expand Down
54 changes: 52 additions & 2 deletions lib/validate.js
Original file line number Diff line number Diff line change
Expand Up @@ -42,14 +42,16 @@ var invoice = function(invoice, cb) {
minimumPrice = 0.00000001;
break;
}
var billionCeiling = 999999999999;

// Line Items
if(invoice.line_items && invoice.line_items.length > 0) {
var balanceDue = 0;
if (invoice.line_items && invoice.line_items.length > 0) {
invoice.line_items.forEach(function(item) {
if (!item.amount || !item.quantity || !item.description) {
errorMessages.push('invalid line_items: ' + JSON.stringify(item));
}
else {
var billionCeiling = 999999999999;
// Amount
item.amount = parseFloat(item.amount);
if (typeof item.amount !== 'number' || item.amount < minimumPrice) {
Expand All @@ -76,13 +78,61 @@ var invoice = function(invoice, cb) {
if (typeof item.description !== 'string') {
errorMessages.push('line_item description must be a string:' + JSON.stringify(item));
}
balanceDue += item.amount * item.quantity;
}
});
}
else {
errorMessages.push('line_items must contain at least one entry');
}

// Discounts
var totalDiscounts = 0;
if (invoice.discounts && invoice.discounts.length > 0) {
invoice.discounts.forEach(function(item) {
if (item.amount && item.percentage) {
errorMessages.push('invalid discounts: ' + JSON.stringify(item));
}
else if (item.amount && !item.description || item.percentage && !item.description) {
errorMessages.push('invalid discounts must have description: ' + JSON.stringify(item));
}
else {
// Amount
item.amount = parseFloat(item.amount);
if (item.amount === 0 || item.amount) {
totalDiscounts += item.amount;
if (typeof item.amount !== 'number' || item.amount < minimumPrice) {
errorMessages.push('discount amount must be >= ' + minimumPrice + ': ' + JSON.stringify(item));
}
if (helper.decimalPlaces(item.amount) > 8) {
errorMessages.push('discount amount must only have 0-8 decimal digits: ' + JSON.stringify(item));
}
if (item.amount > billionCeiling) {
errorMessages.push('discount amount is too large: ' + JSON.stringify(item));
}
}
// Percentage
item.percentage = parseFloat(item.percentage);
if (item.percentage === 0 || item.percentage) {
totalDiscounts += balanceDue * (item.percentage/100);
if (typeof item.percentage !== 'number' || item.percentage < minimumPrice) {
errorMessages.push('discount percentage must be >= ' + minimumPrice + ': ' + JSON.stringify(item));
}
if (helper.decimalPlaces(item.percentage) > 8) {
errorMessages.push('discount percentage must only have 0-8 decimal digits: ' + JSON.stringify(item));
}
if (item.percentage > billionCeiling) {
errorMessages.push('discount percentage is too large: ' + JSON.stringify(item));
}
}
}
});
}

if (totalDiscounts >= balanceDue) {
errorMessages.push('discount cannot be greater than or equal to the total balance due');
}

// Minimum Confirmations
invoice.min_confirmations = parseFloat(invoice.min_confirmations);
if (typeof invoice.min_confirmations !== 'number' || !isInteger(invoice.min_confirmations)) {
Expand Down
4 changes: 4 additions & 0 deletions public/css/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,10 @@ a:hover, a:active {
color: #41a323;
}

.info, .discount, .blue {
color: #024FB1;
}

.gray {
color: #999;
}
Expand Down
4 changes: 2 additions & 2 deletions public/js/reloadinvoice.js
Original file line number Diff line number Diff line change
Expand Up @@ -146,8 +146,8 @@ var reloadInvoice = function(queryUrl, expiration, isExpired, isPaid, isVoid) {
isPaid = invoice.is_paid;
isVoid = invoice.is_void;
isExpired = invoice.is_expired;
var newAmountPaid = invoice.total_paid;
var newAmountDue = invoice.remaining_balance;
var newAmountPaid = invoice.amount_paid;
var newAmountDue = invoice.balance_due;
paymentHistory = invoice.payment_history;

// Update Status Banner
Expand Down
2 changes: 1 addition & 1 deletion routes/invoices.js
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ var invoices = function(app) {
res.status(200).write('Invoice ' + invoiceId + ' is already void.');
res.end();
}
else if (Number(invoice.total_paid) > 0) {
else if (Number(invoice.amount_paid) > 0) {
res.status(400).write('Invoice ' + invoiceId + ' has payments, cannot be void.');
res.end();
}
Expand Down
14 changes: 7 additions & 7 deletions routes/utils/invoices.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,18 +21,18 @@ var findInvoiceAndPaymentHistory = function(invoiceId, cb) {

var isUSD = invoice.currency.toUpperCase() === 'USD';
invoicesLib.calculateLineTotals(invoice);
invoice.total_paid = invoicesLib.getTotalPaid(invoice, paymentsArr);
invoice.balance_due = isUSD ? helper.roundToDecimal(invoice.balance_due, 2) : Number(invoice.balance_due);
invoice.remaining_balance = invoicesLib.getAmountDue(invoice.balance_due, invoice.total_paid, invoice.currency);
invoicesLib.calculateDiscountTotals(invoice);
invoice.amount_paid = invoicesLib.getTotalPaid(invoice, paymentsArr);
invoice.balance_due = invoicesLib.getAmountDue(invoice.invoice_total, invoice.amount_paid, invoice.currency);
invoice.is_expired = false;
invoice.is_void = invoice.is_void ? invoice.is_void : false;
invoice.expiration = invoice.expiration ? invoice.expiration : null;
invoice.text = invoice.text ? invoice.text : null;

var invoiceExpired = validate.invoiceExpired(invoice);
invoice.is_expired = invoiceExpired && invoice.remaining_balance > 0;
invoice.is_expired = invoiceExpired && invoice.balance_due > 0;
invoice.expiration_time = null;
if (invoice.expiration && !invoiceExpired && invoice.remaining_balance > 0) {
if (invoice.expiration && !invoiceExpired && invoice.balance_due > 0) {
invoice.expiration_time = helper.getExpirationCountDown(invoice.expiration);
}

Expand All @@ -55,8 +55,8 @@ var findInvoiceAndPaymentHistory = function(invoiceId, cb) {
return payment.created;
});

invoice.is_paid = !hasPending && invoice.remaining_balance <= 0;
invoice.is_overpaid = !hasPending && invoice.remaining_balance < 0;
invoice.is_paid = !hasPending && invoice.balance_due <= 0;
invoice.is_overpaid = !hasPending && invoice.balance_due < 0;

delete invoice.webhooks;
delete invoice.metadata;
Expand Down
49 changes: 49 additions & 0 deletions tests/invoicehelpertests.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,55 @@ describe('invoicehelper', function() {
});
});

describe('invoicehelper', function() {
describe('#calculateDiscountTotals', function() {
it('should calculate discount totals given percentage or amount', function() {
var invoiceBTC = {
currency: 'BTC',
line_items: [
{ quantity: 2, amount: 0.5 },
{ quantity: 9, amount: 1 }
],
discounts: [
{ amount: 2.5 },
{ percentage: 55 },
{ percentage: 25.643 },
{ percentage: 1 },
{ percentage: 0.5 }
]
};
invoiceHelper.calculateDiscountTotals(invoiceBTC);
assert.equal('2.5', invoiceBTC.discounts[0].amount.toString());
assert.equal('5.5', invoiceBTC.discounts[1].amount.toString());
assert.equal('2.5643', invoiceBTC.discounts[2].amount.toString());
assert.equal('0.1', invoiceBTC.discounts[3].amount.toString());
assert.equal('0.05', invoiceBTC.discounts[4].amount.toString());

var invoiceUSD = {
currency: 'USD',
line_items: [
{ quantity: 3, amount: 50.13 },
{ quantity: 4, amount: 100.64 }
],
discounts: [
{ amount: 10.45 },
{ percentage: 55 },
{ percentage: 25.643 },
{ percentage: 1 },
{ percentage: 0.5 }
]
};
invoiceHelper.calculateDiscountTotals(invoiceUSD);
assert.equal('10.45', invoiceUSD.discounts[0].amount.toString());
assert.equal('304.12', invoiceUSD.discounts[1].amount.toString());
assert.equal('141.79', invoiceUSD.discounts[2].amount.toString());
assert.equal('5.53', invoiceUSD.discounts[3].amount.toString());
assert.equal('2.76', invoiceUSD.discounts[4].amount.toString());

});
});
});

describe('invoicehelper', function() {
describe('#getActivePayment', function() {
it('should return payment with latest creation date', function() {
Expand Down
32 changes: 25 additions & 7 deletions views/invoice.ejs
Original file line number Diff line number Diff line change
Expand Up @@ -72,9 +72,27 @@
</div>
<% }); %>

<% discounts.forEach(function(item){ %>
<div class="row line-item thin-underline discount">
<% if (item.amount && !item.percentage) { %>
<div class="col-xs-12 col-sm-9"><%= item.description %></div>
<div class="col-xs-4 visible-xs mobile-lbl-text">Discount</div>
<div class="col-xs-8 col-sm-3 right">(<%= item.amount %> <%= currency.toUpperCase() %>)</div>
<% } else if (item.amount && item.percentage) { %>
<div class="col-xs-12 col-sm-6"><%= item.description %></div>
<div class="col-xs-4 visible-xs mobile-lbl-text">Percentage</div>
<div class="col-xs-8 col-sm-3 right"><%= item.percentage %> % off</div>
<div class="col-xs-4 visible-xs mobile-lbl-text">Discount</div>
<div class="col-xs-8 col-sm-3 right">
(<%= item.amount %> <%= currency.toUpperCase() %>)
</div>
<% } %>
</div>
<% }); %>

<div class="row">
<div class="col-xs-12 col-sm-4">
<form id="pay-button" style="<% if (remaining_balance <= 0 || is_paid || is_void || is_expired) { %>display: none;<% } %>" method="POST" action="/pay/<%= _id %>">
<form id="pay-button" style="<% if (balance_due <= 0 || is_paid || is_void || is_expired) { %>display: none;<% } %>" method="POST" action="/pay/<%= _id %>">
<button type="submit" class="btn btn-lg btn-default btn-wide">
Pay Now
</button>
Expand All @@ -83,15 +101,15 @@
<div class="col-xs-12 col-sm-8 spacer-5 right dark-gray">
<span class="visible-xs float-left medium-text">Total</span><!-- phone -->
<span class="hidden-xs float-left">Invoice Total</span>
<span class="visible-xs medium-text"><%= balance_due %> <%= currency.toUpperCase() %></span>
<span class="hidden-xs"><%= balance_due %> <%= currency.toUpperCase() %></span>
<span class="visible-xs medium-text"><%= invoice_total %> <%= currency.toUpperCase() %></span>
<span class="hidden-xs"><%= invoice_total %> <%= currency.toUpperCase() %></span>
</div>
</div>

<div class="row visible-xs medium-text">
<div class="col-xs-12 right gray">
<span class="float-left dark-gray">Paid</span><!-- phone -->
<span class="amount-paid-text green"><%= total_paid %></span>
<span class="amount-paid-text green"><%= amount_paid %></span>
<span class="green"> <%= currency.toUpperCase() %></span>
</div>
</div>
Expand All @@ -100,15 +118,15 @@
<div class="col-sm-4"></div>
<div class="col-sm-8 right gray">
<span class="float-left dark-gray">Amount Paid</span>
<span class="amount-paid-text green"><%= total_paid %></span>
<span class="amount-paid-text green"><%= amount_paid %></span>
<span class="green"> <%= currency.toUpperCase() %></span>
</div>
</div>

<div class="row visible-xs medium-text">
<div class="col-xs-12 right gray">
<span class="float-left dark-gray">Due</span><!-- phone -->
<span class="amount-due-text red"><%= remaining_balance %></span>
<span class="amount-due-text red"><%= balance_due %></span>
<span class="red"> <%= currency.toUpperCase() %></span>
</div>
</div>
Expand All @@ -117,7 +135,7 @@
<div class="col-sm-4"></div>
<div class="col-sm-8 right gray">
<span class="float-left dark-gray">Balance Due</span>
<span class="amount-due-text red"><%= remaining_balance %></span>
<span class="amount-due-text red"><%= balance_due %></span>
<span class="red"> <%= currency.toUpperCase() %></span>
</div>
</div>
Expand Down

0 comments on commit 0e610bc

Please sign in to comment.