Compare commits

..

1 Commits

Author SHA1 Message Date
Kevin Papst
ef170a8ca7 prepare webhook support 2025-12-25 09:51:36 +01:00
59 changed files with 866 additions and 570 deletions

View File

@@ -21,3 +21,7 @@ indent_size = 4
[*.yml]
indent_style = space
indent_size = 4
[*.xml]
indent_style = space
indent_size = 4

View File

@@ -45,3 +45,7 @@ APP_SECRET=change_this_to_something_unique
#================================================================================
# unlikely, that you need to change this one
CORS_ALLOW_ORIGIN=^https?://localhost(:[0-9]+)?$
###> symfony/messenger ###
MESSENGER_TRANSPORT_DSN=doctrine://default?auto_setup=0
###< symfony/messenger ###

View File

@@ -53,7 +53,6 @@ body:
attributes:
label: Which PHP version are you using?
options:
- "8.5"
- "8.4"
- "8.2"
- "8.3"

View File

@@ -22,7 +22,7 @@ version-resolver:
- 'translation'
default: patch
template: |
**Compatible with PHP 8.1 to 8.5**
**Compatible with PHP 8.1 to 8.4**
$CHANGES

View File

@@ -19,7 +19,7 @@ jobs:
options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3
strategy:
matrix:
php: ['8.1', '8.2', '8.3', '8.4', '8.5']
php: ['8.1', '8.2', '8.3', '8.4']
name: Integration (${{ matrix.php }})
steps:
@@ -87,7 +87,7 @@ jobs:
MAILER_URL: null://localhost
- name: Full test-suite
if: matrix.php != '8.5'
if: matrix.php != '8.2'
run: vendor/bin/phpunit tests/
env:
DATABASE_URL: mysql://root:kimai@127.0.0.1:${{ job.services.mysql.ports['3306'] }}/kimai?charset=utf8mb4&serverVersion=8.0.35
@@ -95,7 +95,7 @@ jobs:
MAILER_URL: null://localhost
- name: Full test-suite with coverage
if: matrix.php == '8.5'
if: matrix.php == '8.2'
run: vendor/bin/phpunit tests/ --coverage-clover=coverage.xml
env:
DATABASE_URL: mysql://root:kimai@127.0.0.1:${{ job.services.mysql.ports['3306'] }}/kimai?charset=utf8mb4&serverVersion=8.0.35
@@ -103,7 +103,7 @@ jobs:
MAILER_URL: null://localhost
- name: Upload code coverage
if: matrix.php == '8.5'
if: matrix.php == '8.2'
uses: codecov/codecov-action@v5
with:
token: ${{ secrets.CODECOV_TOKEN }}

View File

@@ -29,7 +29,7 @@ and so much more.
### Requirements
- PHP 8.1.3 minimum with support for 8.2, 8.3, 8.4, 8.5
- PHP 8.1.3 minimum (support for PHP 8.2, 8.3, 8.4)
- MariaDB or MySQL
- A webserver and subdomain (subdirectory is not supported)
- PHP extensions: `gd`, `intl`, `json`, `mbstring`, `pdo`, `tokenizer`, `xml`, `xsl`, `zip`
@@ -44,7 +44,7 @@ and so much more.
There are more documented ways for [on-premise hosting](https://www.kimai.org/documentation/chapter-on-premise.html).
And if you don't want to host Kimai, you can use the [Cloud version](https://www.kimai.cloud/) of it.
And if you don't want to host Kimai, you can use [the Cloud version](https://www.kimai.cloud/) of it.
### Updating Kimai

View File

@@ -13,8 +13,6 @@ Perform EACH version specific task between your version and the new one, otherwi
### Developer
Do not use method chaining: all fluent interface, especially in Entities, are no longer supported.
Removed translations:
- `action.edit`: use `edit` instead
- `my.profile`: use `user_profile` instead

View File

@@ -1,4 +1,2 @@
/**
* @deprecated use invoice-pdf instead
*/
require('./sass/_invoice.scss');

View File

@@ -14,7 +14,7 @@
}
],
"require": {
"php": "8.1.*||8.2.*||8.3.*||8.4.*||8.5.*",
"php": "8.1.*||8.2.*||8.3.*||8.4.*",
"ext-gd": "*",
"ext-intl": "*",
"ext-json": "*",
@@ -74,6 +74,7 @@
"symfony/translation": "^6.0",
"symfony/twig-bundle": "^6.0",
"symfony/validator": "^6.0",
"symfony/webhook": "^6.0",
"symfony/webpack-encore-bundle": "^2.0",
"symfony/yaml": "^6.0",
"twig/cssinliner-extra": "^3.0",

274
composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "4f02c81fc72ab3bd887f6e8cf1361757",
"content-hash": "9d1ceee2eb5d9ad5d14add5cd65f49d2",
"packages": [
{
"name": "azuyalabs/yasumi",
@@ -775,16 +775,16 @@
},
{
"name": "doctrine/doctrine-bundle",
"version": "2.18.2",
"version": "2.18.1",
"source": {
"type": "git",
"url": "https://github.com/doctrine/DoctrineBundle.git",
"reference": "0ff098b29b8b3c68307c8987dcaed7fd829c6546"
"reference": "b769877014de053da0e5cbbb63d0ea2f3b2fea76"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/doctrine/DoctrineBundle/zipball/0ff098b29b8b3c68307c8987dcaed7fd829c6546",
"reference": "0ff098b29b8b3c68307c8987dcaed7fd829c6546",
"url": "https://api.github.com/repos/doctrine/DoctrineBundle/zipball/b769877014de053da0e5cbbb63d0ea2f3b2fea76",
"reference": "b769877014de053da0e5cbbb63d0ea2f3b2fea76",
"shasum": ""
},
"require": {
@@ -876,7 +876,7 @@
],
"support": {
"issues": "https://github.com/doctrine/DoctrineBundle/issues",
"source": "https://github.com/doctrine/DoctrineBundle/tree/2.18.2"
"source": "https://github.com/doctrine/DoctrineBundle/tree/2.18.1"
},
"funding": [
{
@@ -892,7 +892,7 @@
"type": "tidelift"
}
],
"time": "2025-12-20T21:35:32+00:00"
"time": "2025-11-05T14:42:10+00:00"
},
{
"name": "doctrine/doctrine-migrations-bundle",
@@ -4138,16 +4138,16 @@
},
{
"name": "phpdocumentor/reflection-docblock",
"version": "5.6.6",
"version": "5.6.5",
"source": {
"type": "git",
"url": "https://github.com/phpDocumentor/ReflectionDocBlock.git",
"reference": "5cee1d3dfc2d2aa6599834520911d246f656bcb8"
"reference": "90614c73d3800e187615e2dd236ad0e2a01bf761"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/5cee1d3dfc2d2aa6599834520911d246f656bcb8",
"reference": "5cee1d3dfc2d2aa6599834520911d246f656bcb8",
"url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/90614c73d3800e187615e2dd236ad0e2a01bf761",
"reference": "90614c73d3800e187615e2dd236ad0e2a01bf761",
"shasum": ""
},
"require": {
@@ -4157,7 +4157,7 @@
"phpdocumentor/reflection-common": "^2.2",
"phpdocumentor/type-resolver": "^1.7",
"phpstan/phpdoc-parser": "^1.7|^2.0",
"webmozart/assert": "^1.9.1 || ^2"
"webmozart/assert": "^1.9.1"
},
"require-dev": {
"mockery/mockery": "~1.3.5 || ~1.6.0",
@@ -4196,9 +4196,9 @@
"description": "With this component, a library can provide support for annotations via DocBlocks or otherwise retrieve information that is embedded in a DocBlock.",
"support": {
"issues": "https://github.com/phpDocumentor/ReflectionDocBlock/issues",
"source": "https://github.com/phpDocumentor/ReflectionDocBlock/tree/5.6.6"
"source": "https://github.com/phpDocumentor/ReflectionDocBlock/tree/5.6.5"
},
"time": "2025-12-22T21:13:58+00:00"
"time": "2025-11-27T19:50:05+00:00"
},
{
"name": "phpdocumentor/type-resolver",
@@ -7725,6 +7725,97 @@
],
"time": "2025-10-24T13:29:09+00:00"
},
{
"name": "symfony/messenger",
"version": "v6.4.28",
"source": {
"type": "git",
"url": "https://github.com/symfony/messenger.git",
"reference": "6a758210a2392c4a326de84a6c8c70e7e18296f9"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/messenger/zipball/6a758210a2392c4a326de84a6c8c70e7e18296f9",
"reference": "6a758210a2392c4a326de84a6c8c70e7e18296f9",
"shasum": ""
},
"require": {
"php": ">=8.1",
"psr/log": "^1|^2|^3",
"symfony/clock": "^6.3|^7.0",
"symfony/deprecation-contracts": "^2.5|^3"
},
"conflict": {
"symfony/console": "<6.3",
"symfony/event-dispatcher": "<5.4",
"symfony/event-dispatcher-contracts": "<2.5",
"symfony/framework-bundle": "<5.4",
"symfony/http-kernel": "<5.4",
"symfony/serializer": "<5.4"
},
"require-dev": {
"psr/cache": "^1.0|^2.0|^3.0",
"symfony/console": "^6.3|^7.0",
"symfony/dependency-injection": "^5.4|^6.0|^7.0",
"symfony/event-dispatcher": "^5.4|^6.0|^7.0",
"symfony/http-kernel": "^5.4|^6.0|^7.0",
"symfony/process": "^5.4|^6.0|^7.0",
"symfony/property-access": "^5.4|^6.0|^7.0",
"symfony/rate-limiter": "^5.4|^6.0|^7.0",
"symfony/routing": "^5.4|^6.0|^7.0",
"symfony/serializer": "^5.4|^6.0|^7.0",
"symfony/service-contracts": "^2.5|^3",
"symfony/stopwatch": "^5.4|^6.0|^7.0",
"symfony/validator": "^5.4|^6.0|^7.0"
},
"type": "library",
"autoload": {
"psr-4": {
"Symfony\\Component\\Messenger\\": ""
},
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Samuel Roze",
"email": "samuel.roze@gmail.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Helps applications send and receive messages to/from other applications or via message queues",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/messenger/tree/v6.4.28"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2025-11-06T11:12:43+00:00"
},
{
"name": "symfony/mime",
"version": "v6.4.30",
@@ -8595,6 +8686,75 @@
],
"time": "2025-07-10T08:14:14+00:00"
},
{
"name": "symfony/remote-event",
"version": "v6.4.24",
"source": {
"type": "git",
"url": "https://github.com/symfony/remote-event.git",
"reference": "920e68c4f09f460609cf39a3540f1aa19da72c21"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/remote-event/zipball/920e68c4f09f460609cf39a3540f1aa19da72c21",
"reference": "920e68c4f09f460609cf39a3540f1aa19da72c21",
"shasum": ""
},
"require": {
"php": ">=8.1",
"symfony/messenger": "^5.4|^6.1|^7.0"
},
"type": "library",
"autoload": {
"psr-4": {
"Symfony\\Component\\RemoteEvent\\": ""
},
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Fabien Potencier",
"email": "fabien@symfony.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Eases handling remote events",
"homepage": "https://symfony.com",
"keywords": [
"event"
],
"support": {
"source": "https://github.com/symfony/remote-event/tree/v6.4.24"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2025-07-10T08:14:14+00:00"
},
{
"name": "symfony/routing",
"version": "v6.4.30",
@@ -10131,6 +10291,78 @@
],
"time": "2025-09-11T09:57:09+00:00"
},
{
"name": "symfony/webhook",
"version": "v6.4.24",
"source": {
"type": "git",
"url": "https://github.com/symfony/webhook.git",
"reference": "95d6f437a17224029983f2bed3cfaca09e530a17"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/webhook/zipball/95d6f437a17224029983f2bed3cfaca09e530a17",
"reference": "95d6f437a17224029983f2bed3cfaca09e530a17",
"shasum": ""
},
"require": {
"php": ">=8.1",
"symfony/http-foundation": "^6.3|^7.0",
"symfony/http-kernel": "^6.3|^7.0",
"symfony/messenger": "^5.4|^6.1|^7.0",
"symfony/remote-event": "^6.3|^7.0"
},
"type": "library",
"autoload": {
"psr-4": {
"Symfony\\Component\\Webhook\\": ""
},
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Fabien Potencier",
"email": "fabien@symfony.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Eases sending and consuming webhooks",
"homepage": "https://symfony.com",
"keywords": [
"webhook"
],
"support": {
"source": "https://github.com/symfony/webhook/tree/v6.4.24"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2025-07-10T08:14:14+00:00"
},
{
"name": "symfony/webpack-encore-bundle",
"version": "v2.4.0",
@@ -12686,16 +12918,16 @@
},
{
"name": "react/child-process",
"version": "v0.6.7",
"version": "v0.6.6",
"source": {
"type": "git",
"url": "https://github.com/reactphp/child-process.git",
"reference": "970f0e71945556422ee4570ccbabaedc3cf04ad3"
"reference": "1721e2b93d89b745664353b9cfc8f155ba8a6159"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/reactphp/child-process/zipball/970f0e71945556422ee4570ccbabaedc3cf04ad3",
"reference": "970f0e71945556422ee4570ccbabaedc3cf04ad3",
"url": "https://api.github.com/repos/reactphp/child-process/zipball/1721e2b93d89b745664353b9cfc8f155ba8a6159",
"reference": "1721e2b93d89b745664353b9cfc8f155ba8a6159",
"shasum": ""
},
"require": {
@@ -12749,7 +12981,7 @@
],
"support": {
"issues": "https://github.com/reactphp/child-process/issues",
"source": "https://github.com/reactphp/child-process/tree/v0.6.7"
"source": "https://github.com/reactphp/child-process/tree/v0.6.6"
},
"funding": [
{
@@ -12757,7 +12989,7 @@
"type": "open_collective"
}
],
"time": "2025-12-23T15:25:20+00:00"
"time": "2025-01-01T16:37:48+00:00"
},
{
"name": "react/dns",
@@ -14544,7 +14776,7 @@
"prefer-stable": false,
"prefer-lowest": false,
"platform": {
"php": "8.1.*||8.2.*||8.3.*||8.4.*||8.5.*",
"php": "8.1.*||8.2.*||8.3.*||8.4.*",
"ext-gd": "*",
"ext-intl": "*",
"ext-json": "*",

View File

@@ -0,0 +1,22 @@
framework:
messenger:
# Uncomment this (and the failed transport below) to send failed messages to this transport for later handling.
# failure_transport: failed
transports:
# https://symfony.com/doc/current/messenger.html#transport-configuration
# async: '%env(MESSENGER_TRANSPORT_DSN)%'
# failed: 'doctrine://default?queue_name=failed'
sync: 'sync://'
routing:
# Route your messages to the transports
# 'App\Message\YourMessage': async
# when@test:
# framework:
# messenger:
# transports:
# # replace with your transport name here (e.g., my_transport: 'in-memory://')
# # For more Messenger testing tools, see https://github.com/zenstruck/messenger-test
# async: 'in-memory://'

View File

@@ -0,0 +1,3 @@
#webhook:
# resource: '@FrameworkBundle/Resources/config/routing/webhook.xml'
# prefix: /webhook

View File

@@ -38,6 +38,13 @@
GRANT execute,select,insert,update,delete,create,alter,drop,index,references ON `kimai2_test`.* TO kimai2_test@127.0.0.1;
-->
<env name="BOOTSTRAP_RESET_DATABASE" value="true"/>
<!-- ###+ symfony/messenger ### -->
<!-- Choose one of the transports below -->
<!-- MESSENGER_TRANSPORT_DSN=amqp://guest:guest@localhost:5672/%2f/messages -->
<!-- MESSENGER_TRANSPORT_DSN=redis://localhost:6379/messages -->
<env name="MESSENGER_TRANSPORT_DSN" value="doctrine://default?auto_setup=0"/>
<!-- ###- symfony/messenger ### -->
</php>
<testsuites>
<testsuite name="Kimai">

View File

@@ -43,8 +43,8 @@ final class InvoiceController extends BaseApiController
#[IsGranted('view_invoice')]
#[OA\Response(response: 200, description: 'Returns a collection of invoices', content: new OA\JsonContent(type: 'array', items: new OA\Items(ref: '#/components/schemas/InvoiceCollection')))]
#[Route(methods: ['GET'], path: '', name: 'get_invoices')]
#[Rest\QueryParam(name: 'begin', requirements: [new Constraints\DateTime(format: 'Y-m-d\TH:i:s')], strict: true, nullable: true, description: 'Only invoices created at or after this date-time will be included (format: HTML5 datetime-local, e.g. YYYY-MM-DDThh:mm:ss)')]
#[Rest\QueryParam(name: 'end', requirements: [new Constraints\DateTime(format: 'Y-m-d\TH:i:s')], strict: true, nullable: true, description: 'Only invoices created before or at this date-time will be included (format: HTML5 datetime-local, e.g. YYYY-MM-DDThh:mm:ss)')]
#[Rest\QueryParam(name: 'begin', requirements: [new Constraints\DateTime(format: 'Y-m-d\TH:i:s')], strict: true, nullable: true, description: 'Only records after this date will be included (format: HTML5 datetime-local, e.g. YYYY-MM-DDThh:mm:ss)')]
#[Rest\QueryParam(name: 'end', requirements: [new Constraints\DateTime(format: 'Y-m-d\TH:i:s')], strict: true, nullable: true, description: 'Only records before this date will be included (format: HTML5 datetime-local, e.g. YYYY-MM-DDThh:mm:ss)')]
#[Rest\QueryParam(name: 'customers', map: true, requirements: '\d+', strict: true, nullable: true, default: [], description: 'List of customer IDs to filter, e.g.: customers[]=1&customers[]=2')]
#[Rest\QueryParam(name: 'status', map: true, requirements: 'pending|paid|canceled|new', strict: true, nullable: true, default: [], description: 'Invoice status: pending, paid, canceled, new. Default: all')]
#[Rest\QueryParam(name: 'page', requirements: '\d+', strict: true, nullable: true, description: 'The page to display, renders a 404 if not found (default: 1)')]

View File

@@ -86,8 +86,8 @@ final class TimesheetController extends BaseApiController
#[Rest\QueryParam(name: 'tags', map: true, strict: true, nullable: true, default: [], description: 'List of tag names, e.g. tags[]=bar&tags[]=foo')]
#[Rest\QueryParam(name: 'orderBy', requirements: 'id|begin|end|rate', strict: true, nullable: true, description: 'The field by which results will be ordered. Allowed values: id, begin, end, rate (default: begin)')]
#[Rest\QueryParam(name: 'order', requirements: 'ASC|DESC', strict: true, nullable: true, description: 'The result order. Allowed values: ASC, DESC (default: DESC)')]
#[Rest\QueryParam(name: 'begin', requirements: [new Constraints\DateTime(format: 'Y-m-d\TH:i:s')], strict: true, nullable: true, description: 'Only records started at or after this date-time will be included (format: HTML5 datetime-local, e.g. YYYY-MM-DDThh:mm:ss)')]
#[Rest\QueryParam(name: 'end', requirements: [new Constraints\DateTime(format: 'Y-m-d\TH:i:s')], strict: true, nullable: true, description: 'Only records started at or before this date-time will be included (format: HTML5 datetime-local, e.g. YYYY-MM-DDThh:mm:ss)')]
#[Rest\QueryParam(name: 'begin', requirements: [new Constraints\DateTime(format: 'Y-m-d\TH:i:s')], strict: true, nullable: true, description: 'Only records after this date will be included (format: HTML5 datetime-local, e.g. YYYY-MM-DDThh:mm:ss)')]
#[Rest\QueryParam(name: 'end', requirements: [new Constraints\DateTime(format: 'Y-m-d\TH:i:s')], strict: true, nullable: true, description: 'Only records before this date will be included (format: HTML5 datetime-local, e.g. YYYY-MM-DDThh:mm:ss)')]
#[Rest\QueryParam(name: 'exported', requirements: '0|1', strict: true, nullable: true, description: 'Use this flag if you want to filter for export state. Allowed values: 0=not exported, 1=exported (default: all)')]
#[Rest\QueryParam(name: 'active', requirements: '0|1', strict: true, nullable: true, description: 'Filter for running/active records. Allowed values: 0=stopped, 1=active (default: all)')]
#[Rest\QueryParam(name: 'billable', requirements: '0|1', strict: true, nullable: true, description: 'Filter for non-/billable records. Allowed values: 0=non-billable, 1=billable (default: all)')]
@@ -380,7 +380,7 @@ final class TimesheetController extends BaseApiController
#[IsGranted('view_own_timesheet')]
#[OA\Response(response: 200, description: 'Returns a collection of recent user activities (always the latest entry of a unique working set grouped by customer, project and activity)', content: new OA\JsonContent(type: 'array', items: new OA\Items(ref: '#/components/schemas/TimesheetCollectionExpanded')))]
#[Route(methods: ['GET'], path: '/recent', name: 'recent_timesheet')]
#[Rest\QueryParam(name: 'begin', requirements: [new Constraints\DateTime(format: 'Y-m-d\TH:i:s')], strict: true, nullable: true, description: 'Only records started at or after this date will be included. Default: today - 1 year (format: HTML5 datetime-local, e.g. YYYY-MM-DDThh:mm:ss)')]
#[Rest\QueryParam(name: 'begin', requirements: [new Constraints\DateTime(format: 'Y-m-d\TH:i:s')], strict: true, nullable: true, description: 'Only records after this date will be included. Default: today - 1 year (format: HTML5 datetime-local, e.g. YYYY-MM-DDThh:mm:ss)')]
#[Rest\QueryParam(name: 'size', requirements: '\d+', strict: true, nullable: true, description: 'The amount of entries (default: 10)')]
public function recentAction(ParamFetcherInterface $paramFetcher): Response
{

View File

@@ -47,8 +47,6 @@ final class TranslationCommand extends Command
->addOption('fill-empty', null, InputOption::VALUE_NONE, 'Pre-fills empty translations with the english version')
->addOption('delete-empty', null, InputOption::VALUE_NONE, 'Delete all empty keys and files which have no translated key at all')
->addOption('move-resname', null, InputOption::VALUE_REQUIRED, 'Move a resname from one file to another (needs "source" and "target" options)')
->addOption('copy-resname', null, InputOption::VALUE_REQUIRED, 'Copy a resname within one file (needs "source" option)')
->addOption('target-resname', null, InputOption::VALUE_REQUIRED, 'Target resname when copying (needs "source" option)')
->addOption('move-all', null, InputOption::VALUE_NONE, 'Move all keys from one file to another (needs "source" and "target" options)')
->addOption('source', null, InputOption::VALUE_REQUIRED, 'Single source file to use')
->addOption('only-core', null, InputOption::VALUE_NONE, 'Do not include plugin and theme directories')
@@ -152,21 +150,6 @@ final class TranslationCommand extends Command
return $this->moveResname($io, $moveResname, $sources, $targets);
}
// ==========================================================================
// Move resname from source to target
// ==========================================================================
$copyResname = $input->getOption('copy-resname');
$targetResname = $input->getOption('target-resname');
if (\is_string($copyResname)) {
if (!\is_string($targetResname)) {
$io->error('To copy a resname, we need a target-resname');
return Command::FAILURE;
}
return $this->copyResname($io, $copyResname, $targetResname, $sources);
}
// ==========================================================================
// Move all keys from source to target
// ==========================================================================
@@ -671,62 +654,6 @@ final class TranslationCommand extends Command
return Command::SUCCESS;
}
/**
* @param array<string> $sources
*/
private function copyResname(SymfonyStyle $io, string $resname, string $target, array $sources): int
{
foreach ($sources as $source) {
$tmp = basename($source);
$pos = strpos($tmp, '.');
if ($pos === false) {
$io->error('Unexpected filename: ' . $source);
return Command::FAILURE;
}
$sourceDocument = new \DOMDocument('1.0');
$sourceDocument->load($source);
$copiedNode = false;
/** @var \DOMElement $element */
foreach ($sourceDocument->getElementsByTagName('trans-unit') as $element) {
if (!$element->hasAttribute('resname')) {
continue;
}
$key = $element->getAttribute('resname');
if ($key === $resname) {
$newElement = clone $element;
$newElement->setAttribute('resname', $target);
foreach ($newElement->childNodes->getIterator() as $child) {
if ($child->nodeName === 'source') {
$child->textContent = $target;
}
}
$newElement->setAttribute('id', $this->generateId($target));
$newNode = $sourceDocument->importNode($newElement, true);
$sourceDocument->documentElement->firstElementChild->firstElementChild->appendChild($newNode); // @phpstan-ignore-line
$copiedNode = true;
break;
}
}
if ($copiedNode) {
$xmlDocument = new \DOMDocument('1.0');
$xmlDocument->preserveWhiteSpace = false;
$xmlDocument->formatOutput = true;
$xmlDocument->loadXML($sourceDocument->saveXML()); // @phpstan-ignore-line
file_put_contents($source, $xmlDocument->saveXML());
}
}
return Command::SUCCESS;
}
/**
* @param array<string> $sources
* @param array<string> $targets

View File

@@ -90,7 +90,7 @@ final class CustomerController extends AbstractController
$table->addColumn('company', ['class' => 'd-none']);
$table->addColumn('vat_id', ['class' => 'd-none w-min']);
$table->addColumn('contact', ['class' => 'd-none']);
$table->addColumn('city', ['class' => 'd-none']);
$table->addColumn('address', ['class' => 'd-none']);
$table->addColumn('country', ['class' => 'd-none w-min']);
$table->addColumn('currency', ['class' => 'd-none w-min']);
$table->addColumn('phone', ['class' => 'd-none']);

View File

@@ -61,15 +61,18 @@ use Twig\Environment;
final class InvoiceController extends AbstractController
{
public function __construct(
private readonly ServiceInvoice $service,
private readonly InvoiceTemplateRepository $templateRepository,
private readonly InvoiceRepository $invoiceRepository,
private readonly EventDispatcherInterface $dispatcher
) {
}
#[Route(path: '/', name: 'invoice', methods: ['GET', 'POST'])]
#[IsGranted('create_invoice')]
public function indexAction(Request $request, ServiceInvoice $service, InvoiceTemplateRepository $templateRepository): Response
public function indexAction(Request $request, CsrfTokenManagerInterface $csrfTokenManager): Response
{
if (!$templateRepository->hasTemplate()) {
if (!$this->templateRepository->hasTemplate()) {
if ($this->isGranted('manage_invoice_template')) {
return $this->redirectToRoute('admin_invoice_template_create');
}
@@ -92,7 +95,7 @@ final class InvoiceController extends AbstractController
if ($form->isValid() && $query->getTemplate() !== null) {
try {
$models = $service->createModels($query);
$models = $this->service->createModels($query);
$searched = true;
} catch (Exception $ex) {
$this->flashUpdateException($ex);
@@ -136,8 +139,12 @@ final class InvoiceController extends AbstractController
#[Route(path: '/preview/{customer}/{token}', name: 'invoice_preview', methods: ['GET'])]
#[IsGranted('create_invoice')]
#[IsGranted('access', 'customer')]
public function previewAction(Customer $customer, string $token, Request $request, ServiceInvoice $service): Response
public function previewAction(Customer $customer, string $token, Request $request): Response
{
if (!$this->templateRepository->hasTemplate()) {
return $this->redirectToRoute('invoice');
}
if (!$this->isCsrfTokenValid('invoice.preview', $token)) {
$this->flashError('action.csrf.error');
@@ -157,10 +164,10 @@ final class InvoiceController extends AbstractController
if ($form->isValid()) {
try {
$query->setCustomers([$customer]);
$model = $service->createModel($query);
$model = $this->service->createModel($query);
$model->setPreview(true);
return $service->renderInvoice($model, $this->dispatcher, true);
return $this->service->renderInvoice($model, $this->dispatcher, true);
} catch (Exception $ex) {
$this->flashUpdateException($ex);
}
@@ -174,8 +181,12 @@ final class InvoiceController extends AbstractController
#[Route(path: '/save-invoice/{customer}/{token}', name: 'invoice_create', methods: ['GET'])]
#[IsGranted('create_invoice')]
#[IsGranted('access', 'customer')]
public function createInvoiceAction(Customer $customer, string $token, Request $request, CustomerRepository $customerRepository, ServiceInvoice $service): Response
public function createInvoiceAction(Customer $customer, string $token, Request $request, CustomerRepository $customerRepository): Response
{
if (!$this->templateRepository->hasTemplate()) {
return $this->redirectToRoute('invoice');
}
if (!$this->isCsrfTokenValid('invoice.create', $token)) {
$this->flashError('action.csrf.error');
@@ -192,7 +203,7 @@ final class InvoiceController extends AbstractController
if ($form->isValid()) {
try {
$query->setCustomers([$customer]);
$model = $service->createModel($query);
$model = $this->service->createModel($query);
// save default template for customer if not yet set
if ($customer->getInvoiceTemplate() === null) {
@@ -200,7 +211,7 @@ final class InvoiceController extends AbstractController
$customerRepository->saveCustomer($customer);
}
$invoice = $service->createInvoice($model, $this->dispatcher);
$invoice = $this->service->createInvoice($model, $this->dispatcher);
$this->flashSuccess('action.update.success');
@@ -218,7 +229,7 @@ final class InvoiceController extends AbstractController
#[Route(path: '/change-status/{id}/{status}/{token}', name: 'admin_invoice_status', methods: ['GET', 'POST'])]
#[IsGranted('create_invoice')]
#[IsGranted(new Expression("is_granted('access', subject.getCustomer())"), 'invoice')]
public function changeStatusAction(Invoice $invoice, string $status, string $token, Request $request, CsrfTokenManagerInterface $csrfTokenManager, ServiceInvoice $service): Response
public function changeStatusAction(Invoice $invoice, string $status, string $token, Request $request, CsrfTokenManagerInterface $csrfTokenManager): Response
{
if (!$csrfTokenManager->isTokenValid(new CsrfToken('invoice.status', $token))) {
$this->flashError('action.csrf.error');
@@ -243,7 +254,7 @@ final class InvoiceController extends AbstractController
}
try {
$service->changeInvoiceStatus($invoice, $status);
$this->service->changeInvoiceStatus($invoice, $status);
$this->flashSuccess('action.update.success');
} catch (Exception $ex) {
$this->flashUpdateException($ex);
@@ -255,14 +266,14 @@ final class InvoiceController extends AbstractController
#[Route(path: '/edit/{id}', name: 'admin_invoice_edit', methods: ['GET', 'POST'])]
#[IsGranted('create_invoice')]
#[IsGranted(new Expression("is_granted('access', subject.getCustomer())"), 'invoice')]
public function editAction(Invoice $invoice, Request $request, InvoiceRepository $invoiceRepository): Response
public function editAction(Invoice $invoice, Request $request): Response
{
$form = $this->createInvoiceEditForm($invoice);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
try {
$invoiceRepository->saveInvoice($invoice);
$this->invoiceRepository->saveInvoice($invoice);
$this->flashSuccess('action.update.success');
return $this->redirectToRoute('admin_invoice_list');
@@ -281,7 +292,7 @@ final class InvoiceController extends AbstractController
#[Route(path: '/delete/{id}/{token}', name: 'admin_invoice_delete', methods: ['GET'])]
#[IsGranted('delete_invoice')]
#[IsGranted(new Expression("is_granted('access', subject.getCustomer())"), 'invoice')]
public function deleteInvoiceAction(Invoice $invoice, string $token, CsrfTokenManagerInterface $csrfTokenManager, ServiceInvoice $service): Response
public function deleteInvoiceAction(Invoice $invoice, string $token, CsrfTokenManagerInterface $csrfTokenManager): Response
{
if (!$csrfTokenManager->isTokenValid(new CsrfToken('invoice.status', $token))) {
$this->flashError('action.csrf.error');
@@ -292,7 +303,7 @@ final class InvoiceController extends AbstractController
$csrfTokenManager->refreshToken('invoice.status');
try {
$service->deleteInvoice($invoice, $this->dispatcher);
$this->service->deleteInvoice($invoice, $this->dispatcher);
$this->flashSuccess('action.delete.success');
} catch (Exception $ex) {
$this->flashDeleteException($ex);
@@ -304,9 +315,9 @@ final class InvoiceController extends AbstractController
#[Route(path: '/download/{id}', name: 'admin_invoice_download', methods: ['GET'])]
#[IsGranted('view_invoice')]
#[IsGranted(new Expression("is_granted('access', subject.getCustomer())"), 'invoice')]
public function downloadAction(Invoice $invoice, ServiceInvoice $service): Response
public function downloadAction(Invoice $invoice): Response
{
$file = $service->getInvoiceFile($invoice);
$file = $this->service->getInvoiceFile($invoice);
if (null === $file) {
throw $this->createNotFoundException(
@@ -319,12 +330,12 @@ final class InvoiceController extends AbstractController
#[Route(path: '/show/{page}', defaults: ['page' => 1], requirements: ['page' => '[1-9]\d*'], name: 'admin_invoice_list', methods: ['GET'])]
#[IsGranted('view_invoice')]
public function showInvoicesAction(Request $request, int $page, InvoiceRepository $invoiceRepository): Response
public function showInvoicesAction(Request $request, int $page): Response
{
$invoice = null;
if (null !== ($id = $request->query->get('id'))) {
$invoice = $invoiceRepository->find($id);
$invoice = $this->invoiceRepository->find($id);
}
$query = new InvoiceArchiveQuery();
@@ -336,7 +347,7 @@ final class InvoiceController extends AbstractController
return $this->redirectToRoute('admin_invoice_list');
}
$entries = $invoiceRepository->getPagerfantaForQuery($query);
$entries = $this->invoiceRepository->getPagerfantaForQuery($query);
$metaColumns = $this->findMetaColumns($query);
$table = new DataTable('invoices', $query);
@@ -378,7 +389,7 @@ final class InvoiceController extends AbstractController
#[Route(path: '/export', name: 'invoice_export', methods: ['GET'])]
#[IsGranted('view_invoice')]
public function exportAction(Request $request, EntityWithMetaFieldsExporter $exporter, InvoiceRepository $invoiceRepository, InvoiceTemplateRepository $templateRepository): Response
public function exportAction(Request $request, EntityWithMetaFieldsExporter $exporter): Response
{
$query = new InvoiceArchiveQuery();
$query->setCurrentUser($this->getUser());
@@ -387,7 +398,7 @@ final class InvoiceController extends AbstractController
$form->setData($query);
$form->submit($request->query->all(), false);
$entries = $invoiceRepository->getInvoicesForQuery($query);
$entries = $this->invoiceRepository->getInvoicesForQuery($query);
$spreadsheet = $exporter->export(
Invoice::class,
@@ -401,12 +412,12 @@ final class InvoiceController extends AbstractController
#[Route(path: '/template/{page}', requirements: ['page' => '[1-9]\d*'], defaults: ['page' => 1], name: 'admin_invoice_template', methods: ['GET', 'POST'])]
#[IsGranted('manage_invoice_template')]
public function listTemplateAction(int $page, InvoiceTemplateRepository $templateRepository): Response
public function listTemplateAction(int $page): Response
{
$query = new BaseQuery();
$query->setPage($page);
$entries = $templateRepository->getPagerfantaForQuery($query);
$entries = $this->templateRepository->getPagerfantaForQuery($query);
$table = new DataTable('invoice_template', $query);
$table->setPagination($entries);
@@ -438,16 +449,16 @@ final class InvoiceController extends AbstractController
#[Route(path: '/template/{id}/edit', name: 'admin_invoice_template_edit', methods: ['GET', 'POST'])]
#[IsGranted('manage_invoice_template')]
public function editTemplateAction(InvoiceTemplate $template, Request $request, InvoiceTemplateRepository $templateRepository): Response
public function editTemplateAction(InvoiceTemplate $template, Request $request): Response
{
return $this->renderTemplateForm($template, $request, $templateRepository);
return $this->renderTemplateForm($template, $request);
}
#[Route(path: '/document_download/{document}', name: 'admin_invoice_document_download', methods: ['GET'])]
#[IsGranted('upload_invoice_template')]
public function downloadDocument(string $document, ServiceInvoice $service): Response
public function downloadDocument(string $document, Environment $twig): Response
{
$event = new InvoiceDocumentsEvent($service->getDocuments(true));
$event = new InvoiceDocumentsEvent($this->service->getDocuments(true));
$this->dispatcher->dispatch($event);
foreach ($event->getInvoiceDocuments() as $doc) {
@@ -461,7 +472,7 @@ final class InvoiceController extends AbstractController
#[Route(path: '/document_upload', name: 'admin_invoice_document_upload', methods: ['GET', 'POST'])]
#[IsGranted('upload_invoice_template')]
public function uploadDocumentAction(Request $request, string $projectDirectory, InvoiceDocumentRepository $documentRepository, Environment $twig, SystemConfiguration $systemConfiguration, ServiceInvoice $service, InvoiceTemplateRepository $templateRepository): Response
public function uploadDocumentAction(Request $request, string $projectDirectory, InvoiceDocumentRepository $documentRepository, Environment $twig, SystemConfiguration $systemConfiguration): Response
{
$dir = $documentRepository->getUploadDirectory();
$invoiceDir = $dir;
@@ -473,11 +484,11 @@ final class InvoiceController extends AbstractController
$invoiceDir = rtrim($invoiceDir, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
$used = [];
foreach ($templateRepository->findAll() as $template) {
foreach ($this->templateRepository->findAll() as $template) {
$used[$template->getRenderer()] = $template;
}
$event = new InvoiceDocumentsEvent($service->getDocuments(true));
$event = new InvoiceDocumentsEvent($this->service->getDocuments(true));
$this->dispatcher->dispatch($event);
$documents = [];
@@ -595,7 +606,7 @@ final class InvoiceController extends AbstractController
#[Route(path: '/document/{id}/delete/{token}', name: 'invoice_document_delete', methods: ['GET', 'POST'])]
#[IsGranted('manage_invoice_template')]
public function deleteDocument(string $id, string $token, CsrfTokenManagerInterface $csrfTokenManager, InvoiceDocumentRepository $documentRepository, InvoiceTemplateRepository $templateRepository): Response
public function deleteDocument(string $id, string $token, CsrfTokenManagerInterface $csrfTokenManager, InvoiceDocumentRepository $documentRepository): Response
{
$document = $documentRepository->findByName($id);
if ($document === null) {
@@ -618,7 +629,7 @@ final class InvoiceController extends AbstractController
}
}
foreach ($templateRepository->findAll() as $template) {
foreach ($this->templateRepository->findAll() as $template) {
if ($template->getRenderer() === $id) {
$this->flashError('Document is used and cannot be deleted.');
@@ -638,19 +649,19 @@ final class InvoiceController extends AbstractController
#[Route(path: '/template/create/{id}', name: 'admin_invoice_template_copy', methods: ['GET', 'POST'])]
#[IsGranted('manage_invoice_template')]
public function copyTemplateAction(Request $request, InvoiceTemplate $copyFrom, InvoiceTemplateRepository $templateRepository): Response
public function copyTemplateAction(Request $request, InvoiceTemplate $copyFrom): Response
{
return $this->createTemplate($request, $templateRepository, $copyFrom);
return $this->createTemplate($request, $copyFrom);
}
#[Route(path: '/template/create', name: 'admin_invoice_template_create', methods: ['GET', 'POST'])]
#[IsGranted('manage_invoice_template')]
public function createTemplateAction(Request $request, InvoiceTemplateRepository $templateRepository): Response
public function createTemplateAction(Request $request): Response
{
return $this->createTemplate($request, $templateRepository, null);
return $this->createTemplate($request, null);
}
private function createTemplate(Request $request, InvoiceTemplateRepository $templateRepository, ?InvoiceTemplate $copyFrom = null): Response
private function createTemplate(Request $request, ?InvoiceTemplate $copyFrom = null): Response
{
$template = new InvoiceTemplate();
$template->setLanguage($request->getLocale());
@@ -660,12 +671,12 @@ final class InvoiceController extends AbstractController
$template->setName($copyFrom->getName() . ' (1)');
}
return $this->renderTemplateForm($template, $request, $templateRepository);
return $this->renderTemplateForm($template, $request);
}
#[Route(path: '/template/{id}/delete/{csrfToken}', name: 'admin_invoice_template_delete', methods: ['GET', 'POST'])]
#[IsGranted('manage_invoice_template')]
public function deleteTemplate(InvoiceTemplate $template, string $csrfToken, CsrfTokenManagerInterface $csrfTokenManager, InvoiceTemplateRepository $templateRepository): Response
public function deleteTemplate(InvoiceTemplate $template, string $csrfToken, CsrfTokenManagerInterface $csrfTokenManager): Response
{
if (!$csrfTokenManager->isTokenValid(new CsrfToken('invoice.delete_template', $csrfToken))) {
$this->flashError('action.csrf.error');
@@ -676,7 +687,7 @@ final class InvoiceController extends AbstractController
$csrfTokenManager->refreshToken('invoice.delete_template');
try {
$templateRepository->removeTemplate($template);
$this->templateRepository->removeTemplate($template);
$this->flashSuccess('action.delete.success');
} catch (Exception $ex) {
$this->flashDeleteException($ex);
@@ -716,7 +727,7 @@ final class InvoiceController extends AbstractController
$this->flashError('action.update.error', $err);
}
private function renderTemplateForm(InvoiceTemplate $template, Request $request, InvoiceTemplateRepository $templateRepository): Response
private function renderTemplateForm(InvoiceTemplate $template, Request $request): Response
{
$event = new InvoiceTemplateMetaDefinitionEvent($template);
$this->dispatcher->dispatch($event);
@@ -727,7 +738,7 @@ final class InvoiceController extends AbstractController
if ($editForm->isSubmitted() && $editForm->isValid()) {
try {
$templateRepository->saveTemplate($template);
$this->templateRepository->saveTemplate($template);
$this->flashSuccess('action.update.success');
return $this->redirectToRoute('admin_invoice_template');

View File

@@ -231,7 +231,7 @@ class Customer implements EntityWithMetaFields, EntityWithBudget, CreatedAt
#[Assert\Length(max: 50)]
#[Serializer\Expose]
#[Serializer\Groups(['Customer_Entity'])]
#[Exporter\Expose(label: 'buyerReference')]
#[Exporter\Expose(label: 'buyer_reference')]
private ?string $buyerReference = null;
public function __construct(string $name)

View File

@@ -12,8 +12,8 @@ namespace App\Entity;
use Doctrine\Common\Collections\Collection;
/**
* @method array<Tag> getTags()
* @method int getBreak()
* @method getTags() array
* @method getBreak() int
*/
interface ExportableItem
{

View File

@@ -88,10 +88,6 @@ class InvoiceTemplate implements EntityWithMetaFields
*/
#[ORM\OneToMany(mappedBy: 'template', targetEntity: InvoiceTemplateMeta::class, cascade: ['persist'])]
private Collection $meta;
/**
* @var array<Tax>
*/
private array $taxRates = [];
public function __construct()
{
@@ -272,29 +268,16 @@ class InvoiceTemplate implements EntityWithMetaFields
$this->language = $language;
}
/**
* @param array<Tax> $taxRates
*/
public function setTaxRates(array $taxRates): void
{
$this->taxRates = $taxRates;
}
/**
* @return Tax[]
*/
public function getTaxRates(): array
{
if (\count($this->taxRates) > 0) {
return $this->taxRates;
}
// TODO make me configurable via UI
$tax = new Tax(
TaxType::STANDARD,
$this->vat ?? 0.00,
'vat',
true,
null
'VAT',
$this->vat ?? 0.00
);
return [$tax];

View File

@@ -11,10 +11,6 @@ namespace App\Entity;
use Symfony\Component\Validator\Constraint;
/**
* @method null|string getSection()
* @method void setSection(?string $name)
*/
interface MetaTableTypeInterface
{
/**
@@ -47,8 +43,6 @@ interface MetaTableTypeInterface
/**
* This will merge the current object with the values from the given $meta instance.
* It should NOT update the name or value, but only the form settings.
*
* Settings from the given $meta object will win over the current ones.
*/
public function merge(MetaTableTypeInterface $meta): MetaTableTypeInterface;
@@ -138,16 +132,6 @@ interface MetaTableTypeInterface
*/
public function getOrder(): int;
/**
* FIXME activate with 3.0
*/
//public function setSection(?string $section): void;
/**
* FIXME activate with 3.0
*/
//public function getSection(): ?string;
/**
* Whether true if this field is defined by a plugin, or false if it is a value stored in the database.
*/

View File

@@ -67,7 +67,6 @@ trait MetaTableTypeTrait
*/
private mixed $data = null;
private bool $updated = false;
private ?string $section = null;
public function getName(): ?string
{
@@ -194,11 +193,13 @@ trait MetaTableTypeTrait
public function merge(MetaTableTypeInterface $meta): MetaTableTypeInterface
{
$this->setConstraints($meta->getConstraints());
$this->setIsRequired($meta->isRequired());
$this->setIsVisible($meta->isVisible());
$this->setOptions($meta->getOptions());
$this->setOrder($meta->getOrder());
$this
->setConstraints($meta->getConstraints())
->setIsRequired($meta->isRequired())
->setIsVisible($meta->isVisible())
->setOptions($meta->getOptions())
->setOrder($meta->getOrder())
;
if ($meta->getLabel() !== null) {
$this->setLabel($meta->getLabel());
@@ -208,10 +209,6 @@ trait MetaTableTypeTrait
$this->setType($meta->getType());
}
if ($meta->getSection() !== null) {
$this->setSection($meta->getSection());
}
return $this;
}
@@ -269,16 +266,6 @@ trait MetaTableTypeTrait
}
}
public function setSection(?string $section): void
{
$this->section = $section;
}
public function getSection(): ?string
{
return $this->section;
}
/**
* Whether this field is defined by a plugin or just a value stored in the database.
*/

View File

@@ -13,10 +13,8 @@ final class Tax
{
public function __construct(
private readonly TaxType $type,
private readonly float $rate,
private readonly string $name,
private readonly bool $show,
private readonly ?string $note,
private readonly string $name = 'VAT',
private readonly float $rate = 0.0,
)
{
}
@@ -35,14 +33,4 @@ final class Tax
{
return $this->rate;
}
public function isShow(): bool
{
return $this->rate > 0.0 || $this->show;
}
public function getNote(): ?string
{
return $this->note;
}
}

View File

@@ -0,0 +1,60 @@
<?php
/*
* 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 App\Entity;
class WebhookConfiguration
{
private string $name;
private string $url;
private string $transport;
private string $secret;
private string $authentication;
public function __construct(
string $name,
string $url,
string $transport,
#[\SensitiveParameter]
string $secret,
string $authentication
)
{
$this->name = $name;
$this->url = $url;
$this->transport = $transport;
$this->secret = $secret;
$this->authentication = $authentication;
}
public function getName(): string
{
return $this->name;
}
public function getUrl(): string
{
return $this->url;
}
public function getTransport(): string
{
return $this->transport;
}
public function getSecret(): string
{
return $this->secret;
}
public function getAuthentication(): string
{
return $this->authentication;
}
}

View File

@@ -0,0 +1,32 @@
<?php
/*
* 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 App\Entity;
class WebhookEvent
{
private string $name;
private WebhookConfiguration $configuration;
public function __construct(string $name, WebhookConfiguration $configuration)
{
$this->name = $name;
$this->configuration = $configuration;
}
public function getName(): string
{
return $this->name;
}
public function getConfiguration(): WebhookConfiguration
{
return $this->configuration;
}
}

View File

@@ -14,14 +14,6 @@ use App\Invoice\RendererInterface;
use App\Model\InvoiceDocument;
use Symfony\Contracts\EventDispatcher\Event;
/**
* Triggered right before an invoice is rendered.
*
* You can use this event to:
* - add invoice hydrator
* - read and change invoice model
* - change tax rates
*/
final class InvoicePreRenderEvent extends Event
{
public function __construct(

View File

@@ -14,9 +14,9 @@ use App\Repository\Query\TimesheetQuery;
use Symfony\Component\HttpFoundation\Response;
/**
* FIXME change interface for 3.0
* @method string getType()
* @method bool isInternal()
* TODO change interface for 3.0
* @method getType() string
* @method isInternal() bool
*/
interface ExportRendererInterface
{

View File

@@ -31,10 +31,6 @@ final class InvoiceModelDefaultHydrator implements InvoiceModelHydrator
$subtotal = $calculator->getSubtotal();
$formatter = $model->getFormatter();
$language = $template->getLanguage();
if ($language === null) {
throw new \InvalidArgumentException('InvoiceTemplate needs a language');
}
$taxRows = $calculator->getTaxRows();
$vat = 0.00;
@@ -52,7 +48,7 @@ final class InvoiceModelDefaultHydrator implements InvoiceModelHydrator
'invoice.language' => $language, // since 1.9
'invoice.currency_symbol' => $formatter->getCurrencySymbol($currency),
'invoice.vat' => $vat, // @deprecated, use invoice.tax_rows instead
'invoice.tax_hide' => $model->isHideZeroTax() && $tax === 0.00, // @deprecated, use invoice.tax_rows instead
'invoice.tax_hide' => $model->isHideZeroTax() && $tax === 0.00,
'invoice.tax' => $formatter->getFormattedMoney($tax, $currency), // @deprecated, use invoice.tax_rows instead
'invoice.tax_nc' => $formatter->getFormattedMoney($tax, $currency, false), // @deprecated, use invoice.tax_rows instead
'invoice.tax_plain' => $tax, // @deprecated, use invoice.tax_rows instead
@@ -95,17 +91,11 @@ final class InvoiceModelDefaultHydrator implements InvoiceModelHydrator
];
$values['invoice.tax_rows'] = [];
$counter = 1;
foreach ($taxRows as $taxRow) {
$tax = $taxRow->getTax();
$values['invoice.tax_rows'][] = [
'counter' => $counter++,
'type' => $tax->getType()->value,
'name' => $tax->getName(),
'rate' => $tax->getRate(),
'note' => $tax->getNote(),
'show' => $tax->isShow(),
'currency' => $currency,
'type' => $taxRow->getTax()->getType()->value,
'name' => $taxRow->getTax()->getName(),
'rate' => $taxRow->getTax()->getRate(),
'amount' => $taxRow->getAmount(), // do not format, only available in twig anyway
'base' => $taxRow->getBasePrice(), // do not format, only available in twig anyway
];

View File

@@ -9,9 +9,6 @@
namespace App\Invoice;
use Symfony\Component\DependencyInjection\Attribute\AutoconfigureTag;
#[AutoconfigureTag]
interface InvoiceItemHydrator
{
public function setInvoiceModel(InvoiceModel $model): void;

View File

@@ -9,10 +9,20 @@
namespace App\Invoice;
use App\Activity\ActivityStatisticService;
use App\Customer\CustomerStatisticService;
use App\Entity\Customer;
use App\Entity\ExportableItem;
use App\Entity\InvoiceTemplate;
use App\Entity\User;
use App\Invoice\Hydrator\InvoiceItemDefaultHydrator;
use App\Invoice\Hydrator\InvoiceModelActivityHydrator;
use App\Invoice\Hydrator\InvoiceModelCustomerHydrator;
use App\Invoice\Hydrator\InvoiceModelDefaultHydrator;
use App\Invoice\Hydrator\InvoiceModelIssuerHydrator;
use App\Invoice\Hydrator\InvoiceModelProjectHydrator;
use App\Invoice\Hydrator\InvoiceModelUserHydrator;
use App\Project\ProjectStatisticService;
use App\Repository\Query\InvoiceQuery;
use App\Timesheet\RateCalculator\RateCalculatorMode;
use Symfony\Component\DependencyInjection\Attribute\Exclude;
@@ -33,6 +43,7 @@ final class InvoiceModel
private ?NumberGeneratorInterface $generator = null;
private \DateTimeInterface $invoiceDate;
private ?User $user = null;
private InvoiceFormatter $formatter;
/**
* @var InvoiceModelHydrator[]
*/
@@ -53,13 +64,24 @@ final class InvoiceModel
* @internal use InvoiceModelFactory
*/
public function __construct(
private InvoiceFormatter $formatter,
InvoiceFormatter $formatter,
CustomerStatisticService $customerStatistic,
ProjectStatisticService $projectStatistic,
ActivityStatisticService $activityStatistic,
private readonly Customer $customer,
private readonly InvoiceTemplate $template,
private readonly RateCalculatorMode $rateCalculatorMode
)
{
$this->invoiceDate = new \DateTimeImmutable();
$this->formatter = $formatter;
$this->addModelHydrator(new InvoiceModelDefaultHydrator());
$this->addModelHydrator(new InvoiceModelCustomerHydrator($customerStatistic));
$this->addModelHydrator(new InvoiceModelIssuerHydrator());
$this->addModelHydrator(new InvoiceModelProjectHydrator($projectStatistic));
$this->addModelHydrator(new InvoiceModelActivityHydrator($activityStatistic));
$this->addModelHydrator(new InvoiceModelUserHydrator());
$this->addItemHydrator(new InvoiceItemDefaultHydrator());
}
/**

View File

@@ -9,36 +9,27 @@
namespace App\Invoice;
use App\Activity\ActivityStatisticService;
use App\Customer\CustomerStatisticService;
use App\Entity\Customer;
use App\Entity\InvoiceTemplate;
use App\Project\ProjectStatisticService;
use App\Repository\Query\InvoiceQuery;
use App\Timesheet\RateCalculator\RateCalculatorMode;
use Symfony\Component\DependencyInjection\Attribute\TaggedIterator;
final class InvoiceModelFactory
{
/**
* @param iterable<InvoiceModelHydrator> $modelHydrators
* @param iterable<InvoiceItemHydrator> $itemHydrators
*/
public function __construct(
private readonly RateCalculatorMode $rateCalculatorMode,
#[TaggedIterator(InvoiceModelHydrator::class)]
private readonly iterable $modelHydrators,
#[TaggedIterator(InvoiceItemHydrator::class)]
private readonly iterable $itemHydrators,
private readonly CustomerStatisticService $customerStatisticService,
private readonly ProjectStatisticService $projectStatisticService,
private readonly ActivityStatisticService $activityStatisticService,
private readonly RateCalculatorMode $rateCalculatorMode
) {
}
public function createModel(InvoiceFormatter $formatter, Customer $customer, InvoiceTemplate $template, InvoiceQuery $query): InvoiceModel
{
$model = new InvoiceModel($formatter, $customer, $template, $this->rateCalculatorMode);
foreach ($this->modelHydrators as $modelHydrator) {
$model->addModelHydrator($modelHydrator);
}
foreach ($this->itemHydrators as $itemHydrator) {
$model->addItemHydrator($itemHydrator);
}
$model = new InvoiceModel($formatter, $this->customerStatisticService, $this->projectStatisticService, $this->activityStatisticService, $customer, $template, $this->rateCalculatorMode);
$model->setQuery($query);

View File

@@ -9,9 +9,6 @@
namespace App\Invoice;
use Symfony\Component\DependencyInjection\Attribute\AutoconfigureTag;
#[AutoconfigureTag]
interface InvoiceModelHydrator
{
public function hydrate(InvoiceModel $model): array;

View File

@@ -16,14 +16,14 @@ class CustomerQuery extends BaseQuery implements VisibilityInterface
public const CUSTOMER_ORDER_ALLOWED = [
'name',
'description' => 'comment',
'country',
'number',
'country', 'number',
'homepage',
'email',
'mobile',
'fax',
'phone',
'currency',
'address',
'contact',
'company',
'vat_id',

View File

@@ -0,0 +1,64 @@
<?php
/*
* 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 App\Serializer;
use JMS\Serializer\Context;
use JMS\Serializer\DeserializationContext;
use JMS\Serializer\SerializationContext;
use JMS\Serializer\Serializer as CoreSerializer;
use JMS\Serializer\SerializerInterface as CoreSerializerInterface;
class Serializer implements SerializerInterface
{
public function __construct(private readonly CoreSerializerInterface $serializer)
{
}
private function prepareContext(Context $jmsContext, array $context = []): void
{
$groups = ['Default'];
if (\array_key_exists('groups', $context)) {
$groups = $context['groups'];
}
$jmsContext->setGroups($groups);
$jmsContext->enableMaxDepthChecks();
}
public function toArray(mixed $data, array $context = []): array
{
$jmsContext = SerializationContext::create();
$this->prepareContext($jmsContext, $context);
if ($this->serializer instanceof CoreSerializer) {
return $this->serializer->toArray($data, $jmsContext);
}
$json = $this->serializer->serialize($data, 'json', $jmsContext);
return json_decode($json, true, 512, JSON_THROW_ON_ERROR);
}
public function serialize(mixed $data, string $format, array $context = []): string
{
$jmsContext = SerializationContext::create();
$this->prepareContext($jmsContext, $context);
return $this->serializer->serialize($data, $format, $jmsContext);
}
public function deserialize(mixed $data, string $type, string $format, array $context = []): mixed
{
$jmsContext = DeserializationContext::create();
$this->prepareContext($jmsContext, $context);
return $this->serializer->deserialize($data, $type, $format, $jmsContext);
}
}

View File

@@ -0,0 +1,17 @@
<?php
/*
* 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 App\Serializer;
use Symfony\Component\Serializer\SerializerInterface as BaseSerializerInterface;
interface SerializerInterface extends BaseSerializerInterface
{
public function toArray(mixed $data, array $context = []): array;
}

View File

@@ -0,0 +1,109 @@
<?php
/*
* 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 App\Webhook;
use App\Event\ActivityCreatePostEvent;
use App\Event\ActivityDeleteEvent;
use App\Event\ActivityUpdatePostEvent;
use App\Event\CustomerCreatePostEvent;
use App\Event\CustomerDeleteEvent;
use App\Event\CustomerUpdatePostEvent;
use App\Event\InvoiceCreatedEvent;
use App\Event\InvoiceDeleteEvent;
use App\Event\ProjectCreatePostEvent;
use App\Event\ProjectDeleteEvent;
use App\Event\ProjectUpdatePostEvent;
use App\Event\TeamCreatePostEvent;
use App\Event\TeamDeleteEvent;
use App\Event\TeamUpdatePostEvent;
use App\Event\TimesheetCreatePostEvent;
use App\Event\TimesheetStopPostEvent;
use App\Event\TimesheetUpdatePostEvent;
use App\Event\UserCreatePostEvent;
use App\Event\UserDeletePostEvent;
use App\Event\UserUpdatePostEvent;
use App\Webhook\Attribute\AsWebhook;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\ExpressionLanguage\ExpressionLanguage;
use Symfony\Contracts\EventDispatcher\Event;
final class WebhookListener implements EventSubscriberInterface
{
public function __construct(private readonly WebhookService $webhookService)
{
}
public static function getSubscribedEvents(): array
{
return [
ActivityCreatePostEvent::class => ['triggerWebhook', 1000],
ActivityDeleteEvent::class => ['triggerWebhook', 1000],
ActivityUpdatePostEvent::class => ['triggerWebhook', 1000],
CustomerCreatePostEvent::class => ['triggerWebhook', 1000],
CustomerDeleteEvent::class => ['triggerWebhook', 1000],
CustomerUpdatePostEvent::class => ['triggerWebhook', 1000],
InvoiceCreatedEvent::class => ['triggerWebhook', 1000],
InvoiceDeleteEvent::class => ['triggerWebhook', 1000],
ProjectCreatePostEvent::class => ['triggerWebhook', 1000],
ProjectDeleteEvent::class => ['triggerWebhook', 1000],
ProjectUpdatePostEvent::class => ['triggerWebhook', 1000],
TimesheetCreatePostEvent::class => ['triggerWebhook', 1000],
TimesheetStopPostEvent::class => ['triggerWebhook', 1000],
TimesheetUpdatePostEvent::class => ['triggerWebhook', 1000],
UserCreatePostEvent::class => ['triggerWebhook', 1000],
UserDeletePostEvent::class => ['triggerWebhook', 1000],
UserUpdatePostEvent::class => ['triggerWebhook', 1000],
TeamCreatePostEvent::class => ['triggerWebhook', 1000],
TeamDeleteEvent::class => ['triggerWebhook', 1000],
TeamUpdatePostEvent::class => ['triggerWebhook', 1000],
];
}
public function triggerWebhook(Event $event): void
{
$attribute = $this->findAttribute($event);
if ($attribute === null) {
return;
}
if (!$this->webhookService->hasWebhook($attribute->name)) {
return;
}
$expressionLanguage = new ExpressionLanguage();
$parsed = $expressionLanguage->parse($attribute->payload, ['object']);
$payload = $parsed->getNodes()->evaluate([], ['object' => $event]);
$this->webhookService->trigger($attribute->name, $payload);
}
private function findAttribute(Event $event): ?AsWebhook
{
try {
$reflectionClass = new \ReflectionClass($event);
$attributes = $reflectionClass->getAttributes(AsWebhook::class);
if (\count($attributes) === 1) {
$attribute = $attributes[0];
$args = $attribute->getArguments();
return new AsWebhook($args['name'], $args['description'], $args['payload']);
}
} catch (\Exception $ex) {
}
return null;
}
}

View File

@@ -0,0 +1,58 @@
<?php
/*
* 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 App\Webhook;
use App\Entity\WebhookConfiguration;
use App\Entity\WebhookEvent;
use App\Serializer\SerializerInterface;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\RemoteEvent\RemoteEvent;
use Symfony\Component\Webhook\Messenger\SendWebhookMessage;
use Symfony\Component\Webhook\Subscriber;
final class WebhookService
{
public function __construct(
private readonly SerializerInterface $serializer,
private readonly MessageBusInterface $bus
)
{
}
public function hasWebhook(string $name): bool
{
return $name === 'user.created';
}
public function trigger(string $name, mixed $payload): void
{
$content = $this->serializer->toArray($payload, ['groups' => ['Default', 'Entity', 'Expanded']]);
foreach ($this->findEventsByName($name) as $event) {
$configuration = $event->getConfiguration();
$subscriber = new Subscriber($configuration->getUrl(), $configuration->getSecret());
$remoteEvent = new RemoteEvent($name, $name . '_' . uniqid(microtime()), $content);
$message = new SendWebhookMessage($subscriber, $remoteEvent);
$this->bus->dispatch($message);
}
}
public function findEventsByName(string $name): array
{
$url = 'TODO';
$secret = 'TODO';
$configuration = new WebhookConfiguration('n8n', $url, 'json', $secret, 'bearer');
return [
new WebhookEvent($name, $configuration),
];
}
}

View File

@@ -467,6 +467,18 @@
"config/packages/mailer.yaml"
]
},
"symfony/messenger": {
"version": "6.4",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "6.0",
"ref": "d8936e2e2230637ef97e5eecc0eea074eecae58b"
},
"files": [
"config/packages/messenger.yaml"
]
},
"symfony/mime": {
"version": "v4.3.3"
},
@@ -598,6 +610,18 @@
"ref": "6bdfa1a95f6b2e677ab985cd1af2eae35d62e0f6"
}
},
"symfony/webhook": {
"version": "6.4",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "6.3",
"ref": "0bc853a74c98e448116ba53e118a7b6b3cdf9a03"
},
"files": [
"config/routes/webhook.yaml"
]
},
"symfony/webpack-encore-bundle": {
"version": "1.0",
"recipe": {

View File

@@ -8,7 +8,6 @@
'back': path('admin_activity')
} %}
{% embed formEditTemplate with formOptions %}
{% import "macros/form_helper.html.twig" as form_helper %}
{% block form_body %}
<div class="row">
<div class="col-md-9">
@@ -50,18 +49,6 @@
</div>
</div>
{% endif %}
{{ form_helper.metafields(form, null) }}
{% embed '@theme/embeds/collapsible.html.twig' with {id: 'activity_invoice_settings'} %}
{% import "macros/widgets.html.twig" as widgets %}
{% import "macros/form_helper.html.twig" as form_helper %}
{% block title %}{{ 'invoices'|trans }}{% endblock %}
{% block body %}
{% if form.invoiceText is defined %}
{{ form_row(form.invoiceText) }}
{% endif %}
{{ form_helper.metafields(form, 'invoice') }}
{% endblock %}
{% endembed %}
<div class="row">
<div class="col-md-6">
{{ form_row(form.visible) }}
@@ -70,6 +57,18 @@
{{ form_row(form.billable) }}
</div>
</div>
{% if form.metaFields is defined and form.metaFields is not empty %}
{{ form_row(form.metaFields) }}
{% endif %}
{% embed '@theme/embeds/collapsible.html.twig' with {id: 'activity_invoice_settings'} %}
{% import "macros/widgets.html.twig" as widgets %}
{% block title %}{{ 'invoices'|trans }}{% endblock %}
{% block body %}
{% if form.invoiceText is defined %}
{{ form_row(form.invoiceText) }}
{% endif %}
{% endblock %}
{% endembed %}
{{ form_rest(form) }}
{% endblock %}
{% endembed %}

View File

@@ -8,7 +8,6 @@
'back': path('admin_customer')
} %}
{% embed formEditTemplate with formOptions %}
{% import "macros/form_helper.html.twig" as form_helper %}
{% block form_body %}
<div class="row">
<div class="col-md-9">
@@ -76,10 +75,11 @@
</div>
</div>
{% endif %}
{{ form_helper.metafields(form, null) }}
{% if form.metaFields is defined and form.metaFields is not empty %}
{{ form_row(form.metaFields) }}
{% endif %}
{% embed '@theme/embeds/collapsible.html.twig' with {id: 'customer_address_settings'} %}
{% import "macros/widgets.html.twig" as widgets %}
{% import "macros/form_helper.html.twig" as form_helper %}
{% block title %}{{ 'address'|trans }}{% endblock %}
{% block body %}
<div class="row">
@@ -125,12 +125,11 @@
{{ form_row(form.timezone) }}
</div>
</div>
{{ form_helper.metafields(form, 'address') }}
{% endblock %}
{% endembed %}
{% embed '@theme/embeds/collapsible.html.twig' with {id: 'customer_invoice_settings'} %}
{% import "macros/widgets.html.twig" as widgets %}
{% import "macros/form_helper.html.twig" as form_helper %}
{% block title %}{{ 'invoices'|trans }}{% endblock %}
{% block body %}
{% if form.buyerReference is defined %}
@@ -142,7 +141,6 @@
{% if form.invoiceText is defined %}
{{ form_row(form.invoiceText) }}
{% endif %}
{{ form_helper.metafields(form, 'invoice') }}
{% endblock %}
{% endembed %}
<div class="row">

View File

@@ -17,8 +17,8 @@
{{ entry.vatId }}
{% elseif column == 'contact' %}
{{ entry.contact }}
{% elseif column == 'city' %}
{{ entry.postcode }} {{ entry.city }}
{% elseif column == 'address' %}
{{ entry.address }}
{% elseif column == 'country' %}
{{ entry.country|country_name }}
{% elseif column == 'currency' %}

View File

@@ -474,9 +474,7 @@
{%- block meta_fields_collection_widget -%}
{% for meta in form|sort((a, b) => a.vars.data.order <=> b.vars.data.order) %}
{% if not meta.rendered %}
{{ form_row(meta.value, {'row_attr': {'class': 'mb-3 ' ~ form.parent.vars.name ~ '_row_' ~ form.vars.name ~ '_' ~ meta.vars.name}}) }}
{% endif %}
{{ form_row(meta.value, {'row_attr': {'class': 'mb-3 ' ~ form.parent.vars.name ~ '_row_' ~ form.vars.name ~ '_' ~ meta.vars.name}}) }}
{% endfor %}
{%- endblock meta_fields_collection_widget %}

View File

@@ -135,25 +135,20 @@ mpdf-->
{% endfor %}
</tbody>
<tfoot>
{% if not invoice['invoice.tax_hide'] %}
<tr>
<td colspan="4" class="text-right">
{{ 'invoice.subtotal'|trans }}
</td>
<td class="last text-right">{{ invoice['invoice.subtotal'] }}</td>
</tr>
{% for taxRow in invoice['invoice.tax_rows'] %}
{% if taxRow['show'] %}
<tr>
<td colspan="4" class="text-right">
{{ taxRow['name']|trans }} ({{ taxRow['rate'] }}%)
{% if taxRow['note'] is not null %}
<sup>[{{ taxRow['counter'] }}]</sup>
{% endif %}
</td>
<td class="last text-right">{{ taxRow['amount']|money(taxRow['currency']) }}</td>
</tr>
{% endif %}
{% endfor %}
<tr>
<td colspan="4" class="text-right">
{{ 'invoice.tax'|trans }} ({{ invoice['invoice.vat'] }}%)
</td>
<td class="last text-right">{{ invoice['invoice.tax'] }}</td>
</tr>
{% endif %}
<tr>
<td colspan="4" class="text-right text-nowrap">
<strong>{{ 'invoice.total'|trans }}</strong>
@@ -165,17 +160,10 @@ mpdf-->
</tfoot>
</table>
{% for taxRow in invoice['invoice.tax_rows'] %}
{% if taxRow['note'] is not null %}
<p>
<sup>[{{ taxRow['counter'] }}]</sup>
{{ taxRow['note']|trans }}
</p>
{% endif %}
{% endfor %}
{% if invoice['template.payment_terms'] is not empty %}
{{ invoice['template.payment_terms']|md2html }}
<div class="paymentTerms">
{{ invoice['template.payment_terms']|md2html }}
</div>
{% endif %}
</body>
</html>

View File

@@ -14,7 +14,7 @@
<div class="row">
<div class="col-xs-12">
<h2 class="page-header">
<span>{{ invoice['template.title'] }}</span>
<span contenteditable="true">{{ invoice['template.title'] }}</span>
</h2>
</div>
</div>
@@ -22,7 +22,7 @@
<div class="row">
<div class="col-sm-5">
{{ 'invoice.to'|trans }}
<address>
<address contenteditable="true">
<strong>{{ invoice['customer.company']|default(invoice['customer.name']) }}</strong><br>
{{ invoice['customer.address']|nl2br }}
{% set country = invoice['customer.country']|country_name(invoice['invoice.language']) %}
@@ -46,7 +46,7 @@
<div class="col-sm-2"></div>
<div class="col-sm-5">
{{ 'invoice.from'|trans }}
<address>
<address contenteditable="true">
<strong>{{ invoice['template.company'] }}</strong><br>
{{ invoice['template.address']|trim|nl2br }}
{% if invoice['template.country'] is not null %}
@@ -66,7 +66,7 @@
<div class="row">
<div class="col-sm-5">
<p>
<p contenteditable="true">
<strong>{{ 'date'|trans }}:</strong>
{{ invoice['invoice.date'] }}
@@ -98,7 +98,7 @@
{% for invoiceLineItem in entries %}
<tr>
<td nowrap class="text-nowrap">{{ invoiceLineItem['entry.begin'] }}</td>
<td>
<td contenteditable="true">
{% if invoiceLineItem['entry.description'] is not empty %}
{{ invoiceLineItem['entry.description']|nl2br }}
{% else %}
@@ -115,25 +115,20 @@
{% endfor %}
</tbody>
<tfoot>
{% if not invoice['invoice.tax_hide'] %}
<tr>
<td colspan="4" class="text-end">
{{ 'invoice.subtotal'|trans }}
</td>
<td class="text-end">{{ invoice['invoice.subtotal'] }}</td>
</tr>
{% for taxRow in invoice['invoice.tax_rows'] %}
{% if taxRow['show'] %}
<tr>
<td colspan="4" class="text-end">
{{ taxRow['name']|trans }} ({{ taxRow['rate'] }}%)
{% if taxRow['note'] is not null %}
<sup>[{{ taxRow['counter'] }}]</sup>
{% endif %}
</td>
<td class="last text-right">{{ taxRow['amount']|money(taxRow['currency']) }}</td>
</tr>
{% endif %}
{% endfor %}
<tr>
<td colspan="4" class="text-end">
{{ 'invoice.tax'|trans }} ({{ invoice['invoice.vat'] }}%)
</td>
<td class="text-end">{{ invoice['invoice.tax'] }}</td>
</tr>
{% endif %}
<tr>
<td colspan="4" class="text-end text-nowrap">
<strong>{{ 'invoice.total'|trans }}</strong>
@@ -149,16 +144,10 @@
<div class="row">
<div class="col-xs-12">
{% for taxRow in invoice['invoice.tax_rows'] %}
{% if taxRow['note'] is not null %}
<p>
<sup>[{{ taxRow['counter'] }}]</sup>
{{ taxRow['note']|trans }}
</p>
{% endif %}
{% endfor %}
{% if invoice['template.payment_terms'] is not empty %}
{{ invoice['template.payment_terms']|md2html }}
<div contenteditable="true" class="paymentTerms">
{{ invoice['template.payment_terms']|md2html }}
</div>
{% endif %}
</div>
</div>

View File

@@ -150,25 +150,20 @@ mpdf-->
{% endfor %}
</tbody>
<tfoot>
{% if not invoice['invoice.tax_hide'] %}
<tr>
<td colspan="3" class="text-right">
{{ 'invoice.subtotal'|trans }}
</td>
<td class="last text-right">{{ invoice['invoice.subtotal'] }}</td>
</tr>
{% for taxRow in invoice['invoice.tax_rows'] %}
{% if taxRow['show'] %}
<tr>
<td colspan="3" class="text-right">
{{ taxRow['name']|trans }} ({{ taxRow['rate'] }}%)
{% if taxRow['note'] is not null %}
<sup>[{{ taxRow['counter'] }}]</sup>
{% endif %}
</td>
<td class="last text-right">{{ taxRow['amount']|money(taxRow['currency']) }}</td>
</tr>
{% endif %}
{% endfor %}
<tr>
<td colspan="3" class="text-right">
{{ 'invoice.tax'|trans }} ({{ invoice['invoice.vat'] }}%)
</td>
<td class="last text-right">{{ invoice['invoice.tax'] }}</td>
</tr>
{% endif %}
<tr>
<td colspan="3" class="text-right text-nowrap">
<strong>{{ 'invoice.total'|trans }}</strong>
@@ -180,17 +175,10 @@ mpdf-->
</tfoot>
</table>
{% for taxRow in invoice['invoice.tax_rows'] %}
{% if taxRow['note'] is not null %}
<p>
<sup>[{{ taxRow['counter'] }}]</sup>
{{ taxRow['note']|trans }}
</p>
{% endif %}
{% endfor %}
{% if invoice['template.payment_terms'] is not empty %}
{{ invoice['template.payment_terms']|md2html }}
<div class="paymentTerms">
{{ invoice['template.payment_terms']|md2html }}
</div>
{% endif %}
</body>
</html>

View File

@@ -1,7 +0,0 @@
{% macro metafields(form, section) %}
{% for meta in form.metaFields|sort((a, b) => a.vars.data.order <=> b.vars.data.order) %}
{% if meta.vars.data.section is same as (section) %}
{{ form_row(meta.value, {'row_attr': {'class': 'mb-3 ' ~ form.vars.name ~ '_row_' ~ form.metaFields.vars.name ~ '_' ~ meta.vars.name}}) }}
{% endif %}
{% endfor %}
{% endmacro %}

View File

@@ -8,7 +8,6 @@
'back': path('admin_project')
} %}
{% embed formEditTemplate with formOptions %}
{% import "macros/form_helper.html.twig" as form_helper %}
{% block form_body %}
<div class="row">
<div class="col-md-9">
@@ -59,10 +58,11 @@
</div>
</div>
{% endif %}
{{ form_helper.metafields(form, null) }}
{% if form.metaFields is defined and form.metaFields is not empty %}
{{ form_row(form.metaFields) }}
{% endif %}
{% embed '@theme/embeds/collapsible.html.twig' with {id: 'project_invoice_settings'} %}
{% import "macros/widgets.html.twig" as widgets %}
{% import "macros/form_helper.html.twig" as form_helper %}
{% block title %}{{ 'invoices'|trans }}{% endblock %}
{% block body %}
<div class="row">
@@ -76,7 +76,6 @@
{% if form.invoiceText is defined %}
{{ form_row(form.invoiceText) }}
{% endif %}
{{ form_helper.metafields(form, 'invoice') }}
{% endblock %}
{% endembed %}
<div class="row">

View File

@@ -38,7 +38,6 @@ abstract class AbstractMetaEntityTestCase extends TestCase
self::assertFalse($sut->isVisible());
self::assertFalse($sut->isRequired());
self::assertEquals(0, $sut->getOrder());
self::assertNull($sut->getSection());
}
public function testSetterAndGetter(): void
@@ -84,11 +83,6 @@ abstract class AbstractMetaEntityTestCase extends TestCase
$entity = $this->getEntity();
self::assertInstanceOf(MetaTableTypeInterface::class, $sut->setEntity($entity));
self::assertSame($entity, $sut->getEntity());
$sut->setSection('foo');
self::assertEquals('foo', $sut->getSection());
$sut->setSection(null);
self::assertNull($sut->getSection());
}
public function testMerge(): void
@@ -96,19 +90,18 @@ abstract class AbstractMetaEntityTestCase extends TestCase
$entity1 = $this->getEntity();
$entity2 = $this->getEntity();
$meta1 = $this->getMetaEntity();
$meta1->setName('foo');
$meta1->setValue('bar');
$meta1->setType('blub');
$meta1->setEntity($entity1);
$meta1->setConstraints([new NotNull()]);
$meta1->setOptions(['foo' => 'bar', 'bar' => 'foo']);
$meta1->setSection('hello');
$meta1
->setName('foo')
->setValue('bar')
->setType('blub')
->setEntity($entity1)
->setConstraints([new NotNull()])
->setOptions(['foo' => 'bar', 'bar' => 'foo'])
;
self::assertEquals('foo', $meta1->getName());
self::assertEquals('bar', $meta1->getValue());
self::assertEquals('blub', $meta1->getType());
self::assertEquals('foo', $meta1->getLabel());
self::assertEquals('hello', $meta1->getSection());
self::assertFalse($meta1->isRequired());
self::assertFalse($meta1->isVisible());
self::assertSame($entity1, $meta1->getEntity());
@@ -116,17 +109,18 @@ abstract class AbstractMetaEntityTestCase extends TestCase
self::assertCount(2, $meta1->getOptions());
$meta2 = $this->getMetaEntity();
$meta2->setName('foo2');
$meta2->setLabel('TRALALA');
$meta2->setValue('bar2');
$meta2->setType('blub2');
$meta2->setSection('world');
$meta2->setEntity($entity2);
$meta2->setIsRequired(true);
$meta2->setIsVisible(true);
$meta2->setOrder(93);
$meta2->setConstraints([new NotBlank(), new Length(['min' => 1])]);
$meta2->setOptions(['foo1' => 'bar1']);
$meta2
->setName('foo2')
->setLabel('TRALALA')
->setValue('bar2')
->setType('blub2')
->setEntity($entity2)
->setIsRequired(true)
->setIsVisible(true)
->setOrder(93)
->setConstraints([new NotBlank(), new Length(['min' => 1])])
->setOptions(['foo1' => 'bar1'])
;
self::assertInstanceOf(MetaTableTypeInterface::class, $meta1->merge($meta2));
@@ -134,7 +128,6 @@ abstract class AbstractMetaEntityTestCase extends TestCase
self::assertEquals('bar', $meta1->getValue());
self::assertEquals('blub2', $meta1->getType());
self::assertEquals('TRALALA', $meta1->getLabel());
self::assertEquals('world', $meta1->getSection());
self::assertEquals(93, $meta1->getOrder());
self::assertTrue($meta1->isRequired());
self::assertTrue($meta1->isVisible());

View File

@@ -280,7 +280,7 @@ zip 12345 looney toon', $sut->getFormattedAddress());
['visible', 'boolean'],
['comment', 'string'],
['billable', 'boolean'],
['buyerReference', 'string'],
['buyer_reference', 'string'],
];
self::assertCount(\count($expected), $columns);

View File

@@ -13,8 +13,6 @@ use App\Entity\Customer;
use App\Entity\CustomerMeta;
use App\Entity\InvoiceTemplate;
use App\Entity\InvoiceTemplateMeta;
use App\Entity\Tax;
use App\Entity\TaxType;
use Doctrine\Common\Collections\Collection;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase;
@@ -46,48 +44,6 @@ class InvoiceTemplateTest extends TestCase
self::assertNull($sut->getMetaField('foo'));
}
public function testTaxRates(): void
{
$sut = new InvoiceTemplate();
$rates = $sut->getTaxRates();
self::assertCount(1, $rates);
self::assertEquals(TaxType::STANDARD, $rates[0]->getType());
self::assertEquals(0.00, $rates[0]->getRate());
self::assertEquals('vat', $rates[0]->getName());
self::assertNull($rates[0]->getNote());
$tax1 = new Tax(
TaxType::REVERSE,
$this->vat ?? 0.00,
'tax.name.reverse_charge',
true,
null
);
$tax2 = new Tax(
TaxType::EXEMPT,
$this->vat ?? 0.00,
'tax.name.exempt',
true,
null
);
$sut->setTaxRates([$tax1, $tax2]);
$rates = $sut->getTaxRates();
self::assertCount(2, $rates);
self::assertEquals(TaxType::REVERSE, $rates[0]->getType());
self::assertEquals(0.00, $rates[0]->getRate());
self::assertEquals('tax.name.reverse_charge', $rates[0]->getName());
self::assertNull($rates[0]->getNote());
self::assertEquals(TaxType::EXEMPT, $rates[1]->getType());
self::assertEquals(0.00, $rates[1]->getRate());
self::assertEquals('tax.name.exempt', $rates[1]->getName());
self::assertNull($rates[1]->getNote());
}
public function testSetterAndGetter(): void
{
$sut = new InvoiceTemplate();

View File

@@ -5,7 +5,7 @@
<head>
<meta charset="utf-8">
<style type="text/css">
{{ encore_entry_css_source('invoice-pdf')|raw }}
{{ encore_entry_css_source('invoice')|raw }}
</style>
</head>
<body class="invoice_print">

View File

@@ -11,13 +11,6 @@ namespace App\Tests\Mocks;
use App\Activity\ActivityStatisticService;
use App\Customer\CustomerStatisticService;
use App\Invoice\Hydrator\InvoiceItemDefaultHydrator;
use App\Invoice\Hydrator\InvoiceModelActivityHydrator;
use App\Invoice\Hydrator\InvoiceModelCustomerHydrator;
use App\Invoice\Hydrator\InvoiceModelDefaultHydrator;
use App\Invoice\Hydrator\InvoiceModelIssuerHydrator;
use App\Invoice\Hydrator\InvoiceModelProjectHydrator;
use App\Invoice\Hydrator\InvoiceModelUserHydrator;
use App\Invoice\InvoiceModelFactory;
use App\Project\ProjectStatisticService;
use App\Timesheet\RateCalculator\DecimalRateCalculator;
@@ -32,19 +25,8 @@ class InvoiceModelFactoryFactory extends AbstractMockFactory
$projectStatistic = $this->getMockBuilder(ProjectStatisticService::class)->disableOriginalConstructor()->getMock();
/** @var ActivityStatisticService $activityStatistic */
$activityStatistic = $this->getMockBuilder(ActivityStatisticService::class)->disableOriginalConstructor()->getMock();
$rateMode = new DecimalRateCalculator();
$modelHydrators = [
new InvoiceModelDefaultHydrator(),
new InvoiceModelCustomerHydrator($customerStatistic),
new InvoiceModelIssuerHydrator(),
new InvoiceModelProjectHydrator($projectStatistic),
new InvoiceModelActivityHydrator($activityStatistic),
new InvoiceModelUserHydrator(),
];
$itemHydrators = [
new InvoiceItemDefaultHydrator(),
];
return new InvoiceModelFactory(new DecimalRateCalculator(), $modelHydrators, $itemHydrators);
return new InvoiceModelFactory($customerStatistic, $projectStatistic, $activityStatistic, $rateMode);
}
}

View File

@@ -2046,26 +2046,6 @@
<source>payment_account_name</source>
<target state="translated">Titular del compte o targeta</target>
</trans-unit>
<trans-unit id="QnN_ONc" resname="invoice_tax_type" xml:space="preserve">
<source>invoice_tax_type</source>
<target state="translated">Estat fiscal</target>
</trans-unit>
<trans-unit id="Mi7y8Q3" resname="tax.type.exempt" xml:space="preserve">
<source>tax.type.exempt</source>
<target state="translated">Exempt</target>
</trans-unit>
<trans-unit id="lU1Njcy" resname="tax.type.standard" xml:space="preserve">
<source>tax.type.standard</source>
<target state="translated">Sotmès a impostos</target>
</trans-unit>
<trans-unit id="E470U8W" resname="tax.type.reverse" xml:space="preserve">
<source>tax.type.reverse</source>
<target state="translated">Inversió del subjecte passiu</target>
</trans-unit>
<trans-unit id="hi31a3v" resname="tax.note.reverse_charge" xml:space="preserve">
<source>tax.note.reverse_charge</source>
<target state="translated">Impost a pagar en règim d’inversió del subjecte passiu</target>
</trans-unit>
</body>
</file>
</xliff>

View File

@@ -2046,26 +2046,6 @@
<source>electronic_invoice</source>
<target>E-Rechnung</target>
</trans-unit>
<trans-unit id="QnN_ONc" resname="invoice_tax_type">
<source>invoice_tax_type</source>
<target>Steuerstatus</target>
</trans-unit>
<trans-unit id="Mi7y8Q3" resname="tax.type.exempt">
<source>tax.type.exempt</source>
<target>Ausgenommen</target>
</trans-unit>
<trans-unit id="lU1Njcy" resname="tax.type.standard">
<source>tax.type.standard</source>
<target>Steuerpflichtig</target>
</trans-unit>
<trans-unit id="E470U8W" resname="tax.type.reverse">
<source>tax.type.reverse</source>
<target>Umkehrung der Steuerschuld</target>
</trans-unit>
<trans-unit id="hi31a3v" resname="tax.note.reverse_charge">
<source>tax.note.reverse_charge</source>
<target>Steuer zahlbar auf Reverse-Charge-Basis</target>
</trans-unit>
</body>
</file>
</xliff>

View File

@@ -2046,26 +2046,6 @@
<source>electronic_invoice</source>
<target>E-Invoice</target>
</trans-unit>
<trans-unit id="QnN_ONc" resname="invoice_tax_type">
<source>invoice_tax_type</source>
<target>Tax status</target>
</trans-unit>
<trans-unit id="Mi7y8Q3" resname="tax.type.exempt">
<source>tax.type.exempt</source>
<target>Exempt</target>
</trans-unit>
<trans-unit id="lU1Njcy" resname="tax.type.standard">
<source>tax.type.standard</source>
<target>Taxable</target>
</trans-unit>
<trans-unit id="E470U8W" resname="tax.type.reverse">
<source>tax.type.reverse</source>
<target>Reverse charge</target>
</trans-unit>
<trans-unit id="hi31a3v" resname="tax.note.reverse_charge">
<source>tax.note.reverse_charge</source>
<target>Tax to be paid on reverse charge basis</target>
</trans-unit>
</body>
</file>
</xliff>

View File

@@ -2046,26 +2046,6 @@
<source>electronic_invoice</source>
<target state="final">Fattura elettronica</target>
</trans-unit>
<trans-unit id="QnN_ONc" resname="invoice_tax_type" xml:space="preserve" approved="yes">
<source>invoice_tax_type</source>
<target state="final">Stato fiscale</target>
</trans-unit>
<trans-unit id="Mi7y8Q3" resname="tax.type.exempt" xml:space="preserve" approved="yes">
<source>tax.type.exempt</source>
<target state="final">Esonera</target>
</trans-unit>
<trans-unit id="lU1Njcy" resname="tax.type.standard" xml:space="preserve" approved="yes">
<source>tax.type.standard</source>
<target state="final">Tassabile</target>
</trans-unit>
<trans-unit id="E470U8W" resname="tax.type.reverse" xml:space="preserve" approved="yes">
<source>tax.type.reverse</source>
<target state="final">Addebito inverso</target>
</trans-unit>
<trans-unit id="hi31a3v" resname="tax.note.reverse_charge" xml:space="preserve" approved="yes">
<source>tax.note.reverse_charge</source>
<target state="final">Tassa da pagare in base all'addebito inverso</target>
</trans-unit>
</body>
</file>
</xliff>

View File

@@ -310,10 +310,6 @@
<source>copy_teams_on_create</source>
<target state="translated">Hereta els equips de l'usuari connectat en crear noves entrades</target>
</trans-unit>
<trans-unit id="6AaVxB2" resname="invoice.rounding_mode" approved="no" xml:space="preserve">
<source>invoice.rounding_mode</source>
<target state="translated">Mode d’arrodoniment</target>
</trans-unit>
</body>
</file>
</xliff>

View File

@@ -310,10 +310,6 @@
<source>activity.number_format</source>
<target state="translated">Formato del número de actividad</target>
</trans-unit>
<trans-unit id="6AaVxB2" resname="invoice.rounding_mode" approved="no" xml:space="preserve">
<source>invoice.rounding_mode</source>
<target state="translated">Modo de redondeo</target>
</trans-unit>
</body>
</file>
</xliff>