Skip to content

Commit

Permalink
Initial commit
Browse files Browse the repository at this point in the history
  • Loading branch information
davidwood committed Oct 6, 2016
0 parents commit aed0390
Show file tree
Hide file tree
Showing 31 changed files with 1,015 additions and 0 deletions.
2 changes: 2 additions & 0 deletions .eslintignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
coverage/**
node_modules/**
7 changes: 7 additions & 0 deletions .eslintrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"extends": "airbnb/base",
"rules": {
"strict": 0,
"prefer-rest-params": 0
}
}
9 changes: 9 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# npm
node_modules/
npm-debug.log

# Mocha
test/mocha.opts

# Coverage
coverage/
1 change: 1 addition & 0 deletions .nvmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
4.6.0
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Changelog

## 1.0.0 (2016-10-06)

* Initial release
21 changes: 21 additions & 0 deletions LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
MIT License

Copyright (c) 2016 SpaceCraft, Inc. (https://gospacecraft.com)

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
28 changes: 28 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# days360

Calculate the difference between two dates based on the [360 day financial year](https://en.wikipedia.org/wiki/360-day_calendar), using the US/NASD method (30US/360) or European method (30E/360).

Excel's implementation of the US/NASD method has an [incorrect implementation](https://wiki.openoffice.org/wiki/Documentation/How_Tos/Calc:_Date_&_Time_functions#Financial_date_systems). This library provides an Excel compatible US/NASD method.

## Usage

```
const days360 = require('days360');
days360(new Date('2016-01-01'), new Date('2016-12-31')); // returns 360
```

### Arguments

* `startDate`: Start date, as a date or milliseconds since Unix epoch
* `endDate`: End date, as a Date or milliseconds since Unix epoch
* `method`: An optional argument to specify the calculation
* `days360.US` (`0`): calculate using the US/NASD method, with [Excel compatibility](https://wiki.openoffice.org/wiki/Documentation/How_Tos/Calc:_Date_&_Time_functions#Financial_date_systems)
* `days360.EU` (`1`): calculate using the European method
* `days360.US_NASD` (`2`): calculate using the US/NASD method

## Testing

Tests require [Mocha](http://visionmedia.github.com/mocha) and can be run with `npm test`. You can specify Mocha options, such as the reporter, by adding a [mocha.opts](http://visionmedia.github.com/mocha/#mocha.opts) file to the `test` directory.

Running `npm test --coverage` will generate code coverage reports with [Istanbul](https://github.com/gotwarlost/istanbul). The code coverage reports will be located in the `coverage` directory, which is excluded from the repository.
10 changes: 10 additions & 0 deletions circle.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
machine:
node:
version: 4.6.0
general:
artifacts:
- "coverage"
test:
override:
- nvm use 4.0.0 && npm test --coverage
- nvm use 4.6.0 && npm test --coverage
81 changes: 81 additions & 0 deletions index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
'use strict';

/**
* Imports
*/
const isNumber = require('./lib/is-number');
const isDate = require('./lib/is-date');
const isLastDay = require('./lib/is-last-day-feb');

/**
* Constants
*/
const US = 0;
const EU = 1;
const US_NASD = 2;

/**
* Calculates the nubmer of days between two dates based on a 360-day year, using the US/NASD
* method (30US/360) or European method (30E/360).
*
* Reference:
* https://en.wikipedia.org/wiki/360-day_calendar
* https://wiki.openoffice.org/wiki/Documentation/How_Tos/Calc:_Date_&_Time_functions#Financial_date_systems
*
* @param {Date|Number} startDate Start date, as a Date or milliseconds since Unix epoch
* @parma {Date|Number} endDate End date, as a Date or milliseconds since Unix epoch
* @param {Boolean|Number} [method] 0 to calculate using the US/NASD method, with Excel
* compatibility (default)
* 1 to calculate using the European method
* 2 to calculate using the US/NASD method
* @returns {Number} number of days
*/
module.exports = (startDate, endDate, method) => {
const start = isNumber(startDate) && startDate >= 0 ? new Date(startDate) : startDate;
if (!isDate(start)) {
return undefined;
}
const end = isNumber(endDate) && endDate >= 0 ? new Date(endDate) : endDate;
if (!isDate(end)) {
return undefined;
}
let startDay = start.getUTCDate();
let endDay = end.getUTCDate();
if (method === EU) {
// If either date A or B falls on the 31st of the month, that date will be changed to the 30th.
startDay = Math.min(startDay, 30);
endDay = Math.min(endDay, 30);
} else {
/**
* If both date A and B fall on the last day of February, then date B will be changed to
* the 30th (unless preserving Excel compatibility)
*/
const isStartLast = isLastDay(start);
if (method === US_NASD && isStartLast && isLastDay(end)) {
endDay = 30;
}
/**
* If date A falls on the 31st of a month or last day of February, then date A will be changed
* to the 30th.
*/
if (isStartLast || startDay === 31) {
startDay = 30;
}
/**
* If date A falls on the 30th of a month after applying (2) above and date B falls on the
* 31st of a month, then date B will be changed to the 30th.
*/
if (startDay === 30 && endDay === 31) {
endDay = 30;
}
}
return ((end.getUTCFullYear() - start.getUTCFullYear()) * 360)
+ ((end.getUTCMonth() - start.getUTCMonth()) * 30)
+ (endDay - startDay);
};

Object.defineProperties(module.exports, {
US_NASD: { value: US_NASD, writable: false, enumerable: true },
US: { value: US, writable: false, enumerable: true },
EU: { value: EU, writable: false, enumerable: true },
});
30 changes: 30 additions & 0 deletions lib/is-date.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
'use strict';

/**
* Imports
*/
const isNumber = require('./is-number');

/**
* Constants
*/
const TYPE = '[object Date]';

/**
* toString reference
*/
const toString = Object.prototype.toString;

/**
* Check if a value is a valid date
*
* @param {Date} value Value to validate
* @returns {Boolean} true if a valid date
*/
module.exports = (value) => {
if (toString.call(value) === TYPE) {
return isNumber(value.valueOf());
}
return false;
};

23 changes: 23 additions & 0 deletions lib/is-last-day-feb.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
'use strict';

/**
* Format a date as yyyymd
*
* @oaram {Date} value Date to format
* @returns {String} formatted date
*/
function formatDate(value) {
return `${value.getUTCFullYear()}${value.getUTCMonth()}${value.getUTCDate()}`;
}
/**
* Check if a date is the last day of February
*
* @param {Date} value Date to validate
* @returns {Boolean} true if the last day of February
*/
module.exports = (value) => {
if (value.getUTCMonth() === 1) {
return formatDate(new Date(value.getUTCFullYear(), 2, 0)) === formatDate(value);
}
return false;
};
24 changes: 24 additions & 0 deletions lib/is-number.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
'use strict';

/**
* Constants
*/
const TYPE = '[object Number]';

/**
* toString reference
*/
const toString = Object.prototype.toString;

/**
* Check if a value is a number and not NaN
*
* @param {Number} value Value to validate
* @returns {Boolean} true if a valid number
*/
module.exports = (value) => {
if (toString.call(value) === TYPE && value === +value) {
return true;
}
return false;
};
40 changes: 40 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
{
"name": "days360",
"version": "1.0.0",
"description": "Calculate the difference between two dates based on the 360 day financial year",
"keywords": [
"days360",
"360 day year",
"30US/360",
"30E/360"
],
"author": "SpaceCraft <[email protected]>",
"contributors": [
"David Wood <[email protected]>"
],
"repository": {
"type": "git",
"url": "git://github.com/spacecraftinc/days360.git"
},
"bugs": "https://github.com/spacecraftinc/days360/issues",
"engines": {
"node": ">= 4.0.0"
},
"main": "./index.js",
"dependencies": {},
"devDependencies": {
"csv": "^1.1.0",
"eslint": "^3.6.1",
"eslint-config-airbnb": "^12.0.0",
"eslint-plugin-import": "^1.16.0",
"eslint-plugin-jsx-a11y": "^2.2.2",
"eslint-plugin-react": "^6.3.0",
"istanbul": "^0.4.5",
"mocha": "^3.1.0"
},
"license": "MIT",
"scripts": {
"test": "NODE_ENV=test istanbul test _mocha -- --check-leaks --recursive --reporter spec",
"lint": "eslint ."
}
}
5 changes: 5 additions & 0 deletions test/.eslintrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
env:
mocha: true
rules:
import/no-extraneous-dependencies: [error, { devDependencies: true }]
no-plusplus: 0
83 changes: 83 additions & 0 deletions test/compatibility.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
'use strict';

/**
* Imports
*/
const fs = require('fs');
const path = require('path');
const assert = require('assert');
const csv = require('csv');
const days360 = require('../');

/**
* Constants
*/
const DATA_DIR = path.join(__dirname, 'data');
const READ_OPTIONS = { encoding: 'utf8' };
const TOTAL = 36 * 36;
const DATASETS = [
{ name: 'Google Sheets (EU)', filename: 'google-sheets-eu.csv', method: days360.EU },
{ name: 'Google Sheets (US/NASD)', filename: 'google-sheets-us.csv', method: days360.US },
{ name: 'LibreOffice Calc (EU)', filename: 'libreoffice-calc-eu.csv', method: days360.EU },
{ name: 'LibreOffice Calc (US/NASD)', filename: 'libreoffice-calc-us.csv', method: days360.US },
{ name: 'Microsoft Excel (EU)', filename: 'microsoft-excel-eu.csv', method: days360.EU },
{ name: 'Microsoft Excel (US/NASD)', filename: 'microsoft-excel-us.csv', method: days360.US },
{ name: 'US/NASD (US/NASD)', filename: 'nasd.csv', method: days360.US_NASD },
{ name: 'OpenOffice Calc (EU)', filename: 'openoffice-calc-eu.csv', method: days360.EU },
{ name: 'OpenOffice Calc (US/NASD)', filename: 'openoffice-calc-us.csv', method: days360.US },
];

/**
* Read and parse a dataset CSV
*
* @param {String} filename Filename
* @param {Function} cb Callback function
*/
function readDataset(filename, cb) {
fs.readFile(path.join(DATA_DIR, filename), READ_OPTIONS, (readError, data) => {
if (readError) {
cb(readError);
return;
}
csv.parse(data, (parseError, rows) => {
if (Array.isArray(rows) && rows.length) {
cb(null, rows);
return;
}
cb(parseError || new Error('Failed to read dataset'));
});
});
}

describe('Compatibility', () => {
DATASETS.forEach((dataset) => {
it(`should generate the same results as ${dataset.name}`, (done) => {
readDataset(dataset.filename, (err, rows) => {
assert.strictEqual(err, null);
assert.strictEqual(Array.isArray(rows), true);
assert.strictEqual(rows.length > 0, true);
const columnDates = rows[0].map(value => (value ? new Date(value) : value));
const rowDates = rows.map((values) => {
const value = values[0];
return value ? new Date(value) : value;
});
let total = 0;
columnDates.forEach((columnDate, columnIndex) => {
if (columnIndex > 0) {
rowDates.forEach((rowDate, rowIndex) => {
if (rowIndex > 0) {
const result = days360(rowDate, columnDate, dataset.method);
const rawValue = rows[rowIndex][columnIndex];
const expected = rawValue ? parseInt(rawValue, 10) : 0;
assert.strictEqual(result, expected, `Row ${rowIndex} (${rowDate.toISOString()}), Column ${columnIndex} (${columnDate.toISOString()}) does not match`);
total += 1;
}
});
}
});
assert.strictEqual(total, TOTAL);
done();
});
});
});
});
13 changes: 13 additions & 0 deletions test/data/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# Reference Data

The directory contains reference data to validate `days360` calculation compatibility with various office suites. The data consists of a 36x36 matrix, generated from the test vector of dates from [https://bz.apache.org/ooo/show_bug.cgi?id=84934](https://bz.apache.org/ooo/show_bug.cgi?id=84934). This directory also contains the US/NASD dataset from the [days360](https://github.com/tamaloa/days360) gem.

The following office suites were used to generate the data:

* Google Sheets
* Apple Numbers 4.0
* Microsoft Excel 15.18
* LibreOffice Calc 5.5.2
* OpenOffice Calc 4.1.2

Microsoft Excel and Apple Numbers both offer desktop and web-based versions. It was verified that both versions generate the same data.
Loading

0 comments on commit aed0390

Please sign in to comment.