From d8da0e8c9eaf43f71fbe3687ea71cb78e5b5d6e1 Mon Sep 17 00:00:00 2001 From: MeisterAdebar Date: Sun, 19 Jul 2026 18:50:17 +0200 Subject: [PATCH 01/33] Add a workflow action booking a percentage of an invoice Deductions that follow from an invoice rather than from a payment had to be entered by hand, one entry per invoice. The amounts are a fixed percentage of the gross total, so nothing about them needs a person. The action books one such percentage and takes everything else from its config: the percentage, both accounts, the tax rate and a remark in which %number% becomes the invoice number. A workflow carries a single action, so two deductions mean two workflows; which invoices they apply to is left to their conditions, so no case is named in the code. Two details worth stating. The entry carries the invoice's date, not the day the workflow happens to run, so it sits next to the revenue entry. And it deliberately carries no invoiceId: the bank import re-dates every entry holding one as soon as a statement line matches that invoice, which would drag the deduction to the payout date - and with a payout covering several invoices, that date means nothing for any single one of them. Checked against entries that were booked by hand: 115.20 gross yields 13.82 at 12% and 1.61 at 1.4%, matching to the cent. The functional test runs through the registry and the real journal service; the unit tests cover the rounding and the skipped cases, verified by breaking the rounding on purpose. --- .../Action/CreatePercentageEntryAction.php | 181 ++++++++++++++++++ .../CreatePercentageEntryActionTest.php | 141 ++++++++++++++ .../CreatePercentageEntryActionTest.php | 174 +++++++++++++++++ translations/Workflow/messages.de.yaml | 12 ++ translations/Workflow/messages.en.yaml | 12 ++ 5 files changed, 520 insertions(+) create mode 100644 src/Workflow/Action/CreatePercentageEntryAction.php create mode 100644 tests/Functional/CreatePercentageEntryActionTest.php create mode 100644 tests/Unit/Workflow/CreatePercentageEntryActionTest.php diff --git a/src/Workflow/Action/CreatePercentageEntryAction.php b/src/Workflow/Action/CreatePercentageEntryAction.php new file mode 100644 index 00000000..dc623451 --- /dev/null +++ b/src/Workflow/Action/CreatePercentageEntryAction.php @@ -0,0 +1,181 @@ +> + */ + public function getConfigSchema(): array + { + $taxRateOptions = [['value' => '', 'label' => '-']]; + foreach ($this->taxRateRepo->findAllOrdered() as $taxRate) { + $taxRateOptions[] = ['value' => (string) $taxRate->getId(), 'label' => $taxRate->getName()]; + } + + return [ + [ + 'key' => 'percent', + 'type' => 'text', + 'label' => 'workflow.form.percentage_entry_percent', + 'help' => 'workflow.form.percentage_entry_percent_help', + 'default' => '', + ], + [ + 'key' => 'debitAccountId', + 'type' => 'accounting_account_select', + 'label' => 'workflow.form.percentage_entry_debit_account', + 'help' => 'workflow.form.percentage_entry_debit_account_help', + 'default' => '', + ], + [ + 'key' => 'creditAccountId', + 'type' => 'accounting_account_select', + 'label' => 'workflow.form.percentage_entry_credit_account', + 'help' => 'workflow.form.percentage_entry_credit_account_help', + 'default' => '', + ], + [ + 'key' => 'taxRateId', + 'type' => 'select', + 'label' => 'workflow.form.percentage_entry_tax_rate', + 'options' => $taxRateOptions, + 'default' => '', + ], + [ + 'key' => 'remark', + 'type' => 'text', + 'label' => 'workflow.form.percentage_entry_remark', + 'help' => 'workflow.form.percentage_entry_remark_help', + 'default' => '', + ], + ]; + } + + /** + * @param array $config + * @param array $context + */ + public function execute(array $config, mixed $entity, array $context): string + { + if (!$entity instanceof Invoice) { + throw new WorkflowSkippedException($this->translator->trans('workflow.log.skipped_unsupported_entity')); + } + + $percent = (float) str_replace(',', '.', trim((string) ($config['percent'] ?? ''))); + if ($percent <= 0.0) { + throw new WorkflowSkippedException($this->translator->trans('workflow.log.skipped_no_percentage')); + } + + $amount = round($this->grossTotal($entity) * $percent / 100.0, 2); + if (0.0 === $amount) { + throw new WorkflowSkippedException($this->translator->trans('workflow.log.skipped_no_amounts')); + } + + $remark = str_replace('%number%', (string) $entity->getNumber(), trim((string) ($config['remark'] ?? ''))); + + $entry = $this->bookingJournalService->createEntryFromStatement( + // The invoice's own date, so the deduction sits next to the revenue + // entry even when the workflow runs later. + $entity->getDate(), + number_format($amount, 2, '.', ''), + !empty($config['debitAccountId']) ? $this->accountRepo->find((int) $config['debitAccountId']) : null, + !empty($config['creditAccountId']) ? $this->accountRepo->find((int) $config['creditAccountId']) : null, + '' !== $remark ? $remark : null, + $entity->getNumber(), + // Deliberately no invoiceId: the bank import re-dates every entry + // carrying one when a statement line matches that invoice, and a + // deduction belongs to the invoice date rather than to the payout. + null, + null, + !empty($config['taxRateId']) ? $this->taxRateRepo->find((int) $config['taxRateId']) : null, + ); + + return $this->translator->trans('workflow.log.percentage_entry_created', [ + // Formatted like the amount beside it: "1,40" rather than PHP's "1.4". + '%percent%' => number_format($percent, 2, ',', '.'), + '%amount%' => number_format($amount, 2, ',', '.'), + '%number%' => (string) $entry->getInvoiceNumber(), + ]); + } + + /** Gross total of the invoice, the same figure the invoice itself shows. */ + private function grossTotal(Invoice $invoice): float + { + $brutto = 0.0; + $netto = 0.0; + $apartmentTotal = 0.0; + $miscTotal = 0.0; + $vats = []; + + $this->invoiceService->calculateSums( + $invoice->getAppartments() ?? new ArrayCollection(), + $invoice->getPositions() ?? new ArrayCollection(), + $vats, + $brutto, + $netto, + $apartmentTotal, + $miscTotal, + ); + + return $brutto; + } +} diff --git a/tests/Functional/CreatePercentageEntryActionTest.php b/tests/Functional/CreatePercentageEntryActionTest.php new file mode 100644 index 00000000..d2f1411f --- /dev/null +++ b/tests/Functional/CreatePercentageEntryActionTest.php @@ -0,0 +1,141 @@ +em = static::getContainer()->get(ManagerRegistry::class)->getManager(); + } + + public function testTheActionIsRegisteredUnderItsType(): void + { + $registry = static::getContainer()->get(WorkflowActionRegistry::class); + + self::assertTrue($registry->has('create_percentage_entry')); + self::assertSame([Invoice::class], $registry->get('create_percentage_entry')->getSupportedEntityClasses()); + } + + public function testBooksCommissionAndFeeTheWayTheyWereBookedByHand(): void + { + $invoice = $this->createInvoice(115.20); + $action = static::getContainer()->get(WorkflowActionRegistry::class)->get('create_percentage_entry'); + + $action->execute($this->config('12', 'Kommission %number%'), $invoice, []); + $action->execute($this->config('1,4', 'Zahlungsgebühr %number%'), $invoice, []); + $this->em->flush(); + + $entries = $this->em->getRepository(BookingEntry::class) + ->findBy(['invoiceNumber' => $invoice->getNumber()], ['id' => 'ASC']); + + self::assertCount(2, $entries); + self::assertSame('13.82', $entries[0]->getAmount()); + self::assertSame('Kommission '.$invoice->getNumber(), $entries[0]->getRemark()); + self::assertSame('1.61', $entries[1]->getAmount()); + self::assertSame('Zahlungsgebühr '.$invoice->getNumber(), $entries[1]->getRemark()); + } + + public function testTheEntryCarriesTheInvoiceDateAndTheConfiguredAccounts(): void + { + $invoice = $this->createInvoice(115.20); + $action = static::getContainer()->get(WorkflowActionRegistry::class)->get('create_percentage_entry'); + + $action->execute($this->config('12', ''), $invoice, []); + $this->em->flush(); + + $entry = $this->em->getRepository(BookingEntry::class)->findOneBy(['invoiceNumber' => $invoice->getNumber()]); + + self::assertSame($invoice->getDate()->format('Y-m-d'), $entry->getDate()->format('Y-m-d')); + self::assertSame($this->account('3123')->getId(), $entry->getDebitAccount()?->getId()); + self::assertSame($this->account('1200')->getId(), $entry->getCreditAccount()?->getId()); + // Left unset on purpose: the bank import re-dates entries carrying one. + self::assertNull($entry->getInvoiceId()); + } + + /** @return array */ + private function config(string $percent, string $remark): array + { + $taxRate = $this->em->getRepository(TaxRate::class)->findOneBy([]); + + return [ + 'percent' => $percent, + 'debitAccountId' => (string) $this->account('3123')->getId(), + 'creditAccountId' => (string) $this->account('1200')->getId(), + 'taxRateId' => (string) $taxRate?->getId(), + 'remark' => $remark, + ]; + } + + private function account(string $number): AccountingAccount + { + /** @var AccountingAccountRepository $repo */ + $repo = $this->em->getRepository(AccountingAccount::class); + $account = $repo->findOneBy(['accountNumber' => $number]); + + if (null === $account) { + $account = new AccountingAccount(); + $account->setAccountNumber($number); + $account->setName('Testkonto '.$number); + $account->setType('expense'); + $this->em->persist($account); + $this->em->flush(); + } + + return $account; + } + + private function createInvoice(float $gross): Invoice + { + $invoice = new Invoice(); + $invoice->setNumber('T'.random_int(100000, 999999)); + $invoice->setDate(new \DateTime('2026-06-21')); + $invoice->setStatus(1); + $invoice->setRemark(''); + $this->em->persist($invoice); + + $apartment = new InvoiceAppartment(); + $apartment->setInvoice($invoice); + $apartment->setNumber('1'); + $apartment->setDescription('Testzimmer'); + $apartment->setBeds(2); + $apartment->setPersons(2); + $apartment->setStartDate(new \DateTime('2026-06-19')); + $apartment->setEndDate(new \DateTime('2026-06-21')); + $apartment->setPrice($gross); + $apartment->setVat(7.0); + $apartment->setIncludesVat(true); + $apartment->setIsFlatPrice(true); + $this->em->persist($apartment); + $this->em->flush(); + + $invoice->getAppartments()->add($apartment); + + return $invoice; + } +} diff --git a/tests/Unit/Workflow/CreatePercentageEntryActionTest.php b/tests/Unit/Workflow/CreatePercentageEntryActionTest.php new file mode 100644 index 00000000..f5fa3440 --- /dev/null +++ b/tests/Unit/Workflow/CreatePercentageEntryActionTest.php @@ -0,0 +1,174 @@ +makeAction(gross: 115.20, capture: $captured); + + $action->execute($this->config(['percent' => '12']), $this->invoice(), []); + + // 115.20 * 12 % = 13.824, commercially rounded. + self::assertSame('13.82', $captured['amount']); + } + + public function testRoundsToTwoDecimals(): void + { + $captured = null; + $action = $this->makeAction(gross: 115.20, capture: $captured); + + $action->execute($this->config(['percent' => '1.4']), $this->invoice(), []); + + // 115.20 * 1.4 % = 1.6128 + self::assertSame('1.61', $captured['amount']); + } + + public function testAcceptsACommaAsDecimalSeparator(): void + { + // The field is free text and German keyboards produce commas. + $captured = null; + $action = $this->makeAction(gross: 115.20, capture: $captured); + + $action->execute($this->config(['percent' => '1,4']), $this->invoice(), []); + + self::assertSame('1.61', $captured['amount']); + } + + public function testPutsTheInvoiceNumberIntoTheRemark(): void + { + $captured = null; + $action = $this->makeAction(gross: 100.0, capture: $captured); + + $action->execute($this->config(['percent' => '10', 'remark' => 'Kommission %number%']), $this->invoice('17730'), []); + + self::assertSame('Kommission 17730', $captured['remark']); + self::assertSame('17730', $captured['invoiceNumber']); + } + + public function testLeavesTheInvoiceIdUnsetSoThePayoutDoesNotRedateIt(): void + { + // The bank import re-dates every entry carrying an invoiceId once a + // statement line matches that invoice; a deduction belongs to the + // invoice date instead. + $captured = null; + $action = $this->makeAction(gross: 100.0, capture: $captured); + + $action->execute($this->config(['percent' => '10']), $this->invoice(), []); + + self::assertNull($captured['invoiceId']); + } + + public function testSkipsWithoutAPercentage(): void + { + $action = $this->makeAction(gross: 100.0); + + $this->expectException(WorkflowSkippedException::class); + $action->execute($this->config(['percent' => '']), $this->invoice(), []); + } + + public function testSkipsWhenTheInvoiceHasNoAmount(): void + { + $action = $this->makeAction(gross: 0.0); + + $this->expectException(WorkflowSkippedException::class); + $action->execute($this->config(['percent' => '12']), $this->invoice(), []); + } + + public function testSkipsForAnyOtherEntity(): void + { + $action = $this->makeAction(gross: 100.0); + + $this->expectException(WorkflowSkippedException::class); + $action->execute($this->config(['percent' => '12']), new \stdClass(), []); + } + + /** + * @param array $overrides + * + * @return array + */ + private function config(array $overrides = []): array + { + return array_merge([ + 'percent' => '12', + 'debitAccountId' => '3', + 'creditAccountId' => '4', + 'taxRateId' => '', + 'remark' => '', + ], $overrides); + } + + private function invoice(string $number = '17730'): Invoice + { + $invoice = $this->createStub(Invoice::class); + $invoice->method('getNumber')->willReturn($number); + $invoice->method('getDate')->willReturn(new \DateTime('2026-06-26')); + + return $invoice; + } + + /** @param array|null $capture receives the arguments the journal was called with */ + private function makeAction(float $gross, mixed &$capture = null): CreatePercentageEntryAction + { + $invoiceService = $this->createStub(InvoiceService::class); + $invoiceService->method('calculateSums')->willReturnCallback( + function ($apartments, $positions, &$vats, &$brutto) use ($gross): void { + $brutto = $gross; + } + ); + + $journal = $this->createStub(BookingJournalService::class); + $journal->method('createEntryFromStatement')->willReturnCallback( + function ($date, $amount, $debit, $credit, $remark, $invoiceNumber = null, $invoiceId = null, $splitGroup = null, $taxRate = null) use (&$capture) { + $capture = [ + 'date' => $date, + 'amount' => $amount, + 'remark' => $remark, + 'invoiceNumber' => $invoiceNumber, + 'invoiceId' => $invoiceId, + 'taxRate' => $taxRate, + ]; + + $entry = $this->createStub(BookingEntry::class); + $entry->method('getInvoiceNumber')->willReturn($invoiceNumber); + + return $entry; + } + ); + + $accountRepo = $this->createStub(AccountingAccountRepository::class); + $accountRepo->method('find')->willReturn($this->createStub(AccountingAccount::class)); + + $taxRateRepo = $this->createStub(TaxRateRepository::class); + $taxRateRepo->method('findAllOrdered')->willReturn([]); + $taxRateRepo->method('find')->willReturn($this->createStub(TaxRate::class)); + + $translator = $this->createStub(TranslatorInterface::class); + $translator->method('trans')->willReturn('ok'); + + return new CreatePercentageEntryAction($journal, $accountRepo, $taxRateRepo, $invoiceService, $translator); + } +} diff --git a/translations/Workflow/messages.de.yaml b/translations/Workflow/messages.de.yaml index 16d06c9b..76d2b475 100644 --- a/translations/Workflow/messages.de.yaml +++ b/translations/Workflow/messages.de.yaml @@ -74,6 +74,15 @@ workflow: attachment_policy_help: "Zum Beispiel, wenn zu einer Reservierung noch gar keine Rechnung existiert." attachment_policy.skip_missing: "E-Mail trotzdem senden (ohne den fehlenden Anhang)" attachment_policy.require_all: "E-Mail nicht senden" + percentage_entry_percent: "Prozentsatz" + percentage_entry_percent_help: "Anteil am Rechnungsbrutto, z.B. 12 für eine Kommission oder 1,4 für eine Zahlungsgebühr. Ohne einschränkende Bedingung wird auf jede Rechnung gebucht." + percentage_entry_debit_account: "Sollkonto" + percentage_entry_debit_account_help: "Konto, auf das der Abzug gebucht wird (Aufwand oder Reverse-Charge)." + percentage_entry_credit_account: "Habenkonto" + percentage_entry_credit_account_help: "Konto, von dem der Abzug abgeht - in der Regel dasselbe, gegen das auch die Rechnung gebucht wurde." + percentage_entry_tax_rate: "Steuersatz" + percentage_entry_remark: "Bemerkung" + percentage_entry_remark_help: "%number% wird durch die Rechnungsnummer ersetzt, z.B. „Kommission %number%“." # Trigger labels trigger: @@ -111,6 +120,7 @@ workflow: send_general_email: "Allgemeine E-Mail anhand eines Templates versenden" send_notification_email: "Benachrichtigungs-E-Mail senden" create_booking_entry: "Buchungseintrag erstellen" + create_percentage_entry: "Prozentualen Buchungseintrag erstellen" change_invoice_status: "Rechnungsstatus ändern" change_payment_means: "Zahlungsmethode ändern" change_reservation_status: "Reservierungsstatus ändern" @@ -177,6 +187,8 @@ workflow: notification_calendar_import_sent: "Kalenderimport-Benachrichtigung an %recipient% gesendet" booking_entries_created: "%count% Buchungseinträge für Rechnung %number% erstellt" skipped_no_amounts: "Übersprungen: Rechnung enthält keine buchbaren Beträge" + skipped_no_percentage: "Übersprungen: kein gültiger Prozentsatz konfiguriert" + percentage_entry_created: "Buchung über %amount% (%percent% % vom Brutto) für Rechnung %number% erstellt" skipped_invalid_config: "Übersprungen: ungültige Konfiguration" skipped_status_not_found: "Übersprungen: Reservierungsstatus nicht gefunden" skipped_no_reservations: "Übersprungen: Rechnung hat keine verknüpften Reservierungen" diff --git a/translations/Workflow/messages.en.yaml b/translations/Workflow/messages.en.yaml index 45ded43b..f10fbc18 100644 --- a/translations/Workflow/messages.en.yaml +++ b/translations/Workflow/messages.en.yaml @@ -74,6 +74,15 @@ workflow: attachment_policy_help: "For example when a reservation does not have an invoice yet." attachment_policy.skip_missing: "Send the email anyway (without the missing attachment)" attachment_policy.require_all: "Do not send the email" + percentage_entry_percent: "Percentage" + percentage_entry_percent_help: "Share of the invoice's gross total, e.g. 12 for a commission or 1.4 for a payment fee. Without a condition narrowing it down, every invoice is booked." + percentage_entry_debit_account: "Debit account" + percentage_entry_debit_account_help: "Account the deduction is booked to (expense or reverse charge)." + percentage_entry_credit_account: "Credit account" + percentage_entry_credit_account_help: "Account the deduction is taken from - usually the same one the invoice was booked against." + percentage_entry_tax_rate: "Tax rate" + percentage_entry_remark: "Remark" + percentage_entry_remark_help: "%number% is replaced by the invoice number, e.g. \"Commission %number%\"." trigger: online_booking_created: "New online booking received" @@ -108,6 +117,7 @@ workflow: send_general_email: "Send general email using a template" send_notification_email: "Send notification email" create_booking_entry: "Create booking entry" + create_percentage_entry: "Create percentage booking entry" change_invoice_status: "Change invoice status" change_payment_means: "Change payment method" change_reservation_status: "Change reservation status" @@ -172,6 +182,8 @@ workflow: notification_calendar_import_sent: "Calendar import notification sent to %recipient%" booking_entries_created: "%count% booking entries created for invoice %number%" skipped_no_amounts: "Skipped: invoice contains no bookable amounts" + skipped_no_percentage: "Skipped: no valid percentage configured" + percentage_entry_created: "Entry of %amount% (%percent% % of gross) created for invoice %number%" skipped_invalid_config: "Skipped: invalid configuration" skipped_status_not_found: "Skipped: reservation status not found" skipped_no_reservations: "Skipped: invoice has no linked reservations" From 0cd288d21dbafed735e6392ba59c9df3b2080592 Mon Sep 17 00:00:00 2001 From: MeisterAdebar Date: Sun, 19 Jul 2026 22:57:25 +0200 Subject: [PATCH 02/33] Hold a batch open until booked-ahead entries have their document number The percentage entries are created when the invoice is, but the document they refer to is the supplier's invoice for the deduction, which arrives later and covers several invoices at once. So the reference cannot be filled in at that point, and putting our own invoice number there would only look right. The entry now says so instead: it is booked without a reference and marked as expecting one, and closing the month refuses while any such entry is still waiting. Reopening stays unguarded - the point is to let them be completed. The batch page names the count before it comes to that, since the reference is easiest to add while the month is still being worked on. A flag is needed because an empty reference means nothing by itself: 49 of this year's 235 entries carry none and are complete without one - cash deposits, private withdrawals, tax payments. Only entries that were told to expect a reference count as unfinished. The guard was verified by removing it: the month then closes with an entry still waiting. --- migrations/Version20260719160000.php | 31 +++ src/Controller/BookingJournalController.php | 19 ++ src/Entity/BookingEntry.php | 28 +++ src/Repository/BookingEntryRepository.php | 18 ++ .../Action/CreatePercentageEntryAction.php | 17 +- templates/BookingJournal/entries.html.twig | 11 + .../CreatePercentageEntryActionTest.php | 198 ++++++++++++++++-- .../CreatePercentageEntryActionTest.php | 14 +- translations/BookingJournal/messages.de.yaml | 3 + translations/BookingJournal/messages.en.yaml | 3 + 10 files changed, 315 insertions(+), 27 deletions(-) create mode 100644 migrations/Version20260719160000.php diff --git a/migrations/Version20260719160000.php b/migrations/Version20260719160000.php new file mode 100644 index 00000000..eafe47e1 --- /dev/null +++ b/migrations/Version20260719160000.php @@ -0,0 +1,31 @@ +addSql('ALTER TABLE booking_entries ADD requires_document_number TINYINT(1) DEFAULT 0 NOT NULL'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE booking_entries DROP requires_document_number'); + } + + public function isTransactional(): bool + { + return false; + } +} diff --git a/src/Controller/BookingJournalController.php b/src/Controller/BookingJournalController.php index 8118726e..e3983c3a 100644 --- a/src/Controller/BookingJournalController.php +++ b/src/Controller/BookingJournalController.php @@ -28,6 +28,7 @@ use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface; use Symfony\Component\Security\Http\Attribute\IsGranted; +use Symfony\Contracts\Translation\TranslatorInterface; #[Route('/journal')] #[IsGranted('ROLE_CASHJOURNAL')] @@ -175,6 +176,7 @@ public function batchEntries( 'search' => $search, 'filter' => $filter, 'pdfTemplates' => $pdfTemplates, + 'missingDocumentNumbers' => $entryRepo->countMissingDocumentNumber($batch), ]); } @@ -184,6 +186,8 @@ public function toggleBatchStatus( Request $request, EntityManagerInterface $em, AuthorizationCheckerInterface $authChecker, + BookingEntryRepository $entryRepo, + TranslatorInterface $translator, ): Response { if (!$this->isCsrfTokenValid('batch_toggle_'.$batch->getId(), $request->request->get('_token'))) { $this->addFlash('warning', 'flash.access.denied'); @@ -197,6 +201,21 @@ public function toggleBatchStatus( return $this->redirectToRoute('journal.batch.entries', ['id' => $batch->getId()]); } + // Closing is what makes a month final, so it is also the last chance to + // notice an entry still waiting for the document reference it was + // booked ahead of. Reopening stays unguarded - the point is to let + // those entries be completed. + if (!$batch->isClosed()) { + $missing = $entryRepo->countMissingDocumentNumber($batch); + if ($missing > 0) { + $this->addFlash('warning', $translator->trans('accounting.journal.flash.batch_missing_document_numbers', [ + '%count%' => $missing, + ])); + + return $this->redirectToRoute('journal.batch.entries', ['id' => $batch->getId()]); + } + } + $batch->setIsClosed(!$batch->isClosed()); $em->flush(); diff --git a/src/Entity/BookingEntry.php b/src/Entity/BookingEntry.php index 70e8d49d..258a42b8 100644 --- a/src/Entity/BookingEntry.php +++ b/src/Entity/BookingEntry.php @@ -63,6 +63,16 @@ class BookingEntry #[ORM\Column(type: Types::STRING, length: 30, nullable: true)] private ?string $sourceType = null; + /** + * Marks an entry whose document reference is known to be missing rather + * than not applicable - a deduction booked ahead of the supplier invoice + * that documents it, for instance. Plenty of entries legitimately carry no + * number at all (cash deposits, private withdrawals, tax payments), so + * only this flag tells the two apart, and closing a batch checks it. + */ + #[ORM\Column(type: Types::BOOLEAN, options: ['default' => false])] + private bool $requiresDocumentNumber = false; + /** * Groups entries that originate from the same underlying document, e.g. a bank * statement line split across multiple debit accounts. Entries with the same @@ -238,6 +248,24 @@ public function setCounterAccountLegacy(?string $counterAccountLegacy): self return $this; } + public function requiresDocumentNumber(): bool + { + return $this->requiresDocumentNumber; + } + + public function setRequiresDocumentNumber(bool $requiresDocumentNumber): self + { + $this->requiresDocumentNumber = $requiresDocumentNumber; + + return $this; + } + + /** True once the entry still waits for the reference it was told to expect. */ + public function isMissingDocumentNumber(): bool + { + return $this->requiresDocumentNumber && ('' === (string) $this->invoiceNumber); + } + public function getSourceType(): ?string { return $this->sourceType; diff --git a/src/Repository/BookingEntryRepository.php b/src/Repository/BookingEntryRepository.php index 4c626bb0..1484e731 100644 --- a/src/Repository/BookingEntryRepository.php +++ b/src/Repository/BookingEntryRepository.php @@ -88,6 +88,24 @@ public function countByTaxRate(TaxRate $taxRate): int ->getSingleScalarResult(); } + /** + * Entries in this batch that were flagged as expecting a document + * reference and still have none. Many entries carry no reference at all + * and are perfectly complete without one, so the flag - not the empty + * field - is what makes an entry count as unfinished here. + */ + public function countMissingDocumentNumber(BookingBatch $batch): int + { + return (int) $this->createQueryBuilder('e') + ->select('COUNT(e.id)') + ->where('e.bookingBatch = :batch') + ->andWhere('e.requiresDocumentNumber = true') + ->andWhere("e.invoiceNumber IS NULL OR e.invoiceNumber = ''") + ->setParameter('batch', $batch) + ->getQuery() + ->getSingleScalarResult(); + } + public function countByAccount(AccountingAccount $account): int { return (int) $this->createQueryBuilder('e') diff --git a/src/Workflow/Action/CreatePercentageEntryAction.php b/src/Workflow/Action/CreatePercentageEntryAction.php index dc623451..afdb2f60 100644 --- a/src/Workflow/Action/CreatePercentageEntryAction.php +++ b/src/Workflow/Action/CreatePercentageEntryAction.php @@ -140,20 +140,27 @@ public function execute(array $config, mixed $entity, array $context): string !empty($config['debitAccountId']) ? $this->accountRepo->find((int) $config['debitAccountId']) : null, !empty($config['creditAccountId']) ? $this->accountRepo->find((int) $config['creditAccountId']) : null, '' !== $remark ? $remark : null, - $entity->getNumber(), - // Deliberately no invoiceId: the bank import re-dates every entry - // carrying one when a statement line matches that invoice, and a - // deduction belongs to the invoice date rather than to the payout. + // No document reference yet: the one that belongs here is the + // supplier's invoice for the deduction, which is issued later and + // usually covers several invoices at once. The entry is flagged as + // waiting for it below, and a batch will not close until it has one. + null, + // Deliberately no invoiceId either: the bank import re-dates every + // entry carrying one when a statement line matches that invoice, + // and a deduction belongs to the invoice date rather than to the + // payout. The invoice number stays traceable through the remark. null, null, !empty($config['taxRateId']) ? $this->taxRateRepo->find((int) $config['taxRateId']) : null, ); + $entry->setRequiresDocumentNumber(true); + return $this->translator->trans('workflow.log.percentage_entry_created', [ // Formatted like the amount beside it: "1,40" rather than PHP's "1.4". '%percent%' => number_format($percent, 2, ',', '.'), '%amount%' => number_format($amount, 2, ',', '.'), - '%number%' => (string) $entry->getInvoiceNumber(), + '%number%' => (string) $entity->getNumber(), ]); } diff --git a/templates/BookingJournal/entries.html.twig b/templates/BookingJournal/entries.html.twig index d971d3c2..462c2647 100644 --- a/templates/BookingJournal/entries.html.twig +++ b/templates/BookingJournal/entries.html.twig @@ -11,6 +11,17 @@ {% block content %}
+ {# Shown here rather than only when closing fails: the reference is easiest + to supply while the month is still being worked on. #} + {% if missingDocumentNumbers|default(0) > 0 %} + + {% endif %}
{% endif %} @@ -89,6 +92,9 @@ + {% if missingDocumentNumbers|default(0) > 0 or filter == 'missing_document' %} + + {% endif %}
diff --git a/tests/Functional/CreatePercentageEntryActionTest.php b/tests/Functional/CreatePercentageEntryActionTest.php index 5a4c5861..d7be7869 100644 --- a/tests/Functional/CreatePercentageEntryActionTest.php +++ b/tests/Functional/CreatePercentageEntryActionTest.php @@ -166,6 +166,30 @@ public function testTheMonthCannotBeClosedWhileAnEntryWaits(): void $this->reopenBatch($batchId); } + public function testTheFilterListsExactlyTheWaitingEntries(): void + { + // The warning is only actionable if it can point at the entries it + // counts - this is the view it links to. + $invoice = $this->createInvoice(115.20, '2026-10-15'); + $action = static::getContainer()->get(WorkflowActionRegistry::class)->get('create_percentage_entry'); + $since = $this->lastEntryId(); + $action->execute($this->config('12', ''), $invoice, []); + $this->em()->flush(); + + $entry = $this->entriesSince($since)[0]; + $repo = $this->em()->getRepository(\App\Entity\BookingEntry::class); + $batch = $entry->getBookingBatch(); + + $waiting = $repo->findByBatch($batch, '', 1, 20, \App\Repository\BookingEntryRepository::MODE_MISSING_DOCUMENT); + self::assertSame([$entry->getId()], array_map(static fn ($e) => $e->getId(), iterator_to_array($waiting))); + + $entry->setInvoiceNumber('1656376969'); + $this->em()->flush(); + + $afterwards = $repo->findByBatch($batch, '', 1, 20, \App\Repository\BookingEntryRepository::MODE_MISSING_DOCUMENT); + self::assertCount(0, iterator_to_array($afterwards), 'entry still listed after the number was supplied'); + } + private function isBatchClosed(int $id): bool { return (bool) $this->connection()->fetchOne('SELECT is_closed FROM booking_batches WHERE id = ?', [$id]); diff --git a/translations/BookingJournal/messages.de.yaml b/translations/BookingJournal/messages.de.yaml index ad45dae9..299b9a41 100644 --- a/translations/BookingJournal/messages.de.yaml +++ b/translations/BookingJournal/messages.de.yaml @@ -224,6 +224,8 @@ accounting.journal.flash.batch_closed: Monat abgeschlossen. accounting.journal.flash.batch_missing_document_numbers: 'Monat kann nicht abgeschlossen werden: %count% Buchung(en) warten noch auf eine Belegnummer.' accounting.journal.missing_document_numbers: '%count% Buchung(en) ohne Belegnummer' accounting.journal.missing_document_numbers_hint: Diese Buchungen wurden vor dem zugehörigen Beleg erfasst. Der Monat lässt sich erst abschließen, wenn die Belegnummer nachgetragen ist. +accounting.journal.filter.missing_document: Ohne Belegnummer +accounting.journal.missing_document_numbers_show: Diese Buchungen anzeigen accounting.journal.flash.batch_reopened: Monat wieder geöffnet. accounting.journal.flash.entry_created: Buchung erfolgreich angelegt. accounting.journal.flash.entry_updated: Buchung erfolgreich aktualisiert. diff --git a/translations/BookingJournal/messages.en.yaml b/translations/BookingJournal/messages.en.yaml index 06bff8eb..ce3c96ce 100644 --- a/translations/BookingJournal/messages.en.yaml +++ b/translations/BookingJournal/messages.en.yaml @@ -224,6 +224,8 @@ accounting.journal.flash.batch_closed: Month closed. accounting.journal.flash.batch_missing_document_numbers: 'Month cannot be closed: %count% entry/entries still wait for a document number.' accounting.journal.missing_document_numbers: '%count% entry/entries without a document number' accounting.journal.missing_document_numbers_hint: These entries were booked before the document they refer to. The month can only be closed once the number has been supplied. +accounting.journal.filter.missing_document: Without document number +accounting.journal.missing_document_numbers_show: Show these entries accounting.journal.flash.batch_reopened: Month reopened. accounting.journal.flash.entry_created: Entry created successfully. accounting.journal.flash.entry_updated: Entry updated successfully. From c3aaf54470c07d95193aaf62dda089a51b8351e8 Mon Sep 17 00:00:00 2001 From: MeisterAdebar Date: Mon, 20 Jul 2026 18:00:59 +0200 Subject: [PATCH 04/33] Book the deduction on the day the payment was recorded Three corrections to the percentage entry, all from working with it. The date is now the day the workflow runs rather than the invoice's own. The action is used with a trigger that fires when the invoice is marked as paid, so that day is the day the payment was recorded - and a deduction settled out of that payment belongs in the month it was settled, not in the one the invoice was written in. It is not configurable: the earlier reasoning for the invoice date, that the deduction sits next to the revenue entry, does not survive the fact that the payment always arrives later. Nothing ties the action to that trigger, though, so the workflow form says which day the entry gets. A test had isolated itself by giving its invoice a month of its own, which no longer works now that every entry lands in the current month. It completes all waiting entries of the batch instead. The same holds in earnest: deductions now collect in the running month regardless of how old their invoices are, so a month close hangs on every one of them that has no document yet. Whether an entry waits for a document number is now a choice, defaulting to yes so workflows configured before it existed keep their guard. Not every deduction is documented by a paper of its own, and one that is not must not hold the month open. The warning naming the waiting entries, and the filter it linked to, are gone. Both stated at the top of the page what the rows themselves can say: the entry now shows "document missing" in its reference column. The count still guards the month close, it just no longer announces itself before it has to. --- src/Controller/BookingJournalController.php | 3 +- src/Repository/BookingEntryRepository.php | 8 --- .../Action/CreatePercentageEntryAction.php | 34 +++++++-- templates/BookingJournal/entries.html.twig | 23 +----- .../CreatePercentageEntryActionTest.php | 70 ++++++++++--------- .../CreatePercentageEntryActionTest.php | 4 +- translations/BookingJournal/messages.de.yaml | 6 +- translations/BookingJournal/messages.en.yaml | 6 +- translations/Workflow/messages.de.yaml | 4 ++ translations/Workflow/messages.en.yaml | 4 ++ 10 files changed, 82 insertions(+), 80 deletions(-) diff --git a/src/Controller/BookingJournalController.php b/src/Controller/BookingJournalController.php index 5b240830..959e1f22 100644 --- a/src/Controller/BookingJournalController.php +++ b/src/Controller/BookingJournalController.php @@ -142,7 +142,7 @@ public function batchEntries( $page = (int) $request->query->get('page', 1); $search = $request->query->get('search', ''); $filter = $request->query->get('filter', 'all'); - if (!in_array($filter, ['all', 'cashbook', 'bankbook', BookingEntryRepository::MODE_MISSING_DOCUMENT], true)) { + if (!in_array($filter, ['all', 'cashbook', 'bankbook'], true)) { $filter = 'all'; } @@ -176,7 +176,6 @@ public function batchEntries( 'search' => $search, 'filter' => $filter, 'pdfTemplates' => $pdfTemplates, - 'missingDocumentNumbers' => $entryRepo->countMissingDocumentNumber($batch), ]); } diff --git a/src/Repository/BookingEntryRepository.php b/src/Repository/BookingEntryRepository.php index 9f555340..1484e731 100644 --- a/src/Repository/BookingEntryRepository.php +++ b/src/Repository/BookingEntryRepository.php @@ -22,9 +22,6 @@ public function __construct(ManagerRegistry $registry) parent::__construct($registry, BookingEntry::class); } - /** Filter mode listing only entries that still wait for their document reference. */ - public const MODE_MISSING_DOCUMENT = 'missing_document'; - public function findByBatch(BookingBatch $batch, string $search = '', int $page = 1, int $limit = 20, string $mode = 'all'): Paginator { $qb = $this->createQueryBuilder('e') @@ -47,11 +44,6 @@ public function findByBatch(BookingBatch $batch, string $search = '', int $page ->andWhere('da.isBankAccount = true OR ca.isBankAccount = true') ->andWhere('e.sourceType IS NULL OR e.sourceType != :opening') ->setParameter('opening', BookingEntry::SOURCE_OPENING_BALANCE); - } elseif (self::MODE_MISSING_DOCUMENT === $mode) { - // The warning about entries still waiting for a reference is only - // useful if it can name them - this is what it links to. - $qb->andWhere('e.requiresDocumentNumber = true') - ->andWhere("e.invoiceNumber IS NULL OR e.invoiceNumber = ''"); } if ('' !== $search) { diff --git a/src/Workflow/Action/CreatePercentageEntryAction.php b/src/Workflow/Action/CreatePercentageEntryAction.php index afdb2f60..fb1ae938 100644 --- a/src/Workflow/Action/CreatePercentageEntryAction.php +++ b/src/Workflow/Action/CreatePercentageEntryAction.php @@ -29,6 +29,8 @@ * same one the invoice itself was booked against * taxRateId int|null – tax rate recorded on the entry * remark string – free text, %number% is replaced by the invoice number + * requiresDocumentNumber string – '1' marks the entry as waiting for a document + * reference, which also holds the month open */ class CreatePercentageEntryAction implements WorkflowActionInterface { @@ -107,6 +109,17 @@ public function getConfigSchema(): array 'help' => 'workflow.form.percentage_entry_remark_help', 'default' => '', ], + [ + 'key' => 'requiresDocumentNumber', + 'type' => 'select', + 'label' => 'workflow.form.percentage_entry_requires_document', + 'help' => 'workflow.form.percentage_entry_requires_document_help', + 'options' => [ + ['value' => '1', 'label' => 'workflow.form.percentage_entry_requires_document_yes'], + ['value' => '0', 'label' => 'workflow.form.percentage_entry_requires_document_no'], + ], + 'default' => '1', + ], ]; } @@ -133,9 +146,13 @@ public function execute(array $config, mixed $entity, array $context): string $remark = str_replace('%number%', (string) $entity->getNumber(), trim((string) ($config['remark'] ?? ''))); $entry = $this->bookingJournalService->createEntryFromStatement( - // The invoice's own date, so the deduction sits next to the revenue - // entry even when the workflow runs later. - $entity->getDate(), + // The day the workflow runs, not the invoice date. The action is + // meant for a workflow that runs when the invoice is marked as paid, + // so today is the day the payment was recorded - and a deduction + // settled out of that payment belongs in the month it was settled, + // not in the one the invoice was written in. Nothing enforces that + // trigger, so the form says which day the entry gets. + new \DateTime('today'), number_format($amount, 2, '.', ''), !empty($config['debitAccountId']) ? $this->accountRepo->find((int) $config['debitAccountId']) : null, !empty($config['creditAccountId']) ? $this->accountRepo->find((int) $config['creditAccountId']) : null, @@ -147,14 +164,19 @@ public function execute(array $config, mixed $entity, array $context): string null, // Deliberately no invoiceId either: the bank import re-dates every // entry carrying one when a statement line matches that invoice, - // and a deduction belongs to the invoice date rather than to the - // payout. The invoice number stays traceable through the remark. + // which would drag the deduction to the payout date - and a payout + // covering several invoices says nothing about any single one of + // them. The date is chosen above; the bank import must not override + // it. The invoice number stays traceable through the remark. null, null, !empty($config['taxRateId']) ? $this->taxRateRepo->find((int) $config['taxRateId']) : null, ); - $entry->setRequiresDocumentNumber(true); + // Defaults to true: workflows configured before the choice existed all + // book deductions that are documented by a supplier invoice arriving + // later, and silently dropping the guard would let their month close. + $entry->setRequiresDocumentNumber('0' !== (string) ($config['requiresDocumentNumber'] ?? '1')); return $this->translator->trans('workflow.log.percentage_entry_created', [ // Formatted like the amount beside it: "1,40" rather than PHP's "1.4". diff --git a/templates/BookingJournal/entries.html.twig b/templates/BookingJournal/entries.html.twig index aae5ead7..35bb2aa9 100644 --- a/templates/BookingJournal/entries.html.twig +++ b/templates/BookingJournal/entries.html.twig @@ -11,20 +11,6 @@ {% block content %}
- {# Shown here rather than only when closing fails: the reference is easiest - to supply while the month is still being worked on. #} - {% if missingDocumentNumbers|default(0) > 0 %} - - {% endif %}
@@ -168,7 +151,7 @@ {% endif %} {{ entry.taxRate ? entry.taxRate.name : '-' }} - {{ entry.invoiceNumber ?? '-' }} + {% if entry.missingDocumentNumber %}{{ 'accounting.journal.entry.missing_document'|trans }}{% else %}{{ entry.invoiceNumber ?? '-' }}{% endif %} {{ entry.remark ?? '' }} {% if not batch.closed %} @@ -242,7 +225,7 @@ {{ entry.counterAccountLegacy ?? '-' }} {% endif %} - {{ entry.invoiceNumber ?? '-' }} + {% if entry.missingDocumentNumber %}{{ 'accounting.journal.entry.missing_document'|trans }}{% else %}{{ entry.invoiceNumber ?? '-' }}{% endif %} {{ entry.remark ?? '' }} {% if not batch.closed %} @@ -304,7 +287,7 @@ {{ entry.debitAccount ? entry.debitAccount.label : (entry.counterAccountLegacy ?? '-') }} {{ entry.creditAccount ? entry.creditAccount.label : '-' }} {{ entry.taxRate ? entry.taxRate.name : '-' }} - {{ entry.invoiceNumber ?? '-' }} + {% if entry.missingDocumentNumber %}{{ 'accounting.journal.entry.missing_document'|trans }}{% else %}{{ entry.invoiceNumber ?? '-' }}{% endif %} {{ entry.remark ?? '' }} {% if not batch.closed %} diff --git a/tests/Functional/CreatePercentageEntryActionTest.php b/tests/Functional/CreatePercentageEntryActionTest.php index d7be7869..d28058af 100644 --- a/tests/Functional/CreatePercentageEntryActionTest.php +++ b/tests/Functional/CreatePercentageEntryActionTest.php @@ -74,9 +74,11 @@ public function testBooksCommissionAndFeeTheWayTheyWereBookedByHand(): void self::assertSame('Zahlungsgebühr '.$invoice->getNumber(), $entries[1]->getRemark()); } - public function testTheEntryCarriesTheInvoiceDateAndTheConfiguredAccounts(): void + public function testTheEntryCarriesTheExecutionDateAndTheConfiguredAccounts(): void { - $invoice = $this->createInvoice(115.20); + // The invoice date is months back, so an entry carrying it instead of + // the day the payment was recorded would be plain to see. + $invoice = $this->createInvoice(115.20, '2026-02-11'); $action = static::getContainer()->get(WorkflowActionRegistry::class)->get('create_percentage_entry'); $since = $this->lastEntryId(); @@ -85,7 +87,7 @@ public function testTheEntryCarriesTheInvoiceDateAndTheConfiguredAccounts(): voi $entry = $this->entriesSince($since)[0]; - self::assertSame($invoice->getDate()->format('Y-m-d'), $entry->getDate()->format('Y-m-d')); + self::assertSame((new \DateTime('today'))->format('Y-m-d'), $entry->getDate()->format('Y-m-d')); self::assertSame($this->account('3123')->getId(), $entry->getDebitAccount()?->getId()); self::assertSame($this->account('1200')->getId(), $entry->getCreditAccount()?->getId()); // Left unset on purpose: the bank import re-dates entries carrying one. @@ -114,6 +116,24 @@ public function testTheEntryWaitsForItsDocumentNumber(): void self::assertFalse($entry->isMissingDocumentNumber(), 'supplying the number completes the entry'); } + public function testTheEntryCanBeConfiguredNotToWait(): void + { + // Not every deduction is documented by a paper of its own, and one that + // is not must never hold the month open. + $invoice = $this->createInvoice(115.20); + $action = static::getContainer()->get(WorkflowActionRegistry::class)->get('create_percentage_entry'); + $since = $this->lastEntryId(); + + $config = $this->config('12', '') + ['requiresDocumentNumber' => '0']; + $action->execute($config, $invoice, []); + $this->em()->flush(); + + $entry = $this->entriesSince($since)[0]; + + self::assertFalse($entry->requiresDocumentNumber()); + self::assertFalse($entry->isMissingDocumentNumber()); + } + public function testTheBatchCountsTheWaitingEntry(): void { $invoice = $this->createInvoice(115.20); @@ -140,16 +160,13 @@ public function testTheMonthCannotBeClosedWhileAnEntryWaits(): void $client = static::createClient(); $client->loginUser($this->adminUser()); - // A month of its own: entries left waiting by the other tests would - // otherwise keep this batch open no matter what happens here. - $invoice = $this->createInvoice(115.20, '2026-09-15'); + $invoice = $this->createInvoice(115.20); $action = static::getContainer()->get(WorkflowActionRegistry::class)->get('create_percentage_entry'); $since = $this->lastEntryId(); $action->execute($this->config('12', ''), $invoice, []); $this->em()->flush(); $entry = $this->entriesSince($since)[0]; - $entryId = $entry->getId(); $batchId = $entry->getBookingBatch()->getId(); // Each client request reboots the kernel, so state is read back from @@ -157,7 +174,10 @@ public function testTheMonthCannotBeClosedWhileAnEntryWaits(): void $this->toggleBatch($client, $batchId); self::assertFalse($this->isBatchClosed($batchId), 'batch closed despite a waiting entry'); - $this->setEntryDocumentNumber($entryId, '1656376969'); + // Every entry booked here lands in the current month, so the other + // tests leave their own waiters in this batch. They have to be + // completed too before the guard can let go. + $this->completeWaitingEntries($batchId); $this->toggleBatch($client, $batchId); self::assertTrue($this->isBatchClosed($batchId), 'batch stayed open although the number was supplied'); @@ -166,30 +186,6 @@ public function testTheMonthCannotBeClosedWhileAnEntryWaits(): void $this->reopenBatch($batchId); } - public function testTheFilterListsExactlyTheWaitingEntries(): void - { - // The warning is only actionable if it can point at the entries it - // counts - this is the view it links to. - $invoice = $this->createInvoice(115.20, '2026-10-15'); - $action = static::getContainer()->get(WorkflowActionRegistry::class)->get('create_percentage_entry'); - $since = $this->lastEntryId(); - $action->execute($this->config('12', ''), $invoice, []); - $this->em()->flush(); - - $entry = $this->entriesSince($since)[0]; - $repo = $this->em()->getRepository(\App\Entity\BookingEntry::class); - $batch = $entry->getBookingBatch(); - - $waiting = $repo->findByBatch($batch, '', 1, 20, \App\Repository\BookingEntryRepository::MODE_MISSING_DOCUMENT); - self::assertSame([$entry->getId()], array_map(static fn ($e) => $e->getId(), iterator_to_array($waiting))); - - $entry->setInvoiceNumber('1656376969'); - $this->em()->flush(); - - $afterwards = $repo->findByBatch($batch, '', 1, 20, \App\Repository\BookingEntryRepository::MODE_MISSING_DOCUMENT); - self::assertCount(0, iterator_to_array($afterwards), 'entry still listed after the number was supplied'); - } - private function isBatchClosed(int $id): bool { return (bool) $this->connection()->fetchOne('SELECT is_closed FROM booking_batches WHERE id = ?', [$id]); @@ -200,9 +196,15 @@ private function reopenBatch(int $id): void $this->connection()->executeStatement('UPDATE booking_batches SET is_closed = 0 WHERE id = ?', [$id]); } - private function setEntryDocumentNumber(int $id, string $number): void + /** Supplies the reference every entry of the batch is still waiting for. */ + private function completeWaitingEntries(int $batchId): void { - $this->connection()->executeStatement('UPDATE booking_entries SET invoice_number = ? WHERE id = ?', [$number, $id]); + $this->connection()->executeStatement( + "UPDATE booking_entries SET invoice_number = '1656376969' + WHERE booking_batch_id = ? AND requires_document_number = 1 + AND (invoice_number IS NULL OR invoice_number = '')", + [$batchId] + ); } private function connection(): \Doctrine\DBAL\Connection diff --git a/tests/Unit/Workflow/CreatePercentageEntryActionTest.php b/tests/Unit/Workflow/CreatePercentageEntryActionTest.php index 86620814..5ae35ccd 100644 --- a/tests/Unit/Workflow/CreatePercentageEntryActionTest.php +++ b/tests/Unit/Workflow/CreatePercentageEntryActionTest.php @@ -83,8 +83,8 @@ public function testLeavesTheDocumentNumberEmptyForLater(): void public function testLeavesTheInvoiceIdUnsetSoThePayoutDoesNotRedateIt(): void { // The bank import re-dates every entry carrying an invoiceId once a - // statement line matches that invoice; a deduction belongs to the - // invoice date instead. + // statement line matches that invoice; a deduction belongs to the day + // the payment was recorded instead. $captured = null; $action = $this->makeAction(gross: 100.0, capture: $captured); diff --git a/translations/BookingJournal/messages.de.yaml b/translations/BookingJournal/messages.de.yaml index 299b9a41..4103aa79 100644 --- a/translations/BookingJournal/messages.de.yaml +++ b/translations/BookingJournal/messages.de.yaml @@ -222,10 +222,8 @@ accounting.journal.back: Zurück zum Journal accounting.journal.flash.batch_created: Monat erfolgreich angelegt. accounting.journal.flash.batch_closed: Monat abgeschlossen. accounting.journal.flash.batch_missing_document_numbers: 'Monat kann nicht abgeschlossen werden: %count% Buchung(en) warten noch auf eine Belegnummer.' -accounting.journal.missing_document_numbers: '%count% Buchung(en) ohne Belegnummer' -accounting.journal.missing_document_numbers_hint: Diese Buchungen wurden vor dem zugehörigen Beleg erfasst. Der Monat lässt sich erst abschließen, wenn die Belegnummer nachgetragen ist. -accounting.journal.filter.missing_document: Ohne Belegnummer -accounting.journal.missing_document_numbers_show: Diese Buchungen anzeigen +accounting.journal.entry.missing_document: Beleg fehlt +accounting.journal.entry.missing_document_hint: Diese Buchung wurde vor dem zugehörigen Beleg erfasst. Der Monat lässt sich erst abschließen, wenn die Belegnummer nachgetragen ist. accounting.journal.flash.batch_reopened: Monat wieder geöffnet. accounting.journal.flash.entry_created: Buchung erfolgreich angelegt. accounting.journal.flash.entry_updated: Buchung erfolgreich aktualisiert. diff --git a/translations/BookingJournal/messages.en.yaml b/translations/BookingJournal/messages.en.yaml index ce3c96ce..ee922b4c 100644 --- a/translations/BookingJournal/messages.en.yaml +++ b/translations/BookingJournal/messages.en.yaml @@ -222,10 +222,8 @@ accounting.journal.back: Back to Journal accounting.journal.flash.batch_created: Month created successfully. accounting.journal.flash.batch_closed: Month closed. accounting.journal.flash.batch_missing_document_numbers: 'Month cannot be closed: %count% entry/entries still wait for a document number.' -accounting.journal.missing_document_numbers: '%count% entry/entries without a document number' -accounting.journal.missing_document_numbers_hint: These entries were booked before the document they refer to. The month can only be closed once the number has been supplied. -accounting.journal.filter.missing_document: Without document number -accounting.journal.missing_document_numbers_show: Show these entries +accounting.journal.entry.missing_document: Document missing +accounting.journal.entry.missing_document_hint: This entry was booked before the document it refers to. The month can only be closed once the number has been supplied. accounting.journal.flash.batch_reopened: Month reopened. accounting.journal.flash.entry_created: Entry created successfully. accounting.journal.flash.entry_updated: Entry updated successfully. diff --git a/translations/Workflow/messages.de.yaml b/translations/Workflow/messages.de.yaml index 76d2b475..00ef6980 100644 --- a/translations/Workflow/messages.de.yaml +++ b/translations/Workflow/messages.de.yaml @@ -83,6 +83,10 @@ workflow: percentage_entry_tax_rate: "Steuersatz" percentage_entry_remark: "Bemerkung" percentage_entry_remark_help: "%number% wird durch die Rechnungsnummer ersetzt, z.B. „Kommission %number%“." + percentage_entry_requires_document: Wartet auf Belegnummer + percentage_entry_requires_document_help: Die Buchung erhält das Datum des Tages, an dem der Workflow läuft, und zählt zu dessen Monat. Markiert sie mit „Beleg fehlt“, solange keine Belegnummer eingetragen ist. Der Monat lässt sich dann erst abschließen, wenn sie nachgetragen wurde. Auf „Nein“ stellen, wenn zu diesem Abzug kein eigener Beleg kommt. + percentage_entry_requires_document_yes: Ja + percentage_entry_requires_document_no: Nein # Trigger labels trigger: diff --git a/translations/Workflow/messages.en.yaml b/translations/Workflow/messages.en.yaml index f10fbc18..3d7eee46 100644 --- a/translations/Workflow/messages.en.yaml +++ b/translations/Workflow/messages.en.yaml @@ -83,6 +83,10 @@ workflow: percentage_entry_tax_rate: "Tax rate" percentage_entry_remark: "Remark" percentage_entry_remark_help: "%number% is replaced by the invoice number, e.g. \"Commission %number%\"." + percentage_entry_requires_document: Waits for a document number + percentage_entry_requires_document_help: The entry is dated the day the workflow runs and belongs to that month. Marks it as "Document missing" while no document number is filled in. The month can then only be closed once it has been supplied. Set to "No" when no separate document is issued for this deduction. + percentage_entry_requires_document_yes: "Yes" + percentage_entry_requires_document_no: "No" trigger: online_booking_created: "New online booking received" From 676ce85649cb99834f2fa4d4985fdf21ff1d69a2 Mon Sep 17 00:00:00 2001 From: MeisterAdebar Date: Tue, 15 Sep 2026 13:24:38 +0200 Subject: [PATCH 05/33] Keep accounts and tax rates a workflow books with from being deleted The existing guard knew one action by name: an account was undeletable while create_booking_entry named it in its config. The percentage action names two of its own, and a tax rate was never guarded at all - deleting one only checked the entries already booked with it. What follows is worse than an error, because nothing reports it. The workflow keeps running, the account or the rate it was configured with is simply gone, and every entry it books from then on carries one field less. The journal is wrong and looks fine. The lookup now reads the config keys from a table per action type, so the next action booking to the journal is an entry there rather than another branch, and it counts each workflow once however many of its fields point at the same thing. The method loses "CreateBookingEntry" from its name for the same reason - it stopped being about that one action. The warning shown when a deletion is refused named booked entries as the only reason a tax rate could be held back, and offered the validity fields as the way out - neither of which fits the reason added here. A workflow holding on to the rate now gets a message of its own, saying to pick another rate there; the one for booked entries stays as it was. The account warning has named workflows all along. --- .../BookingJournalSettingsController.php | 14 +++- src/Repository/WorkflowRepository.php | 53 ++++++++++--- .../BookingJournalControllerTest.php | 77 +++++++++++++++++++ translations/BookingJournal/messages.de.yaml | 1 + translations/BookingJournal/messages.en.yaml | 1 + 5 files changed, 135 insertions(+), 11 deletions(-) diff --git a/src/Controller/BookingJournalSettingsController.php b/src/Controller/BookingJournalSettingsController.php index cc08c31a..ecbef653 100644 --- a/src/Controller/BookingJournalSettingsController.php +++ b/src/Controller/BookingJournalSettingsController.php @@ -295,6 +295,7 @@ public function deleteTaxRate( EntityManagerInterface $em, Request $request, BookingEntryRepository $bookingEntryRepo, + WorkflowRepository $workflowRepo, ): Response { if (!$this->isCsrfTokenValid('delete'.$taxRate->getId(), $request->request->get('_token'))) { $this->addFlash('danger', 'flash.invalidtoken'); @@ -308,6 +309,17 @@ public function deleteTaxRate( return new Response('', Response::HTTP_NO_CONTENT); } + // A workflow counts as much as a booked entry does: an action whose tax + // rate is gone keeps running and books without one, which is a wrong + // figure in the journal rather than an error anybody sees. Its own + // message, since the way out differs - a workflow can be pointed at + // another rate, a booked entry cannot. + if ($workflowRepo->countActionTaxRateReferences($taxRate) > 0) { + $this->addFlash('warning', 'accounting.taxrates.flash.cannot_delete_in_workflow'); + + return new Response('', Response::HTTP_NO_CONTENT); + } + $em->remove($taxRate); $em->flush(); @@ -347,7 +359,7 @@ private function accountHasReferences( return true; } - return $workflowRepo->countCreateBookingEntryAccountReferences($account) > 0; + return $workflowRepo->countActionAccountReferences($account) > 0; } private function ensureExclusiveOpeningBalanceAccount( diff --git a/src/Repository/WorkflowRepository.php b/src/Repository/WorkflowRepository.php index b94ae79c..92db7ce0 100644 --- a/src/Repository/WorkflowRepository.php +++ b/src/Repository/WorkflowRepository.php @@ -5,6 +5,7 @@ namespace App\Repository; use App\Entity\AccountingAccount; +use App\Entity\TaxRate; use App\Entity\Workflow; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\Persistence\ManagerRegistry; @@ -33,21 +34,53 @@ public function findBySystemCode(string $systemCode): ?Workflow return $this->findOneBy(['systemCode' => $systemCode]); } - public function countCreateBookingEntryAccountReferences(AccountingAccount $account): int + /** + * Where an accounting account can be named in an action's config, by action + * type. Every action booking to the journal belongs in here: what is missing + * can be deleted while a workflow still points at it, after which the action + * books without that account and says nothing. + */ + private const ACCOUNT_CONFIG_KEYS = [ + 'create_booking_entry' => ['debitAccountId', 'fallbackCreditAccountId'], + 'create_percentage_entry' => ['debitAccountId', 'creditAccountId'], + ]; + + /** Where a tax rate can be named in an action's config; see the accounts above. */ + private const TAX_RATE_CONFIG_KEYS = [ + 'create_percentage_entry' => ['taxRateId'], + ]; + + public function countActionAccountReferences(AccountingAccount $account): int + { + return $this->countConfigReferences(self::ACCOUNT_CONFIG_KEYS, $account->getId()); + } + + public function countActionTaxRateReferences(TaxRate $taxRate): int { - $accountId = $account->getId(); - if (null === $accountId) { + return $this->countConfigReferences(self::TAX_RATE_CONFIG_KEYS, $taxRate->getId()); + } + + /** + * How many workflows name this id under any of the given config keys. + * + * @param array $keysByActionType + */ + private function countConfigReferences(array $keysByActionType, ?int $id): int + { + if (null === $id) { return 0; } $references = 0; - foreach ($this->findBy(['actionType' => 'create_booking_entry']) as $workflow) { - $config = $workflow->getActionConfig(); - $debitAccountId = (int) ($config['debitAccountId'] ?? 0); - $fallbackCreditAccountId = (int) ($config['fallbackCreditAccountId'] ?? 0); - - if ($debitAccountId === $accountId || $fallbackCreditAccountId === $accountId) { - ++$references; + foreach ($keysByActionType as $actionType => $keys) { + foreach ($this->findBy(['actionType' => $actionType]) as $workflow) { + $config = $workflow->getActionConfig(); + foreach ($keys as $key) { + if ((int) ($config[$key] ?? 0) === $id) { + ++$references; + continue 2; + } + } } } diff --git a/tests/Functional/BookingJournalControllerTest.php b/tests/Functional/BookingJournalControllerTest.php index 71f608c2..00eb0091 100644 --- a/tests/Functional/BookingJournalControllerTest.php +++ b/tests/Functional/BookingJournalControllerTest.php @@ -16,6 +16,7 @@ use Doctrine\Persistence\ManagerRegistry; use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface; +use Symfony\Component\HttpFoundation\Session\FlashBagAwareSessionInterface; final class BookingJournalControllerTest extends WebTestCase { @@ -290,6 +291,53 @@ public function testYearViewDoesNotOfferEditingForClosedMonths(): void self::assertStringNotContainsString('booking-journal#openOffcanvas', (string) $client->getResponse()->getContent()); } + // ── Deleting a tax rate ───────────────────────────────────────── + + public function testTaxRateConfiguredInAWorkflowCannotBeDeleted(): void + { + // Without the guard the rate goes, the action keeps running, and every + // entry it books from then on carries no tax rate at all - silently. + $client = static::createClient(); + $client->loginUser($this->createCashJournalUser()); + + $em = $this->getEntityManager(); + $rate = new TaxRate(); + $rate->setName('Workflow-Satz '.bin2hex(random_bytes(3))); + $rate->setRate('19.00'); + $em->persist($rate); + $em->flush(); + + $workflow = new Workflow(); + $workflow->setName('Portalgebühr '.bin2hex(random_bytes(3))); + $workflow->setTriggerType('invoice.status_changed'); + $workflow->setActionType('create_percentage_entry'); + $workflow->setActionConfig(['percent' => '12', 'taxRateId' => (string) $rate->getId()]); + $em->persist($workflow); + $em->flush(); + + $this->deleteTaxRate($client, $rate); + + self::assertNotNull( + $em->getRepository(TaxRate::class)->find($rate->getId()), + 'a tax rate a workflow books with was deleted' + ); + // Named as the workflow's doing, not the journal's: the way out differs. + $session = $client->getRequest()->getSession(); + self::assertInstanceOf(FlashBagAwareSessionInterface::class, $session); + self::assertSame(['accounting.taxrates.flash.cannot_delete_in_workflow'], $session->getFlashBag()->peek('warning')); + + // Re-read: submitting the delete cleared the manager the entity came from. + $em->remove($em->getRepository(Workflow::class)->find($workflow->getId())); + $em->flush(); + + $this->deleteTaxRate($client, $rate); + + self::assertNull( + $em->getRepository(TaxRate::class)->find($rate->getId()), + 'the rate stayed undeletable although nothing references it any more' + ); + } + public function testYearViewIsForbiddenWithoutRole(): void { $client = static::createClient(); @@ -311,6 +359,35 @@ private function getEntityManager(): EntityManagerInterface return $this->em; } + /** + * Posts the delete the settings page offers for this rate. The token has to + * come from that page, which is where it is minted. + */ + private function deleteTaxRate(\Symfony\Bundle\FrameworkBundle\KernelBrowser $client, TaxRate $rate): void + { + $url = '/journal/settings/tax-rates/'.$rate->getId().'/delete'; + $crawler = $client->request('GET', '/journal/settings'); + + $token = null; + foreach ($crawler->filter('button[data-popover="delete"]') as $button) { + // The crawler hands out DOMNode; only an element carries attributes. + if (!$button instanceof \DOMElement) { + continue; + } + + $content = $button->getAttribute('data-bs-content'); + if (str_contains($content, $url) && preg_match('/name="_token" value="([^"]+)"/', $content, $m)) { + $token = $m[1]; + break; + } + } + + self::assertNotNull($token, 'no delete popover for the tax rate on the settings page'); + + $client->request('DELETE', $url, ['_token' => $token]); + $this->getEntityManager()->clear(); + } + private function createCashJournalUser(): User { return $this->createUserWithRoles(['ROLE_CASHJOURNAL']); diff --git a/translations/BookingJournal/messages.de.yaml b/translations/BookingJournal/messages.de.yaml index 4103aa79..54c3a692 100644 --- a/translations/BookingJournal/messages.de.yaml +++ b/translations/BookingJournal/messages.de.yaml @@ -133,6 +133,7 @@ accounting.taxrates.flash.created: Steuersatz erfolgreich angelegt. accounting.taxrates.flash.updated: Steuersatz erfolgreich aktualisiert. accounting.taxrates.flash.deleted: Steuersatz erfolgreich gelöscht. accounting.taxrates.flash.cannot_delete_in_use: "Dieser Steuersatz kann nicht gelöscht werden, weil er bereits in Buchungsjournal-Einträgen verwendet wird. Nutzen Sie die Gültigkeitsfelder, damit er künftig nicht mehr verwendet wird." +accounting.taxrates.flash.cannot_delete_in_workflow: "Dieser Steuersatz kann nicht gelöscht werden, weil ein Workflow damit bucht. Wähle im Workflow zuerst einen anderen Steuersatz." # Journal - Batches accounting.journal.batch.add: Monat hinzufügen diff --git a/translations/BookingJournal/messages.en.yaml b/translations/BookingJournal/messages.en.yaml index ee922b4c..c08ea51f 100644 --- a/translations/BookingJournal/messages.en.yaml +++ b/translations/BookingJournal/messages.en.yaml @@ -133,6 +133,7 @@ accounting.taxrates.flash.created: Tax rate created successfully. accounting.taxrates.flash.updated: Tax rate updated successfully. accounting.taxrates.flash.deleted: Tax rate deleted successfully. accounting.taxrates.flash.cannot_delete_in_use: "This tax rate cannot be deleted because it is already used in booking journal entries. Use the validity fields to prevent future use." +accounting.taxrates.flash.cannot_delete_in_workflow: "This tax rate cannot be deleted because a workflow books with it. Choose a different tax rate in the workflow first." # Journal - Batches accounting.journal.batch.add: Add Month From aa3e2c2a199c5d915def153ec195a46b8f901d7d Mon Sep 17 00:00:00 2001 From: MeisterAdebar Date: Tue, 15 Sep 2026 13:25:21 +0200 Subject: [PATCH 06/33] Offer only the tax rates the active chart of accounts holds The action built its own list from every tax rate there is, so an SKR04 rate could be picked while SKR03 was in use, as could a rate that expired years ago or does not apply yet. The settings service says as much about itself - it exists to scope account and tax rate listings to the preset in use - and the manual entry form has always done it. The list moves to the controller, which knows which preset is active, the way the account select already works. The action asks for a tax_rate_select and stops knowing where the rates come from. Narrowing a list that a stored config points into needs care: the form writes back whatever its select holds, and restoring a value the select does not offer leaves it empty rather than failing. A rate that has since fallen out of scope would be dropped from the workflow the next time somebody opened it for an unrelated change. Rates a workflow is configured with are therefore kept on the list whatever the filters say. The labels gain the percentage, as in the entry form, where "reduced" alone was never enough to tell two rates apart. --- src/Controller/WorkflowController.php | 47 +++++ src/Repository/WorkflowRepository.php | 28 +++ .../Action/CreatePercentageEntryAction.php | 11 +- .../Functional/WorkflowTaxRateOptionsTest.php | 173 ++++++++++++++++++ 4 files changed, 252 insertions(+), 7 deletions(-) create mode 100644 tests/Functional/WorkflowTaxRateOptionsTest.php diff --git a/src/Controller/WorkflowController.php b/src/Controller/WorkflowController.php index 5b737282..11a785b3 100644 --- a/src/Controller/WorkflowController.php +++ b/src/Controller/WorkflowController.php @@ -9,6 +9,7 @@ use App\Entity\Template; use App\Entity\Workflow; use App\Repository\AccountingAccountRepository; +use App\Repository\TaxRateRepository; use App\Service\BookingJournal\AccountingSettingsService; use App\Service\AppSettingsService; use App\Service\DisplayNameResolver; @@ -40,6 +41,7 @@ public function __construct( private readonly AppSettingsService $settingsService, private readonly AccountingSettingsService $accountingSettingsService, private readonly AccountingAccountRepository $accountRepo, + private readonly TaxRateRepository $taxRateRepo, private readonly DisplayNameResolver $displayNameResolver, ) { } @@ -227,6 +229,9 @@ private function enrichAndTranslateSchema(array $schema, string $entityClass): a } elseif ($type === 'accounting_account_select') { $field['type'] = 'select'; $field['options'] = $this->loadAccountingAccountOptions(); + } elseif ($type === 'tax_rate_select') { + $field['type'] = 'select'; + $field['options'] = $this->loadTaxRateOptions(); } elseif ($type === 'attachment_list') { // Unlike the *_select pseudo types this keeps its own type: the client // renders repeatable rows instead of a plain select. @@ -309,6 +314,48 @@ private function loadAccountingAccountOptions(): array } + /** + * The tax rates a workflow may be configured with: those the active chart of + * accounts holds and that apply today. + * + * An unscoped list would offer another preset's rates - an SKR04 rate under + * an active SKR03 - as well as rates that have expired or do not apply yet, + * none of which an entry booked by the action could carry sensibly. + * + * Rates already configured somewhere are kept regardless of both filters: a + * rate that has since expired is what an existing workflow books with, and + * dropping it from the list would clear the selection the next time somebody + * opens that workflow for an unrelated change. + * + * @return array> + */ + private function loadTaxRateOptions(): array + { + $preset = $this->accountingSettingsService->getActivePreset(); + $rates = $this->taxRateRepo->findValidAt(new \DateTime('today'), $preset); + + $known = []; + foreach ($rates as $rate) { + $known[$rate->getId()] = true; + } + + foreach ($this->workflowRepository->findReferencedTaxRateIds() as $id) { + if (!isset($known[$id]) && null !== ($rate = $this->taxRateRepo->find($id))) { + $rates[] = $rate; + } + } + + $options = [['value' => '', 'label' => '–']]; + foreach ($rates as $rate) { + $options[] = [ + 'value' => (string) $rate->getId(), + 'label' => $rate->getName().' ('.number_format($rate->getRateFloat(), 2, ',', '.').' %)', + ]; + } + + return $options; + } + /** @param array $field */ private function translateField(array $field): array { diff --git a/src/Repository/WorkflowRepository.php b/src/Repository/WorkflowRepository.php index 92db7ce0..0d775a01 100644 --- a/src/Repository/WorkflowRepository.php +++ b/src/Repository/WorkflowRepository.php @@ -60,6 +60,34 @@ public function countActionTaxRateReferences(TaxRate $taxRate): int return $this->countConfigReferences(self::TAX_RATE_CONFIG_KEYS, $taxRate->getId()); } + /** + * Ids of the tax rates workflows currently point at. + * + * Read by the form offering the choice, which narrows its list to what the + * active chart of accounts holds today: a rate a workflow was configured + * with has to stay in that list even once it falls outside, or opening the + * workflow would drop the selection without a word. + * + * @return int[] + */ + public function findReferencedTaxRateIds(): array + { + $ids = []; + foreach (self::TAX_RATE_CONFIG_KEYS as $actionType => $keys) { + foreach ($this->findBy(['actionType' => $actionType]) as $workflow) { + $config = $workflow->getActionConfig(); + foreach ($keys as $key) { + $id = (int) ($config[$key] ?? 0); + if (0 !== $id) { + $ids[$id] = $id; + } + } + } + } + + return array_values($ids); + } + /** * How many workflows name this id under any of the given config keys. * diff --git a/src/Workflow/Action/CreatePercentageEntryAction.php b/src/Workflow/Action/CreatePercentageEntryAction.php index fb1ae938..9b820d00 100644 --- a/src/Workflow/Action/CreatePercentageEntryAction.php +++ b/src/Workflow/Action/CreatePercentageEntryAction.php @@ -68,11 +68,6 @@ public function getSupportedTriggerTypes(): array */ public function getConfigSchema(): array { - $taxRateOptions = [['value' => '', 'label' => '-']]; - foreach ($this->taxRateRepo->findAllOrdered() as $taxRate) { - $taxRateOptions[] = ['value' => (string) $taxRate->getId(), 'label' => $taxRate->getName()]; - } - return [ [ 'key' => 'percent', @@ -97,9 +92,11 @@ public function getConfigSchema(): array ], [ 'key' => 'taxRateId', - 'type' => 'select', + // Resolved by the controller, which scopes the list to the chart + // of accounts in use - an action has no business knowing which + // one that is. + 'type' => 'tax_rate_select', 'label' => 'workflow.form.percentage_entry_tax_rate', - 'options' => $taxRateOptions, 'default' => '', ], [ diff --git a/tests/Functional/WorkflowTaxRateOptionsTest.php b/tests/Functional/WorkflowTaxRateOptionsTest.php new file mode 100644 index 00000000..30ae1945 --- /dev/null +++ b/tests/Functional/WorkflowTaxRateOptionsTest.php @@ -0,0 +1,173 @@ +loginUser($this->adminUser()); + $this->activatePreset(AccountingSettings::PRESET_SKR03); + + $own = $this->createTaxRate('Eigener Satz', AccountingSettings::PRESET_SKR03); + $foreign = $this->createTaxRate('Fremder Kontenrahmen', AccountingSettings::PRESET_SKR04); + $expired = $this->createTaxRate('Abgelaufen', AccountingSettings::PRESET_SKR03, new \DateTime('-1 year')); + $future = $this->createTaxRate('Noch nicht gültig', AccountingSettings::PRESET_SKR03, null, new \DateTime('+1 year')); + + $options = $this->taxRateOptions($client); + + self::assertContains((string) $own->getId(), $options); + self::assertNotContains((string) $foreign->getId(), $options, 'a rate of another chart of accounts was offered'); + self::assertNotContains((string) $expired->getId(), $options, 'an expired rate was offered'); + self::assertNotContains((string) $future->getId(), $options, 'a rate that does not apply yet was offered'); + } + + public function testARateAWorkflowAlreadyBooksWithStaysOnTheList(): void + { + // Narrowing the list must not quietly empty a select: the form writes + // back whatever the select holds, so a configured rate that has dropped + // out would be lost the next time somebody opens that workflow. + $client = static::createClient(); + $client->loginUser($this->adminUser()); + $this->activatePreset(AccountingSettings::PRESET_SKR03); + + $expired = $this->createTaxRate('Abgelaufen, aber konfiguriert', AccountingSettings::PRESET_SKR03, new \DateTime('-1 year')); + + self::assertNotContains((string) $expired->getId(), $this->taxRateOptions($client)); + + $this->createWorkflowBookingWith($expired); + + self::assertContains((string) $expired->getId(), $this->taxRateOptions($client)); + } + + /** + * The values the percentage action's tax rate select offers. + * + * @return string[] + */ + private function taxRateOptions(KernelBrowser $client): array + { + $client->request('POST', '/settings/workflows/compatible-options', ['triggerType' => 'invoice.status_changed']); + + self::assertResponseIsSuccessful(); + $payload = json_decode((string) $client->getResponse()->getContent(), true); + + foreach ($payload['actions'] ?? [] as $action) { + if ('create_percentage_entry' !== $action['type']) { + continue; + } + + foreach ($action['configSchema'] as $field) { + if ('taxRateId' === $field['key']) { + self::assertSame('select', $field['type'], 'the tax rate field was not resolved into a select'); + + return array_column($field['options'], 'value'); + } + } + } + + self::fail('the percentage action offers no tax rate field'); + } + + private function activatePreset(string $preset): void + { + $em = $this->em(); + $settings = $em->getRepository(AccountingSettings::class)->findOneBy([]); + + if (!$settings instanceof AccountingSettings) { + $settings = new AccountingSettings(); + $em->persist($settings); + } + + $settings->setChartPreset($preset); + $em->flush(); + } + + private function createTaxRate( + string $name, + ?string $preset, + ?\DateTime $validTo = null, + ?\DateTime $validFrom = null, + ): TaxRate { + $rate = new TaxRate(); + $rate->setName($name.' '.bin2hex(random_bytes(3))); + $rate->setRate('19.00'); + $rate->setChartPreset($preset); + $rate->setValidFrom($validFrom); + $rate->setValidTo($validTo); + + $em = $this->em(); + $em->persist($rate); + $em->flush(); + + return $rate; + } + + private function createWorkflowBookingWith(TaxRate $rate): Workflow + { + $workflow = new Workflow(); + $workflow->setName('Portalgebühr '.bin2hex(random_bytes(3))); + $workflow->setTriggerType('invoice.status_changed'); + $workflow->setActionType('create_percentage_entry'); + $workflow->setActionConfig(['percent' => '12', 'taxRateId' => (string) $rate->getId()]); + + $em = $this->em(); + $em->persist($workflow); + $em->flush(); + + return $workflow; + } + + private function em(): EntityManagerInterface + { + return static::getContainer()->get(ManagerRegistry::class)->getManager(); + } + + private function adminUser(): User + { + $em = $this->em(); + $passwordHasher = static::getContainer()->get(UserPasswordHasherInterface::class); + + $user = new User(); + $user->setUsername('test_'.bin2hex(random_bytes(6))); + $user->setFirstname('Test'); + $user->setLastname('Admin'); + $user->setEmail(sprintf('test+%s@example.com', bin2hex(random_bytes(4)))); + $user->setActive(true); + $user->setPassword($passwordHasher->hashPassword($user, 'ChangeMe123!')); + + $role = $em->getRepository(Role::class)->findOneBy(['role' => 'ROLE_ADMIN']); + $user->setRoleEntities(null !== $role ? [$role] : []); + + $em->persist($user); + $em->flush(); + + return $user; + } +} From d090a1095de1206eaecbed2342794e51baff2c75 Mon Sep 17 00:00:00 2001 From: MeisterAdebar Date: Tue, 15 Sep 2026 13:25:48 +0200 Subject: [PATCH 07/33] Record the deduction as booked by a workflow, not by hand The journal service creating the entry serves the bank import, where every row is one somebody entered, and marks what it creates accordingly. It leaves the source to the caller, and this caller never said otherwise - so the journal's own record of where its rows came from called a deduction nobody touched a manual entry. Nothing reads the field today beyond the journal's display, which is exactly why it had to be wrong for a while before anyone noticed. --- .../Action/CreatePercentageEntryAction.php | 6 ++++++ .../CreatePercentageEntryActionTest.php | 16 ++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/src/Workflow/Action/CreatePercentageEntryAction.php b/src/Workflow/Action/CreatePercentageEntryAction.php index 9b820d00..d87f93a6 100644 --- a/src/Workflow/Action/CreatePercentageEntryAction.php +++ b/src/Workflow/Action/CreatePercentageEntryAction.php @@ -4,6 +4,7 @@ namespace App\Workflow\Action; +use App\Entity\BookingEntry; use App\Entity\Invoice; use App\Repository\AccountingAccountRepository; use App\Repository\TaxRateRepository; @@ -170,6 +171,11 @@ public function execute(array $config, mixed $entity, array $context): string !empty($config['taxRateId']) ? $this->taxRateRepo->find((int) $config['taxRateId']) : null, ); + // createEntryFromStatement() serves the bank import and marks what it + // creates as manual, leaving the source to the caller. This one is not + // manual: nobody typed it in, a workflow put it there. + $entry->setSourceType(BookingEntry::SOURCE_WORKFLOW); + // Defaults to true: workflows configured before the choice existed all // book deductions that are documented by a supplier invoice arriving // later, and silently dropping the guard would let their month close. diff --git a/tests/Functional/CreatePercentageEntryActionTest.php b/tests/Functional/CreatePercentageEntryActionTest.php index d28058af..fe4622f4 100644 --- a/tests/Functional/CreatePercentageEntryActionTest.php +++ b/tests/Functional/CreatePercentageEntryActionTest.php @@ -94,6 +94,22 @@ public function testTheEntryCarriesTheExecutionDateAndTheConfiguredAccounts(): v self::assertNull($entry->getInvoiceId()); } + public function testTheEntryIsRecordedAsComingFromAWorkflow(): void + { + // The journal service serves the bank import and marks what it creates + // as typed in by hand. Nobody typed this one in, and an entry claiming + // otherwise makes the journal's own record of where its rows came from + // wrong for good. + $invoice = $this->createInvoice(115.20); + $action = static::getContainer()->get(WorkflowActionRegistry::class)->get('create_percentage_entry'); + $since = $this->lastEntryId(); + + $action->execute($this->config('12', ''), $invoice, []); + $this->em()->flush(); + + self::assertSame(BookingEntry::SOURCE_WORKFLOW, $this->entriesSince($since)[0]->getSourceType()); + } + public function testTheEntryWaitsForItsDocumentNumber(): void { // The reference that belongs here is the supplier's invoice for the From f1ae4d66f5cc29cd060ccb77437176fc78c069e1 Mon Sep 17 00:00:00 2001 From: MeisterAdebar Date: Tue, 15 Sep 2026 13:29:40 +0200 Subject: [PATCH 08/33] Carry the document status into a duplicated entry Duplicating an entry copies its values so only what changed has to be typed again, and it left out the one saying the entry is waiting for a document. The copy of a deduction booked ahead of the supplier's invoice looked complete, and the month could be closed on it. Copying the flag alone would have changed nothing: the duplicate only fills a form, which posts to the create route and builds its entry from what was submitted - and the form had no field for it. It has one now, so the flag survives the round trip, and an entry can be marked as waiting by hand rather than only by the workflow that books one. --- src/Controller/BookingJournalController.php | 4 ++ src/Form/BookingEntryType.php | 9 +++ .../BookingJournal/_entry_form.html.twig | 1 + .../BookingJournalControllerTest.php | 65 +++++++++++++++++++ translations/BookingJournal/messages.de.yaml | 2 + translations/BookingJournal/messages.en.yaml | 2 + 6 files changed, 83 insertions(+) diff --git a/src/Controller/BookingJournalController.php b/src/Controller/BookingJournalController.php index 959e1f22..6f08e9dd 100644 --- a/src/Controller/BookingJournalController.php +++ b/src/Controller/BookingJournalController.php @@ -276,6 +276,10 @@ public function duplicateEntry( $copy->setCreditAccount($entry->getCreditAccount()); $copy->setTaxRate($entry->getTaxRate()); $copy->setInvoiceNumber($entry->getInvoiceNumber()); + // Carried over like everything else: a copy of an entry booked ahead of + // its document is waiting for one just as much as the original, and + // losing the flag would let the month close on it. + $copy->setRequiresDocumentNumber($entry->requiresDocumentNumber()); $copy->setRemark($entry->getRemark()); $formOptions = [ diff --git a/src/Form/BookingEntryType.php b/src/Form/BookingEntryType.php index 42837f99..b71b1425 100644 --- a/src/Form/BookingEntryType.php +++ b/src/Form/BookingEntryType.php @@ -11,6 +11,7 @@ use App\Repository\TaxRateRepository; use Symfony\Bridge\Doctrine\Form\Type\EntityType; use Symfony\Component\Form\AbstractType; +use Symfony\Component\Form\Extension\Core\Type\CheckboxType; use Symfony\Component\Form\Extension\Core\Type\ChoiceType; use Symfony\Component\Form\Extension\Core\Type\DateType; use Symfony\Component\Form\Extension\Core\Type\IntegerType; @@ -97,6 +98,14 @@ public function buildForm(FormBuilderInterface $builder, array $options): void 'required' => false, 'attr' => ['maxlength' => 50], ]) + // Entries whose reference is not missing but simply does not exist - + // a cash deposit, a private withdrawal - are the ordinary case, so + // this stays off unless somebody says the entry is waiting for one. + ->add('requiresDocumentNumber', CheckboxType::class, [ + 'label' => 'accounting.journal.entry.requires_document', + 'help' => 'accounting.journal.entry.requires_document_help', + 'required' => false, + ]) ->add('remark', TextType::class, [ 'label' => 'accounting.journal.entry.remark', 'required' => false, diff --git a/templates/BookingJournal/_entry_form.html.twig b/templates/BookingJournal/_entry_form.html.twig index c32c3fbf..b985dc07 100644 --- a/templates/BookingJournal/_entry_form.html.twig +++ b/templates/BookingJournal/_entry_form.html.twig @@ -14,6 +14,7 @@ {% endif %} {{ form_row(form.taxRate) }} {{ form_row(form.invoiceNumber) }} + {{ form_row(form.requiresDocumentNumber) }} {{ form_row(form.remark) }}
+ {% set hasSurcharge = origin.commissionPercent is not null or origin.paymentFeePercent is not null %} +
+
+
+ + +
+
+
+
+
+ + +
+
+ + % +
+
+
+
+ + +
+
+ + % +
+
+
+
\ No newline at end of file diff --git a/tests/Unit/InvoiceServiceGuestSurchargeTest.php b/tests/Unit/InvoiceServiceGuestSurchargeTest.php new file mode 100644 index 00000000..844a9c63 --- /dev/null +++ b/tests/Unit/InvoiceServiceGuestSurchargeTest.php @@ -0,0 +1,126 @@ +invoiceWithOrigin('Booking.com', '12.00', '1.40'); + + $result = $this->resolve($invoice, 115.20); + + self::assertSame('Booking.com', $result['name']); + self::assertSame(13.82, $result['commission']); + self::assertSame(1.61, $result['paymentFee']); + } + + public function testCountsAnOriginWithOnlyOneOfTheTwoPercentages(): void + { + $invoice = $this->invoiceWithOrigin('Fewo-direkt', '15.00', null); + + $result = $this->resolve($invoice, 200.0); + + self::assertSame(30.0, $result['commission']); + self::assertSame(0.0, $result['paymentFee']); + } + + public function testReturnsZeroWhenTheOriginHasNeitherPercentage(): void + { + $invoice = $this->invoiceWithOrigin('Direktbuchung', null, null); + + $result = $this->resolve($invoice, 115.20); + + self::assertNull($result['name']); + self::assertSame(0.0, $result['commission']); + self::assertSame(0.0, $result['paymentFee']); + } + + public function testReturnsZeroWhenNoReservationCarriesAnOrigin(): void + { + $invoice = new Invoice(); + $invoice->addReservation(new Reservation()); + + $result = $this->resolve($invoice, 200.0); + + self::assertNull($result['name']); + self::assertSame(0.0, $result['commission']); + self::assertSame(0.0, $result['paymentFee']); + } + + public function testSkipsOriginsWithoutPercentagesAndTakesTheFirstThatHasOne(): void + { + $invoice = new Invoice(); + $invoice->addReservation($this->reservationWithOrigin('Direktbuchung', null, null)); + $invoice->addReservation($this->reservationWithOrigin('Booking.com', '12.00', '1.40')); + + $result = $this->resolve($invoice, 100.0); + + self::assertSame('Booking.com', $result['name']); + self::assertSame(12.0, $result['commission']); + self::assertSame(1.4, $result['paymentFee']); + } + + /** + * @return array{name: ?string, commission: float, paymentFee: float} + */ + private function resolve(Invoice $invoice, float $brutto): array + { + $method = new \ReflectionMethod(InvoiceService::class, 'resolveGuestSurcharge'); + + return $method->invoke($this->createService(), $invoice, $brutto); + } + + private function invoiceWithOrigin(string $name, ?string $commission, ?string $paymentFee): Invoice + { + $invoice = new Invoice(); + $invoice->addReservation($this->reservationWithOrigin($name, $commission, $paymentFee)); + + return $invoice; + } + + private function reservationWithOrigin(string $name, ?string $commission, ?string $paymentFee): Reservation + { + $origin = new ReservationOrigin(); + $origin->setName($name); + $origin->setCommissionPercent($commission); + $origin->setPaymentFeePercent($paymentFee); + + $reservation = new Reservation(); + $reservation->setReservationOrigin($origin); + + return $reservation; + } + + private function createService(): InvoiceService + { + $em = $this->createStub(EntityManagerInterface::class); + $priceService = $this->createStub(PriceService::class); + $translator = $this->createStub(TranslatorInterface::class); + + $appSettingsService = $this->createStub(AppSettingsService::class); + $appSettingsService->method('getSettings')->willReturn(new AppSettings()); + + return new InvoiceService($em, $priceService, $translator, $appSettingsService, null); + } +} diff --git a/tests/Unit/ReservationOriginSurchargeFormTest.php b/tests/Unit/ReservationOriginSurchargeFormTest.php new file mode 100644 index 00000000..bbef80d2 --- /dev/null +++ b/tests/Unit/ReservationOriginSurchargeFormTest.php @@ -0,0 +1,110 @@ +parse([ + 'name-new' => 'Booking.com', + 'surcharge-enabled-new' => '1', + 'commission-new' => '12', + 'payment-fee-new' => '1,4', + ]); + + self::assertSame('12', $origin->getCommissionPercent()); + // The comma a German keyboard produces is normalised to a dot. + self::assertSame('1.4', $origin->getPaymentFeePercent()); + } + + public function testClearsThePercentagesWhenTheFlagIsMissing(): void + { + // The inputs still submit - they are only hidden - but without the flag + // they must not be stored. + $origin = $this->parse([ + 'name-new' => 'Direktbuchung', + 'commission-new' => '12', + 'payment-fee-new' => '1.4', + ]); + + self::assertNull($origin->getCommissionPercent()); + self::assertNull($origin->getPaymentFeePercent()); + } + + public function testFlaggedButEmptyFieldsStoreNull(): void + { + $origin = $this->parse([ + 'name-new' => 'Booking.com', + 'surcharge-enabled-new' => '1', + 'commission-new' => '', + 'payment-fee-new' => '', + ]); + + self::assertNull($origin->getCommissionPercent()); + self::assertNull($origin->getPaymentFeePercent()); + } + + public function testFlaggedWithoutAnyValueIsRejected(): void + { + $request = new Request([], [ + 'name-new' => 'Booking.com', + 'surcharge-enabled-new' => '1', + 'commission-new' => '', + 'payment-fee-new' => '', + ]); + $origin = $this->service()->getOriginFromForm($request, 'new'); + + self::assertTrue($this->service()->isSurchargeFlagSetWithoutValue($request, 'new', $origin)); + } + + public function testFlaggedWithOneValuePasses(): void + { + $request = new Request([], [ + 'name-new' => 'Booking.com', + 'surcharge-enabled-new' => '1', + 'commission-new' => '12', + 'payment-fee-new' => '', + ]); + $origin = $this->service()->getOriginFromForm($request, 'new'); + + self::assertFalse($this->service()->isSurchargeFlagSetWithoutValue($request, 'new', $origin)); + } + + public function testUnflaggedIsNeverRejectedEvenWhenEmpty(): void + { + $request = new Request([], ['name-new' => 'Direktbuchung']); + $origin = $this->service()->getOriginFromForm($request, 'new'); + + self::assertFalse($this->service()->isSurchargeFlagSetWithoutValue($request, 'new', $origin)); + } + + /** + * @param array $params + */ + private function parse(array $params): \App\Entity\ReservationOrigin + { + return $this->service()->getOriginFromForm(new Request([], $params), 'new'); + } + + private function service(): ReservationOriginService + { + return new ReservationOriginService( + $this->createStub(EntityManagerInterface::class), + $this->createStub(RequestStack::class), + ); + } +} diff --git a/translations/ReservationOrigin/messages.de.xlf b/translations/ReservationOrigin/messages.de.xlf index 6a0aa32d..2e57fad7 100644 --- a/translations/ReservationOrigin/messages.de.xlf +++ b/translations/ReservationOrigin/messages.de.xlf @@ -30,6 +30,18 @@ reservationorigin.color.help Die Farbe wird in der Reservierungsübersicht als Indikator an der Reservierung angezeigt. + + reservationorigin.guest_surcharge_enabled + OTA-Gebühren + + + reservationorigin.commission_percent + Kommission + + + reservationorigin.payment_fee_percent + Zahlungsgebühr + reservationorigin.flash.create.success Reservierungsherkunft erfolgreich angelegt. @@ -42,6 +54,10 @@ reservationorigin.flash.edit.success Reservierungsherkunft erfolgreich bearbeitet. + + reservationorigin.flash.surcharge_required + Bei aktivierten OTA-Gebühren muss mindestens Kommission oder Zahlungsgebühr angegeben werden. + reservationorigin.flash.delete.success Reservierungsherkunft erfolgreich gelöscht. diff --git a/translations/ReservationOrigin/messages.en.yaml b/translations/ReservationOrigin/messages.en.yaml index fa07e594..8c8377e8 100644 --- a/translations/ReservationOrigin/messages.en.yaml +++ b/translations/ReservationOrigin/messages.en.yaml @@ -10,6 +10,10 @@ reservationorigin.flash.create.success: Reservation origin created successfully. reservationorigin.flash.delete.inuse.reservations: Reservation origin cannot be deleted as it is used in existing reservations. reservationorigin.flash.delete.success: Reservation origin successfully deleted. reservationorigin.flash.edit.success: Reservation origin successfully processed. +reservationorigin.flash.surcharge_required: With OTA fees enabled, at least a commission or a payment fee is required. reservationorigin.name: Description +reservationorigin.guest_surcharge_enabled: OTA fees +reservationorigin.commission_percent: Commission +reservationorigin.payment_fee_percent: Payment fee reservationorigin.private: Private reservationorigin.title: Reservation origin diff --git a/translations/Templates/messages.de.xlf b/translations/Templates/messages.de.xlf index ce95b0cb..802f6d78 100644 --- a/translations/Templates/messages.de.xlf +++ b/translations/Templates/messages.de.xlf @@ -822,6 +822,18 @@ templates.editor.apartment_modifier.positions.desc Fügt eine Tabelle mit allen Aufschlag- und Ermäßigungspositionen ein (z.B. Last-Minute-Rabatt, Haustiergebühr). Die Tabelle wird nur angezeigt, wenn entsprechende Positionen auf der Rechnung vorhanden sind. + + templates.editor.origin_name + Buchungsherkunft (Name) + + + templates.editor.origin_commission + OTA-Kommission + + + templates.editor.origin_payment_fee + OTA-Zahlungsgebühr + templates.editor.tourist_tax.positions Liste der Beherbergungsabgabe diff --git a/translations/Templates/messages.en.yaml b/translations/Templates/messages.en.yaml index f127d027..10305d9f 100644 --- a/translations/Templates/messages.en.yaml +++ b/translations/Templates/messages.en.yaml @@ -72,6 +72,9 @@ templates.editor.apartment_modifier.positions.desc: >- Inserts a table with all apartment surcharge / discount positions (e.g. last-minute discounts, pet fees). The table is only rendered when such positions exist on the invoice. +templates.editor.origin_name: Booking origin (name) +templates.editor.origin_commission: OTA commission +templates.editor.origin_payment_fee: OTA payment fee templates.editor.tourist_tax.positions: Tourist tax positions templates.editor.payment_qr: Payment QR code (GiroCode) templates.editor.payment_qr.desc: >- From 7019039031d39d3c8afc486d0ad12888ad7f9866 Mon Sep 17 00:00:00 2001 From: MeisterAdebar Date: Tue, 21 Jul 2026 13:18:03 +0200 Subject: [PATCH 10/33] Let the deduction take its percentage from the reservation origin The commission and payment fee now live on the reservation origin, where the guest-facing figure reads them too. Keeping the workflow's own copy would mean maintaining the same rate in two places, free to drift, and a booked deduction that no longer matches what the invoice tells the guest. The action gains a percentage source: manual, as before, or the origin's commission or payment fee. Manual stays the default, so a workflow that named a rate keeps booking it; the manual field only shows for that source. An origin source that finds no origin, or a field left empty, yields nothing to book - the same skip a blank manual rate already gave, which is also what a direct booking should do. The percentage is the only thing that moves to the origin. Which account, tax rate and remark each deduction carries stays with the workflow, since two deductions off the same origin book to different accounts. --- .../Action/CreatePercentageEntryAction.php | 67 ++++++++++++++++++- .../CreatePercentageEntryActionTest.php | 57 ++++++++++++++++ translations/Workflow/messages.de.yaml | 9 ++- translations/Workflow/messages.en.yaml | 9 ++- 4 files changed, 136 insertions(+), 6 deletions(-) diff --git a/src/Workflow/Action/CreatePercentageEntryAction.php b/src/Workflow/Action/CreatePercentageEntryAction.php index d87f93a6..f138db09 100644 --- a/src/Workflow/Action/CreatePercentageEntryAction.php +++ b/src/Workflow/Action/CreatePercentageEntryAction.php @@ -6,6 +6,7 @@ use App\Entity\BookingEntry; use App\Entity\Invoice; +use App\Entity\ReservationOrigin; use App\Repository\AccountingAccountRepository; use App\Repository\TaxRateRepository; use App\Service\BookingJournal\BookingJournalService; @@ -24,7 +25,9 @@ * workflow's conditions. * * Config: - * percent string – percentage of the invoice's gross total + * percentSource string – where the percentage comes from, see the constants + * percent string – percentage of the gross total, used when the source + * is manual * debitAccountId int|null – expense (or reverse-charge) account * creditAccountId int|null – account the deduction is taken from, usually the * same one the invoice itself was booked against @@ -35,6 +38,15 @@ */ class CreatePercentageEntryAction implements WorkflowActionInterface { + /** The percentage is typed into the workflow. */ + public const PERCENT_SOURCE_MANUAL = 'manual'; + + /** The percentage is the commission configured on the invoice's reservation origin. */ + public const PERCENT_SOURCE_COMMISSION = 'origin_commission'; + + /** The percentage is the payment fee configured on the invoice's reservation origin. */ + public const PERCENT_SOURCE_PAYMENT_FEE = 'origin_payment_fee'; + public function __construct( private readonly BookingJournalService $bookingJournalService, private readonly AccountingAccountRepository $accountRepo, @@ -70,12 +82,27 @@ public function getSupportedTriggerTypes(): array public function getConfigSchema(): array { return [ + [ + 'key' => 'percentSource', + 'type' => 'select', + 'label' => 'workflow.form.percentage_entry_percent_source', + 'help' => 'workflow.form.percentage_entry_percent_source_help', + 'options' => [ + ['value' => self::PERCENT_SOURCE_MANUAL, 'label' => 'workflow.form.percentage_entry_percent_source_manual'], + ['value' => self::PERCENT_SOURCE_COMMISSION, 'label' => 'workflow.form.percentage_entry_percent_source_commission'], + ['value' => self::PERCENT_SOURCE_PAYMENT_FEE, 'label' => 'workflow.form.percentage_entry_percent_source_payment_fee'], + ], + 'default' => self::PERCENT_SOURCE_MANUAL, + ], [ 'key' => 'percent', 'type' => 'text', 'label' => 'workflow.form.percentage_entry_percent', 'help' => 'workflow.form.percentage_entry_percent_help', 'default' => '', + // Only relevant when the percentage is typed in, not read from + // the origin - so it only shows for the manual source. + 'showIf' => ['key' => 'percentSource', 'value' => self::PERCENT_SOURCE_MANUAL], ], [ 'key' => 'debitAccountId', @@ -131,7 +158,7 @@ public function execute(array $config, mixed $entity, array $context): string throw new WorkflowSkippedException($this->translator->trans('workflow.log.skipped_unsupported_entity')); } - $percent = (float) str_replace(',', '.', trim((string) ($config['percent'] ?? ''))); + $percent = $this->resolvePercent($config, $entity); if ($percent <= 0.0) { throw new WorkflowSkippedException($this->translator->trans('workflow.log.skipped_no_percentage')); } @@ -189,6 +216,42 @@ public function execute(array $config, mixed $entity, array $context): string ]); } + /** + * The percentage to book, from wherever the config points it at. Reading it + * from the reservation origin keeps commission and payment fee in one place + * shared with what the guest is shown, rather than repeated in each + * workflow's config where the two could drift apart. An origin source that + * finds no origin or no value yields zero, which the caller treats as + * nothing to book - the same as a manual percentage left blank. + * + * @param array $config + */ + private function resolvePercent(array $config, Invoice $invoice): float + { + $source = (string) ($config['percentSource'] ?? self::PERCENT_SOURCE_MANUAL); + + $raw = match ($source) { + self::PERCENT_SOURCE_COMMISSION => $this->originOf($invoice)?->getCommissionPercent(), + self::PERCENT_SOURCE_PAYMENT_FEE => $this->originOf($invoice)?->getPaymentFeePercent(), + default => $config['percent'] ?? '', + }; + + return (float) str_replace(',', '.', trim((string) $raw)); + } + + /** The origin of the invoice's first reservation that carries one. */ + private function originOf(Invoice $invoice): ?ReservationOrigin + { + foreach ($invoice->getReservations() as $reservation) { + $origin = $reservation->getReservationOrigin(); + if (null !== $origin) { + return $origin; + } + } + + return null; + } + /** Gross total of the invoice, the same figure the invoice itself shows. */ private function grossTotal(Invoice $invoice): float { diff --git a/tests/Unit/Workflow/CreatePercentageEntryActionTest.php b/tests/Unit/Workflow/CreatePercentageEntryActionTest.php index 5ae35ccd..82c87468 100644 --- a/tests/Unit/Workflow/CreatePercentageEntryActionTest.php +++ b/tests/Unit/Workflow/CreatePercentageEntryActionTest.php @@ -7,7 +7,10 @@ use App\Entity\AccountingAccount; use App\Entity\BookingEntry; use App\Entity\Invoice; +use App\Entity\Reservation; +use App\Entity\ReservationOrigin; use App\Entity\TaxRate; +use Doctrine\Common\Collections\ArrayCollection; use App\Repository\AccountingAccountRepository; use App\Repository\TaxRateRepository; use App\Service\BookingJournal\BookingJournalService; @@ -101,6 +104,42 @@ public function testSkipsWithoutAPercentage(): void $action->execute($this->config(['percent' => '']), $this->invoice(), []); } + public function testReadsTheCommissionFromTheReservationOrigin(): void + { + // The percentage is left off the config; it comes from the origin, the + // same value the guest is shown. The manual field is ignored. + $captured = null; + $action = $this->makeAction(gross: 115.20, capture: $captured); + + $config = $this->config(['percent' => '', 'percentSource' => CreatePercentageEntryAction::PERCENT_SOURCE_COMMISSION]); + $action->execute($config, $this->invoiceWithOrigin(commission: '12', paymentFee: '1.4'), []); + + self::assertSame('13.82', $captured['amount']); + } + + public function testReadsThePaymentFeeFromTheReservationOrigin(): void + { + $captured = null; + $action = $this->makeAction(gross: 115.20, capture: $captured); + + $config = $this->config(['percent' => '', 'percentSource' => CreatePercentageEntryAction::PERCENT_SOURCE_PAYMENT_FEE]); + $action->execute($config, $this->invoiceWithOrigin(commission: '12', paymentFee: '1.4'), []); + + self::assertSame('1.61', $captured['amount']); + } + + public function testSkipsWhenTheOriginSourceHasNoValue(): void + { + // A direct booking, or an origin whose fee is not filled in: nothing to + // book, the same as a manual percentage left blank. + $action = $this->makeAction(gross: 115.20); + + $config = $this->config(['percent' => '99', 'percentSource' => CreatePercentageEntryAction::PERCENT_SOURCE_COMMISSION]); + + $this->expectException(WorkflowSkippedException::class); + $action->execute($config, $this->invoiceWithOrigin(commission: null, paymentFee: null), []); + } + public function testSkipsWhenTheInvoiceHasNoAmount(): void { $action = $this->makeAction(gross: 0.0); @@ -142,6 +181,24 @@ private function invoice(string $number = '17730'): Invoice return $invoice; } + private function invoiceWithOrigin(?string $commission, ?string $paymentFee): Invoice + { + $origin = new ReservationOrigin(); + $origin->setName('Booking.com'); + $origin->setCommissionPercent($commission); + $origin->setPaymentFeePercent($paymentFee); + + $reservation = new Reservation(); + $reservation->setReservationOrigin($origin); + + $invoice = $this->createStub(Invoice::class); + $invoice->method('getNumber')->willReturn('17730'); + $invoice->method('getDate')->willReturn(new \DateTime('2026-06-26')); + $invoice->method('getReservations')->willReturn(new ArrayCollection([$reservation])); + + return $invoice; + } + /** @param array|null $capture receives the arguments the journal was called with */ private function makeAction(float $gross, mixed &$capture = null): CreatePercentageEntryAction { diff --git a/translations/Workflow/messages.de.yaml b/translations/Workflow/messages.de.yaml index 00ef6980..83b74120 100644 --- a/translations/Workflow/messages.de.yaml +++ b/translations/Workflow/messages.de.yaml @@ -74,8 +74,13 @@ workflow: attachment_policy_help: "Zum Beispiel, wenn zu einer Reservierung noch gar keine Rechnung existiert." attachment_policy.skip_missing: "E-Mail trotzdem senden (ohne den fehlenden Anhang)" attachment_policy.require_all: "E-Mail nicht senden" - percentage_entry_percent: "Prozentsatz" - percentage_entry_percent_help: "Anteil am Rechnungsbrutto, z.B. 12 für eine Kommission oder 1,4 für eine Zahlungsgebühr. Ohne einschränkende Bedingung wird auf jede Rechnung gebucht." + percentage_entry_percent_source: "Prozentsatz-Quelle" + percentage_entry_percent_source_help: "Woher der Prozentsatz kommt. „Aus Herkunft“ liest ihn aus der Buchungsherkunft der Rechnung – dieselben Werte, die dem Gast gezeigt werden – statt ihn hier fest einzutragen. Bei „Manuell“ gilt das Feld unten." + percentage_entry_percent_source_manual: "Manuell" + percentage_entry_percent_source_commission: "Kommission aus Herkunft" + percentage_entry_percent_source_payment_fee: "Zahlungsgebühr aus Herkunft" + percentage_entry_percent: "Prozentsatz (manuell)" + percentage_entry_percent_help: "Anteil am Rechnungsbrutto, z.B. 12 für eine Kommission oder 1,4 für eine Zahlungsgebühr. Nur wirksam, wenn die Quelle „Manuell“ ist. Ohne einschränkende Bedingung wird auf jede Rechnung gebucht." percentage_entry_debit_account: "Sollkonto" percentage_entry_debit_account_help: "Konto, auf das der Abzug gebucht wird (Aufwand oder Reverse-Charge)." percentage_entry_credit_account: "Habenkonto" diff --git a/translations/Workflow/messages.en.yaml b/translations/Workflow/messages.en.yaml index 3d7eee46..c041f297 100644 --- a/translations/Workflow/messages.en.yaml +++ b/translations/Workflow/messages.en.yaml @@ -74,8 +74,13 @@ workflow: attachment_policy_help: "For example when a reservation does not have an invoice yet." attachment_policy.skip_missing: "Send the email anyway (without the missing attachment)" attachment_policy.require_all: "Do not send the email" - percentage_entry_percent: "Percentage" - percentage_entry_percent_help: "Share of the invoice's gross total, e.g. 12 for a commission or 1.4 for a payment fee. Without a condition narrowing it down, every invoice is booked." + percentage_entry_percent_source: "Percentage source" + percentage_entry_percent_source_help: "Where the percentage comes from. \"From origin\" reads it from the invoice's reservation origin - the same values shown to the guest - instead of typing it here. \"Manual\" uses the field below." + percentage_entry_percent_source_manual: "Manual" + percentage_entry_percent_source_commission: "Commission from origin" + percentage_entry_percent_source_payment_fee: "Payment fee from origin" + percentage_entry_percent: "Percentage (manual)" + percentage_entry_percent_help: "Share of the invoice's gross total, e.g. 12 for a commission or 1.4 for a payment fee. Only used when the source is \"Manual\". Without a condition narrowing it down, every invoice is booked." percentage_entry_debit_account: "Debit account" percentage_entry_debit_account_help: "Account the deduction is booked to (expense or reverse charge)." percentage_entry_credit_account: "Credit account" From 4029609c14de92650b8f03ed4b4dc04fcae7d73e Mon Sep 17 00:00:00 2001 From: MeisterAdebar Date: Thu, 30 Jul 2026 14:59:07 +0200 Subject: [PATCH 11/33] Let the deduction choose what it is a percentage of The action always took its percentage from the full invoice gross. Portals charge commission on what the house earns, and tourist tax is collected on behalf of the municipality - counting it in overstates every commission on an invoice that carries it. The base is now configured per action: full gross, or gross without the tourist-tax positions. Those positions already carry positionGroup "tourist_tax", so singling them out needs no new field; they are dropped before the sum rather than subtracted afterwards, leaving the per-tax-rate rounding in calculateSums() to the positions that remain. New actions are offered the narrower base, configs saved before the choice existed keep the full gross - they were set up against that figure, and moving their base silently would change what they book from one release to the next. Because the base sits on the action rather than on the portal, a commission and a payment fee in the same workflow can each use their own, which contracts do distinguish. The log line now names the base amount as well: with the base configurable, the percentage alone no longer explains the figure. --- .../Action/CreatePercentageEntryAction.php | 85 ++++++++++++-- .../CreatePercentageEntryActionTest.php | 111 +++++++++++++++++- translations/Workflow/messages.de.yaml | 8 +- translations/Workflow/messages.en.yaml | 8 +- 4 files changed, 197 insertions(+), 15 deletions(-) diff --git a/src/Workflow/Action/CreatePercentageEntryAction.php b/src/Workflow/Action/CreatePercentageEntryAction.php index f138db09..b97d57cd 100644 --- a/src/Workflow/Action/CreatePercentageEntryAction.php +++ b/src/Workflow/Action/CreatePercentageEntryAction.php @@ -6,6 +6,8 @@ use App\Entity\BookingEntry; use App\Entity\Invoice; +use App\Entity\InvoiceAppartment; +use App\Entity\InvoicePosition; use App\Entity\ReservationOrigin; use App\Repository\AccountingAccountRepository; use App\Repository\TaxRateRepository; @@ -13,10 +15,11 @@ use App\Service\InvoiceService; use App\Workflow\WorkflowSkippedException; use Doctrine\Common\Collections\ArrayCollection; +use Doctrine\Common\Collections\Collection; use Symfony\Contracts\Translation\TranslatorInterface; /** - * Books a percentage of an invoice's gross total as a single entry. + * Books a percentage of an invoice as a single entry. * * Percentage, accounts, tax rate and text all come from the config, so what * the deduction represents is a matter of configuration rather than of code. @@ -26,8 +29,12 @@ * * Config: * percentSource string – where the percentage comes from, see the constants - * percent string – percentage of the gross total, used when the source - * is manual + * percent string – the percentage itself, used when the source is + * manual + * amountBase string – what the percentage is taken of, see the constants. + * Configured per action, so a commission and a payment + * fee added to the same workflow can each use their own + * base - portals rarely charge both on the same amount * debitAccountId int|null – expense (or reverse-charge) account * creditAccountId int|null – account the deduction is taken from, usually the * same one the invoice itself was booked against @@ -47,6 +54,19 @@ class CreatePercentageEntryAction implements WorkflowActionInterface /** The percentage is the payment fee configured on the invoice's reservation origin. */ public const PERCENT_SOURCE_PAYMENT_FEE = 'origin_payment_fee'; + /** The percentage is taken of the invoice's full gross total. */ + public const AMOUNT_BASE_GROSS = 'gross'; + + /** The percentage is taken of the gross total less the tourist-tax positions. */ + public const AMOUNT_BASE_GROSS_WITHOUT_TOURIST_TAX = 'gross_without_tourist_tax'; + + /** + * Position group the tourist-tax positions carry, see InvoicePosition::$positionGroup. + * They are the pass-through item this code can single out today; anything else + * that should stay out of a commission would need its own marker first. + */ + private const POSITION_GROUP_TOURIST_TAX = 'tourist_tax'; + public function __construct( private readonly BookingJournalService $bookingJournalService, private readonly AccountingAccountRepository $accountRepo, @@ -104,6 +124,20 @@ public function getConfigSchema(): array // the origin - so it only shows for the manual source. 'showIf' => ['key' => 'percentSource', 'value' => self::PERCENT_SOURCE_MANUAL], ], + [ + 'key' => 'amountBase', + 'type' => 'select', + 'label' => 'workflow.form.percentage_entry_amount_base', + 'help' => 'workflow.form.percentage_entry_amount_base_help', + 'options' => [ + ['value' => self::AMOUNT_BASE_GROSS_WITHOUT_TOURIST_TAX, 'label' => 'workflow.form.percentage_entry_amount_base_without_tourist_tax'], + ['value' => self::AMOUNT_BASE_GROSS, 'label' => 'workflow.form.percentage_entry_amount_base_gross'], + ], + // Offered first and preselected: portals charge commission on what + // the house earns, and tourist tax is collected for the municipality. + // Existing configs are left alone, see amountBase() below. + 'default' => self::AMOUNT_BASE_GROSS_WITHOUT_TOURIST_TAX, + ], [ 'key' => 'debitAccountId', 'type' => 'accounting_account_select', @@ -163,7 +197,8 @@ public function execute(array $config, mixed $entity, array $context): string throw new WorkflowSkippedException($this->translator->trans('workflow.log.skipped_no_percentage')); } - $amount = round($this->grossTotal($entity) * $percent / 100.0, 2); + $base = $this->baseAmount($config, $entity); + $amount = round($base * $percent / 100.0, 2); if (0.0 === $amount) { throw new WorkflowSkippedException($this->translator->trans('workflow.log.skipped_no_amounts')); } @@ -208,10 +243,14 @@ public function execute(array $config, mixed $entity, array $context): string // later, and silently dropping the guard would let their month close. $entry->setRequiresDocumentNumber('0' !== (string) ($config['requiresDocumentNumber'] ?? '1')); + // The base goes into the log as a figure: with a configurable base, the + // percentage alone no longer explains how the amount came about, and the + // log is where that gets checked against the portal's own statement. return $this->translator->trans('workflow.log.percentage_entry_created', [ // Formatted like the amount beside it: "1,40" rather than PHP's "1.4". '%percent%' => number_format($percent, 2, ',', '.'), '%amount%' => number_format($amount, 2, ',', '.'), + '%base%' => number_format($base, 2, ',', '.'), '%number%' => (string) $entity->getNumber(), ]); } @@ -252,8 +291,38 @@ private function originOf(Invoice $invoice): ?ReservationOrigin return null; } - /** Gross total of the invoice, the same figure the invoice itself shows. */ - private function grossTotal(Invoice $invoice): float + /** + * The amount the percentage is taken of. + * + * Falls back to the full gross for configs saved before the choice existed: + * those were set up against that figure, and quietly moving their base would + * change what they book from one release to the next. New actions start on + * the narrower base instead, see the schema above. + */ + private function baseAmount(array $config, Invoice $invoice): float + { + $positions = $invoice->getPositions() ?? new ArrayCollection(); + + if (self::AMOUNT_BASE_GROSS_WITHOUT_TOURIST_TAX === (string) ($config['amountBase'] ?? self::AMOUNT_BASE_GROSS)) { + // Dropped before the sum rather than subtracted afterwards, so the + // per-VAT-rate rounding inside calculateSums stays the one the + // remaining positions produce on their own. + $positions = $positions->filter( + fn (InvoicePosition $position): bool => self::POSITION_GROUP_TOURIST_TAX !== $position->getPositionGroup() + ); + } + + return $this->grossTotal($invoice->getAppartments() ?? new ArrayCollection(), $positions); + } + + /** + * Gross total of the given parts, calculated the same way the invoice itself + * calculates the figure it shows. + * + * @param Collection $apartments + * @param Collection $positions + */ + private function grossTotal(Collection $apartments, Collection $positions): float { $brutto = 0.0; $netto = 0.0; @@ -262,8 +331,8 @@ private function grossTotal(Invoice $invoice): float $vats = []; $this->invoiceService->calculateSums( - $invoice->getAppartments() ?? new ArrayCollection(), - $invoice->getPositions() ?? new ArrayCollection(), + $apartments, + $positions, $vats, $brutto, $netto, diff --git a/tests/Unit/Workflow/CreatePercentageEntryActionTest.php b/tests/Unit/Workflow/CreatePercentageEntryActionTest.php index 82c87468..a8b02c70 100644 --- a/tests/Unit/Workflow/CreatePercentageEntryActionTest.php +++ b/tests/Unit/Workflow/CreatePercentageEntryActionTest.php @@ -7,10 +7,12 @@ use App\Entity\AccountingAccount; use App\Entity\BookingEntry; use App\Entity\Invoice; +use App\Entity\InvoicePosition; use App\Entity\Reservation; use App\Entity\ReservationOrigin; use App\Entity\TaxRate; use Doctrine\Common\Collections\ArrayCollection; +use Doctrine\Common\Collections\Collection; use App\Repository\AccountingAccountRepository; use App\Repository\TaxRateRepository; use App\Service\BookingJournal\BookingJournalService; @@ -140,6 +142,59 @@ public function testSkipsWhenTheOriginSourceHasNoValue(): void $action->execute($config, $this->invoiceWithOrigin(commission: null, paymentFee: null), []); } + public function testLeavesTouristTaxOutOfTheBaseByDefaultForNewActions(): void + { + // Tourist tax is collected for the municipality, so it is not part of + // what a portal charges commission on. + $positions = null; + $action = $this->makeAction(gross: 115.20, capturePositions: $positions); + + $config = $this->config(['amountBase' => CreatePercentageEntryAction::AMOUNT_BASE_GROSS_WITHOUT_TOURIST_TAX]); + $action->execute($config, $this->invoiceWithPositions(), []); + + self::assertSame(['Übernachtung', 'Endreinigung'], $this->descriptionsOf($positions)); + } + + public function testKeepsTouristTaxInTheBaseWhenTheFullGrossIsConfigured(): void + { + $positions = null; + $action = $this->makeAction(gross: 115.20, capturePositions: $positions); + + $config = $this->config(['amountBase' => CreatePercentageEntryAction::AMOUNT_BASE_GROSS]); + $action->execute($config, $this->invoiceWithPositions(), []); + + self::assertSame(['Übernachtung', 'Endreinigung', 'Kurtaxe'], $this->descriptionsOf($positions)); + } + + public function testFallsBackToTheFullGrossForConfigsSavedBeforeTheChoiceExisted(): void + { + // Those workflows were set up against that figure; changing what they + // book from one release to the next would be a silent correction. + $positions = null; + $action = $this->makeAction(gross: 115.20, capturePositions: $positions); + + $config = $this->config(); + unset($config['amountBase']); + $action->execute($config, $this->invoiceWithPositions(), []); + + self::assertSame(['Übernachtung', 'Endreinigung', 'Kurtaxe'], $this->descriptionsOf($positions)); + } + + public function testOffersTheNarrowerBaseAsTheDefaultForNewActions(): void + { + $action = $this->makeAction(gross: 100.0); + + $field = null; + foreach ($action->getConfigSchema() as $entry) { + if ('amountBase' === $entry['key']) { + $field = $entry; + } + } + + self::assertNotNull($field); + self::assertSame(CreatePercentageEntryAction::AMOUNT_BASE_GROSS_WITHOUT_TOURIST_TAX, $field['default']); + } + public function testSkipsWhenTheInvoiceHasNoAmount(): void { $action = $this->makeAction(gross: 0.0); @@ -165,6 +220,7 @@ private function config(array $overrides = []): array { return array_merge([ 'percent' => '12', + 'amountBase' => CreatePercentageEntryAction::AMOUNT_BASE_GROSS, 'debitAccountId' => '3', 'creditAccountId' => '4', 'taxRateId' => '', @@ -172,6 +228,51 @@ private function config(array $overrides = []): array ], $overrides); } + /** + * An invoice carrying one tourist-tax position among ordinary ones. Only the + * position group matters here - which of them end up in the sum is what the + * base decides, the arithmetic on them is InvoiceService's job. + */ + private function invoiceWithPositions(): Invoice + { + $positions = new ArrayCollection([ + $this->position('Übernachtung', 'apartment'), + $this->position('Endreinigung', 'misc'), + $this->position('Kurtaxe', 'tourist_tax'), + ]); + + $invoice = $this->createStub(Invoice::class); + $invoice->method('getNumber')->willReturn('17730'); + $invoice->method('getDate')->willReturn(new \DateTime('2026-06-26')); + $invoice->method('getPositions')->willReturn($positions); + + return $invoice; + } + + private function position(string $description, string $group): InvoicePosition + { + $position = new InvoicePosition(); + $position->setDescription($description); + $position->setPositionGroup($group); + + return $position; + } + + /** + * @param Collection|null $positions + * + * @return string[] + */ + private function descriptionsOf(?Collection $positions): array + { + self::assertNotNull($positions); + + return array_values(array_map( + static fn (InvoicePosition $position): string => (string) $position->getDescription(), + $positions->toArray() + )); + } + private function invoice(string $number = '17730'): Invoice { $invoice = $this->createStub(Invoice::class); @@ -199,12 +300,16 @@ private function invoiceWithOrigin(?string $commission, ?string $paymentFee): In return $invoice; } - /** @param array|null $capture receives the arguments the journal was called with */ - private function makeAction(float $gross, mixed &$capture = null): CreatePercentageEntryAction + /** + * @param array|null $capture receives the arguments the journal was called with + * @param Collection|null $capturePositions receives the positions the sum was calculated over + */ + private function makeAction(float $gross, mixed &$capture = null, mixed &$capturePositions = null): CreatePercentageEntryAction { $invoiceService = $this->createStub(InvoiceService::class); $invoiceService->method('calculateSums')->willReturnCallback( - function ($apartments, $positions, &$vats, &$brutto) use ($gross): void { + function ($apartments, $positions, &$vats, &$brutto) use ($gross, &$capturePositions): void { + $capturePositions = $positions; $brutto = $gross; } ); diff --git a/translations/Workflow/messages.de.yaml b/translations/Workflow/messages.de.yaml index 83b74120..c3b6c3f4 100644 --- a/translations/Workflow/messages.de.yaml +++ b/translations/Workflow/messages.de.yaml @@ -80,7 +80,11 @@ workflow: percentage_entry_percent_source_commission: "Kommission aus Herkunft" percentage_entry_percent_source_payment_fee: "Zahlungsgebühr aus Herkunft" percentage_entry_percent: "Prozentsatz (manuell)" - percentage_entry_percent_help: "Anteil am Rechnungsbrutto, z.B. 12 für eine Kommission oder 1,4 für eine Zahlungsgebühr. Nur wirksam, wenn die Quelle „Manuell“ ist. Ohne einschränkende Bedingung wird auf jede Rechnung gebucht." + percentage_entry_percent_help: "Anteil an der gewählten Berechnungsgrundlage, z.B. 12 für eine Kommission oder 1,4 für eine Zahlungsgebühr. Nur wirksam, wenn die Quelle „Manuell“ ist. Ohne einschränkende Bedingung wird auf jede Rechnung gebucht." + percentage_entry_amount_base: "Berechnungsgrundlage" + percentage_entry_amount_base_help: "Betrag, auf den der Prozentsatz angewendet wird. Kurtaxe wird für die Gemeinde vereinnahmt und zählt bei den meisten Portalen nicht zur provisionsfähigen Summe. Kommission und Zahlungsgebühr lassen sich getrennt einstellen, wenn der Vertrag das vorsieht." + percentage_entry_amount_base_without_tourist_tax: "Rechnungsbrutto ohne Kurtaxe" + percentage_entry_amount_base_gross: "Vollständiges Rechnungsbrutto" percentage_entry_debit_account: "Sollkonto" percentage_entry_debit_account_help: "Konto, auf das der Abzug gebucht wird (Aufwand oder Reverse-Charge)." percentage_entry_credit_account: "Habenkonto" @@ -197,7 +201,7 @@ workflow: booking_entries_created: "%count% Buchungseinträge für Rechnung %number% erstellt" skipped_no_amounts: "Übersprungen: Rechnung enthält keine buchbaren Beträge" skipped_no_percentage: "Übersprungen: kein gültiger Prozentsatz konfiguriert" - percentage_entry_created: "Buchung über %amount% (%percent% % vom Brutto) für Rechnung %number% erstellt" + percentage_entry_created: "Buchung über %amount% (%percent% % von %base%) für Rechnung %number% erstellt" skipped_invalid_config: "Übersprungen: ungültige Konfiguration" skipped_status_not_found: "Übersprungen: Reservierungsstatus nicht gefunden" skipped_no_reservations: "Übersprungen: Rechnung hat keine verknüpften Reservierungen" diff --git a/translations/Workflow/messages.en.yaml b/translations/Workflow/messages.en.yaml index c041f297..158d90bd 100644 --- a/translations/Workflow/messages.en.yaml +++ b/translations/Workflow/messages.en.yaml @@ -80,7 +80,11 @@ workflow: percentage_entry_percent_source_commission: "Commission from origin" percentage_entry_percent_source_payment_fee: "Payment fee from origin" percentage_entry_percent: "Percentage (manual)" - percentage_entry_percent_help: "Share of the invoice's gross total, e.g. 12 for a commission or 1.4 for a payment fee. Only used when the source is \"Manual\". Without a condition narrowing it down, every invoice is booked." + percentage_entry_percent_help: "Share of the selected calculation base, e.g. 12 for a commission or 1.4 for a payment fee. Only used when the source is \"Manual\". Without a condition narrowing it down, every invoice is booked." + percentage_entry_amount_base: "Calculation base" + percentage_entry_amount_base_help: "The amount the percentage is applied to. Tourist tax is collected on behalf of the municipality and is not commissionable with most portals. Commission and payment fee can use different bases where the contract says so." + percentage_entry_amount_base_without_tourist_tax: "Invoice gross without tourist tax" + percentage_entry_amount_base_gross: "Full invoice gross" percentage_entry_debit_account: "Debit account" percentage_entry_debit_account_help: "Account the deduction is booked to (expense or reverse charge)." percentage_entry_credit_account: "Credit account" @@ -192,7 +196,7 @@ workflow: booking_entries_created: "%count% booking entries created for invoice %number%" skipped_no_amounts: "Skipped: invoice contains no bookable amounts" skipped_no_percentage: "Skipped: no valid percentage configured" - percentage_entry_created: "Entry of %amount% (%percent% % of gross) created for invoice %number%" + percentage_entry_created: "Entry of %amount% (%percent% % of %base%) created for invoice %number%" skipped_invalid_config: "Skipped: invalid configuration" skipped_status_not_found: "Skipped: reservation status not found" skipped_no_reservations: "Skipped: invoice has no linked reservations" From 245c67f06e018e544fc938752cf63f34cbf2604f Mon Sep 17 00:00:00 2001 From: MeisterAdebar Date: Thu, 30 Jul 2026 15:20:23 +0200 Subject: [PATCH 12/33] Pin a booking's portal rates to the reservation The deduction read commission and payment fee from the reservation origin at the moment it ran. The origin carries the rate that applies today, so renegotiating a contract - or correcting a typo in one - silently changed what invoices from before were charged, months after the booking was made. A reservation now records both rates when its origin is assigned. That happens in the setter rather than in each of the paths creating a reservation, since online booking, calendar import, the reservation form and the fixtures all pass through it; Doctrine hydrates the property directly, so loading a reservation never pins anything. Only an actual change of origin re-pins. The reservation form assigns the origin on every save, and without that guard re-saving an old reservation would restamp it with today's rates - the very thing this prevents. An origin carrying no fees pins nothing rather than a zero. Fees tend to be filled in after the first bookings have come in, and a pinned zero would leave those bookings without a deduction for good, with only a line in the workflow log to show for it. A rate the origin does carry is pinned as it is, an explicit zero included. The surcharge shown to the guest reads the pinned rates as well. That figure is there to be comparable with the deduction booked for it, so leaving it on the origin's current rate would have made the invoice name an amount the journal never booked as soon as a contract changed. Reservations without a pinned rate fall back to the origin. Nothing is back-filled, and there is nothing to back-fill: the rates themselves are new here, so no reservation was ever charged under a recorded one. The fallback carries the changeover - bookings already in the system when this ships, whose invoices are paid afterwards - and everything booked from then on is pinned. --- migrations/Version20260730120000.php | 34 ++++++ src/Entity/Reservation.php | 72 ++++++++++++ src/Service/InvoiceService.php | 22 ++-- .../Action/CreatePercentageEntryAction.php | 42 +++++-- .../Unit/InvoiceServiceGuestSurchargeTest.php | 35 ++++++ .../Unit/ReservationOriginRatePinningTest.php | 107 ++++++++++++++++++ .../CreatePercentageEntryActionTest.php | 66 ++++++++++- 7 files changed, 356 insertions(+), 22 deletions(-) create mode 100644 migrations/Version20260730120000.php create mode 100644 tests/Unit/ReservationOriginRatePinningTest.php diff --git a/migrations/Version20260730120000.php b/migrations/Version20260730120000.php new file mode 100644 index 00000000..9b722ba2 --- /dev/null +++ b/migrations/Version20260730120000.php @@ -0,0 +1,34 @@ +addSql('ALTER TABLE reservations ADD commission_percent NUMERIC(5, 2) DEFAULT NULL, ADD payment_fee_percent NUMERIC(5, 2) DEFAULT NULL'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE reservations DROP commission_percent, DROP payment_fee_percent'); + } + + public function isTransactional(): bool + { + return false; + } +} diff --git a/src/Entity/Reservation.php b/src/Entity/Reservation.php index 6ccaa4f2..cf5de884 100644 --- a/src/Entity/Reservation.php +++ b/src/Entity/Reservation.php @@ -46,6 +46,25 @@ class Reservation private $registrationBookEntries; #[ORM\ManyToOne(targetEntity: 'ReservationOrigin', inversedBy: 'reservations')] private $reservationOrigin; + + /** + * The portal's commission as it stood when this reservation was booked, in + * percent. Pinned from the origin rather than read from it later: the origin + * carries the rate that applies today, so a contract renegotiated in between + * would otherwise be applied to bookings it never covered. + * + * Null means no rate is recorded for this booking - either it predates the + * pinning, or its origin carried no fees at the time. Both fall back to the + * origin, so a house that sets its rates up after the fact still gets them + * applied. A rate the origin does carry is pinned as it is, an explicit zero + * included. + */ + #[ORM\Column(name: 'commission_percent', type: 'decimal', precision: 5, scale: 2, nullable: true)] + private ?string $commissionPercent = null; + + /** The portal's payment fee when this reservation was booked; see commission. */ + #[ORM\Column(name: 'payment_fee_percent', type: 'decimal', precision: 5, scale: 2, nullable: true)] + private ?string $paymentFeePercent = null; #[ORM\OneToMany(targetEntity: 'Correspondence', mappedBy: 'reservation', cascade: ['remove'])] private $correspondences; #[ORM\ManyToMany(targetEntity: Price::class)] @@ -294,11 +313,64 @@ public function getAmount() */ public function setReservationOrigin(?ReservationOrigin $reservationOrigin = null) { + // Pinned here rather than in each of the paths that create a reservation + // - online booking, calendar import, the reservation form - because this + // is the one place all of them pass through. Only on an actual change, so + // re-saving an old reservation does not quietly restamp it with today's + // rates; Doctrine hydrates the property directly, so loading never does. + if ($reservationOrigin !== $this->reservationOrigin) { + $this->commissionPercent = $this->pinnedRate($reservationOrigin?->getCommissionPercent()); + $this->paymentFeePercent = $this->pinnedRate($reservationOrigin?->getPaymentFeePercent()); + } + $this->reservationOrigin = $reservationOrigin; return $this; } + /** + * The origin's rate as it is, or null where it carries none. + * + * Deliberately not pinned as a zero: an origin whose fees are configured only + * after the first bookings have come in - the ordinary order of things when + * setting this up - would otherwise leave those bookings on a rate of nothing + * for good, with only a line in the workflow log to show for it. Null lets + * them fall back to the origin until it has something to say. + */ + private function pinnedRate(?string $rate): ?string + { + return null !== $rate && '' !== trim($rate) ? $rate : null; + } + + /** + * Portal commission that applied when this reservation was booked, null when + * it was booked before rates were pinned. + */ + public function getCommissionPercent(): ?string + { + return $this->commissionPercent; + } + + public function setCommissionPercent(?string $commissionPercent): self + { + $this->commissionPercent = $commissionPercent; + + return $this; + } + + /** Portal payment fee that applied when this reservation was booked; see commission. */ + public function getPaymentFeePercent(): ?string + { + return $this->paymentFeePercent; + } + + public function setPaymentFeePercent(?string $paymentFeePercent): self + { + $this->paymentFeePercent = $paymentFeePercent; + + return $this; + } + /** * Get reservationOrigin. * diff --git a/src/Service/InvoiceService.php b/src/Service/InvoiceService.php index 1f5d2769..ffead698 100644 --- a/src/Service/InvoiceService.php +++ b/src/Service/InvoiceService.php @@ -272,14 +272,20 @@ public function buildTemplateRenderParams(Template $template, Invoice $invoice): /** * The extra a guest paid by booking through the reservation's origin rather * than directly, split into the portal's commission and payment fee, each a - * configured percentage of the gross total. Mirrors the basis the deduction - * workflows book on, so the figures shown to the guest match what the portal - * actually took. + * percentage of the gross total. Mirrors the basis the deduction workflows + * book on, so the figures shown to the guest match what the portal actually + * took: the rates the reservation was booked under, falling back to the + * origin's current ones only where none were pinned. Reading the origin + * outright would show a figure the journal never booked as soon as a + * contract has been renegotiated. * * The first reservation carrying an origin with either percentage decides - * it; an invoice mixing origins is not a case that arises here. Amounts are - * zero rather than null when nothing applies, so templates need no null-guard - * beyond a truthiness check. + * it. An invoice mixing origins - or one portal at rates that changed in + * between - is shown the first of them here, while the deduction skips such + * an invoice rather than pick one; a guest-facing note and a journal entry + * do not carry the same weight. Amounts are zero rather than null when + * nothing applies, so templates need no null-guard beyond a truthiness + * check. * * @return array{name: ?string, commission: float, paymentFee: float} */ @@ -293,8 +299,8 @@ private function resolveGuestSurcharge(Invoice $invoice, float $brutto): array continue; } - $commissionPercent = (float) ($origin->getCommissionPercent() ?? 0.0); - $paymentFeePercent = (float) ($origin->getPaymentFeePercent() ?? 0.0); + $commissionPercent = (float) ($reservation->getCommissionPercent() ?? $origin->getCommissionPercent() ?? 0.0); + $paymentFeePercent = (float) ($reservation->getPaymentFeePercent() ?? $origin->getPaymentFeePercent() ?? 0.0); if ($commissionPercent <= 0.0 && $paymentFeePercent <= 0.0) { continue; } diff --git a/src/Workflow/Action/CreatePercentageEntryAction.php b/src/Workflow/Action/CreatePercentageEntryAction.php index b97d57cd..f24a4e6a 100644 --- a/src/Workflow/Action/CreatePercentageEntryAction.php +++ b/src/Workflow/Action/CreatePercentageEntryAction.php @@ -8,7 +8,7 @@ use App\Entity\Invoice; use App\Entity\InvoiceAppartment; use App\Entity\InvoicePosition; -use App\Entity\ReservationOrigin; +use App\Entity\Reservation; use App\Repository\AccountingAccountRepository; use App\Repository\TaxRateRepository; use App\Service\BookingJournal\BookingJournalService; @@ -269,22 +269,40 @@ private function resolvePercent(array $config, Invoice $invoice): float { $source = (string) ($config['percentSource'] ?? self::PERCENT_SOURCE_MANUAL); - $raw = match ($source) { - self::PERCENT_SOURCE_COMMISSION => $this->originOf($invoice)?->getCommissionPercent(), - self::PERCENT_SOURCE_PAYMENT_FEE => $this->originOf($invoice)?->getPaymentFeePercent(), - default => $config['percent'] ?? '', - }; + // Anything but the two origin sources uses the typed-in field and has no + // business looking at the invoice's reservations at all. + if (self::PERCENT_SOURCE_COMMISSION !== $source && self::PERCENT_SOURCE_PAYMENT_FEE !== $source) { + return $this->toPercent($config['percent'] ?? ''); + } + + // The rate the reservation was booked under wins over the one the origin + // carries today: a portal that renegotiates its commission must not change + // what an invoice from last season is charged. Reservations with no rate + // recorded - booked before the pinning, or under an origin that carried no + // fees yet - fall through to the origin; a pinned rate is an answer + // whatever it says, an explicit zero included. + $reservation = $this->reservationWithOrigin($invoice); + $origin = $reservation?->getReservationOrigin(); + + $raw = self::PERCENT_SOURCE_COMMISSION === $source + ? $reservation?->getCommissionPercent() ?? $origin?->getCommissionPercent() + : $reservation?->getPaymentFeePercent() ?? $origin?->getPaymentFeePercent(); + return $this->toPercent($raw); + } + + /** Reads a percentage as it may have been typed or stored, commas included. */ + private function toPercent(?string $raw): float + { return (float) str_replace(',', '.', trim((string) $raw)); } - /** The origin of the invoice's first reservation that carries one. */ - private function originOf(Invoice $invoice): ?ReservationOrigin + /** The invoice's first reservation that carries an origin. */ + private function reservationWithOrigin(Invoice $invoice): ?Reservation { - foreach ($invoice->getReservations() as $reservation) { - $origin = $reservation->getReservationOrigin(); - if (null !== $origin) { - return $origin; + foreach ($invoice->getReservations() ?? [] as $reservation) { + if (null !== $reservation->getReservationOrigin()) { + return $reservation; } } diff --git a/tests/Unit/InvoiceServiceGuestSurchargeTest.php b/tests/Unit/InvoiceServiceGuestSurchargeTest.php index 844a9c63..e9b2833a 100644 --- a/tests/Unit/InvoiceServiceGuestSurchargeTest.php +++ b/tests/Unit/InvoiceServiceGuestSurchargeTest.php @@ -81,6 +81,41 @@ public function testSkipsOriginsWithoutPercentagesAndTakesTheFirstThatHasOne(): self::assertSame(1.4, $result['paymentFee']); } + public function testShowsTheRatesTheReservationWasBookedUnder(): void + { + // The portal has since raised its commission to 18 %. Showing that to the + // guest would name a figure the journal never booked - the deduction goes + // by the 12 % the booking was made under. + $reservation = $this->reservationWithOrigin('Booking.com', '18.00', '2.50'); + $reservation->setCommissionPercent('12.00'); + $reservation->setPaymentFeePercent('1.40'); + + $invoice = new Invoice(); + $invoice->addReservation($reservation); + + $result = $this->resolve($invoice, 115.20); + + self::assertSame(13.82, $result['commission']); + self::assertSame(1.61, $result['paymentFee']); + } + + public function testFallsBackToTheOriginWhenTheReservationHasNoRatesPinned(): void + { + // Booked before the rates were pinned, or under an origin that carried + // none at the time: the origin is all there is to go on. + $reservation = $this->reservationWithOrigin('Booking.com', '12.00', '1.40'); + $reservation->setCommissionPercent(null); + $reservation->setPaymentFeePercent(null); + + $invoice = new Invoice(); + $invoice->addReservation($reservation); + + $result = $this->resolve($invoice, 115.20); + + self::assertSame(13.82, $result['commission']); + self::assertSame(1.61, $result['paymentFee']); + } + /** * @return array{name: ?string, commission: float, paymentFee: float} */ diff --git a/tests/Unit/ReservationOriginRatePinningTest.php b/tests/Unit/ReservationOriginRatePinningTest.php new file mode 100644 index 00000000..57ee62a7 --- /dev/null +++ b/tests/Unit/ReservationOriginRatePinningTest.php @@ -0,0 +1,107 @@ +setReservationOrigin($this->origin('12.00', '1.40')); + + self::assertSame('12.00', $reservation->getCommissionPercent()); + self::assertSame('1.40', $reservation->getPaymentFeePercent()); + } + + public function testKeepsThePinnedRatesWhenTheOriginLaterChangesItsOwn(): void + { + $origin = $this->origin('12.00', '1.40'); + + $reservation = new Reservation(); + $reservation->setReservationOrigin($origin); + + $origin->setCommissionPercent('18.00'); + $origin->setPaymentFeePercent('2.50'); + + self::assertSame('12.00', $reservation->getCommissionPercent()); + self::assertSame('1.40', $reservation->getPaymentFeePercent()); + } + + public function testDoesNotRestampWhenTheSameOriginIsAssignedAgain(): void + { + // Re-saving an old reservation must not quietly move it onto today's + // rates - the form assigns the origin on every save. + $origin = $this->origin('12.00', '1.40'); + + $reservation = new Reservation(); + $reservation->setReservationOrigin($origin); + + $origin->setCommissionPercent('18.00'); + $reservation->setReservationOrigin($origin); + + self::assertSame('12.00', $reservation->getCommissionPercent()); + } + + public function testRepinsWhenTheReservationMovesToAnotherOrigin(): void + { + $reservation = new Reservation(); + $reservation->setReservationOrigin($this->origin('12.00', '1.40')); + $reservation->setReservationOrigin($this->origin('15.00', '2.00')); + + self::assertSame('15.00', $reservation->getCommissionPercent()); + self::assertSame('2.00', $reservation->getPaymentFeePercent()); + } + + public function testRecordsNoRateForAnOriginThatHasNoneConfigured(): void + { + // Fees are routinely filled in after the first bookings have arrived. + // Pinning a zero here would leave those bookings without a deduction for + // good, even once the origin has its rates. + $reservation = new Reservation(); + $reservation->setReservationOrigin($this->origin(null, null)); + + self::assertNull($reservation->getCommissionPercent()); + self::assertNull($reservation->getPaymentFeePercent()); + } + + public function testPinsARateTheOriginActuallyCarries(): void + { + // An origin set to zero has said something, unlike one left blank. + $reservation = new Reservation(); + $reservation->setReservationOrigin($this->origin('0.00', '1.40')); + + self::assertSame('0.00', $reservation->getCommissionPercent()); + self::assertSame('1.40', $reservation->getPaymentFeePercent()); + } + + public function testClearsThePinnedRatesWhenTheOriginIsRemoved(): void + { + $reservation = new Reservation(); + $reservation->setReservationOrigin($this->origin('12.00', '1.40')); + $reservation->setReservationOrigin(null); + + self::assertNull($reservation->getCommissionPercent()); + self::assertNull($reservation->getPaymentFeePercent()); + } + + private function origin(?string $commission, ?string $paymentFee): ReservationOrigin + { + $origin = new ReservationOrigin(); + $origin->setName('Booking.com'); + $origin->setCommissionPercent($commission); + $origin->setPaymentFeePercent($paymentFee); + + return $origin; + } +} diff --git a/tests/Unit/Workflow/CreatePercentageEntryActionTest.php b/tests/Unit/Workflow/CreatePercentageEntryActionTest.php index a8b02c70..2ebe15b7 100644 --- a/tests/Unit/Workflow/CreatePercentageEntryActionTest.php +++ b/tests/Unit/Workflow/CreatePercentageEntryActionTest.php @@ -130,6 +130,60 @@ public function testReadsThePaymentFeeFromTheReservationOrigin(): void self::assertSame('1.61', $captured['amount']); } + public function testPrefersTheRateTheReservationWasBookedUnder(): void + { + // The portal renegotiated its commission to 18 % since; an invoice for a + // booking made under 12 % must still be charged 12 %. + $captured = null; + $action = $this->makeAction(gross: 115.20, capture: $captured); + + $invoice = $this->invoiceWithOrigin(commission: '18', paymentFee: '2.5', pinnedCommission: '12.00', pinnedPaymentFee: '1.40'); + + $config = $this->config(['percent' => '', 'percentSource' => CreatePercentageEntryAction::PERCENT_SOURCE_COMMISSION]); + $action->execute($config, $invoice, []); + + self::assertSame('13.82', $captured['amount']); + } + + public function testPrefersThePinnedPaymentFeeAsWell(): void + { + $captured = null; + $action = $this->makeAction(gross: 115.20, capture: $captured); + + $invoice = $this->invoiceWithOrigin(commission: '18', paymentFee: '2.5', pinnedCommission: '12.00', pinnedPaymentFee: '1.40'); + + $config = $this->config(['percent' => '', 'percentSource' => CreatePercentageEntryAction::PERCENT_SOURCE_PAYMENT_FEE]); + $action->execute($config, $invoice, []); + + self::assertSame('1.61', $captured['amount']); + } + + public function testSkipsWhenTheReservationWasBookedUnderNoFee(): void + { + // A pinned "0.00" says the portal charged nothing at the time, which the + // origin's current rate must not override. + $action = $this->makeAction(gross: 115.20); + + $invoice = $this->invoiceWithOrigin(commission: '18', paymentFee: '2.5', pinnedCommission: '0.00', pinnedPaymentFee: '0.00'); + + $config = $this->config(['percent' => '', 'percentSource' => CreatePercentageEntryAction::PERCENT_SOURCE_COMMISSION]); + + $this->expectException(WorkflowSkippedException::class); + $action->execute($config, $invoice, []); + } + + public function testFallsBackToTheOriginForReservationsBookedBeforeRatesWerePinned(): void + { + $captured = null; + $action = $this->makeAction(gross: 115.20, capture: $captured); + + // Nothing pinned, as on every reservation that predates the columns. + $config = $this->config(['percent' => '', 'percentSource' => CreatePercentageEntryAction::PERCENT_SOURCE_COMMISSION]); + $action->execute($config, $this->invoiceWithOrigin(commission: '12', paymentFee: '1.4'), []); + + self::assertSame('13.82', $captured['amount']); + } + public function testSkipsWhenTheOriginSourceHasNoValue(): void { // A direct booking, or an origin whose fee is not filled in: nothing to @@ -282,8 +336,12 @@ private function invoice(string $number = '17730'): Invoice return $invoice; } - private function invoiceWithOrigin(?string $commission, ?string $paymentFee): Invoice - { + private function invoiceWithOrigin( + ?string $commission, + ?string $paymentFee, + ?string $pinnedCommission = null, + ?string $pinnedPaymentFee = null, + ): Invoice { $origin = new ReservationOrigin(); $origin->setName('Booking.com'); $origin->setCommissionPercent($commission); @@ -291,6 +349,10 @@ private function invoiceWithOrigin(?string $commission, ?string $paymentFee): In $reservation = new Reservation(); $reservation->setReservationOrigin($origin); + // Overwritten after the assignment, which pins the origin's current rates - + // here the reservation is meant to carry what applied when it was booked. + $reservation->setCommissionPercent($pinnedCommission); + $reservation->setPaymentFeePercent($pinnedPaymentFee); $invoice = $this->createStub(Invoice::class); $invoice->method('getNumber')->willReturn('17730'); From a13170ffb30d279dfaf7091875e5152728207bfd Mon Sep 17 00:00:00 2001 From: MeisterAdebar Date: Thu, 30 Jul 2026 15:44:04 +0200 Subject: [PATCH 13/33] Stop instead of guessing which rate an invoice was booked under The deduction took the first reservation that carried an origin and applied its rate to the whole invoice. On an invoice combining two portals, or a portal booking with a direct one, that charged a rate to revenue it never applied to - and said nothing about it. The rates the invoice's reservations carry are now compared, and the action skips with a log line naming them when they disagree. A reservation without an origin counts as a rate of zero, which is what makes the portal-plus-direct case show up as the disagreement it is; two bookings from one portal taken under rates that changed in between are caught by the same rule. Rates are grouped by their formatted form, so "12", "12.00" and 12.0 are one rate rather than three. Splitting the deduction along the reservations would be the fuller answer, but an invoice records no attribution of its lines to reservations - InvoiceAppartment carries a room number and a period, not a reservation - so it could only be guessed at. A skipped entry that names the rates asks for the manual booking that these invoices need anyway. A typed-in percentage is left alone: it says what to book whatever the bookings behind the invoice were. --- .../Action/CreatePercentageEntryAction.php | 72 ++++++---- .../CreatePercentageEntryActionTest.php | 123 ++++++++++++++++-- translations/Workflow/messages.de.yaml | 1 + translations/Workflow/messages.en.yaml | 1 + 4 files changed, 164 insertions(+), 33 deletions(-) diff --git a/src/Workflow/Action/CreatePercentageEntryAction.php b/src/Workflow/Action/CreatePercentageEntryAction.php index f24a4e6a..13563e51 100644 --- a/src/Workflow/Action/CreatePercentageEntryAction.php +++ b/src/Workflow/Action/CreatePercentageEntryAction.php @@ -8,7 +8,6 @@ use App\Entity\Invoice; use App\Entity\InvoiceAppartment; use App\Entity\InvoicePosition; -use App\Entity\Reservation; use App\Repository\AccountingAccountRepository; use App\Repository\TaxRateRepository; use App\Service\BookingJournal\BookingJournalService; @@ -275,38 +274,61 @@ private function resolvePercent(array $config, Invoice $invoice): float return $this->toPercent($config['percent'] ?? ''); } - // The rate the reservation was booked under wins over the one the origin - // carries today: a portal that renegotiates its commission must not change - // what an invoice from last season is charged. Reservations with no rate - // recorded - booked before the pinning, or under an origin that carried no - // fees yet - fall through to the origin; a pinned rate is an answer - // whatever it says, an explicit zero included. - $reservation = $this->reservationWithOrigin($invoice); - $origin = $reservation?->getReservationOrigin(); - - $raw = self::PERCENT_SOURCE_COMMISSION === $source - ? $reservation?->getCommissionPercent() ?? $origin?->getCommissionPercent() - : $reservation?->getPaymentFeePercent() ?? $origin?->getPaymentFeePercent(); + $rates = $this->ratesOnInvoice($invoice, $source); + + // One entry is booked for the whole invoice, so a single rate has to hold + // for all of it. Two portals on one invoice, or two bookings taken under + // rates that have changed in between, have no single answer - and the + // invoice carries no attribution of its lines to reservations to split it + // along. Booking one of the rates on the full amount would be wrong + // without ever saying so, so this stops and asks for a manual entry. + if (count($rates) > 1) { + throw new WorkflowSkippedException($this->translator->trans('workflow.log.skipped_mixed_rates', [ + '%rates%' => implode(', ', array_keys($rates)), + ])); + } - return $this->toPercent($raw); + return 1 === count($rates) ? reset($rates) : 0.0; } - /** Reads a percentage as it may have been typed or stored, commas included. */ - private function toPercent(?string $raw): float + /** + * The distinct rates the invoice's reservations carry for the given source, + * keyed by their formatted form so the caller can name them. + * + * The rate a reservation was booked under wins over the one its origin carries + * today: a portal that renegotiates its commission must not change what an + * invoice from last season is charged. Reservations with no rate recorded fall + * through to the origin - a pinned rate is an answer whatever it says, an + * explicit zero included. A reservation without an origin counts as a rate of + * zero, which is what makes a direct booking sharing an invoice with a portal + * one show up as the disagreement it is. + * + * @return array + */ + private function ratesOnInvoice(Invoice $invoice, string $source): array { - return (float) str_replace(',', '.', trim((string) $raw)); - } + $rates = []; - /** The invoice's first reservation that carries an origin. */ - private function reservationWithOrigin(Invoice $invoice): ?Reservation - { foreach ($invoice->getReservations() ?? [] as $reservation) { - if (null !== $reservation->getReservationOrigin()) { - return $reservation; - } + $origin = $reservation->getReservationOrigin(); + + $raw = self::PERCENT_SOURCE_COMMISSION === $source + ? $reservation->getCommissionPercent() ?? $origin?->getCommissionPercent() + : $reservation->getPaymentFeePercent() ?? $origin?->getPaymentFeePercent(); + + $rate = $this->toPercent($raw); + // Keyed by the formatted figure: it doubles as the label in the log + // and keeps "12", "12.00" and 12.0 from counting as three rates. + $rates[number_format($rate, 2, ',', '.').' %'] = $rate; } - return null; + return $rates; + } + + /** Reads a percentage as it may have been typed or stored, commas included. */ + private function toPercent(?string $raw): float + { + return (float) str_replace(',', '.', trim((string) $raw)); } /** diff --git a/tests/Unit/Workflow/CreatePercentageEntryActionTest.php b/tests/Unit/Workflow/CreatePercentageEntryActionTest.php index 2ebe15b7..3668f0f5 100644 --- a/tests/Unit/Workflow/CreatePercentageEntryActionTest.php +++ b/tests/Unit/Workflow/CreatePercentageEntryActionTest.php @@ -184,6 +184,95 @@ public function testFallsBackToTheOriginForReservationsBookedBeforeRatesWerePinn self::assertSame('13.82', $captured['amount']); } + public function testSkipsWhenTheInvoiceMixesTwoPortals(): void + { + // One entry is booked for the whole invoice, and there is no attribution + // of invoice lines to reservations to split it along. Charging either + // portal's rate on the full amount would be wrong without saying so. + $action = $this->makeAction(gross: 115.20); + + $invoice = $this->invoiceWithReservations( + $this->reservation(commission: '12', paymentFee: '1.4'), + $this->reservation(commission: '18', paymentFee: '2.5'), + ); + + $config = $this->config(['percent' => '', 'percentSource' => CreatePercentageEntryAction::PERCENT_SOURCE_COMMISSION]); + + $this->expectException(WorkflowSkippedException::class); + $action->execute($config, $invoice, []); + } + + public function testSkipsWhenAPortalBookingSharesTheInvoiceWithADirectOne(): void + { + // The direct booking's share carries no commission, so the portal's rate + // does not hold for the invoice as a whole. + $action = $this->makeAction(gross: 115.20); + + $invoice = $this->invoiceWithReservations( + $this->reservation(commission: '12', paymentFee: '1.4'), + new Reservation(), + ); + + $config = $this->config(['percent' => '', 'percentSource' => CreatePercentageEntryAction::PERCENT_SOURCE_COMMISSION]); + + $this->expectException(WorkflowSkippedException::class); + $action->execute($config, $invoice, []); + } + + public function testSkipsWhenTwoBookingsFromOnePortalCarryDifferentPinnedRates(): void + { + // Same portal, but the contract changed between the two bookings. + $action = $this->makeAction(gross: 115.20); + + $origin = $this->origin(commission: '18', paymentFee: '2.5'); + + $early = new Reservation(); + $early->setReservationOrigin($origin); + $early->setCommissionPercent('12.00'); + + $late = new Reservation(); + $late->setReservationOrigin($origin); + $late->setCommissionPercent('18.00'); + + $config = $this->config(['percent' => '', 'percentSource' => CreatePercentageEntryAction::PERCENT_SOURCE_COMMISSION]); + + $this->expectException(WorkflowSkippedException::class); + $action->execute($config, $this->invoiceWithReservations($early, $late), []); + } + + public function testBooksWhenSeveralReservationsAgreeOnTheRate(): void + { + $captured = null; + $action = $this->makeAction(gross: 115.20, capture: $captured); + + $invoice = $this->invoiceWithReservations( + $this->reservation(commission: '12', paymentFee: '1.4'), + $this->reservation(commission: '12.00', paymentFee: '1.40'), + ); + + $config = $this->config(['percent' => '', 'percentSource' => CreatePercentageEntryAction::PERCENT_SOURCE_COMMISSION]); + $action->execute($config, $invoice, []); + + self::assertSame('13.82', $captured['amount']); + } + + public function testDoesNotCheckTheRatesWhenThePercentageIsTypedIn(): void + { + // A manual percentage says what to book regardless of where the bookings + // came from; the origins are none of its business. + $captured = null; + $action = $this->makeAction(gross: 115.20, capture: $captured); + + $invoice = $this->invoiceWithReservations( + $this->reservation(commission: '12', paymentFee: '1.4'), + $this->reservation(commission: '18', paymentFee: '2.5'), + ); + + $action->execute($this->config(['percent' => '12']), $invoice, []); + + self::assertSame('13.82', $captured['amount']); + } + public function testSkipsWhenTheOriginSourceHasNoValue(): void { // A direct booking, or an origin whose fee is not filled in: nothing to @@ -342,26 +431,44 @@ private function invoiceWithOrigin( ?string $pinnedCommission = null, ?string $pinnedPaymentFee = null, ): Invoice { - $origin = new ReservationOrigin(); - $origin->setName('Booking.com'); - $origin->setCommissionPercent($commission); - $origin->setPaymentFeePercent($paymentFee); - - $reservation = new Reservation(); - $reservation->setReservationOrigin($origin); + $reservation = $this->reservation($commission, $paymentFee); // Overwritten after the assignment, which pins the origin's current rates - // here the reservation is meant to carry what applied when it was booked. $reservation->setCommissionPercent($pinnedCommission); $reservation->setPaymentFeePercent($pinnedPaymentFee); + return $this->invoiceWithReservations($reservation); + } + + private function invoiceWithReservations(Reservation ...$reservations): Invoice + { $invoice = $this->createStub(Invoice::class); $invoice->method('getNumber')->willReturn('17730'); $invoice->method('getDate')->willReturn(new \DateTime('2026-06-26')); - $invoice->method('getReservations')->willReturn(new ArrayCollection([$reservation])); + $invoice->method('getReservations')->willReturn(new ArrayCollection($reservations)); return $invoice; } + /** A reservation booked through a portal, carrying that portal's current rates. */ + private function reservation(?string $commission, ?string $paymentFee): Reservation + { + $reservation = new Reservation(); + $reservation->setReservationOrigin($this->origin($commission, $paymentFee)); + + return $reservation; + } + + private function origin(?string $commission, ?string $paymentFee): ReservationOrigin + { + $origin = new ReservationOrigin(); + $origin->setName('Booking.com'); + $origin->setCommissionPercent($commission); + $origin->setPaymentFeePercent($paymentFee); + + return $origin; + } + /** * @param array|null $capture receives the arguments the journal was called with * @param Collection|null $capturePositions receives the positions the sum was calculated over diff --git a/translations/Workflow/messages.de.yaml b/translations/Workflow/messages.de.yaml index c3b6c3f4..4c760678 100644 --- a/translations/Workflow/messages.de.yaml +++ b/translations/Workflow/messages.de.yaml @@ -201,6 +201,7 @@ workflow: booking_entries_created: "%count% Buchungseinträge für Rechnung %number% erstellt" skipped_no_amounts: "Übersprungen: Rechnung enthält keine buchbaren Beträge" skipped_no_percentage: "Übersprungen: kein gültiger Prozentsatz konfiguriert" + skipped_mixed_rates: "Übersprungen: die Reservierungen dieser Rechnung wurden zu unterschiedlichen Sätzen gebucht (%rates%). Ein einzelner Abzug kann sie nicht abbilden – bitte von Hand buchen." percentage_entry_created: "Buchung über %amount% (%percent% % von %base%) für Rechnung %number% erstellt" skipped_invalid_config: "Übersprungen: ungültige Konfiguration" skipped_status_not_found: "Übersprungen: Reservierungsstatus nicht gefunden" diff --git a/translations/Workflow/messages.en.yaml b/translations/Workflow/messages.en.yaml index 158d90bd..69bfb118 100644 --- a/translations/Workflow/messages.en.yaml +++ b/translations/Workflow/messages.en.yaml @@ -196,6 +196,7 @@ workflow: booking_entries_created: "%count% booking entries created for invoice %number%" skipped_no_amounts: "Skipped: invoice contains no bookable amounts" skipped_no_percentage: "Skipped: no valid percentage configured" + skipped_mixed_rates: "Skipped: the reservations on this invoice were booked at different rates (%rates%). A single deduction cannot represent them - please book it by hand." percentage_entry_created: "Entry of %amount% (%percent% % of %base%) created for invoice %number%" skipped_invalid_config: "Skipped: invalid configuration" skipped_status_not_found: "Skipped: reservation status not found" From a50e6ce82d8bf0081784c0acbd2fffed8d2145bb Mon Sep 17 00:00:00 2001 From: MeisterAdebar Date: Thu, 30 Jul 2026 16:59:07 +0200 Subject: [PATCH 14/33] Show an existing workflow the settings it is running on The form renders a field's default wherever the stored config says nothing, and writes every field back on save. A field added to an action later, whose default had to differ from the behaviour of configs saved without it, was therefore switched over by opening the workflow and pressing save - with a plausible value on screen the whole time and nothing to suggest the deduction would book a different amount from then on. The calculation base is the first such field: new actions are offered the base excluding tourist tax, older ones keep the full gross. A schema field can now carry defaultForExisting alongside default. The controller fills it in for keys the stored config lacks before handing the config to the form, so editing an older workflow shows what it books rather than the suggestion meant for new ones, and saving it changes nothing by itself. Moving such a workflow onto the new base stays possible - by picking it, visibly. The test compares the two paths rather than the constants: the action is run once with no key in the config and once with the value the form shows for that case, and both have to reach the same amount. Drift between the two would otherwise surface as quietly shifted figures. --- src/Controller/WorkflowController.php | 30 ++++++++++++++ .../Action/CreatePercentageEntryAction.php | 6 ++- templates/Settings/Workflow/edit.html.twig | 2 +- .../CreatePercentageEntryActionTest.php | 40 ++++++++++++++++--- 4 files changed, 70 insertions(+), 8 deletions(-) diff --git a/src/Controller/WorkflowController.php b/src/Controller/WorkflowController.php index 11a785b3..50f880a1 100644 --- a/src/Controller/WorkflowController.php +++ b/src/Controller/WorkflowController.php @@ -518,9 +518,39 @@ private function handleForm(Request $request, Workflow $workflow, bool $isNew): 'workflow' => $workflow, 'isNew' => $isNew, 'triggerChoices' => $triggerChoices, + 'actionConfig' => $isNew ? $workflow->getActionConfig() : $this->configForEditing($workflow), ]); } + /** + * The stored action config, with keys added to the action after this workflow + * was saved filled in with what it is actually doing without them. + * + * The form renders a field's default wherever the stored config says nothing, + * and writes every field back on save. A field whose default differs from the + * behaviour of an older config - because the old behaviour had to be preserved + * - would otherwise be switched over by opening the workflow and saving it, + * with nothing on screen suggesting anything changed. + * + * @return array + */ + private function configForEditing(Workflow $workflow): array + { + $config = $workflow->getActionConfig(); + + if (!$this->actionRegistry->has($workflow->getActionType())) { + return $config; + } + + foreach ($this->actionRegistry->get($workflow->getActionType())->getConfigSchema() as $field) { + if (isset($field['defaultForExisting']) && !array_key_exists($field['key'], $config)) { + $config[$field['key']] = $field['defaultForExisting']; + } + } + + return $config; + } + private function serializePreviewEntity(object $entity): array { $data = ['id' => method_exists($entity, 'getId') ? $entity->getId() : null]; diff --git a/src/Workflow/Action/CreatePercentageEntryAction.php b/src/Workflow/Action/CreatePercentageEntryAction.php index 13563e51..d8efca64 100644 --- a/src/Workflow/Action/CreatePercentageEntryAction.php +++ b/src/Workflow/Action/CreatePercentageEntryAction.php @@ -134,8 +134,12 @@ public function getConfigSchema(): array ], // Offered first and preselected: portals charge commission on what // the house earns, and tourist tax is collected for the municipality. - // Existing configs are left alone, see amountBase() below. 'default' => self::AMOUNT_BASE_GROSS_WITHOUT_TOURIST_TAX, + // What a config saved before this field existed is doing, so + // editing such a workflow shows what it books rather than the + // suggestion for new ones - and saving it does not move its base + // as a side effect. Kept in step with the fallback in baseAmount(). + 'defaultForExisting' => self::AMOUNT_BASE_GROSS, ], [ 'key' => 'debitAccountId', diff --git a/templates/Settings/Workflow/edit.html.twig b/templates/Settings/Workflow/edit.html.twig index b52ee79d..afc096ae 100644 --- a/templates/Settings/Workflow/edit.html.twig +++ b/templates/Settings/Workflow/edit.html.twig @@ -132,7 +132,7 @@ {# Dynamic action config fields rendered by Stimulus #}
+ value="{{ actionConfig|json_encode }}">
diff --git a/tests/Unit/Workflow/CreatePercentageEntryActionTest.php b/tests/Unit/Workflow/CreatePercentageEntryActionTest.php index 3668f0f5..782df26b 100644 --- a/tests/Unit/Workflow/CreatePercentageEntryActionTest.php +++ b/tests/Unit/Workflow/CreatePercentageEntryActionTest.php @@ -327,15 +327,43 @@ public function testOffersTheNarrowerBaseAsTheDefaultForNewActions(): void { $action = $this->makeAction(gross: 100.0); - $field = null; - foreach ($action->getConfigSchema() as $entry) { - if ('amountBase' === $entry['key']) { - $field = $entry; + self::assertSame( + CreatePercentageEntryAction::AMOUNT_BASE_GROSS_WITHOUT_TOURIST_TAX, + $this->amountBaseField($action)['default'] ?? null + ); + } + + public function testShowsAnOlderConfigTheBaseItIsActuallyBookingOn(): void + { + // The form fills a field the stored config says nothing about with this + // value, and writes every field back on save. If it drifted from the + // fallback below, opening an old workflow and saving it would move its + // base without anything on screen saying so. + $withoutKey = null; + $action = $this->makeAction(gross: 115.20, capturePositions: $withoutKey); + $config = $this->config(); + unset($config['amountBase']); + $action->execute($config, $this->invoiceWithPositions(), []); + + $asShown = null; + $action = $this->makeAction(gross: 115.20, capturePositions: $asShown); + $shown = $this->amountBaseField($action)['defaultForExisting'] ?? null; + $action->execute($this->config(['amountBase' => $shown]), $this->invoiceWithPositions(), []); + + self::assertNotNull($shown); + self::assertSame($this->descriptionsOf($withoutKey), $this->descriptionsOf($asShown)); + } + + /** @return array|null */ + private function amountBaseField(CreatePercentageEntryAction $action): ?array + { + foreach ($action->getConfigSchema() as $field) { + if ('amountBase' === $field['key']) { + return $field; } } - self::assertNotNull($field); - self::assertSame(CreatePercentageEntryAction::AMOUNT_BASE_GROSS_WITHOUT_TOURIST_TAX, $field['default']); + return null; } public function testSkipsWhenTheInvoiceHasNoAmount(): void From 15d26ed2db627c40714847b96e04653f7f78a3f9 Mon Sep 17 00:00:00 2001 From: MeisterAdebar Date: Sun, 2 Aug 2026 11:13:09 +0200 Subject: [PATCH 15/33] Treat a config without a calculation base like a new one Undoes "Show an existing workflow the settings it is running on". That commit kept workflows saved before the calculation base existed on the full gross, and taught the edit form to show them that base so saving would not move it silently. Both only matter if such workflows can exist - and they cannot: the field ships together with the action that reads it, so the only configs lacking it are the ones set up while this branch was being built. Those are few and known, and are better looked over by hand than served by a second code path that has to be kept in step with the fallback forever. A missing key now means the same as a new action: the gross without the tourist-tax positions. The generic defaultForExisting hook in the controller goes with it, since no field declares it anymore. --- src/Controller/WorkflowController.php | 30 ------------------- .../Action/CreatePercentageEntryAction.php | 13 ++------ templates/Settings/Workflow/edit.html.twig | 2 +- .../CreatePercentageEntryActionTest.php | 30 ++++--------------- 4 files changed, 9 insertions(+), 66 deletions(-) diff --git a/src/Controller/WorkflowController.php b/src/Controller/WorkflowController.php index 50f880a1..11a785b3 100644 --- a/src/Controller/WorkflowController.php +++ b/src/Controller/WorkflowController.php @@ -518,39 +518,9 @@ private function handleForm(Request $request, Workflow $workflow, bool $isNew): 'workflow' => $workflow, 'isNew' => $isNew, 'triggerChoices' => $triggerChoices, - 'actionConfig' => $isNew ? $workflow->getActionConfig() : $this->configForEditing($workflow), ]); } - /** - * The stored action config, with keys added to the action after this workflow - * was saved filled in with what it is actually doing without them. - * - * The form renders a field's default wherever the stored config says nothing, - * and writes every field back on save. A field whose default differs from the - * behaviour of an older config - because the old behaviour had to be preserved - * - would otherwise be switched over by opening the workflow and saving it, - * with nothing on screen suggesting anything changed. - * - * @return array - */ - private function configForEditing(Workflow $workflow): array - { - $config = $workflow->getActionConfig(); - - if (!$this->actionRegistry->has($workflow->getActionType())) { - return $config; - } - - foreach ($this->actionRegistry->get($workflow->getActionType())->getConfigSchema() as $field) { - if (isset($field['defaultForExisting']) && !array_key_exists($field['key'], $config)) { - $config[$field['key']] = $field['defaultForExisting']; - } - } - - return $config; - } - private function serializePreviewEntity(object $entity): array { $data = ['id' => method_exists($entity, 'getId') ? $entity->getId() : null]; diff --git a/src/Workflow/Action/CreatePercentageEntryAction.php b/src/Workflow/Action/CreatePercentageEntryAction.php index d8efca64..af1ff1dc 100644 --- a/src/Workflow/Action/CreatePercentageEntryAction.php +++ b/src/Workflow/Action/CreatePercentageEntryAction.php @@ -135,11 +135,6 @@ public function getConfigSchema(): array // Offered first and preselected: portals charge commission on what // the house earns, and tourist tax is collected for the municipality. 'default' => self::AMOUNT_BASE_GROSS_WITHOUT_TOURIST_TAX, - // What a config saved before this field existed is doing, so - // editing such a workflow shows what it books rather than the - // suggestion for new ones - and saving it does not move its base - // as a side effect. Kept in step with the fallback in baseAmount(). - 'defaultForExisting' => self::AMOUNT_BASE_GROSS, ], [ 'key' => 'debitAccountId', @@ -338,16 +333,14 @@ private function toPercent(?string $raw): float /** * The amount the percentage is taken of. * - * Falls back to the full gross for configs saved before the choice existed: - * those were set up against that figure, and quietly moving their base would - * change what they book from one release to the next. New actions start on - * the narrower base instead, see the schema above. + * A config that says nothing is treated like a new one. The field was part + * of the action from its first release, so no saved workflow predates it. */ private function baseAmount(array $config, Invoice $invoice): float { $positions = $invoice->getPositions() ?? new ArrayCollection(); - if (self::AMOUNT_BASE_GROSS_WITHOUT_TOURIST_TAX === (string) ($config['amountBase'] ?? self::AMOUNT_BASE_GROSS)) { + if (self::AMOUNT_BASE_GROSS_WITHOUT_TOURIST_TAX === (string) ($config['amountBase'] ?? self::AMOUNT_BASE_GROSS_WITHOUT_TOURIST_TAX)) { // Dropped before the sum rather than subtracted afterwards, so the // per-VAT-rate rounding inside calculateSums stays the one the // remaining positions produce on their own. diff --git a/templates/Settings/Workflow/edit.html.twig b/templates/Settings/Workflow/edit.html.twig index afc096ae..b52ee79d 100644 --- a/templates/Settings/Workflow/edit.html.twig +++ b/templates/Settings/Workflow/edit.html.twig @@ -132,7 +132,7 @@ {# Dynamic action config fields rendered by Stimulus #}
+ value="{{ workflow.actionConfig|json_encode }}">
diff --git a/tests/Unit/Workflow/CreatePercentageEntryActionTest.php b/tests/Unit/Workflow/CreatePercentageEntryActionTest.php index 782df26b..87bf2a72 100644 --- a/tests/Unit/Workflow/CreatePercentageEntryActionTest.php +++ b/tests/Unit/Workflow/CreatePercentageEntryActionTest.php @@ -309,10 +309,11 @@ public function testKeepsTouristTaxInTheBaseWhenTheFullGrossIsConfigured(): void self::assertSame(['Übernachtung', 'Endreinigung', 'Kurtaxe'], $this->descriptionsOf($positions)); } - public function testFallsBackToTheFullGrossForConfigsSavedBeforeTheChoiceExisted(): void + public function testTreatsAConfigWithoutTheKeyLikeANewOne(): void { - // Those workflows were set up against that figure; changing what they - // book from one release to the next would be a silent correction. + // The field ships with the action, so only a workflow configured while + // this was still being built can lack it - no reason to keep a second + // behaviour around for those. $positions = null; $action = $this->makeAction(gross: 115.20, capturePositions: $positions); @@ -320,7 +321,7 @@ public function testFallsBackToTheFullGrossForConfigsSavedBeforeTheChoiceExisted unset($config['amountBase']); $action->execute($config, $this->invoiceWithPositions(), []); - self::assertSame(['Übernachtung', 'Endreinigung', 'Kurtaxe'], $this->descriptionsOf($positions)); + self::assertSame(['Übernachtung', 'Endreinigung'], $this->descriptionsOf($positions)); } public function testOffersTheNarrowerBaseAsTheDefaultForNewActions(): void @@ -333,27 +334,6 @@ public function testOffersTheNarrowerBaseAsTheDefaultForNewActions(): void ); } - public function testShowsAnOlderConfigTheBaseItIsActuallyBookingOn(): void - { - // The form fills a field the stored config says nothing about with this - // value, and writes every field back on save. If it drifted from the - // fallback below, opening an old workflow and saving it would move its - // base without anything on screen saying so. - $withoutKey = null; - $action = $this->makeAction(gross: 115.20, capturePositions: $withoutKey); - $config = $this->config(); - unset($config['amountBase']); - $action->execute($config, $this->invoiceWithPositions(), []); - - $asShown = null; - $action = $this->makeAction(gross: 115.20, capturePositions: $asShown); - $shown = $this->amountBaseField($action)['defaultForExisting'] ?? null; - $action->execute($this->config(['amountBase' => $shown]), $this->invoiceWithPositions(), []); - - self::assertNotNull($shown); - self::assertSame($this->descriptionsOf($withoutKey), $this->descriptionsOf($asShown)); - } - /** @return array|null */ private function amountBaseField(CreatePercentageEntryAction $action): ?array { From d111b00d893fb7c00430a9e2df690ec3c759e2a6 Mon Sep 17 00:00:00 2001 From: MeisterAdebar Date: Sun, 2 Aug 2026 11:13:18 +0200 Subject: [PATCH 16/33] Name the excluded positions after what they all are The tourist tax is one lodging levy among several - a tourism fee or a bed tax behave the same way and are just as little commissionable. The option said "without tourist tax" and so read as if it singled that one out, while the code excludes every position tagged as such a levy. The wording now names the group and keeps the tourist tax as the example, in both languages. The stored value stays gross_without_tourist_tax so existing configurations keep working. --- translations/Workflow/messages.de.yaml | 4 ++-- translations/Workflow/messages.en.yaml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/translations/Workflow/messages.de.yaml b/translations/Workflow/messages.de.yaml index 4c760678..4714b813 100644 --- a/translations/Workflow/messages.de.yaml +++ b/translations/Workflow/messages.de.yaml @@ -82,8 +82,8 @@ workflow: percentage_entry_percent: "Prozentsatz (manuell)" percentage_entry_percent_help: "Anteil an der gewählten Berechnungsgrundlage, z.B. 12 für eine Kommission oder 1,4 für eine Zahlungsgebühr. Nur wirksam, wenn die Quelle „Manuell“ ist. Ohne einschränkende Bedingung wird auf jede Rechnung gebucht." percentage_entry_amount_base: "Berechnungsgrundlage" - percentage_entry_amount_base_help: "Betrag, auf den der Prozentsatz angewendet wird. Kurtaxe wird für die Gemeinde vereinnahmt und zählt bei den meisten Portalen nicht zur provisionsfähigen Summe. Kommission und Zahlungsgebühr lassen sich getrennt einstellen, wenn der Vertrag das vorsieht." - percentage_entry_amount_base_without_tourist_tax: "Rechnungsbrutto ohne Kurtaxe" + percentage_entry_amount_base_help: "Betrag, auf den der Prozentsatz angewendet wird. Beherbergungsabgaben wie die Kurtaxe werden für die Gemeinde vereinnahmt und zählen bei den meisten Portalen nicht zur provisionsfähigen Summe; ausgenommen werden alle, die auf der Rechnung als solche ausgewiesen sind. Kommission und Zahlungsgebühr lassen sich getrennt einstellen, wenn der Vertrag das vorsieht." + percentage_entry_amount_base_without_tourist_tax: "Rechnungsbrutto ohne Beherbergungsabgaben" percentage_entry_amount_base_gross: "Vollständiges Rechnungsbrutto" percentage_entry_debit_account: "Sollkonto" percentage_entry_debit_account_help: "Konto, auf das der Abzug gebucht wird (Aufwand oder Reverse-Charge)." diff --git a/translations/Workflow/messages.en.yaml b/translations/Workflow/messages.en.yaml index 69bfb118..14559061 100644 --- a/translations/Workflow/messages.en.yaml +++ b/translations/Workflow/messages.en.yaml @@ -82,8 +82,8 @@ workflow: percentage_entry_percent: "Percentage (manual)" percentage_entry_percent_help: "Share of the selected calculation base, e.g. 12 for a commission or 1.4 for a payment fee. Only used when the source is \"Manual\". Without a condition narrowing it down, every invoice is booked." percentage_entry_amount_base: "Calculation base" - percentage_entry_amount_base_help: "The amount the percentage is applied to. Tourist tax is collected on behalf of the municipality and is not commissionable with most portals. Commission and payment fee can use different bases where the contract says so." - percentage_entry_amount_base_without_tourist_tax: "Invoice gross without tourist tax" + percentage_entry_amount_base_help: "The amount the percentage is applied to. Lodging levies such as tourist tax are collected on behalf of the municipality and are not commissionable with most portals; every levy shown as such on the invoice is left out. Commission and payment fee can use different bases where the contract says so." + percentage_entry_amount_base_without_tourist_tax: "Invoice gross without lodging levies" percentage_entry_amount_base_gross: "Full invoice gross" percentage_entry_debit_account: "Debit account" percentage_entry_debit_account_help: "Account the deduction is booked to (expense or reverse charge)." From 6b1244c87659bb3116696ce4a2556d100d06d832 Mon Sep 17 00:00:00 2001 From: MeisterAdebar Date: Sun, 2 Aug 2026 13:46:14 +0200 Subject: [PATCH 17/33] Mark the deduction as what created it createEntryFromStatement() exists for the bank import and marks what it returns as manual, leaving the source to the caller - which this action never did. A deduction nobody typed in was therefore indistinguishable from a hand-written entry in the data. Nothing reads the field yet, so nothing was visibly wrong; it starts to matter as soon as the entries are reconciled against the portal's own statement, which is where telling automatic from manual is the point. The test now works with a real BookingEntry: the stub swallowed every property the action set on it. --- .../CreatePercentageEntryActionTest.php | 23 +++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/tests/Unit/Workflow/CreatePercentageEntryActionTest.php b/tests/Unit/Workflow/CreatePercentageEntryActionTest.php index 87bf2a72..bdd53e08 100644 --- a/tests/Unit/Workflow/CreatePercentageEntryActionTest.php +++ b/tests/Unit/Workflow/CreatePercentageEntryActionTest.php @@ -324,6 +324,19 @@ public function testTreatsAConfigWithoutTheKeyLikeANewOne(): void self::assertSame(['Übernachtung', 'Endreinigung'], $this->descriptionsOf($positions)); } + public function testMarksTheEntryAsComingFromAWorkflow(): void + { + // createEntryFromStatement() serves the bank import and hands back + // something marked manual; a deduction nobody typed in must not stay + // that way, or the journal cannot tell the two apart. + $capture = null; + $action = $this->makeAction(gross: 100.0, capture: $capture); + + $action->execute($this->config(), $this->invoiceWithPositions(), []); + + self::assertSame(BookingEntry::SOURCE_WORKFLOW, $capture['entry']->getSourceType()); + } + public function testOffersTheNarrowerBaseAsTheDefaultForNewActions(): void { $action = $this->makeAction(gross: 100.0); @@ -503,8 +516,14 @@ function ($date, $amount, $debit, $credit, $remark, $invoiceNumber = null, $invo 'taxRate' => $taxRate, ]; - $entry = $this->createStub(BookingEntry::class); - $entry->method('getInvoiceNumber')->willReturn($invoiceNumber); + // A real entry, not a stub: the action sets properties on what + // it gets back, and a stub would swallow them unseen. + $entry = new BookingEntry(); + $entry->setInvoiceNumber($invoiceNumber); + // What the real createEntryFromStatement() leaves behind - the + // action is expected to correct it. + $entry->setSourceType(BookingEntry::SOURCE_MANUAL); + $capture['entry'] = $entry; return $entry; } From 7481cceacdfc799cecb43527c754caede9f68535 Mon Sep 17 00:00:00 2001 From: MeisterAdebar Date: Tue, 4 Aug 2026 16:23:32 +0200 Subject: [PATCH 18/33] Work the portal's fees out in one place The surcharge an invoice shows the guest and the deduction the journal books were calculated separately, and could not be brought together: the base was a workflow setting, which the render path has no business reading and which several workflows may answer differently. So the guest was shown a percentage of the full gross while the journal booked a percentage of something else. Whether a portal charges commission on a position follows from the booking, not from a workflow, so OriginFeeCalculator derives both fees and both bases from the invoice, and both paths ask it. The rate resolution - pinned on the reservation, falling back to the origin - moves along with it, so it too exists once. The bases now differ on purpose. Booking.com exempts tourist tax from commission as long as it is billed separately, which is how a tax that shows up as a position of its own is set up, so commission is taken on the gross without it. The payment fee is charged on what the portal processed and keeps it. That second base is the rough one: whether the portal collected the payment at all is not recorded yet, so the full gross stands in for it, which overstates the fee for a stay whose tourist tax the house collects on arrival. It is in the workflow log as a figure and can be corrected there. amountBase survives for a percentage somebody types in, where nothing but the config says what it is of, and is hidden for the two origin sources rather than offered and ignored. The sums had to come out of InvoiceService for this - it needs the fees, the calculator needs the sums, and constructor injection has no room for a circle. InvoiceSumCalculator holds the arithmetic unchanged; InvoiceService::calculateSums() stays as the way every caller reaches it. --- src/Dto/OriginFee.php | 55 ++++ src/Dto/OriginFeeBreakdown.php | 26 ++ src/Entity/ReservationOrigin.php | 11 +- src/Service/InvoiceService.php | 131 ++------- src/Service/InvoiceSumCalculator.php | 108 +++++++ src/Service/OriginFeeCalculator.php | 173 +++++++++++ .../Action/CreatePercentageEntryAction.php | 165 +++-------- tests/Unit/BookingJournalServiceTest.php | 4 + .../InvoiceServiceApartmentModifierTest.php | 4 +- .../Unit/InvoiceServiceBuildPositionsTest.php | 4 +- tests/Unit/InvoiceServiceFilenameTest.php | 4 +- .../Unit/InvoiceServiceGuestSurchargeTest.php | 161 ---------- tests/Unit/InvoiceServicePricingFlagsTest.php | 4 +- tests/Unit/InvoiceServiceTouristTaxTest.php | 4 +- tests/Unit/OriginFeeCalculatorTest.php | 275 ++++++++++++++++++ .../CreatePercentageEntryActionTest.php | 19 +- translations/Workflow/messages.de.yaml | 2 +- translations/Workflow/messages.en.yaml | 2 +- 18 files changed, 744 insertions(+), 408 deletions(-) create mode 100644 src/Dto/OriginFee.php create mode 100644 src/Dto/OriginFeeBreakdown.php create mode 100644 src/Service/InvoiceSumCalculator.php create mode 100644 src/Service/OriginFeeCalculator.php delete mode 100644 tests/Unit/InvoiceServiceGuestSurchargeTest.php create mode 100644 tests/Unit/OriginFeeCalculatorTest.php diff --git a/src/Dto/OriginFee.php b/src/Dto/OriginFee.php new file mode 100644 index 00000000..7c61c936 --- /dev/null +++ b/src/Dto/OriginFee.php @@ -0,0 +1,55 @@ + $rates every distinct rate the invoice's reservations + * carry for this fee, keyed by its formatted form + * so a caller can name them in a message. Empty + * where the rate did not come from the booking + * at all but was typed into a workflow + */ + public function __construct( + public float $percent, + public float $base, + public array $rates = [], + ) { + $this->amount = round($base * $percent / 100.0, 2); + } + + /** + * Whether all reservations on the invoice were taken under one rate. An + * invoice without reservations agrees trivially - it has nothing to + * disagree about, and its fee is zero anyway. + */ + public function isAgreedUpon(): bool + { + return count($this->rates) <= 1; + } + + /** @return string[] the rates as they read in a message, e.g. "12,00 %" */ + public function rateLabels(): array + { + return array_keys($this->rates); + } +} diff --git a/src/Dto/OriginFeeBreakdown.php b/src/Dto/OriginFeeBreakdown.php new file mode 100644 index 00000000..f7f0b791 --- /dev/null +++ b/src/Dto/OriginFeeBreakdown.php @@ -0,0 +1,26 @@ +commissionPercent; @@ -122,7 +123,7 @@ public function setCommissionPercent(?string $commissionPercent): self return $this; } - /** Portal payment fee as a percentage of the gross total, null when none applies. */ + /** Portal payment fee in percent, null when none applies. */ public function getPaymentFeePercent(): ?string { return $this->paymentFeePercent; diff --git a/src/Service/InvoiceService.php b/src/Service/InvoiceService.php index ffead698..6893f7b0 100644 --- a/src/Service/InvoiceService.php +++ b/src/Service/InvoiceService.php @@ -51,6 +51,8 @@ public function __construct( private readonly PriceService $ps, private readonly TranslatorInterface $translator, private readonly AppSettingsService $appSettingsService, + private readonly InvoiceSumCalculator $sums, + private readonly OriginFeeCalculator $originFees, private readonly ?TouristTaxService $touristTaxService = null, private readonly ?InvoiceNumberGenerator $numberGenerator = null, // Optional like the two above, so the unit tests that build this service by @@ -62,65 +64,20 @@ public function __construct( /** * Calculates the sums and vats for an invoice. * - * @param array $apps The invoice positions for apartment prices - * @param array $poss The invoice positions for miscellaneous prices - * @param array $vats Returns array of all vat values - * @param float $brutto Returns the total price including vat - * @param float $netto Returns the toal price for all vats - * @param float $appartmentTotal Returns the total sum for all apartment prices - * @param float $miscTotal Returns the total price for all miscellaneous prices + * The arithmetic itself lives in InvoiceSumCalculator; this stays as the + * way every existing caller reaches it. + * + * @param Collection $apps The invoice positions for apartment prices + * @param Collection $poss The invoice positions for miscellaneous prices + * @param array $vats Returns array of all vat values + * @param float $brutto Returns the total price including vat + * @param float $netto Returns the toal price for all vats + * @param float $appartmentTotal Returns the total sum for all apartment prices + * @param float $miscTotal Returns the total price for all miscellaneous prices */ public function calculateSums(Collection $apps, Collection $poss, array &$vats, float &$brutto, float &$netto, float &$appartmentTotal, float &$miscTotal): void { - $vats = []; - $brutto = 0.0; - $netto = 0.0; - $appartmentTotal = 0.0; - $miscTotal = 0.0; - - /* @var $apartment InvoiceAppartment */ - // $apps = $invoice->getAppartments(); - // $poss = $invoice->getPositions(); - foreach ($apps as $apartment) { - $apartmentPrice = ($apartment->getIsFlatPrice() ? $apartment->getPrice() : $apartment->getAmount() * $apartment->getPrice()); - - if ($apartment->getIncludesVat()) { // price includes vat - $vatAmount = (($apartmentPrice * $apartment->getVat()) / (100 + $apartment->getVat())); - $bruttoAmount = $apartmentPrice; - } else { // price does not include vat - $vatAmount = (($apartmentPrice * $apartment->getVat()) / 100); - $bruttoAmount = $apartmentPrice + $vatAmount; - } - - $vats[$apartment->getVat()]['brutto'] = ($vats[$apartment->getVat()]['brutto'] ?? 0) + $bruttoAmount; - $vats[$apartment->getVat()]['netto'] = ($vats[$apartment->getVat()]['netto'] ?? 0) + $vatAmount; - $vats[$apartment->getVat()]['netSum'] = ($vats[$apartment->getVat()]['netSum'] ?? 0) + $bruttoAmount - $vatAmount; - $appartmentTotal += $apartmentPrice; - } - - foreach ($poss as $pos) { - $miscPrice = ($pos->getIsFlatPrice() ? $pos->getPrice() : $pos->getAmount() * $pos->getPrice()); - - if ($pos->getIncludesVat()) { // price includes vat - $vatAmount = (($miscPrice * $pos->getVat()) / (100 + $pos->getVat())); - $bruttoAmount = $miscPrice; - } else { // price does not include vat - $vatAmount = (($miscPrice * $pos->getVat()) / 100); - $bruttoAmount = $miscPrice + $vatAmount; - } - - $vats[$pos->getVat()]['brutto'] = ($vats[$pos->getVat()]['brutto'] ?? 0) + $bruttoAmount; - $vats[$pos->getVat()]['netto'] = ($vats[$pos->getVat()]['netto'] ?? 0) + $vatAmount; - $vats[$pos->getVat()]['netSum'] = ($vats[$pos->getVat()]['netSum'] ?? 0) + $bruttoAmount - $vatAmount; - $miscTotal += $miscPrice; - } - - foreach ($vats as $key => $vat) { - $brutto += round($vat['brutto'], 2); - $netto += round($vat['netto'], 2); - $vats[$key]['nettoFormated'] = number_format(round($vat['netto'], 2), 2, ',', '.'); - } - ksort($vats); + $this->sums->calculate($apps, $poss, $vats, $brutto, $netto, $appartmentTotal, $miscTotal); } /** @@ -238,7 +195,7 @@ public function buildTemplateRenderParams(Template $template, Invoice $invoice): $periods = $this->getUniqueReservationPeriods($invoice); $appartmentNumbers = $this->getUniqueAppartmentsNumber($invoice); - $surcharge = $this->resolveGuestSurcharge($invoice, $brutto); + $originFees = $this->originFees->calculate($invoice); $params = [ 'invoice' => $invoice, @@ -255,66 +212,22 @@ public function buildTemplateRenderParams(Template $template, Invoice $invoice): // each invoice's own payment period. Null when none is configured. 'paymentDueDate' => $this->readinessService?->resolveSettingsFor($invoice)?->dueDateFor($invoice->getDate()), // The portal's commission and payment fee for a booking through the - // reservation's origin. Amounts are zero (not null) when no origin + // reservation's origin, worked out by OriginFeeCalculator - the same + // one the deduction is booked from, so the guest is shown what the + // journal records. Amounts are zero (not null) when no origin // applies; originName is null then, so a template can guard with a // plain [% if originName %]. A total, if wanted, is originCommission // + originPaymentFee - left to the template rather than provided. - 'originName' => $surcharge['name'], - 'originCommission' => $surcharge['commission'], - 'originCommissionFormated' => number_format($surcharge['commission'], 2, ',', '.'), - 'originPaymentFee' => $surcharge['paymentFee'], - 'originPaymentFeeFormated' => number_format($surcharge['paymentFee'], 2, ',', '.'), + 'originName' => $originFees->originName, + 'originCommission' => $originFees->commission->amount, + 'originCommissionFormated' => number_format($originFees->commission->amount, 2, ',', '.'), + 'originPaymentFee' => $originFees->paymentFee->amount, + 'originPaymentFeeFormated' => number_format($originFees->paymentFee->amount, 2, ',', '.'), ]; return $params; } - /** - * The extra a guest paid by booking through the reservation's origin rather - * than directly, split into the portal's commission and payment fee, each a - * percentage of the gross total. Mirrors the basis the deduction workflows - * book on, so the figures shown to the guest match what the portal actually - * took: the rates the reservation was booked under, falling back to the - * origin's current ones only where none were pinned. Reading the origin - * outright would show a figure the journal never booked as soon as a - * contract has been renegotiated. - * - * The first reservation carrying an origin with either percentage decides - * it. An invoice mixing origins - or one portal at rates that changed in - * between - is shown the first of them here, while the deduction skips such - * an invoice rather than pick one; a guest-facing note and a journal entry - * do not carry the same weight. Amounts are zero rather than null when - * nothing applies, so templates need no null-guard beyond a truthiness - * check. - * - * @return array{name: ?string, commission: float, paymentFee: float} - */ - private function resolveGuestSurcharge(Invoice $invoice, float $brutto): array - { - $empty = ['name' => null, 'commission' => 0.0, 'paymentFee' => 0.0]; - - foreach ($invoice->getReservations() as $reservation) { - $origin = $reservation->getReservationOrigin(); - if (null === $origin) { - continue; - } - - $commissionPercent = (float) ($reservation->getCommissionPercent() ?? $origin->getCommissionPercent() ?? 0.0); - $paymentFeePercent = (float) ($reservation->getPaymentFeePercent() ?? $origin->getPaymentFeePercent() ?? 0.0); - if ($commissionPercent <= 0.0 && $paymentFeePercent <= 0.0) { - continue; - } - - return [ - 'name' => $origin->getName(), - 'commission' => round($brutto * $commissionPercent / 100.0, 2), - 'paymentFee' => round($brutto * $paymentFeePercent / 100.0, 2), - ]; - } - - return $empty; - } - public function generateInvoicePdfXml(TemplatesService $ts, EInvoiceExportService $einvoice, Invoice $invoice, Template $template, InvoiceSettingsData $invoiceSettings): string { $templateOutput = $ts->renderTemplate($template->getId(), $invoice->getId()); diff --git a/src/Service/InvoiceSumCalculator.php b/src/Service/InvoiceSumCalculator.php new file mode 100644 index 00000000..8bd98bea --- /dev/null +++ b/src/Service/InvoiceSumCalculator.php @@ -0,0 +1,108 @@ + $apps The invoice positions for apartment prices + * @param Collection $poss The invoice positions for miscellaneous prices + * @param array> $vats Returns array of all vat values + * @param float $brutto Returns the total price including vat + * @param float $netto Returns the toal price for all vats + * @param float $appartmentTotal Returns the total sum for all apartment prices + * @param float $miscTotal Returns the total price for all miscellaneous prices + */ + public function calculate(Collection $apps, Collection $poss, array &$vats, float &$brutto, float &$netto, float &$appartmentTotal, float &$miscTotal): void + { + $vats = []; + $brutto = 0.0; + $netto = 0.0; + $appartmentTotal = 0.0; + $miscTotal = 0.0; + + foreach ($apps as $apartment) { + $apartmentPrice = ($apartment->getIsFlatPrice() ? $apartment->getPrice() : $apartment->getAmount() * $apartment->getPrice()); + + if ($apartment->getIncludesVat()) { // price includes vat + $vatAmount = (($apartmentPrice * $apartment->getVat()) / (100 + $apartment->getVat())); + $bruttoAmount = $apartmentPrice; + } else { // price does not include vat + $vatAmount = (($apartmentPrice * $apartment->getVat()) / 100); + $bruttoAmount = $apartmentPrice + $vatAmount; + } + + // An array key cannot be a float: PHP would truncate 5.5 to 5 and + // merge that rate with another one. As a string, 19.0 still becomes + // the key 19, so whole rates are grouped as before. + $rate = (string) $apartment->getVat(); + $vats[$rate]['brutto'] = ($vats[$rate]['brutto'] ?? 0.0) + $bruttoAmount; + $vats[$rate]['netto'] = ($vats[$rate]['netto'] ?? 0.0) + $vatAmount; + $vats[$rate]['netSum'] = ($vats[$rate]['netSum'] ?? 0.0) + $bruttoAmount - $vatAmount; + $appartmentTotal += $apartmentPrice; + } + + foreach ($poss as $pos) { + $miscPrice = ($pos->getIsFlatPrice() ? $pos->getPrice() : $pos->getAmount() * $pos->getPrice()); + + if ($pos->getIncludesVat()) { // price includes vat + $vatAmount = (($miscPrice * $pos->getVat()) / (100 + $pos->getVat())); + $bruttoAmount = $miscPrice; + } else { // price does not include vat + $vatAmount = (($miscPrice * $pos->getVat()) / 100); + $bruttoAmount = $miscPrice + $vatAmount; + } + + $rate = (string) $pos->getVat(); + $vats[$rate]['brutto'] = ($vats[$rate]['brutto'] ?? 0.0) + $bruttoAmount; + $vats[$rate]['netto'] = ($vats[$rate]['netto'] ?? 0.0) + $vatAmount; + $vats[$rate]['netSum'] = ($vats[$rate]['netSum'] ?? 0.0) + $bruttoAmount - $vatAmount; + $miscTotal += $miscPrice; + } + + foreach ($vats as $key => $vat) { + $brutto += round($vat['brutto'], 2); + $netto += round($vat['netto'], 2); + $vats[$key]['nettoFormated'] = number_format(round($vat['netto'], 2), 2, ',', '.'); + } + ksort($vats); + } + + /** + * The gross total of the given parts, for callers that want nothing else. + * + * @param Collection $apps The invoice positions for apartment prices + * @param Collection $poss The invoice positions for miscellaneous prices + */ + public function grossTotal(Collection $apps, Collection $poss): float + { + $vats = []; + $brutto = 0.0; + $netto = 0.0; + $appartmentTotal = 0.0; + $miscTotal = 0.0; + + $this->calculate($apps, $poss, $vats, $brutto, $netto, $appartmentTotal, $miscTotal); + + return $brutto; + } +} diff --git a/src/Service/OriginFeeCalculator.php b/src/Service/OriginFeeCalculator.php new file mode 100644 index 00000000..e9cd4fe2 --- /dev/null +++ b/src/Service/OriginFeeCalculator.php @@ -0,0 +1,173 @@ +reservationTheFiguresBelongTo($invoice); + + return new OriginFeeBreakdown( + $shown?->getReservationOrigin()?->getName(), + $this->fee( + $invoice, + $shown, + $this->grossTotal($invoice, excludeTouristTax: true), + static fn (Reservation $r): ?string => $r->getCommissionPercent() + ?? $r->getReservationOrigin()?->getCommissionPercent(), + ), + $this->fee( + $invoice, + $shown, + $this->grossTotal($invoice, excludeTouristTax: false), + static fn (Reservation $r): ?string => $r->getPaymentFeePercent() + ?? $r->getReservationOrigin()?->getPaymentFeePercent(), + ), + ); + } + + /** + * The gross total of an invoice, optionally without its tourist tax. + * + * Public for the one caller that still picks its own base: a workflow + * booking a percentage somebody typed in, where nothing but the config says + * what the percentage is of. + */ + public function grossTotal(Invoice $invoice, bool $excludeTouristTax): float + { + $positions = $invoice->getPositions() ?? new ArrayCollection(); + + if ($excludeTouristTax) { + // Dropped before the sum rather than subtracted afterwards, so the + // per-VAT-rate rounding inside the sum stays the one the remaining + // positions produce on their own. + $positions = $positions->filter( + static fn (InvoicePosition $position): bool => self::POSITION_GROUP_TOURIST_TAX !== $position->getPositionGroup() + ); + } + + /** @var Collection $positions */ + return $this->sums->grossTotal($invoice->getAppartments() ?? new ArrayCollection(), $positions); + } + + /** + * One fee, at the rate that holds for the invoice. + * + * Which rate that is has two answers, and both are needed. Where every + * reservation agrees, that agreed rate is it - including the case of an + * invoice with no reservations at all, which yields nothing to book. Where + * they disagree, the rate is the one of the reservation the figures are + * shown for; the journal refuses such an invoice anyway (see + * OriginFee::isAgreedUpon), while the guest is shown a figure rather than a + * blank. + * + * @param callable(Reservation): ?string $rateOf + */ + private function fee(Invoice $invoice, ?Reservation $shown, float $base, callable $rateOf): OriginFee + { + $rates = []; + foreach ($invoice->getReservations() ?? [] as $reservation) { + $rate = $this->toPercent($rateOf($reservation)); + // Keyed by the formatted figure: it doubles as the label in a log + // line and keeps "12", "12.00" and 12.0 from counting as three + // rates. + $rates[number_format($rate, 2, ',', '.').' %'] = $rate; + } + + $percent = 1 === count($rates) + ? reset($rates) + : (null !== $shown ? $this->toPercent($rateOf($shown)) : 0.0); + + return new OriginFee($percent, $base, $rates); + } + + /** + * The reservation whose portal and rates the invoice shows. + * + * The first one that came through a portal charging anything. An invoice can + * hold several - which is a disagreement the journal stops at, but a note to + * the guest names the first rather than staying silent about a surcharge + * they did pay. + * + * A reservation whose rates are both zero is passed over on purpose: an + * origin exists for direct bookings too, and naming one that costs nothing + * would put a portal on an invoice that has no surcharge to explain. + */ + private function reservationTheFiguresBelongTo(Invoice $invoice): ?Reservation + { + foreach ($invoice->getReservations() ?? [] as $reservation) { + $origin = $reservation->getReservationOrigin(); + if (null === $origin) { + continue; + } + + $commission = $this->toPercent($reservation->getCommissionPercent() ?? $origin->getCommissionPercent()); + $paymentFee = $this->toPercent($reservation->getPaymentFeePercent() ?? $origin->getPaymentFeePercent()); + + if ($commission > 0.0 || $paymentFee > 0.0) { + return $reservation; + } + } + + return null; + } + + /** Reads a percentage as it may have been typed or stored, commas included. */ + private function toPercent(?string $raw): float + { + return (float) str_replace(',', '.', trim((string) $raw)); + } +} diff --git a/src/Workflow/Action/CreatePercentageEntryAction.php b/src/Workflow/Action/CreatePercentageEntryAction.php index af1ff1dc..9b83b516 100644 --- a/src/Workflow/Action/CreatePercentageEntryAction.php +++ b/src/Workflow/Action/CreatePercentageEntryAction.php @@ -4,17 +4,14 @@ namespace App\Workflow\Action; +use App\Dto\OriginFee; use App\Entity\BookingEntry; use App\Entity\Invoice; -use App\Entity\InvoiceAppartment; -use App\Entity\InvoicePosition; use App\Repository\AccountingAccountRepository; use App\Repository\TaxRateRepository; use App\Service\BookingJournal\BookingJournalService; -use App\Service\InvoiceService; +use App\Service\OriginFeeCalculator; use App\Workflow\WorkflowSkippedException; -use Doctrine\Common\Collections\ArrayCollection; -use Doctrine\Common\Collections\Collection; use Symfony\Contracts\Translation\TranslatorInterface; /** @@ -30,10 +27,11 @@ * percentSource string – where the percentage comes from, see the constants * percent string – the percentage itself, used when the source is * manual - * amountBase string – what the percentage is taken of, see the constants. - * Configured per action, so a commission and a payment - * fee added to the same workflow can each use their own - * base - portals rarely charge both on the same amount + * amountBase string – what a typed-in percentage is taken of, see the + * constants. Only read for the manual source: what a + * portal charges its commission and its payment fee on + * follows from the booking, not from a setting, and is + * worked out by OriginFeeCalculator * debitAccountId int|null – expense (or reverse-charge) account * creditAccountId int|null – account the deduction is taken from, usually the * same one the invoice itself was booked against @@ -59,18 +57,11 @@ class CreatePercentageEntryAction implements WorkflowActionInterface /** The percentage is taken of the gross total less the tourist-tax positions. */ public const AMOUNT_BASE_GROSS_WITHOUT_TOURIST_TAX = 'gross_without_tourist_tax'; - /** - * Position group the tourist-tax positions carry, see InvoicePosition::$positionGroup. - * They are the pass-through item this code can single out today; anything else - * that should stay out of a commission would need its own marker first. - */ - private const POSITION_GROUP_TOURIST_TAX = 'tourist_tax'; - public function __construct( private readonly BookingJournalService $bookingJournalService, private readonly AccountingAccountRepository $accountRepo, private readonly TaxRateRepository $taxRateRepo, - private readonly InvoiceService $invoiceService, + private readonly OriginFeeCalculator $originFees, private readonly TranslatorInterface $translator, ) { } @@ -135,6 +126,11 @@ public function getConfigSchema(): array // Offered first and preselected: portals charge commission on what // the house earns, and tourist tax is collected for the municipality. 'default' => self::AMOUNT_BASE_GROSS_WITHOUT_TOURIST_TAX, + // Like the percentage above, this only applies to a figure somebody + // typed in. What a commission or a payment fee is charged on follows + // from the booking (see OriginFeeCalculator), and offering a choice + // that is then ignored would be worse than offering none. + 'showIf' => ['key' => 'percentSource', 'value' => self::PERCENT_SOURCE_MANUAL], ], [ 'key' => 'debitAccountId', @@ -190,13 +186,13 @@ public function execute(array $config, mixed $entity, array $context): string throw new WorkflowSkippedException($this->translator->trans('workflow.log.skipped_unsupported_entity')); } - $percent = $this->resolvePercent($config, $entity); - if ($percent <= 0.0) { + $fee = $this->resolveFee($config, $entity); + if ($fee->percent <= 0.0) { throw new WorkflowSkippedException($this->translator->trans('workflow.log.skipped_no_percentage')); } - $base = $this->baseAmount($config, $entity); - $amount = round($base * $percent / 100.0, 2); + $base = $fee->base; + $amount = $fee->amount; if (0.0 === $amount) { throw new WorkflowSkippedException($this->translator->trans('workflow.log.skipped_no_amounts')); } @@ -246,7 +242,7 @@ public function execute(array $config, mixed $entity, array $context): string // log is where that gets checked against the portal's own statement. return $this->translator->trans('workflow.log.percentage_entry_created', [ // Formatted like the amount beside it: "1,40" rather than PHP's "1.4". - '%percent%' => number_format($percent, 2, ',', '.'), + '%percent%' => number_format($fee->percent, 2, ',', '.'), '%amount%' => number_format($amount, 2, ',', '.'), '%base%' => number_format($base, 2, ',', '.'), '%number%' => (string) $entity->getNumber(), @@ -254,26 +250,39 @@ public function execute(array $config, mixed $entity, array $context): string } /** - * The percentage to book, from wherever the config points it at. Reading it - * from the reservation origin keeps commission and payment fee in one place - * shared with what the guest is shown, rather than repeated in each - * workflow's config where the two could drift apart. An origin source that + * The fee to book: its rate, what it is taken of, and the amount that makes. + * + * For the two origin sources all three come from OriginFeeCalculator, which + * is also where the invoice's own figures for the guest come from - a rate + * repeated in each workflow's config, or a base picked there, is a rate and + * a base that can drift away from what the invoice states. A source that * finds no origin or no value yields zero, which the caller treats as - * nothing to book - the same as a manual percentage left blank. + * nothing to book, the same as a manual percentage left blank. * * @param array $config */ - private function resolvePercent(array $config, Invoice $invoice): float + private function resolveFee(array $config, Invoice $invoice): OriginFee { $source = (string) ($config['percentSource'] ?? self::PERCENT_SOURCE_MANUAL); - // Anything but the two origin sources uses the typed-in field and has no - // business looking at the invoice's reservations at all. + // Anything but the two origin sources uses the typed-in field, and only + // there is the base a matter of configuration: nothing about the booking + // says what an arbitrary percentage should be taken of. if (self::PERCENT_SOURCE_COMMISSION !== $source && self::PERCENT_SOURCE_PAYMENT_FEE !== $source) { - return $this->toPercent($config['percent'] ?? ''); + return new OriginFee( + $this->toPercent($config['percent'] ?? ''), + $this->originFees->grossTotal( + $invoice, + // A config that says nothing is treated like a new one. The + // field was part of the action from its first release, so no + // saved workflow predates it. + self::AMOUNT_BASE_GROSS !== (string) ($config['amountBase'] ?? self::AMOUNT_BASE_GROSS_WITHOUT_TOURIST_TAX), + ), + ); } - $rates = $this->ratesOnInvoice($invoice, $source); + $fees = $this->originFees->calculate($invoice); + $fee = self::PERCENT_SOURCE_COMMISSION === $source ? $fees->commission : $fees->paymentFee; // One entry is booked for the whole invoice, so a single rate has to hold // for all of it. Two portals on one invoice, or two bookings taken under @@ -281,102 +290,18 @@ private function resolvePercent(array $config, Invoice $invoice): float // invoice carries no attribution of its lines to reservations to split it // along. Booking one of the rates on the full amount would be wrong // without ever saying so, so this stops and asks for a manual entry. - if (count($rates) > 1) { + if (!$fee->isAgreedUpon()) { throw new WorkflowSkippedException($this->translator->trans('workflow.log.skipped_mixed_rates', [ - '%rates%' => implode(', ', array_keys($rates)), + '%rates%' => implode(', ', $fee->rateLabels()), ])); } - return 1 === count($rates) ? reset($rates) : 0.0; + return $fee; } - /** - * The distinct rates the invoice's reservations carry for the given source, - * keyed by their formatted form so the caller can name them. - * - * The rate a reservation was booked under wins over the one its origin carries - * today: a portal that renegotiates its commission must not change what an - * invoice from last season is charged. Reservations with no rate recorded fall - * through to the origin - a pinned rate is an answer whatever it says, an - * explicit zero included. A reservation without an origin counts as a rate of - * zero, which is what makes a direct booking sharing an invoice with a portal - * one show up as the disagreement it is. - * - * @return array - */ - private function ratesOnInvoice(Invoice $invoice, string $source): array - { - $rates = []; - - foreach ($invoice->getReservations() ?? [] as $reservation) { - $origin = $reservation->getReservationOrigin(); - - $raw = self::PERCENT_SOURCE_COMMISSION === $source - ? $reservation->getCommissionPercent() ?? $origin?->getCommissionPercent() - : $reservation->getPaymentFeePercent() ?? $origin?->getPaymentFeePercent(); - - $rate = $this->toPercent($raw); - // Keyed by the formatted figure: it doubles as the label in the log - // and keeps "12", "12.00" and 12.0 from counting as three rates. - $rates[number_format($rate, 2, ',', '.').' %'] = $rate; - } - - return $rates; - } - - /** Reads a percentage as it may have been typed or stored, commas included. */ + /** Reads a percentage as it may have been typed, commas included. */ private function toPercent(?string $raw): float { return (float) str_replace(',', '.', trim((string) $raw)); } - - /** - * The amount the percentage is taken of. - * - * A config that says nothing is treated like a new one. The field was part - * of the action from its first release, so no saved workflow predates it. - */ - private function baseAmount(array $config, Invoice $invoice): float - { - $positions = $invoice->getPositions() ?? new ArrayCollection(); - - if (self::AMOUNT_BASE_GROSS_WITHOUT_TOURIST_TAX === (string) ($config['amountBase'] ?? self::AMOUNT_BASE_GROSS_WITHOUT_TOURIST_TAX)) { - // Dropped before the sum rather than subtracted afterwards, so the - // per-VAT-rate rounding inside calculateSums stays the one the - // remaining positions produce on their own. - $positions = $positions->filter( - fn (InvoicePosition $position): bool => self::POSITION_GROUP_TOURIST_TAX !== $position->getPositionGroup() - ); - } - - return $this->grossTotal($invoice->getAppartments() ?? new ArrayCollection(), $positions); - } - - /** - * Gross total of the given parts, calculated the same way the invoice itself - * calculates the figure it shows. - * - * @param Collection $apartments - * @param Collection $positions - */ - private function grossTotal(Collection $apartments, Collection $positions): float - { - $brutto = 0.0; - $netto = 0.0; - $apartmentTotal = 0.0; - $miscTotal = 0.0; - $vats = []; - - $this->invoiceService->calculateSums( - $apartments, - $positions, - $vats, - $brutto, - $netto, - $apartmentTotal, - $miscTotal, - ); - - return $brutto; - } } diff --git a/tests/Unit/BookingJournalServiceTest.php b/tests/Unit/BookingJournalServiceTest.php index 08d09c17..9ee1eab1 100644 --- a/tests/Unit/BookingJournalServiceTest.php +++ b/tests/Unit/BookingJournalServiceTest.php @@ -21,6 +21,8 @@ use App\Service\AppSettingsService; use App\Service\BookingJournal\BookingJournalService; use App\Service\InvoiceService; +use App\Service\InvoiceSumCalculator; +use App\Service\OriginFeeCalculator; use App\Service\PriceService; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\ORM\EntityManagerInterface; @@ -723,6 +725,8 @@ private function createInvoiceService(): InvoiceService $this->createStub(PriceService::class), $this->createStub(TranslatorInterface::class), $appSettingsService, + new InvoiceSumCalculator(), + new OriginFeeCalculator(new InvoiceSumCalculator()), ); } diff --git a/tests/Unit/InvoiceServiceApartmentModifierTest.php b/tests/Unit/InvoiceServiceApartmentModifierTest.php index e2d8c68b..f033c177 100644 --- a/tests/Unit/InvoiceServiceApartmentModifierTest.php +++ b/tests/Unit/InvoiceServiceApartmentModifierTest.php @@ -15,6 +15,8 @@ use App\Entity\Reservation; use App\Service\AppSettingsService; use App\Service\InvoiceService; +use App\Service\InvoiceSumCalculator; +use App\Service\OriginFeeCalculator; use App\Service\PriceService; use Doctrine\ORM\EntityManagerInterface; use PHPUnit\Framework\TestCase; @@ -185,7 +187,7 @@ private function createService(Reservation $r, array $breakdowns): InvoiceServic $appSettingsService = $this->createStub(AppSettingsService::class); $appSettingsService->method('getSettings')->willReturn($appSettings); - return new InvoiceService($em, $priceService, $translator, $appSettingsService); + return new InvoiceService($em, $priceService, $translator, $appSettingsService, new InvoiceSumCalculator(), new OriginFeeCalculator(new InvoiceSumCalculator())); } /** diff --git a/tests/Unit/InvoiceServiceBuildPositionsTest.php b/tests/Unit/InvoiceServiceBuildPositionsTest.php index f140b65b..8a675885 100644 --- a/tests/Unit/InvoiceServiceBuildPositionsTest.php +++ b/tests/Unit/InvoiceServiceBuildPositionsTest.php @@ -11,6 +11,8 @@ use App\Entity\RoomCategory; use App\Service\AppSettingsService; use App\Service\InvoiceService; +use App\Service\InvoiceSumCalculator; +use App\Service\OriginFeeCalculator; use App\Service\PriceService; use Doctrine\ORM\EntityManagerInterface; use PHPUnit\Framework\TestCase; @@ -279,7 +281,7 @@ private function createService(PriceService $priceService): InvoiceService $appSettingsService = $this->createStub(AppSettingsService::class); $appSettingsService->method('getSettings')->willReturn($appSettings); - return new InvoiceService($em, $priceService, $translator, $appSettingsService); + return new InvoiceService($em, $priceService, $translator, $appSettingsService, new InvoiceSumCalculator(), new OriginFeeCalculator(new InvoiceSumCalculator())); } private function createRequestStack(): RequestStack diff --git a/tests/Unit/InvoiceServiceFilenameTest.php b/tests/Unit/InvoiceServiceFilenameTest.php index ae1c349f..8aa4771d 100644 --- a/tests/Unit/InvoiceServiceFilenameTest.php +++ b/tests/Unit/InvoiceServiceFilenameTest.php @@ -9,6 +9,8 @@ use App\Entity\AppSettings; use App\Service\AppSettingsService; use App\Service\InvoiceService; +use App\Service\InvoiceSumCalculator; +use App\Service\OriginFeeCalculator; use App\Service\PriceService; use Doctrine\ORM\EntityManagerInterface; use PHPUnit\Framework\TestCase; @@ -130,6 +132,6 @@ private function buildService(string $pattern): InvoiceService $appSettingsService = $this->createStub(AppSettingsService::class); $appSettingsService->method('getSettings')->willReturn($appSettings); - return new InvoiceService($em, $priceService, $translator, $appSettingsService); + return new InvoiceService($em, $priceService, $translator, $appSettingsService, new InvoiceSumCalculator(), new OriginFeeCalculator(new InvoiceSumCalculator())); } } diff --git a/tests/Unit/InvoiceServiceGuestSurchargeTest.php b/tests/Unit/InvoiceServiceGuestSurchargeTest.php deleted file mode 100644 index e9b2833a..00000000 --- a/tests/Unit/InvoiceServiceGuestSurchargeTest.php +++ /dev/null @@ -1,161 +0,0 @@ -invoiceWithOrigin('Booking.com', '12.00', '1.40'); - - $result = $this->resolve($invoice, 115.20); - - self::assertSame('Booking.com', $result['name']); - self::assertSame(13.82, $result['commission']); - self::assertSame(1.61, $result['paymentFee']); - } - - public function testCountsAnOriginWithOnlyOneOfTheTwoPercentages(): void - { - $invoice = $this->invoiceWithOrigin('Fewo-direkt', '15.00', null); - - $result = $this->resolve($invoice, 200.0); - - self::assertSame(30.0, $result['commission']); - self::assertSame(0.0, $result['paymentFee']); - } - - public function testReturnsZeroWhenTheOriginHasNeitherPercentage(): void - { - $invoice = $this->invoiceWithOrigin('Direktbuchung', null, null); - - $result = $this->resolve($invoice, 115.20); - - self::assertNull($result['name']); - self::assertSame(0.0, $result['commission']); - self::assertSame(0.0, $result['paymentFee']); - } - - public function testReturnsZeroWhenNoReservationCarriesAnOrigin(): void - { - $invoice = new Invoice(); - $invoice->addReservation(new Reservation()); - - $result = $this->resolve($invoice, 200.0); - - self::assertNull($result['name']); - self::assertSame(0.0, $result['commission']); - self::assertSame(0.0, $result['paymentFee']); - } - - public function testSkipsOriginsWithoutPercentagesAndTakesTheFirstThatHasOne(): void - { - $invoice = new Invoice(); - $invoice->addReservation($this->reservationWithOrigin('Direktbuchung', null, null)); - $invoice->addReservation($this->reservationWithOrigin('Booking.com', '12.00', '1.40')); - - $result = $this->resolve($invoice, 100.0); - - self::assertSame('Booking.com', $result['name']); - self::assertSame(12.0, $result['commission']); - self::assertSame(1.4, $result['paymentFee']); - } - - public function testShowsTheRatesTheReservationWasBookedUnder(): void - { - // The portal has since raised its commission to 18 %. Showing that to the - // guest would name a figure the journal never booked - the deduction goes - // by the 12 % the booking was made under. - $reservation = $this->reservationWithOrigin('Booking.com', '18.00', '2.50'); - $reservation->setCommissionPercent('12.00'); - $reservation->setPaymentFeePercent('1.40'); - - $invoice = new Invoice(); - $invoice->addReservation($reservation); - - $result = $this->resolve($invoice, 115.20); - - self::assertSame(13.82, $result['commission']); - self::assertSame(1.61, $result['paymentFee']); - } - - public function testFallsBackToTheOriginWhenTheReservationHasNoRatesPinned(): void - { - // Booked before the rates were pinned, or under an origin that carried - // none at the time: the origin is all there is to go on. - $reservation = $this->reservationWithOrigin('Booking.com', '12.00', '1.40'); - $reservation->setCommissionPercent(null); - $reservation->setPaymentFeePercent(null); - - $invoice = new Invoice(); - $invoice->addReservation($reservation); - - $result = $this->resolve($invoice, 115.20); - - self::assertSame(13.82, $result['commission']); - self::assertSame(1.61, $result['paymentFee']); - } - - /** - * @return array{name: ?string, commission: float, paymentFee: float} - */ - private function resolve(Invoice $invoice, float $brutto): array - { - $method = new \ReflectionMethod(InvoiceService::class, 'resolveGuestSurcharge'); - - return $method->invoke($this->createService(), $invoice, $brutto); - } - - private function invoiceWithOrigin(string $name, ?string $commission, ?string $paymentFee): Invoice - { - $invoice = new Invoice(); - $invoice->addReservation($this->reservationWithOrigin($name, $commission, $paymentFee)); - - return $invoice; - } - - private function reservationWithOrigin(string $name, ?string $commission, ?string $paymentFee): Reservation - { - $origin = new ReservationOrigin(); - $origin->setName($name); - $origin->setCommissionPercent($commission); - $origin->setPaymentFeePercent($paymentFee); - - $reservation = new Reservation(); - $reservation->setReservationOrigin($origin); - - return $reservation; - } - - private function createService(): InvoiceService - { - $em = $this->createStub(EntityManagerInterface::class); - $priceService = $this->createStub(PriceService::class); - $translator = $this->createStub(TranslatorInterface::class); - - $appSettingsService = $this->createStub(AppSettingsService::class); - $appSettingsService->method('getSettings')->willReturn(new AppSettings()); - - return new InvoiceService($em, $priceService, $translator, $appSettingsService, null); - } -} diff --git a/tests/Unit/InvoiceServicePricingFlagsTest.php b/tests/Unit/InvoiceServicePricingFlagsTest.php index 66326b6b..70a74260 100644 --- a/tests/Unit/InvoiceServicePricingFlagsTest.php +++ b/tests/Unit/InvoiceServicePricingFlagsTest.php @@ -11,6 +11,8 @@ use App\Entity\Reservation; use App\Service\AppSettingsService; use App\Service\InvoiceService; +use App\Service\InvoiceSumCalculator; +use App\Service\OriginFeeCalculator; use App\Service\PriceService; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\ORM\EntityManagerInterface; @@ -258,7 +260,7 @@ private function createService(PriceService $priceService): InvoiceService $appSettingsService = $this->createStub(AppSettingsService::class); $appSettingsService->method('getSettings')->willReturn($appSettings); - return new InvoiceService($em, $priceService, $translator, $appSettingsService); + return new InvoiceService($em, $priceService, $translator, $appSettingsService, new InvoiceSumCalculator(), new OriginFeeCalculator(new InvoiceSumCalculator())); } private function createRequestStack(): RequestStack diff --git a/tests/Unit/InvoiceServiceTouristTaxTest.php b/tests/Unit/InvoiceServiceTouristTaxTest.php index 6e7c752d..6e3d0900 100644 --- a/tests/Unit/InvoiceServiceTouristTaxTest.php +++ b/tests/Unit/InvoiceServiceTouristTaxTest.php @@ -12,6 +12,8 @@ use App\Entity\TaxRate; use App\Service\AppSettingsService; use App\Service\InvoiceService; +use App\Service\InvoiceSumCalculator; +use App\Service\OriginFeeCalculator; use App\Service\PriceService; use App\Service\TouristTaxService; use Doctrine\ORM\EntityManagerInterface; @@ -194,7 +196,7 @@ private function createService(?TouristTaxService $touristTaxService, ?Translato $appSettingsService = $this->createStub(AppSettingsService::class); $appSettingsService->method('getSettings')->willReturn($appSettings); - return new InvoiceService($em, $priceService, $translator, $appSettingsService, $touristTaxService); + return new InvoiceService($em, $priceService, $translator, $appSettingsService, new InvoiceSumCalculator(), new OriginFeeCalculator(new InvoiceSumCalculator()), $touristTaxService); } private function makeBreakdown( diff --git a/tests/Unit/OriginFeeCalculatorTest.php b/tests/Unit/OriginFeeCalculatorTest.php new file mode 100644 index 00000000..2e5d1da5 --- /dev/null +++ b/tests/Unit/OriginFeeCalculatorTest.php @@ -0,0 +1,275 @@ +invoiceWithOrigin('Booking.com', '12.00', '1.40'); + + $fees = $this->calculate($invoice, gross: 115.20); + + self::assertSame('Booking.com', $fees->originName); + self::assertSame(13.82, $fees->commission->amount); + self::assertSame(1.61, $fees->paymentFee->amount); + } + + public function testCountsAnOriginWithOnlyOneOfTheTwoPercentages(): void + { + $invoice = $this->invoiceWithOrigin('Fewo-direkt', '15.00', null); + + $fees = $this->calculate($invoice, gross: 200.0); + + self::assertSame(30.0, $fees->commission->amount); + self::assertSame(0.0, $fees->paymentFee->amount); + } + + public function testReturnsZeroWhenTheOriginHasNeitherPercentage(): void + { + $invoice = $this->invoiceWithOrigin('Direktbuchung', null, null); + + $fees = $this->calculate($invoice, gross: 115.20); + + self::assertNull($fees->originName); + self::assertSame(0.0, $fees->commission->amount); + self::assertSame(0.0, $fees->paymentFee->amount); + } + + public function testReturnsZeroWhenNoReservationCarriesAnOrigin(): void + { + $invoice = $this->invoice(); + $invoice->addReservation(new Reservation()); + + $fees = $this->calculate($invoice, gross: 200.0); + + self::assertNull($fees->originName); + self::assertSame(0.0, $fees->commission->amount); + self::assertSame(0.0, $fees->paymentFee->amount); + } + + public function testSkipsOriginsWithoutPercentagesAndTakesTheFirstThatHasOne(): void + { + $invoice = $this->invoice(); + $invoice->addReservation($this->reservationWithOrigin('Direktbuchung', null, null)); + $invoice->addReservation($this->reservationWithOrigin('Booking.com', '12.00', '1.40')); + + $fees = $this->calculate($invoice, gross: 100.0); + + self::assertSame('Booking.com', $fees->originName); + self::assertSame(12.0, $fees->commission->amount); + self::assertSame(1.4, $fees->paymentFee->amount); + } + + public function testShowsTheRatesTheReservationWasBookedUnder(): void + { + // The portal has since raised its commission to 18 %. Showing that to the + // guest would name a figure the journal never booked - the deduction goes + // by the 12 % the booking was made under. + $reservation = $this->reservationWithOrigin('Booking.com', '18.00', '2.50'); + $reservation->setCommissionPercent('12.00'); + $reservation->setPaymentFeePercent('1.40'); + + $invoice = $this->invoice(); + $invoice->addReservation($reservation); + + $fees = $this->calculate($invoice, gross: 115.20); + + self::assertSame(13.82, $fees->commission->amount); + self::assertSame(1.61, $fees->paymentFee->amount); + } + + public function testFallsBackToTheOriginWhenTheReservationHasNoRatesPinned(): void + { + // Booked before the rates were pinned, or under an origin that carried + // none at the time: the origin is all there is to go on. + $reservation = $this->reservationWithOrigin('Booking.com', '12.00', '1.40'); + $reservation->setCommissionPercent(null); + $reservation->setPaymentFeePercent(null); + + $invoice = $this->invoice(); + $invoice->addReservation($reservation); + + $fees = $this->calculate($invoice, gross: 115.20); + + self::assertSame(13.82, $fees->commission->amount); + self::assertSame(1.61, $fees->paymentFee->amount); + } + + // ── the two bases ──────────────────────────────────────────────── + + public function testCommissionLeavesTheTouristTaxOutWhileThePaymentFeeKeepsIt(): void + { + // 100.00 room plus 15.00 tourist tax. Booking.com exempts a separately + // billed tourist tax from commission, but processes the money all the + // same, so the payment fee is charged on the full amount. + $invoice = $this->invoiceWithOrigin('Booking.com', '12.00', '1.40'); + $invoice->addPosition($this->position('Übernachtung', 'apartment', 100.00)); + $invoice->addPosition($this->position('Kurtaxe', 'tourist_tax', 15.00)); + + $fees = $this->calculate($invoice); + + self::assertSame(100.00, $fees->commission->base); + self::assertSame(115.00, $fees->paymentFee->base); + self::assertSame(12.00, $fees->commission->amount); + self::assertSame(1.61, $fees->paymentFee->amount); + } + + public function testTheBasesAreEqualWhereNoTouristTaxIsBilled(): void + { + // The common case, and the reason the split goes unnoticed by most + // houses: with no tourist-tax position there is nothing to leave out. + $invoice = $this->invoiceWithOrigin('Booking.com', '12.00', '1.40'); + $invoice->addPosition($this->position('Übernachtung', 'apartment', 115.20)); + + $fees = $this->calculate($invoice); + + self::assertSame(115.20, $fees->commission->base); + self::assertSame(115.20, $fees->paymentFee->base); + } + + // ── rates that disagree ────────────────────────────────────────── + + public function testReportsEveryRateFoundSoTheJournalCanRefuseTheInvoice(): void + { + // Two portals on one invoice. Which rate holds for it has no answer, and + // the caller - not this - decides what to do about that. + $invoice = $this->invoice(); + $invoice->addReservation($this->reservationWithOrigin('Booking.com', '12.00', null)); + $invoice->addReservation($this->reservationWithOrigin('Fewo-direkt', '15.00', null)); + + $fees = $this->calculate($invoice, gross: 100.0); + + self::assertFalse($fees->commission->isAgreedUpon()); + self::assertSame(['12,00 %', '15,00 %'], $fees->commission->rateLabels()); + } + + public function testAPortalBookingSharingAnInvoiceWithADirectOneDisagreesToo(): void + { + // The direct booking carries no rate at all, which is a rate of zero - + // and booking 12 % on the whole invoice would overcharge the house. + $invoice = $this->invoice(); + $invoice->addReservation($this->reservationWithOrigin('Booking.com', '12.00', null)); + $invoice->addReservation(new Reservation()); + + $fees = $this->calculate($invoice, gross: 100.0); + + self::assertFalse($fees->commission->isAgreedUpon()); + } + + public function testShowsTheGuestAFigureEvenWhereTheRatesDisagree(): void + { + // A note on an invoice and an entry in the journal do not carry the same + // weight: the guest is told what the first portal charged rather than + // nothing at all. + $invoice = $this->invoice(); + $invoice->addReservation($this->reservationWithOrigin('Booking.com', '12.00', null)); + $invoice->addReservation($this->reservationWithOrigin('Fewo-direkt', '15.00', null)); + + $fees = $this->calculate($invoice, gross: 100.0); + + self::assertSame('Booking.com', $fees->originName); + self::assertSame(12.0, $fees->commission->amount); + } + + public function testSeveralReservationsAgreeingOnTheRateAreNoDisagreement(): void + { + $invoice = $this->invoice(); + $invoice->addReservation($this->reservationWithOrigin('Booking.com', '12.00', null)); + $invoice->addReservation($this->reservationWithOrigin('Booking.com', '12.00', null)); + + $fees = $this->calculate($invoice, gross: 100.0); + + self::assertTrue($fees->commission->isAgreedUpon()); + self::assertSame(12.0, $fees->commission->amount); + } + + public function testAnInvoiceWithoutReservationsHasNothingToDisagreeAbout(): void + { + $fees = $this->calculate($this->invoice(), gross: 100.0); + + self::assertTrue($fees->commission->isAgreedUpon()); + self::assertSame(0.0, $fees->commission->percent); + } + + /** + * @param ?float $gross a total to hand back for either base, instead of + * adding the invoice's positions up. Most cases here + * are about rates, which have no business depending on + * how a position adds up; the two that are about the + * bases pass null and let the real sum run + */ + private function calculate(Invoice $invoice, ?float $gross = null): OriginFeeBreakdown + { + $sums = new InvoiceSumCalculator(); + + if (null !== $gross) { + $stub = $this->createStub(InvoiceSumCalculator::class); + $stub->method('grossTotal')->willReturn($gross); + $sums = $stub; + } + + return (new OriginFeeCalculator($sums))->calculate($invoice); + } + + private function invoice(): Invoice + { + return new Invoice(); + } + + private function invoiceWithOrigin(string $name, ?string $commission, ?string $paymentFee): Invoice + { + $invoice = $this->invoice(); + $invoice->addReservation($this->reservationWithOrigin($name, $commission, $paymentFee)); + + return $invoice; + } + + private function reservationWithOrigin(string $name, ?string $commission, ?string $paymentFee): Reservation + { + $origin = new ReservationOrigin(); + $origin->setName($name); + $origin->setCommissionPercent($commission); + $origin->setPaymentFeePercent($paymentFee); + + $reservation = new Reservation(); + $reservation->setReservationOrigin($origin); + + return $reservation; + } + + /** A gross-priced position, VAT included, so its price is its gross. */ + private function position(string $description, string $group, float $price): InvoicePosition + { + $position = new InvoicePosition(); + $position->setDescription($description); + $position->setPositionGroup($group); + $position->setPrice($price); + $position->setVat(7.0); + $position->setIncludesVat(true); + $position->setIsFlatPrice(true); + + return $position; + } +} diff --git a/tests/Unit/Workflow/CreatePercentageEntryActionTest.php b/tests/Unit/Workflow/CreatePercentageEntryActionTest.php index bdd53e08..f29bea46 100644 --- a/tests/Unit/Workflow/CreatePercentageEntryActionTest.php +++ b/tests/Unit/Workflow/CreatePercentageEntryActionTest.php @@ -16,7 +16,8 @@ use App\Repository\AccountingAccountRepository; use App\Repository\TaxRateRepository; use App\Service\BookingJournal\BookingJournalService; -use App\Service\InvoiceService; +use App\Service\InvoiceSumCalculator; +use App\Service\OriginFeeCalculator; use App\Workflow\Action\CreatePercentageEntryAction; use App\Workflow\WorkflowSkippedException; use PHPUnit\Framework\TestCase; @@ -496,13 +497,19 @@ private function origin(?string $commission, ?string $paymentFee): ReservationOr */ private function makeAction(float $gross, mixed &$capture = null, mixed &$capturePositions = null): CreatePercentageEntryAction { - $invoiceService = $this->createStub(InvoiceService::class); - $invoiceService->method('calculateSums')->willReturnCallback( - function ($apartments, $positions, &$vats, &$brutto) use ($gross, &$capturePositions): void { + // Stubbed at the sum, so the invoice's own arithmetic stays out of it and + // what is left to check is which positions went into the base. The + // calculator on top of it is real - picking the base apart is its job, + // and stubbing it would leave the action tested against nothing. + $sums = $this->createStub(InvoiceSumCalculator::class); + $sums->method('grossTotal')->willReturnCallback( + function ($apartments, $positions) use ($gross, &$capturePositions): float { $capturePositions = $positions; - $brutto = $gross; + + return $gross; } ); + $originFees = new OriginFeeCalculator($sums); $journal = $this->createStub(BookingJournalService::class); $journal->method('createEntryFromStatement')->willReturnCallback( @@ -539,6 +546,6 @@ function ($date, $amount, $debit, $credit, $remark, $invoiceNumber = null, $invo $translator = $this->createStub(TranslatorInterface::class); $translator->method('trans')->willReturn('ok'); - return new CreatePercentageEntryAction($journal, $accountRepo, $taxRateRepo, $invoiceService, $translator); + return new CreatePercentageEntryAction($journal, $accountRepo, $taxRateRepo, $originFees, $translator); } } diff --git a/translations/Workflow/messages.de.yaml b/translations/Workflow/messages.de.yaml index 4714b813..689980b6 100644 --- a/translations/Workflow/messages.de.yaml +++ b/translations/Workflow/messages.de.yaml @@ -82,7 +82,7 @@ workflow: percentage_entry_percent: "Prozentsatz (manuell)" percentage_entry_percent_help: "Anteil an der gewählten Berechnungsgrundlage, z.B. 12 für eine Kommission oder 1,4 für eine Zahlungsgebühr. Nur wirksam, wenn die Quelle „Manuell“ ist. Ohne einschränkende Bedingung wird auf jede Rechnung gebucht." percentage_entry_amount_base: "Berechnungsgrundlage" - percentage_entry_amount_base_help: "Betrag, auf den der Prozentsatz angewendet wird. Beherbergungsabgaben wie die Kurtaxe werden für die Gemeinde vereinnahmt und zählen bei den meisten Portalen nicht zur provisionsfähigen Summe; ausgenommen werden alle, die auf der Rechnung als solche ausgewiesen sind. Kommission und Zahlungsgebühr lassen sich getrennt einstellen, wenn der Vertrag das vorsieht." + percentage_entry_amount_base_help: "Betrag, auf den ein selbst eingetragener Prozentsatz angewendet wird. Für Kommission und Zahlungsgebühr aus der Buchungsherkunft gilt diese Einstellung nicht: worauf ein Portal sie berechnet, ergibt sich aus der Buchung. Die Kommission lässt dabei auf der Rechnung ausgewiesene Beherbergungsabgaben wie die Kurtaxe außen vor, die Zahlungsgebühr nicht - das Portal wickelt den vollen Betrag ab." percentage_entry_amount_base_without_tourist_tax: "Rechnungsbrutto ohne Beherbergungsabgaben" percentage_entry_amount_base_gross: "Vollständiges Rechnungsbrutto" percentage_entry_debit_account: "Sollkonto" diff --git a/translations/Workflow/messages.en.yaml b/translations/Workflow/messages.en.yaml index 14559061..1584da37 100644 --- a/translations/Workflow/messages.en.yaml +++ b/translations/Workflow/messages.en.yaml @@ -82,7 +82,7 @@ workflow: percentage_entry_percent: "Percentage (manual)" percentage_entry_percent_help: "Share of the selected calculation base, e.g. 12 for a commission or 1.4 for a payment fee. Only used when the source is \"Manual\". Without a condition narrowing it down, every invoice is booked." percentage_entry_amount_base: "Calculation base" - percentage_entry_amount_base_help: "The amount the percentage is applied to. Lodging levies such as tourist tax are collected on behalf of the municipality and are not commissionable with most portals; every levy shown as such on the invoice is left out. Commission and payment fee can use different bases where the contract says so." + percentage_entry_amount_base_help: "The amount a percentage typed in here is applied to. It does not apply to a commission or payment fee taken from the booking origin: what a portal charges those on follows from the booking. Commission then leaves out lodging levies such as tourist tax where the invoice shows them as such, the payment fee does not - the portal processes the full amount." percentage_entry_amount_base_without_tourist_tax: "Invoice gross without lodging levies" percentage_entry_amount_base_gross: "Full invoice gross" percentage_entry_debit_account: "Debit account" From 075d57a60c0212bce37eadeda840a77822c1f789 Mon Sep 17 00:00:00 2001 From: MeisterAdebar Date: Tue, 4 Aug 2026 16:56:07 +0200 Subject: [PATCH 19/33] Put what a portal charges its fees on into the data The base was worked out from the position group: everything counted, tourist tax did not. That covers one case of one portal. What it cannot say is that a breakfast ordered at the counter on a portal booking was never brokered, or that a tourist tax the portal collected carries its payment fee while carrying no commission. Both are properties of the position, so they are recorded there. brokered says the portal brokered it, commissionable says its commission is charged on it; they differ for exactly one thing, a separately billed tourist tax, which Booking.com exempts from commission while still processing the money when it collects the payment. A price answers the first question once for the service it sells, and hands it to every position made from it - recorded there, so a price changed next season leaves invoices already written alone. A position added by hand in the invoice form has no price behind it, and what the house sells on site is what tends to be added that way, so the form asks as well, starting from the answer of the price picked there. Who takes the money becomes an enum on the origin, asked twice because a portal can collect the stay while the tourist tax is paid on arrival, and pinned onto the reservation the way the rates already are - including their exception: an origin that charges no fee pins nothing. It is never asked who collects and only carries its default, so a booking taken before the fees are set up falls back to the origin for this answer as it does for the rates. An enum rather than a boolean because a portal collecting only part of the amount is a case that exists and needs a figure; that becomes another case rather than a migration. Defaults are what the code did before, one exception aside: existing tourist-tax positions are marked as collected by the property, since an invoice already written cannot say otherwise and that is the common case. Existing prices and all other positions count towards a portal's fees as they did. The calculator still derives its bases from the position group; moving it onto these fields is the next step, and the only place that changes. --- assets/controllers/invoices_controller.js | 18 +++++ migrations/Version20260804120000.php | 65 ++++++++++++++++ src/Controller/InvoiceServiceController.php | 3 + src/Entity/Enum/PaymentCollection.php | 34 +++++++++ src/Entity/InvoicePosition.php | 76 +++++++++++++++++++ src/Entity/Price.php | 27 +++++++ src/Entity/Reservation.php | 38 ++++++++++ src/Entity/ReservationOrigin.php | 53 +++++++++++++ src/Form/InvoiceMiscPositionType.php | 9 +++ src/Service/InvoiceService.php | 48 +++++++++++- src/Service/PriceService.php | 6 ++ src/Service/ReservationOriginService.php | 14 ++++ .../Invoices/_miscellaneous_form.html.twig | 9 +++ .../Prices/price_form_input_fields.html.twig | 5 ++ ...ervationorigin_form_input_fields.html.twig | 25 ++++++ .../InvoiceMiscPositionBrokeredTest.php | 72 ++++++++++++++++++ .../Unit/InvoiceServiceBuildPositionsTest.php | 41 ++++++++++ tests/Unit/InvoiceServiceTouristTaxTest.php | 72 ++++++++++++++++++ .../Unit/ReservationOriginRatePinningTest.php | 40 ++++++++++ translations/Prices/messages.de.xlf | 8 ++ translations/Prices/messages.en.yaml | 2 + .../ReservationOrigin/messages.de.xlf | 20 +++++ .../ReservationOrigin/messages.en.yaml | 5 ++ 23 files changed, 688 insertions(+), 2 deletions(-) create mode 100644 migrations/Version20260804120000.php create mode 100644 src/Entity/Enum/PaymentCollection.php create mode 100644 tests/Functional/InvoiceMiscPositionBrokeredTest.php diff --git a/assets/controllers/invoices_controller.js b/assets/controllers/invoices_controller.js index 0cc1119e..b17bd8a2 100644 --- a/assets/controllers/invoices_controller.js +++ b/assets/controllers/invoices_controller.js @@ -8,6 +8,8 @@ import { getLocalStorageItem, updatePDFExportLinks, enableDeletePopover, + enableTooltips, + disposeTooltips, setModalTitle } from '../js/utils.js'; @@ -23,6 +25,10 @@ const debounce = (fn, delay = 300) => { export default class extends Controller { connect() { + // Every form loaded into the modal connects anew, while the bootstrapping + // below runs once per page - so tooltips are set up before that guard. + this.initTooltips(); + this.modalContent = document.getElementById('modal-content-ajax'); const invoicesBootstrapped = this.modalContent.hasAttribute('data-invoices-bootstrapped'); if (invoicesBootstrapped) { @@ -40,6 +46,14 @@ export default class extends Controller { } } + async initTooltips() { + await enableTooltips(this.element); + } + + disconnect() { + disposeTooltips(this.element); + } + // Actions openModalAction(event) { event.preventDefault(); @@ -249,6 +263,10 @@ export default class extends Controller { if (includesVat) includesVat.checked = values[3] === '1'; if (isFlatPrice) isFlatPrice.checked = values[4] === '1'; if (isPerRoom) isPerRoom.checked = values[5] === '1'; + // Carried over from the price like the switches above; a package passes + // its answer on to the components it is broken into. + const brokered = document.getElementById('invoice_misc_position_brokered'); + if (brokered && selected) brokered.checked = selected.dataset.brokered !== '0'; if (isFlatPrice && !isPackage) { this.applyFlatPriceState(isFlatPrice, isPerRoom); } diff --git a/migrations/Version20260804120000.php b/migrations/Version20260804120000.php new file mode 100644 index 00000000..47c72a54 --- /dev/null +++ b/migrations/Version20260804120000.php @@ -0,0 +1,65 @@ +addSql('ALTER TABLE invoice_positions ADD brokered TINYINT(1) DEFAULT 1 NOT NULL, ADD commissionable TINYINT(1) DEFAULT 1 NOT NULL'); + + // A tourist tax billed as its own position carries no commission, which + // existing invoices are set to. It rests on the tax being set up + // separately at the portal - see InvoicePosition::$commissionable. + $this->addSql("UPDATE invoice_positions SET commissionable = 0 WHERE position_group = 'tourist_tax'"); + + // Whether the portal also collected that tax cannot be told from an + // invoice already written, so it is assumed to have been paid at the + // property - the common case, and the one that charges no payment fee on + // it. Going forward the origin's tourist_tax_collection decides. + $this->addSql("UPDATE invoice_positions SET brokered = 0 WHERE position_group = 'tourist_tax'"); + + // The default for the positions made from a price. True for everything + // booked along with the stay; false is for what the house sells on site. + $this->addSql('ALTER TABLE prices ADD brokered TINYINT(1) DEFAULT 1 NOT NULL'); + + // Who takes the money. Defaults to the property on both counts, which is + // what a direct booking does and what an origin that merely passes + // bookings on does - no payment handled, no payment fee charged. A portal + // that settles payments itself is configured as such once, per origin. + $this->addSql("ALTER TABLE reservation_origins ADD payment_collection VARCHAR(16) DEFAULT 'property' NOT NULL, ADD tourist_tax_collection VARCHAR(16) DEFAULT 'property' NOT NULL"); + + // Pinned per reservation like the two rates, and left null here for the + // same reason they were: nothing was recorded for bookings that predate + // the column, and null falls back to the origin rather than asserting an + // answer nobody gave. + $this->addSql('ALTER TABLE reservations ADD payment_collection VARCHAR(16) DEFAULT NULL'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE invoice_positions DROP brokered, DROP commissionable'); + $this->addSql('ALTER TABLE prices DROP brokered'); + $this->addSql('ALTER TABLE reservation_origins DROP payment_collection, DROP tourist_tax_collection'); + $this->addSql('ALTER TABLE reservations DROP payment_collection'); + } + + public function isTransactional(): bool + { + return false; + } +} diff --git a/src/Controller/InvoiceServiceController.php b/src/Controller/InvoiceServiceController.php index b88d784a..fcb19ae0 100644 --- a/src/Controller/InvoiceServiceController.php +++ b/src/Controller/InvoiceServiceController.php @@ -527,6 +527,9 @@ public function newMiscellaneousPosition($invoiceId, ManagerRegistry $doctrine, $pos->setIsFlatPrice($package->getIsFlatPrice()); $pos->setIsPerRoom($package->getIsPerRoom()); $pos->setRevenueAccount($component['component']->getRevenueAccount() ?? $package->getRevenueAccount()); + // The switch in the form, which starts out on the package's own + // answer, covers the components it is broken into. + $pos->markBrokered($invoicePosition->isBrokered()); $positions[] = $pos; } diff --git a/src/Entity/Enum/PaymentCollection.php b/src/Entity/Enum/PaymentCollection.php new file mode 100644 index 00000000..72313987 --- /dev/null +++ b/src/Entity/Enum/PaymentCollection.php @@ -0,0 +1,34 @@ + true])] + private bool $brokered = true; + + /** + * Whether a portal's commission is charged on this position. + * + * Everything brokered is commissionable, with one exception: a tourist tax + * billed as its own position, which carries none. That holds as long as the + * tax is set up as a separate item at the portal too; one buried in the room + * rate is no separate tourist tax as far as the portal is concerned, and is + * commissioned like the stay. The form says so where it is configured. No + * setting covers that case for now - it can follow once a real one turns up. + * The portal may still have collected the money, and then still charges its + * payment fee on it, which is why this is a flag of its own rather than the + * same one. + */ + #[ORM\Column(type: 'boolean', options: ['default' => true])] + private bool $commissionable = true; + public function __construct() { $this->isFlatPrice = false; @@ -200,4 +234,46 @@ public function setPositionGroup(?string $positionGroup): self return $this; } + + /** Whether this position was part of the booking a portal brokered. */ + public function isBrokered(): bool + { + return $this->brokered; + } + + public function setBrokered(bool $brokered): self + { + $this->brokered = $brokered; + + return $this; + } + + /** + * Answers both flags from the one question a user is asked about a position: + * was it part of the portal booking. + * + * They only part company for a separately billed tourist tax, which is + * taken to carry no commission however it was collected - so that position + * keeps that answer whatever this one is. + */ + public function markBrokered(bool $brokered): self + { + $this->brokered = $brokered; + $this->commissionable = $brokered && 'tourist_tax' !== $this->positionGroup; + + return $this; + } + + /** Whether a portal's commission is charged on this position. */ + public function isCommissionable(): bool + { + return $this->commissionable; + } + + public function setCommissionable(bool $commissionable): self + { + $this->commissionable = $commissionable; + + return $this; + } } diff --git a/src/Entity/Price.php b/src/Entity/Price.php index 8576c4b5..e8aafd2d 100644 --- a/src/Entity/Price.php +++ b/src/Entity/Price.php @@ -68,6 +68,20 @@ class Price private bool $isPerRoom; #[ORM\Column(type: 'boolean')] private bool $isDefaultActiveInReservationCreation; + + /** + * Whether this service is part of what a portal brokers when it is billed on + * a booking that came through one. + * + * False for what the house sells on site - a breakfast the guest orders at + * the counter, a late checkout paid in cash - which a portal neither + * brokered nor processed and charges no commission or payment fee on. True + * for everything booked along with the stay, which is the ordinary case and + * the default. Handed on to every invoice position made from this price, + * which is where it is then recorded for good. + */ + #[ORM\Column(type: 'boolean', options: ['default' => true])] + private bool $brokered = true; #[ORM\Column(type: 'boolean')] private bool $isBookableOnline; #[ORM\Column(type: 'boolean')] @@ -406,6 +420,19 @@ public function setIsPerRoom(bool $isPerRoom): self return $this; } + /** Whether a portal brokers this service along with the stay, see the property. */ + public function isBrokered(): bool + { + return $this->brokered; + } + + public function setBrokered(bool $brokered): self + { + $this->brokered = $brokered; + + return $this; + } + public function getIsDefaultActiveInReservationCreation(): bool { return $this->isDefaultActiveInReservationCreation; diff --git a/src/Entity/Reservation.php b/src/Entity/Reservation.php index cf5de884..6679847f 100644 --- a/src/Entity/Reservation.php +++ b/src/Entity/Reservation.php @@ -4,6 +4,7 @@ namespace App\Entity; +use App\Entity\Enum\PaymentCollection; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; use Doctrine\DBAL\Types\Types; @@ -65,6 +66,19 @@ class Reservation /** The portal's payment fee when this reservation was booked; see commission. */ #[ORM\Column(name: 'payment_fee_percent', type: 'decimal', precision: 5, scale: 2, nullable: true)] private ?string $paymentFeePercent = null; + + /** + * Who collected the payment for this booking, pinned from the origin the + * same way the two rates are - a portal that switches to collecting + * payments itself must not change what happened to bookings settled before. + * + * Null where nothing is recorded: bookings that predate the column, any + * without an origin, and any whose origin charged no fee when it was + * assigned - the same bookings whose rates are left open. Those fall back to + * the origin, which answers for the house where there is none. + */ + #[ORM\Column(name: 'payment_collection', type: 'string', length: 16, enumType: PaymentCollection::class, nullable: true)] + private ?PaymentCollection $paymentCollection = null; #[ORM\OneToMany(targetEntity: 'Correspondence', mappedBy: 'reservation', cascade: ['remove'])] private $correspondences; #[ORM\ManyToMany(targetEntity: Price::class)] @@ -321,6 +335,14 @@ public function setReservationOrigin(?ReservationOrigin $reservationOrigin = nul if ($reservationOrigin !== $this->reservationOrigin) { $this->commissionPercent = $this->pinnedRate($reservationOrigin?->getCommissionPercent()); $this->paymentFeePercent = $this->pinnedRate($reservationOrigin?->getPaymentFeePercent()); + // Who collects is pinned along with the rates, and like them only + // where the origin charges a fee. Without one the origin is not + // asked who collects and keeps its default of the house - an answer + // nobody gave. Pinning it would leave a booking taken before the + // fees were set up with the rates it falls back to, but without the + // stay in the payment fee's base, and nothing would say so. + $chargesFees = null !== $this->commissionPercent || null !== $this->paymentFeePercent; + $this->paymentCollection = $chargesFees ? $reservationOrigin?->getPaymentCollection() : null; } $this->reservationOrigin = $reservationOrigin; @@ -371,6 +393,22 @@ public function setPaymentFeePercent(?string $paymentFeePercent): self return $this; } + /** + * Who collected the payment for this booking, null when nothing was + * recorded - then the origin answers, see the property. + */ + public function getPaymentCollection(): ?PaymentCollection + { + return $this->paymentCollection; + } + + public function setPaymentCollection(?PaymentCollection $paymentCollection): self + { + $this->paymentCollection = $paymentCollection; + + return $this; + } + /** * Get reservationOrigin. * diff --git a/src/Entity/ReservationOrigin.php b/src/Entity/ReservationOrigin.php index af44ca25..cb977c65 100644 --- a/src/Entity/ReservationOrigin.php +++ b/src/Entity/ReservationOrigin.php @@ -4,6 +4,7 @@ namespace App\Entity; +use App\Entity\Enum\PaymentCollection; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\ORM\Mapping as ORM; use Symfony\Component\Validator\Constraints as Assert; @@ -36,6 +37,32 @@ class ReservationOrigin #[ORM\Column(type: 'decimal', precision: 5, scale: 2, nullable: true)] private ?string $paymentFeePercent = null; + /** + * Who collects the guest's payment for a booking through this origin. The + * default for its reservations, which pin it as they are booked - a portal + * that changes how it settles must not rewrite what happened to older + * bookings. + * + * Defaults to the house, which is what a direct booking does and the + * harmless answer for a portal that only passes bookings on: no payment + * handled, no payment fee charged. + */ + #[ORM\Column(name: 'payment_collection', type: 'string', length: 16, enumType: PaymentCollection::class, options: ['default' => 'property'])] + private PaymentCollection $paymentCollection = PaymentCollection::PROPERTY; + + /** + * Who collects the tourist tax, asked separately because portals differ and + * because the same portal can be set up either way: entered on their side as + * a fee payable on arrival, the house takes it; entered as a separate local + * tax on a portal that collects payments, the portal does. + * + * It decides nothing about commission - a separately billed tourist tax is + * taken to carry none either way (see InvoicePosition::$commissionable) - + * only whether the portal's payment fee is charged on it. + */ + #[ORM\Column(name: 'tourist_tax_collection', type: 'string', length: 16, enumType: PaymentCollection::class, options: ['default' => 'property'])] + private PaymentCollection $touristTaxCollection = PaymentCollection::PROPERTY; + #[ORM\ManyToMany(targetEntity: 'Price', mappedBy: 'reservationOrigins')] private $prices; #[ORM\OneToMany(targetEntity: 'Reservation', mappedBy: 'reservationOrigin')] @@ -136,6 +163,32 @@ public function setPaymentFeePercent(?string $paymentFeePercent): self return $this; } + /** Who collects the guest's payment for a booking through this origin. */ + public function getPaymentCollection(): PaymentCollection + { + return $this->paymentCollection; + } + + public function setPaymentCollection(PaymentCollection $paymentCollection): self + { + $this->paymentCollection = $paymentCollection; + + return $this; + } + + /** Who collects the tourist tax, which a portal can settle differently from the stay. */ + public function getTouristTaxCollection(): PaymentCollection + { + return $this->touristTaxCollection; + } + + public function setTouristTaxCollection(PaymentCollection $touristTaxCollection): self + { + $this->touristTaxCollection = $touristTaxCollection; + + return $this; + } + /** * Add prices. * diff --git a/src/Form/InvoiceMiscPositionType.php b/src/Form/InvoiceMiscPositionType.php index 64bf27bb..ea5c682c 100644 --- a/src/Form/InvoiceMiscPositionType.php +++ b/src/Form/InvoiceMiscPositionType.php @@ -45,6 +45,15 @@ public function buildForm(FormBuilderInterface $builder, array $options): void 'label_attr' => ['class' => 'checkbox-inline checkbox-switch'], 'required' => false, ]) + // A position typed in here has no price to inherit the answer from, and + // what the house sells on site is exactly what tends to be added by + // hand. Through markBrokered(), so commission follows the answer. + ->add('brokered', CheckboxType::class, [ + 'label' => 'price.brokered', + 'label_attr' => ['class' => 'checkbox-inline checkbox-switch'], + 'required' => false, + 'setter' => static fn (InvoicePosition $position, ?bool $brokered) => $position->markBrokered((bool) $brokered), + ]) ; } diff --git a/src/Service/InvoiceService.php b/src/Service/InvoiceService.php index 6893f7b0..a909952a 100644 --- a/src/Service/InvoiceService.php +++ b/src/Service/InvoiceService.php @@ -597,7 +597,38 @@ public function buildTouristTaxPositions(array $reservations): array } } - return array_map(fn (TouristTaxBreakdown $row): InvoicePosition => $this->makeTouristTaxPosition($row), array_values($aggregates)); + $brokered = $this->touristTaxIsCollectedByPortal($reservations); + + return array_map(fn (TouristTaxBreakdown $row): InvoicePosition => $this->makeTouristTaxPosition($row, $brokered), array_values($aggregates)); + } + + /** + * Whether the portal collects the tourist tax for these reservations, which + * decides whether its payment fee is charged on it. Commission is not at + * stake here: a separately billed tourist tax carries none either way. + * + * Every reservation has to agree and carry an origin that says so. The + * positions are aggregated across reservations and no longer know which one + * they came from, and a stay whose tax the house collects must not be swept + * into a portal's payment fee by a booking sharing the invoice with it. + * + * @param array $reservations + */ + private function touristTaxIsCollectedByPortal(array $reservations): bool + { + $reservations = array_filter($reservations, static fn ($r): bool => $r instanceof Reservation); + if ([] === $reservations) { + return false; + } + + foreach ($reservations as $reservation) { + $origin = $reservation->getReservationOrigin(); + if (null === $origin || !$origin->getTouristTaxCollection()->isPortal()) { + return false; + } + } + + return true; } private function touristTaxAggregateKey(TouristTaxBreakdown $row): string @@ -793,7 +824,7 @@ private function describeModifier(\App\Entity\GuestCategoryModifier $modifier): }; } - private function makeTouristTaxPosition(TouristTaxBreakdown $row): InvoicePosition + private function makeTouristTaxPosition(TouristTaxBreakdown $row, bool $brokered = false): InvoicePosition { $position = new InvoicePosition(); $position->setVat(null !== $row->taxRate ? $row->taxRate->getRateFloat() : 0.0); @@ -802,6 +833,12 @@ private function makeTouristTaxPosition(TouristTaxBreakdown $row): InvoicePositi $position->setIsPerRoom(false); $position->setRevenueAccount($row->revenueAccount); $position->setPositionGroup('tourist_tax'); + // Billed as a position of its own, which is taken to carry no commission + // - an assumption, see InvoicePosition::$commissionable. Whether the + // portal processed the money is a separate question, and the one the + // caller answers. + $position->setCommissionable(false); + $position->setBrokered($brokered); if (TaxCalculationMode::PER_NIGHT_FLAT === $row->calculationMode) { $description = $this->translator->trans('invoice.tourist_tax.position', [ @@ -1079,6 +1116,10 @@ private function createMiscPositionsFromAggregates(array $tmpPricesArr): array $position->setIsPerRoom($price->getIsPerRoom()); $position->setRevenueAccount($price->getRevenueAccount()); $position->setPositionGroup('misc'); + // What a portal brokers is decided per service and recorded here, so + // a price whose answer changes next season leaves this invoice as it + // was. + $position->markBrokered($price->isBrokered()); $positions[] = $position; } @@ -1114,6 +1155,9 @@ private function expandPackageAggregate(array $tmpPrice): array $position->setIsPerRoom($price->getIsPerRoom()); $position->setRevenueAccount($component['component']->getRevenueAccount() ?? $price->getRevenueAccount()); $position->setPositionGroup('misc'); + // The package is what was booked, so its answer covers the components + // it is broken into. + $position->markBrokered($price->isBrokered()); $positions[] = $position; } diff --git a/src/Service/PriceService.php b/src/Service/PriceService.php index db17dbef..6bdc8579 100644 --- a/src/Service/PriceService.php +++ b/src/Service/PriceService.php @@ -173,6 +173,12 @@ public function getPriceFromForm(Request $request, $id = 'new') $price->setIsDefaultActiveInReservationCreation(false); } + // Off means the house sells this on site: a portal neither brokered nor + // processed it, so none of its fees are charged on it. On is the ordinary + // case and how the switch starts out, so a price saved without touching + // it keeps counting towards a portal's fees as it did before. + $price->setBrokered(null != $request->request->get('brokered-'.$id)); + $mandatoryOnline = 1 == $price->getType() && null != $request->request->get('isMandatoryOnline-'.$id); $bookableOnline = 1 == $price->getType() && null != $request->request->get('isBookableOnline-'.$id); // Pflicht impliziert online verfügbar — auch wenn der Switch im UI gesperrt war. diff --git a/src/Service/ReservationOriginService.php b/src/Service/ReservationOriginService.php index 98d573d0..64a61c4e 100644 --- a/src/Service/ReservationOriginService.php +++ b/src/Service/ReservationOriginService.php @@ -13,6 +13,7 @@ namespace App\Service; +use App\Entity\Enum\PaymentCollection; use App\Entity\ReservationOrigin; use Doctrine\ORM\EntityManagerInterface; use Symfony\Component\HttpFoundation\Request; @@ -57,14 +58,27 @@ public function getOriginFromForm(Request $request, $id = 'new') $paymentFee = str_replace(',', '.', trim((string) $request->request->get('payment-fee-'.$id, ''))); $origin->setPaymentFeePercent('' === $paymentFee ? null : $paymentFee); + + // Who collects the money is only asked where fees are charged, since + // that is all it decides. An unreadable value falls back to the + // house, which is the answer that charges nothing. + $origin->setPaymentCollection($this->collectionFromForm($request, 'payment-collection-'.$id)); + $origin->setTouristTaxCollection($this->collectionFromForm($request, 'tourist-tax-collection-'.$id)); } else { $origin->setCommissionPercent(null); $origin->setPaymentFeePercent(null); + $origin->setPaymentCollection(PaymentCollection::PROPERTY); + $origin->setTouristTaxCollection(PaymentCollection::PROPERTY); } return $origin; } + private function collectionFromForm(Request $request, string $field): PaymentCollection + { + return PaymentCollection::tryFrom((string) $request->request->get($field, '')) ?? PaymentCollection::PROPERTY; + } + /** * True when the OTA-fee flag is set but neither percentage was given, so the * origin would be marked as charging fees with no fee to charge. diff --git a/templates/Invoices/_miscellaneous_form.html.twig b/templates/Invoices/_miscellaneous_form.html.twig index ef853201..baf1ba17 100644 --- a/templates/Invoices/_miscellaneous_form.html.twig +++ b/templates/Invoices/_miscellaneous_form.html.twig @@ -9,6 +9,7 @@ {% for price in prices %} {% endfor %} @@ -29,3 +30,11 @@ {{ form_widget(form.isPerRoom, {'attr': {'disabled': form.isFlatPrice.vars.checked ? 'disabled' : null}}) }} +
+
+ {{ form_widget(form.brokered) }} + + + +
+
diff --git a/templates/Prices/price_form_input_fields.html.twig b/templates/Prices/price_form_input_fields.html.twig index bca62431..7dd8c421 100644 --- a/templates/Prices/price_form_input_fields.html.twig +++ b/templates/Prices/price_form_input_fields.html.twig @@ -126,6 +126,11 @@ +
+ + +
{{ 'price.brokered.hint'|trans }}
+
+ {# Who takes the money decides what the payment fee is charged on - a + portal charges it for processing a payment, so an amount it never + handled carries none. Asked twice because a portal can collect the + stay while the tourist tax is paid on arrival. #} +
+ + +
+ +
+
+
+ + +
+ +
{{ 'reservationorigin.tourist_tax_collection.hint'|trans }}
+
+
diff --git a/tests/Functional/InvoiceMiscPositionBrokeredTest.php b/tests/Functional/InvoiceMiscPositionBrokeredTest.php new file mode 100644 index 00000000..36357f02 --- /dev/null +++ b/tests/Functional/InvoiceMiscPositionBrokeredTest.php @@ -0,0 +1,72 @@ +submit(new InvoicePosition(), brokered: false); + + self::assertFalse($position->isBrokered()); + self::assertFalse($position->isCommissionable(), 'commission was still charged on a service sold on site'); + } + + public function testAPositionLeftOnCountsTowardsBoth(): void + { + $position = $this->submit(new InvoicePosition(), brokered: true); + + self::assertTrue($position->isBrokered()); + self::assertTrue($position->isCommissionable()); + } + + public function testATouristTaxKeepsItsExemptionFromCommission(): void + { + // Editing a tourist-tax position goes through the same form. The portal + // may well have collected it, but commission is never charged on it. + $tax = new InvoicePosition(); + $tax->setPositionGroup('tourist_tax'); + $tax->setCommissionable(false); + + $position = $this->submit($tax, brokered: true); + + self::assertTrue($position->isBrokered()); + self::assertFalse($position->isCommissionable()); + } + + private function submit(InvoicePosition $position, bool $brokered): InvoicePosition + { + self::bootKernel(); + $form = static::getContainer()->get(FormFactoryInterface::class) + ->create(InvoiceMiscPositionType::class, $position, ['csrf_protection' => false]); + + $data = [ + 'amount' => '1', + 'description' => 'Frühstück', + 'price' => '12,50', + 'vat' => '7', + ]; + if ($brokered) { + $data['brokered'] = '1'; + } + $form->submit($data); + + self::assertTrue($form->isSynchronized()); + + return $position; + } +} diff --git a/tests/Unit/InvoiceServiceBuildPositionsTest.php b/tests/Unit/InvoiceServiceBuildPositionsTest.php index 8a675885..e0536378 100644 --- a/tests/Unit/InvoiceServiceBuildPositionsTest.php +++ b/tests/Unit/InvoiceServiceBuildPositionsTest.php @@ -206,6 +206,47 @@ public function testPrefillMiscPositionsOvernightStay(): void self::assertSame(4, $positions[0]->getAmount()); } + public function testAMiscPositionInheritsWhetherAPortalBrokersIt(): void + { + // Answered once per service on the price, and recorded on the position + // it produces: a price changed next season must not rewrite what a + // portal charged on an invoice already written. + $price = $this->createMiscPrice(1004, false); + $price->setBrokered(false); + + $positions = $this->prefill($price, $this->createReservation(2004, 2, '2026-03-25', '2026-03-25')); + + self::assertFalse($positions[0]->isBrokered()); + // Nothing but a separately billed tourist tax is brokered without being + // commissionable, so the two follow each other here. + self::assertFalse($positions[0]->isCommissionable()); + } + + public function testAMiscPositionCountsTowardsThePortalsFeesByDefault(): void + { + $positions = $this->prefill( + $this->createMiscPrice(1005, false), + $this->createReservation(2005, 2, '2026-03-25', '2026-03-25') + ); + + self::assertTrue($positions[0]->isBrokered()); + self::assertTrue($positions[0]->isCommissionable()); + } + + /** @return \Doctrine\Common\Collections\Collection */ + private function prefill(Price $price, Reservation $reservation): \Doctrine\Common\Collections\Collection + { + $requestStack = $this->createRequestStack(); + + $priceService = $this->createStub(PriceService::class); + $priceService->method('getPricesForReservationDays') + ->willReturn([0 => null, 1 => [$price]]); + + $this->createService($priceService)->prefillMiscPositionsWithReservations([$reservation], $requestStack); + + return $requestStack->getSession()->get('invoicePositionsMiscellaneous'); + } + // ─── InvoiceAppartment::getAmount (entity-level) ─────────────────── public function testInvoiceAppartmentAmountOvernightPerPerson(): void diff --git a/tests/Unit/InvoiceServiceTouristTaxTest.php b/tests/Unit/InvoiceServiceTouristTaxTest.php index 6e3d0900..b8945df3 100644 --- a/tests/Unit/InvoiceServiceTouristTaxTest.php +++ b/tests/Unit/InvoiceServiceTouristTaxTest.php @@ -8,7 +8,9 @@ use App\Entity\AccountingAccount; use App\Entity\AppSettings; use App\Entity\Enum\TaxCalculationMode; +use App\Entity\Enum\PaymentCollection; use App\Entity\Reservation; +use App\Entity\ReservationOrigin; use App\Entity\TaxRate; use App\Service\AppSettingsService; use App\Service\InvoiceService; @@ -180,6 +182,76 @@ public function testWaivedReservationProducesNoPositions(): void self::assertSame([], $service->buildTouristTaxPositions([$r])); } + // ── what a portal charges on the tax ───────────────────────────── + + public function testTouristTaxIsNeverCommissionable(): void + { + // Billed as a position of its own, which is the form Booking.com exempts + // from commission - regardless of who collects it. + $positions = $this->buildFor($this->reservationFrom(PaymentCollection::PORTAL)); + + self::assertFalse($positions[0]->isCommissionable()); + } + + public function testTheTaxCountsAsBrokeredWhereThePortalCollectsIt(): void + { + // The portal handled the money, so its payment fee is charged on it. + $positions = $this->buildFor($this->reservationFrom(PaymentCollection::PORTAL)); + + self::assertTrue($positions[0]->isBrokered()); + } + + public function testTheTaxIsNotBrokeredWhereTheHouseCollectsIt(): void + { + $positions = $this->buildFor($this->reservationFrom(PaymentCollection::PROPERTY)); + + self::assertFalse($positions[0]->isBrokered()); + } + + public function testTheTaxIsNotBrokeredWithoutAnOrigin(): void + { + // A direct booking: nobody but the house took anything. + $positions = $this->buildFor(new Reservation()); + + self::assertFalse($positions[0]->isBrokered()); + } + + public function testOneStayCollectedByTheHouseSettlesItForTheWholeInvoice(): void + { + // The positions are aggregated across reservations and no longer know + // which one they came from, so a stay whose tax the house collects must + // not be swept into a portal's payment fee by the booking beside it. + $positions = $this->buildFor( + $this->reservationFrom(PaymentCollection::PORTAL), + new Reservation(), + ); + + self::assertFalse($positions[0]->isBrokered()); + } + + /** @return \App\Entity\InvoicePosition[] */ + private function buildFor(Reservation ...$reservations): array + { + $touristTaxService = $this->createStub(TouristTaxService::class); + $touristTaxService->method('calculateForReservation')->willReturn( + [$this->makeBreakdown(1, 'Kurtaxe', 1, 'Erwachsene', 3.0, 2, 1)] + ); + + return $this->createService($touristTaxService)->buildTouristTaxPositions($reservations); + } + + private function reservationFrom(PaymentCollection $touristTaxCollection): Reservation + { + $origin = new ReservationOrigin(); + $origin->setName('Booking.com'); + $origin->setTouristTaxCollection($touristTaxCollection); + + $reservation = new Reservation(); + $reservation->setReservationOrigin($origin); + + return $reservation; + } + private function createService(?TouristTaxService $touristTaxService, ?TranslatorInterface $translator = null): InvoiceService { $em = $this->createStub(EntityManagerInterface::class); diff --git a/tests/Unit/ReservationOriginRatePinningTest.php b/tests/Unit/ReservationOriginRatePinningTest.php index 57ee62a7..b4580585 100644 --- a/tests/Unit/ReservationOriginRatePinningTest.php +++ b/tests/Unit/ReservationOriginRatePinningTest.php @@ -4,6 +4,7 @@ namespace App\Tests\Unit; +use App\Entity\Enum\PaymentCollection; use App\Entity\Reservation; use App\Entity\ReservationOrigin; use PHPUnit\Framework\TestCase; @@ -95,6 +96,45 @@ public function testClearsThePinnedRatesWhenTheOriginIsRemoved(): void self::assertNull($reservation->getPaymentFeePercent()); } + public function testPinsWhoCollectsThePaymentAlongWithTheRates(): void + { + $origin = $this->origin('12.00', '1.40'); + $origin->setPaymentCollection(PaymentCollection::PORTAL); + + $reservation = new Reservation(); + $reservation->setReservationOrigin($origin); + + self::assertSame(PaymentCollection::PORTAL, $reservation->getPaymentCollection()); + } + + public function testKeepsWhoCollectedWhenTheOriginLaterSwitches(): void + { + // A portal that starts collecting payments itself says nothing about the + // bookings it merely passed on before. + $origin = $this->origin('12.00', '1.40'); + + $reservation = new Reservation(); + $reservation->setReservationOrigin($origin); + + $origin->setPaymentCollection(PaymentCollection::PORTAL); + + self::assertSame(PaymentCollection::PROPERTY, $reservation->getPaymentCollection()); + } + + public function testRecordsNoCollectionForABookingWithoutAnOrigin(): void + { + // Unlike the rates there is no blank to guard against - an origin always + // answers - so only its absence leaves this unrecorded. + $origin = $this->origin('12.00', '1.40'); + $origin->setPaymentCollection(PaymentCollection::PORTAL); + + $reservation = new Reservation(); + $reservation->setReservationOrigin($origin); + $reservation->setReservationOrigin(null); + + self::assertNull($reservation->getPaymentCollection()); + } + private function origin(?string $commission, ?string $paymentFee): ReservationOrigin { $origin = new ReservationOrigin(); diff --git a/translations/Prices/messages.de.xlf b/translations/Prices/messages.de.xlf index b12c2e39..4d7e41ee 100644 --- a/translations/Prices/messages.de.xlf +++ b/translations/Prices/messages.de.xlf @@ -222,6 +222,14 @@ price.defaultactiveinreservationcreation Bei Reservierungs-Neuanlage standardmäßig aktiv + + price.brokered + Teil einer Portalbuchung + + + price.brokered.hint + Ausschalten für Leistungen, die das Haus vor Ort verkauft, etwa ein an der Rezeption bestelltes Frühstück. Darauf berechnet ein Portal weder Kommission noch Zahlungsgebühr. + price.bookableonline Für Online-Buchung verfügbar diff --git a/translations/Prices/messages.en.yaml b/translations/Prices/messages.en.yaml index d0f13013..ae8e6591 100644 --- a/translations/Prices/messages.en.yaml +++ b/translations/Prices/messages.en.yaml @@ -74,6 +74,8 @@ price.isflatprice: flat rate price.perroom: per night (else per person) price.defaultactiveinreservationcreation: active by default in reservation creation price.bookableonline: Available for online booking +price.brokered: Part of a portal booking +price.brokered.hint: Switch off for what the house sells on site, such as a breakfast ordered at the counter. A portal charges neither commission nor payment fee on it. price.mandatoryonline: Mandatory in online booking price.mandatoryonline.hint: The guest cannot deselect this item. price.visibility.label: Visibility & availability diff --git a/translations/ReservationOrigin/messages.de.xlf b/translations/ReservationOrigin/messages.de.xlf index 2e57fad7..5b5e07b5 100644 --- a/translations/ReservationOrigin/messages.de.xlf +++ b/translations/ReservationOrigin/messages.de.xlf @@ -42,6 +42,26 @@ reservationorigin.payment_fee_percent Zahlungsgebühr + + reservationorigin.payment_collection + Zahlung wird eingezogen von + + + reservationorigin.tourist_tax_collection + Kurtaxe wird eingezogen von + + + reservationorigin.tourist_tax_collection.hint + Die Zahlungsgebühr berechnet ein Portal auf den Betrag, den es abgewickelt hat. Auf die Kommission wirkt sich das nicht aus: eine separat ausgewiesene Kurtaxe wird als kommissionsfrei behandelt. Voraussetzung ist, dass sie auch beim Portal getrennt eingerichtet ist - steckt sie im Zimmerpreis, ist sie für das Portal keine eigene Kurtaxe. + + + reservationorigin.collection.property + dem Haus + + + reservationorigin.collection.portal + dem Portal + reservationorigin.flash.create.success Reservierungsherkunft erfolgreich angelegt. diff --git a/translations/ReservationOrigin/messages.en.yaml b/translations/ReservationOrigin/messages.en.yaml index 8c8377e8..21368aaf 100644 --- a/translations/ReservationOrigin/messages.en.yaml +++ b/translations/ReservationOrigin/messages.en.yaml @@ -15,5 +15,10 @@ reservationorigin.name: Description reservationorigin.guest_surcharge_enabled: OTA fees reservationorigin.commission_percent: Commission reservationorigin.payment_fee_percent: Payment fee +reservationorigin.payment_collection: Payment collected by +reservationorigin.tourist_tax_collection: Tourist tax collected by +reservationorigin.tourist_tax_collection.hint: A portal charges its payment fee on what it processed. Commission is not affected - a tourist tax billed separately is treated as carrying none. That assumes it is set up as a separate item at the portal as well; one buried in the room rate is no separate tourist tax as far as the portal is concerned. +reservationorigin.collection.property: The property +reservationorigin.collection.portal: The portal reservationorigin.private: Private reservationorigin.title: Reservation origin From 36e3c88948d31de6a666dd2312f7693314f4557b Mon Sep 17 00:00:00 2001 From: MeisterAdebar Date: Tue, 4 Aug 2026 17:07:20 +0200 Subject: [PATCH 20/33] Read the fee bases off the invoice instead of its position groups The bases were a rule the calculator applied - everything counts, tourist tax does not - which is as far as a position group can carry it. Now the positions say it themselves: commission is taken on the stay plus what is marked commissionable, the payment fee on the brokered positions plus the stay where the portal collected the payment for it. Two things follow that the old rule could not express. A breakfast the guest orders at the counter on a portal booking drops out of both bases, which is what Alex's second point was about. And a booking the house was paid for directly carries no payment fee at all, however much the portal brokered - there was no payment for it to process. positionGroup is out of the calculation entirely and back to being what it says it is, a grouping for the invoice layout. Existing origins that charge a payment fee are marked as collecting the payment, since the column added for it defaults to the property and would otherwise have them quietly book nothing. An origin charging a percentage for processing payments does process them. Reservations keep their NULL and fall back to the origin, which is the point of pinning: stamping today's answer onto old bookings is what it exists to prevent. The manual base a workflow can still pick now goes by the same flag, and is named after it: the commissionable part of the invoice gross, rather than a list of what it leaves out. For invoices written before this it comes to what it always did, since that is what the flags were migrated from. --- migrations/Version20260804140000.php | 45 ++++++ src/Service/OriginFeeCalculator.php | 129 ++++++++++----- .../Action/CreatePercentageEntryAction.php | 17 +- tests/Unit/OriginFeeCalculatorTest.php | 152 ++++++++++++++++-- .../CreatePercentageEntryActionTest.php | 12 +- translations/Workflow/messages.de.yaml | 4 +- translations/Workflow/messages.en.yaml | 4 +- 7 files changed, 296 insertions(+), 67 deletions(-) create mode 100644 migrations/Version20260804140000.php diff --git a/migrations/Version20260804140000.php b/migrations/Version20260804140000.php new file mode 100644 index 00000000..f40ce975 --- /dev/null +++ b/migrations/Version20260804140000.php @@ -0,0 +1,45 @@ +addSql("UPDATE reservation_origins SET payment_collection = 'portal' WHERE payment_fee_percent IS NOT NULL AND payment_fee_percent > 0"); + + // Reservations booked before this keep NULL and fall back to the origin, + // which now answers for them. Deliberately not stamped: pinning today's + // answer onto old bookings is exactly what the column exists to prevent. + } + + public function down(Schema $schema): void + { + $this->addSql("UPDATE reservation_origins SET payment_collection = 'property' WHERE payment_fee_percent IS NOT NULL AND payment_fee_percent > 0"); + } + + public function isTransactional(): bool + { + return false; + } +} diff --git a/src/Service/OriginFeeCalculator.php b/src/Service/OriginFeeCalculator.php index e9cd4fe2..11dbd5e9 100644 --- a/src/Service/OriginFeeCalculator.php +++ b/src/Service/OriginFeeCalculator.php @@ -23,34 +23,25 @@ * separately, and the deduction's base was a workflow setting the render path * had no way of reading. * - * The bases differ because the fees are charged on different things: + * Neither base is a rule about invoices; both are read off what the invoice + * records about itself. Every position says whether a portal brokered it and + * whether commission is charged on it (see InvoicePosition), and the reservation + * says who collected the payment. So: * - * - Commission is taken on what the house earns. Booking.com exempts the - * tourist tax from it as long as the tax is entered on their side as a - * separate local tax or as payable on arrival, which is how a tax that shows - * up as a position of its own here is set up. Tourist tax therefore drops out - * of the base. - * - The payment fee is taken on what the portal actually processed, tourist tax - * included when the portal collected it. + * - Commission is taken on the stay plus every position marked commissionable. + * A separately billed tourist tax is not one - see + * InvoicePosition::$commissionable for what that rests on - and neither is + * what the house sells on site. + * - The payment fee is taken on what the portal actually processed: the brokered + * positions, plus the stay where the portal collected the payment for it. A + * booking the house was paid for directly leaves the portal nothing to charge + * a payment fee on, however much it brokered. * - * That second base is the rough one for now: whether the portal collected the - * payment at all is not recorded yet, so the full gross stands in for it. It - * overstates the fee for a stay whose tourist tax the house collects on - * arrival. The figure is in the workflow log (%base% in - * workflow.log.percentage_entry_created), so it can be checked against the - * portal's own statement and corrected. Once a position can say whether the - * portal brokered it, both bases are read off the positions instead and this is - * the only place that changes. + * The stay itself carries no such flags. It needs none: it is the thing the + * portal brokered, and commission on it is the whole point of the arrangement. */ class OriginFeeCalculator { - /** - * Position group the tourist-tax positions carry, see - * InvoicePosition::$positionGroup and - * InvoiceService::makeTouristTaxPosition(). - */ - private const POSITION_GROUP_TOURIST_TAX = 'tourist_tax'; - public function __construct( private readonly InvoiceSumCalculator $sums, ) { @@ -65,14 +56,22 @@ public function calculate(Invoice $invoice): OriginFeeBreakdown $this->fee( $invoice, $shown, - $this->grossTotal($invoice, excludeTouristTax: true), + $this->baseOf( + $invoice, + static fn (InvoicePosition $p): bool => $p->isCommissionable(), + includeStay: true, + ), static fn (Reservation $r): ?string => $r->getCommissionPercent() ?? $r->getReservationOrigin()?->getCommissionPercent(), ), $this->fee( $invoice, $shown, - $this->grossTotal($invoice, excludeTouristTax: false), + $this->baseOf( + $invoice, + static fn (InvoicePosition $p): bool => $p->isBrokered(), + includeStay: $this->portalCollectedThePayment($invoice), + ), static fn (Reservation $r): ?string => $r->getPaymentFeePercent() ?? $r->getReservationOrigin()?->getPaymentFeePercent(), ), @@ -80,27 +79,75 @@ public function calculate(Invoice $invoice): OriginFeeBreakdown } /** - * The gross total of an invoice, optionally without its tourist tax. + * The gross total of an invoice, optionally counting only what a commission + * would be charged on. * - * Public for the one caller that still picks its own base: a workflow - * booking a percentage somebody typed in, where nothing but the config says - * what the percentage is of. + * Public for the one caller that picks its own base: a workflow booking a + * percentage somebody typed in, where nothing about the booking says what + * the percentage is of and only the config can answer. */ - public function grossTotal(Invoice $invoice, bool $excludeTouristTax): float + public function grossTotal(Invoice $invoice, bool $commissionableOnly = false): float { - $positions = $invoice->getPositions() ?? new ArrayCollection(); - - if ($excludeTouristTax) { - // Dropped before the sum rather than subtracted afterwards, so the - // per-VAT-rate rounding inside the sum stays the one the remaining - // positions produce on their own. - $positions = $positions->filter( - static fn (InvoicePosition $position): bool => self::POSITION_GROUP_TOURIST_TAX !== $position->getPositionGroup() - ); - } + return $this->baseOf( + $invoice, + static fn (InvoicePosition $p): bool => !$commissionableOnly || $p->isCommissionable(), + includeStay: true, + ); + } + /** + * The sum of the parts a fee is charged on. + * + * Positions are dropped before the sum rather than subtracted afterwards, so + * the per-VAT-rate rounding stays the one the remaining parts produce on + * their own. + * + * @param callable(InvoicePosition): bool $keep + */ + private function baseOf(Invoice $invoice, callable $keep, bool $includeStay): float + { /** @var Collection $positions */ - return $this->sums->grossTotal($invoice->getAppartments() ?? new ArrayCollection(), $positions); + $positions = ($invoice->getPositions() ?? new ArrayCollection())->filter($keep); + + $stay = $includeStay + ? $invoice->getAppartments() ?? new ArrayCollection() + : new ArrayCollection(); + + return $this->sums->grossTotal($stay, $positions); + } + + /** + * Whether the portal took the money for the stay itself. + * + * Every reservation has to say so, and one without an origin never does. An + * invoice mixing a portal booking with a direct one has no single answer, + * and charging a payment fee on a stay the house was paid for directly is + * the error worth avoiding - the other way round it costs the house nothing + * it cannot correct. + * + * What was recorded on the reservation wins over what its origin says today, + * as with the rates: a portal that starts collecting payments must not + * rewrite how older bookings were settled. Where nothing was recorded the + * origin answers, and where there is no origin either, nobody but the house + * took anything. + */ + private function portalCollectedThePayment(Invoice $invoice): bool + { + $reservations = $invoice->getReservations() ?? new ArrayCollection(); + if (0 === count($reservations)) { + return false; + } + + foreach ($reservations as $reservation) { + $collection = $reservation->getPaymentCollection() + ?? $reservation->getReservationOrigin()?->getPaymentCollection(); + + if (null === $collection || !$collection->isPortal()) { + return false; + } + } + + return true; } /** diff --git a/src/Workflow/Action/CreatePercentageEntryAction.php b/src/Workflow/Action/CreatePercentageEntryAction.php index 9b83b516..2d92cebc 100644 --- a/src/Workflow/Action/CreatePercentageEntryAction.php +++ b/src/Workflow/Action/CreatePercentageEntryAction.php @@ -54,8 +54,15 @@ class CreatePercentageEntryAction implements WorkflowActionInterface /** The percentage is taken of the invoice's full gross total. */ public const AMOUNT_BASE_GROSS = 'gross'; - /** The percentage is taken of the gross total less the tourist-tax positions. */ - public const AMOUNT_BASE_GROSS_WITHOUT_TOURIST_TAX = 'gross_without_tourist_tax'; + /** + * The percentage is taken of the gross total less what carries no commission + * - a separately billed tourist tax, and anything the house sells on site. + * The stored value still names the tourist tax alone, which is what the + * option meant when it was written and what it still amounts to on invoices + * from back then; the positions now say it for themselves. It stays as it is + * so that workflows saved with it keep their base. + */ + public const AMOUNT_BASE_COMMISSIONABLE = 'gross_without_tourist_tax'; public function __construct( private readonly BookingJournalService $bookingJournalService, @@ -120,12 +127,12 @@ public function getConfigSchema(): array 'label' => 'workflow.form.percentage_entry_amount_base', 'help' => 'workflow.form.percentage_entry_amount_base_help', 'options' => [ - ['value' => self::AMOUNT_BASE_GROSS_WITHOUT_TOURIST_TAX, 'label' => 'workflow.form.percentage_entry_amount_base_without_tourist_tax'], + ['value' => self::AMOUNT_BASE_COMMISSIONABLE, 'label' => 'workflow.form.percentage_entry_amount_base_commissionable'], ['value' => self::AMOUNT_BASE_GROSS, 'label' => 'workflow.form.percentage_entry_amount_base_gross'], ], // Offered first and preselected: portals charge commission on what // the house earns, and tourist tax is collected for the municipality. - 'default' => self::AMOUNT_BASE_GROSS_WITHOUT_TOURIST_TAX, + 'default' => self::AMOUNT_BASE_COMMISSIONABLE, // Like the percentage above, this only applies to a figure somebody // typed in. What a commission or a payment fee is charged on follows // from the booking (see OriginFeeCalculator), and offering a choice @@ -276,7 +283,7 @@ private function resolveFee(array $config, Invoice $invoice): OriginFee // A config that says nothing is treated like a new one. The // field was part of the action from its first release, so no // saved workflow predates it. - self::AMOUNT_BASE_GROSS !== (string) ($config['amountBase'] ?? self::AMOUNT_BASE_GROSS_WITHOUT_TOURIST_TAX), + self::AMOUNT_BASE_GROSS !== (string) ($config['amountBase'] ?? self::AMOUNT_BASE_COMMISSIONABLE), ), ); } diff --git a/tests/Unit/OriginFeeCalculatorTest.php b/tests/Unit/OriginFeeCalculatorTest.php index 2e5d1da5..fabdc86e 100644 --- a/tests/Unit/OriginFeeCalculatorTest.php +++ b/tests/Unit/OriginFeeCalculatorTest.php @@ -5,7 +5,9 @@ namespace App\Tests\Unit; use App\Dto\OriginFeeBreakdown; +use App\Entity\Enum\PaymentCollection; use App\Entity\Invoice; +use App\Entity\InvoiceAppartment; use App\Entity\InvoicePosition; use App\Entity\Reservation; use App\Entity\ReservationOrigin; @@ -119,14 +121,14 @@ public function testFallsBackToTheOriginWhenTheReservationHasNoRatesPinned(): vo // ── the two bases ──────────────────────────────────────────────── - public function testCommissionLeavesTheTouristTaxOutWhileThePaymentFeeKeepsIt(): void + public function testCommissionLeavesOutWhatCarriesNoneWhileThePaymentFeeKeepsIt(): void { - // 100.00 room plus 15.00 tourist tax. Booking.com exempts a separately - // billed tourist tax from commission, but processes the money all the - // same, so the payment fee is charged on the full amount. - $invoice = $this->invoiceWithOrigin('Booking.com', '12.00', '1.40'); - $invoice->addPosition($this->position('Übernachtung', 'apartment', 100.00)); - $invoice->addPosition($this->position('Kurtaxe', 'tourist_tax', 15.00)); + // 100.00 room plus a 15.00 tourist tax the portal collected: exempt from + // commission, but the portal handled the money, so its payment fee is + // charged on all of it. + $invoice = $this->invoiceCollectedBy(PaymentCollection::PORTAL); + $invoice->addPosition($this->position('Übernachtung', 100.00)); + $invoice->addPosition($this->position('Kurtaxe', 15.00, commissionable: false)); $fees = $this->calculate($invoice); @@ -136,12 +138,27 @@ public function testCommissionLeavesTheTouristTaxOutWhileThePaymentFeeKeepsIt(): self::assertSame(1.61, $fees->paymentFee->amount); } - public function testTheBasesAreEqualWhereNoTouristTaxIsBilled(): void + public function testWhatTheHouseSellsOnSiteCarriesNeitherFee(): void + { + // A breakfast ordered at the counter on a portal booking: the portal + // neither brokered nor processed it, so it drops out of both bases - + // the case a rule about tourist tax alone could never cover. + $invoice = $this->invoiceCollectedBy(PaymentCollection::PORTAL); + $invoice->addPosition($this->position('Übernachtung', 100.00)); + $invoice->addPosition($this->position('Frühstück vor Ort', 20.00, brokered: false, commissionable: false)); + + $fees = $this->calculate($invoice); + + self::assertSame(100.00, $fees->commission->base); + self::assertSame(100.00, $fees->paymentFee->base); + } + + public function testTheBasesAreEqualWhereEverythingIsBrokeredAndCommissionable(): void { // The common case, and the reason the split goes unnoticed by most - // houses: with no tourist-tax position there is nothing to leave out. - $invoice = $this->invoiceWithOrigin('Booking.com', '12.00', '1.40'); - $invoice->addPosition($this->position('Übernachtung', 'apartment', 115.20)); + // houses: with nothing exempt there is nothing to leave out. + $invoice = $this->invoiceCollectedBy(PaymentCollection::PORTAL); + $invoice->addPosition($this->position('Übernachtung', 115.20)); $fees = $this->calculate($invoice); @@ -149,6 +166,59 @@ public function testTheBasesAreEqualWhereNoTouristTaxIsBilled(): void self::assertSame(115.20, $fees->paymentFee->base); } + // ── who took the money ─────────────────────────────────────────── + + public function testTheStayCarriesNoPaymentFeeWhereTheHouseWasPaidDirectly(): void + { + // The portal brokered the booking and takes its commission, but it + // processed nothing, so there is no payment to charge a fee for. + $invoice = $this->invoiceCollectedBy(PaymentCollection::PROPERTY); + $invoice->addAppartment($this->stay(200.00)); + + $fees = $this->calculate($invoice); + + self::assertSame(200.00, $fees->commission->base); + self::assertSame(0.0, $fees->paymentFee->base); + self::assertSame(0.0, $fees->paymentFee->amount); + } + + public function testTheStayCountsWhereThePortalCollectedThePayment(): void + { + $invoice = $this->invoiceCollectedBy(PaymentCollection::PORTAL); + $invoice->addAppartment($this->stay(200.00)); + + $fees = $this->calculate($invoice); + + self::assertSame(200.00, $fees->paymentFee->base); + self::assertSame(2.80, $fees->paymentFee->amount); + } + + public function testWhatWasRecordedOnTheBookingBeatsWhatTheOriginSaysToday(): void + { + // The portal has since started collecting payments. A booking settled + // directly with the house before that must not be charged for it. + $invoice = $this->invoiceCollectedBy(PaymentCollection::PORTAL); + $invoice->getReservations()->first()->setPaymentCollection(PaymentCollection::PROPERTY); + $invoice->addAppartment($this->stay(200.00)); + + $fees = $this->calculate($invoice); + + self::assertSame(0.0, $fees->paymentFee->base); + } + + public function testOneStayPaidToTheHouseSettlesItForTheWholeInvoice(): void + { + // The base covers the invoice, not a single stay, and charging a payment + // fee on money the portal never saw is the error worth avoiding. + $invoice = $this->invoiceCollectedBy(PaymentCollection::PORTAL); + $invoice->addReservation(new Reservation()); + $invoice->addAppartment($this->stay(200.00)); + + $fees = $this->calculate($invoice); + + self::assertSame(0.0, $fees->paymentFee->base); + } + // ── rates that disagree ────────────────────────────────────────── public function testReportsEveryRateFoundSoTheJournalCanRefuseTheInvoice(): void @@ -205,6 +275,29 @@ public function testSeveralReservationsAgreeingOnTheRateAreNoDisagreement(): voi self::assertSame(12.0, $fees->commission->amount); } + public function testABookingTakenBeforeTheFeesWereSetUpIsChargedThePaymentFeeOnItsStay(): void + { + // The ordinary order of setting this up: bookings come in through an + // origin with nothing configured, and the fees are filled in afterwards. + // Such a booking falls back to the origin for its rates, and has to for + // who collects as well - or the fee is charged on the extras alone. + $origin = new ReservationOrigin(); + $origin->setName('Booking.com'); + + $reservation = new Reservation(); + $reservation->setReservationOrigin($origin); + + $origin->setCommissionPercent('12.00'); + $origin->setPaymentFeePercent('1.40'); + $origin->setPaymentCollection(PaymentCollection::PORTAL); + + $invoice = $this->invoice(); + $invoice->addReservation($reservation); + $invoice->addAppartment($this->stay(100.0)); + + self::assertSame(1.4, $this->calculate($invoice)->paymentFee->amount); + } + public function testAnInvoiceWithoutReservationsHasNothingToDisagreeAbout(): void { $fees = $this->calculate($this->invoice(), gross: 100.0); @@ -260,16 +353,49 @@ private function reservationWithOrigin(string $name, ?string $commission, ?strin } /** A gross-priced position, VAT included, so its price is its gross. */ - private function position(string $description, string $group, float $price): InvoicePosition + private function position(string $description, float $price, bool $brokered = true, bool $commissionable = true): InvoicePosition { $position = new InvoicePosition(); $position->setDescription($description); - $position->setPositionGroup($group); $position->setPrice($price); $position->setVat(7.0); $position->setIncludesVat(true); $position->setIsFlatPrice(true); + $position->setBrokered($brokered); + $position->setCommissionable($commissionable); return $position; } + + /** The room nights, gross-priced, so the stay's price is its gross. */ + private function stay(float $price): InvoiceAppartment + { + $stay = new InvoiceAppartment(); + $stay->setDescription('Doppelzimmer'); + $stay->setNumber('1'); + $stay->setStartDate(new \DateTime('2026-06-19')); + $stay->setEndDate(new \DateTime('2026-06-21')); + $stay->setPersons(2); + $stay->setBeds(2); + $stay->setPrice($price); + $stay->setVat(7.0); + $stay->setIncludesVat(true); + $stay->setIsFlatPrice(true); + + return $stay; + } + + /** An invoice for one 12 % / 1.4 % portal booking, settled as given. */ + private function invoiceCollectedBy(PaymentCollection $collection): Invoice + { + $invoice = $this->invoice(); + $reservation = $this->reservationWithOrigin('Booking.com', '12.00', '1.40'); + $reservation->getReservationOrigin()->setPaymentCollection($collection); + // Assigned after the origin carries its answer - setReservationOrigin() + // pins what the origin says at that moment, as it does for the rates. + $reservation->setPaymentCollection($collection); + $invoice->addReservation($reservation); + + return $invoice; + } } diff --git a/tests/Unit/Workflow/CreatePercentageEntryActionTest.php b/tests/Unit/Workflow/CreatePercentageEntryActionTest.php index f29bea46..6773aef1 100644 --- a/tests/Unit/Workflow/CreatePercentageEntryActionTest.php +++ b/tests/Unit/Workflow/CreatePercentageEntryActionTest.php @@ -293,7 +293,7 @@ public function testLeavesTouristTaxOutOfTheBaseByDefaultForNewActions(): void $positions = null; $action = $this->makeAction(gross: 115.20, capturePositions: $positions); - $config = $this->config(['amountBase' => CreatePercentageEntryAction::AMOUNT_BASE_GROSS_WITHOUT_TOURIST_TAX]); + $config = $this->config(['amountBase' => CreatePercentageEntryAction::AMOUNT_BASE_COMMISSIONABLE]); $action->execute($config, $this->invoiceWithPositions(), []); self::assertSame(['Übernachtung', 'Endreinigung'], $this->descriptionsOf($positions)); @@ -343,7 +343,7 @@ public function testOffersTheNarrowerBaseAsTheDefaultForNewActions(): void $action = $this->makeAction(gross: 100.0); self::assertSame( - CreatePercentageEntryAction::AMOUNT_BASE_GROSS_WITHOUT_TOURIST_TAX, + CreatePercentageEntryAction::AMOUNT_BASE_COMMISSIONABLE, $this->amountBaseField($action)['default'] ?? null ); } @@ -395,8 +395,8 @@ private function config(array $overrides = []): array /** * An invoice carrying one tourist-tax position among ordinary ones. Only the - * position group matters here - which of them end up in the sum is what the - * base decides, the arithmetic on them is InvoiceService's job. + * flags matter here - which of them end up in the sum is what the base + * decides, the arithmetic on them is InvoiceSumCalculator's job. */ private function invoiceWithPositions(): Invoice { @@ -419,6 +419,10 @@ private function position(string $description, string $group): InvoicePosition $position = new InvoicePosition(); $position->setDescription($description); $position->setPositionGroup($group); + // As InvoiceService marks them: a separately billed tourist tax carries + // no commission, which is what the narrower base now goes by. The group + // is left on for what it is for, telling the invoice how to lay them out. + $position->setCommissionable('tourist_tax' !== $group); return $position; } diff --git a/translations/Workflow/messages.de.yaml b/translations/Workflow/messages.de.yaml index 689980b6..747f2660 100644 --- a/translations/Workflow/messages.de.yaml +++ b/translations/Workflow/messages.de.yaml @@ -82,8 +82,8 @@ workflow: percentage_entry_percent: "Prozentsatz (manuell)" percentage_entry_percent_help: "Anteil an der gewählten Berechnungsgrundlage, z.B. 12 für eine Kommission oder 1,4 für eine Zahlungsgebühr. Nur wirksam, wenn die Quelle „Manuell“ ist. Ohne einschränkende Bedingung wird auf jede Rechnung gebucht." percentage_entry_amount_base: "Berechnungsgrundlage" - percentage_entry_amount_base_help: "Betrag, auf den ein selbst eingetragener Prozentsatz angewendet wird. Für Kommission und Zahlungsgebühr aus der Buchungsherkunft gilt diese Einstellung nicht: worauf ein Portal sie berechnet, ergibt sich aus der Buchung. Die Kommission lässt dabei auf der Rechnung ausgewiesene Beherbergungsabgaben wie die Kurtaxe außen vor, die Zahlungsgebühr nicht - das Portal wickelt den vollen Betrag ab." - percentage_entry_amount_base_without_tourist_tax: "Rechnungsbrutto ohne Beherbergungsabgaben" + percentage_entry_amount_base_help: "Betrag, auf den der Prozentsatz angewendet wird. „Kommissionspflichtiger Teil des Rechnungsbruttos“ lässt weg, worauf ein Portal keine Kommission berechnet: eine separat ausgewiesene Kurtaxe und alles, was nicht Teil der Portalbuchung ist – eingestellt am Preis oder an der Rechnungsposition." + percentage_entry_amount_base_commissionable: "Kommissionspflichtiger Teil des Rechnungsbruttos" percentage_entry_amount_base_gross: "Vollständiges Rechnungsbrutto" percentage_entry_debit_account: "Sollkonto" percentage_entry_debit_account_help: "Konto, auf das der Abzug gebucht wird (Aufwand oder Reverse-Charge)." diff --git a/translations/Workflow/messages.en.yaml b/translations/Workflow/messages.en.yaml index 1584da37..904b8d7a 100644 --- a/translations/Workflow/messages.en.yaml +++ b/translations/Workflow/messages.en.yaml @@ -82,8 +82,8 @@ workflow: percentage_entry_percent: "Percentage (manual)" percentage_entry_percent_help: "Share of the selected calculation base, e.g. 12 for a commission or 1.4 for a payment fee. Only used when the source is \"Manual\". Without a condition narrowing it down, every invoice is booked." percentage_entry_amount_base: "Calculation base" - percentage_entry_amount_base_help: "The amount a percentage typed in here is applied to. It does not apply to a commission or payment fee taken from the booking origin: what a portal charges those on follows from the booking. Commission then leaves out lodging levies such as tourist tax where the invoice shows them as such, the payment fee does not - the portal processes the full amount." - percentage_entry_amount_base_without_tourist_tax: "Invoice gross without lodging levies" + percentage_entry_amount_base_help: "The amount the percentage is applied to. \"Commissionable part of the invoice gross\" leaves out what a portal charges no commission on: a separately billed tourist tax, and whatever was not part of the portal booking - set on the price or on the invoice position." + percentage_entry_amount_base_commissionable: "Commissionable part of the invoice gross" percentage_entry_amount_base_gross: "Full invoice gross" percentage_entry_debit_account: "Debit account" percentage_entry_debit_account_help: "Account the deduction is booked to (expense or reverse charge)." From 57918b0f143448a6ba2aac7bc3b0a6584a4dc775 Mon Sep 17 00:00:00 2001 From: MeisterAdebar Date: Tue, 4 Aug 2026 17:56:49 +0200 Subject: [PATCH 21/33] Cover the invoice where the two bases part company A stay with a tourist tax billed beside it, booked through a portal and run the way a workflow runs it. It is the only shape of invoice where commission and payment fee come to different figures, and the difference hangs on one setting on the origin - worth pinning down as a whole rather than in the pieces the unit tests check. --- tests/Functional/OriginFeeTouristTaxTest.php | 239 +++++++++++++++++++ 1 file changed, 239 insertions(+) create mode 100644 tests/Functional/OriginFeeTouristTaxTest.php diff --git a/tests/Functional/OriginFeeTouristTaxTest.php b/tests/Functional/OriginFeeTouristTaxTest.php new file mode 100644 index 00000000..ec7ed219 --- /dev/null +++ b/tests/Functional/OriginFeeTouristTaxTest.php @@ -0,0 +1,239 @@ +invoiceWithStayAndTouristTax(PaymentCollection::PORTAL); + + [$commission, $paymentFee] = $this->book($invoice); + + // 236.00 × 12 % = 28.32 - the tax stays out of it. + self::assertSame('28.32', $commission->getAmount()); + // 248.00 × 1.4 % = 3.472 → 3.47, the tax counted in. + self::assertSame('3.47', $paymentFee->getAmount()); + } + + public function testTheTaxDropsOutOfBothWhereTheHouseCollectsIt(): void + { + // Entered on the portal's side as payable on arrival: it brokered the + // stay but never handled the tax, so it charges nothing on it. + $invoice = $this->invoiceWithStayAndTouristTax(PaymentCollection::PROPERTY); + + [$commission, $paymentFee] = $this->book($invoice); + + self::assertSame('28.32', $commission->getAmount()); + // 236.00 × 1.4 % = 3.304 → 3.30, the stay alone. + self::assertSame('3.30', $paymentFee->getAmount()); + } + + /** + * Books the two deductions the way the two configured workflows do, and + * hands back the entries they produced. + * + * @return array{0: BookingEntry, 1: BookingEntry} + */ + private function book(Invoice $invoice): array + { + $action = static::getContainer()->get(WorkflowActionRegistry::class)->get('create_percentage_entry'); + $since = $this->lastEntryId(); + + $action->execute($this->config(CreatePercentageEntryAction::PERCENT_SOURCE_COMMISSION), $invoice, []); + $action->execute($this->config(CreatePercentageEntryAction::PERCENT_SOURCE_PAYMENT_FEE), $invoice, []); + $this->em()->flush(); + + $entries = $this->entriesSince($since); + self::assertCount(2, $entries, 'both deductions have to be booked'); + + return [$entries[0], $entries[1]]; + } + + /** + * An invoice for one portal booking: four nights plus the tourist tax as a + * position of its own, flagged as InvoiceService flags them. + */ + private function invoiceWithStayAndTouristTax(PaymentCollection $taxCollectedBy): Invoice + { + $origin = new ReservationOrigin(); + $origin->setName('Booking.com Test'); + $origin->setCommissionPercent('12.00'); + $origin->setPaymentFeePercent('1.40'); + $origin->setPaymentCollection(PaymentCollection::PORTAL); + $origin->setTouristTaxCollection($taxCollectedBy); + $this->em()->persist($origin); + + $reservation = new Reservation(); + $reservation->setStartDate(new \DateTime('2026-08-14')); + $reservation->setEndDate(new \DateTime('2026-08-18')); + $reservation->setPersons(1); + $reservation->setUuid(Uuid::v4()); + $reservation->setReservationStatus($this->anyReservationStatus()); + // Pins the origin's rates and who collects, as every booking path does. + $reservation->setReservationOrigin($origin); + $this->em()->persist($reservation); + + $invoice = new Invoice(); + $invoice->setNumber('T'.random_int(100000, 999999)); + $invoice->setDate(new \DateTime('2026-08-18')); + $invoice->setStatus(1); + $invoice->setRemark(''); + $this->em()->persist($invoice); + + $stay = new InvoiceAppartment(); + $stay->setInvoice($invoice); + $stay->setNumber('6'); + $stay->setDescription('Einzelzimmer'); + $stay->setBeds(1); + $stay->setPersons(1); + $stay->setStartDate(new \DateTime('2026-08-14')); + $stay->setEndDate(new \DateTime('2026-08-18')); + $stay->setPrice(59.00); + $stay->setVat(7.0); + $stay->setIncludesVat(true); + $stay->setIsFlatPrice(false); + $this->em()->persist($stay); + + $tax = new InvoicePosition(); + $tax->setInvoice($invoice); + $tax->setDescription('Kurtaxe'); + $tax->setAmount(4); + $tax->setPrice(3.00); + $tax->setVat(7.0); + $tax->setIncludesVat(true); + $tax->setIsFlatPrice(false); + $tax->setIsPerRoom(false); + $tax->setPositionGroup('tourist_tax'); + // What InvoiceService::makeTouristTaxPosition() records: never + // commissionable, brokered only where the portal collects it. + $tax->setCommissionable(false); + $tax->setBrokered($taxCollectedBy->isPortal()); + $this->em()->persist($tax); + + $this->em()->flush(); + + $invoice->getAppartments()->add($stay); + $invoice->addPosition($tax); + $invoice->addReservation($reservation); + + return $invoice; + } + + /** @return array */ + private function config(string $percentSource): array + { + $taxRate = $this->em()->getRepository(TaxRate::class)->findOneBy([]); + + return [ + 'percentSource' => $percentSource, + 'percent' => '', + 'debitAccountId' => (string) $this->account('3123')->getId(), + 'creditAccountId' => (string) $this->account('1200')->getId(), + 'taxRateId' => (string) $taxRate?->getId(), + 'remark' => '', + ]; + } + + private function anyReservationStatus(): ReservationStatus + { + $status = $this->em()->getRepository(ReservationStatus::class)->findOneBy([]); + self::assertNotNull($status, 'the fixture needs at least one reservation status'); + + return $status; + } + + private function account(string $number): AccountingAccount + { + /** @var AccountingAccountRepository $repo */ + $repo = $this->em()->getRepository(AccountingAccount::class); + $account = $repo->findOneBy(['accountNumber' => $number]); + + if (null === $account) { + $account = new AccountingAccount(); + $account->setAccountNumber($number); + $account->setName('Testkonto '.$number); + $account->setType('expense'); + $this->em()->persist($account); + $this->em()->flush(); + } + + return $account; + } + + /** @return BookingEntry[] */ + private function entriesSince(int $id): array + { + return $this->em()->getRepository(BookingEntry::class) + ->createQueryBuilder('e') + ->where('e.id > :id') + ->setParameter('id', $id) + ->orderBy('e.id', 'ASC') + ->getQuery() + ->getResult(); + } + + private function lastEntryId(): int + { + return (int) ($this->em()->getRepository(BookingEntry::class) + ->createQueryBuilder('e') + ->select('MAX(e.id)') + ->getQuery() + ->getSingleScalarResult() ?? 0); + } + + private function em(): EntityManagerInterface + { + if (null === $this->em) { + if (!static::$booted && null === static::$kernel) { + self::bootKernel(); + } + $this->em = static::getContainer()->get(ManagerRegistry::class)->getManager(); + } + + return $this->em; + } +} From 4f6f04c44c1050083b1852844b76fd666c354b4d Mon Sep 17 00:00:00 2001 From: MeisterAdebar Date: Tue, 15 Sep 2026 18:04:12 +0200 Subject: [PATCH 22/33] Ask who collects the tourist tax only where one exists A house with no tourist tax configured has nothing for a portal to collect, so the question had one possible answer and still took up a row in the form. It is now asked only where a tax exists; with the field gone the form sends no value and the service falls back to the property, which is that one answer. "The house" and "the portal" read as the two sides of a sentence rather than as the things they name, so they are now "Unterkunft" and "Portal". --- .../ReservationOriginServiceController.php | 9 +-- ...ervationorigin_form_input_fields.html.twig | 23 ++++--- .../ReservationOriginFormRenderTest.php | 62 +++++++++++++++++++ .../ReservationOrigin/messages.de.xlf | 4 +- .../ReservationOrigin/messages.en.yaml | 4 +- 5 files changed, 85 insertions(+), 17 deletions(-) create mode 100644 tests/Functional/ReservationOriginFormRenderTest.php diff --git a/src/Controller/ReservationOriginServiceController.php b/src/Controller/ReservationOriginServiceController.php index a4bb7a16..18b74619 100644 --- a/src/Controller/ReservationOriginServiceController.php +++ b/src/Controller/ReservationOriginServiceController.php @@ -14,6 +14,7 @@ namespace App\Controller; use App\Entity\ReservationOrigin; +use App\Repository\TouristTaxRepository; use App\Service\CSRFProtectionService; use App\Service\ReservationOriginService; use Doctrine\Persistence\ManagerRegistry; @@ -43,13 +44,14 @@ public function indexAction(ManagerRegistry $doctrine) * Show single entity. */ #[Route('/{id}/get', name: 'reservationorigin.get.origin', methods: ['GET'], defaults: ['id' => '0'])] - public function getAction(ManagerRegistry $doctrine, CSRFProtectionService $csrf, $id) + public function getAction(ManagerRegistry $doctrine, CSRFProtectionService $csrf, TouristTaxRepository $touristTaxRepo, $id) { $em = $doctrine->getManager(); $origin = $em->getRepository(ReservationOrigin::class)->find($id); return $this->render('ReservationOrigin/reservationorigin_form_edit.html.twig', [ 'origin' => $origin, + 'hasTouristTax' => [] !== $touristTaxRepo->findAllOrdered(), 'token' => $csrf->getCSRFTokenForForm(), ]); } @@ -58,15 +60,14 @@ public function getAction(ManagerRegistry $doctrine, CSRFProtectionService $csrf * Show form for new entity. */ #[Route('/new', name: 'reservationorigin.new.origin', methods: ['GET'])] - public function newAction(ManagerRegistry $doctrine, CSRFProtectionService $csrf) + public function newAction(CSRFProtectionService $csrf, TouristTaxRepository $touristTaxRepo) { - $em = $doctrine->getManager(); - $origin = new ReservationOrigin(); $origin->setId('new'); return $this->render('ReservationOrigin/reservationorigin_form_create.html.twig', [ 'origin' => $origin, + 'hasTouristTax' => [] !== $touristTaxRepo->findAllOrdered(), 'token' => $csrf->getCSRFTokenForForm(), ]); } diff --git a/templates/ReservationOrigin/reservationorigin_form_input_fields.html.twig b/templates/ReservationOrigin/reservationorigin_form_input_fields.html.twig index 2b77d414..7d24ab58 100644 --- a/templates/ReservationOrigin/reservationorigin_form_input_fields.html.twig +++ b/templates/ReservationOrigin/reservationorigin_form_input_fields.html.twig @@ -76,17 +76,22 @@ -
- + {# Only asked where a tourist tax exists at all - with none configured + there is nothing for a portal to collect, and the answer is the + property either way. #} + {% if hasTouristTax %} +
+ -
- -
{{ 'reservationorigin.tourist_tax_collection.hint'|trans }}
+
+ +
{{ 'reservationorigin.tourist_tax_collection.hint'|trans }}
+
-
+ {% endif %}
diff --git a/tests/Functional/ReservationOriginFormRenderTest.php b/tests/Functional/ReservationOriginFormRenderTest.php new file mode 100644 index 00000000..8313019b --- /dev/null +++ b/tests/Functional/ReservationOriginFormRenderTest.php @@ -0,0 +1,62 @@ +render(true)); + } + + public function testTheTouristTaxQuestionIsLeftOutWhereNoneIsConfigured(): void + { + // Nothing for a portal to collect, so the question has one answer and + // does not need asking. The form then sends no value and the service + // falls back to the property. + $html = $this->render(false); + + self::assertStringNotContainsString('tourist-tax-collection-7', $html); + // The ordinary payment question stays, it does not depend on a tax. + self::assertStringContainsString('payment-collection-7', $html); + } + + public function testTheCollectionOptionsNameTheTwoSides(): void + { + $html = $this->render(true); + + self::assertStringContainsString('>Unterkunft<', $html); + self::assertStringContainsString('>Portal<', $html); + self::assertStringNotContainsString('dem Haus', $html); + } + + private function render(bool $hasTouristTax): string + { + self::bootKernel(); + $twig = static::getContainer()->get(Environment::class); + + // A numeric id, unlike the "new" the create form uses: the field names + // carry it, and nothing here turns on which of the two forms renders. + $origin = new ReservationOrigin(); + $origin->setId(7); + $origin->setName('Booking.com'); + + return $twig->render('ReservationOrigin/reservationorigin_form_input_fields.html.twig', [ + 'origin' => $origin, + 'hasTouristTax' => $hasTouristTax, + 'token' => 'test-token', + ]); + } +} diff --git a/translations/ReservationOrigin/messages.de.xlf b/translations/ReservationOrigin/messages.de.xlf index 5b5e07b5..41159d2d 100644 --- a/translations/ReservationOrigin/messages.de.xlf +++ b/translations/ReservationOrigin/messages.de.xlf @@ -56,11 +56,11 @@
reservationorigin.collection.property - dem Haus + Unterkunft reservationorigin.collection.portal - dem Portal + Portal reservationorigin.flash.create.success diff --git a/translations/ReservationOrigin/messages.en.yaml b/translations/ReservationOrigin/messages.en.yaml index 21368aaf..fccd73ac 100644 --- a/translations/ReservationOrigin/messages.en.yaml +++ b/translations/ReservationOrigin/messages.en.yaml @@ -18,7 +18,7 @@ reservationorigin.payment_fee_percent: Payment fee reservationorigin.payment_collection: Payment collected by reservationorigin.tourist_tax_collection: Tourist tax collected by reservationorigin.tourist_tax_collection.hint: A portal charges its payment fee on what it processed. Commission is not affected - a tourist tax billed separately is treated as carrying none. That assumes it is set up as a separate item at the portal as well; one buried in the room rate is no separate tourist tax as far as the portal is concerned. -reservationorigin.collection.property: The property -reservationorigin.collection.portal: The portal +reservationorigin.collection.property: Property +reservationorigin.collection.portal: Portal reservationorigin.private: Private reservationorigin.title: Reservation origin From 4f5e5462e448f3d90acd2cc4123040b5037b38fd Mon Sep 17 00:00:00 2001 From: MeisterAdebar Date: Tue, 15 Sep 2026 18:04:26 +0200 Subject: [PATCH 23/33] Refuse a percentage the fee cannot be charged at MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The form's min, max and step are a courtesy to whoever types; what arrives at the server is whatever was posted. A commission of "zwölf", of -5 or of 12.345 went straight into a decimal(5,2) column, to be rounded away or refused far from where anybody could connect it to what they entered. A value the column cannot hold is now dropped before it reaches the entity, and the save is refused with a message saying what a percentage may look like. The check for the flag set without any value at all joins it: both are reasons the same form cannot be saved, so they are one question with two answers now. --- .../ReservationOriginServiceController.php | 8 +-- src/Service/ReservationOriginService.php | 64 +++++++++++++++---- .../ReservationOriginSurchargeFormTest.php | 55 +++++++++++++++- .../ReservationOrigin/messages.de.xlf | 4 ++ .../ReservationOrigin/messages.en.yaml | 1 + 5 files changed, 112 insertions(+), 20 deletions(-) diff --git a/src/Controller/ReservationOriginServiceController.php b/src/Controller/ReservationOriginServiceController.php index 18b74619..513f76bf 100644 --- a/src/Controller/ReservationOriginServiceController.php +++ b/src/Controller/ReservationOriginServiceController.php @@ -86,9 +86,9 @@ public function createAction(ManagerRegistry $doctrine, CSRFProtectionService $c if (0 == strlen($origin->getName())) { $error = true; $this->addFlash('warning', 'flash.mandatory'); - } elseif ($ros->isSurchargeFlagSetWithoutValue($request, 'new', $origin)) { + } elseif (null !== ($surchargeError = $ros->findSurchargeValueError($request, 'new', $origin))) { $error = true; - $this->addFlash('warning', 'reservationorigin.flash.surcharge_required'); + $this->addFlash('warning', $surchargeError); } else { $em = $doctrine->getManager(); $em->persist($origin); @@ -122,9 +122,9 @@ public function editAction(ManagerRegistry $doctrine, CSRFProtectionService $csr $this->addFlash('warning', 'flash.mandatory'); // stop auto commit of doctrine with invalid field values $em->clear(); - } elseif ($ros->isSurchargeFlagSetWithoutValue($request, $id, $origin)) { + } elseif (null !== ($surchargeError = $ros->findSurchargeValueError($request, $id, $origin))) { $error = true; - $this->addFlash('warning', 'reservationorigin.flash.surcharge_required'); + $this->addFlash('warning', $surchargeError); // stop auto commit of doctrine with invalid field values $em->clear(); } else { diff --git a/src/Service/ReservationOriginService.php b/src/Service/ReservationOriginService.php index 64a61c4e..2e48e586 100644 --- a/src/Service/ReservationOriginService.php +++ b/src/Service/ReservationOriginService.php @@ -53,11 +53,8 @@ public function getOriginFromForm(Request $request, $id = 'new') // them; without the flag they are cleared, whatever the hidden fields // still carried. if ($request->request->get('surcharge-enabled-'.$id)) { - $commission = str_replace(',', '.', trim((string) $request->request->get('commission-'.$id, ''))); - $origin->setCommissionPercent('' === $commission ? null : $commission); - - $paymentFee = str_replace(',', '.', trim((string) $request->request->get('payment-fee-'.$id, ''))); - $origin->setPaymentFeePercent('' === $paymentFee ? null : $paymentFee); + $origin->setCommissionPercent($this->percentFromForm($request, 'commission-'.$id)); + $origin->setPaymentFeePercent($this->percentFromForm($request, 'payment-fee-'.$id)); // Who collects the money is only asked where fees are charged, since // that is all it decides. An unreadable value falls back to the @@ -74,22 +71,63 @@ public function getOriginFromForm(Request $request, $id = 'new') return $origin; } - private function collectionFromForm(Request $request, string $field): PaymentCollection + /** + * A percentage as it may have been typed, or null where nothing readable + * was given. + * + * Anything the column cannot hold is dropped rather than handed on: the + * field is a decimal(5,2), and a value it cannot take is either rounded + * away or refused by the database further down, where nobody connects it + * to what they typed. What is dropped here is reported separately, see + * findSurchargeValueError(). + */ + private function percentFromForm(Request $request, string $field): ?string { - return PaymentCollection::tryFrom((string) $request->request->get($field, '')) ?? PaymentCollection::PROPERTY; + $raw = str_replace(',', '.', trim((string) $request->request->get($field, ''))); + + return self::isValidPercent($raw) ? $raw : null; + } + + /** Whether a typed percentage is a figure this fee can actually be charged at. */ + private static function isValidPercent(string $raw): bool + { + return 1 === preg_match('/^\\d{1,3}(\\.\\d{1,2})?$/', $raw) && (float) $raw <= 100.0; } /** - * True when the OTA-fee flag is set but neither percentage was given, so the - * origin would be marked as charging fees with no fee to charge. + * The key of the message explaining why the form cannot be saved, or null + * when it can. + * + * The HTML fields carry min, max and step, which is a courtesy rather than + * a guarantee - they are trivially bypassed, and what arrives here has to + * stand on its own. * * @param string $id */ - public function isSurchargeFlagSetWithoutValue(Request $request, $id, ReservationOrigin $origin): bool + public function findSurchargeValueError(Request $request, $id, ReservationOrigin $origin): ?string { - return (bool) $request->request->get('surcharge-enabled-'.$id) - && null === $origin->getCommissionPercent() - && null === $origin->getPaymentFeePercent(); + if (!$request->request->get('surcharge-enabled-'.$id)) { + return null; + } + + foreach (['commission-'.$id, 'payment-fee-'.$id] as $field) { + $raw = str_replace(',', '.', trim((string) $request->request->get($field, ''))); + if ('' !== $raw && !self::isValidPercent($raw)) { + return 'reservationorigin.flash.surcharge_invalid'; + } + } + + // Flagged as charging fees with no fee to charge. + if (null === $origin->getCommissionPercent() && null === $origin->getPaymentFeePercent()) { + return 'reservationorigin.flash.surcharge_required'; + } + + return null; + } + + private function collectionFromForm(Request $request, string $field): PaymentCollection + { + return PaymentCollection::tryFrom((string) $request->request->get($field, '')) ?? PaymentCollection::PROPERTY; } /** diff --git a/tests/Unit/ReservationOriginSurchargeFormTest.php b/tests/Unit/ReservationOriginSurchargeFormTest.php index bbef80d2..95f79964 100644 --- a/tests/Unit/ReservationOriginSurchargeFormTest.php +++ b/tests/Unit/ReservationOriginSurchargeFormTest.php @@ -68,7 +68,7 @@ public function testFlaggedWithoutAnyValueIsRejected(): void ]); $origin = $this->service()->getOriginFromForm($request, 'new'); - self::assertTrue($this->service()->isSurchargeFlagSetWithoutValue($request, 'new', $origin)); + self::assertSame('reservationorigin.flash.surcharge_required', $this->service()->findSurchargeValueError($request, 'new', $origin)); } public function testFlaggedWithOneValuePasses(): void @@ -81,7 +81,7 @@ public function testFlaggedWithOneValuePasses(): void ]); $origin = $this->service()->getOriginFromForm($request, 'new'); - self::assertFalse($this->service()->isSurchargeFlagSetWithoutValue($request, 'new', $origin)); + self::assertNull($this->service()->findSurchargeValueError($request, 'new', $origin)); } public function testUnflaggedIsNeverRejectedEvenWhenEmpty(): void @@ -89,7 +89,56 @@ public function testUnflaggedIsNeverRejectedEvenWhenEmpty(): void $request = new Request([], ['name-new' => 'Direktbuchung']); $origin = $this->service()->getOriginFromForm($request, 'new'); - self::assertFalse($this->service()->isSurchargeFlagSetWithoutValue($request, 'new', $origin)); + self::assertNull($this->service()->findSurchargeValueError($request, 'new', $origin)); + } + + /** + * Values the decimal(5,2) column cannot hold, each rejected by name. + * + * The form's own min/max/step are a courtesy to whoever types; anything can + * be posted past them. + */ + public static function unusablePercentages(): \Generator + { + yield 'nicht numerisch' => ['zwölf']; + yield 'negativ' => ['-5']; + yield 'über hundert' => ['120']; + yield 'zu viele Nachkommastellen' => ['12.345']; + yield 'Ausdruck' => ['12%']; + } + + #[\PHPUnit\Framework\Attributes\DataProvider('unusablePercentages')] + public function testAnUnusablePercentageIsRejectedAndNeverStored(string $typed): void + { + $request = new Request([], [ + 'name-new' => 'Booking.com', + 'surcharge-enabled-new' => '1', + 'commission-new' => $typed, + 'payment-fee-new' => '1,4', + ]); + $origin = $this->service()->getOriginFromForm($request, 'new'); + + self::assertNull($origin->getCommissionPercent(), 'the unusable value reached the entity'); + self::assertSame( + 'reservationorigin.flash.surcharge_invalid', + $this->service()->findSurchargeValueError($request, 'new', $origin) + ); + } + + public function testTheBoundsThemselvesArePercentages(): void + { + foreach (['0', '100', '12,5', '1.4', '99.99'] as $typed) { + $request = new Request([], [ + 'name-new' => 'Booking.com', + 'surcharge-enabled-new' => '1', + 'commission-new' => $typed, + 'payment-fee-new' => '', + ]); + $origin = $this->service()->getOriginFromForm($request, 'new'); + + self::assertSame(str_replace(',', '.', $typed), $origin->getCommissionPercent()); + self::assertNull($this->service()->findSurchargeValueError($request, 'new', $origin), $typed); + } } /** diff --git a/translations/ReservationOrigin/messages.de.xlf b/translations/ReservationOrigin/messages.de.xlf index 41159d2d..e1859235 100644 --- a/translations/ReservationOrigin/messages.de.xlf +++ b/translations/ReservationOrigin/messages.de.xlf @@ -74,6 +74,10 @@ reservationorigin.flash.edit.success Reservierungsherkunft erfolgreich bearbeitet. + + reservationorigin.flash.surcharge_invalid + Kommission und Zahlungsgebühr müssen Prozentwerte zwischen 0 und 100 mit höchstens zwei Nachkommastellen sein. + reservationorigin.flash.surcharge_required Bei aktivierten OTA-Gebühren muss mindestens Kommission oder Zahlungsgebühr angegeben werden. diff --git a/translations/ReservationOrigin/messages.en.yaml b/translations/ReservationOrigin/messages.en.yaml index fccd73ac..ad3802f1 100644 --- a/translations/ReservationOrigin/messages.en.yaml +++ b/translations/ReservationOrigin/messages.en.yaml @@ -10,6 +10,7 @@ reservationorigin.flash.create.success: Reservation origin created successfully. reservationorigin.flash.delete.inuse.reservations: Reservation origin cannot be deleted as it is used in existing reservations. reservationorigin.flash.delete.success: Reservation origin successfully deleted. reservationorigin.flash.edit.success: Reservation origin successfully processed. +reservationorigin.flash.surcharge_invalid: Commission and payment fee must be percentages between 0 and 100 with at most two decimals. reservationorigin.flash.surcharge_required: With OTA fees enabled, at least a commission or a payment fee is required. reservationorigin.name: Description reservationorigin.guest_surcharge_enabled: OTA fees From b11f344994e184e266ff77daf395579fa85fb329 Mon Sep 17 00:00:00 2001 From: MeisterAdebar Date: Tue, 15 Sep 2026 18:05:15 +0200 Subject: [PATCH 24/33] Pin who collects the tourist tax to the booking The rates and the payment collection are recorded on the reservation when its origin is assigned, so that a portal changing its terms cannot rewrite what older bookings were settled under. Who collects the tourist tax was the one answer still read live off the origin while the invoice was written - and an invoice is often written weeks after the stay. It is now pinned with the rest. Bookings taken before this keep nothing recorded and fall back to the origin, which answers for them; stamping today's setting onto them would assert something nobody ever recorded. --- migrations/Version20260915120000.php | 40 ++++++++++++++++ src/Entity/Reservation.php | 28 +++++++++++ src/Service/InvoiceService.php | 20 +++++--- .../Unit/ReservationOriginRatePinningTest.php | 46 ++++++++++++++++++- 4 files changed, 126 insertions(+), 8 deletions(-) create mode 100644 migrations/Version20260915120000.php diff --git a/migrations/Version20260915120000.php b/migrations/Version20260915120000.php new file mode 100644 index 00000000..64f76e7f --- /dev/null +++ b/migrations/Version20260915120000.php @@ -0,0 +1,40 @@ +addSql('ALTER TABLE reservations ADD tourist_tax_collection VARCHAR(16) DEFAULT NULL'); + + // Left NULL for everything booked so far, which falls back to the + // origin. Stamping today's answer onto old bookings would assert + // something about them that nobody recorded. + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE reservations DROP tourist_tax_collection'); + } + + public function isTransactional(): bool + { + return false; + } +} diff --git a/src/Entity/Reservation.php b/src/Entity/Reservation.php index 6679847f..a7ff0765 100644 --- a/src/Entity/Reservation.php +++ b/src/Entity/Reservation.php @@ -79,6 +79,17 @@ class Reservation */ #[ORM\Column(name: 'payment_collection', type: 'string', length: 16, enumType: PaymentCollection::class, nullable: true)] private ?PaymentCollection $paymentCollection = null; + + /** + * Who collected the tourist tax for this booking, pinned like the payment + * above and for the same reason. Asked separately because the answers + * differ: a portal can settle the stay while the tax is paid on arrival. + * + * Null where nothing is recorded, in the same cases as the payment above, + * which then falls back to the origin. + */ + #[ORM\Column(name: 'tourist_tax_collection', type: 'string', length: 16, enumType: PaymentCollection::class, nullable: true)] + private ?PaymentCollection $touristTaxCollection = null; #[ORM\OneToMany(targetEntity: 'Correspondence', mappedBy: 'reservation', cascade: ['remove'])] private $correspondences; #[ORM\ManyToMany(targetEntity: Price::class)] @@ -343,6 +354,7 @@ public function setReservationOrigin(?ReservationOrigin $reservationOrigin = nul // stay in the payment fee's base, and nothing would say so. $chargesFees = null !== $this->commissionPercent || null !== $this->paymentFeePercent; $this->paymentCollection = $chargesFees ? $reservationOrigin?->getPaymentCollection() : null; + $this->touristTaxCollection = $chargesFees ? $reservationOrigin?->getTouristTaxCollection() : null; } $this->reservationOrigin = $reservationOrigin; @@ -409,6 +421,22 @@ public function setPaymentCollection(?PaymentCollection $paymentCollection): sel return $this; } + /** + * Who collected the tourist tax for this booking, null when nothing was + * recorded - then the origin answers, see the property. + */ + public function getTouristTaxCollection(): ?PaymentCollection + { + return $this->touristTaxCollection; + } + + public function setTouristTaxCollection(?PaymentCollection $touristTaxCollection): self + { + $this->touristTaxCollection = $touristTaxCollection; + + return $this; + } + /** * Get reservationOrigin. * diff --git a/src/Service/InvoiceService.php b/src/Service/InvoiceService.php index a909952a..01919022 100644 --- a/src/Service/InvoiceService.php +++ b/src/Service/InvoiceService.php @@ -607,10 +607,16 @@ public function buildTouristTaxPositions(array $reservations): array * decides whether its payment fee is charged on it. Commission is not at * stake here: a separately billed tourist tax carries none either way. * - * Every reservation has to agree and carry an origin that says so. The - * positions are aggregated across reservations and no longer know which one - * they came from, and a stay whose tax the house collects must not be swept - * into a portal's payment fee by a booking sharing the invoice with it. + * What the reservation recorded wins over what its origin says today, as + * with the rates and the payment: an origin that starts collecting the tax + * must not change how bookings taken before were settled. Where nothing was + * recorded the origin answers, and a booking without an origin never says + * a portal took anything. + * + * Every reservation has to agree. The positions are aggregated across + * reservations and no longer know which one they came from, and a stay + * whose tax the house collects must not be swept into a portal's payment + * fee by a booking sharing the invoice with it. * * @param array $reservations */ @@ -622,8 +628,10 @@ private function touristTaxIsCollectedByPortal(array $reservations): bool } foreach ($reservations as $reservation) { - $origin = $reservation->getReservationOrigin(); - if (null === $origin || !$origin->getTouristTaxCollection()->isPortal()) { + $collection = $reservation->getTouristTaxCollection() + ?? $reservation->getReservationOrigin()?->getTouristTaxCollection(); + + if (null === $collection || !$collection->isPortal()) { return false; } } diff --git a/tests/Unit/ReservationOriginRatePinningTest.php b/tests/Unit/ReservationOriginRatePinningTest.php index b4580585..fbe833da 100644 --- a/tests/Unit/ReservationOriginRatePinningTest.php +++ b/tests/Unit/ReservationOriginRatePinningTest.php @@ -121,10 +121,23 @@ public function testKeepsWhoCollectedWhenTheOriginLaterSwitches(): void self::assertSame(PaymentCollection::PROPERTY, $reservation->getPaymentCollection()); } + public function testRecordsNoCollectionWhereTheOriginChargesNoFee(): void + { + // An origin without fees is never asked who collects and only carries its + // default. Pinned, that default would keep a booking taken before the + // fees were set up off the stay in the payment fee's base, while its + // rates fall back to the ones configured later. + $origin = $this->origin(null, null); + + $reservation = new Reservation(); + $reservation->setReservationOrigin($origin); + + self::assertNull($reservation->getPaymentCollection()); + self::assertNull($reservation->getTouristTaxCollection()); + } + public function testRecordsNoCollectionForABookingWithoutAnOrigin(): void { - // Unlike the rates there is no blank to guard against - an origin always - // answers - so only its absence leaves this unrecorded. $origin = $this->origin('12.00', '1.40'); $origin->setPaymentCollection(PaymentCollection::PORTAL); @@ -135,6 +148,35 @@ public function testRecordsNoCollectionForABookingWithoutAnOrigin(): void self::assertNull($reservation->getPaymentCollection()); } + public function testPinsWhoCollectsTheTouristTaxAsWell(): void + { + // Asked separately from the payment because the answers differ: a portal + // can settle the stay while the tax is paid on arrival. + $origin = $this->origin('12.00', '1.40'); + $origin->setPaymentCollection(PaymentCollection::PORTAL); + $origin->setTouristTaxCollection(PaymentCollection::PROPERTY); + + $reservation = new Reservation(); + $reservation->setReservationOrigin($origin); + + self::assertSame(PaymentCollection::PORTAL, $reservation->getPaymentCollection()); + self::assertSame(PaymentCollection::PROPERTY, $reservation->getTouristTaxCollection()); + } + + public function testKeepsWhoCollectedTheTouristTaxWhenTheOriginLaterSwitches(): void + { + // The reason the column exists: an invoice written next month for a + // booking taken today must charge what was agreed today. + $origin = $this->origin('12.00', '1.40'); + + $reservation = new Reservation(); + $reservation->setReservationOrigin($origin); + + $origin->setTouristTaxCollection(PaymentCollection::PORTAL); + + self::assertSame(PaymentCollection::PROPERTY, $reservation->getTouristTaxCollection()); + } + private function origin(?string $commission, ?string $paymentFee): ReservationOrigin { $origin = new ReservationOrigin(); From de7037094086c7834820cdeec31b944173db12ec Mon Sep 17 00:00:00 2001 From: MeisterAdebar Date: Tue, 15 Sep 2026 18:05:31 +0200 Subject: [PATCH 25/33] Offer the brokered switch only where it decides anything A night is the thing a portal brokers - the calculator counts it towards both fees whatever a price says, and an apartment position never carried the flag. The switch was offered there all the same, so the form asked a question whose answer it then ignored. It is now shown for miscellaneous prices only, like the other switches that mean nothing for a night. Since a hidden field posts nothing, apartment prices are kept on "brokered" rather than having the silence read as a no, which would have recorded the opposite of what the calculator does. The explanation moves into a tooltip on the label, as in the online booking settings. The price dialog is long enough without a paragraph under a switch. --- assets/controllers/prices_controller.js | 10 +++ src/Service/PriceService.php | 7 +- .../Prices/price_form_input_fields.html.twig | 10 ++- tests/Unit/PriceServiceBrokeredTest.php | 79 +++++++++++++++++++ 4 files changed, 102 insertions(+), 4 deletions(-) create mode 100644 tests/Unit/PriceServiceBrokeredTest.php diff --git a/assets/controllers/prices_controller.js b/assets/controllers/prices_controller.js index 4bbea2c9..ba2a3025 100644 --- a/assets/controllers/prices_controller.js +++ b/assets/controllers/prices_controller.js @@ -189,6 +189,16 @@ export default class extends Controller { if (defaultActiveCheckbox) { defaultActiveCheckbox.disabled = !isMisc; } + // A night is what the portal brokered, so the question is only put for + // miscellaneous prices; PriceService keeps apartment prices on "yes". + const brokeredWrapper = this.element.querySelector(`#brokered-wrap-${priceId}`); + const brokeredCheckbox = this.element.querySelector(`#brokered-${priceId}`); + if (brokeredWrapper) { + brokeredWrapper.classList.toggle('d-none', !isMisc); + } + if (brokeredCheckbox) { + brokeredCheckbox.disabled = !isMisc; + } const bookableOnlineWrapper = this.element.querySelector(`#bookable-online-wrap-${priceId}`); const bookableOnlineCheckbox = this.element.querySelector(`#isBookableOnline-${priceId}`); if (bookableOnlineWrapper) { diff --git a/src/Service/PriceService.php b/src/Service/PriceService.php index 6bdc8579..98dcf7ce 100644 --- a/src/Service/PriceService.php +++ b/src/Service/PriceService.php @@ -177,7 +177,12 @@ public function getPriceFromForm(Request $request, $id = 'new') // processed it, so none of its fees are charged on it. On is the ordinary // case and how the switch starts out, so a price saved without touching // it keeps counting towards a portal's fees as it did before. - $price->setBrokered(null != $request->request->get('brokered-'.$id)); + // + // Asked for miscellaneous prices only. A night is the thing the portal + // brokered - the calculator counts it towards the fees whatever this + // says - so the form does not offer the switch there, and a missing + // field must not be read as an answer of "no". + $price->setBrokered(1 != $price->getType() || null != $request->request->get('brokered-'.$id)); $mandatoryOnline = 1 == $price->getType() && null != $request->request->get('isMandatoryOnline-'.$id); $bookableOnline = 1 == $price->getType() && null != $request->request->get('isBookableOnline-'.$id); diff --git a/templates/Prices/price_form_input_fields.html.twig b/templates/Prices/price_form_input_fields.html.twig index 7dd8c421..71453545 100644 --- a/templates/Prices/price_form_input_fields.html.twig +++ b/templates/Prices/price_form_input_fields.html.twig @@ -126,10 +126,14 @@ -
- + {# Only offered for miscellaneous prices: a night is what the + portal brokered, so the question has no answer to give there. #} +
+ -
{{ 'price.brokered.hint'|trans }}
+ + +
parse(type: 1, brokered: '1')->isBrokered()); + self::assertFalse($this->parse(type: 1, brokered: null)->isBrokered(), 'the switch was turned off'); + } + + public function testAnApartmentPriceStaysBrokeredWithoutBeingAsked(): void + { + // The form hides and disables the switch for apartment prices, so + // nothing is posted. Reading that as "not brokered" would record the + // opposite of what the calculator does with the night. + self::assertTrue($this->parse(type: 2, brokered: null)->isBrokered()); + } + + private function parse(int $type, ?string $brokered): \App\Entity\Price + { + $params = [ + 'description-new' => 'Frühstück', + 'price-new' => '12.00', + 'vat-new' => '7', + 'type-new' => (string) $type, + ]; + if (null !== $brokered) { + $params['brokered-new'] = $brokered; + } + + // The service also resolves origins and categories against repositories. + // findById() is one of Doctrine's magic finders and cannot be stubbed, + // so this stands in and answers everything with nothing. + $repository = new class($this->createStub(EntityManagerInterface::class), new ClassMetadata(ReservationOrigin::class)) extends EntityRepository { + /** @return array */ + public function findById(mixed $ids): array + { + return []; + } + + public function find(mixed $id, mixed $lockMode = null, mixed $lockVersion = null): ?object + { + return null; + } + + /** @return array */ + public function findAll(): array + { + return []; + } + }; + + $em = $this->createStub(EntityManagerInterface::class); + $em->method('getRepository')->willReturn($repository); + + $service = new PriceService($em); + + return $service->getPriceFromForm(new Request([], $params), 'new'); + } +} From 552628b0008c6efd62f3cdd99d64b67d285e31e3 Mon Sep 17 00:00:00 2001 From: MeisterAdebar Date: Tue, 15 Sep 2026 18:06:36 +0200 Subject: [PATCH 26/33] Stop instead of booking a fee on a base the invoice cannot state An invoice whose stays were settled differently - one paid through the portal, one paid to the house - gives the payment fee no single base. The calculator answered by leaving the whole stay out, which books a fee on the extras alone and reads like a correct deduction; nothing said that a figure had been dropped. Whether the portal took the money now has three answers, the third being that the invoice does not say, and a fee carries whether it has one base at all. The journal refuses such an invoice the way it already refuses one whose reservations carry different rates, with its own reason given in the log. The commission is untouched by this: it is charged on what the portal brokered, not on what it processed, so a mixed settlement leaves it stateable and it goes on being booked. --- src/Dto/OriginFee.php | 42 ++++++++++-- src/Service/OriginFeeCalculator.php | 66 ++++++++++++------- .../Action/CreatePercentageEntryAction.php | 8 +++ tests/Unit/OriginFeeCalculatorTest.php | 42 ++++++++++-- .../CreatePercentageEntryActionTest.php | 43 ++++++++++++ translations/Workflow/messages.de.yaml | 1 + translations/Workflow/messages.en.yaml | 1 + 7 files changed, 170 insertions(+), 33 deletions(-) diff --git a/src/Dto/OriginFee.php b/src/Dto/OriginFee.php index 7c61c936..b012f13e 100644 --- a/src/Dto/OriginFee.php +++ b/src/Dto/OriginFee.php @@ -21,18 +21,21 @@ public float $amount; /** - * @param float $percent the rate this fee is charged at - * @param float $base the amount the percentage is taken of - * @param array $rates every distinct rate the invoice's reservations - * carry for this fee, keyed by its formatted form - * so a caller can name them in a message. Empty - * where the rate did not come from the booking - * at all but was typed into a workflow + * @param float $percent the rate this fee is charged at + * @param float $base the amount the percentage is taken of + * @param array $rates every distinct rate the invoice's reservations + * carry for this fee, keyed by its formatted form + * so a caller can name them in a message. Empty + * where the rate did not come from the booking + * at all but was typed into a workflow + * @param bool $baseIsOne false where the invoice does not yield a single + * amount this fee is charged on - see hasOneBase() */ public function __construct( public float $percent, public float $base, public array $rates = [], + public bool $baseIsOne = true, ) { $this->amount = round($base * $percent / 100.0, 2); } @@ -47,6 +50,31 @@ public function isAgreedUpon(): bool return count($this->rates) <= 1; } + /** + * Whether the invoice says what this fee is charged on. + * + * It does not when its reservations were settled differently - one paid + * through the portal, another directly to the house. The stay is then + * charged a payment fee for one and none for the other, and an invoice + * carries no attribution of its lines to reservations to split it along. + */ + public function hasOneBase(): bool + { + return $this->baseIsOne; + } + + /** + * Whether the amount can be stated at all, rather than guessed. + * + * Both callers stop here, differently: the journal refuses to book and says + * why, while an invoice shown to a guest leaves the figure out instead of + * printing one that may be wrong. + */ + public function isSettled(): bool + { + return $this->isAgreedUpon() && $this->hasOneBase(); + } + /** @return string[] the rates as they read in a message, e.g. "12,00 %" */ public function rateLabels(): array { diff --git a/src/Service/OriginFeeCalculator.php b/src/Service/OriginFeeCalculator.php index 11dbd5e9..c1aa41f2 100644 --- a/src/Service/OriginFeeCalculator.php +++ b/src/Service/OriginFeeCalculator.php @@ -64,17 +64,7 @@ public function calculate(Invoice $invoice): OriginFeeBreakdown static fn (Reservation $r): ?string => $r->getCommissionPercent() ?? $r->getReservationOrigin()?->getCommissionPercent(), ), - $this->fee( - $invoice, - $shown, - $this->baseOf( - $invoice, - static fn (InvoicePosition $p): bool => $p->isBrokered(), - includeStay: $this->portalCollectedThePayment($invoice), - ), - static fn (Reservation $r): ?string => $r->getPaymentFeePercent() - ?? $r->getReservationOrigin()?->getPaymentFeePercent(), - ), + $this->paymentFee($invoice, $shown), ); } @@ -117,13 +107,44 @@ private function baseOf(Invoice $invoice, callable $keep, bool $includeStay): fl } /** - * Whether the portal took the money for the stay itself. + * The payment fee, which unlike the commission depends on who took the + * money: a portal charges it for processing a payment, so a stay the house + * was paid for directly carries none. + * + * Where the invoice's reservations were settled differently the stay has no + * single answer, and the fee is marked as having no single base rather than + * quietly leaving the whole stay out. Dropping it silently books too little + * and reads as if that were the figure. + */ + private function paymentFee(Invoice $invoice, ?Reservation $shown): OriginFee + { + $collectedByPortal = $this->portalCollectedThePayment($invoice); + + return $this->fee( + $invoice, + $shown, + $this->baseOf( + $invoice, + static fn (InvoicePosition $p): bool => $p->isBrokered(), + // Undecided counts as not collected here: the base is only used + // where the caller has accepted it, and a figure nobody may use + // is better too small than too large. + includeStay: true === $collectedByPortal, + ), + static fn (Reservation $r): ?string => $r->getPaymentFeePercent() + ?? $r->getReservationOrigin()?->getPaymentFeePercent(), + baseIsOne: null !== $collectedByPortal, + ); + } + + /** + * Whether the portal took the money for the stay itself - null where the + * invoice's reservations disagree about it. * * Every reservation has to say so, and one without an origin never does. An * invoice mixing a portal booking with a direct one has no single answer, - * and charging a payment fee on a stay the house was paid for directly is - * the error worth avoiding - the other way round it costs the house nothing - * it cannot correct. + * and an invoice carries no attribution of its lines to reservations to + * split the stay along. * * What was recorded on the reservation wins over what its origin says today, * as with the rates: a portal that starts collecting payments must not @@ -131,23 +152,24 @@ private function baseOf(Invoice $invoice, callable $keep, bool $includeStay): fl * origin answers, and where there is no origin either, nobody but the house * took anything. */ - private function portalCollectedThePayment(Invoice $invoice): bool + private function portalCollectedThePayment(Invoice $invoice): ?bool { $reservations = $invoice->getReservations() ?? new ArrayCollection(); if (0 === count($reservations)) { return false; } + $answers = []; foreach ($reservations as $reservation) { $collection = $reservation->getPaymentCollection() ?? $reservation->getReservationOrigin()?->getPaymentCollection(); - if (null === $collection || !$collection->isPortal()) { - return false; - } + $answers[] = null !== $collection && $collection->isPortal(); } - return true; + $answers = array_unique($answers); + + return 1 === count($answers) ? reset($answers) : null; } /** @@ -163,7 +185,7 @@ private function portalCollectedThePayment(Invoice $invoice): bool * * @param callable(Reservation): ?string $rateOf */ - private function fee(Invoice $invoice, ?Reservation $shown, float $base, callable $rateOf): OriginFee + private function fee(Invoice $invoice, ?Reservation $shown, float $base, callable $rateOf, bool $baseIsOne = true): OriginFee { $rates = []; foreach ($invoice->getReservations() ?? [] as $reservation) { @@ -178,7 +200,7 @@ private function fee(Invoice $invoice, ?Reservation $shown, float $base, callabl ? reset($rates) : (null !== $shown ? $this->toPercent($rateOf($shown)) : 0.0); - return new OriginFee($percent, $base, $rates); + return new OriginFee($percent, $base, $rates, $baseIsOne); } /** diff --git a/src/Workflow/Action/CreatePercentageEntryAction.php b/src/Workflow/Action/CreatePercentageEntryAction.php index 2d92cebc..fef238d2 100644 --- a/src/Workflow/Action/CreatePercentageEntryAction.php +++ b/src/Workflow/Action/CreatePercentageEntryAction.php @@ -303,6 +303,14 @@ private function resolveFee(array $config, Invoice $invoice): OriginFee ])); } + // Same again for what the fee is charged on: an invoice whose stays were + // settled partly through the portal and partly with the house gives the + // payment fee no single base. Booking it on what is left would quietly + // take the stay out of the figure and look like a correct deduction. + if (!$fee->hasOneBase()) { + throw new WorkflowSkippedException($this->translator->trans('workflow.log.skipped_mixed_collection')); + } + return $fee; } diff --git a/tests/Unit/OriginFeeCalculatorTest.php b/tests/Unit/OriginFeeCalculatorTest.php index fabdc86e..7d313852 100644 --- a/tests/Unit/OriginFeeCalculatorTest.php +++ b/tests/Unit/OriginFeeCalculatorTest.php @@ -206,17 +206,51 @@ public function testWhatWasRecordedOnTheBookingBeatsWhatTheOriginSaysToday(): vo self::assertSame(0.0, $fees->paymentFee->base); } - public function testOneStayPaidToTheHouseSettlesItForTheWholeInvoice(): void + public function testAnInvoiceSettledBothWaysStatesNoPaymentFeeBase(): void { - // The base covers the invoice, not a single stay, and charging a payment - // fee on money the portal never saw is the error worth avoiding. + // One stay paid through the portal, one paid to the house. The invoice + // carries no attribution of its lines to reservations, so the stay + // cannot be split - and taking it out entirely books too little while + // reading like a correct deduction. $invoice = $this->invoiceCollectedBy(PaymentCollection::PORTAL); $invoice->addReservation(new Reservation()); $invoice->addAppartment($this->stay(200.00)); $fees = $this->calculate($invoice); - self::assertSame(0.0, $fees->paymentFee->base); + self::assertFalse($fees->paymentFee->hasOneBase()); + self::assertFalse($fees->paymentFee->isSettled()); + self::assertSame(0.0, $fees->paymentFee->base, 'the unusable base stays on the cautious side'); + } + + public function testAgreementOnHowItWasSettledIsEnoughForABase(): void + { + // Two portal bookings on one invoice agree, whatever else differs. + $invoice = $this->invoiceCollectedBy(PaymentCollection::PORTAL); + $second = $this->reservationWithOrigin('Booking.com', '12.00', '1.40'); + $second->setPaymentCollection(PaymentCollection::PORTAL); + $invoice->addReservation($second); + $invoice->addAppartment($this->stay(200.00)); + + $fees = $this->calculate($invoice); + + self::assertTrue($fees->paymentFee->hasOneBase()); + self::assertTrue($fees->paymentFee->isSettled()); + self::assertSame(200.00, $fees->paymentFee->base); + } + + public function testTheCommissionIsUnaffectedByWhoTookTheMoney(): void + { + // Commission is charged on what was brokered, not on what was + // processed, so a mixed settlement leaves it stateable. + $invoice = $this->invoiceCollectedBy(PaymentCollection::PORTAL); + $invoice->addReservation(new Reservation()); + $invoice->addAppartment($this->stay(200.00)); + + $fees = $this->calculate($invoice); + + self::assertTrue($fees->commission->hasOneBase()); + self::assertSame(200.00, $fees->commission->base); } // ── rates that disagree ────────────────────────────────────────── diff --git a/tests/Unit/Workflow/CreatePercentageEntryActionTest.php b/tests/Unit/Workflow/CreatePercentageEntryActionTest.php index 6773aef1..ddcacfe8 100644 --- a/tests/Unit/Workflow/CreatePercentageEntryActionTest.php +++ b/tests/Unit/Workflow/CreatePercentageEntryActionTest.php @@ -6,6 +6,7 @@ use App\Entity\AccountingAccount; use App\Entity\BookingEntry; +use App\Entity\Enum\PaymentCollection; use App\Entity\Invoice; use App\Entity\InvoicePosition; use App\Entity\Reservation; @@ -220,6 +221,48 @@ public function testSkipsWhenAPortalBookingSharesTheInvoiceWithADirectOne(): voi $action->execute($config, $invoice, []); } + public function testSkipsWhenTheStaysOnOneInvoiceWereSettledDifferently(): void + { + // One stay paid through the portal, one paid to the house. Both were + // brokered at the same rate, so the rate is not the problem - what the + // payment fee is charged on is, and the invoice cannot say. + $action = $this->makeAction(gross: 115.20); + + $throughPortal = $this->reservation(commission: '12', paymentFee: '1.4'); + $throughPortal->setPaymentCollection(PaymentCollection::PORTAL); + + $toTheHouse = $this->reservation(commission: '12', paymentFee: '1.4'); + $toTheHouse->setPaymentCollection(PaymentCollection::PROPERTY); + + $invoice = $this->invoiceWithReservations($throughPortal, $toTheHouse); + + $config = $this->config(['percent' => '', 'percentSource' => CreatePercentageEntryAction::PERCENT_SOURCE_PAYMENT_FEE]); + + $this->expectException(WorkflowSkippedException::class); + $action->execute($config, $invoice, []); + } + + public function testTheCommissionIsStillBookedWhereOnlyTheSettlementDiffers(): void + { + // Commission is charged on what was brokered, not on what was + // processed, so it is unaffected and must not be held back with it. + $captured = null; + $action = $this->makeAction(gross: 115.20, capture: $captured); + + $throughPortal = $this->reservation(commission: '12', paymentFee: '1.4'); + $throughPortal->setPaymentCollection(PaymentCollection::PORTAL); + + $toTheHouse = $this->reservation(commission: '12', paymentFee: '1.4'); + $toTheHouse->setPaymentCollection(PaymentCollection::PROPERTY); + + $invoice = $this->invoiceWithReservations($throughPortal, $toTheHouse); + + $config = $this->config(['percent' => '', 'percentSource' => CreatePercentageEntryAction::PERCENT_SOURCE_COMMISSION]); + $action->execute($config, $invoice, []); + + self::assertSame('13.82', $captured['amount']); + } + public function testSkipsWhenTwoBookingsFromOnePortalCarryDifferentPinnedRates(): void { // Same portal, but the contract changed between the two bookings. diff --git a/translations/Workflow/messages.de.yaml b/translations/Workflow/messages.de.yaml index 747f2660..f64247b6 100644 --- a/translations/Workflow/messages.de.yaml +++ b/translations/Workflow/messages.de.yaml @@ -202,6 +202,7 @@ workflow: skipped_no_amounts: "Übersprungen: Rechnung enthält keine buchbaren Beträge" skipped_no_percentage: "Übersprungen: kein gültiger Prozentsatz konfiguriert" skipped_mixed_rates: "Übersprungen: die Reservierungen dieser Rechnung wurden zu unterschiedlichen Sätzen gebucht (%rates%). Ein einzelner Abzug kann sie nicht abbilden – bitte von Hand buchen." + skipped_mixed_collection: "Übersprungen: auf dieser Rechnung wurde ein Teil der Aufenthalte über das Portal bezahlt, ein anderer direkt an das Haus. Worauf die Zahlungsgebühr entfällt, lässt sich daraus nicht ableiten – bitte von Hand buchen." percentage_entry_created: "Buchung über %amount% (%percent% % von %base%) für Rechnung %number% erstellt" skipped_invalid_config: "Übersprungen: ungültige Konfiguration" skipped_status_not_found: "Übersprungen: Reservierungsstatus nicht gefunden" diff --git a/translations/Workflow/messages.en.yaml b/translations/Workflow/messages.en.yaml index 904b8d7a..8393c53c 100644 --- a/translations/Workflow/messages.en.yaml +++ b/translations/Workflow/messages.en.yaml @@ -197,6 +197,7 @@ workflow: skipped_no_amounts: "Skipped: invoice contains no bookable amounts" skipped_no_percentage: "Skipped: no valid percentage configured" skipped_mixed_rates: "Skipped: the reservations on this invoice were booked at different rates (%rates%). A single deduction cannot represent them - please book it by hand." + skipped_mixed_collection: "Skipped: some stays on this invoice were paid through the portal and others directly to the property. What the payment fee is charged on does not follow from that - please book it by hand." percentage_entry_created: "Entry of %amount% (%percent% % of %base%) created for invoice %number%" skipped_invalid_config: "Skipped: invalid configuration" skipped_status_not_found: "Skipped: reservation status not found" From bb65a778affc3cd340c73862b8d65790b4dd301e Mon Sep 17 00:00:00 2001 From: MeisterAdebar Date: Tue, 15 Sep 2026 18:06:47 +0200 Subject: [PATCH 27/33] Leave the fee off the invoice where the journal will not book it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The placeholders took the first reservation that carried an origin and applied its rate to the whole invoice. The journal refuses exactly that invoice, so the guest could be shown a commission the accounts never recorded - and the one thing this calculator exists for is that the two cannot drift apart. They now print nothing where the invoice does not yield one figure: rates that disagree, or stays settled partly through the portal and partly with the house. The portal is still named, so a template can say whose fees are missing rather than falling silent altogether. The ready-made snippets guard on the amount rather than printing the currency sign on its own, which is what an empty figure next to a hard-coded "€" would look like. That also covers the invoice with no portal behind it at all. --- src/Service/InvoiceService.php | 37 ++++- .../InvoiceTemplatePreviewProvider.php | 6 +- .../InvoiceServiceOriginPlaceholderTest.php | 148 ++++++++++++++++++ 3 files changed, 181 insertions(+), 10 deletions(-) create mode 100644 tests/Unit/InvoiceServiceOriginPlaceholderTest.php diff --git a/src/Service/InvoiceService.php b/src/Service/InvoiceService.php index 01919022..eb1d2d70 100644 --- a/src/Service/InvoiceService.php +++ b/src/Service/InvoiceService.php @@ -13,6 +13,7 @@ namespace App\Service; +use App\Dto\OriginFee; use App\Dto\TouristTaxBreakdown; use App\Entity\Enum\ModifierType; use App\Entity\Enum\TaxCalculationMode; @@ -215,14 +216,24 @@ public function buildTemplateRenderParams(Template $template, Invoice $invoice): // reservation's origin, worked out by OriginFeeCalculator - the same // one the deduction is booked from, so the guest is shown what the // journal records. Amounts are zero (not null) when no origin - // applies; originName is null then, so a template can guard with a - // plain [% if originName %]. A total, if wanted, is originCommission - // + originPaymentFee - left to the template rather than provided. + // applies, and originName is null then. A total, if wanted, is + // originCommission + originPaymentFee - left to the template rather + // than provided. + // + // Null where the invoice does not yield one figure: reservations + // taken at different rates, or stays settled partly through the + // portal and partly with the house. The journal refuses such an + // invoice too (see CreatePercentageEntryAction), and the two must + // not disagree - printing the first reservation's rate across the + // whole invoice would state a figure nobody can stand behind. The + // portal is still named, so a template can say what it cannot say - + // which is also why a template printing an amount has to guard on + // the amount itself, not on originName. 'originName' => $originFees->originName, - 'originCommission' => $originFees->commission->amount, - 'originCommissionFormated' => number_format($originFees->commission->amount, 2, ',', '.'), - 'originPaymentFee' => $originFees->paymentFee->amount, - 'originPaymentFeeFormated' => number_format($originFees->paymentFee->amount, 2, ',', '.'), + 'originCommission' => $this->settledAmount($originFees->commission), + 'originCommissionFormated' => $this->settledAmountFormatted($originFees->commission), + 'originPaymentFee' => $this->settledAmount($originFees->paymentFee), + 'originPaymentFeeFormated' => $this->settledAmountFormatted($originFees->paymentFee), ]; return $params; @@ -602,6 +613,18 @@ public function buildTouristTaxPositions(array $reservations): array return array_map(fn (TouristTaxBreakdown $row): InvoicePosition => $this->makeTouristTaxPosition($row, $brokered), array_values($aggregates)); } + /** The fee's amount, or null where the invoice does not state one. */ + private function settledAmount(OriginFee $fee): ?float + { + return $fee->isSettled() ? $fee->amount : null; + } + + /** The same figure ready to print; an empty string where there is none. */ + private function settledAmountFormatted(OriginFee $fee): string + { + return $fee->isSettled() ? number_format($fee->amount, 2, ',', '.') : ''; + } + /** * Whether the portal collects the tourist tax for these reservations, which * decides whether its payment fee is charged on it. Commission is not at diff --git a/src/Service/TemplatePreview/InvoiceTemplatePreviewProvider.php b/src/Service/TemplatePreview/InvoiceTemplatePreviewProvider.php index fafcaddf..ddb06c53 100644 --- a/src/Service/TemplatePreview/InvoiceTemplatePreviewProvider.php +++ b/src/Service/TemplatePreview/InvoiceTemplatePreviewProvider.php @@ -270,21 +270,21 @@ public function getAvailableSnippets(): array 'label' => 'templates.editor.origin_name', 'group' => 'Invoice', 'complexity' => 'simple', - 'content' => '[[ originName ]]', + 'content' => '[[ originName ]]', ], [ 'id' => 'invoice.origin_commission', 'label' => 'templates.editor.origin_commission', 'group' => 'Invoice', 'complexity' => 'simple', - 'content' => '[[ originCommissionFormated ]] €', + 'content' => '[[ originCommissionFormated ]] €', ], [ 'id' => 'invoice.origin_payment_fee', 'label' => 'templates.editor.origin_payment_fee', 'group' => 'Invoice', 'complexity' => 'simple', - 'content' => '[[ originPaymentFeeFormated ]] €', + 'content' => '[[ originPaymentFeeFormated ]] €', ], [ 'id' => 'pdf.header', diff --git a/tests/Unit/InvoiceServiceOriginPlaceholderTest.php b/tests/Unit/InvoiceServiceOriginPlaceholderTest.php new file mode 100644 index 00000000..13f7af8f --- /dev/null +++ b/tests/Unit/InvoiceServiceOriginPlaceholderTest.php @@ -0,0 +1,148 @@ +invoice($this->reservation('12.00', '1.40', PaymentCollection::PORTAL)); + + $params = $this->service()->buildTemplateRenderParams(new Template(), $invoice); + + self::assertSame('Booking.com', $params['originName']); + self::assertSame(24.00, $params['originCommission']); + self::assertSame('24,00', $params['originCommissionFormated']); + self::assertSame(2.80, $params['originPaymentFee']); + self::assertSame('2,80', $params['originPaymentFeeFormated']); + } + + public function testPrintsNoFigureWhereTheReservationsWereBookedAtDifferentRates(): void + { + // The journal skips such an invoice. Printing the first reservation's + // rate across the whole of it would put a number on the invoice that + // nothing else in the system agrees with. + $invoice = $this->invoice( + $this->reservation('12.00', '1.40', PaymentCollection::PORTAL), + $this->reservation('18.00', '2.50', PaymentCollection::PORTAL), + ); + + $params = $this->service()->buildTemplateRenderParams(new Template(), $invoice); + + self::assertNull($params['originCommission']); + self::assertSame('', $params['originCommissionFormated']); + // The portal is still named, so a template can say whose fees these are. + self::assertSame('Booking.com', $params['originName']); + } + + public function testPrintsNoPaymentFeeWhereTheStaysWereSettledDifferently(): void + { + $invoice = $this->invoice( + $this->reservation('12.00', '1.40', PaymentCollection::PORTAL), + $this->reservation('12.00', '1.40', PaymentCollection::PROPERTY), + ); + + $params = $this->service()->buildTemplateRenderParams(new Template(), $invoice); + + self::assertNull($params['originPaymentFee']); + self::assertSame('', $params['originPaymentFeeFormated']); + } + + public function testTheCommissionStillPrintsWhereOnlyTheSettlementDiffers(): void + { + // Commission is charged on what the portal brokered, which both stays + // were - who took the money does not come into it. + $invoice = $this->invoice( + $this->reservation('12.00', '1.40', PaymentCollection::PORTAL), + $this->reservation('12.00', '1.40', PaymentCollection::PROPERTY), + ); + + $params = $this->service()->buildTemplateRenderParams(new Template(), $invoice); + + self::assertSame(24.00, $params['originCommission']); + } + + private function invoice(Reservation ...$reservations): Invoice + { + $invoice = new Invoice(); + $invoice->setNumber('T1'); + $invoice->setDate(new \DateTime('2026-06-21')); + + foreach ($reservations as $reservation) { + $invoice->addReservation($reservation); + } + + $stay = new InvoiceAppartment(); + $stay->setDescription('Doppelzimmer'); + $stay->setNumber('1'); + $stay->setStartDate(new \DateTime('2026-06-19')); + $stay->setEndDate(new \DateTime('2026-06-21')); + $stay->setPersons(2); + $stay->setBeds(2); + $stay->setPrice(200.00); + $stay->setVat(7.0); + $stay->setIncludesVat(true); + $stay->setIsFlatPrice(true); + $invoice->addAppartment($stay); + + return $invoice; + } + + private function reservation(string $commission, string $paymentFee, PaymentCollection $collection): Reservation + { + $origin = new ReservationOrigin(); + $origin->setName('Booking.com'); + $origin->setCommissionPercent($commission); + $origin->setPaymentFeePercent($paymentFee); + $origin->setPaymentCollection($collection); + + $reservation = new Reservation(); + $reservation->setReservationOrigin($origin); + + return $reservation; + } + + private function service(): InvoiceService + { + $translator = $this->createStub(TranslatorInterface::class); + $translator->method('trans')->willReturnCallback(static fn (string $id): string => $id); + + $appSettingsService = $this->createStub(AppSettingsService::class); + $appSettingsService->method('getSettings')->willReturn(new AppSettings()); + + return new InvoiceService( + $this->createStub(EntityManagerInterface::class), + $this->createStub(PriceService::class), + $translator, + $appSettingsService, + new InvoiceSumCalculator(), + new OriginFeeCalculator(new InvoiceSumCalculator()), + ); + } +} From d6bd0ec814032f10c76bcfa801bf2bb7c1a52129 Mon Sep 17 00:00:00 2001 From: MeisterAdebar Date: Tue, 15 Sep 2026 18:23:20 +0200 Subject: [PATCH 28/33] Keep the preset test from depending on the order the suite runs in The test clears all tax rates and accounts before loading a preset. Entries booked by other tests reference both, and whether any exist by the time this one runs depends on the order PHPUnit happens to walk the directory in - which changes when a test file is added. It fell over on a foreign key today for exactly that reason. The entries now go first, which is what the test meant by starting from nothing. --- tests/Functional/BookingJournalControllerTest.php | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/Functional/BookingJournalControllerTest.php b/tests/Functional/BookingJournalControllerTest.php index db3ac8bc..b68eec69 100644 --- a/tests/Functional/BookingJournalControllerTest.php +++ b/tests/Functional/BookingJournalControllerTest.php @@ -57,7 +57,11 @@ public function testLoadPresetCreatesAccountsAndTaxRates(): void $client = static::createClient(); $client->loginUser($this->createCashJournalUser()); - // Clear any existing accounts and tax rates from previous test runs + // Clear any existing accounts and tax rates from previous test runs. + // Entries booked by earlier tests reference both, and whether any exist + // by now depends on the order the suite happens to run in - so they go + // first rather than leaving this test to fail on a foreign key. + $this->getEntityManager()->createQuery('DELETE FROM App\Entity\BookingEntry')->execute(); $this->getEntityManager()->createQuery('DELETE FROM App\Entity\TaxRate')->execute(); $this->getEntityManager()->createQuery('DELETE FROM App\Entity\AccountingAccount')->execute(); From 5bd695e62153fcb95f885e12b6872313e37814b5 Mon Sep 17 00:00:00 2001 From: MeisterAdebar Date: Tue, 15 Sep 2026 18:34:42 +0200 Subject: [PATCH 29/33] Admit that a reservation may have no origin The getter was annotated as returning a ReservationOrigin, while a booking that came in directly has none - which the fee calculation leans on rather than treats as an edge case. Static analysis believed the annotation, so every null check around the getter read as dead code to it, and a real one added here would have been reported as a mistake. Correcting the annotation removes seven findings in the files that call it and adds none. --- src/Entity/Reservation.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/Entity/Reservation.php b/src/Entity/Reservation.php index a7ff0765..235cfd87 100644 --- a/src/Entity/Reservation.php +++ b/src/Entity/Reservation.php @@ -440,7 +440,10 @@ public function setTouristTaxCollection(?PaymentCollection $touristTaxCollection /** * Get reservationOrigin. * - * @return ReservationOrigin + * Null for a booking that came in directly - the field is optional, and the + * fee calculation leans on that. + * + * @return ReservationOrigin|null */ public function getReservationOrigin() { From 3c69c89856177ed414e7b81f35949a1847a5d2ab Mon Sep 17 00:00:00 2001 From: MeisterAdebar Date: Tue, 15 Sep 2026 18:34:42 +0200 Subject: [PATCH 30/33] Say plainly what the calculator does with an invoice that disagrees with itself Three docblocks still described the arrangement from before the invoice stopped printing a figure it cannot stand behind: the journal refuses, the guest is shown the first reservation's rate anyway. Both callers stop now, so the comments said the opposite of what the code does - the kind of contradiction that costs a reader more than no comment at all. Whether the invoice agrees on who took the money is asked outright instead of through array_unique() over booleans, which worked by casting them to strings. --- src/Dto/OriginFee.php | 10 +++++----- src/Service/OriginFeeCalculator.php | 24 ++++++++++++------------ 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/src/Dto/OriginFee.php b/src/Dto/OriginFee.php index b012f13e..62c00f1c 100644 --- a/src/Dto/OriginFee.php +++ b/src/Dto/OriginFee.php @@ -8,11 +8,11 @@ * One fee a portal charges for a booking - a commission or a payment fee - * with the amount it comes to and everything needed to judge that amount. * - * The rates found on the invoice come along because the two callers disagree on - * what to do when they disagree: the journal refuses to book an invoice whose - * reservations were taken under different rates, while an invoice shown to a - * guest names the first of them rather than nothing at all. Deciding that here - * would force one of those answers onto both. + * The rates found on the invoice come along so that a caller refusing such an + * invoice can name them: the journal says in its log which rates it found + * rather than only that it gave up. Whether an amount can be stated at all is + * decided here, by isSettled(), so that the journal and the invoice cannot + * answer it differently. */ final readonly class OriginFee { diff --git a/src/Service/OriginFeeCalculator.php b/src/Service/OriginFeeCalculator.php index c1aa41f2..d279202c 100644 --- a/src/Service/OriginFeeCalculator.php +++ b/src/Service/OriginFeeCalculator.php @@ -167,21 +167,21 @@ private function portalCollectedThePayment(Invoice $invoice): ?bool $answers[] = null !== $collection && $collection->isPortal(); } - $answers = array_unique($answers); + $portal = in_array(true, $answers, true); + $property = in_array(false, $answers, true); - return 1 === count($answers) ? reset($answers) : null; + return $portal && $property ? null : $portal; } /** * One fee, at the rate that holds for the invoice. * - * Which rate that is has two answers, and both are needed. Where every - * reservation agrees, that agreed rate is it - including the case of an - * invoice with no reservations at all, which yields nothing to book. Where - * they disagree, the rate is the one of the reservation the figures are - * shown for; the journal refuses such an invoice anyway (see - * OriginFee::isAgreedUpon), while the guest is shown a figure rather than a - * blank. + * Where every reservation agrees, that agreed rate is it - including the + * case of an invoice with no reservations at all, which yields nothing to + * book. Where they disagree there is no such rate, and the fee says so + * through OriginFee::isAgreedUpon(); what it carries then is the rate of + * the reservation the figures belong to, which no caller may state as the + * invoice's own but which keeps the amount from being an arbitrary zero. * * @param callable(Reservation): ?string $rateOf */ @@ -207,9 +207,9 @@ private function fee(Invoice $invoice, ?Reservation $shown, float $base, callabl * The reservation whose portal and rates the invoice shows. * * The first one that came through a portal charging anything. An invoice can - * hold several - which is a disagreement the journal stops at, but a note to - * the guest names the first rather than staying silent about a surcharge - * they did pay. + * hold several, which is a disagreement both callers stop at - but the + * portal is still named, so an invoice can say whose fees it cannot state + * rather than staying silent about them altogether. * * A reservation whose rates are both zero is passed over on purpose: an * origin exists for direct bookings too, and naming one that costs nothing From 048ee572bc8852929ea2e04b7217cf66829f04ff Mon Sep 17 00:00:00 2001 From: MeisterAdebar Date: Sat, 19 Sep 2026 14:31:18 +0200 Subject: [PATCH 31/33] Build the price service the way 4.12.0-dev does PriceService gained a ReservationPeriodService in its constructor there, and this test still built it with the entity manager alone. The neighbouring price tests hand it the real service, which is final and does no I/O, so this one does the same. --- tests/Unit/PriceServiceBrokeredTest.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/Unit/PriceServiceBrokeredTest.php b/tests/Unit/PriceServiceBrokeredTest.php index 2c293060..3820bd37 100644 --- a/tests/Unit/PriceServiceBrokeredTest.php +++ b/tests/Unit/PriceServiceBrokeredTest.php @@ -6,6 +6,7 @@ use App\Entity\ReservationOrigin; use App\Service\PriceService; +use App\Service\ReservationPeriodService; use Doctrine\ORM\EntityRepository; use Doctrine\ORM\Mapping\ClassMetadata; use Doctrine\ORM\EntityManagerInterface; @@ -72,7 +73,7 @@ public function findAll(): array $em = $this->createStub(EntityManagerInterface::class); $em->method('getRepository')->willReturn($repository); - $service = new PriceService($em); + $service = new PriceService($em, new ReservationPeriodService()); return $service->getPriceFromForm(new Request([], $params), 'new'); } From c9c92a62d7bc08eccebafc916f59183694c81502 Mon Sep 17 00:00:00 2001 From: MeisterAdebar Date: Sat, 19 Sep 2026 14:31:18 +0200 Subject: [PATCH 32/33] Let the panel test look at entries, not at the mark-all-read button The test walked every .dropdown-item carrying a data-url and required it to open the shared modal. The button that marks everything as read matches that too, so the test only passed while the panel held no notifications at all - which is what the suite happens to arrange until this branch adds tests that leave some behind. It now looks at the links, which are the entries it is about. --- tests/Functional/NotificationBellTest.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/Functional/NotificationBellTest.php b/tests/Functional/NotificationBellTest.php index 89a0af79..64327aa4 100644 --- a/tests/Functional/NotificationBellTest.php +++ b/tests/Functional/NotificationBellTest.php @@ -74,7 +74,9 @@ public function testConflictEntriesLinkAtTheExistingModal(): void self::assertResponseIsSuccessful(); // Whatever is present must open through the shared modal, never navigate. - foreach ($crawler->filter('.dropdown-item[data-url]') as $node) { + // Entries are links; the "mark all read" button below them carries a + // data-url of its own and is not one. + foreach ($crawler->filter('a.dropdown-item[data-url]') as $node) { self::assertSame( 'click->notifications#openItemAction', $node->getAttribute('data-action'), From 690814ab4cffb423140c5e1c22f8e736b6f8faa1 Mon Sep 17 00:00:00 2001 From: MeisterAdebar Date: Sat, 19 Sep 2026 14:35:00 +0200 Subject: [PATCH 33/33] Note the portal fees in the release notes The branch was built against master, where docs/release-notes does not exist. On 4.12.0-dev it does, and every user-visible change belongs in it. --- docs/release-notes/4.12.0.de.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/docs/release-notes/4.12.0.de.md b/docs/release-notes/4.12.0.de.md index 78173db1..d0f4c47e 100644 --- a/docs/release-notes/4.12.0.de.md +++ b/docs/release-notes/4.12.0.de.md @@ -11,6 +11,16 @@ * **Zeitgesteuerte Automatisierungen** können täglich, Montag–Freitag oder Montag–Samstag ab einer gewählten vollen Stunde laufen. Ausgeschlossene Tage werden am nächsten erlaubten Tag nachgeholt. Bestehende Regeln bleiben bei täglich ab 00:00 Uhr; der Cronjob prüft weiterhin viertelstündlich und führt sie beim ersten passenden Durchlauf aus. * Beim Versand oder Anhängen einer Rechnung kannst du eine **PDF-Rechnungsvorlage** auswählen – auch für Anhänge an Vorlagen-E-Mails. Ohne Auswahl gilt die Standardvorlage. +## 🏷️ Portalgebühren je Buchungsherkunft + +* Bei einer Buchungsherkunft hinterlegst du **Kommission und Zahlungsgebühr in Prozent** sowie, wer die Zahlung und wer die Kurtaxe einzieht. Beim Anlegen einer Reservierung werden diese Angaben mitgeschrieben, damit ein später geänderter Vertrag nicht rückwirkend ändert, was für ältere Buchungen gilt. Beitrag von @MeisterAdebar ([#293](https://github.com/developeregrem/fewohbee/pull/293)). +* Die neue Automatisierung **„Prozentualen Buchungseintrag erstellen“** bucht den Abzug ins Buchungsjournal – wahlweise mit dem Satz aus der Herkunft oder einem selbst eingetragenen. Soll- und Habenkonto, Steuersatz und Bemerkung legst du an der Automatisierung fest; `%number%` wird durch die Rechnungsnummer ersetzt. +* Für Rechnungsvorlagen stehen die Bausteine **Buchungsherkunft, OTA-Kommission und OTA-Zahlungsgebühr** bereit. Dieselbe Berechnung liefert die Beträge für Rechnung und Buchung, damit beides zusammenpasst. +* Worauf die Gebühren entfallen, steht an den Rechnungspositionen. Am Preis gibt der Schalter **„Teil einer Portalbuchung“** vor, was an der Rezeption verkauft wird und damit keine Gebühren trägt; an einer von Hand hinzugefügten Position lässt sich das ändern. Eine separat ausgewiesene Kurtaxe bleibt kommissionsfrei, sofern sie auch beim Portal getrennt eingerichtet ist. +* Sind auf einer Rechnung unterschiedliche Sätze im Spiel oder wurden die Aufenthalte teils über das Portal und teils direkt bezahlt, bucht die Automatisierung nichts und schreibt den Grund ins Protokoll; die Rechnung lässt die Beträge dann weg. So steht auf der Rechnung nie eine Zahl, die im Journal fehlt. +* Eine Buchung kann als **„wartet auf Belegnummer“** markiert werden. Solange sie fehlt, lässt sich der Monat nicht abschließen. Konten und Steuersätze, mit denen eine Automatisierung bucht, lassen sich nicht mehr löschen. +* ⚠️ **Beim Update:** Bestehende Buchungsherkünfte tragen keine Gebühren, es ändert sich also zunächst nichts. Herkünfte mit Zahlungsgebühr gelten künftig als „Zahlung zieht das Portal ein“. Kurtaxe-Positionen auf bereits geschriebenen Rechnungen gelten als vom Haus kassiert und kommissionsfrei. + ## 💳 Zahlungs-QR-Code und Rechnungsvorlagen * Der neue Baustein **„Zahlungs-QR-Code (GiroCode)“** übernimmt Empfänger, IBAN, Betrag und Rechnungsnummer für eine SEPA-Überweisung in die Banking-App. Größe und Position sind anpassbar. Er erscheint nur mit hinterlegter Bankverbindung, übertragbarem Betrag und Euro als Währung. [Anleitung und Beispiel](https://github.com/developeregrem/fewohbee/wiki/Templates-Invoice) · Beitrag von @MeisterAdebar ([#280](https://github.com/developeregrem/fewohbee/pull/280)).