├── public ├── .nojekyll ├── CNAME ├── robots.txt ├── favicon.ico ├── assets │ └── binders.jpg ├── humans.txt ├── crossdomain.xml └── testem.js ├── app ├── templates │ ├── client.hbs │ ├── components │ │ ├── export-button.hbs │ │ ├── import-button.hbs │ │ └── drop-down.hbs │ ├── loading.hbs │ ├── invoices │ │ ├── new.hbs │ │ └── index.hbs │ ├── accounts │ │ ├── new.hbs │ │ └── index.hbs │ ├── clients │ │ ├── new.hbs │ │ └── index.hbs │ ├── invoice │ │ ├── edit.hbs │ │ └── show.hbs │ ├── account │ │ └── edit.hbs │ ├── client │ │ └── edit.hbs │ ├── _account_form.hbs │ ├── _client_form.hbs │ ├── application.hbs │ ├── settings.hbs │ ├── home.hbs │ └── _invoice_form.hbs ├── adapters │ ├── unit.js │ ├── currency.js │ ├── language.js │ ├── tax-rate.js │ ├── numeration-type.js │ ├── fixture.js │ └── application.js ├── resolver.js ├── routes │ ├── index.js │ ├── accounts.js │ ├── clients.js │ ├── invoices.js │ ├── account.js │ ├── client.js │ ├── home.js │ ├── invoice.js │ ├── application.js │ ├── invoice │ │ ├── show.js │ │ └── edit.js │ ├── client │ │ └── edit.js │ ├── account │ │ └── edit.js │ ├── clients │ │ └── new.js │ ├── accounts │ │ └── new.js │ ├── settings.js │ └── invoices │ │ └── new.js ├── controllers │ ├── accounts.js │ ├── clients.js │ ├── invoices.js │ ├── accounts │ │ ├── new.js │ │ └── index.js │ ├── client │ │ └── edit.js │ ├── clients │ │ ├── new.js │ │ └── index.js │ ├── account │ │ └── edit.js │ ├── invoices │ │ ├── index.js │ │ └── new.js │ ├── application.js │ ├── invoice │ │ └── edit.js │ └── settings.js ├── serializers │ └── firebase.js ├── services │ ├── dummy-session.js │ ├── firebase.js │ └── current-session.js ├── helpers │ └── format-cents.js ├── models │ ├── language.js │ ├── numeration-type.js │ ├── account.js │ ├── user.js │ ├── item.js │ ├── settings.js │ ├── tax-rate.js │ ├── client.js │ ├── invoice.js │ ├── unit.js │ ├── currency.js │ └── exchange-rates-table.js ├── initializers │ ├── session.js │ └── route.js ├── instance-initializers │ └── inject-owner-to-form.js ├── lib │ ├── parse_cents.js │ ├── format_cents.js │ └── firebase_auth.js ├── app.js ├── components │ ├── import-button.js │ ├── export-button.js │ ├── drop-down.js │ └── cents-field.js ├── forms │ ├── account.js │ ├── client.js │ ├── item.js │ ├── settings.js │ └── invoice.js ├── mixins │ ├── new-controller.js │ ├── edit-controller.js │ ├── item-properties.js │ ├── form.js │ ├── exchange-rate.js │ └── invoice-properties.js ├── router.js ├── index.html └── styles │ └── app.less ├── .prettierrc ├── .watchmanconfig ├── tests ├── .eslintrc.js ├── test-helper.js ├── helpers │ ├── destroy-app.js │ ├── resolver.js │ ├── start-app.js │ ├── auth.js │ └── module-for-acceptance.js ├── acceptance │ ├── invoices-test.js │ └── clients-test.js ├── pages │ ├── new-client.js │ └── clients.js ├── unit │ ├── invoice-test.js │ └── services │ │ └── current-session-test.js └── index.html ├── lib ├── .eslintrc.js ├── ember-md5 │ ├── package.json │ └── index.js ├── ember-bootstrap │ ├── package.json │ └── index.js └── ember-polish-to-words │ ├── package.json │ └── index.js ├── .firebaserc ├── jsconfig.json ├── mirage ├── serializers │ ├── client.js │ └── application.js ├── scenarios │ └── default.js ├── factories │ ├── user.js │ └── client.js └── config.js ├── config ├── targets.js └── environment.js ├── vendor ├── shims │ ├── md5.js │ └── polish-to-words.js └── toword.js ├── .gitignore ├── firebase.json ├── .ember-cli ├── .eslintrc.js ├── .travis.yml ├── testem.js ├── .editorconfig ├── ember-cli-build.js ├── LICENSE ├── README.md └── package.json /public/.nojekyll: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/CNAME: -------------------------------------------------------------------------------- 1 | fakturama.pl 2 | -------------------------------------------------------------------------------- /app/templates/client.hbs: -------------------------------------------------------------------------------- 1 | {{outlet}} -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true 3 | } 4 | -------------------------------------------------------------------------------- /app/adapters/unit.js: -------------------------------------------------------------------------------- 1 | export { default } from './fixture'; 2 | -------------------------------------------------------------------------------- /app/templates/components/export-button.hbs: -------------------------------------------------------------------------------- 1 | Exportuj dane 2 | -------------------------------------------------------------------------------- /.watchmanconfig: -------------------------------------------------------------------------------- 1 | { 2 | "ignore_dirs": ["tmp", "dist"] 3 | } 4 | -------------------------------------------------------------------------------- /app/adapters/currency.js: -------------------------------------------------------------------------------- 1 | export { default } from './fixture'; 2 | -------------------------------------------------------------------------------- /app/adapters/language.js: -------------------------------------------------------------------------------- 1 | export { default } from './fixture'; 2 | -------------------------------------------------------------------------------- /app/adapters/tax-rate.js: -------------------------------------------------------------------------------- 1 | export { default } from './fixture'; 2 | -------------------------------------------------------------------------------- /app/adapters/numeration-type.js: -------------------------------------------------------------------------------- 1 | export { default } from './fixture'; 2 | -------------------------------------------------------------------------------- /app/templates/loading.hbs: -------------------------------------------------------------------------------- 1 |
{{client.companyName}}
13 |Nie dodano jeszcze żadnego klienta.
20 | {{/if}} 21 | 22 | {{link-to "Dodaj nowego klienta" "clients.new" data-test-new-client class="btn btn-success" active=null}} 23 | -------------------------------------------------------------------------------- /app/router.js: -------------------------------------------------------------------------------- 1 | import EmberRouter from '@ember/routing/router'; 2 | import config from './config/environment'; 3 | 4 | const Router = EmberRouter.extend({ 5 | location: config.locationType, 6 | rootURL: config.rootURL 7 | }); 8 | 9 | Router.map(function() { 10 | this.route('home', { path: '/' }); 11 | 12 | this.route('clients', function() { 13 | this.route('new'); 14 | }); 15 | this.route('client', { path: 'client/:client_id' }, function() { 16 | this.route('edit'); 17 | }); 18 | 19 | this.route('accounts', function() { 20 | this.route('new'); 21 | }); 22 | this.route('account', { path: 'account/:account_id' }, function() { 23 | this.route('edit'); 24 | }); 25 | 26 | this.route('invoices', function() { 27 | this.route('new'); 28 | }); 29 | this.route('invoice', { path: 'invoice/:invoice_id' }, function() { 30 | this.route('show', { path: '/' }); 31 | this.route('edit'); 32 | }); 33 | 34 | this.route('settings'); 35 | }); 36 | 37 | export default Router; 38 | -------------------------------------------------------------------------------- /app/templates/accounts/index.hbs: -------------------------------------------------------------------------------- 1 | {{#if accounts}} 2 || Opis | 6 |Nazwa banku | 7 |Numer rachunku | 8 |SWIFT | 9 |10 | |
|---|---|---|---|---|
| {{account.description}} | 16 |{{account.bankName}} | 17 |{{account.number}} | 18 |{{account.swift}} | 19 |{{link-to "Edytuj" "account.edit" account}} | 20 |
Nie dodano jeszcze żadnego rachunku bankowego.
26 | {{/if}} 27 | 28 | {{link-to "Dodaj nowy rachunek bankowy" "accounts.new" class="btn btn-success" active=null}} 29 | -------------------------------------------------------------------------------- /tests/unit/invoice-test.js: -------------------------------------------------------------------------------- 1 | // import Invoice from "fakturama/models/invoice"; 2 | // import TaxRate from "fakturama/models/tax_rate"; 3 | 4 | // var invoice; 5 | 6 | // module("Unit - Invoice", { 7 | // setup: function () { 8 | // TaxRate.fetch(); 9 | // invoice = Invoice.create({ 10 | // itemsAttributes: [] 11 | // }); 12 | // } 13 | // }); 14 | 15 | // test("calculates tax correctly", function () { 16 | // expect(3); 17 | 18 | // Ember.run(function () { 19 | // invoice.set("itemsAttributes", [{ 20 | // taxRateCode: "23", 21 | // netPrice: 24390, 22 | // quantity: 1 23 | // }, { 24 | // taxRateCode: "23", 25 | // netPrice: 24390, 26 | // quantity: 1 27 | // }]); 28 | 29 | // strictEqual(invoice.get("totalNetAmount"), 48780); 30 | // strictEqual(invoice.get("totalTaxAmount"), 11219); 31 | // strictEqual(invoice.get("totalGrossAmount"), 59999); 32 | // }); 33 | // }); 34 | -------------------------------------------------------------------------------- /ember-cli-build.js: -------------------------------------------------------------------------------- 1 | /* eslint-env node */ 2 | 'use strict'; 3 | 4 | const EmberApp = require('ember-cli/lib/broccoli/ember-app'); 5 | 6 | module.exports = function(defaults) { 7 | let app = new EmberApp(defaults, { 8 | lessOptions: { 9 | paths: ['node_modules'] 10 | } 11 | }); 12 | 13 | app.import('vendor/firebase.js'); 14 | app.import('vendor/toword.js'); 15 | 16 | // Use `app.import` to add additional libraries to the generated 17 | // output files. 18 | // 19 | // If you need to use different assets in different 20 | // environments, specify an object as the first parameter. That 21 | // object's keys should be the environment name and the values 22 | // should be the asset to use in that environment. 23 | // 24 | // If the library that you are including contains AMD or ES6 25 | // modules that you would like to import into your application 26 | // please specify an object with the list of modules as keys 27 | // along with the exports of each module as its value. 28 | 29 | return app.toTree(); 30 | }; 31 | -------------------------------------------------------------------------------- /app/mixins/item-properties.js: -------------------------------------------------------------------------------- 1 | import Mixin from '@ember/object/mixin'; 2 | import { computed } from '@ember/object'; 3 | 4 | export default Mixin.create({ 5 | netAmount: computed('netPrice', 'quantity', function() { 6 | return Math.round(this.get('netPrice') * this.get('quantity')); 7 | }), 8 | 9 | taxAmount: computed('netAmount', 'taxRate.value', function() { 10 | return Math.round( 11 | (this.get('netAmount') * this.get('taxRate.value')) / 100 12 | ); 13 | }), 14 | 15 | grossAmount: computed('netAmount', 'taxAmount', function() { 16 | return this.get('netAmount') + this.get('taxAmount'); 17 | }), 18 | 19 | unit: computed('unitCode', function() { 20 | const code = this.get('unitCode'); 21 | if (code) { 22 | return this.get('store').queryRecord('unit', { code }); 23 | } 24 | }), 25 | 26 | taxRate: computed('taxRateCode', function() { 27 | const code = this.get('taxRateCode'); 28 | 29 | if (code) { 30 | return this.get('store').queryRecord('tax-rate', { code }); 31 | } 32 | }) 33 | }); 34 | -------------------------------------------------------------------------------- /app/controllers/application.js: -------------------------------------------------------------------------------- 1 | import Controller from '@ember/controller'; 2 | import { inject as service } from '@ember/service'; 3 | import { readOnly } from '@ember/object/computed'; 4 | 5 | export default Controller.extend({ 6 | session: service(), 7 | 8 | user: readOnly('session.currentUser'), 9 | 10 | isAlertDismissed: false, 11 | 12 | actions: { 13 | signIn: function(method) { 14 | const session = this.get('session'); 15 | session.create(method).then( 16 | () => { 17 | this.clearCache(); 18 | this.send('refresh'); 19 | }, 20 | error => alert(error.message) 21 | ); 22 | }, 23 | 24 | signOut: function() { 25 | const session = this.get('session'); 26 | session.remove().then(() => { 27 | this.clearCache(); 28 | this.transitionToRoute('home'); 29 | }); 30 | }, 31 | 32 | dismissAlert: function() { 33 | this.set('isAlertDismissed', true); 34 | } 35 | }, 36 | 37 | clearCache: function() { 38 | this.get('store').unloadAll(); 39 | } 40 | }); 41 | -------------------------------------------------------------------------------- /tests/pages/clients.js: -------------------------------------------------------------------------------- 1 | import { 2 | attribute, 3 | create, 4 | clickable, 5 | collection, 6 | text, 7 | visitable 8 | } from 'ember-cli-page-object'; 9 | import { reject } from 'rsvp'; 10 | 11 | export default create({ 12 | visit: visitable('/clients'), 13 | newClient: clickable('[data-test-new-client]'), 14 | showClient(id) { 15 | const client = this.client(id); 16 | if (client) { 17 | return client.visit(); 18 | } else { 19 | reject(`Client ${id} not found`); 20 | } 21 | }, 22 | clients: collection('[data-test-clients]', { 23 | id: attribute('data-test-client-id', 'div'), 24 | contactName: text('[data-test-client-contact-name]'), 25 | companyName: text('[data-test-client-company-name]'), 26 | visit: clickable('[data-test-client-visit]') 27 | }), 28 | client(id = null) { 29 | const clients = this.clients; 30 | if (id) { 31 | return clients.filter(c => c.id === id).objectAt(0); 32 | } else { 33 | return clients.objectAt(clients.length - 1); 34 | } 35 | } 36 | }); 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014 Kuba Kuźma 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /app/lib/firebase_auth.js: -------------------------------------------------------------------------------- 1 | import EmberObject from '@ember/object'; 2 | 3 | import config from 'fakturama/config/environment'; 4 | 5 | const { 6 | APP: { 7 | FIREBASE, 8 | FIREBASE: { projectId } 9 | } 10 | } = config; 11 | const { firebase } = window; 12 | const app = firebase.initializeApp(FIREBASE); 13 | 14 | function delegateMethod(name) { 15 | return function() { 16 | return this.get('content')[name](...arguments); 17 | }; 18 | } 19 | 20 | export default EmberObject.extend({ 21 | init() { 22 | this._super(...arguments); 23 | this.set('content', app.auth(projectId)); 24 | this.set('googleProvider', new firebase.auth.GoogleAuthProvider()); 25 | }, 26 | 27 | onAuthStateChanged: delegateMethod('onAuthStateChanged'), 28 | signInAnonymously: delegateMethod('signInAnonymously'), 29 | signInWithGoogle() { 30 | const content = this.get('content'); 31 | const provider = this.get('googleProvider'); 32 | provider.addScope('email'); 33 | provider.addScope('profile'); 34 | return content.signInWithPopup(provider); 35 | }, 36 | signOut: delegateMethod('signOut') 37 | }); 38 | -------------------------------------------------------------------------------- /app/models/unit.js: -------------------------------------------------------------------------------- 1 | import DS from 'ember-data'; 2 | 3 | const { Model, attr } = DS; 4 | 5 | let Unit = Model.extend({ 6 | code: attr(), 7 | name: attr(), 8 | nameEN: attr() 9 | }); 10 | 11 | Unit.reopenClass({ 12 | primaryKey: 'code', 13 | FIXTURES: [ 14 | { id: 1, code: 'hour', name: 'godz.', nameEN: 'hrs' }, 15 | { id: 2, code: 'service', name: 'usł.', nameEN: 'service' }, 16 | { id: 3, code: 'piece', name: 'szt.', nameEN: 'pcs' }, 17 | { id: 4, code: 'day', name: 'dni', nameEN: 'days' }, 18 | { id: 5, code: 'discount', name: 'rabat', nameEN: 'discount' }, 19 | { id: 6, code: 'kilogram', name: 'kg', nameEN: 'kg' }, 20 | { id: 7, code: 'ton', name: 'ton', nameEN: 'tons' }, 21 | { id: 8, code: 'metre', name: 'm', nameEN: 'm' }, 22 | { id: 9, code: 'kilometre', name: 'km', nameEN: 'km' }, 23 | { id: 10, code: 'advance', name: 'zaliczka', nameEN: 'advance' }, 24 | { id: 11, code: 'set', name: 'komplet', nameEN: 'set' }, 25 | { id: 12, code: 'squaremetre', name: 'm²', nameEN: 'm²' }, 26 | { id: 13, code: 'cubicmetre', name: 'm³', nameEN: 'm³' } 27 | ] 28 | }); 29 | 30 | export default Unit; 31 | -------------------------------------------------------------------------------- /app/models/currency.js: -------------------------------------------------------------------------------- 1 | import { computed } from '@ember/object'; 2 | import DS from 'ember-data'; 3 | 4 | const { Model, attr } = DS; 5 | 6 | let Currency = Model.extend({ 7 | code: attr(), 8 | name: attr(), 9 | precision: attr(), 10 | nameWithCode: computed('code', 'name', function() { 11 | return '%@ (%@)'.fmt(this.get('name'), this.get('code')); 12 | }) 13 | }); 14 | 15 | Currency.reopenClass({ 16 | primaryKey: 'code', 17 | FIXTURES: [ 18 | { id: 1, code: 'PLN', name: 'złoty', precision: 2 }, 19 | { id: 2, code: 'GBP', name: 'funt szterling', precision: 2 }, 20 | { id: 3, code: 'USD', name: 'dolar amerykański', precision: 2 }, 21 | { id: 4, code: 'EUR', name: 'euro', precision: 2 }, 22 | { id: 5, code: 'CHF', name: 'frank szwajcarski', precision: 2 }, 23 | { id: 6, code: 'CZK', name: 'korona czeska', precision: 2 }, 24 | { id: 7, code: 'NOK', name: 'korona norweska', precision: 2 }, 25 | { id: 8, code: 'SEK', name: 'korona szwedzka', precision: 2 }, 26 | { id: 9, code: 'CAD', name: 'dolar kanadyjski', precision: 2 }, 27 | { id: 10, code: 'DKK', name: 'korona duńska', precision: 2 } 28 | ] 29 | }); 30 | 31 | export default Currency; 32 | -------------------------------------------------------------------------------- /app/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 || Numer | 6 |Data wystawienia | 7 |Termin płatności | 8 |Nabywca | 9 |Kwota brutto | 10 |Opłacona? | 11 |12 | |
|---|---|---|---|---|---|---|
| 18 | {{invoice.number}} 19 | | 20 |21 | {{invoice.issueDate}} 22 | | 23 |24 | {{invoice.dueDate}} 25 | | 26 |27 | {{invoice.buyerFirstLine}} 28 | | 29 |
30 | {{format-cents invoice.totalGrossAmount}}
31 | {{invoice.currencyCode}}
32 | {{#if invoice.isForeignCurrency}}
33 | {{format-cents invoice.totalGrossAmountPLN}} 34 | PLN 35 | {{/if}} 36 | |
37 | 38 | {{#if invoice.isPaid}} 39 | tak 40 | {{else}} 41 | nie 42 | {{/if}} 43 | | 44 |45 | {{link-to "Zobacz podgląd" "invoice.show" invoice}} 46 | | 47 |
Nie utworzono jeszcze żadnej faktury.
53 | {{/if}} 54 | 55 | {{link-to "Utwórz nową fakturę" "invoices.new" class="btn btn-success" active=null}} 56 | -------------------------------------------------------------------------------- /app/templates/_account_form.hbs: -------------------------------------------------------------------------------- 1 | 64 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Fakturama  2 | 3 | Fakturama to prosta aplikacja do fakturowania, napisana w całości w języku JavaScript, z wykorzystaniem frameworka [Ember](http://emberjs.com/). Dane składowane są w bazie danych [Firebase](https://www.firebase.com/), kursy walut ściąganę są za pośrednictwem [YQL](http://developer.yahoo.com/yql/) z serwerów [Narodowego Banku Polskiego](http://www.nbp.pl/kursy/xml/). Ogólnodostępna wersja aplikacji znajduje się pod adresem [https://fakturama.pl/](https://fakturama.pl/), ale nic nie stoi na przeszkodzie, aby uruchomić własną. 4 | 5 | ## Uruchamianie środowiska programistycznego 6 | 7 | Aby uruchomić aplikację lokalnie, musisz posiadać node.js (wersja w okolicy 8.9.0) i yarn (okolice 1.3.2). Wszelkie zależności aplikacji instalowane są po wywołaniu `yarn install`. Aby uruchomić lokalną wersję serwera w środowisku `development`, należy uruchomić polecenie `ember server` i otworzyć w przeglądarce adres [http://localhost:8000/](http://localhost:8000/). 8 | 9 | ## Budowanie wersji produkcyjnej 10 | 11 | Do budowania wersji produkcyjnej aplikacji służy polecenie `ember build --environment production`. Po wykonaniu polecenia w katalogu `dist` powinno znajdować się kilka plików, wśród których najważniejsze to: 12 | 13 | * `index.html` - strona startowa 14 | * `assets/fakturama.xxxxxxxx.css` - arkusz styli 15 | * `assets/fakturama.xxxxxxxx.js` - kod źródłowy aplikacji 16 | * `assets/vendor.xxxxxxxx.js` - kod źródłowy zależności (zewnętrznych bibliotek) 17 | * `assets/vendor.xxxxxxxx.css` - arkusz styli zależności 18 | 19 | ## Publikowanie 20 | 21 | Ogólnodostępna wersja hostowana jest przy pomocy [Firebase Hosting](https://firebase.google.com/) i [Cloudflare](https://www.cloudflare.com/). Do publikowania aplikacji służy polecenie `ember build --environment production && firebase use production && firebase deploy`. 22 | 23 | ## Licencja 24 | 25 | Autorem Fakturamy jest [Kuba Kuźma](https://kubakuzma.com/). Kod aplikacji udostępniany jest na zasadach licencji [MIT](https://raw.githubusercontent.com/cowbell/fakturama/master/LICENSE). 26 | -------------------------------------------------------------------------------- /tests/acceptance/clients-test.js: -------------------------------------------------------------------------------- 1 | import { test } from 'qunit'; 2 | import moduleForAcceptance from 'fakturama/tests/helpers/module-for-acceptance'; 3 | import clientsPage from 'fakturama/tests/pages/clients'; 4 | import newClientPage from 'fakturama/tests/pages/new-client'; 5 | import { authenticate } from 'fakturama/tests/helpers/auth'; 6 | 7 | moduleForAcceptance('Acceptance | clients', { 8 | beforeEach() { 9 | const user = server.create('user'); 10 | authenticate(user, 'token'); 11 | } 12 | }); 13 | 14 | test('creating a new client', function(assert) { 15 | clientsPage.visit().newClient(); 16 | 17 | andThen(function() { 18 | const attrs = { 19 | companyName: 'Simpsons LLC', 20 | address: 'Springfield', 21 | contactName: 'Bart Simpson' 22 | }; 23 | newClientPage.saveWith(attrs); 24 | 25 | andThen(function() { 26 | const client = clientsPage.client(); 27 | assert.equal(client.contactName, attrs.contactName); 28 | assert.equal(client.companyName, attrs.companyName); 29 | }); 30 | }); 31 | }); 32 | 33 | test('editing a client', function(assert) { 34 | const client = server.create('client'); 35 | clientsPage.visit(); 36 | 37 | andThen(function() { 38 | clientsPage.showClient(client.id); 39 | 40 | andThen(function() { 41 | const attrs = Object.assign({}, client, { 42 | companyName: 'Simpsons LLC', 43 | address: 'Springfield', 44 | contactName: 'Bart Simpson' 45 | }); 46 | newClientPage.saveWith(attrs); 47 | 48 | andThen(function() { 49 | const client = clientsPage.client(); 50 | assert.equal(client.contactName, attrs.contactName); 51 | assert.equal(client.companyName, attrs.companyName); 52 | }); 53 | }); 54 | }); 55 | }); 56 | 57 | test('removing a client', function(assert) { 58 | const client = server.create('client'); 59 | clientsPage.visit(); 60 | 61 | andThen(function() { 62 | clientsPage.showClient(client.id); 63 | 64 | andThen(function() { 65 | newClientPage.delete(); 66 | 67 | andThen(function() { 68 | const clients = clientsPage.clients; 69 | assert.equal(clients.length, 0); 70 | }); 71 | }); 72 | }); 73 | }); 74 | -------------------------------------------------------------------------------- /tests/unit/services/current-session-test.js: -------------------------------------------------------------------------------- 1 | import EmberObject from '@ember/object'; 2 | import { resolve } from 'rsvp'; 3 | import { moduleFor, test } from 'ember-qunit'; 4 | 5 | moduleFor('service:current-session', 'Unit | Service | session'); 6 | 7 | test('it creates a service with authClass', function(assert) { 8 | assert.expect(2); 9 | const authClass = EmberObject.extend({ 10 | init() { 11 | this._super(...arguments); 12 | assert.ok(this, 'authClass#create called'); 13 | } 14 | }); 15 | let service = this.subject({ authClass }); 16 | 17 | assert.ok(service); 18 | }); 19 | 20 | test('it creates a new session with provided method', function(assert) { 21 | assert.expect(2); 22 | const authClass = EmberObject.extend({ 23 | signInAnonymously() { 24 | assert.ok(this, 'authClass#signInAnonymously called'); 25 | return resolve(true); 26 | }, 27 | signInWithGoogle() { 28 | assert.ok(this, 'authClass#signInWithGoogle called'); 29 | return resolve(true); 30 | } 31 | }); 32 | 33 | let service = this.subject({ authClass }); 34 | service.create(); 35 | service.create('google'); 36 | }); 37 | 38 | test('it removes a session and then create an anonymous one', function(assert) { 39 | assert.expect(2); 40 | const authClass = EmberObject.extend({ 41 | signInAnonymously() { 42 | assert.ok(this, 'authClass#signInAnonymously called'); 43 | return resolve(); 44 | }, 45 | signOut() { 46 | assert.ok('authClass#signOut called'); 47 | return resolve(); 48 | } 49 | }); 50 | 51 | let service = this.subject({ authClass }); 52 | service.remove(); 53 | }); 54 | 55 | test('it setup a session', function(assert) { 56 | assert.expect(2); 57 | const user = EmberObject.create({ 58 | getIdToken() { 59 | return resolve('token'); 60 | } 61 | }); 62 | const authClass = EmberObject.extend({ 63 | onAuthStateChanged(callback) { 64 | callback(user); 65 | } 66 | }); 67 | 68 | let service = this.subject({ authClass }); 69 | return service.setup().then(() => { 70 | const currentUser = service.get('currentUser'); 71 | assert.ok(currentUser); 72 | assert.equal(currentUser.get('authToken'), 'token'); 73 | }); 74 | }); 75 | -------------------------------------------------------------------------------- /app/services/current-session.js: -------------------------------------------------------------------------------- 1 | import Service from '@ember/service'; 2 | import { computed } from '@ember/object'; 3 | import { resolve, Promise as EmberPromise } from 'rsvp'; 4 | 5 | import FirebaseAuth from 'fakturama/lib/firebase_auth'; 6 | import User from 'fakturama/models/user'; 7 | 8 | // This service needs to be injected via app initializer under 'session' name. 9 | // This allow us to stub ad-hoc injection in in the acceptance tests. 10 | export default Service.extend({ 11 | init() { 12 | this._super(...arguments); 13 | 14 | const authClass = this.getWithDefault('authClass', FirebaseAuth); 15 | this.set('_auth', authClass.create()); 16 | }, 17 | 18 | setup() { 19 | if (this.getWithDefault('_setup', false)) { 20 | return resolve(); 21 | } 22 | 23 | return new EmberPromise(resolve => { 24 | const auth = this.get('_auth'); 25 | auth.onAuthStateChanged(user => { 26 | if (user) { 27 | return this._setUser(user).then(resolve); 28 | } else { 29 | return this.create().then(resolve); 30 | } 31 | }); 32 | this.set('_setup', true); 33 | }); 34 | }, 35 | 36 | currentUser: computed('userData.{user,token}', function() { 37 | const { user, token } = this.get('userData'); 38 | return User.create({ 39 | uid: user.uid, 40 | authToken: token, 41 | email: user.email, 42 | displayName: user.displayName, 43 | isAnonymous: user.isAnonymous 44 | }); 45 | }), 46 | 47 | create(method = 'anonymous') { 48 | const auth = this.get('_auth'); 49 | return new EmberPromise((resolve, reject) => { 50 | switch (method) { 51 | case 'anonymous': 52 | auth.signInAnonymously().then(resolve, reject); 53 | break; 54 | case 'google': 55 | auth.signInWithGoogle().then(resolve, reject); 56 | break; 57 | default: 58 | reject(`Session#create with ${method} is not supported`); 59 | } 60 | }); 61 | }, 62 | 63 | remove() { 64 | const auth = this.get('_auth'); 65 | return auth.signOut().then(() => this.create()); 66 | }, 67 | 68 | _setUser(user) { 69 | return user.getIdToken().then(token => { 70 | this.set('userData', { user, token }); 71 | }); 72 | } 73 | }); 74 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fakturama", 3 | "version": "1.0.0", 4 | "private": true, 5 | "license": "MIT", 6 | "author": "Kuba Kuźma", 7 | "directories": { 8 | "doc": "doc", 9 | "test": "tests" 10 | }, 11 | "repository": "", 12 | "scripts": { 13 | "build": "ember build", 14 | "precommit": "lint-staged", 15 | "start": "ember server", 16 | "test": "ember test" 17 | }, 18 | "lint-staged": { 19 | "*.{js,json,css}": [ 20 | "prettier --write", 21 | "git add" 22 | ] 23 | }, 24 | "devDependencies": { 25 | "blueimp-md5": "^2.10.0", 26 | "bootstrap": "^3.3.7", 27 | "broccoli-asset-rev": "^2.4.5", 28 | "broccoli-funnel": "^2.0.1", 29 | "broccoli-merge-trees": "^2.0.0", 30 | "ember-ajax": "^3.0.0", 31 | "ember-cli": "~2.15.1", 32 | "ember-cli-app-version": "^3.0.0", 33 | "ember-cli-babel": "^6.3.0", 34 | "ember-cli-dependency-checker": "^2.0.0", 35 | "ember-cli-eslint": "^4.0.0", 36 | "ember-cli-htmlbars": "^2.0.1", 37 | "ember-cli-htmlbars-inline-precompile": "^1.0.0", 38 | "ember-cli-inject-live-reload": "^1.4.1", 39 | "ember-cli-less": "^1.5.5", 40 | "ember-cli-mirage": "^0.4.3", 41 | "ember-cli-page-object": "^1.15.0-beta.2", 42 | "ember-cli-qunit": "^4.0.0", 43 | "ember-cli-shims": "^1.1.0", 44 | "ember-cli-sri": "^2.1.0", 45 | "ember-cli-uglify": "^1.2.0", 46 | "ember-data": "~2.15.0", 47 | "ember-data-fixture-adapter": "^1.13.0", 48 | "ember-export-application-global": "^2.0.0", 49 | "ember-load-initializers": "^1.0.0", 50 | "ember-resolver": "^4.0.0", 51 | "ember-source": "~2.15.0", 52 | "ember-test-selectors": "^0.3.8", 53 | "ember-truth-helpers": "^2.0.0", 54 | "ember-validations": "github:CassioFelippe/ember-validations", 55 | "eslint-config-prettier": "^2.9.0", 56 | "eslint-plugin-prettier": "^2.6.0", 57 | "firebase-tools": "^3.16.0", 58 | "husky": "^0.14.3", 59 | "lint-staged": "^7.2.0", 60 | "loader.js": "^4.2.3", 61 | "polish-to-words": "github:qoobaa/polish-to-words", 62 | "prettier": "^1.13.5" 63 | }, 64 | "engines": { 65 | "node": "^4.5 || 6.* || >= 7.*" 66 | }, 67 | "false": {}, 68 | "ember-addon": { 69 | "paths": [ 70 | "lib/ember-bootstrap", 71 | "lib/ember-md5", 72 | "lib/ember-polish-to-words" 73 | ] 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /vendor/toword.js: -------------------------------------------------------------------------------- 1 | // Convert numbers to words 2 | // copyright 25th July 2006, by Stephen Chapman http://javascript.about.com 3 | // permission to use this Javascript on your web page is granted 4 | // provided that all of the code (including this copyright notice) is 5 | // used exactly as shown (you can change the numbering system if you wish) 6 | 7 | // American Numbering System 8 | var th = ['', 'thousand', 'million', 'billion', 'trillion']; 9 | // uncomment this line for English Number System 10 | // var th = ['','thousand','million','milliard','billion']; 11 | 12 | var dg = [ 13 | 'zero', 14 | 'one', 15 | 'two', 16 | 'three', 17 | 'four', 18 | 'five', 19 | 'six', 20 | 'seven', 21 | 'eight', 22 | 'nine' 23 | ]; 24 | var tn = [ 25 | 'ten', 26 | 'eleven', 27 | 'twelve', 28 | 'thirteen', 29 | 'fourteen', 30 | 'fifteen', 31 | 'sixteen', 32 | 'seventeen', 33 | 'eighteen', 34 | 'nineteen' 35 | ]; 36 | var tw = [ 37 | 'twenty', 38 | 'thirty', 39 | 'forty', 40 | 'fifty', 41 | 'sixty', 42 | 'seventy', 43 | 'eighty', 44 | 'ninety' 45 | ]; 46 | function toWords(s) { 47 | s = s.replace(/[\, ]/g, ''); 48 | if (s != parseFloat(s)) return 'not a number'; 49 | var x = s.indexOf('.'); 50 | if (x == -1) x = s.length; 51 | if (x > 15) return 'too big'; 52 | var n = s.split(''); 53 | var str = ''; 54 | var sk = 0; 55 | for (var i = 0; i < x; i++) { 56 | if ((x - i) % 3 == 2) { 57 | if (n[i] == '1') { 58 | str += tn[Number(n[i + 1])] + ' '; 59 | i++; 60 | sk = 1; 61 | } else if (n[i] != 0) { 62 | str += tw[n[i] - 2] + ' '; 63 | sk = 1; 64 | } 65 | } else if (n[i] != 0) { 66 | str += dg[n[i]] + ' '; 67 | if ((x - i) % 3 == 0) str += 'hundred '; 68 | sk = 1; 69 | } 70 | if ((x - i) % 3 == 1) { 71 | if (sk) str += th[(x - i - 1) / 3] + ' '; 72 | sk = 0; 73 | } 74 | } 75 | if (x != s.length) { 76 | var y = s.length; 77 | str += 'point '; 78 | for (var i = x + 1; i < y; i++) str += dg[n[i]] + ' '; 79 | } 80 | return str.replace(/\s+/g, ' '); 81 | } 82 | 83 | /* 84 | FILE ARCHIVED ON 02:38:55 Aug 04, 2016 AND RETRIEVED FROM THE 85 | INTERNET ARCHIVE ON 17:36:55 Jan 19, 2018. 86 | JAVASCRIPT APPENDED BY WAYBACK MACHINE, COPYRIGHT INTERNET ARCHIVE. 87 | 88 | ALL OTHER CONTENT MAY ALSO BE PROTECTED BY COPYRIGHT (17 U.S.C. 89 | SECTION 108(a)(3)). 90 | */ 91 | -------------------------------------------------------------------------------- /config/environment.js: -------------------------------------------------------------------------------- 1 | /* eslint-env node */ 2 | 'use strict'; 3 | 4 | module.exports = function(environment) { 5 | let ENV = { 6 | modulePrefix: 'fakturama', 7 | environment, 8 | rootURL: '/', 9 | locationType: 'auto', 10 | EmberENV: { 11 | FEATURES: { 12 | // Here you can enable experimental features on an ember canary build 13 | // e.g. "with-controller": true 14 | }, 15 | EXTEND_PROTOTYPES: { 16 | // Prevent Ember Data from overriding Date.parse. 17 | Date: false 18 | } 19 | }, 20 | 21 | APP: { 22 | // Here you can pass flags/options to your application instance 23 | // when it is created 24 | } 25 | }; 26 | 27 | if (environment === 'development') { 28 | ENV['ember-cli-mirage'] = { 29 | enabled: false 30 | }; 31 | ENV.APP.FIREBASE = { 32 | apiKey: 'AIzaSyCsqfzOAhMod8CgmLQofz24JHk8lSgpZwo', 33 | authDomain: 'fakturama-development.firebaseapp.com', 34 | databaseURL: 'https://fakturama-development.firebaseio.com', 35 | projectId: 'fakturama-development', 36 | storageBucket: '', 37 | messagingSenderId: '1068753197003' 38 | }; 39 | 40 | // ENV.APP.LOG_RESOLVER = true; 41 | // ENV.APP.LOG_ACTIVE_GENERATION = true; 42 | // ENV.APP.LOG_TRANSITIONS = true; 43 | // ENV.APP.LOG_TRANSITIONS_INTERNAL = true; 44 | // ENV.APP.LOG_VIEW_LOOKUPS = true; 45 | } 46 | 47 | if (environment === 'test') { 48 | ENV.APP.FIREBASE = {}; 49 | // Testem prefers this... 50 | ENV.locationType = 'none'; 51 | 52 | // keep test console output quieter 53 | ENV.APP.LOG_ACTIVE_GENERATION = false; 54 | ENV.APP.LOG_VIEW_LOOKUPS = false; 55 | 56 | ENV.APP.rootElement = '#ember-testing'; 57 | } 58 | 59 | if (environment === 'staging') { 60 | ENV.APP.FIREBASE = { 61 | apiKey: 'AIzaSyDP6rWEbwd3F8Kd-2q97a24Nqm6DmQbuLQ', 62 | authDomain: 'fakturama-staging.firebaseapp.com', 63 | databaseURL: 'https://fakturama-staging.firebaseio.com', 64 | projectId: 'fakturama-staging', 65 | storageBucket: 'fakturama-staging.appspot.com', 66 | messagingSenderId: '230102749631' 67 | }; 68 | } 69 | 70 | if (environment === 'production') { 71 | ENV.APP.FIREBASE = { 72 | apiKey: 'AIzaSyDYHmwCeXtn6Nlj51XqUwR6KbJmw97Wo3A', 73 | authDomain: 'fakturama-production-61b9a.firebaseapp.com', 74 | databaseURL: 'https://fakturama-production-61b9a.firebaseio.com', 75 | projectId: 'fakturama-production-61b9a', 76 | storageBucket: 'fakturama-production-61b9a.appspot.com', 77 | messagingSenderId: '54449445025' 78 | }; 79 | } 80 | 81 | return ENV; 82 | }; 83 | -------------------------------------------------------------------------------- /app/controllers/invoices/new.js: -------------------------------------------------------------------------------- 1 | import { next } from '@ember/runloop'; 2 | import Controller from '@ember/controller'; 3 | import NewController from 'fakturama/mixins/new-controller'; 4 | 5 | export default Controller.extend(NewController, { 6 | transitionTo() { 7 | this.transitionToRoute('invoice.show', this.get('content.model')); 8 | }, 9 | settings: null, 10 | currencies: null, 11 | taxRates: null, 12 | languages: null, 13 | units: null, 14 | clients: null, 15 | invoices: null, 16 | accounts: null, 17 | 18 | isRemoveItemDisabled: function() { 19 | return this.get('items.length') <= 1; 20 | }.property('items.@each'), 21 | 22 | contentDidChange: function() { 23 | let periodNumber, 24 | lastNumber, 25 | properties = {}; 26 | 27 | if (this.get('settings.numerationTypeCode') === 'year') { 28 | periodNumber = new Date().getFullYear().toString(); 29 | } 30 | 31 | if (this.get('settings.numerationTypeCode') === 'month') { 32 | periodNumber = 33 | (new Date().getMonth() + 1).toString() + 34 | '/' + 35 | new Date().getFullYear().toString(); 36 | } 37 | 38 | if (periodNumber) { 39 | lastNumber = 40 | this.get('invoices') 41 | .filterBy('periodNumber', periodNumber) 42 | .sortBy('periodicalNumber') 43 | .get('lastObject.periodicalNumber') || 0; 44 | properties.number = lastNumber + 1 + '/' + periodNumber; 45 | } 46 | 47 | properties.seller = this.get('settings.seller'); 48 | properties.sellerSignature = this.get('settings.contactName'); 49 | properties.dueDays = this.getWithDefault('settings.dueDays', 14); 50 | 51 | let model = this.get('model'); 52 | if (model) model.setProperties(properties); 53 | 54 | // bindings somehow don't work in minified version without Ember.run.next 55 | next(() => { 56 | let content = this.get('content'); 57 | if (content) content.addItem(); 58 | }); 59 | }.observes('content'), 60 | 61 | actions: { 62 | addItem() { 63 | this.get('model').addItem(); 64 | }, 65 | 66 | removeItem(item) { 67 | this.get('model.items').removeObject(item); 68 | }, 69 | 70 | chooseClient: function(client) { 71 | this.get('model').setProperties({ 72 | buyer: client.get('buyer'), 73 | buyerSignature: client.get('contactName') 74 | }); 75 | }, 76 | 77 | chooseAccount: function(account) { 78 | this.get('model').setProperties({ 79 | accountBankName: account.get('bankName'), 80 | accountSwift: account.get('swift'), 81 | accountNumber: account.get('number') 82 | }); 83 | } 84 | } 85 | }); 86 | -------------------------------------------------------------------------------- /app/templates/_client_form.hbs: -------------------------------------------------------------------------------- 1 | 49 | 50 | 83 | -------------------------------------------------------------------------------- /app/templates/application.hbs: -------------------------------------------------------------------------------- 1 | {{#if showLayout}} 2 |14 | {{#if model.isEnglish}} 15 | Numer / Number: 16 | {{else}} 17 | Numer: 18 | {{/if}} 19 | {{model.number}} 20 |
21 |22 | {{#if model.isEnglish}} 23 | Data wystawienia / Date of issue: 24 | {{else}} 25 | Data wystawienia: 26 | {{/if}} 27 | {{model.issueDate}} 28 |
29 |30 | {{#if model.isEnglish}} 31 | Data dostawy towarów/wykonania usługi / Delivery date of goods/services: 32 | {{else}} 33 | Data dostawy towarów/wykonania usługi: 34 | {{/if}} 35 | {{model.deliveryDate}} 36 |
37 |38 | {{#if model.isEnglish}} 39 | Termin płatności / Due date: 40 | {{else}} 41 | Termin płatności: 42 | {{/if}} 43 | {{model.dueDate}} 44 |
45 | 46 | {{#if model.isExchanging}} 47 |48 | Kwoty podatku VAT zostały przeliczone po kursie średnim {{model.currencyCode}} z dnia {{model.exchangeDate}}, 49 | wynoszącym {{format-cents exchangeRate precision=4}} PLN. 50 |
51 | {{/if}} 52 |
65 | {{model.sellerFirstLine}}
66 | {{#each model.sellerRest as |line|}}
67 | {{line}}
68 | {{/each}}
69 |
82 | {{model.buyerFirstLine}}
83 | {{#each model.buyerRest as |line|}}
84 | {{line}}
85 | {{/each}}
86 |
| 96 | Nazwa 97 | | 98 |99 | Liczba 100 | | 101 |102 | J.m. 103 | | 104 |105 | Cena netto 106 | | 107 |108 | Wartość netto 109 | | 110 |111 | Stawka VAT 112 | | 113 |114 | Wartość VAT 115 | | 116 |117 | Wartość brutto 118 | | 119 |
|---|---|---|---|---|---|---|---|
| 124 | Name 125 | | 126 |127 | Quantity 128 | | 129 |130 | Unit 131 | | 132 |133 | Net price 134 | | 135 |136 | Net amount 137 | | 138 |139 | VAT rate 140 | | 141 |142 | VAT amount 143 | | 144 |145 | Gross amount 146 | | 147 |
|
155 | 156 | {{item.description}} 157 | 158 | |
159 |
160 | {{item.quantity}} 161 | |
162 |
163 |
164 | {{item.unit.name}}
165 | {{#if model.isEnglish}}
166 | |
170 |
171 | 172 | {{format-cents item.netPrice}} 173 | {{model.currency.code}} 174 | 175 | |
176 |
177 | 178 | {{format-cents item.netAmount}} 179 | {{model.currency.code}} 180 | 181 | |
182 |
183 |
184 | {{item.taxRate.name}}
185 | {{#if model.isEnglish}}
186 | |
190 |
191 | 192 | {{format-cents item.taxAmount}} 193 | {{model.currency.code}} 194 | 195 | |
196 |
197 | 198 | {{format-cents item.grossAmount}} 199 | {{model.currency.code}} 200 | 201 | |
202 |
| 206 | Razem 207 | {{#if model.isEnglish}} 208 | / Total 209 | {{/if}} 210 | | 211 |212 | {{format-cents model.totalNetAmount}} 213 | {{model.currency.code}} 214 | | 215 |216 | |
217 | {{format-cents model.totalTaxAmount}}
218 | {{model.currency.code}}
219 | {{#if model.isExchanging}}
220 | {{format-cents model.totalTaxAmountPLN}} 221 | PLN 222 | {{/if}} 223 | |
224 | 225 | {{format-cents model.totalGrossAmount}} 226 | {{model.currency.code}} 227 | | 228 ||||
| 232 | W tym 233 | {{#if model.isEnglish}} 234 | / Including 235 | {{/if}} 236 | | 237 |238 | {{format-cents subTotal.netAmount}} 239 | {{model.currency.code}} 240 | | 241 |
242 | {{subTotal.taxRate.name}}
243 | {{#if isEnglish}}
244 | {{subTotal.taxRate.nameEN}} 245 | {{/if}} 246 | |
247 |
248 | {{format-cents subTotal.taxAmount}}
249 | {{currency.code}}
250 | {{#if model.isExchanging}}
251 | {{format-cents subTotal.taxAmountPLN}} 252 | PLN 253 | {{/if}} 254 | |
255 | 256 | {{format-cents subTotal.grossAmount}} 257 | {{currency.code}} 258 | | 259 ||||
267 | Słownie: {{model.totalGrossAmountInWords}} 268 |
269 | {{#if model.isEnglish}} 270 |271 | In words: {{model.englishTotalGrossAmountInWords}} 272 |
273 | {{/if}} 274 |290 | {{model.accountBankName}} 291 |
292 | {{/if}} 293 | {{#if model.accountSwift}} 294 |295 | SWIFT: {{model.accountSwift}} 296 |
297 | {{/if}} 298 |299 | IBAN: {{model.accountNumber}} 300 |
301 |
317 | {{#each model.commentLines as |line|}}
318 | {{line}}
319 | {{/each}}
320 |
{{model.sellerSignature}}
331 |332 | Podpis wystawiającego 333 | {{#if model.isEnglish}} 334 | / Issuer's signature 335 | {{/if}} 336 |
337 | {{/if}} 338 |{{model.buyerSignature}}
342 |343 | Podpis nabywcy 344 | {{#if model.isEnglish}} 345 | / Buyer's signature 346 | {{/if}} 347 |
348 | {{/if}} 349 |33 | Korzystając z prostego i przejrzystego interfejsu, możesz 34 | utworzyć nielimitowaną liczbę faktur, zupełnie za 35 | darmo. Faktury możesz wydrukować bezpośrednio z 36 | przeglądarki internetowej. 37 |
38 |50 | Wystawiaj faktury w języku polskim, lub w 51 | wersji polsko-angielskiej dla klientów z 52 | zagranicy. Aplikacja automatycznie przewalutuje kwoty w 53 | walutach obcych, korzystając z tabeli 54 | kursów NBP. 55 |
56 |63 | Przyspiesz wystawianie faktur dla stałych odbiorców 64 | dodając ich do bazy danych. Możesz także zdefiniować 65 | dowolną liczbę rachunków bankowych, 66 | rozliczanych w złotówkach lub innych walutach. 67 |
68 |