Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .env
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ APP_WEEK_GOAL_LOW=25.0
APP_WEEK_GOAL_HIGH=34.5
APP_INVOICE_SUPPLIER_ACCOUNT=APP_INVOICE_SUPPLIER_ACCOUNT
# The receiver account value ("ITK Dev Ekstern") required on external invoices
# before they can be recorded. Leave empty to disable the check.
# before they can be recorded. Also autoselected on the invoice form when an
# external client is chosen. Leave empty to disable the check and the autoselect.
APP_INVOICE_EXTERNAL_RECEIVER_ACCOUNT=
APP_INVOICE_DESCRIPTION_TEMPLATE="Spørgsmål vedrørende fakturaen rettes til %name%, %email%."
APP_PROJECT_BILLING_DEFAULT_DESCRIPTION=
Expand Down
1 change: 1 addition & 0 deletions .env.test
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ PANTHER_ERROR_SCREENSHOT_DIR=./var/error-screenshots
DATABASE_URL="mysql://root:password@mariadb:3306/db_test?serverVersion=10.9.3-MariaDB&charset=utf8mb4"

APP_INVOICE_SUPPLIER_ACCOUNT=1111
APP_INVOICE_EXTERNAL_RECEIVER_ACCOUNT=ACC002
APP_PROJECT_BILLING_DEFAULT_DESCRIPTION="Beskrivelse"

APP_API_KEY="test-api-key"
Expand Down
45 changes: 45 additions & 0 deletions .woodpecker/prod_economics.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ steps:
- itkdev-docker-compose-server run --rm phpfpm bin/console messenger:setup-transports
- itkdev-docker-compose-server run --rm phpfpm bin/console cache:clear
cron:
# Modified syncs
sync-modified:
minute: "*/15"
hour: "*"
Expand All @@ -41,3 +42,47 @@ steps:
month: "*"
weekday: "*"
job: "itkdev-docker-compose-server exec phpfpm bin/console app:data-providers:sync-deleted"
# Nightly sync deleted for the last week
sync-deleted-week:
minute: "50"
hour: "2"
day: "*"
month: "*"
weekday: "*"
job: "itkdev-docker-compose-server exec phpfpm bin/console app:data-providers:sync-deleted --interval=P1W"
# Nightly full syncs
full-sync-projects:
minute: "0"
hour: "2"
day: "*"
month: "*"
weekday: "*"
job: "itkdev-docker-compose-server exec phpfpm bin/console app:data-providers:sync -j -p -d"
full-sync-workers:
minute: "10"
hour: "2"
day: "*"
month: "*"
weekday: "*"
job: "itkdev-docker-compose-server exec phpfpm bin/console app:data-providers:sync -j -r -d"
full-sync-versions:
minute: "20"
hour: "2"
day: "*"
month: "*"
weekday: "*"
job: "itkdev-docker-compose-server exec phpfpm bin/console app:data-providers:sync -j -s -d"
full-sync-issues:
minute: "30"
hour: "2"
day: "*"
month: "*"
weekday: "*"
job: "itkdev-docker-compose-server exec phpfpm bin/console app:data-providers:sync -j -i -d"
full-sync-worklogs:
minute: "40"
hour: "2"
day: "*"
month: "*"
weekday: "*"
job: "itkdev-docker-compose-server exec phpfpm bin/console app:data-providers:sync -j -w -d"
45 changes: 45 additions & 0 deletions .woodpecker/prod_itk_economics.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ steps:
- itkdev-docker-compose-server run --rm phpfpm bin/console messenger:setup-transports
- itkdev-docker-compose-server run --rm phpfpm bin/console cache:clear
cron:
# Modified syncs
sync-modified:
minute: "*/15"
hour: "*"
Expand All @@ -41,3 +42,47 @@ steps:
month: "*"
weekday: "*"
job: "itkdev-docker-compose-server exec phpfpm bin/console app:data-providers:sync-deleted"
# Nightly sync deleted for the last week
sync-deleted-week:
minute: "50"
hour: "2"
day: "*"
month: "*"
weekday: "*"
job: "itkdev-docker-compose-server exec phpfpm bin/console app:data-providers:sync-deleted --interval=P1W"
# Nightly full syncs
full-sync-projects:
minute: "0"
hour: "2"
day: "*"
month: "*"
weekday: "*"
job: "itkdev-docker-compose-server exec phpfpm bin/console app:data-providers:sync -j -p -d"
full-sync-workers:
minute: "10"
hour: "2"
day: "*"
month: "*"
weekday: "*"
job: "itkdev-docker-compose-server exec phpfpm bin/console app:data-providers:sync -j -r -d"
full-sync-versions:
minute: "20"
hour: "2"
day: "*"
month: "*"
weekday: "*"
job: "itkdev-docker-compose-server exec phpfpm bin/console app:data-providers:sync -j -s -d"
full-sync-issues:
minute: "30"
hour: "2"
day: "*"
month: "*"
weekday: "*"
job: "itkdev-docker-compose-server exec phpfpm bin/console app:data-providers:sync -j -i -d"
full-sync-worklogs:
minute: "40"
hour: "2"
day: "*"
month: "*"
weekday: "*"
job: "itkdev-docker-compose-server exec phpfpm bin/console app:data-providers:sync -j -w -d"
20 changes: 19 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [3.7.0] - 2026-06-26

* [PR-303](https://github.com/itk-dev/economics/pull/303)
* Added nightly safety-net sync cron jobs to `.woodpecker/prod_economics.yml`
and `.woodpecker/prod_itk_economics.yml`. Five staggered jobs run at
02:00/02:10/02:20/02:30/02:40 invoking
`app:data-providers:sync -j -d` for projects (`-p`),
workers (`-r`), versions (`-s`), issues (`-i`), and worklogs (`-w`) —
re-syncing everything touched within the past week and bypassing the local
`modifiedAt` short-circuit (`-d`), since the upstream source isn't fully
trusted to update `modifiedAt` on every change. A sixth job at 02:50 runs
`app:data-providers:sync-deleted --interval=P1W` to widen the deletion
window to the past week (vs. the default `PT1H` used by the 25-minute
cron).
* [PR-322](https://github.com/itk-dev/economics/pull/322)
* Autoselect external receiver account from client.

## [3.6.1] - 2026-06-23

* DevOps: Added docker compose dependency between phpfpm and rabbit.
Expand Down Expand Up @@ -655,7 +672,8 @@ complete process.
* Updated to authorization code flow.
* Changed worklog save button styling to be sticky.

[Unreleased]: https://github.com/itk-dev/economics/compare/3.6.0...HEAD
[Unreleased]: https://github.com/itk-dev/economics/compare/3.7.0...HEAD
[3.7.0]: https://github.com/itk-dev/economics/compare/3.6.0...3.7.0
[3.6.0]: https://github.com/itk-dev/economics/compare/3.5.0...3.6.0
[3.5.0]: https://github.com/itk-dev/economics/compare/3.3.0...3.5.0
[3.3.0]: https://github.com/itk-dev/economics/compare/3.1.0...3.3.0
Expand Down
26 changes: 26 additions & 0 deletions assets/controllers/autofill-receiver-account_controller.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { Controller } from "@hotwired/stimulus";

/** Autoselects the invoice receiver account from the selected client. */
export default class extends Controller {
static targets = ["client", "receiver"];

static values = { map: Object };

update() {
// The map sends external clients to the configured external receiver
// account and everyone else to the default account. Clients with no
// mapping (the empty "no client" option) reset the field to "".
const account = this.mapValue[this.clientTarget.value] ?? "";

// The receiver select is enhanced by choices.js, so go through its API
// (exposed on the element by the choices controller) to keep the widget
// and the underlying <select> in sync. Fall back to the native value
// when choices.js is not active, e.g. on a disabled recorded invoice.
const { choices } = this.receiverTarget;
if (choices) {
choices.setChoiceByValue(account);
} else {
this.receiverTarget.value = account;
}
}
}
8 changes: 5 additions & 3 deletions assets/controllers/choices_controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,13 @@ export default class extends Controller {

connect() {
this.choicesTargets.forEach((target) => {
const notDisabled = !target.disabled;
const theTarget = target;
const notDisabled = !theTarget.disabled;

if (notDisabled) {
/* eslint-disable-next-line no-new */
new Choices(target, {
// Keep the instance on the element so other controllers can drive
// the widget (e.g. autofill-receiver-account#update).
theTarget.choices = new Choices(theTarget, {
allowHTML: true,
itemSelectText: "",
removeItems: true,
Expand Down
2 changes: 1 addition & 1 deletion phpstan-baseline.neon
Original file line number Diff line number Diff line change
Expand Up @@ -2398,7 +2398,7 @@ parameters:
-
message: '#^Call to an undefined method object\:\:findOneBy\(\)\.$#'
identifier: method.notFound
count: 1
count: 2
path: tests/Integration/Controller/InvoiceFullFlowTest.php

-
Expand Down
25 changes: 24 additions & 1 deletion src/Controller/InvoiceController.php
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ public function __construct(
private readonly BillingService $billingService,
private readonly TranslatorInterface $translator,
private readonly InvoiceEntryHelper $invoiceEntryHelper,
private readonly string $invoiceExternalReceiverAccount = '',
) {
}

Expand Down Expand Up @@ -120,6 +121,13 @@ public function edit(Request $request, Invoice $invoice, InvoiceRepository $invo
}
}

// Make sure the external receiver account is selectable, so the client
// autofill can pick it even if it is not among the synced accounts.
if ('' !== $this->invoiceExternalReceiverAccount
&& !in_array($this->invoiceExternalReceiverAccount, $defaultReceiverAccountChoices)) {
$defaultReceiverAccountChoices[$this->invoiceExternalReceiverAccount] = $this->invoiceExternalReceiverAccount;
}

$form->add('paidByAccount', ChoiceType::class, [
'required' => false,
'label' => 'invoices.paid_by_account',
Expand All @@ -141,6 +149,7 @@ public function edit(Request $request, Invoice $invoice, InvoiceRepository $invo
'attr' => [
'class' => 'form-element',
'data-choices-target' => 'choices',
'data-autofill-receiver-account-target' => 'receiver',
],
'choices' => $defaultReceiverAccountChoices,
'help' => 'invoices.default_receiver_account_helptext',
Expand All @@ -155,7 +164,8 @@ public function edit(Request $request, Invoice $invoice, InvoiceRepository $invo
'class' => 'form-element',
'data-choices-target' => 'choices',
'data-autofill-material-number-target' => 'client',
'data-action' => 'change->autofill-material-number#update',
'data-autofill-receiver-account-target' => 'client',
'data-action' => 'change->autofill-material-number#update change->autofill-receiver-account#update',
],
'help' => 'invoices.client_helptext',
'choices' => $clientChoices,
Expand All @@ -170,6 +180,18 @@ public function edit(Request $request, Invoice $invoice, InvoiceRepository $invo
$clientMaterialNumbers[$clientChoice->getId()] = null !== $type ? $type->toMaterialNumber()->value : '';
}

// Map each client to the receiver account it implies. External clients use
// the dedicated external receiver account (when configured); everyone else
// uses the default account, so the field stays consistent with the client.
$defaultReceiverAccount = $this->invoiceEntryHelper->getDefaultAccount() ?? '';
$clientReceiverAccounts = [];
foreach ($clientChoices as $clientChoice) {
$isExternal = $clientChoice->getType()?->isExternal() ?? false;
$clientReceiverAccounts[$clientChoice->getId()] = ($isExternal && '' !== $this->invoiceExternalReceiverAccount)
? $this->invoiceExternalReceiverAccount
: $defaultReceiverAccount;
}

$form->handleRequest($request);

if ($form->isSubmitted() && $form->isValid()) {
Expand Down Expand Up @@ -210,6 +232,7 @@ public function edit(Request $request, Invoice $invoice, InvoiceRepository $invo
}, 0.0),
'clientHelper' => $clientHelper,
'clientMaterialNumbers' => $clientMaterialNumbers,
'clientReceiverAccounts' => $clientReceiverAccounts,
]);
}

Expand Down
2 changes: 1 addition & 1 deletion templates/invoices/edit.html.twig
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
}) }}

{{ form_start(form) }}
<div class="grid-split selections" {{ stimulus_controller('choices')|stimulus_controller('autofill-material-number', {map: clientMaterialNumbers}) }}>
<div class="grid-split selections" {{ stimulus_controller('choices')|stimulus_controller('autofill-material-number', {map: clientMaterialNumbers})|stimulus_controller('autofill-receiver-account', {map: clientReceiverAccounts}) }}>
<div>
{{ form_row(form.name) }}
<div {{ stimulus_controller('generate-description') }} data-endpoint="{{ path('app_invoices_generate_description', {id: invoice.id}) }}">
Expand Down
19 changes: 19 additions & 0 deletions tests/Integration/Controller/InvoiceFullFlowTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ public function testFullInvoiceLifecycle(): void
->findOneBy(['type' => ClientTypeEnum::INTERNAL]);
$this->assertInstanceOf(Client::class, $internalClient, 'Expected an internal client fixture.');

$externalClient = $container->get(ClientRepository::class)
->findOneBy(['type' => ClientTypeEnum::EXTERNAL]);
$this->assertInstanceOf(Client::class, $externalClient, 'Expected an external client fixture.');

// 1. Create invoice.
$crawler = $client->request('GET', '/admin/invoices/new');
$this->assertResponseIsSuccessful();
Expand Down Expand Up @@ -60,6 +64,21 @@ public function testFullInvoiceLifecycle(): void
$this->assertStringContainsString('data-autofill-material-number-target="material"', $editHtml);
$this->assertStringContainsString('autofill-material-number#update', $editHtml);

// The same client select also autoselects the receiver account.
$this->assertStringContainsString('data-autofill-receiver-account-map-value', $editHtml);
$this->assertStringContainsString('data-autofill-receiver-account-target="client"', $editHtml);
$this->assertStringContainsString('data-autofill-receiver-account-target="receiver"', $editHtml);
$this->assertStringContainsString('autofill-receiver-account#update', $editHtml);

// External clients map to the configured external receiver account, while
// internal clients map to the default account.
$receiverMapJson = $crawler->filter('[data-autofill-receiver-account-map-value]')
->attr('data-autofill-receiver-account-map-value');
$this->assertNotNull($receiverMapJson);
$receiverMap = json_decode($receiverMapJson, true);
$this->assertSame('ACC002', $receiverMap[$externalClient->getId()] ?? null);
$this->assertSame('test', $receiverMap[$internalClient->getId()] ?? null);

$finalName = 'FullFlow-edit-'.uniqid();
$description = 'Full flow invoice description.';
$periodFrom = '2025-01-01';
Expand Down
Loading