Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
d8da0e8
Add a workflow action booking a percentage of an invoice
MeisterAdebar Jul 19, 2026
0cd288d
Hold a batch open until booked-ahead entries have their document number
MeisterAdebar Jul 19, 2026
251f0de
Let the warning point at the entries it counts
MeisterAdebar Jul 19, 2026
c3aaf54
Book the deduction on the day the payment was recorded
MeisterAdebar Jul 20, 2026
676ce85
Keep accounts and tax rates a workflow books with from being deleted
MeisterAdebar Sep 15, 2026
aa3e2c2
Offer only the tax rates the active chart of accounts holds
MeisterAdebar Sep 15, 2026
d090a10
Record the deduction as booked by a workflow, not by hand
MeisterAdebar Sep 15, 2026
f1ae4d6
Carry the document status into a duplicated entry
MeisterAdebar Sep 15, 2026
fa1cd16
Record a booking's portal fees on its reservation origin
MeisterAdebar Jul 21, 2026
7019039
Let the deduction take its percentage from the reservation origin
MeisterAdebar Jul 21, 2026
4029609
Let the deduction choose what it is a percentage of
MeisterAdebar Jul 30, 2026
245c67f
Pin a booking's portal rates to the reservation
MeisterAdebar Jul 30, 2026
a13170f
Stop instead of guessing which rate an invoice was booked under
MeisterAdebar Jul 30, 2026
a50e6ce
Show an existing workflow the settings it is running on
MeisterAdebar Jul 30, 2026
15d26ed
Treat a config without a calculation base like a new one
MeisterAdebar Aug 2, 2026
d111b00
Name the excluded positions after what they all are
MeisterAdebar Aug 2, 2026
6b1244c
Mark the deduction as what created it
MeisterAdebar Aug 2, 2026
7481cce
Work the portal's fees out in one place
MeisterAdebar Aug 4, 2026
075d57a
Put what a portal charges its fees on into the data
MeisterAdebar Aug 4, 2026
36e3c88
Read the fee bases off the invoice instead of its position groups
MeisterAdebar Aug 4, 2026
57918b0
Cover the invoice where the two bases part company
MeisterAdebar Aug 4, 2026
4f6f04c
Ask who collects the tourist tax only where one exists
MeisterAdebar Sep 15, 2026
4f5e546
Refuse a percentage the fee cannot be charged at
MeisterAdebar Sep 15, 2026
b11f344
Pin who collects the tourist tax to the booking
MeisterAdebar Sep 15, 2026
de70370
Offer the brokered switch only where it decides anything
MeisterAdebar Sep 15, 2026
552628b
Stop instead of booking a fee on a base the invoice cannot state
MeisterAdebar Sep 15, 2026
bb65a77
Leave the fee off the invoice where the journal will not book it
MeisterAdebar Sep 15, 2026
d6bd0ec
Keep the preset test from depending on the order the suite runs in
MeisterAdebar Sep 15, 2026
5bd695e
Admit that a reservation may have no origin
MeisterAdebar Sep 15, 2026
3c69c89
Say plainly what the calculator does with an invoice that disagrees w…
MeisterAdebar Sep 15, 2026
75663ec
Merge 4.12.0-dev into the portal fees branch
MeisterAdebar Sep 19, 2026
048ee57
Build the price service the way 4.12.0-dev does
MeisterAdebar Sep 19, 2026
c9c92a6
Let the panel test look at entries, not at the mark-all-read button
MeisterAdebar Sep 19, 2026
690814a
Note the portal fees in the release notes
MeisterAdebar Sep 19, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions assets/controllers/invoices_controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import {
getLocalStorageItem,
updatePDFExportLinks,
enableDeletePopover,
enableTooltips,
disposeTooltips,
setModalTitle
} from '../js/utils.js';

Expand All @@ -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) {
Expand All @@ -40,6 +46,14 @@ export default class extends Controller {
}
}

async initTooltips() {
await enableTooltips(this.element);
}

disconnect() {
disposeTooltips(this.element);
}

// Actions
openModalAction(event) {
event.preventDefault();
Expand Down Expand Up @@ -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);
}
Expand Down
10 changes: 10 additions & 0 deletions assets/controllers/prices_controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
9 changes: 9 additions & 0 deletions assets/controllers/settings_controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
10 changes: 10 additions & 0 deletions docs/release-notes/4.12.0.de.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)).
Expand Down
31 changes: 31 additions & 0 deletions migrations/Version20260719160000.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?php

declare(strict_types=1);

namespace DoctrineMigrations;

use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;

final class Version20260719160000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add requires_document_number to booking_entries (marks entries whose document reference is still to be supplied)';
}

public function up(Schema $schema): void
{
$this->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;
}
}
31 changes: 31 additions & 0 deletions migrations/Version20260720170000.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?php

declare(strict_types=1);

namespace DoctrineMigrations;

use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;

final class Version20260720170000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add commission_percent and payment_fee_percent to reservation_origins (the portal fees a guest carries on top of the direct price)';
}

public function up(Schema $schema): void
{
$this->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;
}
}
34 changes: 34 additions & 0 deletions migrations/Version20260730120000.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<?php

declare(strict_types=1);

namespace DoctrineMigrations;

use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;

final class Version20260730120000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add commission_percent and payment_fee_percent to reservations (the portal rates as they stood when the reservation was booked)';
}

public function up(Schema $schema): void
{
// Left null for existing reservations: those were booked before the rates
// were pinned, and the origin's current rate is the only figure available
// for them - which is what the deduction falls back to.
$this->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;
}
}
65 changes: 65 additions & 0 deletions migrations/Version20260804120000.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
<?php

declare(strict_types=1);

namespace DoctrineMigrations;

use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;

final class Version20260804120000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Record on the data what a portal charges its fees on: brokered/commissionable per invoice position, brokered per price, and who collects the payment on origin and reservation';
}

public function up(Schema $schema): void
{
// What a portal brokered, and what it charges commission on, becomes a
// property of the position rather than a setting in a workflow: the
// invoice's own figures and the journal's deduction both read it, and
// neither can reach a workflow's config.
$this->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;
}
}
45 changes: 45 additions & 0 deletions migrations/Version20260804140000.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
<?php

declare(strict_types=1);

namespace DoctrineMigrations;

use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;

final class Version20260804140000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Mark origins that charge a payment fee as collecting the payment, now that the fee is charged on what the portal processed';
}

public function up(Schema $schema): void
{
// The payment fee is now taken on what the portal actually processed
// (see OriginFeeCalculator), and the column added for that defaults to
// the property - which for an origin that charges a payment fee would
// quietly book nothing at all from here on.
//
// An origin charging a percentage for processing payments does process
// them; that is what the fee is. So the ones that have such a fee are
// marked accordingly, and every other origin keeps the default. The
// tourist tax is left alone: portals differ on it and the setting is
// there to be answered per origin.
$this->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;
}
}
40 changes: 40 additions & 0 deletions migrations/Version20260915120000.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<?php

declare(strict_types=1);

namespace DoctrineMigrations;

use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;

final class Version20260915120000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Pin who collects the tourist tax onto the reservation, as the payment collection and the rates already are';
}

public function up(Schema $schema): void
{
// The tourist tax was the one answer still read live off the origin
// while the invoice was written. An origin that changes who collects it
// would therefore have changed what older, not yet invoiced bookings
// are charged - the very thing the pinned columns next to this one
// exist to prevent.
$this->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;
}
}
22 changes: 22 additions & 0 deletions src/Controller/BookingJournalController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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')]
Expand Down Expand Up @@ -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');
Expand All @@ -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();
Expand Down Expand Up @@ -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 = [
Expand Down
Loading