Skip to content
Open

Doc #37

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 11 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,18 @@
A work in progress - Experiment in which we attempt to manage invoices, expenses, budgets and analysis in a transparent manner.

## .env
```MONGODB_URI``` Path to reach your database (e.g.```mongodb+srv://<username>:<password>@cluster0-e7fdv.mongodb.net/test?retryWrites=true```)
```MONGODB_URI``` Path to reach your database (e.g.```mongodb+srv://<username>:<password>@cluster0-e7fdv.mongodb.net/test?retryWrites=true```)

```DB_NAME``` The database name (e.g. ```open-wallet``` or ```test```)
```DB_NAME``` The database name (e.g. ```open-wallet``` or ```test```)

```APP_SECRET``` A secret used by JWT to sign tokens, e.g. ```A secret is a secret``` (this is not a good secret)
```APP_SECRET``` A secret used by JWT to sign tokens, e.g. ```A secret is a secret``` (this is not a good secret)

```CLOUDINARY_URL``` e.g. ```cloudinary://<my_key>:<my_secret>@<my_cloud_name>```

```STRING_MAX_CHAR``` maximum string length authorized for user inputs (```default 500```)

```HASH_ROUNDS``` bcrypt hash rounds (```default 10```)

```INVOICE_REF_SIZE``` number of characters of an invoice reference (```default 4```)

[See example](./backend/.env.example)
6 changes: 5 additions & 1 deletion backend/.env.example
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
MONGODB_URI=mongodb+srv://<username>:<password>@cluster0-e7fdv.mongodb.net/test?retryWrites=true
DB_NAME=test
APP_SECRET=this_is_a_secret
APP_SECRET=this_is_a_secret
CLOUDINARY_URL=cloudinary://<my_key>:<my_secret>@<my_cloud_name>
STRING_MAX_CHAR=1000
HASH_ROUNDS=13
INVOICE_REF_SIZE=4
2 changes: 1 addition & 1 deletion backend/src/auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ const loggedUser = async ({ token }, { User }) => {
/**
* Returns whether a user is logged in or not.
* @param { object } user - logged in user
* @returns {boolean} True of false
* @returns {boolean} True or false
*/
const loggedIn = ({ user }) => !!user;

Expand Down
5 changes: 5 additions & 0 deletions backend/src/constants.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
/**
* Gather constants that are neither env variables nor messages.
* TR = transaction
*/

exports.TR_FLOW = {
IN: 'IN',
OUT: 'OUT'
Expand Down
9 changes: 9 additions & 0 deletions backend/src/db.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
const mongoose = require('mongoose');

/**
* Open connection to the database and creates collections.
*/
module.exports.connect = async () => {
return mongoose
.connect(process.env.MONGODB_URI, {
Expand All @@ -21,6 +24,9 @@ module.exports.connect = async () => {
});
};

/**
* Close the database connection. Used in tests.
*/
module.exports.disconnect = async () => {
return mongoose
.disconnect()
Expand All @@ -30,4 +36,7 @@ module.exports.disconnect = async () => {
});
};

/**
* Expose mongoose startSession function.
*/
module.exports.startSession = async () => mongoose.startSession();
30 changes: 27 additions & 3 deletions backend/src/graphql/resolvers/mutations.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,12 @@
/**
* Saves (updates) or retrieves a company.
* If id is set the company will only be retrieved
* If name is set (without id), the company will be updated.
* @param {Company} company company's data
* @param {Model} Company Mongoose model
* @param {boolean} upsert true will insert if not found when only a name is provided.
* @returns {Promise} Promise that represents the company
*/
const saveOrRetrieveCompany = async (company, Company, upsert) => {
if (!company) return null;
const { name, id } = company;
Expand All @@ -11,17 +20,32 @@ const saveOrRetrieveCompany = async (company, Company, upsert) => {
return null;
};

/**
* Returns an invoice reference.
* @param {string} id Counter document id
* @param {Model} Counter Mongoose model
* @returns {string} the reference
*/
const getInvoiceRef = async (id, Counter) => {
const counter = await Counter.findByIdAndUpdate(
id,
{ $inc: { sequence: 1 } },
{ new: true, upsert: true }
);
const INVOICE_REF_SIZE = process.env.INVOICE_REF_SIZE || 4;
const INVOICE_REF_SIZE = parseInt(process.env.INVOICE_REF_SIZE, 10) || 4;
const z = '0';
return `${id}/${`${z.repeat(INVOICE_REF_SIZE)}${counter.sequence}`.slice(-INVOICE_REF_SIZE)}`;
};

/**
* Stores file by passing it to the cloud storage
* @param {Stream} file a file stream
* @param {string} tags tags to give to the file
* @param {string} folder folder in which to store the file
* @param {object} cloudinary cloudinary functions
* @param {boolean} generated flag that tells whether the file has been generated by the backend or not
* @returns {Promise} Promise that represents the file
*/
const store = (file, tags, folder, cloudinary, generated) =>
new Promise((resolve, reject) => {
const uploadStream = cloudinary.uploader.upload_stream({ tags, folder }, (err, image) => {
Expand Down Expand Up @@ -139,7 +163,6 @@ module.exports = {
const { formatData, rules, messages } = uploadInvoiceValidation;
await validate(formatData({ ...invoice }), rules, messages);

// look for a company (by id or name), if name is provided: update it or save it if doesn't exist
const company = await saveOrRetrieveCompany(invoice.company, Company, true);
invoice.company = company;
if (invoice.category && (invoice.category.name || invoice.category.id)) {
Expand Down Expand Up @@ -174,7 +197,6 @@ module.exports = {
) => {
const { formatData, rules, messages } = generateInvoiceValidation;

// look for a company (by id or name), if name is provided: update it or save it if doesn't exist
const company = await saveOrRetrieveCompany(invoice.company, Company, true);
invoice.company = company;

Expand All @@ -184,6 +206,8 @@ module.exports = {
invoice.flow = TR_FLOW.OUT;
invoice.date = Date.now();

// TODO add category to the invoice

const noInvoice = await getInvoiceRef(new Date(invoice.date).getFullYear(), Counter);
invoice.ref = noInvoice;

Expand Down
33 changes: 28 additions & 5 deletions backend/src/graphql/types.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,21 +6,31 @@ module.exports = gql`

# types
type Query {
users: [User]! @auth
"Returns all the users - inaccessible for unauthenticated users"
users: [User]! @auth #TODO restrict access
"Returns the authenticated user profile"
me: User @auth
"Returns the authenticated user expenses"
myExpenses: [Transaction]! @auth
transactions: [Transaction]! @auth
"Returns all transactions - inaccessible for unauthenticated users"
transactions: [Transaction]! @auth #TODO restrict access
"Returns all companies - inaccessible for unauthenticated users"
companies: [Company] @auth
}

type Mutation {
"Registers a user"
register(user: UserInput!): User! @guest
logout: Boolean!
login(email: String!, password: String!): User! @guest
"Handles expense claims - inaccessible for unauthenticated users"
expenseClaim(expense: Expense!): Transaction! @auth
"Handles profile updates - inaccessible for unauthenticated users"
updateProfile(user: UserUpdateInput!): User! @auth
uploadInvoice(invoice: InvoiceUpload!): Transaction! @auth
generateInvoice(invoice: GenerateInvoiceInput!): Transaction! @auth
"Handles invoice uploads - inaccessible for unauthenticated users"
uploadInvoice(invoice: InvoiceUpload!): Transaction! @auth #TODO restrict access
"Handles invoice generations - inaccessible for unauthenticated users"
generateInvoice(invoice: GenerateInvoiceInput!): Transaction! @auth #TODO restrict access
}
type Success {
status: Boolean!
Expand All @@ -32,7 +42,7 @@ module.exports = gql`
zipCode: Int!
}

type BankDetails {
type BankDetails { # need attention
iban: String!
bic: String
}
Expand All @@ -58,6 +68,7 @@ module.exports = gql`
expDate: String
description: String
file: String
"VAT rate"
VAT: Int
type: TransactionType!
ref: String
Expand All @@ -72,6 +83,7 @@ module.exports = gql`
name: String!
email: String
phone: String
"VAT number"
VAT: String
bankDetails: BankDetails
address: Address
Expand Down Expand Up @@ -111,6 +123,7 @@ module.exports = gql`
date: String
description: String!
receipt: Upload!
"VAT rate"
VAT: Int
}

Expand All @@ -121,6 +134,7 @@ module.exports = gql`
company: CompanyInput
expDate: String
invoice: Upload!
"VAT rate"
VAT: Int
}

Expand All @@ -137,6 +151,8 @@ module.exports = gql`
input GenerateInvoiceInput {
company: CompanyInput!
details: [GenerateInvoiceDetailsInput!]!
# add category
"VAT rate"
VAT: Int!
}

Expand All @@ -146,13 +162,20 @@ module.exports = gql`
}

#enums

"Transaction flow"
enum Flow {
"Incoming transaction"
IN
"Outgoing transaction"
OUT
}

"Transaction state"
enum State {
"An unreviewed transaction"
UNCLEARED
"An accepted transaction"
CLEARED
PAID
REJECTED
Expand Down
1 change: 0 additions & 1 deletion backend/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,6 @@ if (process.env.NODE_ENV !== 'test') {
module.exports = {
models,
typeDefs,
// TODO exports resolvers in their on folder via index
resolvers: {
Query,
Mutation
Expand Down
12 changes: 11 additions & 1 deletion backend/src/invoiceFactory.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ const pdf = require('html-pdf');

const options = { format: 'Letter' };

// TODO retrieve from the database
const organization = {
name: 'Open Knowledge Belgium vzw',
address: {
Expand All @@ -23,7 +24,16 @@ const renderContactDetails = contact => {
}`;
};

module.exports = /* sender */ (details, metadata, receiver) => {
/**
* Generate an invoice.
* @example
* generateInvoice({[description: "", amount: 0]}, {VAT: 21, date: 13/05/2019, noInvoice: "2019/00001"}, company: Company)
* @param {object} details - invoice details (see example)
* @param {object} metadata - invoice metadata (see example)
* @param {Company} company buying company
* @returns {Promise} Promise that represents the file stream or an error
*/
module.exports = (details, metadata, receiver) => {
const total = details.reduce((acc, current) => acc + current.amount, 0);
const amountVAT = (total / 100) * metadata.VAT;
const totalInclVAT = total + amountVAT;
Expand Down
4 changes: 4 additions & 0 deletions backend/src/lib/validation.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
/* eslint-disable no-useless-escape */
/**
* We are using the api of the library indicative to achieve user input validation.
* Please refer to the doc: http://indicative.adonisjs.com/
*/
const { validateAll, configure } = require('indicative');
const { UserInputError } = require('apollo-server-express');
const {
Expand Down
4 changes: 3 additions & 1 deletion backend/src/messages.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
// export constant messages
/**
* Gather constant (format of) messages that are sent to the client.
*/
module.exports = {
MUST_LOGIN: 'You must be logged in.',
MUST_LOGOUT: 'You must be logged out.',
Expand Down
3 changes: 3 additions & 0 deletions backend/src/models/Counter.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
/**
* Model that creates unique sequential ids.
*/
const mongoose = require('mongoose');

const { Schema } = mongoose;
Expand Down
20 changes: 16 additions & 4 deletions backend/src/models/User.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ const bcrypt = require('bcrypt');
const address = require('./address');
const bankDetails = require('./bankDetails');

const HASH_ROUNDS = parseInt(process.env.HASH_ROUNDS, 10) || 10;

const { Schema } = mongoose;

const userSchema = new Schema({
Expand All @@ -30,6 +32,11 @@ const userSchema = new Schema({
]
});

/**
* Mongoose middleware.
* Hash the password and check for email existance.
* @throws {Error} if email found
*/
userSchema.pre('save', async function() {
const { email } = this;
// accessing the constructor to make request against the db
Expand All @@ -38,20 +45,25 @@ userSchema.pre('save', async function() {
throw new Error('Email already exists!');
}
if (this.isModified('password')) {
// TODO Retrieve hash factor from env
this.password = await bcrypt.hash(this.password, 10);
this.password = await bcrypt.hash(this.password, HASH_ROUNDS);
}
});

/**
* Mongoose middleware.
* Hash the password if provided.
*/
userSchema.pre('findOneAndUpdate', async function() {
const input = this.getUpdate();
const { password } = input;
if (password) {
// TODO Retrieve hash factor from env
this.getUpdate().password = await bcrypt.hash(password, 10);
this.getUpdate().password = await bcrypt.hash(password, HASH_ROUNDS);
}
});

/**
* Expose bcrypt compare function.
*/
userSchema.methods.rightPassword = async function(password) {
return bcrypt.compare(password, this.password);
};
Expand Down
1 change: 1 addition & 0 deletions backend/src/models/address.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
// This schema is meant to be a subdocument common to several documents
const mongoose = require('mongoose');

const { Schema } = mongoose;
Expand Down
1 change: 1 addition & 0 deletions backend/src/models/bankDetails.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
// This schema is meant to be a subdocument common to several documents
const mongoose = require('mongoose');

const { Schema } = mongoose;
Expand Down
Loading