Skip to content

Instantly share code, notes, and snippets.

@marek-saji
Created June 4, 2015 09:24
Show Gist options
  • Save marek-saji/94abb49be59f2db2019d to your computer and use it in GitHub Desktop.
Save marek-saji/94abb49be59f2db2019d to your computer and use it in GitHub Desktop.
Fakturomir: dead–simple, single element polish invoicong
<!DOCTYPE html>
<!--
TODO
[ ] ładność
[ ] tabela
[ ] kolor?
[ ] suma kontrolna numeru konta
http://wipos.p.lodz.pl/zylla/ut/banki.html
[ ] suma kontrolna NIP
http://pl.wikipedia.org/wiki/NIP#Znaczenie_numeru
-->
<html>
<head>
<title>Faktura</title>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<style media=all>
html
{
font-family: serif;
line-height: 1.5em;
}
body
{
/* A4 measuring 210 × 297 millimetres (8.3 in × 11.7 in) */
max-width: 21.0cm;
margin: 0 auto;
padding: 1em;
}
h1
{
float: left;
font-size: 6em;
line-height: 1em;
text-align: left;
width: 55%;
margin: 0;
padding: 0;
}
h1 + h2
{
font-size: 1em;
text-align: right;
vertical-align: top;
margin: 0 0 1em 0;
padding: 0;
}
h1 + h2,
header .dates
{
float: right;
clear: none;
text-align: center;
width: 45%;
overflow: hidden;
position: relative;
}
header > dl > dt
{
font-style: italic;
}
header > dl > dd:last-of-type
{
margin-bottom: 2em;
}
header > dl > dt,
header > dl > dd
{
display: inline-block;
width: 49%;
}
header > dl > dt:first-of-type,
header > dl > dd:first-of-type
{
width: 51%;
float: left;
}
header dt::after
{
content: '';
}
header > dl
{
clear: both;
text-align: center;
margin: 0;
padding: 0;
}
.parties > dt
{
margin-bottom: 1em;
}
.parties > dd:nth-of-type(1)::before,
.parties > dd:nth-of-type(2)::before
{
content: '→';
font-size: 4em;
position: absolute;
width: 2em;
left: 50%; margin-left: -0.9em;
text-align: center;
color: #ccc;
}
.parties > dd:nth-of-type(2)::before
{
content: '←';
margin-top: 0.76em;
}
.parties > dt,
.parties > dd
{
text-align: left;
box-sizing: border-box;
padding: 0 3em;
}
.parties > dt:first-of-type,
.parties > dd:first-of-type
{
text-align: right;
}
.parties > dt > dl
{
display: inline-block;
text-align: left;
}
h2,
h3
{
font-size: 1em;
margin: 0;
padding: 0;
}
dl
{
margin: 0;
padding: 0;
}
dd,
dt
{
display: inline;
margin: 0;
padding: 0;
}
dt::after
{
content: ': ';
}
dt::before
{
content: ' ';
display: block;
font-size: 0;
height: 0;
}
.payment
{
clear: both;
margin-bottom: 2em;
}
table .col-id,
table .col-short-word
{
width: 5%;
}
table .col-money
{
width: 12.5%;
}
table .col-short-money,
table .col-num
{
width: 10%;
}
.products
{
counter-reset: products;
}
.products .product-id::before
{
counter-increment: products;
content: counter(products);
}
.products
{
width: 100%;
border-collapse: collapse;
}
.products th,
.products td
{
border: #aaa solid thin;
padding: 0.25em;
}
.products td[colspan]
{
border: none;
}
.products th
{
background-color: #eee;
}
.products td,
.products tfoot th
{
text-align: right;
line-height: 2em;
}
.products tbody td:nth-child(1),
.products tbody td:nth-child(4)
{
text-align: center;
}
.products tbody td:nth-child(2)
{
text-align: left;
}
.products tfoot
{
font-weight: bold;
}
footer
{
margin-top: 2em;
text-align: right;
}
footer .sum,
footer .sum + dd
{
font-weight: bold;
}
footer .sum + dd
{
font-size: 2em;
line-height: 1em;
}
footer .sum + dd > dl
{
font-size: 0.5em;
font-weight: normal;
}
.signatures,
.signatures li
{
list-style: none;
margin: 0; padding: 0;
text-align: center;
line-height: 1.2em;
}
.signatures > li
{
width: 39%;
font-size: 0.75em;
display: inline-block;
vertical-align: top;
margin: 5rem 5% 0 5%;
padding-top: 1em;
border-top: #aaa dashed thin;
}
.signatures > li p
{
margin: 0;
}
</style>
<style media=screen>
[contenteditable]
{
text-align: center;
display: inline-block;
min-width: 4em;
position: relative;
padding: 0 .4em;
outline: none;
}
[contenteditable].error
{
border: red solid thin;
}
[contenteditable][id*=date],
[contenteditable][id*=deadline],
[contenteditable][id$="-no"],
[contenteditable][for*=date],
td:not(:nth-of-type(2)) > [contenteditable],
h1 [contenteditable]
{
min-width: 1em;
}
[contenteditable]::before
{
content: '';
border: #b44 solid thin;
border-width: 0 thin thin;
position: absolute;
left: 0.1em; right: 0.1em; bottom: 0.1em;
height: 0.2em; max-height: 0.5rem;
}
[placeholder]:empty:not(:focus)::after
{
content: attr(placeholder);
color: #b44;
}
[for]:not([for*=","]):not([contenteditable])
{
cursor: pointer;
}
body
{
margin-bottom: 4em;
}
#tools
{
position: fixed;
max-width: 21.0cm;
bottom: 0;
width: 100%;
height: 3em;
background-color: #eee;
opacity: 0.7;
margin-left: -1em;
padding-right: 1em;
}
#tools > ul,
#tools > ul > li
{
list-style: none;
margin: 0;
padding: 0;
}
#tools > ul
{
display: flex;
}
#tools > ul > li
{
flex: 1 1 auto;
text-align: center;
line-height: 1em;
padding: 1em 0;
}
</style>
<style media=print>
[contenteditable]:empty,
output:empty
{
outline: #b44 solid thick;
text-align: center;
display: inline-block;
min-width: 2em; min-height: 1em;
position: relative;
padding: 0 .4em;
}
[contenteditable]:empty::after
{
content: attr(placeholder);
color: #b44;
}
#tools
{
display: none;
}
</style>
</head>
<body>
<header>
<h1>
<span contenteditable id=org placeholder=Firma></span>
</h1>
<h2>
Faktura VAT №
<span id=invoice-no>
<output class=concat-of for=invoice-date-y placeholder="YYYY"></output>
/
<output class=concat-of for=invoice-date-m contenteditable placeholder="MM"></output>
/
<span id=invoice-no-no contenteditable placeholder="№"></span>
</span>
</h2>
<dl class=dates>
<dt>data wystawienia</dt>
<dd id=invoice-date>
<span id=invoice-date-d contenteditable placeholder="DD"></span>
-
<span id=invoice-date-m contenteditable placeholder="MM"></span>
-
<span id=invoice-date-y contenteditable placeholder="YYYY"></span>
</dd>
<dt>data sprzedaży</dt>
<dd id=sell-date>
<span id=sell-date-d class=concat-of for=invoice-date-d contenteditable placeholder="DD"></span>
-
<span id=sell-date-m class=concat-of for=invoice-date-m contenteditable placeholder="MM"></span>
-
<span id=sell-date-y class=concat-of for=invoice-date-y contenteditable placeholder="YYYY"></span>
</dd>
</dl>
<dl class=parties>
<dt>Sprzedawca</dt>
<dd>
<h3>
<em class=concat-of for=org placeholder="Nazwa firmy"></em>
<span contenteditable id=seller-n placeholder="Imię i nazwisko"></span>
</h3>
<dl>
<dd>
<span contenteditable id=seller-street-address placeholder="Ulica"></span>
<br>
<span contenteditable id=seller-postal-code placeholder="Kod pocztowy"></span>
<span contenteditable id=seller-locality placeholder="Miejscowość"></span>
</dd>
<dt>NIP</dt>
<dd>
<span contenteditable id=seller-NIP placeholder="xxxxxxxxxx" type=nip></span>
</dd>
</dl>
</dd>
<dt>Nabywca</dt>
<dd>
<h3>
<em contenteditable id=purchaser-org placeholder="Nazwa firmy"></em>
<span contenteditable id=purchaser-n placeholder="Imię i nazwisko"></span>
</h3>
<dl>
<dd>
<span contenteditable id=purchaser-street-address placeholder="Ulica"></span>
<br>
<span contenteditable id=purchaser-postal-code placeholder="Kod pocztowy"></span>
<span contenteditable id=purchaser-locality placeholder="Miejscowość"></span>
</dd>
<dt>NIP</dt>
<dd>
<span contenteditable id=purchaser-NIP placeholder="xxxxxxxxxx" type=nip></span>
</dd>
</dl>
</dd>
</dl>
</header>
<dl class=payment>
<dt>Termin płatności</dt>
<dd id=payment-deadline>
<span contenteditable id=payment-deadline-d placeholder="DD"></span>
-
<span contenteditable id=payment-deadline-m placeholder="MM"></span>
-
<span contenteditable id=payment-deadline-y placeholder="YYYY"></span>
</dd>
<dt>Sposób płatności</dt>
<dd contenteditable id=payment-type>Przelew</dd>
<dt>Bank</dt>
<dd contenteditable id=payment-bank>mBank</dd>
<dt>Numer konta</dt>
<dd contenteditable id=payment-account placeholder="PL xx xxxx xxxx xxxx" type=iban></dd>
</dl>
<table class=products>
<thead>
<tr>
<th class=col-id>L.p.</th>
<th>Nazwa</th>
<th class=col-num>Ilość</th>
<th class=col-short-word>J.m.</th>
<th class=col-money>Cena netto</th>
<th class=col-money>Wartość netto</th>
<th class=col-num>Stawka VAT</th>
<th class=col-short-money>Kwota VAT</th>
<th class=col-money>Wartość brutto</th>
</tr>
</thead>
<tbody>
<tr class=product>
<td class=product-id></td>
<td><span contenteditable id=product-1-name>Usługi</span></td>
<td><span contenteditable class=product-count
id=product-1-count>1</span></td>
<td><span contenteditable class=product-unit
id=product-1-unit>szt.</span></td>
<td><span contenteditable class=product-unit-price
type=money id=product-1-unit-price></span></td>
<td><output class=product-of for=product-1-count,product-1-unit-price
id=product-1-price type=money></output></td>
<td><span contenteditable class=product-tax-rate
id=product-1-tax-rate>23</span>%</td>
<td><output class=product-of for=product-1-price,product-1-tax-rate,0.01
id=product-1-tax type=money></output></td>
<td><output class=sum-of for=product-1-price,product-1-tax
id=product-1-value type=money></output></td>
</tr>
</tbody>
<tfoot>
<tr class>
<td colspan=4>&nbsp;</td>
<th>Razem</th>
<td><output type=money class=sum-of for=product-1-price></output></td>
<td>–</td>
<td><output type=money class=sum-of for=product-1-tax></output></td>
<td><output type=money class=sum-of for=product-1-value id=sum></output></td>
</tr>
</tfoot>
<tfoot>
<tr>
<td colspan=4>&nbsp;</td>
<th>W tym</th>
<td><output type=money class=sum-of for=product-1-price></output></td>
<td><output class=sum-of for=product-1-tax-rate>23</output>%</td>
<td><output type=money class=sum-of for=product-1-tax></output></td>
<td><output type=money class=sum-of for=product-1-value></output></td>
</tr>
</tfoot>
</table>
<footer>
<dl>
<dt>Zapłacono</dt>
<dd>
<span contenteditable id=paid type=money>0,00</span> zł
<dl>
<dt>Słownie</dt>
<dd>
<span contenteditable id=paid-verbally-zł
class=verbalization-of for=paid>zero</span> zł,
<span contenteditable id=paid-verbally-gr
class=fraction-verbalization-of for=paid>zero</span> gr
</dd>
</dl>
</dd>
<dt class=sum>Razem do zapłaty</dt>
<dd>
<span class=sum-of for=sum type=money placeholder=Suma></span> zł
<dl>
<dt>Słownie</dt>
<dd>
<span contenteditable id=sum-verbally-zł
class=verbalization-of for=sum></span> zł,
<span contenteditable id=sum-verbally-gr
class=fraction-verbalization-of for=sum></span> gr
</dd>
</dl>
</dd>
</dl>
<ol class=signatures>
<li class=seller>
<dl>
<dt>Sprzedawca</dt>
<dd>
<p>
<output class=concat-of for=org></output>
<output class=concat-of for=seller-n></output>
</p>
</dd>
</dl>
</li>
<li class=purchaser>
<dl>
<dt>Nabywca</dt>
<dd>
<p>
<output class=concat-of for=purchaser-org></output>
<output class=concat-of for=purchaser-n></output>
</p>
</dd>
</dl>
</li>
</ol>
</footer>
<nav id=tools>
<ul>
<li>
<a id=download download="faktura.html" href="#html">
pobierz html
</a>
</li>
<li>
<a id=print href="#">
drukuj
</a>
</li>
<li>
<a id=clear href=".">
wyczyść
</a>
</li>
</ul>
</nav>
<script>
const APP_ID = 'Fakturomir';
/**
* Convert formatted money string to a float
*/
function unformatMoney (value)
{
if (value === undefined || "" == value)
{
return '';
}
else
{
return parseFloat( (value+"").replace(/[,.]/g, '.').replace(/\s/g, '') );
}
}
/**
* Convert float or string to a polish currency format
*/
function formatMoney (value)
{
value = unformatMoney(value);
if (value === undefined || isNaN(value) || "" == ""+value)
{
return "";
}
else
{
return formatMoney.formatter.format(value).replace(/\s*PLN\s*/, '');
}
}
formatMoney.formatter = new Intl.NumberFormat('pl', {
style: 'currency',
currency: 'PLN',
currencyDisplay: 'code',
minimumFractionDigits: 2,
maximumFractionDigits: 2
});
function verbalizeMoney (value)
{
var idx,
verbally = '';
value = parseInt(value);
if (!isNaN(value) && verbalizeMoney.MIN <= value && value <= verbalizeMoney.MAX)
{
while (value > 9)
{
for (idx in verbalizeMoney.WORDS)
{
if (value >= verbalizeMoney.WORDS[idx].amount)
{
value -= verbalizeMoney.WORDS[idx].amount;
verbally += verbalizeMoney.WORDS[idx].word + ' ';
break;
}
}
}
if (verbalizeMoney.DIGITS.hasOwnProperty(value))
{
verbally += verbalizeMoney.DIGITS[value];
}
else if ('' === verbally)
{
verbally = verbalizeMoney.ZERO;
}
}
return verbally;
}
verbalizeMoney.MIN = 0;
verbalizeMoney.MAX = 9999;
verbalizeMoney.ZERO = 'zero';
verbalizeMoney.DIGITS = {
9: 'dziewięć',
8: 'osiem',
7: 'siedem',
6: 'sześć',
5: 'pięć',
4: 'cztery',
3: 'trzy',
2: 'dwa',
1: 'jeden'
};
verbalizeMoney.WORDS =[
{amount:1000, word:'tysiąc'},
{amount:900, word: 'dziewięćset'},
{amount:800, word: 'osiemset'},
{amount:700, word: 'siedemset'},
{amount:600, word: 'sześćset'},
{amount:500, word: 'pięćset'},
{amount:400, word: 'czterysta'},
{amount:300, word: 'trzysta'},
{amount:200, word: 'dwieście'},
{amount:100, word: 'sto'},
{amount:90, word: 'dziewięćdziesiąt'},
{amount:80, word: 'osiemdziesiąt'},
{amount:70, word: 'siedemdziesiąt'},
{amount:60, word: 'sześćdziesiąt'},
{amount:50, word: 'pięćdziesiąt'},
{amount:40, word: 'czterdzieści'},
{amount:30, word: 'trzydzieści'},
{amount:20, word: 'dwadzieścia'},
{amount:19, word: 'dziewiętnaście'},
{amount:18, word: 'osiemnaście'},
{amount:17, word: 'siedemnaście'},
{amount:16, word: 'szesnaście'},
{amount:15, word: 'piętnaście'},
{amount:14, word: 'czternaście'},
{amount:13, word: 'trzynaście'},
{amount:12, word: 'dwanaście'},
{amount:11, word: 'jedenaście'},
{amount:10, word: 'dziesięć'}
];
verbalizeMoney.WORDS = []
.concat(
[10,9,8,7,6,5].map(function (digit) {
return {
amount: digit * 1000,
word: verbalizeMoney(digit) + ' tysięcy'
};
})
)
.concat(
[4,3,2].map(function (digit) {
return {
amount: digit * 1000,
word: verbalizeMoney(digit) + ' tysiące'
};
})
)
.concat(verbalizeMoney.WORDS);
/**
* Get id used for storing data
*/
function getStorageId (id)
{
return APP_ID + ':values:' + id;
}
/**
* Set element's value
*
* - Format elemnts with [type=money]
* - Dispatch 'input' event after setting value
*/
function updateValue (value, element)
{
var event;
if (element.getAttribute('type') === 'money')
{
value = formatMoney(value);
}
if (element.textContent !== value)
{
element.textContent = value;
event = document.createEvent("HTMLEvents");
event.initEvent('input', true, true);
// dispatching events seems to be working only after DOM is ready
if (document.readyState !== 'complete')
{
setTimeout(element.dispatchEvent.bind(element, event), 0);
}
else
{
element.dispatchEvent(event);
}
}
}
/**
* Store element's value on input
*/
function eventInput (event)
{
var element = event.target,
storageId;
if (element.id)
{
storageId = getStorageId(element.id);
if (element.textContent)
{
localStorage.setItem(storageId, element.textContent);
}
else
{
localStorage.removeItem(storageId);
}
}
}
/**
* Format currency, when bluring [type=money]
*/
function eventMoneyBlur (event)
{
var element = event.target;
updateValue( element.textContent, element );
}
/**
* Bind input and blur events, restore stored values
*/
Array.prototype.forEach.call(
document.querySelectorAll('[contenteditable][id], output'),
function (element) {
var storedContent = localStorage.getItem( getStorageId(element.id) );
element.addEventListener('input', eventInput, false);
if (element.getAttribute('type') === 'money')
{
element.addEventListener('blur', eventMoneyBlur, false);
}
if (null !== storedContent)
{
updateValue(storedContent, element);
}
}
);
/**
* Update filename and page title, when invoice number changes
*/
function eventInputInvoiceNo (event)
{
var invoiceNo = this.textContent.replace(/\s*/g, '').replace(/[\/_]/g, '-').trim();
document.title = 'Faktura ' + invoiceNo;
document.getElementById('download').download = 'faktura_'
+ invoiceNo.replace(/[^a-zA-Z0-9_-]+/g, '-')
+ '.html';
}
document.getElementById('invoice-no').addEventListener(
'input', eventInputInvoiceNo, false
);
/**
* Upate payment deadline, when invoice date changes
*/
function eventInputInvoiceDate (event)
{
var date;
try
{
date = new Date(
document.getElementById(this.id + '-y').textContent,
document.getElementById(this.id + '-m').textContent,
document.getElementById(this.id + '-d').textContent
);
if (isNaN(date.getDate()))
{
throw date;
}
date.setDate( date.getDate() + 7 );
updateValue(
( date.getDate() < 10 ? '0' : '' ) + date.getDate(),
document.getElementById('payment-deadline-d')
);
updateValue(
( date.getMonth() < 10 ? '0' : '' ) + date.getMonth(),
document.getElementById('payment-deadline-m')
);
updateValue(
date.getFullYear(),
document.getElementById('payment-deadline-y')
);
}
catch (e)
{}
}
document.getElementById('invoice-date').addEventListener(
'input', eventInputInvoiceDate, false
);
/**
* Check account number (IBAN) checksum
*/
/*
function validateIban (iban)
{
iban = iban.toLowerCase().replace(/[^a-z0-9]/g, '');
if ('' === iban)
{
return true;
}
else if (!iban.match(/^[a-z]{2}[0-9]{26}$/))
{
return false;
}
else
{
iban = iban.split('').map(function (char) {
var charCode = char.charCodeAt(0);
if ('a'.charCodeAt(0) <= charCode && charCode <= 'z'.charCodeAt(0))
{
return charCode - 'a'.charCodeAt(0) + 10;
}
else
{
return +char;
}
});
iban = iban.concat( iban.splice(0,4) );
return 1 === (+iban.join('')) % 97;
}
}
function eventBlurIban (event)
{
var element = event.target;
element.classList.toggle('error', !validateIban(element.textContent));
}
Array.prototype.forEach.call(
document.querySelectorAll('[type=iban]'),
function (element) {
element.addEventListener('blur', eventBlurIban, false);
}
);
*/
/**
* Synchronize .concat-of, .sum-of and .product-of with their [for].
*/
function eventInputOperationParticipant (event)
{
var element = event.target;
Array.prototype.forEach.call(
document.querySelectorAll('[class$="-of"][for]'),
function (dep) {
var parts = dep.getAttribute('for').split(','),
values, newValue;
if (-1 !== parts.indexOf(element.id))
{
values = parts.map(function (part) {
var element = document.getElementById(part);
if (!element)
{
return part;
}
else if (element.getAttribute('type') === 'money')
{
return unformatMoney(element.textContent);
}
else
{
return element.textContent;
}
});
if (dep.classList.contains('concat-of'))
{
newValue = values.reduce(
function (prevValue, value) {
return prevValue + value;
},
''
);
}
else if (dep.classList.contains('verbalization-of'))
{
newValue = values.map(verbalizeMoney).join(', ');
}
else if (dep.classList.contains('fraction-verbalization-of'))
{
newValue = values.map(function (value) {
return (value.toString().split('.')[1] + '00').substr(0,2);
}).map(verbalizeMoney).join(', ');
}
else
{
if (dep.classList.contains('sum-of'))
{
newValue = values.reduce(
function (prevValue, value) {
return prevValue + parseFloat(value);
},
0
);
}
else if (dep.classList.contains('product-of'))
{
newValue = values.reduce(
function (prevValue, value) {
return prevValue * value;
},
1
);
}
if (isNaN(newValue) || !isFinite(newValue))
{
value = '';
}
}
updateValue(newValue, dep);
}
}
);
}
Array.prototype.forEach.call(
document.querySelectorAll('[contenteditable][id], output'),
function (element) {
element.addEventListener('input', eventInputOperationParticipant, false);
}
);
/**
* When clicking a [for], focus what it's for
*
* Browsers support this only for form elements and we use it
* with *[contenteditable].
*/
function eventFocusFor (event)
{
var targetFor = document.getElementById( event.target.getAttribute('for') );
if (targetFor)
{
targetFor.focus();
}
}
Array.prototype.forEach.call(
document.querySelectorAll('[for]:not([contenteditable])'),
function (element) {
element.addEventListener('click', eventFocusFor, false);
}
);
/**
* Download HTML with scripts etc stripped out and data filled in.
*/
function eventDownload (event)
{
var doc = document.implementation.createHTMLDocument();
doc.documentElement.innerHTML = document.documentElement.innerHTML;
Array.prototype.forEach.call(
doc.querySelectorAll('[contenteditable]'),
function (element) {
element.removeAttribute('contenteditable');
if ('' === element.textContent.trim())
{
element = document.getElementById( element.id );
element.scrollIntoViewIfNeeded();
element.focus();
event.preventDefault();
}
}
);
Array.prototype.forEach.call(
doc.querySelectorAll('[for], [placeholder]'),
function (element) {
element.removeAttribute('for');
element.removeAttribute('placeholder');
}
);
Array.prototype.forEach.call(
doc.querySelectorAll('script, #tools, style[media=screen]'),
function (element) {
console.log(element);
element.remove();
}
);
event.target.href = 'data:text/html,'
+ encodeURIComponent(doc.documentElement.outerHTML);
}
document.getElementById('download').addEventListener(
'click', eventDownload, false
);
/**
* Clear ctored data
*/
function eventClean (event)
{
localStorage.clear();
event.preventDefault();
window.location.reload();
}
document.getElementById('clear').addEventListener(
'click', eventClean, false
);
/**
* Print
*/
document.getElementById('print').addEventListener(
'click', window.print.bind(null), false
);
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment