Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

add prices and other improvements #11

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
Open
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
9 changes: 9 additions & 0 deletions .vscode/launch.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,15 @@
"console": "integratedTerminal",
"internalConsoleOptions": "neverOpen",
"protocol": "inspector"
},
{
"name": "Attach by Process ID",
"processId": "${command:PickProcess}",
"request": "attach",
"skipFiles": [
"<node_internals>/**"
],
"type": "node"
}
]
}
7 changes: 5 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@
],
"scripts": {
"build": "tsc",
"test": "./node_modules/.bin/nyc ./node_modules/.bin/mocha -r ts-node/register test/*.ts"
"auth": "./node_modules/.bin/ts-node ./test/http-server-oauth-authorize.ts",
Copy link
Author

@nbelyh nbelyh Sep 20, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To get OAuth2 token: npm run auth. As far as I understand, now fortnox only allows OAuth2 for "new" apps

"test": "./node_modules/.bin/nyc ./node_modules/.bin/mocha -r ts-node/register test/*.spec.ts"
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

changes .ts => .spec.ts (now test folder contains also non-test helper files)

},
"author": "Gustaf Ridderstolpe, Enfo Sweden AB",
"repository": "github:obrut/fortnox",
Expand Down Expand Up @@ -45,7 +46,9 @@
"src/**/*"
],
"exclude": [
"**/*.d.ts"
"**/*.d.ts",
"src/dispatch.ts",
"src/utils.ts"
],
"reporter": [
"text"
Expand Down
7 changes: 1 addition & 6 deletions src/articles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,10 @@ type ArticleResult = {

export class Articles {
private dispatch: Dispatch;
private util: Util;
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Made Util use static methods (they are private anyway and not used outside). There seem to be no reason for extra allocations and repeating code.

private path = 'articles';

constructor(dispatch: Dispatch){
this.dispatch = dispatch;
this.util = new Util();
}

async get(articleNumber: string) {
Expand All @@ -22,10 +20,7 @@ export class Articles {
}

async getAll(filter?: string) {
let path = this.path;
if (filter)
path += '?filter=' + filter;
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unified all "filter" parameters (remove repeated code)

const result = await this.util.getAllPages(path, 'Articles', this.dispatch) as FNArticle[];
const result = await Util.getAllPages(this.path, 'Articles', this.dispatch, filter) as FNArticle[];
return result;
}

Expand Down
7 changes: 1 addition & 6 deletions src/customers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,10 @@ type CustomerResult = {

export class Customers {
private dispatch: Dispatch;
private util: Util;
private path = 'customers'

constructor(dispatch: Dispatch){
this.dispatch = dispatch;
this.util = new Util();
}

async get(customerNumber: string) {
Expand All @@ -22,10 +20,7 @@ export class Customers {
}

async getAll(filter?: string) {
let path = this.path;
if (filter)
path += '?filter=' + filter;
const result = await this.util.getAllPages(path, 'Customers', this.dispatch) as FNCustomer[];
const result = await Util.getAllPages(this.path, 'Customers', this.dispatch, filter) as FNCustomer[];
return result;
}

Expand Down
33 changes: 24 additions & 9 deletions src/dispatch.ts
Original file line number Diff line number Diff line change
@@ -1,40 +1,55 @@
import fetch from 'node-fetch';
import { Defaults } from './types/defaults';
import { Util } from './utils';

export class Dispatch {
private host: string;
private defaults: Defaults;

constructor(config: {Host: string, Defaults: Defaults}) {
constructor(config: { Host: string, Defaults: Defaults }) {
this.host = config.Host,
this.defaults = config.Defaults
this.defaults = config.Defaults
}

async get(path?: string) {
const response = await fetch(`${this.host}${path}`, { method: 'GET', headers: this.defaults.headers });
if (response.status === 200)
return await response.json() as object;
throw new Error(response.statusText);
const timeouts = [250, 250, 250, 500, 1000, 2000, 4000, 8000];
let timeout = 0;

while (true) {
const response = await fetch(`${this.host}${path}`, { method: 'GET', headers: this.defaults.headers })
if (response.status === 200) {
return await response.json() as object;
}

// 429 = too many requests (overload), use exponential retries
if (response.status === 429 && timeout < timeouts.length) {
await Util.delay(timeouts[timeout]);
timeout += 1;
continue;
}

throw await Util.makeErrorFromResponse(response)
}
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"exponential" exception handling for "too many requests".

}

async post(path: string, body: any) {
const response = await fetch(`${this.host}${path}`, { method: 'POST', headers: this.defaults.headers, body: JSON.stringify(body, null, 4) });
if (response.status === 201)
return await response.json() as object;
throw new Error(response.statusText);
throw await Util.makeErrorFromResponse(response);
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use FORTNOX error, do not ignore it - it is really useful to figure out what went wrong

}

async put(path: string, body?: any) {
const response = await fetch(`${this.host}${path}`, { method: 'PUT', headers: this.defaults.headers, body: body && JSON.stringify(body, null, 4) });
if (response.status === 200)
return await response.json() as object;
throw new Error(response.statusText);
throw await Util.makeErrorFromResponse(response);
}

async delete(path: string) {
const response = await fetch(`${this.host}${path}`, { method: 'DELETE', headers: this.defaults.headers });
if (response && response.status === 204)
return response.ok;
throw new Error(response.statusText);
throw await Util.makeErrorFromResponse(response);
}
}
17 changes: 13 additions & 4 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,33 +1,42 @@
import { Articles } from './articles';
import { Customers } from './customers';
import { Invoices } from './invoices';
import { Prices } from './prices';
import { SupplierInvoices } from './supplierinvoices'
import { Dispatch } from './dispatch';
import { Suppliers } from './suppliers';
import { Defaults } from './types/defaults';

export class Fortnox {
constructor(config: { host: string, clientSecret: string, accessToken: string }){
constructor(config: {
host: string,
bearerToken?: string,
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add bearer token support for OAuth2

clientSecret?: string,
accessToken?: string,
}) {
const defaults: Defaults = {
json: true,
headers: {
'client-secret': config.clientSecret,
'access-token': config.accessToken,
...(config.bearerToken && { 'Authorization': `Bearer ${config.bearerToken}` }),
...(config.clientSecret && { 'client-secret': config.clientSecret }),
...(config.accessToken && { 'access-token': config.accessToken }),
'Content-Type': 'application/json',
'Accept': 'application/json'
}
}
const dispatch = new Dispatch( {Host: config.host, Defaults: defaults } );
const dispatch = new Dispatch({ Host: config.host, Defaults: defaults });
this.articles = new Articles(dispatch);
this.customers = new Customers(dispatch);
this.invoices = new Invoices(dispatch);
this.supplierInvoices = new SupplierInvoices(dispatch);
this.suppliers = new Suppliers(dispatch);
this.prices = new Prices(dispatch);
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add prices support

}

public articles: Articles;
public customers: Customers;
public invoices: Invoices;
public supplierInvoices: SupplierInvoices;
public suppliers: Suppliers;
public prices: Prices;
}
8 changes: 3 additions & 5 deletions src/invoices.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,26 +8,24 @@ type InvoiceResult = {

export class Invoices {
private dispatch: Dispatch;
private util: Util;
private path = 'invoices';

constructor(dispatch: Dispatch){
this.dispatch = dispatch;
this.util = new Util();
}

async get(documentNumber?: string) {
const result = await this.dispatch.get(`${this.path}/${documentNumber || ''}`) as InvoiceResult;
return result.Invoice;
}

async getAll(filter: string) {
const result = await this.util.getAllPages(this.path + '?filter=' + filter, 'Invoices', this.dispatch) as InvoiceResult[];
async getAll(filter?: string) {
const result = await Util.getAllPages(this.path, 'Invoices', this.dispatch, filter) as FNInvoice[];
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fix a bug - the results is array of FNInvoice[] actually, not of InvoiceResult[].

return result;
}

async getByCustomer(customerNumber: string) {
const allInvoices = await this.util.getAllPages(`${this.path}/`, 'Invoices', this.dispatch);
const allInvoices = await Util.getAllPages(this.path, 'Invoices', this.dispatch);
return allInvoices.filter(invoice => invoice.CustomerNumber.toLowerCase() == customerNumber.toLowerCase());
}

Expand Down
44 changes: 44 additions & 0 deletions src/prices.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { Dispatch } from './dispatch';
import { FNPrice } from './types/FNPrice';
import { Util } from './utils';

type PriceResult = {
Price: FNPrice
}

export class Prices {
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Prices/price lists support

private dispatch: Dispatch;
private path = 'prices';

constructor(dispatch: Dispatch) {
this.dispatch = dispatch;
}

async getAll(priceList?: string, articleNumber?: string) {
let path = `${this.path}/sublist`;
if (priceList)
path += `/${priceList}`;
if (articleNumber)
path += `/${articleNumber}`
const result = await Util.getAllPages(path, 'Prices', this.dispatch) as FNPrice[];
return result;
}

async create(price: any) {
const result = await this.dispatch.post(this.path, { Price: price }) as PriceResult;
return result.Price;
}

async update(price: FNPrice) {
const result = await this.dispatch.put(`${this.path}/${price.PriceList}/${price.ArticleNumber}/${price.FromQuantity}`, { Price: price }) as PriceResult;
return result.Price;
}

async remove(priceList: string, articleNumber: string, fromQuantity?: number) {
let path = `${this.path}/${priceList}/${articleNumber}`;
if (fromQuantity)
path += `/${fromQuantity}`;
const result = await this.dispatch.delete(path);
return result;
}
}
7 changes: 1 addition & 6 deletions src/supplierinvoices.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,10 @@ type SupplierInvoiceResult = {

export class SupplierInvoices {
private dispatch: Dispatch;
private util: Util;
private path = 'supplierinvoices';

constructor(dispatch: Dispatch){
this.dispatch = dispatch;
this.util = new Util();
}

async get(givenNumber?: string) {
Expand All @@ -23,10 +21,7 @@ export class SupplierInvoices {
}

async getAll(filter?: string) {
let path = this.path;
if (filter)
path += '?filter=' + filter;
const result = await this.util.getAllPages(path, 'SupplierInvoices', this.dispatch) as FNSupplierInvoice[];
const result = await Util.getAllPages(this.path, 'SupplierInvoices', this.dispatch, filter) as FNSupplierInvoice[];
return result;
}

Expand Down
16 changes: 5 additions & 11 deletions src/suppliers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,30 +3,24 @@ import { FNSupplier } from './types/FNSupplier';
import { Util } from './utils';

type SupplierResult = {
Supplier: FNSupplier,
Suppliers: FNSupplier[]
Supplier: FNSupplier
}

export class Suppliers {
private dispatch: Dispatch;
private util: Util;
private path = 'suppliers';

constructor(dispatch: Dispatch){
this.dispatch = dispatch;
this.util = new Util();
}

async get(supplierNumber?: string) {
const result = await this.dispatch.get(this.path) as SupplierResult;
return supplierNumber ? result.Supplier : result.Suppliers;
async get(supplierNumber: string) {
const result = await this.dispatch.get(`${this.path}/${supplierNumber}`) as SupplierResult;
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fix a bug - parameter not used (should be part of the query path)

return result.Supplier;
}

async getAll(filter?: string) {
let path = this.path;
if (filter)
path += '?filter=' + filter;
const result = await this.util.getAllPages(path, 'Suppliers', this.dispatch) as FNSupplier[];
const result = await Util.getAllPages(this.path, 'Suppliers', this.dispatch, filter) as FNSupplier[];
return result;
}

Expand Down
6 changes: 6 additions & 0 deletions src/types/FNPrice.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export type FNPrice = {
ArticleNumber?: string,
FromQuantity?: number,
Price?: number,
PriceList?: string,
}
30 changes: 28 additions & 2 deletions src/utils.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,47 @@
import { Dispatch } from "./dispatch";

export class Util {
async getAllPages(path: string, arrayName: string, dispatch: Dispatch) {
static async getAllPages(path: string, arrayName: string, dispatch: Dispatch, filter?: string) {
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unify filter/paging. Before it had an issue that filter + paging did not work together.

const items: any = await dispatch.get(path);

if (filter)
path += '?filter=' + filter;

let allItems: any[] = [];
const totalPages = Number.parseInt(items.MetaInformation['@TotalPages']);
allItems.push(...items[arrayName]);

if (totalPages > 1){
let currentPage: number = 2;
while(currentPage <= totalPages) {
allItems.push(...await dispatch.get(path + '&page=' + currentPage)[arrayName]);
const pageItems: any = await dispatch.get(path + (filter ? '&' : '?') + 'page=' + currentPage);
allItems.push(...pageItems[arrayName]);
currentPage++;
}
}

return allItems;
}

// convert fortnox error into standard error
static async makeErrorFromResponse (response: any) {

// get fortnox error if exist
if (response?.headers?.get('content-type')?.startsWith('application/json')) {
const fnError = await response.json();
Copy link
Author

@nbelyh nbelyh Sep 20, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fortnox error is packed like { ErrorInformation: { message: "bla-bla-bla", code: 100500 } }

const fnMessage = fnError?.ErrorInformation?.message;
if (fnMessage) {
return new Error(fnMessage);
}
}

return new Error(response.statusText ?? "An error has occured");
}

// a promise that resolves after specified amount of time
static delay(ms: number): Promise<void> {
return new Promise((resolve: () => void) => {
setTimeout(resolve, ms);
});
}
}
Loading