From be065d206b696e4b3440b4ad221cd3b3e7c7d723 Mon Sep 17 00:00:00 2001 From: Alexander Elchlepp Date: Fri, 1 May 2026 09:22:55 +0200 Subject: [PATCH 01/38] #203 added generic csv bank import and csv profile crud, rule and split logic, invoice number detection --- assets/controllers/bank_import_controller.js | 15 + .../bank_import_preview_controller.js | 656 ++++++++++++++++ .../bank_import_profiles_controller.js | 46 ++ .../controllers/confirm_submit_controller.js | 123 +++ config/services.yaml | 2 + migrations/Version20260424190000.php | 119 +++ src/Controller/BankCsvProfileController.php | 108 +++ src/Controller/BankImportController.php | 727 ++++++++++++++++++ src/Controller/BankImportRuleController.php | 138 ++++ src/Controller/BookingJournalController.php | 6 +- .../BookingJournalSettingsController.php | 4 +- src/Controller/PriceServiceController.php | 2 +- src/Controller/WorkflowController.php | 2 +- .../BookingJournal/BankImport/ImportState.php | 136 ++++ .../BookingJournal/BankImport/ParseResult.php | 24 + .../BankImport/StatementLineDto.php | 49 ++ src/Entity/AccountingAccount.php | 19 +- src/Entity/AccountingSettings.php | 41 + src/Entity/BankCsvProfile.php | 306 ++++++++ src/Entity/BankImportFingerprint.php | 97 +++ src/Entity/BankImportRule.php | 222 ++++++ src/Entity/BankStatementImport.php | 210 +++++ src/Entity/BookingEntry.php | 21 + src/Form/AccountingAccountType.php | 6 + src/Form/AccountingSettingsType.php | 52 +- src/Form/BankCsvProfileType.php | 165 ++++ src/Form/BankImportRuleType.php | 73 ++ src/Form/BankStatementUploadType.php | 73 ++ .../AccountingAccountRepository.php | 6 + src/Repository/BankCsvProfileRepository.php | 34 + .../BankImportFingerprintRepository.php | 51 ++ src/Repository/BankImportRuleRepository.php | 48 ++ .../BankStatementImportRepository.php | 53 ++ src/Repository/BookingEntryRepository.php | 22 +- src/Repository/InvoiceRepository.php | 37 +- .../AccountingPresetSeeder.php | 2 +- .../AccountingSettingsService.php | 2 +- .../BankImport/BankImportDraftSession.php | 98 +++ .../BankImport/BankImportRuleMatcher.php | 69 ++ .../BankImport/BankStatementCommitter.php | 283 +++++++ .../BankImport/BankStatementDeduplicator.php | 40 + .../BankImport/CompiledMatcher.php | 61 ++ .../BankImport/InvoiceMatcher.php | 142 ++++ .../InvoiceNumberPatternBuilder.php | 253 ++++++ .../Parser/BankStatementParserRegistry.php | 66 ++ .../BankImport/Parser/GenericCsvParser.php | 373 +++++++++ .../BankImport/Parser/ParserInterface.php | 28 + .../BankImport/RuleActionApplicator.php | 150 ++++ .../BankImport/RuleConditionEvaluator.php | 108 +++ .../BookingJournalService.php | 103 ++- .../OpeningBalanceService.php | 2 +- .../CashJournalTemplatePreviewProvider.php | 2 +- .../Action/CreateBookingEntryAction.php | 2 +- .../BankImport/_rule_modal.html.twig | 154 ++++ .../BankImport/_rule_summary.html.twig | 98 +++ .../BankImport/_split_modal.html.twig | 109 +++ .../BankImport/_status_badge.html.twig | 22 + .../BookingJournal/BankImport/index.html.twig | 119 +++ .../BankImport/preview.html.twig | 383 +++++++++ .../BankImport/profile_form.html.twig | 225 ++++++ .../BankImport/profiles_index.html.twig | 95 +++ .../BankImport/rule_form.html.twig | 87 +++ .../BankImport/rules_index.html.twig | 104 +++ .../BookingJournal/_account_form.html.twig | 1 + templates/BookingJournal/entries.html.twig | 8 +- templates/BookingJournal/index.html.twig | 3 + templates/BookingJournal/settings.html.twig | 15 + tests/Unit/AccountingPresetSeederTest.php | 2 +- .../BankImport/BankCsvProfileTypeTest.php | 88 +++ .../BankImport/BankImportRuleMatcherTest.php | 211 +++++ .../BankImport/BankStatementCommitterTest.php | 405 ++++++++++ .../BankImport/InvoiceMatcherTest.php | 222 ++++++ .../InvoiceNumberPatternBuilderTest.php | 148 ++++ .../BankImport/RuleActionApplicatorTest.php | 192 +++++ .../BankImport/RuleConditionEvaluatorTest.php | 150 ++++ tests/Unit/BookingJournalServiceTest.php | 84 +- translations/BankImport/messages.de.yaml | 349 +++++++++ translations/BankImport/messages.en.yaml | 348 +++++++++ translations/BookingJournal/messages.de.yaml | 8 + translations/BookingJournal/messages.en.yaml | 8 + 80 files changed, 9070 insertions(+), 45 deletions(-) create mode 100644 assets/controllers/bank_import_controller.js create mode 100644 assets/controllers/bank_import_preview_controller.js create mode 100644 assets/controllers/bank_import_profiles_controller.js create mode 100644 assets/controllers/confirm_submit_controller.js create mode 100644 migrations/Version20260424190000.php create mode 100644 src/Controller/BankCsvProfileController.php create mode 100644 src/Controller/BankImportController.php create mode 100644 src/Controller/BankImportRuleController.php create mode 100644 src/Dto/BookingJournal/BankImport/ImportState.php create mode 100644 src/Dto/BookingJournal/BankImport/ParseResult.php create mode 100644 src/Dto/BookingJournal/BankImport/StatementLineDto.php create mode 100644 src/Entity/BankCsvProfile.php create mode 100644 src/Entity/BankImportFingerprint.php create mode 100644 src/Entity/BankImportRule.php create mode 100644 src/Entity/BankStatementImport.php create mode 100644 src/Form/BankCsvProfileType.php create mode 100644 src/Form/BankImportRuleType.php create mode 100644 src/Form/BankStatementUploadType.php create mode 100644 src/Repository/BankCsvProfileRepository.php create mode 100644 src/Repository/BankImportFingerprintRepository.php create mode 100644 src/Repository/BankImportRuleRepository.php create mode 100644 src/Repository/BankStatementImportRepository.php rename src/Service/{ => BookingJournal}/AccountingPresetSeeder.php (99%) rename src/Service/{ => BookingJournal}/AccountingSettingsService.php (97%) create mode 100644 src/Service/BookingJournal/BankImport/BankImportDraftSession.php create mode 100644 src/Service/BookingJournal/BankImport/BankImportRuleMatcher.php create mode 100644 src/Service/BookingJournal/BankImport/BankStatementCommitter.php create mode 100644 src/Service/BookingJournal/BankImport/BankStatementDeduplicator.php create mode 100644 src/Service/BookingJournal/BankImport/CompiledMatcher.php create mode 100644 src/Service/BookingJournal/BankImport/InvoiceMatcher.php create mode 100644 src/Service/BookingJournal/BankImport/InvoiceNumberPatternBuilder.php create mode 100644 src/Service/BookingJournal/BankImport/Parser/BankStatementParserRegistry.php create mode 100644 src/Service/BookingJournal/BankImport/Parser/GenericCsvParser.php create mode 100644 src/Service/BookingJournal/BankImport/Parser/ParserInterface.php create mode 100644 src/Service/BookingJournal/BankImport/RuleActionApplicator.php create mode 100644 src/Service/BookingJournal/BankImport/RuleConditionEvaluator.php rename src/Service/{ => BookingJournal}/BookingJournalService.php (73%) rename src/Service/{ => BookingJournal}/OpeningBalanceService.php (99%) create mode 100644 templates/BookingJournal/BankImport/_rule_modal.html.twig create mode 100644 templates/BookingJournal/BankImport/_rule_summary.html.twig create mode 100644 templates/BookingJournal/BankImport/_split_modal.html.twig create mode 100644 templates/BookingJournal/BankImport/_status_badge.html.twig create mode 100644 templates/BookingJournal/BankImport/index.html.twig create mode 100644 templates/BookingJournal/BankImport/preview.html.twig create mode 100644 templates/BookingJournal/BankImport/profile_form.html.twig create mode 100644 templates/BookingJournal/BankImport/profiles_index.html.twig create mode 100644 templates/BookingJournal/BankImport/rule_form.html.twig create mode 100644 templates/BookingJournal/BankImport/rules_index.html.twig create mode 100644 tests/Unit/BookingJournal/BankImport/BankCsvProfileTypeTest.php create mode 100644 tests/Unit/BookingJournal/BankImport/BankImportRuleMatcherTest.php create mode 100644 tests/Unit/BookingJournal/BankImport/BankStatementCommitterTest.php create mode 100644 tests/Unit/BookingJournal/BankImport/InvoiceMatcherTest.php create mode 100644 tests/Unit/BookingJournal/BankImport/InvoiceNumberPatternBuilderTest.php create mode 100644 tests/Unit/BookingJournal/BankImport/RuleActionApplicatorTest.php create mode 100644 tests/Unit/BookingJournal/BankImport/RuleConditionEvaluatorTest.php create mode 100644 translations/BankImport/messages.de.yaml create mode 100644 translations/BankImport/messages.en.yaml diff --git a/assets/controllers/bank_import_controller.js b/assets/controllers/bank_import_controller.js new file mode 100644 index 00000000..b32379ce --- /dev/null +++ b/assets/controllers/bank_import_controller.js @@ -0,0 +1,15 @@ +import { Controller } from '@hotwired/stimulus'; +import { enableDeletePopover, enableTooltips, disposeTooltips } from '../js/utils.js'; + +/* stimulusFetch: 'lazy' */ + +export default class extends Controller { + connect() { + enableDeletePopover({ root: this.element }); + enableTooltips(this.element); + } + + disconnect() { + disposeTooltips(this.element); + } +} diff --git a/assets/controllers/bank_import_preview_controller.js b/assets/controllers/bank_import_preview_controller.js new file mode 100644 index 00000000..1ea230eb --- /dev/null +++ b/assets/controllers/bank_import_preview_controller.js @@ -0,0 +1,656 @@ +import { Controller } from '@hotwired/stimulus'; +import { enableDeletePopover, enableTooltips, disposeTooltips } from '../js/utils.js'; + +/** + * Inline editor for a bank-statement preview. + * + * Each line is represented by one row. The user can pick debit/credit + * accounts, type a remark, toggle "ignore" or check a box for bulk actions. + * Every change is persisted to the session via JSON endpoints; status badges + * and filter counts update locally to keep the UI snappy. + */ +/* stimulusFetch: 'lazy' */ +export default class extends Controller { + static targets = [ + 'row', + 'rowCheckbox', + 'selectAll', + 'filterChip', + 'bulkBar', + 'bulkCount', + 'bulkAccountSelect', + 'statusCell', + 'ignoreButton', + // Split modal + 'splitModal', + 'splitTotal', + 'splitAssigned', + 'splitDelta', + 'splitRows', + 'splitRowTemplate', + // Rule modal + 'ruleModal', + 'ruleName', + 'ruleCondCounterparty', + 'ruleCondCounterpartyValue', + 'ruleCondIban', + 'ruleCondIbanValue', + 'ruleCondDirection', + 'ruleCondDirectionValue', + 'ruleCondPurpose', + 'ruleCondPurposeValue', + 'ruleDebit', + 'ruleCredit', + 'ruleTaxRate', + 'ruleRemark', + 'rulePriority', + 'ruleScope', + 'linesData', + ]; + static values = { + lineUrlPrefix: String, + bulkUrl: String, + discardRedirectUrl: String, + csrf: String, + locale: String, + currencySymbol: String, + bulkSelectedLabel: String, + statusPendingTitle: String, + statusDuplicateTitle: String, + statusIgnoredTitle: String, + statusReadyTitle: String, + updateFailedMessage: String, + saveFailedMessage: String, + errorPrefix: String, + splitRemainderLabel: String, + splitTooMuchLabel: String, + splitOpenLabel: String, + ruleDefaultName: String, + ruleConditionRequiredMessage: String, + ruleNameRequiredMessage: String, + }; + + connect() { + enableDeletePopover({ + root: this.element, + onSuccess: () => { + window.location.href = this.discardRedirectUrlValue; + }, + }); + enableTooltips(this.element); + this.activeFilter = 'all'; + this.activeIdx = null; + this._lines = this._loadLineSnapshot(); + this._refreshBulkBar(); + } + + _loadLineSnapshot() { + if (!this.hasLinesDataTarget) return []; + try { + return JSON.parse(this.linesDataTarget.textContent || '[]'); + } catch { + return []; + } + } + + _lineByIdx(idx) { + const i = parseInt(idx, 10); + return this._lines.find((line) => line.idx === i) ?? null; + } + + disconnect() { + disposeTooltips(this.element); + } + + // ── Per-line edits ──────────────────────────────────────────────── + + fieldChange(event) { + const input = event.currentTarget; + const row = input.closest('tr'); + const idx = row?.dataset.idx; + const field = input.dataset.field; + if (idx === undefined || !field) return; + + // Remark fires on both change + blur; debounce identical values. + if (field === 'remark' && input.dataset.lastSent === input.value) return; + if (field === 'remark') input.dataset.lastSent = input.value; + + this._sendUpdate(row, idx, field, input.value); + } + + toggleIgnore(event) { + const row = event.currentTarget.closest('tr'); + const idx = row?.dataset.idx; + if (idx === undefined) return; + const currentlyIgnored = row.classList.contains('table-secondary') + && !row.dataset.duplicate; // duplicates also have that class — guard + // We use the data-status attr as authoritative state. + const willIgnore = row.dataset.status !== 'ignored'; + this._sendUpdate(row, idx, 'isIgnored', willIgnore ? '1' : '0'); + } + + // ── Filtering ───────────────────────────────────────────────────── + + setFilter(event) { + const filter = event.currentTarget.dataset.filter || 'all'; + this.activeFilter = filter; + + this.filterChipTargets.forEach((chip) => { + chip.classList.toggle('active', chip.dataset.filter === filter); + }); + + this._applyFilter(); + } + + _applyFilter() { + const f = this.activeFilter; + this.rowTargets.forEach((row) => { + const status = row.dataset.status; + const hasRule = row.dataset.rule === '1'; + const hasInvoice = row.dataset.invoice === '1'; + let visible = true; + + switch (f) { + case 'pending': visible = status === 'pending'; break; + case 'ready': visible = status === 'ready'; break; + case 'duplicate': visible = status === 'duplicate'; break; + case 'ignored': visible = status === 'ignored'; break; + case 'rule': visible = hasRule; break; + case 'invoice': visible = hasInvoice; break; + case 'all': + default: visible = true; + } + + row.hidden = !visible; + }); + } + + // ── Bulk actions ────────────────────────────────────────────────── + + toggleAll(event) { + const checked = event.currentTarget.checked; + this.rowCheckboxTargets.forEach((cb) => { + if (cb.disabled) return; + const row = cb.closest('tr'); + if (row && row.hidden) return; // only act on currently visible rows + cb.checked = checked; + }); + this._refreshBulkBar(); + } + + rowSelectChange() { + this._refreshBulkBar(); + } + + _selectedIndices() { + return this.rowCheckboxTargets + .filter((cb) => cb.checked && !cb.disabled) + .map((cb) => cb.closest('tr')?.dataset.idx) + .filter((v) => v !== undefined); + } + + _refreshBulkBar() { + if (!this.hasBulkBarTarget) return; + const count = this._selectedIndices().length; + this.bulkBarTarget.hidden = count === 0; + if (this.hasBulkCountTarget) { + this.bulkCountTarget.textContent = count > 0 + ? this._formatText(this.bulkSelectedLabelValue, { '%count%': count }) + : ''; + } + } + + bulkIgnore() { this._sendBulk('ignore'); } + bulkUnignore() { this._sendBulk('unignore'); } + + bulkAssignDebit() { + const accountId = this.bulkAccountSelectTarget.value; + if (!accountId) return; + this._sendBulk('assign_debit', { debitAccountId: accountId }); + } + bulkAssignCredit() { + const accountId = this.bulkAccountSelectTarget.value; + if (!accountId) return; + this._sendBulk('assign_credit', { creditAccountId: accountId }); + } + + // ── Network helpers ─────────────────────────────────────────────── + + async _sendUpdate(row, idx, field, value) { + row.classList.add('is-saving'); + const body = new URLSearchParams(); + body.set('_token', this.csrfValue); + body.set('field', field); + body.set('value', value); + + try { + const res = await fetch(this._updateUrl(idx), { + method: 'POST', + headers: { 'X-Requested-With': 'XMLHttpRequest' }, + body, + }); + if (!res.ok) throw new Error('http ' + res.status); + const json = await res.json(); + this._applyServerState(row, idx, json); + this._flashSaved(row); + } catch (e) { + row.classList.add('table-danger'); + setTimeout(() => row.classList.remove('table-danger'), 1500); + } finally { + row.classList.remove('is-saving'); + } + } + + async _sendBulk(action, extra = {}) { + const indices = this._selectedIndices(); + if (indices.length === 0) return; + + const body = new URLSearchParams(); + body.set('_token', this.csrfValue); + body.set('action', action); + indices.forEach((idx) => body.append('indices[]', idx)); + Object.entries(extra).forEach(([k, v]) => body.set(k, v)); + + try { + const res = await fetch(this.bulkUrlValue, { + method: 'POST', + headers: { 'X-Requested-With': 'XMLHttpRequest' }, + body, + }); + if (!res.ok) throw new Error('http ' + res.status); + // For bulk we just reload — many rows changed at once. + window.location.reload(); + } catch (e) { + // eslint-disable-next-line no-alert + alert(this.updateFailedMessageValue); + } + } + + _updateUrl(idx) { + return this.lineUrlPrefixValue + encodeURIComponent(idx); + } + + _applyServerState(row, idx, payload) { + if (payload?.status) { + row.dataset.status = payload.status; + row.classList.toggle('table-secondary', payload.status === 'ignored' || payload.status === 'duplicate'); + row.classList.toggle('text-muted', payload.status === 'ignored' || payload.status === 'duplicate'); + this._refreshStatusBadge(row, payload.status); + this._refreshIgnoreButton(row, payload.status === 'ignored'); + } + if (payload?.counts) this._refreshFilterCounts(payload.counts); + // Re-evaluate visibility under the active filter (e.g. row that just + // moved to "ready" should disappear from the "pending" filter). + this._applyFilter(); + } + + _refreshStatusBadge(row, status) { + const cell = row.querySelector('[data-bank-import-preview-target="statusCell"]'); + if (!cell) return; + // Replace only the leading badge (first child element). + const first = cell.firstElementChild; + if (!first) return; + + let cls = 'badge bg-warning text-dark'; + let icon = 'fa-circle-notch'; + let title = this.statusPendingTitleValue; + if (status === 'duplicate') { cls = 'badge bg-info text-dark'; icon = 'fa-clone'; title = this.statusDuplicateTitleValue; } + else if (status === 'ignored') { cls = 'badge bg-light text-dark border'; icon = 'fa-eye-slash'; title = this.statusIgnoredTitleValue; } + else if (status === 'ready') { cls = 'badge bg-success'; icon = 'fa-check'; title = this.statusReadyTitleValue; } + + first.className = cls; + first.setAttribute('title', title); + const inner = first.querySelector('i'); + if (inner) inner.className = 'fas ' + icon; + // Refresh tooltip. + const tooltip = window.bootstrap?.Tooltip?.getInstance(first); + if (tooltip) tooltip.setContent({ '.tooltip-inner': title }); + } + + _refreshIgnoreButton(row, isIgnored) { + const btn = row.querySelector('[data-bank-import-preview-target="ignoreButton"]'); + if (!btn) return; + btn.classList.toggle('btn-secondary', isIgnored); + btn.classList.toggle('btn-outline-secondary', !isIgnored); + const icon = btn.querySelector('i'); + if (icon) icon.className = 'fas ' + (isIgnored ? 'fa-eye-slash' : 'fa-eye'); + } + + _refreshFilterCounts(counts) { + this.filterChipTargets.forEach((chip) => { + const filter = chip.dataset.filter; + if (counts[filter] === undefined) return; + const badge = chip.querySelector('.badge'); + if (badge) badge.textContent = counts[filter]; + }); + } + + _flashSaved(row) { + row.classList.add('is-saved'); + setTimeout(() => row.classList.remove('is-saved'), 800); + } + + // ── Split modal ─────────────────────────────────────────────────── + + openSplit(event) { + const row = event.currentTarget.closest('tr'); + const idx = row?.dataset.idx; + if (idx === undefined) return; + + const line = this._lineByIdx(idx); + if (!line) return; + + this.activeIdx = idx; + this.splitTotalTarget.textContent = this._formatAmount(line.amount); + this.splitRowsTarget.innerHTML = ''; + + if (Array.isArray(line.splits) && line.splits.length > 0) { + line.splits.forEach((split) => this._addSplitRow(split)); + } else { + // Seed with two empty rows so the user has something to fill in. + this._addSplitRow(); + this._addSplitRow(); + } + + this._splitRecalc(); + this._showModal(this.splitModalTarget); + } + + splitAddRow() { + this._addSplitRow(); + this._splitRecalc(); + } + + splitRemoveRow(event) { + event.currentTarget.closest('.split-row')?.remove(); + this._splitRecalc(); + } + + splitRecalc() { + this._splitRecalc(); + } + + async splitSubmit() { + if (this.activeIdx === null) return; + + // Resolve percent/remainder modes to absolute amounts using the line's + // total — the per-line endpoint expects fixed amounts only. + const total = Math.abs(parseFloat((this._lineByIdx(this.activeIdx) || {}).amount || '0')); + const rows = Array.from(this.splitRowsTarget.querySelectorAll('.split-row')); + const raw = rows.map((rowEl) => ({ + mode: rowEl.querySelector('.split-mode')?.value || 'amount', + value: parseFloat(rowEl.querySelector('.split-value')?.value || '0') || 0, + debitAccountId: rowEl.querySelector('.split-debit')?.value || '', + creditAccountId: rowEl.querySelector('.split-credit')?.value || '', + taxRateId: rowEl.querySelector('.split-tax-rate')?.value || '', + remark: rowEl.querySelector('.split-remark')?.value || '', + })); + + let assigned = 0; + const resolved = raw.map((r) => { + if (r.mode === 'percent') { + const a = total * (r.value / 100); + assigned += a; + return { ...r, amount: a }; + } + if (r.mode === 'amount') { + assigned += r.value; + return { ...r, amount: r.value }; + } + return { ...r, amount: 0 }; // remainder placeholder + }); + const remainder = Math.max(0, total - assigned); + const splits = resolved + .map((r) => (r.mode === 'remainder' ? { ...r, amount: remainder } : r)) + .filter((s) => s.amount > 0); + + const body = new URLSearchParams(); + body.set('_token', this.csrfValue); + splits.forEach((s, i) => { + body.append(`splits[${i}][amount]`, s.amount.toFixed(2)); + body.append(`splits[${i}][debitAccountId]`, s.debitAccountId); + body.append(`splits[${i}][creditAccountId]`, s.creditAccountId); + body.append(`splits[${i}][taxRateId]`, s.taxRateId); + body.append(`splits[${i}][remark]`, s.remark); + }); + + try { + const res = await fetch(this._lineSubResourceUrl(this.activeIdx, 'split'), { + method: 'POST', + headers: { 'X-Requested-With': 'XMLHttpRequest' }, + body, + }); + if (!res.ok) throw new Error('http ' + res.status); + // The whole row visualisation needs refreshing — easiest via reload. + window.location.reload(); + } catch { + // eslint-disable-next-line no-alert + alert(this.saveFailedMessageValue); + } + } + + _addSplitRow(prefill = null) { + const tpl = this.splitRowTemplateTarget.content.firstElementChild.cloneNode(true); + if (prefill) { + const modeSel = tpl.querySelector('.split-mode'); + const valueInput = tpl.querySelector('.split-value'); + const mode = prefill.mode + || (prefill.remainder ? 'remainder' : (prefill.percent !== undefined ? 'percent' : 'amount')); + if (modeSel) modeSel.value = mode; + if (valueInput) { + if (mode === 'percent') valueInput.value = prefill.percent ?? ''; + else if (mode === 'amount') valueInput.value = Math.abs(parseFloat(prefill.amount ?? 0)).toFixed(2); + else valueInput.value = ''; + } + const debitSel = tpl.querySelector('.split-debit'); + if (debitSel && prefill.debitAccountId) debitSel.value = String(prefill.debitAccountId); + const creditSel = tpl.querySelector('.split-credit'); + if (creditSel && prefill.creditAccountId) creditSel.value = String(prefill.creditAccountId); + const taxRateSel = tpl.querySelector('.split-tax-rate'); + if (taxRateSel && prefill.taxRateId) taxRateSel.value = String(prefill.taxRateId); + const remarkInput = tpl.querySelector('.split-remark'); + if (remarkInput) remarkInput.value = prefill.remark ?? prefill.remarkTemplate ?? ''; + } + this.splitRowsTarget.appendChild(tpl); + this._applyModeUI(tpl); + } + + splitModeChange(event) { + this._applyModeUI(event.currentTarget.closest('.split-row')); + this._splitRecalc(); + } + + _applyModeUI(rowEl) { + if (!rowEl) return; + const mode = rowEl.querySelector('.split-mode')?.value || 'amount'; + const input = rowEl.querySelector('.split-value'); + const unit = rowEl.querySelector('.split-unit'); + if (input) { + input.disabled = mode === 'remainder'; + if (mode === 'remainder') input.value = ''; + } + if (unit) unit.textContent = mode === 'percent' ? '%' : (mode === 'remainder' ? '↻' : '€'); + } + + _splitRecalc() { + const total = Math.abs(parseFloat((this._lineByIdx(this.activeIdx) || {}).amount || '0')); + let assigned = 0; + let hasRemainder = false; + + this.splitRowsTarget.querySelectorAll('.split-row').forEach((rowEl) => { + const mode = rowEl.querySelector('.split-mode')?.value || 'amount'; + const v = parseFloat(rowEl.querySelector('.split-value')?.value || '0') || 0; + if (mode === 'amount') assigned += v; + else if (mode === 'percent') assigned += total * (v / 100); + else if (mode === 'remainder') hasRemainder = true; + }); + + this.splitAssignedTarget.textContent = this._formatAmountValue(assigned); + const delta = total - assigned; + if (hasRemainder) { + this.splitDeltaTarget.textContent = delta >= 0 + ? this._formatText(this.splitRemainderLabelValue, { '%amount%': this._formatAmountValue(Math.max(0, delta)) }) + : this._formatText(this.splitTooMuchLabelValue, { '%amount%': this._formatAmountValue(delta) }); + this.splitDeltaTarget.className = delta >= 0 ? 'small text-muted' : 'small text-danger'; + } else if (Math.abs(delta) < 0.005) { + this.splitDeltaTarget.textContent = '✓'; + this.splitDeltaTarget.className = 'small text-success'; + } else if (delta > 0) { + this.splitDeltaTarget.textContent = this._formatText(this.splitOpenLabelValue, { '%amount%': this._formatAmountValue(delta) }); + this.splitDeltaTarget.className = 'small text-warning'; + } else { + this.splitDeltaTarget.textContent = this._formatText(this.splitTooMuchLabelValue, { '%amount%': this._formatAmountValue(delta) }); + this.splitDeltaTarget.className = 'small text-danger'; + } + } + + _formatAmount(rawSigned) { + const n = parseFloat(rawSigned); + const sign = n < 0 ? '-' : ''; + return sign + this._formatAmountValue(Math.abs(n)); + } + + _formatAmountValue(value) { + return this._formatNumber(value) + ' ' + this.currencySymbolValue; + } + + _formatNumber(n) { + const locale = this.localeValue || document.documentElement.lang || 'de-DE'; + return n.toLocaleString(locale, { minimumFractionDigits: 2, maximumFractionDigits: 2 }); + } + + _formatText(template, replacements = {}) { + return Object.entries(replacements).reduce( + (text, [search, replace]) => text.split(search).join(String(replace)), + template || '', + ); + } + + // ── Rule modal ──────────────────────────────────────────────────── + + openRule(event) { + const row = event.currentTarget.closest('tr'); + const idx = row?.dataset.idx; + if (idx === undefined) return; + const line = this._lineByIdx(idx); + if (!line) return; + + this.activeIdx = idx; + + // Suggest a sensible default name. + this.ruleNameTarget.value = (line.counterpartyName || this.ruleDefaultNameValue).slice(0, 80); + + const name = (line.counterpartyName || '').trim(); + this.ruleCondCounterpartyValueTarget.textContent = name || '—'; + this.ruleCondCounterpartyTarget.checked = name !== ''; + this.ruleCondCounterpartyTarget.disabled = name === ''; + + const iban = (line.counterpartyIban || '').trim(); + this.ruleCondIbanValueTarget.textContent = iban || '—'; + this.ruleCondIbanTarget.checked = false; + this.ruleCondIbanTarget.disabled = iban === ''; + + const direction = parseFloat(line.amount) >= 0 ? 'in' : 'out'; + this.ruleCondDirectionValueTarget.textContent = direction === 'in' + ? this.ruleCondDirectionValueTarget.dataset.in || direction + : this.ruleCondDirectionValueTarget.dataset.out || direction; + this.ruleCondDirectionTarget.checked = false; + + this.ruleCondPurposeTarget.checked = false; + this.ruleCondPurposeValueTarget.value = ''; + + // Pre-fill the action with the line's current edit state. + this.ruleDebitTarget.value = line.userDebitAccountId ? String(line.userDebitAccountId) : ''; + this.ruleCreditTarget.value = line.userCreditAccountId ? String(line.userCreditAccountId) : ''; + this.ruleTaxRateTarget.value = line.userTaxRateId ? String(line.userTaxRateId) : ''; + this.ruleRemarkTarget.value = line.userRemark || ''; + this.rulePriorityTarget.value = '50'; + this.ruleScopeTarget.checked = true; + + this._showModal(this.ruleModalTarget); + } + + async ruleSubmit() { + if (this.activeIdx === null) return; + + const conditionFields = []; + if (this.ruleCondCounterpartyTarget.checked) conditionFields.push('counterpartyName'); + if (this.ruleCondIbanTarget.checked) conditionFields.push('counterpartyIban'); + if (this.ruleCondDirectionTarget.checked) conditionFields.push('direction'); + if (this.ruleCondPurposeTarget.checked && this.ruleCondPurposeValueTarget.value.trim() !== '') { + conditionFields.push('purpose'); + } + + if (conditionFields.length === 0) { + // eslint-disable-next-line no-alert + alert(this.ruleConditionRequiredMessageValue); + return; + } + + const name = this.ruleNameTarget.value.trim(); + if (name === '') { + // eslint-disable-next-line no-alert + alert(this.ruleNameRequiredMessageValue); + return; + } + + const body = new URLSearchParams(); + body.set('_token', this.csrfValue); + body.set('name', name); + conditionFields.forEach((f) => body.append('conditionFields[]', f)); + body.set('purposeContains', this.ruleCondPurposeValueTarget.value.trim()); + // If the line has splits, persist them as a split-action rule — + // otherwise a plain assign rule. Modes (amount/percent/remainder) + // come straight from the split modal so "Rest"/"%"-cases survive. + const line = this._lineByIdx(this.activeIdx); + const lineSplits = Array.isArray(line?.splits) ? line.splits : []; + if (lineSplits.length > 0) { + body.set('actionMode', 'split'); + lineSplits.forEach((s, i) => { + body.append(`splits[${i}][amount]`, String(Math.abs(parseFloat(s.amount || 0)))); + body.append(`splits[${i}][debitAccountId]`, s.debitAccountId ?? ''); + body.append(`splits[${i}][creditAccountId]`, s.creditAccountId ?? ''); + body.append(`splits[${i}][taxRateId]`, s.taxRateId ?? ''); + body.append(`splits[${i}][remark]`, s.remark ?? ''); + }); + } else { + body.set('actionMode', 'assign'); + body.set('debitAccountId', this.ruleDebitTarget.value); + body.set('creditAccountId', this.ruleCreditTarget.value); + body.set('taxRateId', this.ruleTaxRateTarget.value); + body.set('remarkTemplate', this.ruleRemarkTarget.value); + } + body.set('priority', this.rulePriorityTarget.value || '50'); + body.set('scopeToBankAccount', this.ruleScopeTarget.checked ? '1' : '0'); + + try { + const res = await fetch(this._lineSubResourceUrl(this.activeIdx, 'rule'), { + method: 'POST', + headers: { 'X-Requested-With': 'XMLHttpRequest' }, + body, + }); + const json = await res.json(); + if (!res.ok) { + // eslint-disable-next-line no-alert + alert(this._formatText(this.errorPrefixValue, { '%message%': json?.error || res.status })); + return; + } + // The new rule may have applied to other lines too — reload. + window.location.reload(); + } catch { + // eslint-disable-next-line no-alert + alert(this.saveFailedMessageValue); + } + } + + // ── Modal helpers ───────────────────────────────────────────────── + + _showModal(element) { + const modal = window.bootstrap?.Modal?.getOrCreateInstance(element); + modal?.show(); + } + + _lineSubResourceUrl(idx, suffix) { + return this.lineUrlPrefixValue + encodeURIComponent(idx) + '/' + suffix; + } +} diff --git a/assets/controllers/bank_import_profiles_controller.js b/assets/controllers/bank_import_profiles_controller.js new file mode 100644 index 00000000..b37e7b95 --- /dev/null +++ b/assets/controllers/bank_import_profiles_controller.js @@ -0,0 +1,46 @@ +import { Controller } from '@hotwired/stimulus'; +import { enableDeletePopover, enableTooltips, disposeTooltips } from '../js/utils.js'; + +/* stimulusFetch: 'lazy' */ + +export default class extends Controller { + static targets = ['offcanvas', 'offcanvasTitle', 'offcanvasBody']; + static values = { + formLoadFailed: String, + }; + + connect() { + enableDeletePopover({ root: this.element }); + enableTooltips(this.element); + } + + disconnect() { + disposeTooltips(this.element); + } + + async openOffcanvas(event) { + event.preventDefault(); + + const button = event.currentTarget; + const url = button.dataset.url; + const title = button.dataset.offcanvasTitle; + + this.offcanvasTitleTarget.textContent = title; + this.offcanvasBodyTarget.innerHTML = '
'; + + const offcanvas = window.bootstrap.Offcanvas.getOrCreateInstance(this.offcanvasTarget); + offcanvas.show(); + + try { + const response = await fetch(url, { + headers: { 'X-Requested-With': 'XMLHttpRequest' }, + }); + this.offcanvasBodyTarget.innerHTML = await response.text(); + } catch { + const alert = document.createElement('div'); + alert.className = 'alert alert-danger'; + alert.textContent = this.formLoadFailedValue; + this.offcanvasBodyTarget.replaceChildren(alert); + } + } +} diff --git a/assets/controllers/confirm_submit_controller.js b/assets/controllers/confirm_submit_controller.js new file mode 100644 index 00000000..69faba51 --- /dev/null +++ b/assets/controllers/confirm_submit_controller.js @@ -0,0 +1,123 @@ +import { Controller } from '@hotwired/stimulus'; +import { whenBootstrapAndIconsReady } from '../js/utils.js'; + +/** + * Generic confirm-before-submit guard. + * + * Attach to a
and set data-confirm-submit-message-value="...". + * Optional values: + * - data-confirm-submit-confirm-label-value + * - data-confirm-submit-cancel-label-value + */ +/* stimulusFetch: 'lazy' */ +export default class extends Controller { + static values = { + message: String, + confirmLabel: String, + cancelLabel: String, + }; + + connect() { + this.ready = false; + this.popover = null; + this.submitter = null; + this.element.addEventListener('submit', this._guard); + this.readyPromise = whenBootstrapAndIconsReady().then((ready) => { + this.ready = ready; + return ready; + }); + } + + disconnect() { + this.element.removeEventListener('submit', this._guard); + this._hidePopover(); + } + + _guard = (event) => { + if (this.element.dataset.confirmed === '1') { + return; + } + + if (!this.hasMessageValue || this.messageValue.trim() === '') { + return; + } + + event.preventDefault(); + this.submitter = event.submitter || this.element.querySelector('[type="submit"]') || this.element; + + if (!this.ready) { + this.readyPromise.then((ready) => { + if (ready) { + this._showPopover(this.submitter); + } + }); + return; + } + + this._showPopover(this.submitter); + }; + + _showPopover(anchor) { + this._hidePopover(); + + this.popover = new window.bootstrap.Popover(anchor, { + content: () => this._buildContent(), + html: true, + placement: 'top', + sanitize: false, + trigger: 'manual', + }); + this.popover.show(); + } + + _buildContent() { + const wrapper = document.createElement('div'); + wrapper.className = 'text-center'; + + const message = document.createElement('p'); + message.className = 'mb-2 small'; + message.textContent = this.messageValue; + wrapper.appendChild(message); + + const actions = document.createElement('div'); + actions.className = 'd-flex gap-2 justify-content-center'; + wrapper.appendChild(actions); + + const confirm = document.createElement('button'); + confirm.type = 'button'; + confirm.className = 'btn btn-primary btn-sm'; + confirm.textContent = this.confirmLabelValue || 'OK'; + confirm.addEventListener('click', () => this._confirm()); + actions.appendChild(confirm); + + const cancel = document.createElement('button'); + cancel.type = 'button'; + cancel.className = 'btn btn-outline-secondary btn-sm'; + cancel.textContent = this.cancelLabelValue || 'Cancel'; + cancel.addEventListener('click', () => this._hidePopover()); + actions.appendChild(cancel); + + return wrapper; + } + + _confirm() { + this.element.dataset.confirmed = '1'; + this._hidePopover(); + + if (this.submitter instanceof HTMLElement && typeof this.element.requestSubmit === 'function') { + this.element.requestSubmit(this.submitter); + return; + } + + this.element.submit(); + } + + _hidePopover() { + if (!this.popover) { + return; + } + this.popover.hide(); + this.popover.dispose(); + this.popover = null; + } +} diff --git a/config/services.yaml b/config/services.yaml index d6f73e03..ef484480 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -36,6 +36,8 @@ services: tags: ['app.workflow.condition'] App\Workflow\Action\WorkflowActionInterface: tags: ['app.workflow.action'] + App\Service\BookingJournal\BankImport\Parser\ParserInterface: + tags: ['app.bank_import.parser'] # makes classes in src/ available to be used as services # this creates a service per class whose id is the fully-qualified class name diff --git a/migrations/Version20260424190000.php b/migrations/Version20260424190000.php new file mode 100644 index 00000000..739563eb --- /dev/null +++ b/migrations/Version20260424190000.php @@ -0,0 +1,119 @@ +addSql('CREATE TABLE bank_csv_profiles ( + id INT AUTO_INCREMENT NOT NULL, + name VARCHAR(100) NOT NULL, + description LONGTEXT DEFAULT NULL, + delimiter VARCHAR(3) NOT NULL, + enclosure VARCHAR(1) NOT NULL, + encoding VARCHAR(20) NOT NULL, + header_skip INT NOT NULL DEFAULT 0, + has_header_row TINYINT(1) NOT NULL DEFAULT 1, + column_map JSON NOT NULL, + date_format VARCHAR(20) NOT NULL, + amount_decimal_separator VARCHAR(1) NOT NULL, + amount_thousands_separator VARCHAR(1) DEFAULT NULL, + direction_mode VARCHAR(20) NOT NULL, + iban_source_line INT DEFAULT NULL, + period_source_line INT DEFAULT NULL, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL, + PRIMARY KEY (id) + ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci ENGINE = InnoDB'); + + $this->addSql('CREATE TABLE bank_import_rules ( + id INT AUTO_INCREMENT NOT NULL, + bank_account_id INT DEFAULT NULL, + name VARCHAR(150) NOT NULL, + description LONGTEXT DEFAULT NULL, + priority INT NOT NULL DEFAULT 0, + is_enabled TINYINT(1) NOT NULL DEFAULT 1, + conditions JSON NOT NULL, + action JSON NOT NULL, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL, + INDEX idx_bank_import_rule_enabled (is_enabled), + INDEX idx_bank_import_rule_priority (priority), + INDEX IDX_bank_import_rule_account (bank_account_id), + PRIMARY KEY (id), + CONSTRAINT FK_bank_import_rule_account FOREIGN KEY (bank_account_id) REFERENCES accounting_accounts (id) ON DELETE SET NULL + ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci ENGINE = InnoDB'); + + $this->addSql('CREATE TABLE bank_statement_imports ( + id INT AUTO_INCREMENT NOT NULL, + bank_account_id INT NOT NULL, + created_by_id INT DEFAULT NULL, + period_from DATE DEFAULT NULL, + period_to DATE DEFAULT NULL, + line_count_total INT NOT NULL DEFAULT 0, + line_count_committed INT NOT NULL DEFAULT 0, + line_count_ignored INT NOT NULL DEFAULT 0, + line_count_duplicate INT NOT NULL DEFAULT 0, + file_format VARCHAR(20) NOT NULL, + status VARCHAR(20) NOT NULL, + created_at DATETIME NOT NULL, + committed_at DATETIME DEFAULT NULL, + INDEX idx_bank_statement_import_account (bank_account_id), + INDEX idx_bank_statement_import_status (status), + INDEX IDX_bank_statement_import_user (created_by_id), + PRIMARY KEY (id), + CONSTRAINT FK_bank_statement_import_account FOREIGN KEY (bank_account_id) REFERENCES accounting_accounts (id), + CONSTRAINT FK_bank_statement_import_user FOREIGN KEY (created_by_id) REFERENCES users (id) ON DELETE SET NULL + ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci ENGINE = InnoDB'); + + $this->addSql('CREATE TABLE bank_import_fingerprints ( + id INT AUTO_INCREMENT NOT NULL, + bank_account_id INT NOT NULL, + booking_entry_id INT DEFAULT NULL, + statement_import_id INT DEFAULT NULL, + raw_hash VARCHAR(64) NOT NULL, + committed_at DATETIME NOT NULL, + UNIQUE INDEX uq_bank_fingerprint (bank_account_id, raw_hash), + INDEX IDX_bank_fingerprint_entry (booking_entry_id), + INDEX IDX_bank_fingerprint_import (statement_import_id), + PRIMARY KEY (id), + CONSTRAINT FK_bank_fingerprint_account FOREIGN KEY (bank_account_id) REFERENCES accounting_accounts (id) ON DELETE CASCADE, + CONSTRAINT FK_bank_fingerprint_entry FOREIGN KEY (booking_entry_id) REFERENCES booking_entries (id) ON DELETE SET NULL, + CONSTRAINT FK_bank_fingerprint_import FOREIGN KEY (statement_import_id) REFERENCES bank_statement_imports (id) ON DELETE SET NULL + ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci ENGINE = InnoDB'); + + $this->addSql('ALTER TABLE accounting_accounts ADD iban VARCHAR(34) DEFAULT NULL'); + $this->addSql('ALTER TABLE accounting_settings ADD invoice_number_samples JSON DEFAULT NULL'); + $this->addSql('ALTER TABLE booking_entries ADD split_group_uuid VARCHAR(36) DEFAULT NULL'); + $this->addSql('CREATE INDEX idx_booking_entry_split_group ON booking_entries (split_group_uuid)'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE bank_import_fingerprints DROP FOREIGN KEY FK_bank_fingerprint_account'); + $this->addSql('ALTER TABLE bank_import_fingerprints DROP FOREIGN KEY FK_bank_fingerprint_entry'); + $this->addSql('ALTER TABLE bank_import_fingerprints DROP FOREIGN KEY FK_bank_fingerprint_import'); + $this->addSql('ALTER TABLE bank_import_rules DROP FOREIGN KEY FK_bank_import_rule_account'); + $this->addSql('ALTER TABLE bank_statement_imports DROP FOREIGN KEY FK_bank_statement_import_account'); + $this->addSql('ALTER TABLE bank_statement_imports DROP FOREIGN KEY FK_bank_statement_import_user'); + $this->addSql('DROP TABLE bank_csv_profiles'); + $this->addSql('DROP TABLE bank_import_rules'); + $this->addSql('DROP TABLE bank_statement_imports'); + $this->addSql('DROP TABLE bank_import_fingerprints'); + $this->addSql('ALTER TABLE accounting_accounts DROP COLUMN iban'); + $this->addSql('ALTER TABLE accounting_settings DROP COLUMN invoice_number_samples'); + $this->addSql('DROP INDEX idx_booking_entry_split_group ON booking_entries'); + $this->addSql('ALTER TABLE booking_entries DROP COLUMN split_group_uuid'); + } +} diff --git a/src/Controller/BankCsvProfileController.php b/src/Controller/BankCsvProfileController.php new file mode 100644 index 00000000..4284e908 --- /dev/null +++ b/src/Controller/BankCsvProfileController.php @@ -0,0 +1,108 @@ +render('BookingJournal/BankImport/profiles_index.html.twig', [ + 'profiles' => $repo->findAllOrdered(), + ]); + } + + #[Route('/new', name: 'bank_import.profiles.new', methods: ['GET'])] + public function new(): Response + { + $profile = new BankCsvProfile(); + $form = $this->createForm(BankCsvProfileType::class, $profile, [ + 'action' => $this->generateUrl('bank_import.profiles.create'), + ]); + + return $this->renderProfileForm($form, $profile, isNew: true); + } + + #[Route('/create', name: 'bank_import.profiles.create', methods: ['POST'])] + public function create(Request $request, EntityManagerInterface $em): Response + { + $profile = new BankCsvProfile(); + $form = $this->createForm(BankCsvProfileType::class, $profile); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + $em->persist($profile); + $em->flush(); + $this->addFlash('success', 'accounting.bank_import.profile.flash.created'); + + return $this->redirectToRoute('bank_import.profiles.index'); + } + + return $this->renderProfileForm($form, $profile, isNew: true); + } + + #[Route('/{id}/edit', name: 'bank_import.profiles.edit', methods: ['GET'])] + public function edit(BankCsvProfile $profile): Response + { + $form = $this->createForm(BankCsvProfileType::class, $profile, [ + 'action' => $this->generateUrl('bank_import.profiles.update', ['id' => $profile->getId()]), + ]); + + return $this->renderProfileForm($form, $profile, isNew: false); + } + + #[Route('/{id}/update', name: 'bank_import.profiles.update', methods: ['POST'])] + public function update(BankCsvProfile $profile, Request $request, EntityManagerInterface $em): Response + { + $form = $this->createForm(BankCsvProfileType::class, $profile); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + $em->flush(); + $this->addFlash('success', 'accounting.bank_import.profile.flash.updated'); + + return $this->redirectToRoute('bank_import.profiles.index'); + } + + return $this->renderProfileForm($form, $profile, isNew: false); + } + + #[Route('/{id}/delete', name: 'bank_import.profiles.delete', methods: ['DELETE'])] + public function delete(BankCsvProfile $profile, EntityManagerInterface $em, Request $request): Response + { + if (!$this->isCsrfTokenValid('delete'.$profile->getId(), $request->request->get('_token'))) { + $this->addFlash('danger', 'flash.invalidtoken'); + + return new Response('', Response::HTTP_NO_CONTENT); + } + + $em->remove($profile); + $em->flush(); + $this->addFlash('success', 'accounting.bank_import.profile.flash.deleted'); + + return new Response('', Response::HTTP_NO_CONTENT); + } + + private function renderProfileForm($form, BankCsvProfile $profile, bool $isNew): Response + { + return $this->render('BookingJournal/BankImport/profile_form.html.twig', [ + 'form' => $form, + 'profile' => $profile, + 'isNew' => $isNew, + ]); + } +} diff --git a/src/Controller/BankImportController.php b/src/Controller/BankImportController.php new file mode 100644 index 00000000..9822ecd0 --- /dev/null +++ b/src/Controller/BankImportController.php @@ -0,0 +1,727 @@ +findAll() as $account) { + $accountsById[$account->getId()] = $account; + } + + $form = $this->createForm(BankStatementUploadType::class, null, [ + 'action' => $this->generateUrl('bank_import.upload'), + ]); + + return $this->render('BookingJournal/BankImport/index.html.twig', [ + 'drafts' => $drafts->list(), + 'accountsById' => $accountsById, + 'uploadForm' => $form, + ]); + } + + #[Route('/upload', name: 'bank_import.upload', methods: ['POST'])] + public function upload( + Request $request, + BankStatementParserRegistry $parsers, + BankImportDraftSession $drafts, + BankStatementDeduplicator $deduplicator, + InvoiceMatcher $invoiceMatcher, + BankImportRuleMatcher $ruleMatcher, + BankStatementImportRepository $statementImportRepo, + TranslatorInterface $translator, + ): Response { + $form = $this->createForm(BankStatementUploadType::class); + $form->handleRequest($request); + + if (!$form->isSubmitted() || !$form->isValid()) { + $this->addFlash('danger', $translator->trans('accounting.bank_import.upload.flash.invalid')); + + return $this->redirectToRoute('bank_import.index'); + } + + /** @var AccountingAccount $bankAccount */ + $bankAccount = $form->get('bankAccount')->getData(); + /** @var BankCsvProfile $profile */ + $profile = $form->get('csvProfile')->getData(); + /** @var UploadedFile $file */ + $file = $form->get('file')->getData(); + + try { + $result = $parsers->get(GenericCsvParser::FORMAT_KEY)->parse( + new \SplFileInfo($file->getPathname()), + $profile, + ); + } catch (\Throwable $e) { + $this->addFlash('danger', $translator->trans('accounting.bank_import.upload.flash.parse_failed', [ + '%message%' => $e->getMessage(), + ])); + + return $this->redirectToRoute('bank_import.index'); + } + + if ([] === $result->lines) { + $this->addFlash('warning', $translator->trans('accounting.bank_import.upload.flash.no_lines')); + + return $this->redirectToRoute('bank_import.index'); + } + + $state = ImportState::fromParseResult( + sessionImportId: '', + bankAccountId: (int) $bankAccount->getId(), + fileFormat: GenericCsvParser::FORMAT_KEY, + bankCsvProfileId: $profile->getId(), + originalFilename: $file->getClientOriginalName(), + result: $result, + ); + + $this->appendOverlapWarnings($state, $bankAccount, $statementImportRepo, $translator); + $this->prefillBankAccount($state, $bankAccount); + $deduplicator->annotate($state, $bankAccount); + $invoiceMatcher->annotate($state); + $ruleMatcher->annotate($state, $bankAccount); + $this->normalizeInitialStatuses($state); + + $sessionImportId = $drafts->create($state); + + if (null !== $state->sourceIban && null !== $bankAccount->getIban() + && $state->sourceIban !== $bankAccount->getIban()) { + $this->addFlash('warning', $translator->trans('accounting.bank_import.upload.flash.iban_mismatch', [ + '%file%' => $state->sourceIban, + '%account%' => $bankAccount->getIban(), + ])); + } + + return $this->redirectToRoute('bank_import.preview', ['sessionImportId' => $sessionImportId]); + } + + #[Route('/{sessionImportId}', name: 'bank_import.preview', methods: ['GET'], requirements: ['sessionImportId' => '[0-9a-f-]{36}'])] + public function preview( + string $sessionImportId, + BankImportDraftSession $drafts, + AccountingAccountRepository $accountRepo, + TaxRateRepository $taxRateRepo, + AccountingSettingsService $settingsService, + ): Response { + $state = $drafts->load($sessionImportId); + if (null === $state) { + $this->addFlash('warning', 'accounting.bank_import.draft.not_found'); + + return $this->redirectToRoute('bank_import.index'); + } + + $bankAccount = $accountRepo->find($state->bankAccountId); + if (null === $bankAccount) { + $drafts->discard($sessionImportId); + $this->addFlash('danger', 'accounting.bank_import.draft.account_missing'); + + return $this->redirectToRoute('bank_import.index'); + } + + $activePreset = $bankAccount->getChartPreset(); + $accounts = $accountRepo->findAllOrdered($activePreset); + $taxRates = $taxRateRepo->findValidAt(new \DateTimeImmutable(), $activePreset); + + return $this->render('BookingJournal/BankImport/preview.html.twig', [ + 'state' => $state, + 'bankAccount' => $bankAccount, + 'counts' => $this->countByStatus($state), + 'accounts' => $accounts, + 'taxRates' => $taxRates, + 'invoiceMatchingDisabled' => [] === $settingsService->getSettings()->getInvoiceNumberSamples(), + ]); + } + + #[Route('/{sessionImportId}/line/{idx}', name: 'bank_import.line.update', methods: ['POST'], requirements: ['sessionImportId' => '[0-9a-f-]{36}', 'idx' => '\d+'])] + public function updateLine( + string $sessionImportId, + int $idx, + Request $request, + BankImportDraftSession $drafts, + ): JsonResponse { + if (!$this->isCsrfTokenValid('bank_import_line_'.$sessionImportId, (string) $request->request->get('_token'))) { + return new JsonResponse(['error' => 'invalid_token'], Response::HTTP_FORBIDDEN); + } + + $state = $drafts->load($sessionImportId); + if (null === $state || !isset($state->lines[$idx])) { + return new JsonResponse(['error' => 'line_not_found'], Response::HTTP_NOT_FOUND); + } + + if (true === ($state->lines[$idx]['isDuplicate'] ?? false)) { + // Duplicates are read-only — silently ignore the change. + return new JsonResponse(['status' => $state->lines[$idx]['status']]); + } + + $field = (string) $request->request->get('field'); + $value = $request->request->get('value'); + + $line = &$state->lines[$idx]; + + switch ($field) { + case 'debitAccountId': + $line['userDebitAccountId'] = $this->normalizeAccountId($value); + break; + case 'creditAccountId': + $line['userCreditAccountId'] = $this->normalizeAccountId($value); + break; + case 'taxRateId': + $line['userTaxRateId'] = $this->normalizeTaxRateId($value); + break; + case 'remark': + $remark = trim((string) $value); + $line['userRemark'] = '' === $remark ? null : mb_substr($remark, 0, 255); + break; + case 'isIgnored': + $line['isIgnored'] = (bool) ((int) $value); + break; + default: + return new JsonResponse(['error' => 'unknown_field'], Response::HTTP_BAD_REQUEST); + } + + $line['status'] = $this->deriveStatus($line); + unset($line); + + $drafts->save($state); + + return new JsonResponse([ + 'status' => $state->lines[$idx]['status'], + 'counts' => $this->countByStatus($state), + ]); + } + + #[Route('/{sessionImportId}/line/{idx}/split', name: 'bank_import.line.split', methods: ['POST'], requirements: ['sessionImportId' => '[0-9a-f-]{36}', 'idx' => '\d+'])] + public function splitLine( + string $sessionImportId, + int $idx, + Request $request, + BankImportDraftSession $drafts, + ): JsonResponse { + if (!$this->isCsrfTokenValid('bank_import_line_'.$sessionImportId, (string) $request->request->get('_token'))) { + return new JsonResponse(['error' => 'invalid_token'], Response::HTTP_FORBIDDEN); + } + + $state = $drafts->load($sessionImportId); + if (null === $state || !isset($state->lines[$idx])) { + return new JsonResponse(['error' => 'line_not_found'], Response::HTTP_NOT_FOUND); + } + + if (true === ($state->lines[$idx]['isDuplicate'] ?? false)) { + return new JsonResponse(['error' => 'line_readonly'], Response::HTTP_CONFLICT); + } + + $rawSplits = $request->request->all('splits'); + if (!is_array($rawSplits)) { + $rawSplits = []; + } + + $line = &$state->lines[$idx]; + $isOutgoing = ((float) ($line['amount'] ?? 0)) < 0.0; + $splits = []; + + foreach ($rawSplits as $piece) { + if (!is_array($piece)) { + continue; + } + + $absAmount = abs((float) ($piece['amount'] ?? 0)); + if ($absAmount <= 0) { + continue; + } + $signed = $isOutgoing ? -$absAmount : $absAmount; + + $splits[] = [ + 'amount' => number_format($signed, 2, '.', ''), + 'debitAccountId' => $this->normalizeAccountId($piece['debitAccountId'] ?? null), + 'creditAccountId' => $this->normalizeAccountId($piece['creditAccountId'] ?? null), + 'taxRateId' => $this->normalizeTaxRateId($piece['taxRateId'] ?? null), + 'remark' => $this->cleanRemark($piece['remark'] ?? null), + ]; + } + + $line['splits'] = $splits; + $line['status'] = $this->deriveStatus($line); + unset($line); + + $drafts->save($state); + + return new JsonResponse([ + 'status' => $state->lines[$idx]['status'], + 'splitCount' => count($splits), + 'counts' => $this->countByStatus($state), + ]); + } + + #[Route('/{sessionImportId}/line/{idx}/rule', name: 'bank_import.line.save_rule', methods: ['POST'], requirements: ['sessionImportId' => '[0-9a-f-]{36}', 'idx' => '\d+'])] + public function saveRuleFromLine( + string $sessionImportId, + int $idx, + Request $request, + BankImportDraftSession $drafts, + AccountingAccountRepository $accountRepo, + BankImportRuleMatcher $ruleMatcher, + EntityManagerInterface $em, + ): JsonResponse { + if (!$this->isCsrfTokenValid('bank_import_line_'.$sessionImportId, (string) $request->request->get('_token'))) { + return new JsonResponse(['error' => 'invalid_token'], Response::HTTP_FORBIDDEN); + } + + $state = $drafts->load($sessionImportId); + if (null === $state || !isset($state->lines[$idx])) { + return new JsonResponse(['error' => 'line_not_found'], Response::HTTP_NOT_FOUND); + } + + $bankAccount = $accountRepo->find($state->bankAccountId); + if (null === $bankAccount) { + return new JsonResponse(['error' => 'account_missing'], Response::HTTP_NOT_FOUND); + } + + $name = trim((string) $request->request->get('name')); + if ('' === $name) { + return new JsonResponse(['error' => 'name_required'], Response::HTTP_BAD_REQUEST); + } + + $conditions = $this->buildConditionsFromRequest($request, $state->lines[$idx]); + if ([] === $conditions) { + return new JsonResponse(['error' => 'condition_required'], Response::HTTP_BAD_REQUEST); + } + + $action = $this->buildActionFromRequest($request); + + $rule = new BankImportRule(); + $rule->setName($name); + $rule->setPriority((int) $request->request->get('priority', 50)); + $rule->setIsEnabled(true); + $rule->setConditions($conditions); + $rule->setAction($action); + + if ('1' === (string) $request->request->get('scopeToBankAccount')) { + $rule->setBankAccount($bankAccount); + } + + $em->persist($rule); + $em->flush(); + + // Re-run the rule matcher so the freshly saved rule is applied to all + // remaining unassigned lines in this draft immediately. + $ruleMatcher->annotate($state, $bankAccount); + $drafts->save($state); + + return new JsonResponse([ + 'ruleId' => $rule->getId(), + 'counts' => $this->countByStatus($state), + ]); + } + + #[Route('/{sessionImportId}/bulk', name: 'bank_import.bulk', methods: ['POST'], requirements: ['sessionImportId' => '[0-9a-f-]{36}'])] + public function bulkAction( + string $sessionImportId, + Request $request, + BankImportDraftSession $drafts, + ): JsonResponse { + if (!$this->isCsrfTokenValid('bank_import_line_'.$sessionImportId, (string) $request->request->get('_token'))) { + return new JsonResponse(['error' => 'invalid_token'], Response::HTTP_FORBIDDEN); + } + + $state = $drafts->load($sessionImportId); + if (null === $state) { + return new JsonResponse(['error' => 'draft_not_found'], Response::HTTP_NOT_FOUND); + } + + $action = (string) $request->request->get('action'); + $indices = $request->request->all('indices'); + $indices = array_map('intval', is_array($indices) ? $indices : []); + + $touched = 0; + foreach ($indices as $idx) { + if (!isset($state->lines[$idx])) { + continue; + } + if (true === ($state->lines[$idx]['isDuplicate'] ?? false)) { + continue; + } + + $line = &$state->lines[$idx]; + + switch ($action) { + case 'ignore': + $line['isIgnored'] = true; + break; + case 'unignore': + $line['isIgnored'] = false; + break; + case 'assign_debit': + $line['userDebitAccountId'] = $this->normalizeAccountId($request->request->get('debitAccountId')); + break; + case 'assign_credit': + $line['userCreditAccountId'] = $this->normalizeAccountId($request->request->get('creditAccountId')); + break; + default: + unset($line); + + return new JsonResponse(['error' => 'unknown_action'], Response::HTTP_BAD_REQUEST); + } + + $line['status'] = $this->deriveStatus($line); + unset($line); + ++$touched; + } + + $drafts->save($state); + + return new JsonResponse([ + 'touched' => $touched, + 'counts' => $this->countByStatus($state), + ]); + } + + #[Route('/{sessionImportId}/commit', name: 'bank_import.commit', methods: ['POST'], requirements: ['sessionImportId' => '[0-9a-f-]{36}'])] + public function commit( + string $sessionImportId, + Request $request, + BankImportDraftSession $drafts, + AccountingAccountRepository $accountRepo, + BankStatementCommitter $committer, + TranslatorInterface $translator, + ): Response { + if (!$this->isCsrfTokenValid('bank_import_line_'.$sessionImportId, (string) $request->request->get('_token'))) { + $this->addFlash('danger', 'flash.invalidtoken'); + + return $this->redirectToRoute('bank_import.preview', ['sessionImportId' => $sessionImportId]); + } + + $state = $drafts->load($sessionImportId); + if (null === $state) { + $this->addFlash('warning', 'accounting.bank_import.draft.not_found'); + + return $this->redirectToRoute('bank_import.index'); + } + + $bankAccount = $accountRepo->find($state->bankAccountId); + if (null === $bankAccount) { + $drafts->discard($sessionImportId); + $this->addFlash('danger', 'accounting.bank_import.draft.account_missing'); + + return $this->redirectToRoute('bank_import.index'); + } + + try { + $result = $committer->commit($state, $bankAccount, $this->getUser() instanceof \App\Entity\User ? $this->getUser() : null); + } catch (\Throwable $e) { + $this->addFlash('danger', $translator->trans('accounting.bank_import.commit.flash.failed', [ + '%message%' => $e->getMessage(), + ])); + + return $this->redirectToRoute('bank_import.preview', ['sessionImportId' => $sessionImportId]); + } + + $this->addFlash('success', $translator->trans('accounting.bank_import.commit.flash.done', [ + '%committed%' => $result['committed'], + '%ignored%' => $result['ignored'], + '%duplicates%' => $result['duplicates'], + '%redated%' => $result['redated'], + ])); + + return $this->redirectToRoute('journal.overview'); + } + + #[Route('/{sessionImportId}/discard', name: 'bank_import.discard', methods: ['DELETE'], requirements: ['sessionImportId' => '[0-9a-f-]{36}'])] + public function discard( + string $sessionImportId, + Request $request, + BankImportDraftSession $drafts, + ): Response { + if (!$this->isCsrfTokenValid('delete'.$sessionImportId, $request->request->get('_token'))) { + $this->addFlash('danger', 'flash.invalidtoken'); + + return new Response('', Response::HTTP_NO_CONTENT); + } + + $drafts->discard($sessionImportId); + $this->addFlash('success', 'accounting.bank_import.draft.discarded'); + + return new Response('', Response::HTTP_NO_CONTENT); + } + + /** + * Builds rule conditions from the form. The user picks which fields of + * the source line should become conditions; we copy the line's actual + * values in and use sensible operators per field. + * + * @param array $line + * + * @return list + */ + private function buildConditionsFromRequest(Request $request, array $line): array + { + $fields = $request->request->all('conditionFields'); + if (!is_array($fields)) { + return []; + } + + $conditions = []; + foreach ($fields as $field) { + switch ($field) { + case BankImportRule::CONDITION_FIELD_COUNTERPARTY_NAME: + $value = trim((string) ($line['counterpartyName'] ?? '')); + if ('' !== $value) { + $conditions[] = ['field' => $field, 'operator' => BankImportRule::CONDITION_OP_CONTAINS, 'value' => $value]; + } + break; + case BankImportRule::CONDITION_FIELD_COUNTERPARTY_IBAN: + $value = trim((string) ($line['counterpartyIban'] ?? '')); + if ('' !== $value) { + $conditions[] = ['field' => $field, 'operator' => BankImportRule::CONDITION_OP_EQUALS, 'value' => $value]; + } + break; + case BankImportRule::CONDITION_FIELD_PURPOSE: + $needle = trim((string) $request->request->get('purposeContains', '')); + if ('' !== $needle) { + $conditions[] = ['field' => $field, 'operator' => BankImportRule::CONDITION_OP_CONTAINS, 'value' => $needle]; + } + break; + case BankImportRule::CONDITION_FIELD_DIRECTION: + $direction = ((float) ($line['amount'] ?? 0)) >= 0.0 ? 'in' : 'out'; + $conditions[] = ['field' => $field, 'operator' => BankImportRule::CONDITION_OP_EQUALS, 'value' => $direction]; + break; + } + } + + return $conditions; + } + + /** + * @return array + */ + private function buildActionFromRequest(Request $request): array + { + $mode = (string) $request->request->get('actionMode', BankImportRule::ACTION_MODE_ASSIGN); + + if (BankImportRule::ACTION_MODE_IGNORE === $mode) { + return ['mode' => BankImportRule::ACTION_MODE_IGNORE]; + } + + if (BankImportRule::ACTION_MODE_SPLIT === $mode) { + $rawSplits = $request->request->all('splits'); + $splits = []; + foreach (is_array($rawSplits) ? $rawSplits : [] as $piece) { + if (!is_array($piece)) { + continue; + } + $absAmount = abs((float) ($piece['amount'] ?? 0)); + if ($absAmount <= 0) { + continue; + } + $splits[] = [ + 'amount' => round($absAmount, 2), + 'debitAccountId' => $this->normalizeAccountId($piece['debitAccountId'] ?? null), + 'creditAccountId' => $this->normalizeAccountId($piece['creditAccountId'] ?? null), + 'taxRateId' => $this->normalizeTaxRateId($piece['taxRateId'] ?? null), + 'remarkTemplate' => $this->cleanRemark($piece['remark'] ?? null), + ]; + } + + return ['mode' => BankImportRule::ACTION_MODE_SPLIT, 'splits' => $splits]; + } + + return [ + 'mode' => BankImportRule::ACTION_MODE_ASSIGN, + 'debitAccountId' => $this->normalizeAccountId($request->request->get('debitAccountId')), + 'creditAccountId' => $this->normalizeAccountId($request->request->get('creditAccountId')), + 'taxRateId' => $this->normalizeTaxRateId($request->request->get('taxRateId')), + 'remarkTemplate' => $this->cleanRemark($request->request->get('remarkTemplate')), + ]; + } + + private function cleanRemark(mixed $value): ?string + { + if (null === $value) { + return null; + } + $value = trim((string) $value); + + return '' === $value ? null : mb_substr($value, 0, 255); + } + + /** + * Pre-fills the bank account on the side of every line that the user + * doesn't have to think about: debit for incoming amounts, credit for + * outgoing. Rules and manual edits later are free to overwrite this. + */ + private function prefillBankAccount(ImportState $state, AccountingAccount $bankAccount): void + { + $bankAccountId = (int) $bankAccount->getId(); + + foreach ($state->lines as &$line) { + if (((float) ($line['amount'] ?? 0)) >= 0.0) { + $line['userDebitAccountId'] = $bankAccountId; + } else { + $line['userCreditAccountId'] = $bankAccountId; + } + } + } + + private function normalizeAccountId(mixed $value): ?int + { + if (null === $value || '' === $value) { + return null; + } + + $id = (int) $value; + + return $id > 0 ? $id : null; + } + + private function normalizeTaxRateId(mixed $value): ?int + { + return $this->normalizeAccountId($value); + } + + /** + * @param array $line + */ + private function deriveStatus(array $line): string + { + if (true === ($line['isDuplicate'] ?? false)) { + return ImportState::LINE_STATUS_DUPLICATE; + } + + if (true === ($line['isIgnored'] ?? false)) { + return ImportState::LINE_STATUS_IGNORED; + } + + $hasSplits = !empty($line['splits']); + $hasInvoiceAutoMatch = null !== ($line['matchedInvoiceId'] ?? null) + && true === ($line['matchedInvoiceAmountMatches'] ?? false) + && ((float) ($line['amount'] ?? 0)) >= 0.0 + && null !== ($line['userDebitAccountId'] ?? null); + $hasAccounts = null !== ($line['userDebitAccountId'] ?? null) + && null !== ($line['userCreditAccountId'] ?? null); + + return ($hasSplits || $hasInvoiceAutoMatch || $hasAccounts) ? ImportState::LINE_STATUS_READY : ImportState::LINE_STATUS_PENDING; + } + + private function normalizeInitialStatuses(ImportState $state): void + { + foreach ($state->lines as &$line) { + if (true === ($line['isDuplicate'] ?? false)) { + $line['status'] = ImportState::LINE_STATUS_DUPLICATE; + continue; + } + + if (true === ($line['isIgnored'] ?? false)) { + $line['status'] = ImportState::LINE_STATUS_IGNORED; + continue; + } + + if (null !== ($line['matchedInvoiceId'] ?? null) + && true !== ($line['matchedInvoiceAmountMatches'] ?? false) + && empty($line['splits']) + ) { + $line['status'] = ImportState::LINE_STATUS_PENDING; + continue; + } + + $line['status'] = $this->deriveStatus($line); + } + unset($line); + } + + /** + * @return array{total: int, pending: int, ready: int, ignored: int, duplicate: int} + */ + private function countByStatus(ImportState $state): array + { + $counts = ['total' => 0, 'pending' => 0, 'ready' => 0, 'ignored' => 0, 'duplicate' => 0]; + foreach ($state->lines as $line) { + ++$counts['total']; + $status = $line['status'] ?? ImportState::LINE_STATUS_PENDING; + if (isset($counts[$status])) { + ++$counts[$status]; + } + } + + return $counts; + } + + private function appendOverlapWarnings( + ImportState $state, + AccountingAccount $bankAccount, + BankStatementImportRepository $statementImportRepo, + TranslatorInterface $translator, + ): void { + if (null === $state->periodFrom || null === $state->periodTo) { + return; + } + + try { + $periodFrom = new \DateTime($state->periodFrom); + $periodTo = new \DateTime($state->periodTo); + } catch (\Throwable) { + return; + } + + $overlaps = $statementImportRepo->findOverlapping($bankAccount, $periodFrom, $periodTo); + if ([] === $overlaps) { + return; + } + + $examples = []; + foreach (array_slice($overlaps, 0, 3) as $import) { + $from = $import->getPeriodFrom()?->format('d.m.Y') ?? '?'; + $to = $import->getPeriodTo()?->format('d.m.Y') ?? '?'; + $committedAt = $import->getCommittedAt()?->format('d.m.Y H:i') ?? '?'; + $examples[] = $translator->trans('accounting.bank_import.preview.overlap_warning.import', [ + '%from%' => $from, + '%to%' => $to, + '%committedAt%' => $committedAt, + '%committed%' => $import->getLineCountCommitted(), + ]); + } + + $more = max(0, count($overlaps) - count($examples)); + $moreLabel = $more > 0 + ? $translator->trans('accounting.bank_import.preview.overlap_warning.more', ['%count%' => $more]) + : ''; + $state->warnings[] = $translator->trans('accounting.bank_import.preview.overlap_warning.message', [ + '%from%' => $periodFrom->format('d.m.Y'), + '%to%' => $periodTo->format('d.m.Y'), + '%count%' => count($overlaps), + '%imports%' => implode('; ', $examples), + '%more%' => $moreLabel, + ]); + } +} diff --git a/src/Controller/BankImportRuleController.php b/src/Controller/BankImportRuleController.php new file mode 100644 index 00000000..76774ca2 --- /dev/null +++ b/src/Controller/BankImportRuleController.php @@ -0,0 +1,138 @@ +findAll() as $account) { + $accountsById[(int) $account->getId()] = $account; + } + + return $this->render('BookingJournal/BankImport/rules_index.html.twig', [ + 'rules' => $ruleRepo->findAllOrdered(), + 'accountsById' => $accountsById, + 'taxRatesById' => $this->mapTaxRatesById($taxRateRepo), + ]); + } + + #[Route('/{id}/edit', name: 'bank_import.rules.edit', methods: ['GET'])] + public function edit(BankImportRule $rule, AccountingAccountRepository $accountRepo, TaxRateRepository $taxRateRepo): Response + { + $form = $this->createForm(BankImportRuleType::class, $rule, [ + 'action' => $this->generateUrl('bank_import.rules.update', ['id' => $rule->getId()]), + ]); + + return $this->render('BookingJournal/BankImport/rule_form.html.twig', [ + 'form' => $form, + 'rule' => $rule, + 'accountsById' => $this->mapAccountsById($accountRepo), + 'taxRatesById' => $this->mapTaxRatesById($taxRateRepo), + ]); + } + + #[Route('/{id}/update', name: 'bank_import.rules.update', methods: ['POST'])] + public function update(BankImportRule $rule, Request $request, EntityManagerInterface $em, AccountingAccountRepository $accountRepo, TaxRateRepository $taxRateRepo): Response + { + $form = $this->createForm(BankImportRuleType::class, $rule); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + $em->flush(); + $this->addFlash('success', 'accounting.bank_import.rules.flash.updated'); + + return $this->redirectToRoute('bank_import.rules.index'); + } + + return $this->render('BookingJournal/BankImport/rule_form.html.twig', [ + 'form' => $form, + 'rule' => $rule, + 'accountsById' => $this->mapAccountsById($accountRepo), + 'taxRatesById' => $this->mapTaxRatesById($taxRateRepo), + ]); + } + + #[Route('/{id}/toggle', name: 'bank_import.rules.toggle', methods: ['POST'])] + public function toggle(BankImportRule $rule, Request $request, EntityManagerInterface $em): Response + { + if (!$this->isCsrfTokenValid('toggle'.$rule->getId(), (string) $request->request->get('_token'))) { + $this->addFlash('danger', 'flash.invalidtoken'); + + return $this->redirectToRoute('bank_import.rules.index'); + } + + $rule->setIsEnabled(!$rule->isEnabled()); + $em->flush(); + + return $this->redirectToRoute('bank_import.rules.index'); + } + + #[Route('/{id}/delete', name: 'bank_import.rules.delete', methods: ['DELETE'])] + public function delete(BankImportRule $rule, Request $request, EntityManagerInterface $em): Response + { + if (!$this->isCsrfTokenValid('delete'.$rule->getId(), (string) $request->request->get('_token'))) { + $this->addFlash('danger', 'flash.invalidtoken'); + + return new Response('', Response::HTTP_NO_CONTENT); + } + + $em->remove($rule); + $em->flush(); + $this->addFlash('success', 'accounting.bank_import.rules.flash.deleted'); + + return new Response('', Response::HTTP_NO_CONTENT); + } + + /** + * @return array + */ + private function mapAccountsById(AccountingAccountRepository $accountRepo): array + { + $accounts = []; + foreach ($accountRepo->findAll() as $account) { + $accounts[(int) $account->getId()] = $account; + } + + return $accounts; + } + + /** + * @return array + */ + private function mapTaxRatesById(TaxRateRepository $taxRateRepo): array + { + $taxRates = []; + foreach ($taxRateRepo->findAll() as $taxRate) { + $taxRates[(int) $taxRate->getId()] = $taxRate; + } + + return $taxRates; + } +} diff --git a/src/Controller/BookingJournalController.php b/src/Controller/BookingJournalController.php index ff504d54..a5c1a754 100644 --- a/src/Controller/BookingJournalController.php +++ b/src/Controller/BookingJournalController.php @@ -14,10 +14,10 @@ use App\Repository\AccountingSettingsRepository; use App\Repository\BookingBatchRepository; use App\Repository\BookingEntryRepository; -use App\Service\AccountingSettingsService; -use App\Service\BookingJournalService; +use App\Service\BookingJournal\AccountingSettingsService; +use App\Service\BookingJournal\BookingJournalService; use App\Service\JournalExport\DatevExportService; -use App\Service\OpeningBalanceService; +use App\Service\BookingJournal\OpeningBalanceService; use App\Service\TemplatesService; use Doctrine\ORM\EntityManagerInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; diff --git a/src/Controller/BookingJournalSettingsController.php b/src/Controller/BookingJournalSettingsController.php index 39cbcc8b..0bc1f696 100644 --- a/src/Controller/BookingJournalSettingsController.php +++ b/src/Controller/BookingJournalSettingsController.php @@ -15,8 +15,8 @@ use App\Repository\PriceRepository; use App\Repository\TaxRateRepository; use App\Repository\WorkflowRepository; -use App\Service\AccountingPresetSeeder; -use App\Service\AccountingSettingsService; +use App\Service\BookingJournal\AccountingPresetSeeder; +use App\Service\BookingJournal\AccountingSettingsService; use Doctrine\ORM\EntityManagerInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Request; diff --git a/src/Controller/PriceServiceController.php b/src/Controller/PriceServiceController.php index 6ed1c93c..7f0d2485 100644 --- a/src/Controller/PriceServiceController.php +++ b/src/Controller/PriceServiceController.php @@ -19,7 +19,7 @@ use App\Entity\ReservationOrigin; use App\Entity\RoomCategory; use App\Entity\TaxRate; -use App\Service\AccountingSettingsService; +use App\Service\BookingJournal\AccountingSettingsService; use App\Service\CSRFProtectionService; use App\Service\PriceService; use Doctrine\Persistence\ManagerRegistry; diff --git a/src/Controller/WorkflowController.php b/src/Controller/WorkflowController.php index bb7ea79a..2ad3bcb2 100644 --- a/src/Controller/WorkflowController.php +++ b/src/Controller/WorkflowController.php @@ -8,7 +8,7 @@ use App\Entity\Template; use App\Entity\Workflow; use App\Repository\AccountingAccountRepository; -use App\Service\AccountingSettingsService; +use App\Service\BookingJournal\AccountingSettingsService; use App\Service\AppSettingsService; use App\Repository\WorkflowLogRepository; use App\Repository\WorkflowRepository; diff --git a/src/Dto/BookingJournal/BankImport/ImportState.php b/src/Dto/BookingJournal/BankImport/ImportState.php new file mode 100644 index 00000000..6c3cfcbd --- /dev/null +++ b/src/Dto/BookingJournal/BankImport/ImportState.php @@ -0,0 +1,136 @@ +> $lines + */ + public function __construct( + public string $sessionImportId, + public int $bankAccountId, + public string $fileFormat, + public ?int $bankCsvProfileId, + public string $originalFilename, + public ?string $sourceIban, + public ?string $periodFrom, + public ?string $periodTo, + public \DateTimeImmutable $createdAt, + public array $lines = [], + public array $warnings = [], + ) { + } + + /** + * @param array $data + */ + public static function fromArray(array $data): self + { + return new self( + sessionImportId: (string) $data['sessionImportId'], + bankAccountId: (int) $data['bankAccountId'], + fileFormat: (string) $data['fileFormat'], + bankCsvProfileId: isset($data['bankCsvProfileId']) ? (int) $data['bankCsvProfileId'] : null, + originalFilename: (string) $data['originalFilename'], + sourceIban: $data['sourceIban'] ?? null, + periodFrom: $data['periodFrom'] ?? null, + periodTo: $data['periodTo'] ?? null, + createdAt: new \DateTimeImmutable((string) $data['createdAt']), + lines: $data['lines'] ?? [], + warnings: $data['warnings'] ?? [], + ); + } + + /** + * @return array + */ + public function toArray(): array + { + return [ + 'sessionImportId' => $this->sessionImportId, + 'bankAccountId' => $this->bankAccountId, + 'fileFormat' => $this->fileFormat, + 'bankCsvProfileId' => $this->bankCsvProfileId, + 'originalFilename' => $this->originalFilename, + 'sourceIban' => $this->sourceIban, + 'periodFrom' => $this->periodFrom, + 'periodTo' => $this->periodTo, + 'createdAt' => $this->createdAt->format(\DateTimeInterface::ATOM), + 'lines' => $this->lines, + 'warnings' => $this->warnings, + ]; + } + + /** + * Builds a fresh state from a {@see ParseResult}. + */ + public static function fromParseResult( + string $sessionImportId, + int $bankAccountId, + string $fileFormat, + ?int $bankCsvProfileId, + string $originalFilename, + ParseResult $result, + ): self { + $lines = []; + foreach ($result->lines as $idx => $dto) { + $lines[] = [ + 'idx' => $idx, + 'bookDate' => $dto->bookDate->format('Y-m-d'), + 'valueDate' => $dto->valueDate->format('Y-m-d'), + 'amount' => $dto->amount, + 'counterpartyName' => $dto->counterpartyName, + 'counterpartyIban' => $dto->counterpartyIban, + 'purpose' => $dto->purpose, + 'endToEndId' => $dto->endToEndId, + 'mandateReference' => $dto->mandateReference, + 'creditorId' => $dto->creditorId, + 'fingerprint' => $dto->fingerprint(), + 'status' => self::LINE_STATUS_PENDING, + 'isIgnored' => false, + 'isDuplicate' => false, + 'userDebitAccountId' => null, + 'userCreditAccountId' => null, + 'userTaxRateId' => null, + 'userRemark' => null, + 'appliedRuleId' => null, + 'matchedInvoiceId' => null, + 'matchedInvoiceNumber' => null, + 'matchedInvoiceAmountMatches' => false, + 'splits' => [], + ]; + } + + return new self( + sessionImportId: $sessionImportId, + bankAccountId: $bankAccountId, + fileFormat: $fileFormat, + bankCsvProfileId: $bankCsvProfileId, + originalFilename: $originalFilename, + sourceIban: $result->sourceIban, + periodFrom: $result->periodFrom?->format('Y-m-d'), + periodTo: $result->periodTo?->format('Y-m-d'), + createdAt: new \DateTimeImmutable(), + lines: $lines, + warnings: $result->warnings, + ); + } +} diff --git a/src/Dto/BookingJournal/BankImport/ParseResult.php b/src/Dto/BookingJournal/BankImport/ParseResult.php new file mode 100644 index 00000000..5b6dfb41 --- /dev/null +++ b/src/Dto/BookingJournal/BankImport/ParseResult.php @@ -0,0 +1,24 @@ + $lines + * @param list $warnings non-fatal issues encountered while parsing + */ + public function __construct( + public readonly array $lines, + public readonly ?string $sourceIban = null, + public readonly ?\DateTimeImmutable $periodFrom = null, + public readonly ?\DateTimeImmutable $periodTo = null, + public readonly array $warnings = [], + ) { + } +} diff --git a/src/Dto/BookingJournal/BankImport/StatementLineDto.php b/src/Dto/BookingJournal/BankImport/StatementLineDto.php new file mode 100644 index 00000000..01de28dd --- /dev/null +++ b/src/Dto/BookingJournal/BankImport/StatementLineDto.php @@ -0,0 +1,49 @@ +amount >= 0.0; + } + + /** + * Deterministic fingerprint used to detect duplicate lines across imports. + * Not reversible. Used only for comparison inside one bank account scope. + */ + public function fingerprint(): string + { + $normalizedPurpose = strtolower(preg_replace('/\s+/', ' ', trim($this->purpose)) ?? ''); + + return hash('sha256', implode('|', [ + $this->bookDate->format('Y-m-d'), + $this->valueDate->format('Y-m-d'), + $this->amount, + $this->counterpartyIban ?? '', + $normalizedPurpose, + $this->endToEndId ?? '', + ])); + } +} diff --git a/src/Entity/AccountingAccount.php b/src/Entity/AccountingAccount.php index 5638eb4d..6efea31a 100644 --- a/src/Entity/AccountingAccount.php +++ b/src/Entity/AccountingAccount.php @@ -12,6 +12,7 @@ #[ORM\Entity(repositoryClass: AccountingAccountRepository::class)] #[ORM\Table(name: 'accounting_accounts')] +#[ORM\UniqueConstraint(name: 'uniq_account_per_preset', columns: ['account_number', 'chart_preset'])] #[UniqueEntity(fields: ['accountNumber', 'chartPreset'])] class AccountingAccount { @@ -32,7 +33,7 @@ class AccountingAccount #[ORM\Column(type: Types::INTEGER)] private ?int $id = null; - #[ORM\Column(type: Types::STRING, length: 10, unique: true)] + #[ORM\Column(type: Types::STRING, length: 10)] #[Assert\NotBlank] #[Assert\Length(max: 10)] private string $accountNumber = ''; @@ -53,6 +54,10 @@ class AccountingAccount #[ORM\Column(type: Types::BOOLEAN, options: ['default' => false])] private bool $isBankAccount = false; + #[ORM\Column(type: Types::STRING, length: 34, nullable: true)] + #[Assert\Length(max: 34)] + private ?string $iban = null; + #[ORM\Column(type: Types::BOOLEAN, options: ['default' => false])] private bool $isOpeningBalanceAccount = false; @@ -153,6 +158,18 @@ public function setIsBankAccount(bool $isBankAccount): self return $this; } + public function getIban(): ?string + { + return $this->iban; + } + + public function setIban(?string $iban): self + { + $this->iban = $iban ? str_replace(' ', '', strtoupper($iban)) : null; + + return $this; + } + public function isOpeningBalanceAccount(): bool { return $this->isOpeningBalanceAccount; diff --git a/src/Entity/AccountingSettings.php b/src/Entity/AccountingSettings.php index 9052a986..1656d703 100644 --- a/src/Entity/AccountingSettings.php +++ b/src/Entity/AccountingSettings.php @@ -63,6 +63,15 @@ class AccountingSettings #[Assert\Length(max: 60)] private ?string $miscPositionLabel = null; + /** + * Up to three example invoice numbers from which {@see InvoiceNumberPatternBuilder} + * derives the matcher used during bank statement import. + * + * @var list|null + */ + #[ORM\Column(type: Types::JSON, nullable: true)] + private ?array $invoiceNumberSamples = null; + #[ORM\Column(type: Types::DATETIME_MUTABLE)] private \DateTime $updatedAt; @@ -183,4 +192,36 @@ public function setMiscPositionLabel(?string $miscPositionLabel): self return $this; } + + /** + * @return list + */ + public function getInvoiceNumberSamples(): array + { + return $this->invoiceNumberSamples ?? []; + } + + /** + * @param list|null $samples + */ + public function setInvoiceNumberSamples(?array $samples): self + { + if (null === $samples) { + $this->invoiceNumberSamples = null; + + return $this; + } + + $clean = []; + foreach ($samples as $sample) { + $value = trim((string) $sample); + if ('' !== $value) { + $clean[] = $value; + } + } + + $this->invoiceNumberSamples = [] === $clean ? null : array_values(array_slice($clean, 0, 3)); + + return $this; + } } diff --git a/src/Entity/BankCsvProfile.php b/src/Entity/BankCsvProfile.php new file mode 100644 index 00000000..8cc0803c --- /dev/null +++ b/src/Entity/BankCsvProfile.php @@ -0,0 +1,306 @@ + 0])] + #[Assert\Range(min: 0, max: 50)] + private int $headerSkip = 0; + + #[ORM\Column(type: Types::BOOLEAN, options: ['default' => true])] + private bool $hasHeaderRow = true; + + /** + * Mapping from logical target field to 0-based column index. + * Keys: bookDate, valueDate, counterpartyName, counterpartyIban, purpose, amount, + * amountDebit, amountCredit, endToEndId, mandateReference, creditorId. + * + * @var array + */ + #[ORM\Column(type: Types::JSON)] + private array $columnMap = []; + + #[ORM\Column(type: Types::STRING, length: 20)] + #[Assert\NotBlank] + #[Assert\Length(max: 20)] + private string $dateFormat = 'd.m.Y'; + + #[ORM\Column(type: Types::STRING, length: 1)] + #[Assert\Length(min: 1, max: 1)] + private string $amountDecimalSeparator = ','; + + #[ORM\Column(type: Types::STRING, length: 1, nullable: true)] + #[Assert\Length(max: 1)] + private ?string $amountThousandsSeparator = '.'; + + #[ORM\Column(type: Types::STRING, length: 20)] + #[Assert\NotBlank] + #[Assert\Choice(choices: self::VALID_DIRECTION_MODES)] + private string $directionMode = self::DIRECTION_SIGNED; + + #[ORM\Column(type: Types::INTEGER, nullable: true)] + #[Assert\Range(min: 0, max: 50)] + private ?int $ibanSourceLine = null; + + #[ORM\Column(type: Types::INTEGER, nullable: true)] + #[Assert\Range(min: 0, max: 50)] + private ?int $periodSourceLine = null; + + #[ORM\Column(type: Types::DATETIME_IMMUTABLE)] + private \DateTimeImmutable $createdAt; + + #[ORM\Column(type: Types::DATETIME_IMMUTABLE)] + private \DateTimeImmutable $updatedAt; + + public function __construct() + { + $this->createdAt = new \DateTimeImmutable(); + $this->updatedAt = new \DateTimeImmutable(); + } + + #[ORM\PreUpdate] + public function onPreUpdate(): void + { + $this->updatedAt = new \DateTimeImmutable(); + } + + public function getId(): ?int + { + return $this->id; + } + + public function getName(): string + { + return $this->name; + } + + public function setName(string $name): self + { + $this->name = $name; + + return $this; + } + + public function getDescription(): ?string + { + return $this->description; + } + + public function setDescription(?string $description): self + { + $this->description = $description; + + return $this; + } + + public function getDelimiter(): string + { + return $this->delimiter; + } + + public function setDelimiter(string $delimiter): self + { + $this->delimiter = $delimiter; + + return $this; + } + + public function getEnclosure(): string + { + return $this->enclosure; + } + + public function setEnclosure(string $enclosure): self + { + $this->enclosure = $enclosure; + + return $this; + } + + public function getEncoding(): string + { + return $this->encoding; + } + + public function setEncoding(string $encoding): self + { + $this->encoding = $encoding; + + return $this; + } + + public function getHeaderSkip(): int + { + return $this->headerSkip; + } + + public function setHeaderSkip(int $headerSkip): self + { + $this->headerSkip = $headerSkip; + + return $this; + } + + public function hasHeaderRow(): bool + { + return $this->hasHeaderRow; + } + + public function setHasHeaderRow(bool $hasHeaderRow): self + { + $this->hasHeaderRow = $hasHeaderRow; + + return $this; + } + + /** + * @return array + */ + public function getColumnMap(): array + { + return $this->columnMap; + } + + /** + * @param array $columnMap + */ + public function setColumnMap(array $columnMap): self + { + $this->columnMap = $columnMap; + + return $this; + } + + public function getDateFormat(): string + { + return $this->dateFormat; + } + + public function setDateFormat(string $dateFormat): self + { + $this->dateFormat = $dateFormat; + + return $this; + } + + public function getAmountDecimalSeparator(): string + { + return $this->amountDecimalSeparator; + } + + public function setAmountDecimalSeparator(string $separator): self + { + $this->amountDecimalSeparator = $separator; + + return $this; + } + + public function getAmountThousandsSeparator(): ?string + { + return $this->amountThousandsSeparator; + } + + public function setAmountThousandsSeparator(?string $separator): self + { + $this->amountThousandsSeparator = $separator; + + return $this; + } + + public function getDirectionMode(): string + { + return $this->directionMode; + } + + public function setDirectionMode(string $directionMode): self + { + $this->directionMode = $directionMode; + + return $this; + } + + public function getIbanSourceLine(): ?int + { + return $this->ibanSourceLine; + } + + public function setIbanSourceLine(?int $line): self + { + $this->ibanSourceLine = $line; + + return $this; + } + + public function getPeriodSourceLine(): ?int + { + return $this->periodSourceLine; + } + + public function setPeriodSourceLine(?int $line): self + { + $this->periodSourceLine = $line; + + return $this; + } + + public function getCreatedAt(): \DateTimeImmutable + { + return $this->createdAt; + } + + public function getUpdatedAt(): \DateTimeImmutable + { + return $this->updatedAt; + } + + public function __toString(): string + { + return $this->name; + } +} diff --git a/src/Entity/BankImportFingerprint.php b/src/Entity/BankImportFingerprint.php new file mode 100644 index 00000000..997ec83d --- /dev/null +++ b/src/Entity/BankImportFingerprint.php @@ -0,0 +1,97 @@ +bankAccount = $bankAccount; + $this->rawHash = $rawHash; + $this->committedAt = new \DateTimeImmutable(); + } + + public function getId(): ?int + { + return $this->id; + } + + public function getBankAccount(): AccountingAccount + { + return $this->bankAccount; + } + + public function getRawHash(): string + { + return $this->rawHash; + } + + public function getBookingEntry(): ?BookingEntry + { + return $this->bookingEntry; + } + + public function setBookingEntry(?BookingEntry $bookingEntry): self + { + $this->bookingEntry = $bookingEntry; + + return $this; + } + + public function getStatementImport(): ?BankStatementImport + { + return $this->statementImport; + } + + public function setStatementImport(?BankStatementImport $statementImport): self + { + $this->statementImport = $statementImport; + + return $this; + } + + public function getCommittedAt(): \DateTimeImmutable + { + return $this->committedAt; + } +} diff --git a/src/Entity/BankImportRule.php b/src/Entity/BankImportRule.php new file mode 100644 index 00000000..b8b4b08a --- /dev/null +++ b/src/Entity/BankImportRule.php @@ -0,0 +1,222 @@ + 0])] + private int $priority = 0; + + #[ORM\Column(type: Types::BOOLEAN, options: ['default' => true])] + private bool $isEnabled = true; + + /** + * Array of condition objects. All conditions must match (AND logic). + * Each element: { field: string, operator: string, value: mixed } + * + * @var list + */ + #[ORM\Column(type: Types::JSON)] + private array $conditions = []; + + /** + * Action to perform when all conditions match. + * Mode "assign": { mode: "assign", debitAccountId: int|null, creditAccountId: int|null, taxRateId: int|null, remarkTemplate: string|null } + * Mode "split": { mode: "split", splits: [{ amount: float|null, percent: float|null, remainder: bool, debitAccountId: int, creditAccountId: int, taxRateId: int|null, remarkTemplate: string|null }] } + * Mode "ignore": { mode: "ignore" } + * + * @var array + */ + #[ORM\Column(type: Types::JSON)] + private array $action = ['mode' => self::ACTION_MODE_IGNORE]; + + #[ORM\Column(type: Types::DATETIME_IMMUTABLE)] + private \DateTimeImmutable $createdAt; + + #[ORM\Column(type: Types::DATETIME_IMMUTABLE)] + private \DateTimeImmutable $updatedAt; + + public function __construct() + { + $this->createdAt = new \DateTimeImmutable(); + $this->updatedAt = new \DateTimeImmutable(); + } + + #[ORM\PreUpdate] + public function onPreUpdate(): void + { + $this->updatedAt = new \DateTimeImmutable(); + } + + public function getId(): ?int + { + return $this->id; + } + + public function getName(): string + { + return $this->name; + } + + public function setName(string $name): self + { + $this->name = $name; + + return $this; + } + + public function getDescription(): ?string + { + return $this->description; + } + + public function setDescription(?string $description): self + { + $this->description = $description; + + return $this; + } + + public function getBankAccount(): ?AccountingAccount + { + return $this->bankAccount; + } + + public function setBankAccount(?AccountingAccount $bankAccount): self + { + $this->bankAccount = $bankAccount; + + return $this; + } + + public function getPriority(): int + { + return $this->priority; + } + + public function setPriority(int $priority): self + { + $this->priority = $priority; + + return $this; + } + + public function isEnabled(): bool + { + return $this->isEnabled; + } + + public function setIsEnabled(bool $isEnabled): self + { + $this->isEnabled = $isEnabled; + + return $this; + } + + /** + * @return list + */ + public function getConditions(): array + { + return $this->conditions; + } + + /** + * @param list $conditions + */ + public function setConditions(array $conditions): self + { + $this->conditions = $conditions; + + return $this; + } + + /** + * @return array + */ + public function getAction(): array + { + return $this->action; + } + + /** + * @param array $action + */ + public function setAction(array $action): self + { + $this->action = $action; + + return $this; + } + + public function getActionMode(): string + { + return $this->action['mode'] ?? self::ACTION_MODE_IGNORE; + } + + public function getCreatedAt(): \DateTimeImmutable + { + return $this->createdAt; + } + + public function getUpdatedAt(): \DateTimeImmutable + { + return $this->updatedAt; + } + + public function __toString(): string + { + return $this->name; + } +} diff --git a/src/Entity/BankStatementImport.php b/src/Entity/BankStatementImport.php new file mode 100644 index 00000000..d1cdffba --- /dev/null +++ b/src/Entity/BankStatementImport.php @@ -0,0 +1,210 @@ + 0])] + private int $lineCountTotal = 0; + + #[ORM\Column(type: Types::INTEGER, options: ['default' => 0])] + private int $lineCountCommitted = 0; + + #[ORM\Column(type: Types::INTEGER, options: ['default' => 0])] + private int $lineCountIgnored = 0; + + #[ORM\Column(type: Types::INTEGER, options: ['default' => 0])] + private int $lineCountDuplicate = 0; + + #[ORM\Column(type: Types::STRING, length: 20)] + private string $fileFormat = 'csv_generic'; + + #[ORM\Column(type: Types::STRING, length: 20)] + private string $status = self::STATUS_COMMITTED; + + #[ORM\Column(type: Types::DATETIME_IMMUTABLE)] + private \DateTimeImmutable $createdAt; + + #[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true)] + private ?\DateTimeImmutable $committedAt = null; + + public function __construct(AccountingAccount $bankAccount) + { + $this->bankAccount = $bankAccount; + $this->createdAt = new \DateTimeImmutable(); + } + + public function getId(): ?int + { + return $this->id; + } + + public function getBankAccount(): AccountingAccount + { + return $this->bankAccount; + } + + public function setBankAccount(AccountingAccount $bankAccount): self + { + $this->bankAccount = $bankAccount; + + return $this; + } + + public function getCreatedBy(): ?User + { + return $this->createdBy; + } + + public function setCreatedBy(?User $createdBy): self + { + $this->createdBy = $createdBy; + + return $this; + } + + public function getPeriodFrom(): ?\DateTime + { + return $this->periodFrom; + } + + public function setPeriodFrom(?\DateTime $periodFrom): self + { + $this->periodFrom = $periodFrom; + + return $this; + } + + public function getPeriodTo(): ?\DateTime + { + return $this->periodTo; + } + + public function setPeriodTo(?\DateTime $periodTo): self + { + $this->periodTo = $periodTo; + + return $this; + } + + public function getLineCountTotal(): int + { + return $this->lineCountTotal; + } + + public function setLineCountTotal(int $count): self + { + $this->lineCountTotal = $count; + + return $this; + } + + public function getLineCountCommitted(): int + { + return $this->lineCountCommitted; + } + + public function setLineCountCommitted(int $count): self + { + $this->lineCountCommitted = $count; + + return $this; + } + + public function getLineCountIgnored(): int + { + return $this->lineCountIgnored; + } + + public function setLineCountIgnored(int $count): self + { + $this->lineCountIgnored = $count; + + return $this; + } + + public function getLineCountDuplicate(): int + { + return $this->lineCountDuplicate; + } + + public function setLineCountDuplicate(int $count): self + { + $this->lineCountDuplicate = $count; + + return $this; + } + + public function getFileFormat(): string + { + return $this->fileFormat; + } + + public function setFileFormat(string $fileFormat): self + { + $this->fileFormat = $fileFormat; + + return $this; + } + + public function getStatus(): string + { + return $this->status; + } + + public function setStatus(string $status): self + { + $this->status = $status; + + return $this; + } + + public function getCreatedAt(): \DateTimeImmutable + { + return $this->createdAt; + } + + public function getCommittedAt(): ?\DateTimeImmutable + { + return $this->committedAt; + } + + public function setCommittedAt(?\DateTimeImmutable $committedAt): self + { + $this->committedAt = $committedAt; + + return $this; + } +} diff --git a/src/Entity/BookingEntry.php b/src/Entity/BookingEntry.php index 2c538547..70e8d49d 100644 --- a/src/Entity/BookingEntry.php +++ b/src/Entity/BookingEntry.php @@ -10,6 +10,7 @@ #[ORM\Entity(repositoryClass: BookingEntryRepository::class)] #[ORM\Table(name: 'booking_entries')] +#[ORM\Index(name: 'idx_booking_entry_split_group', columns: ['split_group_uuid'])] class BookingEntry { public const SOURCE_MANUAL = 'manual'; @@ -62,6 +63,14 @@ class BookingEntry #[ORM\Column(type: Types::STRING, length: 30, nullable: true)] private ?string $sourceType = null; + /** + * Groups entries that originate from the same underlying document, e.g. a bank + * statement line split across multiple debit accounts. Entries with the same + * UUID are rendered together in the journal view. + */ + #[ORM\Column(type: Types::STRING, length: 36, nullable: true)] + private ?string $splitGroupUuid = null; + #[ORM\Column(type: Types::DATETIME_IMMUTABLE)] private \DateTimeImmutable $createdAt; @@ -246,6 +255,18 @@ public function getCreatedAt(): \DateTimeImmutable return $this->createdAt; } + public function getSplitGroupUuid(): ?string + { + return $this->splitGroupUuid; + } + + public function setSplitGroupUuid(?string $splitGroupUuid): self + { + $this->splitGroupUuid = $splitGroupUuid; + + return $this; + } + public function isOpeningBalance(): bool { return self::SOURCE_OPENING_BALANCE === $this->sourceType; diff --git a/src/Form/AccountingAccountType.php b/src/Form/AccountingAccountType.php index e2845325..c88b6ffc 100644 --- a/src/Form/AccountingAccountType.php +++ b/src/Form/AccountingAccountType.php @@ -41,6 +41,12 @@ public function buildForm(FormBuilderInterface $builder, array $options): void 'label' => 'accounting.accounts.is_bank', 'required' => false, ]) + ->add('iban', TextType::class, [ + 'label' => 'accounting.accounts.iban', + 'required' => false, + 'help' => 'accounting.accounts.iban.help', + 'attr' => ['maxlength' => 34, 'placeholder' => 'DE00…'], + ]) ->add('isOpeningBalanceAccount', CheckboxType::class, [ 'label' => 'accounting.accounts.is_opening_balance', 'required' => false, diff --git a/src/Form/AccountingSettingsType.php b/src/Form/AccountingSettingsType.php index 3d7c5ced..5b03fcff 100644 --- a/src/Form/AccountingSettingsType.php +++ b/src/Form/AccountingSettingsType.php @@ -7,13 +7,16 @@ use App\Entity\AccountingSettings; use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\Extension\Core\Type\ChoiceType; -use Symfony\Component\Form\Extension\Core\Type\IntegerType; use Symfony\Component\Form\Extension\Core\Type\TextType; use Symfony\Component\Form\FormBuilderInterface; +use Symfony\Component\Form\FormEvent; +use Symfony\Component\Form\FormEvents; use Symfony\Component\OptionsResolver\OptionsResolver; class AccountingSettingsType extends AbstractType { + public const INVOICE_SAMPLE_FIELDS = ['invoiceNumberSample1', 'invoiceNumberSample2', 'invoiceNumberSample3']; + public function buildForm(FormBuilderInterface $builder, array $options): void { $builder @@ -65,6 +68,53 @@ public function buildForm(FormBuilderInterface $builder, array $options): void ], ]) ; + + // Three synthetic text fields capture user-supplied invoice number + // examples; on submit they are folded into the JSON property + // {@see AccountingSettings::$invoiceNumberSamples}. + foreach (self::INVOICE_SAMPLE_FIELDS as $i => $name) { + $builder->add($name, TextType::class, [ + 'label' => 'accounting.settings.invoice_number_sample.'.($i + 1), + 'required' => false, + 'mapped' => false, + 'attr' => ['maxlength' => 50, 'placeholder' => match ($i) { + 0 => 'RE-12345', + 1 => '2026-0001', + default => '', + }], + ]); + } + + $builder->addEventListener(FormEvents::PRE_SET_DATA, function (FormEvent $event): void { + /** @var AccountingSettings|null $settings */ + $settings = $event->getData(); + if (!$settings instanceof AccountingSettings) { + return; + } + + $samples = $settings->getInvoiceNumberSamples(); + $form = $event->getForm(); + foreach (self::INVOICE_SAMPLE_FIELDS as $i => $name) { + if ($form->has($name)) { + $form->get($name)->setData($samples[$i] ?? ''); + } + } + }); + + $builder->addEventListener(FormEvents::SUBMIT, function (FormEvent $event): void { + /** @var AccountingSettings|null $settings */ + $settings = $event->getData(); + if (!$settings instanceof AccountingSettings) { + return; + } + + $form = $event->getForm(); + $samples = []; + foreach (self::INVOICE_SAMPLE_FIELDS as $name) { + $samples[] = (string) ($form->get($name)->getData() ?? ''); + } + $settings->setInvoiceNumberSamples($samples); + }); } public function configureOptions(OptionsResolver $resolver): void diff --git a/src/Form/BankCsvProfileType.php b/src/Form/BankCsvProfileType.php new file mode 100644 index 00000000..24190a8c --- /dev/null +++ b/src/Form/BankCsvProfileType.php @@ -0,0 +1,165 @@ + 'accounting.bank_import.profile.col.book_date', + 'valueDate' => 'accounting.bank_import.profile.col.value_date', + 'counterpartyName' => 'accounting.bank_import.profile.col.counterparty_name', + 'counterpartyIban' => 'accounting.bank_import.profile.col.counterparty_iban', + 'purpose' => 'accounting.bank_import.profile.col.purpose', + 'amount' => 'accounting.bank_import.profile.col.amount', + 'amountDebit' => 'accounting.bank_import.profile.col.amount_debit', + 'amountCredit' => 'accounting.bank_import.profile.col.amount_credit', + 'endToEndId' => 'accounting.bank_import.profile.col.end_to_end_id', + 'mandateReference' => 'accounting.bank_import.profile.col.mandate_reference', + 'creditorId' => 'accounting.bank_import.profile.col.creditor_id', + ]; + + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $builder + ->add('name', TextType::class, [ + 'label' => 'accounting.bank_import.profile.name', + 'attr' => ['maxlength' => 100, 'placeholder' => 'DKB Girokonto'], + ]) + ->add('description', TextareaType::class, [ + 'label' => 'accounting.bank_import.profile.description', + 'required' => false, + 'attr' => ['rows' => 2], + ]) + ->add('delimiter', TextType::class, [ + 'label' => 'accounting.bank_import.profile.delimiter', + 'attr' => ['maxlength' => 3, 'placeholder' => ';', 'class' => 'form-control-sm'], + ]) + ->add('enclosure', TextType::class, [ + 'label' => 'accounting.bank_import.profile.enclosure', + 'attr' => ['maxlength' => 1, 'placeholder' => '"', 'class' => 'form-control-sm'], + ]) + ->add('encoding', ChoiceType::class, [ + 'label' => 'accounting.bank_import.profile.encoding', + 'choices' => [ + 'UTF-8' => 'UTF-8', + 'ISO-8859-15 (Westeuropa)' => 'ISO-8859-15', + 'Windows-1252' => 'Windows-1252', + ], + 'attr' => ['class' => 'form-select-sm'], + ]) + ->add('headerSkip', IntegerType::class, [ + 'label' => 'accounting.bank_import.profile.header_skip', + 'help' => 'accounting.bank_import.profile.header_skip.help', + 'attr' => ['min' => 0, 'max' => 50, 'class' => 'form-control-sm'], + ]) + ->add('hasHeaderRow', CheckboxType::class, [ + 'label' => 'accounting.bank_import.profile.has_header_row', + 'required' => false, + ]) + ->add('dateFormat', TextType::class, [ + 'label' => 'accounting.bank_import.profile.date_format', + 'help' => 'accounting.bank_import.profile.date_format.help', + 'attr' => ['maxlength' => 20, 'placeholder' => 'd.m.Y', 'class' => 'form-control-sm'], + ]) + ->add('amountDecimalSeparator', TextType::class, [ + 'label' => 'accounting.bank_import.profile.amount_decimal_separator', + 'attr' => ['maxlength' => 1, 'placeholder' => ',', 'class' => 'form-control-sm'], + ]) + ->add('amountThousandsSeparator', TextType::class, [ + 'label' => 'accounting.bank_import.profile.amount_thousands_separator', + 'required' => false, + 'attr' => ['maxlength' => 1, 'placeholder' => '.', 'class' => 'form-control-sm'], + ]) + ->add('directionMode', ChoiceType::class, [ + 'label' => 'accounting.bank_import.profile.direction_mode', + 'help' => 'accounting.bank_import.profile.direction_mode.help', + 'choices' => [ + 'accounting.bank_import.profile.direction.signed' => BankCsvProfile::DIRECTION_SIGNED, + 'accounting.bank_import.profile.direction.separate_columns' => BankCsvProfile::DIRECTION_SEPARATE_COLUMNS, + ], + 'attr' => ['class' => 'form-select-sm'], + ]) + ->add('ibanSourceLine', IntegerType::class, [ + 'label' => 'accounting.bank_import.profile.iban_source_line', + 'help' => 'accounting.bank_import.profile.iban_source_line.help', + 'required' => false, + 'attr' => ['min' => 0, 'max' => 50, 'class' => 'form-control-sm'], + ]) + ->add('periodSourceLine', IntegerType::class, [ + 'label' => 'accounting.bank_import.profile.period_source_line', + 'help' => 'accounting.bank_import.profile.period_source_line.help', + 'required' => false, + 'attr' => ['min' => 0, 'max' => 50, 'class' => 'form-control-sm'], + ]); + + // Synthetic per-field column index inputs that are mapped to the JSON + // columnMap field on submission. + foreach (self::COLUMN_FIELDS as $key => $label) { + $builder->add('col_'.$key, IntegerType::class, [ + 'label' => $label, + 'required' => false, + 'mapped' => false, + 'attr' => ['min' => 0, 'max' => 100, 'class' => 'form-control-sm'], + ]); + } + + $builder->addEventListener(FormEvents::POST_SET_DATA, function (FormEvent $event): void { + /** @var BankCsvProfile|null $profile */ + $profile = $event->getData(); + if (!$profile instanceof BankCsvProfile) { + return; + } + + $form = $event->getForm(); + foreach ($profile->getColumnMap() as $key => $index) { + $child = 'col_'.$key; + if ($form->has($child)) { + $form->get($child)->setData($index); + } + } + }); + + $builder->addEventListener(FormEvents::SUBMIT, function (FormEvent $event): void { + /** @var BankCsvProfile|null $profile */ + $profile = $event->getData(); + if (!$profile instanceof BankCsvProfile) { + return; + } + + $form = $event->getForm(); + $columnMap = []; + foreach (array_keys(self::COLUMN_FIELDS) as $key) { + $value = $form->get('col_'.$key)->getData(); + if (null !== $value && '' !== $value) { + $columnMap[$key] = (int) $value; + } + } + $profile->setColumnMap($columnMap); + }); + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'data_class' => BankCsvProfile::class, + ]); + } +} diff --git a/src/Form/BankImportRuleType.php b/src/Form/BankImportRuleType.php new file mode 100644 index 00000000..ae2ab494 --- /dev/null +++ b/src/Form/BankImportRuleType.php @@ -0,0 +1,73 @@ +settingsService->getActivePreset(); + + $builder + ->add('name', TextType::class, [ + 'label' => 'accounting.bank_import.rules.field.name', + 'attr' => ['maxlength' => 150], + ]) + ->add('description', TextareaType::class, [ + 'label' => 'accounting.bank_import.rules.field.description', + 'required' => false, + 'attr' => ['rows' => 2], + ]) + ->add('priority', IntegerType::class, [ + 'label' => 'accounting.bank_import.rules.field.priority', + 'help' => 'accounting.bank_import.rules.field.priority.help', + 'attr' => ['min' => 0, 'max' => 999], + ]) + ->add('isEnabled', CheckboxType::class, [ + 'label' => 'accounting.bank_import.rules.field.enabled', + 'required' => false, + 'label_attr' => ['class' => 'checkbox-inline checkbox-switch'], + ]) + ->add('bankAccount', EntityType::class, [ + 'class' => AccountingAccount::class, + 'label' => 'accounting.bank_import.rules.field.bank_account', + 'help' => 'accounting.bank_import.rules.field.bank_account.help', + 'placeholder' => 'accounting.bank_import.rules.field.bank_account.global', + 'required' => false, + 'choice_label' => 'label', + 'query_builder' => fn (AccountingAccountRepository $repo) => $repo->createBankAccountsQueryBuilder($activePreset), + ]) + ; + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults(['data_class' => BankImportRule::class]); + } +} diff --git a/src/Form/BankStatementUploadType.php b/src/Form/BankStatementUploadType.php new file mode 100644 index 00000000..3128bc13 --- /dev/null +++ b/src/Form/BankStatementUploadType.php @@ -0,0 +1,73 @@ +settingsService->getActivePreset(); + + $builder + ->add('bankAccount', EntityType::class, [ + 'class' => AccountingAccount::class, + 'label' => 'accounting.bank_import.upload.bank_account', + 'help' => 'accounting.bank_import.upload.bank_account.help', + 'placeholder' => 'accounting.bank_import.upload.bank_account.placeholder', + 'choice_label' => 'label', + 'query_builder' => fn (AccountingAccountRepository $repo) => $repo->createBankAccountsQueryBuilder($activePreset), + 'constraints' => [new NotNull()], + ]) + ->add('csvProfile', EntityType::class, [ + 'class' => BankCsvProfile::class, + 'label' => 'accounting.bank_import.upload.csv_profile', + 'placeholder' => 'accounting.bank_import.upload.csv_profile.placeholder', + 'choice_label' => 'name', + 'query_builder' => static fn (BankCsvProfileRepository $repo) => $repo->createOrderedQueryBuilder(), + 'constraints' => [new NotNull()], + ]) + ->add('file', FileType::class, [ + 'label' => 'accounting.bank_import.upload.file', + 'mapped' => false, + 'constraints' => [ + new File( + maxSize: '5M', + mimeTypes: [ + 'text/csv', + 'text/plain', + 'application/csv', + 'application/vnd.ms-excel', + ], + mimeTypesMessage: 'accounting.bank_import.upload.file.invalid_type', + ), + ], + ]); + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'data_class' => null, + ]); + } +} diff --git a/src/Repository/AccountingAccountRepository.php b/src/Repository/AccountingAccountRepository.php index 838c6352..e96efad5 100644 --- a/src/Repository/AccountingAccountRepository.php +++ b/src/Repository/AccountingAccountRepository.php @@ -153,6 +153,12 @@ public function createOrderedQueryBuilder(?string $preset = null): QueryBuilder return $qb; } + public function createBankAccountsQueryBuilder(?string $preset = null): QueryBuilder + { + return $this->createOrderedQueryBuilder($preset) + ->andWhere('a.isBankAccount = true'); + } + public function createNonCashQueryBuilder(?string $preset = null): QueryBuilder { $qb = $this->createQueryBuilder('a') diff --git a/src/Repository/BankCsvProfileRepository.php b/src/Repository/BankCsvProfileRepository.php new file mode 100644 index 00000000..7b03035c --- /dev/null +++ b/src/Repository/BankCsvProfileRepository.php @@ -0,0 +1,34 @@ +createOrderedQueryBuilder() + ->getQuery() + ->getResult(); + } + + public function createOrderedQueryBuilder(): QueryBuilder + { + return $this->createQueryBuilder('p') + ->orderBy('p.name', 'ASC'); + } +} diff --git a/src/Repository/BankImportFingerprintRepository.php b/src/Repository/BankImportFingerprintRepository.php new file mode 100644 index 00000000..ca781671 --- /dev/null +++ b/src/Repository/BankImportFingerprintRepository.php @@ -0,0 +1,51 @@ +createQueryBuilder('f') + ->select('f.rawHash') + ->andWhere('f.bankAccount = :account') + ->andWhere('f.rawHash IN (:hashes)') + ->setParameter('account', $bankAccount) + ->setParameter('hashes', $hashes) + ->getQuery() + ->getScalarResult(); + + return array_column($rows, 'rawHash'); + } + + public function findByHash(AccountingAccount $bankAccount, string $hash): ?BankImportFingerprint + { + return $this->findOneBy([ + 'bankAccount' => $bankAccount, + 'rawHash' => $hash, + ]); + } +} diff --git a/src/Repository/BankImportRuleRepository.php b/src/Repository/BankImportRuleRepository.php new file mode 100644 index 00000000..143ecb24 --- /dev/null +++ b/src/Repository/BankImportRuleRepository.php @@ -0,0 +1,48 @@ +createQueryBuilder('r') + ->andWhere('r.isEnabled = true') + ->andWhere('r.bankAccount = :account OR r.bankAccount IS NULL') + ->setParameter('account', $bankAccount) + ->orderBy('r.priority', 'DESC') + ->addOrderBy('r.id', 'ASC') + ->getQuery() + ->getResult(); + } + + /** + * @return BankImportRule[] + */ + public function findAllOrdered(): array + { + return $this->createQueryBuilder('r') + ->orderBy('r.priority', 'DESC') + ->addOrderBy('r.name', 'ASC') + ->getQuery() + ->getResult(); + } +} diff --git a/src/Repository/BankStatementImportRepository.php b/src/Repository/BankStatementImportRepository.php new file mode 100644 index 00000000..b35c6629 --- /dev/null +++ b/src/Repository/BankStatementImportRepository.php @@ -0,0 +1,53 @@ +createQueryBuilder('i') + ->andWhere('i.bankAccount = :account') + ->setParameter('account', $bankAccount) + ->orderBy('i.createdAt', 'DESC') + ->setMaxResults($limit) + ->getQuery() + ->getResult(); + } + + /** + * Checks whether a committed import already covers a given period for this account. + * Used to warn users about overlapping imports. + * + * @return BankStatementImport[] + */ + public function findOverlapping(AccountingAccount $bankAccount, \DateTime $from, \DateTime $to): array + { + return $this->createQueryBuilder('i') + ->andWhere('i.bankAccount = :account') + ->andWhere('i.status = :committed') + ->andWhere('i.periodFrom <= :to AND i.periodTo >= :from') + ->setParameter('account', $bankAccount) + ->setParameter('committed', BankStatementImport::STATUS_COMMITTED) + ->setParameter('from', $from) + ->setParameter('to', $to) + ->orderBy('i.committedAt', 'DESC') + ->getQuery() + ->getResult(); + } +} diff --git a/src/Repository/BookingEntryRepository.php b/src/Repository/BookingEntryRepository.php index 57921547..4e7bf13f 100644 --- a/src/Repository/BookingEntryRepository.php +++ b/src/Repository/BookingEntryRepository.php @@ -110,8 +110,8 @@ public function getBankOpeningBalance(BookingBatch $batch): float { $result = $this->createQueryBuilder('e') ->select(' - SUM(CASE WHEN da.isBankAccount = true THEN e.amount ELSE 0 END) - - SUM(CASE WHEN ca.isBankAccount = true THEN e.amount ELSE 0 END) AS balance + SUM(CASE WHEN da.isBankAccount = true THEN ABS(e.amount) ELSE 0 END) + - SUM(CASE WHEN ca.isBankAccount = true THEN ABS(e.amount) ELSE 0 END) AS balance ') ->join('e.bookingBatch', 'b') ->leftJoin('e.debitAccount', 'da') @@ -133,8 +133,8 @@ public function getBankBatchDelta(BookingBatch $batch): float { $result = $this->createQueryBuilder('e') ->select(' - SUM(CASE WHEN da.isBankAccount = true THEN e.amount ELSE 0 END) - - SUM(CASE WHEN ca.isBankAccount = true THEN e.amount ELSE 0 END) AS balance + SUM(CASE WHEN da.isBankAccount = true THEN ABS(e.amount) ELSE 0 END) + - SUM(CASE WHEN ca.isBankAccount = true THEN ABS(e.amount) ELSE 0 END) AS balance ') ->leftJoin('e.debitAccount', 'da') ->leftJoin('e.creditAccount', 'ca') @@ -155,8 +155,8 @@ public function getCashOpeningBalance(BookingBatch $batch): float { $result = $this->createQueryBuilder('e') ->select(' - SUM(CASE WHEN da.isCashAccount = true THEN e.amount ELSE 0 END) - - SUM(CASE WHEN ca.isCashAccount = true THEN e.amount ELSE 0 END) AS balance + SUM(CASE WHEN da.isCashAccount = true THEN ABS(e.amount) ELSE 0 END) + - SUM(CASE WHEN ca.isCashAccount = true THEN ABS(e.amount) ELSE 0 END) AS balance ') ->join('e.bookingBatch', 'b') ->leftJoin('e.debitAccount', 'da') @@ -178,8 +178,8 @@ public function getCashBatchDelta(BookingBatch $batch): float { $result = $this->createQueryBuilder('e') ->select(' - SUM(CASE WHEN da.isCashAccount = true THEN e.amount ELSE 0 END) - - SUM(CASE WHEN ca.isCashAccount = true THEN e.amount ELSE 0 END) AS balance + SUM(CASE WHEN da.isCashAccount = true THEN ABS(e.amount) ELSE 0 END) + - SUM(CASE WHEN ca.isCashAccount = true THEN ABS(e.amount) ELSE 0 END) AS balance ') ->leftJoin('e.debitAccount', 'da') ->leftJoin('e.creditAccount', 'ca') @@ -202,8 +202,8 @@ public function getCashDeltasByMonth(int $year): array { $rows = $this->createQueryBuilder('e') ->select('b.month AS month, ' - .'SUM(CASE WHEN da.isCashAccount = true THEN e.amount ELSE 0 END) ' - .'- SUM(CASE WHEN ca.isCashAccount = true THEN e.amount ELSE 0 END) AS delta') + .'SUM(CASE WHEN da.isCashAccount = true THEN ABS(e.amount) ELSE 0 END) ' + .'- SUM(CASE WHEN ca.isCashAccount = true THEN ABS(e.amount) ELSE 0 END) AS delta') ->join('e.bookingBatch', 'b') ->leftJoin('e.debitAccount', 'da') ->leftJoin('e.creditAccount', 'ca') @@ -229,7 +229,7 @@ public function getCashDeltasByMonth(int $year): array public function getCashOpeningForYear(int $year): float { $result = $this->createQueryBuilder('e') - ->select('SUM(e.amount) AS balance') + ->select('SUM(ABS(e.amount)) AS balance') ->join('e.bookingBatch', 'b') ->leftJoin('e.debitAccount', 'da') ->where('b.year = :year') diff --git a/src/Repository/InvoiceRepository.php b/src/Repository/InvoiceRepository.php index ae0372f0..b5a344b5 100644 --- a/src/Repository/InvoiceRepository.php +++ b/src/Repository/InvoiceRepository.php @@ -4,10 +4,12 @@ namespace App\Repository; +use App\Entity\Invoice; +use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\DBAL\ArrayParameterType; -use Doctrine\ORM\EntityRepository; use Doctrine\ORM\NoResultException; use Doctrine\ORM\Tools\Pagination\Paginator; +use Doctrine\Persistence\ManagerRegistry; /** * InvoiceRepository. @@ -15,8 +17,13 @@ * This class was generated by the Doctrine ORM. Add your own custom * repository methods below. */ -class InvoiceRepository extends EntityRepository +class InvoiceRepository extends ServiceEntityRepository { + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, Invoice::class); + } + public function getLastInvoiceId() { $q = $this @@ -72,6 +79,32 @@ public function getInvoicesForYear(\DateTimeInterface $start, \DateTimeInterface } } + public function findOneByNumber(string $number): ?Invoice + { + return $this->findOneBy(['number' => $number]); + } + + /** + * Look up invoices by an array of candidate numbers in one query. + * + * @param list $numbers + * + * @return list + */ + public function findByNumbers(array $numbers): array + { + if ([] === $numbers) { + return []; + } + + $q = $this->createQueryBuilder('i') + ->andWhere('i.number IN (:numbers)') + ->setParameter('numbers', array_values($numbers), ArrayParameterType::STRING) + ->getQuery(); + + return $q->getResult(); + } + public function supportsClass($class) { return $this->getEntityName() === $class diff --git a/src/Service/AccountingPresetSeeder.php b/src/Service/BookingJournal/AccountingPresetSeeder.php similarity index 99% rename from src/Service/AccountingPresetSeeder.php rename to src/Service/BookingJournal/AccountingPresetSeeder.php index ba24c128..625cd4e2 100644 --- a/src/Service/AccountingPresetSeeder.php +++ b/src/Service/BookingJournal/AccountingPresetSeeder.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\Service; +namespace App\Service\BookingJournal; use App\Entity\AccountingAccount; use App\Entity\AccountingSettings; diff --git a/src/Service/AccountingSettingsService.php b/src/Service/BookingJournal/AccountingSettingsService.php similarity index 97% rename from src/Service/AccountingSettingsService.php rename to src/Service/BookingJournal/AccountingSettingsService.php index 5d87e732..a6f86f7a 100644 --- a/src/Service/AccountingSettingsService.php +++ b/src/Service/BookingJournal/AccountingSettingsService.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\Service; +namespace App\Service\BookingJournal; use App\Entity\AccountingSettings; use App\Repository\AccountingSettingsRepository; diff --git a/src/Service/BookingJournal/BankImport/BankImportDraftSession.php b/src/Service/BookingJournal/BankImport/BankImportDraftSession.php new file mode 100644 index 00000000..c0c671f4 --- /dev/null +++ b/src/Service/BookingJournal/BankImport/BankImportDraftSession.php @@ -0,0 +1,98 @@ + + */ +final class BankImportDraftSession +{ + public const SESSION_KEY = 'bank_import.drafts'; + + public function __construct( + private readonly RequestStack $requestStack, + private readonly ?TranslatorInterface $translator = null, + ) { + } + + public function create(ImportState $state): string + { + if ('' === $state->sessionImportId) { + $state->sessionImportId = Uuid::v4()->toRfc4122(); + } + + $this->save($state); + + return $state->sessionImportId; + } + + public function load(string $sessionImportId): ?ImportState + { + $drafts = $this->session()->get(self::SESSION_KEY, []); + if (!isset($drafts[$sessionImportId])) { + return null; + } + + return ImportState::fromArray($drafts[$sessionImportId]); + } + + public function save(ImportState $state): void + { + $session = $this->session(); + $drafts = $session->get(self::SESSION_KEY, []); + $drafts[$state->sessionImportId] = $state->toArray(); + $session->set(self::SESSION_KEY, $drafts); + } + + public function discard(string $sessionImportId): void + { + $session = $this->session(); + $drafts = $session->get(self::SESSION_KEY, []); + unset($drafts[$sessionImportId]); + $session->set(self::SESSION_KEY, $drafts); + } + + /** + * @return list + */ + public function list(): array + { + $drafts = $this->session()->get(self::SESSION_KEY, []); + $result = []; + foreach ($drafts as $entry) { + $result[] = ImportState::fromArray($entry); + } + + return $result; + } + + private function session(): \Symfony\Component\HttpFoundation\Session\SessionInterface + { + $request = $this->requestStack->getMainRequest(); + if (null === $request || !$request->hasSession()) { + throw new \LogicException($this->trans('accounting.bank_import.draft.session_required')); + } + + return $request->getSession(); + } + + /** + * @param array $parameters + */ + private function trans(string $key, array $parameters = []): string + { + return $this->translator?->trans($key, $parameters) ?? $key; + } +} diff --git a/src/Service/BookingJournal/BankImport/BankImportRuleMatcher.php b/src/Service/BookingJournal/BankImport/BankImportRuleMatcher.php new file mode 100644 index 00000000..ab62a71a --- /dev/null +++ b/src/Service/BookingJournal/BankImport/BankImportRuleMatcher.php @@ -0,0 +1,69 @@ +ruleRepo->findActiveForAccount($bankAccount); + if ([] === $rules) { + return; + } + + foreach ($state->lines as &$line) { + if (true === ($line['isDuplicate'] ?? false)) { + continue; + } + + foreach ($rules as $rule) { + if (!$this->ruleMatches($rule, $line)) { + continue; + } + + $this->applicator->apply($rule, $line); + break; // first matching rule wins. + } + } + unset($line); + } + + /** + * All conditions must match (AND logic). An empty rule matches everything — + * useful as a catch-all default at low priority. + * + * @param array $line + */ + private function ruleMatches(BankImportRule $rule, array $line): bool + { + foreach ($rule->getConditions() as $condition) { + if (!$this->evaluator->matches($condition, $line)) { + return false; + } + } + + return true; + } +} diff --git a/src/Service/BookingJournal/BankImport/BankStatementCommitter.php b/src/Service/BookingJournal/BankImport/BankStatementCommitter.php new file mode 100644 index 00000000..d7b2471a --- /dev/null +++ b/src/Service/BookingJournal/BankImport/BankStatementCommitter.php @@ -0,0 +1,283 @@ +loadAccountsById(); + $taxRates = $this->loadTaxRatesById(); + $this->em->beginTransaction(); + + try { + $audit = new BankStatementImport($bankAccount); + $audit->setCreatedBy($user); + $audit->setFileFormat($state->fileFormat); + $audit->setStatus(BankStatementImport::STATUS_COMMITTED); + $audit->setCommittedAt(new \DateTimeImmutable()); + $audit->setPeriodFrom($this->parseDate($state->periodFrom)); + $audit->setPeriodTo($this->parseDate($state->periodTo)); + $this->em->persist($audit); + + $committed = 0; + $ignored = 0; + $duplicates = 0; + $redated = 0; + $statementEntryYears = []; + + foreach ($state->lines as $line) { + if (true === ($line['isDuplicate'] ?? false)) { + ++$duplicates; + continue; + } + + $hash = (string) ($line['fingerprint'] ?? ''); + $fingerprint = new BankImportFingerprint($bankAccount, $hash); + $fingerprint->setStatementImport($audit); + + if (true === ($line['isIgnored'] ?? false)) { + $this->em->persist($fingerprint); + ++$ignored; + continue; + } + + if (ImportState::LINE_STATUS_READY !== ($line['status'] ?? '')) { + // Skip pending lines silently — caller is expected to gate + // on this; we just avoid bad data sneaking in. + continue; + } + + $valueDate = new \DateTimeImmutable((string) ($line['valueDate'] ?? $line['bookDate'])); + + // Re-date existing workflow entries when this line maps to a + // known invoice that has already been booked. + $existing = null !== ($line['matchedInvoiceId'] ?? null) + ? $this->entryRepo->findBy(['invoiceId' => (int) $line['matchedInvoiceId']]) + : []; + + if (null !== ($line['matchedInvoiceId'] ?? null) && [] === ($line['splits'] ?? [])) { + if ([] !== $existing && true === ($line['matchedInvoiceAmountMatches'] ?? false)) { + $this->updateExistingInvoiceEntries($existing, $line, $valueDate, $bankAccount); + $fingerprint->setBookingEntry($existing[0]); + $this->em->persist($fingerprint); + $redated += count($existing); + ++$committed; + continue; + } + + $invoice = $this->invoiceRepo->find((int) $line['matchedInvoiceId']); + if (null !== $invoice && true === ($line['matchedInvoiceAmountMatches'] ?? false)) { + $newEntries = $this->journal->createEntriesFromInvoice( + $invoice, + $bankAccount, + null, + $line['userRemark'] ?? null, + $valueDate, + BookingEntry::SOURCE_MANUAL, + ); + if ([] === $newEntries) { + continue; + } + + $fingerprint->setBookingEntry($newEntries[0]); + $this->em->persist($fingerprint); + ++$committed; + continue; + } + } + + // Otherwise create fresh entries. + $newEntries = $this->createEntries($line, $valueDate, $accounts, $taxRates); + if ([] === $newEntries) { + continue; + } + $statementEntryYears[] = (int) $valueDate->format('Y'); + // Link the fingerprint to the first new entry — that's what + // appears on the journal page anyway. + $fingerprint->setBookingEntry($newEntries[0]); + $this->em->persist($fingerprint); + ++$committed; + } + + $audit->setLineCountTotal(count($state->lines)); + $audit->setLineCountCommitted($committed); + $audit->setLineCountIgnored($ignored); + $audit->setLineCountDuplicate($duplicates); + + $this->em->flush(); + if ([] !== $statementEntryYears) { + $this->journal->recalculateDocumentNumbersForYears(...array_values(array_unique($statementEntryYears))); + } + $this->em->commit(); + } catch (\Throwable $e) { + $this->em->rollback(); + throw $e; + } + + $this->drafts->discard($state->sessionImportId); + + return [ + 'importId' => (int) $audit->getId(), + 'committed' => $committed, + 'ignored' => $ignored, + 'duplicates' => $duplicates, + 'redated' => $redated, + ]; + } + + /** + * @param list $entries + * @param array $line + */ + private function updateExistingInvoiceEntries(array $entries, array $line, \DateTimeImmutable $valueDate, AccountingAccount $bankAccount): void + { + $isIncoming = ((float) ($line['amount'] ?? 0)) >= 0.0; + + foreach ($entries as $entry) { + if ($isIncoming) { + $entry->setDebitAccount($bankAccount); + } else { + $entry->setCreditAccount($bankAccount); + } + + $this->journal->updateEntryDate($entry, $valueDate); + } + } + + /** + * @param array $line + * @param array $accounts + * @param array $taxRates + * + * @return list + */ + private function createEntries(array $line, \DateTimeImmutable $valueDate, array $accounts, array $taxRates): array + { + $invoiceId = $line['matchedInvoiceId'] ?? null; + $invoiceNumber = $line['matchedInvoiceNumber'] ?? null; + + $splits = $line['splits'] ?? []; + if (!is_array($splits) || [] === $splits) { + $entry = $this->journal->createEntryFromStatement( + $valueDate, + $this->journalAmount($line['amount'] ?? '0.00'), + $accounts[(int) ($line['userDebitAccountId'] ?? 0)] ?? null, + $accounts[(int) ($line['userCreditAccountId'] ?? 0)] ?? null, + $line['userRemark'] ?? null, + $invoiceNumber, + $invoiceId !== null ? (int) $invoiceId : null, + null, + $taxRates[(int) ($line['userTaxRateId'] ?? 0)] ?? null, + ); + + return [$entry]; + } + + $groupUuid = Uuid::v4()->toRfc4122(); + $created = []; + foreach ($splits as $split) { + $created[] = $this->journal->createEntryFromStatement( + $valueDate, + $this->journalAmount($split['amount'] ?? '0.00'), + $accounts[(int) ($split['debitAccountId'] ?? 0)] ?? null, + $accounts[(int) ($split['creditAccountId'] ?? 0)] ?? null, + $split['remark'] ?? null, + $invoiceNumber, + $invoiceId !== null ? (int) $invoiceId : null, + $groupUuid, + $taxRates[(int) ($split['taxRateId'] ?? 0)] ?? null, + ); + } + + return $created; + } + + private function journalAmount(mixed $amount): string + { + return number_format(abs((float) $amount), 2, '.', ''); + } + + /** + * @return array + */ + private function loadAccountsById(): array + { + $accounts = []; + foreach ($this->accountRepo->findAll() as $account) { + $accounts[(int) $account->getId()] = $account; + } + + return $accounts; + } + + /** + * @return array + */ + private function loadTaxRatesById(): array + { + $taxRates = []; + foreach ($this->taxRateRepo->findAll() as $taxRate) { + $taxRates[(int) $taxRate->getId()] = $taxRate; + } + + return $taxRates; + } + + private function parseDate(?string $value): ?\DateTime + { + if (null === $value || '' === $value) { + return null; + } + try { + return new \DateTime($value); + } catch (\Throwable) { + return null; + } + } +} diff --git a/src/Service/BookingJournal/BankImport/BankStatementDeduplicator.php b/src/Service/BookingJournal/BankImport/BankStatementDeduplicator.php new file mode 100644 index 00000000..9c1bb757 --- /dev/null +++ b/src/Service/BookingJournal/BankImport/BankStatementDeduplicator.php @@ -0,0 +1,40 @@ +lines) { + return; + } + + $hashes = array_map(static fn (array $line): string => (string) $line['fingerprint'], $state->lines); + $existing = array_flip($this->fingerprintRepo->findExistingHashes($bankAccount, $hashes)); + + foreach ($state->lines as &$line) { + if (isset($existing[$line['fingerprint']])) { + $line['isDuplicate'] = true; + $line['status'] = ImportState::LINE_STATUS_DUPLICATE; + } + } + unset($line); + } +} diff --git a/src/Service/BookingJournal/BankImport/CompiledMatcher.php b/src/Service/BookingJournal/BankImport/CompiledMatcher.php new file mode 100644 index 00000000..02563082 --- /dev/null +++ b/src/Service/BookingJournal/BankImport/CompiledMatcher.php @@ -0,0 +1,61 @@ + $regexes PCRE alternatives, each with capture group 1 returning the bare number + * @param list $samples Original sample inputs (for diagnostics / UI preview) + */ + public function __construct( + public readonly array $regexes, + public readonly array $samples, + /** + * If true, every digit-only candidate must additionally pass a strict + * existence check against the invoice repository — otherwise we'd match + * any 5+ digit number in the purpose line (VISA refs, mandate IDs, …). + */ + public readonly bool $requiresStrictExistenceCheck = false, + ) { + } + + public function isEmpty(): bool + { + return [] === $this->regexes; + } + + /** + * Extracts unique candidate invoice numbers from $haystack, in source order. + * + * @return list + */ + public function extractCandidates(string $haystack): array + { + if ('' === $haystack || $this->isEmpty()) { + return []; + } + + $found = []; + foreach ($this->regexes as $regex) { + if (preg_match_all($regex, $haystack, $matches)) { + foreach ($matches[0] as $match) { + $candidate = trim((string) $match); + if ('' !== $candidate && !in_array($candidate, $found, true)) { + $found[] = $candidate; + } + } + } + } + + return $found; + } +} diff --git a/src/Service/BookingJournal/BankImport/InvoiceMatcher.php b/src/Service/BookingJournal/BankImport/InvoiceMatcher.php new file mode 100644 index 00000000..4b31370d --- /dev/null +++ b/src/Service/BookingJournal/BankImport/InvoiceMatcher.php @@ -0,0 +1,142 @@ +settingsService->getSettings()->getInvoiceNumberSamples(); + $matcher = $this->patternBuilder->buildFromSamples($samples); + + if ($matcher->isEmpty()) { + return; + } + + foreach ($state->lines as &$line) { + // Skip lines that already have a stronger signal (rule-applied, + // ignored, duplicates) — invoice matching is just a hint. + if (true === ($line['isDuplicate'] ?? false) || true === ($line['isIgnored'] ?? false)) { + continue; + } + + $invoice = $this->matchPurpose((string) ($line['purpose'] ?? ''), (string) ($line['amount'] ?? ''), $matcher); + if (null === $invoice) { + continue; + } + + $line['matchedInvoiceId'] = $invoice->getId(); + $line['matchedInvoiceNumber'] = $invoice->getNumber(); + $line['matchedInvoiceAmountMatches'] = $this->amountsMatch($invoice, abs((float) ($line['amount'] ?? '0'))); + + if (true === $line['matchedInvoiceAmountMatches'] + && ((float) ($line['amount'] ?? 0)) >= 0.0 + && null !== ($line['userDebitAccountId'] ?? null) + ) { + $line['status'] = ImportState::LINE_STATUS_READY; + } + } + unset($line); + } + + /** + * Returns the best matching invoice for the given purpose text + line amount, + * or null if none of the candidates resolves to an actual invoice. + */ + public function matchPurpose(string $purpose, string $lineAmount, CompiledMatcher $matcher): ?Invoice + { + $candidates = $matcher->extractCandidates($purpose); + if ([] === $candidates) { + return null; + } + + $invoices = $this->invoiceRepo->findByNumbers($candidates); + if ([] === $invoices) { + return null; + } + + if (1 === count($invoices)) { + return $invoices[0]; + } + + // Several invoices matched — prefer the one whose total equals the + // line amount (absolute, since incoming/outgoing varies). + $target = abs((float) $lineAmount); + + $byNumber = []; + foreach ($invoices as $invoice) { + $byNumber[$invoice->getNumber()] = $invoice; + } + + // Walk candidates in source order so the first textual match wins + // when no amount disambiguates. + $bestExact = null; + foreach ($candidates as $number) { + $invoice = $byNumber[$number] ?? null; + if (null === $invoice) { + continue; + } + + if ($this->amountsMatch($invoice, $target)) { + return $invoice; // exact amount match — stop here. + } + + $bestExact ??= $invoice; + } + + return $bestExact; + } + + public function amountsMatch(Invoice $invoice, float $target): bool + { + if (0.0 === $target) { + return false; + } + + $brutto = 0.0; + $netto = 0.0; + $apartmentTotal = 0.0; + $miscTotal = 0.0; + $vats = []; + + $this->invoiceService->calculateSums( + $invoice->getAppartments() ?? new ArrayCollection(), + $invoice->getPositions() ?? new ArrayCollection(), + $vats, + $brutto, + $netto, + $apartmentTotal, + $miscTotal, + ); + + return abs($brutto - $target) < 0.01; + } +} diff --git a/src/Service/BookingJournal/BankImport/InvoiceNumberPatternBuilder.php b/src/Service/BookingJournal/BankImport/InvoiceNumberPatternBuilder.php new file mode 100644 index 00000000..95e6c0b5 --- /dev/null +++ b/src/Service/BookingJournal/BankImport/InvoiceNumberPatternBuilder.php @@ -0,0 +1,253 @@ + $samples + */ + public function buildFromSamples(array $samples): CompiledMatcher + { + $cleanSamples = []; + foreach ($samples as $raw) { + $value = trim((string) $raw); + if ('' !== $value) { + $cleanSamples[] = $value; + } + } + + if ([] === $cleanSamples) { + return new CompiledMatcher([], [], false); + } + + $regexes = []; + $strictNeeded = false; + + foreach ($cleanSamples as $sample) { + $tokens = $this->tokenize($sample); + if ([] === $tokens) { + continue; + } + + $isPure = $this->isPureDigitsOnly($tokens); + if ($isPure) { + $strictNeeded = true; + } + + // Pure-numeric samples ("12345") get tight digit bounds so they + // don't accidentally match every number on a statement (booking + // counts, fees, …). Mixed samples ("RE-12345") keep loose bounds + // because their literal prefix already disambiguates. + $regexes[] = $this->compileTokens($tokens, $isPure); + } + + // Deduplicate while preserving order. + $regexes = array_values(array_unique($regexes)); + + return new CompiledMatcher( + regexes: $regexes, + samples: $cleanSamples, + requiresStrictExistenceCheck: $strictNeeded, + ); + } + + /** + * Returns a short, user-friendly description of what gets matched. + * Shown in the settings UI so people can sanity-check their inputs. + */ + public function describe(CompiledMatcher $matcher): string + { + if ($matcher->isEmpty()) { + return ''; + } + + return implode(', ', array_map([$this, 'describeSample'], $matcher->samples)); + } + + /** + * Synthesises example matches for a single sample — useful for preview UI. + * + * @return list + */ + public function exemplifyMatches(string $sample): array + { + $tokens = $this->tokenize(trim($sample)); + if ([] === $tokens) { + return []; + } + + $minVariant = ''; + $observedVariant = ''; + $maxVariant = ''; + + foreach ($tokens as $token) { + switch ($token['kind']) { + case 'letters': + $minVariant .= $token['value']; + $observedVariant .= $token['value']; + $maxVariant .= $token['value']; + break; + case 'separator': + $minVariant .= $token['value']; + $observedVariant .= $token['value']; + $maxVariant .= $token['value']; + break; + case 'digits': + $observedLen = strlen($token['value']); + $minVariant .= '1'; + $observedVariant .= $token['value']; + $maxVariant .= str_repeat('9', $observedLen + 2); + break; + } + } + + return array_values(array_unique([$minVariant, $observedVariant, $maxVariant])); + } + + // ── Internals ───────────────────────────────────────────────────── + + /** + * @return list + */ + private function tokenize(string $sample): array + { + if ('' === $sample) { + return []; + } + + $tokens = []; + $length = strlen($sample); + $i = 0; + + while ($i < $length) { + $char = $sample[$i]; + + if (ctype_alpha($char)) { + $start = $i; + while ($i < $length && ctype_alpha($sample[$i])) { + ++$i; + } + $tokens[] = ['kind' => 'letters', 'value' => substr($sample, $start, $i - $start)]; + continue; + } + + if (ctype_digit($char)) { + $start = $i; + while ($i < $length && ctype_digit($sample[$i])) { + ++$i; + } + $tokens[] = ['kind' => 'digits', 'value' => substr($sample, $start, $i - $start)]; + continue; + } + + if (in_array($char, ['-', '_', '/', '.', ' '], true)) { + $tokens[] = ['kind' => 'separator', 'value' => $char]; + ++$i; + continue; + } + + // Unknown character — skip silently rather than fail. This keeps + // the inference forgiving for stray punctuation in samples. + ++$i; + } + + return $tokens; + } + + /** + * @param list $tokens + */ + private function isPureDigitsOnly(array $tokens): bool + { + foreach ($tokens as $token) { + if ('digits' !== $token['kind']) { + return false; + } + } + + return [] !== $tokens; + } + + /** + * @param list $tokens + */ + /** + * @param list $tokens + */ + private function compileTokens(array $tokens, bool $tightDigitBounds = false): string + { + $parts = []; + + foreach ($tokens as $token) { + switch ($token['kind']) { + case 'letters': + // Normalise to upper-case so "re" and "RE" yield the same + // regex string and can be deduplicated. The /i flag still + // matches both cases at runtime. + $parts[] = preg_quote(strtoupper($token['value']), '/'); + break; + case 'separator': + $parts[] = preg_quote($token['value'], '/'); + break; + case 'digits': + $observedLen = strlen($token['value']); + if ($tightDigitBounds) { + // Pure-numeric: stay close to observed length. + $minLen = $observedLen; + $maxLen = $observedLen + 2; + } else { + // Mixed: be liberal — literal context prevents drift. + $minLen = 1; + $maxLen = $observedLen + 4; + } + $parts[] = sprintf('\d{%d,%d}', $minLen, $maxLen); + break; + } + } + + // Word boundaries on both sides keep "RE-12345" out of "CORE-123456789". + return '/\b'.implode('', $parts).'\b/i'; + } + + private function describeSample(string $sample): string + { + $tokens = $this->tokenize(trim($sample)); + if ([] === $tokens) { + return $sample; + } + + $pieces = []; + foreach ($tokens as $token) { + switch ($token['kind']) { + case 'letters': + $pieces[] = '"'.$token['value'].'"'; + break; + case 'separator': + $pieces[] = '"'.$token['value'].'"'; + break; + case 'digits': + $pieces[] = strlen($token['value']).' Ziffern'; + break; + } + } + + return implode(' ', $pieces); + } +} diff --git a/src/Service/BookingJournal/BankImport/Parser/BankStatementParserRegistry.php b/src/Service/BookingJournal/BankImport/Parser/BankStatementParserRegistry.php new file mode 100644 index 00000000..75f4de3d --- /dev/null +++ b/src/Service/BookingJournal/BankImport/Parser/BankStatementParserRegistry.php @@ -0,0 +1,66 @@ + + */ + private array $parsers = []; + + /** + * @param iterable $parsers + */ + public function __construct( + #[AutowireIterator('app.bank_import.parser')] + iterable $parsers, + private readonly ?TranslatorInterface $translator = null, + ) { + foreach ($parsers as $parser) { + $this->parsers[$parser->getFormatKey()] = $parser; + } + } + + public function get(string $formatKey): ParserInterface + { + if (!isset($this->parsers[$formatKey])) { + throw new \InvalidArgumentException($this->trans('accounting.bank_import.parser.error.format_not_registered', [ + '%format%' => $formatKey, + '%available%' => implode(', ', array_keys($this->parsers)) ?: $this->trans('accounting.bank_import.parser.error.none_available'), + ])); + } + + return $this->parsers[$formatKey]; + } + + /** + * @return list + */ + public function getFormatKeys(): array + { + return array_keys($this->parsers); + } + + public function has(string $formatKey): bool + { + return isset($this->parsers[$formatKey]); + } + + /** + * @param array $parameters + */ + private function trans(string $key, array $parameters = []): string + { + return $this->translator?->trans($key, $parameters) ?? $key; + } +} diff --git a/src/Service/BookingJournal/BankImport/Parser/GenericCsvParser.php b/src/Service/BookingJournal/BankImport/Parser/GenericCsvParser.php new file mode 100644 index 00000000..4a3202dd --- /dev/null +++ b/src/Service/BookingJournal/BankImport/Parser/GenericCsvParser.php @@ -0,0 +1,373 @@ +trans('accounting.bank_import.parser.error.profile_required')); + } + + $rows = $this->readAllRows($file, $profile); + + $sourceIban = $this->extractIban($rows, $profile); + [$periodFrom, $periodTo] = $this->extractPeriod($rows, $profile); + + $dataStart = $profile->getHeaderSkip() + ($profile->hasHeaderRow() ? 1 : 0); + $columnMap = $profile->getColumnMap(); + $this->assertRequiredColumns($columnMap, $profile->getDirectionMode()); + + $lines = []; + $warnings = []; + + for ($i = $dataStart, $n = count($rows); $i < $n; ++$i) { + $row = $rows[$i]; + if ($this->isEmptyRow($row)) { + continue; + } + + // Banks often append a trailing balance row like "Kontostand;…" + // that shares the data layout but isn't a transaction. Skip + // silently if the date column carries no digits at all. + if (!$this->looksLikeDataRow($row, $columnMap)) { + continue; + } + + try { + $lines[] = $this->buildLine($row, $columnMap, $profile); + } catch (\Throwable $e) { + $warnings[] = $this->trans('accounting.bank_import.parser.warning.row_skipped', [ + '%line%' => $i + 1, + '%message%' => $e->getMessage(), + ]); + } + } + + return new ParseResult($lines, $sourceIban, $periodFrom, $periodTo, $warnings); + } + + /** + * @return list> + */ + private function readAllRows(\SplFileInfo $file, BankCsvProfile $profile): array + { + $path = $file->getPathname(); + $content = file_get_contents($path); + if (false === $content) { + throw new \RuntimeException($this->trans('accounting.bank_import.parser.error.file_read_failed', [ + '%path%' => $path, + ])); + } + + $encoding = $profile->getEncoding(); + if ('UTF-8' !== strtoupper($encoding)) { + $converted = @mb_convert_encoding($content, 'UTF-8', $encoding); + if (false !== $converted) { + $content = $converted; + } + } + + // Strip UTF-8 BOM if present. + if (str_starts_with($content, "\xEF\xBB\xBF")) { + $content = substr($content, 3); + } + + $stream = fopen('php://memory', 'r+'); + if (false === $stream) { + throw new \RuntimeException($this->trans('accounting.bank_import.parser.error.temp_stream_failed')); + } + fwrite($stream, $content); + rewind($stream); + + $rows = []; + while (false !== ($row = fgetcsv($stream, 0, $profile->getDelimiter(), $profile->getEnclosure(), '\\'))) { + $rows[] = $row; + } + fclose($stream); + + return $rows; + } + + /** + * @param array $columnMap + */ + private function buildLine(array $row, array $columnMap, BankCsvProfile $profile): StatementLineDto + { + $bookDate = $this->parseDate($this->cell($row, $columnMap['bookDate']), $profile->getDateFormat()); + $valueDate = isset($columnMap['valueDate']) + ? $this->parseDate($this->cell($row, $columnMap['valueDate']), $profile->getDateFormat()) + : $bookDate; + + $amount = $this->parseAmount($row, $columnMap, $profile); + + $counterpartyName = isset($columnMap['counterpartyName']) + ? trim($this->cell($row, $columnMap['counterpartyName'])) + : ''; + + $counterpartyIban = isset($columnMap['counterpartyIban']) + ? $this->normalizeIban($this->cell($row, $columnMap['counterpartyIban'])) + : null; + + $purpose = isset($columnMap['purpose']) + ? trim($this->cell($row, $columnMap['purpose'])) + : ''; + + return new StatementLineDto( + bookDate: $bookDate, + valueDate: $valueDate, + amount: $amount, + counterpartyName: $counterpartyName, + counterpartyIban: $counterpartyIban, + purpose: $purpose, + endToEndId: $this->optional($row, $columnMap, 'endToEndId'), + mandateReference: $this->optional($row, $columnMap, 'mandateReference'), + creditorId: $this->optional($row, $columnMap, 'creditorId'), + ); + } + + private function parseDate(string $raw, string $format): \DateTimeImmutable + { + $raw = trim($raw); + $date = \DateTimeImmutable::createFromFormat('!'.$format, $raw); + if (false === $date) { + throw new \InvalidArgumentException($this->trans('accounting.bank_import.parser.error.invalid_date', [ + '%value%' => $raw, + '%format%' => $format, + ])); + } + + return $date; + } + + /** + * Returns the amount as a fixed-point string with two decimals, e.g. "-41.98". + * + * @param array $columnMap + */ + private function parseAmount(array $row, array $columnMap, BankCsvProfile $profile): string + { + if (BankCsvProfile::DIRECTION_SEPARATE_COLUMNS === $profile->getDirectionMode()) { + $debit = $this->normalizeAmount( + $this->cell($row, $columnMap['amountDebit'] ?? -1), + $profile, + ); + $credit = $this->normalizeAmount( + $this->cell($row, $columnMap['amountCredit'] ?? -1), + $profile, + ); + + // Only one side carries a value per row; debit becomes negative. + if ('' !== $debit && '0.00' !== $debit) { + return number_format(-1 * (float) $debit, 2, '.', ''); + } + + return '' === $credit ? '0.00' : $credit; + } + + $value = $this->normalizeAmount($this->cell($row, $columnMap['amount']), $profile); + + return '' === $value ? '0.00' : $value; + } + + private function normalizeAmount(string $raw, BankCsvProfile $profile): string + { + $raw = trim($raw); + if ('' === $raw) { + return ''; + } + + $thousands = $profile->getAmountThousandsSeparator(); + if (null !== $thousands && '' !== $thousands) { + $raw = str_replace($thousands, '', $raw); + } + $raw = str_replace($profile->getAmountDecimalSeparator(), '.', $raw); + $raw = preg_replace('/[^\d.\-+]/', '', $raw) ?? ''; + + if ('' === $raw || '-' === $raw || '+' === $raw) { + throw new \InvalidArgumentException($this->trans('accounting.bank_import.parser.error.amount_parse_failed')); + } + + return number_format((float) $raw, 2, '.', ''); + } + + private function normalizeIban(string $raw): ?string + { + $raw = strtoupper(preg_replace('/\s+/', '', trim($raw)) ?? ''); + + return '' === $raw ? null : $raw; + } + + private function cell(array $row, int $index): string + { + return (string) ($row[$index] ?? ''); + } + + /** + * @param array $columnMap + */ + private function optional(array $row, array $columnMap, string $field): ?string + { + if (!isset($columnMap[$field])) { + return null; + } + + $value = trim($this->cell($row, $columnMap[$field])); + + return '' === $value ? null : $value; + } + + /** + * @param array $columnMap + */ + private function assertRequiredColumns(array $columnMap, string $directionMode): void + { + if (!isset($columnMap['bookDate'])) { + throw new \InvalidArgumentException($this->trans('accounting.bank_import.parser.error.required_column', [ + '%field%' => 'bookDate', + ])); + } + + if (BankCsvProfile::DIRECTION_SEPARATE_COLUMNS === $directionMode) { + if (!isset($columnMap['amountDebit'], $columnMap['amountCredit'])) { + throw new \InvalidArgumentException($this->trans('accounting.bank_import.parser.error.required_separate_columns')); + } + + return; + } + + if (!isset($columnMap['amount'])) { + throw new \InvalidArgumentException($this->trans('accounting.bank_import.parser.error.required_column', [ + '%field%' => 'amount', + ])); + } + } + + /** + * @param list> $rows + */ + private function extractIban(array $rows, BankCsvProfile $profile): ?string + { + $line = $profile->getIbanSourceLine(); + if (null === $line || !isset($rows[$line])) { + return null; + } + + foreach ($rows[$line] as $cell) { + $candidate = $this->normalizeIban((string) $cell); + if (null !== $candidate && preg_match('/^[A-Z]{2}\d{2}[A-Z0-9]{4,30}$/', $candidate)) { + return $candidate; + } + } + + return null; + } + + /** + * @param list> $rows + * + * @return array{0: ?\DateTimeImmutable, 1: ?\DateTimeImmutable} + */ + private function extractPeriod(array $rows, BankCsvProfile $profile): array + { + $line = $profile->getPeriodSourceLine(); + if (null === $line || !isset($rows[$line])) { + return [null, null]; + } + + $haystack = implode(' ', array_map('strval', $rows[$line])); + if (!preg_match_all('/(\d{1,2}\.\d{1,2}\.\d{2,4})/', $haystack, $matches)) { + return [null, null]; + } + + $dates = $matches[1]; + if (count($dates) < 2) { + return [null, null]; + } + + $format = $this->guessDateFormat($dates[0]); + $from = \DateTimeImmutable::createFromFormat('!'.$format, $dates[0]); + $to = \DateTimeImmutable::createFromFormat('!'.$format, $dates[1]); + + return [$from ?: null, $to ?: null]; + } + + private function guessDateFormat(string $sample): string + { + // Look at the trailing year segment: 4 digits → full year, 2 → short. + // Day and month parts may be unpadded ("1.4.2025") so we use j.n which + // accepts both forms. + if (1 === preg_match('/\.(\d{2,4})$/', $sample, $matches)) { + return 4 === strlen($matches[1]) ? 'j.n.Y' : 'j.n.y'; + } + + return 'j.n.Y'; + } + + /** + * @param array $columnMap + */ + private function looksLikeDataRow(array $row, array $columnMap): bool + { + $dateCell = trim($this->cell($row, $columnMap['bookDate'])); + if ('' === $dateCell) { + return false; + } + + // A real date contains digits. Trailer rows like "Kontostand;…" or + // "Saldo neu" don't, so we can skip them silently. + return 1 === preg_match('/\d/', $dateCell); + } + + private function isEmptyRow(array $row): bool + { + if (1 === count($row) && '' === trim((string) $row[0])) { + return true; + } + foreach ($row as $cell) { + if ('' !== trim((string) $cell)) { + return false; + } + } + + return true; + } + + /** + * @param array $parameters + */ + private function trans(string $key, array $parameters = []): string + { + return $this->translator?->trans($key, $parameters) ?? $key; + } +} diff --git a/src/Service/BookingJournal/BankImport/Parser/ParserInterface.php b/src/Service/BookingJournal/BankImport/Parser/ParserInterface.php new file mode 100644 index 00000000..f21e18eb --- /dev/null +++ b/src/Service/BookingJournal/BankImport/Parser/ParserInterface.php @@ -0,0 +1,28 @@ + $line + */ + public function apply(BankImportRule $rule, array &$line): void + { + $action = $rule->getAction(); + $line['appliedRuleId'] = $rule->getId(); + + switch ($action['mode'] ?? BankImportRule::ACTION_MODE_IGNORE) { + case BankImportRule::ACTION_MODE_IGNORE: + $line['isIgnored'] = true; + $line['status'] = ImportState::LINE_STATUS_IGNORED; + break; + + case BankImportRule::ACTION_MODE_ASSIGN: + $this->applyAssign($action, $line); + break; + + case BankImportRule::ACTION_MODE_SPLIT: + $this->applySplit($action, $line); + break; + } + } + + /** + * @param array $action + * @param array $line + */ + private function applyAssign(array $action, array &$line): void + { + if (isset($action['debitAccountId'])) { + $line['userDebitAccountId'] = (int) $action['debitAccountId']; + } + if (isset($action['creditAccountId'])) { + $line['userCreditAccountId'] = (int) $action['creditAccountId']; + } + if (isset($action['taxRateId'])) { + $line['userTaxRateId'] = null !== $action['taxRateId'] ? (int) $action['taxRateId'] : null; + } + + $remark = $this->renderTemplate($action['remarkTemplate'] ?? null, $line); + if (null !== $remark) { + $line['userRemark'] = $remark; + } + + $line['status'] = ImportState::LINE_STATUS_READY; + } + + /** + * @param array $action + * @param array $line + */ + private function applySplit(array $action, array &$line): void + { + $splitsConfig = $action['splits'] ?? []; + if (!is_array($splitsConfig) || [] === $splitsConfig) { + // Misconfigured rule — leave the line untouched at the action level + // but flag the attempt so the UI can show "rule applied, no effect". + return; + } + + $totalAmount = abs((float) ($line['amount'] ?? 0)); + if (0.0 === $totalAmount) { + return; + } + + $resolved = []; + $assigned = 0.0; + $remainderIndex = null; + + foreach ($splitsConfig as $idx => $piece) { + if (true === ($piece['remainder'] ?? false)) { + $remainderIndex = $idx; + $resolved[$idx] = ['amount' => 0.0, 'config' => $piece]; + continue; + } + + if (isset($piece['amount'])) { + $share = round((float) $piece['amount'], 2); + } elseif (isset($piece['percent'])) { + $share = round($totalAmount * ((float) $piece['percent'] / 100.0), 2); + } else { + continue; + } + + $resolved[$idx] = ['amount' => $share, 'config' => $piece]; + $assigned += $share; + } + + if (null !== $remainderIndex) { + $resolved[$remainderIndex]['amount'] = round($totalAmount - $assigned, 2); + } + + $isOutgoing = ((float) ($line['amount'] ?? 0)) < 0.0; + $splits = []; + foreach ($resolved as $entry) { + $signed = $isOutgoing ? -$entry['amount'] : $entry['amount']; + $splits[] = [ + 'amount' => number_format($signed, 2, '.', ''), + 'debitAccountId' => isset($entry['config']['debitAccountId']) ? (int) $entry['config']['debitAccountId'] : null, + 'creditAccountId' => isset($entry['config']['creditAccountId']) ? (int) $entry['config']['creditAccountId'] : null, + 'taxRateId' => isset($entry['config']['taxRateId']) ? (int) $entry['config']['taxRateId'] : null, + 'remark' => $this->renderTemplate($entry['config']['remarkTemplate'] ?? null, $line), + ]; + } + + $line['splits'] = $splits; + $line['status'] = ImportState::LINE_STATUS_READY; + } + + /** + * @param array $line + */ + private function renderTemplate(?string $template, array $line): ?string + { + if (null === $template || '' === $template) { + return null; + } + + return strtr($template, [ + '{counterparty}' => (string) ($line['counterpartyName'] ?? ''), + '{purpose}' => (string) ($line['purpose'] ?? ''), + '{date}' => (string) ($line['valueDate'] ?? $line['bookDate'] ?? ''), + '{invoiceNumber}' => (string) ($line['matchedInvoiceNumber'] ?? ''), + ]); + } +} diff --git a/src/Service/BookingJournal/BankImport/RuleConditionEvaluator.php b/src/Service/BookingJournal/BankImport/RuleConditionEvaluator.php new file mode 100644 index 00000000..4778f34c --- /dev/null +++ b/src/Service/BookingJournal/BankImport/RuleConditionEvaluator.php @@ -0,0 +1,108 @@ + $line + */ + public function matches(array $condition, array $line): bool + { + $field = $condition['field'] ?? ''; + $operator = $condition['operator'] ?? ''; + $value = $condition['value'] ?? null; + + $resolved = $this->resolveField($field, $line); + + return match ($operator) { + BankImportRule::CONDITION_OP_CONTAINS => $this->containsCi($resolved, (string) $value), + BankImportRule::CONDITION_OP_NOT_CONTAINS => !$this->containsCi($resolved, (string) $value), + BankImportRule::CONDITION_OP_EQUALS => $this->equalsCi($resolved, (string) $value), + BankImportRule::CONDITION_OP_REGEX => $this->safeRegexMatch((string) $value, (string) $resolved), + BankImportRule::CONDITION_OP_GT => (float) $resolved > (float) $value, + BankImportRule::CONDITION_OP_LT => (float) $resolved < (float) $value, + BankImportRule::CONDITION_OP_BETWEEN => $this->between((float) $resolved, $value), + default => false, + }; + } + + /** + * Returns the line value to compare against. Strings for textual fields, + * float for amount, and a normalised "in"/"out" token for direction. + * + * @param array $line + */ + private function resolveField(string $field, array $line): string|float + { + return match ($field) { + BankImportRule::CONDITION_FIELD_COUNTERPARTY_NAME => (string) ($line['counterpartyName'] ?? ''), + BankImportRule::CONDITION_FIELD_COUNTERPARTY_IBAN => (string) ($line['counterpartyIban'] ?? ''), + BankImportRule::CONDITION_FIELD_PURPOSE => (string) ($line['purpose'] ?? ''), + BankImportRule::CONDITION_FIELD_AMOUNT => (float) ($line['amount'] ?? 0), + BankImportRule::CONDITION_FIELD_DIRECTION => ((float) ($line['amount'] ?? 0)) >= 0.0 ? 'in' : 'out', + default => '', + }; + } + + private function containsCi(string|float $haystack, string $needle): bool + { + if ('' === $needle) { + return false; + } + + return false !== stripos((string) $haystack, $needle); + } + + private function equalsCi(string|float $haystack, string $value): bool + { + return 0 === strcasecmp((string) $haystack, $value); + } + + private function safeRegexMatch(string $pattern, string $haystack): bool + { + if ('' === $pattern) { + return false; + } + + // Wrap raw patterns in delimiters if the user didn't. Always force the + // case-insensitive flag — the rule editor is for hoteliers, not regex + // power-users; "tibber" should match "Tibber". + if (1 === preg_match('/^([\/#~]).+\1([imsxueADSUXJ]*)$/', $pattern, $m)) { + if (false === stripos($m[2], 'i')) { + $pattern .= 'i'; + } + } else { + $pattern = '/'.str_replace('/', '\\/', $pattern).'/i'; + } + + $result = @preg_match($pattern, $haystack); + + return 1 === $result; + } + + private function between(float $value, mixed $bounds): bool + { + if (!is_array($bounds) || 2 !== count($bounds)) { + return false; + } + + [$lo, $hi] = array_values($bounds); + + return $value >= (float) $lo && $value <= (float) $hi; + } +} diff --git a/src/Service/BookingJournalService.php b/src/Service/BookingJournal/BookingJournalService.php similarity index 73% rename from src/Service/BookingJournalService.php rename to src/Service/BookingJournal/BookingJournalService.php index 5b822c31..93fa855c 100644 --- a/src/Service/BookingJournalService.php +++ b/src/Service/BookingJournal/BookingJournalService.php @@ -2,12 +2,13 @@ declare(strict_types=1); -namespace App\Service; +namespace App\Service\BookingJournal; use App\Entity\AccountingAccount; use App\Entity\BookingBatch; use App\Entity\BookingEntry; use App\Entity\Invoice; +use App\Entity\TaxRate; use App\Repository\AccountingAccountRepository; use App\Repository\BookingBatchRepository; use App\Repository\BookingEntryRepository; @@ -143,10 +144,14 @@ public function createEntriesFromInvoice( ?AccountingAccount $debitAccount = null, ?AccountingAccount $creditAccount = null, ?string $remark = null, + ?\DateTimeInterface $bookingDate = null, + string $sourceType = BookingEntry::SOURCE_WORKFLOW, ): array { - $today = new \DateTime(); - $year = (int) $today->format('Y'); - $month = (int) $today->format('n'); + $bookingDate = null !== $bookingDate + ? \DateTime::createFromInterface($bookingDate) + : new \DateTime(); + $year = (int) $bookingDate->format('Y'); + $month = (int) $bookingDate->format('n'); $batch = $this->getOrCreateBatch($year, $month); @@ -173,10 +178,10 @@ public function createEntriesFromInvoice( // ("Hauptleistung") and misc ("Sonstige Leistungen") surface as distinct entries even // when they hit the same account + VAT. $taxRateCache = []; - $resolveTaxRate = function (string $vatKey) use (&$taxRateCache, $today, $activePreset) { + $resolveTaxRate = function (string $vatKey) use (&$taxRateCache, $bookingDate, $activePreset) { if (!array_key_exists($vatKey, $taxRateCache)) { // Strip the "v" prefix added to prevent PHP int-casting numeric string keys. - $taxRateCache[$vatKey] = $this->taxRateRepo->findByRate((float) ltrim($vatKey, 'v'), $today, $activePreset); + $taxRateCache[$vatKey] = $this->taxRateRepo->findByRate((float) ltrim($vatKey, 'v'), $bookingDate, $activePreset); } return $taxRateCache[$vatKey]; @@ -241,7 +246,7 @@ public function createEntriesFromInvoice( $entryCreditAccount = $row['account']; $entry = new BookingEntry(); - $entry->setDate(clone $today); + $entry->setDate(clone $bookingDate); $entry->setDocumentNumber($nextDocNumber++); $entry->setAmount($vatBrutto); $entry->setDebitAccount($debitAccount); @@ -256,7 +261,7 @@ public function createEntriesFromInvoice( ? $scopeLabel.' – '.$accountName : ($accountName ?: ($debitAccount?->getName() ?? '')); $entry->setRemark($remark ?: $defaultRemark); - $entry->setSourceType(BookingEntry::SOURCE_WORKFLOW); + $entry->setSourceType($sourceType); $entry->setBookingBatch($batch); $batch->addEntry($entry); @@ -272,6 +277,88 @@ public function createEntriesFromInvoice( return $entries; } + /** + * Creates one BookingEntry for a single statement piece — either a whole + * line or one of its splits. Persisted (but not yet flushed) and assigned + * to the matching month batch. + * + * Caller is expected to set Source via {@see BookingEntry::setSourceType()} + * (typically SOURCE_MANUAL — bank-import is a kind of manual booking). + */ + public function createEntryFromStatement( + \DateTimeInterface $date, + string $amount, + ?AccountingAccount $debitAccount, + ?AccountingAccount $creditAccount, + ?string $remark, + ?string $invoiceNumber = null, + ?int $invoiceId = null, + ?string $splitGroupUuid = null, + ?TaxRate $taxRate = null, + ): BookingEntry { + $entry = new BookingEntry(); + $entry->setDate(\DateTime::createFromInterface($date)); + $entry->setAmount($amount); + $entry->setDebitAccount($debitAccount); + $entry->setCreditAccount($creditAccount); + $entry->setRemark($remark); + $entry->setInvoiceNumber($invoiceNumber); + $entry->setInvoiceId($invoiceId); + $entry->setTaxRate($taxRate); + $entry->setSourceType(BookingEntry::SOURCE_MANUAL); + if (null !== $splitGroupUuid) { + $entry->setSplitGroupUuid($splitGroupUuid); + } + + $batch = $this->assignBatchByEntryDate($entry); + $entry->setDocumentNumber($this->entryRepo->getLastDocumentNumber($batch) + 1); + $this->em->persist($entry); + + return $entry; + } + + /** + * Moves an existing entry to a new date — used when a bank statement reveals + * the actual value date for an invoice that was previously booked on the + * invoice date by a workflow. + * + * Re-assigns the entry to the matching month batch when the month changes + * and re-numbers the affected year(s). Throws when either the source or the + * target batch is closed. + */ + public function updateEntryDate(BookingEntry $entry, \DateTimeInterface $newDate): void + { + $newDate = \DateTime::createFromInterface($newDate); + if ($entry->getDate()->format('Y-m-d') === $newDate->format('Y-m-d')) { + return; + } + + $oldBatch = $entry->getBookingBatch(); + if ($oldBatch->isClosed()) { + throw new \RuntimeException( + $this->translator->trans('journal.error.journal.closed', [ + '%month%' => $oldBatch->getMonth(), + '%year%' => $oldBatch->getYear(), + ]) + ); + } + + $entry->setDate($newDate); + + $oldYear = $oldBatch->getYear(); + $oldMonth = $oldBatch->getMonth(); + $newYear = (int) $newDate->format('Y'); + $newMonth = (int) $newDate->format('n'); + + if ($oldYear !== $newYear || $oldMonth !== $newMonth) { + $this->assignBatchByEntryDate($entry); // throws if target closed + } + + $this->em->flush(); + $years = array_unique([$oldYear, $newYear]); + $this->recalculateDocumentNumbersForYears(...$years); + } + /** * Build template render params for Kassenbuch PDF export. */ diff --git a/src/Service/OpeningBalanceService.php b/src/Service/BookingJournal/OpeningBalanceService.php similarity index 99% rename from src/Service/OpeningBalanceService.php rename to src/Service/BookingJournal/OpeningBalanceService.php index bad4c8fb..a08e1040 100644 --- a/src/Service/OpeningBalanceService.php +++ b/src/Service/BookingJournal/OpeningBalanceService.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\Service; +namespace App\Service\BookingJournal; use App\Entity\AccountingAccount; use App\Entity\BookingBatch; diff --git a/src/Service/TemplatePreview/CashJournalTemplatePreviewProvider.php b/src/Service/TemplatePreview/CashJournalTemplatePreviewProvider.php index a2e25c8d..471d8ea9 100644 --- a/src/Service/TemplatePreview/CashJournalTemplatePreviewProvider.php +++ b/src/Service/TemplatePreview/CashJournalTemplatePreviewProvider.php @@ -20,7 +20,7 @@ use App\Entity\TaxRate; use App\Interfaces\ITemplatePreviewProvider; use App\Repository\BookingBatchRepository; -use App\Service\BookingJournalService; +use App\Service\BookingJournal\BookingJournalService; /** * Preview provider for cash journal PDF templates. diff --git a/src/Workflow/Action/CreateBookingEntryAction.php b/src/Workflow/Action/CreateBookingEntryAction.php index cf23e8f1..780df140 100644 --- a/src/Workflow/Action/CreateBookingEntryAction.php +++ b/src/Workflow/Action/CreateBookingEntryAction.php @@ -6,7 +6,7 @@ use App\Entity\Invoice; use App\Repository\AccountingAccountRepository; -use App\Service\BookingJournalService; +use App\Service\BookingJournal\BookingJournalService; use App\Workflow\WorkflowSkippedException; use Symfony\Contracts\Translation\TranslatorInterface; diff --git a/templates/BookingJournal/BankImport/_rule_modal.html.twig b/templates/BookingJournal/BankImport/_rule_modal.html.twig new file mode 100644 index 00000000..474e4ee8 --- /dev/null +++ b/templates/BookingJournal/BankImport/_rule_modal.html.twig @@ -0,0 +1,154 @@ +{# Save-as-rule modal — pre-filled with the selected line's data when opened. #} + diff --git a/templates/BookingJournal/BankImport/_rule_summary.html.twig b/templates/BookingJournal/BankImport/_rule_summary.html.twig new file mode 100644 index 00000000..19916a83 --- /dev/null +++ b/templates/BookingJournal/BankImport/_rule_summary.html.twig @@ -0,0 +1,98 @@ +{# Renders a human-readable summary of a BankImportRule's conditions + action. + Reusable in the list view and the edit view. #} + +{% macro conditionLine(condition) %} + {% set fieldLabels = { + 'counterpartyName': 'accounting.bank_import.rules.field_label.counterparty_name', + 'counterpartyIban': 'accounting.bank_import.rules.field_label.counterparty_iban', + 'purpose': 'accounting.bank_import.rules.field_label.purpose', + 'amount': 'accounting.bank_import.rules.field_label.amount', + 'direction': 'accounting.bank_import.rules.field_label.direction', + } %} + {% set opLabels = { + 'contains': 'accounting.bank_import.rules.op.contains', + 'not_contains': 'accounting.bank_import.rules.op.not_contains', + 'equals': 'accounting.bank_import.rules.op.equals', + 'regex': 'accounting.bank_import.rules.op.regex', + 'gt': 'accounting.bank_import.rules.op.gt', + 'lt': 'accounting.bank_import.rules.op.lt', + 'between': 'accounting.bank_import.rules.op.between', + } %} + {{ (fieldLabels[condition.field] ?? condition.field)|trans }} + {{ (opLabels[condition.operator] ?? condition.operator)|trans }} + {% if condition.value is iterable %}{{ condition.value|join(' – ') }}{% else %}{{ condition.value }}{% endif %} +{% endmacro %} + +{% macro conditionsList(conditions) %} + {% import _self as ui %} + {% if conditions is empty %} + {{ 'accounting.bank_import.rules.conditions_empty'|trans }} + {% else %} + {% for cond in conditions %} +
{{ ui.conditionLine(cond) }}
+ {% if not loop.last %} +
{{ 'accounting.bank_import.rules.and'|trans }}
+ {% endif %} + {% endfor %} + {% endif %} +{% endmacro %} + +{% macro actionSummary(action, accountsById, taxRatesById) %} + {% set mode = action.mode|default('ignore') %} + {% if mode == 'ignore' %} + {{ 'accounting.bank_import.rules.action.ignore'|trans }} + {% elseif mode == 'split' %} + + + {{ 'accounting.bank_import.rules.action.split'|trans({'%count%': (action.splits|default([]))|length}) }} + + {% for split in action.splits|default([]) %} + {% set debit = accountsById[split.debitAccountId|default(0)] ?? null %} + {% set credit = accountsById[split.creditAccountId|default(0)] ?? null %} + {% set taxRate = taxRatesById[split.taxRateId|default(0)] ?? null %} +
+ #{{ loop.index }} + {% if split.amount is defined %} + {{ split.amount|number_format(2, ',', '.') }} + {% elseif split.percent is defined %} + {{ split.percent|number_format(2, ',', '.') }}% + {% elseif split.remainder|default(false) %} + {{ 'accounting.bank_import.split.mode.remainder'|trans }} + {% endif %} + {% if debit %} + {{ 'accounting.bank_import.preview.col.debit'|trans }}: + {{ debit.label }} + {% endif %} + {% if credit %} + {{ 'accounting.bank_import.preview.col.credit'|trans }}: + {{ credit.label }} + {% endif %} + {% if taxRate %} + {{ 'accounting.bank_import.preview.col.tax_rate'|trans }}: + {{ taxRate.name }} ({{ taxRate.rateFloat|number_format(2, ',', '.') }}%) + {% endif %} +
+ {% endfor %} + {% else %} + {% set debit = accountsById[action.debitAccountId|default(0)] ?? null %} + {% set credit = accountsById[action.creditAccountId|default(0)] ?? null %} + {% set taxRate = taxRatesById[action.taxRateId|default(0)] ?? null %} +
+ {% if debit %} + {{ 'accounting.bank_import.preview.col.debit'|trans }}: + {{ debit.label }} + {% endif %} + {% if credit %} + {{ 'accounting.bank_import.preview.col.credit'|trans }}: + {{ credit.label }} + {% endif %} + {% if taxRate %} + {{ 'accounting.bank_import.preview.col.tax_rate'|trans }}: + {{ taxRate.name }} ({{ taxRate.rateFloat|number_format(2, ',', '.') }}%) + {% endif %} + {% if action.remarkTemplate is defined and action.remarkTemplate %} +
„{{ action.remarkTemplate }}"
+ {% endif %} +
+ {% endif %} +{% endmacro %} diff --git a/templates/BookingJournal/BankImport/_split_modal.html.twig b/templates/BookingJournal/BankImport/_split_modal.html.twig new file mode 100644 index 00000000..b5dde30e --- /dev/null +++ b/templates/BookingJournal/BankImport/_split_modal.html.twig @@ -0,0 +1,109 @@ +{# Split modal — rendered once on the preview page, populated by Stimulus when opened. #} + + +{# Hidden template for one split row — cloned by Stimulus. #} + diff --git a/templates/BookingJournal/BankImport/_status_badge.html.twig b/templates/BookingJournal/BankImport/_status_badge.html.twig new file mode 100644 index 00000000..23950255 --- /dev/null +++ b/templates/BookingJournal/BankImport/_status_badge.html.twig @@ -0,0 +1,22 @@ +{# Renders the leading status badge for one statement line. #} +{% if line.isDuplicate %} + + + +{% elseif line.isIgnored %} + + + +{% elseif line.status == 'ready' %} + + + +{% else %} + + + +{% endif %} diff --git a/templates/BookingJournal/BankImport/index.html.twig b/templates/BookingJournal/BankImport/index.html.twig new file mode 100644 index 00000000..09db4e08 --- /dev/null +++ b/templates/BookingJournal/BankImport/index.html.twig @@ -0,0 +1,119 @@ +{% extends 'base.html.twig' %} + +{% block title %} + {{ parent() }} - {{ 'accounting.bank_import.title'|trans }} +{% endblock %} + +{% block flashMessage %} + {% include 'BookingJournal/_flash_messages.html.twig' %} +{% endblock %} + +{% block content %} +
+ + +
+
+

{{ 'accounting.bank_import.title'|trans }}

+

{{ 'accounting.bank_import.subtitle'|trans }}

+
+ +
+ + {% include 'feedback.html.twig' %} + + {# ── Open drafts ─────────────────────────────────────────────── #} + {% if drafts is not empty %} +
+
+
{{ 'accounting.bank_import.drafts.title'|trans }}
+
+
+ + + + + + + + + + + + + {% for draft in drafts %} + {% set id = draft.sessionImportId %} + {% set targetUrl = path('bank_import.discard', {'sessionImportId': draft.sessionImportId}) %} + {% use "common/delete_popover.html.twig" %} + + + + + + + + + {% endfor %} + +
{{ 'accounting.bank_import.drafts.file'|trans }}{{ 'accounting.bank_import.drafts.account'|trans }}{{ 'accounting.bank_import.drafts.period'|trans }}{{ 'accounting.bank_import.drafts.lines'|trans }}{{ 'accounting.bank_import.drafts.created'|trans }}
{{ draft.originalFilename }} + {% set acc = accountsById[draft.bankAccountId] ?? null %} + {% if acc %}{{ acc.label }}{% else %}{{ 'accounting.bank_import.drafts.account_missing'|trans }}{% endif %} + + {% if draft.periodFrom and draft.periodTo %} + {{ draft.periodFrom|date('d.m.Y') }} – {{ draft.periodTo|date('d.m.Y') }} + {% else %} + + {% endif %} + {{ draft.lines|length }}{{ draft.createdAt|date('d.m.Y H:i') }} + + {{ 'accounting.bank_import.drafts.open'|trans }} + + +
+
+
+ {% endif %} + + {# ── Upload form ─────────────────────────────────────────────── #} +
+
+
{{ 'accounting.bank_import.upload.title'|trans }}
+
+
+ {{ form_start(uploadForm, {'attr': {'enctype': 'multipart/form-data'}}) }} +
+
{{ form_row(uploadForm.bankAccount) }}
+
{{ form_row(uploadForm.csvProfile) }}
+
+ {{ form_row(uploadForm.file) }} +
+ +
+ {{ form_end(uploadForm) }} +
+
+
+{% endblock %} diff --git a/templates/BookingJournal/BankImport/preview.html.twig b/templates/BookingJournal/BankImport/preview.html.twig new file mode 100644 index 00000000..c0ffbc7c --- /dev/null +++ b/templates/BookingJournal/BankImport/preview.html.twig @@ -0,0 +1,383 @@ +{% extends 'base.html.twig' %} + +{% block title %} + {{ parent() }} - {{ 'accounting.bank_import.preview.title'|trans }} +{% endblock %} + +{% block flashMessage %} + {% include 'BookingJournal/_flash_messages.html.twig' %} +{% endblock %} + +{% block content %} + +
+ + +
+
+

+ {{ state.originalFilename }} +

+

+ {{ bankAccount.label }} + {% if state.periodFrom and state.periodTo %} + · {{ state.periodFrom|date('d.m.Y') }} – {{ state.periodTo|date('d.m.Y') }} + {% endif %} + {% if state.sourceIban %}· {{ state.sourceIban }}{% endif %} +

+
+
+ {% set id = state.sessionImportId %} + {% set targetUrl = path('bank_import.discard', {'sessionImportId': state.sessionImportId}) %} + {% use "common/delete_popover.html.twig" %} + +
+
+ + {% include 'feedback.html.twig' %} + + {# ── Filter chips ─────────────────────────────────────────── #} +
+ {% set filters = [ + {'key': 'all', 'label': 'accounting.bank_import.preview.filter.all', 'count': counts.total, 'class': 'btn-outline-secondary'}, + {'key': 'pending', 'label': 'accounting.bank_import.preview.filter.pending', 'count': counts.pending, 'class': 'btn-outline-warning'}, + {'key': 'ready', 'label': 'accounting.bank_import.preview.filter.ready', 'count': counts.ready, 'class': 'btn-outline-success'}, + {'key': 'duplicate', 'label': 'accounting.bank_import.preview.filter.duplicate', 'count': counts.duplicate, 'class': 'btn-outline-info'}, + {'key': 'ignored', 'label': 'accounting.bank_import.preview.filter.ignored', 'count': counts.ignored, 'class': 'btn-outline-dark'}, + {'key': 'rule', 'label': 'accounting.bank_import.preview.filter.rule', 'count': null, 'class': 'btn-outline-primary'}, + {'key': 'invoice', 'label': 'accounting.bank_import.preview.filter.invoice', 'count': null, 'class': 'btn-outline-primary'}, + ] %} + {% for f in filters %} + + {% endfor %} +
+ + {# ── Bulk action bar ──────────────────────────────────────── #} + + + {% if state.warnings is not empty %} + + {% endif %} + + {% if invoiceMatchingDisabled %} + + {% endif %} + +
+
+ + + + + + + + + + + + + + + + + {% for line in state.lines %} + {% set readonly = line.isDuplicate %} + + + + {# Status badges (re-rendered by JS when status changes) #} + + + + + + + + + + + {# Debit account #} + + + {# Credit account #} + + + {# Tax rate #} + + + {# Remark + actions #} + + + {% else %} + + + + {% endfor %} + +
+ + {{ 'accounting.bank_import.preview.col.status'|trans }}{{ 'accounting.bank_import.preview.col.date'|trans }}{{ 'accounting.bank_import.preview.col.counterparty'|trans }}{{ 'accounting.bank_import.preview.col.purpose'|trans }}{{ 'accounting.bank_import.preview.col.amount'|trans }}{{ 'accounting.bank_import.preview.col.tax_rate'|trans }}{{ 'accounting.bank_import.preview.col.remark'|trans }}
+ + + {% include 'BookingJournal/BankImport/_status_badge.html.twig' with {'line': line} only %} + {% if line.appliedRuleId %} + + + + {% endif %} + {% if line.splits is not empty %} + + {{ line.splits|length }} + + {% endif %} + {{ line.bookDate|date('d.m.Y') }} +
{{ line.counterpartyName ?: '—' }}
+ {% if line.counterpartyIban %} +
{{ line.counterpartyIban }}
+ {% endif %} +
+ + {{ line.purpose }} + + {% if line.matchedInvoiceNumber %} +
+ + {{ line.matchedInvoiceNumber }} + +
+ {% endif %} +
+ {{ line.amount|number_format(2, ',', '.') }} € + + + + {% if line.matchedInvoiceId and line.matchedInvoiceAmountMatches and line.splits is empty %} +
+ {{ 'accounting.bank_import.preview.invoice_accounts.label'|trans }} +
+ {% else %} + + {% endif %} +
+ {% if line.matchedInvoiceId and line.matchedInvoiceAmountMatches and line.splits is empty %} +
+ {{ 'accounting.bank_import.preview.invoice_tax_rate.label'|trans }} +
+ {% else %} + + {% endif %} +
+
+ + + + +
+
+ {{ 'accounting.bank_import.preview.empty'|trans }} +
+
+
+ + {# Commit bar — sticky, shows progress and the commit action. #} + + +
+ {{ counts.ready }} {{ 'accounting.bank_import.commit.summary.ready'|trans }} + · {{ counts.pending }} {{ 'accounting.bank_import.commit.summary.pending'|trans }} + · {{ counts.ignored }} {{ 'accounting.bank_import.commit.summary.ignored'|trans }} + · {{ counts.duplicate }} {{ 'accounting.bank_import.commit.summary.duplicate'|trans }} +
+ + + + {# JSON snapshot of every line — Stimulus reads it when opening modals. #} + + + {% include 'BookingJournal/BankImport/_split_modal.html.twig' with {'accounts': accounts, 'taxRates': taxRates} only %} + {% include 'BookingJournal/BankImport/_rule_modal.html.twig' with {'accounts': accounts, 'taxRates': taxRates} only %} +
+{% endblock %} diff --git a/templates/BookingJournal/BankImport/profile_form.html.twig b/templates/BookingJournal/BankImport/profile_form.html.twig new file mode 100644 index 00000000..749b481b --- /dev/null +++ b/templates/BookingJournal/BankImport/profile_form.html.twig @@ -0,0 +1,225 @@ +{% extends 'base.html.twig' %} + +{% block title %} + {{ parent() }} - + {% if isNew %}{{ 'accounting.bank_import.profile.add'|trans }}{% else %}{{ 'accounting.bank_import.profile.edit'|trans }}{% endif %} +{% endblock %} + +{% block flashMessage %} + {% include 'BookingJournal/_flash_messages.html.twig' %} +{% endblock %} + +{# ── Reusable horizontal field row ──────────────────────────────── + Renders any field as: right-aligned label | input — always on one + line, with all inputs across the form starting at the same x-axis. +─────────────────────────────────────────────────────────────────── #} +{% macro field(field, options = {}) %} + {% set width = options.width|default(null) %} +
+ +
+ {% if width %} + {{ form_widget(field, {'attr': field.vars.attr|merge({'style': 'max-width: ' ~ width})}) }} + {% else %} + {{ form_widget(field) }} + {% endif %} + {% if field.vars.help %} +
{{ field.vars.help|trans }}
+ {% endif %} + {{ form_errors(field) }} +
+
+{% endmacro %} + +{# Section heading inside the form card. #} +{% macro section(label, help = null) %} +
+
+
+ {{ label|trans }} +
+ {% if help %}

{{ help|trans }}

{% endif %} +
+
+{% endmacro %} + +{% block content %} +{% import _self as ui %} +
+ + +
+
+

+ {% if isNew %} + {{ 'accounting.bank_import.profile.add'|trans }} + {% else %} + {{ 'accounting.bank_import.profile.edit'|trans }} + {% endif %} +

+

{{ 'accounting.bank_import.profile.help.intro'|trans }}

+
+
+ + {% include 'feedback.html.twig' %} + + {{ form_start(form) }} +
+ {# ── Left: form ────────────────────────────────────────── #} +
+
+
+ + {{ ui.field(form.name) }} + {{ ui.field(form.description) }} + + {{ ui.section('accounting.bank_import.profile.section.csv') }} + {{ ui.field(form.delimiter, {'width': '4rem'}) }} + {{ ui.field(form.enclosure, {'width': '4rem'}) }} + {{ ui.field(form.encoding, {'width': '14rem'}) }} + {{ ui.field(form.headerSkip, {'width': '4rem'}) }} + {{ ui.field(form.hasHeaderRow) }} + + {{ ui.section('accounting.bank_import.profile.section.values') }} + {{ ui.field(form.dateFormat, {'width': '8rem'}) }} + {{ ui.field(form.directionMode, {'width': '18rem'}) }} + {{ ui.field(form.amountDecimalSeparator, {'width': '4rem'}) }} + {{ ui.field(form.amountThousandsSeparator, {'width': '4rem'}) }} + + {{ ui.section( + 'accounting.bank_import.profile.section.header', + 'accounting.bank_import.profile.section.header.help' + ) }} + {{ ui.field(form.ibanSourceLine, {'width': '4rem'}) }} + {{ ui.field(form.periodSourceLine, {'width': '4rem'}) }} + + {{ ui.section( + 'accounting.bank_import.profile.section.columns', + 'accounting.bank_import.profile.section.columns.help' + ) }} + +
+ {{ 'accounting.bank_import.profile.col.group.required'|trans }} +
+ {{ ui.field(form.col_bookDate, {'width': '4rem'}) }} + {{ ui.field(form.col_amount, {'width': '4rem'}) }} + +
+ {{ 'accounting.bank_import.profile.col.group.separate'|trans }} +
+ {{ ui.field(form.col_amountDebit, {'width': '4rem'}) }} + {{ ui.field(form.col_amountCredit, {'width': '4rem'}) }} + +
+ {{ 'accounting.bank_import.profile.col.group.context'|trans }} +
+ {{ ui.field(form.col_valueDate, {'width': '4rem'}) }} + {{ ui.field(form.col_purpose, {'width': '4rem'}) }} + {{ ui.field(form.col_counterpartyName, {'width': '4rem'}) }} + {{ ui.field(form.col_counterpartyIban, {'width': '4rem'}) }} + +
+ {{ 'accounting.bank_import.profile.col.group.optional'|trans }} +
+ {{ ui.field(form.col_endToEndId, {'width': '4rem'}) }} + {{ ui.field(form.col_mandateReference, {'width': '4rem'}) }} + {{ ui.field(form.col_creditorId, {'width': '4rem'}) }} +
+
+ +
+ + {{ 'button.cancel'|trans }} + + +
+
+ + {# ── Right: visual example ────────────────────────────── #} +
+
+
+ {{ 'accounting.bank_import.profile.example.title'|trans }} +
+
+

{{ 'accounting.bank_import.profile.example.intro'|trans }}

+ +
+
+Z 0: "Girokonto";"DE44…"
+Z 1: "Zeitraum:";"01.03.2026 - 31.03.2026"
+Z 2: "Kontostand vom …"
+Z 3: (leer)
+Z 4: "Buchungsdatum";"Wertstellung";"Status";"Zahler";"Empfänger";"Zweck";"Typ";"IBAN";"Betrag";…
+Z 5: "31.03.26";"31.03.26";"Gebucht";"ISSUER";"Company XYZ";"VISA Debitkartenumsatz";"Ausgang";"DE45…";"-41,98";…
+      ↑0          ↑1           ↑2        ↑3      ↑4            ↑5                  ↑6       ↑7     ↑8
+
+ +
+
+ + {{ 'accounting.bank_import.profile.example.iban'|trans }} +
+
+ {{ 'accounting.bank_import.profile.iban_source_line'|trans }} = 0 +
+ +
+ + {{ 'accounting.bank_import.profile.example.period'|trans }} +
+
+ {{ 'accounting.bank_import.profile.period_source_line'|trans }} = 1 +
+ +
+ + {{ 'accounting.bank_import.profile.example.dates'|trans }} +
+
+ col_bookDate = 0, col_valueDate = 1 +
+ +
+ + {{ 'accounting.bank_import.profile.example.context'|trans }} +
+
+ col_counterpartyName = 4, col_purpose = 5,
+ col_counterpartyIban = 7 +
+ +
+ + {{ 'accounting.bank_import.profile.example.amount'|trans }} +
+
+ col_amount = 8 ({{ 'accounting.bank_import.profile.example.signed_hint'|trans }}) +
+ +
{{ 'accounting.bank_import.profile.example.header_skip'|trans }}
+
+ {{ 'accounting.bank_import.profile.example.header_skip.help'|trans }}
+ headerSkip = 4, hasHeaderRow = ✓ +
+
+
+
+
+
+ {{ form_end(form) }} +
+{% endblock %} diff --git a/templates/BookingJournal/BankImport/profiles_index.html.twig b/templates/BookingJournal/BankImport/profiles_index.html.twig new file mode 100644 index 00000000..a8f07f91 --- /dev/null +++ b/templates/BookingJournal/BankImport/profiles_index.html.twig @@ -0,0 +1,95 @@ +{% extends 'base.html.twig' %} + +{% block title %} + {{ parent() }} - {{ 'accounting.bank_import.profile.title'|trans }} +{% endblock %} + +{% block flashMessage %} + {% include 'BookingJournal/_flash_messages.html.twig' %} +{% endblock %} + +{% block content %} +
+ + +
+
+

{{ 'accounting.bank_import.profile.title'|trans }}

+

{{ 'accounting.bank_import.profile.subtitle'|trans }}

+
+ +
+ + {% include 'feedback.html.twig' %} + +
+
+ + + + + + + + + + + + + {% for profile in profiles %} + {% set id = profile.id %} + {% set targetUrl = path('bank_import.profiles.delete', {'id': profile.id}) %} + {% use "common/delete_popover.html.twig" %} + + + + + + + + + {% else %} + + + + {% endfor %} + +
{{ 'accounting.bank_import.profile.name'|trans }}{{ 'accounting.bank_import.profile.delimiter'|trans }}{{ 'accounting.bank_import.profile.encoding'|trans }}{{ 'accounting.bank_import.profile.date_format'|trans }}{{ 'accounting.bank_import.profile.direction_mode'|trans }}{{ 'accounting.bank_import.profile.actions'|trans }}
+ {{ profile.name }} + {% if profile.description %} +
{{ profile.description }}
+ {% endif %} +
{{ profile.delimiter }}{{ profile.encoding }}{{ profile.dateFormat }} + {{ ('accounting.bank_import.profile.direction.' ~ profile.directionMode)|trans }} + + + + + +
+ {{ 'accounting.bank_import.profile.empty'|trans }} +
+
+
+
+{% endblock %} diff --git a/templates/BookingJournal/BankImport/rule_form.html.twig b/templates/BookingJournal/BankImport/rule_form.html.twig new file mode 100644 index 00000000..948ee236 --- /dev/null +++ b/templates/BookingJournal/BankImport/rule_form.html.twig @@ -0,0 +1,87 @@ +{% extends 'base.html.twig' %} +{% import 'BookingJournal/BankImport/_rule_summary.html.twig' as ruleUi %} + +{% block title %}{{ parent() }} - {{ 'accounting.bank_import.rules.edit'|trans }}{% endblock %} + +{% block flashMessage %} + {% include 'BookingJournal/_flash_messages.html.twig' %} +{% endblock %} + +{% block content %} +
+ + +
+
+

{{ 'accounting.bank_import.rules.edit'|trans }}

+

{{ 'accounting.bank_import.rules.edit.help'|trans }}

+
+
+ + {% include 'feedback.html.twig' %} + +
+
+ {{ form_start(form) }} +
+
+ {{ 'accounting.bank_import.rules.section.basics'|trans }} +
+
+ {{ form_row(form.name) }} + {{ form_row(form.description) }} + {{ form_row(form.isEnabled) }} +
+
{{ form_row(form.priority) }}
+
+
{{ form_row(form.bankAccount) }}
+
+
+
+ +
+ + {{ 'button.cancel'|trans }} + + +
+ {{ form_end(form) }} +
+ +
+
+
+ {{ 'accounting.bank_import.rules.section.conditions'|trans }} +
+
+ {{ ruleUi.conditionsList(rule.conditions) }} +
+
+ +
+
+ {{ 'accounting.bank_import.rules.section.action'|trans }} +
+
+ {{ ruleUi.actionSummary(rule.action, accountsById, taxRatesById) }} +
+
+ +

+ + {{ 'accounting.bank_import.rules.edit.recreate_hint'|trans }} +

+
+
+
+{% endblock %} diff --git a/templates/BookingJournal/BankImport/rules_index.html.twig b/templates/BookingJournal/BankImport/rules_index.html.twig new file mode 100644 index 00000000..475c8f56 --- /dev/null +++ b/templates/BookingJournal/BankImport/rules_index.html.twig @@ -0,0 +1,104 @@ +{% extends 'base.html.twig' %} +{% import 'BookingJournal/BankImport/_rule_summary.html.twig' as ruleUi %} + +{% block title %}{{ parent() }} - {{ 'accounting.bank_import.rules.title'|trans }}{% endblock %} + +{% block flashMessage %} + {% include 'BookingJournal/_flash_messages.html.twig' %} +{% endblock %} + +{% block content %} +
+ + +
+
+

{{ 'accounting.bank_import.rules.title'|trans }}

+

{{ 'accounting.bank_import.rules.subtitle'|trans }}

+
+
+ + {% include 'feedback.html.twig' %} + +
+
+ + + + + + + + + + + + + {% for rule in rules %} + {% set id = rule.id %} + {% set targetUrl = path('bank_import.rules.delete', {'id': rule.id}) %} + {% use "common/delete_popover.html.twig" %} + + + + + + + + + {% else %} + + + + {% endfor %} + +
{{ 'accounting.bank_import.rules.col.priority'|trans }}{{ 'accounting.bank_import.rules.col.name'|trans }}{{ 'accounting.bank_import.rules.col.scope'|trans }}{{ 'accounting.bank_import.rules.col.conditions'|trans }}{{ 'accounting.bank_import.rules.col.action'|trans }}{{ 'accounting.bank_import.rules.col.actions'|trans }}
{{ rule.priority }} + {{ rule.name }} + {% if rule.description %} +
{{ rule.description }}
+ {% endif %} +
+ {% if rule.bankAccount %} + {{ rule.bankAccount.label }} + {% else %} + {{ 'accounting.bank_import.rules.scope.global'|trans }} + {% endif %} + {{ ruleUi.conditionsList(rule.conditions) }}{{ ruleUi.actionSummary(rule.action, accountsById, taxRatesById) }} +
+ + +
+ + + + +
+ {{ 'accounting.bank_import.rules.empty'|trans }} +
+
+
+ +

+ + {{ 'accounting.bank_import.rules.create_hint'|trans }} +

+
+{% endblock %} diff --git a/templates/BookingJournal/_account_form.html.twig b/templates/BookingJournal/_account_form.html.twig index 698993dd..11f1696f 100644 --- a/templates/BookingJournal/_account_form.html.twig +++ b/templates/BookingJournal/_account_form.html.twig @@ -4,6 +4,7 @@ {{ form_row(form.type) }} {{ form_row(form.isCashAccount) }} {{ form_row(form.isBankAccount) }} + {{ form_row(form.iban) }} {{ form_row(form.isOpeningBalanceAccount) }} {{ form_row(form.isAutoAccount) }} {{ form_row(form.datevSachverhaltLuL) }} diff --git a/templates/BookingJournal/entries.html.twig b/templates/BookingJournal/entries.html.twig index 910a9d49..9c58b527 100644 --- a/templates/BookingJournal/entries.html.twig +++ b/templates/BookingJournal/entries.html.twig @@ -104,8 +104,8 @@ {{ entry.date|date('d.m.Y') }} {{ '%04d'|format(entry.documentNumber) }} - {{ isIncome ? entry.amount|number_format(2, ',', '.') : '' }} - {{ isExpense ? entry.amount|number_format(2, ',', '.') : '' }} + {{ isIncome ? entry.amountFloat|abs|number_format(2, ',', '.') : '' }} + {{ isExpense ? entry.amountFloat|abs|number_format(2, ',', '.') : '' }} {% if isIncome and entry.creditAccount %} {{ entry.creditAccount.label }} @@ -172,8 +172,8 @@ {{ entry.date|date('d.m.Y') }} {{ '%04d'|format(entry.documentNumber) }} - {{ isIncome ? entry.amount|number_format(2, ',', '.') : '' }} - {{ isExpense ? entry.amount|number_format(2, ',', '.') : '' }} + {{ isIncome ? entry.amountFloat|abs|number_format(2, ',', '.') : '' }} + {{ isExpense ? entry.amountFloat|abs|number_format(2, ',', '.') : '' }} {% if isIncome and entry.creditAccount %} {{ entry.creditAccount.label }} diff --git a/templates/BookingJournal/index.html.twig b/templates/BookingJournal/index.html.twig index 7acce75f..7c0ab49d 100644 --- a/templates/BookingJournal/index.html.twig +++ b/templates/BookingJournal/index.html.twig @@ -52,6 +52,9 @@ title="{{ 'accounting.journal.export.year.help'|trans }}"> {{ 'accounting.journal.export.year'|trans }} + + + diff --git a/templates/BookingJournal/settings.html.twig b/templates/BookingJournal/settings.html.twig index 25de45cb..4540a57c 100644 --- a/templates/BookingJournal/settings.html.twig +++ b/templates/BookingJournal/settings.html.twig @@ -264,6 +264,21 @@ {{ form_row(settingsForm.mainPositionLabel) }} {{ form_row(settingsForm.miscPositionLabel) }} +
+
+ {{ 'accounting.settings.invoice_number_samples.title'|trans }} + + + +
+

{{ 'accounting.settings.invoice_number_samples.intro'|trans }}

+
+
{{ form_row(settingsForm.invoiceNumberSample1) }}
+
{{ form_row(settingsForm.invoiceNumberSample2) }}
+
{{ form_row(settingsForm.invoiceNumberSample3) }}
+
+
-
-

{{ getLocalizedMonth(batch.month, 'MMMM', app.request.locale) }} {{ batch.year }}

+
+
+ {% if previousBatch %} + {% set previousBatchLabel = getLocalizedMonth(previousBatch.month, 'MMMM', app.request.locale) ~ ' ' ~ previousBatch.year %} + + {% endif %} +

{{ getLocalizedMonth(batch.month, 'MMMM', app.request.locale) }} {{ batch.year }}

+ {% if nextBatch %} + {% set nextBatchLabel = getLocalizedMonth(nextBatch.month, 'MMMM', app.request.locale) ~ ' ' ~ nextBatch.year %} + + {% endif %} +
- - {% for account in accounts %} - - {% endfor %} - -
-
- - +
+
+
+ + +
+
+ + +
+
+ + +
-
- - + +
+ + +
{{ 'accounting.bank_import.rule.remark_template.help'|trans }}
-
- - -
{{ 'accounting.bank_import.rule.remark_template.help'|trans }}
+
+ + diff --git a/templates/BookingJournal/BankImport/_rule_summary.html.twig b/templates/BookingJournal/BankImport/_rule_summary.html.twig index 19916a83..6066740e 100644 --- a/templates/BookingJournal/BankImport/_rule_summary.html.twig +++ b/templates/BookingJournal/BankImport/_rule_summary.html.twig @@ -52,7 +52,9 @@ {% set taxRate = taxRatesById[split.taxRateId|default(0)] ?? null %}
#{{ loop.index }} - {% if split.amount is defined %} + {% if split.amountSource|default(null) == 'purpose_marker' %} + {{ 'accounting.bank_import.rules.action.split_marker'|trans({'%marker%': split.marker|default('')}) }} + {% elseif split.amount is defined %} {{ split.amount|number_format(2, ',', '.') }} {% elseif split.percent is defined %} {{ split.percent|number_format(2, ',', '.') }}% diff --git a/templates/BookingJournal/BankImport/_split_modal.html.twig b/templates/BookingJournal/BankImport/_split_modal.html.twig index b5dde30e..22b3571f 100644 --- a/templates/BookingJournal/BankImport/_split_modal.html.twig +++ b/templates/BookingJournal/BankImport/_split_modal.html.twig @@ -26,6 +26,12 @@
+
+
{{ 'accounting.bank_import.split.purpose'|trans }}
+
+
+
diff --git a/translations/BankImport/messages.de.yaml b/translations/BankImport/messages.de.yaml index ea457110..83a9de82 100644 --- a/translations/BankImport/messages.de.yaml +++ b/translations/BankImport/messages.de.yaml @@ -270,7 +270,7 @@ accounting: deleted: Regel gelöscht. profile: - title: CSV-Profile für Kontoauszüge + title: CSV-Profile subtitle: | Definiere für jede Bank, wie deren CSV-Export aussieht – Spalten, Datums- und Zahlenformat. Diese Profile werden beim Hochladen ausgewählt. diff --git a/translations/BankImport/messages.en.yaml b/translations/BankImport/messages.en.yaml index 600fb1da..f808b867 100644 --- a/translations/BankImport/messages.en.yaml +++ b/translations/BankImport/messages.en.yaml @@ -269,7 +269,7 @@ accounting: deleted: Rule deleted. profile: - title: CSV profiles for bank statements + title: CSV profiles subtitle: | For each bank, describe what its CSV export looks like — columns, date and number format. These profiles are picked when uploading. From 82b9cb8e51c44ac735c74154291e59347ebf24f7 Mon Sep 17 00:00:00 2001 From: Alexander Elchlepp Date: Mon, 4 May 2026 08:57:23 +0200 Subject: [PATCH 06/38] #203 added campt 052/053 import parser --- config/services.yaml | 2 +- src/Controller/Attribute/ImportDraft.php | 20 + src/Controller/BankImportController.php | 224 ++++----- .../Resolver/ImportDraftResolver.php | 62 +++ .../BankImport/BankImportFormatChoice.php | 22 + .../BookingJournal/BankImport/ImportState.php | 70 +++ .../MultipleSourceAccountsException.php | 13 + .../BookingJournal/BankImport/ParseResult.php | 65 +++ .../BankImportEditExceptionSubscriber.php | 39 ++ src/Exception/BankImportEditException.php | 41 ++ src/Form/BankImportFormatType.php | 118 +++++ src/Form/BankStatementUploadType.php | 36 +- .../BankImport/Parser/GenericCsvParser.php | 5 + .../BankImport/Parser/Iso20022CamtParser.php | 471 ++++++++++++++++++ .../BankImport/Parser/ParserInterface.php | 7 + .../BookingJournal/BankImport/index.html.twig | 2 +- tests/Functional/BankImportControllerTest.php | 71 ++- .../BankImport/Iso20022CamtParserTest.php | 91 ++++ translations/BankImport/messages.de.yaml | 29 +- translations/BankImport/messages.en.yaml | 29 +- 20 files changed, 1257 insertions(+), 160 deletions(-) create mode 100644 src/Controller/Attribute/ImportDraft.php create mode 100644 src/Controller/Resolver/ImportDraftResolver.php create mode 100644 src/Dto/BookingJournal/BankImport/BankImportFormatChoice.php create mode 100644 src/Dto/BookingJournal/BankImport/MultipleSourceAccountsException.php create mode 100644 src/EventSubscriber/BankImportEditExceptionSubscriber.php create mode 100644 src/Exception/BankImportEditException.php create mode 100644 src/Form/BankImportFormatType.php create mode 100644 src/Service/BookingJournal/BankImport/Parser/Iso20022CamtParser.php create mode 100644 tests/Unit/BookingJournal/BankImport/Iso20022CamtParserTest.php diff --git a/config/services.yaml b/config/services.yaml index ef484480..3ba96866 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -43,7 +43,7 @@ services: # this creates a service per class whose id is the fully-qualified class name App\: resource: '../src/*' - exclude: '../src/{Entity,Migrations,Tests,Kernel.php}' + exclude: '../src/{Entity,Migrations,Tests,Controller/Attribute,Exception,Kernel.php}' # controllers are imported separately to make sure services can be injected # as action arguments even if you don't extend any base controller class diff --git a/src/Controller/Attribute/ImportDraft.php b/src/Controller/Attribute/ImportDraft.php new file mode 100644 index 00000000..28ac9f96 --- /dev/null +++ b/src/Controller/Attribute/ImportDraft.php @@ -0,0 +1,20 @@ +") and returns + * the {@see \App\Dto\BookingJournal\BankImport\ImportState}. On failure it + * throws a {@see \App\Exception\BankImportEditException} which is + * rendered as a JSON response by the matching exception subscriber. + */ +#[\Attribute(\Attribute::TARGET_PARAMETER)] +final class ImportDraft +{ +} diff --git a/src/Controller/BankImportController.php b/src/Controller/BankImportController.php index 7114b92d..2fc55303 100644 --- a/src/Controller/BankImportController.php +++ b/src/Controller/BankImportController.php @@ -4,7 +4,12 @@ namespace App\Controller; +use App\Controller\Attribute\ImportDraft; +use App\Exception\BankImportEditException; +use App\Dto\BookingJournal\BankImport\BankImportFormatChoice; use App\Dto\BookingJournal\BankImport\ImportState; +use App\Dto\BookingJournal\BankImport\MultipleSourceAccountsException; +use App\Dto\BookingJournal\BankImport\ParseResult; use App\Entity\AccountingAccount; use App\Entity\BankCsvProfile; use App\Entity\BankImportRule; @@ -19,7 +24,7 @@ use App\Service\BookingJournal\BankImport\BankStatementDeduplicator; use App\Service\BookingJournal\BankImport\InvoiceMatcher; use App\Service\BookingJournal\BankImport\Parser\BankStatementParserRegistry; -use App\Service\BookingJournal\BankImport\Parser\GenericCsvParser; +use App\Service\BookingJournal\BankImport\Parser\ParserInterface; use Doctrine\ORM\EntityManagerInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\File\UploadedFile; @@ -75,16 +80,31 @@ public function upload( /** @var AccountingAccount $bankAccount */ $bankAccount = $form->get('bankAccount')->getData(); - /** @var BankCsvProfile $profile */ - $profile = $form->get('csvProfile')->getData(); - /** @var UploadedFile $file */ - $file = $form->get('file')->getData(); + /** @var BankImportFormatChoice $format */ + $format = $form->get('format')->getData(); + $formatKey = $format->formatKey; + $profile = $format->profile; + $files = $this->uploadedFiles($form->get('file')->getData()); + + if ([] === $files) { + $this->addFlash('danger', $translator->trans('accounting.bank_import.upload.flash.invalid')); + + return $this->redirectToRoute('bank_import.index'); + } + + $parser = $parsers->get($formatKey); + if (!$parser->supportsMultipleFiles() && 1 !== count($files)) { + $this->addFlash('danger', $translator->trans('accounting.bank_import.upload.flash.csv_requires_single_file')); + + return $this->redirectToRoute('bank_import.index'); + } try { - $result = $parsers->get(GenericCsvParser::FORMAT_KEY)->parse( - new \SplFileInfo($file->getPathname()), - $profile, - ); + $result = $this->parseUploadedFiles($files, $parser, $profile); + } catch (MultipleSourceAccountsException) { + $this->addFlash('danger', $translator->trans('accounting.bank_import.parser.error.camt_multiple_accounts')); + + return $this->redirectToRoute('bank_import.index'); } catch (\Throwable $e) { $this->addFlash('danger', $translator->trans('accounting.bank_import.upload.flash.parse_failed', [ '%message%' => $e->getMessage(), @@ -102,9 +122,9 @@ public function upload( $state = ImportState::fromParseResult( sessionImportId: '', bankAccountId: (int) $bankAccount->getId(), - fileFormat: GenericCsvParser::FORMAT_KEY, - bankCsvProfileId: $profile->getId(), - originalFilename: $file->getClientOriginalName(), + fileFormat: $formatKey, + bankCsvProfileId: $profile?->getId(), + originalFilename: $this->originalFilename($files, $translator), result: $result, ); @@ -113,7 +133,7 @@ public function upload( $deduplicator->annotate($state, $bankAccount); $invoiceMatcher->annotate($state); $ruleMatcher->annotate($state, $bankAccount); - $this->normalizeInitialStatuses($state); + $state->normalizeLineStatuses(); $sessionImportId = $drafts->create($state); @@ -128,6 +148,52 @@ public function upload( return $this->redirectToRoute('bank_import.preview', ['sessionImportId' => $sessionImportId]); } + /** + * @return list + */ + private function uploadedFiles(mixed $data): array + { + if ($data instanceof UploadedFile) { + return [$data]; + } + if (!is_array($data)) { + return []; + } + + return array_values(array_filter($data, static fn (mixed $file): bool => $file instanceof UploadedFile)); + } + + /** + * @param list $files + */ + private function parseUploadedFiles(array $files, ParserInterface $parser, ?BankCsvProfile $profile): ParseResult + { + if (1 === count($files)) { + return $parser->parse(new \SplFileInfo($files[0]->getPathname()), $profile); + } + + $results = []; + foreach ($files as $file) { + $results[] = $parser->parse(new \SplFileInfo($file->getPathname()), $profile); + } + + return ParseResult::merge($results); + } + + /** + * @param list $files + */ + private function originalFilename(array $files, TranslatorInterface $translator): string + { + if (1 === count($files)) { + return $files[0]->getClientOriginalName(); + } + + return $translator->trans('accounting.bank_import.upload.multiple_files_name', [ + '%count%' => count($files), + ]); + } + #[Route('/{sessionImportId}', name: 'bank_import.preview', methods: ['GET'], requirements: ['sessionImportId' => '[0-9a-f-]{36}'])] public function preview( string $sessionImportId, @@ -158,7 +224,7 @@ public function preview( return $this->render('BookingJournal/BankImport/preview.html.twig', [ 'state' => $state, 'bankAccount' => $bankAccount, - 'counts' => $this->countByStatus($state), + 'counts' => $state->countByStatus(), 'accounts' => $accounts, 'taxRates' => $taxRates, 'invoiceMatchingDisabled' => [] === $settingsService->getSettings()->getInvoiceNumberSamples(), @@ -167,18 +233,13 @@ public function preview( #[Route('/{sessionImportId}/line/{idx}', name: 'bank_import.line.update', methods: ['POST'], requirements: ['sessionImportId' => '[0-9a-f-]{36}', 'idx' => '\d+'])] public function updateLine( - string $sessionImportId, int $idx, Request $request, + #[ImportDraft] ImportState $state, BankImportDraftSession $drafts, ): JsonResponse { - if (!$this->isCsrfTokenValid('bank_import_line_'.$sessionImportId, (string) $request->request->get('_token'))) { - return new JsonResponse(['error' => 'invalid_token'], Response::HTTP_FORBIDDEN); - } - - $state = $drafts->load($sessionImportId); - if (null === $state || !isset($state->lines[$idx])) { - return new JsonResponse(['error' => 'line_not_found'], Response::HTTP_NOT_FOUND); + if (!isset($state->lines[$idx])) { + throw BankImportEditException::lineNotFound(); } if (true === ($state->lines[$idx]['isDuplicate'] ?? false)) { @@ -212,35 +273,30 @@ public function updateLine( return new JsonResponse(['error' => 'unknown_field'], Response::HTTP_BAD_REQUEST); } - $line['status'] = $this->deriveStatus($line); + $line['status'] = ImportState::deriveLineStatus($line); unset($line); $drafts->save($state); return new JsonResponse([ 'status' => $state->lines[$idx]['status'], - 'counts' => $this->countByStatus($state), + 'counts' => $state->countByStatus(), ]); } #[Route('/{sessionImportId}/line/{idx}/split', name: 'bank_import.line.split', methods: ['POST'], requirements: ['sessionImportId' => '[0-9a-f-]{36}', 'idx' => '\d+'])] public function splitLine( - string $sessionImportId, int $idx, Request $request, + #[ImportDraft] ImportState $state, BankImportDraftSession $drafts, ): JsonResponse { - if (!$this->isCsrfTokenValid('bank_import_line_'.$sessionImportId, (string) $request->request->get('_token'))) { - return new JsonResponse(['error' => 'invalid_token'], Response::HTTP_FORBIDDEN); - } - - $state = $drafts->load($sessionImportId); - if (null === $state || !isset($state->lines[$idx])) { - return new JsonResponse(['error' => 'line_not_found'], Response::HTTP_NOT_FOUND); + if (!isset($state->lines[$idx])) { + throw BankImportEditException::lineNotFound(); } if (true === ($state->lines[$idx]['isDuplicate'] ?? false)) { - return new JsonResponse(['error' => 'line_readonly'], Response::HTTP_CONFLICT); + throw BankImportEditException::lineReadonly(); } $rawSplits = $request->request->all('splits'); @@ -273,7 +329,7 @@ public function splitLine( } $line['splits'] = $splits; - $line['status'] = $this->deriveStatus($line); + $line['status'] = ImportState::deriveLineStatus($line); unset($line); $drafts->save($state); @@ -281,27 +337,22 @@ public function splitLine( return new JsonResponse([ 'status' => $state->lines[$idx]['status'], 'splitCount' => count($splits), - 'counts' => $this->countByStatus($state), + 'counts' => $state->countByStatus(), ]); } #[Route('/{sessionImportId}/line/{idx}/rule', name: 'bank_import.line.save_rule', methods: ['POST'], requirements: ['sessionImportId' => '[0-9a-f-]{36}', 'idx' => '\d+'])] public function saveRuleFromLine( - string $sessionImportId, int $idx, Request $request, + #[ImportDraft] ImportState $state, BankImportDraftSession $drafts, AccountingAccountRepository $accountRepo, BankImportRuleMatcher $ruleMatcher, EntityManagerInterface $em, ): JsonResponse { - if (!$this->isCsrfTokenValid('bank_import_line_'.$sessionImportId, (string) $request->request->get('_token'))) { - return new JsonResponse(['error' => 'invalid_token'], Response::HTTP_FORBIDDEN); - } - - $state = $drafts->load($sessionImportId); - if (null === $state || !isset($state->lines[$idx])) { - return new JsonResponse(['error' => 'line_not_found'], Response::HTTP_NOT_FOUND); + if (!isset($state->lines[$idx])) { + throw BankImportEditException::lineNotFound(); } $bankAccount = $accountRepo->find($state->bankAccountId); @@ -342,25 +393,16 @@ public function saveRuleFromLine( return new JsonResponse([ 'ruleId' => $rule->getId(), - 'counts' => $this->countByStatus($state), + 'counts' => $state->countByStatus(), ]); } #[Route('/{sessionImportId}/bulk', name: 'bank_import.bulk', methods: ['POST'], requirements: ['sessionImportId' => '[0-9a-f-]{36}'])] public function bulkAction( - string $sessionImportId, Request $request, + #[ImportDraft] ImportState $state, BankImportDraftSession $drafts, ): JsonResponse { - if (!$this->isCsrfTokenValid('bank_import_line_'.$sessionImportId, (string) $request->request->get('_token'))) { - return new JsonResponse(['error' => 'invalid_token'], Response::HTTP_FORBIDDEN); - } - - $state = $drafts->load($sessionImportId); - if (null === $state) { - return new JsonResponse(['error' => 'draft_not_found'], Response::HTTP_NOT_FOUND); - } - $action = (string) $request->request->get('action'); $indices = $request->request->all('indices'); $indices = array_map('intval', is_array($indices) ? $indices : []); @@ -395,7 +437,7 @@ public function bulkAction( return new JsonResponse(['error' => 'unknown_action'], Response::HTTP_BAD_REQUEST); } - $line['status'] = $this->deriveStatus($line); + $line['status'] = ImportState::deriveLineStatus($line); unset($line); ++$touched; } @@ -404,7 +446,7 @@ public function bulkAction( return new JsonResponse([ 'touched' => $touched, - 'counts' => $this->countByStatus($state), + 'counts' => $state->countByStatus(), ]); } @@ -438,8 +480,9 @@ public function commit( return $this->redirectToRoute('bank_import.index'); } + $user = $this->getUser(); try { - $result = $committer->commit($state, $bankAccount, $this->getUser() instanceof \App\Entity\User ? $this->getUser() : null); + $result = $committer->commit($state, $bankAccount, $user instanceof \App\Entity\User ? $user : null); } catch (\Throwable $e) { $this->addFlash('danger', $translator->trans('accounting.bank_import.commit.flash.failed', [ '%message%' => $e->getMessage(), @@ -643,73 +686,6 @@ private function normalizeTaxRateId(mixed $value): ?int return $this->normalizeAccountId($value); } - /** - * @param array $line - */ - private function deriveStatus(array $line): string - { - if (true === ($line['isDuplicate'] ?? false)) { - return ImportState::LINE_STATUS_DUPLICATE; - } - - if (true === ($line['isIgnored'] ?? false)) { - return ImportState::LINE_STATUS_IGNORED; - } - - $hasSplits = !empty($line['splits']); - $hasInvoiceAutoMatch = null !== ($line['matchedInvoiceId'] ?? null) - && true === ($line['matchedInvoiceAmountMatches'] ?? false) - && ((float) ($line['amount'] ?? 0)) >= 0.0 - && null !== ($line['userDebitAccountId'] ?? null); - $hasAccounts = null !== ($line['userDebitAccountId'] ?? null) - && null !== ($line['userCreditAccountId'] ?? null); - - return ($hasSplits || $hasInvoiceAutoMatch || $hasAccounts) ? ImportState::LINE_STATUS_READY : ImportState::LINE_STATUS_PENDING; - } - - private function normalizeInitialStatuses(ImportState $state): void - { - foreach ($state->lines as &$line) { - if (true === ($line['isDuplicate'] ?? false)) { - $line['status'] = ImportState::LINE_STATUS_DUPLICATE; - continue; - } - - if (true === ($line['isIgnored'] ?? false)) { - $line['status'] = ImportState::LINE_STATUS_IGNORED; - continue; - } - - if (null !== ($line['matchedInvoiceId'] ?? null) - && true !== ($line['matchedInvoiceAmountMatches'] ?? false) - && empty($line['splits']) - ) { - $line['status'] = ImportState::LINE_STATUS_PENDING; - continue; - } - - $line['status'] = $this->deriveStatus($line); - } - unset($line); - } - - /** - * @return array{total: int, pending: int, ready: int, ignored: int, duplicate: int} - */ - private function countByStatus(ImportState $state): array - { - $counts = ['total' => 0, 'pending' => 0, 'ready' => 0, 'ignored' => 0, 'duplicate' => 0]; - foreach ($state->lines as $line) { - ++$counts['total']; - $status = $line['status'] ?? ImportState::LINE_STATUS_PENDING; - if (isset($counts[$status])) { - ++$counts[$status]; - } - } - - return $counts; - } - private function appendOverlapWarnings( ImportState $state, AccountingAccount $bankAccount, diff --git a/src/Controller/Resolver/ImportDraftResolver.php b/src/Controller/Resolver/ImportDraftResolver.php new file mode 100644 index 00000000..9ad43c33 --- /dev/null +++ b/src/Controller/Resolver/ImportDraftResolver.php @@ -0,0 +1,62 @@ +", + * 2. loading the draft via {@see BankImportDraftSession}. + * + * Failure paths throw a {@see BankImportEditException} that the matching + * subscriber turns into a JsonResponse, so the JSON edit endpoints no longer + * each carry the same five lines of guard code. + */ +final class ImportDraftResolver implements ValueResolverInterface +{ + public function __construct( + private readonly BankImportDraftSession $drafts, + private readonly CsrfTokenManagerInterface $csrfTokenManager, + ) { + } + + public function resolve(Request $request, ArgumentMetadata $argument): iterable + { + if (ImportState::class !== $argument->getType()) { + return []; + } + if ([] === $argument->getAttributesOfType(ImportDraft::class)) { + return []; + } + + $sessionImportId = (string) $request->attributes->get('sessionImportId'); + if ('' === $sessionImportId) { + throw BankImportEditException::draftNotFound(); + } + + $token = (string) $request->request->get('_token'); + if (!$this->csrfTokenManager->isTokenValid(new CsrfToken('bank_import_line_'.$sessionImportId, $token))) { + throw BankImportEditException::invalidToken(); + } + + $state = $this->drafts->load($sessionImportId); + if (null === $state) { + throw BankImportEditException::draftNotFound(); + } + + return [$state]; + } +} diff --git a/src/Dto/BookingJournal/BankImport/BankImportFormatChoice.php b/src/Dto/BookingJournal/BankImport/BankImportFormatChoice.php new file mode 100644 index 00000000..1f311218 --- /dev/null +++ b/src/Dto/BookingJournal/BankImport/BankImportFormatChoice.php @@ -0,0 +1,22 @@ +" entry; the transformer resolves + * that into the canonical parser format key and (for CSV) the matching + * profile so the controller no longer has to parse strings itself. + */ +final readonly class BankImportFormatChoice +{ + public function __construct( + public string $formatKey, + public ?BankCsvProfile $profile, + ) { + } +} diff --git a/src/Dto/BookingJournal/BankImport/ImportState.php b/src/Dto/BookingJournal/BankImport/ImportState.php index 6c3cfcbd..e87a7477 100644 --- a/src/Dto/BookingJournal/BankImport/ImportState.php +++ b/src/Dto/BookingJournal/BankImport/ImportState.php @@ -21,6 +21,76 @@ final class ImportState public const LINE_STATUS_IGNORED = 'ignored'; public const LINE_STATUS_DUPLICATE = 'duplicate'; + /** + * Re-derives a single line's status from its current flags. Lines flagged + * as duplicate or ignored short-circuit; otherwise a line is "ready" once + * a user can post it (splits set, an auto-matched invoice on an incoming + * line, or both account sides assigned). + * + * @param array $line + */ + public static function deriveLineStatus(array $line): string + { + if (true === ($line['isDuplicate'] ?? false)) { + return self::LINE_STATUS_DUPLICATE; + } + + if (true === ($line['isIgnored'] ?? false)) { + return self::LINE_STATUS_IGNORED; + } + + $hasSplits = !empty($line['splits']); + $hasInvoiceAutoMatch = null !== ($line['matchedInvoiceId'] ?? null) + && true === ($line['matchedInvoiceAmountMatches'] ?? false) + && ((float) ($line['amount'] ?? 0)) >= 0.0 + && null !== ($line['userDebitAccountId'] ?? null); + $hasAccounts = null !== ($line['userDebitAccountId'] ?? null) + && null !== ($line['userCreditAccountId'] ?? null); + + return ($hasSplits || $hasInvoiceAutoMatch || $hasAccounts) ? self::LINE_STATUS_READY : self::LINE_STATUS_PENDING; + } + + /** + * Walks all lines and re-derives their status. Called after the matchers + * run on a freshly parsed import so the UI starts with consistent badges. + */ + public function normalizeLineStatuses(): void + { + foreach ($this->lines as &$line) { + // An invoice was matched but the amounts don't line up — the user + // still has to confirm or correct, so we keep the line pending. + if (null !== ($line['matchedInvoiceId'] ?? null) + && true !== ($line['matchedInvoiceAmountMatches'] ?? false) + && empty($line['splits']) + && true !== ($line['isDuplicate'] ?? false) + && true !== ($line['isIgnored'] ?? false) + ) { + $line['status'] = self::LINE_STATUS_PENDING; + continue; + } + + $line['status'] = self::deriveLineStatus($line); + } + unset($line); + } + + /** + * @return array{total: int, pending: int, ready: int, ignored: int, duplicate: int} + */ + public function countByStatus(): array + { + $counts = ['total' => 0, 'pending' => 0, 'ready' => 0, 'ignored' => 0, 'duplicate' => 0]; + foreach ($this->lines as $line) { + ++$counts['total']; + $status = $line['status'] ?? self::LINE_STATUS_PENDING; + if (isset($counts[$status])) { + ++$counts[$status]; + } + } + + return $counts; + } + /** * @param list> $lines */ diff --git a/src/Dto/BookingJournal/BankImport/MultipleSourceAccountsException.php b/src/Dto/BookingJournal/BankImport/MultipleSourceAccountsException.php new file mode 100644 index 00000000..6076a2cf --- /dev/null +++ b/src/Dto/BookingJournal/BankImport/MultipleSourceAccountsException.php @@ -0,0 +1,13 @@ + $results + */ + public static function merge(array $results): self + { + $lines = []; + $warnings = []; + $ibans = []; + $periodFrom = null; + $periodTo = null; + + foreach ($results as $result) { + array_push($lines, ...$result->lines); + array_push($warnings, ...$result->warnings); + + if (null !== $result->sourceIban) { + $ibans[$result->sourceIban] = true; + } + + $periodFrom = self::minDate($periodFrom, $result->periodFrom); + $periodTo = self::maxDate($periodTo, $result->periodTo); + } + + if (count($ibans) > 1) { + throw new MultipleSourceAccountsException(); + } + + return new self( + lines: $lines, + sourceIban: array_key_first($ibans), + periodFrom: $periodFrom, + periodTo: $periodTo, + warnings: $warnings, + ); + } + + public static function minDate(?\DateTimeImmutable $left, ?\DateTimeImmutable $right): ?\DateTimeImmutable + { + if (null === $left) { + return $right; + } + if (null === $right) { + return $left; + } + + return $right < $left ? $right : $left; + } + + public static function maxDate(?\DateTimeImmutable $left, ?\DateTimeImmutable $right): ?\DateTimeImmutable + { + if (null === $left) { + return $right; + } + if (null === $right) { + return $left; + } + + return $right > $left ? $right : $left; + } } diff --git a/src/EventSubscriber/BankImportEditExceptionSubscriber.php b/src/EventSubscriber/BankImportEditExceptionSubscriber.php new file mode 100644 index 00000000..e9e409f8 --- /dev/null +++ b/src/EventSubscriber/BankImportEditExceptionSubscriber.php @@ -0,0 +1,39 @@ +"}`. + */ +final class BankImportEditExceptionSubscriber implements EventSubscriberInterface +{ + public static function getSubscribedEvents(): array + { + return [ + KernelEvents::EXCEPTION => 'onException', + ]; + } + + public function onException(ExceptionEvent $event): void + { + $exception = $event->getThrowable(); + if (!$exception instanceof BankImportEditException) { + return; + } + + $event->setResponse(new JsonResponse( + ['error' => $exception->errorCode], + $exception->httpStatus, + )); + } +} diff --git a/src/Exception/BankImportEditException.php b/src/Exception/BankImportEditException.php new file mode 100644 index 00000000..e9fc1c5d --- /dev/null +++ b/src/Exception/BankImportEditException.php @@ -0,0 +1,41 @@ +") so + * it round-trips through HTML form posts. A model transformer resolves that + * to a {@see BankImportFormatChoice} value object — the controller therefore + * receives the parser format key plus the loaded CSV profile and never has + * to inspect the raw selection string. + */ +final class BankImportFormatType extends AbstractType +{ + public function __construct( + private readonly BankCsvProfileRepository $csvProfileRepository, + private readonly ?TranslatorInterface $translator = null, + ) { + } + + public function getParent(): string + { + return ChoiceType::class; + } + + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $builder->addModelTransformer(new CallbackTransformer( + fn (?BankImportFormatChoice $choice): ?string => null === $choice ? null : $this->encode($choice), + fn (?string $selection): ?BankImportFormatChoice => null === $selection || '' === $selection + ? null + : $this->decode($selection), + )); + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'choices' => $this->buildChoices(), + 'choice_translation_domain' => false, + 'placeholder' => 'accounting.bank_import.upload.format.placeholder', + 'expanded' => false, + 'multiple' => false, + ]); + } + + /** + * @return array + */ + private function buildChoices(): array + { + $choices = [ + $this->trans('accounting.bank_import.upload.format.camt') => Iso20022CamtParser::FORMAT_KEY, + ]; + + foreach ($this->csvProfileRepository->findAllOrdered() as $profile) { + if (null === $profile->getId()) { + continue; + } + $choices[$this->trans('accounting.bank_import.upload.format.csv_profile', [ + '%profile%' => $profile->getName(), + ])] = 'csv:'.$profile->getId(); + } + + return $choices; + } + + private function decode(string $selection): BankImportFormatChoice + { + if (Iso20022CamtParser::FORMAT_KEY === $selection) { + return new BankImportFormatChoice(Iso20022CamtParser::FORMAT_KEY, null); + } + + if (str_starts_with($selection, 'csv:')) { + $profileId = (int) substr($selection, 4); + $profile = $this->csvProfileRepository->find($profileId); + if (!$profile instanceof BankCsvProfile) { + throw new \InvalidArgumentException($this->trans('accounting.bank_import.upload.flash.csv_profile_missing')); + } + + return new BankImportFormatChoice(GenericCsvParser::FORMAT_KEY, $profile); + } + + throw new \InvalidArgumentException($this->trans('accounting.bank_import.upload.flash.invalid')); + } + + private function encode(BankImportFormatChoice $choice): string + { + if (Iso20022CamtParser::FORMAT_KEY === $choice->formatKey) { + return Iso20022CamtParser::FORMAT_KEY; + } + + return 'csv:'.($choice->profile?->getId() ?? 0); + } + + /** + * @param array $parameters + */ + private function trans(string $key, array $parameters = []): string + { + return $this->translator?->trans($key, $parameters) ?? $key; + } +} diff --git a/src/Form/BankStatementUploadType.php b/src/Form/BankStatementUploadType.php index 3128bc13..ef3d0c01 100644 --- a/src/Form/BankStatementUploadType.php +++ b/src/Form/BankStatementUploadType.php @@ -5,15 +5,14 @@ namespace App\Form; use App\Entity\AccountingAccount; -use App\Entity\BankCsvProfile; use App\Repository\AccountingAccountRepository; -use App\Repository\BankCsvProfileRepository; use App\Service\BookingJournal\AccountingSettingsService; use Symfony\Bridge\Doctrine\Form\Type\EntityType; use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\Extension\Core\Type\FileType; use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\OptionsResolver\OptionsResolver; +use Symfony\Component\Validator\Constraints\All; use Symfony\Component\Validator\Constraints\File; use Symfony\Component\Validator\Constraints\NotNull; @@ -38,28 +37,29 @@ public function buildForm(FormBuilderInterface $builder, array $options): void 'query_builder' => fn (AccountingAccountRepository $repo) => $repo->createBankAccountsQueryBuilder($activePreset), 'constraints' => [new NotNull()], ]) - ->add('csvProfile', EntityType::class, [ - 'class' => BankCsvProfile::class, - 'label' => 'accounting.bank_import.upload.csv_profile', - 'placeholder' => 'accounting.bank_import.upload.csv_profile.placeholder', - 'choice_label' => 'name', - 'query_builder' => static fn (BankCsvProfileRepository $repo) => $repo->createOrderedQueryBuilder(), + ->add('format', BankImportFormatType::class, [ + 'label' => 'accounting.bank_import.upload.format', 'constraints' => [new NotNull()], ]) ->add('file', FileType::class, [ 'label' => 'accounting.bank_import.upload.file', 'mapped' => false, + 'multiple' => true, 'constraints' => [ - new File( - maxSize: '5M', - mimeTypes: [ - 'text/csv', - 'text/plain', - 'application/csv', - 'application/vnd.ms-excel', - ], - mimeTypesMessage: 'accounting.bank_import.upload.file.invalid_type', - ), + new All([ + new File( + maxSize: '5M', + mimeTypes: [ + 'text/csv', + 'text/plain', + 'text/xml', + 'application/csv', + 'application/xml', + 'application/vnd.ms-excel', + ], + mimeTypesMessage: 'accounting.bank_import.upload.file.invalid_type', + ), + ]), ], ]); } diff --git a/src/Service/BookingJournal/BankImport/Parser/GenericCsvParser.php b/src/Service/BookingJournal/BankImport/Parser/GenericCsvParser.php index 1893c99a..92f6e06c 100644 --- a/src/Service/BookingJournal/BankImport/Parser/GenericCsvParser.php +++ b/src/Service/BookingJournal/BankImport/Parser/GenericCsvParser.php @@ -33,6 +33,11 @@ public function getFormatKey(): string return self::FORMAT_KEY; } + public function supportsMultipleFiles(): bool + { + return false; + } + public function parse(\SplFileInfo $file, ?BankCsvProfile $profile): ParseResult { if (null === $profile) { diff --git a/src/Service/BookingJournal/BankImport/Parser/Iso20022CamtParser.php b/src/Service/BookingJournal/BankImport/Parser/Iso20022CamtParser.php new file mode 100644 index 00000000..837f2832 --- /dev/null +++ b/src/Service/BookingJournal/BankImport/Parser/Iso20022CamtParser.php @@ -0,0 +1,471 @@ +loadXml($file); + [$messageType, $version] = $this->detectMessage($document); + $warnings = $this->versionWarnings($messageType, $version, $file); + + // camt.052 wraps account reports in Rpt, camt.053 wraps statements in Stmt. + // The contained Ntry records are deliberately processed through one path. + $containers = match ($messageType) { + '052' => $this->xpath($document, '/*[local-name()="Document"]/*[local-name()="BkToCstmrAcctRpt"]/*[local-name()="Rpt"]'), + '053' => $this->xpath($document, '/*[local-name()="Document"]/*[local-name()="BkToCstmrStmt"]/*[local-name()="Stmt"]'), + default => throw new \RuntimeException($this->trans('accounting.bank_import.parser.error.camt_unsupported_message')), + }; + + if ([] === $containers) { + throw new \RuntimeException($this->trans('accounting.bank_import.parser.error.camt_no_statements')); + } + + $lines = []; + $ibans = []; + $periodFrom = null; + $periodTo = null; + $skippedNonBooked = 0; + + foreach ($containers as $container) { + // All selected files must describe the same account; the controller + // also checks this after merging multiple camt files into one draft. + $sourceIban = $this->normalizeIban($this->textPath($container, ['Acct', 'Id', 'IBAN'])); + if (null !== $sourceIban) { + $ibans[$sourceIban] = true; + } + + // Some banks provide a statement/report period, others only dates + // on the entries. We collect both and keep the widest range. + [$containerFrom, $containerTo] = $this->extractPeriod($container); + $periodFrom = ParseResult::minDate($periodFrom, $containerFrom); + $periodTo = ParseResult::maxDate($periodTo, $containerTo); + + foreach ($this->children($container, 'Ntry') as $entry) { + // camt.052 may also carry pending or informational movements; + // only BOOK entries are safe to post to the journal. + if ('052' === $messageType && 'BOOK' !== $this->entryStatus($entry)) { + ++$skippedNonBooked; + continue; + } + + try { + foreach ($this->buildEntryLines($entry) as $line) { + $periodFrom = ParseResult::minDate($periodFrom, $line->bookDate); + $periodTo = ParseResult::maxDate($periodTo, $line->bookDate); + $lines[] = $line; + } + } catch (\Throwable $e) { + $warnings[] = $this->trans('accounting.bank_import.parser.warning.entry_skipped', [ + '%file%' => $file->getBasename(), + '%message%' => $e->getMessage(), + ]); + } + } + } + + if (count($ibans) > 1) { + throw new \RuntimeException($this->trans('accounting.bank_import.parser.error.camt_multiple_accounts')); + } + + if ($skippedNonBooked > 0) { + $warnings[] = $this->trans('accounting.bank_import.parser.warning.camt_non_booked_skipped', [ + '%count%' => $skippedNonBooked, + '%file%' => $file->getBasename(), + ]); + } + + return new ParseResult( + lines: $lines, + sourceIban: array_key_first($ibans), + periodFrom: $periodFrom, + periodTo: $periodTo, + warnings: $warnings, + ); + } + + private function loadXml(\SplFileInfo $file): \SimpleXMLElement + { + $previous = libxml_use_internal_errors(true); + libxml_clear_errors(); + $xml = simplexml_load_file($file->getPathname(), \SimpleXMLElement::class, LIBXML_NONET | LIBXML_NOCDATA); + $errors = libxml_get_errors(); + libxml_clear_errors(); + libxml_use_internal_errors($previous); + + if (!$xml instanceof \SimpleXMLElement) { + $message = $errors[0]->message ?? $this->trans('accounting.bank_import.parser.error.camt_invalid_xml'); + + throw new \RuntimeException(trim($message)); + } + + return $xml; + } + + /** + * @return array{0: string, 1: ?int} + */ + private function detectMessage(\SimpleXMLElement $document): array + { + // Real exports are not consistent: some use the camt namespace as the + // default namespace, others declare it as a prefix on Document. + foreach ($document->getDocNamespaces(false) as $namespace) { + if (1 === preg_match('/camt\.(052|053)\.001\.(\d{2})/', $namespace, $matches)) { + return [$matches[1], (int) $matches[2]]; + } + } + + // If the namespace is missing or non-standard, still detect by the + // ISO 20022 message root and report an "unknown version" warning. + if ([] !== $this->xpath($document, '/*[local-name()="Document"]/*[local-name()="BkToCstmrAcctRpt"]')) { + return ['052', null]; + } + if ([] !== $this->xpath($document, '/*[local-name()="Document"]/*[local-name()="BkToCstmrStmt"]')) { + return ['053', null]; + } + + throw new \RuntimeException($this->trans('accounting.bank_import.parser.error.camt_unsupported_message')); + } + + /** + * @return list + */ + private function versionWarnings(string $messageType, ?int $version, \SplFileInfo $file): array + { + if (null === $version) { + return [$this->trans('accounting.bank_import.parser.warning.camt_unknown_version', [ + '%file%' => $file->getBasename(), + ])]; + } + + if ($version < 8) { + throw new \RuntimeException($this->trans('accounting.bank_import.parser.error.camt_version_too_old', [ + '%type%' => $messageType, + '%version%' => sprintf('%02d', $version), + ])); + } + + if ($version > 14) { + return [$this->trans('accounting.bank_import.parser.warning.camt_newer_version', [ + '%type%' => $messageType, + '%version%' => sprintf('%02d', $version), + ])]; + } + + return []; + } + + /** + * @return list + */ + private function buildEntryLines(\SimpleXMLElement $entry): array + { + // Ntry is the booked account movement. Its amount and dates are the + // reliable fallback when a bank does not provide TxDtls-level values. + $entryAmountNode = $this->firstChild($entry, 'Amt'); + $entryIndicator = $this->textPath($entry, ['CdtDbtInd']); + $entryAmount = $this->parseSignedAmount($entryAmountNode, $entryIndicator); + + $bookDate = $this->dateChoice($this->firstChild($entry, 'BookgDt')); + $valueDate = $this->dateChoice($this->firstChild($entry, 'ValDt')) ?? $bookDate; + if (null === $bookDate) { + $bookDate = $valueDate; + } + if (null === $bookDate || null === $valueDate) { + throw new \RuntimeException($this->trans('accounting.bank_import.parser.error.camt_missing_date')); + } + + $details = []; + foreach ($this->children($entry, 'NtryDtls') as $entryDetails) { + foreach ($this->children($entryDetails, 'TxDtls') as $transactionDetails) { + $details[] = $transactionDetails; + } + } + + // Some camt files expose only Ntry, others split one Ntry into one or + // more TxDtls records. For journal lines, TxDtls is more descriptive. + if ([] === $details) { + return [$this->buildLine($entry, $entry, $entryAmount, $bookDate, $valueDate)]; + } + + $lines = []; + foreach ($details as $transactionDetails) { + $txAmountNode = $this->firstChild($transactionDetails, 'Amt'); + $txIndicator = $this->textPath($transactionDetails, ['CdtDbtInd']) ?: $entryIndicator; + $amount = null !== $txAmountNode ? $this->parseSignedAmount($txAmountNode, $txIndicator) : $entryAmount; + $lines[] = $this->buildLine($entry, $transactionDetails, $amount, $bookDate, $valueDate); + } + + return $lines; + } + + private function buildLine( + \SimpleXMLElement $entry, + \SimpleXMLElement $source, + string $amount, + \DateTimeImmutable $bookDate, + \DateTimeImmutable $valueDate, + ): StatementLineDto { + $isIncoming = (float) $amount >= 0.0; + // For incoming money the counterparty is usually Dbtr; for outgoing + // money it is usually Cdtr. Fallbacks handle imperfect bank exports. + $counterpartyName = $this->counterpartyName($source, $isIncoming); + $counterpartyIban = $this->counterpartyIban($source, $isIncoming); + + return new StatementLineDto( + bookDate: $bookDate, + valueDate: $valueDate, + amount: $amount, + counterpartyName: $counterpartyName, + counterpartyIban: $counterpartyIban, + purpose: $this->purpose($entry, $source), + endToEndId: $this->optionalTextPath($source, ['Refs', 'EndToEndId']) + ?? $this->optionalTextPath($source, ['Refs', 'TxId']), + mandateReference: $this->optionalTextPath($source, ['Refs', 'MndtId']) + ?? $this->optionalTextPath($source, ['RltdPties', 'MndtRltdInf', 'MndtId']), + creditorId: $this->firstText($this->xpath($source, './/*[local-name()="CdtrSchmeId"]//*[local-name()="Id"]')) + ?? $this->optionalTextPath($source, ['RltdPties', 'Cdtr', 'Pty', 'Id', 'PrvtId', 'Othr', 'Id']), + ); + } + + private function parseSignedAmount(?\SimpleXMLElement $amountNode, string $indicator): string + { + if (null === $amountNode) { + throw new \RuntimeException($this->trans('accounting.bank_import.parser.error.camt_missing_amount')); + } + + $indicator = strtoupper(trim($indicator)); + if (!in_array($indicator, ['CRDT', 'DBIT'], true)) { + throw new \RuntimeException($this->trans('accounting.bank_import.parser.error.camt_missing_direction')); + } + + $amount = (float) trim((string) $amountNode); + // ISO amounts are unsigned; CdtDbtInd carries the direction. + if ('DBIT' === $indicator) { + $amount *= -1; + } + + return number_format($amount, 2, '.', ''); + } + + private function entryStatus(\SimpleXMLElement $entry): string + { + // camt v8+ wraps the status code in (or for proprietary + // codes). The plain-text Sts shape only exists in pre-v8 documents and + // is rejected by versionWarnings() before we get here. + $code = $this->textPath($entry, ['Sts', 'Cd']); + if ('' === $code) { + $code = $this->textPath($entry, ['Sts', 'Prtry', 'Cd']); + } + + return strtoupper(trim($code)); + } + + private function counterpartyName(\SimpleXMLElement $source, bool $isIncoming): string + { + $preferred = $isIncoming ? 'Dbtr' : 'Cdtr'; + $fallback = $isIncoming ? 'Cdtr' : 'Dbtr'; + + $name = $this->optionalTextPath($source, ['RltdPties', $preferred, 'Pty', 'Nm']) + ?? $this->optionalTextPath($source, ['RltdPties', 'Ultmt'.$preferred, 'Pty', 'Nm']) + ?? $this->optionalTextPath($source, ['RltdPties', $fallback, 'Pty', 'Nm']) + ?? ''; + + // Sparkasse and others pad fixed-width name fields with runs of spaces; + // collapse them so rule matching on the name is whitespace-insensitive. + return preg_replace('/\s+/', ' ', $name) ?? $name; + } + + private function counterpartyIban(\SimpleXMLElement $source, bool $isIncoming): ?string + { + $preferred = $isIncoming ? 'DbtrAcct' : 'CdtrAcct'; + $fallback = $isIncoming ? 'CdtrAcct' : 'DbtrAcct'; + + return $this->normalizeIban($this->textPath($source, ['RltdPties', $preferred, 'Id', 'IBAN'])) + ?? $this->normalizeIban($this->textPath($source, ['RltdPties', $fallback, 'Id', 'IBAN'])); + } + + private function purpose(\SimpleXMLElement $entry, \SimpleXMLElement $source): string + { + $parts = []; + // Ustrd is the normal German "Verwendungszweck"; structured remittance + // references and AddtlNtryInf add useful context for rules/matching. + foreach ($this->xpath($source, './*[local-name()="RmtInf"]/*[local-name()="Ustrd"]') as $node) { + $this->appendPart($parts, (string) $node); + } + foreach ($this->xpath($source, './*[local-name()="RmtInf"]//*[local-name()="Ref"]') as $node) { + $this->appendPart($parts, (string) $node); + } + foreach ($this->xpath($source, './*[local-name()="RmtInf"]//*[local-name()="AddtlRmtInf"]') as $node) { + $this->appendPart($parts, (string) $node); + } + + $this->appendPart($parts, $this->textPath($entry, ['AddtlNtryInf'])); + + return implode(' ', $parts); + } + + /** + * @param list $parts + */ + private function appendPart(array &$parts, string $value): void + { + $value = preg_replace('/\s+/', ' ', trim($value)) ?? ''; + if ('' !== $value && !in_array($value, $parts, true)) { + $parts[] = $value; + } + } + + /** + * @return array{0: ?\DateTimeImmutable, 1: ?\DateTimeImmutable} + */ + private function extractPeriod(\SimpleXMLElement $container): array + { + $from = $this->firstDateText($this->xpath($container, './*[local-name()="FrToDt"]//*[local-name()="FrDt" or local-name()="FrDtTm"]')); + $to = $this->firstDateText($this->xpath($container, './*[local-name()="FrToDt"]//*[local-name()="ToDt" or local-name()="ToDtTm"]')); + + return [$from, $to]; + } + + private function dateChoice(?\SimpleXMLElement $node): ?\DateTimeImmutable + { + if (null === $node) { + return null; + } + + return $this->firstDateText($this->xpath($node, './*[local-name()="Dt" or local-name()="DtTm"]')); + } + + /** + * @param list<\SimpleXMLElement> $nodes + */ + private function firstDateText(array $nodes): ?\DateTimeImmutable + { + $value = $this->firstText($nodes); + if (null === $value) { + return null; + } + + try { + return new \DateTimeImmutable($value); + } catch (\Throwable) { + return null; + } + } + + private function firstChild(\SimpleXMLElement $node, string $name): ?\SimpleXMLElement + { + return $this->children($node, $name)[0] ?? null; + } + + /** + * @return list<\SimpleXMLElement> + */ + private function children(\SimpleXMLElement $node, string $name): array + { + return $this->xpath($node, './*[local-name()="'.$name.'"]'); + } + + /** + * @return list<\SimpleXMLElement> + */ + private function xpath(\SimpleXMLElement $node, string $query): array + { + $result = $node->xpath($query); + if (false === $result) { + return []; + } + + return array_values(array_filter($result, static fn (mixed $item): bool => $item instanceof \SimpleXMLElement)); + } + + /** + * @param list $path + */ + private function textPath(\SimpleXMLElement $node, array $path): string + { + $current = $node; + foreach ($path as $segment) { + $current = $this->firstChild($current, $segment); + if (null === $current) { + return ''; + } + } + + return trim((string) $current); + } + + /** + * @param list $path + */ + private function optionalTextPath(\SimpleXMLElement $node, array $path): ?string + { + $value = $this->textPath($node, $path); + + return '' === $value ? null : $value; + } + + /** + * @param list<\SimpleXMLElement> $nodes + */ + private function firstText(array $nodes): ?string + { + foreach ($nodes as $node) { + $value = trim((string) $node); + if ('' !== $value) { + return $value; + } + } + + return null; + } + + private function normalizeIban(string $raw): ?string + { + $raw = strtoupper(preg_replace('/\s+/', '', trim($raw)) ?? ''); + + return '' === $raw ? null : $raw; + } + + /** + * @param array $parameters + */ + private function trans(string $key, array $parameters = []): string + { + return $this->translator?->trans($key, $parameters) ?? $key; + } +} diff --git a/src/Service/BookingJournal/BankImport/Parser/ParserInterface.php b/src/Service/BookingJournal/BankImport/Parser/ParserInterface.php index f21e18eb..dd64653e 100644 --- a/src/Service/BookingJournal/BankImport/Parser/ParserInterface.php +++ b/src/Service/BookingJournal/BankImport/Parser/ParserInterface.php @@ -25,4 +25,11 @@ public function getFormatKey(): string; * mapping, locale, etc.). Other parsers may ignore it. */ public function parse(\SplFileInfo $file, ?BankCsvProfile $profile): ParseResult; + + /** + * Whether this format may be uploaded as multiple files at once that get + * merged into a single ParseResult. CSV is single-file-per-upload, ISO + * 20022 camt typically arrives as one file per booking day. + */ + public function supportsMultipleFiles(): bool; } diff --git a/templates/BookingJournal/BankImport/index.html.twig b/templates/BookingJournal/BankImport/index.html.twig index 09db4e08..d000edbd 100644 --- a/templates/BookingJournal/BankImport/index.html.twig +++ b/templates/BookingJournal/BankImport/index.html.twig @@ -104,7 +104,7 @@ {{ form_start(uploadForm, {'attr': {'enctype': 'multipart/form-data'}}) }}
{{ form_row(uploadForm.bankAccount) }}
-
{{ form_row(uploadForm.csvProfile) }}
+
{{ form_row(uploadForm.format) }}
{{ form_row(uploadForm.file) }}
diff --git a/tests/Functional/BankImportControllerTest.php b/tests/Functional/BankImportControllerTest.php index 42ed6f7a..ea6cab7e 100644 --- a/tests/Functional/BankImportControllerTest.php +++ b/tests/Functional/BankImportControllerTest.php @@ -18,6 +18,9 @@ use Doctrine\Persistence\ManagerRegistry; use PHPUnit\Framework\Attributes\DataProvider; use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; +use Symfony\Component\DomCrawler\Field\FileFormField; +use Symfony\Component\DomCrawler\Form; +use Symfony\Component\HttpFoundation\File\UploadedFile; use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface; final class BankImportControllerTest extends WebTestCase @@ -51,8 +54,8 @@ public function testUploadBuildsPreviewFromRealisticCsvExport(string $profileTyp $form = $crawler->filter('form')->form(); $form['bank_statement_upload[bankAccount]']->select((string) $bankAccount->getId()); - $form['bank_statement_upload[csvProfile]']->select((string) $profile->getId()); - $form['bank_statement_upload[file]']->upload(self::FIXTURE_DIR.'/'.$fixtureName); + $form['bank_statement_upload[format]']->select('csv:'.$profile->getId()); + $this->uploadStatementFile($form, self::FIXTURE_DIR.'/'.$fixtureName); $client->submit($form); @@ -111,8 +114,8 @@ public function testSavedSplitRuleUsesPurposeMarkersForRecurringLines(): void $crawler = $client->request('GET', '/journal/bank-import'); $form = $crawler->filter('form')->form(); $form['bank_statement_upload[bankAccount]']->select((string) $bankAccount->getId()); - $form['bank_statement_upload[csvProfile]']->select((string) $profile->getId()); - $form['bank_statement_upload[file]']->upload(self::FIXTURE_DIR.'/postbank-girokonto-anonymized.csv'); + $form['bank_statement_upload[format]']->select('csv:'.$profile->getId()); + $this->uploadStatementFile($form, self::FIXTURE_DIR.'/postbank-girokonto-anonymized.csv'); $client->submit($form); preg_match('#/journal/bank-import/([0-9a-f-]{36})$#', (string) $client->getResponse()->headers->get('Location'), $matches); @@ -159,6 +162,55 @@ public function testSavedSplitRuleUsesPurposeMarkersForRecurringLines(): void self::assertSame('-30.00', $recurringLine['splits'][2]['amount']); } + public function testUploadBuildsPreviewFromMultipleCamtXmlFiles(): void + { + $client = static::createClient(); + $client->loginUser($this->createCashJournalUser()); + + $bankAccount = $this->createBankAccount('DE00SPARKASSETEST0001'); + + $crawler = $client->request('GET', '/journal/bank-import'); + self::assertResponseIsSuccessful(); + + $form = $crawler->filter('form')->form(); + $form['bank_statement_upload[bankAccount]']->select((string) $bankAccount->getId()); + $form['bank_statement_upload[format]']->select('iso20022_camt'); + + $client->request('POST', '/journal/bank-import/upload', $form->getPhpValues(), [ + 'bank_statement_upload' => [ + 'file' => [ + new UploadedFile(self::FIXTURE_DIR.'/camt052-booked-day1.xml', 'camt052-booked-day1.xml', 'text/xml', null, true), + new UploadedFile(self::FIXTURE_DIR.'/camt052-booked-day2.xml', 'camt052-booked-day2.xml', 'text/xml', null, true), + ], + ], + ]); + + self::assertResponseRedirects(); + $location = (string) $client->getResponse()->headers->get('Location'); + self::assertMatchesRegularExpression('#/journal/bank-import/([0-9a-f-]{36})$#', $location); + preg_match('#/journal/bank-import/([0-9a-f-]{36})$#', $location, $matches); + $sessionImportId = $matches[1]; + + $state = $this->loadDraftFromSession($client->getRequest()->getSession(), $sessionImportId); + self::assertSame((int) $bankAccount->getId(), $state->bankAccountId); + self::assertSame('iso20022_camt', $state->fileFormat); + self::assertNull($state->bankCsvProfileId); + self::assertSame('DE00SPARKASSETEST0001', $state->sourceIban); + self::assertSame('2026-04-09', $state->periodFrom); + self::assertSame('2026-04-10', $state->periodTo); + self::assertCount(3, $state->lines); + + $income = $this->findLineByPurpose($state, 'Invoice 2026-0101'); + self::assertNotNull($income); + self::assertSame('518.00', $income['amount']); + self::assertSame('Example Guest GmbH', $income['counterpartyName']); + + $outgoing = $this->findLineByPurpose($state, 'Monthly subscription'); + self::assertNotNull($outgoing); + self::assertSame('-25.00', $outgoing['amount']); + self::assertSame('Example Subscription AG', $outgoing['counterpartyName']); + } + /** * @return iterable}> */ @@ -387,6 +439,17 @@ private function loadDraftFromSession(\Symfony\Component\HttpFoundation\Session\ return ImportState::fromArray($drafts[$sessionImportId]); } + private function uploadStatementFile(Form $form, string $path): void + { + $field = $form['bank_statement_upload[file]']; + if (is_array($field)) { + $field = reset($field); + } + + self::assertInstanceOf(FileFormField::class, $field); + $field->upload($path); + } + private function getEntityManager(): EntityManagerInterface { if (!isset($this->em)) { diff --git a/tests/Unit/BookingJournal/BankImport/Iso20022CamtParserTest.php b/tests/Unit/BookingJournal/BankImport/Iso20022CamtParserTest.php new file mode 100644 index 00000000..760fb57b --- /dev/null +++ b/tests/Unit/BookingJournal/BankImport/Iso20022CamtParserTest.php @@ -0,0 +1,91 @@ +parse(new \SplFileInfo(self::FIXTURE_DIR.'/camt052-booked-day1.xml'), null); + $day2 = $parser->parse(new \SplFileInfo(self::FIXTURE_DIR.'/camt052-booked-day2.xml'), null); + + self::assertSame('iso20022_camt', $parser->getFormatKey()); + self::assertSame('DE00SPARKASSETEST0001', $day1->sourceIban); + self::assertCount(2, $day1->lines); + self::assertCount(1, $day2->lines); + + $income = $day1->lines[0]; + self::assertSame('2026-04-09', $income->bookDate->format('Y-m-d')); + self::assertSame('518.00', $income->amount); + self::assertSame('Example Guest GmbH', $income->counterpartyName); + self::assertSame('DE00GUEST000000000001', $income->counterpartyIban); + self::assertSame('E2E-IN-001', $income->endToEndId); + self::assertStringContainsString('Invoice 2026-0101', $income->purpose); + + $outgoing = $day1->lines[1]; + self::assertSame('-29.98', $outgoing->amount); + self::assertSame('Office Supplies GmbH', $outgoing->counterpartyName); + self::assertSame('DE00VENDOR0000000001', $outgoing->counterpartyIban); + } + + public function testSkipsNonBookedCamt052Entries(): void + { + $parser = new Iso20022CamtParser(); + $result = $parser->parse(new \SplFileInfo(self::FIXTURE_DIR.'/camt052-pending.xml'), null); + + self::assertCount(1, $result->lines); + self::assertSame('BOOKED-ONLY', $result->lines[0]->endToEndId); + self::assertContains('accounting.bank_import.parser.warning.camt_non_booked_skipped', $result->warnings); + } + + public function testParsesCamt053Statement(): void + { + $parser = new Iso20022CamtParser(); + $result = $parser->parse(new \SplFileInfo(self::FIXTURE_DIR.'/camt053-statement.xml'), null); + + self::assertSame('DE00SPARKASSETEST0001', $result->sourceIban); + self::assertNotNull($result->periodFrom); + self::assertNotNull($result->periodTo); + self::assertSame('2026-04-01', $result->periodFrom->format('Y-m-d')); + self::assertSame('2026-04-30', $result->periodTo->format('Y-m-d')); + self::assertCount(1, $result->lines); + + $line = $result->lines[0]; + self::assertSame('120.00', $line->amount); + self::assertSame('Statement Guest GmbH', $line->counterpartyName); + self::assertSame('DE00STATEMENTGUEST01', $line->counterpartyIban); + self::assertStringContainsString('Statement invoice 2026-0102', $line->purpose); + } + + public function testDetectsVersionFromPrefixedNamespaceDeclaration(): void + { + $fixture = (string) file_get_contents(self::FIXTURE_DIR.'/camt053-statement.xml'); + $fixture = str_replace( + '', + '', + $fixture, + ); + + $tmp = tempnam(sys_get_temp_dir(), 'camt-prefixed-namespace-'); + self::assertIsString($tmp); + file_put_contents($tmp, $fixture); + + try { + $parser = new Iso20022CamtParser(); + $result = $parser->parse(new \SplFileInfo($tmp), null); + } finally { + @unlink($tmp); + } + + self::assertSame([], $result->warnings); + self::assertCount(1, $result->lines); + } +} diff --git a/translations/BankImport/messages.de.yaml b/translations/BankImport/messages.de.yaml index 83a9de82..c9429834 100644 --- a/translations/BankImport/messages.de.yaml +++ b/translations/BankImport/messages.de.yaml @@ -27,6 +27,10 @@ accounting: parser: warning: row_skipped: 'Zeile %line% übersprungen: %message%' + entry_skipped: 'Eintrag in %file% übersprungen: %message%' + camt_non_booked_skipped: '%count% nicht gebuchte camt.052-Einträge in %file% wurden übersprungen.' + camt_unknown_version: 'camt-Version in %file% konnte nicht aus dem Namespace gelesen werden — Import erfolgt best-effort.' + camt_newer_version: 'camt.%type%.001.%version% ist neuer als die getesteten Versionen — Import erfolgt best-effort.' error: profile_required: Für den generischen CSV-Parser wird ein CSV-Profil benötigt. file_read_failed: 'Konnte Datei nicht lesen: %path%' @@ -37,6 +41,14 @@ accounting: required_separate_columns: Profil mit separaten Spalten benötigt "amountDebit" und "amountCredit". format_not_registered: 'Kein Parser für Format "%format%" registriert. Verfügbar: %available%' none_available: keine + camt_invalid_xml: XML-Datei konnte nicht gelesen werden. + camt_unsupported_message: Nur camt.052 und camt.053 werden unterstützt. + camt_no_statements: Die camt-Datei enthält keine Konto-Reports oder Kontoauszüge. + camt_version_too_old: 'camt.%type%.001.%version% wird nicht unterstützt; bitte Version 08 oder neuer exportieren.' + camt_multiple_accounts: Die ausgewählten camt-Dateien enthalten unterschiedliche Konto-IBANs. + camt_missing_date: Buchungsdatum/Valutadatum fehlt. + camt_missing_amount: Betrag fehlt. + camt_missing_direction: Soll/Haben-Kennzeichen fehlt. upload: title: Neuen Kontoauszug hochladen @@ -47,14 +59,19 @@ accounting: Auf dieses Sachkonto werden die Buchungen gegengebucht. Bei Eingängen wird es automatisch als Soll-Konto vorausgefüllt, bei Ausgängen als Haben-Konto. Du musst dann nur noch die jeweils andere Seite zuordnen. - csv_profile: CSV-Profil - csv_profile.placeholder: Bitte wählen… - file: CSV-Datei - file.invalid_type: Bitte eine CSV-Datei hochladen. + format: Format + format.placeholder: Bitte wählen… + format.camt: 'XML: camt.052/053 ab v08' + format.csv_profile: 'CSV: %profile%' + file: Kontoauszugsdatei(en) + file.invalid_type: Bitte CSV- oder XML-Datei(en) hochladen. + multiple_files_name: '%count% XML-Dateien' flash: invalid: Eingabe unvollständig — bitte alle Felder prüfen. - parse_failed: 'CSV konnte nicht eingelesen werden: %message%' - no_lines: Die Datei enthielt keine erkennbaren Buchungszeilen — passt das gewählte Profil zur Datei? + csv_profile_missing: Das gewählte CSV-Profil wurde nicht gefunden. + csv_requires_single_file: Für CSV-Importe bitte genau eine Datei hochladen. + parse_failed: 'Datei konnte nicht eingelesen werden: %message%' + no_lines: Die Datei enthielt keine erkennbaren Buchungszeilen — passt das gewählte Format zur Datei? iban_mismatch: 'Achtung: IBAN in der Datei (%file%) weicht vom hinterlegten Bankkonto (%account%) ab.' preview: diff --git a/translations/BankImport/messages.en.yaml b/translations/BankImport/messages.en.yaml index f808b867..f36e7b41 100644 --- a/translations/BankImport/messages.en.yaml +++ b/translations/BankImport/messages.en.yaml @@ -27,6 +27,10 @@ accounting: parser: warning: row_skipped: 'Row %line% skipped: %message%' + entry_skipped: 'Entry in %file% skipped: %message%' + camt_non_booked_skipped: '%count% non-booked camt.052 entries in %file% were skipped.' + camt_unknown_version: 'Could not read the camt version from the namespace in %file% — importing best-effort.' + camt_newer_version: 'camt.%type%.001.%version% is newer than the tested versions — importing best-effort.' error: profile_required: The generic CSV parser requires a CSV profile. file_read_failed: 'Could not read file: %path%' @@ -37,6 +41,14 @@ accounting: required_separate_columns: Profile with separate columns requires "amountDebit" and "amountCredit". format_not_registered: 'No parser registered for format "%format%". Available: %available%' none_available: none + camt_invalid_xml: XML file could not be read. + camt_unsupported_message: Only camt.052 and camt.053 are supported. + camt_no_statements: The camt file does not contain account reports or statements. + camt_version_too_old: 'camt.%type%.001.%version% is not supported; please export version 08 or newer.' + camt_multiple_accounts: The selected camt files contain different account IBANs. + camt_missing_date: Booking date/value date is missing. + camt_missing_amount: Amount is missing. + camt_missing_direction: Credit/debit indicator is missing. upload: title: Upload a new bank statement @@ -47,14 +59,19 @@ accounting: Bookings will be posted against this ledger account. For incoming amounts it is auto-filled as the debit account, for outgoing ones as the credit account. You only have to assign the other side. - csv_profile: CSV profile - csv_profile.placeholder: Please choose… - file: CSV file - file.invalid_type: Please upload a CSV file. + format: Format + format.placeholder: Please choose… + format.camt: 'XML: camt.052/053 from v08' + format.csv_profile: 'CSV: %profile%' + file: Bank statement file(s) + file.invalid_type: Please upload CSV or XML file(s). + multiple_files_name: '%count% XML files' flash: invalid: Input incomplete — please check all fields. - parse_failed: 'Could not parse the CSV: %message%' - no_lines: No recognizable transaction lines were found — does the chosen profile fit the file? + csv_profile_missing: The selected CSV profile was not found. + csv_requires_single_file: For CSV imports, please upload exactly one file. + parse_failed: 'Could not parse the file: %message%' + no_lines: No recognizable transaction lines were found — does the chosen format fit the file? iban_mismatch: 'Warning: IBAN in file (%file%) differs from the bank account on record (%account%).' preview: From d166951bef447b77957d92c1e9dd1d04f07e7634 Mon Sep 17 00:00:00 2001 From: Alexander Elchlepp Date: Mon, 4 May 2026 16:14:54 +0200 Subject: [PATCH 07/38] #203 allow column letters as well in csv profile --- src/Form/BankCsvProfileType.php | 5 +- src/Form/Type/CsvColumnType.php | 92 +++++++++++++++++++ .../BankImport/profile_form.html.twig | 32 +++---- tests/Unit/Form/CsvColumnTypeTest.php | 43 +++++++++ translations/BankImport/messages.de.yaml | 5 +- translations/BankImport/messages.en.yaml | 5 +- 6 files changed, 162 insertions(+), 20 deletions(-) create mode 100644 src/Form/Type/CsvColumnType.php create mode 100644 tests/Unit/Form/CsvColumnTypeTest.php diff --git a/src/Form/BankCsvProfileType.php b/src/Form/BankCsvProfileType.php index 24190a8c..3fbbb2d4 100644 --- a/src/Form/BankCsvProfileType.php +++ b/src/Form/BankCsvProfileType.php @@ -5,6 +5,7 @@ namespace App\Form; use App\Entity\BankCsvProfile; +use App\Form\Type\CsvColumnType; use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\Extension\Core\Type\CheckboxType; use Symfony\Component\Form\Extension\Core\Type\ChoiceType; @@ -113,11 +114,11 @@ public function buildForm(FormBuilderInterface $builder, array $options): void // Synthetic per-field column index inputs that are mapped to the JSON // columnMap field on submission. foreach (self::COLUMN_FIELDS as $key => $label) { - $builder->add('col_'.$key, IntegerType::class, [ + $builder->add('col_'.$key, CsvColumnType::class, [ 'label' => $label, 'required' => false, 'mapped' => false, - 'attr' => ['min' => 0, 'max' => 100, 'class' => 'form-control-sm'], + 'attr' => ['class' => 'form-control-sm', 'maxlength' => 4, 'autocomplete' => 'off'], ]); } diff --git a/src/Form/Type/CsvColumnType.php b/src/Form/Type/CsvColumnType.php new file mode 100644 index 00000000..ee9b3560 --- /dev/null +++ b/src/Form/Type/CsvColumnType.php @@ -0,0 +1,92 @@ +addModelTransformer(new CallbackTransformer( + static function (?int $index): string { + if (null === $index || $index < 0) { + return ''; + } + + return self::indexToLetters($index); + }, + static function (?string $input): ?int { + if (null === $input) { + return null; + } + $value = strtoupper(trim($input)); + if ('' === $value) { + return null; + } + if (1 === preg_match('/^\d+$/', $value)) { + $n = (int) $value; + if ($n < 0 || $n > 1000) { + throw new TransformationFailedException('Out of range'); + } + + return $n; + } + if (1 === preg_match('/^[A-Z]+$/', $value)) { + return self::lettersToIndex($value); + } + throw new TransformationFailedException('Invalid column reference'); + }, + )); + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'invalid_message' => 'accounting.bank_import.profile.col.invalid', + ]); + } + + public static function indexToLetters(int $index): string + { + $result = ''; + $n = $index; + while (true) { + $result = chr(65 + ($n % 26)).$result; + $n = intdiv($n, 26) - 1; + if ($n < 0) { + break; + } + } + + return $result; + } + + public static function lettersToIndex(string $letters): int + { + $letters = strtoupper($letters); + $index = 0; + for ($i = 0, $len = strlen($letters); $i < $len; ++$i) { + $index = $index * 26 + (ord($letters[$i]) - 64); + } + + return $index - 1; + } +} diff --git a/templates/BookingJournal/BankImport/profile_form.html.twig b/templates/BookingJournal/BankImport/profile_form.html.twig index 749b481b..c4d5aac4 100644 --- a/templates/BookingJournal/BankImport/profile_form.html.twig +++ b/templates/BookingJournal/BankImport/profile_form.html.twig @@ -112,29 +112,29 @@
{{ 'accounting.bank_import.profile.col.group.required'|trans }}
- {{ ui.field(form.col_bookDate, {'width': '4rem'}) }} - {{ ui.field(form.col_amount, {'width': '4rem'}) }} + {{ ui.field(form.col_bookDate, {'width': '5rem'}) }} + {{ ui.field(form.col_amount, {"width": "5rem"}) }}
{{ 'accounting.bank_import.profile.col.group.separate'|trans }}
- {{ ui.field(form.col_amountDebit, {'width': '4rem'}) }} - {{ ui.field(form.col_amountCredit, {'width': '4rem'}) }} + {{ ui.field(form.col_amountDebit, {"width": "5rem"}) }} + {{ ui.field(form.col_amountCredit, {"width": "5rem"}) }}
{{ 'accounting.bank_import.profile.col.group.context'|trans }}
- {{ ui.field(form.col_valueDate, {'width': '4rem'}) }} - {{ ui.field(form.col_purpose, {'width': '4rem'}) }} - {{ ui.field(form.col_counterpartyName, {'width': '4rem'}) }} - {{ ui.field(form.col_counterpartyIban, {'width': '4rem'}) }} + {{ ui.field(form.col_valueDate, {"width": "5rem"}) }} + {{ ui.field(form.col_purpose, {"width": "5rem"}) }} + {{ ui.field(form.col_counterpartyName, {"width": "5rem"}) }} + {{ ui.field(form.col_counterpartyIban, {"width": "5rem"}) }}
{{ 'accounting.bank_import.profile.col.group.optional'|trans }}
- {{ ui.field(form.col_endToEndId, {'width': '4rem'}) }} - {{ ui.field(form.col_mandateReference, {'width': '4rem'}) }} - {{ ui.field(form.col_creditorId, {'width': '4rem'}) }} + {{ ui.field(form.col_endToEndId, {"width": "5rem"}) }} + {{ ui.field(form.col_mandateReference, {"width": "5rem"}) }} + {{ ui.field(form.col_creditorId, {"width": "5rem"}) }}
@@ -165,7 +165,7 @@ Z 3: (leer) Z 4: "Buchungsdatum";"Wertstellung";"Status";"Zahler";"Empfänger";"Zweck";"Typ";"IBAN";"Betrag";… Z 5: "31.03.26";"31.03.26";"Gebucht";"ISSUER";"Company XYZ";"VISA Debitkartenumsatz";"Ausgang";"DE45…";"-41,98";… - ↑0 ↑1 ↑2 ↑3 ↑4 ↑5 ↑6 ↑7 ↑8 + ↑A ↑B ↑C ↑D ↑E ↑F ↑G ↑H ↑I
@@ -190,7 +190,7 @@ {{ 'accounting.bank_import.profile.example.dates'|trans }}
- col_bookDate = 0, col_valueDate = 1 + col_bookDate = A, col_valueDate = B
@@ -198,8 +198,8 @@ {{ 'accounting.bank_import.profile.example.context'|trans }}
- col_counterpartyName = 4, col_purpose = 5,
- col_counterpartyIban = 7 + col_counterpartyName = E, col_purpose = F,
+ col_counterpartyIban = H
@@ -207,7 +207,7 @@ {{ 'accounting.bank_import.profile.example.amount'|trans }}
- col_amount = 8 ({{ 'accounting.bank_import.profile.example.signed_hint'|trans }}) + col_amount = I ({{ 'accounting.bank_import.profile.example.signed_hint'|trans }})
{{ 'accounting.bank_import.profile.example.header_skip'|trans }}
diff --git a/tests/Unit/Form/CsvColumnTypeTest.php b/tests/Unit/Form/CsvColumnTypeTest.php new file mode 100644 index 00000000..de715f47 --- /dev/null +++ b/tests/Unit/Form/CsvColumnTypeTest.php @@ -0,0 +1,43 @@ + Date: Tue, 5 May 2026 08:14:41 +0200 Subject: [PATCH 08/38] #203 add diferent csv and campt file tests --- .../BankImport/camt052-booked-day1.xml | 81 ++++ .../BankImport/camt052-booked-day2.xml | 52 +++ tests/Fixtures/BankImport/camt052-pending.xml | 28 ++ .../Fixtures/BankImport/camt053-statement.xml | 37 ++ .../BankImport/dkb-girokonto-anonymized.csv | 12 + .../postbank-girokonto-anonymized.csv | 17 + .../BankImport/GenericCsvParserTest.php | 346 ++++++++++++++++++ 7 files changed, 573 insertions(+) create mode 100644 tests/Fixtures/BankImport/camt052-booked-day1.xml create mode 100644 tests/Fixtures/BankImport/camt052-booked-day2.xml create mode 100644 tests/Fixtures/BankImport/camt052-pending.xml create mode 100644 tests/Fixtures/BankImport/camt053-statement.xml create mode 100644 tests/Fixtures/BankImport/dkb-girokonto-anonymized.csv create mode 100644 tests/Fixtures/BankImport/postbank-girokonto-anonymized.csv create mode 100644 tests/Unit/BookingJournal/BankImport/GenericCsvParserTest.php diff --git a/tests/Fixtures/BankImport/camt052-booked-day1.xml b/tests/Fixtures/BankImport/camt052-booked-day1.xml new file mode 100644 index 00000000..ac49e888 --- /dev/null +++ b/tests/Fixtures/BankImport/camt052-booked-day1.xml @@ -0,0 +1,81 @@ + + + + + CAMT052-DAY1 + 2026-05-02T13:01:21+02:00 + + + CAMT052-DAY1-RPT + 2026-05-02T13:01:21+02:00 + + DE00SPARKASSETEST0001 + EUR + + + OPBD + 1000.00 + CRDT +
2026-04-08
+
+ + CLBD + 1488.02 + CRDT +
2026-04-09
+
+ + 518.00 + CRDT + BOOK +
2026-04-09
+
2026-04-09
+ CAMT052-DAY1-001 + + + + + E2E-IN-001 + FI-UMSATZ-IDCAMT052-DAY1-001 + + 518.00 + + Example Guest GmbH + DE00GUEST000000000001 + Fewohbee Test Account + DE00SPARKASSETEST0001 + + Invoice 2026-0101 + + + GUTSCHRIFT UEBERWEISUNG +
+ + 29.98 + DBIT + BOOK +
2026-04-09
+
2026-04-09
+ CAMT052-DAY1-002 + + + + + E2E-OUT-001 + FI-UMSATZ-IDCAMT052-DAY1-002 + + 29.98 + + Fewohbee Test Account + DE00SPARKASSETEST0001 + Office Supplies GmbH + DE00VENDOR0000000001 + + Paper and pens + + + KARTENZAHLUNG +
+
+
+
diff --git a/tests/Fixtures/BankImport/camt052-booked-day2.xml b/tests/Fixtures/BankImport/camt052-booked-day2.xml new file mode 100644 index 00000000..03febdea --- /dev/null +++ b/tests/Fixtures/BankImport/camt052-booked-day2.xml @@ -0,0 +1,52 @@ + + + + + CAMT052-DAY2 + 2026-05-02T13:01:21+02:00 + + + CAMT052-DAY2-RPT + + DE00SPARKASSETEST0001 + EUR + + + OPBD + 1488.02 + CRDT +
2026-04-09
+
+ + CLBD + 1463.02 + CRDT +
2026-04-10
+
+ + 25.00 + DBIT + BOOK +
2026-04-10
+
2026-04-10
+ + + + + E2E-OUT-002 + MANDATE-002 + + + Fewohbee Test Account + DE00SPARKASSETEST0001 + Example Subscription AG + DE00SUBSCRIPTION0001 + + Monthly subscription + + + FOLGELASTSCHRIFT +
+
+
+
diff --git a/tests/Fixtures/BankImport/camt052-pending.xml b/tests/Fixtures/BankImport/camt052-pending.xml new file mode 100644 index 00000000..0ffab91b --- /dev/null +++ b/tests/Fixtures/BankImport/camt052-pending.xml @@ -0,0 +1,28 @@ + + + + CAMT052-PENDING2026-05-02T13:01:21+02:00 + + CAMT052-PENDING-RPT + DE00SPARKASSETEST0001EUR + CLBD1000.00CRDT
2026-04-11
+ + 10.00 + CRDT + BOOK +
2026-04-11
+
2026-04-11
+ + BOOKED-ONLYBooked CounterpartyBooked line +
+ + 99.00 + DBIT + PDNG +
2026-04-11
+
2026-04-11
+ +
+
+
+
diff --git a/tests/Fixtures/BankImport/camt053-statement.xml b/tests/Fixtures/BankImport/camt053-statement.xml new file mode 100644 index 00000000..31c4b623 --- /dev/null +++ b/tests/Fixtures/BankImport/camt053-statement.xml @@ -0,0 +1,37 @@ + + + + CAMT053-STMT2026-05-01T20:00:00+02:00 + + CAMT053-STMT-APRIL + + 2026-04-01T00:00:00+02:00 + 2026-04-30T23:59:59+02:00 + + DE00SPARKASSETEST0001EUR + CLBD1463.02CRDT
2026-04-30
+ + 120.00 + CRDT + BOOK +
2026-04-30
+
2026-04-30
+ CAMT053-001 + + + + E2E-STMT-001 + + Statement Guest GmbH + DE00STATEMENTGUEST01 + Fewohbee Test Account + DE00SPARKASSETEST0001 + + Statement invoice 2026-0102 + + + GUTSCHRIFT +
+
+
+
diff --git a/tests/Fixtures/BankImport/dkb-girokonto-anonymized.csv b/tests/Fixtures/BankImport/dkb-girokonto-anonymized.csv new file mode 100644 index 00000000..4b747388 --- /dev/null +++ b/tests/Fixtures/BankImport/dkb-girokonto-anonymized.csv @@ -0,0 +1,12 @@ +"Girokonto";"DE00DKBTESTKONTO0001" +"Zeitraum:";"01.03.2026 - 31.03.2026" +"Kontostand vom 24.04.2026:";"2.000,46 €" +"" +"Buchungsdatum";"Wertstellung";"Status";"Zahlungspflichtige*r";"Zahlungsempfänger*in";"Verwendungszweck";"Umsatztyp";"IBAN";"Betrag (€)";"Gläubiger-ID";"Mandatsreferenz";"Kundenreferenz" +"31.03.26";"31.03.26";"Gebucht";"ISSUER";"MUSTERHANDEL XYZ";"VISA Debitkartenumsatz vom 30.03.2026";"Ausgang";"DKB-CP-001";"-41,98";"";"";"DKB-2026-000001" +"30.03.26";"30.03.26";"Gebucht";"Muster Arbeitgeber AG";"Ferienhaus Muster GmbH";"Lohn/Gehalt 2026-03";"Eingang";"DKB-CP-002";"8.837,21";"";"";"DKB-2026-000002" +"26.03.26";"26.03.26";"Gebucht";"Ferienhaus Muster GmbH";"Hosting Beispiel GmbH";"Kundennummer: K0900214419 Rechnungsnummer: 086000759745";"Ausgang";"DKB-CP-003";"-5,39";"CREDITOR-001";"M-K0900214419-0002";"DKB-2026-000003" +"23.03.26";"23.03.26";"Gebucht";"Ferienhaus Muster GmbH";"Test GMBH";"R2655913G - 2026-826";"Ausgang";"DKB-CP-004";"-318,50";"";"";"DKB-2026-000004" +"18.03.26";"18.03.26";"Gebucht";"Ferienhaus Muster GmbH";"Energie Beispiel GmbH";"Monatsabschlag 03/2026";"Ausgang";"DKB-CP-005";"-113,61";"CREDITOR-002";"M-ENERGIE-2026-03";"DKB-2026-000005" +"12.03.26";"12.03.26";"Gebucht";"Max Mustermann";"Ferienhaus Muster GmbH";"Rechnung 2026-0101 Aufenthalt Maerz 2026";"Eingang";"DKB-CP-006";"585,00";"";"";"RG-2026-0101" +"03.03.26";"03.03.26";"Gebucht";"Ferienhaus Muster GmbH";"Muster Sparen";"Umbuchung Ruecklage 03/2026";"Ausgang";"DKB-CP-007";"-100";"CREDITOR-003";"M-RUECKLAGE-2026";"DKB-2026-000006" diff --git a/tests/Fixtures/BankImport/postbank-girokonto-anonymized.csv b/tests/Fixtures/BankImport/postbank-girokonto-anonymized.csv new file mode 100644 index 00000000..d9b6735f --- /dev/null +++ b/tests/Fixtures/BankImport/postbank-girokonto-anonymized.csv @@ -0,0 +1,17 @@ +Umsätze +Konto;Filial-/Kontonummer;IBAN;Währung +Postbank Giro plus;209 7051840 00;DE00POSTBANKTEST0001;EUR + +1.4.2025 - 26.4.2026 +Letzter Kontostand;;;;1.526,02;EUR +Vorgemerkte und noch nicht gebuchte Umsätze sind nicht Bestandteil dieser Übersicht. +Buchungstag;Wert;Umsatzart;Begünstigter / Auftraggeber;Verwendungszweck;IBAN / Kontonummer;BIC;Kundenreferenz;Mandatsreferenz;Gläubiger ID;Fremde Gebühren;Betrag;Abweichender Empfänger;Anzahl der Aufträge;Anzahl der Schecks;Soll;Haben;Währung +19.4.2026;19.4.2026;Kartenzahlung;MUSTERHANDEL XYZ;MUSTERHANDEL XYZ KARTENZAHLUNG 17-04-2026T14:53:03 Folgenr. 01 Verfalld. 1226;PB-CP-001;;PB-2026-000001;177671;CREDITOR-101;;-5,50;;;;-5,50;;EUR +22.4.2026;22.4.2026;SEPA-Gutschrift;Booking Beispiel BV;BOOKING BEISPIEL BV SAMMELAUSZAHLUNG APRIL 2026 3 BUCHUNGEN KOMMISSION 199,33 GEBUEHR 7,45;PB-CP-002;BIC-PB-002;BOOK-APR-2026-0001;;;;812,34;;;;;812,34;EUR +23.4.2026;23.4.2026;Entgeltabrechnung;POSTBANK;Information zur Abrechnung Zinsen fuer Kredit 12,30- Kreditprovision 4,20- Entgelte vom 23.04.2026 25,00-;PB-OWN-001;BIC-PB-OWN;ABR-2026-04;;;;-41,50;;;;-41,50;;EUR +23.4.2026;23.4.2026;Entgeltabrechnung;POSTBANK;Information zur Abrechnung Zinsen fuer Kredit 15,00- Kreditprovision 5,00- Entgelte vom 23.04.2026 30,00-;PB-OWN-001;BIC-PB-OWN;ABR-2026-04-B;;;;-50,00;;;;-50,00;;EUR +24.4.2026;24.4.2026;Lastschrift;POSTBANK DARLEHEN;Darl.-Leistung 987654 Zinsen 180,00 Rate April 2026;PB-CP-003;BIC-PB-003;DARL-987654-2026-04;;;;-1000,00;;;;-1000,00;;EUR +25.4.2026;25.4.2026;SEPA-Lastschrift;STADTWERKE MUSTERSTADT;Monatsabrechnung 04/2026 Grundpreis 29,00 Arbeitspreis 90,00;PB-CP-004;BIC-PB-004;SW-2026-04;;;;-119,00;;;;-119,00;;EUR +25.4.2026;25.4.2026;SEPA-Gutschrift;MAX MUSTERMANN;Rechnung 2026-0101 Aufenthalt April 2026;PB-CP-005;BIC-PB-005;RG-2026-0101;;;;585,00;;;;;585,00;EUR +26.4.2026;26.4.2026;SEPA-Gutschrift;ERIKA BEISPIEL;Rechnung 2026-0102 Kurzaufenthalt April 2026;PB-CP-006;BIC-PB-006;RG-2026-0102;;;;230,00;;;;;230,00;EUR +Kontostand;26.4.2026;;;1.520,52;EUR diff --git a/tests/Unit/BookingJournal/BankImport/GenericCsvParserTest.php b/tests/Unit/BookingJournal/BankImport/GenericCsvParserTest.php new file mode 100644 index 00000000..ae521641 --- /dev/null +++ b/tests/Unit/BookingJournal/BankImport/GenericCsvParserTest.php @@ -0,0 +1,346 @@ +createDkbProfile(); + $parser = new GenericCsvParser(); + $result = $parser->parse(new \SplFileInfo(self::EXAMPLE_CSV), $profile); + + self::assertSame('csv_generic', $parser->getFormatKey()); + + self::assertCount(7, $result->lines, sprintf( + 'Erwartet 7 Datenzeilen, bekam %d. Warnungen: %s', + count($result->lines), + implode(' / ', $result->warnings), + )); + self::assertSame([], $result->warnings); + + // IBAN aus dem Vorspann. + self::assertSame('DE00DKBTESTKONTO0001', $result->sourceIban); + + // Zeitraum aus dem Vorspann. + self::assertNotNull($result->periodFrom); + self::assertNotNull($result->periodTo); + self::assertSame('2026-03-01', $result->periodFrom->format('Y-m-d')); + self::assertSame('2026-03-31', $result->periodTo->format('Y-m-d')); + + $first = $result->lines[0]; + self::assertInstanceOf(StatementLineDto::class, $first); + self::assertSame('2026-03-31', $first->bookDate->format('Y-m-d')); + self::assertSame('2026-03-31', $first->valueDate->format('Y-m-d')); + self::assertSame('-41.98', $first->amount); + self::assertSame('MUSTERHANDEL XYZ', $first->counterpartyName); + self::assertSame('DKB-CP-001', $first->counterpartyIban); + self::assertStringContainsString('VISA Debitkartenumsatz', $first->purpose); + self::assertSame('DKB-2026-000001', $first->endToEndId); + } + + public function testParsesIncomingAmountWithThousandsSeparator(): void + { + if (!is_file(self::EXAMPLE_CSV)) { + self::markTestSkipped('Beispiel-CSV nicht vorhanden.'); + } + + $profile = $this->createDkbProfile(); + $parser = new GenericCsvParser(); + $result = $parser->parse(new \SplFileInfo(self::EXAMPLE_CSV), $profile); + + // Lohn/Gehalt-Zeile: "8.837,21" → 8837.21 mit positivem Vorzeichen. + $income = $this->findLineByPurpose($result->lines, 'Lohn/Gehalt'); + self::assertNotNull($income); + self::assertSame('8837.21', $income->amount); + self::assertTrue($income->isIncoming()); + } + + public function testParsesNegativeThousandsAmount(): void + { + if (!is_file(self::EXAMPLE_CSV)) { + self::markTestSkipped('Beispiel-CSV nicht vorhanden.'); + } + + $profile = $this->createDkbProfile(); + $parser = new GenericCsvParser(); + $result = $parser->parse(new \SplFileInfo(self::EXAMPLE_CSV), $profile); + + // Invoice-like reference row: "-318,50" → -318.50. + $invoice = $this->findLineByPurpose($result->lines, '2026-826'); + self::assertNotNull($invoice); + self::assertSame('-318.50', $invoice->amount); + self::assertFalse($invoice->isIncoming()); + } + + public function testParsesSparkasseExampleFully(): void + { + if (!is_file(self::SPARKASSE_CSV)) { + self::markTestSkipped('Sparkassen-Beispiel-CSV nicht vorhanden.'); + } + + $profile = $this->createSparkasseProfile(); + $parser = new GenericCsvParser(); + $result = $parser->parse(new \SplFileInfo(self::SPARKASSE_CSV), $profile); + + self::assertSame('csv_generic', $parser->getFormatKey()); + self::assertCount(4, $result->lines, sprintf( + 'Erwartet 4 Sparkassen-Datenzeilen, bekam %d. Warnungen: %s', + count($result->lines), + implode(' / ', $result->warnings), + )); + self::assertSame([], $result->warnings); + + self::assertSame('DE00SPKTESTKONTO0001', $result->sourceIban); + self::assertNull($result->periodFrom); + self::assertNull($result->periodTo); + + $first = $result->lines[0]; + self::assertSame('2026-04-30', $first->bookDate->format('Y-m-d')); + self::assertSame('2026-05-01', $first->valueDate->format('Y-m-d')); + self::assertSame('-14.60', $first->amount); + self::assertSame('TEST SPARKASSE', $first->counterpartyName); + self::assertSame('SPK-CP-FEE', $first->counterpartyIban); + self::assertSame('SPK-E2E-0001', $first->endToEndId); + self::assertStringContainsString('Entgeltabrechnung', $first->purpose); + + $income = $this->findLineByPurpose($result->lines, '2026-826'); + self::assertNotNull($income); + self::assertSame('1234.56', $income->amount); + self::assertTrue($income->isIncoming()); + self::assertSame('BEISPIELKUNDE AG', $income->counterpartyName); + + $rent = $this->findLineByPurpose($result->lines, 'Miete 1790'); + self::assertNotNull($rent); + self::assertSame('-2260.00', $rent->amount); + self::assertFalse($rent->isIncoming()); + + $fingerprints = array_map(static fn (StatementLineDto $line): string => $line->fingerprint(), $result->lines); + self::assertCount(count($fingerprints), array_unique($fingerprints)); + } + + public function testFingerprintIsDeterministicAndDistinguishesAmounts(): void + { + $base = new StatementLineDto( + bookDate: new \DateTimeImmutable('2026-03-15'), + valueDate: new \DateTimeImmutable('2026-03-15'), + amount: '-12.34', + counterpartyName: 'ACME GmbH', + counterpartyIban: 'CP-001', + purpose: 'Rechnung 2026-0007', + endToEndId: 'X-1', + ); + + $sameAgain = new StatementLineDto( + bookDate: new \DateTimeImmutable('2026-03-15'), + valueDate: new \DateTimeImmutable('2026-03-15'), + amount: '-12.34', + counterpartyName: 'IRRELEVANT — name not part of fingerprint', + counterpartyIban: 'CP-001', + purpose: ' Rechnung 2026-0007 ', + endToEndId: 'X-1', + ); + + $differentAmount = new StatementLineDto( + bookDate: new \DateTimeImmutable('2026-03-15'), + valueDate: new \DateTimeImmutable('2026-03-15'), + amount: '-12.35', + counterpartyName: 'ACME GmbH', + counterpartyIban: 'CP-001', + purpose: 'Rechnung 2026-0007', + endToEndId: 'X-1', + ); + + self::assertSame($base->fingerprint(), $sameAgain->fingerprint(), + 'Whitespace und Gegenpartei-Name dürfen den Fingerabdruck nicht beeinflussen.'); + self::assertNotSame($base->fingerprint(), $differentAmount->fingerprint(), + 'Unterschiedliche Beträge müssen unterschiedliche Fingerabdrücke ergeben.'); + } + + public function testSeparateColumnsProfile(): void + { + $csv = <<setName('Sep') + ->setDelimiter(',') + ->setEnclosure('"') + ->setEncoding('UTF-8') + ->setHeaderSkip(0) + ->setHasHeaderRow(true) + ->setColumnMap([ + 'bookDate' => 0, + 'amountDebit' => 1, + 'amountCredit' => 2, + 'purpose' => 3, + ]) + ->setDateFormat('d.m.Y') + ->setAmountDecimalSeparator('.') + ->setAmountThousandsSeparator(null) + ->setDirectionMode(BankCsvProfile::DIRECTION_SEPARATE_COLUMNS); + + $parser = new GenericCsvParser(); + $result = $parser->parse(new \SplFileInfo($meta['uri']), $profile); + + self::assertCount(2, $result->lines); + self::assertSame('-10.50', $result->lines[0]->amount); + self::assertSame('250.00', $result->lines[1]->amount); + } + + public function testTwoDigitBankYearWithFullYearProfileIsNormalizedToCurrentCentury(): void + { + $csv = <<setName('Short year') + ->setDelimiter(';') + ->setEnclosure('"') + ->setEncoding('UTF-8') + ->setHeaderSkip(0) + ->setHasHeaderRow(true) + ->setColumnMap([ + 'bookDate' => 0, + 'amount' => 1, + 'purpose' => 2, + ]) + ->setDateFormat('d.m.Y') + ->setAmountDecimalSeparator(',') + ->setAmountThousandsSeparator(null) + ->setDirectionMode(BankCsvProfile::DIRECTION_SIGNED); + + $result = (new GenericCsvParser())->parse(new \SplFileInfo($meta['uri']), $profile); + + self::assertCount(1, $result->lines); + self::assertSame('2026-03-02', $result->lines[0]->bookDate->format('Y-m-d')); + } + + public function testTwoDigitBankYearIsAlwaysInterpretedAsTwoThousandYear(): void + { + $csv = <<setName('Short year') + ->setDelimiter(';') + ->setEnclosure('"') + ->setEncoding('UTF-8') + ->setHeaderSkip(0) + ->setHasHeaderRow(true) + ->setColumnMap([ + 'bookDate' => 0, + 'amount' => 1, + 'purpose' => 2, + ]) + ->setDateFormat('d.m.Y') + ->setAmountDecimalSeparator(',') + ->setAmountThousandsSeparator(null) + ->setDirectionMode(BankCsvProfile::DIRECTION_SIGNED); + + $result = (new GenericCsvParser())->parse(new \SplFileInfo($meta['uri']), $profile); + + self::assertCount(1, $result->lines); + self::assertSame('2099-03-02', $result->lines[0]->bookDate->format('Y-m-d')); + } + + private function createDkbProfile(): BankCsvProfile + { + return (new BankCsvProfile()) + ->setName('DKB Girokonto (Test)') + ->setDelimiter(';') + ->setEnclosure('"') + ->setEncoding('UTF-8') + ->setHeaderSkip(4) + ->setHasHeaderRow(true) + ->setColumnMap([ + 'bookDate' => 0, + 'valueDate' => 1, + 'counterpartyName' => 4, + 'purpose' => 5, + 'counterpartyIban' => 7, + 'amount' => 8, + 'endToEndId' => 11, + ]) + ->setDateFormat('d.m.y') + ->setAmountDecimalSeparator(',') + ->setAmountThousandsSeparator('.') + ->setDirectionMode(BankCsvProfile::DIRECTION_SIGNED) + ->setIbanSourceLine(0) + ->setPeriodSourceLine(1); + } + + private function createSparkasseProfile(): BankCsvProfile + { + return (new BankCsvProfile()) + ->setName('Sparkasse Girokonto (Test)') + ->setDelimiter(';') + ->setEnclosure('"') + ->setEncoding('UTF-8') + ->setHeaderSkip(0) + ->setHasHeaderRow(true) + ->setColumnMap([ + 'bookDate' => 1, + 'valueDate' => 2, + 'counterpartyName' => 11, + 'purpose' => 4, + 'counterpartyIban' => 12, + 'amount' => 14, + 'creditorId' => 5, + 'mandateReference' => 6, + 'endToEndId' => 7, + ]) + ->setDateFormat('d.m.y') + ->setAmountDecimalSeparator(',') + ->setAmountThousandsSeparator('.') + ->setDirectionMode(BankCsvProfile::DIRECTION_SIGNED) + ->setIbanSourceLine(1); + } + + /** + * @param list $lines + */ + private function findLineByPurpose(array $lines, string $needle): ?StatementLineDto + { + foreach ($lines as $line) { + if (str_contains($line->purpose, $needle)) { + return $line; + } + } + + return null; + } +} From a9ca0bfb55e81bd469a23d5c8d49c3377f46da74 Mon Sep 17 00:00:00 2001 From: Alexander Elchlepp Date: Wed, 6 May 2026 12:00:09 +0200 Subject: [PATCH 09/38] #204 added new guestcategory settingspage --- migrations/Version20260424190000.php | 5 + migrations/Version20260505120000.php | 93 ++++++++ src/Command/FirstRunCommand.php | 5 + src/Controller/GuestCategoryController.php | 81 +++++++ src/Entity/Enum/GuestStatisticalGroup.php | 23 ++ src/Entity/GuestCategory.php | 210 ++++++++++++++++++ src/Entity/Reservation.php | 70 ++++++ src/Form/GuestCategoryType.php | 78 +++++++ src/Repository/GuestCategoryRepository.php | 82 +++++++ src/Service/GuestCategorySeeder.php | 116 ++++++++++ src/Service/ReservationService.php | 76 +++++++ templates/GuestCategory/_form.html.twig | 3 + .../_guest_counts_table.html.twig | 88 ++++++++ templates/GuestCategory/edit.html.twig | 35 +++ templates/GuestCategory/index.html.twig | 83 +++++++ templates/GuestCategory/new.html.twig | 20 ++ templates/base.html.twig | 1 + translations/Base/messages.de.xlf | 4 + translations/Base/messages.en.yaml | 1 + translations/GuestCategory/messages.de.yaml | 73 ++++++ translations/GuestCategory/messages.en.yaml | 73 ++++++ 21 files changed, 1220 insertions(+) create mode 100644 migrations/Version20260505120000.php create mode 100644 src/Controller/GuestCategoryController.php create mode 100644 src/Entity/Enum/GuestStatisticalGroup.php create mode 100644 src/Entity/GuestCategory.php create mode 100644 src/Form/GuestCategoryType.php create mode 100644 src/Repository/GuestCategoryRepository.php create mode 100644 src/Service/GuestCategorySeeder.php create mode 100644 templates/GuestCategory/_form.html.twig create mode 100644 templates/GuestCategory/_guest_counts_table.html.twig create mode 100644 templates/GuestCategory/edit.html.twig create mode 100644 templates/GuestCategory/index.html.twig create mode 100644 templates/GuestCategory/new.html.twig create mode 100644 translations/GuestCategory/messages.de.yaml create mode 100644 translations/GuestCategory/messages.en.yaml diff --git a/migrations/Version20260424190000.php b/migrations/Version20260424190000.php index 739563eb..1c6a7f19 100644 --- a/migrations/Version20260424190000.php +++ b/migrations/Version20260424190000.php @@ -116,4 +116,9 @@ public function down(Schema $schema): void $this->addSql('DROP INDEX idx_booking_entry_split_group ON booking_entries'); $this->addSql('ALTER TABLE booking_entries DROP COLUMN split_group_uuid'); } + + public function isTransactional(): bool + { + return false; + } } diff --git a/migrations/Version20260505120000.php b/migrations/Version20260505120000.php new file mode 100644 index 00000000..10696387 --- /dev/null +++ b/migrations/Version20260505120000.php @@ -0,0 +1,93 @@ +addSql('CREATE TABLE guest_categories ( + id INT AUTO_INCREMENT NOT NULL, + name VARCHAR(100) NOT NULL, + acronym VARCHAR(20) NOT NULL, + min_age SMALLINT DEFAULT NULL, + max_age SMALLINT DEFAULT NULL, + is_counted_in_occupancy TINYINT(1) NOT NULL DEFAULT 1, + statistical_group VARCHAR(20) NOT NULL, + sort_order INT NOT NULL DEFAULT 0, + active TINYINT(1) NOT NULL DEFAULT 1, + system_code VARCHAR(50) DEFAULT NULL, + UNIQUE INDEX UNIQ_guest_category_system_code (system_code), + PRIMARY KEY (id) + ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci ENGINE = InnoDB'); + + $this->addSql('CREATE TABLE guest_categories_has_subsidiaries ( + guest_category_id INT NOT NULL, + subsidiary_id INT NOT NULL, + INDEX IDX_gchsub_category (guest_category_id), + INDEX IDX_gchsub_subsidiary (subsidiary_id), + PRIMARY KEY (guest_category_id, subsidiary_id), + CONSTRAINT FK_gchsub_category FOREIGN KEY (guest_category_id) REFERENCES guest_categories (id) ON DELETE CASCADE, + CONSTRAINT FK_gchsub_subsidiary FOREIGN KEY (subsidiary_id) REFERENCES objects (id) ON DELETE CASCADE + ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci ENGINE = InnoDB'); + + $this->addSql('ALTER TABLE reservations + ADD guest_counts JSON NOT NULL, + ADD kurtaxe_waived TINYINT(1) NOT NULL DEFAULT 0, + ADD adult_rule_override TINYINT(1) NOT NULL DEFAULT 0'); + + // Seed default guest categories with literal German default values + // (anwender-editierbare Stammdaten, keine Translation-Keys). + $this->addSql("INSERT INTO guest_categories + (name, acronym, min_age, max_age, is_counted_in_occupancy, statistical_group, sort_order, active, system_code) + VALUES + ('Erwachsene', 'ERW', 18, NULL, 1, 'adult', 10, 1, 'default_adult'), + ('Kind 6-17', 'K6-17', 6, 17, 1, 'child', 20, 1, 'default_child'), + ('Kleinkind 0-5', 'BABY', 0, 5, 0, 'infant', 30, 1, 'default_infant'), + ('Nichtpflichtige Personen','NP', NULL, NULL, 1, 'other', 40, 1, 'default_exempt')"); + + // Backfill existing reservations into the default-adult bucket so + // guest_counts is the authoritative source from now on. + $this->addSql("UPDATE reservations r + JOIN guest_categories gc ON gc.system_code = 'default_adult' + SET r.guest_counts = JSON_OBJECT(CAST(gc.id AS CHAR), r.persons) + WHERE r.persons IS NOT NULL AND r.persons > 0"); + + $this->addSql("UPDATE reservations + SET guest_counts = JSON_OBJECT() + WHERE guest_counts IS NULL OR JSON_LENGTH(guest_counts) IS NULL"); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE reservations + DROP COLUMN guest_counts, + DROP COLUMN kurtaxe_waived, + DROP COLUMN adult_rule_override'); + $this->addSql('ALTER TABLE guest_categories_has_subsidiaries DROP FOREIGN KEY FK_gchsub_category'); + $this->addSql('ALTER TABLE guest_categories_has_subsidiaries DROP FOREIGN KEY FK_gchsub_subsidiary'); + $this->addSql('DROP TABLE guest_categories_has_subsidiaries'); + $this->addSql('DROP TABLE guest_categories'); + } + + public function isTransactional(): bool + { + return false; + } +} diff --git a/src/Command/FirstRunCommand.php b/src/Command/FirstRunCommand.php index f90b1525..3d99d5d3 100644 --- a/src/Command/FirstRunCommand.php +++ b/src/Command/FirstRunCommand.php @@ -16,6 +16,7 @@ use App\DataFixtures\ReservationFixtures; use App\DataFixtures\SettingsFixtures; use App\DataFixtures\TemplatesFixtures; +use App\Service\GuestCategorySeeder; use App\Workflow\WorkflowSeeder; use App\Entity\Customer; use App\Entity\Role; @@ -49,6 +50,7 @@ public function __construct( private readonly SettingsFixtures $settingsFixtures, private readonly ReservationFixtures $reservationFixtures, private readonly WorkflowSeeder $workflowSeeder, + private readonly GuestCategorySeeder $guestCategorySeeder, ) { parent::__construct(); } @@ -187,6 +189,9 @@ function ($value) { $this->workflowSeeder->seedExampleWorkflows(); $io->note('Example workflows created.'); + + $this->guestCategorySeeder->seedDefaults(); + $io->note('Default guest categories prepared.'); } $io->success('All done! You can now navigate to the app and login with the provided username and password.'); diff --git a/src/Controller/GuestCategoryController.php b/src/Controller/GuestCategoryController.php new file mode 100644 index 00000000..e4bfbc10 --- /dev/null +++ b/src/Controller/GuestCategoryController.php @@ -0,0 +1,81 @@ +render('GuestCategory/index.html.twig', [ + 'guest_categories' => $repository->findBy([], ['sortOrder' => 'ASC', 'id' => 'ASC']), + ]); + } + + #[Route('/new', name: 'guest_category_new', methods: ['GET', 'POST'])] + public function new(ManagerRegistry $doctrine, Request $request): Response + { + $category = new GuestCategory(); + $form = $this->createForm(GuestCategoryType::class, $category); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + $em = $doctrine->getManager(); + $em->persist($category); + $em->flush(); + + $this->addFlash('success', 'guest_category.flash.create.success'); + + return new Response('', Response::HTTP_NO_CONTENT); + } + + return $this->render('GuestCategory/new.html.twig', [ + 'category' => $category, + 'form' => $form->createView(), + ]); + } + + #[Route('/{id}/edit', name: 'guest_category_edit', methods: ['GET', 'POST'])] + public function edit(ManagerRegistry $doctrine, Request $request, GuestCategory $category): Response + { + $form = $this->createForm(GuestCategoryType::class, $category); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + $doctrine->getManager()->flush(); + $this->addFlash('success', 'guest_category.flash.edit.success'); + + return new Response('', Response::HTTP_NO_CONTENT); + } + + return $this->render('GuestCategory/edit.html.twig', [ + 'category' => $category, + 'form' => $form->createView(), + ]); + } + + #[Route('/{id}/delete', name: 'guest_category_delete', methods: ['DELETE'])] + public function delete(ManagerRegistry $doctrine, Request $request, GuestCategory $category): Response + { + if ($this->isCsrfTokenValid('delete'.$category->getId(), $request->request->get('_token'))) { + $em = $doctrine->getManager(); + $em->remove($category); + $em->flush(); + $this->addFlash('success', 'guest_category.flash.delete.success'); + } + + return new Response('', Response::HTTP_NO_CONTENT); + } +} diff --git a/src/Entity/Enum/GuestStatisticalGroup.php b/src/Entity/Enum/GuestStatisticalGroup.php new file mode 100644 index 00000000..090fd706 --- /dev/null +++ b/src/Entity/Enum/GuestStatisticalGroup.php @@ -0,0 +1,23 @@ + 'Adult', + self::CHILD => 'Child', + self::INFANT => 'Infant', + default => null, + }; + } +} diff --git a/src/Entity/GuestCategory.php b/src/Entity/GuestCategory.php new file mode 100644 index 00000000..2fa6f3ee --- /dev/null +++ b/src/Entity/GuestCategory.php @@ -0,0 +1,210 @@ +subsidiaries = new ArrayCollection(); + } + + public function getId(): ?int + { + return $this->id; + } + + public function getName(): string + { + return $this->name; + } + + public function setName(string $name): self + { + $this->name = $name; + + return $this; + } + + public function getAcronym(): string + { + return $this->acronym; + } + + public function setAcronym(string $acronym): self + { + $this->acronym = $acronym; + + return $this; + } + + /** @return Collection */ + public function getSubsidiaries(): Collection + { + return $this->subsidiaries; + } + + public function addSubsidiary(Subsidiary $subsidiary): self + { + if (!$this->subsidiaries->contains($subsidiary)) { + $this->subsidiaries[] = $subsidiary; + } + + return $this; + } + + public function removeSubsidiary(Subsidiary $subsidiary): self + { + $this->subsidiaries->removeElement($subsidiary); + + return $this; + } + + public function getMinAge(): ?int + { + return $this->minAge; + } + + public function setMinAge(?int $minAge): self + { + $this->minAge = $minAge; + + return $this; + } + + public function getMaxAge(): ?int + { + return $this->maxAge; + } + + public function setMaxAge(?int $maxAge): self + { + $this->maxAge = $maxAge; + + return $this; + } + + public function isAdult(): bool + { + return GuestStatisticalGroup::ADULT === $this->statisticalGroup; + } + + public function isCountedInOccupancy(): bool + { + return $this->isCountedInOccupancy; + } + + public function setIsCountedInOccupancy(bool $isCountedInOccupancy): self + { + $this->isCountedInOccupancy = $isCountedInOccupancy; + + return $this; + } + + public function getStatisticalGroup(): GuestStatisticalGroup + { + return $this->statisticalGroup; + } + + public function setStatisticalGroup(GuestStatisticalGroup $statisticalGroup): self + { + $this->statisticalGroup = $statisticalGroup; + + return $this; + } + + public function getSortOrder(): int + { + return $this->sortOrder; + } + + public function setSortOrder(int $sortOrder): self + { + $this->sortOrder = $sortOrder; + + return $this; + } + + public function isActive(): bool + { + return $this->active; + } + + public function setActive(bool $active): self + { + $this->active = $active; + + return $this; + } + + public function getSystemCode(): ?string + { + return $this->systemCode; + } + + public function setSystemCode(?string $systemCode): self + { + $this->systemCode = $systemCode; + + return $this; + } + + public function isSystem(): bool + { + return null !== $this->systemCode && '' !== $this->systemCode; + } + + public function getOtaCode(): ?string + { + return $this->statisticalGroup->otaCode(); + } +} diff --git a/src/Entity/Reservation.php b/src/Entity/Reservation.php index 27537a7f..6ccaa4f2 100644 --- a/src/Entity/Reservation.php +++ b/src/Entity/Reservation.php @@ -78,6 +78,26 @@ class Reservation #[ORM\Column(type: Types::TIME_MUTABLE, nullable: true)] private ?\DateTime $departureTime = null; + /** + * Map of guest counts per GuestCategory id: {guestCategoryId: count}. + * Authoritative source for the personal composition of a reservation. + * `persons` is the derived sum of categories with isCountedInOccupancy=true. + * + * @var array + */ + #[ORM\Column(name: 'guest_counts', type: 'json')] + private array $guestCounts = []; + + #[ORM\Column(name: 'kurtaxe_waived', type: 'boolean')] + private bool $kurtaxeWaived = false; + + /** + * Explicit override that disables the "at least one adult" validation + * for this booking (e.g. youth groups travelling without supervision). + */ + #[ORM\Column(name: 'adult_rule_override', type: 'boolean')] + private bool $adultRuleOverride = false; + public function __construct() { $this->reservationDate = new \DateTime('now'); @@ -488,4 +508,54 @@ public function setDepartureTime(?\DateTime $departureTime): static return $this; } + + /** @return array */ + public function getGuestCounts(): array + { + return $this->guestCounts; + } + + /** @param array $guestCounts */ + public function setGuestCounts(array $guestCounts): self + { + $normalized = []; + foreach ($guestCounts as $catId => $count) { + $count = (int) $count; + if ($count > 0) { + $normalized[(int) $catId] = $count; + } + } + $this->guestCounts = $normalized; + + return $this; + } + + public function getCountForCategory(int $guestCategoryId): int + { + return (int) ($this->guestCounts[$guestCategoryId] ?? 0); + } + + public function isKurtaxeWaived(): bool + { + return $this->kurtaxeWaived; + } + + public function setKurtaxeWaived(bool $kurtaxeWaived): self + { + $this->kurtaxeWaived = $kurtaxeWaived; + + return $this; + } + + public function isAdultRuleOverride(): bool + { + return $this->adultRuleOverride; + } + + public function setAdultRuleOverride(bool $adultRuleOverride): self + { + $this->adultRuleOverride = $adultRuleOverride; + + return $this; + } } diff --git a/src/Form/GuestCategoryType.php b/src/Form/GuestCategoryType.php new file mode 100644 index 00000000..d4a2ab97 --- /dev/null +++ b/src/Form/GuestCategoryType.php @@ -0,0 +1,78 @@ +add('name', TextType::class, [ + 'label' => 'guest_category.field.name', + 'empty_data' => '', + ]) + ->add('acronym', TextType::class, [ + 'label' => 'guest_category.field.acronym', + 'empty_data' => '', + 'help' => 'guest_category.field.acronym.help', + ]) + ->add('statisticalGroup', EnumType::class, [ + 'class' => GuestStatisticalGroup::class, + 'label' => 'guest_category.field.statistical_group', + 'choice_label' => fn (GuestStatisticalGroup $g) => 'guest_category.statistical_group.'.$g->value, + 'help' => 'guest_category.field.statistical_group.help', + ]) + ->add('isCountedInOccupancy', CheckboxType::class, [ + 'label' => 'guest_category.field.is_counted_in_occupancy', + 'help' => 'guest_category.field.is_counted_in_occupancy.help', + 'required' => false, + ]) + ->add('minAge', IntegerType::class, [ + 'label' => 'guest_category.field.min_age', + 'required' => false, + ]) + ->add('maxAge', IntegerType::class, [ + 'label' => 'guest_category.field.max_age', + 'required' => false, + ]) + ->add('sortOrder', IntegerType::class, [ + 'label' => 'guest_category.field.sort_order', + 'empty_data' => '0', + ]) + ->add('active', CheckboxType::class, [ + 'label' => 'guest_category.field.active', + 'required' => false, + ]) + ->add('subsidiaries', EntityType::class, [ + 'class' => Subsidiary::class, + 'choice_label' => 'name', + 'multiple' => true, + 'expanded' => true, + 'required' => false, + 'label' => 'guest_category.field.subsidiaries', + 'help' => 'guest_category.field.subsidiaries.help', + ]) + ; + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'data_class' => GuestCategory::class, + ]); + } +} diff --git a/src/Repository/GuestCategoryRepository.php b/src/Repository/GuestCategoryRepository.php new file mode 100644 index 00000000..752af928 --- /dev/null +++ b/src/Repository/GuestCategoryRepository.php @@ -0,0 +1,82 @@ + + */ +class GuestCategoryRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, GuestCategory::class); + } + + public function findBySystemCode(string $systemCode): ?GuestCategory + { + return $this->findOneBy(['systemCode' => $systemCode]); + } + + /** + * @return GuestCategory[] + */ + public function findActiveOrdered(): array + { + return $this->createQueryBuilder('gc') + ->where('gc.active = :active') + ->setParameter('active', true) + ->orderBy('gc.sortOrder', 'ASC') + ->addOrderBy('gc.id', 'ASC') + ->getQuery() + ->getResult(); + } + + /** + * Returns active categories available for the given subsidiary. + * Categories without any subsidiary mapping are considered globally available. + * + * @return GuestCategory[] + */ + public function findActiveForSubsidiary(?Subsidiary $subsidiary): array + { + $qb = $this->createQueryBuilder('gc') + ->leftJoin('gc.subsidiaries', 's') + ->where('gc.active = :active') + ->setParameter('active', true) + ->orderBy('gc.sortOrder', 'ASC') + ->addOrderBy('gc.id', 'ASC'); + + if (null !== $subsidiary) { + $qb->andWhere('s.id IS NULL OR s.id = :sid') + ->setParameter('sid', $subsidiary->getId()); + } + + return $qb->getQuery()->getResult(); + } + + public function findDefaultAdult(): ?GuestCategory + { + $byCode = $this->findBySystemCode('default_adult'); + if ($byCode instanceof GuestCategory) { + return $byCode; + } + + return $this->createQueryBuilder('gc') + ->where('gc.statisticalGroup = :g') + ->andWhere('gc.active = :active') + ->setParameter('g', GuestStatisticalGroup::ADULT) + ->setParameter('active', true) + ->orderBy('gc.sortOrder', 'ASC') + ->setMaxResults(1) + ->getQuery() + ->getOneOrNullResult(); + } +} diff --git a/src/Service/GuestCategorySeeder.php b/src/Service/GuestCategorySeeder.php new file mode 100644 index 00000000..bc0e1a33 --- /dev/null +++ b/src/Service/GuestCategorySeeder.php @@ -0,0 +1,116 @@ + $this->translator->trans($key, [], 'GuestCategory'); + + $this->createOrUpdate( + systemCode: 'default_adult', + name: $t('guest_category.default.adult.name'), + acronym: $t('guest_category.default.adult.acronym'), + statisticalGroup: GuestStatisticalGroup::ADULT, + isCountedInOccupancy: true, + minAge: 18, + maxAge: null, + sortOrder: 10, + ); + + $this->createOrUpdate( + systemCode: 'default_child', + name: $t('guest_category.default.child.name'), + acronym: $t('guest_category.default.child.acronym'), + statisticalGroup: GuestStatisticalGroup::CHILD, + isCountedInOccupancy: true, + minAge: 6, + maxAge: 17, + sortOrder: 20, + ); + + $this->createOrUpdate( + systemCode: 'default_infant', + name: $t('guest_category.default.infant.name'), + acronym: $t('guest_category.default.infant.acronym'), + statisticalGroup: GuestStatisticalGroup::INFANT, + isCountedInOccupancy: false, + minAge: 0, + maxAge: 5, + sortOrder: 30, + ); + + $this->createOrUpdate( + systemCode: 'default_exempt', + name: $t('guest_category.default.exempt.name'), + acronym: $t('guest_category.default.exempt.acronym'), + statisticalGroup: GuestStatisticalGroup::OTHER, + isCountedInOccupancy: true, + minAge: null, + maxAge: null, + sortOrder: 40, + ); + + $this->em->flush(); + } + + private function createOrUpdate( + string $systemCode, + string $name, + string $acronym, + GuestStatisticalGroup $statisticalGroup, + bool $isCountedInOccupancy, + ?int $minAge, + ?int $maxAge, + int $sortOrder, + ): void { + $existing = $this->repository->findBySystemCode($systemCode); + + if ($existing instanceof GuestCategory) { + // Preserve user edits to name/acronym/active/sortOrder; only refresh + // the structural defaults that drive system behaviour. + $existing->setStatisticalGroup($statisticalGroup); + $existing->setIsCountedInOccupancy($isCountedInOccupancy); + $existing->setMinAge($minAge); + $existing->setMaxAge($maxAge); + + return; + } + + $category = new GuestCategory(); + $category->setSystemCode($systemCode); + $category->setName($name); + $category->setAcronym($acronym); + $category->setStatisticalGroup($statisticalGroup); + $category->setIsCountedInOccupancy($isCountedInOccupancy); + $category->setMinAge($minAge); + $category->setMaxAge($maxAge); + $category->setSortOrder($sortOrder); + $category->setActive(true); + + $this->em->persist($category); + } +} diff --git a/src/Service/ReservationService.php b/src/Service/ReservationService.php index e21ca9df..888b3d03 100644 --- a/src/Service/ReservationService.php +++ b/src/Service/ReservationService.php @@ -21,6 +21,7 @@ use App\Entity\ReservationStatus; use App\Entity\Template; use App\Event\ReservationStatusChangedEvent; +use App\Repository\GuestCategoryRepository; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; use Doctrine\ORM\EntityManagerInterface; @@ -37,9 +38,84 @@ public function __construct( private readonly RequestStack $requestStack, private readonly InvoiceService $is, private readonly EventDispatcherInterface $eventDispatcher, + private readonly GuestCategoryRepository $guestCategoryRepository, ) { } + /** + * Apply a guest-counts map to the reservation and recompute persons + * as the sum of categories flagged isCountedInOccupancy. + * + * @param array $counts + */ + public function applyGuestCounts(Reservation $reservation, array $counts): void + { + $reservation->setGuestCounts($counts); + $reservation->setPersons($this->computePersonsFromCounts($counts)); + } + + /** + * Sum of counts for categories with isCountedInOccupancy=true. + * + * @param array $counts + */ + public function computePersonsFromCounts(array $counts): int + { + if ([] === $counts) { + return 0; + } + $categories = $this->guestCategoryRepository->findBy(['id' => array_keys($counts)]); + $sum = 0; + foreach ($categories as $category) { + if ($category->isCountedInOccupancy()) { + $sum += (int) ($counts[(int) $category->getId()] ?? 0); + } + } + + return $sum; + } + + /** + * Returns the total number of guests in the reservation that match + * the given boolean flag accessor on the GuestCategory entity. + * + * @param 'isAdult'|'isCountedInOccupancy' $flag + */ + public function getCountByFlag(Reservation $reservation, string $flag): int + { + $counts = $reservation->getGuestCounts(); + if ([] === $counts) { + return 0; + } + $categories = $this->guestCategoryRepository->findBy(['id' => array_keys($counts)]); + $sum = 0; + foreach ($categories as $category) { + $matches = match ($flag) { + 'isAdult' => $category->isAdult(), + 'isCountedInOccupancy' => $category->isCountedInOccupancy(), + default => false, + }; + if ($matches) { + $sum += (int) ($counts[(int) $category->getId()] ?? 0); + } + } + + return $sum; + } + + /** + * Validates the "at least one adult" rule. Returns true if the booking + * is allowed (has at least one adult OR adultRuleOverride is set). + */ + public function isAdultRuleSatisfied(Reservation $reservation): bool + { + if ($reservation->isAdultRuleOverride()) { + return true; + } + + return $this->getCountByFlag($reservation, 'isAdult') >= 1; + } + public function changeStatus(Reservation $reservation, ?ReservationStatus $newStatus, bool $flush = true): void { $previous = $reservation->getReservationStatus(); diff --git a/templates/GuestCategory/_form.html.twig b/templates/GuestCategory/_form.html.twig new file mode 100644 index 00000000..178d5bbb --- /dev/null +++ b/templates/GuestCategory/_form.html.twig @@ -0,0 +1,3 @@ +
+ {{ form_widget(form) }} +
diff --git a/templates/GuestCategory/_guest_counts_table.html.twig b/templates/GuestCategory/_guest_counts_table.html.twig new file mode 100644 index 00000000..30706961 --- /dev/null +++ b/templates/GuestCategory/_guest_counts_table.html.twig @@ -0,0 +1,88 @@ +{# + Reusable partial: per-category guest count inputs with live persons total. + + Required variables: + categories iterable of GuestCategory entities (active, ordered) + counts map {categoryId: count} (Reservation.guestCounts) + personsTotal current persons sum (int) — used for initial display only + adultRuleOverride bool — current Reservation.adultRuleOverride + fieldNames map with keys: 'counts', 'persons', 'override' + defines the form field names posted to the backend +#} +{% set fieldNames = fieldNames|default({ + counts: 'guestCounts', + persons: 'persons', + override: 'adultRuleOverride', +}) %} + +
+ + + + + + + + + {% for category in categories %} + + + + + {% endfor %} + + + + + + + +
{{ 'guest_category.col.name'|trans }}{{ 'guest_category.col.count'|trans }}
+ {{ category.name }} + ({{ category.acronym }}) + {% if not category.isCountedInOccupancy %} + + {{ 'guest_category.flag.no_occupancy'|trans }} + + {% endif %} + + +
{{ 'guest_category.persons_total'|trans }} + {{ personsTotal|default(0) }} +
+ +
+ {{ 'guest_category.warning.no_adult'|trans }} +
+ +
+ + +
+ + + +
diff --git a/templates/GuestCategory/edit.html.twig b/templates/GuestCategory/edit.html.twig new file mode 100644 index 00000000..f848c5af --- /dev/null +++ b/templates/GuestCategory/edit.html.twig @@ -0,0 +1,35 @@ +{{ form_start(form, {'attr': { + 'id':'entry-form-'~category.id, + 'data-controller':'settings', + 'data-action':'submit->settings#submitFormAction', + 'data-url': path('guest_category_edit', {'id': category.id}) +} }) }} + + +{{ form_end(form) }} diff --git a/templates/GuestCategory/index.html.twig b/templates/GuestCategory/index.html.twig new file mode 100644 index 00000000..29be9111 --- /dev/null +++ b/templates/GuestCategory/index.html.twig @@ -0,0 +1,83 @@ +{% extends 'base.html.twig' %} + +{% block title %} + {{ parent() }} - {{ 'guest_category.title'|trans }} +{% endblock %} + +{% block description %} + {{ parent() }} - {{ 'guest_category.description'|trans }} +{% endblock %} + +{% block content %} +
+
+
+ +
+
+
+
+ + + + + + + + + + + + + {% for category in guest_categories %} + + + + + + + + + {% endfor %} + +
{{ 'guest_category.col.name'|trans }}{{ 'guest_category.col.acronym'|trans }}{{ 'guest_category.col.statistical_group'|trans }}{{ 'guest_category.col.age'|trans }}{{ 'guest_category.col.active'|trans }}{{ 'guest_category.col.action'|trans }}
{{ category.name }}{{ category.acronym }}{{ ('guest_category.statistical_group.' ~ category.statisticalGroup.value)|trans }} + {{ category.minAge is null ? '–' : category.minAge }} + – + {{ category.maxAge is null ? '∞' : category.maxAge }} + + {% if category.active %} + + {% else %} + + {% endif %} + + + + + + {% set id = category.id %} + {% set targetUrl = path('guest_category_delete', {'id': category.id}) %} + {% use "common/delete_popover.html.twig" %} + + +
+
+
+
+{% endblock %} diff --git a/templates/GuestCategory/new.html.twig b/templates/GuestCategory/new.html.twig new file mode 100644 index 00000000..8e3519e0 --- /dev/null +++ b/templates/GuestCategory/new.html.twig @@ -0,0 +1,20 @@ +{{ form_start(form, {'attr': { + 'id':'entry-form-new', + 'data-controller':'settings', + 'data-action':'submit->settings#submitFormAction', + 'data-url': path('guest_category_new'), + 'data-success-url': path('guest_category_index') +} }) }} + + +{{ form_end(form) }} diff --git a/templates/base.html.twig b/templates/base.html.twig index b863a8d6..615b5a19 100644 --- a/templates/base.html.twig +++ b/templates/base.html.twig @@ -103,6 +103,7 @@ {{ 'nav.prices'|trans }} {{ 'nav.reservationorigin'|trans }} {{ 'nav.status'|trans }} + {{ 'nav.guest_category'|trans }} {{ 'nav.templates'|trans }} {{ 'online_booking.nav.label'|trans }} {{ 'workflow.nav.label'|trans }} diff --git a/translations/Base/messages.de.xlf b/translations/Base/messages.de.xlf index def65e92..b17d6cbc 100644 --- a/translations/Base/messages.de.xlf +++ b/translations/Base/messages.de.xlf @@ -378,6 +378,10 @@ nav.status Reservierungsstatus + + nav.guest_category + Gastkategorien + copy.text Kopieren diff --git a/translations/Base/messages.en.yaml b/translations/Base/messages.en.yaml index be1f6eea..7c1126d1 100644 --- a/translations/Base/messages.en.yaml +++ b/translations/Base/messages.en.yaml @@ -94,6 +94,7 @@ nav.reservationorigin: Reservation Origin nav.settings: Settings nav.statistics: Statistics nav.status: Reservation Status +nav.guest_category: Guest Categories nav.templates: Templates nav.turnover: Turnovers nav.tourism: Tourism diff --git a/translations/GuestCategory/messages.de.yaml b/translations/GuestCategory/messages.de.yaml new file mode 100644 index 00000000..f34b0d10 --- /dev/null +++ b/translations/GuestCategory/messages.de.yaml @@ -0,0 +1,73 @@ +guest_category: + title: "Gastkategorien" + description: "Verwaltung der Gastkategorien (z. B. Erwachsene, Kinder, nichtpflichtige Personen)" + add: "Gastkategorie hinzufügen" + delete: + ask: "Diese Gastkategorie wirklich löschen?" + system: + locked: "Systemkategorien können nicht gelöscht werden." + persons_total: "Personen gesamt (Belegung)" + + flag: + adult: "Erwachsen" + occupancy: "belegt Zimmerbett" + no_occupancy: "belegt kein Zimmerbett" + + warning: + no_adult: "Diese Buchung enthält keinen Erwachsenen. Aktiviere die Freigabe weiter unten, um trotzdem zu speichern." + + field: + name: "Bezeichnung" + acronym: "Kürzel" + acronym.help: "Kurze Abkürzung, z. B. ERW, K6-16, BABY." + statistical_group: "Gruppe" + statistical_group.help: "Legt fest, wie diese Kategorie ausgewertet wird. Kategorien mit Gruppe \"Erwachsen\" erfüllen die Prüfung, dass eine Buchung normalerweise mindestens einen Erwachsenen enthalten muss." + is_counted_in_occupancy: "Zählt als belegtes Zimmerbett" + is_counted_in_occupancy.help: "Aktivieren, wenn Gäste dieser Kategorie ein Bett des Zimmers belegen. Diese Anzahl wird von den freien Betten abgezogen. Deaktivieren z. B. für Babys im Babybett." + min_age: "Min. Alter" + max_age: "Max. Alter" + sort_order: "Sortierreihenfolge" + active: "Aktiv" + subsidiaries: "Gültig für Niederlassung" + subsidiaries.help: "Leer lassen = global verfügbar." + adult_rule_override: "Buchung ohne Erwachsenen-Begleitung freigeben" + + statistical_group: + adult: "Erwachsen" + child: "Kind" + infant: "Kleinkind / Baby" + other: "Sonstige" + + col: + name: "Bezeichnung" + acronym: "Kürzel" + statistical_group: "Gruppe" + flags: "Eigenschaften" + age: "Alter" + active: "Aktiv" + action: "Aktion" + count: "Anzahl" + + flash: + create: + success: "Gastkategorie wurde erstellt." + edit: + success: "Gastkategorie wurde gespeichert." + delete: + success: "Gastkategorie wurde gelöscht." + error: + system: "Systemkategorien können nicht gelöscht werden." + + default: + adult: + name: "Erwachsene" + acronym: "ERW" + child: + name: "Kind 6–17" + acronym: "K6-17" + infant: + name: "Kleinkind 0–5" + acronym: "BABY" + exempt: + name: "Nichtpflichtige Personen" + acronym: "NP" diff --git a/translations/GuestCategory/messages.en.yaml b/translations/GuestCategory/messages.en.yaml new file mode 100644 index 00000000..0a22f943 --- /dev/null +++ b/translations/GuestCategory/messages.en.yaml @@ -0,0 +1,73 @@ +guest_category: + title: "Guest categories" + description: "Manage guest categories (e.g. adults, children, non-liable guests)" + add: "Add guest category" + delete: + ask: "Are you sure you want to delete this guest category?" + system: + locked: "System categories cannot be deleted." + persons_total: "Persons total (occupancy)" + + flag: + adult: "Adult" + occupancy: "occupies room bed" + no_occupancy: "does not occupy a room bed" + + warning: + no_adult: "This booking contains no adult guest. Enable the override below to save anyway." + + field: + name: "Name" + acronym: "Acronym" + acronym.help: "Short label, e.g. ADT, C6-16, INF." + statistical_group: "Group" + statistical_group.help: "Defines how this category is evaluated. Categories in the \"Adult\" group satisfy the check that a booking normally needs at least one adult guest." + is_counted_in_occupancy: "Counts as an occupied room bed" + is_counted_in_occupancy.help: "Enable this when guests in this category occupy a bed in the room. This count is subtracted from the available beds. Disable it, for example, for infants in a cot." + min_age: "Min age" + max_age: "Max age" + sort_order: "Sort order" + active: "Active" + subsidiaries: "Available in properties" + subsidiaries.help: "Leave empty = globally available." + adult_rule_override: "Allow this booking without an adult guest" + + statistical_group: + adult: "Adult" + child: "Child" + infant: "Infant" + other: "Other" + + col: + name: "Name" + acronym: "Acronym" + statistical_group: "Group" + flags: "Flags" + age: "Age" + active: "Active" + action: "Action" + count: "Count" + + flash: + create: + success: "Guest category created." + edit: + success: "Guest category saved." + delete: + success: "Guest category deleted." + error: + system: "System categories cannot be deleted." + + default: + adult: + name: "Adults" + acronym: "ADT" + child: + name: "Child 6–17" + acronym: "C6-17" + infant: + name: "Infant 0–5" + acronym: "INF" + exempt: + name: "Non-liable guests" + acronym: "NL" From 86822867231b876db1817870d87f8c7aa55cf56d Mon Sep 17 00:00:00 2001 From: Alexander Elchlepp Date: Fri, 8 May 2026 12:13:48 +0200 Subject: [PATCH 10/38] added tourist tax rates #204 --- assets/controllers/collection_controller.js | 30 +++ assets/controllers/guest_counts_controller.js | 154 +++++++++++ assets/controllers/reservations_controller.js | 16 +- assets/controllers/settings_controller.js | 4 +- assets/js/utils.js | 14 + migrations/Version20260505120000.php | 62 ++++- .../ReservationServiceController.php | 68 ++++- src/Dto/TouristTaxBreakdown.php | 34 +++ src/Entity/InvoicePosition.php | 22 ++ src/Entity/TouristTax.php | 242 ++++++++++++++++++ src/Entity/TouristTaxRate.php | 92 +++++++ src/Form/TouristTaxRateType.php | 53 ++++ src/Form/TouristTaxType.php | 108 ++++++++ src/Repository/TouristTaxRepository.php | 75 ++++++ src/Service/InvoiceService.php | 76 +++++- src/Service/ReservationObject.php | 47 ++++ src/Service/ReservationService.php | 49 +++- src/Service/TouristTaxService.php | 123 +++++++++ .../_guest_counts_table.html.twig | 126 ++++----- .../Invoices/invoice_form_show.html.twig | 47 +++- ...rm_show_create_invoice_positions.html.twig | 9 + .../Invoices/invoice_show_preview.html.twig | 5 +- .../invoice_table_misc_positions.html.twig | 28 +- ...ice_table_misc_preview_positions.html.twig | 7 +- ...oice_table_tourist_tax_positions.html.twig | 40 +++ ...le_tourist_tax_preview_positions.html.twig | 34 +++ .../reservation_form_edit.html.twig | 70 ++--- ..._edit_show_available_appartments.html.twig | 4 +- ...orm_select_period_and_appartment.html.twig | 6 +- ..._form_show_available_appartments.html.twig | 10 +- .../reservation_form_show_fields.html.twig | 40 ++- ..._appartment_options_input_fields.html.twig | 49 ++-- templates/TouristTax/_form.html.twig | 57 +++++ templates/TouristTax/_rate_row.html.twig | 15 ++ templates/TouristTax/edit.html.twig | 32 +++ templates/TouristTax/index.html.twig | 90 +++++++ templates/TouristTax/new.html.twig | 18 ++ templates/base.html.twig | 1 + templates/common/delete_popover.html.twig | 5 +- tests/Unit/InvoiceServiceTouristTaxTest.php | 171 +++++++++++++ tests/Unit/ReservationGuestCountsTest.php | 115 +++++++++ ...vationServiceMiscDefaultActivationTest.php | 1 + tests/Unit/TouristTaxServiceTest.php | 238 +++++++++++++++++ translations/Base/messages.de.xlf | 4 + translations/Base/messages.en.yaml | 1 + translations/Invoices/messages.de.xlf | 20 ++ translations/Invoices/messages.en.yaml | 5 + translations/Reservations/messages.de.xlf | 8 + translations/Reservations/messages.en.yaml | 2 + translations/TouristTax/messages.de.yaml | 66 +++++ translations/TouristTax/messages.en.yaml | 61 +++++ 51 files changed, 2484 insertions(+), 170 deletions(-) create mode 100644 assets/controllers/collection_controller.js create mode 100644 assets/controllers/guest_counts_controller.js create mode 100644 src/Dto/TouristTaxBreakdown.php create mode 100644 src/Entity/TouristTax.php create mode 100644 src/Entity/TouristTaxRate.php create mode 100644 src/Form/TouristTaxRateType.php create mode 100644 src/Form/TouristTaxType.php create mode 100644 src/Repository/TouristTaxRepository.php create mode 100644 src/Service/TouristTaxService.php create mode 100644 templates/Invoices/invoice_table_tourist_tax_positions.html.twig create mode 100644 templates/Invoices/invoice_table_tourist_tax_preview_positions.html.twig create mode 100644 templates/TouristTax/_form.html.twig create mode 100644 templates/TouristTax/_rate_row.html.twig create mode 100644 templates/TouristTax/edit.html.twig create mode 100644 templates/TouristTax/index.html.twig create mode 100644 templates/TouristTax/new.html.twig create mode 100644 tests/Unit/InvoiceServiceTouristTaxTest.php create mode 100644 tests/Unit/ReservationGuestCountsTest.php create mode 100644 tests/Unit/TouristTaxServiceTest.php create mode 100644 translations/TouristTax/messages.de.yaml create mode 100644 translations/TouristTax/messages.en.yaml diff --git a/assets/controllers/collection_controller.js b/assets/controllers/collection_controller.js new file mode 100644 index 00000000..afd30a11 --- /dev/null +++ b/assets/controllers/collection_controller.js @@ -0,0 +1,30 @@ +import { Controller } from '@hotwired/stimulus'; + +/* stimulusFetch: 'lazy' */ + +/** + * Generic Symfony-form CollectionType helper. + * Adds new entries by cloning a prototype HTML fragment and removes entries + * on click of [data-action="collection#remove"]. The prototype is expected + * to already contain its own root element (e.g. a .row wrapper). + */ +export default class extends Controller { + static targets = ['entries']; + static values = { prototype: String, index: Number }; + + add(event) { + event.preventDefault(); + const html = this.prototypeValue.replace(/__name__/g, this.indexValue); + const tpl = document.createElement('template'); + tpl.innerHTML = html.trim(); + const node = tpl.content.firstElementChild; + if (node) this.entriesTarget.appendChild(node); + this.indexValue += 1; + } + + remove(event) { + event.preventDefault(); + const row = event.currentTarget.closest('.collection-row'); + if (row) row.remove(); + } +} diff --git a/assets/controllers/guest_counts_controller.js b/assets/controllers/guest_counts_controller.js new file mode 100644 index 00000000..446319a1 --- /dev/null +++ b/assets/controllers/guest_counts_controller.js @@ -0,0 +1,154 @@ +import { Controller } from '@hotwired/stimulus'; + +/* stimulusFetch: 'lazy' */ + +/** + * Maintains the per-category guest count steppers in a reservation form, + * serializes them into a single JSON field (`guestCounts`) and shows a + * live total (sum of categories flagged isCountedInOccupancy). + * + * Targets: + * - input: number inputs, each carrying data-category-id, + * data-counted-in-occupancy ("1" or "0") and data-is-adult + * - personsDisplay: element whose textContent reflects the live total + * - personsInput: hidden field receiving the total (legacy `persons`) + * - hiddenJson: hidden input receiving the JSON-encoded counts map + * - adultWarning: warning shown when no adult guest is selected + * - overrideToggle: checkbox that suppresses the adult warning + * - overrideContainer: wrapper around warning + toggle (hidden when adult >= 1) + * - overWarning: transient warning shown when occupancy max is hit + * + * Values: + * - max: hard cap for the sum of categories flagged isCountedInOccupancy + * (typically the apartment's bedsMax). 0 / unset = no cap. + */ +export default class extends Controller { + static targets = [ + 'input', 'personsDisplay', 'personsInput', 'hiddenJson', + 'adultWarning', 'overrideToggle', 'overrideContainer', 'overWarning', + ]; + + static values = { max: Number }; + + connect() { + this.recompute(); + } + + inputAction(event) { + // Direct keyboard input: clamp the just-changed input so the occupancy + // sum cannot exceed maxValue. + const input = event ? event.target : null; + if (input && input.dataset.countedInOccupancy === '1' && this._hasMax()) { + const others = this._occupancySumExcluding(input); + const allowed = Math.max(0, this.maxValue - others); + const value = Math.max(0, parseInt(input.value, 10) || 0); + if (value > allowed) { + input.value = allowed; + this._flashOverCapacity(); + } + } + this.recompute(); + } + + increment(event) { + const catId = event.currentTarget.dataset.categoryId; + const input = this.inputTargets.find((i) => i.dataset.categoryId === catId); + if (!input) { + return; + } + if (input.dataset.countedInOccupancy === '1' && this._hasMax()) { + if (this._occupancySum() + 1 > this.maxValue) { + this._flashOverCapacity(); + return; + } + } + input.value = (parseInt(input.value, 10) || 0) + 1; + this.recompute(); + } + + decrement(event) { + const catId = event.currentTarget.dataset.categoryId; + const input = this.inputTargets.find((i) => i.dataset.categoryId === catId); + if (!input) { + return; + } + input.value = Math.max(0, (parseInt(input.value, 10) || 0) - 1); + this.recompute(); + } + + overrideAction() { + this.recompute(); + } + + recompute() { + const counts = {}; + let occupancySum = 0; + let adultSum = 0; + + this.inputTargets.forEach((input) => { + const value = Math.max(0, parseInt(input.value, 10) || 0); + input.value = value; + const catId = input.dataset.categoryId; + if (value > 0 && catId) { + counts[catId] = value; + } + if (input.dataset.countedInOccupancy === '1') { + occupancySum += value; + } + if (input.dataset.isAdult === '1') { + adultSum += value; + } + }); + + if (this.hasHiddenJsonTarget) { + this.hiddenJsonTarget.value = JSON.stringify(counts); + } + if (this.hasPersonsInputTarget) { + this.personsInputTarget.value = occupancySum; + } + if (this.hasPersonsDisplayTarget) { + this.personsDisplayTarget.textContent = occupancySum; + } + + const overridden = this.hasOverrideToggleTarget && this.overrideToggleTarget.checked; + if (this.hasAdultWarningTarget) { + const showWarning = adultSum < 1 && !overridden; + this.adultWarningTarget.classList.toggle('d-none', !showWarning); + } + + if (this.hasOverrideContainerTarget) { + const showContainer = adultSum < 1; + this.overrideContainerTarget.classList.toggle('d-none', !showContainer); + if (!showContainer && this.hasOverrideToggleTarget && this.overrideToggleTarget.checked) { + this.overrideToggleTarget.checked = false; + } + } + } + + _hasMax() { + return this.hasMaxValue && this.maxValue > 0; + } + + _occupancySum() { + return this.inputTargets + .filter((i) => i.dataset.countedInOccupancy === '1') + .reduce((sum, i) => sum + (parseInt(i.value, 10) || 0), 0); + } + + _occupancySumExcluding(skipInput) { + return this.inputTargets + .filter((i) => i !== skipInput && i.dataset.countedInOccupancy === '1') + .reduce((sum, i) => sum + (parseInt(i.value, 10) || 0), 0); + } + + _flashOverCapacity() { + if (!this.hasOverWarningTarget) { + return; + } + this.overWarningTarget.classList.remove('d-none'); + clearTimeout(this._overTimer); + this._overTimer = setTimeout(() => { + this.overWarningTarget.classList.add('d-none'); + }, 2500); + } +} diff --git a/assets/controllers/reservations_controller.js b/assets/controllers/reservations_controller.js index 4a94a857..7ce75b20 100644 --- a/assets/controllers/reservations_controller.js +++ b/assets/controllers/reservations_controller.js @@ -1851,7 +1851,19 @@ export default class extends Controller { editUpdateReservation(appartmentId) { const reservationId = $('#reservation-id').val() || appartmentId; - const options = 'status=' + $('#appartment-' + appartmentId).find('#status :selected').val() + '&persons=' + $('#appartment-' + appartmentId).find('#persons :selected').val(); + const optsContainer = $('#appartment-options-' + appartmentId); + const status = optsContainer.find('select[name="status"]').val() ?? ''; + const guestCounts = optsContainer.find('input[name="guestCounts"]').val() ?? '{}'; + const persons = optsContainer.find('input[name="persons"]').val() ?? '0'; + const overrideEl = optsContainer.find('input[name="adultRuleOverride"]')[0]; + const params = new URLSearchParams(); + params.append('status', status); + params.append('guestCounts', guestCounts); + params.append('persons', persons); + if (overrideEl && overrideEl.checked) { + params.append('adultRuleOverride', '1'); + } + const options = params.toString(); const targetForm = document.getElementById('reservation-period'); const url = targetForm ? targetForm.dataset.target : null; if (!url) { @@ -1946,7 +1958,7 @@ export default class extends Controller { method: 'POST', loader: false, data: httpSerializeForm(form), - onSuccess: + onSuccess: () => this.getReservation(reservationId == 'new' ? reservationId : window.lastClickedReservationUrl, 'prices', false) }); return false; diff --git a/assets/controllers/settings_controller.js b/assets/controllers/settings_controller.js index 5ebe2572..ddf5ffa6 100644 --- a/assets/controllers/settings_controller.js +++ b/assets/controllers/settings_controller.js @@ -1,6 +1,6 @@ import { Controller } from '@hotwired/stimulus'; import { request as httpRequest, serializeForm as httpSerializeForm } from '../js/http.js'; -import { setModalTitle, enableDeletePopover } from '../js/utils.js'; +import { setModalTitle, enableDeletePopover, enableTooltips, enablePopovers } from '../js/utils.js'; export default class extends Controller { connect() { @@ -31,6 +31,8 @@ export default class extends Controller { if (enableEdit) { this.enableEditFromModal(); } + enableTooltips(this.modalContent); + enablePopovers(this.modalContent); }, }); } diff --git a/assets/js/utils.js b/assets/js/utils.js index a4ae41ce..1199e88f 100644 --- a/assets/js/utils.js +++ b/assets/js/utils.js @@ -124,6 +124,20 @@ export function disposeTooltips(root = document) { }); } +/** + * Initialize Bootstrap popovers on all generic `[data-bs-toggle="popover"]` + * elements within the given root. Skips delete-confirmation popovers (those + * are handled by enableDeletePopover with their own click semantics). + */ +export async function enablePopovers(root = document) { + const ready = await whenBootstrapAndIconsReady(); + if (!ready) return []; + const rootEl = root?.querySelectorAll ? root : document; + return [...rootEl.querySelectorAll('[data-bs-toggle="popover"]')] + .filter((el) => el.dataset.popover !== 'delete') + .map((el) => window.bootstrap.Popover.getOrCreateInstance(el)); +} + /** * Creates a confirmation popover (yes/no) when clicking on an element which has the * data-popover="delete" attribute assigned. Internally waits for Bootstrap and Font diff --git a/migrations/Version20260505120000.php b/migrations/Version20260505120000.php index 10696387..893b6d51 100644 --- a/migrations/Version20260505120000.php +++ b/migrations/Version20260505120000.php @@ -8,7 +8,7 @@ use Doctrine\Migrations\AbstractMigration; /** - * Phase 1 GuestCategory: introduce GuestCategory entity (with M:N to subsidiaries), + * GuestCategory: introduce GuestCategory entity (with M:N to subsidiaries), * add guest_counts/kurtaxe_waived/adult_rule_override columns to reservations, * seed default guest categories and backfill existing reservations into the * default-adult bucket. Adult status is derived from statistical_group. @@ -17,7 +17,7 @@ final class Version20260505120000 extends AbstractMigration { public function getDescription(): string { - return 'Phase 1 guest categories: new tables guest_categories + guest_categories_has_subsidiaries; reservation gets guest_counts/kurtaxe_waived/adult_rule_override; seeds default categories and backfills existing reservations. Adult status is derived from statistical_group.'; + return 'guest categories: new tables guest_categories + guest_categories_has_subsidiaries; reservation gets guest_counts/kurtaxe_waived/adult_rule_override; seeds default categories and backfills existing reservations. Adult status is derived from statistical_group.'; } public function up(Schema $schema): void @@ -72,6 +72,52 @@ public function up(Schema $schema): void $this->addSql("UPDATE reservations SET guest_counts = JSON_OBJECT() WHERE guest_counts IS NULL OR JSON_LENGTH(guest_counts) IS NULL"); + + // tourist tax: tourist_taxes, tourist_taxes_has_subsidiaries, tourist_tax_rates. + $this->addSql('CREATE TABLE tourist_taxes ( + id INT AUTO_INCREMENT NOT NULL, + tax_rate_id INT DEFAULT NULL, + revenue_account_id INT DEFAULT NULL, + name VARCHAR(100) NOT NULL, + valid_from DATE DEFAULT NULL, + valid_to DATE DEFAULT NULL, + includes_vat TINYINT(1) NOT NULL DEFAULT 1, + active TINYINT(1) NOT NULL DEFAULT 1, + applies_only_to_adult TINYINT(1) NOT NULL DEFAULT 0, + sort_order INT NOT NULL DEFAULT 0, + INDEX IDX_tt_taxrate (tax_rate_id), + INDEX IDX_tt_revenue (revenue_account_id), + CONSTRAINT FK_tt_taxrate FOREIGN KEY (tax_rate_id) REFERENCES tax_rates (id) ON DELETE SET NULL, + CONSTRAINT FK_tt_revenue FOREIGN KEY (revenue_account_id) REFERENCES accounting_accounts (id) ON DELETE SET NULL, + PRIMARY KEY (id) + ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci ENGINE = InnoDB'); + + $this->addSql('CREATE TABLE tourist_taxes_has_subsidiaries ( + tourist_tax_id INT NOT NULL, + subsidiary_id INT NOT NULL, + INDEX IDX_tths_tax (tourist_tax_id), + INDEX IDX_tths_subsidiary (subsidiary_id), + PRIMARY KEY (tourist_tax_id, subsidiary_id), + CONSTRAINT FK_tths_tax FOREIGN KEY (tourist_tax_id) REFERENCES tourist_taxes (id) ON DELETE CASCADE, + CONSTRAINT FK_tths_subsidiary FOREIGN KEY (subsidiary_id) REFERENCES objects (id) ON DELETE CASCADE + ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci ENGINE = InnoDB'); + + $this->addSql('CREATE TABLE tourist_tax_rates ( + id INT AUTO_INCREMENT NOT NULL, + tourist_tax_id INT NOT NULL, + guest_category_id INT NOT NULL, + price_per_night NUMERIC(10, 2) NOT NULL, + report_group VARCHAR(50) DEFAULT NULL, + INDEX IDX_ttr_tax (tourist_tax_id), + INDEX IDX_ttr_category (guest_category_id), + UNIQUE INDEX UNIQ_ttr_tax_category (tourist_tax_id, guest_category_id), + CONSTRAINT FK_ttr_tax FOREIGN KEY (tourist_tax_id) REFERENCES tourist_taxes (id) ON DELETE CASCADE, + CONSTRAINT FK_ttr_category FOREIGN KEY (guest_category_id) REFERENCES guest_categories (id) ON DELETE CASCADE, + PRIMARY KEY (id) + ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci ENGINE = InnoDB'); + + // invoice integration: invoice_positions.position_group marker + $this->addSql('ALTER TABLE invoice_positions ADD position_group VARCHAR(32) DEFAULT NULL'); } public function down(Schema $schema): void @@ -84,6 +130,18 @@ public function down(Schema $schema): void $this->addSql('ALTER TABLE guest_categories_has_subsidiaries DROP FOREIGN KEY FK_gchsub_subsidiary'); $this->addSql('DROP TABLE guest_categories_has_subsidiaries'); $this->addSql('DROP TABLE guest_categories'); + + $this->addSql('ALTER TABLE tourist_tax_rates DROP FOREIGN KEY FK_ttr_tax'); + $this->addSql('ALTER TABLE tourist_tax_rates DROP FOREIGN KEY FK_ttr_category'); + $this->addSql('DROP TABLE tourist_tax_rates'); + $this->addSql('ALTER TABLE tourist_taxes_has_subsidiaries DROP FOREIGN KEY FK_tths_tax'); + $this->addSql('ALTER TABLE tourist_taxes_has_subsidiaries DROP FOREIGN KEY FK_tths_subsidiary'); + $this->addSql('DROP TABLE tourist_taxes_has_subsidiaries'); + $this->addSql('ALTER TABLE tourist_taxes DROP FOREIGN KEY FK_tt_taxrate'); + $this->addSql('ALTER TABLE tourist_taxes DROP FOREIGN KEY FK_tt_revenue'); + $this->addSql('DROP TABLE tourist_taxes'); + + $this->addSql('ALTER TABLE invoice_positions DROP COLUMN position_group'); } public function isTransactional(): bool diff --git a/src/Controller/ReservationServiceController.php b/src/Controller/ReservationServiceController.php index 7055d544..1bb514d3 100644 --- a/src/Controller/ReservationServiceController.php +++ b/src/Controller/ReservationServiceController.php @@ -17,6 +17,7 @@ use App\Entity\Correspondence; use App\Entity\Customer; use App\Entity\Enum\IDCardType; +use App\Entity\GuestCategory; use App\Entity\Price; use App\Entity\Reservation; use App\Entity\ReservationOrigin; @@ -33,6 +34,7 @@ use App\Service\ReservationObject; use App\Service\ReservationService; use App\Service\TemplatesService; +use App\Service\TouristTaxService; use App\Service\ReservationTableService; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Persistence\ManagerRegistry; @@ -300,6 +302,7 @@ public function showSelectAppartmentsFormAction(ManagerRegistry $doctrine, Reque } $reservations = $rs->createReservationsFromReservationInformationArray($newReservationsInformationArray); + $guestCategories = $em->getRepository(GuestCategory::class)->findActiveOrdered(); return $this->render('Reservations/reservation_form_select_period_and_appartment.html.twig', [ 'objects' => $objects, @@ -307,6 +310,7 @@ public function showSelectAppartmentsFormAction(ManagerRegistry $doctrine, Reque 'objectHasAppartments' => $objectHasAppartments, 'reservations' => $reservations, 'reservationStatus' => $reservationStatus, + 'guestCategories' => $guestCategories, ]); } @@ -331,10 +335,12 @@ public function getAvailableAppartmentsAction(ManagerRegistry $doctrine, Reserva $apartments = $rs->getAvailableApartments($startDate, $endDate, null, $request->request->get('object')); $reservationStatus = $em->getRepository(ReservationStatus::class)->findAll(); + $guestCategories = $em->getRepository(GuestCategory::class)->findActiveOrdered(); return $this->render('Reservations/reservation_form_show_available_appartments.html.twig', [ 'appartments' => $apartments, 'reservationStatus' => $reservationStatus, + 'guestCategories' => $guestCategories, ]); } @@ -359,10 +365,12 @@ public function getEditAvailableAppartmentsAction(ManagerRegistry $doctrine, Req $apartments = $rs->getAvailableApartments($startDate, $endDate, null, $request->request->get('object')); $reservationStatus = $em->getRepository(ReservationStatus::class)->findAll(); + $guestCategories = $em->getRepository(GuestCategory::class)->findActiveOrdered(); return $this->render('Reservations/reservation_form_edit_show_available_appartments.html.twig', [ 'appartments' => $apartments, 'reservationStatus' => $reservationStatus, + 'guestCategories' => $guestCategories, ]); } @@ -380,13 +388,19 @@ public function addAppartmentToReservationAction(HttpKernelInterface $kernel, Re if ($from > $end) { [$from, $end] = [$end, $from]; } - $newReservationsInformationArray[] = new ReservationObject( + $reservationObject = new ReservationObject( $request->request->get('appartmentid'), $from, $end, $request->request->get('status'), - $request->request->get('persons') + (int) $request->request->get('persons', 0) ); + $guestCountsRaw = $request->request->get('guestCounts', '{}'); + $guestCounts = is_string($guestCountsRaw) ? (json_decode($guestCountsRaw, true) ?: []) : []; + $reservationObject->setGuestCounts($guestCounts); + $reservationObject->setAdultRuleOverride((bool) $request->request->get('adultRuleOverride', false)); + $reservationObject->setKurtaxeWaived((bool) $request->request->get('kurtaxeWaived', false)); + $newReservationsInformationArray[] = $reservationObject; $requestStack->getSession()->set('reservationInCreation', $newReservationsInformationArray); } @@ -504,7 +518,12 @@ public function modifyAppartmentOptionsAction(HttpKernelInterface $kernel, Reque $newReservationsInformationArray = $requestStack->getSession()->get('reservationInCreation'); $newReservationInformation = $newReservationsInformationArray[$request->request->get('appartmentid')]; - $newReservationInformation->setPersons($request->request->get('persons')); + $guestCountsRaw = $request->request->get('guestCounts', '{}'); + $guestCounts = is_string($guestCountsRaw) ? (json_decode($guestCountsRaw, true) ?: []) : []; + $newReservationInformation->setGuestCounts($guestCounts); + $newReservationInformation->setPersons((int) $request->request->get('persons', 0)); + $newReservationInformation->setAdultRuleOverride((bool) $request->request->get('adultRuleOverride', false)); + $newReservationInformation->setKurtaxeWaived((bool) $request->request->get('kurtaxeWaived', false)); $newReservationInformation->setReservationStatus($request->request->get('status')); $requestStack->getSession()->set('reservationInCreation', $newReservationsInformationArray); @@ -615,7 +634,7 @@ public function createNewCustomerAction(ManagerRegistry $doctrine, HttpKernelInt * Creates a preview of the new reservation, where the user can add additional guests to the reservation. */ #[Route('/reservation/new/preview', name: 'reservations.create.preview', methods: ['POST'])] - public function previewNewReservationAction(ManagerRegistry $doctrine, CSRFProtectionService $csrf, RequestStack $requestStack, InvoiceService $is, ReservationService $rs, PriceService $ps, Request $request) + public function previewNewReservationAction(ManagerRegistry $doctrine, CSRFProtectionService $csrf, RequestStack $requestStack, InvoiceService $is, ReservationService $rs, PriceService $ps, TouristTaxService $touristTaxService, Request $request) { $em = $doctrine->getManager(); $tab = $request->request->get('tab', 'booker'); @@ -704,6 +723,9 @@ public function previewNewReservationAction(ManagerRegistry $doctrine, CSRFProte 'netto' => $netto, 'apartmentTotal' => $apartmentTotal, 'miscTotal' => $miscTotal, + 'hasActiveTouristTax' => count($reservations) > 0 + ? $touristTaxService->hasActiveTaxForSubsidiary($reservations[0]->getAppartment()?->getObject()) + : false, ]); } @@ -778,7 +800,7 @@ public function createNewReservationAction(ManagerRegistry $doctrine, CSRFProtec * Gets an already existing reservation and shows it. */ #[Route('/get/{id}', name: 'reservations.get.reservation', methods: ['GET'])] - public function getReservationAction(ManagerRegistry $doctrine, CSRFProtectionService $csrf, RequestStack $requestStack, InvoiceService $is, PriceService $ps, Request $request, Reservation $reservation): Response + public function getReservationAction(ManagerRegistry $doctrine, CSRFProtectionService $csrf, RequestStack $requestStack, InvoiceService $is, PriceService $ps, TouristTaxService $touristTaxService, Request $request, Reservation $reservation): Response { $tab = $request->query->get('tab', 'booker'); $em = $doctrine->getManager(); @@ -827,6 +849,7 @@ public function getReservationAction(ManagerRegistry $doctrine, CSRFProtectionSe 'netto' => $netto, 'apartmentTotal' => $apartmentTotal, 'miscTotal' => $miscTotal, + 'hasActiveTouristTax' => $touristTaxService->hasActiveTaxForSubsidiary($reservation->getAppartment()?->getObject()), ]); } @@ -848,6 +871,7 @@ public function editReservationAction(ManagerRegistry $doctrine, RequestStack $r $requestStack->getSession()->set('reservationInCreation', []); $origins = $em->getRepository(ReservationOrigin::class)->findAll(); + $guestCategories = $em->getRepository(GuestCategory::class)->findActiveOrdered(); return $this->render('Reservations/reservation_form_edit.html.twig', [ 'objects' => $objects, @@ -856,6 +880,7 @@ public function editReservationAction(ManagerRegistry $doctrine, RequestStack $r 'error' => $error, 'origins' => $origins, 'reservationStatus' => $reservationStatus, + 'guestCategories' => $guestCategories, ]); } @@ -1269,6 +1294,39 @@ public function previewTemplateAction(ManagerRegistry $doctrine, CSRFProtectionS ]); } + #[Route(path: '/{reservationId}/edit/kurtaxe-waived/toggle', name: 'reservations.update.kurtaxe.waived', methods: ['POST'])] + public function toggleKurtaxeWaivedForReservation($reservationId, ManagerRegistry $doctrine, RequestStack $requestStack, Request $request): Response + { + if (!$this->isCsrfTokenValid('reservation-update-kurtaxe-waived', $request->request->get('_token'))) { + return new Response('', Response::HTTP_FORBIDDEN); + } + + if ('new' === $reservationId) { + // In-creation flow: flip kurtaxeWaived on every ReservationObject in the session. + $array = $requestStack->getSession()->get('reservationInCreation', []); + foreach ($array as $info) { + if ($info instanceof ReservationObject) { + $info->setKurtaxeWaived(!$info->isKurtaxeWaived()); + } + } + $requestStack->getSession()->set('reservationInCreation', $array); + + return new Response('', Response::HTTP_OK); + } + + $em = $doctrine->getManager(); + /* @var $reservation Reservation */ + $reservation = $em->getRepository(Reservation::class)->find($reservationId); + if (null === $reservation) { + return new Response('', Response::HTTP_NOT_FOUND); + } + $reservation->setKurtaxeWaived(!$reservation->isKurtaxeWaived()); + $em->persist($reservation); + $em->flush(); + + return new Response('', Response::HTTP_OK); + } + #[Route(path: '/{reservationId}/edit/prices/{id}/update', name: 'reservations.update.misc.price', methods: ['POST'])] public function updateMiscPriceForReservation($reservationId, Price $price, ManagerRegistry $doctrine, ReservationService $rs, RequestStack $requestStack, Request $request): Response { diff --git a/src/Dto/TouristTaxBreakdown.php b/src/Dto/TouristTaxBreakdown.php new file mode 100644 index 00000000..28d589be --- /dev/null +++ b/src/Dto/TouristTaxBreakdown.php @@ -0,0 +1,34 @@ +pricePerNight * $this->nights * $this->count; + } +} diff --git a/src/Entity/InvoicePosition.php b/src/Entity/InvoicePosition.php index e2ae0121..8e48a6c4 100644 --- a/src/Entity/InvoicePosition.php +++ b/src/Entity/InvoicePosition.php @@ -39,6 +39,16 @@ class InvoicePosition #[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')] private ?AccountingAccount $revenueAccount = null; + /** + * Visual / semantic grouping marker on the invoice. Values: + * - null → legacy / unspecified + * - "apartment" → overnight stay positions + * - "tourist_tax"→ tourist-tax block (separate sub-table) + * - "misc" → ancillary services + */ + #[ORM\Column(name: 'position_group', type: 'string', length: 32, nullable: true)] + private ?string $positionGroup = null; + public function __construct() { $this->isFlatPrice = false; @@ -178,4 +188,16 @@ public function setRevenueAccount(?AccountingAccount $revenueAccount): self return $this; } + + public function getPositionGroup(): ?string + { + return $this->positionGroup; + } + + public function setPositionGroup(?string $positionGroup): self + { + $this->positionGroup = $positionGroup; + + return $this; + } } diff --git a/src/Entity/TouristTax.php b/src/Entity/TouristTax.php new file mode 100644 index 00000000..2ca2dc54 --- /dev/null +++ b/src/Entity/TouristTax.php @@ -0,0 +1,242 @@ + */ + #[ORM\OneToMany(mappedBy: 'touristTax', targetEntity: TouristTaxRate::class, cascade: ['persist', 'remove'], orphanRemoval: true)] + private Collection $rates; + + public function __construct() + { + $this->subsidiaries = new ArrayCollection(); + $this->rates = new ArrayCollection(); + } + + public function getId(): ?int + { + return $this->id; + } + + public function getName(): string + { + return $this->name; + } + + public function setName(string $name): self + { + $this->name = $name; + + return $this; + } + + /** @return Collection */ + public function getSubsidiaries(): Collection + { + return $this->subsidiaries; + } + + public function addSubsidiary(Subsidiary $s): self + { + if (!$this->subsidiaries->contains($s)) { + $this->subsidiaries[] = $s; + } + + return $this; + } + + public function removeSubsidiary(Subsidiary $s): self + { + $this->subsidiaries->removeElement($s); + + return $this; + } + + public function getValidFrom(): ?\DateTimeInterface + { + return $this->validFrom; + } + + public function setValidFrom(?\DateTimeInterface $d): self + { + $this->validFrom = $d; + + return $this; + } + + public function getValidTo(): ?\DateTimeInterface + { + return $this->validTo; + } + + public function setValidTo(?\DateTimeInterface $d): self + { + $this->validTo = $d; + + return $this; + } + + public function getTaxRate(): ?TaxRate + { + return $this->taxRate; + } + + public function setTaxRate(?TaxRate $tr): self + { + $this->taxRate = $tr; + + return $this; + } + + public function getRevenueAccount(): ?AccountingAccount + { + return $this->revenueAccount; + } + + public function setRevenueAccount(?AccountingAccount $a): self + { + $this->revenueAccount = $a; + + return $this; + } + + public function isIncludesVat(): bool + { + return $this->includesVat; + } + + public function setIncludesVat(bool $v): self + { + $this->includesVat = $v; + + return $this; + } + + public function isActive(): bool + { + return $this->active; + } + + public function setActive(bool $v): self + { + $this->active = $v; + + return $this; + } + + public function isAppliesOnlyToAdult(): bool + { + return $this->appliesOnlyToAdult; + } + + public function setAppliesOnlyToAdult(bool $v): self + { + $this->appliesOnlyToAdult = $v; + + return $this; + } + + public function getSortOrder(): int + { + return $this->sortOrder; + } + + public function setSortOrder(int $v): self + { + $this->sortOrder = $v; + + return $this; + } + + /** @return Collection */ + public function getRates(): Collection + { + return $this->rates; + } + + public function addRate(TouristTaxRate $r): self + { + if (!$this->rates->contains($r)) { + $this->rates[] = $r; + $r->setTouristTax($this); + } + + return $this; + } + + public function removeRate(TouristTaxRate $r): self + { + if ($this->rates->removeElement($r)) { + if ($r->getTouristTax() === $this) { + $r->setTouristTax(null); + } + } + + return $this; + } + + public function isValidOn(\DateTimeInterface $date): bool + { + if (!$this->active) { + return false; + } + if (null !== $this->validFrom && $date < $this->validFrom) { + return false; + } + if (null !== $this->validTo && $date > $this->validTo) { + return false; + } + + return true; + } +} diff --git a/src/Entity/TouristTaxRate.php b/src/Entity/TouristTaxRate.php new file mode 100644 index 00000000..203dbcf7 --- /dev/null +++ b/src/Entity/TouristTaxRate.php @@ -0,0 +1,92 @@ +id; + } + + public function getTouristTax(): ?TouristTax + { + return $this->touristTax; + } + + public function setTouristTax(?TouristTax $t): self + { + $this->touristTax = $t; + + return $this; + } + + public function getGuestCategory(): ?GuestCategory + { + return $this->guestCategory; + } + + public function setGuestCategory(?GuestCategory $c): self + { + $this->guestCategory = $c; + + return $this; + } + + public function getPricePerNight(): string + { + return $this->pricePerNight; + } + + public function getPricePerNightFloat(): float + { + return (float) $this->pricePerNight; + } + + public function setPricePerNight(string|float|int $v): self + { + $this->pricePerNight = is_string($v) ? $v : number_format((float) $v, 2, '.', ''); + + return $this; + } + + public function getReportGroup(): ?string + { + return $this->reportGroup; + } + + public function setReportGroup(?string $v): self + { + $this->reportGroup = $v; + + return $this; + } +} diff --git a/src/Form/TouristTaxRateType.php b/src/Form/TouristTaxRateType.php new file mode 100644 index 00000000..dee7ad2b --- /dev/null +++ b/src/Form/TouristTaxRateType.php @@ -0,0 +1,53 @@ +add('guestCategory', EntityType::class, [ + 'class' => GuestCategory::class, + 'choice_label' => 'name', + 'choices' => $this->guestCategoryRepository->findActiveOrdered(), + 'label' => false, + 'placeholder' => '-', + ]) + ->add('pricePerNight', NumberType::class, [ + 'label' => false, + 'scale' => 2, + 'html5' => true, + 'attr' => ['step' => '0.01'], + ]) + ->add('reportGroup', TextType::class, [ + 'label' => false, + 'required' => false, + ]) + ; + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'data_class' => TouristTaxRate::class, + ]); + } +} diff --git a/src/Form/TouristTaxType.php b/src/Form/TouristTaxType.php new file mode 100644 index 00000000..aebaf189 --- /dev/null +++ b/src/Form/TouristTaxType.php @@ -0,0 +1,108 @@ +add('name', TextType::class, [ + 'label' => 'tourist_tax.field.name', + 'empty_data' => '', + ]) + ->add('taxRate', EntityType::class, [ + 'class' => TaxRate::class, + 'required' => false, + 'placeholder' => '-', + 'choice_label' => 'label', + 'query_builder' => fn (TaxRateRepository $repo) => $repo->createValidAtQueryBuilder($referenceDate, $activePreset), + 'label' => 'tourist_tax.field.tax_rate', + 'help' => 'tourist_tax.field.tax_rate.help', + ]) + ->add('revenueAccount', EntityType::class, [ + 'class' => AccountingAccount::class, + 'required' => false, + 'placeholder' => '-', + 'choice_label' => 'label', + 'query_builder' => fn (AccountingAccountRepository $repo) => $repo->createOrderedQueryBuilder($activePreset), + 'label' => 'tourist_tax.field.revenue_account', + ]) + ->add('includesVat', CheckboxType::class, [ + 'label' => 'tourist_tax.field.includes_vat', + 'help' => 'tourist_tax.field.includes_vat.help', + 'required' => false, + ]) + ->add('appliesOnlyToAdult', CheckboxType::class, [ + 'label' => 'tourist_tax.field.applies_only_to_adult', + 'help' => 'tourist_tax.field.applies_only_to_adult.help', + 'required' => false, + ]) + ->add('validFrom', DateType::class, [ + 'label' => 'tourist_tax.field.valid_from', + 'widget' => 'single_text', + 'required' => false, + ]) + ->add('validTo', DateType::class, [ + 'label' => 'tourist_tax.field.valid_to', + 'widget' => 'single_text', + 'required' => false, + ]) + ->add('sortOrder', IntegerType::class, [ + 'label' => 'tourist_tax.field.sort_order', + 'empty_data' => '0', + ]) + ->add('active', CheckboxType::class, [ + 'label' => 'tourist_tax.field.active', + 'required' => false, + ]) + ->add('subsidiaries', EntityType::class, [ + 'class' => Subsidiary::class, + 'choice_label' => 'name', + 'multiple' => true, + 'expanded' => true, + 'required' => false, + 'label' => 'tourist_tax.field.subsidiaries', + 'help' => 'tourist_tax.field.subsidiaries.help', + ]) + ->add('rates', CollectionType::class, [ + 'entry_type' => TouristTaxRateType::class, + 'allow_add' => true, + 'allow_delete' => true, + 'by_reference' => false, + 'prototype' => true, + 'label' => false, + ]) + ; + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'data_class' => TouristTax::class, + 'reference_date' => new \DateTime(), + 'active_preset' => null, + ]); + } +} diff --git a/src/Repository/TouristTaxRepository.php b/src/Repository/TouristTaxRepository.php new file mode 100644 index 00000000..102ffdbd --- /dev/null +++ b/src/Repository/TouristTaxRepository.php @@ -0,0 +1,75 @@ + + */ +class TouristTaxRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, TouristTax::class); + } + + /** + * Active taxes for the given subsidiary whose validity range overlaps the + * given [start, end] date range (inclusive on both ends). Per-night + * filtering is the caller's responsibility (TouristTax::isValidOn). + * + * @return TouristTax[] + */ + public function findActiveForSubsidiaryInRange(?Subsidiary $subsidiary, \DateTimeInterface $start, \DateTimeInterface $end): array + { + $qb = $this->createQueryBuilder('t') + ->leftJoin('t.subsidiaries', 's') + ->andWhere('t.active = :active') + ->andWhere('t.validFrom IS NULL OR t.validFrom <= :end') + ->andWhere('t.validTo IS NULL OR t.validTo >= :start') + ->setParameter('active', true) + ->setParameter('start', $start->format('Y-m-d')) + ->setParameter('end', $end->format('Y-m-d')) + ->orderBy('t.sortOrder', 'ASC') + ->addOrderBy('t.id', 'ASC'); + + if (null !== $subsidiary) { + $qb->andWhere('s.id IS NULL OR s.id = :sid') + ->setParameter('sid', $subsidiary->getId()); + } + + return $qb->getQuery()->getResult(); + } + + public function hasActiveForSubsidiary(?Subsidiary $subsidiary): bool + { + $qb = $this->createQueryBuilder('t') + ->select('COUNT(t.id)') + ->leftJoin('t.subsidiaries', 's') + ->andWhere('t.active = :active') + ->setParameter('active', true); + + if (null !== $subsidiary) { + $qb->andWhere('s.id IS NULL OR s.id = :sid') + ->setParameter('sid', $subsidiary->getId()); + } + + return (int) $qb->getQuery()->getSingleScalarResult() > 0; + } + + /** @return TouristTax[] */ + public function findAllOrdered(): array + { + return $this->createQueryBuilder('t') + ->orderBy('t.sortOrder', 'ASC') + ->addOrderBy('t.id', 'ASC') + ->getQuery() + ->getResult(); + } +} diff --git a/src/Service/InvoiceService.php b/src/Service/InvoiceService.php index 487a8551..954d9a85 100644 --- a/src/Service/InvoiceService.php +++ b/src/Service/InvoiceService.php @@ -13,6 +13,7 @@ namespace App\Service; +use App\Dto\TouristTaxBreakdown; use App\Entity\Customer; use App\Entity\CustomerAddresses; use App\Entity\Invoice; @@ -34,8 +35,13 @@ class InvoiceService { - public function __construct(private readonly EntityManagerInterface $em, private readonly PriceService $ps, private readonly TranslatorInterface $translator, private readonly AppSettingsService $appSettingsService) - { + public function __construct( + private readonly EntityManagerInterface $em, + private readonly PriceService $ps, + private readonly TranslatorInterface $translator, + private readonly AppSettingsService $appSettingsService, + private readonly ?TouristTaxService $touristTaxService = null, + ) { } /** @@ -418,6 +424,13 @@ private function prefillMiscPositions(array $reservations, RequestStack $request { $tmpMiscArr = $this->computeMiscPriceAggregates($reservations, $useExistingPrices); $this->makeMiscPositions($tmpMiscArr, $requestStack); + + // Tourist-tax positions live in the same session collection but are + // tagged with positionGroup="tourist_tax" so templates can render them + // in a separate block. + foreach ($this->buildTouristTaxPositions($reservations) as $touristTaxPosition) { + $this->saveNewMiscPosition($touristTaxPosition, $requestStack); + } } /** @@ -432,6 +445,63 @@ public function buildMiscPositions(array $reservations, bool $useExistingPrices return $this->createMiscPositionsFromAggregates($tmpMiscArr); } + /** + * Build tourist-tax invoice positions for the given reservations. One + * position per (TouristTax × GuestCategory) breakdown row, marked with + * positionGroup="tourist_tax" so templates can render them in a separate + * block. Returns an empty array when no TouristTaxService is configured + * or no active tax matches. + * + * @param Reservation[] $reservations + * + * @return InvoicePosition[] + */ + public function buildTouristTaxPositions(array $reservations): array + { + if (null === $this->touristTaxService) { + return []; + } + + $positions = []; + foreach ($reservations as $reservation) { + if (!$reservation instanceof Reservation) { + continue; + } + foreach ($this->touristTaxService->calculateForReservation($reservation) as $row) { + $positions[] = $this->makeTouristTaxPosition($row); + } + } + + return $positions; + } + + private function makeTouristTaxPosition(TouristTaxBreakdown $row): InvoicePosition + { + $description = $this->translator->trans('invoice.tourist_tax.position', [ + '%tax%' => $row->taxName, + '%category%' => $row->categoryName, + '%nights%' => $this->translator->trans('invoice.tourist_tax.position.nights', [ + '%count%' => $row->nights, + ]), + '%count%' => $this->translator->trans('invoice.tourist_tax.position.persons', [ + '%count%' => $row->count, + ]), + ]); + + $position = new InvoicePosition(); + $position->setAmount($row->nights * $row->count); + $position->setDescription($description); + $position->setPrice(number_format($row->pricePerNight, 2, '.', '')); + $position->setVat(null !== $row->taxRate ? $row->taxRate->getRateFloat() : 0.0); + $position->setIncludesVat($row->includesVat); + $position->setIsFlatPrice(false); + $position->setIsPerRoom(false); + $position->setRevenueAccount($row->revenueAccount); + $position->setPositionGroup('tourist_tax'); + + return $position; + } + /** * Aggregates price amounts across all reservations and days into a keyed array, without any session involvement. */ @@ -672,6 +742,7 @@ private function createMiscPositionsFromAggregates(array $tmpPricesArr): array $position->setIsFlatPrice($price->getIsFlatPrice()); $position->setIsPerRoom($price->getIsPerRoom()); $position->setRevenueAccount($price->getRevenueAccount()); + $position->setPositionGroup('misc'); $positions[] = $position; } @@ -706,6 +777,7 @@ private function expandPackageAggregate(array $tmpPrice): array $position->setIsFlatPrice($price->getIsFlatPrice()); $position->setIsPerRoom($price->getIsPerRoom()); $position->setRevenueAccount($component['component']->getRevenueAccount() ?? $price->getRevenueAccount()); + $position->setPositionGroup('misc'); $positions[] = $position; } diff --git a/src/Service/ReservationObject.php b/src/Service/ReservationObject.php index 558371fc..b50b8e40 100644 --- a/src/Service/ReservationObject.php +++ b/src/Service/ReservationObject.php @@ -21,6 +21,10 @@ class ReservationObject private $reservationStatus; private $persons; private $customerId; + /** @var array */ + private array $guestCounts = []; + private bool $adultRuleOverride = false; + private bool $kurtaxeWaived = false; public function __construct($appartmentId, $start, $end, $status, $persons) { @@ -75,4 +79,47 @@ public function setPersons($persons): void { $this->persons = $persons; } + + /** + * @return array + */ + public function getGuestCounts(): array + { + return $this->guestCounts; + } + + /** + * @param array $guestCounts + */ + public function setGuestCounts(array $guestCounts): void + { + $normalized = []; + foreach ($guestCounts as $categoryId => $count) { + $count = (int) $count; + if ($count > 0) { + $normalized[(int) $categoryId] = $count; + } + } + $this->guestCounts = $normalized; + } + + public function isAdultRuleOverride(): bool + { + return $this->adultRuleOverride; + } + + public function setAdultRuleOverride(bool $adultRuleOverride): void + { + $this->adultRuleOverride = $adultRuleOverride; + } + + public function isKurtaxeWaived(): bool + { + return $this->kurtaxeWaived; + } + + public function setKurtaxeWaived(bool $kurtaxeWaived): void + { + $this->kurtaxeWaived = $kurtaxeWaived; + } } diff --git a/src/Service/ReservationService.php b/src/Service/ReservationService.php index 888b3d03..a5f4c6a0 100644 --- a/src/Service/ReservationService.php +++ b/src/Service/ReservationService.php @@ -307,7 +307,22 @@ public function createReservationsFromReservationInformationArray($newReservatio $reservation->setEndDate(new \DateTime($reservationInformation->getEnd())); $reservation->setStartDate(new \DateTime($reservationInformation->getStart())); $reservation->setReservationStatus($this->em->getRepository(ReservationStatus::class)->find($reservationInformation->getReservationStatus())); - $reservation->setPersons((int) $reservationInformation->getPersons()); + $reservation->setAdultRuleOverride($reservationInformation->isAdultRuleOverride()); + $reservation->setKurtaxeWaived($reservationInformation->isKurtaxeWaived()); + + $counts = $reservationInformation->getGuestCounts(); + if ([] !== $counts) { + $this->applyGuestCounts($reservation, $counts); + } else { + // Fallback: legacy persons-only path → bucket into the default adult category + $persons = (int) $reservationInformation->getPersons(); + $defaultAdult = $this->guestCategoryRepository->findDefaultAdult(); + if ($persons > 0 && null !== $defaultAdult) { + $this->applyGuestCounts($reservation, [(int) $defaultAdult->getId() => $persons]); + } else { + $reservation->setPersons($persons); + } + } if (isset($customer)) { $reservation->setBooker($customer); @@ -365,13 +380,14 @@ public function deleteReservation($id) public function updateReservation(Request $request, Reservation $reservation): bool { $apartmentId = $request->request->get('aid'); - $persons = (int) $request->request->get('persons'); $status = $request->request->get('status'); $start = new \DateTime($request->request->get('from')); $end = new \DateTime($request->request->get('end')); - $dateInterval = date_diff($start, $end); - // number of days - $interval = $dateInterval->format('%a'); + + $guestCountsRaw = $request->request->get('guestCounts', '{}'); + $guestCounts = is_string($guestCountsRaw) ? (json_decode($guestCountsRaw, true) ?: []) : []; + $adultRuleOverride = (bool) $request->request->get('adultRuleOverride', false); + $kurtaxeWaived = (bool) $request->request->get('kurtaxeWaived', false); $apartment = $this->em->getRepository(Appartment::class)->find($apartmentId); $reservationStatus = $this->em->getRepository(ReservationStatus::class)->find($status); @@ -382,16 +398,35 @@ public function updateReservation(Request $request, Reservation $reservation): b $end = $tmp; } + // Apply guest counts (or fall back to legacy persons-only path) + $reservation->setAdultRuleOverride($adultRuleOverride); + $reservation->setKurtaxeWaived($kurtaxeWaived); + if ([] !== $guestCounts) { + $this->applyGuestCounts($reservation, $guestCounts); + } else { + $persons = (int) $request->request->get('persons', 0); + $defaultAdult = $this->guestCategoryRepository->findDefaultAdult(); + if ($persons > 0 && null !== $defaultAdult) { + $this->applyGuestCounts($reservation, [(int) $defaultAdult->getId() => $persons]); + } else { + $reservation->setPersons($persons); + } + } + + // Hard "at least one adult" validation + if (!$this->isAdultRuleSatisfied($reservation)) { + return false; + } + + $persons = $reservation->getPersons(); $available = $this->isApartmentAvailable($start, $end, $apartment, $persons, $reservation); // update reservation if no other stands in conflict if ($available) { $reservation->setStartDate($start); $reservation->setEndDate($end); - $reservation->setPersons($persons); } $reservation->setAppartment($apartment); - $reservation->setAppartment($apartment); $this->changeStatus($reservation, $reservationStatus, flush: false); $this->em->persist($reservation); diff --git a/src/Service/TouristTaxService.php b/src/Service/TouristTaxService.php new file mode 100644 index 00000000..3f57a598 --- /dev/null +++ b/src/Service/TouristTaxService.php @@ -0,0 +1,123 @@ +isKurtaxeWaived()) { + return []; + } + + $start = $reservation->getStartDate(); + $end = $reservation->getEndDate(); + if (!$start instanceof \DateTimeInterface || !$end instanceof \DateTimeInterface) { + return []; + } + $totalNights = max(1, (int) $start->diff($end)->format('%a')); + + $guestCounts = $reservation->getGuestCounts(); + if (empty($guestCounts)) { + return []; + } + + // Each overnight is attributed to its arrival day (= the night that + // starts on date X). The last day of the reservation is the checkout + // day, no overnight there. Per-night validity check below means a tax + // that ends mid-stay only counts the nights it actually covers. + $lastNightDate = (clone $start)->modify('+'.($totalNights - 1).' days'); + $subsidiary = $reservation->getAppartment()?->getObject(); + $taxes = $this->touristTaxRepository->findActiveForSubsidiaryInRange($subsidiary, $start, $lastNightDate); + if (empty($taxes)) { + return []; + } + + $categories = []; + foreach ($this->guestCategoryRepository->findAll() as $gc) { + $categories[$gc->getId()] = $gc; + } + + // Aggregate nights per (taxId, categoryId) so each combination yields + // one breakdown row with the actual covered-nights count. + $aggregates = []; + for ($i = 0; $i < $totalNights; ++$i) { + $night = (clone $start)->modify('+'.$i.' days'); + foreach ($taxes as $tax) { + if (!$tax->isValidOn($night)) { + continue; + } + foreach ($tax->getRates() as $rate) { + $catId = $rate->getGuestCategory()?->getId(); + if (null === $catId) { + continue; + } + $count = (int) ($guestCounts[$catId] ?? 0); + if ($count <= 0) { + continue; + } + $category = $categories[$catId] ?? $rate->getGuestCategory(); + if ($tax->isAppliesOnlyToAdult() && !$category?->isAdult()) { + continue; + } + + $key = $tax->getId().':'.$catId; + if (!isset($aggregates[$key])) { + $aggregates[$key] = ['tax' => $tax, 'rate' => $rate, 'count' => $count, 'nights' => 0]; + } + ++$aggregates[$key]['nights']; + } + } + } + + $result = []; + foreach ($aggregates as $a) { + $result[] = $this->makeBreakdown($a['tax'], $a['rate'], $a['nights'], $a['count']); + } + + return $result; + } + + public function hasActiveTaxForSubsidiary(?Subsidiary $subsidiary): bool + { + return $this->touristTaxRepository->hasActiveForSubsidiary($subsidiary); + } + + private function makeBreakdown(TouristTax $tax, TouristTaxRate $rate, int $nights, int $count): TouristTaxBreakdown + { + $category = $rate->getGuestCategory(); + + return new TouristTaxBreakdown( + taxId: (int) $tax->getId(), + taxName: $tax->getName(), + categoryId: (int) $category?->getId(), + categoryName: $category?->getName() ?? '', + pricePerNight: $rate->getPricePerNightFloat(), + nights: $nights, + count: $count, + reportGroup: $rate->getReportGroup(), + taxRate: $tax->getTaxRate(), + revenueAccount: $tax->getRevenueAccount(), + includesVat: $tax->isIncludesVat(), + ); + } +} diff --git a/templates/GuestCategory/_guest_counts_table.html.twig b/templates/GuestCategory/_guest_counts_table.html.twig index 30706961..3f8af82b 100644 --- a/templates/GuestCategory/_guest_counts_table.html.twig +++ b/templates/GuestCategory/_guest_counts_table.html.twig @@ -1,5 +1,5 @@ {# - Reusable partial: per-category guest count inputs with live persons total. + Reusable partial: per-category guest count steppers with live persons total. Required variables: categories iterable of GuestCategory entities (active, ordered) @@ -8,6 +8,8 @@ adultRuleOverride bool — current Reservation.adultRuleOverride fieldNames map with keys: 'counts', 'persons', 'override' defines the form field names posted to the backend + maxOccupancy optional int — hard cap for the sum of categories with + isCountedInOccupancy=true (typically appartment.bedsmax) #} {% set fieldNames = fieldNames|default({ counts: 'guestCounts', @@ -15,66 +17,74 @@ override: 'adultRuleOverride', }) %} -
- - - - - - - - - {% for category in categories %} - - - - - {% endfor %} - - - - - - - -
{{ 'guest_category.col.name'|trans }}{{ 'guest_category.col.count'|trans }}
- {{ category.name }} - ({{ category.acronym }}) - {% if not category.isCountedInOccupancy %} - - {{ 'guest_category.flag.no_occupancy'|trans }} - - {% endif %} - - -
{{ 'guest_category.persons_total'|trans }} - {{ personsTotal|default(0) }} -
+
+
    + {% for category in categories %} +
  • +
    + {{ category.name }} + {#{{ category.acronym }}#} + {% if not category.isCountedInOccupancy %} + · {{ 'guest_category.flag.no_occupancy'|trans }} + {% endif %} +
    +
    + + + +
    +
  • + {% endfor %} +
-
- {{ 'guest_category.warning.no_adult'|trans }} +
+ + {{ 'guest_category.persons_total'|trans }}: + {{ personsTotal|default(0) }} + {% if maxOccupancy is defined and maxOccupancy %} + / {{ maxOccupancy }} + {% endif %} + + + {{ 'guest_category.warning.over_capacity'|trans }} +
-
- - +
+
+ {{ 'guest_category.warning.no_adult'|trans }} +
+
+ + +
+ {% set touristTaxPositions = invoice.positions|filter(p => p.positionGroup == 'tourist_tax') %} + {% if touristTaxPositions|length > 0 %} + + + + + + + + + + + + + {% set touristTaxTotal = 0 %} + {% for position in touristTaxPositions %} + + + + + + + + + {% set touristTaxTotal = touristTaxTotal + position.totalPriceRaw %} + {% endfor %} + + + + +
{{ 'invoice.tourist_tax.heading'|trans }}{{ 'invoice.position.amount'|trans }}{{ 'invoice.price.single'|trans }}{{ 'invoice.vat'|trans }}{{ 'invoice.price.total'|trans }}{{ 'invoice.action'|trans }}
{{ position.description }}{{ position.amount }}{{ position.price|number_format(2, ',', '.') }} + {% if is_decimal_place_0(position.vat) %}{{ position.vat|number_format(0, ',', '.') }}{% else %}{{ position.vat|number_format(2, ',', '.') }}{% endif %} + {{ position.totalPrice }} + + + + +
{{ touristTaxTotal|number_format(2, ',', '.') }}
+ {% endif %} + @@ -117,7 +158,8 @@ - {% for position in invoice.positions %} + {% set miscOnlyTotal = 0 %} + {% for position in invoice.positions|filter(p => p.positionGroup != 'tourist_tax') %} @@ -143,11 +185,12 @@ {% set vatWarning = true %} {% endif %} {% set lastIncludesVat = position.includesVat %} + {% set miscOnlyTotal = miscOnlyTotal + position.totalPriceRaw %} {% endfor %} - +
{{ position.description }} {{ position.amount }}
{{ miscTotal|number_format(2, ',', '.') }}{{ miscOnlyTotal|number_format(2, ',', '.') }}
diff --git a/templates/Invoices/invoice_form_show_create_invoice_positions.html.twig b/templates/Invoices/invoice_form_show_create_invoice_positions.html.twig index 413d73ee..98593049 100644 --- a/templates/Invoices/invoice_form_show_create_invoice_positions.html.twig +++ b/templates/Invoices/invoice_form_show_create_invoice_positions.html.twig @@ -50,6 +50,15 @@
+ {% if positionsMiscellaneous|filter(p => p.positionGroup == 'tourist_tax')|length > 0 %} +
+
+

{{ 'invoice.tourist_tax.heading'|trans }}

+ {% include 'Invoices/invoice_table_tourist_tax_positions.html.twig' with {'mode': 'edit'} %} +
+
+ {% endif %} +

diff --git a/templates/Invoices/invoice_show_preview.html.twig b/templates/Invoices/invoice_show_preview.html.twig index bbb96383..8386ee22 100644 --- a/templates/Invoices/invoice_show_preview.html.twig +++ b/templates/Invoices/invoice_show_preview.html.twig @@ -30,7 +30,10 @@

{% include 'Invoices/invoice_table_apartment_preview_positions.html.twig' with {'mode': 'edit'} %}
-
+
+ {% include 'Invoices/invoice_table_tourist_tax_preview_positions.html.twig' %} +
+
{% include 'Invoices/invoice_table_misc_preview_positions.html.twig' with {'mode': 'edit'} %}
diff --git a/templates/Invoices/invoice_table_misc_positions.html.twig b/templates/Invoices/invoice_table_misc_positions.html.twig index d55834cb..78ff71af 100644 --- a/templates/Invoices/invoice_table_misc_positions.html.twig +++ b/templates/Invoices/invoice_table_misc_positions.html.twig @@ -11,7 +11,7 @@ - {% for key,position in positionsMiscellaneous %} + {% for key,position in positionsMiscellaneous|filter(p => p.positionGroup != 'tourist_tax') %} {{ position.description }} {{ position.amount }} @@ -22,17 +22,17 @@ {{ position.vat|number_format(2, ',', '.') }} {% endif %} - {{ position.price|number_format(2, ',', '.') }} - {% if mode is defined and mode == 'edit' %} - - - - - - - {% endif %} - - {% endfor %} - + {{ position.price|number_format(2, ',', '.') }} + {% if mode is defined and mode == 'edit' %} + + + + + + + {% endif %} + + {% endfor %} + - + diff --git a/templates/Invoices/invoice_table_misc_preview_positions.html.twig b/templates/Invoices/invoice_table_misc_preview_positions.html.twig index e3da0ffa..6f1df9c1 100644 --- a/templates/Invoices/invoice_table_misc_preview_positions.html.twig +++ b/templates/Invoices/invoice_table_misc_preview_positions.html.twig @@ -9,7 +9,9 @@ - {% for position in positionsMiscellaneous %} + {% set miscOnly = positionsMiscellaneous|filter(p => p.positionGroup != 'tourist_tax') %} + {% set miscOnlyTotal = 0 %} + {% for position in miscOnly %} {{ position.description }} {{ position.amount }} @@ -23,6 +25,7 @@ {{ position.totalPrice }} + {% set miscOnlyTotal = miscOnlyTotal + position.totalPriceRaw %} {# check whether there are different prices used, e.g. one uses uses includes vat and one not #} {% if lastIncludesVat is not null and lastIncludesVat != position.includesVat %} {% set vatWarning = true %} @@ -30,7 +33,7 @@ {% set lastIncludesVat = position.includesVat %} {% endfor %} - {{ miscTotal|number_format(2, ',', '.') }} + {{ miscOnlyTotal|number_format(2, ',', '.') }} \ No newline at end of file diff --git a/templates/Invoices/invoice_table_tourist_tax_positions.html.twig b/templates/Invoices/invoice_table_tourist_tax_positions.html.twig new file mode 100644 index 00000000..db83f1ca --- /dev/null +++ b/templates/Invoices/invoice_table_tourist_tax_positions.html.twig @@ -0,0 +1,40 @@ +{% set touristTaxPositions = positionsMiscellaneous|filter(p => p.positionGroup == 'tourist_tax') %} +{% if touristTaxPositions|length > 0 %} + + + + + + + + {% if mode is defined and mode == 'edit' %} + + {% endif %} + + + + {% for key, position in touristTaxPositions %} + + + + + + {% if mode is defined and mode == 'edit' %} + + {% endif %} + + {% endfor %} + +
{{ 'invoice.appartment.position.description'|trans }}{{ 'invoice.position.amount'|trans }}{{ 'invoice.vat'|trans }}{{ 'invoice.appartment.position.price'|trans }}{{ 'invoice.action'|trans }}
{{ position.description }}{{ position.amount }} + {% if is_decimal_place_0(position.vat) %} + {{ position.vat|number_format(0, ',', '.') }} + {% else %} + {{ position.vat|number_format(2, ',', '.') }} + {% endif %} + {{ position.price|number_format(2, ',', '.') }} + + + + +
+{% endif %} diff --git a/templates/Invoices/invoice_table_tourist_tax_preview_positions.html.twig b/templates/Invoices/invoice_table_tourist_tax_preview_positions.html.twig new file mode 100644 index 00000000..781f9260 --- /dev/null +++ b/templates/Invoices/invoice_table_tourist_tax_preview_positions.html.twig @@ -0,0 +1,34 @@ +{% set touristTaxPositions = positionsMiscellaneous|filter(p => p.positionGroup == 'tourist_tax') %} +{% if touristTaxPositions|length > 0 %} + + + + + + + + + + + + {% set touristTaxTotal = 0 %} + {% for position in touristTaxPositions %} + + + + + + + + {% set touristTaxTotal = touristTaxTotal + position.totalPriceRaw %} + {% endfor %} + + + + +
{{ 'invoice.tourist_tax.heading'|trans }}{{ 'invoice.position.amount'|trans }}{{ 'invoice.price.single'|trans }}{{ 'invoice.vat'|trans }}{{ 'invoice.price.total'|trans }}
{{ position.description }}{{ position.amount }}{{ position.price|number_format(2, ',', '.') }} + {% if is_decimal_place_0(position.vat) %}{{ position.vat|number_format(0, ',', '.') }}{% else %}{{ position.vat|number_format(2, ',', '.') }}{% endif %} + {{ position.totalPrice }}
{{ touristTaxTotal|number_format(2, ',', '.') }}
+ {% else %} +

{{ 'invoice.tourist_tax.no_positions'|trans }}

+ {% endif %} diff --git a/templates/Reservations/reservation_form_edit.html.twig b/templates/Reservations/reservation_form_edit.html.twig index ea7ea9df..c8286fc7 100644 --- a/templates/Reservations/reservation_form_edit.html.twig +++ b/templates/Reservations/reservation_form_edit.html.twig @@ -29,13 +29,13 @@
-
- - - - - - +
+
{{ 'appartment.number'|trans }}{{ 'appartment.bedsmax'|trans }}{{ 'appartment.description'|trans }}
+ + + + + @@ -43,21 +43,21 @@ - - - - - -
{{ 'appartment.number'|trans }}{{ 'appartment.bedsmax'|trans }}{{ 'appartment.description'|trans }}
{{ reservation.appartment.number }} {{ reservation.appartment.bedsmax }} {{ reservation.appartment.description }}
-
- {% include 'Reservations/reservations_form_appartment_options_input_fields.html.twig' with {'appartment': reservation.appartment, 'reservation': reservation } %} -
-
-
-
- -
-
+ + + +
+ {% include 'Reservations/reservations_form_appartment_options_input_fields.html.twig' with {'appartment': reservation.appartment, 'reservation': reservation } %} +
+ + + + +
+
+ +
+

{{ 'reservation.appartment.available'|trans }}

@@ -69,19 +69,19 @@ {% if objectSelected == "all" %}selected{% endif %}>{{ 'reservation.objects.all'|trans }} {% for object in objects %} - {% endfor %} - -
-
-
-
-
-
-
-
- -
+ {% if objectSelected == object.id %}selected{% endif %}>{{ object.name }} + {% endfor %} + +
+
+
+
+
+
+
+ + + {% endblock %} diff --git a/templates/GuestCategory/modifier_edit.html.twig b/templates/GuestCategory/modifier_edit.html.twig new file mode 100644 index 00000000..3bfcadd9 --- /dev/null +++ b/templates/GuestCategory/modifier_edit.html.twig @@ -0,0 +1,35 @@ +{{ form_start(form, {'attr': { + 'id':'modifier-form-'~modifier.id, + 'data-controller':'settings', + 'data-action':'submit->settings#submitFormAction', + 'data-url': path('guest_category_modifier_edit', {'id': modifier.id}) +} }) }} + + +{{ form_end(form) }} diff --git a/templates/GuestCategory/modifier_new.html.twig b/templates/GuestCategory/modifier_new.html.twig new file mode 100644 index 00000000..b894544e --- /dev/null +++ b/templates/GuestCategory/modifier_new.html.twig @@ -0,0 +1,20 @@ +{{ form_start(form, {'attr': { + 'id':'modifier-form-new', + 'data-controller':'settings', + 'data-action':'submit->settings#submitFormAction', + 'data-url': path('guest_category_modifier_new'), + 'data-success-url': path('guest_category_index') +} }) }} + + +{{ form_end(form) }} diff --git a/templates/Invoices/invoice_form_show.html.twig b/templates/Invoices/invoice_form_show.html.twig index c8eb91e8..28f3392b 100644 --- a/templates/Invoices/invoice_form_show.html.twig +++ b/templates/Invoices/invoice_form_show.html.twig @@ -105,6 +105,47 @@ + {% set apartmentModifierPositions = invoice.positions|filter(p => p.positionGroup == 'apartment_modifier') %} + {% if apartmentModifierPositions|length > 0 %} + + + + + + + + + + + + + {% set apartmentModifierTotal = 0 %} + {% for position in apartmentModifierPositions %} + + + + + + + + + {% set apartmentModifierTotal = apartmentModifierTotal + position.totalPriceRaw %} + {% endfor %} + + + + +
{{ 'invoice.apartment_modifier.heading'|trans }}{{ 'invoice.position.amount'|trans }}{{ 'invoice.price.single'|trans }}{{ 'invoice.vat'|trans }}{{ 'invoice.price.total'|trans }}{{ 'invoice.action'|trans }}
{{ position.description }}{{ position.amount }}{{ position.price|number_format(2, ',', '.') }} + {% if is_decimal_place_0(position.vat) %}{{ position.vat|number_format(0, ',', '.') }}{% else %}{{ position.vat|number_format(2, ',', '.') }}{% endif %} + {{ position.totalPrice }} + + + + +
{{ apartmentModifierTotal|number_format(2, ',', '.') }}
+ {% endif %} + {% set touristTaxPositions = invoice.positions|filter(p => p.positionGroup == 'tourist_tax') %} {% if touristTaxPositions|length > 0 %} @@ -159,7 +200,7 @@ {% set miscOnlyTotal = 0 %} - {% for position in invoice.positions|filter(p => p.positionGroup != 'tourist_tax') %} + {% for position in invoice.positions|filter(p => p.positionGroup != 'tourist_tax' and p.positionGroup != 'apartment_modifier') %} diff --git a/templates/Invoices/invoice_form_show_create_invoice_positions.html.twig b/templates/Invoices/invoice_form_show_create_invoice_positions.html.twig index 98593049..522f110b 100644 --- a/templates/Invoices/invoice_form_show_create_invoice_positions.html.twig +++ b/templates/Invoices/invoice_form_show_create_invoice_positions.html.twig @@ -50,6 +50,15 @@ + {% if positionsMiscellaneous|filter(p => p.positionGroup == 'apartment_modifier')|length > 0 %} +
+
+

{{ 'invoice.apartment_modifier.heading'|trans }}

+ {% include 'Invoices/invoice_table_apartment_modifier_positions.html.twig' with {'mode': 'edit'} %} +
+
+ {% endif %} + {% if positionsMiscellaneous|filter(p => p.positionGroup == 'tourist_tax')|length > 0 %}
diff --git a/templates/Invoices/invoice_show_preview.html.twig b/templates/Invoices/invoice_show_preview.html.twig index 8386ee22..8be775a9 100644 --- a/templates/Invoices/invoice_show_preview.html.twig +++ b/templates/Invoices/invoice_show_preview.html.twig @@ -30,6 +30,9 @@
{% include 'Invoices/invoice_table_apartment_preview_positions.html.twig' with {'mode': 'edit'} %}
+
+ {% include 'Invoices/invoice_table_apartment_modifier_preview_positions.html.twig' %} +
{% include 'Invoices/invoice_table_tourist_tax_preview_positions.html.twig' %}
diff --git a/templates/Invoices/invoice_table_apartment_modifier_positions.html.twig b/templates/Invoices/invoice_table_apartment_modifier_positions.html.twig new file mode 100644 index 00000000..1a83dbc9 --- /dev/null +++ b/templates/Invoices/invoice_table_apartment_modifier_positions.html.twig @@ -0,0 +1,40 @@ +{% set modifierPositions = positionsMiscellaneous|filter(p => p.positionGroup == 'apartment_modifier') %} +{% if modifierPositions|length > 0 %} +
{{ position.description }} {{ position.amount }}
+ + + + + + + {% if mode is defined and mode == 'edit' %} + + {% endif %} + + + + {% for key, position in modifierPositions %} + + + + + + {% if mode is defined and mode == 'edit' %} + + {% endif %} + + {% endfor %} + +
{{ 'invoice.appartment.position.description'|trans }}{{ 'invoice.position.amount'|trans }}{{ 'invoice.vat'|trans }}{{ 'invoice.appartment.position.price'|trans }}{{ 'invoice.action'|trans }}
{{ position.description }}{{ position.amount }} + {% if is_decimal_place_0(position.vat) %} + {{ position.vat|number_format(0, ',', '.') }} + {% else %} + {{ position.vat|number_format(2, ',', '.') }} + {% endif %} + {{ position.price|number_format(2, ',', '.') }} + + + + +
+{% endif %} diff --git a/templates/Invoices/invoice_table_apartment_modifier_preview_positions.html.twig b/templates/Invoices/invoice_table_apartment_modifier_preview_positions.html.twig new file mode 100644 index 00000000..5d145ca2 --- /dev/null +++ b/templates/Invoices/invoice_table_apartment_modifier_preview_positions.html.twig @@ -0,0 +1,32 @@ +{% set modifierPositions = positionsMiscellaneous|filter(p => p.positionGroup == 'apartment_modifier') %} +{% if modifierPositions|length > 0 %} + + + + + + + + + + + + {% set modifierTotal = 0 %} + {% for position in modifierPositions %} + + + + + + + + {% set modifierTotal = modifierTotal + position.totalPriceRaw %} + {% endfor %} + + + + +
{{ 'invoice.apartment_modifier.heading'|trans }}{{ 'invoice.position.amount'|trans }}{{ 'invoice.price.single'|trans }}{{ 'invoice.vat'|trans }}{{ 'invoice.price.total'|trans }}
{{ position.description }}{{ position.amount }}{{ position.price|number_format(2, ',', '.') }} + {% if is_decimal_place_0(position.vat) %}{{ position.vat|number_format(0, ',', '.') }}{% else %}{{ position.vat|number_format(2, ',', '.') }}{% endif %} + {{ position.totalPrice }}
{{ modifierTotal|number_format(2, ',', '.') }}
+{% endif %} diff --git a/templates/Invoices/invoice_table_misc_positions.html.twig b/templates/Invoices/invoice_table_misc_positions.html.twig index 78ff71af..17f49a4f 100644 --- a/templates/Invoices/invoice_table_misc_positions.html.twig +++ b/templates/Invoices/invoice_table_misc_positions.html.twig @@ -11,7 +11,7 @@ - {% for key,position in positionsMiscellaneous|filter(p => p.positionGroup != 'tourist_tax') %} + {% for key,position in positionsMiscellaneous|filter(p => p.positionGroup != 'tourist_tax' and p.positionGroup != 'apartment_modifier') %} {{ position.description }} {{ position.amount }} diff --git a/templates/Invoices/invoice_table_misc_preview_positions.html.twig b/templates/Invoices/invoice_table_misc_preview_positions.html.twig index 6f1df9c1..670214ed 100644 --- a/templates/Invoices/invoice_table_misc_preview_positions.html.twig +++ b/templates/Invoices/invoice_table_misc_preview_positions.html.twig @@ -9,7 +9,7 @@ - {% set miscOnly = positionsMiscellaneous|filter(p => p.positionGroup != 'tourist_tax') %} + {% set miscOnly = positionsMiscellaneous|filter(p => p.positionGroup != 'tourist_tax' and p.positionGroup != 'apartment_modifier') %} {% set miscOnlyTotal = 0 %} {% for position in miscOnly %} diff --git a/templates/Reservations/reservation_form_show_fields.html.twig b/templates/Reservations/reservation_form_show_fields.html.twig index abd513ca..7ce4d311 100644 --- a/templates/Reservations/reservation_form_show_fields.html.twig +++ b/templates/Reservations/reservation_form_show_fields.html.twig @@ -420,6 +420,15 @@ + {% if positionsMiscellaneous|filter(p => p.positionGroup == 'apartment_modifier')|length > 0 %} +
+
+
{{ 'invoice.apartment_modifier.heading'|trans }}
+ {% include 'Invoices/invoice_table_apartment_modifier_preview_positions.html.twig' %} +
+
+ {% endif %} + {% if hasActiveTouristTax|default(false) %}
diff --git a/tests/Unit/InvoiceServiceApartmentModifierTest.php b/tests/Unit/InvoiceServiceApartmentModifierTest.php new file mode 100644 index 00000000..ef212316 --- /dev/null +++ b/tests/Unit/InvoiceServiceApartmentModifierTest.php @@ -0,0 +1,232 @@ +makePrice('100.00'); + $r = new Reservation(); + $breakdowns = [ + $this->makeBreakdown($base, []), + $this->makeBreakdown($base, []), + ]; + + $service = $this->createService($r, $breakdowns); + self::assertSame([], $service->buildApartmentModifierPositions([$r])); + } + + public function testDiscountPercentEmitsNegativeDeltaPosition(): void + { + $base = $this->makePrice('100.00'); + $child = $this->makeCategory(2, GuestStatisticalGroup::CHILD); + $modifier = $this->makeModifier($child, ModifierType::DISCOUNT_PERCENT, '50'); + + // 2 nights, 1 child, child unitPrice = 50 (= base 100 with 50% off) + $r = new Reservation(); + $breakdowns = [ + $this->makeBreakdown($base, [new PriceBreakdownLine($child, 1, 50.0, $modifier)]), + $this->makeBreakdown($base, [new PriceBreakdownLine($child, 1, 50.0, $modifier)]), + ]; + + $service = $this->createService($r, $breakdowns); + $positions = $service->buildApartmentModifierPositions([$r]); + + self::assertCount(1, $positions); + // delta per unit = 50 - 100 = -50; amount = 2 nights × 1 child = 2 + self::assertSame('-50.00', $positions[0]->getPrice()); + self::assertSame(2, $positions[0]->getAmount()); + self::assertSame(-100.0, $positions[0]->getTotalPriceRaw()); + self::assertSame('apartment_modifier', $positions[0]->getPositionGroup()); + } + + public function testFreeProducesFullNegativeDelta(): void + { + $base = $this->makePrice('80.00'); + $infant = $this->makeCategory(3, GuestStatisticalGroup::INFANT); + $modifier = $this->makeModifier($infant, ModifierType::FREE, '0'); + + $r = new Reservation(); + $breakdowns = [ + $this->makeBreakdown($base, [new PriceBreakdownLine($infant, 1, 0.0, $modifier)]), + ]; + + $service = $this->createService($r, $breakdowns); + $positions = $service->buildApartmentModifierPositions([$r]); + + self::assertCount(1, $positions); + self::assertSame('-80.00', $positions[0]->getPrice()); + self::assertSame(1, $positions[0]->getAmount()); + } + + public function testSurchargeEmitsPositiveDelta(): void + { + $base = $this->makePrice('100.00'); + $other = $this->makeCategory(4, GuestStatisticalGroup::OTHER); + $modifier = $this->makeModifier($other, ModifierType::SURCHARGE_ABSOLUTE, '15'); + + $r = new Reservation(); + $breakdowns = [ + $this->makeBreakdown($base, [new PriceBreakdownLine($other, 1, 115.0, $modifier)]), + ]; + + $service = $this->createService($r, $breakdowns); + $positions = $service->buildApartmentModifierPositions([$r]); + + self::assertCount(1, $positions); + self::assertSame('15.00', $positions[0]->getPrice()); + } + + public function testFlatPriceReservationIsSkipped(): void + { + $base = $this->makePrice('200.00'); + $base->setIsFlatPrice(true); + $child = $this->makeCategory(2, GuestStatisticalGroup::CHILD); + $modifier = $this->makeModifier($child, ModifierType::DISCOUNT_PERCENT, '50'); + + $r = new Reservation(); + $breakdowns = [ + $this->makeBreakdown($base, [new PriceBreakdownLine($child, 1, 100.0, $modifier)]), + ]; + + $service = $this->createService($r, $breakdowns); + self::assertSame([], $service->buildApartmentModifierPositions([$r])); + } + + public function testPerRoomReservationIsSkipped(): void + { + $base = $this->makePrice('200.00'); + $base->setIsPerRoom(true); + $child = $this->makeCategory(2, GuestStatisticalGroup::CHILD); + $modifier = $this->makeModifier($child, ModifierType::FREE, '0'); + + $r = new Reservation(); + $breakdowns = [ + $this->makeBreakdown($base, [new PriceBreakdownLine($child, 1, 0.0, $modifier)]), + ]; + + $service = $this->createService($r, $breakdowns); + self::assertSame([], $service->buildApartmentModifierPositions([$r])); + } + + public function testNonOccupancyCategoryEmitsNoDelta(): void + { + // Baby with isCountedInOccupancy=false is excluded from `persons`, so + // the apartment line never billed it — emitting a "discount" would + // subtract a charge that was never added. + $base = $this->makePrice('30.00'); + $infant = $this->makeCategory(3, GuestStatisticalGroup::INFANT, isCountedInOccupancy: false); + $modifier = $this->makeModifier($infant, ModifierType::FREE, '0'); + + $r = new Reservation(); + $breakdowns = [ + $this->makeBreakdown($base, [new PriceBreakdownLine($infant, 1, 0.0, $modifier)]), + ]; + + $service = $this->createService($r, $breakdowns); + self::assertSame([], $service->buildApartmentModifierPositions([$r])); + } + + public function testZeroDeltaIsSkipped(): void + { + // FLAT_RATE = base ⇒ delta = 0 ⇒ no position emitted (would be noise). + $base = $this->makePrice('100.00'); + $child = $this->makeCategory(2, GuestStatisticalGroup::CHILD); + $modifier = $this->makeModifier($child, ModifierType::FLAT_RATE, '100'); + + $r = new Reservation(); + $breakdowns = [ + $this->makeBreakdown($base, [new PriceBreakdownLine($child, 1, 100.0, $modifier)]), + ]; + + $service = $this->createService($r, $breakdowns); + self::assertSame([], $service->buildApartmentModifierPositions([$r])); + } + + /** + * @param PriceBreakdown[] $breakdowns + */ + private function createService(Reservation $r, array $breakdowns): InvoiceService + { + $em = $this->createStub(EntityManagerInterface::class); + + $priceService = $this->createMock(PriceService::class); + $priceService->method('getPriceBreakdownForReservation')->with($r)->willReturn($breakdowns); + + $translator = $this->createStub(TranslatorInterface::class); + $translator->method('trans')->willReturnCallback( + fn (string $id, array $params = []) => $id.':'.implode(',', array_values($params)) + ); + + $appSettings = new AppSettings(); + $appSettingsService = $this->createStub(AppSettingsService::class); + $appSettingsService->method('getSettings')->willReturn($appSettings); + + return new InvoiceService($em, $priceService, $translator, $appSettingsService); + } + + /** + * @param PriceBreakdownLine[] $lines + */ + private function makeBreakdown(?Price $base, array $lines): PriceBreakdown + { + $b = new PriceBreakdown(new \DateTime('2026-06-01'), $base); + foreach ($lines as $line) { + $b->addLine($line); + } + + return $b; + } + + private function makePrice(string $value): Price + { + $p = new Price(); + $p->setPrice($value); + $p->setVat(7.0); + $p->setDescription('apt'); + + return $p; + } + + private function makeCategory(int $id, GuestStatisticalGroup $group, bool $isCountedInOccupancy = true): GuestCategory + { + $c = new GuestCategory(); + $c->setName('cat'.$id); + $c->setAcronym('C'.$id); + $c->setStatisticalGroup($group); + $c->setIsCountedInOccupancy($isCountedInOccupancy); + (new \ReflectionProperty(GuestCategory::class, 'id'))->setValue($c, $id); + + return $c; + } + + private function makeModifier(GuestCategory $category, ModifierType $type, string $value): GuestCategoryModifier + { + $m = new GuestCategoryModifier(); + $m->setCategory($category); + $m->setType($type); + $m->setValue($value); + (new \ReflectionProperty(GuestCategoryModifier::class, 'id'))->setValue($m, abs(crc32($category->getId().$type->value)) % 100000); + + return $m; + } +} diff --git a/tests/Unit/PriceServiceModifierTest.php b/tests/Unit/PriceServiceModifierTest.php new file mode 100644 index 00000000..6caa317d --- /dev/null +++ b/tests/Unit/PriceServiceModifierTest.php @@ -0,0 +1,221 @@ +makeCategory(1, GuestStatisticalGroup::ADULT); + $child = $this->makeCategory(2, GuestStatisticalGroup::CHILD); + + $reservation = $this->makeReservation([1 => 2, 2 => 1]); + $service = $this->makeService([$adult, $child], [], $reservation, $this->makePrice('80.00')); + + $breakdowns = $service->getPriceBreakdownForReservation($reservation); + + self::assertCount(2, $breakdowns); // 2 nights + $night0 = $breakdowns[0]; + self::assertSame(2, $night0->lines[0]->count); + self::assertSame(80.0, $night0->lines[0]->unitPrice); + self::assertSame(1, $night0->lines[1]->count); + self::assertSame(80.0, $night0->lines[1]->unitPrice); // no modifier => base + self::assertSame(240.0, $night0->total()); + } + + public function testDiscountPercentReducesNonAdultUnitPrice(): void + { + $adult = $this->makeCategory(1, GuestStatisticalGroup::ADULT); + $child = $this->makeCategory(2, GuestStatisticalGroup::CHILD); + $modifier = $this->makeModifier($child, ModifierType::DISCOUNT_PERCENT, '50'); + + $reservation = $this->makeReservation([1 => 2, 2 => 1]); + $service = $this->makeService([$adult, $child], [$modifier], $reservation, $this->makePrice('100.00')); + + $night = $service->getPriceBreakdownForReservation($reservation)[0]; + $childLine = $night->lines[1]; + self::assertSame(50.0, $childLine->unitPrice); + self::assertSame(250.0, $night->total()); + } + + public function testFlatRateOverridesBasePrice(): void + { + $adult = $this->makeCategory(1, GuestStatisticalGroup::ADULT); + $child = $this->makeCategory(2, GuestStatisticalGroup::CHILD); + $modifier = $this->makeModifier($child, ModifierType::FLAT_RATE, '30.00'); + + $reservation = $this->makeReservation([1 => 2, 2 => 2]); + $service = $this->makeService([$adult, $child], [$modifier], $reservation, $this->makePrice('100.00')); + + $night = $service->getPriceBreakdownForReservation($reservation)[0]; + self::assertSame(30.0, $night->lines[1]->unitPrice); + self::assertSame(260.0, $night->total()); + } + + public function testFreeYieldsZero(): void + { + $adult = $this->makeCategory(1, GuestStatisticalGroup::ADULT); + $infant = $this->makeCategory(3, GuestStatisticalGroup::INFANT); + $modifier = $this->makeModifier($infant, ModifierType::FREE, '0'); + + $reservation = $this->makeReservation([1 => 2, 3 => 2]); + $service = $this->makeService([$adult, $infant], [$modifier], $reservation, $this->makePrice('80.00')); + + $night = $service->getPriceBreakdownForReservation($reservation)[0]; + $infantLine = $night->lines[1]; + self::assertSame(0.0, $infantLine->unitPrice); + self::assertSame(160.0, $night->total()); + } + + public function testSurchargeAbsoluteAddsToBase(): void + { + $adult = $this->makeCategory(1, GuestStatisticalGroup::ADULT); + $other = $this->makeCategory(4, GuestStatisticalGroup::OTHER); + $modifier = $this->makeModifier($other, ModifierType::SURCHARGE_ABSOLUTE, '15'); + + $reservation = $this->makeReservation([1 => 1, 4 => 1]); + $service = $this->makeService([$adult, $other], [$modifier], $reservation, $this->makePrice('100.00')); + + $night = $service->getPriceBreakdownForReservation($reservation)[0]; + self::assertSame(115.0, $night->lines[1]->unitPrice); + self::assertSame(215.0, $night->total()); + } + + public function testAdultIsNeverModified(): void + { + $adult = $this->makeCategory(1, GuestStatisticalGroup::ADULT); + // A misconfigured FREE modifier on the adult category must be ignored. + $modifier = $this->makeModifier($adult, ModifierType::FREE, '0'); + + $reservation = $this->makeReservation([1 => 2]); + $service = $this->makeService([$adult], [$modifier], $reservation, $this->makePrice('120.00')); + + $night = $service->getPriceBreakdownForReservation($reservation)[0]; + self::assertSame(120.0, $night->lines[0]->unitPrice); + self::assertSame(240.0, $night->total()); + } + + public function testInactiveOrOutOfRangeModifierIsFilteredByRepository(): void + { + // The repository is responsible for active/date filtering, so simulate + // it by returning an empty list — base price must apply. + $adult = $this->makeCategory(1, GuestStatisticalGroup::ADULT); + $child = $this->makeCategory(2, GuestStatisticalGroup::CHILD); + + $reservation = $this->makeReservation([1 => 1, 2 => 1]); + $service = $this->makeService([$adult, $child], [], $reservation, $this->makePrice('60.00')); + + $night = $service->getPriceBreakdownForReservation($reservation)[0]; + self::assertSame(60.0, $night->lines[1]->unitPrice); + self::assertSame(120.0, $night->total()); + } + + public function testEmptyGuestCountsProducesNoLines(): void + { + $adult = $this->makeCategory(1, GuestStatisticalGroup::ADULT); + + $reservation = $this->makeReservation([]); + $service = $this->makeService([$adult], [], $reservation, $this->makePrice('80.00')); + + $night = $service->getPriceBreakdownForReservation($reservation)[0]; + self::assertSame([], $night->lines); + self::assertSame(0.0, $night->total()); + } + + /** + * @param GuestCategory[] $categories + * @param GuestCategoryModifier[] $modifiers + */ + private function makeService(array $categories, array $modifiers, Reservation $reservation, Price $price): PriceService + { + $byId = []; + foreach ($categories as $c) { + $byId[$c->getId()] = $c; + } + + $catRepo = $this->createStub(GuestCategoryRepository::class); + $catRepo->method('findAll')->willReturn(array_values($byId)); + + $modRepo = $this->createStub(GuestCategoryModifierRepository::class); + $modRepo->method('findActiveOn')->willReturn($modifiers); + + $em = $this->createStub(EntityManagerInterface::class); + + return new class($em, $catRepo, $modRepo, $price) extends PriceService { + public function __construct( + EntityManagerInterface $em, + GuestCategoryRepository $catRepo, + GuestCategoryModifierRepository $modRepo, + private readonly Price $price, + ) { + parent::__construct($em, $catRepo, $modRepo); + } + + public function getPricesForReservationDays(Reservation $reservation, int $type, ?\Doctrine\Common\Collections\Collection $prices = null): array + { + $days = max(1, (int) $reservation->getStartDate()->diff($reservation->getEndDate())->format('%a')); + $out = []; + for ($i = 0; $i < $days; ++$i) { + $out[$i] = [$this->price]; + } + + return $out; + } + }; + } + + private function makeReservation(array $guestCounts): Reservation + { + $r = new Reservation(); + $r->setStartDate(new \DateTime('2026-06-01')); + $r->setEndDate(new \DateTime('2026-06-03')); + $r->setGuestCounts($guestCounts); + + return $r; + } + + private function makeCategory(int $id, GuestStatisticalGroup $group): GuestCategory + { + $c = new GuestCategory(); + $c->setName('cat'.$id); + $c->setAcronym('C'.$id); + $c->setStatisticalGroup($group); + (new \ReflectionProperty(GuestCategory::class, 'id'))->setValue($c, $id); + + return $c; + } + + private function makeModifier(GuestCategory $category, ModifierType $type, string $value): GuestCategoryModifier + { + $m = new GuestCategoryModifier(); + $m->setCategory($category); + $m->setType($type); + $m->setValue($value); + + return $m; + } + + private function makePrice(string $value): Price + { + $p = new Price(); + $p->setPrice($value); + $p->setVat(7.0); + $p->setDescription('apt'); + + return $p; + } +} diff --git a/translations/GuestCategory/messages.de.yaml b/translations/GuestCategory/messages.de.yaml index f34b0d10..7ed80217 100644 --- a/translations/GuestCategory/messages.de.yaml +++ b/translations/GuestCategory/messages.de.yaml @@ -15,6 +15,7 @@ guest_category: warning: no_adult: "Diese Buchung enthält keinen Erwachsenen. Aktiviere die Freigabe weiter unten, um trotzdem zu speichern." + over_capacity: "Maximale Bettenbelegung des Zimmers erreicht." field: name: "Bezeichnung" @@ -71,3 +72,45 @@ guest_category: exempt: name: "Nichtpflichtige Personen" acronym: "NP" + +guest_category_modifier: + title: "Preisanpassungen je Gastkategorie" + description: "Lege je Gastkategorie einen Aufschlag, Rabatt, Pauschalpreis oder \"frei\" auf den Übernachtungspreis fest. Der Erwachsenenpreis bleibt der Basistarif." + add: "Preisanpassung hinzufügen" + empty: "Noch keine Preisanpassungen angelegt — Übernachtungspreise gelten unverändert für alle Kategorien." + delete: + ask: "Diese Preisanpassung wirklich löschen?" + + type: + surcharge_absolute: "Aufschlag (Betrag)" + discount_percent: "Rabatt (%)" + flat_rate: "Pauschalpreis je Nacht" + free: "Kostenlos" + + field: + category: "Gastkategorie" + category.help: "Es werden nur Kategorien angezeigt, auf die ein Modifier wirken kann: keine Erwachsenen-Kategorien (Basistarif) und nur Kategorien, die zur Zimmerbelegung zählen. Nicht-belegende Gäste (z. B. Kleinkinder im Babybett) werden vom Apartmentpreis ohnehin nicht erfasst." + type: "Art der Anpassung" + type.help: "Aufschlag addiert einen Betrag zum Erwachsenenpreis. Rabatt zieht einen Prozentsatz ab. Pauschalpreis ersetzt den Erwachsenenpreis komplett. Kostenlos = 0,00." + value: "Wert" + value.help: "Betrag in der eingestellten Währung bei Aufschlag/Pauschalpreis, Prozentsatz bei Rabatt. Bei \"Kostenlos\" wird der Wert ignoriert." + valid_from: "Gültig ab" + valid_to: "Gültig bis" + sort_order: "Sortierreihenfolge" + active: "Aktiv" + + col: + category: "Gastkategorie" + type: "Typ" + value: "Wert" + validity: "Gültigkeit" + active: "Aktiv" + action: "Aktion" + + flash: + create: + success: "Preisanpassung wurde erstellt." + edit: + success: "Preisanpassung wurde gespeichert." + delete: + success: "Preisanpassung wurde gelöscht." diff --git a/translations/GuestCategory/messages.en.yaml b/translations/GuestCategory/messages.en.yaml index 0a22f943..36fa2ef5 100644 --- a/translations/GuestCategory/messages.en.yaml +++ b/translations/GuestCategory/messages.en.yaml @@ -15,6 +15,7 @@ guest_category: warning: no_adult: "This booking contains no adult guest. Enable the override below to save anyway." + over_capacity: "Room bed capacity reached." field: name: "Name" @@ -71,3 +72,45 @@ guest_category: exempt: name: "Non-liable guests" acronym: "NL" + +guest_category_modifier: + title: "Per-category surcharges / discounts" + description: "Define a surcharge, discount, flat rate or \"free\" per guest category on top of the nightly rate. The adult rate stays the base price." + add: "Add modifier" + empty: "No modifiers configured yet — nightly rates apply unchanged for every category." + delete: + ask: "Really delete this modifier?" + + type: + surcharge_absolute: "Surcharge (amount)" + discount_percent: "Discount (%)" + flat_rate: "Flat rate per night" + free: "Free" + + field: + category: "Guest category" + category.help: "Only categories on which a modifier can take effect are listed: no adult categories (they are the base rate) and only categories that count toward room occupancy. Non-occupancy guests (e.g. infants in a cot) are not billed by the apartment line." + type: "Modifier type" + type.help: "Surcharge adds an amount to the adult rate. Discount subtracts a percentage. Flat rate replaces the adult rate. Free = 0.00." + value: "Value" + value.help: "Amount in the configured currency for surcharge/flat rate, percentage for discount. Ignored for \"Free\"." + valid_from: "Valid from" + valid_to: "Valid to" + sort_order: "Sort order" + active: "Active" + + col: + category: "Guest category" + type: "Type" + value: "Value" + validity: "Validity" + active: "Active" + action: "Action" + + flash: + create: + success: "Modifier created." + edit: + success: "Modifier saved." + delete: + success: "Modifier deleted." diff --git a/translations/Invoices/messages.de.xlf b/translations/Invoices/messages.de.xlf index d09389e9..81e2e3a6 100644 --- a/translations/Invoices/messages.de.xlf +++ b/translations/Invoices/messages.de.xlf @@ -478,6 +478,30 @@ invoice.tourist_tax.heading Kurtaxe + + invoice.apartment_modifier.heading + Aufschläge / Ermäßigungen + + + invoice.apartment_modifier.position + %category% — %modifier% (%nights% × %count%) + + + invoice.apartment_modifier.label.surcharge_absolute + Aufschlag %value% + + + invoice.apartment_modifier.label.discount_percent + Rabatt %value%% + + + invoice.apartment_modifier.label.flat_rate + Pauschalpreis %value% + + + invoice.apartment_modifier.label.free + Kostenlos + invoice.tourist_tax.position %tax% — %category% (%nights% × %count%) diff --git a/translations/Invoices/messages.en.yaml b/translations/Invoices/messages.en.yaml index c04dd327..7d6d7f27 100644 --- a/translations/Invoices/messages.en.yaml +++ b/translations/Invoices/messages.en.yaml @@ -122,6 +122,12 @@ SEPA_CREDIT_TRANSFER: SEPA bank transfer SEPA_DIRECT_DEBIT: SEPA direct debit invoice.misc.package.hint: This package will be split into multiple lines on save. invoice.tourist_tax.heading: Tourist tax +invoice.apartment_modifier.heading: Surcharges / discounts +invoice.apartment_modifier.position: '%category% — %modifier% (%nights% × %count%)' +invoice.apartment_modifier.label.surcharge_absolute: 'Surcharge %value%' +invoice.apartment_modifier.label.discount_percent: 'Discount %value%%' +invoice.apartment_modifier.label.flat_rate: 'Flat rate %value%' +invoice.apartment_modifier.label.free: Free invoice.tourist_tax.position: '%tax% — %category% (%nights% × %count%)' invoice.tourist_tax.position.nights: '{0} %count% nights|{1} %count% night|]1,Inf] %count% nights' invoice.tourist_tax.position.persons: '{0} %count% people|{1} %count% person|]1,Inf] %count% people' From bdcebba6147372abc55ed80bdec32f1c75db23db Mon Sep 17 00:00:00 2001 From: Alexander Elchlepp Date: Sat, 9 May 2026 13:18:13 +0200 Subject: [PATCH 12/38] #204 applied guestcategory, modifier and tourist tax to public booking --- migrations/Version20260505120000.php | 5 +- src/Controller/GuestCategoryController.php | 6 + src/Controller/PublicBookingController.php | 107 +++++++- src/Service/GuestCategoryAgeMapper.php | 113 ++++++++ src/Service/InvoiceService.php | 2 +- src/Service/PublicAvailabilityService.php | 13 +- src/Service/PublicBookingService.php | 144 +++++++++- src/Service/PublicPricingService.php | 36 ++- templates/GuestCategory/index.html.twig | 7 + templates/PublicBooking/book.html.twig | 200 ++++++++++++-- tests/Unit/GuestCategoryAgeMapperTest.php | 131 +++++++++ tests/Unit/GuestCategorySeederTest.php | 93 +++++++ tests/Unit/PublicBookingGuestCountsTest.php | 287 ++++++++++++++++++++ tests/Unit/PublicPricingServiceTest.php | 85 ++++++ translations/OnlineBooking/messages.de.yaml | 9 + translations/OnlineBooking/messages.en.yaml | 9 + 16 files changed, 1210 insertions(+), 37 deletions(-) create mode 100644 src/Service/GuestCategoryAgeMapper.php create mode 100644 tests/Unit/GuestCategoryAgeMapperTest.php create mode 100644 tests/Unit/GuestCategorySeederTest.php create mode 100644 tests/Unit/PublicBookingGuestCountsTest.php diff --git a/migrations/Version20260505120000.php b/migrations/Version20260505120000.php index ffa461e9..3637dbc0 100644 --- a/migrations/Version20260505120000.php +++ b/migrations/Version20260505120000.php @@ -57,10 +57,7 @@ public function up(Schema $schema): void $this->addSql("INSERT INTO guest_categories (name, acronym, min_age, max_age, is_counted_in_occupancy, statistical_group, sort_order, active, system_code) VALUES - ('Erwachsene', 'ERW', 18, NULL, 1, 'adult', 10, 1, 'default_adult'), - ('Kind 6-17', 'K6-17', 6, 17, 1, 'child', 20, 1, 'default_child'), - ('Kleinkind 0-5', 'BABY', 0, 5, 0, 'infant', 30, 1, 'default_infant'), - ('Nichtpflichtige Personen','NP', NULL, NULL, 1, 'other', 40, 1, 'default_exempt')"); + ('Erwachsene', 'ERW', 18, NULL, 1, 'adult', 10, 1, 'default_adult')"); // Backfill existing reservations into the default-adult bucket so // guest_counts is the authoritative source from now on. diff --git a/src/Controller/GuestCategoryController.php b/src/Controller/GuestCategoryController.php index 66dd5f87..2cb43e86 100644 --- a/src/Controller/GuestCategoryController.php +++ b/src/Controller/GuestCategoryController.php @@ -74,6 +74,12 @@ public function edit(ManagerRegistry $doctrine, Request $request, GuestCategory public function delete(ManagerRegistry $doctrine, Request $request, GuestCategory $category): Response { if ($this->isCsrfTokenValid('delete'.$category->getId(), $request->request->get('_token'))) { + // prevent deletion of default adult category + if ($category->isSystem() && $category->getSystemCode() === 'default_adult') { + $this->addFlash('warning', 'status.flash.delete.error.system'); + + return new Response('', Response::HTTP_NO_CONTENT); + } $em = $doctrine->getManager(); $em->remove($category); $em->flush(); diff --git a/src/Controller/PublicBookingController.php b/src/Controller/PublicBookingController.php index 7fe1f4fc..050d1ef3 100644 --- a/src/Controller/PublicBookingController.php +++ b/src/Controller/PublicBookingController.php @@ -9,6 +9,8 @@ use App\Service\OnlineBookingRestrictionService; use App\Service\PublicBookingAbuseProtectionService; use App\Service\PublicBookingService; +use App\Repository\GuestCategoryRepository; +use App\Service\GuestCategoryAgeMapper; use Symfony\Component\Intl\Countries; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Request; @@ -25,6 +27,8 @@ public function book( OnlineBookingConfigService $configService, PublicBookingAbuseProtectionService $abuseProtectionService, OnlineBookingRestrictionService $restrictionService, + GuestCategoryRepository $guestCategoryRepository, + GuestCategoryAgeMapper $ageMapper, ): Response { $config = $configService->getConfig(); @@ -41,10 +45,18 @@ public function book( } } + // Public-Mode: OTHER-Statistik-Kategorien sind ein Backend-Konzept + // (Statistik-Berichte) und gehören nicht in die Endkunden-UI. + $guestCategories = array_values(array_filter( + $guestCategoryRepository->findActiveOrdered(), + static fn ($c) => 'other' !== $c->getStatisticalGroup()->value, + )); + $view = [ 'embed' => $embed, 'config' => $config, 'countries' => $countries, + 'guestCategories' => $guestCategories, 'errorMessage' => $error, 'successMessage' => $successMessage, 'minArrivalDate' => (new \DateTimeImmutable('tomorrow'))->format('Y-m-d'), @@ -57,6 +69,11 @@ public function book( 'dateTo' => (string) $request->request->get('dateTo', ''), 'persons' => (int) $request->request->get('persons', 1), 'roomsCount' => (int) $request->request->get('roomsCount', 1), + 'adults' => max(1, (int) $request->request->get('adults', 1)), + 'childAges' => array_values(array_filter( + (array) $request->request->all('childAges'), + static fn ($v) => is_numeric($v), + )), ], 'availability' => [], 'selectedQty' => [], @@ -80,6 +97,9 @@ public function book( 'extrasTotalFormatted' => null, 'grandTotalFormatted' => null, 'extrasBreakdown' => [], + 'touristTaxLines' => [], + 'touristTaxTotalFormatted' => null, + 'touristTaxTotal' => 0.0, 'bookingResult' => null, ]; @@ -103,6 +123,20 @@ public function book( } [$dateFrom, $dateTo, $persons, $roomsCount] = $this->parseSearchInput($request); + $guestCounts = $this->resolveGuestCounts($request, $ageMapper); + // Derive persons from the guestCounts when the wizard supplied them + // — `persons` request field may still carry the legacy fallback. + if ([] !== $guestCounts) { + $derived = 0; + foreach ($guestCategoryRepository->findActiveOrdered() as $cat) { + if ($cat->isCountedInOccupancy()) { + $derived += $guestCounts[(int) $cat->getId()] ?? 0; + } + } + if ($derived > 0) { + $persons = $derived; + } + } $maxDeparture = $restrictionService->getMaxDepartureDate(); if (null !== $maxDeparture && $dateTo > $maxDeparture) { @@ -110,14 +144,14 @@ public function book( } if ('availability' === $intent) { - $preview = $publicBookingService->buildSelectionPreview($dateFrom, $dateTo, $persons, $roomsCount, [], $request); + $preview = $publicBookingService->buildSelectionPreview($dateFrom, $dateTo, $persons, $roomsCount, [], $request, [], $guestCounts); $view['availabilityChecked'] = true; $view['step'] = 2; $view['availability'] = $preview['availability']; $view['extras'] = $preview['extras']; $view['formState'] = $abuseProtectionService->createFormState(false); } elseif ('preview' === $intent) { - $preview = $publicBookingService->buildSelectionPreview($dateFrom, $dateTo, $persons, $roomsCount, $occupancySelection, $request, $extrasSelection); + $preview = $publicBookingService->buildSelectionPreview($dateFrom, $dateTo, $persons, $roomsCount, $occupancySelection, $request, $extrasSelection, $guestCounts); $view['availabilityChecked'] = true; $view['step'] = 3; $view['availability'] = $preview['availability']; @@ -129,6 +163,9 @@ public function book( $view['extrasTotalFormatted'] = $preview['extrasTotalFormatted']; $view['extrasBreakdown'] = $preview['extrasBreakdown']; $view['grandTotalFormatted'] = $preview['grandTotalFormatted']; + $view['touristTaxLines'] = $preview['touristTaxLines']; + $view['touristTaxTotalFormatted'] = $preview['touristTaxTotalFormatted']; + $view['touristTaxTotal'] = $preview['touristTaxTotal']; $view['formState'] = $abuseProtectionService->createFormState(true); } elseif ('submit' === $intent) { $result = $publicBookingService->createBooking( @@ -140,6 +177,7 @@ public function book( $this->extractBookerInput($request, $defaultCountry), $request, $extrasSelection, + $guestCounts, ); $view['step'] = 4; @@ -167,7 +205,7 @@ public function book( try { $selectedForPreview = 'submit' === $intent ? $occupancySelection : []; $selectedExtrasForPreview = 'submit' === $intent ? $extrasSelection : []; - $fallbackPreview = $publicBookingService->buildSelectionPreview($dateFrom, $dateTo, $persons, $roomsCount, $selectedForPreview, $request, $selectedExtrasForPreview); + $fallbackPreview = $publicBookingService->buildSelectionPreview($dateFrom, $dateTo, $persons, $roomsCount, $selectedForPreview, $request, $selectedExtrasForPreview, $guestCounts ?? []); $view['availabilityChecked'] = true; $view['availability'] = $fallbackPreview['availability']; $view['extras'] = $fallbackPreview['extras'] ?? []; @@ -234,6 +272,69 @@ private function parseSearchInput(Request $request): array return [$dateFrom, $dateTo, $persons, $roomsCount]; } + /** + * Resolves the wizard's per-category counts into a `{categoryId: count}` map. + * + * Preferred input: `adults` (int) + `childAges[]` + * (array of int ages). The age mapper looks up each child's age against + * the configured GuestCategory ranges. This keeps the public UI to two + * inputs even when the hotelier has many child tiers. + * + * Fallback input: `guestCounts` JSON keyed directly by category id — + * used by API clients or non-browser submissions. + * + * @return array + */ + private function resolveGuestCounts(Request $request, GuestCategoryAgeMapper $ageMapper): array + { + $adultsRaw = $request->request->get('adults'); + $childAgesRaw = $request->request->all('childAges'); + if (null !== $adultsRaw || (is_array($childAgesRaw) && [] !== $childAgesRaw)) { + $adults = max(0, (int) $adultsRaw); + $childAges = []; + if (is_array($childAgesRaw)) { + foreach ($childAgesRaw as $age) { + $age = (int) $age; + if ($age >= 0 && $age <= 120) { + $childAges[] = $age; + } + } + } + + return $ageMapper->map($adults, $childAges); + } + + return $this->parseGuestCounts($request); + } + + /** + * Legacy fallback: directly parse a `guestCounts` JSON field + * (`{categoryId: count}`) from the request. + * + * @return array + */ + private function parseGuestCounts(Request $request): array + { + $raw = $request->request->get('guestCounts'); + if (!is_string($raw) || '' === $raw) { + return []; + } + $decoded = json_decode($raw, true); + if (!is_array($decoded)) { + return []; + } + $normalized = []; + foreach ($decoded as $catId => $count) { + $catId = (int) $catId; + $count = (int) $count; + if ($catId > 0 && $count > 0) { + $normalized[$catId] = $count; + } + } + + return $normalized; + } + /** * Extract occupancy-based selection from POST fields. * diff --git a/src/Service/GuestCategoryAgeMapper.php b/src/Service/GuestCategoryAgeMapper.php new file mode 100644 index 00000000..14b80946 --- /dev/null +++ b/src/Service/GuestCategoryAgeMapper.php @@ -0,0 +1,113 @@ + categoryId => count + */ + public function map(int $adults, array $childAges): array + { + $categories = array_filter( + $this->guestCategoryRepository->findActiveOrdered(), + static fn (GuestCategory $c) => GuestStatisticalGroup::OTHER !== $c->getStatisticalGroup(), + ); + + $adultCategory = null; + $childCategories = []; + foreach ($categories as $c) { + if (GuestStatisticalGroup::ADULT === $c->getStatisticalGroup()) { + if (null === $adultCategory || $c->getSortOrder() < $adultCategory->getSortOrder()) { + $adultCategory = $c; + } + } else { + $childCategories[] = $c; + } + } + + $counts = []; + if ($adults > 0 && null !== $adultCategory) { + $counts[(int) $adultCategory->getId()] = $adults; + } + + if ([] === $childCategories) { + return $counts; + } + + foreach ($childAges as $age) { + $age = (int) $age; + if ($age < 0) { + continue; + } + $match = $this->matchByAge($childCategories, $age); + if (null === $match) { + continue; + } + $id = (int) $match->getId(); + $counts[$id] = ($counts[$id] ?? 0) + 1; + } + + return $counts; + } + + /** + * @param GuestCategory[] $categories + */ + private function matchByAge(array $categories, int $age): ?GuestCategory + { + $best = null; + foreach ($categories as $c) { + $minAge = $c->getMinAge(); + $maxAge = $c->getMaxAge(); + if (null !== $minAge && $age < $minAge) { + continue; + } + if (null !== $maxAge && $age > $maxAge) { + continue; + } + if (null === $best + || $c->getSortOrder() < $best->getSortOrder() + || ($c->getSortOrder() === $best->getSortOrder() && (int) $c->getId() < (int) $best->getId()) + ) { + $best = $c; + } + } + + return $best; + } +} diff --git a/src/Service/InvoiceService.php b/src/Service/InvoiceService.php index 362d81f1..e8027f4c 100644 --- a/src/Service/InvoiceService.php +++ b/src/Service/InvoiceService.php @@ -563,7 +563,7 @@ public function buildApartmentModifierPositions(array $reservations): array $positions[] = $this->makeApartmentModifierPosition($a); } } -dump($positions); + return $positions; } diff --git a/src/Service/PublicAvailabilityService.php b/src/Service/PublicAvailabilityService.php index 87908ce5..c9b61bed 100644 --- a/src/Service/PublicAvailabilityService.php +++ b/src/Service/PublicAvailabilityService.php @@ -44,12 +44,19 @@ public function __construct( * occupancyAvailableCounts: array * }> */ + /** + * @param array $guestCounts category-id => count from the wizard, + * forwarded to per-occupancy pricing so the option that matches the + * user's mix reflects modifier deltas (children's discount etc.) already + * in step 2. + */ public function getAvailability( \DateTimeImmutable $dateFrom, \DateTimeImmutable $dateTo, int $persons, int $roomsCount, - ?OnlineBookingConfig $config = null + ?OnlineBookingConfig $config = null, + array $guestCounts = [] ): array { if ($dateFrom > $dateTo || $persons < 1 || $roomsCount < 1 || $persons < $roomsCount) { return []; @@ -148,6 +155,10 @@ public function getAvailability( $dateFrom, $dateTo, min((int) $row['maxGuests'], $persons), + $guestCounts, + // `$persons` is already the occupancy-counted total derived by + // the controller from the user's guestCounts mix. + $persons, ); // Apply minimum occupancy restriction: remove occupancy options below threshold diff --git a/src/Service/PublicBookingService.php b/src/Service/PublicBookingService.php index 2219f214..15ce882b 100644 --- a/src/Service/PublicBookingService.php +++ b/src/Service/PublicBookingService.php @@ -16,6 +16,7 @@ use App\Entity\Template; use App\Repository\AppartmentRepository; use App\Repository\CustomerRepository; +use App\Repository\GuestCategoryRepository; use App\Event\OnlineBookingCreatedEvent; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\ORM\EntityManagerInterface; @@ -37,6 +38,8 @@ public function __construct( private readonly TranslatorInterface $translator, private readonly EventDispatcherInterface $eventDispatcher, private readonly PublicPricingService $pricingService, + private readonly ?GuestCategoryRepository $guestCategoryRepository = null, + private readonly ?TouristTaxService $touristTaxService = null, ) { } @@ -55,9 +58,10 @@ public function buildSelectionPreview( array $occupancySelection, Request $request, array $selectedExtras = [], + array $guestCounts = [], ): array { $config = $this->configService->getConfig(); - $availability = $this->availabilityService->getAvailability($dateFrom, $dateTo, $persons, $roomsCount, $config); + $availability = $this->availabilityService->getAvailability($dateFrom, $dateTo, $persons, $roomsCount, $config, $guestCounts); $selection = $this->normalizeOccupancySelection($occupancySelection); if ([] === $selection && [] !== $occupancySelection) { @@ -69,12 +73,15 @@ public function buildSelectionPreview( } $assignedRoomsWithPersons = $this->assignRoomsWithOccupancy($availability, $selection); $roomReservations = $this->buildTransientReservationsWithExplicitPersons($assignedRoomsWithPersons, $dateFrom, $dateTo); + $this->distributeGuestCounts($roomReservations, $guestCounts); $pricing = $this->calculateRoomTotal($roomReservations); // Load bookable extras using any available room as sample $extras = $this->loadBookableExtras($availability, $dateFrom, $dateTo, $persons, $roomsCount); $extrasResult = $this->calculateExtrasTotal($extras, $selectedExtras); + $grandTotal = $pricing['roomTotal'] + $extrasResult['extrasTotal'] + $pricing['touristTaxTotal']; + return [ 'availability' => $availability, 'selected' => $selection, @@ -82,13 +89,16 @@ public function buildSelectionPreview( 'roomTotalFormatted' => $pricing['roomTotalFormatted'], 'roomPriceBreakdown' => $pricing['roomPriceBreakdown'], 'roomReservations' => $roomReservations, + 'touristTaxTotal' => $pricing['touristTaxTotal'], + 'touristTaxTotalFormatted' => $pricing['touristTaxTotalFormatted'], + 'touristTaxLines' => $pricing['touristTaxLines'], 'extras' => $extras, 'selectedExtras' => $selectedExtras, 'extrasTotal' => $extrasResult['extrasTotal'], 'extrasTotalFormatted' => $extrasResult['extrasTotalFormatted'], 'extrasBreakdown' => $extrasResult['extrasBreakdown'], - 'grandTotal' => $pricing['roomTotal'] + $extrasResult['extrasTotal'], - 'grandTotalFormatted' => number_format($pricing['roomTotal'] + $extrasResult['extrasTotal'], 2, ',', '.'), + 'grandTotal' => $grandTotal, + 'grandTotalFormatted' => number_format($grandTotal, 2, ',', '.'), ]; } @@ -109,16 +119,18 @@ public function createBooking( array $booker, Request $request, array $selectedExtras = [], + array $guestCounts = [], ): array { $config = $this->configService->getConfig(); $this->assertConfigReady($config); $selection = $this->normalizeOccupancySelection($occupancySelection); - $availability = $this->availabilityService->getAvailability($dateFrom, $dateTo, $persons, $roomsCount, $config); + $availability = $this->availabilityService->getAvailability($dateFrom, $dateTo, $persons, $roomsCount, $config, $guestCounts); $this->validateOccupancySelectionAgainstAvailability($selection, $availability, $persons, $roomsCount); $assignedRoomsWithPersons = $this->assignRoomsWithOccupancy($availability, $selection); $reservations = $this->buildTransientReservationsWithExplicitPersons($assignedRoomsWithPersons, $dateFrom, $dateTo); + $this->distributeGuestCounts($reservations, $guestCounts); // Validate and resolve selected extras $extraPrices = $this->resolveAndValidateExtras($selectedExtras, $availability, $dateFrom, $dateTo, $persons, $roomsCount); @@ -471,19 +483,119 @@ private function buildTransientReservationsWithExplicitPersons(array $assignedRo return $reservations; } + /** + * Distribute the wizard's per-category counts onto the (already + * persons-distributed) transient reservations. + * + * Single-room: full guestCounts go to the only reservation. + * Multi-room: greedy distribution — guarantees ≥1 adult per room (when + * adults available), then fills each room up to its `persons` capacity + * by walking through ADULT, then non-ADULT occupancy categories. Any + * non-occupancy categories (e.g. infants) are attached to the first + * reservation. This is a heuristic, not a user-controlled split. + * + * @param Reservation[] $reservations + * @param array $guestCounts category-id => count + */ + private function distributeGuestCounts(array $reservations, array $guestCounts): void + { + if ([] === $reservations || [] === $guestCounts || null === $this->guestCategoryRepository) { + return; + } + + $categories = []; + foreach ($this->guestCategoryRepository->findAll() as $gc) { + $categories[$gc->getId()] = $gc; + } + + // Single-room shortcut: full counts on the single reservation. + if (1 === count($reservations)) { + $reservations[0]->setGuestCounts($guestCounts); + + return; + } + + // Multi-room: build buckets by occupancy semantics. + $adultIds = []; + $otherOccupancyIds = []; + $nonOccupancyIds = []; + foreach ($guestCounts as $catId => $count) { + $cat = $categories[$catId] ?? null; + if (null === $cat || $count <= 0) { + continue; + } + if (!$cat->isCountedInOccupancy()) { + $nonOccupancyIds[] = $catId; + continue; + } + if ($cat->isAdult()) { + $adultIds[] = $catId; + } else { + $otherOccupancyIds[] = $catId; + } + } + + $remaining = $guestCounts; + $perRoomCounts = array_fill(0, count($reservations), []); + + // Pass 1: guarantee 1 adult per room (when adults available). + foreach ($reservations as $idx => $_) { + foreach ($adultIds as $catId) { + if (($remaining[$catId] ?? 0) > 0) { + $perRoomCounts[$idx][$catId] = ($perRoomCounts[$idx][$catId] ?? 0) + 1; + --$remaining[$catId]; + break; + } + } + } + + // Pass 2: fill each room up to its persons capacity, drawing first + // from remaining adults, then from other occupancy categories. + foreach ($reservations as $idx => $reservation) { + $capacity = $reservation->getPersons(); + $taken = array_sum($perRoomCounts[$idx]); + foreach ([...$adultIds, ...$otherOccupancyIds] as $catId) { + while ($taken < $capacity && ($remaining[$catId] ?? 0) > 0) { + $perRoomCounts[$idx][$catId] = ($perRoomCounts[$idx][$catId] ?? 0) + 1; + --$remaining[$catId]; + ++$taken; + } + } + } + + // Pass 3: non-occupancy categories — all remaining attach to room 1. + foreach ($nonOccupancyIds as $catId) { + if (($remaining[$catId] ?? 0) > 0) { + $perRoomCounts[0][$catId] = ($perRoomCounts[0][$catId] ?? 0) + $remaining[$catId]; + $remaining[$catId] = 0; + } + } + + foreach ($reservations as $idx => $reservation) { + if ([] !== $perRoomCounts[$idx]) { + $reservation->setGuestCounts($perRoomCounts[$idx]); + } + } + } + /** * Calculate the room-only total using session-free apartment position building. + * Includes apartment-modifier delta lines and tourist-tax positions when + * configured — both come from the apartment-pricing pipeline. * * @param Reservation[] $reservations - * @return array{roomTotal: float, roomTotalFormatted: string, roomPriceBreakdown: array} + * @return array{roomTotal: float, roomTotalFormatted: string, roomPriceBreakdown: array, touristTaxTotal: float, touristTaxTotalFormatted: string, touristTaxLines: array} */ private function calculateRoomTotal(array $reservations): array { $apartmentTotal = 0.0; $breakdown = []; + $touristTaxTotal = 0.0; + $touristTaxLines = []; foreach ($reservations as $reservation) { $positions = $this->invoiceService->buildAppartmentPositions($reservation); + $modifierPositions = $this->invoiceService->buildApartmentModifierPositions([$reservation]); $vatSums = []; $brutto = 0.0; @@ -492,13 +604,16 @@ private function calculateRoomTotal(array $reservations): array $miscTotal = 0.0; $this->invoiceService->calculateSums( new ArrayCollection($positions), - new ArrayCollection(), + new ArrayCollection($modifierPositions), $vatSums, $brutto, $netto, $singleTotal, $miscTotal ); + // Modifier deltas net into apartment total — semantically they're + // adjustments to the room rate (same scope as the journal routing). + $singleTotal += $miscTotal; $label = $this->buildReservationTypeLabel($reservation); if (!isset($breakdown[$label])) { @@ -512,6 +627,20 @@ private function calculateRoomTotal(array $reservations): array $breakdown[$label]['quantity']++; $breakdown[$label]['total'] += $singleTotal; $apartmentTotal += $singleTotal; + + // Tourist-tax breakdown stays a separate line on the preview so + // the guest sees the levy distinctly from the room rate. + if (null !== $this->touristTaxService) { + foreach ($this->touristTaxService->calculateForReservation($reservation) as $row) { + $rowTotal = $row->total(); + $touristTaxTotal += $rowTotal; + $touristTaxLines[] = [ + 'label' => $row->taxName.' — '.$row->categoryName, + 'total' => $rowTotal, + 'totalFormatted' => number_format($rowTotal, 2, ',', '.'), + ]; + } + } } $formattedBreakdown = array_map(static function (array $row): array { @@ -524,6 +653,9 @@ private function calculateRoomTotal(array $reservations): array 'roomTotal' => $apartmentTotal, 'roomTotalFormatted' => number_format($apartmentTotal, 2, ',', '.'), 'roomPriceBreakdown' => $formattedBreakdown, + 'touristTaxTotal' => $touristTaxTotal, + 'touristTaxTotalFormatted' => number_format($touristTaxTotal, 2, ',', '.'), + 'touristTaxLines' => $touristTaxLines, ]; } diff --git a/src/Service/PublicPricingService.php b/src/Service/PublicPricingService.php index a1abe366..8d517800 100644 --- a/src/Service/PublicPricingService.php +++ b/src/Service/PublicPricingService.php @@ -27,6 +27,25 @@ public function __construct( * For a given room category, date range and max occupancy, compute the total stay price * for each valid number-of-persons (1..maxGuests) that has a matching price category. * + * When `guestCounts` is supplied (pattern from the public wizard) and its + * occupancy-counted total matches the option's persons count, the option is priced with + * the *actual* category mix — i.e. apartment-modifier deltas (children's discount etc.) + * are reflected in the displayed step-2 price, so the guest sees the same number in + * step 2 and step 3. Other occupancy options (1..maxGuests except the matching one) keep + * the legacy adult-only fallback because the wizard hasn't asked for those mixes. + * + * Tourist tax is intentionally **not** applied here — it is shown as a separate line at + * the end of the booking flow, not bundled into the room rate. + * + * @param array $guestCounts category-id => count from the wizard search step + * @param int $mixOccupancyPersons sum of occupancy-counted entries in $guestCounts; + * used to decide which occupancy option matches the + * user's mix and should reflect the modifier-aware + * price. The caller already knows this value + * (controller derives it from `isCountedInOccupancy`) + * — passing it here avoids re-injecting the + * GuestCategoryRepository into this service. + * * @return array * Indexed by persons count. Only entries with a non-zero price are returned. */ @@ -36,6 +55,8 @@ public function getOccupancyPrices( \DateTimeImmutable $dateFrom, \DateTimeImmutable $dateTo, int $maxGuests, + array $guestCounts = [], + int $mixOccupancyPersons = 0, ): array { $origin = $this->configService->getReservationOrigin(); $options = []; @@ -50,11 +71,21 @@ public function getOccupancyPrices( $reservation->setReservationOrigin($origin); } + // Apply the user's actual mix only on the option that matches the + // occupancy-counted total of the mix. For other rows we keep the + // adult-only baseline so the table still reflects "what would N + // adults cost in this room". + if ($mixOccupancyPersons > 0 && $mixOccupancyPersons === $persons && [] !== $guestCounts) { + $reservation->setGuestCounts($guestCounts); + } + $positions = $this->invoiceService->buildAppartmentPositions($reservation); if ([] === $positions) { continue; } + $modifierPositions = $this->invoiceService->buildApartmentModifierPositions([$reservation]); + $vatSums = []; $brutto = 0.0; $netto = 0.0; @@ -62,13 +93,16 @@ public function getOccupancyPrices( $miscTotal = 0.0; $this->invoiceService->calculateSums( new ArrayCollection($positions), - new ArrayCollection(), + new ArrayCollection($modifierPositions), $vatSums, $brutto, $netto, $singleTotal, $miscTotal, ); + // Modifier deltas net into the room total (same scope as the + // booking-journal routing: apartment_modifier groups with apartment). + $singleTotal += $miscTotal; if ($singleTotal <= 0.0) { continue; diff --git a/templates/GuestCategory/index.html.twig b/templates/GuestCategory/index.html.twig index 4311b172..a52f8a8d 100644 --- a/templates/GuestCategory/index.html.twig +++ b/templates/GuestCategory/index.html.twig @@ -53,6 +53,7 @@ {% endif %} + + {% if category.systemCode != 'default_adult'%} {% set id = category.id %} {% set targetUrl = path('guest_category_delete', {'id': category.id}) %} {% use "common/delete_popover.html.twig" %} @@ -72,6 +74,11 @@ data-popover="delete" data-title="{{ 'guest_category.delete.ask'|trans }}" data-bs-content='{{ block('deletePopoverContent') }}'> + {% else %} + + + + {% endif %} {% endfor %} diff --git a/templates/PublicBooking/book.html.twig b/templates/PublicBooking/book.html.twig index bafd848a..d4be2f79 100644 --- a/templates/PublicBooking/book.html.twig +++ b/templates/PublicBooking/book.html.twig @@ -67,23 +67,100 @@
{# ── Step 1: Search ── #} -
-
- - -
-
- - -
-
- - -
-
- - + {% set hasCategories = guestCategories|default([])|length > 0 %} + {% set hasChildCategory = hasCategories and (guestCategories|filter(c => c.statisticalGroup.value != 'adult')|length > 0) %} + {% set initialChildAges = search.childAges|default([]) %} + {# Booking.com-Pattern: zwei Stepper (Erwachsene + Kinder), pro Kind ein Alters-Select. + Server mappt das Alter via GuestCategoryAgeMapper auf die richtige Kategorie. #} + + +
+
+ + +
+
+ + +
+
+ + +
+ + {% if hasCategories %} +
+ + +
+ {% else %} +
+ + +
+ {% endif %}
+ + {% if hasChildCategory %} +
+ + + {{ 'online_booking.public.guests.add_children'|trans }} + +
+
+
+ + +
+
+
+ {% for age in initialChildAges %} +
+ + +
+ {% endfor %} +
+
+
+
+
+ {% endif %} + + {% if hasCategories %} +
+ {{ 'online_booking.public.guests.adult_required'|trans }} +
+ + {# Hidden persons input — synced by JS as adults + children for capacity filtering. #} + + {# Inline template used by JS to clone new age-select rows. #} + + {% endif %}
@@ -392,15 +469,36 @@
{% endif %} - {% if grandTotalFormatted and extrasBreakdown is not empty %} + {# Tourist tax breakdown — eigene Rubrik, dezent abgegrenzt #} + {% if touristTaxLines is defined and touristTaxLines|length > 0 %} +
+
{{ 'invoice.tourist_tax.heading'|trans }}
+ {% for taxRow in touristTaxLines %} +
+ {{ taxRow.label }} + {{ taxRow.totalFormatted }} {{ currency_symbol }} +
+ {% endfor %} +
+ {% endif %} + + {% if grandTotalFormatted and (extrasBreakdown is not empty or (touristTaxLines is defined and touristTaxLines|length > 0)) %}
{{ 'online_booking.public.room_total'|trans }} {{ roomTotalFormatted }} {{ currency_symbol }}
-
- {{ 'online_booking.public.extras_total'|trans }} - {{ extrasTotalFormatted }} {{ currency_symbol }} -
+ {% if extrasBreakdown is not empty %} +
+ {{ 'online_booking.public.extras_total'|trans }} + {{ extrasTotalFormatted }} {{ currency_symbol }} +
+ {% endif %} + {% if touristTaxLines is defined and touristTaxLines|length > 0 %} +
+ {{ 'invoice.tourist_tax.heading'|trans }} + {{ touristTaxTotalFormatted }} {{ currency_symbol }} +
+ {% endif %}
{{ 'online_booking.public.grand_total'|trans }} {{ grandTotalFormatted }} {{ currency_symbol }} @@ -469,4 +567,64 @@ })(); {% endif %} + +{# Public guest-counts wiring — Vanilla-JS, da auf der Public-Seite kein + Stimulus läuft. Verarbeitet zwei Standard-Number-Inputs (Erwachsene + + Kinder), rendert pro Kind einen Alters-Select via