Pārlūkot izejas kodu

Feature: First version of the REST API done

febbweiss 10 gadi atpakaļ
revīzija
e6fc0be0ed

+ 3 - 0
.bowerrc

@@ -0,0 +1,3 @@
+{
+	"directory": "public/libs"
+}

+ 5 - 0
.gitignore

@@ -0,0 +1,5 @@
+node_modules/
+logs/*
+data/*
+mongod*
+public/libs/*

+ 1 - 0
README.md

@@ -0,0 +1 @@
+Work in progress

+ 3 - 0
app.js

@@ -0,0 +1,3 @@
+var server  = require('./server.js');
+
+server.listen();

+ 227 - 0
app/controllers/accounts.js

@@ -0,0 +1,227 @@
+var mongoose        = require('mongoose'),
+    ObjectId        = mongoose.Schema.Types.ObjectId,
+    Account         = mongoose.model('Account'),
+    Entry           = mongoose.model('Entry'),
+    Handler         = require('../helpers/handler'),
+    categories      = require('../models/categories');
+    
+var load_categories = function(language, callback) {
+    var catz = {};
+    categories.forEach(function(category) {
+        var selected_category = category[language],
+            key = category.en.categorygroup.toLowerCase().replace(/ /g, '_'),
+            cat = catz[key] || {key: key, label: selected_category.categorygroup, sub_categories: []};
+            
+            if( !selected_category.category ) {
+                cat.sub_categories.push({label: selected_category.subcategory, key: selected_category.subcategory.toLowerCase().replace(/ /g, '_')});
+            }
+            
+            catz[key] = cat;
+    });
+    
+    callback(null, catz);
+};
+
+var check_account = function(request, response, callback) {
+    Account.findById(request.params.account_id, function(errors, account) {
+        if( errors ) {
+            return Handler.errorHandler(errors, 404, response);
+        }
+        
+        if( !account ) {
+            return response.status(404).json({message: 'Unknown account'});
+        }
+        
+        if( !account.user_id.equals(request.user.id) ) {
+            return response.status(401).end();
+        }
+        
+        return callback(null, account);
+    });
+};
+
+var delete_account = function(account, callback) {
+    Entry.find({account_id: account.id}).remove(function(errors) {
+        if( errors ) {
+            if( callback ) {
+                return callback(errors);
+            }
+            
+            return;
+        }
+        
+        account.remove(function(errors) {
+            if( errors ) {
+                if( callback ) {
+                return callback(errors);
+            }
+                return;
+            }
+            
+            if( callback ) {
+                return callback();
+            }
+        });
+    });
+};
+
+module.exports = {
+    create : function(request, response) {
+        var user = request.user,
+            account = new Account({
+                name: request.body.name,
+                reference: request.body.reference,
+                user_id: user.id
+            });
+        
+        load_categories(user.language, function(error, result) {
+            for( var key in result ) {
+               account.categories.push( result[key] );
+            }
+            
+            account.save(function(errors) {
+                if( errors ) {
+                    return Handler.errorHandler(errors, 400, response);
+                }
+                
+                return response.status(201).json(account);
+            });
+        });
+    },
+    
+    modify : function(request, response) {
+        return check_account(request, response, function(error, account) {
+            account.name = request.body.name;
+            account.reference = request.body.reference;
+            
+            account.save(function(errors) {
+               if( errors ) {
+                   return Handler.errorHandler(errors, 400, response);
+               }
+               
+               return response.json(account);
+            });
+        });
+    },
+    
+    delete : function(request, response) {
+        return check_account(request, response, function(error, account) {
+            return delete_account(account, function(errors) {
+                if( errors ) {
+                    return Handler.errorHandler(errors, 500, response);
+                }
+                
+                return response.status(204).end();
+                
+            });
+        });
+    },
+    
+    delete_account : delete_account,
+    
+    get : function(request, response) {
+       return check_account(request, response, function(error, account) {
+            return response.json(account);
+        });
+    },
+    
+    add_entry : function(request, response) {
+        return check_account(request, response, function(error, account) {
+            var data = request.body,
+                entry = new Entry({
+                account_id: account.id,
+                category: data.category ? new ObjectId(data.category) : undefined,
+                sub_category: data.sub_category ? new ObjectId(data.sub_category) : undefined,
+                label: data.label,
+                amount: data.amount,
+                date: new Date(data.date),
+                type: data.amount >= 0 ? 'DEPOSIT' : 'BILL'
+            });
+            
+            entry.save(function(errors) {
+                if( errors ) {
+                    return Handler.errorHandler(errors, 400, response);
+                }
+                
+                response.status(201).json(entry);
+            })
+        });
+    },
+    
+    modify_entry : function(request, response) {
+        return check_account(request, response, function(error, account) {
+            Entry.findById(request.params.entry_id, function(errors, entry) {
+                if( errors ) {
+                    return Handler.errorHandler(errors, 404, response);
+                }
+                
+                if( !entry ) {
+                    return response.status(404).end();
+                }
+                
+                if( !entry.account_id.equals( account.id ) ) {
+                    return response.status(401).end();
+                }
+                
+                var data = request.body;
+                
+                entry.category = data.category ? new ObjectId(data.category) : undefined;
+                entry.sub_category = data.sub_category ? new ObjectId(data.sub_category) : undefined;
+                entry.label = data.label;
+                entry.amount = data.amount;
+                entry.date = new Date(data.date);
+                entry.type = data.amount >= 0 ? 'DEPOSIT' : 'BILL';
+                
+                entry.save(function(errors) {
+                    if( errors ) {
+                        return Handler.errorHandler(errors, 400, response );
+                    }
+                    
+                    return response.json(entry);
+                });
+            });    
+        });
+    },
+    
+    delete_entry : function(request, response) {
+      return check_account(request, response, function(errors, account) {
+           Entry.findById(request.params.entry_id, function(errors, entry) {
+                if( errors ) {
+                    return Handler.errorHandler(errors, 404, response);
+                }
+                
+                if( !entry ) {
+                    return response.status(404).end();
+                }
+                
+                if( !entry.account_id.equals( account.id ) ) {
+                    return response.status(401).end();
+                }
+                
+                entry.remove(function(errors) {
+                    if( errors ) {
+                        return Handler.errorHandler(errors, 500, response);
+                    }
+                    
+                    return response.status(204).end();
+                });
+           });
+      });
+    },
+    
+    list_entries : function(request, response) {
+        return check_account(request, response, function(errors, account) {
+             Entry.find({
+                    account_id: account.id
+                })
+                .sort('-date')
+                .exec(function(errors, entries) {
+                    if( errors ) {
+                        return Handler.errorHandler(errors, 500, response);
+                    }
+                    
+                    return response.json(entries);
+                });
+        });
+    }
+}

+ 79 - 0
app/controllers/users.js

@@ -0,0 +1,79 @@
+var mongoose        = require('mongoose'),
+    User            = mongoose.model('User'),
+    jwt             = require('jsonwebtoken'),
+    security        = require('../../config/security'),
+    Handler         = require('../helpers/handler'),
+    EventEmitter    = require('../events/listeners');
+
+module.exports = {
+    login :  function(request, response) {
+        var user = request.user;
+        if( !user ) {
+            return response.status(401).json({message: 'Authentication failed'});
+        }
+        
+        return response.json(
+            {
+                username: user.username, 
+                token: jwt.sign(
+            {
+                user_id: user.id
+            }, security.jwt.secretOrKey)
+        
+        });
+    },
+    
+    logout : function(request, response) {
+        return response.status(200).end();
+    },
+    
+    subscribe : function(request, response) {
+        var registered = new User({username: request.body.username, password: request.body.password});
+        registered.validate(function(errors) {
+            if( errors ) {
+                return Handler.errorHandler(errors, 400, response);
+            }
+            
+            User.findOne({username: request.body.username}, function(error, user) {
+                if( error ) {
+                    return response.send(error);
+                }
+                if( !user ) {
+                    registered.save(function(errors) {
+                        if( errors ) {
+                            return Handler.errorHandler(errors, 500, response);
+                        }
+                        
+                        return response.status(201).json({
+                                    username: registered.username, 
+                                    token: jwt.sign(
+                                        {
+                                            user_id: registered.id
+                                        }, security.jwt.secretOrKey)
+                                    });
+                    });
+                } else {
+                    return response.status(409).json({message: 'Account already exists'});
+                }
+            });
+        });
+    },
+    
+    unsubscribe : function(request, response) {
+        var user = request.user;
+         
+        if( !user ) {
+            return response.status(401).json({message: 'Authentication failed'});
+        }
+        
+        User.remove({username: user.username}, function(error) {
+            if( error ) {
+                return response.status(500).send(error);
+            }
+            
+            EventEmitter.eventEmitter.emit(EventEmitter.events.ACCOUNTS_DELETE_BY_USER_ID_EVT, user.id);
+            
+            return response.status(204).end();
+        });
+    }
+}

+ 35 - 0
app/events/listeners.js

@@ -0,0 +1,35 @@
+var mongoose        = require('mongoose'),
+    Account         = mongoose.model('Account'),
+    EventEmitter    = require('events').EventEmitter,
+    AccountController   = require('../controllers/accounts');
+
+
+var eventEmitter = new EventEmitter(),
+    ACCOUNTS_DELETE_BY_USER_ID_EVT = 'accounts.delete.by.user.id',
+    ENTRIES_DELETE_BY_ACCOUNT_EVT = 'entries.delete.by.account';
+
+eventEmitter.on(ACCOUNTS_DELETE_BY_USER_ID_EVT, function(user_id) {
+    Account.find({user_id: user_id}, function(errors, accounts) {
+        if( errors ) {
+            console.error('An error occurs during accounts deletion for user ' + user_id, errors);
+            return;
+        }
+        
+        if( !accounts ) {
+            console.log('No accounts');
+            return;
+        }
+       for( var index in accounts ) {
+           eventEmitter.emit(ENTRIES_DELETE_BY_ACCOUNT_EVT, accounts[index]);
+       } 
+    });
+});
+eventEmitter.on(ENTRIES_DELETE_BY_ACCOUNT_EVT, AccountController.delete_account);
+
+module.exports = {
+    events : {
+        ACCOUNTS_DELETE_BY_USER_ID_EVT: ACCOUNTS_DELETE_BY_USER_ID_EVT,
+        ENTRIES_DELETE_BY_ACCOUNT_EVT: ENTRIES_DELETE_BY_ACCOUNT_EVT
+    },
+    eventEmitter: eventEmitter
+}

+ 18 - 0
app/helpers/handler.js

@@ -0,0 +1,18 @@
+module.exports = {
+    errorHandler : function(errors, status, response) {
+        var message = []
+        if( errors.errors) {
+            Object.keys(errors.errors).forEach(function (field) {
+                var error = errors.errors[field];
+                message.push({
+                    field: error.path,
+                    rule: error.kind,
+                    message: error.message
+                });
+            });
+            return response.status(status).json(message);
+        } else {
+            return response.status(status).end();
+        }
+    }
+}

+ 25 - 0
app/models/account.js

@@ -0,0 +1,25 @@
+var mongoose    = require('mongoose'),
+    Schema      = mongoose.Schema,
+    ObjectId    = Schema.Types.ObjectId;
+    
+var CategorySchema = new Schema({
+    label: {type: String, required:true},
+    key: {type: String, required: true, index: {unique: false} },
+    sub_categories: [{
+        label: {type: String, required:true},
+        key: {type: String, required: true, index: {unique: false} },
+    }]
+});
+
+var AccountSchema = new Schema({
+   name: {type: String, required: true},
+   reference: {type: String, required: false},
+   categories: {type: [CategorySchema], required: true},
+   user_id: {type: ObjectId, ref: 'User', required: true},
+   created_at: {type: Date, default: Date.now} 
+});
+
+var Account = mongoose.model('Account', AccountSchema);
+var Category = mongoose.model('Category', CategorySchema);
+
+module.exports = Account;

+ 1514 - 0
app/models/categories.js

@@ -0,0 +1,1514 @@
+module.exports = [
+    {
+        "en": {
+            "category": "Alimony",
+            "categorygroup": "Alimony Payments",
+            "subcategory": undefined
+        },
+        "fr": {
+            "category": "Pension",
+            "categorygroup": "Les paiements de pension alimentaire",
+            "subcategory": undefined
+        }
+    },
+    {
+        "en": {
+            "category": "Automobile",
+            "categorygroup": "Automobile Expenses",
+            "subcategory": undefined
+        },
+        "fr": {
+            "category": "Automobile",
+            "categorygroup": "Frais d'automobile",
+            "subcategory": undefined
+        }
+    },
+    {
+        "en": {
+            "category": undefined,
+            "categorygroup": "Automobile Expenses",
+            "subcategory": "Car Payment"
+        },
+        "fr": {
+            "category": undefined,
+            "categorygroup": "Frais d'automobile",
+            "subcategory": "Paiement de voiture"
+        }
+    },
+    {
+        "en": {
+            "category": undefined,
+            "categorygroup": "Automobile Expenses",
+            "subcategory": "Gasoline"
+        },
+        "fr": {
+            "category": undefined,
+            "categorygroup": "Frais d'automobile",
+            "subcategory": "Essence"
+        }
+    },
+    {
+        "en": {
+            "category": undefined,
+            "categorygroup": "Automobile Expenses",
+            "subcategory": "Maintenance"
+        },
+        "fr": {
+            "category": undefined,
+            "categorygroup": "Frais d'automobile",
+            "subcategory": "Entretien"
+        }
+    },
+    {
+        "en": {
+            "category": "Bank Charges",
+            "categorygroup": "Bank Charges",
+            "subcategory": undefined
+        },
+        "fr": {
+            "category": "Frais bancaires",
+            "categorygroup": "Frais bancaires",
+            "subcategory": undefined
+        }
+    },
+    {
+        "en": {
+            "category": undefined,
+            "categorygroup": "Bank Charges",
+            "subcategory": "Interest Paid"
+        },
+        "fr": {
+            "category": undefined,
+            "categorygroup": "Frais bancaires",
+            "subcategory": "Intérêts Payés"
+        }
+    },
+    {
+        "en": {
+            "category": undefined,
+            "categorygroup": "Bank Charges",
+            "subcategory": "Service Charge"
+        },
+        "fr": {
+            "category": undefined,
+            "categorygroup": "Frais bancaires",
+            "subcategory": "Frais de service"
+        }
+    },
+    {
+        "en": {
+            "category": "Bills",
+            "categorygroup": "Other Bills",
+            "subcategory": undefined
+        },
+        "fr": {
+            "category": "Factures",
+            "categorygroup": "Autres factures",
+            "subcategory": undefined
+        }
+    },
+    {
+        "en": {
+            "category": undefined,
+            "categorygroup": "Cable Bill",
+            "subcategory": "Cable/Satellite Television"
+        },
+        "fr": {
+            "category": undefined,
+            "categorygroup": "Bill câble",
+            "subcategory": "Câble / Télévision par satellite"
+        }
+    },
+    {
+        "en": {
+            "category": undefined,
+            "categorygroup": "Telephone Bill",
+            "subcategory": "Cell Phone"
+        },
+        "fr": {
+            "category": undefined,
+            "categorygroup": "Facture De Téléphone",
+            "subcategory": "Téléphone cellulaire"
+        }
+    },
+    {
+        "en": {
+            "category": undefined,
+            "categorygroup": "Telephone Bill",
+            "subcategory": "Cellular"
+        },
+        "fr": {
+            "category": undefined,
+            "categorygroup": "Facture De Téléphone",
+            "subcategory": "Cellulaire"
+        }
+    },
+    {
+        "en": {
+            "category": undefined,
+            "categorygroup": "Gas & Electric Bill",
+            "subcategory": "Electricity"
+        },
+        "fr": {
+            "category": undefined,
+            "categorygroup": "Gas & Electric Bill",
+            "subcategory": "Électricité"
+        }
+    },
+    {
+        "en": {
+            "category": undefined,
+            "categorygroup": "Garbage/Recycle Bill",
+            "subcategory": "Garbage & Recycle"
+        },
+        "fr": {
+            "category": undefined,
+            "categorygroup": "Déchets / Recyclage projet de loi",
+            "subcategory": "Déchets et recyclage"
+        }
+    },
+    {
+        "en": {
+            "category": undefined,
+            "categorygroup": "Other Bills",
+            "subcategory": "Health Club"
+        },
+        "fr": {
+            "category": undefined,
+            "categorygroup": "Autres factures",
+            "subcategory": "Club de santé"
+        }
+    },
+    {
+        "en": {
+            "category": undefined,
+            "categorygroup": "Other Bills",
+            "subcategory": "Homeowner's Dues"
+        },
+        "fr": {
+            "category": undefined,
+            "categorygroup": "Autres factures",
+            "subcategory": "Cotisations d'propriétaires"
+        }
+    },
+    {
+        "en": {
+            "category": undefined,
+            "categorygroup": "Other Bills",
+            "subcategory": "Membership Fees"
+        },
+        "fr": {
+            "category": undefined,
+            "categorygroup": "Autres factures",
+            "subcategory": "Frais d'adhésion"
+        }
+    },
+    {
+        "en": {
+            "category": undefined,
+            "categorygroup": "Other Bills",
+            "subcategory": "Mortgage Payment"
+        },
+        "fr": {
+            "category": undefined,
+            "categorygroup": "Autres factures",
+            "subcategory": "de paiement d'hypothèque"
+        }
+    },
+    {
+        "en": {
+            "category": undefined,
+            "categorygroup": "Gas & Electric Bill",
+            "subcategory": "Natural Gas/Oil"
+        },
+        "fr": {
+            "category": undefined,
+            "categorygroup": "Gas & Electric Bill",
+            "subcategory": "Gaz naturel / huile"
+        }
+    },
+    {
+        "en": {
+            "category": undefined,
+            "categorygroup": "Other Bills",
+            "subcategory": "Newspaper"
+        },
+        "fr": {
+            "category": undefined,
+            "categorygroup": "Autres factures",
+            "subcategory": "Journal"
+        }
+    },
+    {
+        "en": {
+            "category": undefined,
+            "categorygroup": "Other Bills",
+            "subcategory": "Online/Internet Service"
+        },
+        "fr": {
+            "category": undefined,
+            "categorygroup": "Autres factures",
+            "subcategory": "Service en ligne / Internet"
+        }
+    },
+    {
+        "en": {
+            "category": undefined,
+            "categorygroup": "Other Bills",
+            "subcategory": "Other Loan Payment"
+        },
+        "fr": {
+            "category": undefined,
+            "categorygroup": "Autres factures",
+            "subcategory": "Autres conditions de prêt"
+        }
+    },
+    {
+        "en": {
+            "category": undefined,
+            "categorygroup": "Rent Bill",
+            "subcategory": "Rent"
+        },
+        "fr": {
+            "category": undefined,
+            "categorygroup": "Louer le projet de loi",
+            "subcategory": "Location"
+        }
+    },
+    {
+        "en": {
+            "category": undefined,
+            "categorygroup": "Other Bills",
+            "subcategory": "Student Loan Payment"
+        },
+        "fr": {
+            "category": undefined,
+            "categorygroup": "Autres factures",
+            "subcategory": "Paiement de prêts aux étudiants"
+        }
+    },
+    {
+        "en": {
+            "category": undefined,
+            "categorygroup": "Telephone Bill",
+            "subcategory": "Telephone"
+        },
+        "fr": {
+            "category": undefined,
+            "categorygroup": "Facture De Téléphone",
+            "subcategory": "Téléphone"
+        }
+    },
+    {
+        "en": {
+            "category": undefined,
+            "categorygroup": "Water & Sewer Bill",
+            "subcategory": "Water & Sewer"
+        },
+        "fr": {
+            "category": undefined,
+            "categorygroup": "Bill eau et d'égout",
+            "subcategory": "Eau et d'égout"
+        }
+    },
+    {
+        "en": {
+            "category": "Cash Withdrawal",
+            "categorygroup": "Cash Withdrawal",
+            "subcategory": undefined
+        },
+        "fr": {
+            "category": "Retrait D'Argent",
+            "categorygroup": "Retrait D'Argent",
+            "subcategory": undefined
+        }
+    },
+    {
+        "en": {
+            "category": "Charitable Donations",
+            "categorygroup": "Charitable Donations",
+            "subcategory": undefined
+        },
+        "fr": {
+            "category": "Dons de bienfaisance",
+            "categorygroup": "Dons de bienfaisance",
+            "subcategory": undefined
+        }
+    },
+    {
+        "en": {
+            "category": "Childcare",
+            "categorygroup": "Childcare Expenses",
+            "subcategory": undefined
+        },
+        "fr": {
+            "category": "Garde d'enfants",
+            "categorygroup": "Frais de garde d'enfants",
+            "subcategory": undefined
+        }
+    },
+    {
+        "en": {
+            "category": "Children/Toys",
+            "categorygroup": "Childcare Expenses",
+            "subcategory": undefined
+        },
+        "fr": {
+            "category": "Enfants / Jouets",
+            "categorygroup": "Frais de garde d'enfants",
+            "subcategory": undefined
+        }
+    },
+    {
+        "en": {
+            "category": undefined,
+            "categorygroup": "Childcare Expenses",
+            "subcategory": "Child Support"
+        },
+        "fr": {
+            "category": undefined,
+            "categorygroup": "Frais de garde d'enfants",
+            "subcategory": "Pensions alimentaires pour enfants"
+        }
+    },
+    {
+        "en": {
+            "category": undefined,
+            "categorygroup": "Childcare Expenses",
+            "subcategory": "Daycare"
+        },
+        "fr": {
+            "category": undefined,
+            "categorygroup": "Frais de garde d'enfants",
+            "subcategory": "Garderie"
+        }
+    },
+    {
+        "en": {
+            "category": "Clothing",
+            "categorygroup": "Clothing Expenses",
+            "subcategory": undefined
+        },
+        "fr": {
+            "category": "Vêtements",
+            "categorygroup": "les frais d'habillement",
+            "subcategory": undefined
+        }
+    },
+    {
+        "en": {
+            "category": "Credit Card Payments/Transfers",
+            "categorygroup": "Other Expense",
+            "subcategory": undefined
+        },
+        "fr": {
+            "category": "Carte de crédit Paiements / Transferts",
+            "categorygroup": "Autres charges",
+            "subcategory": undefined
+        }
+    },
+    {
+        "en": {
+            "category": "Dining Out",
+            "categorygroup": "Dining Out",
+            "subcategory": undefined
+        },
+        "fr": {
+            "category": "Dîner À L'Extérieur",
+            "categorygroup": "Dîner À L'Extérieur",
+            "subcategory": undefined
+        }
+    },
+    {
+        "en": {
+            "category": "Education",
+            "categorygroup": "Education",
+            "subcategory": undefined
+        },
+        "fr": {
+            "category": "Éducation",
+            "categorygroup": "Éducation",
+            "subcategory": undefined
+        }
+    },
+    {
+        "en": {
+            "category": undefined,
+            "categorygroup": "Education",
+            "subcategory": "Books"
+        },
+        "fr": {
+            "category": undefined,
+            "categorygroup": "Éducation",
+            "subcategory": "Livres"
+        }
+    },
+    {
+        "en": {
+            "category": undefined,
+            "categorygroup": "Education",
+            "subcategory": "Fees"
+        },
+        "fr": {
+            "category": undefined,
+            "categorygroup": "Éducation",
+            "subcategory": "Honoraires"
+        }
+    },
+    {
+        "en": {
+            "category": undefined,
+            "categorygroup": "Education",
+            "subcategory": "Tuition"
+        },
+        "fr": {
+            "category": undefined,
+            "categorygroup": "Éducation",
+            "subcategory": "Cours"
+        }
+    },
+    {
+        "en": {
+            "category": "Entertainment",
+            "categorygroup": "Entertainment",
+            "subcategory": undefined
+        },
+        "fr": {
+            "category": "Divertissement",
+            "categorygroup": "Divertissement",
+            "subcategory": undefined
+        }
+    },
+    {
+        "en": {
+            "category": "Fees",
+            "categorygroup": "Bank Charges",
+            "subcategory": undefined
+        },
+        "fr": {
+            "category": "Honoraires",
+            "categorygroup": "Frais bancaires",
+            "subcategory": undefined
+        }
+    },
+    {
+        "en": {
+            "category": "Food",
+            "categorygroup": "Grocery Costs",
+            "subcategory": undefined
+        },
+        "fr": {
+            "category": "Aliments",
+            "categorygroup": "Coûts d'épicerie",
+            "subcategory": undefined
+        }
+    },
+    {
+        "en": {
+            "category": "Gifts",
+            "categorygroup": "Other Expense",
+            "subcategory": undefined
+        },
+        "fr": {
+            "category": "Cadeaux",
+            "categorygroup": "Autres charges",
+            "subcategory": undefined
+        }
+    },
+    {
+        "en": {
+            "category": "Groceries",
+            "categorygroup": "Grocery Costs",
+            "subcategory": undefined
+        },
+        "fr": {
+            "category": "Épicerie",
+            "categorygroup": "Coûts d'épicerie",
+            "subcategory": undefined
+        }
+    },
+    {
+        "en": {
+            "category": "Healthcare",
+            "categorygroup": "Medical/Dental Expenses",
+            "subcategory": undefined
+        },
+        "fr": {
+            "category": "Soins De Santé",
+            "categorygroup": "Frais médicaux / dentaires",
+            "subcategory": undefined
+        }
+    },
+    {
+        "en": {
+            "category": undefined,
+            "categorygroup": "Medical/Dental Expenses",
+            "subcategory": "Dental"
+        },
+        "fr": {
+            "category": undefined,
+            "categorygroup": "Frais médicaux / dentaires",
+            "subcategory": "Dentaire"
+        }
+    },
+    {
+        "en": {
+            "category": undefined,
+            "categorygroup": "Medical/Dental Expenses",
+            "subcategory": "Eyecare"
+        },
+        "fr": {
+            "category": undefined,
+            "categorygroup": "Frais médicaux / dentaires",
+            "subcategory": "Soin Des Yeux"
+        }
+    },
+    {
+        "en": {
+            "category": undefined,
+            "categorygroup": "Medical/Dental Expenses",
+            "subcategory": "Hospital"
+        },
+        "fr": {
+            "category": undefined,
+            "categorygroup": "Frais médicaux / dentaires",
+            "subcategory": "Hôpital"
+        }
+    },
+    {
+        "en": {
+            "category": undefined,
+            "categorygroup": "Medical/Dental Expenses",
+            "subcategory": "Physician"
+        },
+        "fr": {
+            "category": undefined,
+            "categorygroup": "Frais médicaux / dentaires",
+            "subcategory": "Médecin"
+        }
+    },
+    {
+        "en": {
+            "category": undefined,
+            "categorygroup": "Medical/Dental Expenses",
+            "subcategory": "Prescriptions"
+        },
+        "fr": {
+            "category": undefined,
+            "categorygroup": "Frais médicaux / dentaires",
+            "subcategory": "Prescriptions"
+        }
+    },
+    {
+        "en": {
+            "category": "Hobbies/Leisure",
+            "categorygroup": "Entertainment",
+            "subcategory": undefined
+        },
+        "fr": {
+            "category": "Loisirs / Loisirs",
+            "categorygroup": "Divertissement",
+            "subcategory": undefined
+        }
+    },
+    {
+        "en": {
+            "category": undefined,
+            "categorygroup": "Entertainment",
+            "subcategory": "Books & Magazines"
+        },
+        "fr": {
+            "category": undefined,
+            "categorygroup": "Divertissement",
+            "subcategory": "Livres et revues"
+        }
+    },
+    {
+        "en": {
+            "category": undefined,
+            "categorygroup": "Entertainment",
+            "subcategory": "Cultural Events"
+        },
+        "fr": {
+            "category": undefined,
+            "categorygroup": "Divertissement",
+            "subcategory": "Événements Culturels"
+        }
+    },
+    {
+        "en": {
+            "category": undefined,
+            "categorygroup": "Entertainment",
+            "subcategory": "Entertaining"
+        },
+        "fr": {
+            "category": undefined,
+            "categorygroup": "Divertissement",
+            "subcategory": "Divertissant"
+        }
+    },
+    {
+        "en": {
+            "category": undefined,
+            "categorygroup": "Entertainment",
+            "subcategory": "Movies & Video Rentals"
+        },
+        "fr": {
+            "category": undefined,
+            "categorygroup": "Divertissement",
+            "subcategory": "Cinéma et la location de vidéos"
+        }
+    },
+    {
+        "en": {
+            "category": undefined,
+            "categorygroup": "Entertainment",
+            "subcategory": "Sporting Events"
+        },
+        "fr": {
+            "category": undefined,
+            "categorygroup": "Divertissement",
+            "subcategory": "Événements Sportifs"
+        }
+    },
+    {
+        "en": {
+            "category": undefined,
+            "categorygroup": "Entertainment",
+            "subcategory": "Sporting Goods"
+        },
+        "fr": {
+            "category": undefined,
+            "categorygroup": "Divertissement",
+            "subcategory": "Sporting Goods"
+        }
+    },
+    {
+        "en": {
+            "category": undefined,
+            "categorygroup": "Entertainment",
+            "subcategory": "Tapes & CDs"
+        },
+        "fr": {
+            "category": undefined,
+            "categorygroup": "Divertissement",
+            "subcategory": "Tapes & CD"
+        }
+    },
+    {
+        "en": {
+            "category": undefined,
+            "categorygroup": "Entertainment",
+            "subcategory": "Toys & Games"
+        },
+        "fr": {
+            "category": undefined,
+            "categorygroup": "Divertissement",
+            "subcategory": "Jouets & Jeux"
+        }
+    },
+    {
+        "en": {
+            "category": "Home Improvement",
+            "categorygroup": "Other Expense",
+            "subcategory": undefined
+        },
+        "fr": {
+            "category": "Rénovations",
+            "categorygroup": "Autres charges",
+            "subcategory": undefined
+        }
+    },
+    {
+        "en": {
+            "category": "Household",
+            "categorygroup": "Household Expenses",
+            "subcategory": undefined
+        },
+        "fr": {
+            "category": "Ménage",
+            "categorygroup": "Dépenses des ménages",
+            "subcategory": undefined
+        }
+    },
+    {
+        "en": {
+            "category": undefined,
+            "categorygroup": "Household Expenses",
+            "subcategory": "Furnishings"
+        },
+        "fr": {
+            "category": undefined,
+            "categorygroup": "Dépenses des ménages",
+            "subcategory": "Ameublement"
+        }
+    },
+    {
+        "en": {
+            "category": undefined,
+            "categorygroup": "Household Expenses",
+            "subcategory": "House Cleaning"
+        },
+        "fr": {
+            "category": undefined,
+            "categorygroup": "Dépenses des ménages",
+            "subcategory": "Nettoyage De Maison"
+        }
+    },
+    {
+        "en": {
+            "category": undefined,
+            "categorygroup": "Household Expenses",
+            "subcategory": "Yard Service"
+        },
+        "fr": {
+            "category": undefined,
+            "categorygroup": "Dépenses des ménages",
+            "subcategory": "Service de manœuvre"
+        }
+    },
+    {
+        "en": {
+            "category": "Insurance",
+            "categorygroup": "Life Insurance",
+            "subcategory": undefined
+        },
+        "fr": {
+            "category": "Assurance",
+            "categorygroup": "Assurance Vie",
+            "subcategory": undefined
+        }
+    },
+    {
+        "en": {
+            "category": undefined,
+            "categorygroup": "Automobile Insurance",
+            "subcategory": "Automobile"
+        },
+        "fr": {
+            "category": undefined,
+            "categorygroup": "Assurance automobile",
+            "subcategory": "Automobile"
+        }
+    },
+    {
+        "en": {
+            "category": undefined,
+            "categorygroup": "Medical/Dental Expenses",
+            "subcategory": "Health"
+        },
+        "fr": {
+            "category": undefined,
+            "categorygroup": "Frais médicaux / dentaires",
+            "subcategory": "Santé"
+        }
+    },
+    {
+        "en": {
+            "category": undefined,
+            "categorygroup": "Home/Rent Insurance",
+            "subcategory": "Homeowner's/Renter's"
+        },
+        "fr": {
+            "category": undefined,
+            "categorygroup": "Accueil / Louer Assurances",
+            "subcategory": "De propriétaire / locataire de"
+        }
+    },
+    {
+        "en": {
+            "category": undefined,
+            "categorygroup": "Life Insurance",
+            "subcategory": "Life"
+        },
+        "fr": {
+            "category": undefined,
+            "categorygroup": "Assurance Vie",
+            "subcategory": "Vie"
+        }
+    },
+    {
+        "en": {
+            "category": "Job Expense",
+            "categorygroup": "Non-Reimb. Job Exp.",
+            "subcategory": undefined
+        },
+        "fr": {
+            "category": "Dépenses d'emploi",
+            "categorygroup": "Non-Reimb. ",
+            "subcategory": undefined
+        }
+    },
+    {
+        "en": {
+            "category": undefined,
+            "categorygroup": "Non-Reimb. Job Exp.",
+            "subcategory": "Non-Reimbursed"
+        },
+        "fr": {
+            "category": undefined,
+            "categorygroup": "Non-Reimb. ",
+            "subcategory": "Non remboursés"
+        }
+    },
+    {
+        "en": {
+            "category": undefined,
+            "categorygroup": "Reimbursed Job Exp.",
+            "subcategory": "Reimbursed"
+        },
+        "fr": {
+            "category": undefined,
+            "categorygroup": "Remboursé emploi Exp.",
+            "subcategory": "Remboursé"
+        }
+    },
+    {
+        "en": {
+            "category": "Loan",
+            "categorygroup": "Other Interest",
+            "subcategory": undefined
+        },
+        "fr": {
+            "category": "Prêt",
+            "categorygroup": "Autres intérêts",
+            "subcategory": undefined
+        }
+    },
+    {
+        "en": {
+            "category": undefined,
+            "categorygroup": "Other Interest",
+            "subcategory": "Loan Interest"
+        },
+        "fr": {
+            "category": undefined,
+            "categorygroup": "Autres intérêts",
+            "subcategory": "Intérêts d'emprunt"
+        }
+    },
+    {
+        "en": {
+            "category": undefined,
+            "categorygroup": "Mortgage Interest",
+            "subcategory": "Mortgage Interest"
+        },
+        "fr": {
+            "category": undefined,
+            "categorygroup": "Intérêts hypothécaires",
+            "subcategory": "Intérêts hypothécaires"
+        }
+    },
+    {
+        "en": {
+            "category": undefined,
+            "categorygroup": "Other Interest",
+            "subcategory": "Student Loan Interest"
+        },
+        "fr": {
+            "category": undefined,
+            "categorygroup": "Autres intérêts",
+            "subcategory": "Intérêts sur les prêts étudiants"
+        }
+    },
+    {
+        "en": {
+            "category": "Miscellaneous",
+            "categorygroup": "Other Expense",
+            "subcategory": undefined
+        },
+        "fr": {
+            "category": "Divers",
+            "categorygroup": "Autres charges",
+            "subcategory": undefined
+        }
+    },
+    {
+        "en": {
+            "category": "Mortgage/Rent",
+            "categorygroup": "Rent Bill",
+            "subcategory": undefined
+        },
+        "fr": {
+            "category": "Hypothèque / Loyer",
+            "categorygroup": "Louer le projet de loi",
+            "subcategory": undefined
+        }
+    },
+    {
+        "en": {
+            "category": "Personal Care",
+            "categorygroup": "Other Expense",
+            "subcategory": undefined
+        },
+        "fr": {
+            "category": "Soins personnels",
+            "categorygroup": "Autres charges",
+            "subcategory": undefined
+        }
+    },
+    {
+        "en": {
+            "category": "Pet Care",
+            "categorygroup": "Other Expense",
+            "subcategory": undefined
+        },
+        "fr": {
+            "category": "S'occuper D'Un Animal",
+            "categorygroup": "Autres charges",
+            "subcategory": undefined
+        }
+    },
+    {
+        "en": {
+            "category": undefined,
+            "categorygroup": "Other Expense",
+            "subcategory": "Food"
+        },
+        "fr": {
+            "category": undefined,
+            "categorygroup": "Autres charges",
+            "subcategory": "Aliments"
+        }
+    },
+    {
+        "en": {
+            "category": undefined,
+            "categorygroup": "Other Expense",
+            "subcategory": "Supplies"
+        },
+        "fr": {
+            "category": undefined,
+            "categorygroup": "Autres charges",
+            "subcategory": "Provisions"
+        }
+    },
+    {
+        "en": {
+            "category": undefined,
+            "categorygroup": "Other Expense",
+            "subcategory": "Veterinarian"
+        },
+        "fr": {
+            "category": undefined,
+            "categorygroup": "Autres charges",
+            "subcategory": "Vétérinaire"
+        }
+    },
+    {
+        "en": {
+            "category": "Phone/Wireless",
+            "categorygroup": "Telephone Bill",
+            "subcategory": undefined
+        },
+        "fr": {
+            "category": "Téléphone / sans fil",
+            "categorygroup": "Facture De Téléphone",
+            "subcategory": undefined
+        }
+    },
+    {
+        "en": {
+            "category": "Services/Memberships",
+            "categorygroup": "Other Expense",
+            "subcategory": undefined
+        },
+        "fr": {
+            "category": "Services / Adhésion",
+            "categorygroup": "Autres charges",
+            "subcategory": undefined
+        }
+    },
+    {
+        "en": {
+            "category": "Taxes",
+            "categorygroup": "Other Tax Payments",
+            "subcategory": undefined
+        },
+        "fr": {
+            "category": "Impôts",
+            "categorygroup": "Autres paiements d'impôt",
+            "subcategory": undefined
+        }
+    },
+    {
+        "en": {
+            "category": undefined,
+            "categorygroup": "Federal Taxes",
+            "subcategory": "Federal Income Tax"
+        },
+        "fr": {
+            "category": undefined,
+            "categorygroup": "Impôts fédéraux",
+            "subcategory": "Impôt sur le revenu fédéral"
+        }
+    },
+    {
+        "en": {
+            "category": undefined,
+            "categorygroup": "Other Expense",
+            "subcategory": "Federal Income Tax-Previous Year"
+        },
+        "fr": {
+            "category": undefined,
+            "categorygroup": "Autres charges",
+            "subcategory": "Année d'impôt fédéral précédente"
+        }
+    },
+    {
+        "en": {
+            "category": undefined,
+            "categorygroup": "Other Tax Payments",
+            "subcategory": "Local Income Tax"
+        },
+        "fr": {
+            "category": undefined,
+            "categorygroup": "Autres paiements d'impôt",
+            "subcategory": "Impôt sur le revenu local"
+        }
+    },
+    {
+        "en": {
+            "category": undefined,
+            "categorygroup": "Medicare Taxes",
+            "subcategory": "Medicare Tax"
+        },
+        "fr": {
+            "category": undefined,
+            "categorygroup": "Medicare impôts",
+            "subcategory": "Impôt Medicare"
+        }
+    },
+    {
+        "en": {
+            "category": undefined,
+            "categorygroup": "Other Tax Payments",
+            "subcategory": "Other Taxes"
+        },
+        "fr": {
+            "category": undefined,
+            "categorygroup": "Autres paiements d'impôt",
+            "subcategory": "Autres taxes"
+        }
+    },
+    {
+        "en": {
+            "category": undefined,
+            "categorygroup": "Real Estate Taxes",
+            "subcategory": "Real Estate Taxes"
+        },
+        "fr": {
+            "category": undefined,
+            "categorygroup": "Immobilier Impôts",
+            "subcategory": "Immobilier Impôts"
+        }
+    },
+    {
+        "en": {
+            "category": undefined,
+            "categorygroup": "Other Tax Payments",
+            "subcategory": "Sales Tax"
+        },
+        "fr": {
+            "category": undefined,
+            "categorygroup": "Autres paiements d'impôt",
+            "subcategory": "la taxe de vente"
+        }
+    },
+    {
+        "en": {
+            "category": undefined,
+            "categorygroup": "Social Security Taxes",
+            "subcategory": "Social Security Tax"
+        },
+        "fr": {
+            "category": undefined,
+            "categorygroup": "Impôts de sécurité sociale",
+            "subcategory": "L'impôt sur la sécurité sociale"
+        }
+    },
+    {
+        "en": {
+            "category": undefined,
+            "categorygroup": "State Tax Payments",
+            "subcategory": "State Income Tax"
+        },
+        "fr": {
+            "category": undefined,
+            "categorygroup": "Paiements d'impôts étatiques",
+            "subcategory": "Impôt sur le revenu État"
+        }
+    },
+    {
+        "en": {
+            "category": undefined,
+            "categorygroup": "State Tax Payments",
+            "subcategory": "State/Provincial"
+        },
+        "fr": {
+            "category": undefined,
+            "categorygroup": "Paiements d'impôts étatiques",
+            "subcategory": "État / provincial"
+        }
+    },
+    {
+        "en": {
+            "category": "Travel/Vacation",
+            "categorygroup": "Entertainment",
+            "subcategory": undefined
+        },
+        "fr": {
+            "category": "Voyage / vacances",
+            "categorygroup": "Divertissement",
+            "subcategory": undefined
+        }
+    },
+    {
+        "en": {
+            "category": undefined,
+            "categorygroup": "Entertainment",
+            "subcategory": "Lodging"
+        },
+        "fr": {
+            "category": undefined,
+            "categorygroup": "Divertissement",
+            "subcategory": "Hébergement"
+        }
+    },
+    {
+        "en": {
+            "category": undefined,
+            "categorygroup": "Entertainment",
+            "subcategory": "Travel"
+        },
+        "fr": {
+            "category": undefined,
+            "categorygroup": "Divertissement",
+            "subcategory": "Voyage"
+        }
+    },
+    {
+        "en": {
+            "category": "Utilities",
+            "categorygroup": "Other Bills",
+            "subcategory": undefined
+        },
+        "fr": {
+            "category": "Utilitaires",
+            "categorygroup": "Autres factures",
+            "subcategory": undefined
+        }
+    },
+    {
+        "en": {
+            "category": "Income/Interest",
+            "categorygroup": "Salary Income",
+            "subcategory": undefined
+        },
+        "fr": {
+            "category": "Revenu / intérêt",
+            "categorygroup": "Revenu Salaire",
+            "subcategory": undefined
+        }
+    },
+    {
+        "en": {
+            "category": "Investment Income",
+            "categorygroup": "Interest & Dividends",
+            "subcategory": undefined
+        },
+        "fr": {
+            "category": "Revenu de placement",
+            "categorygroup": "Intérêts et dividendes",
+            "subcategory": undefined
+        }
+    },
+    {
+        "en": {
+            "category": undefined,
+            "categorygroup": "Interest & Dividends",
+            "subcategory": "Capital Gains"
+        },
+        "fr": {
+            "category": undefined,
+            "categorygroup": "Intérêts et dividendes",
+            "subcategory": "Gains en capital"
+        }
+    },
+    {
+        "en": {
+            "category": undefined,
+            "categorygroup": "Interest & Dividends",
+            "subcategory": "Dividends"
+        },
+        "fr": {
+            "category": undefined,
+            "categorygroup": "Intérêts et dividendes",
+            "subcategory": "Dividendes"
+        }
+    },
+    {
+        "en": {
+            "category": undefined,
+            "categorygroup": "Interest & Dividends",
+            "subcategory": "Interest"
+        },
+        "fr": {
+            "category": undefined,
+            "categorygroup": "Intérêts et dividendes",
+            "subcategory": "Intérêt"
+        }
+    },
+    {
+        "en": {
+            "category": undefined,
+            "categorygroup": "Tax-Exempt Income",
+            "subcategory": "Tax-Exempt Interest"
+        },
+        "fr": {
+            "category": undefined,
+            "categorygroup": "Revenu exonéré d'impôt",
+            "subcategory": "Les intérêts exonérés d'impôt"
+        }
+    },
+    {
+        "en": {
+            "category": "Not an Expense",
+            "categorygroup": "Other Income",
+            "subcategory": undefined
+        },
+        "fr": {
+            "category": "Pas une dépense",
+            "categorygroup": "Autre Revenu",
+            "subcategory": undefined
+        }
+    },
+    {
+        "en": {
+            "category": "Other Income",
+            "categorygroup": "Other Income",
+            "subcategory": undefined
+        },
+        "fr": {
+            "category": "Autre Revenu",
+            "categorygroup": "Autre Revenu",
+            "subcategory": undefined
+        }
+    },
+    {
+        "en": {
+            "category": undefined,
+            "categorygroup": "Child Support Received",
+            "subcategory": "Child Support Received"
+        },
+        "fr": {
+            "category": undefined,
+            "categorygroup": "Child Support Reçues",
+            "subcategory": "Child Support Reçues"
+        }
+    },
+    {
+        "en": {
+            "category": undefined,
+            "categorygroup": "Periodic Income",
+            "subcategory": "Employee Stock Option"
+        },
+        "fr": {
+            "category": undefined,
+            "categorygroup": "Revenu périodique",
+            "subcategory": "Employee Stock Option"
+        }
+    },
+    {
+        "en": {
+            "category": undefined,
+            "categorygroup": "Gifts Received",
+            "subcategory": "Gifts Received"
+        },
+        "fr": {
+            "category": undefined,
+            "categorygroup": "Cadeaux reçus",
+            "subcategory": "Cadeaux reçus"
+        }
+    },
+    {
+        "en": {
+            "category": undefined,
+            "categorygroup": "Tax-Exempt Income",
+            "subcategory": "Loan Principal Received"
+        },
+        "fr": {
+            "category": undefined,
+            "categorygroup": "Revenu exonéré d'impôt",
+            "subcategory": "Principal du prêt Reçues"
+        }
+    },
+    {
+        "en": {
+            "category": undefined,
+            "categorygroup": "Other Income",
+            "subcategory": "Lotteries"
+        },
+        "fr": {
+            "category": undefined,
+            "categorygroup": "Autre Revenu",
+            "subcategory": "Loteries"
+        }
+    },
+    {
+        "en": {
+            "category": undefined,
+            "categorygroup": "State/Local Tax Refund",
+            "subcategory": "State & Local Tax Refund"
+        },
+        "fr": {
+            "category": undefined,
+            "categorygroup": "Etat / local Remboursement de la taxe",
+            "subcategory": "State & Local Remboursement de la taxe"
+        }
+    },
+    {
+        "en": {
+            "category": undefined,
+            "categorygroup": "Unemployment Income",
+            "subcategory": "Unemployment Compensation"
+        },
+        "fr": {
+            "category": undefined,
+            "categorygroup": "Revenu de chômage",
+            "subcategory": "indemnisation du chômage"
+        }
+    },
+    {
+        "en": {
+            "category": "Retirement Income",
+            "categorygroup": "Other Income",
+            "subcategory": undefined
+        },
+        "fr": {
+            "category": "De revenu de retraite",
+            "categorygroup": "Autre Revenu",
+            "subcategory": undefined
+        }
+    },
+    {
+        "en": {
+            "category": undefined,
+            "categorygroup": "IRA/Pension Income",
+            "subcategory": "IRA Distributions"
+        },
+        "fr": {
+            "category": undefined,
+            "categorygroup": "IRA / revenu de pension",
+            "subcategory": "IRA Distributions"
+        }
+    },
+    {
+        "en": {
+            "category": undefined,
+            "categorygroup": "IRA/Pension Income",
+            "subcategory": "Pensions & Annuities"
+        },
+        "fr": {
+            "category": undefined,
+            "categorygroup": "IRA / revenu de pension",
+            "subcategory": "Pensions et rentes"
+        }
+    },
+    {
+        "en": {
+            "category": undefined,
+            "categorygroup": "Social Security Income",
+            "subcategory": "Social Security Benefits"
+        },
+        "fr": {
+            "category": undefined,
+            "categorygroup": "Revenu de la sécurité sociale",
+            "subcategory": "Prestations sociales"
+        }
+    },
+    {
+        "en": {
+            "category": "Wages & Salary",
+            "categorygroup": "Salary Income",
+            "subcategory": undefined
+        },
+        "fr": {
+            "category": "Salaires et Salaire",
+            "categorygroup": "Revenu Salaire",
+            "subcategory": undefined
+        }
+    },
+    {
+        "en": {
+            "category": undefined,
+            "categorygroup": "Periodic Income",
+            "subcategory": "Bonus"
+        },
+        "fr": {
+            "category": undefined,
+            "categorygroup": "Revenu périodique",
+            "subcategory": "Prime"
+        }
+    },
+    {
+        "en": {
+            "category": undefined,
+            "categorygroup": "Periodic Income",
+            "subcategory": "Commission"
+        },
+        "fr": {
+            "category": undefined,
+            "categorygroup": "Revenu périodique",
+            "subcategory": "Commission"
+        }
+    },
+    {
+        "en": {
+            "category": undefined,
+            "categorygroup": "Employer Matching",
+            "subcategory": "Employer Matching"
+        },
+        "fr": {
+            "category": undefined,
+            "categorygroup": "Employeur Matching",
+            "subcategory": "Employeur Matching"
+        }
+    },
+    {
+        "en": {
+            "category": undefined,
+            "categorygroup": "Salary Income",
+            "subcategory": "Gross Pay"
+        },
+        "fr": {
+            "category": undefined,
+            "categorygroup": "Revenu Salaire",
+            "subcategory": "Salaire Brut"
+        }
+    },
+    {
+        "en": {
+            "category": undefined,
+            "categorygroup": "Salary Income",
+            "subcategory": "Net Pay"
+        },
+        "fr": {
+            "category": undefined,
+            "categorygroup": "Revenu Salaire",
+            "subcategory": "Salaire Net"
+        }
+    },
+    {
+        "en": {
+            "category": undefined,
+            "categorygroup": "Periodic Income",
+            "subcategory": "Overtime"
+        },
+        "fr": {
+            "category": undefined,
+            "categorygroup": "Revenu périodique",
+            "subcategory": "Avec Le Temps"
+        }
+    }
+]

+ 20 - 0
app/models/entry.js

@@ -0,0 +1,20 @@
+var mongoose    = require('mongoose'),
+    Schema      = mongoose.Schema,
+    ObjectId    = Schema.Types.ObjectId;
+    
+var DEBIT   = 'D',
+    BILL    = 'B';
+    
+var EntrySchema = new Schema({
+    account_id: {type: ObjectId, ref: 'Category', required: true},
+    category: {type: ObjectId, ref: 'Account', required: false},
+    sub_category: {type: ObjectId, required: false},
+    label: {type: String, required:false},
+    type: {type: String, required: true},
+    amount: {type: Number, required: true},
+    date: {type: Date, required: true}, 
+    created_at:  {type: Date, default: Date.now}
+});
+
+var Entry = mongoose.model('Entry', EntrySchema);
+module.exports = Entry;

+ 87 - 0
app/models/user.js

@@ -0,0 +1,87 @@
+var mongoose    = require('mongoose'),
+    Schema      = mongoose.Schema,
+    bcrypt      = require('bcrypt'),
+    SALT_WORK_FACTOR = 10;
+    
+var UserSchema = new Schema({
+    username: { type: String, required: true, index: { unique: true } },
+    password: { type: String, required: true },
+    language: {type: String, required: true, default: 'en'},
+    created_at: {type: Date, 'default': Date.now}
+});
+
+UserSchema.statics.getAuthenticated = function(username, password, callback) {
+    this.findOne({ username: username }, function(error, user) {
+        if (error) {
+            console.error(error);
+            return callback(error);
+        }
+        // make sure the user exists
+        if (!user) {
+            return callback(null, null, 404);
+        }
+        
+        user.comparePassword(password, function(error, isMatch) {
+            if (isMatch) {
+                return callback(null, user);
+            }
+            
+            return callback(null, null, 401);
+        });
+
+    });
+};
+
+
+UserSchema.pre('save', function(next) {
+    var user = this;
+
+    // only hash the password if it has been modified (or is new)
+    if (!user.isModified('password')) {
+        return next();
+    }
+
+    // generate a salt
+    bcrypt.genSalt(SALT_WORK_FACTOR, function(error, salt) {
+        if (error) {
+            console.log(error);
+            return next(error);
+        }
+
+        // hash the password using our new salt
+        bcrypt.hash(user.password, salt, function(error, hash) {
+            if (error) {
+                return next(error);
+            }
+
+            // override the cleartext password with the hashed one
+            user.password = hash;
+            next();
+        });
+    });
+});
+
+UserSchema.methods.comparePassword = function(candidatePassword, callback) {
+    bcrypt.compare(candidatePassword, this.password, function(error, isMatch) {
+        if (error) {
+            return callback(error);
+        }
+        callback(null, isMatch);
+    });
+};
+
+var User = mongoose.model('User', UserSchema);
+
+User.schema.path('username').validate(function (username) {
+  return username.length;
+}, 'Username cannot be blank');
+
+User.schema.path('password').validate(function(password) {
+    return password.length;
+}, 'Password cannot be blank');
+
+User.schema.path('language').validate(function(language) {
+    return /en|fr/i.test(language);
+}, 'Unknown language ("en" or "fr" only)')
+
+module.exports = User;

+ 16 - 0
app/routes.js

@@ -0,0 +1,16 @@
+var fs  = require('fs');
+
+module.exports = function(app) {
+    
+    var routes_path = __dirname + '/routes'
+    fs.readdirSync(routes_path).forEach(function (file) {
+      if (~file.indexOf('.js')) {
+          var route = require(routes_path + '/' + file);
+          route(app);
+      }
+    })
+    
+     app.get('*', function(req, res) {
+        res.sendfile('./public/views/index.html');
+    });
+};

+ 22 - 0
app/routes/accounts.js

@@ -0,0 +1,22 @@
+var passport            = require('../security/passport'),
+    AccountController   = require('../controllers/accounts');
+    
+module.exports = function(app) {
+    
+    app.post('/api/accounts', passport.jwt, AccountController.create);
+    
+    app.delete('/api/accounts/:account_id', passport.jwt, AccountController.delete);
+    
+    app.get('/api/accounts/:account_id', passport.jwt, AccountController.get);
+    
+    app.put('/api/accounts/:account_id', passport.jwt, AccountController.modify);
+    
+    app.post('/api/accounts/:account_id/entries', passport.jwt, AccountController.add_entry);
+    
+    app.put('/api/accounts/:account_id/entries/:entry_id', passport.jwt, AccountController.modify_entry);
+    
+    app.delete('/api/accounts/:account_id/entries/:entry_id', passport.jwt, AccountController.delete_entry);
+    
+    app.get('/api/accounts/:account_id/entries', passport.jwt, AccountController.list_entries);
+    
+};

+ 13 - 0
app/routes/users.js

@@ -0,0 +1,13 @@
+var passport        = require('../security/passport'),
+    UserController  = require('../controllers/users');
+    
+module.exports = function(app) {
+    
+    app.post('/api/users/login', passport.local, UserController.login);
+    
+    app.delete('/api/users/login', UserController.logout);
+    
+    app.post('/api/users', UserController.subscribe);
+    
+    app.delete('/api/users', passport.jwt, UserController.unsubscribe);
+};

+ 40 - 0
app/security/passport.js

@@ -0,0 +1,40 @@
+var mongoose        = require('mongoose'),
+    User            = mongoose.model('User'),
+    passport        = require('passport'),
+    LocalStrategy   = require('passport-local'),
+    JwtStrategy     = require('passport-jwt').Strategy,
+    security        = require('../../config/security');
+    
+passport.use( new LocalStrategy( 
+    function(username, password, done) {
+        User.getAuthenticated(username, password, function(error, user, errorStatus) {
+            if( error ) {
+                return done(error, null);
+            }
+            
+            if( !user ) {
+                return done(null, false);
+            }
+            
+            return done(null, user);
+        });
+    }
+));
+
+passport.use( new JwtStrategy(security.jwt, function(jwt_payload, done) {
+    User.findById(jwt_payload.user_id, function(error, user) {
+        if( error ) {
+            return done(error, null);
+        }
+        if( user ) {
+            return done(null, user);
+        } else {
+            return done(null, false);
+        }
+    });
+}));
+
+module.exports = {
+    jwt: passport.authenticate('jwt', {session: false}),
+    local: passport.authenticate('local', {session: false})
+}

+ 11 - 0
bower.json

@@ -0,0 +1,11 @@
+{
+    "name": "cloud-budget",
+    "version": "0.0.1-SNAPSHOT",
+    "dependencies": {
+        "bootstrap": "~3.3.5",
+        "font-awesome": "~4.3.0",
+        "animate.css": "~3.3.0",
+        "angular": "~1.4.1",
+        "angular-route": "~1.4.1"
+    }
+}

+ 11 - 0
config/db.js

@@ -0,0 +1,11 @@
+module.exports = {
+    development: {
+        url: 'mongodb://' + process.env.IP + ':27017/cloudbudget_dev'
+    },
+    test: {
+        url: 'mongodb://localhost:27017/cloudbudget_test'
+    },
+    production: {
+        url: 'mongodb://' + process.env.IP + ':27017/cloudbudget'
+    },
+}

+ 7 - 0
config/security.js

@@ -0,0 +1,7 @@
+module.exports = {
+    jwt : {
+        secretOrKey : 's3cr3t',
+        issuer      : undefined,    // accounts.examplesoft.com
+        audience    : undefined     // yoursite.net
+    }
+}

+ 17 - 0
config/server.js

@@ -0,0 +1,17 @@
+module.exports = {
+    development: {
+        port    : process.env.PORT || 3000,
+        server  : process.env.IP || '0.0.0.0',
+        errorHandlerOptions: {"dumpExceptions": true, "showStack": true}
+    },
+    test: {
+        port    : 3000,
+        server  : 'localhost',
+        errorHandlerOptions: {"dumpExceptions": false, "showStack": false}
+    },
+    production: {
+        port    : process.env.PORT || 3000,
+        server  : process.env.IP || '0.0.0.0',
+        errorHandlerOptions: {"dumpExceptions": false, "showStack": false}
+    },
+}

+ 30 - 0
package.json

@@ -0,0 +1,30 @@
+{
+    "name": "cloud-budget",
+    "main": "app.js",
+    "dependencies": {
+        "express": "~4.5.1",
+        "mongoose": "~4.0.8",
+        "body-parser": "~1.4.2",
+        "method-override": "~2.0.2",
+        "morgan": "~1.6.0",
+        "file-stream-rotator": "~0.0.6",
+        "errorhandler": "~1.4.1",
+        
+        "jsonwebtoken": "~5.0.4",
+        "bcrypt": "~0.8.3",
+        
+        "passport": "~0.2.2",
+        "passport-local": "~1.0.0",
+        "passport-jwt": "~1.1.0"
+    },
+    "devDependencies": {
+        "mocha": "~2.2.5",
+        "supertest": "~1.0.1",
+        "should": "~7.0.2",
+        "sinon": "~1.15.4"
+    },
+    "scripts": {
+        "test": "NODE_ENV=test mocha",
+        "start": "app.js"
+    }
+}

+ 39 - 0
public/views/index.html

@@ -0,0 +1,39 @@
+<!DOCTYPE html>
+<html lang="en">
+    <head>
+        <meta charset="UTF-8" />
+        <base href="/" />
+        
+        <title>MEAN Demo Single Page Application</title>
+        
+        <link rel="stylesheet" href="libs/bootstrap/dist/css/bootstrap.min.css" />
+        <link rel="stylesheet" href="css/style.css" />
+        
+        
+        <script src="libs/angular/angular.min.js" ></script>
+        <script src="libs/angular-route/angular-route.min.js" ></script>
+        
+        <script src="js/controllers/MainCtrl.js"></script>
+        <script src="js/controllers/NerdCtrl.js"></script>
+        <script src="js/services/NerdService.js"></script>
+        <script src="js/appRoutes.js"></script>
+        <script src="js/app.js"></script>
+    </head>
+    
+    <body ng-app="sampleApp" ng-controller="NerdController">
+        <div class="container">
+            <nav class="navbar navbar-inverse">
+                <div class="navbar-header">
+                    <a class="navbar-brand" href="/">MEAN-Demo</a>
+                </div>
+                
+                <ul class="nav navbar-nav">
+                    <li><a href="/nerds">Nerds</a></li>                    
+                </ul>
+            </nav>
+            
+            <div ng-view></div>
+            
+        </div>
+    </body>
+</html>

+ 0 - 0
public/views/user.html


+ 64 - 0
server.js

@@ -0,0 +1,64 @@
+// modules
+var express           = require('express'),
+    app               = express(),
+    bodyParser        = require('body-parser'),
+    methodOverride    = require('method-override'),
+    morgan            = require('morgan'),
+    errorHandler      = require('errorhandler'),
+    FileStreamRotator = require('file-stream-rotator'),
+    fs                = require('fs'),
+    mongoose          = require('mongoose');
+    
+//config
+var db              = require('./config/db')[process.env.NODE_ENV],
+    server          = require('./config/server')[process.env.NODE_ENV],
+    logDir          = __dirname + '/logs';
+    
+fs.existsSync(logDir) || fs.mkdirSync(logDir);
+
+var accessLogStream = FileStreamRotator.getStream({
+        filename  : logDir + '/access-%DATE%.log',
+        frequency : 'daily',
+        verbose   : false,
+        date_format: 'YYYY-MM-DD'
+      });
+     
+mongoose.connect(db.url);
+
+/** Hack to load Models before routing **/
+var models_path = __dirname + '/app/models'
+fs.readdirSync(models_path).forEach(function (file) {
+  if (~file.indexOf('.js')) require(models_path + '/' + file)
+})
+
+switch(process.env.NODE_ENV) {
+    case 'development' :
+        app.use(morgan('dev'));
+        break;
+    case 'production' :
+        app.use(morgan('combined', {stream: accessLogStream}));
+        break;
+}
+
+app.use(bodyParser.json());
+app.use(bodyParser.json({type: 'application/vnd.api+json'}));
+app.use(bodyParser.urlencoded({extended: true}));
+app.use(methodOverride('X-HTTP-Method-Override'));
+app.use(express.static(__dirname + '/public'));
+app.use(errorHandler(server.errorHandlerOptions));
+
+require('./app/routes')(app);
+
+this.app = app;
+this.server = server;
+
+exports.listen = function () {
+    if( process.env.NODE_ENV !== 'test' ) {
+        console.log('Server running in ' + process.env.NODE_ENV + ' mode on port ' + this.server.port );
+    }
+    return this.app.listen.apply(this.app, [this.server.port]);
+};
+
+exports.close = function (callback) {
+    this.app.close.apply(callback);
+};

+ 543 - 0
test/accounts.js

@@ -0,0 +1,543 @@
+var should      = require('should'), 
+    request     = require('supertest'),
+    app         = require('../server.js'),
+    Db          = require('./db.js'),
+    globalServer, token, hacker_token, account_id;
+
+describe('API /accounts', function() {
+    
+     before( function(done) {
+        globalServer = app.listen();
+        token = Db.get_user_token();
+        hacker_token = Db.get_hacker_token();
+        account_id = Db.ACCOUNT_ID;
+        Db.init(done);
+    });
+    
+    after( function() {
+        globalServer.close(); 
+    });
+
+    describe('* Creation', function() {
+       it('should create an account', function(done) {
+           request(globalServer)
+                .post('/api/accounts')
+                .send({
+                    name: 'Home',
+                    reference: '1234567890'
+                })
+                .set('Authorization', 'JWT ' + token)
+                .set('Accept', 'application/json')
+                .expect(201)
+                .expect('Content-Type', /json/)
+                .end( function(error, result) {
+                    should.not.exist(error);
+                    var account = result.body;
+                    should.exist(account);
+                    account.name.should.be.equal('Home');
+                    account.reference.should.be.equal('1234567890');
+                    done();
+                });
+       });
+       
+       it('should fail to create account without params', function(done) {
+           request(globalServer)
+                .post('/api/accounts')
+                .set('Authorization', 'JWT ' + token)
+                .set('Accept', 'application/json')
+                .expect(400)
+                .expect('Content-Type', /json/)
+                .end( function(error, result) {
+                    var errors = result.body;
+                    should.exist(errors);
+                    errors.should.be.instanceof(Array).and.have.lengthOf(1);
+                    var error = errors[0];
+                    error.field.should.be.equal('name');
+                    done();
+                });
+       });
+       
+       it('should fail to create account without valid token', function(done) {
+          request(globalServer)
+                .post('/api/accounts')
+                .send({
+                    name: 'Home',
+                    reference: '1234567890'
+                })
+                .set('Authorization', 'JWT fake')
+                .expect(401, done);
+       });
+       
+       it('should fail to create account without token', function(done) {
+          request(globalServer)
+                .post('/api/accounts')
+                .send({
+                    name: 'Home',
+                    reference: '1234567890'
+                })
+                .expect(401, done);
+       });
+    });
+    
+    describe('* Deletion', function() {
+        it('should delete the given account', function(done) {
+             request(globalServer)
+                .post('/api/accounts')
+                .send({
+                    name: 'Todelete',
+                    reference: '0987654321'
+                })
+                .set('Authorization', 'JWT ' + token)
+                .end(function(error, result) {
+                    var account_to_delete_id = result.body._id;
+                    
+                    request(globalServer)
+                        .delete('/api/accounts/' + account_to_delete_id)
+                        .set('Authorization', 'JWT ' + token)
+                        .set('Accept', 'application/json')
+                        .expect(204, done);
+                });
+        });
+        
+        it('should fail to delete unknown account', function(done) {
+            request(globalServer)
+                .delete('/api/accounts/4fc67871349bb7bf6a000002')
+                .set('Authorization', 'JWT ' + token)
+                .expect(404, done);
+        });
+        
+        it('should fail to delete invalid account', function(done) {
+            request(globalServer)
+                .delete('/api/accounts/1')
+                .set('Authorization', 'JWT ' + token)
+                .expect(404, done);
+        });
+        
+        it('should fail to delete account for another user', function(done) {
+             request(globalServer)
+                .post('/api/accounts')
+                .send({
+                    name: 'Todelete',
+                    reference: '0987654321'
+                })
+                .set('Authorization', 'JWT ' + token)
+                .end(function(error, result) {
+                    var account_to_delete_id = result.body._id;
+                    request(globalServer)
+                        .delete('/api/accounts/' + account_to_delete_id)
+                        .set('Authorization', 'JWT ' + hacker_token)
+                        .expect(401, done);
+                });
+        });
+    });
+
+    describe('* Retrieve', function() {
+        it('should retrieve the given account', function(done) {
+            request(globalServer)
+                .get('/api/accounts/' + account_id)
+                .set('Authorization', 'JWT ' + token)
+                .expect(200)
+                .expect('Content-Type', /json/)
+                .end( function(error, result) {
+                    should.not.exist(error);
+                    
+                    var account = result.body;
+                    should.exist(account);
+                    account.name.should.be.equal('Default');
+                    account.reference.should.be.equal('1234567890');
+                    done();
+                })
+        });
+        
+        it('should fail to retrieve an unknown account', function(done) {
+            request(globalServer)
+                .get('/api/accounts/4fc67871349bb7bf6a000002')
+                .set('Authorization', 'JWT ' + token)
+                .expect(404, done);
+        });
+        
+         it('should fail to retrieve an invalid account', function(done) {
+            request(globalServer)
+                .get('/api/accounts/1')
+                .set('Authorization', 'JWT ' + token)
+                .expect(404, done);
+        });
+        
+        it('should fail to retrieve the account for another user', function(done) {
+            request(globalServer)
+                .get('/api/accounts/' + account_id)
+                .set('Authorization', 'JWT ' + hacker_token)
+                .expect(401, done);
+        });
+    });
+    
+    describe('* Modify', function() {
+       it('should modify the given account', function(done) {
+            request(globalServer)
+                .put('/api/accounts/' + account_id)
+                .send( {
+                    name: 'Home 2',
+                    reference: '0987654321'
+                })
+                .set('Authorization', 'JWT ' + token)
+                .expect(200)
+                .expect('Content-Type', /json/)
+                .end(function(error, result) {
+                   should.not.exist(error);
+                   
+                   var account = result.body;
+                   should.exist(account);
+                   account.name.should.be.equal('Home 2');
+                   account.reference.should.be.equal('0987654321');
+                   
+                   done(); 
+            });
+       });
+       
+       it('should fail to modify without arguments', function(done) {
+           request(globalServer)
+                .put('/api/accounts/' + account_id)
+                .set('Authorization', 'JWT ' + token)
+                .expect(400, done)
+       });
+
+       it('should fail to modify missing arguments', function(done) {
+           request(globalServer)
+                .put('/api/accounts/' + account_id)
+                .send({reference: 'AZERTY'})
+                .set('Authorization', 'JWT ' + token)
+                .expect(400, done);
+       });
+
+       it('should fail to modify invalid account', function(done) {
+            request(globalServer)
+                .put('/api/accounts/1')
+                .set('Authorization', 'JWT ' + token)
+                .expect(404, done)
+       });
+       
+       it('should fail to modify account for another user', function(done) {
+           request(globalServer)
+                .put('/api/accounts/' + account_id)
+                .set('Authorization', 'JWT ' + hacker_token)
+                .expect(401, done)
+       });
+    });
+    
+    describe('* Entries', function() {
+       describe('* Creation', function() {
+          it('should create an entry with minimal data (DEPOSIT)' , function(done) {
+              request(globalServer)
+                .post('/api/accounts/' + account_id + '/entries')
+                .send({
+                    amount: 1000,
+                    date: new Date('2014-12-08')
+                })
+                .set('Authorization', 'JWT ' + token)
+                .expect(201)
+                .expect('Content-Type', /json/)
+                .end(function(error, result) {
+                    should.not.exist(error);
+                    
+                    var entry = result.body;
+                    should.exist(entry);
+                    entry.amount.should.be.equal(1000);
+                    new Date(entry.date).should.eql(new Date(2014, 11, 8));
+                    entry.type.should.be.equal('DEPOSIT');
+                    should.not.exist(entry.category);
+                    should.not.exist(entry.sub_category);
+                    done();
+                });
+          });
+          it('should create an entry with minimal data (BILL)' , function(done) {
+              request(globalServer)
+                .post('/api/accounts/' + account_id + '/entries')
+                .send({
+                    label: 'test',
+                    amount: -1000,
+                    date: new Date('2014-12-08')
+                })
+                .set('Authorization', 'JWT ' + token)
+                .expect(201)
+                .expect('Content-Type', /json/)
+                .end(function(error, result) {
+                    should.not.exist(error);
+                    
+                    var entry = result.body;
+                    should.exist(entry);
+                    entry.amount.should.be.equal(-1000);
+                    new Date(entry.date).should.eql(new Date(2014, 11, 8));
+                    entry.type.should.be.equal('BILL');
+                    should.not.exist(entry.category);
+                    should.not.exist(entry.sub_category);
+                    done();
+                });
+          });
+          
+          it('should fail to create entry without data', function(done) {
+              request(globalServer)
+                .post('/api/accounts/' + account_id + '/entries')
+                .set('Authorization', 'JWT ' + token)
+                .expect(400, done);
+          });
+          
+          it('should fail to create entry for not owned account', function(done) {
+              request(globalServer)
+                .post('/api/accounts/' + account_id + '/entries')
+                .set('Authorization', 'JWT ' + hacker_token)
+                .send({
+                    label: 'test',
+                    amount: -1000,
+                    date: new Date('2014-12-08')
+                })
+                .expect(401, done);
+          });
+          
+          it('should fail to create entry for not valid account', function(done) {
+              request(globalServer)
+                .post('/api/accounts/1/entries')
+                .send({
+                    label: 'test',
+                    amount: -1000,
+                    date: new Date('2014-12-08')
+                })
+                .set('Authorization', 'JWT ' + token)
+                .expect(404, done);
+          });
+          
+          it('should fail to create entry for unknown account', function(done) {
+              request(globalServer)
+                .post('/api/accounts/' + token + '/entries')
+                .send({
+                    label: 'test',
+                    amount: -1000,
+                    date: new Date('2014-12-08')
+                })
+                .set('Authorization', 'JWT ' + token)
+                .expect(404, done);
+          });
+       });
+       
+       describe('* Modify', function() {
+          it('should modify the given entry', function(done) {
+              request(globalServer)
+                .post('/api/accounts/' + account_id + '/entries')
+                .send({
+                    label: 'test',
+                    amount: 50,
+                    date: new Date('2014-12-08')
+                })
+                .set('Authorization', 'JWT ' + token)
+                .end(function(error, result) {
+                    var entry_id = result.body._id;
+                    request(globalServer)
+                        .put('/api/accounts/' + account_id + '/entries/' + entry_id)
+                        .send({
+                            label: 'modified',
+                            amount: 55,
+                            date: new Date('2014-12-09')
+                        })
+                        .set('Authorization', 'JWT ' + token)
+                        .expect(200)
+                        .expect('Content-Type', /json/)
+                        .end( function(errors, result) {
+                            should.not.exist(errors);
+                            
+                            var entry = result.body;
+                            should.exist(entry);
+                            entry.label.should.be.equal('modified');
+                            entry.amount.should.be.equal(55);
+                            new Date(entry.date).should.eql(new Date(2014,11,9));
+                            
+                            done(); 
+                        });
+                });
+          });
+          
+          it('should fail to modify the given entry without data', function(done) {
+              request(globalServer)
+                .post('/api/accounts/' + account_id + '/entries')
+                .send({
+                    label: 'test',
+                    amount: 50,
+                    date: new Date('2014-12-08')
+                })
+                .set('Authorization', 'JWT ' + token)
+                .end(function(error, result) {
+                    var entry_id = result.body._id;
+                    request(globalServer)
+                        .put('/api/accounts/' + account_id + '/entries/' + entry_id)
+                        .set('Authorization', 'JWT ' + token)
+                        .expect(400, done);
+                });
+          });
+          
+          it('should fail to modify unknown entry', function(done) {
+              request(globalServer)
+                .post('/api/accounts/' + account_id + '/entries')
+                .send({
+                    label: 'test',
+                    amount: 50,
+                    date: new Date('2014-12-08')
+                })
+                .set('Authorization', 'JWT ' + token)
+                .end(function(error, result) {
+                    var entry_id = result.body._id;
+                    request(globalServer)
+                        .put('/api/accounts/' + account_id + '/entries/' + token)
+                        .send({
+                            label: 'modified',
+                            amount: 55,
+                            date: new Date('2014-12-09')
+                        })
+                        .set('Authorization', 'JWT ' + token)
+                        .expect(404, done);
+                });
+          });
+          
+          it('should fail to modify invalid entry', function(done) {
+              request(globalServer)
+                .post('/api/accounts/' + account_id + '/entries')
+                .send({
+                    label: 'test',
+                    amount: 50,
+                    date: new Date('2014-12-08')
+                })
+                .set('Authorization', 'JWT ' + token)
+                .end(function(error, result) {
+                    var entry_id = result.body._id;
+                    request(globalServer)
+                        .put('/api/accounts/' + account_id + '/entries/1')
+                        .send({
+                            label: 'modified',
+                            amount: 55,
+                            date: new Date('2014-12-09')
+                        })
+                        .set('Authorization', 'JWT ' + token)
+                        .expect(404, done);
+                });
+          });
+          
+          it('should fail to modify the given entry for unknown account', function(done) {
+              request(globalServer)
+                .post('/api/accounts/' + account_id + '/entries')
+                .send({
+                    label: 'test',
+                    amount: 50,
+                    date: new Date('2014-12-08')
+                })
+                .set('Authorization', 'JWT ' + token)
+                .end(function(error, result) {
+                    var entry_id = result.body._id;
+                    request(globalServer)
+                        .put('/api/accounts/' + token + '/entries/' + entry_id)
+                        .send({
+                            label: 'modified',
+                            amount: 55,
+                            date: new Date('2014-12-09')
+                        })
+                        .set('Authorization', 'JWT ' + token)
+                        .expect(404, done);
+                });
+          });
+          
+          it('should fail to modify the given entry for invalid account', function(done) {
+              request(globalServer)
+                .post('/api/accounts/' + account_id + '/entries')
+                .send({
+                    label: 'test',
+                    amount: 50,
+                    date: new Date('2014-12-08')
+                })
+                .set('Authorization', 'JWT ' + token)
+                .end(function(error, result) {
+                    var entry_id = result.body._id;
+                    request(globalServer)
+                        .put('/api/accounts/1/entries/' + entry_id)
+                        .send({
+                            label: 'modified',
+                            amount: 55,
+                            date: new Date('2014-12-09')
+                        })
+                        .set('Authorization', 'JWT ' + token)
+                        .expect(404, done);
+                });
+          });
+          
+          it('should fail to modify the given not owned entry', function(done) {
+              request(globalServer)
+                .post('/api/accounts/' + account_id + '/entries')
+                .send({
+                    label: 'test',
+                    amount: 50,
+                    date: new Date('2014-12-08')
+                })
+                .set('Authorization', 'JWT ' + token)
+                .end(function(error, result) {
+                    var entry_id = result.body._id;
+                    request(globalServer)
+                        .put('/api/accounts/' + account_id + '/entries/' + entry_id)
+                        .send({
+                            label: 'modified',
+                            amount: 55,
+                            date: new Date('2014-12-09')
+                        })
+                        .set('Authorization', 'JWT ' + hacker_token)
+                        .expect(401, done);
+                });
+          });
+       });
+       
+       describe('* Deletion', function() {
+          it('should delete the given entry', function(done) {
+             request(globalServer)
+                .post('/api/accounts/' + account_id + '/entries')
+                .send({
+                    label: 'test',
+                    amount: 50,
+                    date: new Date('2014-12-08')
+                })
+                .set('Authorization', 'JWT ' + token)
+                .end(function(error, result) {
+                    var entry_id = result.body._id;
+                    request(globalServer)
+                        .delete('/api/accounts/' + account_id + '/entries/' + entry_id)
+                        .set('Authorization', 'JWT ' + token)
+                        .expect(204, done);
+                });
+          });
+          
+          it('should fail to delete an unknown entry', function(done) {
+              request(globalServer)
+                .delete('/api/accounts/' + account_id + '/entries/' + token)
+                .set('Authorization', 'JWT ' + token)
+                .expect(404, done);
+          });
+          
+          it('should fail to delete an invalid entry', function(done) {
+              request(globalServer)
+                .delete('/api/accounts/' + account_id + '/entries/1')
+                .set('Authorization', 'JWT ' + token)
+                .expect(404, done);
+          });
+          
+          it('should fail to delete the not owned given entry', function(done) {
+             request(globalServer)
+                .post('/api/accounts/' + account_id + '/entries')
+                .send({
+                    label: 'test',
+                    amount: 50,
+                    date: new Date('2014-12-08')
+                })
+                .set('Authorization', 'JWT ' + token)
+                .end(function(error, result) {
+                    var entry_id = result.body._id;
+                    request(globalServer)
+                        .delete('/api/accounts/' + account_id + '/entries/' + entry_id)
+                        .set('Authorization', 'JWT ' + hacker_token)
+                        .expect(401, done);
+                });
+          });
+       });
+    });
+});

+ 119 - 0
test/db.js

@@ -0,0 +1,119 @@
+var mongoose    = require('mongoose'),
+    jwt         = require('jsonwebtoken'),
+    security    = require('../config/security');
+    
+
+var USER_ID = '55c9e2e3d300cc798928cc87',
+    HACKER_ID = '55c9e2e4d300cc798928cc88',
+    ACCOUNT_ID = '55c9e2fcd300cc798928cc8b';
+    
+var DATA = {
+        User: [
+            {
+                _id: USER_ID, 
+                username: 'test', 
+                password: 's3cr3t'
+            },
+            {
+                _id: HACKER_ID, 
+                username: 'hacker', 
+                password: 'bl4ckh4t'
+            }
+        ],
+        Account: [
+            {
+                _id: ACCOUNT_ID,
+                name: 'Default', 
+                reference: '1234567890', 
+                user_id: USER_ID, 
+                categories: [{key: 'test', label: 'Test', sub_categories: []}]
+            }
+        ],
+        Entry: [
+            {
+                account_id: ACCOUNT_ID,
+                label: 'Test bill',
+                type: 'BILL',
+                amount: -100,
+                date: '2015-08-13', 
+            }
+        ]
+    },
+    
+    process_collection = function(collection, data, done) {
+        mongoose.connection.base.models[collection].find({}).remove(function(err) {
+			if (err) {
+			    console.log('Can\'t delete collection ' + collection, err );
+			}
+            var res = [];
+            for( var item  in data ) {
+                mongoose.connection.base.models[collection].create(data[item], function(err, newItem) {
+                    res.push(err);
+                    if( err ) {
+                        console.log('Can\'t insert document', err);
+                    }
+                    if (res.length === data.length) {
+                        return done();
+                    }
+                    
+                    newItem.save(function(error) {
+                        res.push(err);
+                        if (res.length === data.length) {
+                            return done();
+                        }
+                    });
+                });
+            }
+        });
+    },
+    
+    drop_collection = function(collection, data, done) {
+        mongoose.connection.base.models[collection].find({}).remove(function(err) {
+			if (err) {
+			    console.log('Can\'t delete collection ' + collection, err );
+			}
+            return done();
+        });
+    };
+
+module.exports = {
+    USER_ID: USER_ID,
+    HACKER_ID: HACKER_ID,
+    ACCOUNT_ID: ACCOUNT_ID,
+    
+    init : function(done) {
+        var collections_to_process = Object.keys(DATA).length,
+            collectionsDone = 0;
+        
+        for( var collection in DATA ) {
+           process_collection(collection, DATA[collection], function() {
+               collectionsDone++;
+               if( collectionsDone === collections_to_process ) {
+                   done();
+               }
+           }) 
+        }
+    },
+    
+    drop : function(done) {
+        var collections_to_process = Object.keys(DATA).length,
+            collectionsDone = 0;
+        
+        for( var collection in DATA ) {
+           drop_collection(collection, DATA[collection], function() {
+               collectionsDone++;
+               if( collectionsDone === collections_to_process ) {
+                   done();
+               }
+           }) 
+        }
+    },
+    
+    get_user_token: function() {
+        return jwt.sign( { user_id: USER_ID}, security.jwt.secretOrKey);
+    },
+    
+    get_hacker_token: function() {
+        return jwt.sign( { user_id: HACKER_ID}, security.jwt.secretOrKey);
+    }
+}

+ 23 - 0
test/server.js

@@ -0,0 +1,23 @@
+var should = require('should'), 
+    request = require('supertest'),
+    app  = require('../server.js'),
+    globalServer;
+
+describe('Static resources', function(){
+    
+    before(function () {
+        globalServer = app.listen();
+    });
+
+    after(function () {
+        globalServer.close();
+    });
+    
+    it('should send index.html', function(done){
+            request(globalServer)
+            .get('/')
+            .set('Accept', 'text/html')
+            .expect('Content-Type', /html/)
+            .expect(200, done);
+    })
+})

+ 198 - 0
test/users.js

@@ -0,0 +1,198 @@
+var should          = require('should'), 
+    request         = require('supertest'),
+    app             = require('../server.js'),
+    mongoose        = require('mongoose'),
+    User            = mongoose.model('User'),
+    Db              = require('./db.js'),
+    sinon           = require('sinon'),
+    EventEmitter    = require('../app/events/listeners'),
+    globalServer, token, hacker_token, account_id, user_id;
+
+describe('API /users', function() {
+    
+    before(function(done) {
+        globalServer = app.listen();
+        token = Db.get_user_token();
+        hacker_token = Db.get_hacker_token();
+        account_id = Db.ACCOUNT_ID;
+        user_id = Db.USER_ID;
+        Db.init(done);
+    });
+    
+    after( function() {
+        globalServer.close(); 
+    });
+    
+    describe('* Login', function() {
+        
+        it('should log successfully', function(done) {
+            request(globalServer)
+                .post('/api/users/login')
+                .send({
+                    username: 'test',
+                    password: 's3cr3t'
+                })
+                .set('Accept', 'application/json')
+                .expect(200)
+                .expect('Content-Type', /json/)
+                .end( function(error, result) {
+                    should.not.exist(error);
+                    
+                    var user = result.body;
+                    should.exist(user);
+                    user.username.should.be.equal('test');
+                    should.exist(user.token);
+                    done();
+                });
+        });
+    
+        it('should fail login', function(done) {
+            request(globalServer)
+                .post('/api/users/login')
+                .send({
+                    username: 'test',
+                    password: 'secret'
+                })
+                .set('Accept', 'application/json')
+                .expect(401, done);
+        });
+        
+        it('should logout', function(done) {
+           request(globalServer)
+            .delete('/api/users/login')
+            .expect(200, done);
+        });
+    });
+    
+    describe('* Registration', function() {
+       
+       it('should fail without any params', function(done) {
+           request(globalServer)
+            .post('/api/users')
+            .set('Accept', 'application/json')
+            .expect(400)
+            .end(function(err, result) {
+                var errors = result.body;
+                should.exist(errors);
+                errors.should.be.instanceof(Array).and.have.lengthOf(2);
+                done();
+            });
+       });
+       
+       it('should fail without a password', function(done) {
+           request(globalServer)
+            .post('/api/users')
+            .send( { username: 'registration'})
+            .expect(400, done);
+       });
+       
+       it('should fail without an username', function(done) {
+           request(globalServer)
+            .post('/api/users')
+            .send({password: 'secret'})
+            .set('Accept', 'application/json')
+            .expect(400, done);
+       });
+       
+       it('should fail on duplicate account', function(done) {
+           request(globalServer)
+            .post('/api/users')
+            .send({
+                username: 'test',
+                password: 'secret'
+            })
+            .set('Accept', 'application/json')
+            .expect(409, done);
+       });
+       
+       it('should register successfully', function(done) {
+           request(globalServer)
+            .post('/api/users')
+            .send({
+                username: 'registration',
+                password: 'secret'
+            })
+            .set('Accept', 'application/json')
+            .expect(201)
+            .end(function(error, result) {
+                
+                should.not.exist(error);
+                var user = result.body;
+                should.exist(user);
+                user.username.should.be.equal('registration');
+                should.exist(user.token);
+                User.getAuthenticated('registration', 'secret', function(error, user) {
+                    should.not.exist(error);
+                    should.exist(user);
+                    done();
+                });
+            });
+       });
+       
+    });
+    
+    describe('* Deregistration', function() {
+        it('should fail to delete user account without security token', function(done) {
+            request(globalServer)
+                .delete('/api/users')
+                .expect(401, done);
+        });
+        
+        it('should fail to delete user account with fake security token', function(done) {
+            request(globalServer)
+                .delete('/api/users')
+                .set('Authorization', 'JWT fake_token')
+                .expect(401, done);
+        });
+        
+        it('should delete user with accounts and entries', function(done) {
+            var eventEmitter= EventEmitter.eventEmitter,
+                spy_accounts = sinon.spy(),
+                spy_entries = sinon.spy();
+            
+            eventEmitter.on(EventEmitter.events.ACCOUNTS_DELETE_BY_USER_ID_EVT, spy_accounts);
+            eventEmitter.on(EventEmitter.events.ENTRIES_DELETE_BY_ACCOUNT_EVT, spy_entries)
+            
+            request(globalServer)
+                .delete('/api/users')
+                .set('Authorization', 'JWT ' + token)
+                .expect(204)
+                .end(function(error, result) {
+                    User.findOne({username: 'test'}, function(error, user) {
+                        should.not.exist(error);
+                        should.not.exist(user);
+
+                        sinon.assert.calledWith(spy_accounts, user_id);
+                        spy_entries.called.should.equal.true;
+                        spy_entries.args[0][0].id.should.be.equal(account_id);
+                        done();
+                    });
+                });
+        });
+
+        it('should delete user without account', function(done) {
+            var eventEmitter= EventEmitter.eventEmitter,
+                spy_accounts = sinon.spy(),
+                spy_entries = sinon.spy();
+            
+            eventEmitter.on(EventEmitter.events.ACCOUNTS_DELETE_BY_USER_ID_EVT, spy_accounts);
+            eventEmitter.on(EventEmitter.events.ENTRIES_DELETE_BY_ACCOUNT_EVT, spy_entries)
+            
+            request(globalServer)
+                .delete('/api/users')
+                .set('Authorization', 'JWT ' + hacker_token)
+                .expect(204)
+                .end(function(error, result) {
+                    User.findOne({username: 'hacker'}, function(error, user) {
+                        should.not.exist(error);
+                        should.not.exist(user);
+
+                        spy_accounts.called.should.equal.true;
+                        spy_entries.called.should.equal.false;
+                        
+                        done();
+                    });
+                });
+        });
+    });
+});