1
0
Fork 0

invoicer initial version

Change-Id: Ib20a96c224f5c055874f72f8f9a04a4dc8bbbc24
master
arsenicum 2023-10-05 00:05:32 +02:00
parent 2b27fa6a37
commit bdf2defc07
38 changed files with 20472 additions and 0 deletions

109
personal/arsenicum/invoicer/.gitignore vendored Normal file
View File

@ -0,0 +1,109 @@
out/
.terraform
# User-specific stuff:
.idea
*.iml
*id_rsa*
md5-file
# Gradle:
.idea/gradle.xml
.idea/libraries
# Mongo Explorer plugin:
.idea/mongoSettings.xml
## File-based project format:
*.iws
## Plugin-specific files:
# IntelliJ
/out/
# mpeltonen/sbt-idea plugin
.idea_modules/
# JIRA plugin
atlassian-ide-plugin.xml
# Crashlytics plugin (for Android Studio and IntelliJ)
com_crashlytics_export_strings.xml
crashlytics.properties
crashlytics-build.properties
fabric.properties
### Intellij Patch ###
# Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721
# *.iml
# modules.xml
# .idea/misc.xml
# *.ipr
### Java ###
*.class
# BlueJ files
*.ctxt
# Mobile Tools for Java (J2ME)
.mtj.tmp/
# Package Files #
*.jar
*.war
*.ear
# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml
hs_err_pid*
### Gradle ###
.gradle
/build/
# Ignore Gradle GUI config
gradle-app.setting
# Avoid ignoring Gradle wrapper jar file (.jar files are usually ignored)
!gradle-wrapper.jar
# Cache of project
.gradletasknamecache
# # Work around https://youtrack.jetbrains.com/issue/IDEA-116898
# gradle/wrapper/gradle-wrapper.properties
# End of https://www.gitignore.io/api/java,intellij,gradle
.gradle/
.idea/
id-core.iml
**/*.iml
build/
/.settings/
/.classpath
/.DS_Store
/.project
classes/
/output/
dump.rdb
/libs
*.DS_Store
bin/
.vscode
application-scratch.yml
api-doc/*0.yml
*.generated*
ignite/
# Helm Charts
/helm/*/charts/
/helm/*/requirements.lock
/helm/*/Chart.lock

View File

@ -0,0 +1,13 @@
# binaries
bin/
dist/
lib/
# editors
*.swp
.idea/
.vs/
.vscode/
.DS_Store
node_modules

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,53 @@
{
"name": "frontend",
"version": "0.1.0",
"private": true,
"dependencies": {
"@testing-library/jest-dom": "^5.11.10",
"@testing-library/react": "^11.2.5",
"@testing-library/user-event": "^12.8.3",
"@testing-library/dom": "^7.21.4",
"@types/webpack": "^5.28.0",
"sockjs-client": "^1.4.0",
"type-fest": "^0.13.1",
"webpack-hot-middleware": "^2.25.1",
"webpack-plugin-serve": "^1.5.0",
"bootstrap": "^5.1",
"react": "^17.0.2",
"react-cookie": "^4.1.1",
"react-dom": "^17.0.2",
"react-router-dom": "^5.3.0",
"react-scripts": "5.0.1",
"reactstrap": "^8.10.0",
"web-vitals": "^1.1.1",
"@mui/material": "^5.14.10",
"@emotion/react": "^11.11.1",
"@emotion/styled": "^11.11.0",
"file-saver": "^2.0.5"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
},
"proxy": "http://localhost:8080",
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

@ -0,0 +1,29 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta
name="description"
content="Web site created using create-react-app"
/>
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<!--
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
-->
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;600;700&display=swap" />
<meta name="viewport" content="initial-scale=1, width=device-width" />
<title>Invoicer</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

View File

@ -0,0 +1,25 @@
{
"short_name": "React App",
"name": "Create React App Sample",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
},
{
"src": "logo192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "logo512.png",
"type": "image/png",
"sizes": "512x512"
}
],
"start_url": ".",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff"
}

View File

@ -0,0 +1,3 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:

View File

@ -0,0 +1,42 @@
.App {
text-align: center;
}
.App-logo {
height: 40vmin;
pointer-events: none;
}
@media (prefers-reduced-motion: no-preference) {
.App-logo {
animation: App-logo-spin infinite 20s linear;
}
}
.App-header {
background-color: #282c34;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-size: calc(10px + 2vmin);
color: white;
}
.App-link {
color: #61dafb;
}
@keyframes App-logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.container, .container-fluid {
margin-top: 20px;
}

View File

@ -0,0 +1,34 @@
import React, { Component } from 'react';
import Container from '@mui/material/Container';
import Typography from '@mui/material/Typography';
import Box from '@mui/material/Box';
import './App.css';
import Home from './Home';
import { BrowserRouter as Router, Route, Switch } from 'react-router-dom';
import ClientList from './ClientList';
import InvoiceList from './InvoiceList';
import ClientEdit from "./ClientEdit";
class App extends Component {
render() {
return (
<Container maxWidth="sm">
<Box sx={{ my: 4 }}>
<Typography variant="h4" component="h1" gutterBottom>
Invoicer App
</Typography>
<Router>
<Switch>
<Route path='/' exact={true} component={Home}/>
<Route path='/invoices' exact={true} component={InvoiceList}/>
<Route path='/clients' exact={true} component={ClientList}/>
<Route path='/clients/:id' component={ClientEdit}/>
</Switch>
</Router>
</Box>
</Container>
)
}
}
export default App;

View File

@ -0,0 +1,8 @@
import { render, screen } from '@testing-library/react';
import App from './App';
test('renders learn react link', () => {
render(<App />);
const linkElement = screen.getByText(/Clients/i);
expect(linkElement).toBeInTheDocument();
});

View File

@ -0,0 +1,23 @@
import React, {Component} from 'react';
import {Navbar, NavbarBrand} from 'reactstrap';
import {Link} from 'react-router-dom';
export default class AppNavbar extends Component {
constructor(props) {
super(props);
this.state = {isOpen: false};
this.toggle = this.toggle.bind(this);
}
toggle() {
this.setState({
isOpen: !this.state.isOpen
});
}
render() {
return <Navbar color="dark" dark expand="md">
<NavbarBrand tag={Link} to="/">Home</NavbarBrand>
</Navbar>;
}
}

View File

@ -0,0 +1,82 @@
import React, { Component } from 'react';
import { Link, withRouter } from 'react-router-dom';
import { Button, Container, Form, FormGroup, Input, Label } from 'reactstrap';
import AppNavbar from './AppNavbar';
class ClientEdit extends Component {
emptyItem = {
name: '',
email: ''
};
constructor(props) {
super(props);
this.state = {
item: this.emptyItem
};
this.handleChange = this.handleChange.bind(this);
this.handleSubmit = this.handleSubmit.bind(this);
}
async componentDidMount() {
if (this.props.match.params.id !== 'new') {
const client = await (await fetch(`/clients/${this.props.match.params.id}`)).json();
this.setState({item: client});
}
}
handleChange(event) {
const target = event.target;
const value = target.value;
const name = target.name;
let item = {...this.state.item};
item[name] = value;
this.setState({item});
}
async handleSubmit(event) {
event.preventDefault();
const {item} = this.state;
await fetch('/clients' + (item.id ? '/' + item.id : ''), {
method: (item.id) ? 'PUT' : 'POST',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json'
},
body: JSON.stringify(item),
});
this.props.history.push('/clients');
}
render() {
const {item} = this.state;
const title = <h2>{item.id ? 'Edit Client' : 'Add Client'}</h2>;
return <div>
<AppNavbar/>
<Container>
{title}
<Form onSubmit={this.handleSubmit}>
<FormGroup>
<Label for="name">Name</Label>
<Input type="text" name="name" id="name" value={item.name || ''}
onChange={this.handleChange} autoComplete="name"/>
</FormGroup>
<FormGroup>
<Label for="email">Email</Label>
<Input type="text" name="email" id="email" value={item.email || ''}
onChange={this.handleChange} autoComplete="email"/>
</FormGroup>
<FormGroup>
<Button color="primary" type="submit">Save</Button>{' '}
<Button color="secondary" tag={Link} to="/clients">Cancel</Button>
</FormGroup>
</Form>
</Container>
</div>
}
}
export default withRouter(ClientEdit);

View File

@ -0,0 +1,99 @@
import React, {Component} from 'react';
import {Button, ButtonGroup, Container, Table} from 'reactstrap';
import AppNavbar from './AppNavbar';
import {Link} from 'react-router-dom';
import {saveAs} from 'file-saver';
class ClientList extends Component {
constructor(props) {
super(props);
this.state = {clients: []};
this.remove = this.remove.bind(this);
}
componentDidMount() {
fetch('/clients')
.then(response => response.json())
.then(data => this.setState({clients: data}));
}
async remove(id) {
await fetch(`/clients/${id}`, {
method: 'DELETE',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json'
}
}).then(() => {
let updatedClients = [...this.state.clients].filter(i => i.id !== id);
this.setState({clients: updatedClients});
});
}
async generateInvoice(nip) {
fetch(`/invoices`, {
method: 'POST',
body: JSON.stringify({nip: `${nip}`, monthOfInvoice: 'a1'}),
headers: {
'Accept': 'application/pdf',
'Content-Type': 'application/json'
}
})
.then(response => {
if (response.status === 401 || response.status === 403) {
alert("Error " + response.status);
sessionStorage.clear();
return;
}
const filename = response.headers.get("Content-Disposition").split("filename=")[1];
response.blob()
.then(blob => saveAs(new Blob([blob]), filename));
});
}
render() {
const {clients} = this.state;
const clientList = clients.map(client => {
return <tr key={client.nip}>
<td style={{whiteSpace: 'nowrap'}}>{client.name}</td>
<td>{client.nip}</td>
<td>
<ButtonGroup>
<Button size="sm" color="primary" tag={Link} to={"/clients/" + client.nip}>Edit</Button>
<Button size="sm" color="danger" onClick={() => this.remove(client.nip)}>Delete</Button>
<Button size="sm" color="primary" onClick={() => this.generateInvoice(client.nip)}>Generate
invoice</Button>
</ButtonGroup>
</td>
</tr>
});
return (
<div>
<AppNavbar/>
<Container fluid>
<div className="float-right">
<Button color="success" tag={Link} to="/clients/new">Add Client</Button>
</div>
<h3>Clients</h3>
<Table className="mt-4">
<thead>
<tr>
<th width="30%">Name</th>
<th width="30%">NIP</th>
<th width="40%">Actions</th>
</tr>
</thead>
<tbody>
{clientList}
</tbody>
</Table>
</Container>
</div>
);
}
}
export default ClientList;

View File

@ -0,0 +1,21 @@
import React, { Component } from 'react';
import './App.css';
import AppNavbar from './AppNavbar';
import { Link } from 'react-router-dom';
import { Button, Container } from 'reactstrap';
class Home extends Component {
render() {
return (
<div>
<AppNavbar/>
<Container fluid>
<Button color="link"><Link to="/clients">Clients</Link></Button>
<Button color="link"><Link to="/invoices">Invoices</Link></Button>
</Container>
</div>
);
}
}
export default Home;

View File

@ -0,0 +1,75 @@
import React, { Component } from 'react';
import { Button, ButtonGroup, Container, Table } from 'reactstrap';
import AppNavbar from './AppNavbar';
import { Link } from 'react-router-dom';
class InvoiceList extends Component {
constructor(props) {
super(props);
this.state = {invoices: []};
this.remove = this.remove.bind(this);
}
componentDidMount() {
fetch('/invoices')
.then(response => response.json())
.then(data => this.setState({invoices: data}));
}
async remove(id) {
await fetch(`/invoices/${id}`, {
method: 'DELETE',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json'
}
}).then(() => {
let updatedClients = [...this.state.invoices].filter(i => i.id !== id);
this.setState({invoices: updatedClients});
});
}
render() {
const {invoices} = this.state;
const clientList = invoices.map(invoice => {
return <tr key={invoice.id}>
<td style={{whiteSpace: 'nowrap'}}>{invoice.invoiceTitle}</td>
<td>{invoice.id}</td>
<td>
<ButtonGroup>
<Button size="sm" color="primary" tag={Link} to={"/invoices/" + invoice.id}>Edit</Button>
<Button size="sm" color="danger" onClick={() => this.remove(invoice.id)}>Delete</Button>
</ButtonGroup>
</td>
</tr>
});
return (
<div>
<AppNavbar/>
<Container fluid>
<div className="float-right">
<Button color="success" tag={Link} to="/invoices/new">Add Invoice</Button>
</div>
<h3>Clients</h3>
<Table className="mt-4">
<thead>
<tr>
<th width="30%">Id</th>
<th width="30%">Invoice Title</th>
<th width="40%">Actions</th>
</tr>
</thead>
<tbody>
{clientList}
</tbody>
</Table>
</Container>
</div>
);
}
}
export default InvoiceList;

View File

@ -0,0 +1,13 @@
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}

View File

@ -0,0 +1,16 @@
import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import App from './App';
import 'bootstrap/dist/css/bootstrap.min.css';
ReactDOM.render(
<React.StrictMode>
<App />
</React.StrictMode>
,
document.getElementById('root')
);

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 841.9 595.3"><g fill="#61DAFB"><path d="M666.3 296.5c0-32.5-40.7-63.3-103.1-82.4 14.4-63.6 8-114.2-20.2-130.4-6.5-3.8-14.1-5.6-22.4-5.6v22.3c4.6 0 8.3.9 11.4 2.6 13.6 7.8 19.5 37.5 14.9 75.7-1.1 9.4-2.9 19.3-5.1 29.4-19.6-4.8-41-8.5-63.5-10.9-13.5-18.5-27.5-35.3-41.6-50 32.6-30.3 63.2-46.9 84-46.9V78c-27.5 0-63.5 19.6-99.9 53.6-36.4-33.8-72.4-53.2-99.9-53.2v22.3c20.7 0 51.4 16.5 84 46.6-14 14.7-28 31.4-41.3 49.9-22.6 2.4-44 6.1-63.6 11-2.3-10-4-19.7-5.2-29-4.7-38.2 1.1-67.9 14.6-75.8 3-1.8 6.9-2.6 11.5-2.6V78.5c-8.4 0-16 1.8-22.6 5.6-28.1 16.2-34.4 66.7-19.9 130.1-62.2 19.2-102.7 49.9-102.7 82.3 0 32.5 40.7 63.3 103.1 82.4-14.4 63.6-8 114.2 20.2 130.4 6.5 3.8 14.1 5.6 22.5 5.6 27.5 0 63.5-19.6 99.9-53.6 36.4 33.8 72.4 53.2 99.9 53.2 8.4 0 16-1.8 22.6-5.6 28.1-16.2 34.4-66.7 19.9-130.1 62-19.1 102.5-49.9 102.5-82.3zm-130.2-66.7c-3.7 12.9-8.3 26.2-13.5 39.5-4.1-8-8.4-16-13.1-24-4.6-8-9.5-15.8-14.4-23.4 14.2 2.1 27.9 4.7 41 7.9zm-45.8 106.5c-7.8 13.5-15.8 26.3-24.1 38.2-14.9 1.3-30 2-45.2 2-15.1 0-30.2-.7-45-1.9-8.3-11.9-16.4-24.6-24.2-38-7.6-13.1-14.5-26.4-20.8-39.8 6.2-13.4 13.2-26.8 20.7-39.9 7.8-13.5 15.8-26.3 24.1-38.2 14.9-1.3 30-2 45.2-2 15.1 0 30.2.7 45 1.9 8.3 11.9 16.4 24.6 24.2 38 7.6 13.1 14.5 26.4 20.8 39.8-6.3 13.4-13.2 26.8-20.7 39.9zm32.3-13c5.4 13.4 10 26.8 13.8 39.8-13.1 3.2-26.9 5.9-41.2 8 4.9-7.7 9.8-15.6 14.4-23.7 4.6-8 8.9-16.1 13-24.1zM421.2 430c-9.3-9.6-18.6-20.3-27.8-32 9 .4 18.2.7 27.5.7 9.4 0 18.7-.2 27.8-.7-9 11.7-18.3 22.4-27.5 32zm-74.4-58.9c-14.2-2.1-27.9-4.7-41-7.9 3.7-12.9 8.3-26.2 13.5-39.5 4.1 8 8.4 16 13.1 24 4.7 8 9.5 15.8 14.4 23.4zM420.7 163c9.3 9.6 18.6 20.3 27.8 32-9-.4-18.2-.7-27.5-.7-9.4 0-18.7.2-27.8.7 9-11.7 18.3-22.4 27.5-32zm-74 58.9c-4.9 7.7-9.8 15.6-14.4 23.7-4.6 8-8.9 16-13 24-5.4-13.4-10-26.8-13.8-39.8 13.1-3.1 26.9-5.8 41.2-7.9zm-90.5 125.2c-35.4-15.1-58.3-34.9-58.3-50.6 0-15.7 22.9-35.6 58.3-50.6 8.6-3.7 18-7 27.7-10.1 5.7 19.6 13.2 40 22.5 60.9-9.2 20.8-16.6 41.1-22.2 60.6-9.9-3.1-19.3-6.5-28-10.2zM310 490c-13.6-7.8-19.5-37.5-14.9-75.7 1.1-9.4 2.9-19.3 5.1-29.4 19.6 4.8 41 8.5 63.5 10.9 13.5 18.5 27.5 35.3 41.6 50-32.6 30.3-63.2 46.9-84 46.9-4.5-.1-8.3-1-11.3-2.7zm237.2-76.2c4.7 38.2-1.1 67.9-14.6 75.8-3 1.8-6.9 2.6-11.5 2.6-20.7 0-51.4-16.5-84-46.6 14-14.7 28-31.4 41.3-49.9 22.6-2.4 44-6.1 63.6-11 2.3 10.1 4.1 19.8 5.2 29.1zm38.5-66.7c-8.6 3.7-18 7-27.7 10.1-5.7-19.6-13.2-40-22.5-60.9 9.2-20.8 16.6-41.1 22.2-60.6 9.9 3.1 19.3 6.5 28.1 10.2 35.4 15.1 58.3 34.9 58.3 50.6-.1 15.7-23 35.6-58.4 50.6zM320.8 78.4z"/><circle cx="420.9" cy="296.5" r="45.7"/><path d="M520.5 78.1z"/></g></svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

@ -0,0 +1,13 @@
const reportWebVitals = onPerfEntry => {
if (onPerfEntry && onPerfEntry instanceof Function) {
import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
getCLS(onPerfEntry);
getFID(onPerfEntry);
getFCP(onPerfEntry);
getLCP(onPerfEntry);
getTTFB(onPerfEntry);
});
}
};
export default reportWebVitals;

View File

@ -0,0 +1,5 @@
// jest-dom adds custom jest matchers for asserting on DOM nodes.
// allows you to do things like:
// expect(element).toHaveTextContent(/react/i)
// learn more: https://github.com/testing-library/jest-dom
import '@testing-library/jest-dom';

View File

@ -0,0 +1,180 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<artifactId>invoicer</artifactId>
<groupId>pl.hackerspace</groupId>
<version>1.0.0-SNAPSHOT</version>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.apache.logging.log4j</groupId>
<artifactId>log4j-bom</artifactId>
<version>${log4j2.version}</version>
<scope>import</scope>
<type>pom</type>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-dependencies</artifactId>
<version>${spring-boot.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.yaml</groupId>
<artifactId>snakeyaml</artifactId>
<version>2.2</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.28</version>
</dependency>
<dependency>
<groupId>org.jsoup</groupId>
<artifactId>jsoup</artifactId>
<version>1.16.1</version>
</dependency>
<dependency>
<groupId>org.xhtmlrenderer</groupId>
<artifactId>flying-saucer-pdf-openpdf</artifactId>
<version>9.1.22</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>exec-maven-plugin</artifactId>
<version>3.1.0</version>
<configuration>
<executable>maven</executable>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.11.0</version>
<configuration>
<source>${java.version}</source>
<target>${java.version}</target>
</configuration>
</plugin>
<plugin>
<artifactId>maven-resources-plugin</artifactId>
<version>3.3.1</version>
<executions>
<execution>
<id>copy-resources</id>
<phase>process-classes</phase>
<goals>
<goal>copy-resources</goal>
</goals>
<configuration>
<outputDirectory>${basedir}/target/classes/static</outputDirectory>
<resources>
<resource>
<directory>frontend/build</directory>
</resource>
</resources>
</configuration>
</execution>
</executions>
</plugin>
<plugin>
<groupId>com.github.eirslett</groupId>
<artifactId>frontend-maven-plugin</artifactId>
<version>1.13.4</version>
<configuration>
<workingDirectory>frontend</workingDirectory>
</configuration>
<executions>
<execution>
<id>install node</id>
<goals>
<goal>install-node-and-yarn</goal>
</goals>
<configuration>
<nodeVersion>${node.version}</nodeVersion>
<yarnVersion>${yarn.version}</yarnVersion>
</configuration>
</execution>
<execution>
<id>yarn install</id>
<goals>
<goal>yarn</goal>
</goals>
<phase>generate-resources</phase>
</execution>
<execution>
<id>yarn test</id>
<goals>
<goal>yarn</goal>
</goals>
<phase>test</phase>
<configuration>
<arguments>test</arguments>
<environmentVariables>
<CI>true</CI>
</environmentVariables>
</configuration>
</execution>
<execution>
<id>yarn build</id>
<goals>
<goal>yarn</goal>
</goals>
<phase>compile</phase>
<configuration>
<arguments>build</arguments>
</configuration>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<version>3.1.3</version>
</plugin>
</plugins>
</build>
<properties>
<java.version>17</java.version>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
<node.version>v14.18.0</node.version>
<yarn.version>v1.12.1</yarn.version>
<spring-boot.version>3.1.3</spring-boot.version>
<javafaker.version>1.0.2</javafaker.version>
<log4j2.version>2.17.1</log4j2.version>
</properties>
</project>

View File

@ -0,0 +1,49 @@
package pl.hackerspace;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.CommandLineRunner;
import org.springframework.stereotype.Component;
import pl.hackerspace.domain.Client;
import pl.hackerspace.domain.Invoice;
import pl.hackerspace.repository.ClientRepository;
import pl.hackerspace.repository.InvoiceRepository;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@Component
@Slf4j
@RequiredArgsConstructor
public class BoostrapInitialData implements CommandLineRunner {
private final ClientRepository clientRepository;
private final InvoiceRepository invoiceRepository;
@Override
public void run(String... args) {
log.info("Saving new clients");
Client client = Client.builder()
.price(BigDecimal.valueOf(200))
.nip("PL5252497215")
.name("Arseniy Sorokin")
.addressLine1("ul. Bródnowska 3/23")
.addressLine2("03-439 Warszawa, Polska")
.serviceName("Dostęp do Internetu - Umowa HSWAW/666 - Opłata abonamentowa %invoice_month_string%")
.email("arssorokin@gmail.com")
.build();
clientRepository.save(client);
clientRepository.save(Client.builder()
.price(BigDecimal.valueOf(100))
.nip("PL111")
.name("Pope Francis")
.addressLine1("St.Peter's square")
.addressLine2("Rome")
.serviceName("Dostęp do Internetu - Umowa HSWAW/2137 - Opłata abonamentowa %invoice_month_string%")
.email("pope@vatican.va")
.build());
log.info("Saving last invoice");
invoiceRepository.save(Invoice.builder().id(21196).invoiceTitle("FV21196").creationDate(LocalDateTime.now())
.client(client).pdfContent(new byte[]{}).build());
}
}

View File

@ -0,0 +1,15 @@
package pl.hackerspace;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.autoconfigure.domain.EntityScan;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
@SpringBootApplication
@EnableJpaRepositories
public class SpringBootReactApplication {
public static void main(String[] args) {
SpringApplication.run(SpringBootReactApplication.class, args);
}
}

View File

@ -0,0 +1,54 @@
package pl.hackerspace.controller;
import pl.hackerspace.domain.Client;
import pl.hackerspace.repository.ClientRepository;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.List;
@RestController
@RequestMapping("/clients")
public class ClientsController {
private final ClientRepository clientRepository;
public ClientsController(ClientRepository clientRepository) {
this.clientRepository = clientRepository;
}
@GetMapping
public List<Client> getClients() {
return clientRepository.findAll();
}
@GetMapping("/{nip}")
public Client getClient(@PathVariable String nip) {
return clientRepository.findById(nip).orElseThrow(RuntimeException::new);
}
@PostMapping
public ResponseEntity<Client> createClient(@RequestBody Client client) throws URISyntaxException {
Client savedClient = clientRepository.save(client);
return ResponseEntity.created(new URI("/clients/" + savedClient.getNip())).body(savedClient);
}
@PutMapping("/{nip}")
public ResponseEntity<Client> updateClient(@PathVariable String nip, @RequestBody Client client) {
Client currentClient = clientRepository.findById(nip).orElseThrow(RuntimeException::new);
currentClient.setName(client.getName());
currentClient.setEmail(client.getEmail());
currentClient = clientRepository.save(client);
return ResponseEntity.ok(currentClient);
}
@DeleteMapping("/{nip}")
public ResponseEntity<Void> deleteClient(@PathVariable String nip) {
clientRepository.deleteById(nip);
return ResponseEntity.ok().build();
}
}

View File

@ -0,0 +1,42 @@
package pl.hackerspace.controller;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.core.io.Resource;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import pl.hackerspace.domain.Client;
import pl.hackerspace.domain.Invoice;
import pl.hackerspace.dto.InvoiceGenerationDto;
import pl.hackerspace.service.InvoiceService;
import java.io.IOException;
import java.util.List;
@RestController
@RequestMapping("/invoices")
@RequiredArgsConstructor
@CrossOrigin(value = {"*"}, exposedHeaders = {"Content-Disposition"})
public class InvoicesController {
private final InvoiceService invoiceService;
@PostMapping
@ResponseBody
public ResponseEntity<Resource> generateSingleInvoice(@RequestBody InvoiceGenerationDto request)
throws IOException {
return invoiceService.generateNewInvoice(request);
}
@GetMapping("/all")
@ResponseBody
public void generateAllSubscriberInvoices(HttpServletResponse response, @RequestParam String monthOfInvoice)
throws IOException {
invoiceService.generateInvoicesForAllSubscribers(response, monthOfInvoice);
}
@GetMapping
public List<Invoice> getInvoices() {
return invoiceService.findAll();
}
}

View File

@ -0,0 +1,69 @@
package pl.hackerspace.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.OneToMany;
import jakarta.persistence.Table;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.math.BigDecimal;
import java.util.Set;
@Entity
@Table(name = "client")
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class Client {
@Id
private String nip;
@Column(nullable = false)
private String name;
@Column(nullable = false)
private String email;
private String addressLine1;
private String addressLine2;
@Column(nullable = false)
private String serviceName;
@Column(nullable = false)
private BigDecimal price;
@Builder.Default
@Column(nullable = false)
private BigDecimal amount = BigDecimal.valueOf(1);
@Builder.Default
@Column(nullable = false)
private BigDecimal vat = BigDecimal.valueOf(23);
@Builder.Default
@Column(nullable = false)
private int paymentOffsetDays = 14;
@Builder.Default
private boolean subscriber = true;
private boolean prepaid;
@OneToMany(mappedBy="client")
Set<Invoice> invoices;
public byte[] getInvoiceForSubscriptionMonth(String monthOfInvoice) {
return invoices.stream()
.filter(i -> monthOfInvoice.equals(i.getMonthOfSubscription()))
.map(Invoice::getPdfContent)
.findFirst()
.orElse(null);
}
}

View File

@ -0,0 +1,41 @@
package pl.hackerspace.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
@Entity
@Table(name = "invoice")
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class Invoice {
@Id
private long id;
@Column(nullable = false)
private String invoiceTitle;
private String monthOfSubscription;
@Column(nullable = false)
private LocalDateTime creationDate;
@Column(columnDefinition = "VARBINARY(50000)", nullable = false)
private byte[] pdfContent;
@ManyToOne
@JoinColumn(nullable = false)
private Client client;
}

View File

@ -0,0 +1,23 @@
package pl.hackerspace.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.math.BigDecimal;
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class CustomInvoiceDataDto {
private String customServiceName;
private BigDecimal customPrice;
private BigDecimal customAmount = BigDecimal.valueOf(1);
private BigDecimal customVat = BigDecimal.valueOf(23);
}

View File

@ -0,0 +1,22 @@
package pl.hackerspace.dto;
import jakarta.persistence.Column;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.math.BigDecimal;
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class InvoiceGenerationDto {
private String nip;
private String monthOfInvoice;
private CustomInvoiceDataDto customInvoiceData;
}

View File

@ -0,0 +1,12 @@
package pl.hackerspace.repository;
import org.springframework.stereotype.Repository;
import pl.hackerspace.domain.Client;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
@Repository
public interface ClientRepository extends JpaRepository<Client, String> {
List<Client> findBySubscriberTrue();
}

View File

@ -0,0 +1,14 @@
package pl.hackerspace.repository;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.stereotype.Repository;
import pl.hackerspace.domain.Client;
import pl.hackerspace.domain.Invoice;
@Repository
public interface InvoiceRepository extends JpaRepository<Invoice, String> {
@Query(value = "SELECT max(id) FROM Invoice")
long getMaxId();
}

View File

@ -0,0 +1,143 @@
package pl.hackerspace.service;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.core.io.InputStreamResource;
import org.springframework.core.io.Resource;
import org.springframework.http.ContentDisposition;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StreamUtils;
import pl.hackerspace.domain.Client;
import pl.hackerspace.domain.Invoice;
import pl.hackerspace.dto.CustomInvoiceDataDto;
import pl.hackerspace.dto.InvoiceGenerationDto;
import pl.hackerspace.repository.ClientRepository;
import pl.hackerspace.repository.InvoiceRepository;
import java.io.BufferedInputStream;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
import static pl.hackerspace.service.TemplateService.withTemplate;
@Slf4j
@Service
@RequiredArgsConstructor
public class InvoiceService {
private final ClientRepository clientRepository;
private final InvoiceRepository invoiceRepository;
@Transactional
public ResponseEntity<Resource> generateNewInvoice(InvoiceGenerationDto generationRequest) throws IOException {
Client client = clientRepository.findById(generationRequest.getNip()).orElseThrow(() -> new RuntimeException("Not found"));
return withTemplate(template -> {
byte[] invoice;
if (!client.isSubscriber() || generationRequest.getCustomInvoiceData() != null) {
invoice = createPdfInvoice(client, template, null, generationRequest.getCustomInvoiceData());
} else {
invoice = getOrCreateSubscriptionPdfInvoice(template, client, generationRequest.getMonthOfInvoice());
}
return ResponseEntity.ok()
.headers(getHttpHeaders(client.getName() + " " + generationRequest.getMonthOfInvoice()))
.contentLength(-1)
.contentType(MediaType.APPLICATION_PDF)
.body(new ByteArrayResource(invoice));
});
}
@Transactional
public void generateInvoicesForAllSubscribers(HttpServletResponse response, String monthOfInvoice) throws IOException {
setHeaders(response, "application/zip", LocalDate.now().format(DateTimeFormatter.ISO_LOCAL_DATE) + ".zip");
List<Client> subscribers = clientRepository.findBySubscriberTrue();
withTemplate(template -> {
try (ZipOutputStream zipOutputStream = new ZipOutputStream(response.getOutputStream())) {
for (Client client : subscribers) {
try (InputStream inputStream = new ByteArrayInputStream(
getOrCreateSubscriptionPdfInvoice(template, client, monthOfInvoice))) {
zipOutputStream.putNextEntry(new ZipEntry(getPdfFilename(client.getName() + " "
+ monthOfInvoice)));
StreamUtils.copy(inputStream, zipOutputStream);
zipOutputStream.flush();
}
}
} catch (IOException e) {
throw new RuntimeException(e);
}
return null;
});
}
private byte[] getOrCreateSubscriptionPdfInvoice(String template, Client client, final String monthOfInvoice) {
byte[] invoice = client.getInvoiceForSubscriptionMonth(monthOfInvoice);
if (invoice == null) {
invoice = createPdfInvoice(client, template, monthOfInvoice, null);
}
return invoice;
}
private static void setHeaders(HttpServletResponse response, String contentType, String filename) {
response.setContentType(contentType);
response.setHeader(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.attachment()
.filename(filename, StandardCharsets.UTF_8)
.build()
.toString());
}
private byte[] createPdfInvoice(Client client, String template, String monthOfSubscription,
CustomInvoiceDataDto customInvoiceData) {
LocalDateTime creationDate = LocalDateTime.now();
Invoice newInvoice = createNewInvoice(client, creationDate, monthOfSubscription);
byte[] bytes = TemplateService.convertHtmlToPdf(template, client, creationDate,
newInvoice.getInvoiceTitle(), monthOfSubscription, customInvoiceData);
newInvoice.setPdfContent(bytes);
save(newInvoice);
return bytes;
}
private static HttpHeaders getHttpHeaders(final String filename) {
HttpHeaders headers = new HttpHeaders();
headers.add(HttpHeaders.ACCESS_CONTROL_EXPOSE_HEADERS, HttpHeaders.CONTENT_DISPOSITION);
headers.add(HttpHeaders.CONTENT_TYPE, "application/octet-stream");
headers.add(HttpHeaders.CONTENT_DISPOSITION, "inline; filename=" + getPdfFilename(filename));
return headers;
}
private static String getPdfFilename(String filename) {
return filename + ".pdf";
}
public Invoice createNewInvoice(Client client, final LocalDateTime creationDate, String monthOfSubscription) {
long nextInvoiceId = invoiceRepository.getMaxId() + 1;
return Invoice.builder().id(nextInvoiceId)
.invoiceTitle("FV" + nextInvoiceId)
.creationDate(creationDate)
.monthOfSubscription(monthOfSubscription)
.client(client)
.build();
}
public void save(Invoice newInvoice) {
invoiceRepository.save(newInvoice);
}
public List<Invoice> findAll() {
return invoiceRepository.findAll();
}
}

View File

@ -0,0 +1,130 @@
package pl.hackerspace.service;
import com.lowagie.text.pdf.BaseFont;
import lombok.AccessLevel;
import lombok.NoArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
import org.springframework.stereotype.Service;
import org.xhtmlrenderer.layout.SharedContext;
import org.xhtmlrenderer.pdf.ITextRenderer;
import pl.hackerspace.domain.Client;
import pl.hackerspace.dto.CustomInvoiceDataDto;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.text.DecimalFormat;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.HashSet;
import java.util.Optional;
import java.util.Set;
import java.util.function.Function;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@Slf4j
@Service
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public class TemplateService {
public static final DecimalFormat MONEY_FORMAT = twoDecimalsFormatter();
public static final DateTimeFormatter MONTH_FORMATTER = DateTimeFormatter.ofPattern("yyyy/MM");
public static final String HTML_TEMPLATE_FILE = "invoiceTemplates/invoice_one_service.html";
public static <A> A withTemplate(Function<String, A> execute) throws IOException {
try (InputStream resourceAsStream = TemplateService.class.getClassLoader().getResourceAsStream(HTML_TEMPLATE_FILE)) {
String template = new String(resourceAsStream.readAllBytes());
return execute.apply(template);
}
}
static String populateTemplate(String unprocessedTemplate, Client client, final String subscriptionMonth,
final LocalDateTime invoiceCreationDate, final String invoiceTitle,
CustomInvoiceDataDto customInvoiceData) {
boolean isCustom = customInvoiceData != null;
BigDecimal price = isCustom ? customInvoiceData.getCustomPrice() : client.getPrice();
BigDecimal amount = isCustom ? customInvoiceData.getCustomAmount() : client.getAmount();
BigDecimal vat = isCustom ? customInvoiceData.getCustomVat() : client.getVat();
String serviceName = isCustom ? customInvoiceData.getCustomServiceName() : client.getServiceName();
BigDecimal totalNet = amount.multiply(price);
String processedHtml = unprocessedTemplate
.replace("%client_name%", client.getName())
.replace("%invoice_title%", invoiceTitle)
.replace("%client_price%", formatAsMoney(price))
.replace("%client_addressLine1%", client.getAddressLine1())
.replace("%client_addressLine2%", client.getAddressLine2())
.replace("%client_service_name%", serviceName)
.replace("%client_nip%", Optional.ofNullable(client.getNip()).map(nip -> "NIP: " + nip).orElse(""))
.replace("%client_total_net%", formatAsMoney(totalNet))
.replace("%client_total_gross%", formatAsMoney(totalNet.add(percentageValue(totalNet, vat))))
.replace("%client_amount%", formatAsMoney(amount))
.replace("%client_vat%", formatAsMoney(vat))
.replace("%client_total_tax%", formatAsMoney(percentageValue(totalNet, vat)))
.replace("%invoice_month_string%", subscriptionMonth)
.replace("%client_payment_date%", formatAsDate(invoiceCreationDate.plusDays(client.getPaymentOffsetDays())))
.replace("%invoice_date%", formatAsDate(invoiceCreationDate));
validateAllPlaceholdersSubstituted(processedHtml);
return processedHtml;
}
private static String formatAsMoney(BigDecimal totalNet) {
return MONEY_FORMAT.format(totalNet);
}
private static String formatAsDate(LocalDateTime invoiceCreationDate) {
return invoiceCreationDate.format(DateTimeFormatter.ISO_LOCAL_DATE);
}
private static void validateAllPlaceholdersSubstituted(String processed) {
Set<String> allMatches = new HashSet<>();
Matcher m = Pattern.compile("%(\\w*?)%").matcher(processed);
while (m.find()) {
allMatches.add(m.group());
}
if (!allMatches.isEmpty()) {
throw new IllegalStateException("There are unsubstituted placeholders in the template: " + allMatches);
}
}
private static DecimalFormat twoDecimalsFormatter() {
DecimalFormat df = new DecimalFormat();
df.setMaximumFractionDigits(2);
df.setMinimumFractionDigits(2);
df.setGroupingUsed(false);
return df;
}
public static BigDecimal percentageValue(BigDecimal base, BigDecimal pct) {
return base.multiply(pct).divide(new BigDecimal(100), 2, RoundingMode.HALF_UP);
}
static byte[] convertHtmlToPdf(String unprocessedTemplate, Client client, final LocalDateTime creationDate,
final String invoiceTitle, final String monthOfInvoice, CustomInvoiceDataDto customInvoiceData) {
String processedHtml = populateTemplate(unprocessedTemplate, client, monthOfInvoice, creationDate, invoiceTitle,
customInvoiceData);
try (ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
ITextRenderer renderer = new ITextRenderer();
SharedContext sharedContext = renderer.getSharedContext();
sharedContext.setPrint(true);
sharedContext.setInteractive(false);
renderer.getFontResolver().addFont("fonts/calibri.ttf", BaseFont.IDENTITY_H, true);
renderer.setDocumentFromString(htmlToXhtml(processedHtml));
renderer.layout();
renderer.createPDF(outputStream);
return outputStream.toByteArray();
} catch (IOException e) {
throw new RuntimeException("Exception during pdf conversion", e);
}
}
private static String htmlToXhtml(String html) {
Document document = Jsoup.parse(html);
document.outputSettings().syntax(Document.OutputSettings.Syntax.xml);
return document.html();
}
}

View File

@ -0,0 +1,102 @@
<style type="text/css">
body {
font-family: Calibri, sans-serif;
}
</style>
<html lang="en">
<head>
<meta http-equiv="Content-Language" content="pl">
<meta charset="UTF-8">
</head>
<body>
<table style="width: 100%; border-collapse: collapse; height: 148px;" border="0">
<tbody>
<tr style="height: 18px;">
<td style="width: 50%; height: 18px;">
<h4><span style="font-size: 14px;">Sprzedawca (Seller):</span></h4>
</td>
<td style="width: 64%; text-align: right; height: 18px;">
<h4><span style="font-size: 14px;">Nabywca (Bill to)</span></h4>
</td>
</tr>
<tr style="height: 18px;">
<td style="width: 50%; height: 18px;"><span style="font-size: 14px;">Stowarzyszenie Warszawski Hackerspace</span>
</td>
<td style="width: 64%; text-align: right; height: 18px;"><span
style="font-size: 14px;">%client_name%</span></td>
</tr>
<tr style="height: 18px;">
<td style="width: 50%; height: 18px;"><span style="font-size: 12px;">ul. Wolność 2A</span></td>
<td style="width: 64%; text-align: right; height: 18px;"><span style="font-size: 12px;">%client_addressLine1%</span></td>
</tr>
<tr style="height: 18px;">
<td style="width: 50%; height: 18px;"><span style="font-size: 12px;">01-018 Warszawa</span></td>
<td style="width: 64%; text-align: right; height: 18px;"><span style="font-size: 12px;">%client_addressLine2%</span></td>
</tr>
<tr style="height: 18px;">
<td style="width: 50%; height: 18px;"><span style="font-size: 12px;">NIP: 525-25-40-655</span><span
style="font-size: 12px;"><br></span></td>
<td style="width: 64%; text-align: right; height: 18px;"><span style="font-size: 12px;">%client_nip%</span></td>
</tr>
<tr style="height: 18px;">
<td style="width: 50%; height: 18px;"><span style="font-size: 12px;">IBAN: PL 64 1950 0001 2006 0006 4889 0005</span><span
style="font-size: 12px;"><br></span></td>
<td style="width: 64%; height: 18px;"></td>
</tr>
<tr style="height: 18px;">
<td style="width: 50%; height: 18px;"><span style="font-size: 12px;">SWIFT/BIC: PKOPPLPW</span><span
style="font-size: 12px;"><br></span></td>
<td style="width: 64%; height: 18px;"></td>
</tr>
</tbody>
</table>
<h3><span style="font-size: 16px;">Faktura VAT nr / Invoice No.: %invoice_title%</span></h3>
<p><span style="font-size: 16px;"><span style="font-size: 12px;">Data wystawienia: %invoice_date%<br>Termin płatności: %client_payment_date%</span></span>
</p>
<table style="border-collapse: collapse; width: 100%; height: 54px;" border="1">
<tbody>
<tr style="height: 36px;background-color: #e5e7e9">
<td style="width: 2%; height: 36px; text-align: center;"><span style="font-size: 14px;">Lp<br>No</span></td>
<td style="width: 40%; height: 36px; text-align: center;"><span style="font-size: 14px;">Nazwa<br>Description</span></td>
<td style="width: 2%; height: 36px; text-align: center;"><span style="font-size: 14px;">Ilość<br>Qt</span></td>
<td style="width: 14%; height: 36px; text-align: center;"><span style="font-size: 14px;">Cena netto<br>Unit price</span>
</td>
<td style="width: 14%; height: 36px; text-align: center;"><span style="font-size: 14px;">Stawka VAT, %<br>Tax Rate</span>
</td>
<td style="width: 14%; height: 36px; text-align: center;"><span
style="font-size: 14px;">Wartość netto<br>Total Excl. Tax</span></td>
<td style="width: 14%; height: 36px; text-align: center;"><span
style="font-size: 14px;">Wartość brutto<br>Total Incl. Tax</span></td>
</tr>
<tr style="height: 18px;">
<td style="width: 2%; height: 18px; text-align: center;"><span style="font-size: 14px;">1</span></td>
<td style="width: 40%; height: 18px;"><span style="font-size: 14px;">%client_service_name%</span></td>
<td style="width: 2%; height: 18px; text-align: center;"><span style="font-size: 14px;">%client_amount%</span></td>
<td style="width: 14%; height: 18px; text-align: center;"><span style="font-size: 14px;">%client_price%</span></td>
<td style="width: 14%; height: 18px; text-align: center;"><span style="font-size: 14px;">%client_vat%</span></td>
<td style="width: 14%; height: 18px; text-align: center;"><span style="font-size: 14px;">%client_total_net%</span></td>
<td style="width: 14%; height: 18px; text-align: center;"><span style="font-size: 14px;">%client_total_gross%</span></td>
</tr>
</tbody>
</table>
<br>
<table style="width: 50%; border-collapse: collapse; height: 56px;" border="0">
<tbody>
<tr style="height: 18px;">
<td style="width: 65%; height: 18px; text-align: right;">Razem netto / Total Excl. Tax</td>
<td style="width: 35%; height: 18px; text-align: right;">%client_total_net%</td>
</tr>
<tr style="height: 18px;">
<td style="width: 65%; height: 18px; text-align: right;">VAT / Total tax</td>
<td style="width: 35%; height: 18px; text-align: right;">%client_total_tax%</td>
</tr>
<tr style="height: 20px;">
<td style="width: 65%; height: 20px; text-align: right;"><strong>Razem brutto / Total Incl. Tax</strong></td>
<td style="width: 35%; height: 20px; text-align: right;"><strong>%client_total_gross%</strong></td>
</tr>
</tbody>
</table>
</body>
</html>