added new invoice fields, improved invoice templates (#1258)

This commit is contained in:
Kevin Papst
2019-11-18 12:01:26 +01:00
committed by GitHub
parent 2287c9951c
commit 47bd8b0ce7
50 changed files with 1530 additions and 1222 deletions

2
.gitignore vendored
View File

@@ -22,6 +22,8 @@ templates/invoice/renderer/.~lock*
/var/invoices/*
/var/packages/*
!var/packages/.gitkeep
/var/plugins/*
!var/plugins/.gitkeep

View File

@@ -10,6 +10,8 @@ Perform EACH version specific task between your version and the new one, otherwi
## [1.6](https://github.com/kevinpapst/kimai2/releases/tag/1.6)
- Moved CSV, ODS and XSLX invoice templates to [another repository](https://github.com/Keleo/kimai2-invoice-templates) - if you use them, add them manually (see [invoice documentation](https://www.kimai.org/documentation/invoices.html))
- Added new invoice fields (VAT, bank account) and customer fields (VAT), check if you want to update your settings
- New functionality to manage permissions via Admin UI were added. Please move your permission settings from [local.yaml to your database](https://www.kimai.org/documentation/permissions.html)
- New database tables were created, don't forget to [run the migrations](https://www.kimai.org/documentation/updates.html)

View File

@@ -197,6 +197,10 @@ export default class KimaiAjaxModalForm extends KimaiClickHandlerReducedInTableR
err = '[' + xhr.status +'] ' + xhr.statusText;
}
alert.error(message, err);
// this is useful for changing form fields and retrying to save (and in development to test form changes)
setTimeout(function() {
btn.button('reset');
}, 1500);
}
});
});

View File

@@ -5,301 +5,284 @@
* file that was distributed with this source code.
*/
.invoice {
margin: 0;
border: none;
}
@media (min-width: $screen-sm-min) {
body.invoice_print {
background-color: #eee;
.invoice {
margin: 25px;
margin: 105px auto 30px auto;
padding: 50px 65px;
min-height: 297mm;
position: relative;
max-width: 210mm;
box-shadow: 0 0 20px rgba(80,80,80,0.7);
}
}
.page-header-small{
font-size: 14px;
}
div.invoice-address {
margin-top: 30px;
margin-bottom: 30px;
}
table.invoice-records {}
table.invoice-meta th {
padding-right: 40px;
}
table.invoice-sum th {
width: 70%;
}
table.invoice-sum th, table.invoice-sum td {
text-align: right;
}
/* ================================ FREELANCER INVOICE ================================ */
#freelancer-invoice {
font: 15px/1 $font-family-sans-serif;
box-sizing: border-box;
h1 {
font: bold 100% sans-serif;
letter-spacing: 0.5em;
text-align: center;
text-transform: uppercase;
.page-header h3 {
border-color: #ddd;
}
h2 {
font: bold 1.5em sans-serif;
margin-bottom: 1em;
div.invoice-address {
margin-top: 30px;
margin-bottom: 30px;
}
/* table */
table {
table-layout: fixed;
width: 100%;
table.invoice-meta th {
padding-right: 40px;
}
table {
border-collapse: separate;
border-spacing: 0;
table.invoice-sum th {
width: 70%;
}
/* header */
header {
}
header:after {
clear: both;
content: "";
display: table;
font-size: 1em;
}
header address {
float: left;
font-style: normal;
line-height: 1.25;
margin: 0 1em 1em 0;
font-weight: bold;
font-size: 0.7em;
}
article address {
font-size: 1em;
float: left;
margin: 1em 0;
}
article.address {
margin-bottom: 1.5em;
}
article.footer {
margin-top: 60px;
}
/* article */
article, table.meta, table.inventory {
margin: 1em 0;
}
article:after {
clear: both;
content: "";
display: table;
}
article h1 {
clip: rect(0 0 0 0);
position: absolute;
}
article p {
margin-bottom: 20px;
}
/* table meta & balance */
table.meta:after, table.balance:after {
clear: both;
content: "";
display: table;
}
/* table meta */
table.meta {
float: right;
width: 50%;
font-size: 85%;
th {
text-align: right;
font-weight: normal;
}
td {
width: 120px;
text-align: right;
}
}
/* table balance */
table.balance {
float: right;
th {
text-align: right;
font-weight: normal;
}
td {
width: 140px;
text-align: right;
}
.total {
font-weight: bold;
}
}
/* table items */
table.inventory {
clear: both;
width: 100%;
margin-top: 2em;
border-bottom: 1px solid #000;
}
table.inventory th {
font-weight: bold;
border-width: 0 0 1px 0;
border-bottom: 1px solid #000;
}
table.inventory th,
table.inventory td {
padding: 5px;
}
table.inventory td:nth-child(1),
table.inventory th:nth-child(1) {
text-align: left;
}
table.inventory td:nth-child(2),
table.inventory th:nth-child(2) {
text-align: center;
width: 20%;
}
table.inventory td:nth-child(3),
table.inventory th:nth-child(3) {
text-align: center;
width: 20%;
}
table.inventory td:nth-child(4),
table.inventory th:nth-child(4) {
table.invoice-sum th, table.invoice-sum td {
text-align: right;
width: 20%;
}
.invoice-items {
margin-top: 2em;
margin-bottom: 3em;
.table {
thead th {
font-weight: bold;
border-bottom: 1px solid #ddd;
padding-bottom: 15px
}
tfoot {
border-top: 1px solid #ddd;
tr:first-child th,
tr:first-child td {
padding-top: 15px
}
tr {
th,td {
border:none;
padding: 4px 8px;
}
}
}
}
}
/* footer */
footer {
.footer {
border-top: 1px solid #ccc;
margin-top: 50px;
}
footer table {
border-top: 1px solid #000;
font-size: 0.8em;
padding-top: 5px;
}
footer th {
text-align: left;
font-weight: bold;
margin-bottom: 10px;
}
footer table th:nth-child(1) {
width: 32%;
}
footer table th:nth-child(3) {
width: 28%;
}
footer td {
text-align: left;
vertical-align: bottom;
}
/* aside */
aside {
position: absolute;
top: 10in;
width: 7.5in;
}
aside h1 {
border: none;
border-width: 0 0 1px;
margin: 0 0 1em;
}
aside h1 {
border-color: #999;
border-bottom-style: solid;
}
aside p {
font-size: 85%;
}
aside div {
margin-top: 15px;
}
aside div p {
line-height: 1.4;
}
aside p span {
width: 130px;
font-weight: bold;
display: inline-block;
}
}
@media print {
* {
-webkit-print-color-adjust: exact;
}
body, .wrapper, #freelancer-invoice {
margin: 0;
padding: 0;
padding-top: 10px;
width: 100%;
}
/* ================================ FREELANCER INVOICE ================================ */
#freelancer-invoice {
margin: 1em 2em;
span:empty {
display: none;
box-sizing: border-box;
h1 {
font: bold 100% sans-serif;
letter-spacing: 0.5em;
text-align: center;
text-transform: uppercase;
}
h2 {
font: bold 1.5em sans-serif;
margin-bottom: 1em;
}
/* table */
table {
table-layout: fixed;
width: 100%;
}
table {
border-collapse: separate;
border-spacing: 0;
}
/* header */
header {
&:after {
clear: both;
content: "";
display: table;
font-size: 1em;
}
address {
float: left;
font-style: normal;
line-height: 1.25;
margin: 0 1em 1em 0;
font-size: 0.7em;
}
}
article address {
font-size: 1em;
float: left;
margin: 1em 0;
}
article.address {
margin-bottom: 1.5em;
}
/* article */
article, table.meta, table.inventory {
margin: 1em 0;
}
article:after {
clear: both;
content: "";
display: table;
}
article h1 {
clip: rect(0 0 0 0);
position: absolute;
}
article p {
margin-bottom: 20px;
}
/* table meta & balance */
table.meta:after, table.balance:after {
clear: both;
content: "";
display: table;
}
/* table meta */
table.meta {
float: right;
width: 50%;
font-size: 85%;
th {
text-align: right;
font-weight: normal;
}
td {
width: 120px;
text-align: right;
}
}
/* table balance */
table.balance {
float: right;
th {
text-align: right;
font-weight: normal;
}
td {
width: 140px;
text-align: right;
line-height: 22px;
}
.total {
font-weight: bold;
}
}
/* table items */
table.inventory {
clear: both;
width: 100%;
margin-top: 2em;
border-bottom: 1px solid #000;
}
table.inventory th,
table.inventory td {
padding: 5px;
}
table.inventory thead th {
font-weight: bold;
border-width: 0 0 1px 0;
border-bottom: 1px solid #000;
padding-bottom: 15px
}
table.inventory tbody tr:first-child td {
padding-top: 15px
}
table.inventory tbody tr:last-child td {
padding-bottom: 15px
}
table.inventory td:nth-child(1),
table.inventory th:nth-child(1) {
text-align: left;
}
table.inventory td:nth-child(2),
table.inventory th:nth-child(2) {
text-align: center;
width: 20%;
}
table.inventory td:nth-child(3),
table.inventory th:nth-child(3) {
text-align: center;
width: 20%;
}
table.inventory td:nth-child(4),
table.inventory th:nth-child(4) {
text-align: right;
width: 20%;
}
.add, .cut {
display: none;
.footer {
border-color: #000;
}
}
.invoice {
width: unset;
/* ================================ PRINTING RULES ================================ */
@media print {
* {
-webkit-print-color-adjust: exact;
}
body, .wrapper {
margin: 0;
padding: 0;
background-color: white;
}
#freelancer-invoice {
span:empty {
display: none;
}
.add, .cut {
display: none;
}
}
.invoice {
margin: 1em 2em;
font-size: 90%;
width: unset;
padding: 0;
min-height: unset;
position: unset;
max-width: unset;
box-shadow: unset;
}
}
}

View File

@@ -76,6 +76,12 @@
"symfony/thanks": "^1.0",
"symfony/web-server-bundle": "^4.0"
},
"repositories": [
{
"type": "artifact",
"url": "var/packages/"
}
],
"config": {
"platform": {
"php": "7.2"

1393
composer.lock generated

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -5,10 +5,10 @@
"build/runtime.4ee6be68.js",
"build/0.a87622f3.js",
"build/1.c1bee41f.js",
"build/app.120bf1d1.js"
"build/app.c36bdaf3.js"
],
"css": [
"build/app.ca6a8b7f.css"
"build/app.e5eb271e.css"
]
},
"chart": {
@@ -35,8 +35,8 @@
"build/runtime.4ee6be68.js": "sha384-xNNrNinl64G3nCUrIskgSjU0mUXXCB9lj6XCSInBTwxSKXk8uTMafnLHtdWdIGtd",
"build/0.a87622f3.js": "sha384-ncT/BKhCsqH6jhxwdsSG95m1ei7ZZjeZtzH1262h+OPUU80TSFFE3dt+abcHHMok",
"build/1.c1bee41f.js": "sha384-7UVWcP6Hefp2k/CrtGSITKXx4dSZqtvpAiU8WX7dClETkzMewrUjoCRtVXQ5j3KI",
"build/app.120bf1d1.js": "sha384-2Eohdb33hChqGmyJIUGgZFiqzPfUDIv74IoMAX+09VozhJDD/GtPIg5+N2CCOACl",
"build/app.ca6a8b7f.css": "sha384-NrITZnNaaIkzEjZkOjxciIbq6V6dKC9VlbJfxliLc2vi8n0113/yLfhbwi5rjzg2",
"build/app.c36bdaf3.js": "sha384-lU6L1apGsiE2oAJfhqW7YaQo+J1eA5ysuOCfERdfGp63Z9ouvhQ9Ov3DlN+6aYXK",
"build/app.e5eb271e.css": "sha384-BNUNuu56X2x+Xmn1JrQe6d3mt6R+QZu7OgR/4OXkBDueSE6vu0UqmRaDPkyiRzMw",
"build/2.7be60d8d.js": "sha384-txR0QG+838LKYtPQ99Gx4OU7WmgN9J3joZEyGwIskSz74EN1T4/IBVnmNaKiFN1q",
"build/chart.0af3f813.js": "sha384-I57c9DtU3AOG2kzKqIZkIu0hi1aGYHRZ5QG4LKC9+9slzJnAMttPGXoL2cQG3m6y",
"build/calendar.6f94d071.js": "sha384-CnbCkgF1mbGOycK2A78CF1ZsQ4O07ol9U4gB0Tiec0wKu1mNw8lE22lvoRNzYs/8",

View File

@@ -2,8 +2,8 @@
"build/0.a87622f3.js": "build/0.a87622f3.js",
"build/1.c1bee41f.js": "build/1.c1bee41f.js",
"build/2.7be60d8d.js": "build/2.7be60d8d.js",
"build/app.css": "build/app.ca6a8b7f.css",
"build/app.js": "build/app.120bf1d1.js",
"build/app.css": "build/app.e5eb271e.css",
"build/app.js": "build/app.c36bdaf3.js",
"build/calendar.css": "build/calendar.36ef2a02.css",
"build/calendar.js": "build/calendar.6f94d071.js",
"build/chart.js": "build/chart.0af3f813.js",

View File

@@ -17,11 +17,11 @@ class Constants
/**
* The current release version
*/
public const VERSION = '1.5';
public const VERSION = '1.6';
/**
* The current release status, either "stable" or "dev"
*/
public const STATUS = 'stable';
public const STATUS = 'dev';
/**
* The software name
*/

View File

@@ -302,10 +302,6 @@ class ProfileController extends AbstractController
private function createPreferencesForm(User $user): FormInterface
{
// we need to prepare the user preferences, which is done via an EventSubscriber
$event = new PrepareUserEvent($user);
$this->dispatcher->dispatch($event);
return $this->createForm(
UserPreferencesForm::class,
$user,

View File

@@ -46,7 +46,7 @@ class CustomerFixtures extends Fixture
*/
public function load(ObjectManager $manager)
{
$faker = Factory::create();
$faker = Factory::create('at_AT');
$amountCustomers = rand(self::MIN_CUSTOMERS, self::MAX_CUSTOMERS);
for ($c = 1; $c <= $amountCustomers; $c++) {
@@ -100,6 +100,7 @@ class CustomerFixtures extends Fixture
->setCountry($faker->countryCode)
->setTimezone($faker->timezone)
->setVisible($visible)
->setVatId($faker->vat)
;
if (rand(0, 3) % 3) {

View File

@@ -31,7 +31,9 @@ class InvoiceFixtures extends Fixture
*/
public function load(ObjectManager $manager)
{
foreach ($this->getInvoiceConfigs() as $invoiceConfig) {
$faker = Factory::create('at_AT');
foreach ($this->getInvoiceConfigs($faker) as $invoiceConfig) {
$template = new InvoiceTemplate();
// name, title, renderer, calculator, numberGenerator, company, vat, dueDays, address, paymentTerms
@@ -44,8 +46,11 @@ class InvoiceFixtures extends Fixture
->setCompany($invoiceConfig[5])
->setVat($invoiceConfig[6])
->setDueDays($invoiceConfig[7])
->setAddress($invoiceConfig[8])
->setPaymentTerms($invoiceConfig[9])
->setPaymentTerms($invoiceConfig[8])
->setVatId($faker->vat)
->setAddress($this->generateAddress($faker))
->setContact($this->generateContact($faker))
->setPaymentDetails($this->generatePaymentDetails($faker))
;
$manager->persist($template);
@@ -53,10 +58,8 @@ class InvoiceFixtures extends Fixture
}
}
private function getInvoiceConfigs()
private function getInvoiceConfigs(Generator $faker)
{
$faker = Factory::create();
$paymentTerms =
'I would like to thank you for your confidence and will gladly be there for you in the future.' .
PHP_EOL .
@@ -64,11 +67,9 @@ class InvoiceFixtures extends Fixture
'as reference.'
;
$address =
$faker->streetAddress . PHP_EOL .
$faker->city . ', ' . $faker->stateAbbr . ' ' . $faker->postcode . PHP_EOL .
'Phone: ' . $faker->phoneNumber . PHP_EOL .
'Email: ' . $faker->safeEmail
$paymentTerms_alt =
$faker->firstName . ', thank you very much. We really appreciate your business.' . PHP_EOL .
'Please send payments before the due date.I would like to thank you for your confidence and will gladly be there for you in the future.'
;
$paymentTerms_de =
@@ -85,17 +86,14 @@ class InvoiceFixtures extends Fixture
// name, title, renderer, calculator, numberGenerator, company, vat, dueDays, address, paymentTerms
return [
['Invoice (HTML)', 'Company name', 'default', 'default', 'default', $faker->company, 19, 30, $address, $paymentTerms],
['Freelancer (HTML, short)', 'Invoice', 'freelancer', 'short', 'default', $faker->company, 19, 14, $this->generateAddress($faker), $paymentTerms_de],
['Timesheet (HTML)', 'Timesheet', 'timesheet', 'default', 'default', $faker->company, 19, 7, $this->generateAddress($faker), ''],
['Company invoice (DOCX)', 'Invoice', 'company', 'default', 'default', 'Kimai Inc.', 19, 14, $this->generateAddress($faker, true), $this->generatePaymentTerms($faker)],
['Export (CSV, user-group)', 'User-grouped', 'export', 'user', 'default', $faker->company, 7, 28, '', ''],
['Export (ODS)', 'Spreadsheet', 'open-spreadsheet', 'default', 'default', $faker->company, 19, 14, '', ''],
['Export (XLSX, user-group)', 'Spreadsheet', 'spreadsheet', 'user', 'default', $faker->company, 13, 10, '', ''],
['Invoice (HTML)', 'Company name', 'default', 'default', 'default', $faker->company, 19, 30, $paymentTerms],
['Freelancer (HTML, short)', 'Invoice', 'freelancer', 'short', 'default', $faker->company, 19, 14, $paymentTerms_de],
['Timesheet (HTML)', 'Timesheet', 'timesheet', 'default', 'default', $faker->company, 19, 7, $paymentTerms_alt],
['Company invoice (DOCX)', 'Invoice', 'company', 'default', 'default', 'Kimai Inc.', 19, 14, $paymentTerms_alt],
];
}
protected function generatePaymentTerms(Generator $faker)
protected function generatePaymentDetails(Generator $faker)
{
return
'Acme Bank' . PHP_EOL .
@@ -104,15 +102,17 @@ class InvoiceFixtures extends Fixture
;
}
protected function generateAddress(Generator $faker, $lineBreaks = false)
protected function generateContact(Generator $faker)
{
if (!$lineBreaks) {
return
$faker->name . ' - ' .
$faker->streetAddress . '-' .
$faker->postcode . ' ' . $faker->city;
}
return
'Phone: ' . $faker->phoneNumber . PHP_EOL .
'Email: ' . $faker->safeEmail . PHP_EOL .
'Web: www.' . $faker->domainName
;
}
protected function generateAddress(Generator $faker)
{
return
'Kimai Inc.' . PHP_EOL .
$faker->streetAddress . PHP_EOL .

View File

@@ -55,7 +55,7 @@ abstract class AbstractMigration extends BaseAbstractMigration implements Contai
/**
* Whether we should deactivate foreign key support for SQLite.
* This is required, if columns are changed.
* SQLite will drop the table and therefor all referenced data if we don't deactivate this.
* SQLite will drop the table and all referenced data, if we don't deactivate this.
*
* @return bool
*/

View File

@@ -79,6 +79,14 @@ class Customer implements EntityWithMetaFields
*/
private $company;
/**
* @var string
*
* @ORM\Column(name="vat_id", type="string", length=50, nullable=true)
* @Assert\Length(max=50)
*/
private $vatId;
/**
* @var string
*
@@ -272,6 +280,18 @@ class Customer implements EntityWithMetaFields
return $this->company;
}
public function getVatId(): ?string
{
return $this->vatId;
}
public function setVatId(?string $vatId): Customer
{
$this->vatId = $vatId;
return $this;
}
public function setContact(?string $contact): Customer
{
$this->contact = $contact;

View File

@@ -56,6 +56,14 @@ class InvoiceTemplate
*/
private $company;
/**
* @var string
*
* @ORM\Column(name="vat_id", type="string", length=50, nullable=true)
* @Assert\Length(max=50)
*/
private $vatId;
/**
* @var string
*
@@ -63,6 +71,13 @@ class InvoiceTemplate
*/
private $address;
/**
* @var string
*
* @ORM\Column(name="contact", type="text", nullable=true)
*/
private $contact;
/**
* @var int
*
@@ -109,6 +124,13 @@ class InvoiceTemplate
*/
private $paymentTerms;
/**
* @var string
*
* @ORM\Column(name="payment_details", type="text", nullable=true)
*/
private $paymentDetails;
public function getId(): ?int
{
return $this->id;
@@ -234,6 +256,42 @@ class InvoiceTemplate
return $this;
}
public function getVatId(): ?string
{
return $this->vatId;
}
public function setVatId(?string $vatId): InvoiceTemplate
{
$this->vatId = $vatId;
return $this;
}
public function getContact(): ?string
{
return $this->contact;
}
public function setContact(?string $contact): InvoiceTemplate
{
$this->contact = $contact;
return $this;
}
public function getPaymentDetails(): ?string
{
return $this->paymentDetails;
}
public function setPaymentDetails(?string $paymentDetails): InvoiceTemplate
{
$this->paymentDetails = $paymentDetails;
return $this;
}
/**
* @return string
*/

View File

@@ -71,9 +71,9 @@ final class MenuSubscriber implements EventSubscriberInterface
}
if ($auth->isGranted('view_invoice')) {
$menu->addItem(
new MenuItemModel('invoice', 'menu.invoice', 'invoice', [], $this->getIcon('invoice'))
);
$invoice = new MenuItemModel('invoice', 'menu.invoice', 'invoice', [], $this->getIcon('invoice'));
$invoice->setChildRoutes(['admin_invoice_template', 'admin_invoice_template_edit', 'admin_invoice_template_create']);
$menu->addItem($invoice);
}
if ($auth->isGranted('create_export')) {

View File

@@ -56,6 +56,10 @@ class CustomerEditForm extends AbstractType
'label' => 'label.company',
'required' => false,
])
->add('vatId', TextType::class, [
'label' => 'label.vat_id',
'required' => false,
])
->add('contact', TextType::class, [
'label' => 'label.contact',
'required' => false,

View File

@@ -40,14 +40,26 @@ class InvoiceTemplateForm extends AbstractType
->add('company', TextType::class, [
'label' => 'label.company',
])
->add('vatId', TextType::class, [
'label' => 'label.vat_id',
'required' => false,
])
->add('address', TextareaType::class, [
'label' => 'label.address',
'required' => false,
])
->add('contact', TextareaType::class, [
'label' => 'label.contact',
'required' => false,
])
->add('paymentTerms', TextareaType::class, [
'label' => 'label.payment_terms',
'required' => false,
])
->add('paymentDetails', TextareaType::class, [
'label' => 'label.invoice_bank_account',
'required' => false,
])
->add('dueDays', TextType::class, [
'label' => 'label.due_days',
])
@@ -71,6 +83,11 @@ class InvoiceTemplateForm extends AbstractType
'csrf_protection' => true,
'csrf_field_name' => '_token',
'csrf_token_id' => 'admin_invoice_template',
'attr' => [
'data-form-event' => 'kimai.invoiceTemplateUpdate',
'data-msg-success' => 'action.update.success',
'data-msg-error' => 'action.update.error',
],
]);
}
}

View File

@@ -41,7 +41,7 @@ class InvoiceToolbarForm extends AbstractToolbarForm
]);
$builder->add('create', SubmitType::class, [
'label' => 'button.print',
'attr' => ['formtarget' => 'invoice'],
'attr' => ['formtarget' => '_blank'],
]);
$builder->add('preview', SubmitType::class, [
'label' => 'button.preview',

View File

@@ -50,6 +50,7 @@ class InvoiceCalculatorType extends AbstractType
return $renderer;
},
'translation_domain' => 'invoice-calculator',
'search' => false,
]);
}

View File

@@ -50,6 +50,7 @@ class InvoiceNumberGeneratorType extends AbstractType
return $renderer;
},
'translation_domain' => 'invoice-numbergenerator',
'search' => false,
]);
}

View File

@@ -56,6 +56,7 @@ class InvoiceRendererType extends AbstractType
},
'translation_domain' => 'invoice-renderer',
'docu_chapter' => 'invoices.html',
'search' => false,
]);
}

View File

@@ -25,7 +25,7 @@ class YesNoType extends AbstractType
{
$resolver->setDefaults([
'value' => true,
'false_values' => [null, 0, false, 'false'],
'false_values' => [null, 0, false, 'false', '', '0'],
'required' => false,
]);
}

View File

@@ -114,6 +114,9 @@ trait RendererTrait
'template.title' => $model->getTemplate()->getTitle(),
'template.payment_terms' => $model->getTemplate()->getPaymentTerms(),
'template.due_days' => $model->getTemplate()->getDueDays(),
'template.vat_id' => $model->getTemplate()->getVatId(),
'template.contact' => $model->getTemplate()->getContact(),
'template.payment_details' => $model->getTemplate()->getPaymentDetails(),
'query.begin' => $this->getFormattedDateTime($model->getQuery()->getBegin()),
'query.day' => $model->getQuery()->getBegin()->format('d'),
@@ -164,6 +167,7 @@ trait RendererTrait
'customer.name' => $customer->getName(),
'customer.contact' => $customer->getContact(),
'customer.company' => $customer->getCompany(),
'customer.vat' => $customer->getVatId(),
'customer.number' => $customer->getNumber(),
'customer.country' => $customer->getCountry(),
'customer.homepage' => $customer->getHomepage(),

View File

@@ -15,35 +15,23 @@ use App\Invoice\RendererInterface;
use Symfony\Component\HttpFoundation\Response;
use Twig\Environment;
class TwigRenderer implements RendererInterface
final class TwigRenderer implements RendererInterface
{
/**
* @var Environment
*/
protected $twig;
private $twig;
/**
* @param Environment $twig
*/
public function __construct(Environment $twig)
{
$this->twig = $twig;
}
/**
* @param InvoiceDocument $document
* @return bool
*/
public function supports(InvoiceDocument $document): bool
{
return stripos($document->getFilename(), '.twig') !== false;
return stripos($document->getFilename(), '.html.twig') !== false;
}
/**
* @param InvoiceDocument $document
* @param InvoiceModel $model
* @return Response
*/
public function render(InvoiceDocument $document, InvoiceModel $model): Response
{
$content = $this->twig->render('@invoice/' . basename($document->getFilename()), [

View File

@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace DoctrineMigrations;
use App\Doctrine\AbstractMigration;
use Doctrine\DBAL\Schema\Schema;
/**
* New Vat ID columns and invoice template improvements
*
* @version 1.6
*/
final class Version20191116110124 extends AbstractMigration
{
public function getDescription(): string
{
return 'New Vat ID columns and invoice template improvements';
}
protected function isSupportingForeignKeys(): bool
{
return false;
}
public function isTransactional(): bool
{
if ($this->isPlatformSqlite()) {
// does fail if we use transactions, as tables are re-created and foreign keys would fail
return false;
}
return true;
}
public function up(Schema $schema): void
{
$customers = $schema->getTable('kimai2_customers');
$customers->addColumn('vat_id', 'string', ['length' => 50, 'notnull' => false]);
$invoiceTemplates = $schema->getTable('kimai2_invoice_templates');
$invoiceTemplates->addColumn('vat_id', 'string', ['length' => 50, 'notnull' => false, 'default' => null]);
$invoiceTemplates->addColumn('contact', 'text', ['notnull' => false, 'default' => null]);
$invoiceTemplates->addColumn('payment_details', 'text', ['notnull' => false, 'default' => null]);
$this->addSql("UPDATE kimai2_invoice_templates SET renderer = 'default' WHERE renderer IN ('export', 'open-spreadsheet', 'spreadsheet')");
}
public function down(Schema $schema): void
{
$invoiceTemplates = $schema->getTable('kimai2_invoice_templates');
$invoiceTemplates->dropColumn('payment_details');
$invoiceTemplates->dropColumn('contact');
$invoiceTemplates->dropColumn('vat_id');
$customers = $schema->getTable('kimai2_customers');
$customers->dropColumn('vat_id');
}
}

View File

@@ -243,6 +243,8 @@ class CustomerRepository extends EntityRepository
$qb->expr()->orX(
$qb->expr()->like('c.name', ':searchTerm'),
$qb->expr()->like('c.comment', ':searchTerm'),
$qb->expr()->like('c.company', ':searchTerm'),
$qb->expr()->like('c.vatId', ':searchTerm'),
$qb->expr()->like('c.number', ':searchTerm'),
$qb->expr()->like('c.contact', ':searchTerm'),
$qb->expr()->like('c.phone', ':searchTerm'),

View File

@@ -25,7 +25,14 @@
{{ form_row(form.comment) }}
<fieldset>
{#<legend>Company data</legend>#}
{{ form_row(form.company) }}
<div class="row">
<div class="col-md-8">
{{ form_row(form.company) }}
</div>
<div class="col-md-4">
{{ form_row(form.vatId) }}
</div>
</div>
{{ form_row(form.address) }}
<div class="row">
<div class="col-md-4">

View File

@@ -10,56 +10,66 @@
</div>
</div>
<div class="row invoice-info">
<div class="col-sm-4 invoice-col">
<div class="row">
<div class="col-sm-5">
{{ 'invoice.from'|trans }}
<address contenteditable="true">
<strong>{{ model.template.company }}</strong><br>
{{ model.template.address|trim|nl2br }}
{% if model.template.vatId is not empty %}
<br>
{{ 'label.vat_id'|trans }}:
{{ model.template.vatId }}
{% endif %}
</address>
</div>
<div class="col-sm-4 invoice-col">
<div class="col-sm-2"></div>
<div class="col-sm-5">
{% set customerPhone = model.customer.phone|default(model.customer.mobile) %}
{{ 'invoice.to'|trans }}
<address contenteditable="true">
<strong>{{ model.customer.company|default(model.customer.name) }}</strong><br>
{{ model.customer.address|nl2br }}
{% if model.customer.phone is not empty %}
{% if model.customer.vatId is not empty %}
<br>
{{ 'label.phone'|trans }}: {{ model.customer.phone }}
{{ 'label.vat_id'|trans }}: {{ model.customer.vatId }}
{% endif %}
{% if model.customer.email is not empty %}
<br>
{{ 'label.email'|trans }}: {{ model.customer.email }}
{% endif %}
</address>
</div>
<div class="col-sm-4 invoice-col">
<p contenteditable="true">
<b>{{ 'invoice.number'|trans }}: {{ model.numberGenerator.invoiceNumber }}</b>
</p>
<p contenteditable="true">
<b>{{ 'invoice.due_days'|trans }}:</b> {{ model.dueDate|date_short }}
{% if model.customer.number is not empty %}
<br><b>{{ 'label.number'|trans }}:</b> {{ model.customer.number }}
<br>
{{ 'label.number'|trans }}: {{ model.customer.number }}
{% endif %}
{% if model.query.project is not empty and model.query.project.orderNumber is not empty %}
<br><b>{{ 'label.orderNumber'|trans }}:</b> {{ model.query.project.orderNumber }}
<br>
{{ 'label.orderNumber'|trans }}: {{ model.query.project.orderNumber }}
{% endif %}
</p>
</address>
</div>
</div>
<div class="row">
<div class="col-sm-5">
<p contenteditable="true">
<strong>{{ 'invoice.number'|trans }}:</strong>
{{ model.numberGenerator.invoiceNumber }}
<br>
<strong>{{ 'invoice.due_days'|trans }}:</strong>
{{ model.dueDate|date_short }}
</p>
</div>
<div class="col-sm-7"></div>
</div>
<div class="row invoice-items">
<div class="col-xs-12 table-responsive">
<table class="table table-striped">
<table class="table">
<thead>
<tr>
<th>{{ 'label.date'|trans }}</th>
<th>{{ 'label.description'|trans }}</th>
<th>{{ 'label.unit_price'|trans }}</th>
<th>{{ 'label.hours'|trans }}</th>
<th>{{ 'label.total_rate'|trans }}</th>
<th class="text-center">{{ 'label.hours'|trans }}</th>
<th class="text-right">{{ 'label.total_rate'|trans }}</th>
</tr>
</thead>
<tbody>
@@ -74,7 +84,7 @@
{% set rate = app.user.getPreferenceValue('hourly_rate') %}
{% endif %}
<tr>
<td>{{ entry.begin|date_short }}</td>
<td nowrap class="text-nowrap">{{ entry.begin|date_short }}</td>
<td contenteditable="true">
{% if entry.description is not empty %}
{{ entry.description|nl2br }}
@@ -82,48 +92,56 @@
{{ entry.activity.name }} / {{ entry.project.name }}
{% endif %}
</td>
<td contenteditable="true">{{ rate|money(model.calculator.currency) }}</td>
<td>{{ duration }}</td>
<td>{{ entry.rate|money(model.calculator.currency) }}</td>
<td nowrap class="text-nowrap" contenteditable="true">{{ rate|money(model.calculator.currency) }}</td>
<td nowrap class="text-nowrap text-center">{{ duration }}</td>
<td nowrap class="text-nowrap text-right">{{ entry.rate|money(model.calculator.currency) }}</td>
</tr>
{% endfor %}
</tbody>
<tfoot>
<tr>
<td colspan="4" class="text-right">
{{ 'invoice.subtotal'|trans }}
</td>
<td class="text-right">{{ model.calculator.subtotal|money(model.calculator.currency) }}</td>
</tr>
<tr>
<td colspan="4" class="text-right">
{{ 'invoice.tax'|trans }} ({{ model.calculator.vat }}%)
</td>
<td class="text-right">{{ model.calculator.tax|money(model.calculator.currency) }}</td>
</tr>
<tr>
<td colspan="4" class="text-right">
<strong>{{ 'invoice.total'|trans }}</strong>
</td>
<td class="text-right">
<strong>{{ model.calculator.total|money(model.calculator.currency) }}</strong>
</td>
</tr>
</tfoot>
</table>
</div>
</div>
<div class="row">
<div class="col-xs-6">
<div class="col-xs-12">
{% if model.template.paymentTerms is not empty %}
<p class="lead">{{ 'label.payment_terms'|trans }}</p>
<p class="text-muted well well-sm no-shadow" contenteditable="true">
{{ model.template.paymentTerms|trim|nl2br }}
<p contenteditable="true" class="paymentTerms">
{{ model.template.paymentTerms|nl2br|md2html }}
</p>
{% endif %}
</div>
<div class="col-xs-6">
<p class="lead" contenteditable="true">{{ 'invoice.due_days'|trans }} {{ model.dueDate|date_short }}</p>
<div class="table-responsive">
<table class="table">
<tbody>
<tr>
<th style="width:50%">{{ 'invoice.subtotal'|trans }}</th>
<td>{{ model.calculator.subtotal|money(model.calculator.currency) }}</td>
</tr>
<tr>
<th>{{ 'invoice.tax'|trans }} ({{ model.calculator.vat }}%)</th>
<td>{{ model.calculator.tax|money(model.calculator.currency) }}</td>
</tr>
<tr>
<th>{{ 'invoice.total'|trans }}</th>
<td>{{ model.calculator.total|money(model.calculator.currency) }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<footer class="footer">
<p>
<strong>{{ 'label.address'|trans }}</strong>: {{ model.template.address|replace({"\n": ' &ndash; ', "\r\n": ' &ndash; ', "\r": ' &ndash; '})|raw }}
<br>
<strong>{{ 'label.invoice_bank_account'|trans }}</strong>: {{ model.template.paymentDetails|replace({"\n": ' &ndash; ', "\r\n": ' &ndash; ', "\r": ' &ndash; '})|raw }}
<br>
<strong>{{ 'label.contact'|trans }}</strong>: {{ model.template.contact|replace({"\n": ' &ndash; ', "\r\n": ' &ndash; ', "\r": ' &ndash; '})|raw }}
</p>
</footer>
{% endblock %}

View File

@@ -4,30 +4,18 @@
{% block invoice %}
{# You can overwrite these settings in your config/packages/local.yaml under the key "twig.globals.company" #}
{% set company = company|default({
name: 'Kimai Inc.',
homepage: 'www.kimai.org',
email: 'kimai@example.com',
phone: '0123-4567890',
tax_number: 'YourTaxNumber',
signature: '/build/images/signature.png',
address: 'Kimai Inc.
Example road 42
D 12345 City
',
bank_account: '
Kimai Inc.
IBAN: DE00 0000 0000 0000 0000 00
BIC: XXXXXXXX (a bank name)
'
}) %}
{% set signature = null %}
{# You can overwrite these settings in your config/packages/local.yaml under the key "twig.globals.company" - previously '/build/images/signature.png' #}
{% set company = company|default({signature: null}) %}
{% if company.signature is defined and company.signature is not empty %}
{% set signature = company.signature %}
{% endif %}
<div class="row" id="freelancer-invoice" style="page:invoiceFreelancer;">
<div class="row" id="freelancer-invoice">
<div class="col-xs-12">
<header>
<address>
<p contenteditable="true">{{ model.template.address }}</p>
<p contenteditable="true">{{ model.template.address|replace({"\n": ' &ndash; ', "\r\n": ' &ndash; ', "\r": ' &ndash; '})|raw }}</p>
</address>
</header>
<article class="address">
@@ -36,6 +24,10 @@
<p>
<strong>{{ model.customer.company|default(model.customer.name) }}</strong><br>
{{ model.customer.address|nl2br }}
{% if model.customer.vatId is not empty %}
<br>
{{ 'label.vat_id'|trans }}: {{ model.customer.vatId }}
{% endif %}
</p>
</address>
<table class="meta">
@@ -59,6 +51,14 @@
</td>
</tr>
{% endif %}
{% if model.template.vatId is not empty %}
<tr>
<th>{{ 'label.vat_id'|trans }}:</th>
<td>
{{ model.template.vatId }}
</td>
</tr>
{% endif %}
</table>
</article>
<article>
@@ -118,44 +118,44 @@
</table>
</article>
{% if model.template.paymentTerms is not empty %}
<article class="footer">
<article class="paymentTerms">
<p contenteditable="true">
{{ model.template.paymentTerms|trim|nl2br }}
{{ model.template.paymentTerms|nl2br|md2html }}
</p>
{% if company.signature is defined %}
<img src="{{ company.signature }}">
{% if signature is not empty %}
<img src="{{ signature }}">
{% endif %}
</article>
{% endif %}
<footer>
<table>
<thead>
<tr>
<th>{{ 'label.address'|trans }}</th>
<th>{{ 'label.invoice_bank_account'|trans }}</th>
<th>{{ 'label.contact'|trans }}</th>
</tr>
</thead>
<tbody>
<tr>
<td contenteditable="true">
{{ company.address|nl2br }}
{{ 'label.invoice_tax_number'|trans }} {{ company.tax_number }}
</td>
<td contenteditable="true">
{{ company.bank_account|nl2br }}
</td>
<td contenteditable="true">
{{ company.phone }}
<br/>
{{ company.email }}
<br/>
{{ company.homepage }}
</td>
</tr>
</tbody>
</table>
<footer class="footer">
<div class="row">
<div class="col-sm-4" contenteditable="true">
<p>
<strong>{{ 'label.address'|trans }}</strong>
</p>
<p>
{{ model.template.address|nl2br }}
</p>
</div>
<div class="col-sm-4 text-center" contenteditable="true">
<p>
<strong>{{ 'label.invoice_bank_account'|trans }}</strong>
</p>
<p>
{{ model.template.paymentDetails|nl2br }}
</p>
</div>
<div class="col-sm-4 text-right" contenteditable="true">
<p>
<strong>{{ 'label.contact'|trans }}</strong>
</p>
<p>
{{ model.template.contact|nl2br }}
</p>
</div>
</div>
</footer>
</div>
</div>
{% endblock %}

View File

@@ -52,7 +52,7 @@
</div>
</div>
<div class="row">
<div class="row invoice-items">
<div class="col-xs-12 table-responsive">
<table class="table table-striped">
<thead>
@@ -97,22 +97,21 @@
</div>
</div>
<div class="row" style="padding: 60px 0">
<div class="row">
<div class="col-xs-12">
{% if model.template.paymentTerms is not empty %}
<p class="lead">{{ 'label.payment_terms'|trans }}</p>
<p class="text-muted well well-sm no-shadow" contenteditable="true">
<p class="text-muted well well-sm no-shadow" contenteditable="true" style="margin-bottom: 100px">
{{ model.template.paymentTerms|trim|nl2br }}
</p>
{% endif %}
<div class="table-responsive">
<table class="table">
<tbody>
<tr>
<th style="width:50%;padding-bottom: 60px">{{ 'invoice.signature_user'|trans }}</th>
<th style="padding-bottom: 60px">{{ 'invoice.signature_user'|trans }}</th>
</tr>
<tr>
<th>{{ 'invoice.signature_customer'|trans }}</th>

View File

@@ -3,9 +3,54 @@
{% block page_title %}{{ 'admin_invoice_template.title'|trans }}{% endblock %}
{% block main %}
{{ include(app.request.xmlHttpRequest ? 'default/_form_modal.html.twig' : 'default/_form.html.twig', {
{% set formEditTemplate = app.request.xmlHttpRequest ? 'default/_form_modal.html.twig' : 'default/_form.html.twig' %}
{% set formOptions = {
'title': template.name|default('create'|trans),
'form': form,
'back': path('admin_invoice_template')
}) }}
} %}
{% embed formEditTemplate with formOptions %}
{% block form_body %}
{{ form_row(form.name) }}
{{ form_row(form.title) }}
<div class="row">
<div class="col-md-6">
{{ form_row(form.company) }}
</div>
<div class="col-md-6">
{{ form_row(form.vatId) }}
</div>
</div>
<div class="row">
<div class="col-md-6">
{{ form_row(form.address) }}
</div>
<div class="col-md-6">
{{ form_row(form.contact) }}
</div>
</div>
<div class="row">
<div class="col-md-6">
{{ form_row(form.paymentTerms) }}
</div>
<div class="col-md-6">
{{ form_row(form.paymentDetails) }}
</div>
</div>
<div class="row">
<div class="col-md-6">
{{ form_row(form.dueDays) }}
</div>
<div class="col-md-6">
{{ form_row(form.vat) }}
</div>
</div>
{{ form_row(form.renderer) }}
{{ form_row(form.calculator) }}
{{ form_row(form.numberGenerator) }}
{{ form_widget(form) }}
{% endblock %}
{% endembed %}
{% endblock %}

View File

@@ -21,7 +21,7 @@
{% set tableName = 'invoice_template' %}
{{ tables.data_table_header(tableName, columns) }}
{{ tables.datatable_header(tableName, columns, null, {'reload': 'kimai.invoiceTemplateUpdate'}) }}
{% for entry in entries %}
<tr class="modal-ajax-form open-edit" data-href="{{ path('admin_invoice_template_edit', {'id' : entry.id}) }}">

View File

@@ -23,85 +23,89 @@ class CustomerTest extends TestCase
public function testDefaultValues()
{
$sut = new Customer();
$this->assertNull($sut->getId());
$this->assertNull($sut->getName());
$this->assertNull($sut->getNumber());
$this->assertNull($sut->getComment());
$this->assertTrue($sut->getVisible());
self::assertNull($sut->getId());
self::assertNull($sut->getName());
self::assertNull($sut->getNumber());
self::assertNull($sut->getComment());
self::assertTrue($sut->getVisible());
$this->assertNull($sut->getCompany());
$this->assertNull($sut->getContact());
$this->assertNull($sut->getAddress());
$this->assertNull($sut->getCountry());
$this->assertEquals('EUR', $sut->getCurrency());
$this->assertEquals('EUR', Customer::DEFAULT_CURRENCY);
$this->assertNull($sut->getPhone());
$this->assertNull($sut->getFax());
$this->assertNull($sut->getMobile());
$this->assertNull($sut->getEmail());
$this->assertNull($sut->getHomepage());
$this->assertNull($sut->getTimezone());
self::assertNull($sut->getCompany());
self::assertNull($sut->getVatId());
self::assertNull($sut->getContact());
self::assertNull($sut->getAddress());
self::assertNull($sut->getCountry());
self::assertEquals('EUR', $sut->getCurrency());
self::assertEquals('EUR', Customer::DEFAULT_CURRENCY);
self::assertNull($sut->getPhone());
self::assertNull($sut->getFax());
self::assertNull($sut->getMobile());
self::assertNull($sut->getEmail());
self::assertNull($sut->getHomepage());
self::assertNull($sut->getTimezone());
$this->assertNull($sut->getFixedRate());
$this->assertNull($sut->getHourlyRate());
$this->assertNull($sut->getColor());
$this->assertEquals(0.0, $sut->getBudget());
$this->assertEquals(0, $sut->getTimeBudget());
$this->assertInstanceOf(Collection::class, $sut->getMetaFields());
$this->assertEquals(0, $sut->getMetaFields()->count());
$this->assertNull($sut->getMetaField('foo'));
$this->assertInstanceOf(Collection::class, $sut->getTeams());
$this->assertEquals(0, $sut->getTeams()->count());
self::assertNull($sut->getFixedRate());
self::assertNull($sut->getHourlyRate());
self::assertNull($sut->getColor());
self::assertEquals(0.0, $sut->getBudget());
self::assertEquals(0, $sut->getTimeBudget());
self::assertInstanceOf(Collection::class, $sut->getMetaFields());
self::assertEquals(0, $sut->getMetaFields()->count());
self::assertNull($sut->getMetaField('foo'));
self::assertInstanceOf(Collection::class, $sut->getTeams());
self::assertEquals(0, $sut->getTeams()->count());
}
public function testSetterAndGetter()
{
$sut = new Customer();
$this->assertInstanceOf(Customer::class, $sut->setName('foo-bar'));
$this->assertEquals('foo-bar', $sut->getName());
$this->assertEquals('foo-bar', (string) $sut);
self::assertInstanceOf(Customer::class, $sut->setName('foo-bar'));
self::assertEquals('foo-bar', $sut->getName());
self::assertEquals('foo-bar', (string) $sut);
$this->assertInstanceOf(Customer::class, $sut->setVisible(false));
$this->assertFalse($sut->getVisible());
self::assertInstanceOf(Customer::class, $sut->setVisible(false));
self::assertFalse($sut->getVisible());
$this->assertInstanceOf(Customer::class, $sut->setComment('hello world'));
$this->assertEquals('hello world', $sut->getComment());
self::assertInstanceOf(Customer::class, $sut->setComment('hello world'));
self::assertEquals('hello world', $sut->getComment());
$this->assertInstanceOf(Customer::class, $sut->setColor('#fffccc'));
$this->assertEquals('#fffccc', $sut->getColor());
self::assertInstanceOf(Customer::class, $sut->setColor('#fffccc'));
self::assertEquals('#fffccc', $sut->getColor());
$this->assertInstanceOf(Customer::class, $sut->setCompany('test company'));
$this->assertEquals('test company', $sut->getCompany());
self::assertInstanceOf(Customer::class, $sut->setCompany('test company'));
self::assertEquals('test company', $sut->getCompany());
$this->assertInstanceOf(Customer::class, $sut->setContact('test contact'));
$this->assertEquals('test contact', $sut->getContact());
self::assertInstanceOf(Customer::class, $sut->setContact('test contact'));
self::assertEquals('test contact', $sut->getContact());
$this->assertInstanceOf(Customer::class, $sut->setPhone('0123456789'));
$this->assertEquals('0123456789', $sut->getPhone());
self::assertInstanceOf(Customer::class, $sut->setPhone('0123456789'));
self::assertEquals('0123456789', $sut->getPhone());
$this->assertInstanceOf(Customer::class, $sut->setFax('asdfghjkl'));
$this->assertEquals('asdfghjkl', $sut->getFax());
self::assertInstanceOf(Customer::class, $sut->setFax('asdfghjkl'));
self::assertEquals('asdfghjkl', $sut->getFax());
$this->assertInstanceOf(Customer::class, $sut->setMobile('76576534'));
$this->assertEquals('76576534', $sut->getMobile());
self::assertInstanceOf(Customer::class, $sut->setMobile('76576534'));
self::assertEquals('76576534', $sut->getMobile());
$this->assertInstanceOf(Customer::class, $sut->setEmail('test@example.com'));
$this->assertEquals('test@example.com', $sut->getEmail());
self::assertInstanceOf(Customer::class, $sut->setEmail('test@example.com'));
self::assertEquals('test@example.com', $sut->getEmail());
$this->assertInstanceOf(Customer::class, $sut->setHomepage('https://www.example.com'));
$this->assertEquals('https://www.example.com', $sut->getHomepage());
self::assertInstanceOf(Customer::class, $sut->setHomepage('https://www.example.com'));
self::assertEquals('https://www.example.com', $sut->getHomepage());
$this->assertInstanceOf(Customer::class, $sut->setFixedRate(13.47));
$this->assertEquals(13.47, $sut->getFixedRate());
self::assertInstanceOf(Customer::class, $sut->setFixedRate(13.47));
self::assertEquals(13.47, $sut->getFixedRate());
$this->assertInstanceOf(Customer::class, $sut->setHourlyRate(99));
$this->assertEquals(99, $sut->getHourlyRate());
self::assertInstanceOf(Customer::class, $sut->setHourlyRate(99));
self::assertEquals(99, $sut->getHourlyRate());
$this->assertInstanceOf(Customer::class, $sut->setBudget(12345.67));
$this->assertEquals(12345.67, $sut->getBudget());
self::assertInstanceOf(Customer::class, $sut->setBudget(12345.67));
self::assertEquals(12345.67, $sut->getBudget());
$this->assertInstanceOf(Customer::class, $sut->setTimeBudget(937321));
$this->assertEquals(937321, $sut->getTimeBudget());
self::assertInstanceOf(Customer::class, $sut->setTimeBudget(937321));
self::assertEquals(937321, $sut->getTimeBudget());
self::assertInstanceOf(Customer::class, $sut->setVatId('ID 1234567890'));
self::assertEquals('ID 1234567890', $sut->getVatId());
}
public function testMetaFields()
@@ -109,7 +113,7 @@ class CustomerTest extends TestCase
$sut = new Customer();
$meta = new CustomerMeta();
$meta->setName('foo')->setValue('bar')->setType('test');
$this->assertInstanceOf(Customer::class, $sut->setMetaField($meta));
self::assertInstanceOf(Customer::class, $sut->setMetaField($meta));
self::assertEquals(1, $sut->getMetaFields()->count());
$result = $sut->getMetaField('foo');
self::assertSame($result, $meta);
@@ -117,7 +121,7 @@ class CustomerTest extends TestCase
$meta2 = new CustomerMeta();
$meta2->setName('foo')->setValue('bar')->setType('test2');
$this->assertInstanceOf(Customer::class, $sut->setMetaField($meta2));
self::assertInstanceOf(Customer::class, $sut->setMetaField($meta2));
self::assertEquals(1, $sut->getMetaFields()->count());
self::assertCount(0, $sut->getVisibleMetaFields());

View File

@@ -17,36 +17,63 @@ use PHPUnit\Framework\TestCase;
*/
class InvoiceTemplateTest extends TestCase
{
protected function assertIsFluent($actual)
public function testDefaultValues()
{
$this->assertInstanceOf(InvoiceTemplate::class, $actual);
$sut = new InvoiceTemplate();
self::assertNull($sut->getVatId());
self::assertNull($sut->getPaymentDetails());
self::assertNull($sut->getPaymentTerms());
self::assertNull($sut->getContact());
self::assertEquals(0.00, $sut->getVat());
self::assertNull($sut->getId());
self::assertNull($sut->getName());
self::assertNull($sut->getAddress());
self::assertNull($sut->getTitle());
self::assertNull($sut->getCompany());
self::assertEquals('default', $sut->getCalculator());
self::assertEquals('default', $sut->getNumberGenerator());
self::assertEquals('default', $sut->getRenderer());
self::assertEquals(30, $sut->getDueDays());
}
protected function getEntity()
public function testSetNullForOptionalValues()
{
$entity = new InvoiceTemplate();
$sut = new InvoiceTemplate();
return $entity;
self::assertInstanceOf(InvoiceTemplate::class, $sut->setPaymentDetails(null));
self::assertInstanceOf(InvoiceTemplate::class, $sut->setVatId(null));
self::assertInstanceOf(InvoiceTemplate::class, $sut->setContact(null));
self::assertInstanceOf(InvoiceTemplate::class, $sut->setAddress(null));
self::assertInstanceOf(InvoiceTemplate::class, $sut->setPaymentTerms(null));
}
public function testSetGetPaymentTerms()
public function testSetterAndGetter()
{
$sut = $this->getEntity();
$sut = new InvoiceTemplate();
$this->assertNull($sut->getPaymentTerms());
$this->assertIsFluent($sut->setPaymentTerms(null));
$this->assertIsFluent($sut->setPaymentTerms(''));
$this->assertIsFluent($sut->setPaymentTerms('foo bar'));
$this->assertEquals('foo bar', $sut->getPaymentTerms());
self::assertInstanceOf(InvoiceTemplate::class, $sut->setPaymentTerms('foo bar'));
self::assertEquals('foo bar', $sut->getPaymentTerms());
self::assertInstanceOf(InvoiceTemplate::class, $sut->setPaymentDetails('iuasdzgf isdfhvlksdjfbnvl ksdfbglisbdf'));
self::assertEquals('iuasdzgf isdfhvlksdjfbnvl ksdfbglisbdf', $sut->getPaymentDetails());
self::assertInstanceOf(InvoiceTemplate::class, $sut->setContact('hello world'));
self::assertEquals('hello world', $sut->getContact());
self::assertInstanceOf(InvoiceTemplate::class, $sut->setVat(7.31));
self::assertEquals(7.31, $sut->getVat());
self::assertInstanceOf(InvoiceTemplate::class, $sut->setVatId('1234567890'));
self::assertEquals('1234567890', $sut->getVatId());
}
public function testToString()
{
$sut = $this->getEntity();
$sut = new InvoiceTemplate();
$this->assertNull($sut->__toString());
$this->assertIsFluent($sut->setName('a template name'));
$this->assertEquals('a template name', $sut->__toString());
$this->assertEquals('a template name', (string) $sut);
self::assertInstanceOf(InvoiceTemplate::class, $sut->setName('a template name'));
self::assertEquals('a template name', $sut->__toString());
self::assertEquals('a template name', (string) $sut);
}
}

View File

@@ -33,7 +33,7 @@ class CsvRendererTest extends TestCase
$this->assertFalse($sut->supports($this->getInvoiceDocument('timesheet.html.twig')));
$this->assertFalse($sut->supports($this->getInvoiceDocument('foo.html.twig')));
$this->assertFalse($sut->supports($this->getInvoiceDocument('company.docx')));
$this->assertTrue($sut->supports($this->getInvoiceDocument('export.csv')));
$this->assertTrue($sut->supports($this->getInvoiceDocument('export.csv', true)));
$this->assertFalse($sut->supports($this->getInvoiceDocument('spreadsheet.xlsx')));
$this->assertFalse($sut->supports($this->getInvoiceDocument('open-spreadsheet.ods')));
}
@@ -51,7 +51,7 @@ class CsvRendererTest extends TestCase
{
/** @var CsvRenderer $sut */
$sut = $this->getAbstractRenderer(CsvRenderer::class);
$document = $this->getInvoiceDocument('export.csv');
$document = $this->getInvoiceDocument('export.csv', true);
/** @var BinaryFileResponse $response */
$response = $sut->render($document, $model);

View File

@@ -73,6 +73,9 @@ class DebugRendererTest extends TestCase
'template.title',
'template.payment_terms',
'template.due_days',
'template.vat_id',
'template.contact',
'template.payment_details',
'query.begin',
'query.day',
'query.end',
@@ -84,6 +87,7 @@ class DebugRendererTest extends TestCase
'customer.name',
'customer.contact',
'customer.company',
'customer.vat',
'customer.country',
'customer.number',
'customer.homepage',

View File

@@ -35,7 +35,7 @@ class OdsRendererTest extends TestCase
$this->assertFalse($sut->supports($this->getInvoiceDocument('company.docx')));
$this->assertFalse($sut->supports($this->getInvoiceDocument('export.csv')));
$this->assertFalse($sut->supports($this->getInvoiceDocument('spreadsheet.xlsx')));
$this->assertTrue($sut->supports($this->getInvoiceDocument('open-spreadsheet.ods')));
$this->assertTrue($sut->supports($this->getInvoiceDocument('open-spreadsheet.ods', true)));
}
public function getTestModel()
@@ -52,7 +52,7 @@ class OdsRendererTest extends TestCase
/** @var OdsRenderer $sut */
$sut = $this->getAbstractRenderer(OdsRenderer::class);
$model = $this->getInvoiceModel();
$document = $this->getInvoiceDocument('open-spreadsheet.ods');
$document = $this->getInvoiceDocument('open-spreadsheet.ods', true);
/** @var BinaryFileResponse $response */
$response = $sut->render($document, $model);

View File

@@ -47,10 +47,16 @@ trait RendererTestTrait
* @param string $filename
* @return InvoiceDocument
*/
protected function getInvoiceDocument(string $filename)
protected function getInvoiceDocument(string $filename, bool $testOnly = false)
{
if (!$testOnly) {
return new InvoiceDocument(
new \SplFileInfo($this->getInvoiceTemplatePath() . $filename)
);
}
return new InvoiceDocument(
new \SplFileInfo($this->getInvoiceTemplatePath() . $filename)
new \SplFileInfo(__DIR__ . '/../templates/' . $filename)
);
}

View File

@@ -34,7 +34,7 @@ class XlsxRendererTest extends TestCase
$this->assertFalse($sut->supports($this->getInvoiceDocument('foo.html.twig')));
$this->assertFalse($sut->supports($this->getInvoiceDocument('company.docx')));
$this->assertFalse($sut->supports($this->getInvoiceDocument('export.csv')));
$this->assertTrue($sut->supports($this->getInvoiceDocument('spreadsheet.xlsx')));
$this->assertTrue($sut->supports($this->getInvoiceDocument('spreadsheet.xlsx', true)));
$this->assertFalse($sut->supports($this->getInvoiceDocument('open-spreadsheet.ods')));
}
@@ -52,7 +52,7 @@ class XlsxRendererTest extends TestCase
/** @var XlsxRenderer $sut */
$sut = $this->getAbstractRenderer(XlsxRenderer::class);
$model = $this->getInvoiceModel();
$document = $this->getInvoiceDocument('spreadsheet.xlsx');
$document = $this->getInvoiceDocument('spreadsheet.xlsx', true);
/** @var BinaryFileResponse $response */
$response = $sut->render($document, $model);

View File

@@ -25,10 +25,7 @@ class InvoiceDocumentRepositoryTest extends TestCase
protected static $defaultDocuments = [
'company.docx',
'default.html.twig',
'export.csv',
'freelancer.html.twig',
'open-spreadsheet.ods',
'spreadsheet.xlsx',
'timesheet.html.twig',
];

View File

@@ -551,6 +551,10 @@
<source>label.vat</source>
<target>Umsatzsteuer</target>
</trans-unit>
<trans-unit id="label.vat_id">
<source>label.vat_id</source>
<target>Umsatzsteuer-ID</target>
</trans-unit>
<trans-unit id="label.contact">
<source>label.contact</source>
<target>Kontakt</target>

View File

@@ -551,6 +551,10 @@
<source>label.vat</source>
<target>Tax</target>
</trans-unit>
<trans-unit id="label.vat_id">
<source>label.vat_id</source>
<target>VAT-ID</target>
</trans-unit>
<trans-unit id="label.contact">
<source>label.contact</source>
<target>Contact</target>

Binary file not shown.

1
var/packages/.gitkeep Normal file
View File

@@ -0,0 +1 @@
This directory can store as Kimai plugin/bundle folder.