diff --git a/assets/controllers/invoices_controller.js b/assets/controllers/invoices_controller.js index c8900361..5dbd0c22 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/assets/controllers/prices_controller.js b/assets/controllers/prices_controller.js index 86cc8b9a..ff047743 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/assets/controllers/settings_controller.js b/assets/controllers/settings_controller.js index e403a3d0..00951d99 100644 --- a/assets/controllers/settings_controller.js +++ b/assets/controllers/settings_controller.js @@ -77,6 +77,15 @@ export default class extends Controller { } } + // Show or hide a container based on a checkbox, e.g. the portal-fee fields + // that only apply once an origin is marked as charging them. + toggleFieldsAction(event) { + const targetSelector = event.currentTarget.dataset.targetSelector; + if (!targetSelector) return; + const target = document.querySelector(targetSelector); + if (target) target.classList.toggle('d-none', !event.currentTarget.checked); + } + submitFormAction(event) { event.preventDefault(); const form = event.target.closest('form'); 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)). 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/migrations/Version20260720170000.php b/migrations/Version20260720170000.php new file mode 100644 index 00000000..c64cc5e5 --- /dev/null +++ b/migrations/Version20260720170000.php @@ -0,0 +1,31 @@ +addSql('ALTER TABLE reservation_origins 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 reservation_origins DROP commission_percent, DROP payment_fee_percent'); + } + + public function isTransactional(): bool + { + return false; + } +} 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/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/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/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/Controller/BookingJournalController.php b/src/Controller/BookingJournalController.php index 8118726e..6f08e9dd 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')] @@ -184,6 +185,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 +200,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(); @@ -258,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/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/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/Controller/ReservationOriginServiceController.php b/src/Controller/ReservationOriginServiceController.php index 8990ccb8..513f76bf 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(), ]); } @@ -85,6 +86,9 @@ public function createAction(ManagerRegistry $doctrine, CSRFProtectionService $c if (0 == strlen($origin->getName())) { $error = true; $this->addFlash('warning', 'flash.mandatory'); + } elseif (null !== ($surchargeError = $ros->findSurchargeValueError($request, 'new', $origin))) { + $error = true; + $this->addFlash('warning', $surchargeError); } else { $em = $doctrine->getManager(); $em->persist($origin); @@ -117,7 +121,12 @@ public function editAction(ManagerRegistry $doctrine, CSRFProtectionService $csr $error = true; $this->addFlash('warning', 'flash.mandatory'); // stop auto commit of doctrine with invalid field values - $em->clear(ReservationOrigin::class); + $em->clear(); + } elseif (null !== ($surchargeError = $ros->findSurchargeValueError($request, $id, $origin))) { + $error = true; + $this->addFlash('warning', $surchargeError); + // stop auto commit of doctrine with invalid field values + $em->clear(); } else { $em->persist($origin); $em->flush(); diff --git a/src/Controller/WorkflowController.php b/src/Controller/WorkflowController.php index e1957837..2520f7de 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\Repository\WorkflowLogRepository; use App\Repository\WorkflowRepository; use App\Service\AppSettingsService; @@ -41,6 +42,7 @@ public function __construct( private readonly AppSettingsService $settingsService, private readonly AccountingSettingsService $accountingSettingsService, private readonly AccountingAccountRepository $accountRepo, + private readonly TaxRateRepository $taxRateRepo, private readonly DisplayNameResolver $displayNameResolver, ) { } @@ -228,6 +230,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. @@ -334,6 +339,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/Dto/OriginFee.php b/src/Dto/OriginFee.php new file mode 100644 index 00000000..62c00f1c --- /dev/null +++ b/src/Dto/OriginFee.php @@ -0,0 +1,83 @@ + $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); + } + + /** + * 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; + } + + /** + * 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 + { + 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 @@ + 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/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; @@ -208,4 +242,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 59c4378a..da490cf4 100644 --- a/src/Entity/Price.php +++ b/src/Entity/Price.php @@ -74,6 +74,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')] @@ -425,6 +439,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 55d45346..aa714b83 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; @@ -46,6 +47,49 @@ 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; + + /** + * 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; + + /** + * 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)] @@ -294,15 +338,112 @@ 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()); + // 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->touristTaxCollection = $chargesFees ? $reservationOrigin?->getTouristTaxCollection() : null; + } + $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; + } + + /** + * 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; + } + + /** + * 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. * - * @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() { diff --git a/src/Entity/ReservationOrigin.php b/src/Entity/ReservationOrigin.php index f3416818..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; @@ -21,6 +22,47 @@ class ReservationOrigin #[ORM\Column(type: 'string', length: 7, nullable: true)] #[Assert\Regex('/^#[0-9a-f]{6}$/i')] private $color; + + /** + * The portal's commission for a booking through this origin, in percent - + * of what the invoice marks as commissionable rather than of its gross + * total, see OriginFeeCalculator. Together with the payment fee it makes up + * what the guest carries over the direct price; kept apart so it mirrors + * the two deductions booked for it. Null when none applies (direct booking). + */ + #[ORM\Column(type: 'decimal', precision: 5, scale: 2, nullable: true)] + private ?string $commissionPercent = null; + + /** The portal's payment fee in percent, taken of what the portal processed; see commission. */ + #[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')] @@ -95,6 +137,58 @@ public function setColor(?string $color): self return $this; } + /** Portal commission in percent, null when none applies. */ + public function getCommissionPercent(): ?string + { + return $this->commissionPercent; + } + + public function setCommissionPercent(?string $commissionPercent): self + { + $this->commissionPercent = $commissionPercent; + + return $this; + } + + /** Portal payment fee in percent, null when none applies. */ + public function getPaymentFeePercent(): ?string + { + return $this->paymentFeePercent; + } + + public function setPaymentFeePercent(?string $paymentFeePercent): self + { + $this->paymentFeePercent = $paymentFeePercent; + + 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/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/src/Form/InvoiceMiscPositionType.php b/src/Form/InvoiceMiscPositionType.php index afb0dbc5..9c71c16d 100644 --- a/src/Form/InvoiceMiscPositionType.php +++ b/src/Form/InvoiceMiscPositionType.php @@ -47,6 +47,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/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/Repository/WorkflowRepository.php b/src/Repository/WorkflowRepository.php index b94ae79c..0d775a01 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,81 @@ 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 + { + 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 { - $accountId = $account->getId(); - if (null === $accountId) { + $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. + * + * @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/src/Service/InvoiceService.php b/src/Service/InvoiceService.php index 7798db7e..071fb089 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; @@ -51,6 +52,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 +65,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,6 +196,8 @@ public function buildTemplateRenderParams(Template $template, Invoice $invoice): $periods = $this->getUniqueReservationPeriods($invoice); $appartmentNumbers = $this->getUniqueAppartmentsNumber($invoice); + $originFees = $this->originFees->calculate($invoice); + $params = [ 'invoice' => $invoice, 'vats' => $vatSums, @@ -252,6 +212,28 @@ public function buildTemplateRenderParams(Template $template, Invoice $invoice): // Issuer data follows the invoice's branch, so a two-company setup prints // 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, 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, 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' => $this->settledAmount($originFees->commission), + 'originCommissionFormated' => $this->settledAmountFormatted($originFees->commission), + 'originPaymentFee' => $this->settledAmount($originFees->paymentFee), + 'originPaymentFeeFormated' => $this->settledAmountFormatted($originFees->paymentFee), ]; return $params; @@ -626,7 +608,58 @@ 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)); + } + + /** 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 + * stake here: a separately billed tourist tax carries none either way. + * + * 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 + */ + 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) { + $collection = $reservation->getTouristTaxCollection() + ?? $reservation->getReservationOrigin()?->getTouristTaxCollection(); + + if (null === $collection || !$collection->isPortal()) { + return false; + } + } + + return true; } private function touristTaxAggregateKey(TouristTaxBreakdown $row): string @@ -822,7 +855,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); @@ -831,6 +864,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', [ @@ -1108,6 +1147,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; } @@ -1143,6 +1186,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/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..d279202c --- /dev/null +++ b/src/Service/OriginFeeCalculator.php @@ -0,0 +1,242 @@ +reservationTheFiguresBelongTo($invoice); + + return new OriginFeeBreakdown( + $shown?->getReservationOrigin()?->getName(), + $this->fee( + $invoice, + $shown, + $this->baseOf( + $invoice, + static fn (InvoicePosition $p): bool => $p->isCommissionable(), + includeStay: true, + ), + static fn (Reservation $r): ?string => $r->getCommissionPercent() + ?? $r->getReservationOrigin()?->getCommissionPercent(), + ), + $this->paymentFee($invoice, $shown), + ); + } + + /** + * The gross total of an invoice, optionally counting only what a commission + * would be charged on. + * + * 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 $commissionableOnly = false): float + { + 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 */ + $positions = ($invoice->getPositions() ?? new ArrayCollection())->filter($keep); + + $stay = $includeStay + ? $invoice->getAppartments() ?? new ArrayCollection() + : new ArrayCollection(); + + return $this->sums->grossTotal($stay, $positions); + } + + /** + * 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 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 + * 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; + } + + $answers = []; + foreach ($reservations as $reservation) { + $collection = $reservation->getPaymentCollection() + ?? $reservation->getReservationOrigin()?->getPaymentCollection(); + + $answers[] = null !== $collection && $collection->isPortal(); + } + + $portal = in_array(true, $answers, true); + $property = in_array(false, $answers, true); + + return $portal && $property ? null : $portal; + } + + /** + * One fee, at the rate that holds for the invoice. + * + * 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 + */ + private function fee(Invoice $invoice, ?Reservation $shown, float $base, callable $rateOf, bool $baseIsOne = true): 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, $baseIsOne); + } + + /** + * 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 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 + * 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/Service/PriceService.php b/src/Service/PriceService.php index 33735cd1..e3f4222f 100644 --- a/src/Service/PriceService.php +++ b/src/Service/PriceService.php @@ -175,6 +175,17 @@ 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. + // + // 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); // 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 8666049d..2e48e586 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; @@ -48,9 +49,87 @@ public function getOriginFromForm(Request $request, $id = 'new') $colorEnabled = $request->request->getBoolean('color-enabled-'.$id); $origin->setColor($colorEnabled && 1 === preg_match('/^#[0-9a-f]{6}$/', $color) ? $color : null); + // The two percentages only apply while the origin is flagged as charging + // them; without the flag they are cleared, whatever the hidden fields + // still carried. + if ($request->request->get('surcharge-enabled-'.$id)) { + $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 + // 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; } + /** + * 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 + { + $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; + } + + /** + * 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 findSurchargeValueError(Request $request, $id, ReservationOrigin $origin): ?string + { + 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; + } + /** * Delete origin if its not used in reservations. * diff --git a/src/Service/TemplatePreview/InvoiceTemplatePreviewProvider.php b/src/Service/TemplatePreview/InvoiceTemplatePreviewProvider.php index e1949f02..e248ea20 100644 --- a/src/Service/TemplatePreview/InvoiceTemplatePreviewProvider.php +++ b/src/Service/TemplatePreview/InvoiceTemplatePreviewProvider.php @@ -114,6 +114,11 @@ public function getRenderParamsSchema(): array 'appartmentTotal' => ['type' => 'scalar'], 'miscTotal' => ['type' => 'scalar'], 'paymentDueDate' => ['type' => 'date'], + 'originName' => ['type' => 'scalar'], + 'originCommission' => ['type' => 'scalar'], + 'originCommissionFormated' => ['type' => 'scalar'], + 'originPaymentFee' => ['type' => 'scalar'], + 'originPaymentFeeFormated' => ['type' => 'scalar'], ]; } @@ -289,6 +294,27 @@ public function getAvailableSnippets(): array 'complexity' => 'easy', 'content' => "

{{ 'invoice.payment_due_date'|trans }}: [[ paymentDueDate|date('d.m.Y') ]]

", ], + [ + 'id' => 'invoice.origin_name', + 'label' => 'templates.editor.origin_name', + 'group' => 'Invoice', + 'complexity' => 'simple', + 'content' => '[[ originName ]]', + ], + [ + 'id' => 'invoice.origin_commission', + 'label' => 'templates.editor.origin_commission', + 'group' => 'Invoice', + 'complexity' => 'simple', + 'content' => '[[ originCommissionFormated ]] €', + ], + [ + 'id' => 'invoice.origin_payment_fee', + 'label' => 'templates.editor.origin_payment_fee', + 'group' => 'Invoice', + 'complexity' => 'simple', + 'content' => '[[ originPaymentFeeFormated ]] €', + ], [ 'id' => 'pdf.header', 'label' => 'templates.preview.snippet.pdf_header', @@ -368,6 +394,11 @@ private function buildSampleParams(array $ctx = []): array $periods = $this->invoiceService->getUniqueReservationPeriods($invoice); $numbers = $this->invoiceService->getUniqueAppartmentsNumber($invoice); + // Sample surcharges so a template using the origin placeholders renders + // in the preview; a real invoice fills these from its reservation origin. + $sampleCommission = round($brutto * 12.0 / 100.0, 2); + $samplePaymentFee = round($brutto * 1.4 / 100.0, 2); + $params = [ 'invoice' => $invoice, 'vats' => $vats, @@ -382,6 +413,11 @@ private function buildSampleParams(array $ctx = []): array // A sample invoice has no issuer behind it, so the preview shows what a // ten-day period would look like rather than leaving the line empty. 'paymentDueDate' => (new \DateTimeImmutable('today'))->modify('+10 days'), + 'originName' => 'Booking.com', + 'originCommission' => $sampleCommission, + 'originCommissionFormated' => number_format($sampleCommission, 2, ',', '.'), + 'originPaymentFee' => $samplePaymentFee, + 'originPaymentFeeFormated' => number_format($samplePaymentFee, 2, ',', '.'), ]; return $this->appendPreviewMeta($params, $ctx); diff --git a/src/Workflow/Action/CreatePercentageEntryAction.php b/src/Workflow/Action/CreatePercentageEntryAction.php new file mode 100644 index 00000000..fef238d2 --- /dev/null +++ b/src/Workflow/Action/CreatePercentageEntryAction.php @@ -0,0 +1,322 @@ +> + */ + 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' => 'amountBase', + 'type' => 'select', + 'label' => 'workflow.form.percentage_entry_amount_base', + 'help' => 'workflow.form.percentage_entry_amount_base_help', + 'options' => [ + ['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_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 + // that is then ignored would be worse than offering none. + 'showIf' => ['key' => 'percentSource', 'value' => self::PERCENT_SOURCE_MANUAL], + ], + [ + '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', + // 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', + 'default' => '', + ], + [ + 'key' => 'remark', + 'type' => 'text', + 'label' => 'workflow.form.percentage_entry_remark', + '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', + ], + ]; + } + + /** + * @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')); + } + + $fee = $this->resolveFee($config, $entity); + if ($fee->percent <= 0.0) { + throw new WorkflowSkippedException($this->translator->trans('workflow.log.skipped_no_percentage')); + } + + $base = $fee->base; + $amount = $fee->amount; + 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 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, + '' !== $remark ? $remark : null, + // 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, + // 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, + ); + + // 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. + $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($fee->percent, 2, ',', '.'), + '%amount%' => number_format($amount, 2, ',', '.'), + '%base%' => number_format($base, 2, ',', '.'), + '%number%' => (string) $entity->getNumber(), + ]); + } + + /** + * 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. + * + * @param array $config + */ + 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 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 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_COMMISSIONABLE), + ), + ); + } + + $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 + // 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 (!$fee->isAgreedUpon()) { + throw new WorkflowSkippedException($this->translator->trans('workflow.log.skipped_mixed_rates', [ + '%rates%' => implode(', ', $fee->rateLabels()), + ])); + } + + // 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; + } + + /** Reads a percentage as it may have been typed, commas included. */ + private function toPercent(?string $raw): float + { + return (float) str_replace(',', '.', trim((string) $raw)); + } +} 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) }}
+
+
+ {{ 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 e1ce9eb7..979a5d3d 100644 --- a/templates/Prices/price_form_input_fields.html.twig +++ b/templates/Prices/price_form_input_fields.html.twig @@ -127,6 +127,15 @@ + {# Only offered for miscellaneous prices: a night is what the + portal brokered, so the question has no answer to give there. #} +
+ + + + + +
{{ 'reservationorigin.color.help'|trans }}
+ {% set hasSurcharge = origin.commissionPercent is not null or origin.paymentFeePercent is not null %} +
+
+
+ + +
+
+
+
+
+ + +
+
+ + % +
+
+
+
+ + +
+
+ + % +
+
+
+ {# 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. #} +
+ + +
+ +
+
+ {# 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 }}
+
+
+ {% endif %} +
\ No newline at end of file diff --git a/tests/Functional/BookingJournalControllerTest.php b/tests/Functional/BookingJournalControllerTest.php index 71f608c2..b68eec69 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 { @@ -56,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(); @@ -290,6 +295,88 @@ public function testYearViewDoesNotOfferEditingForClosedMonths(): void self::assertStringNotContainsString('booking-journal#openOffcanvas', (string) $client->getResponse()->getContent()); } + // ── Duplicating an entry ──────────────────────────────────────── + + public function testDuplicatingAnEntryKeepsTheDocumentFlag(): void + { + // The copy of an entry booked ahead of its document is waiting for one + // just as much as the original; losing the flag would let the month be + // closed on a deduction nothing documents. + $client = static::createClient(); + $client->loginUser($this->createCashJournalUser()); + + $entry = $this->createEntryIn(2095, 4, static function (BookingEntry $entry): void { + $entry->setRequiresDocumentNumber(true); + }); + + $crawler = $client->request('GET', '/journal/entry/'.$entry->getId().'/duplicate'); + + self::assertResponseIsSuccessful(); + $checkbox = $crawler->filter('input[id$="_requiresDocumentNumber"]'); + self::assertGreaterThan(0, $checkbox->count(), 'the entry form does not offer the document flag'); + self::assertNotNull($checkbox->attr('checked'), 'the copy does not wait for the document the original waits for'); + } + + public function testDuplicatingAnOrdinaryEntryLeavesTheDocumentFlagOff(): void + { + $client = static::createClient(); + $client->loginUser($this->createCashJournalUser()); + + $entry = $this->createEntryIn(2095, 5); + + $crawler = $client->request('GET', '/journal/entry/'.$entry->getId().'/duplicate'); + + self::assertResponseIsSuccessful(); + self::assertNull($crawler->filter('input[id$="_requiresDocumentNumber"]')->attr('checked')); + } + + // ── 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 +398,65 @@ 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(); + } + + /** An entry of its own month, so the tests do not disturb each other. */ + private function createEntryIn(int $year, int $month, ?callable $adjust = null): BookingEntry + { + $em = $this->getEntityManager(); + + $batch = new BookingBatch(); + $batch->setYear($year); + $batch->setMonth($month); + $batch->setIsClosed(false); + $batch->setCashStart(0); + $batch->setCashEnd(0); + $em->persist($batch); + + $entry = new BookingEntry(); + $entry->setBookingBatch($batch); + $entry->setDate(new \DateTime(sprintf('%04d-%02d-15', $year, $month))); + $entry->setDocumentNumber(1); + $entry->setAmount('42.00'); + $entry->setRemark('Kopier-Testbuchung'); + + if (null !== $adjust) { + $adjust($entry); + } + + $em->persist($entry); + $em->flush(); + + return $entry; + } + private function createCashJournalUser(): User { return $this->createUserWithRoles(['ROLE_CASHJOURNAL']); diff --git a/tests/Functional/CreatePercentageEntryActionTest.php b/tests/Functional/CreatePercentageEntryActionTest.php new file mode 100644 index 00000000..fe4622f4 --- /dev/null +++ b/tests/Functional/CreatePercentageEntryActionTest.php @@ -0,0 +1,339 @@ +em) { + if (!static::$booted && null === static::$kernel) { + self::bootKernel(); + } + $this->em = static::getContainer()->get(ManagerRegistry::class)->getManager(); + } + + return $this->em; + } + + 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'); + $since = $this->lastEntryId(); + + $action->execute($this->config('12', 'Kommission %number%'), $invoice, []); + $action->execute($this->config('1,4', 'Zahlungsgebühr %number%'), $invoice, []); + $this->em()->flush(); + + $entries = $this->entriesSince($since); + + 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 testTheEntryCarriesTheExecutionDateAndTheConfiguredAccounts(): void + { + // 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(); + + $action->execute($this->config('12', ''), $invoice, []); + $this->em()->flush(); + + $entry = $this->entriesSince($since)[0]; + + 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. + 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 + // deduction, which does not exist yet - so the entry says it is + // incomplete instead of carrying a stand-in. + $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]; + + self::assertNull($entry->getInvoiceNumber()); + self::assertTrue($entry->requiresDocumentNumber()); + self::assertTrue($entry->isMissingDocumentNumber()); + + $entry->setInvoiceNumber('1656376969'); + 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); + $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 = static::getContainer()->get(BookingEntryRepository::class); + $before = $repo->countMissingDocumentNumber($entry->getBookingBatch()); + + $entry->setInvoiceNumber('1656376969'); + $this->em()->flush(); + + self::assertSame($before - 1, $repo->countMissingDocumentNumber($entry->getBookingBatch())); + } + + public function testTheMonthCannotBeClosedWhileAnEntryWaits(): void + { + // The guard is the point of the flag: without it the month could be + // finalised with a deduction that has no document behind it. + $client = static::createClient(); + $client->loginUser($this->adminUser()); + + $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]; + $batchId = $entry->getBookingBatch()->getId(); + + // Each client request reboots the kernel, so state is read back from + // the database rather than from entities of an earlier container. + $this->toggleBatch($client, $batchId); + self::assertFalse($this->isBatchClosed($batchId), 'batch closed despite a waiting entry'); + + // 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'); + + // Leave the fixture as found - a closed batch would block later tests. + $this->reopenBatch($batchId); + } + + private function isBatchClosed(int $id): bool + { + return (bool) $this->connection()->fetchOne('SELECT is_closed FROM booking_batches WHERE id = ?', [$id]); + } + + private function reopenBatch(int $id): void + { + $this->connection()->executeStatement('UPDATE booking_batches SET is_closed = 0 WHERE id = ?', [$id]); + } + + /** 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 = '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 + { + return static::getContainer()->get(ManagerRegistry::class)->getConnection(); + } + + /** + * Entries created after the given id - the action reports a log line, not + * the entry, and matching on the remark would catch unrelated rows. + * + * @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(); + } + + /** + * Submits the close/reopen form the way the page offers it - the token has + * to come from that page, since minting one needs a session the test + * process does not have. + */ + private function toggleBatch(\Symfony\Bundle\FrameworkBundle\KernelBrowser $client, int $batchId): void + { + $crawler = $client->request('GET', '/journal/batch/'.$batchId); + $form = $crawler->filter('form[action$="/toggle-status"]')->first(); + self::assertGreaterThan(0, $form->count(), 'no close/reopen form on the batch page'); + + $client->submit($form->form()); + } + + private function adminUser(): \App\Entity\User + { + $user = $this->em()->getRepository(\App\Entity\User::class)->findOneBy([]); + self::assertNotNull($user, 'the fixture needs at least one user'); + + return $user; + } + + private function lastEntryId(): int + { + return (int) ($this->em()->getRepository(BookingEntry::class) + ->createQueryBuilder('e') + ->select('MAX(e.id)') + ->getQuery() + ->getSingleScalarResult() ?? 0); + } + + /** @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, string $date = '2026-06-21'): Invoice + { + $invoice = new Invoice(); + $invoice->setNumber('T'.random_int(100000, 999999)); + $invoice->setDate(new \DateTime($date)); + $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($date))->modify('-2 days')); + $apartment->setEndDate(new \DateTime($date)); + $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/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/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'), 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; + } +} 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/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; + } +} 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..e0536378 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; @@ -204,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 @@ -279,7 +322,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/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()), + ); + } +} diff --git a/tests/Unit/InvoiceServicePricingFlagsTest.php b/tests/Unit/InvoiceServicePricingFlagsTest.php index 186b7ede..8c34ef98 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; @@ -380,7 +382,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..b8945df3 100644 --- a/tests/Unit/InvoiceServiceTouristTaxTest.php +++ b/tests/Unit/InvoiceServiceTouristTaxTest.php @@ -8,10 +8,14 @@ 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; +use App\Service\InvoiceSumCalculator; +use App\Service\OriginFeeCalculator; use App\Service\PriceService; use App\Service\TouristTaxService; use Doctrine\ORM\EntityManagerInterface; @@ -178,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); @@ -194,7 +268,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..7d313852 --- /dev/null +++ b/tests/Unit/OriginFeeCalculatorTest.php @@ -0,0 +1,435 @@ +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 testCommissionLeavesOutWhatCarriesNoneWhileThePaymentFeeKeepsIt(): void + { + // 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); + + 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 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 nothing exempt there is nothing to leave out. + $invoice = $this->invoiceCollectedBy(PaymentCollection::PORTAL); + $invoice->addPosition($this->position('Übernachtung', 115.20)); + + $fees = $this->calculate($invoice); + + self::assertSame(115.20, $fees->commission->base); + 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 testAnInvoiceSettledBothWaysStatesNoPaymentFeeBase(): void + { + // 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::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 ────────────────────────────────────────── + + 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 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); + + 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, float $price, bool $brokered = true, bool $commissionable = true): InvoicePosition + { + $position = new InvoicePosition(); + $position->setDescription($description); + $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/PriceServiceBrokeredTest.php b/tests/Unit/PriceServiceBrokeredTest.php new file mode 100644 index 00000000..3820bd37 --- /dev/null +++ b/tests/Unit/PriceServiceBrokeredTest.php @@ -0,0 +1,80 @@ +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, new ReservationPeriodService()); + + return $service->getPriceFromForm(new Request([], $params), 'new'); + } +} diff --git a/tests/Unit/ReservationOriginRatePinningTest.php b/tests/Unit/ReservationOriginRatePinningTest.php new file mode 100644 index 00000000..fbe833da --- /dev/null +++ b/tests/Unit/ReservationOriginRatePinningTest.php @@ -0,0 +1,189 @@ +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()); + } + + 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 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 + { + $origin = $this->origin('12.00', '1.40'); + $origin->setPaymentCollection(PaymentCollection::PORTAL); + + $reservation = new Reservation(); + $reservation->setReservationOrigin($origin); + $reservation->setReservationOrigin(null); + + 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(); + $origin->setName('Booking.com'); + $origin->setCommissionPercent($commission); + $origin->setPaymentFeePercent($paymentFee); + + return $origin; + } +} diff --git a/tests/Unit/ReservationOriginSurchargeFormTest.php b/tests/Unit/ReservationOriginSurchargeFormTest.php new file mode 100644 index 00000000..95f79964 --- /dev/null +++ b/tests/Unit/ReservationOriginSurchargeFormTest.php @@ -0,0 +1,159 @@ +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::assertSame('reservationorigin.flash.surcharge_required', $this->service()->findSurchargeValueError($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::assertNull($this->service()->findSurchargeValueError($request, 'new', $origin)); + } + + public function testUnflaggedIsNeverRejectedEvenWhenEmpty(): void + { + $request = new Request([], ['name-new' => 'Direktbuchung']); + $origin = $this->service()->getOriginFromForm($request, 'new'); + + 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); + } + } + + /** + * @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/tests/Unit/Workflow/CreatePercentageEntryActionTest.php b/tests/Unit/Workflow/CreatePercentageEntryActionTest.php new file mode 100644 index 00000000..ddcacfe8 --- /dev/null +++ b/tests/Unit/Workflow/CreatePercentageEntryActionTest.php @@ -0,0 +1,598 @@ +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']); + } + + public function testLeavesTheDocumentNumberEmptyForLater(): void + { + // The reference that belongs in that field is the supplier's invoice + // for the deduction, which does not exist yet - the remark carries the + // link to our own invoice instead. + $captured = null; + $action = $this->makeAction(gross: 100.0, capture: $captured); + + $action->execute($this->config(['percent' => '10']), $this->invoice('17730'), []); + + self::assertNull($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 day + // the payment was recorded 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 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 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 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 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. + $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 + // 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 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_COMMISSIONABLE]); + $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 testTreatsAConfigWithoutTheKeyLikeANewOne(): void + { + // 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); + + $config = $this->config(); + unset($config['amountBase']); + $action->execute($config, $this->invoiceWithPositions(), []); + + 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); + + self::assertSame( + CreatePercentageEntryAction::AMOUNT_BASE_COMMISSIONABLE, + $this->amountBaseField($action)['default'] ?? null + ); + } + + /** @return array|null */ + private function amountBaseField(CreatePercentageEntryAction $action): ?array + { + foreach ($action->getConfigSchema() as $field) { + if ('amountBase' === $field['key']) { + return $field; + } + } + + return null; + } + + 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', + 'amountBase' => CreatePercentageEntryAction::AMOUNT_BASE_GROSS, + 'debitAccountId' => '3', + 'creditAccountId' => '4', + 'taxRateId' => '', + 'remark' => '', + ], $overrides); + } + + /** + * An invoice carrying one tourist-tax position among ordinary ones. Only the + * 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 + { + $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); + // 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; + } + + /** + * @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); + $invoice->method('getNumber')->willReturn($number); + $invoice->method('getDate')->willReturn(new \DateTime('2026-06-26')); + + return $invoice; + } + + private function invoiceWithOrigin( + ?string $commission, + ?string $paymentFee, + ?string $pinnedCommission = null, + ?string $pinnedPaymentFee = null, + ): Invoice { + $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($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 + */ + private function makeAction(float $gross, mixed &$capture = null, mixed &$capturePositions = null): CreatePercentageEntryAction + { + // 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; + + return $gross; + } + ); + $originFees = new OriginFeeCalculator($sums); + + $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, + ]; + + // 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; + } + ); + + $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, $originFees, $translator); + } +} diff --git a/translations/BookingJournal/messages.de.yaml b/translations/BookingJournal/messages.de.yaml index 720dcfac..c8d6f0fd 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 @@ -212,6 +213,8 @@ accounting.journal.entry.debit: Soll accounting.journal.entry.credit: Haben accounting.journal.entry.tax_rate: Steuersatz accounting.journal.entry.invoice: Rechnungs-Nr. +accounting.journal.entry.requires_document: Beleg wird nachgereicht +accounting.journal.entry.requires_document_help: Die Buchung wartet auf eine Belegnummer. Solange sie fehlt, lässt sich der Monat nicht abschließen. accounting.journal.entry.remark: Bemerkung accounting.journal.entry.empty: Keine Buchungen vorhanden. accounting.journal.entry.delete_ask: Diese Buchung wirklich löschen? @@ -221,6 +224,9 @@ accounting.journal.back: Zurück zum Journal # Journal - Flash Messages 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.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 b3a8466c..ebebdf99 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 @@ -212,6 +213,8 @@ accounting.journal.entry.debit: Debit accounting.journal.entry.credit: Credit accounting.journal.entry.tax_rate: Tax Rate accounting.journal.entry.invoice: Invoice No. +accounting.journal.entry.requires_document: Document to follow +accounting.journal.entry.requires_document_help: The entry is waiting for a document number. The month cannot be closed while it is missing. accounting.journal.entry.remark: Remark accounting.journal.entry.empty: No entries yet. accounting.journal.entry.delete_ask: Delete this entry? @@ -221,6 +224,9 @@ accounting.journal.back: Back to Journal # Journal - Flash Messages 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.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/Prices/messages.de.xlf b/translations/Prices/messages.de.xlf index 6bce2a8e..33127445 100644 --- a/translations/Prices/messages.de.xlf +++ b/translations/Prices/messages.de.xlf @@ -234,6 +234,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 a79ec52b..add67773 100644 --- a/translations/Prices/messages.en.yaml +++ b/translations/Prices/messages.en.yaml @@ -77,6 +77,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 6a0aa32d..e1859235 100644 --- a/translations/ReservationOrigin/messages.de.xlf +++ b/translations/ReservationOrigin/messages.de.xlf @@ -30,6 +30,38 @@ 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.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 + Unterkunft + + + reservationorigin.collection.portal + Portal + reservationorigin.flash.create.success Reservierungsherkunft erfolgreich angelegt. @@ -42,6 +74,14 @@ 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. + reservationorigin.flash.delete.success Reservierungsherkunft erfolgreich gelöscht. diff --git a/translations/ReservationOrigin/messages.en.yaml b/translations/ReservationOrigin/messages.en.yaml index fa07e594..ad3802f1 100644 --- a/translations/ReservationOrigin/messages.en.yaml +++ b/translations/ReservationOrigin/messages.en.yaml @@ -10,6 +10,16 @@ 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 +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: Property +reservationorigin.collection.portal: Portal reservationorigin.private: Private reservationorigin.title: Reservation origin diff --git a/translations/Templates/messages.de.xlf b/translations/Templates/messages.de.xlf index 534db3cd..66c7451e 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. 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 b2ef4859..c33a241f 100644 --- a/translations/Templates/messages.en.yaml +++ b/translations/Templates/messages.en.yaml @@ -68,6 +68,9 @@ templates.editor.apartment_modifier.positions: Surcharge / discount positions templates.editor.apartment_modifier.positions.desc: >- Inserts a table with all apartment surcharge / discount positions. 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.opening_hours: Opening hours of the branch templates.editor.check_in_times: Check-in times of the branch diff --git a/translations/Workflow/messages.de.yaml b/translations/Workflow/messages.de.yaml index 4324324e..24ace2d2 100644 --- a/translations/Workflow/messages.de.yaml +++ b/translations/Workflow/messages.de.yaml @@ -77,6 +77,28 @@ 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_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 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. „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)." + 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%“." + 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 notification_note: "Hinweistext (optional)" notification_note_help: "Erklärt im Benachrichtigungsfenster, warum die Benachrichtigung erscheint — z.B. 'Zahlungsziel überschritten'." notification_severity: "Wichtigkeit" @@ -136,6 +158,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" @@ -209,6 +232,10 @@ 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" + 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" 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 c20dec2f..47dc73c5 100644 --- a/translations/Workflow/messages.en.yaml +++ b/translations/Workflow/messages.en.yaml @@ -77,6 +77,28 @@ 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_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 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. \"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)." + 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%\"." + 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" notification_note: "Explanation (optional)" notification_note_help: "Explains in the notification window why the notification appeared, e.g. 'Payment overdue'." notification_severity: "Importance" @@ -133,6 +155,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" @@ -204,6 +227,10 @@ 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" + 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" skipped_no_reservations: "Skipped: invoice has no linked reservations"