Skip to content

Instantly share code, notes, and snippets.

@stravid
Last active August 29, 2015 14:03
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save stravid/0da9bd2e96b485eff0a4 to your computer and use it in GitHub Desktop.
Save stravid/0da9bd2e96b485eff0a4 to your computer and use it in GitHub Desktop.
Invoice Push Update Application
require 'sinatra'
require 'json'
current_invoice_id = 0
invoices = {}
before do
content_type :json
end
test('Invoice drafts are grouped and correctly displayed', function() {
visit('/');
andThen(function() {
equal(currentPath(), 'index');
});
});
import Ember from 'ember';
import startApp from '../helpers/start-app';
var dueAt = new Date();
dueAt.setTime(new Date().getTime() + 8 * 24 * 60 * 60 * 1000 + 60000);
var billedAt = new Date();
billedAt.setTime(new Date().getTime() - 1 * 24 * 60 * 60 * 1000 + 1000);
var paidAt = new Date(2014, 7, 12, 10);
var INVOICES = {
"1": {
id: 1,
title: 'Draft 1',
client: 'Company A',
description: '',
amount: 420,
status: 'draft',
due_at: null,
billed_at: null,
paid_at: null
},
"2": {
id: 2,
title: 'Draft 2',
client: 'Company B',
description: '',
amount: 4200,
status: 'draft',
due_at: null,
billed_at: null,
paid_at: null
},
"3": {
id: 3,
title: 'Open',
client: 'Company C',
description: '',
amount: 42200,
status: 'open',
due_at: dueAt,
billed_at: billedAt,
paid_at: null
},
"4": {
id: 4,
title: 'Paid',
client: 'Company D',
description: '',
amount: 422200,
status: 'paid',
due_at: dueAt,
billed_at: billedAt,
paid_at: paidAt
}
};
var App;
var server;
module('Acceptance: InvoiceOverview', {
setup: function() {
App = startApp();
server = new Pretender(function() {
this.get('/invoices', function(request) {
var result = JSON.stringify({ invoices: Object.keys(INVOICES).map(function(k) { return INVOICES[k]; }) });
return [200, { 'Content-Type': 'application/json' }, result];
});
});
},
teardown: function() {
Ember.run(App, 'destroy');
server.shutdown();
}
});
test('Invoice drafts are grouped and correctly displayed', function() {
visit('/');
andThen(function() {
equal(find('table.drafts tr:eq(1) td:eq(0)').text(), 'Draft 1');
equal(find('table.drafts tr:eq(1) td:eq(1)').text(), 'Company A');
equal(find('table.drafts tr:eq(1) td:eq(2)').text(), '€ 4,20');
equal(find('table.drafts tr:eq(2) td:eq(0)').text(), 'Draft 2');
equal(find('table.drafts tr:eq(2) td:eq(1)').text(), 'Company B');
equal(find('table.drafts tr:eq(2) td:eq(2)').text(), '€ 42,00');
equal(find('table.heading:eq(0) th:eq(1)').text(), '€ 46,20');
equal(find('table.drafts tr').length, 3);
});
});
import Ember from 'ember';
export default Ember.Route.extend({
model: function() {
return this.store.find('invoice');
}
});
import DS from 'ember-data';
export default DS.ActiveModelAdapter.extend({
});
import DS from 'ember-data';
export default DS.Model.extend({
title: DS.attr('string'),
client: DS.attr('string'),
description: DS.attr('string'),
status: DS.attr('string'),
amount: DS.attr('number'),
dueAt: DS.attr('date'),
billedAt: DS.attr('date'),
paidAt: DS.attr('date')
});
<table class="heading">
<tr>
<th>Invoice Drafts</th>
<th>{{totalAmount}}</th>
</tr>
</table>
<table class="drafts">
<tr>
<th>Invoice</th>
<th>Client</th>
<th class="align-right">Amount</th>
<th></th>
</tr>
{{#each model}}
<tr>
<td>{{title}}</td>
<td>{{client}}</td>
<td class="align-right">{{amount}}</td>
<td>
<a href="#">
<svg class="icon edit-icon"><use xlink:href="#edit" /></svg>
</a>
</td>
</tr>
{{/each}}
</table>
import Ember from 'ember';
export default Ember.ArrayController.extend({
invoiceDrafts: function() {
return this.get('model').filterBy('status', 'draft');
}.property('model.@each.status'),
invoiceDraftAmounts: Ember.computed.mapBy('invoiceDrafts', 'amount'),
totalAmountForInvoiceDrafts: Ember.computed.sum('invoiceDraftAmounts')
});
import { numberToCurrency } from '../../../helpers/number-to-currency';
module('number-to-currency Handlebars Helper');
test('convert invalid input to zero', function() {
equal(numberToCurrency(null), '€ 0,00');
equal(numberToCurrency(undefined), '€ 0,00');
});
test('handle single digit input', function() {
equal(numberToCurrency(0), '€ 0,00');
equal(numberToCurrency(9), '€ 0,09');
});
test('handle two digit input', function() {
equal(numberToCurrency(99), '€ 0,99');
});
test('handle multi digit input', function() {
equal(numberToCurrency(199), '€ 1,99');
equal(numberToCurrency(10099), '€ 100,99');
equal(numberToCurrency(100099), '€ 1.000,99');
equal(numberToCurrency(100000099), '€ 1.000.000,99');
});
import Ember from 'ember';
function numberToCurrency(value) {
if (value === null) {
value = 0;
}
if (isNaN(value)) {
value = 0;
}
value = value.toString();
while (value.length < 3) {
value = '0' + value;
}
var fractionalPart = value.slice(-2);
var integerPart = value.slice(0, -2).replace(/\B(?=(\d{3})+(?!\d))/g, '.');
return '€ ' + integerPart + ',' + fractionalPart;
}
export { numberToCurrency };
export default Ember.Handlebars.makeBoundHelper(numberToCurrency);
post '/invoices' do
params = JSON.parse request.body.read, symbolize_names: true
current_invoice_id += 1
new_invoice = params[:invoice].merge!({ id: current_invoice_id })
invoices[current_invoice_id] = new_invoice
{ invoice: new_invoice }.to_json
end
test('Open invoices are grouped and correctly displayed', function() {
visit('/');
andThen(function() {
equal(find('table.open-invoices tr:eq(1) td:eq(0)').text(), 'Open');
equal(find('table.open-invoices tr:eq(1) td:eq(1)').text(), 'Company C');
equal(find('table.open-invoices tr:eq(1) td:eq(2)').text().trim(), 'Due in 8 days');
equal(find('table.open-invoices tr:eq(1) td:eq(3)').text(), '€ 422,00');
equal(find('table.heading:eq(1) th:eq(1)').text(), '€ 422,00');
equal(find('table.open-invoices tr').length, 2);
});
});
import Ember from 'ember';
export default Ember.ArrayController.extend({
invoiceDrafts: function() {
return this.get('model').filterBy('status', 'draft');
}.property('model.@each.status'),
invoiceDraftAmounts: Ember.computed.mapBy('invoiceDrafts', 'amount'),
totalAmountForInvoiceDrafts: Ember.computed.sum('invoiceDraftAmounts'),
openInvoices: function() {
return this.get('model').filterBy('status', 'open');
}.property('model.@each.status'),
openInvoiceAmounts: Ember.computed.mapBy('openInvoices', 'amount'),
totalAmountForOpenInvoices: Ember.computed.sum('openInvoiceAmounts')
});
<table class="heading">
<tr>
<th>Open Invoices</th>
<th>{{number-to-currency totalAmountForOpenInvoices}}</th>
</tr>
</table>
<table class="open-invoices">
<tr>
<th>Invoice</th>
<th>Client</th>
<th>Due in</th>
<th class="align-right">Amount</th>
<th></th>
</tr>
{{#each openInvoices}}
<tr>
<td>{{title}}</td>
<td>{{client}}</td>
<td>{{dueAt}}</td>
<td class="align-right">{{number-to-currency amount}}</td>
<td>
<a href="#">
<svg class="icon edit-icon"><use xlink:href="#edit" /></svg>
</a>
</td>
</tr>
{{/each}}
</table>
import Ember from 'ember';
import { test, moduleForComponent } from 'ember-qunit';
moduleForComponent('due-at-label');
var dayBasis = 24 * 60 * 60 * 1000;
function setDueAtToDaysFromNow(component, days) {
Ember.run(function() {
var dueAt = new Date();
dueAt.setTime(new Date().getTime() + days + 60000);
component.set('dueAt', dueAt);
});
}
test('displays correct text', function() {
var component = this.subject();
setDueAtToDaysFromNow(component, 8 * dayBasis);
equal(this.$().text().trim(), 'Due in 8 days');
setDueAtToDaysFromNow(component, 1 * dayBasis);
equal(this.$().text().trim(), 'Due in 1 day');
setDueAtToDaysFromNow(component, 0 * dayBasis);
equal(this.$().text().trim(), 'Due today');
setDueAtToDaysFromNow(component, -1 * dayBasis);
equal(this.$().text().trim(), '1 day overdue');
setDueAtToDaysFromNow(component, -8 * dayBasis);
equal(this.$().text().trim(), '8 days overdue');
});
test('is a span tag', function() {
this.subject().set('dueAt', new Date());
equal('SPAN', this.$().prop('tagName'));
});
test('has correct class', function() {
var component = this.subject();
setDueAtToDaysFromNow(component, 8 * dayBasis);
ok(this.$().hasClass('ok'));
setDueAtToDaysFromNow(component, 7 * dayBasis);
ok(this.$().hasClass('warning'));
setDueAtToDaysFromNow(component, 0 * dayBasis);
ok(this.$().hasClass('overdue'));
setDueAtToDaysFromNow(component, -1 * dayBasis);
ok(this.$().hasClass('overdue'));
});
import Ember from 'ember';
export default Ember.Component.extend({
tagName: 'span',
classNameBindings: [':due-label', 'isOverdue:overdue', 'isComingDue:warning', 'isLaterDue:ok'],
daysUntilDueDate: function() {
var difference = this.get('dueAt').getTime() - new Date().getTime();
return Math.floor(difference / (24 * 60 * 60 * 1000));
}.property('dueAt'),
isOverdue: function() {
return this.get('daysUntilDueDate') <= 0;
}.property('daysUntilDueDate'),
isComingDue: function() {
return this.get('daysUntilDueDate') > 0 && this.get('daysUntilDueDate') <= 7;
}.property('daysUntilDueDate'),
isLaterDue: function() {
return this.get('daysUntilDueDate') > 7;
}.property('daysUntilDueDate'),
label: function() {
var daysUntilDueDate = this.get('daysUntilDueDate');
var dayPart = Math.abs(daysUntilDueDate) > 1 ? 'days' : 'day';
if (daysUntilDueDate > 0) {
return 'Due in ' + daysUntilDueDate + ' ' + dayPart;
} else if (daysUntilDueDate === 0) {
return 'Due today';
} else {
return Math.abs(daysUntilDueDate) + ' ' + dayPart + ' overdue';
}
}.property('daysUntilDueDate')
});
test('Paid invoices are grouped and correctly displayed', function() {
visit('/');
andThen(function() {
equal(find('table.paid-invoices tr:eq(1) td:eq(0)').text(), 'Paid');
equal(find('table.paid-invoices tr:eq(1) td:eq(1)').text(), 'Company D');
equal(find('table.paid-invoices tr:eq(1) td:eq(2)').text(), '12.08.2014');
equal(find('table.paid-invoices tr:eq(1) td:eq(3)').text(), '€ 4.222,00');
equal(find('table.heading:eq(2) th:eq(1)').text(), '€ 4.222,00');
equal(find('table.paid-invoices tr').length, 2);
});
});
import Ember from 'ember';
export default Ember.ArrayController.extend({
invoiceDrafts: function() {
return this.get('model').filterBy('status', 'draft');
}.property('model.@each.status'),
invoiceDraftAmounts: Ember.computed.mapBy('invoiceDrafts', 'amount'),
totalAmountForInvoiceDrafts: Ember.computed.sum('invoiceDraftAmounts'),
openInvoices: function() {
return this.get('model').filterBy('status', 'open');
}.property('model.@each.status'),
openInvoiceAmounts: Ember.computed.mapBy('openInvoices', 'amount'),
totalAmountForOpenInvoices: Ember.computed.sum('openInvoiceAmounts'),
paidInvoices: function() {
return this.get('model').filterBy('status', 'paid');
}.property('model.@each.status'),
paidInvoiceAmounts: Ember.computed.mapBy('paidInvoices', 'amount'),
totalAmountForPaidInvoices: Ember.computed.sum('paidInvoiceAmounts')
});
<table class="heading">
<tr>
<th>Paid Invoices</th>
<th class="align-right">{{number-to-currency totalAmountForPaidInvoices}}</th>
</tr>
</table>
<table class="paid-invoices">
<tr>
<th>Invoice</th>
<th>Client</th>
<th>Paid on</th>
<th class="align-right">Amount</th>
<th></th>
</tr>
{{#each paidInvoices}}
<tr>
<td>{{title}}</td>
<td>{{client}}</td>
<td>{{paidAt}}</td>
<td class="align-right">{{number-to-currency amount}}</td>
<td>
<a href="">
<svg class="icon show-icon"><use xlink:href="#show" /></svg>
</a>
</td>
</tr>
{{/each}}
</table>
<div class="container">
{{outlet}}
</div>
As a small business owner
I want to create new invoice drafts
So that future me will not forget to bill a client
get '/invoices' do
{ invoices: invoices.map { |key, value| value }}.to_json
end
get '/invoices/:id' do |id|
{ invoice: invoices[id.to_i] }.to_json
end
import Ember from 'ember';
import startApp from '../helpers/start-app';
var INVOICES = {};
var CURRENT_INVOICE_ID = 1;
var App;
var server;
module('Acceptance: NewInvoiceDraft', {
setup: function() {
App = startApp();
server = new Pretender(function() {
this.get('/invoices', function(request) {
var result = JSON.stringify({ invoices: Object.keys(INVOICES).map(function(k) { return INVOICES[k]; }) });
return [200, { 'Content-Type': 'application/json' }, result];
});
this.post('/invoices', function(request) {
var invoice = JSON.parse(request.requestBody).invoice;
invoice.id = CURRENT_INVOICE_ID;
INVOICES[CURRENT_INVOICE_ID] = invoice;
var result = JSON.stringify({ invoice: invoice });
CURRENT_INVOICE_ID++;
return [200, { 'Content-Type': 'application/json' }, result];
});
});
},
teardown: function() {
Ember.run(App, 'destroy');
server.shutdown();
}
});
test('Create a new invoice draft', function() {
visit('/');
click('.heading-inline-link');
fillIn('#title', 'Writing Ember.js Learning Material');
fillIn('#client', 'Awesome Inc.');
fillIn('#amount', 4000);
fillIn('#description', 'A multi part article series detailing how to build an Ember.js Application with Push Updates');
andThen(function() {
equal(find('h2').text(), 'Writing Ember.js Learning Material');
click('#save-button');
andThen(function() {
equal(currentRouteName(), 'index');
equal(find('table.drafts tr:eq(1) td:eq(0)').text(), 'Writing Ember.js Learning Material');
equal(find('table.drafts tr:eq(1) td:eq(2)').text(), '€ 4.000,00');
});
});
});
Router.map(function() {
this.route('new');
});
<table class="heading">
<tr>
<th>Invoice Drafts &middot; {{link-to 'Create new draft' 'new' class='heading-inline-link'}}</th>
<th>{{number-to-currency totalAmountForInvoiceDrafts}}</th>
</tr>
</table>
<form>
{{#if title}}
<h2>{{title}}</h2>
{{else}}
<h2>Untitled Invoice Draft</h2>
{{/if}}
<div class="row">
<div class="column">
<div class="input-pair">
<label for="title">Title</label>
{{input value=title id="title"}}
</div>
<div class="input-pair">
<label for="client">Client</label>
{{input value=client id="client"}}
</div>
<div class="input-pair">
<label for="amount">Amount</label>
{{currency-input value=amount inputId="amount"}}
</div>
</div>
<div class="column">
<div class="input-pair">
<label for="description">Description</label>
{{textarea value=description id="description"}}
</div>
</div>
</div>
<div class="row button-row">
<a {{action discard}} class="destructive-link">Discard draft</a>
<button id="save-button" {{action save}}>Create draft</button>
</div>
</form>
import Ember from 'ember';
export default Ember.Route.extend({
actions: {
save: function() {
this.modelFor('new').save();
this.transitionTo('index');
}
},
model: function() {
return this.store.createRecord('invoice');
}
});
import { test, moduleForComponent } from 'ember-qunit';
import Ember from 'ember';
moduleForComponent('currency-input');
test('passes ID to input', function() {
var component = this.subject();
Ember.run(function() {
component.set('inputId', 'test-id');
});
equal(this.$().find('input').attr('id'), 'test-id');
});
test('displays nothing as default value', function() {
equal(this.$().find('input').val(), '');
});
test('returns undefined as default value', function() {
equal(this.subject().get('value'), undefined);
});
test('displays formatted value in input', function() {
var component = this.subject();
Ember.run(function() {
component.set('value', 420000);
});
equal(this.$().find('input').val(), '4200');
Ember.run(function(){
component.set('value', 420);
});
equal(this.$().find('input').val(), '4,2');
});
test('converts euro input to cents', function() {
var component = this.subject();
var that = this;
Ember.run(function() {
that.$().find('input').val(100);
that.$().find('input').trigger('keyup');
});
equal(component.get('value'), 10000);
Ember.run(function() {
that.$().find('input').val('100,33');
that.$().find('input').trigger('keyup');
});
equal(component.get('value'), 10033);
Ember.run(function() {
that.$().find('input').val('4.2');
that.$().find('input').trigger('keyup');
});
equal(component.get('value'), 420);
});
import Ember from 'ember';
export default Ember.Component.extend({
inputValue: function(key, value) {
if (arguments.length > 1) {
this.set('value', parseFloat(value.toString().replace(',', '.')) * 100);
}
var displayValue = this.get('value') / 100;
if (isNaN(displayValue)) {
return '';
} else {
return displayValue.toString().replace('.', ',');
}
}.property('value')
});
test('Discard an unsaved invoice draft', function() {
visit('/');
click('.heading-inline-link');
fillIn('#title', 'Write an AngularJS book');
click('.destructive-link');
andThen(function() {
equal(currentRouteName(), 'index');
equal(find('table.drafts').text().indexOf('Write an AngularJS book'), -1);
});
});
import Ember from 'ember';
export default Ember.Route.extend({
actions: {
save: function() {
this.modelFor('new').save();
this.transitionTo('index');
},
discard: function() {
this.modelFor('new').deleteRecord();
this.transitionTo('index');
}
},
model: function() {
return this.store.createRecord('invoice');
}
});
import Ember from 'ember';
import startApp from '../helpers/start-app';
var INVOICES = {
"1": {
id: 1,
title: 'Draft 1',
client: 'Company A',
description: '',
amount: 420,
status: 'draft',
due_at: null,
billed_at: null,
paid_at: null
}
};
var App;
var server;
module('Acceptance: EditInvoiceDraft', {
setup: function() {
App = startApp();
server = new Pretender(function() {
this.get('/invoices', function(request) {
var result = JSON.stringify({ invoices: Object.keys(INVOICES).map(function(k) { return INVOICES[k]; }) });
return [200, { 'Content-Type': 'application/json' }, result];
});
this.put('/invoices/1', function(request) {
var invoiceAttributes = JSON.parse(request.requestBody).invoice;
INVOICES['1'].title = invoiceAttributes.title;
var result = JSON.stringify({ invoice: INVOICES['1'] });
return [200, { 'Content-Type': 'application/json' }, result];
});
});
},
teardown: function() {
Ember.run(App, 'destroy');
server.shutdown();
}
});
test('Edit an existing invoice draft', function() {
visit('/');
click('table.drafts tr:eq(1) td:eq(3) a');
fillIn('#title', 'Writing Ember.js Learning Material');
click('#update-button');
andThen(function() {
equal(find('table.drafts tr:eq(1) td:eq(0)').text(), 'Writing Ember.js Learning Material');
});
});
put '/invoices/:id' do |id|
params = JSON.parse request.body.read, symbolize_names: true
invoices[id.to_i].merge!(params[:invoice])
{ invoice: invoices[id.to_i] }.to_json
end
{{#if title}}
<h2>{{title}}</h2>
{{else}}
<h2>Untitled Invoice Draft</h2>
{{/if}}
<div class="row">
<div class="column">
<div class="input-pair">
<label for="title">Title</label>
{{input value=title id="title"}}
</div>
<div class="input-pair">
<label for="client">Client</label>
{{input value=client id="client"}}
</div>
<div class="input-pair">
<label for="amount">Amount</label>
{{currency-input value=amount inputId="amount"}}
</div>
</div>
<div class="column">
<div class="input-pair">
<label for="description">Description</label>
{{textarea value=description id="description"}}
</div>
</div>
</div>
<form>
{{partial 'draft-input-fields'}}
<div class="row button-row">
<a {{action discard}} class="destructive-link">Discard draft</a>
<button id="save-button" {{action save}}>Create draft</button>
</div>
</form>
<form>
{{partial 'draft-input-fields'}}
<div class="row button-row">
<button id="update-button" {{action save}}>Update draft</button>
</div>
</form>
{{#link-to 'edit' this}}
<svg class="icon edit-icon"><use xlink:href="#edit" /></svg>
{{/link-to}}
import Ember from 'ember';
export default Ember.Route.extend({
actions: {
save: function() {
this.modelFor('edit').save();
this.transitionTo('index');
}
}
});
import { formatDate } from '../../../helpers/format-date';
module('format-date Handlebars Helper');
test('handle non-Date objects as input', function() {
equal(formatDate(null), '');
equal(formatDate(undefined), '');
equal(formatDate(100), '');
equal(formatDate('a string'), '');
});
test('handle Date object as input', function() {
equal(formatDate(new Date(2014, 11, 12)), '12.12.2014');
});
test('prepend single digit days', function() {
equal(formatDate(new Date(2014, 11, 2)), '02.12.2014');
});
test('prepend single digit months', function() {
equal(formatDate(new Date(2014, 0, 12)), '12.01.2014');
});
import Ember from 'ember';
function formatDate(value) {
if (value === null || value === undefined) {
return '';
}
if (!(value instanceof Date)) {
return '';
}
var day = value.getDate();
var month = value.getMonth() + 1;
var year = value.getFullYear();
if (day < 10) {
day = '0' + day;
}
if (month < 10) {
month = '0' + month;
}
return day + '.' + month + '.' + year;
}
export { formatDate };
export default Ember.Handlebars.makeBoundHelper(formatDate);
delete '/invoices/:id' do |id|
invoices.delete id.to_i
200
end
As a small business owner
I want to create new invoice drafts
So that future me will not forget to bill a client
As a small business owner
I want to edit invoice drafts
So that an invoice draft always mirrors the actual project terms
As a small business owner
I want to turn an invoice draft into an open invoice
So that I remember the invoice is already billed
As a small business owner
I want to turn an open invoice into a paid invoice
So that I remember the invoice is paid
As a small business owner
I want to delete invoices
So that obsolete invoices do not clutter the system
As a small business owner
I want to see a grouped overview of all invoices in the system
So that I can get an insight into my business financials with one look
As a small business owner
I want to instantly see changes made by my cofounder
So that I do not accidently do things which are already taken care of
DEFINITIONS
Invoice - Represents an actual or future invoice of the business.
An invoice consists of a title, client, amount, description,
status ('draft', 'open' and 'paid'), billing date, due date and payment date.
Invoices can be only edited if they have the 'draft' status.
Client - Someone the small business owner is working with. Is only saved as single text field.
ROLES
Small business owner - Someone running a small business who wants to keep tabs on his invoices.
current_invoice_id = 4
invoices = {
1 => {
id: 1,
title: 'Developing the frontend',
client: 'edgy circle',
description: '',
amount: 890000,
status: 'draft',
due_at: nil,
billed_at: nil,
paid_at: nil
},
2 => {
id: 2,
title: 'Invoice backend development',
client: 'edgy circle',
description: '',
amount: '420000',
status: 'open',
due_at: (Date.today + 5).to_datetime,
billed_at: Date.today.to_datetime,
paid_at: nil
},
3 => {
id: 3,
title: 'Requirements Workshop "Invoice Application"',
client: 'edgy circle',
description: 'Note: Suppies were provided by the client.',
amount: 99900,
status: 'paid',
due_at: (Date.today - 10).to_datetime,
billed_at: (Date.today - 13).to_datetime,
paid_at: Date.today.to_datetime
}
}
Display the source blob
Display the rendered blob
Raw
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
As a small business owner
I want to see a grouped overview of all invoices in the system
So that I can get an insight into my business financials with one look
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment