diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..5b13b837 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,45 @@ +# VCS / IDE +.git/ +.gitignore +.gitattributes +.idea/ +.vscode/ +.DS_Store +nbproject/ + +# Build / runtime artifacts (regenerated in image build) +vendor/ +var/ +node_modules/ +public/assets/ +public/bundles/ +assets/vendor/ + +# Local env / secrets (never bake into image) +.env +.env.local +.env.*.local +.env.dev +.env.test + +# Test artifacts +.phpunit +.phpunit.result.cache +.phpunit.cache/ +phpunit.xml + +# User uploads (mounted at runtime, never bake) +public/resources/images/export/ +public/resources/images/room-categories/ + +# Tooling +.php-cs-fixer.php +.php-cs-fixer.cache +phpstan.neon +tools/ + +# Docker context itself (Dockerfiles under docker/ are needed) +.dockerignore + +# CI +.github/ diff --git a/.env.dist b/.env.dist index 15c1391d..53485b61 100644 --- a/.env.dist +++ b/.env.dist @@ -36,7 +36,9 @@ RETURN_PATH=info@domain.tld MAIL_COPY=true ### mailer settings ### -### redis settings (only used when APP_ENV is set to "redis") ### +### redis settings (only used when USE_REDIS_CACHE is true) ### +# Set to true to use Redis as Symfony cache backend, false for filesystem cache. +USE_REDIS_CACHE=false REDIS_IDX=1 REDIS_HOST=redis @@ -60,4 +62,9 @@ RELYING_PARTY_ID=example.com RELYING_PARTY_NAME="FewohBee" # Enable or disable passkey login/management PASSKEY_ENABLED=false -###< passkeys ### \ No newline at end of file +###< passkeys ### + +# this token is used to protect the health /health/ready endpoint, generate a random string and set it here, e.g. with "openssl rand -base64 32" +# leave empty for public access to the health endpoint, which is not recommended for production environments +# to access the health endpoint with a token, add X-Health-Token header with the token value to the request, e.g. with curl: "curl -H 'X-Health-Token: ' https://fewohbee/health/ready" +HEALTH_TOKEN= \ No newline at end of file diff --git a/.github/workflows/images.yml b/.github/workflows/images.yml new file mode 100644 index 00000000..89fe096a --- /dev/null +++ b/.github/workflows/images.yml @@ -0,0 +1,190 @@ +name: Build & publish images + +on: + push: + branches: ['**'] + tags: ['v*'] + pull_request: + workflow_dispatch: + +env: + REGISTRY: ghcr.io + IMAGE_OWNER: developeregrem + +permissions: + contents: read + packages: write + +jobs: + # ------------------------------------------------------------------------- + # phpfpm-prod, phpfpm-debug, cli-prod — all from docker/app/Dockerfile. + # base/vendor-prod/app-prod stages are shared, so cache:warmup runs once per + # build and is reused across phpfpm-prod and cli-prod. + # nginx depends on phpfpm-prod and runs after. + # ------------------------------------------------------------------------- + build-app: + name: ${{ matrix.image }} (${{ matrix.target }}) + runs-on: ubuntu-latest + # Skip duplicate runs: when a PR is opened from a same-repo branch, GitHub + # fires BOTH push (for the branch) and pull_request (for the PR) events. + # The push run is authoritative (it pushes to GHCR); the PR run would only + # build locally and confuse downstream jobs (nginx can't pull phpfpm). + # External fork PRs still run (head repo != base repo). + if: >- + github.event_name != 'pull_request' || + github.event.pull_request.head.repo.full_name != github.repository + strategy: + fail-fast: false + matrix: + include: + - target: phpfpm-prod + image: phpfpm + suffix: '' + - target: phpfpm-debug + image: phpfpm + suffix: '-debug' + - target: cli-prod + image: cli + suffix: '' + steps: + - uses: actions/checkout@v4 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to GHCR + if: github.event_name != 'pull_request' + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_OWNER }}/fewohbee-${{ matrix.image }} + flavor: | + suffix=${{ matrix.suffix }},onlatest=true + tags: | + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=semver,pattern={{major}} + type=ref,event=branch,prefix=branch- + type=ref,event=pr,prefix=pr- + type=sha,prefix=sha- + type=edge,branch=master + type=raw,value=latest,enable={{is_default_branch}} + + - name: Build & push + uses: docker/build-push-action@v6 + with: + context: . + file: docker/app/Dockerfile + target: ${{ matrix.target }} + platforms: linux/amd64,linux/arm64 + push: ${{ github.event_name != 'pull_request' }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha,scope=app-${{ matrix.target }} + cache-to: type=gha,scope=app-${{ matrix.target }},mode=max + + # ------------------------------------------------------------------------- + # nginx pulls compiled public/assets from phpfpm-prod via COPY --from. + # Runs after build-app so the phpfpm image is in GHCR. + # ------------------------------------------------------------------------- + build-nginx: + name: nginx (prod) + runs-on: ubuntu-latest + needs: build-app + if: >- + github.event_name != 'pull_request' || + github.event.pull_request.head.repo.full_name != github.repository + steps: + - uses: actions/checkout@v4 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to GHCR + if: github.event_name != 'pull_request' + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Resolve phpfpm tag for COPY --from + id: phpfpm-tag + # Use a tag we KNOW metadata-action emits for the phpfpm-prod build: + # - v* tag push -> bare semver (4.8.0) + # - branch push -> branch- (slashes/dots sanitized to '-') + # - PR / other -> sha- as last resort + env: + REF: ${{ github.ref }} + BRANCH: ${{ github.ref_name }} + SHA: ${{ github.sha }} + run: | + if [[ "$REF" == refs/tags/v* ]]; then + TAG="${REF#refs/tags/v}" + elif [[ "$REF" == refs/heads/* ]]; then + TAG="branch-${BRANCH//[\/.]/-}" + else + TAG="sha-${SHA:0:7}" + fi + echo "Resolved phpfpm tag: $TAG" + echo "tag=$TAG" >> "$GITHUB_OUTPUT" + + - name: Wait for phpfpm image to appear in GHCR + # build-push-action returns once the manifest list is pushed, but + # GHCR sometimes lags a few seconds before the tag becomes resolvable + # for downstream pulls. Poll the manifest with retries. + env: + IMAGE: ${{ env.REGISTRY }}/${{ env.IMAGE_OWNER }}/fewohbee-phpfpm:${{ steps.phpfpm-tag.outputs.tag }} + run: | + for i in 1 2 3 4 5 6 7 8 9 10; do + if docker buildx imagetools inspect "$IMAGE" >/dev/null 2>&1; then + echo "phpfpm image $IMAGE is available." + exit 0 + fi + echo "Attempt $i: $IMAGE not yet available, sleeping 5s ..." + sleep 5 + done + echo "Giving up: phpfpm image $IMAGE not found in registry after 50s." + exit 1 + + - name: Extract metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_OWNER }}/fewohbee-nginx + tags: | + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=semver,pattern={{major}} + type=ref,event=branch,prefix=branch- + type=ref,event=pr,prefix=pr- + type=sha,prefix=sha- + type=edge,branch=master + type=raw,value=latest,enable={{is_default_branch}} + + - name: Build & push + uses: docker/build-push-action@v6 + with: + context: . + file: docker/nginx/Dockerfile + platforms: linux/amd64,linux/arm64 + push: ${{ github.event_name != 'pull_request' }} + build-args: | + PHPFPM_IMAGE=${{ env.REGISTRY }}/${{ env.IMAGE_OWNER }}/fewohbee-phpfpm:${{ steps.phpfpm-tag.outputs.tag }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha,scope=nginx-prod + cache-to: type=gha,scope=nginx-prod,mode=max 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..6a1b5612 --- /dev/null +++ b/assets/controllers/bank_import_preview_controller.js @@ -0,0 +1,1179 @@ +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', + 'sortHeader', + 'statusCell', + 'ignoreButton', + 'commitForm', + 'commitButton', + 'commitReadyCount', + 'commitPendingCount', + 'commitIgnoredCount', + 'commitDuplicateCount', + // Split modal + 'splitModal', + 'splitTotal', + 'splitPurpose', + 'splitAssigned', + 'splitDelta', + 'splitRows', + 'splitRowTemplate', + // Rule modal + 'ruleModal', + 'ruleName', + 'ruleCondCounterparty', + 'ruleCondCounterpartyValue', + 'ruleCondIban', + 'ruleCondIbanValue', + 'ruleCondDirection', + 'ruleCondDirectionValue', + 'ruleCondPurpose', + 'ruleCondPurposeValue', + 'rulePurpose', + 'ruleDebit', + 'ruleCredit', + 'ruleTaxRate', + 'ruleRemark', + 'ruleAssignSection', + 'ruleSplitSection', + 'ruleSplitRows', + 'ruleSplitRowTemplate', + 'ruleInvoiceExtractionMode', + 'ruleInvoiceExtractionMarkerGroup', + 'ruleInvoiceExtractionMarker', + 'ruleInvoiceExtractionRegexGroup', + 'ruleInvoiceExtractionRegex', + 'ruleInvoiceExtractionPreview', + '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, + commitConfirmTemplate: String, + splitRemainderLabel: String, + splitTooMuchLabel: String, + splitOpenLabel: String, + ruleDefaultName: String, + ruleConditionRequiredMessage: String, + ruleNameRequiredMessage: String, + ruleSplitFoundLabel: String, + ruleSplitMissingLabel: String, + ruleSplitRemainderLabel: String, + ruleInvoiceFoundLabel: String, + ruleInvoiceMissingLabel: String, + ruleSplitMarkerRequiredMessage: String, + ruleSplitRegexRequiredMessage: String, + ruleSplitInvalidLabel: String, + }; + + connect() { + enableDeletePopover({ + root: this.element, + onSuccess: () => { + window.location.href = this.discardRedirectUrlValue; + }, + }); + enableTooltips(this.element); + this.activeFilter = 'all'; + this.activeIdx = null; + this.activeSortKey = null; + this.activeSortDirection = 'asc'; + this._collator = new Intl.Collator( + this.localeValue || document.documentElement.lang || undefined, + { numeric: true, sensitivity: 'base' }, + ); + 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; + + // Text inputs fire on both change + blur; debounce identical values. + if ((field === 'remark' || field === 'invoiceNumber') && input.dataset.lastSent === input.value) return; + if (field === 'remark' || field === 'invoiceNumber') 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; + }); + } + + // ── Sorting ─────────────────────────────────────────────────────── + + sortTable(event) { + const header = event.currentTarget.closest('th[data-sort-key]'); + const tbody = this.rowTargets[0]?.parentElement; + if (!header || !tbody) return; + + const key = header.dataset.sortKey; + const type = header.dataset.sortType || 'text'; + const direction = this.activeSortKey === key && this.activeSortDirection === 'asc' ? 'desc' : 'asc'; + const modifier = direction === 'asc' ? 1 : -1; + + const rows = [...this.rowTargets].sort((a, b) => { + const valueA = this._sortValue(a, key, type); + const valueB = this._sortValue(b, key, type); + const compared = this._compareSortValues(valueA, valueB, type); + if (compared !== 0) return compared * modifier; + + return this._originalRowIndex(a) - this._originalRowIndex(b); + }); + + rows.forEach((row) => tbody.appendChild(row)); + this.activeSortKey = key; + this.activeSortDirection = direction; + this._refreshSortHeaders(header, direction); + } + + _sortValue(row, key, type) { + const line = this._lineByIdx(row.dataset.idx); + switch (key) { + case 'status': + return row.dataset.status || line?.status || ''; + case 'date': + return line?.bookDate || ''; + case 'counterparty': + return [line?.counterpartyName, line?.counterpartyIban].filter(Boolean).join(' '); + case 'purpose': + return line?.purpose || ''; + case 'amount': + return parseFloat(line?.amount ?? '0') || 0; + case 'invoice': + return this._fieldSortText(row, 'invoiceNumber') || line?.matchedInvoiceNumber || ''; + case 'debit': + return this._fieldSortText(row, 'debitAccountId') || this._cellSortText(row, 7); + case 'credit': + return this._fieldSortText(row, 'creditAccountId') || this._cellSortText(row, 8); + case 'tax': + return this._fieldSortText(row, 'taxRateId') || this._cellSortText(row, 9); + case 'remark': + return this._fieldSortText(row, 'remark'); + default: + return type === 'number' ? 0 : ''; + } + } + + _compareSortValues(valueA, valueB, type) { + if (type === 'number') { + return valueA - valueB; + } + + if (type === 'date') { + return String(valueA).localeCompare(String(valueB)); + } + + if (type === 'status') { + const order = { pending: 0, ready: 1, duplicate: 2, ignored: 3 }; + return (order[valueA] ?? 99) - (order[valueB] ?? 99); + } + + return this._collator.compare(String(valueA || '').trim(), String(valueB || '').trim()); + } + + _fieldSortText(row, field) { + const fieldEl = row.querySelector(`[data-field="${field}"]`); + if (!fieldEl) return ''; + + if (fieldEl instanceof HTMLSelectElement) { + return fieldEl.selectedOptions[0]?.textContent || ''; + } + + return fieldEl.value ?? fieldEl.textContent ?? ''; + } + + _cellSortText(row, index) { + return row.cells[index]?.textContent || ''; + } + + _originalRowIndex(row) { + const idx = parseInt(row.dataset.idx, 10); + return Number.isFinite(idx) ? idx : 0; + } + + _refreshSortHeaders(activeHeader, direction) { + this.sortHeaderTargets.forEach((header) => { + const isActive = header === activeHeader; + header.setAttribute('aria-sort', isActive ? (direction === 'asc' ? 'ascending' : 'descending') : 'none'); + + const icon = header.querySelector('.sort-icon'); + if (!icon) return; + icon.innerHTML = ``; + window.FontAwesome?.dom?.i2svg?.({ node: icon }); + }); + } + + // ── 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(); + const reloadAfterSave = field === 'invoiceNumber' && this._invoiceNumberChangeNeedsReload(idx); + this._updateLineSnapshot(idx, field, value); + this._applyServerState(row, idx, json); + if (reloadAfterSave) { + window.location.reload(); + return; + } + 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); + } + + _invoiceNumberChangeNeedsReload(idx) { + const line = this._lineByIdx(idx); + return Boolean( + line?.matchedInvoiceId + && line.matchedInvoiceAmountMatches + && (!Array.isArray(line.splits) || line.splits.length === 0), + ); + } + + _applyServerState(row, idx, payload) { + if (payload?.status) { + row.dataset.status = payload.status; + const line = this._lineByIdx(idx); + if (line) { + row.dataset.invoice = (line.matchedInvoiceId || line.userInvoiceNumber) ? '1' : '0'; + } + 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); + this._refreshCommitBar(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(); + } + + _updateLineSnapshot(idx, field, value) { + const line = this._lineByIdx(idx); + if (!line) return; + + const idOrNull = (raw) => { + const parsed = parseInt(raw, 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : null; + }; + + switch (field) { + case 'debitAccountId': + line.userDebitAccountId = idOrNull(value); + break; + case 'creditAccountId': + line.userCreditAccountId = idOrNull(value); + break; + case 'taxRateId': + line.userTaxRateId = idOrNull(value); + break; + case 'remark': + line.userRemark = String(value || '').trim() || null; + break; + case 'invoiceNumber': + line.userInvoiceNumber = String(value || '').trim().slice(0, 50) || null; + break; + case 'isIgnored': + line.isIgnored = String(value) === '1'; + break; + default: + break; + } + } + + _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); + first.innerHTML = ``; + window.FontAwesome?.dom?.i2svg?.({ node: first }); + // 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]; + }); + } + + _refreshCommitBar(counts) { + if (this.hasCommitReadyCountTarget) this.commitReadyCountTarget.textContent = counts.ready ?? 0; + if (this.hasCommitPendingCountTarget) this.commitPendingCountTarget.textContent = counts.pending ?? 0; + if (this.hasCommitIgnoredCountTarget) this.commitIgnoredCountTarget.textContent = counts.ignored ?? 0; + if (this.hasCommitDuplicateCountTarget) this.commitDuplicateCountTarget.textContent = counts.duplicate ?? 0; + if (this.hasCommitButtonTarget) this.commitButtonTarget.disabled = (counts.ready ?? 0) === 0; + if (this.hasCommitFormTarget && this.hasCommitConfirmTemplateValue) { + this.commitFormTarget.dataset.confirmSubmitMessageValue = this._formatText(this.commitConfirmTemplateValue, { + '%count%': counts.ready ?? 0, + }); + } + } + + _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.splitPurposeTarget.textContent = line.purpose || '—'; + 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._defaultSplitPrefill()); + this._addSplitRow(this._defaultSplitPrefill()); + } + + this._splitRecalc(); + this._showModal(this.splitModalTarget); + } + + splitAddRow() { + this._addSplitRow(this._defaultSplitPrefill()); + 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' && prefill.amount !== undefined) 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); + } + + _defaultSplitPrefill() { + const line = this._lineByIdx(this.activeIdx); + if (!line) return null; + + return { + debitAccountId: line.userDebitAccountId || null, + creditAccountId: line.userCreditAccountId || null, + taxRateId: line.userTaxRateId || null, + }; + } + + 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' ? '↻' : this.currencySymbolValue); + } + + _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; + if (this.hasRulePurposeTarget) { + this.rulePurposeTarget.textContent = line.purpose || '—'; + } + + // 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._populateRuleInvoiceExtraction(line); + this._populateRuleSplitAction(line); + + 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 dynamic marker/remainder split rules. + const line = this._lineByIdx(this.activeIdx); + const lineSplits = Array.isArray(line?.splits) ? line.splits : []; + if (lineSplits.length > 0) { + body.set('actionMode', 'split'); + const splitRows = Array.from(this.ruleSplitRowsTarget.querySelectorAll('.rule-split-row')); + for (const [i, rowEl] of splitRows.entries()) { + const source = rowEl.querySelector('.rule-split-source')?.value || 'purpose_marker'; + const pattern = (rowEl.querySelector('.rule-split-marker')?.value || '').trim(); + if (source === 'purpose_marker' && pattern === '') { + // eslint-disable-next-line no-alert + alert(this.ruleSplitMarkerRequiredMessageValue); + return; + } + if (source === 'purpose_regex' && pattern === '') { + // eslint-disable-next-line no-alert + alert(this.ruleSplitRegexRequiredMessageValue); + return; + } + + if (source === 'remainder') { + body.append(`splits[${i}][remainder]`, '1'); + } else if (source === 'purpose_regex') { + body.append(`splits[${i}][amountSource]`, 'purpose_regex'); + body.append(`splits[${i}][pattern]`, pattern); + } else { + body.append(`splits[${i}][amountSource]`, 'purpose_marker'); + body.append(`splits[${i}][marker]`, pattern); + } + body.append(`splits[${i}][debitAccountId]`, rowEl.querySelector('.rule-split-debit')?.value || ''); + body.append(`splits[${i}][creditAccountId]`, rowEl.querySelector('.rule-split-credit')?.value || ''); + body.append(`splits[${i}][taxRateId]`, rowEl.querySelector('.rule-split-tax-rate')?.value || ''); + body.append(`splits[${i}][remark]`, rowEl.querySelector('.rule-split-remark')?.value || ''); + } + } 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); + } + this._appendInvoiceExtractionToRuleBody(body); + 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); + } + } + + _populateRuleInvoiceExtraction(line) { + if (!this.hasRuleInvoiceExtractionModeTarget) return; + + const invoiceNumber = String(line?.userInvoiceNumber || '').trim(); + const marker = invoiceNumber ? this._guessMarkerForText(line?.purpose || '', invoiceNumber) : ''; + this.ruleInvoiceExtractionModeTarget.value = invoiceNumber ? 'marker' : 'none'; + this.ruleInvoiceExtractionMarkerTarget.value = marker; + this.ruleInvoiceExtractionRegexTarget.value = ''; + this._refreshRuleInvoiceExtraction(); + } + + ruleInvoiceExtractionModeChange() { + this._refreshRuleInvoiceExtraction(); + } + + ruleInvoiceExtractionInput() { + this._refreshRuleInvoiceExtraction(); + } + + _refreshRuleInvoiceExtraction() { + if (!this.hasRuleInvoiceExtractionModeTarget) return; + + const mode = this.ruleInvoiceExtractionModeTarget.value || 'none'; + this.ruleInvoiceExtractionMarkerGroupTarget.hidden = mode !== 'marker'; + this.ruleInvoiceExtractionRegexGroupTarget.hidden = mode !== 'regex'; + + const preview = this.ruleInvoiceExtractionPreviewTarget; + if (mode === 'none') { + preview.className = 'small text-muted'; + preview.textContent = ''; + return; + } + + const purpose = (this._lineByIdx(this.activeIdx) || {}).purpose || ''; + const found = mode === 'marker' + ? this._extractInvoiceNumberAfterMarker(purpose, this.ruleInvoiceExtractionMarkerTarget.value.trim()) + : this._extractInvoiceNumberByRegex(purpose, this.ruleInvoiceExtractionRegexTarget.value.trim()); + + if (!found) { + preview.className = 'small text-warning'; + preview.textContent = this.ruleInvoiceMissingLabelValue; + return; + } + + preview.className = 'small text-success'; + preview.textContent = this._formatText(this.ruleInvoiceFoundLabelValue, { '%number%': found }); + } + + _appendInvoiceExtractionToRuleBody(body) { + if (!this.hasRuleInvoiceExtractionModeTarget) return; + + const mode = this.ruleInvoiceExtractionModeTarget.value || 'none'; + body.set('invoiceExtractionMode', mode); + if (mode === 'marker') { + body.set('invoiceExtractionMarker', this.ruleInvoiceExtractionMarkerTarget.value.trim()); + } else if (mode === 'regex') { + body.set('invoiceExtractionRegex', this.ruleInvoiceExtractionRegexTarget.value.trim()); + } + } + + _populateRuleSplitAction(line) { + const splits = Array.isArray(line?.splits) ? line.splits : []; + if (splits.length === 0) { + if (this.hasRuleAssignSectionTarget) this.ruleAssignSectionTarget.hidden = false; + if (this.hasRuleSplitSectionTarget) this.ruleSplitSectionTarget.hidden = true; + if (this.hasRuleSplitRowsTarget) this.ruleSplitRowsTarget.innerHTML = ''; + return; + } + + if (this.hasRuleAssignSectionTarget) this.ruleAssignSectionTarget.hidden = true; + if (this.hasRuleSplitSectionTarget) this.ruleSplitSectionTarget.hidden = false; + this.ruleSplitRowsTarget.innerHTML = ''; + splits.forEach((split, idx) => this._addRuleSplitRow(split, line, idx)); + } + + _addRuleSplitRow(split, line, idx) { + const tpl = this.ruleSplitRowTemplateTarget.content.firstElementChild.cloneNode(true); + const amount = Math.abs(parseFloat(split.amount || '0')) || 0; + const currentAmount = tpl.querySelector('.rule-split-current-amount'); + if (currentAmount) currentAmount.textContent = this._formatAmountValue(amount); + + const marker = this._guessMarkerForAmount(line.purpose || '', amount); + const sourceSel = tpl.querySelector('.rule-split-source'); + const markerInput = tpl.querySelector('.rule-split-marker'); + const isLast = idx === (Array.isArray(line.splits) ? line.splits.length - 1 : idx); + if (sourceSel) sourceSel.value = marker === '' && isLast ? 'remainder' : 'purpose_marker'; + if (markerInput) markerInput.value = marker; + + const debitSel = tpl.querySelector('.rule-split-debit'); + if (debitSel && split.debitAccountId) debitSel.value = String(split.debitAccountId); + const creditSel = tpl.querySelector('.rule-split-credit'); + if (creditSel && split.creditAccountId) creditSel.value = String(split.creditAccountId); + const taxRateSel = tpl.querySelector('.rule-split-tax-rate'); + if (taxRateSel && split.taxRateId) taxRateSel.value = String(split.taxRateId); + const remarkInput = tpl.querySelector('.rule-split-remark'); + if (remarkInput) remarkInput.value = split.remark ?? ''; + + this.ruleSplitRowsTarget.appendChild(tpl); + this._refreshRuleSplitRow(tpl); + } + + ruleSplitSourceChange(event) { + this._refreshRuleSplitRow(event.currentTarget.closest('.rule-split-row')); + } + + ruleSplitMarkerInput(event) { + this._refreshRuleSplitRow(event.currentTarget.closest('.rule-split-row')); + } + + _refreshRuleSplitRow(rowEl) { + if (!rowEl) return; + const source = rowEl.querySelector('.rule-split-source')?.value || 'purpose_marker'; + const patternInput = rowEl.querySelector('.rule-split-marker'); + const preview = rowEl.querySelector('.rule-split-preview'); + if (source === 'remainder') { + if (patternInput) patternInput.disabled = true; + if (preview) { + preview.className = 'small rule-split-preview text-muted'; + preview.textContent = this.ruleSplitRemainderLabelValue; + } + return; + } + + if (patternInput) patternInput.disabled = false; + const pattern = (patternInput?.value || '').trim(); + const purpose = (this._lineByIdx(this.activeIdx) || {}).purpose || ''; + const found = source === 'purpose_regex' + ? this._extractAmountByRegex(purpose, pattern) + : this._extractAmountAfterMarker(purpose, pattern); + if (!preview) return; + if (found === false) { + preview.className = 'small rule-split-preview text-warning'; + preview.textContent = this.ruleSplitInvalidLabelValue; + return; + } + if (found === null) { + preview.className = 'small rule-split-preview text-warning'; + preview.textContent = this.ruleSplitMissingLabelValue; + return; + } + + preview.className = 'small rule-split-preview text-success'; + preview.textContent = this._formatText(this.ruleSplitFoundLabelValue, { + '%amount%': this._formatAmountValue(found), + }); + } + + _guessMarkerForAmount(purpose, amount) { + if (!purpose || amount <= 0) return ''; + const matches = this._amountMatches(purpose); + const match = matches.find((candidate) => Math.abs(candidate.amount - amount) < 0.005); + if (!match) return ''; + + const before = purpose.slice(0, match.index).replace(/\s+/g, ' ').trim(); + if (before === '') return ''; + const afterPreviousAmount = before.replace(/^.*(?:\d{1,3}(?:[.,]\d{3})+[.,]\d{2}|\d+[.,]\d{2}|\d{1,3}(?:[.,]\d{3})+|\d+)\s*-?\s*/u, '').trim(); + const words = (afterPreviousAmount || before).split(/\s+/).filter(Boolean); + return words.slice(-4).join(' ').replace(/[:\-–]+$/u, '').trim(); + } + + _guessMarkerForText(purpose, needle) { + if (!purpose || !needle) return ''; + const idx = purpose.toLocaleLowerCase().indexOf(needle.toLocaleLowerCase()); + if (idx < 0) return ''; + + const before = purpose.slice(0, idx).replace(/\s+/g, ' ').trim(); + if (before === '') return ''; + const words = before.split(/\s+/).filter(Boolean); + + return words.slice(-4).join(' ').replace(/[:\-–#]+$/u, '').trim(); + } + + _extractInvoiceNumberAfterMarker(purpose, marker) { + if (!purpose || !marker) return null; + const idx = purpose.toLocaleLowerCase().indexOf(marker.toLocaleLowerCase()); + if (idx < 0) return null; + const tail = purpose.slice(idx + marker.length); + const match = tail.match(/^\s*(?:(?:nr\.?|nummer|no\.?|#)\s*)?[:\-#\s]*([\p{L}\p{N}][\p{L}\p{N}.\/_-]{1,49})/u); + return match ? this._cleanInvoiceNumber(match[1]) : null; + } + + _extractInvoiceNumberByRegex(purpose, pattern) { + if (!purpose || !pattern) return null; + let regex; + try { + const delimited = pattern.match(/^\/(.+)\/([gimsuy]*)$/u); + if (delimited) { + regex = new RegExp(delimited[1], delimited[2].replace('g', '')); + } else { + regex = new RegExp(pattern, 'iu'); + } + } catch { + return null; + } + + const match = purpose.match(regex); + return match ? this._cleanInvoiceNumber(match[1] || match[0]) : null; + } + + _cleanInvoiceNumber(value) { + const cleaned = String(value || '').trim().replace(/^[.,;:]+|[.,;:]+$/gu, ''); + return cleaned ? cleaned.slice(0, 50) : null; + } + + _extractAmountAfterMarker(purpose, marker) { + if (!purpose || !marker) return null; + const idx = purpose.toLocaleLowerCase().indexOf(marker.toLocaleLowerCase()); + if (idx < 0) return null; + const tail = purpose.slice(idx + marker.length); + const matches = this._amountMatches(tail); + return matches.length > 0 ? matches[0].amount : null; + } + + _extractAmountByRegex(purpose, pattern) { + if (!purpose || !pattern) return null; + let regex; + try { + const delimited = pattern.match(/^\/(.+)\/([gimsuy]*)$/u); + if (delimited) { + regex = new RegExp(delimited[1], delimited[2].replace('g', '')); + } else { + regex = new RegExp(pattern, 'iu'); + } + } catch { + return false; + } + + const match = purpose.match(regex); + if (!match) return null; + + for (const capture of match.slice(1)) { + const parsed = this._parseLooseAmount(capture); + if (parsed !== null) return parsed; + } + + return this._parseLooseAmount(match[0]); + } + + _amountMatches(text) { + const regex = /(^|[^\d.,])(?:([+-]?(?:\d{1,3}(?:[.,]\d{3})+[.,]\d{2}|\d+[.,]\d{2}))\s*-?(?![.,]\d)|([+-]?(?:\d{1,3}(?:[.,]\d{3})+|\d+))\s*-?(?=\s*(?:€|EUR\b|Euro\b|,|;|$))(?![.,]\d))/giu; + const matches = []; + for (const match of text.matchAll(regex)) { + const amount = this._parseLooseAmount(match[2] || match[3]); + if (amount !== null) { + matches.push({ index: (match.index || 0) + (match[1]?.length || 0), amount }); + } + } + return matches; + } + + _parseLooseAmount(raw) { + let value = String(raw || '').replace(/[^\d,.\-+]/g, ''); + if (value === '' || value === '-' || value === '+') return null; + const lastComma = value.lastIndexOf(','); + const lastDot = value.lastIndexOf('.'); + if (lastComma >= 0 || lastDot >= 0) { + const separator = lastComma >= 0 && lastDot >= 0 + ? (lastComma > lastDot ? ',' : '.') + : (lastComma >= 0 ? ',' : '.'); + const separatorPos = value.lastIndexOf(separator); + const digitsAfterSeparator = separatorPos < 0 ? 0 : value.length - separatorPos - 1; + const isThousandsOnly = digitsAfterSeparator === 3 + && value.split(separator).length === 2 + && /^[+-]?\d{1,3}[.,]\d{3}$/u.test(value); + + if (isThousandsOnly) { + value = value.split(separator).join(''); + } else { + const decimal = lastComma >= 0 && lastDot >= 0 + ? (lastComma > lastDot ? ',' : '.') + : separator; + const thousands = decimal === ',' ? '.' : ','; + value = value.split(thousands).join('').replace(decimal, '.'); + } + } + const parsed = parseFloat(value); + return Number.isNaN(parsed) ? null : Math.abs(parsed); + } + + // ── 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/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/confirm_submit_controller.js b/assets/controllers/confirm_submit_controller.js new file mode 100644 index 00000000..0580063d --- /dev/null +++ b/assets/controllers/confirm_submit_controller.js @@ -0,0 +1,133 @@ +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; + } + + const popover = this.popover; + this.popover = null; + const anchor = popover._element; + if (!anchor) { + popover.dispose(); + return; + } + + anchor.addEventListener('hidden.bs.popover', () => { + popover.dispose(); + }, { once: true }); + popover.hide(); + } +} 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/prices_controller.js b/assets/controllers/prices_controller.js index 535de707..36b0f68d 100644 --- a/assets/controllers/prices_controller.js +++ b/assets/controllers/prices_controller.js @@ -74,10 +74,16 @@ export default class extends Controller { } } - flatPriceChangeAction(event) { + bookableOnlineChangeAction(event) { const checkbox = event.currentTarget; const priceId = checkbox.dataset.priceId || this.getPriceId(); - this.applyFlatPriceState(checkbox.checked, priceId); + this.applyBookableOnlineState(checkbox.checked, priceId); + } + + mandatoryOnlineChangeAction(event) { + const checkbox = event.currentTarget; + const priceId = checkbox.dataset.priceId || this.getPriceId(); + this.applyMandatoryOnlineState(checkbox.checked, priceId); } dayCheckboxChangeAction(event) { @@ -137,9 +143,9 @@ export default class extends Controller { this.applyStartEndState(allPeriods.checked, priceId); } - const isFlatPrice = this.element.querySelector(`#isFlatPrice-${priceId}`); - if (isFlatPrice) { - this.applyFlatPriceState(isFlatPrice.checked, priceId); + const mandatoryOnline = this.element.querySelector(`#isMandatoryOnline-${priceId}`); + if (mandatoryOnline) { + this.applyMandatoryOnlineState(mandatoryOnline.checked, priceId); } } @@ -153,7 +159,7 @@ export default class extends Controller { const isAppartment = parseInt(value, 10) === 2; const defaultActiveWrapper = this.element.querySelector(`#default-active-in-reservation-creation-wrap-${priceId}`); const defaultActiveCheckbox = this.element.querySelector(`#isDefaultActiveInReservationCreation-${priceId}`); - const isPerRoomCheckbox = this.element.querySelector(`#isPerRoom-${priceId}`); + const perRoomRadio = this.element.querySelector(`#calc-per-room-${priceId}`); const isMisc = !isAppartment; if (fieldset) { @@ -191,6 +197,19 @@ export default class extends Controller { if (bookableOnlineCheckbox) { bookableOnlineCheckbox.disabled = !isMisc; } + const mandatoryOnlineWrapper = this.element.querySelector(`#mandatory-online-wrap-${priceId}`); + const mandatoryOnlineCheckbox = this.element.querySelector(`#isMandatoryOnline-${priceId}`); + if (mandatoryOnlineWrapper) { + mandatoryOnlineWrapper.classList.toggle('d-none', !isMisc); + } + if (mandatoryOnlineCheckbox) { + if (!isMisc) { + mandatoryOnlineCheckbox.checked = false; + mandatoryOnlineCheckbox.disabled = true; + } else { + this.applyMandatoryOnlineState(mandatoryOnlineCheckbox.checked, priceId); + } + } const packageWrapper = this.element.querySelector(`#package-wrap-${priceId}`); if (packageWrapper) { packageWrapper.style.display = isMisc ? '' : 'none'; @@ -205,8 +224,8 @@ export default class extends Controller { } } } - if (priceId === 'new' && isAppartment && isPerRoomCheckbox && !isPerRoomCheckbox.disabled) { - isPerRoomCheckbox.checked = true; + if (priceId === 'new' && isAppartment && perRoomRadio && !perRoomRadio.disabled) { + perRoomRadio.checked = true; } } @@ -217,15 +236,26 @@ export default class extends Controller { if (end) end.disabled = allPeriodsChecked; } - applyFlatPriceState(isFlatPriceChecked, priceId) { - const isPerRoom = this.element.querySelector(`#isPerRoom-${priceId}`); - if (!isPerRoom) return; + applyBookableOnlineState(bookableChecked, priceId) { + const mandatory = this.element.querySelector(`#isMandatoryOnline-${priceId}`); + if (!mandatory) return; + if (!bookableChecked) { + mandatory.checked = false; + mandatory.disabled = true; + } else { + mandatory.disabled = false; + } + } - if (isFlatPriceChecked) { - isPerRoom.checked = false; - isPerRoom.disabled = true; + applyMandatoryOnlineState(mandatoryChecked, priceId) { + const bookable = this.element.querySelector(`#isBookableOnline-${priceId}`); + if (!bookable) return; + if (mandatoryChecked) { + // Pflicht impliziert online verfügbar — Switch erzwingen und sperren. + bookable.checked = true; + bookable.disabled = true; } else { - isPerRoom.disabled = false; + bookable.disabled = false; } } 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/controllers/tab_persistence_controller.js b/assets/controllers/tab_persistence_controller.js new file mode 100644 index 00000000..1b50091c --- /dev/null +++ b/assets/controllers/tab_persistence_controller.js @@ -0,0 +1,38 @@ +import { Controller } from '@hotwired/stimulus'; + +/* stimulusFetch: 'lazy' */ + +/** + * Reflects the currently active Bootstrap tab in the URL as `?tab=`, + * so that page reloads (e.g. after form save with redirect) restore the user's + * last tab. The initial active tab is decided server-side via the same query + * parameter; this controller only keeps the URL in sync while the user clicks + * around. + */ +export default class extends Controller { + connect() { + this._listeners = []; + this.element.querySelectorAll('[data-bs-toggle="tab"]').forEach((trigger) => { + const handler = (event) => this._onShown(event); + trigger.addEventListener('shown.bs.tab', handler); + this._listeners.push({ trigger, handler }); + }); + } + + disconnect() { + for (const { trigger, handler } of this._listeners) { + trigger.removeEventListener('shown.bs.tab', handler); + } + this._listeners = []; + } + + _onShown(event) { + const target = event.target.dataset.bsTarget || ''; + const id = target.replace(/^#/, ''); + if (!id) return; + + const url = new URL(window.location.href); + url.searchParams.set('tab', id); + window.history.replaceState(null, '', url.toString()); + } +} diff --git a/assets/controllers/template_editor_controller.js b/assets/controllers/template_editor_controller.js index b7139545..b1be76e2 100644 --- a/assets/controllers/template_editor_controller.js +++ b/assets/controllers/template_editor_controller.js @@ -1824,7 +1824,26 @@ export default class extends Controller { } restoreContentFromVisual(content) { - return this.decodeStyleBlocksFromVisual(this.decodeTemplateCommentsFromVisual(content)); + const decoded = this.decodeStyleBlocksFromVisual(this.decodeTemplateCommentsFromVisual(content)); + return this.normalizeTemplateControlAttributeEntities(decoded); + } + + normalizeTemplateControlAttributeEntities(content) { + if (!content) { + return ''; + } + + return content.replace( + /\b(data-repeat|data-repeat-as|data-repeat-key|data-if)=(["'])(.*?)\2/gi, + (match, attributeName, quote, value) => `${attributeName}=${quote}${this.decodeTemplateControlAttributeValue(value)}${quote}` + ); + } + + decodeTemplateControlAttributeValue(value) { + return (value || '') + .replace(/>/gi, '>') + .replace(/</gi, '<') + .replace(/&/gi, '&'); } normalizeLegacyDocumentHtml(content) { diff --git a/assets/controllers/tourist_tax_mode_controller.js b/assets/controllers/tourist_tax_mode_controller.js new file mode 100644 index 00000000..0c230ee7 --- /dev/null +++ b/assets/controllers/tourist_tax_mode_controller.js @@ -0,0 +1,45 @@ +import { Controller } from '@hotwired/stimulus'; + +/* stimulusFetch: 'lazy' */ + +/** + * Toggles visibility between the per-night rates table and the percentage + * configuration fields based on the selected TaxCalculationMode. Also strips + * the `required` attribute from inputs/selects inside the hidden section so + * the browser does not block form submission on non-focusable fields. + */ +export default class extends Controller { + static targets = ['select', 'percentFields', 'ratesSection', 'adultOnlyWrapper']; + static values = { flat: String }; + + connect() { + this.update(); + } + + update() { + const isFlat = this.selectTarget.value === this.flatValue; + + this.toggle(this.percentFieldsTargets, !isFlat); + this.toggle(this.ratesSectionTargets, isFlat); + // appliesOnlyToAdult only makes sense for the per-person flat mode + // (Swiss accommodation levy etc.). For percent-of-room it has no effect. + this.toggle(this.adultOnlyWrapperTargets, isFlat); + } + + toggle(elements, visible) { + elements.forEach(el => { + el.classList.toggle('d-none', !visible); + el.querySelectorAll('input, select, textarea').forEach(field => { + if (visible) { + if (field.dataset.requiredBackup === '1') { + field.required = true; + delete field.dataset.requiredBackup; + } + } else if (field.required) { + field.dataset.requiredBackup = '1'; + field.required = false; + } + }); + }); + } +} 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/composer.json b/composer.json index b4cdcb5c..d0e03c0a 100644 --- a/composer.json +++ b/composer.json @@ -41,7 +41,7 @@ "symfony/web-link": "8.0.*", "symfony/yaml": "8.0.*", "symfonycasts/reset-password-bundle": "^1.18", - "web-auth/webauthn-lib": "5.3.x-dev" + "web-auth/webauthn-lib": "5.3.*" }, "require-dev": { "phpunit/phpunit": "^12.4", diff --git a/composer.lock b/composer.lock index f358259b..82e9040a 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "dac8c0571c7907d00890e9cd3ae31a9b", + "content-hash": "0a36aabf441590392b33ff9130bed665", "packages": [ { "name": "azuyalabs/yasumi", @@ -81,16 +81,16 @@ }, { "name": "brick/math", - "version": "0.17.0", + "version": "0.17.1", "source": { "type": "git", "url": "https://github.com/brick/math.git", - "reference": "a62af7ab2e3cee9f9bf4cf77a5d1e6ba408a44ee" + "reference": "6aef71a9fbbd1ee7be0e313cd627f8e6f7125a5b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/brick/math/zipball/a62af7ab2e3cee9f9bf4cf77a5d1e6ba408a44ee", - "reference": "a62af7ab2e3cee9f9bf4cf77a5d1e6ba408a44ee", + "url": "https://api.github.com/repos/brick/math/zipball/6aef71a9fbbd1ee7be0e313cd627f8e6f7125a5b", + "reference": "6aef71a9fbbd1ee7be0e313cd627f8e6f7125a5b", "shasum": "" }, "require": { @@ -128,7 +128,7 @@ ], "support": { "issues": "https://github.com/brick/math/issues", - "source": "https://github.com/brick/math/tree/0.17.0" + "source": "https://github.com/brick/math/tree/0.17.1" }, "funding": [ { @@ -136,7 +136,7 @@ "type": "github" } ], - "time": "2026-03-17T12:54:54+00:00" + "time": "2026-04-19T20:55:20+00:00" }, { "name": "composer/semver", @@ -1162,16 +1162,16 @@ }, { "name": "doctrine/migrations", - "version": "3.9.6", + "version": "3.9.7", "source": { "type": "git", "url": "https://github.com/doctrine/migrations.git", - "reference": "ffd8355cdd8505fc650d9604f058bf62aedd80a1" + "reference": "96cb2a89b56c9efb0bac38e606dc0b0f13e650ec" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/migrations/zipball/ffd8355cdd8505fc650d9604f058bf62aedd80a1", - "reference": "ffd8355cdd8505fc650d9604f058bf62aedd80a1", + "url": "https://api.github.com/repos/doctrine/migrations/zipball/96cb2a89b56c9efb0bac38e606dc0b0f13e650ec", + "reference": "96cb2a89b56c9efb0bac38e606dc0b0f13e650ec", "shasum": "" }, "require": { @@ -1245,7 +1245,7 @@ ], "support": { "issues": "https://github.com/doctrine/migrations/issues", - "source": "https://github.com/doctrine/migrations/tree/3.9.6" + "source": "https://github.com/doctrine/migrations/tree/3.9.7" }, "funding": [ { @@ -1261,20 +1261,20 @@ "type": "tidelift" } ], - "time": "2026-02-11T06:46:11+00:00" + "time": "2026-04-23T19:33:20+00:00" }, { "name": "doctrine/orm", - "version": "3.6.3", + "version": "3.6.6", "source": { "type": "git", "url": "https://github.com/doctrine/orm.git", - "reference": "e88cd591f0786089dee22b972c28aa2076df51c0" + "reference": "471b12949ff9bc23ecdc809ce838613c1aa9a0b9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/orm/zipball/e88cd591f0786089dee22b972c28aa2076df51c0", - "reference": "e88cd591f0786089dee22b972c28aa2076df51c0", + "url": "https://api.github.com/repos/doctrine/orm/zipball/471b12949ff9bc23ecdc809ce838613c1aa9a0b9", + "reference": "471b12949ff9bc23ecdc809ce838613c1aa9a0b9", "shasum": "" }, "require": { @@ -1347,25 +1347,26 @@ ], "support": { "issues": "https://github.com/doctrine/orm/issues", - "source": "https://github.com/doctrine/orm/tree/3.6.3" + "source": "https://github.com/doctrine/orm/tree/3.6.6" }, - "time": "2026-04-02T06:53:27+00:00" + "time": "2026-05-21T06:05:47+00:00" }, { "name": "doctrine/persistence", - "version": "4.1.1", + "version": "4.2.0", "source": { "type": "git", "url": "https://github.com/doctrine/persistence.git", - "reference": "b9c49ad3558bb77ef973f4e173f2e9c2eca9be09" + "reference": "49ab73e0d3e2ac8d1f5ecda3dd8acd5503781e8b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/persistence/zipball/b9c49ad3558bb77ef973f4e173f2e9c2eca9be09", - "reference": "b9c49ad3558bb77ef973f4e173f2e9c2eca9be09", + "url": "https://api.github.com/repos/doctrine/persistence/zipball/49ab73e0d3e2ac8d1f5ecda3dd8acd5503781e8b", + "reference": "49ab73e0d3e2ac8d1f5ecda3dd8acd5503781e8b", "shasum": "" }, "require": { + "doctrine/deprecations": "^1", "doctrine/event-manager": "^1 || ^2", "php": "^8.1", "psr/cache": "^1.0 || ^2.0 || ^3.0" @@ -1376,13 +1377,13 @@ "phpstan/phpstan-phpunit": "^2", "phpstan/phpstan-strict-rules": "^2", "phpunit/phpunit": "^10.5.58 || ^12", - "symfony/cache": "^4.4 || ^5.4 || ^6.0 || ^7.0", - "symfony/finder": "^4.4 || ^5.4 || ^6.0 || ^7.0" + "symfony/cache": "^4.4 || ^5.4 || ^6.0 || ^7.0 || ^8.0", + "symfony/finder": "^4.4 || ^5.4 || ^6.0 || ^7.0 || ^8.0" }, "type": "library", "autoload": { "psr-4": { - "Doctrine\\Persistence\\": "src/Persistence" + "Doctrine\\Persistence\\": "src" } }, "notification-url": "https://packagist.org/downloads/", @@ -1426,7 +1427,7 @@ ], "support": { "issues": "https://github.com/doctrine/persistence/issues", - "source": "https://github.com/doctrine/persistence/tree/4.1.1" + "source": "https://github.com/doctrine/persistence/tree/4.2.0" }, "funding": [ { @@ -1442,7 +1443,7 @@ "type": "tidelift" } ], - "time": "2025-10-16T20:13:18+00:00" + "time": "2026-04-26T12:12:52+00:00" }, { "name": "doctrine/sql-formatter", @@ -3063,16 +3064,16 @@ }, { "name": "setasign/fpdi", - "version": "v2.6.6", + "version": "v2.6.7", "source": { "type": "git", "url": "https://github.com/Setasign/FPDI.git", - "reference": "de0cf35911be3e9ea63b48e0f307883b1c7c48ac" + "reference": "388c51e69982a3fc16698710b763e8107a49f510" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Setasign/FPDI/zipball/de0cf35911be3e9ea63b48e0f307883b1c7c48ac", - "reference": "de0cf35911be3e9ea63b48e0f307883b1c7c48ac", + "url": "https://api.github.com/repos/Setasign/FPDI/zipball/388c51e69982a3fc16698710b763e8107a49f510", + "reference": "388c51e69982a3fc16698710b763e8107a49f510", "shasum": "" }, "require": { @@ -3123,7 +3124,7 @@ ], "support": { "issues": "https://github.com/Setasign/FPDI/issues", - "source": "https://github.com/Setasign/FPDI/tree/v2.6.6" + "source": "https://github.com/Setasign/FPDI/tree/v2.6.7" }, "funding": [ { @@ -3131,20 +3132,20 @@ "type": "tidelift" } ], - "time": "2026-03-13T08:38:20+00:00" + "time": "2026-05-13T10:16:22+00:00" }, { "name": "smalot/pdfparser", - "version": "v2.12.4", + "version": "v2.12.5", "source": { "type": "git", "url": "https://github.com/smalot/pdfparser.git", - "reference": "028d7cc0ceff323bc001d763caa2bbdf611866c4" + "reference": "2cfa0d92bd557875c9f52a75fde0e8392302a354" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/smalot/pdfparser/zipball/028d7cc0ceff323bc001d763caa2bbdf611866c4", - "reference": "028d7cc0ceff323bc001d763caa2bbdf611866c4", + "url": "https://api.github.com/repos/smalot/pdfparser/zipball/2cfa0d92bd557875c9f52a75fde0e8392302a354", + "reference": "2cfa0d92bd557875c9f52a75fde0e8392302a354", "shasum": "" }, "require": { @@ -3180,9 +3181,9 @@ ], "support": { "issues": "https://github.com/smalot/pdfparser/issues", - "source": "https://github.com/smalot/pdfparser/tree/v2.12.4" + "source": "https://github.com/smalot/pdfparser/tree/v2.12.5" }, - "time": "2026-03-10T15:39:47+00:00" + "time": "2026-04-17T11:37:58+00:00" }, { "name": "spomky-labs/cbor-php", @@ -3437,16 +3438,16 @@ }, { "name": "symfony/asset-mapper", - "version": "v8.0.8", + "version": "v8.0.11", "source": { "type": "git", "url": "https://github.com/symfony/asset-mapper.git", - "reference": "554b562577a3b23d15388dee12dc482401e45fbd" + "reference": "b2c33bf6934bfe5b37a6d70d0b0f7011d0ec4a0c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/asset-mapper/zipball/554b562577a3b23d15388dee12dc482401e45fbd", - "reference": "554b562577a3b23d15388dee12dc482401e45fbd", + "url": "https://api.github.com/repos/symfony/asset-mapper/zipball/b2c33bf6934bfe5b37a6d70d0b0f7011d0ec4a0c", + "reference": "b2c33bf6934bfe5b37a6d70d0b0f7011d0ec4a0c", "shasum": "" }, "require": { @@ -3494,7 +3495,7 @@ "description": "Maps directories of assets & makes them available in a public directory with versioned filenames.", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/asset-mapper/tree/v8.0.8" + "source": "https://github.com/symfony/asset-mapper/tree/v8.0.11" }, "funding": [ { @@ -3514,20 +3515,20 @@ "type": "tidelift" } ], - "time": "2026-03-30T15:14:47+00:00" + "time": "2026-05-13T12:07:53+00:00" }, { "name": "symfony/cache", - "version": "v8.0.8", + "version": "v8.0.12", "source": { "type": "git", "url": "https://github.com/symfony/cache.git", - "reference": "8abf3ccbeae9d3071b81a3ae7ee11b209f9e1e78" + "reference": "11dc0681506ff07ca80bfb4cbf84c601c3cf04f7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/cache/zipball/8abf3ccbeae9d3071b81a3ae7ee11b209f9e1e78", - "reference": "8abf3ccbeae9d3071b81a3ae7ee11b209f9e1e78", + "url": "https://api.github.com/repos/symfony/cache/zipball/11dc0681506ff07ca80bfb4cbf84c601c3cf04f7", + "reference": "11dc0681506ff07ca80bfb4cbf84c601c3cf04f7", "shasum": "" }, "require": { @@ -3539,7 +3540,6 @@ "symfony/var-exporter": "^7.4|^8.0" }, "conflict": { - "doctrine/dbal": "<4.3", "ext-redis": "<6.1", "ext-relay": "<0.12.1" }, @@ -3594,7 +3594,7 @@ "psr6" ], "support": { - "source": "https://github.com/symfony/cache/tree/v8.0.8" + "source": "https://github.com/symfony/cache/tree/v8.0.12" }, "funding": [ { @@ -3614,20 +3614,20 @@ "type": "tidelift" } ], - "time": "2026-03-30T15:18:51+00:00" + "time": "2026-05-20T07:22:03+00:00" }, { "name": "symfony/cache-contracts", - "version": "v3.6.0", + "version": "v3.7.0", "source": { "type": "git", "url": "https://github.com/symfony/cache-contracts.git", - "reference": "5d68a57d66910405e5c0b63d6f0af941e66fc868" + "reference": "225e8a254166bd3442e370c6f50145465db63831" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/cache-contracts/zipball/5d68a57d66910405e5c0b63d6f0af941e66fc868", - "reference": "5d68a57d66910405e5c0b63d6f0af941e66fc868", + "url": "https://api.github.com/repos/symfony/cache-contracts/zipball/225e8a254166bd3442e370c6f50145465db63831", + "reference": "225e8a254166bd3442e370c6f50145465db63831", "shasum": "" }, "require": { @@ -3641,7 +3641,7 @@ "name": "symfony/contracts" }, "branch-alias": { - "dev-main": "3.6-dev" + "dev-main": "3.7-dev" } }, "autoload": { @@ -3674,7 +3674,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/cache-contracts/tree/v3.6.0" + "source": "https://github.com/symfony/cache-contracts/tree/v3.7.0" }, "funding": [ { @@ -3685,12 +3685,16 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-03-13T15:25:07+00:00" + "time": "2026-05-05T15:33:14+00:00" }, { "name": "symfony/clock", @@ -3771,16 +3775,16 @@ }, { "name": "symfony/config", - "version": "v8.0.8", + "version": "v8.0.10", "source": { "type": "git", "url": "https://github.com/symfony/config.git", - "reference": "c7369cc1da250fcbfe0c5a9d109e419661549c39" + "reference": "de665e669412ec2effe004d90298dbbdaf6e7e8b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/config/zipball/c7369cc1da250fcbfe0c5a9d109e419661549c39", - "reference": "c7369cc1da250fcbfe0c5a9d109e419661549c39", + "url": "https://api.github.com/repos/symfony/config/zipball/de665e669412ec2effe004d90298dbbdaf6e7e8b", + "reference": "de665e669412ec2effe004d90298dbbdaf6e7e8b", "shasum": "" }, "require": { @@ -3825,7 +3829,7 @@ "description": "Helps you find, load, combine, autofill and validate configuration values of any kind", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/config/tree/v8.0.8" + "source": "https://github.com/symfony/config/tree/v8.0.10" }, "funding": [ { @@ -3845,20 +3849,20 @@ "type": "tidelift" } ], - "time": "2026-03-30T15:14:47+00:00" + "time": "2026-05-04T13:41:39+00:00" }, { "name": "symfony/console", - "version": "v8.0.8", + "version": "v8.0.11", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "5b66d385dc58f69652e56f78a4184615e3f2b7f7" + "reference": "3156577f46a38aa1b9323aad223de7a9cd426782" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/5b66d385dc58f69652e56f78a4184615e3f2b7f7", - "reference": "5b66d385dc58f69652e56f78a4184615e3f2b7f7", + "url": "https://api.github.com/repos/symfony/console/zipball/3156577f46a38aa1b9323aad223de7a9cd426782", + "reference": "3156577f46a38aa1b9323aad223de7a9cd426782", "shasum": "" }, "require": { @@ -3915,7 +3919,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v8.0.8" + "source": "https://github.com/symfony/console/tree/v8.0.11" }, "funding": [ { @@ -3935,20 +3939,20 @@ "type": "tidelift" } ], - "time": "2026-03-30T15:14:47+00:00" + "time": "2026-05-13T12:07:53+00:00" }, { "name": "symfony/dependency-injection", - "version": "v8.0.8", + "version": "v8.0.10", "source": { "type": "git", "url": "https://github.com/symfony/dependency-injection.git", - "reference": "3ce58b0fa844dc647ca1d66ea34748af985728c5" + "reference": "6fc374dae45a7633a5865da7fc2908baf29d4900" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/dependency-injection/zipball/3ce58b0fa844dc647ca1d66ea34748af985728c5", - "reference": "3ce58b0fa844dc647ca1d66ea34748af985728c5", + "url": "https://api.github.com/repos/symfony/dependency-injection/zipball/6fc374dae45a7633a5865da7fc2908baf29d4900", + "reference": "6fc374dae45a7633a5865da7fc2908baf29d4900", "shasum": "" }, "require": { @@ -3996,7 +4000,7 @@ "description": "Allows you to standardize and centralize the way objects are constructed in your application", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/dependency-injection/tree/v8.0.8" + "source": "https://github.com/symfony/dependency-injection/tree/v8.0.10" }, "funding": [ { @@ -4016,20 +4020,20 @@ "type": "tidelift" } ], - "time": "2026-03-31T07:15:36+00:00" + "time": "2026-05-06T11:55:35+00:00" }, { "name": "symfony/deprecation-contracts", - "version": "v3.6.0", + "version": "v3.7.0", "source": { "type": "git", "url": "https://github.com/symfony/deprecation-contracts.git", - "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62" + "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/63afe740e99a13ba87ec199bb07bbdee937a5b62", - "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/50f59d1f3ca46d41ac911f97a78626b6756af35b", + "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b", "shasum": "" }, "require": { @@ -4042,7 +4046,7 @@ "name": "symfony/contracts" }, "branch-alias": { - "dev-main": "3.6-dev" + "dev-main": "3.7-dev" } }, "autoload": { @@ -4067,7 +4071,7 @@ "description": "A generic function and convention to trigger deprecation notices", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/deprecation-contracts/tree/v3.6.0" + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.0" }, "funding": [ { @@ -4078,25 +4082,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-09-25T14:21:43+00:00" + "time": "2026-04-13T15:52:40+00:00" }, { "name": "symfony/doctrine-bridge", - "version": "v8.0.8", + "version": "v8.0.9", "source": { "type": "git", "url": "https://github.com/symfony/doctrine-bridge.git", - "reference": "a45ac00ffcc763ffc36c791647e93d310142804f" + "reference": "dfe3dddc9c22756b9b145785fb5fd4b0445cd06e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/doctrine-bridge/zipball/a45ac00ffcc763ffc36c791647e93d310142804f", - "reference": "a45ac00ffcc763ffc36c791647e93d310142804f", + "url": "https://api.github.com/repos/symfony/doctrine-bridge/zipball/dfe3dddc9c22756b9b145785fb5fd4b0445cd06e", + "reference": "dfe3dddc9c22756b9b145785fb5fd4b0445cd06e", "shasum": "" }, "require": { @@ -4165,7 +4173,7 @@ "description": "Provides integration for Doctrine with various Symfony components", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/doctrine-bridge/tree/v8.0.8" + "source": "https://github.com/symfony/doctrine-bridge/tree/v8.0.9" }, "funding": [ { @@ -4185,20 +4193,20 @@ "type": "tidelift" } ], - "time": "2026-03-30T15:14:47+00:00" + "time": "2026-04-29T15:02:55+00:00" }, { "name": "symfony/dotenv", - "version": "v8.0.8", + "version": "v8.0.11", "source": { "type": "git", "url": "https://github.com/symfony/dotenv.git", - "reference": "5ba6337f9a86e78e13b1ac11a89f85689b12cf2c" + "reference": "82e1d8f888896a215bb6673e6d1f6d5ca47a9dfe" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/dotenv/zipball/5ba6337f9a86e78e13b1ac11a89f85689b12cf2c", - "reference": "5ba6337f9a86e78e13b1ac11a89f85689b12cf2c", + "url": "https://api.github.com/repos/symfony/dotenv/zipball/82e1d8f888896a215bb6673e6d1f6d5ca47a9dfe", + "reference": "82e1d8f888896a215bb6673e6d1f6d5ca47a9dfe", "shasum": "" }, "require": { @@ -4239,7 +4247,7 @@ "environment" ], "support": { - "source": "https://github.com/symfony/dotenv/tree/v8.0.8" + "source": "https://github.com/symfony/dotenv/tree/v8.0.11" }, "funding": [ { @@ -4259,7 +4267,7 @@ "type": "tidelift" } ], - "time": "2026-03-30T15:14:47+00:00" + "time": "2026-05-11T13:06:45+00:00" }, { "name": "symfony/error-handler", @@ -4344,16 +4352,16 @@ }, { "name": "symfony/event-dispatcher", - "version": "v8.0.8", + "version": "v8.0.9", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher.git", - "reference": "f662acc6ab22a3d6d716dcb44c381c6002940df6" + "reference": "0c3c1a17604c4dbbec4b93fe162c538482096e1f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/f662acc6ab22a3d6d716dcb44c381c6002940df6", - "reference": "f662acc6ab22a3d6d716dcb44c381c6002940df6", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/0c3c1a17604c4dbbec4b93fe162c538482096e1f", + "reference": "0c3c1a17604c4dbbec4b93fe162c538482096e1f", "shasum": "" }, "require": { @@ -4405,7 +4413,7 @@ "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/event-dispatcher/tree/v8.0.8" + "source": "https://github.com/symfony/event-dispatcher/tree/v8.0.9" }, "funding": [ { @@ -4425,20 +4433,20 @@ "type": "tidelift" } ], - "time": "2026-03-30T15:14:47+00:00" + "time": "2026-04-18T13:51:42+00:00" }, { "name": "symfony/event-dispatcher-contracts", - "version": "v3.6.0", + "version": "v3.7.0", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher-contracts.git", - "reference": "59eb412e93815df44f05f342958efa9f46b1e586" + "reference": "ccba7060602b7fed0b03c85bf025257f76d9ef32" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/59eb412e93815df44f05f342958efa9f46b1e586", - "reference": "59eb412e93815df44f05f342958efa9f46b1e586", + "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/ccba7060602b7fed0b03c85bf025257f76d9ef32", + "reference": "ccba7060602b7fed0b03c85bf025257f76d9ef32", "shasum": "" }, "require": { @@ -4452,7 +4460,7 @@ "name": "symfony/contracts" }, "branch-alias": { - "dev-main": "3.6-dev" + "dev-main": "3.7-dev" } }, "autoload": { @@ -4485,7 +4493,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.6.0" + "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.7.0" }, "funding": [ { @@ -4496,12 +4504,16 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-09-25T14:21:43+00:00" + "time": "2026-01-05T13:30:16+00:00" }, { "name": "symfony/expression-language", @@ -4572,16 +4584,16 @@ }, { "name": "symfony/filesystem", - "version": "v8.0.8", + "version": "v8.0.11", "source": { "type": "git", "url": "https://github.com/symfony/filesystem.git", - "reference": "66b769ae743ce2d13e435528fbef4af03d623e5a" + "reference": "224db910898ce1317b892a9a1338f1f8f17eb7c7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/filesystem/zipball/66b769ae743ce2d13e435528fbef4af03d623e5a", - "reference": "66b769ae743ce2d13e435528fbef4af03d623e5a", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/224db910898ce1317b892a9a1338f1f8f17eb7c7", + "reference": "224db910898ce1317b892a9a1338f1f8f17eb7c7", "shasum": "" }, "require": { @@ -4618,7 +4630,7 @@ "description": "Provides basic utilities for the filesystem", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/filesystem/tree/v8.0.8" + "source": "https://github.com/symfony/filesystem/tree/v8.0.11" }, "funding": [ { @@ -4638,7 +4650,7 @@ "type": "tidelift" } ], - "time": "2026-03-30T15:14:47+00:00" + "time": "2026-05-11T16:39:47+00:00" }, { "name": "symfony/finder", @@ -4783,16 +4795,16 @@ }, { "name": "symfony/form", - "version": "v8.0.8", + "version": "v8.0.9", "source": { "type": "git", "url": "https://github.com/symfony/form.git", - "reference": "c163f5db2f1ffecb3d5b33a2e662d13115323b20" + "reference": "dd9f73dd3b92e657c97aeeca1f47e981c635ea91" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/form/zipball/c163f5db2f1ffecb3d5b33a2e662d13115323b20", - "reference": "c163f5db2f1ffecb3d5b33a2e662d13115323b20", + "url": "https://api.github.com/repos/symfony/form/zipball/dd9f73dd3b92e657c97aeeca1f47e981c635ea91", + "reference": "dd9f73dd3b92e657c97aeeca1f47e981c635ea91", "shasum": "" }, "require": { @@ -4854,7 +4866,7 @@ "description": "Allows to easily create, process and reuse HTML forms", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/form/tree/v8.0.8" + "source": "https://github.com/symfony/form/tree/v8.0.9" }, "funding": [ { @@ -4874,20 +4886,20 @@ "type": "tidelift" } ], - "time": "2026-03-30T15:14:47+00:00" + "time": "2026-04-29T15:02:55+00:00" }, { "name": "symfony/framework-bundle", - "version": "v8.0.8", + "version": "v8.0.11", "source": { "type": "git", "url": "https://github.com/symfony/framework-bundle.git", - "reference": "ce3ee5db0a9c1b6c52f5e3ba16b63a677b18b7df" + "reference": "c0d53dba8de800f5dd1e9dac79683d8c59934d34" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/framework-bundle/zipball/ce3ee5db0a9c1b6c52f5e3ba16b63a677b18b7df", - "reference": "ce3ee5db0a9c1b6c52f5e3ba16b63a677b18b7df", + "url": "https://api.github.com/repos/symfony/framework-bundle/zipball/c0d53dba8de800f5dd1e9dac79683d8c59934d34", + "reference": "c0d53dba8de800f5dd1e9dac79683d8c59934d34", "shasum": "" }, "require": { @@ -4916,6 +4928,7 @@ "symfony/form": "<7.4", "symfony/json-streamer": "<7.4", "symfony/messenger": "<7.4", + "symfony/mime": "<7.4.9|>=8.0,<8.0.9", "symfony/security-csrf": "<7.4", "symfony/serializer": "<7.4", "symfony/translation": "<7.4", @@ -4944,9 +4957,9 @@ "symfony/lock": "^7.4|^8.0", "symfony/mailer": "^7.4|^8.0", "symfony/messenger": "^7.4|^8.0", - "symfony/mime": "^7.4|^8.0", + "symfony/mime": "^7.4.9|^8.0.9", "symfony/notifier": "^7.4|^8.0", - "symfony/object-mapper": "^7.4|^8.0", + "symfony/object-mapper": "^7.4.9|^8.0.9", "symfony/polyfill-intl-icu": "^1.0", "symfony/process": "^7.4|^8.0", "symfony/property-info": "^7.4|^8.0", @@ -4994,7 +5007,7 @@ "description": "Provides a tight integration between Symfony components and the Symfony full-stack framework", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/framework-bundle/tree/v8.0.8" + "source": "https://github.com/symfony/framework-bundle/tree/v8.0.11" }, "funding": [ { @@ -5014,20 +5027,20 @@ "type": "tidelift" } ], - "time": "2026-03-30T15:14:47+00:00" + "time": "2026-05-13T12:07:53+00:00" }, { "name": "symfony/http-client", - "version": "v8.0.8", + "version": "v8.0.9", "source": { "type": "git", "url": "https://github.com/symfony/http-client.git", - "reference": "356e43d6994ae9d7761fd404d40f78691deabe0e" + "reference": "537c7f164078975b800f3f1c56810791024e4c77" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-client/zipball/356e43d6994ae9d7761fd404d40f78691deabe0e", - "reference": "356e43d6994ae9d7761fd404d40f78691deabe0e", + "url": "https://api.github.com/repos/symfony/http-client/zipball/537c7f164078975b800f3f1c56810791024e4c77", + "reference": "537c7f164078975b800f3f1c56810791024e4c77", "shasum": "" }, "require": { @@ -5090,7 +5103,7 @@ "http" ], "support": { - "source": "https://github.com/symfony/http-client/tree/v8.0.8" + "source": "https://github.com/symfony/http-client/tree/v8.0.9" }, "funding": [ { @@ -5110,20 +5123,20 @@ "type": "tidelift" } ], - "time": "2026-03-30T15:14:47+00:00" + "time": "2026-04-29T15:02:55+00:00" }, { "name": "symfony/http-client-contracts", - "version": "v3.6.0", + "version": "v3.7.0", "source": { "type": "git", "url": "https://github.com/symfony/http-client-contracts.git", - "reference": "75d7043853a42837e68111812f4d964b01e5101c" + "reference": "4a2d00c37651c0bdc2b9e1c773487a8bf4edb12d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/75d7043853a42837e68111812f4d964b01e5101c", - "reference": "75d7043853a42837e68111812f4d964b01e5101c", + "url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/4a2d00c37651c0bdc2b9e1c773487a8bf4edb12d", + "reference": "4a2d00c37651c0bdc2b9e1c773487a8bf4edb12d", "shasum": "" }, "require": { @@ -5136,7 +5149,7 @@ "name": "symfony/contracts" }, "branch-alias": { - "dev-main": "3.6-dev" + "dev-main": "3.7-dev" } }, "autoload": { @@ -5172,7 +5185,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/http-client-contracts/tree/v3.6.0" + "source": "https://github.com/symfony/http-client-contracts/tree/v3.7.0" }, "funding": [ { @@ -5183,12 +5196,16 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-04-29T11:18:49+00:00" + "time": "2026-03-06T13:17:50+00:00" }, { "name": "symfony/http-foundation", @@ -5272,16 +5289,16 @@ }, { "name": "symfony/http-kernel", - "version": "v8.0.8", + "version": "v8.0.12", "source": { "type": "git", "url": "https://github.com/symfony/http-kernel.git", - "reference": "1770f6818d83b2fddc12185025b93f39a90cb628" + "reference": "c00291734c59c05c54c5a3abc2ab18e99b070157" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-kernel/zipball/1770f6818d83b2fddc12185025b93f39a90cb628", - "reference": "1770f6818d83b2fddc12185025b93f39a90cb628", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/c00291734c59c05c54c5a3abc2ab18e99b070157", + "reference": "c00291734c59c05c54c5a3abc2ab18e99b070157", "shasum": "" }, "require": { @@ -5352,7 +5369,7 @@ "description": "Provides a structured process for converting a Request into a Response", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-kernel/tree/v8.0.8" + "source": "https://github.com/symfony/http-kernel/tree/v8.0.12" }, "funding": [ { @@ -5372,7 +5389,7 @@ "type": "tidelift" } ], - "time": "2026-03-31T21:14:05+00:00" + "time": "2026-05-20T09:47:36+00:00" }, { "name": "symfony/intl", @@ -5465,16 +5482,16 @@ }, { "name": "symfony/mailer", - "version": "v8.0.8", + "version": "v8.0.12", "source": { "type": "git", "url": "https://github.com/symfony/mailer.git", - "reference": "ca5f6edaf8780ece814404b58a4482b22b509c56" + "reference": "5266d594e83593dff3492b5655ff6e8f38d67cfc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mailer/zipball/ca5f6edaf8780ece814404b58a4482b22b509c56", - "reference": "ca5f6edaf8780ece814404b58a4482b22b509c56", + "url": "https://api.github.com/repos/symfony/mailer/zipball/5266d594e83593dff3492b5655ff6e8f38d67cfc", + "reference": "5266d594e83593dff3492b5655ff6e8f38d67cfc", "shasum": "" }, "require": { @@ -5521,7 +5538,7 @@ "description": "Helps sending emails", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/mailer/tree/v8.0.8" + "source": "https://github.com/symfony/mailer/tree/v8.0.12" }, "funding": [ { @@ -5541,20 +5558,20 @@ "type": "tidelift" } ], - "time": "2026-03-30T15:14:47+00:00" + "time": "2026-05-20T07:22:03+00:00" }, { "name": "symfony/mime", - "version": "v8.0.8", + "version": "v8.0.12", "source": { "type": "git", "url": "https://github.com/symfony/mime.git", - "reference": "ddff21f14c7ce04b98101b399a9463dce8b0ce66" + "reference": "7d9a72bbf0a9cb169ed1cbbbbbf709a592207fc1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mime/zipball/ddff21f14c7ce04b98101b399a9463dce8b0ce66", - "reference": "ddff21f14c7ce04b98101b399a9463dce8b0ce66", + "url": "https://api.github.com/repos/symfony/mime/zipball/7d9a72bbf0a9cb169ed1cbbbbbf709a592207fc1", + "reference": "7d9a72bbf0a9cb169ed1cbbbbbf709a592207fc1", "shasum": "" }, "require": { @@ -5607,7 +5624,7 @@ "mime-type" ], "support": { - "source": "https://github.com/symfony/mime/tree/v8.0.8" + "source": "https://github.com/symfony/mime/tree/v8.0.12" }, "funding": [ { @@ -5627,20 +5644,20 @@ "type": "tidelift" } ], - "time": "2026-03-30T15:14:47+00:00" + "time": "2026-05-20T07:22:03+00:00" }, { "name": "symfony/monolog-bridge", - "version": "v8.0.8", + "version": "v8.0.12", "source": { "type": "git", "url": "https://github.com/symfony/monolog-bridge.git", - "reference": "c6efdcbd5cc17cf7618fb4447053b792df6ae724" + "reference": "fc53ae60b2d01d3086d28aea876cfef9bcf75f1e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/monolog-bridge/zipball/c6efdcbd5cc17cf7618fb4447053b792df6ae724", - "reference": "c6efdcbd5cc17cf7618fb4447053b792df6ae724", + "url": "https://api.github.com/repos/symfony/monolog-bridge/zipball/fc53ae60b2d01d3086d28aea876cfef9bcf75f1e", + "reference": "fc53ae60b2d01d3086d28aea876cfef9bcf75f1e", "shasum": "" }, "require": { @@ -5684,7 +5701,7 @@ "description": "Provides integration for Monolog with various Symfony components", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/monolog-bridge/tree/v8.0.8" + "source": "https://github.com/symfony/monolog-bridge/tree/v8.0.12" }, "funding": [ { @@ -5704,7 +5721,7 @@ "type": "tidelift" } ], - "time": "2026-03-30T15:14:47+00:00" + "time": "2026-05-20T07:22:03+00:00" }, { "name": "symfony/monolog-bundle", @@ -5931,16 +5948,16 @@ }, { "name": "symfony/polyfill-intl-grapheme", - "version": "v1.36.0", + "version": "v1.37.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-grapheme.git", - "reference": "ad1b7b9092976d6c948b8a187cec9faaea9ec1df" + "reference": "4864388bfbd3001ce88e234fab652acd91fdc57e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/ad1b7b9092976d6c948b8a187cec9faaea9ec1df", - "reference": "ad1b7b9092976d6c948b8a187cec9faaea9ec1df", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/4864388bfbd3001ce88e234fab652acd91fdc57e", + "reference": "4864388bfbd3001ce88e234fab652acd91fdc57e", "shasum": "" }, "require": { @@ -5989,7 +6006,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.36.0" + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.37.0" }, "funding": [ { @@ -6009,11 +6026,11 @@ "type": "tidelift" } ], - "time": "2026-04-10T16:19:22+00:00" + "time": "2026-04-26T13:13:48+00:00" }, { "name": "symfony/polyfill-intl-icu", - "version": "v1.36.0", + "version": "v1.37.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-icu.git", @@ -6077,7 +6094,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-icu/tree/v1.36.0" + "source": "https://github.com/symfony/polyfill-intl-icu/tree/v1.37.0" }, "funding": [ { @@ -6101,7 +6118,7 @@ }, { "name": "symfony/polyfill-intl-idn", - "version": "v1.36.0", + "version": "v1.37.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-idn.git", @@ -6164,7 +6181,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.36.0" + "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.37.0" }, "funding": [ { @@ -6188,7 +6205,7 @@ }, { "name": "symfony/polyfill-intl-normalizer", - "version": "v1.36.0", + "version": "v1.37.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-normalizer.git", @@ -6249,7 +6266,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.36.0" + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.37.0" }, "funding": [ { @@ -6273,7 +6290,7 @@ }, { "name": "symfony/polyfill-mbstring", - "version": "v1.36.0", + "version": "v1.37.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-mbstring.git", @@ -6334,7 +6351,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.36.0" + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.37.0" }, "funding": [ { @@ -6358,16 +6375,16 @@ }, { "name": "symfony/polyfill-php85", - "version": "v1.36.0", + "version": "v1.37.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php85.git", - "reference": "2c408a6bb0313e6001a83628dc5506100474254e" + "reference": "fcfa4973a9917cef23f2e38774da74a2b7d115ee" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/2c408a6bb0313e6001a83628dc5506100474254e", - "reference": "2c408a6bb0313e6001a83628dc5506100474254e", + "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/fcfa4973a9917cef23f2e38774da74a2b7d115ee", + "reference": "fcfa4973a9917cef23f2e38774da74a2b7d115ee", "shasum": "" }, "require": { @@ -6414,7 +6431,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php85/tree/v1.36.0" + "source": "https://github.com/symfony/polyfill-php85/tree/v1.37.0" }, "funding": [ { @@ -6434,11 +6451,11 @@ "type": "tidelift" } ], - "time": "2026-04-10T16:50:15+00:00" + "time": "2026-04-26T13:10:57+00:00" }, { "name": "symfony/polyfill-uuid", - "version": "v1.36.0", + "version": "v1.37.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-uuid.git", @@ -6497,7 +6514,7 @@ "uuid" ], "support": { - "source": "https://github.com/symfony/polyfill-uuid/tree/v1.36.0" + "source": "https://github.com/symfony/polyfill-uuid/tree/v1.37.0" }, "funding": [ { @@ -6521,16 +6538,16 @@ }, { "name": "symfony/process", - "version": "v8.0.8", + "version": "v8.0.11", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "cb8939aff03470d1a9d1d1b66d08c6fa71b3bbdc" + "reference": "26d89e459f037d2873300605d0a07e7a8ef84db0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/cb8939aff03470d1a9d1d1b66d08c6fa71b3bbdc", - "reference": "cb8939aff03470d1a9d1d1b66d08c6fa71b3bbdc", + "url": "https://api.github.com/repos/symfony/process/zipball/26d89e459f037d2873300605d0a07e7a8ef84db0", + "reference": "26d89e459f037d2873300605d0a07e7a8ef84db0", "shasum": "" }, "require": { @@ -6562,7 +6579,7 @@ "description": "Executes commands in sub-processes", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/process/tree/v8.0.8" + "source": "https://github.com/symfony/process/tree/v8.0.11" }, "funding": [ { @@ -6582,7 +6599,7 @@ "type": "tidelift" } ], - "time": "2026-03-30T15:14:47+00:00" + "time": "2026-05-11T16:56:32+00:00" }, { "name": "symfony/property-access", @@ -6753,16 +6770,16 @@ }, { "name": "symfony/rate-limiter", - "version": "v8.0.8", + "version": "v8.0.10", "source": { "type": "git", "url": "https://github.com/symfony/rate-limiter.git", - "reference": "48d80b302c58efb369d6f37a389e3c3dcaf8987a" + "reference": "81146a00aacbc9f610f22391cc4ea170090dfb8a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/rate-limiter/zipball/48d80b302c58efb369d6f37a389e3c3dcaf8987a", - "reference": "48d80b302c58efb369d6f37a389e3c3dcaf8987a", + "url": "https://api.github.com/repos/symfony/rate-limiter/zipball/81146a00aacbc9f610f22391cc4ea170090dfb8a", + "reference": "81146a00aacbc9f610f22391cc4ea170090dfb8a", "shasum": "" }, "require": { @@ -6803,7 +6820,7 @@ "rate-limiter" ], "support": { - "source": "https://github.com/symfony/rate-limiter/tree/v8.0.8" + "source": "https://github.com/symfony/rate-limiter/tree/v8.0.10" }, "funding": [ { @@ -6823,20 +6840,20 @@ "type": "tidelift" } ], - "time": "2026-03-30T15:14:47+00:00" + "time": "2026-05-04T13:41:39+00:00" }, { "name": "symfony/routing", - "version": "v8.0.8", + "version": "v8.0.12", "source": { "type": "git", "url": "https://github.com/symfony/routing.git", - "reference": "0de330ec2ea922a7b08ec45615bd51179de7fda4" + "reference": "c7f22a665faa3e5212b8f042e0c5831a6b85492f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/routing/zipball/0de330ec2ea922a7b08ec45615bd51179de7fda4", - "reference": "0de330ec2ea922a7b08ec45615bd51179de7fda4", + "url": "https://api.github.com/repos/symfony/routing/zipball/c7f22a665faa3e5212b8f042e0c5831a6b85492f", + "reference": "c7f22a665faa3e5212b8f042e0c5831a6b85492f", "shasum": "" }, "require": { @@ -6883,7 +6900,7 @@ "url" ], "support": { - "source": "https://github.com/symfony/routing/tree/v8.0.8" + "source": "https://github.com/symfony/routing/tree/v8.0.12" }, "funding": [ { @@ -6903,20 +6920,20 @@ "type": "tidelift" } ], - "time": "2026-03-30T15:14:47+00:00" + "time": "2026-05-20T07:22:03+00:00" }, { "name": "symfony/runtime", - "version": "v8.0.8", + "version": "v8.0.12", "source": { "type": "git", "url": "https://github.com/symfony/runtime.git", - "reference": "30884f00e017a26100fcd9aa281082ebf9a87dce" + "reference": "890458ae03d89c45b1735c5bd4df1d698ebd7166" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/runtime/zipball/30884f00e017a26100fcd9aa281082ebf9a87dce", - "reference": "30884f00e017a26100fcd9aa281082ebf9a87dce", + "url": "https://api.github.com/repos/symfony/runtime/zipball/890458ae03d89c45b1735c5bd4df1d698ebd7166", + "reference": "890458ae03d89c45b1735c5bd4df1d698ebd7166", "shasum": "" }, "require": { @@ -6929,6 +6946,7 @@ "require-dev": { "composer/composer": "^2.6", "symfony/console": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", "symfony/dotenv": "^7.4|^8.0", "symfony/http-foundation": "^7.4|^8.0", "symfony/http-kernel": "^7.4|^8.0" @@ -6966,7 +6984,7 @@ "runtime" ], "support": { - "source": "https://github.com/symfony/runtime/tree/v8.0.8" + "source": "https://github.com/symfony/runtime/tree/v8.0.12" }, "funding": [ { @@ -6986,20 +7004,20 @@ "type": "tidelift" } ], - "time": "2026-03-30T15:14:47+00:00" + "time": "2026-05-20T07:22:03+00:00" }, { "name": "symfony/security-bundle", - "version": "v8.0.8", + "version": "v8.0.12", "source": { "type": "git", "url": "https://github.com/symfony/security-bundle.git", - "reference": "00a13be2abf3fe9baf54e1e16e7583ca9c708f09" + "reference": "c376fddb035751fb56a74bc2213cc1a67c4805c6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/security-bundle/zipball/00a13be2abf3fe9baf54e1e16e7583ca9c708f09", - "reference": "00a13be2abf3fe9baf54e1e16e7583ca9c708f09", + "url": "https://api.github.com/repos/symfony/security-bundle/zipball/c376fddb035751fb56a74bc2213cc1a67c4805c6", + "reference": "c376fddb035751fb56a74bc2213cc1a67c4805c6", "shasum": "" }, "require": { @@ -7066,7 +7084,7 @@ "description": "Provides a tight integration of the Security component into the Symfony full-stack framework", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/security-bundle/tree/v8.0.8" + "source": "https://github.com/symfony/security-bundle/tree/v8.0.12" }, "funding": [ { @@ -7086,20 +7104,20 @@ "type": "tidelift" } ], - "time": "2026-03-30T15:14:47+00:00" + "time": "2026-05-20T07:22:03+00:00" }, { "name": "symfony/security-core", - "version": "v8.0.8", + "version": "v8.0.12", "source": { "type": "git", "url": "https://github.com/symfony/security-core.git", - "reference": "8456ed58e22f59a4c50f50d7dd82b2f41d162c5f" + "reference": "29e2ef1855dff38f01a131645298e0c88001ca07" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/security-core/zipball/8456ed58e22f59a4c50f50d7dd82b2f41d162c5f", - "reference": "8456ed58e22f59a4c50f50d7dd82b2f41d162c5f", + "url": "https://api.github.com/repos/symfony/security-core/zipball/29e2ef1855dff38f01a131645298e0c88001ca07", + "reference": "29e2ef1855dff38f01a131645298e0c88001ca07", "shasum": "" }, "require": { @@ -7148,7 +7166,7 @@ "description": "Symfony Security Component - Core Library", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/security-core/tree/v8.0.8" + "source": "https://github.com/symfony/security-core/tree/v8.0.12" }, "funding": [ { @@ -7168,7 +7186,7 @@ "type": "tidelift" } ], - "time": "2026-03-31T07:15:36+00:00" + "time": "2026-05-19T19:56:11+00:00" }, { "name": "symfony/security-csrf", @@ -7243,16 +7261,16 @@ }, { "name": "symfony/security-http", - "version": "v8.0.8", + "version": "v8.0.12", "source": { "type": "git", "url": "https://github.com/symfony/security-http.git", - "reference": "d3918d827ad0d18dcf009cf8fee82fd6e107de92" + "reference": "d776945b2dc41c0e609c56c1ef69863ab56856ed" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/security-http/zipball/d3918d827ad0d18dcf009cf8fee82fd6e107de92", - "reference": "d3918d827ad0d18dcf009cf8fee82fd6e107de92", + "url": "https://api.github.com/repos/symfony/security-http/zipball/d776945b2dc41c0e609c56c1ef69863ab56856ed", + "reference": "d776945b2dc41c0e609c56c1ef69863ab56856ed", "shasum": "" }, "require": { @@ -7306,7 +7324,7 @@ "description": "Symfony Security Component - HTTP Integration", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/security-http/tree/v8.0.8" + "source": "https://github.com/symfony/security-http/tree/v8.0.12" }, "funding": [ { @@ -7326,20 +7344,20 @@ "type": "tidelift" } ], - "time": "2026-03-30T15:14:47+00:00" + "time": "2026-05-20T07:22:03+00:00" }, { "name": "symfony/serializer", - "version": "v8.0.8", + "version": "v8.0.10", "source": { "type": "git", "url": "https://github.com/symfony/serializer.git", - "reference": "0e3169be25dbf0c23686c8089662cee9dd714932" + "reference": "72ed7e1475790714f07c3a59bd01fd32cd022fdf" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/serializer/zipball/0e3169be25dbf0c23686c8089662cee9dd714932", - "reference": "0e3169be25dbf0c23686c8089662cee9dd714932", + "url": "https://api.github.com/repos/symfony/serializer/zipball/72ed7e1475790714f07c3a59bd01fd32cd022fdf", + "reference": "72ed7e1475790714f07c3a59bd01fd32cd022fdf", "shasum": "" }, "require": { @@ -7349,6 +7367,7 @@ "conflict": { "phpdocumentor/reflection-docblock": "<5.2|>=7", "phpdocumentor/type-resolver": "<1.5.1", + "symfony/property-access": "<7.4.2|>=8.0,<8.0.2", "symfony/property-info": "<7.4", "symfony/type-info": "<7.4" }, @@ -7367,7 +7386,7 @@ "symfony/http-kernel": "^7.4|^8.0", "symfony/messenger": "^7.4|^8.0", "symfony/mime": "^7.4|^8.0", - "symfony/property-access": "^7.4|^8.0", + "symfony/property-access": "^7.4.2|^8.0.2", "symfony/property-info": "^7.4|^8.0", "symfony/translation-contracts": "^2.5|^3", "symfony/type-info": "^7.4|^8.0", @@ -7403,7 +7422,7 @@ "description": "Handles serializing and deserializing data structures, including object graphs, into array structures or other formats like XML and JSON.", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/serializer/tree/v8.0.8" + "source": "https://github.com/symfony/serializer/tree/v8.0.10" }, "funding": [ { @@ -7423,20 +7442,20 @@ "type": "tidelift" } ], - "time": "2026-03-31T07:15:36+00:00" + "time": "2026-05-04T13:41:39+00:00" }, { "name": "symfony/service-contracts", - "version": "v3.6.1", + "version": "v3.7.0", "source": { "type": "git", "url": "https://github.com/symfony/service-contracts.git", - "reference": "45112560a3ba2d715666a509a0bc9521d10b6c43" + "reference": "d25d82433a80eba6aa0e6c24b61d7370d99e444a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/service-contracts/zipball/45112560a3ba2d715666a509a0bc9521d10b6c43", - "reference": "45112560a3ba2d715666a509a0bc9521d10b6c43", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/d25d82433a80eba6aa0e6c24b61d7370d99e444a", + "reference": "d25d82433a80eba6aa0e6c24b61d7370d99e444a", "shasum": "" }, "require": { @@ -7454,7 +7473,7 @@ "name": "symfony/contracts" }, "branch-alias": { - "dev-main": "3.6-dev" + "dev-main": "3.7-dev" } }, "autoload": { @@ -7490,7 +7509,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/service-contracts/tree/v3.6.1" + "source": "https://github.com/symfony/service-contracts/tree/v3.7.0" }, "funding": [ { @@ -7510,7 +7529,7 @@ "type": "tidelift" } ], - "time": "2025-07-15T11:30:57+00:00" + "time": "2026-03-28T09:44:51+00:00" }, { "name": "symfony/stimulus-bundle", @@ -7653,16 +7672,16 @@ }, { "name": "symfony/string", - "version": "v8.0.8", + "version": "v8.0.11", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "ae9488f874d7603f9d2dfbf120203882b645d963" + "reference": "39be2ad058a3c0bd558edca23e65f009865d75ff" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/ae9488f874d7603f9d2dfbf120203882b645d963", - "reference": "ae9488f874d7603f9d2dfbf120203882b645d963", + "url": "https://api.github.com/repos/symfony/string/zipball/39be2ad058a3c0bd558edca23e65f009865d75ff", + "reference": "39be2ad058a3c0bd558edca23e65f009865d75ff", "shasum": "" }, "require": { @@ -7719,7 +7738,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v8.0.8" + "source": "https://github.com/symfony/string/tree/v8.0.11" }, "funding": [ { @@ -7739,20 +7758,20 @@ "type": "tidelift" } ], - "time": "2026-03-30T15:14:47+00:00" + "time": "2026-05-13T12:07:53+00:00" }, { "name": "symfony/translation", - "version": "v8.0.8", + "version": "v8.0.10", "source": { "type": "git", "url": "https://github.com/symfony/translation.git", - "reference": "27c03ae3940de24ba2f71cfdbac824f2aa1fdf2f" + "reference": "f63e9342e12646a57c91ef8a366a4f9d8e557b67" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/translation/zipball/27c03ae3940de24ba2f71cfdbac824f2aa1fdf2f", - "reference": "27c03ae3940de24ba2f71cfdbac824f2aa1fdf2f", + "url": "https://api.github.com/repos/symfony/translation/zipball/f63e9342e12646a57c91ef8a366a4f9d8e557b67", + "reference": "f63e9342e12646a57c91ef8a366a4f9d8e557b67", "shasum": "" }, "require": { @@ -7812,7 +7831,7 @@ "description": "Provides tools to internationalize your application", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/translation/tree/v8.0.8" + "source": "https://github.com/symfony/translation/tree/v8.0.10" }, "funding": [ { @@ -7832,20 +7851,20 @@ "type": "tidelift" } ], - "time": "2026-03-30T15:14:47+00:00" + "time": "2026-05-06T11:30:54+00:00" }, { "name": "symfony/translation-contracts", - "version": "v3.6.1", + "version": "v3.7.0", "source": { "type": "git", "url": "https://github.com/symfony/translation-contracts.git", - "reference": "65a8bc82080447fae78373aa10f8d13b38338977" + "reference": "0ab302977a952b42fd51475c4ebac81f8da0a95d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/65a8bc82080447fae78373aa10f8d13b38338977", - "reference": "65a8bc82080447fae78373aa10f8d13b38338977", + "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/0ab302977a952b42fd51475c4ebac81f8da0a95d", + "reference": "0ab302977a952b42fd51475c4ebac81f8da0a95d", "shasum": "" }, "require": { @@ -7858,7 +7877,7 @@ "name": "symfony/contracts" }, "branch-alias": { - "dev-main": "3.6-dev" + "dev-main": "3.7-dev" } }, "autoload": { @@ -7894,7 +7913,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/translation-contracts/tree/v3.6.1" + "source": "https://github.com/symfony/translation-contracts/tree/v3.7.0" }, "funding": [ { @@ -7914,20 +7933,20 @@ "type": "tidelift" } ], - "time": "2025-07-15T13:41:35+00:00" + "time": "2026-01-05T13:30:16+00:00" }, { "name": "symfony/twig-bridge", - "version": "v8.0.8", + "version": "v8.0.12", "source": { "type": "git", "url": "https://github.com/symfony/twig-bridge.git", - "reference": "a892d0b7f3d5d51b35895467e48aafbd1f2612a0" + "reference": "f1397eb19ab4f738bd22570d65d40792c1ba3f79" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/twig-bridge/zipball/a892d0b7f3d5d51b35895467e48aafbd1f2612a0", - "reference": "a892d0b7f3d5d51b35895467e48aafbd1f2612a0", + "url": "https://api.github.com/repos/symfony/twig-bridge/zipball/f1397eb19ab4f738bd22570d65d40792c1ba3f79", + "reference": "f1397eb19ab4f738bd22570d65d40792c1ba3f79", "shasum": "" }, "require": { @@ -7939,7 +7958,7 @@ "phpdocumentor/reflection-docblock": "<5.2|>=7", "phpdocumentor/type-resolver": "<1.5.1", "symfony/form": "<7.4.4|>8.0,<8.0.4", - "symfony/mime": "<7.4.8|>8.0,<8.0.8" + "symfony/mime": "<7.4.9|>8.0,<8.0.9" }, "require-dev": { "egulias/email-validator": "^2.1.10|^3|^4", @@ -7957,7 +7976,7 @@ "symfony/http-foundation": "^7.4|^8.0", "symfony/http-kernel": "^7.4|^8.0", "symfony/intl": "^7.4|^8.0", - "symfony/mime": "^7.4.8|^8.0.8", + "symfony/mime": "^7.4.9|^8.0.9", "symfony/polyfill-intl-icu": "^1.0", "symfony/property-info": "^7.4|^8.0", "symfony/routing": "^7.4|^8.0", @@ -8002,7 +8021,7 @@ "description": "Provides integration for Twig with various Symfony components", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/twig-bridge/tree/v8.0.8" + "source": "https://github.com/symfony/twig-bridge/tree/v8.0.12" }, "funding": [ { @@ -8022,7 +8041,7 @@ "type": "tidelift" } ], - "time": "2026-03-30T15:14:47+00:00" + "time": "2026-04-29T18:17:56+00:00" }, { "name": "symfony/twig-bundle", @@ -8110,16 +8129,16 @@ }, { "name": "symfony/type-info", - "version": "v8.0.8", + "version": "v8.0.9", "source": { "type": "git", "url": "https://github.com/symfony/type-info.git", - "reference": "622d81551770029d44d16be68969712eb47892f1" + "reference": "08723aceb8c3271e8cb3db8b2565728b0c88e866" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/type-info/zipball/622d81551770029d44d16be68969712eb47892f1", - "reference": "622d81551770029d44d16be68969712eb47892f1", + "url": "https://api.github.com/repos/symfony/type-info/zipball/08723aceb8c3271e8cb3db8b2565728b0c88e866", + "reference": "08723aceb8c3271e8cb3db8b2565728b0c88e866", "shasum": "" }, "require": { @@ -8168,7 +8187,7 @@ "type" ], "support": { - "source": "https://github.com/symfony/type-info/tree/v8.0.8" + "source": "https://github.com/symfony/type-info/tree/v8.0.9" }, "funding": [ { @@ -8188,20 +8207,20 @@ "type": "tidelift" } ], - "time": "2026-03-30T15:14:47+00:00" + "time": "2026-04-29T15:02:55+00:00" }, { "name": "symfony/uid", - "version": "v8.0.8", + "version": "v8.0.9", "source": { "type": "git", "url": "https://github.com/symfony/uid.git", - "reference": "f63fa6096a24147283bce4d29327d285326438e0" + "reference": "4d9d6510bbe88ebb4608b7200d18606cdf80825c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/uid/zipball/f63fa6096a24147283bce4d29327d285326438e0", - "reference": "f63fa6096a24147283bce4d29327d285326438e0", + "url": "https://api.github.com/repos/symfony/uid/zipball/4d9d6510bbe88ebb4608b7200d18606cdf80825c", + "reference": "4d9d6510bbe88ebb4608b7200d18606cdf80825c", "shasum": "" }, "require": { @@ -8246,7 +8265,7 @@ "uuid" ], "support": { - "source": "https://github.com/symfony/uid/tree/v8.0.8" + "source": "https://github.com/symfony/uid/tree/v8.0.9" }, "funding": [ { @@ -8266,7 +8285,7 @@ "type": "tidelift" } ], - "time": "2026-03-30T15:14:47+00:00" + "time": "2026-04-30T16:10:06+00:00" }, { "name": "symfony/ux-turbo", @@ -8373,16 +8392,16 @@ }, { "name": "symfony/validator", - "version": "v8.0.8", + "version": "v8.0.10", "source": { "type": "git", "url": "https://github.com/symfony/validator.git", - "reference": "1a559eecc841a6fd3dabdcbff9401a0b8951be90" + "reference": "12bb4be483a8626bd1b2f46f5d44c9449cf4361f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/validator/zipball/1a559eecc841a6fd3dabdcbff9401a0b8951be90", - "reference": "1a559eecc841a6fd3dabdcbff9401a0b8951be90", + "url": "https://api.github.com/repos/symfony/validator/zipball/12bb4be483a8626bd1b2f46f5d44c9449cf4361f", + "reference": "12bb4be483a8626bd1b2f46f5d44c9449cf4361f", "shasum": "" }, "require": { @@ -8444,7 +8463,7 @@ "description": "Provides tools to validate values", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/validator/tree/v8.0.8" + "source": "https://github.com/symfony/validator/tree/v8.0.10" }, "funding": [ { @@ -8464,7 +8483,7 @@ "type": "tidelift" } ], - "time": "2026-03-30T15:14:47+00:00" + "time": "2026-05-05T16:03:11+00:00" }, { "name": "symfony/var-dumper", @@ -8555,16 +8574,16 @@ }, { "name": "symfony/var-exporter", - "version": "v8.0.8", + "version": "v8.0.9", "source": { "type": "git", "url": "https://github.com/symfony/var-exporter.git", - "reference": "15776bb07a91b089037da89f8832fa41d5fa6ec6" + "reference": "24cf67be4dd0926e4413635418682f4fff831412" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-exporter/zipball/15776bb07a91b089037da89f8832fa41d5fa6ec6", - "reference": "15776bb07a91b089037da89f8832fa41d5fa6ec6", + "url": "https://api.github.com/repos/symfony/var-exporter/zipball/24cf67be4dd0926e4413635418682f4fff831412", + "reference": "24cf67be4dd0926e4413635418682f4fff831412", "shasum": "" }, "require": { @@ -8611,7 +8630,7 @@ "serialize" ], "support": { - "source": "https://github.com/symfony/var-exporter/tree/v8.0.8" + "source": "https://github.com/symfony/var-exporter/tree/v8.0.9" }, "funding": [ { @@ -8631,7 +8650,7 @@ "type": "tidelift" } ], - "time": "2026-03-30T15:14:47+00:00" + "time": "2026-04-18T13:51:42+00:00" }, { "name": "symfony/web-link", @@ -8719,16 +8738,16 @@ }, { "name": "symfony/yaml", - "version": "v8.0.8", + "version": "v8.0.12", "source": { "type": "git", "url": "https://github.com/symfony/yaml.git", - "reference": "54174ab48c0c0f9e21512b304be17f8150ccf8f1" + "reference": "2a36f4b8405d41fa31799b06874dbd45c1b16c30" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/yaml/zipball/54174ab48c0c0f9e21512b304be17f8150ccf8f1", - "reference": "54174ab48c0c0f9e21512b304be17f8150ccf8f1", + "url": "https://api.github.com/repos/symfony/yaml/zipball/2a36f4b8405d41fa31799b06874dbd45c1b16c30", + "reference": "2a36f4b8405d41fa31799b06874dbd45c1b16c30", "shasum": "" }, "require": { @@ -8770,7 +8789,7 @@ "description": "Loads and dumps YAML files", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/yaml/tree/v8.0.8" + "source": "https://github.com/symfony/yaml/tree/v8.0.12" }, "funding": [ { @@ -8790,7 +8809,7 @@ "type": "tidelift" } ], - "time": "2026-03-30T15:14:47+00:00" + "time": "2026-05-20T07:22:03+00:00" }, { "name": "symfonycasts/reset-password-bundle", @@ -8842,16 +8861,16 @@ }, { "name": "twig/twig", - "version": "v3.24.0", + "version": "v3.26.0", "source": { "type": "git", "url": "https://github.com/twigphp/Twig.git", - "reference": "a6769aefb305efef849dc25c9fd1653358c148f0" + "reference": "1fcae487b180d78e6351f4e0afa91f9eab96a2bc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/twigphp/Twig/zipball/a6769aefb305efef849dc25c9fd1653358c148f0", - "reference": "a6769aefb305efef849dc25c9fd1653358c148f0", + "url": "https://api.github.com/repos/twigphp/Twig/zipball/1fcae487b180d78e6351f4e0afa91f9eab96a2bc", + "reference": "1fcae487b180d78e6351f4e0afa91f9eab96a2bc", "shasum": "" }, "require": { @@ -8906,7 +8925,7 @@ ], "support": { "issues": "https://github.com/twigphp/Twig/issues", - "source": "https://github.com/twigphp/Twig/tree/v3.24.0" + "source": "https://github.com/twigphp/Twig/tree/v3.26.0" }, "funding": [ { @@ -8918,20 +8937,20 @@ "type": "tidelift" } ], - "time": "2026-03-17T21:31:11+00:00" + "time": "2026-05-20T07:31:59+00:00" }, { "name": "web-auth/cose-lib", - "version": "4.5.1", + "version": "4.5.2", "source": { "type": "git", "url": "https://github.com/web-auth/cose-lib.git", - "reference": "3185af4df10dc537b65c140c315b88d15ae15b80" + "reference": "5b38660f90070a8e45f3dbc9528ade3b608dd77d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/web-auth/cose-lib/zipball/3185af4df10dc537b65c140c315b88d15ae15b80", - "reference": "3185af4df10dc537b65c140c315b88d15ae15b80", + "url": "https://api.github.com/repos/web-auth/cose-lib/zipball/5b38660f90070a8e45f3dbc9528ade3b608dd77d", + "reference": "5b38660f90070a8e45f3dbc9528ade3b608dd77d", "shasum": "" }, "require": { @@ -8977,7 +8996,7 @@ ], "support": { "issues": "https://github.com/web-auth/cose-lib/issues", - "source": "https://github.com/web-auth/cose-lib/tree/4.5.1" + "source": "https://github.com/web-auth/cose-lib/tree/4.5.2" }, "funding": [ { @@ -8989,20 +9008,20 @@ "type": "patreon" } ], - "time": "2026-04-01T12:47:39+00:00" + "time": "2026-05-03T09:49:50+00:00" }, { "name": "web-auth/webauthn-lib", - "version": "5.3.x-dev", + "version": "5.3.3", "source": { "type": "git", "url": "https://github.com/web-auth/webauthn-lib.git", - "reference": "1ea7e2cae320f04c75212bf019e845d8d7faf950" + "reference": "e6f656d6c6b29fa305382fe6a0a3be8177d177df" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/web-auth/webauthn-lib/zipball/1ea7e2cae320f04c75212bf019e845d8d7faf950", - "reference": "1ea7e2cae320f04c75212bf019e845d8d7faf950", + "url": "https://api.github.com/repos/web-auth/webauthn-lib/zipball/e6f656d6c6b29fa305382fe6a0a3be8177d177df", + "reference": "e6f656d6c6b29fa305382fe6a0a3be8177d177df", "shasum": "" }, "require": { @@ -9029,7 +9048,6 @@ "symfony/event-dispatcher": "Recommended to use dispatched events", "web-token/jwt-library": "Mandatory for fetching Metadata Statement from distant sources" }, - "default-branch": true, "type": "library", "extra": { "thanks": { @@ -9064,7 +9082,7 @@ "webauthn" ], "support": { - "source": "https://github.com/web-auth/webauthn-lib/tree/5.3.x" + "source": "https://github.com/web-auth/webauthn-lib/tree/5.3.3" }, "funding": [ { @@ -9076,20 +9094,20 @@ "type": "patreon" } ], - "time": "2026-03-22T17:54:03+00:00" + "time": "2026-05-17T19:04:30+00:00" }, { "name": "webmozart/assert", - "version": "2.3.0", + "version": "2.4.0", "source": { "type": "git", "url": "https://github.com/webmozarts/assert.git", - "reference": "eb0d790f735ba6cff25c683a85a1da0eadeff9e4" + "reference": "9007ea6f45ecf352a9422b36644e4bfc039b9155" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/webmozarts/assert/zipball/eb0d790f735ba6cff25c683a85a1da0eadeff9e4", - "reference": "eb0d790f735ba6cff25c683a85a1da0eadeff9e4", + "url": "https://api.github.com/repos/webmozarts/assert/zipball/9007ea6f45ecf352a9422b36644e4bfc039b9155", + "reference": "9007ea6f45ecf352a9422b36644e4bfc039b9155", "shasum": "" }, "require": { @@ -9105,7 +9123,11 @@ }, "type": "library", "extra": { + "psalm": { + "pluginClass": "Webmozart\\Assert\\PsalmPlugin" + }, "branch-alias": { + "dev-master": "2.0-dev", "dev-feature/2-0": "2.0-dev" } }, @@ -9136,9 +9158,9 @@ ], "support": { "issues": "https://github.com/webmozarts/assert/issues", - "source": "https://github.com/webmozarts/assert/tree/2.3.0" + "source": "https://github.com/webmozarts/assert/tree/2.4.0" }, - "time": "2026-04-11T10:33:05+00:00" + "time": "2026-05-20T13:07:01+00:00" } ], "packages-dev": [ @@ -9718,16 +9740,16 @@ }, { "name": "phpunit/phpunit", - "version": "12.5.23", + "version": "12.5.26", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "c54fcf3d6bcb6e96ac2f7e40097dc37b5f139969" + "reference": "e78c9ad74f73fd3642a23e65ace83746dc8df26d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/c54fcf3d6bcb6e96ac2f7e40097dc37b5f139969", - "reference": "c54fcf3d6bcb6e96ac2f7e40097dc37b5f139969", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/e78c9ad74f73fd3642a23e65ace83746dc8df26d", + "reference": "e78c9ad74f73fd3642a23e65ace83746dc8df26d", "shasum": "" }, "require": { @@ -9746,15 +9768,15 @@ "phpunit/php-invoker": "^6.0.0", "phpunit/php-text-template": "^5.0.0", "phpunit/php-timer": "^8.0.0", - "sebastian/cli-parser": "^4.2.0", - "sebastian/comparator": "^7.1.6", + "sebastian/cli-parser": "^4.2.1", + "sebastian/comparator": "^7.1.8", "sebastian/diff": "^7.0.0", - "sebastian/environment": "^8.1.0", - "sebastian/exporter": "^7.0.2", + "sebastian/environment": "^8.1.1", + "sebastian/exporter": "^7.0.3", "sebastian/global-state": "^8.0.2", "sebastian/object-enumerator": "^7.0.0", "sebastian/recursion-context": "^7.0.1", - "sebastian/type": "^6.0.3", + "sebastian/type": "^6.0.4", "sebastian/version": "^6.0.0", "staabm/side-effects-detector": "^1.0.5" }, @@ -9796,7 +9818,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.23" + "source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.26" }, "funding": [ { @@ -9804,7 +9826,7 @@ "type": "other" } ], - "time": "2026-04-18T06:12:49+00:00" + "time": "2026-05-21T12:36:53+00:00" }, { "name": "rector/rector", @@ -9869,23 +9891,23 @@ }, { "name": "sebastian/cli-parser", - "version": "4.2.0", + "version": "4.2.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/cli-parser.git", - "reference": "90f41072d220e5c40df6e8635f5dafba2d9d4d04" + "reference": "7d05781b13f7dec9043a629a21d086ed74582a15" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/90f41072d220e5c40df6e8635f5dafba2d9d4d04", - "reference": "90f41072d220e5c40df6e8635f5dafba2d9d4d04", + "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/7d05781b13f7dec9043a629a21d086ed74582a15", + "reference": "7d05781b13f7dec9043a629a21d086ed74582a15", "shasum": "" }, "require": { "php": ">=8.3" }, "require-dev": { - "phpunit/phpunit": "^12.0" + "phpunit/phpunit": "^12.5.25" }, "type": "library", "extra": { @@ -9914,7 +9936,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/cli-parser/issues", "security": "https://github.com/sebastianbergmann/cli-parser/security/policy", - "source": "https://github.com/sebastianbergmann/cli-parser/tree/4.2.0" + "source": "https://github.com/sebastianbergmann/cli-parser/tree/4.2.1" }, "funding": [ { @@ -9934,20 +9956,20 @@ "type": "tidelift" } ], - "time": "2025-09-14T09:36:45+00:00" + "time": "2026-05-17T05:29:34+00:00" }, { "name": "sebastian/comparator", - "version": "7.1.6", + "version": "7.1.8", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/comparator.git", - "reference": "c769009dee98f494e0edc3fd4f4087501688f11e" + "reference": "7c65c1e79836812819705b473a90c12399542485" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/c769009dee98f494e0edc3fd4f4087501688f11e", - "reference": "c769009dee98f494e0edc3fd4f4087501688f11e", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/7c65c1e79836812819705b473a90c12399542485", + "reference": "7c65c1e79836812819705b473a90c12399542485", "shasum": "" }, "require": { @@ -9955,10 +9977,10 @@ "ext-mbstring": "*", "php": ">=8.3", "sebastian/diff": "^7.0", - "sebastian/exporter": "^7.0" + "sebastian/exporter": "^7.0.3" }, "require-dev": { - "phpunit/phpunit": "^12.2" + "phpunit/phpunit": "^12.5.25" }, "suggest": { "ext-bcmath": "For comparing BcMath\\Number objects" @@ -10006,7 +10028,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/comparator/issues", "security": "https://github.com/sebastianbergmann/comparator/security/policy", - "source": "https://github.com/sebastianbergmann/comparator/tree/7.1.6" + "source": "https://github.com/sebastianbergmann/comparator/tree/7.1.8" }, "funding": [ { @@ -10026,7 +10048,7 @@ "type": "tidelift" } ], - "time": "2026-04-14T08:23:15+00:00" + "time": "2026-05-21T04:45:25+00:00" }, { "name": "sebastian/complexity", @@ -10155,23 +10177,23 @@ }, { "name": "sebastian/environment", - "version": "8.1.0", + "version": "8.1.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/environment.git", - "reference": "b121608b28a13f721e76ffbbd386d08eff58f3f6" + "reference": "334bc42a97ec6fc44c59001dc3467e0d739a20e9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/b121608b28a13f721e76ffbbd386d08eff58f3f6", - "reference": "b121608b28a13f721e76ffbbd386d08eff58f3f6", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/334bc42a97ec6fc44c59001dc3467e0d739a20e9", + "reference": "334bc42a97ec6fc44c59001dc3467e0d739a20e9", "shasum": "" }, "require": { "php": ">=8.3" }, "require-dev": { - "phpunit/phpunit": "^12.0" + "phpunit/phpunit": "^12.5.25" }, "suggest": { "ext-posix": "*" @@ -10207,7 +10229,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/environment/issues", "security": "https://github.com/sebastianbergmann/environment/security/policy", - "source": "https://github.com/sebastianbergmann/environment/tree/8.1.0" + "source": "https://github.com/sebastianbergmann/environment/tree/8.1.1" }, "funding": [ { @@ -10227,29 +10249,29 @@ "type": "tidelift" } ], - "time": "2026-04-15T12:13:01+00:00" + "time": "2026-05-21T08:45:32+00:00" }, { "name": "sebastian/exporter", - "version": "7.0.2", + "version": "7.0.3", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/exporter.git", - "reference": "016951ae10980765e4e7aee491eb288c64e505b7" + "reference": "c5e21b5de653ce0a769fb36f5cdfcb5e7a32cf23" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/016951ae10980765e4e7aee491eb288c64e505b7", - "reference": "016951ae10980765e4e7aee491eb288c64e505b7", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/c5e21b5de653ce0a769fb36f5cdfcb5e7a32cf23", + "reference": "c5e21b5de653ce0a769fb36f5cdfcb5e7a32cf23", "shasum": "" }, "require": { "ext-mbstring": "*", "php": ">=8.3", - "sebastian/recursion-context": "^7.0" + "sebastian/recursion-context": "^7.0.1" }, "require-dev": { - "phpunit/phpunit": "^12.0" + "phpunit/phpunit": "^12.5.25" }, "type": "library", "extra": { @@ -10297,7 +10319,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/exporter/issues", "security": "https://github.com/sebastianbergmann/exporter/security/policy", - "source": "https://github.com/sebastianbergmann/exporter/tree/7.0.2" + "source": "https://github.com/sebastianbergmann/exporter/tree/7.0.3" }, "funding": [ { @@ -10317,7 +10339,7 @@ "type": "tidelift" } ], - "time": "2025-09-24T06:16:11+00:00" + "time": "2026-05-20T04:37:17+00:00" }, { "name": "sebastian/global-state", @@ -10395,24 +10417,24 @@ }, { "name": "sebastian/lines-of-code", - "version": "4.0.0", + "version": "4.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/lines-of-code.git", - "reference": "97ffee3bcfb5805568d6af7f0f893678fc076d2f" + "reference": "d543b8ef219dcd8da262cbb958639a96bedba10e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/97ffee3bcfb5805568d6af7f0f893678fc076d2f", - "reference": "97ffee3bcfb5805568d6af7f0f893678fc076d2f", + "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/d543b8ef219dcd8da262cbb958639a96bedba10e", + "reference": "d543b8ef219dcd8da262cbb958639a96bedba10e", "shasum": "" }, "require": { - "nikic/php-parser": "^5.0", + "nikic/php-parser": "^5.7.0", "php": ">=8.3" }, "require-dev": { - "phpunit/phpunit": "^12.0" + "phpunit/phpunit": "^12.5.25" }, "type": "library", "extra": { @@ -10441,15 +10463,27 @@ "support": { "issues": "https://github.com/sebastianbergmann/lines-of-code/issues", "security": "https://github.com/sebastianbergmann/lines-of-code/security/policy", - "source": "https://github.com/sebastianbergmann/lines-of-code/tree/4.0.0" + "source": "https://github.com/sebastianbergmann/lines-of-code/tree/4.0.1" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/lines-of-code", + "type": "tidelift" } ], - "time": "2025-02-07T04:57:28+00:00" + "time": "2026-05-19T16:22:07+00:00" }, { "name": "sebastian/object-enumerator", @@ -10643,23 +10677,23 @@ }, { "name": "sebastian/type", - "version": "6.0.3", + "version": "6.0.4", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/type.git", - "reference": "e549163b9760b8f71f191651d22acf32d56d6d4d" + "reference": "82ff822c2edc46724be9f7411d3163021f602773" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/e549163b9760b8f71f191651d22acf32d56d6d4d", - "reference": "e549163b9760b8f71f191651d22acf32d56d6d4d", + "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/82ff822c2edc46724be9f7411d3163021f602773", + "reference": "82ff822c2edc46724be9f7411d3163021f602773", "shasum": "" }, "require": { "php": ">=8.3" }, "require-dev": { - "phpunit/phpunit": "^12.0" + "phpunit/phpunit": "^12.5.25" }, "type": "library", "extra": { @@ -10688,7 +10722,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/type/issues", "security": "https://github.com/sebastianbergmann/type/security/policy", - "source": "https://github.com/sebastianbergmann/type/tree/6.0.3" + "source": "https://github.com/sebastianbergmann/type/tree/6.0.4" }, "funding": [ { @@ -10708,7 +10742,7 @@ "type": "tidelift" } ], - "time": "2025-08-09T06:57:12+00:00" + "time": "2026-05-20T06:45:45+00:00" }, { "name": "sebastian/version", @@ -10890,16 +10924,16 @@ }, { "name": "symfony/css-selector", - "version": "v8.0.8", + "version": "v8.0.9", "source": { "type": "git", "url": "https://github.com/symfony/css-selector.git", - "reference": "8db1c00226a94d8ab6aa89d9224eeee91e2ea2ed" + "reference": "3665cfade90565430909b906394c73c8739e57d0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/css-selector/zipball/8db1c00226a94d8ab6aa89d9224eeee91e2ea2ed", - "reference": "8db1c00226a94d8ab6aa89d9224eeee91e2ea2ed", + "url": "https://api.github.com/repos/symfony/css-selector/zipball/3665cfade90565430909b906394c73c8739e57d0", + "reference": "3665cfade90565430909b906394c73c8739e57d0", "shasum": "" }, "require": { @@ -10935,7 +10969,7 @@ "description": "Converts CSS selectors to XPath expressions", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/css-selector/tree/v8.0.8" + "source": "https://github.com/symfony/css-selector/tree/v8.0.9" }, "funding": [ { @@ -10955,7 +10989,7 @@ "type": "tidelift" } ], - "time": "2026-03-30T15:14:47+00:00" + "time": "2026-04-18T13:51:42+00:00" }, { "name": "symfony/debug-bundle", @@ -11034,16 +11068,16 @@ }, { "name": "symfony/dom-crawler", - "version": "v8.0.8", + "version": "v8.0.12", "source": { "type": "git", "url": "https://github.com/symfony/dom-crawler.git", - "reference": "284ace90732b445b027728b5e0eec6418a17a364" + "reference": "011b0ce60417f6d40052434d8ae6295b876ecbdd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/dom-crawler/zipball/284ace90732b445b027728b5e0eec6418a17a364", - "reference": "284ace90732b445b027728b5e0eec6418a17a364", + "url": "https://api.github.com/repos/symfony/dom-crawler/zipball/011b0ce60417f6d40052434d8ae6295b876ecbdd", + "reference": "011b0ce60417f6d40052434d8ae6295b876ecbdd", "shasum": "" }, "require": { @@ -11080,7 +11114,7 @@ "description": "Eases DOM navigation for HTML and XML documents", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/dom-crawler/tree/v8.0.8" + "source": "https://github.com/symfony/dom-crawler/tree/v8.0.12" }, "funding": [ { @@ -11100,7 +11134,7 @@ "type": "tidelift" } ], - "time": "2026-03-30T15:14:47+00:00" + "time": "2026-05-20T07:22:03+00:00" }, { "name": "symfony/maker-bundle", @@ -11288,16 +11322,16 @@ }, { "name": "symfony/web-profiler-bundle", - "version": "v8.0.8", + "version": "v8.0.12", "source": { "type": "git", "url": "https://github.com/symfony/web-profiler-bundle.git", - "reference": "8a8614df26c6436b47fbb9debeca74ddfa5b8e46" + "reference": "1d92c5e8b4939c93717cde24c6a5bfd909409dbf" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/web-profiler-bundle/zipball/8a8614df26c6436b47fbb9debeca74ddfa5b8e46", - "reference": "8a8614df26c6436b47fbb9debeca74ddfa5b8e46", + "url": "https://api.github.com/repos/symfony/web-profiler-bundle/zipball/1d92c5e8b4939c93717cde24c6a5bfd909409dbf", + "reference": "1d92c5e8b4939c93717cde24c6a5bfd909409dbf", "shasum": "" }, "require": { @@ -11349,7 +11383,7 @@ "dev" ], "support": { - "source": "https://github.com/symfony/web-profiler-bundle/tree/v8.0.8" + "source": "https://github.com/symfony/web-profiler-bundle/tree/v8.0.12" }, "funding": [ { @@ -11369,7 +11403,7 @@ "type": "tidelift" } ], - "time": "2026-03-30T15:14:47+00:00" + "time": "2026-05-20T07:22:03+00:00" }, { "name": "theseer/tokenizer", @@ -11424,9 +11458,7 @@ ], "aliases": [], "minimum-stability": "stable", - "stability-flags": { - "web-auth/webauthn-lib": 20 - }, + "stability-flags": {}, "prefer-stable": true, "prefer-lowest": false, "platform": { diff --git a/config/packages/cache.php b/config/packages/cache.php new file mode 100644 index 00000000..0ef3d9f0 --- /dev/null +++ b/config/packages/cache.php @@ -0,0 +1,20 @@ + 'fewohbee']; + + if ($useRedis) { + $redisHost = $_SERVER['REDIS_HOST'] ?? 'redis'; + $redisIdx = $_SERVER['REDIS_IDX'] ?? '1'; + $cache['app'] = 'cache.adapter.redis'; + $cache['default_redis_provider'] = sprintf('redis://%s/%s', $redisHost, $redisIdx); + } + + $container->extension('framework', ['cache' => $cache]); +}; diff --git a/config/packages/cache.yaml b/config/packages/cache.yaml deleted file mode 100644 index 6899b720..00000000 --- a/config/packages/cache.yaml +++ /dev/null @@ -1,19 +0,0 @@ -framework: - cache: - # Unique name of your app: used to compute stable namespaces for cache keys. - #prefix_seed: your_vendor_name/app_name - - # The "app" cache stores to the filesystem by default. - # The data in this cache should persist between deploys. - # Other options include: - - # Redis - #app: cache.adapter.redis - #default_redis_provider: redis://localhost - - # APCu (not recommended with heavy random-write workloads as memory fragmentation can cause perf issues) - #app: cache.adapter.apcu - - # Namespaced pools use the above "app" backend by default - #pools: - #my.dedicated.cache: null diff --git a/config/packages/redis/cache.yaml b/config/packages/redis/cache.yaml deleted file mode 100644 index c12ebbbf..00000000 --- a/config/packages/redis/cache.yaml +++ /dev/null @@ -1,15 +0,0 @@ -parameters: - # fallback parameters - # define the redis host - env(REDIS_HOST): 'redis' - # define the used redix index - env(REDIS_IDX): '1' - -framework: - cache: - # Unique name of your app: used to compute stable namespaces for cache keys. - prefix_seed: fewohbee - - # Redis - app: cache.adapter.redis - default_redis_provider: 'redis://%env(resolve:REDIS_HOST)%/%env(resolve:REDIS_IDX)%' diff --git a/config/packages/security.yaml b/config/packages/security.yaml index 61a43b73..bd61ab8e 100644 --- a/config/packages/security.yaml +++ b/config/packages/security.yaml @@ -14,6 +14,9 @@ security: dev: pattern: ^/(_profiler|_wdt|assets|build)/ security: false + health: + pattern: ^/health + security: false main: #pattern: ^/ provider: our_db_provider @@ -45,6 +48,7 @@ security: # Easy way to control access for large sections of your site # Note: Only the *first* access control that matches will be used access_control: + - { path: ^/health, roles: PUBLIC_ACCESS } - { path: ^/login, roles: PUBLIC_ACCESS } - { path: ^/reset-password, roles: PUBLIC_ACCESS } - { path: ^/register, roles: PUBLIC_ACCESS} diff --git a/config/reference.php b/config/reference.php index 3a5de7f3..523d0e5d 100644 --- a/config/reference.php +++ b/config/reference.php @@ -128,7 +128,7 @@ * @psalm-type FrameworkConfig = array{ * secret?: scalar|Param|null, * http_method_override?: bool|Param, // Set true to enable support for the '_method' request parameter to determine the intended HTTP method on POST requests. // Default: false - * allowed_http_method_override?: list|null, + * allowed_http_method_override?: null|list, * trust_x_sendfile_type_header?: scalar|Param|null, // Set true to enable support for xsendfile in binary file responses. // Default: "%env(bool:default::SYMFONY_TRUST_X_SENDFILE_TYPE_HEADER)%" * ide?: scalar|Param|null, // Default: "%env(default::SYMFONY_IDE)%" * test?: bool|Param, @@ -136,9 +136,9 @@ * set_locale_from_accept_language?: bool|Param, // Whether to use the Accept-Language HTTP header to set the Request locale (only when the "_locale" request attribute is not passed). // Default: false * set_content_language_from_locale?: bool|Param, // Whether to set the Content-Language HTTP header on the Response using the Request locale. // Default: false * enabled_locales?: list, - * trusted_hosts?: list, + * trusted_hosts?: string|list, * trusted_proxies?: mixed, // Default: ["%env(default::SYMFONY_TRUSTED_PROXIES)%"] - * trusted_headers?: list, + * trusted_headers?: string|list, * error_controller?: scalar|Param|null, // Default: "error_controller" * handle_all_throwables?: bool|Param, // HttpKernel will handle all kinds of \Throwable. // Default: true * csrf_protection?: bool|array{ @@ -202,23 +202,23 @@ * property?: scalar|Param|null, * service?: scalar|Param|null, * }, - * supports?: list, + * supports?: string|list, * definition_validators?: list, * support_strategy?: scalar|Param|null, - * initial_marking?: list, - * events_to_dispatch?: list|null, - * places?: list, + * events_to_dispatch?: null|list, + * places?: string|list, * }>, * transitions?: list, - * to?: list, @@ -268,7 +268,7 @@ * version_format?: scalar|Param|null, // Default: "%%s?%%s" * json_manifest_path?: scalar|Param|null, // Default: null * base_path?: scalar|Param|null, // Default: "" - * base_urls?: list, + * base_urls?: string|list, * packages?: array, + * base_urls?: string|list, * }>, * }, * asset_mapper?: bool|array{ // Asset Mapper configuration * enabled?: bool|Param, // Default: true - * paths?: array, + * paths?: string|array, * excluded_patterns?: list, * exclude_dotfiles?: bool|Param, // If true, any files starting with "." will be excluded from the asset mapper. // Default: true * server?: bool|Param, // If true, a "dev server" will return the assets from the public directory (true in "debug" mode only by default). // Default: true @@ -300,7 +300,7 @@ * }, * translator?: bool|array{ // Translator configuration * enabled?: bool|Param, // Default: true - * fallbacks?: list, + * fallbacks?: string|list, * logging?: bool|Param, // Default: false * formatter?: scalar|Param|null, // Default: "translator.formatter.default" * cache_dir?: scalar|Param|null, // Default: "%kernel.cache_dir%/translations" @@ -329,7 +329,7 @@ * validation?: bool|array{ // Validation configuration * enabled?: bool|Param, // Default: true * enable_attributes?: bool|Param, // Default: true - * static_method?: list, + * static_method?: string|list, * translation_domain?: scalar|Param|null, // Default: "validators" * email_validation_mode?: "html5"|"html5-allow-no-tld"|"strict"|Param, // Default: "html5" * mapping?: array{ @@ -389,7 +389,7 @@ * default_doctrine_dbal_provider?: scalar|Param|null, // Default: "database_connection" * default_pdo_provider?: scalar|Param|null, // Default: null * pools?: array, + * adapters?: string|list, * tags?: scalar|Param|null, // Default: null * public?: bool|Param, // Default: false * default_lifetime?: scalar|Param|null, // Default lifetime of the pool. @@ -412,11 +412,11 @@ * }, * lock?: bool|string|array{ // Lock configuration * enabled?: bool|Param, // Default: false - * resources?: array>, + * resources?: string|array>, * }, * semaphore?: bool|string|array{ // Semaphore configuration * enabled?: bool|Param, // Default: false - * resources?: array, + * resources?: string|array, * }, * messenger?: bool|array{ // Messenger configuration * enabled?: bool|Param, // Default: false @@ -446,7 +446,7 @@ * rate_limiter?: scalar|Param|null, // Rate limiter name to use when processing messages. // Default: null * }>, * failure_transport?: scalar|Param|null, // Transport name to send failed messages to (after all retries have failed). // Default: null - * stop_worker_on_signals?: list, + * stop_worker_on_signals?: int|string|list, * default_bus?: scalar|Param|null, // Default: null * buses?: array, * }>, @@ -503,9 +503,9 @@ * retry_failed?: bool|array{ * enabled?: bool|Param, // Default: false * retry_strategy?: scalar|Param|null, // service id to override the retry strategy. // Default: null - * http_codes?: array, + * methods?: string|list, * }>, * max_retries?: int|Param, // Default: 3 * delay?: int|Param, // Time in ms to delay (or the initial value when multiplier is used). // Default: 1000 @@ -556,9 +556,9 @@ * retry_failed?: bool|array{ * enabled?: bool|Param, // Default: false * retry_strategy?: scalar|Param|null, // service id to override the retry strategy. // Default: null - * http_codes?: array, + * methods?: string|list, * }>, * max_retries?: int|Param, // Default: 3 * delay?: int|Param, // Time in ms to delay (or the initial value when multiplier is used). // Default: 1000 @@ -575,8 +575,8 @@ * transports?: array, * envelope?: array{ // Mailer Envelope configuration * sender?: scalar|Param|null, - * recipients?: list, - * allowed_recipients?: list, + * recipients?: string|list, + * allowed_recipients?: string|list, * }, * headers?: array, + * limiters?: string|list, * limit?: int|Param, // The maximum allowed hits in a fixed interval or burst. * interval?: scalar|Param|null, // Configures the fixed interval if "policy" is set to "fixed_window" or "sliding_window". The value must be a number followed by "second", "minute", "hour", "day", "week" or "month" (or their plural equivalent). * rate?: array{ // Configures the fill rate if "policy" is set to "token_bucket". @@ -651,20 +651,20 @@ * allow_safe_elements?: bool|Param, // Allows "safe" elements and attributes. // Default: false * allow_static_elements?: bool|Param, // Allows all static elements and attributes from the W3C Sanitizer API standard. // Default: false * allow_elements?: array, - * block_elements?: list, - * drop_elements?: list, + * block_elements?: string|list, + * drop_elements?: string|list, * allow_attributes?: array, * drop_attributes?: array, * force_attributes?: array>, * force_https_urls?: bool|Param, // Transforms URLs using the HTTP scheme to use the HTTPS scheme instead. // Default: false - * allowed_link_schemes?: list, - * allowed_link_hosts?: list|null, + * allowed_link_schemes?: string|list, + * allowed_link_hosts?: null|string|list, * allow_relative_links?: bool|Param, // Allows relative URLs to be used in links href attributes. // Default: false - * allowed_media_schemes?: list, - * allowed_media_hosts?: list|null, + * allowed_media_schemes?: string|list, + * allowed_media_hosts?: null|string|list, * allow_relative_medias?: bool|Param, // Allows relative URLs to be used in media source attributes (img, audio, video, ...). // Default: false - * with_attribute_sanitizers?: list, - * without_attribute_sanitizers?: list, + * with_attribute_sanitizers?: string|list, + * without_attribute_sanitizers?: string|list, * max_input_length?: int|Param, // The maximum length allowed for the sanitized input. // Default: 0 * }>, * }, @@ -699,7 +699,7 @@ * auto_reload?: scalar|Param|null, * optimizations?: int|Param, * default_path?: scalar|Param|null, // The default path used to load templates. // Default: "%kernel.project_dir%/templates" - * file_name_pattern?: list, + * file_name_pattern?: string|list, * paths?: array, * date?: array{ // The default format options used by the date filter. * format?: scalar|Param|null, // Default: "F j, Y H:i" @@ -729,7 +729,7 @@ * }, * password_hashers?: array, + * migrate_from?: string|list, * hash_algorithm?: scalar|Param|null, // Name of hashing algorithm for PBKDF2 (i.e. sha256, sha512, etc..) See hash_algos() for a list of supported algorithms. // Default: "sha512" * key_length?: scalar|Param|null, // Default: 40 * ignore_case?: bool|Param, // Default: false @@ -743,12 +743,12 @@ * providers?: array, + * providers?: string|list, * }, * memory?: array{ * users?: array, + * roles?: string|list, * }>, * }, * ldap?: array{ @@ -757,7 +757,7 @@ * search_dn?: scalar|Param|null, // Default: null * search_password?: scalar|Param|null, // Default: null * extra_fields?: list, - * default_roles?: list, + * default_roles?: string|list, * role_fetcher?: scalar|Param|null, // Default: null * uid_key?: scalar|Param|null, // Default: "sAMAccountName" * filter?: scalar|Param|null, // Default: "({uid_key}={user_identifier})" @@ -772,7 +772,7 @@ * firewalls?: array, + * methods?: string|list, * security?: bool|Param, // Default: true * user_checker?: scalar|Param|null, // The UserChecker to use when authenticating users in this firewall. // Default: "security.user_checker" * request_matcher?: scalar|Param|null, @@ -791,8 +791,8 @@ * path?: scalar|Param|null, // Default: "/logout" * target?: scalar|Param|null, // Default: "/" * invalidate_session?: bool|Param, // Default: true - * clear_site_data?: list<"*"|"cache"|"cookies"|"storage"|"executionContexts"|Param>, - * delete_cookies?: array, + * delete_cookies?: string|array, + * token_extractors?: string|list, * token_handler?: string|array{ * id?: scalar|Param|null, * oidc_user_info?: string|array{ @@ -945,7 +945,7 @@ * }, * oidc?: array{ * discovery?: array{ // Enable the OIDC discovery. - * base_uri?: list, + * base_uri?: string|list, * cache?: array{ * id?: scalar|Param|null, // Cache service id to use to cache the OIDC discovery configuration. * }, @@ -986,7 +986,7 @@ * remember_me?: array{ * secret?: scalar|Param|null, // Default: "%kernel.secret%" * service?: scalar|Param|null, - * user_providers?: list, + * user_providers?: string|list, * catch_exceptions?: bool|Param, // Default: true * signature_properties?: list, * token_provider?: string|array{ @@ -1014,12 +1014,12 @@ * path?: scalar|Param|null, // Use the urldecoded format. // Default: null * host?: scalar|Param|null, // Default: null * port?: int|Param, // Default: null - * ips?: list, + * ips?: string|list, * attributes?: array, * route?: scalar|Param|null, // Default: null - * methods?: list, + * methods?: string|list, * allow_if?: scalar|Param|null, // Default: null - * roles?: list, + * roles?: string|list, * }>, * role_hierarchy?: array>, * } @@ -1308,7 +1308,7 @@ * delay_between_messages?: bool|Param, // Default: false * topic?: int|Param, // Default: null * factor?: int|Param, // Default: 1 - * tags?: list, + * tags?: string|list, * console_formatter_options?: mixed, // Default: [] * formatter?: scalar|Param|null, * nested?: bool|Param, // Default: false @@ -1352,7 +1352,7 @@ * host?: scalar|Param|null, * }, * from_email?: scalar|Param|null, - * to_email?: list, + * to_email?: string|list, * subject?: scalar|Param|null, * content_type?: scalar|Param|null, // Default: null * headers?: list, diff --git a/config/services.yaml b/config/services.yaml index d6f73e03..8e9c73b4 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -10,12 +10,17 @@ parameters: uploadDirectory: '%kernel.project_dir%/public/%publicUploadDirectory%' roomCategoryImageDirectory: '%kernel.project_dir%/public/resources/images/room-categories' roomCategoryImagePublicDirectory: 'resources/images/room-categories' - version: '4.7.0' + version: '4.8.0' mailCopy: '%env(default:default_mailcopy:MAIL_COPY)%' default_mailcopy: 'true' passkey_enabled: '%env(bool:PASSKEY_ENABLED)%' default_frame_ancestors: '' frameAncestors: '%env(default:default_frame_ancestors:FRAME_ANCESTORS)%' + # fallback parameters + # define the redis host + env(REDIS_HOST): 'redis' + # define the used redix index + env(REDIS_IDX): '1' services: # default configuration for services in *this* file @@ -36,12 +41,14 @@ 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 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/docker/app/Dockerfile b/docker/app/Dockerfile new file mode 100644 index 00000000..280abcfa --- /dev/null +++ b/docker/app/Dockerfile @@ -0,0 +1,143 @@ +# syntax=docker/dockerfile:1.7 +# +# Unified application image for fewohbee. +# Final targets: +# phpfpm-prod – minimal php-fpm runtime, vendor without dev-deps, cache warmed +# phpfpm-debug – php-fpm with dev-deps + xdebug +# cli-prod – crond runtime for the dockerized setup +# +# All targets share base, vendor-prod, and app-prod stages, so composer install +# and cache:warmup run only once per build. +# +ARG PHP_VERSION=8.5 +ARG PHP_EXTS="redis intl gd pdo_mysql exif" + +############################ +# Stage: base +############################ +FROM php:${PHP_VERSION}-fpm-alpine AS base + +ARG PHP_EXTS +ENV COMPOSER_ALLOW_SUPERUSER=1 \ + COMPOSER_NO_INTERACTION=1 + +ADD --chmod=0755 https://github.com/mlocati/docker-php-extension-installer/releases/latest/download/install-php-extensions /usr/local/bin/ + +RUN apk add --update --no-cache \ + git \ + zip \ + unzip \ + tzdata \ + icu \ + icu-data-full \ + && install-php-extensions ${PHP_EXTS} + +COPY --from=composer:latest /usr/bin/composer /usr/bin/composer +COPY docker/app/conf.ini /usr/local/etc/php/conf.d/conf.ini + +WORKDIR /var/www/html + +############################ +# Stage: vendor-prod +############################ +FROM base AS vendor-prod + +COPY composer.json composer.lock symfony.lock ./ +RUN --mount=type=cache,target=/root/.composer/cache \ + composer install --no-dev --no-scripts --no-autoloader --prefer-dist + +############################ +# Stage: vendor-debug +############################ +FROM base AS vendor-debug + +RUN install-php-extensions xdebug + +COPY composer.json composer.lock symfony.lock ./ +RUN --mount=type=cache,target=/root/.composer/cache \ + composer install --no-scripts --no-autoloader --prefer-dist + +############################ +# Stage: app-prod +# +# Shared by phpfpm-prod and cli-prod: brings in vendor + code, dumps optimized +# autoloader, warms cache and compiles assets. Final stages only add their +# entrypoint, user, and CMD. +############################ +FROM base AS app-prod + +# These ENVs are baked into the image and define its identity. Deployments +# should pick the right image tag (prod vs -debug) rather than override these +# at runtime — overrides cause cache/vendor mismatches (prod-warmed container +# trying to load dev-only bundles, etc). +ENV APP_ENV=prod \ + APP_DEBUG=0 \ + USE_REDIS_CACHE=true + +COPY --from=vendor-prod /var/www/html/vendor ./vendor +COPY --chown=82:82 . ./ + +# Build-time dummies kept inline so they do NOT persist into the image. +# Runtime TZ/DATABASE_URL/REDIS_HOST come from the deployment. +RUN TZ=UTC \ + DATABASE_URL="mysql://build:build@127.0.0.1:3306/build?serverVersion=10.11" \ + REDIS_HOST=127.0.0.1 \ + REDIS_IDX=1 \ + sh -c 'composer dump-autoload --no-dev --optimize --classmap-authoritative \ + && php bin/console cache:warmup --no-interaction \ + && php bin/console importmap:install --no-interaction \ + && php bin/console asset-map:compile --no-interaction' \ + && rm -rf tests docker .github .git .dockerignore \ + && chown -R 82:82 var public/assets + +############################ +# Stage: phpfpm-prod +############################ +FROM app-prod AS phpfpm-prod + +COPY docker/app/phpfpm-entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + +USER www-data +EXPOSE 9000 +ENTRYPOINT ["/entrypoint.sh"] +CMD ["php-fpm"] + +############################ +# Stage: phpfpm-debug +############################ +FROM base AS phpfpm-debug + +# Same as app-prod: image identity, not user-overridable in normal deployments. +ENV APP_ENV=dev \ + APP_DEBUG=1 \ + USE_REDIS_CACHE=false + +COPY --from=vendor-debug /var/www/html/vendor ./vendor +COPY --chown=82:82 . ./ + +RUN TZ=UTC composer dump-autoload \ + && mkdir -p var/cache var/log \ + && chown -R 82:82 var + +COPY docker/app/phpfpm-entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + +USER www-data +EXPOSE 9000 +ENTRYPOINT ["/entrypoint.sh"] +CMD ["php-fpm"] + +############################ +# Stage: cli-prod (crond for dockerized) +############################ +FROM app-prod AS cli-prod + +COPY docker/app/cli-entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + +COPY docker/app/crontab /etc/crontabs/www-data +RUN chown root:root /etc/crontabs/www-data && chmod 600 /etc/crontabs/www-data + +ENTRYPOINT ["/entrypoint.sh"] +CMD ["crond", "-f", "-L", "/dev/stdout"] diff --git a/docker/app/cli-entrypoint.sh b/docker/app/cli-entrypoint.sh new file mode 100644 index 00000000..3965e5f3 --- /dev/null +++ b/docker/app/cli-entrypoint.sh @@ -0,0 +1,12 @@ +#!/bin/sh +set -e + +cd /var/www/html + +# Legacy compatibility — see phpfpm-entrypoint.sh +if [ "${APP_ENV}" = "redis" ]; then + export APP_ENV=prod + export USE_REDIS_CACHE=true +fi + +exec "$@" diff --git a/docker/app/conf.ini b/docker/app/conf.ini new file mode 100644 index 00000000..c9d02ff4 --- /dev/null +++ b/docker/app/conf.ini @@ -0,0 +1,22 @@ +date.timezone=${TZ} + +opcache.enable=1 +opcache.enable_cli=1 +opcache.interned_strings_buffer=8 +opcache.max_accelerated_files=10000 +opcache.memory_consumption=128 +opcache.save_comments=1 +opcache.revalidate_freq=1 +opcache.jit_buffer_size=100M + +session.save_handler = redis +session.save_path = "tcp://${REDIS_HOST}:6379?database=${REDIS_IDX}" + +expose_php = Off + +display_errors = 0 +error_reporting = E_ALL +log_errors = On + +upload_max_filesize = 10M +post_max_size = 12M diff --git a/docker/app/crontab b/docker/app/crontab new file mode 100644 index 00000000..3726f258 --- /dev/null +++ b/docker/app/crontab @@ -0,0 +1,6 @@ +SHELL=/bin/sh +PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin + +0 * * * * cd /var/www/html && /usr/local/bin/php bin/console calendar:import:sync --force +*/15 * * * * cd /var/www/html && /usr/local/bin/php bin/console workflow:process-scheduled +0 3 * * * cd /var/www/html && /usr/local/bin/php bin/console app:purge-logs --days=90 diff --git a/docker/app/phpfpm-entrypoint.sh b/docker/app/phpfpm-entrypoint.sh new file mode 100644 index 00000000..f0122845 --- /dev/null +++ b/docker/app/phpfpm-entrypoint.sh @@ -0,0 +1,30 @@ +#!/bin/sh +set -e + +cd /var/www/html + +# Backward compatibility: the legacy fewohbee convention used APP_ENV=redis to +# enable the Redis cache adapter. Symfony reserves APP_ENV for prod/dev/test, so +# we translate the old value to the new flag here. Existing dockerized .env files +# keep working without manual changes. +if [ "${APP_ENV}" = "redis" ]; then + echo "[entrypoint] WARNING: APP_ENV=redis is deprecated. Treating as APP_ENV=prod USE_REDIS_CACHE=true." + echo "[entrypoint] Update your .env to: APP_ENV=prod and USE_REDIS_CACHE=true" + export APP_ENV=prod + export USE_REDIS_CACHE=true +fi + +if [ "${RUN_MIGRATIONS:-false}" = "true" ]; then + php bin/console doctrine:migrations:migrate --no-interaction --allow-no-migration +fi + +if [ "${APP_ENV}" = "dev" ]; then + php bin/console cache:clear --no-interaction || true +fi + +# first arg looks like a flag → assume php-fpm +if [ "${1#-}" != "$1" ]; then + set -- php-fpm "$@" +fi + +exec "$@" diff --git a/docker/nginx/Dockerfile b/docker/nginx/Dockerfile new file mode 100644 index 00000000..020ff003 --- /dev/null +++ b/docker/nginx/Dockerfile @@ -0,0 +1,35 @@ +# syntax=docker/dockerfile:1.7 +# +# nginx image for fewohbee. +# Pulls compiled AssetMapper output (public/assets/) from the phpfpm-prod stage, +# so the nginx image must be built AFTER phpfpm-prod with the same code base. +# +ARG PHPFPM_IMAGE=fewohbee-phpfpm:dev + +FROM ${PHPFPM_IMAGE} AS php-assets + +############################ +FROM nginx:mainline-alpine + +RUN apk add --no-cache gettext + +# Application's static assets (incl. compiled public/assets/) +COPY --from=php-assets /var/www/html/public /var/www/html/public + +# nginx configuration +COPY docker/nginx/site.conf /etc/nginx/conf.d/site.conf +COPY docker/nginx/site.conf.no-ssl /etc/nginx/conf.d/site.conf.no-ssl +COPY docker/nginx/site-enabled-http /etc/nginx/conf.d/site-enabled-http +COPY docker/nginx/site-enabled-https /etc/nginx/conf.d/site-enabled-https +COPY docker/nginx/snippets /etc/nginx/conf.d/snippets +COPY docker/nginx/templates /etc/nginx/conf.d/templates +COPY docker/nginx/entrypoint.sh /docker-entrypoint.sh + +# Remove the default nginx config (replaced by site.conf) +RUN rm -f /etc/nginx/conf.d/default.conf \ + /etc/nginx/conf.d/server_name.active \ + && chmod +x /docker-entrypoint.sh + +EXPOSE 80 443 + +ENTRYPOINT ["/docker-entrypoint.sh"] diff --git a/docker/nginx/entrypoint.sh b/docker/nginx/entrypoint.sh new file mode 100644 index 00000000..f71f0d0c --- /dev/null +++ b/docker/nginx/entrypoint.sh @@ -0,0 +1,56 @@ +#!/bin/sh +set -e + +: "${PHP_FPM_HOST:=php:9000}" +export PHP_FPM_HOST + +# Select site config based on SSL mode +if [ "${REVERSE_PROXY:-false}" = "true" ]; then + cp /etc/nginx/conf.d/site.conf.no-ssl /etc/nginx/conf.d/site.conf +fi + +# Resolve ${HOST_NAME} placeholder in server-name include +envsubst '${HOST_NAME}' < /etc/nginx/conf.d/templates/server_name.template > /etc/nginx/conf.d/server_name.active + +# Resolve ${PHP_FPM_HOST} in the fewohbee snippet (keeping nginx $-vars untouched) +envsubst '${PHP_FPM_HOST}' \ + < /etc/nginx/conf.d/site-enabled-https/01_fewohbee.snippet \ + > /etc/nginx/conf.d/site-enabled-https/01_fewohbee.snippet.tmp \ + && mv /etc/nginx/conf.d/site-enabled-https/01_fewohbee.snippet.tmp \ + /etc/nginx/conf.d/site-enabled-https/01_fewohbee.snippet + +if [ "${REVERSE_PROXY:-false}" != "true" ]; then + # Wait for SSL certificates to be provided by the acme container. + echo "Waiting for SSL certificates ..." + while [ ! -f "/certs/fullchain.pem" ] || [ ! -f "/certs/privkey.pem" ] || [ ! -f "/certs/dhparams.pem" ]; do + sleep 2 + done + echo "Certificates found, starting nginx." +fi + +# Start nginx in the background so we can watch for cert changes +nginx -g 'daemon off;' & +NGINX_PID=$! + +if [ "${REVERSE_PROXY:-false}" != "true" ]; then + # Record the initial cert fingerprint + CERT_HASH=$(md5sum /certs/fullchain.pem | cut -d' ' -f1) + + # Watch for certificate renewal and reload nginx when changed + while kill -0 "$NGINX_PID" 2>/dev/null; do + sleep 60 + NEW_HASH=$(md5sum /certs/fullchain.pem 2>/dev/null | cut -d' ' -f1) + if [ -n "$NEW_HASH" ] && [ "$NEW_HASH" != "$CERT_HASH" ]; then + CERT_HASH="$NEW_HASH" + echo "Certificate changed, reloading nginx ..." + nginx -s reload 2>/dev/null || true + fi + done +else + # In reverse proxy mode just wait for nginx to exit + while kill -0 "$NGINX_PID" 2>/dev/null; do + sleep 60 + done +fi + +wait "$NGINX_PID" diff --git a/docker/nginx/site-enabled-http/00_acme.snippet b/docker/nginx/site-enabled-http/00_acme.snippet new file mode 100644 index 00000000..1ec5f11f --- /dev/null +++ b/docker/nginx/site-enabled-http/00_acme.snippet @@ -0,0 +1,5 @@ +location ^~ /.well-known/acme-challenge/ { + allow all; + default_type "text/plain"; + root /var/www/html; +} diff --git a/docker/nginx/site-enabled-https/00_acme.snippet b/docker/nginx/site-enabled-https/00_acme.snippet new file mode 100644 index 00000000..1ec5f11f --- /dev/null +++ b/docker/nginx/site-enabled-https/00_acme.snippet @@ -0,0 +1,5 @@ +location ^~ /.well-known/acme-challenge/ { + allow all; + default_type "text/plain"; + root /var/www/html; +} diff --git a/docker/nginx/site-enabled-https/01_fewohbee.snippet b/docker/nginx/site-enabled-https/01_fewohbee.snippet new file mode 100644 index 00000000..0125a1d3 --- /dev/null +++ b/docker/nginx/site-enabled-https/01_fewohbee.snippet @@ -0,0 +1,18 @@ +location / { + root /var/www/html/public; + # try to serve file directly, fallback to index.php + try_files $uri /index.php$is_args$args; +} + +location ~ ^/index\.php(/|$) { + root /var/www/html/public; + fastcgi_split_path_info ^(.+\.php)(/.+)$; + resolver 127.0.0.11 valid=5s; + set $php_backend ${PHP_FPM_HOST}; + fastcgi_pass $php_backend; + fastcgi_index index.php; + include fastcgi_params; + fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name; + fastcgi_param DOCUMENT_ROOT $realpath_root; + internal; +} diff --git a/docker/nginx/site.conf b/docker/nginx/site.conf new file mode 100644 index 00000000..5ede7373 --- /dev/null +++ b/docker/nginx/site.conf @@ -0,0 +1,58 @@ +server_tokens off; + +# Do not set X-Frame-Options for /book paths — embedding is controlled by the app via CSP frame-ancestors +map $request_uri $x_frame_options { + ~^/book ""; + default "SAMEORIGIN"; +} + +server { + listen 80; + listen [::]:80; + index index.html index.php; + error_log /var/log/nginx/error.log; + access_log /var/log/nginx/access.log; + + root /var/www/html/public; + + include /etc/nginx/conf.d/server_name.active; + + # other configs + include /etc/nginx/conf.d/site-enabled-http/*; + + return 301 https://$server_name$request_uri; +} + +server { + listen 443 ssl; + listen [::]:443 ssl; + http2 on; + client_max_body_size 10m; + + index index.html index.php; + + root /var/www/html/public; + + include /etc/nginx/conf.d/server_name.active; + + include /etc/nginx/conf.d/snippets/header.snippet; + include /etc/nginx/conf.d/snippets/sslconf.snippet; + + error_log /var/log/nginx/error.log; + access_log /var/log/nginx/access.log; + + # other configs + include /etc/nginx/conf.d/site-enabled-https/*; +} + +server { + # this vhost is for internal connections only (e.g. mpdf fetches images from + # here via WEB_HOST, and the docker healthcheck calls /health/live here to + # bypass the SSL redirect on port 80). + listen 8080; + + root /var/www/html/public; + + include /etc/nginx/conf.d/server_name.active; + include /etc/nginx/conf.d/site-enabled-https/*; +} diff --git a/docker/nginx/site.conf.no-ssl b/docker/nginx/site.conf.no-ssl new file mode 100644 index 00000000..dc1ba007 --- /dev/null +++ b/docker/nginx/site.conf.no-ssl @@ -0,0 +1,37 @@ +server_tokens off; + +# Do not set X-Frame-Options for /book paths — embedding is controlled by the app via CSP frame-ancestors +map $request_uri $x_frame_options { + ~^/book ""; + default "SAMEORIGIN"; +} + +server { + listen 80; + listen [::]:80; + index index.html index.php; + client_max_body_size 10m; + error_log /var/log/nginx/error.log; + access_log /var/log/nginx/access.log; + + root /var/www/html/public; + + include /etc/nginx/conf.d/server_name.active; + + include /etc/nginx/conf.d/snippets/header.snippet; + + # app configs (PHP/FastCGI — reused from HTTPS setup) + include /etc/nginx/conf.d/site-enabled-https/*; +} + +server { + # this vhost is for internal connections only (e.g. mpdf fetches images from + # here via WEB_HOST, and the docker healthcheck calls /health/live here for + # consistency with the SSL setup). + listen 8080; + + root /var/www/html/public; + + include /etc/nginx/conf.d/server_name.active; + include /etc/nginx/conf.d/site-enabled-https/*; +} diff --git a/docker/nginx/snippets/header.snippet b/docker/nginx/snippets/header.snippet new file mode 100644 index 00000000..fc1a7712 --- /dev/null +++ b/docker/nginx/snippets/header.snippet @@ -0,0 +1,3 @@ +#add_header Strict-Transport-Security "max-age=15768000; includeSubDomains"; +add_header X-Content-Type-Options nosniff; +add_header X-Frame-Options $x_frame_options; diff --git a/docker/nginx/snippets/sslconf.snippet b/docker/nginx/snippets/sslconf.snippet new file mode 100644 index 00000000..677c0e08 --- /dev/null +++ b/docker/nginx/snippets/sslconf.snippet @@ -0,0 +1,17 @@ +ssl_protocols TLSv1.2 TLSv1.3; +ssl_prefer_server_ciphers on; + +ssl_dhparam /certs/dhparams.pem; +ssl_certificate /certs/fullchain.pem; +ssl_certificate_key /certs/privkey.pem; + +ssl_ciphers HIGH:!aNULL:!MD5:!SHA:!CAMELLIA:!SHA256:!SHA384; +ssl_ecdh_curve secp384r1; + +ssl_session_timeout 10m; +ssl_session_cache shared:SSL:10m; +ssl_session_tickets off; +#ssl_stapling on; +#ssl_stapling_verify on; +#resolver $DNS-IP-1 $DNS-IP-2 valid=300s; +#resolver_timeout 5s; diff --git a/docker/nginx/templates/server_name.template b/docker/nginx/templates/server_name.template new file mode 100644 index 00000000..60fe1567 --- /dev/null +++ b/docker/nginx/templates/server_name.template @@ -0,0 +1 @@ +server_name ${HOST_NAME}; diff --git a/importmap.php b/importmap.php index ae9a480e..83d3d375 100644 --- a/importmap.php +++ b/importmap.php @@ -32,103 +32,103 @@ 'version' => '1.15.4', ], '@tiptap/core' => [ - 'version' => '3.22.3', + 'version' => '3.23.5', ], '@tiptap/starter-kit' => [ - 'version' => '3.22.3', + 'version' => '3.23.5', ], '@tiptap/extension-image' => [ - 'version' => '3.22.3', + 'version' => '3.23.5', ], '@tiptap/extension-link' => [ - 'version' => '3.22.3', + 'version' => '3.23.5', ], '@tiptap/extension-text-align' => [ - 'version' => '3.22.3', + 'version' => '3.23.5', ], '@tiptap/extension-table' => [ - 'version' => '3.22.3', + 'version' => '3.23.5', ], '@tiptap/extension-underline' => [ - 'version' => '3.22.3', + 'version' => '3.23.5', ], '@tiptap/extension-text-style' => [ - 'version' => '3.22.3', + 'version' => '3.23.5', ], '@tiptap/extension-dropcursor' => [ - 'version' => '3.22.3', + 'version' => '3.23.5', ], '@tiptap/extension-gapcursor' => [ - 'version' => '3.22.3', + 'version' => '3.23.5', ], '@tiptap/pm/transform' => [ - 'version' => '3.22.3', + 'version' => '3.23.5', ], '@tiptap/pm/commands' => [ - 'version' => '3.22.3', + 'version' => '3.23.5', ], '@tiptap/pm/state' => [ - 'version' => '3.22.3', + 'version' => '3.23.5', ], '@tiptap/pm/model' => [ - 'version' => '3.22.3', + 'version' => '3.23.5', ], '@tiptap/pm/schema-list' => [ - 'version' => '3.22.3', + 'version' => '3.23.5', ], '@tiptap/pm/view' => [ - 'version' => '3.22.3', + 'version' => '3.23.5', ], '@tiptap/pm/keymap' => [ - 'version' => '3.22.3', + 'version' => '3.23.5', ], '@tiptap/extension-blockquote' => [ - 'version' => '3.22.3', + 'version' => '3.23.5', ], '@tiptap/extension-bold' => [ - 'version' => '3.22.3', + 'version' => '3.23.5', ], '@tiptap/extension-code' => [ - 'version' => '3.22.3', + 'version' => '3.23.5', ], '@tiptap/extension-code-block' => [ - 'version' => '3.22.3', + 'version' => '3.23.5', ], '@tiptap/extension-document' => [ - 'version' => '3.22.3', + 'version' => '3.23.5', ], '@tiptap/extension-hard-break' => [ - 'version' => '3.22.3', + 'version' => '3.23.5', ], '@tiptap/extension-heading' => [ - 'version' => '3.22.3', + 'version' => '3.23.5', ], '@tiptap/extension-horizontal-rule' => [ - 'version' => '3.22.3', + 'version' => '3.23.5', ], '@tiptap/extension-italic' => [ - 'version' => '3.22.3', + 'version' => '3.23.5', ], '@tiptap/extension-list' => [ - 'version' => '3.22.3', + 'version' => '3.23.5', ], '@tiptap/extension-paragraph' => [ - 'version' => '3.22.3', + 'version' => '3.23.5', ], '@tiptap/extension-strike' => [ - 'version' => '3.22.3', + 'version' => '3.23.5', ], '@tiptap/extension-text' => [ - 'version' => '3.22.3', + 'version' => '3.23.5', ], '@tiptap/extensions' => [ - 'version' => '3.22.3', + 'version' => '3.23.5', ], 'linkifyjs' => [ - 'version' => '4.3.2', + 'version' => '4.3.3', ], '@tiptap/pm/tables' => [ - 'version' => '3.22.3', + 'version' => '3.23.5', ], 'prosemirror-transform' => [ 'version' => '1.12.0', @@ -140,7 +140,7 @@ 'version' => '1.4.4', ], 'prosemirror-model' => [ - 'version' => '1.25.4', + 'version' => '1.25.7', ], 'prosemirror-schema-list' => [ 'version' => '1.5.1', @@ -152,16 +152,16 @@ 'version' => '1.2.3', ], '@tiptap/core/jsx-runtime' => [ - 'version' => '3.22.3', + 'version' => '3.23.5', ], '@tiptap/pm/dropcursor' => [ - 'version' => '3.22.3', + 'version' => '3.23.5', ], '@tiptap/pm/gapcursor' => [ - 'version' => '3.22.3', + 'version' => '3.23.5', ], '@tiptap/pm/history' => [ - 'version' => '3.22.3', + 'version' => '3.23.5', ], 'prosemirror-tables' => [ 'version' => '1.8.5', @@ -197,13 +197,13 @@ 'type' => 'css', ], '@tiptap/extension-table-row' => [ - 'version' => '3.22.3', + 'version' => '3.23.5', ], '@tiptap/extension-table-cell' => [ - 'version' => '3.22.3', + 'version' => '3.23.5', ], '@tiptap/extension-table-header' => [ - 'version' => '3.22.3', + 'version' => '3.23.5', ], 'codemirror' => [ 'version' => '6.0.2', @@ -212,10 +212,10 @@ 'version' => '6.4.11', ], '@codemirror/autocomplete' => [ - 'version' => '6.20.1', + 'version' => '6.20.2', ], '@codemirror/view' => [ - 'version' => '6.41.0', + 'version' => '6.43.0', ], '@codemirror/state' => [ 'version' => '6.6.0', @@ -227,10 +227,10 @@ 'version' => '6.10.3', ], '@codemirror/search' => [ - 'version' => '6.6.0', + 'version' => '6.7.0', ], '@codemirror/lint' => [ - 'version' => '6.9.5', + 'version' => '6.9.6', ], '@lezer/html' => [ 'version' => '1.3.13', @@ -257,7 +257,7 @@ 'version' => '1.2.3', ], '@lezer/lr' => [ - 'version' => '1.4.9', + 'version' => '1.4.10', ], '@lezer/css' => [ 'version' => '1.3.3', diff --git a/migrations/Version20260424190000.php b/migrations/Version20260424190000.php new file mode 100644 index 00000000..1c6a7f19 --- /dev/null +++ b/migrations/Version20260424190000.php @@ -0,0 +1,124 @@ +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'); + } + + public function isTransactional(): bool + { + return false; + } +} diff --git a/migrations/Version20260505120000.php b/migrations/Version20260505120000.php new file mode 100644 index 00000000..3637dbc0 --- /dev/null +++ b/migrations/Version20260505120000.php @@ -0,0 +1,166 @@ +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')"); + + // 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"); + + // 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'); + + // pricing modifier layer: guest_category_modifiers (subsidiary scope inherited from category) + $this->addSql('CREATE TABLE guest_category_modifiers ( + id INT AUTO_INCREMENT NOT NULL, + category_id INT NOT NULL, + type VARCHAR(32) NOT NULL, + value NUMERIC(10, 2) NOT NULL, + valid_from DATE DEFAULT NULL, + valid_to DATE DEFAULT NULL, + active TINYINT(1) NOT NULL DEFAULT 1, + sort_order INT NOT NULL DEFAULT 0, + INDEX IDX_gcm_category (category_id), + CONSTRAINT FK_gcm_category FOREIGN KEY (category_id) REFERENCES guest_categories (id) ON DELETE CASCADE, + PRIMARY KEY (id) + ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci ENGINE = InnoDB'); + } + + 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'); + + $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'); + + $this->addSql('ALTER TABLE guest_category_modifiers DROP FOREIGN KEY FK_gcm_category'); + $this->addSql('DROP TABLE guest_category_modifiers'); + } + + public function isTransactional(): bool + { + return false; + } +} diff --git a/migrations/Version20260520120000.php b/migrations/Version20260520120000.php new file mode 100644 index 00000000..7ce06f07 --- /dev/null +++ b/migrations/Version20260520120000.php @@ -0,0 +1,41 @@ +addSql("ALTER TABLE tourist_taxes + ADD calculation_mode VARCHAR(32) NOT NULL DEFAULT 'per_night_flat', + ADD percentage_rate NUMERIC(5, 2) DEFAULT NULL, + ADD percentage_base VARCHAR(16) DEFAULT NULL"); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE tourist_taxes + DROP COLUMN calculation_mode, + DROP COLUMN percentage_rate, + DROP COLUMN percentage_base'); + } + + public function isTransactional(): bool + { + return false; + } +} diff --git a/migrations/Version20260523194549.php b/migrations/Version20260523194549.php new file mode 100644 index 00000000..eec63232 --- /dev/null +++ b/migrations/Version20260523194549.php @@ -0,0 +1,31 @@ +addSql('ALTER TABLE prices ADD is_mandatory_online TINYINT(1) NOT NULL DEFAULT 0'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE prices DROP is_mandatory_online'); + } + + 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/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/BankCsvProfileController.php b/src/Controller/BankCsvProfileController.php new file mode 100644 index 00000000..2ac71211 --- /dev/null +++ b/src/Controller/BankCsvProfileController.php @@ -0,0 +1,110 @@ +redirectToSettings(); + } + + private function redirectToSettings(): Response + { + return $this->redirect($this->generateUrl('bank_import.settings', ['tab' => 'tab-profiles'])); + } + + #[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->redirectToSettings(); + } + + 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->redirectToSettings(); + } + + 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..97e1113c --- /dev/null +++ b/src/Controller/BankImportController.php @@ -0,0 +1,892 @@ +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 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 = $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(), + ])); + + 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: $formatKey, + bankCsvProfileId: $profile?->getId(), + originalFilename: $this->originalFilename($files, $translator), + result: $result, + ); + + $this->appendOverlapWarnings($state, $bankAccount, $statementImportRepo, $translator); + $this->prefillBankAccount($state, $bankAccount); + $deduplicator->annotate($state, $bankAccount); + $invoiceMatcher->annotate($state); + $ruleMatcher->annotate($state, $bankAccount); + $state->normalizeLineStatuses(); + + $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]); + } + + /** + * @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, + 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' => $state->countByStatus(), + '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( + int $idx, + Request $request, + #[ImportDraft] ImportState $state, + BankImportDraftSession $drafts, + ): JsonResponse { + if (!isset($state->lines[$idx])) { + throw BankImportEditException::lineNotFound(); + } + + if (true === ($state->lines[$idx]['isDuplicate'] ?? false) + && true !== ($state->lines[$idx]['forceImportDuplicate'] ?? 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 'invoiceNumber': + $line['userInvoiceNumber'] = $this->cleanInvoiceNumber($value); + break; + case 'isIgnored': + $line['isIgnored'] = (bool) ((int) $value); + break; + default: + return new JsonResponse(['error' => 'unknown_field'], Response::HTTP_BAD_REQUEST); + } + + $line['status'] = ImportState::deriveLineStatus($line); + unset($line); + + $drafts->save($state); + + return new JsonResponse([ + 'status' => $state->lines[$idx]['status'], + '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( + int $idx, + Request $request, + #[ImportDraft] ImportState $state, + BankImportDraftSession $drafts, + ): JsonResponse { + if (!isset($state->lines[$idx])) { + throw BankImportEditException::lineNotFound(); + } + + if (true === ($state->lines[$idx]['isDuplicate'] ?? false) + && true !== ($state->lines[$idx]['forceImportDuplicate'] ?? false) + ) { + throw BankImportEditException::lineReadonly(); + } + + $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'] = ImportState::deriveLineStatus($line); + unset($line); + + $drafts->save($state); + + return new JsonResponse([ + 'status' => $state->lines[$idx]['status'], + 'splitCount' => count($splits), + '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( + int $idx, + Request $request, + #[ImportDraft] ImportState $state, + BankImportDraftSession $drafts, + AccountingAccountRepository $accountRepo, + BankImportRuleMatcher $ruleMatcher, + EntityManagerInterface $em, + ): JsonResponse { + if (!isset($state->lines[$idx])) { + throw BankImportEditException::lineNotFound(); + } + + $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' => $state->countByStatus(), + ]); + } + + #[Route('/{sessionImportId}/line/{idx}/force-duplicate', name: 'bank_import.line.force_duplicate', methods: ['POST'], requirements: ['sessionImportId' => '[0-9a-f-]{36}', 'idx' => '\d+'])] + public function forceDuplicateLine( + string $sessionImportId, + int $idx, + Request $request, + #[ImportDraft] ImportState $state, + BankImportDraftSession $drafts, + AccountingAccountRepository $accountRepo, + BankImportRuleMatcher $ruleMatcher, + ): Response { + if (!isset($state->lines[$idx])) { + throw BankImportEditException::lineNotFound(); + } + + $bankAccount = $accountRepo->find($state->bankAccountId); + if (null === $bankAccount) { + if (!$request->isXmlHttpRequest()) { + $this->addFlash('danger', 'accounting.bank_import.draft.account_missing'); + + return $this->redirectToRoute('bank_import.preview', ['sessionImportId' => $sessionImportId]); + } + + return new JsonResponse(['error' => 'account_missing'], Response::HTTP_NOT_FOUND); + } + + $line = &$state->lines[$idx]; + if (true !== ($line['isDuplicate'] ?? false)) { + unset($line); + + if (!$request->isXmlHttpRequest()) { + return $this->redirectToRoute('bank_import.preview', ['sessionImportId' => $sessionImportId]); + } + + return new JsonResponse(['error' => 'not_duplicate'], Response::HTTP_BAD_REQUEST); + } + + $line['forceImportDuplicate'] = true; + $line['status'] = ImportState::deriveLineStatus($line); + unset($line); + $ruleMatcher->annotateLine($state, $idx, $bankAccount); + + $drafts->save($state); + + if (!$request->isXmlHttpRequest()) { + return $this->redirectToRoute('bank_import.preview', ['sessionImportId' => $sessionImportId]); + } + + return new JsonResponse([ + 'status' => $state->lines[$idx]['status'], + 'counts' => $state->countByStatus(), + ]); + } + + #[Route('/{sessionImportId}/bulk', name: 'bank_import.bulk', methods: ['POST'], requirements: ['sessionImportId' => '[0-9a-f-]{36}'])] + public function bulkAction( + Request $request, + #[ImportDraft] ImportState $state, + BankImportDraftSession $drafts, + ): JsonResponse { + $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) + && true !== ($state->lines[$idx]['forceImportDuplicate'] ?? 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'] = ImportState::deriveLineStatus($line); + unset($line); + ++$touched; + } + + $drafts->save($state); + + return new JsonResponse([ + 'touched' => $touched, + 'counts' => $state->countByStatus(), + ]); + } + + #[Route('/{sessionImportId}/rules/reapply', name: 'bank_import.rules.reapply_preview', methods: ['POST'], requirements: ['sessionImportId' => '[0-9a-f-]{36}'])] + public function reapplyRules( + string $sessionImportId, + Request $request, + BankImportDraftSession $drafts, + AccountingAccountRepository $accountRepo, + BankImportRuleMatcher $ruleMatcher, + 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'); + } + + $touched = $ruleMatcher->reapplyPreviouslyAppliedRules($state, $bankAccount); + $drafts->save($state); + + $this->addFlash('success', $translator->trans('accounting.bank_import.preview.reapply_rules.flash.done', [ + '%count%' => $touched, + ])); + + return $this->redirectToRoute('bank_import.preview', ['sessionImportId' => $sessionImportId]); + } + + #[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'); + } + + $user = $this->getUser(); + try { + $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(), + ])); + + 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; + } + + $base = [ + 'debitAccountId' => $this->normalizeAccountId($piece['debitAccountId'] ?? null), + 'creditAccountId' => $this->normalizeAccountId($piece['creditAccountId'] ?? null), + 'taxRateId' => $this->normalizeTaxRateId($piece['taxRateId'] ?? null), + 'remarkTemplate' => $this->cleanRemark($piece['remark'] ?? null), + ]; + + if ('purpose_marker' === (string) ($piece['amountSource'] ?? '')) { + $marker = trim((string) ($piece['marker'] ?? '')); + if ('' === $marker) { + continue; + } + + $splits[] = $base + [ + 'amountSource' => 'purpose_marker', + 'marker' => mb_substr($marker, 0, 120), + ]; + continue; + } + + if ('purpose_regex' === (string) ($piece['amountSource'] ?? '')) { + $pattern = trim((string) ($piece['pattern'] ?? '')); + if ('' === $pattern) { + continue; + } + + $splits[] = $base + [ + 'amountSource' => 'purpose_regex', + 'pattern' => mb_substr($pattern, 0, 120), + ]; + continue; + } + + if ($this->truthy($piece['remainder'] ?? false)) { + $splits[] = $base + ['remainder' => true]; + continue; + } + + if (isset($piece['percent'])) { + $percent = abs((float) $piece['percent']); + if ($percent > 0) { + $splits[] = $base + ['percent' => round($percent, 4)]; + } + continue; + } + + $absAmount = abs((float) ($piece['amount'] ?? 0)); + if ($absAmount <= 0) { + continue; + } + $splits[] = $base + ['amount' => round($absAmount, 2)]; + } + + return [ + 'mode' => BankImportRule::ACTION_MODE_SPLIT, + 'splits' => $splits, + 'invoiceNumberExtraction' => $this->buildInvoiceNumberExtractionFromRequest($request), + ]; + } + + 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')), + 'invoiceNumberExtraction' => $this->buildInvoiceNumberExtractionFromRequest($request), + ]; + } + + /** + * @return array{mode: string, marker?: string, pattern?: string} + */ + private function buildInvoiceNumberExtractionFromRequest(Request $request): array + { + $mode = (string) $request->request->get('invoiceExtractionMode', 'none'); + if ('marker' === $mode) { + $marker = trim((string) $request->request->get('invoiceExtractionMarker', '')); + + return '' === $marker + ? ['mode' => 'none'] + : ['mode' => 'marker', 'marker' => mb_substr($marker, 0, 120)]; + } + + if ('regex' === $mode) { + $pattern = trim((string) $request->request->get('invoiceExtractionRegex', '')); + + return '' === $pattern + ? ['mode' => 'none'] + : ['mode' => 'regex', 'pattern' => mb_substr($pattern, 0, 255)]; + } + + return ['mode' => 'none']; + } + + private function cleanRemark(mixed $value): ?string + { + if (null === $value) { + return null; + } + $value = trim((string) $value); + + return '' === $value ? null : mb_substr($value, 0, 255); + } + + private function cleanInvoiceNumber(mixed $value): ?string + { + if (null === $value) { + return null; + } + + $value = trim((string) $value); + + return '' === $value ? null : mb_substr($value, 0, 50); + } + + private function truthy(mixed $value): bool + { + return true === $value || 1 === $value || in_array((string) $value, ['1', 'true', 'on', 'yes'], true); + } + + /** + * 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); + } + + 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..dab5637b --- /dev/null +++ b/src/Controller/BankImportRuleController.php @@ -0,0 +1,134 @@ +redirectToSettings(); + } + + private function redirectToSettings(): Response + { + return $this->redirect($this->generateUrl('bank_import.settings', ['tab' => 'tab-rules'])); + } + + #[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->redirectToSettings(); + } + + 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->redirectToSettings(); + } + + $rule->setIsEnabled(!$rule->isEnabled()); + $em->flush(); + + return $this->redirectToSettings(); + } + + #[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/BankImportSettingsController.php b/src/Controller/BankImportSettingsController.php new file mode 100644 index 00000000..ab029885 --- /dev/null +++ b/src/Controller/BankImportSettingsController.php @@ -0,0 +1,77 @@ +getSettings(); + $form = $this->createForm(BankImportSettingsType::class, $settings, [ + 'action' => $this->generateUrl('bank_import.settings', ['tab' => 'tab-invoice-matching']), + ]); + + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + $settingsService->saveSettings($settings); + $this->addFlash('success', 'accounting.bank_import.settings.flash.saved'); + + return $this->redirect($this->generateUrl('bank_import.settings', ['tab' => 'tab-invoice-matching'])); + } + + $requestedTab = (string) $request->query->get('tab', self::DEFAULT_TAB); + $activeTab = in_array($requestedTab, self::VALID_TABS, true) ? $requestedTab : self::DEFAULT_TAB; + + $accountsById = []; + foreach ($accountRepo->findAll() as $account) { + $accountsById[(int) $account->getId()] = $account; + } + $taxRatesById = []; + foreach ($taxRateRepo->findAll() as $taxRate) { + $taxRatesById[(int) $taxRate->getId()] = $taxRate; + } + + return $this->render('BookingJournal/BankImport/settings.html.twig', [ + 'form' => $form, + 'activeTab' => $activeTab, + 'rules' => $ruleRepo->findAllOrdered(), + 'profiles' => $profileRepo->findAllOrdered(), + 'accountsById' => $accountsById, + 'taxRatesById' => $taxRatesById, + ]); + } +} diff --git a/src/Controller/BookingJournalController.php b/src/Controller/BookingJournalController.php index ff504d54..33102c2e 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; @@ -118,6 +118,7 @@ public function createBatch( #[Route('/batch/{id}', name: 'journal.batch.entries', methods: ['GET'])] public function batchEntries( BookingBatch $batch, + BookingBatchRepository $batchRepo, BookingEntryRepository $entryRepo, BookingJournalService $journalService, EntityManagerInterface $em, @@ -144,8 +145,14 @@ public function batchEntries( $bankClosingBalance = $bankOpeningBalance + $entryRepo->getBankBatchDelta($batch); } + $batchDate = new \DateTimeImmutable(sprintf('%04d-%02d-01', $batch->getYear(), $batch->getMonth())); + $previousBatchDate = $batchDate->modify('-1 month'); + $nextBatchDate = $batchDate->modify('+1 month'); + return $this->render('BookingJournal/entries.html.twig', [ 'batch' => $batch, + 'previousBatch' => $batchRepo->findByYearAndMonth((int) $previousBatchDate->format('Y'), (int) $previousBatchDate->format('n')), + 'nextBatch' => $batchRepo->findByYearAndMonth((int) $nextBatchDate->format('Y'), (int) $nextBatchDate->format('n')), 'entries' => $entries, 'bankOpeningBalance' => $bankOpeningBalance, 'bankClosingBalance' => $bankClosingBalance, diff --git a/src/Controller/BookingJournalSettingsController.php b/src/Controller/BookingJournalSettingsController.php index 39cbcc8b..cc08c31a 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; @@ -29,6 +29,15 @@ #[IsGranted('ROLE_CASHJOURNAL')] class BookingJournalSettingsController extends AbstractController { + private const VALID_TABS = ['tab-preset', 'tab-accounts', 'tab-tax-rates', 'tab-datev', 'tab-remark-labels']; + + private function redirectToTab(string $tab): Response + { + $tab = in_array($tab, self::VALID_TABS, true) ? $tab : 'tab-preset'; + + return $this->redirect($this->generateUrl('journal.settings.index', ['tab' => $tab])); + } + #[Route('', name: 'journal.settings.index', methods: ['GET', 'POST'])] public function index( Request $request, @@ -47,10 +56,14 @@ public function index( $settingsService->saveSettings($settings); $this->addFlash('success', 'accounting.settings.flash.saved'); - return $this->redirectToRoute('journal.settings.index'); + $tab = (string) $request->request->get('_save_tab', 'tab-datev'); + + return $this->redirectToTab($tab); } $activePreset = $settings->getChartPreset(); + $requestedTab = (string) $request->query->get('tab', 'tab-preset'); + $activeTab = in_array($requestedTab, self::VALID_TABS, true) ? $requestedTab : 'tab-preset'; return $this->render('BookingJournal/settings.html.twig', [ 'settings' => $settings, @@ -58,6 +71,7 @@ public function index( 'accounts' => $accountRepo->findAllOrdered($activePreset), 'taxRates' => $taxRateRepo->findAllOrdered($activePreset), 'presets' => AccountingSettings::VALID_PRESETS, + 'activeTab' => $activeTab, ]); } @@ -104,7 +118,7 @@ public function loadPreset( ])); } - return $this->redirectToRoute('journal.settings.index'); + return $this->redirectToTab('tab-preset'); } // ── Accounts CRUD ──────────────────────────────────────────────── @@ -137,7 +151,7 @@ public function createAccount( $this->addFlash('success', 'accounting.accounts.flash.created'); - return $this->redirectToRoute('journal.settings.index'); + return $this->redirectToTab('tab-accounts'); } return $this->renderAccountForm($form); @@ -170,7 +184,7 @@ public function updateAccount( $this->addFlash('success', 'accounting.accounts.flash.updated'); - return $this->redirectToRoute('journal.settings.index'); + return $this->redirectToTab('tab-accounts'); } return $this->renderAccountForm($form); @@ -239,7 +253,7 @@ public function createTaxRate( $this->addFlash('success', 'accounting.taxrates.flash.created'); - return $this->redirectToRoute('journal.settings.index'); + return $this->redirectToTab('tab-tax-rates'); } return $this->renderTaxRateForm($form); @@ -269,7 +283,7 @@ public function updateTaxRate( $this->addFlash('success', 'accounting.taxrates.flash.updated'); - return $this->redirectToRoute('journal.settings.index'); + return $this->redirectToTab('tab-tax-rates'); } return $this->renderTaxRateForm($form); diff --git a/src/Controller/GuestCategoryController.php b/src/Controller/GuestCategoryController.php new file mode 100644 index 00000000..2cb43e86 --- /dev/null +++ b/src/Controller/GuestCategoryController.php @@ -0,0 +1,146 @@ +render('GuestCategory/index.html.twig', [ + 'guest_categories' => $repository->findBy([], ['sortOrder' => 'ASC', 'id' => 'ASC']), + 'modifiers' => $modifierRepository->findAllOrdered(), + ]); + } + + #[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'))) { + // 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(); + $this->addFlash('success', 'guest_category.flash.delete.success'); + } + + return new Response('', Response::HTTP_NO_CONTENT); + } + + #[Route('/modifiers/new', name: 'guest_category_modifier_new', methods: ['GET', 'POST'])] + public function newModifier(ManagerRegistry $doctrine, Request $request): Response + { + $modifier = new GuestCategoryModifier(); + $form = $this->createForm(GuestCategoryModifierType::class, $modifier); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + $em = $doctrine->getManager(); + $em->persist($modifier); + $em->flush(); + + $this->addFlash('success', 'guest_category_modifier.flash.create.success'); + + return new Response('', Response::HTTP_NO_CONTENT); + } + + return $this->render('GuestCategory/modifier_new.html.twig', [ + 'modifier' => $modifier, + 'form' => $form->createView(), + ]); + } + + #[Route('/modifiers/{id}/edit', name: 'guest_category_modifier_edit', methods: ['GET', 'POST'])] + public function editModifier(ManagerRegistry $doctrine, Request $request, GuestCategoryModifier $modifier): Response + { + $form = $this->createForm(GuestCategoryModifierType::class, $modifier); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + $doctrine->getManager()->flush(); + $this->addFlash('success', 'guest_category_modifier.flash.edit.success'); + + return new Response('', Response::HTTP_NO_CONTENT); + } + + return $this->render('GuestCategory/modifier_edit.html.twig', [ + 'modifier' => $modifier, + 'form' => $form->createView(), + ]); + } + + #[Route('/modifiers/{id}/delete', name: 'guest_category_modifier_delete', methods: ['DELETE'])] + public function deleteModifier(ManagerRegistry $doctrine, Request $request, GuestCategoryModifier $modifier): Response + { + if ($this->isCsrfTokenValid('delete'.$modifier->getId(), $request->request->get('_token'))) { + $em = $doctrine->getManager(); + $em->remove($modifier); + $em->flush(); + $this->addFlash('success', 'guest_category_modifier.flash.delete.success'); + } + + return new Response('', Response::HTTP_NO_CONTENT); + } +} diff --git a/src/Controller/HealthController.php b/src/Controller/HealthController.php new file mode 100644 index 00000000..c0c4ae73 --- /dev/null +++ b/src/Controller/HealthController.php @@ -0,0 +1,94 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace App\Controller; + +use Doctrine\DBAL\Connection; +use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; +use Symfony\Component\DependencyInjection\Attribute\Autowire; +use Symfony\Component\HttpFoundation\JsonResponse; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\Routing\Attribute\Route; + +class HealthController extends AbstractController +{ + public function __construct( + #[Autowire('%env(bool:default::USE_REDIS_CACHE)%')] + private readonly bool $useRedisCache, + #[Autowire('%env(default::REDIS_HOST)%')] + private readonly string $redisHost, + #[Autowire('%env(int:default::REDIS_IDX)%')] + private readonly int $redisIdx, + #[Autowire('%env(default::HEALTH_TOKEN)%')] + private readonly ?string $healthToken, + ) { + } + + #[Route('/health/live', name: 'health.live', methods: ['GET'])] + public function live(): JsonResponse + { + return new JsonResponse(['status' => 'ok']); + } + + #[Route('/health/ready', name: 'health.ready', methods: ['GET'])] + public function ready(Request $request, Connection $db): JsonResponse + { + if ($this->healthToken !== null && $request->headers->get('X-Health-Token') !== $this->healthToken) { + return new JsonResponse(['status' => 'unauthorized'], 401); + } + + $checks = ['db' => $this->checkDb($db) ? 'ok' : 'fail']; + + if ($this->isRedisInUse()) { + $checks['redis'] = $this->checkRedis() ? 'ok' : 'fail'; + } + + $ok = !in_array('fail', $checks, true); + + return new JsonResponse( + ['status' => $ok ? 'ok' : 'fail', 'checks' => $checks], + $ok ? 200 : 503, + ); + } + + private function checkDb(Connection $db): bool + { + try { + $db->executeQuery('SELECT 1'); + + return true; + } catch (\Throwable) { + return false; + } + } + + private function isRedisInUse(): bool + { + return $this->useRedisCache && $this->redisHost !== '' && \extension_loaded('redis'); + } + + private function checkRedis(): bool + { + try { + $redis = new \Redis(); + $redis->connect($this->redisHost, 6379, 1.0); + $redis->select($this->redisIdx); + $result = $redis->ping(); + $redis->close(); + + return $result === true || $result === '+PONG' || $result === 'PONG'; + } catch (\Throwable) { + return false; + } + } +} 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/PublicBookingController.php b/src/Controller/PublicBookingController.php index 7fe1f4fc..018df39b 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,13 +45,21 @@ 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'), + 'minArrivalDate' => (new \DateTimeImmutable('today'))->format('Y-m-d'), 'maxDepartureDate' => $restrictionService->getMaxDepartureDate()?->format('Y-m-d'), 'availabilityChecked' => false, 'formState' => $abuseProtectionService->createFormState(false), @@ -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,11 @@ public function book( 'extrasTotalFormatted' => null, 'grandTotalFormatted' => null, 'extrasBreakdown' => [], + 'touristTaxLines' => [], + 'touristTaxTotalFormatted' => null, + 'touristTaxTotal' => 0.0, + 'mixOccupancyTotal' => 0, + 'nonOccupancyIcons' => [], 'bookingResult' => null, ]; @@ -103,6 +125,39 @@ 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. + // At the same time collect the non-occupancy entries (e.g. infants + // in a cot) so the wizard can show them as "+ baby"-icons next to + // the room's bed icons in step 2 — otherwise the guest is unsure + // whether the room actually accommodates their party. + if ([] !== $guestCounts) { + $derived = 0; + $view['nonOccupancyIcons'] = []; + foreach ($guestCategoryRepository->findActiveOrdered() as $cat) { + $count = (int) ($guestCounts[(int) $cat->getId()] ?? 0); + if (0 === $count) { + continue; + } + if ($cat->isCountedInOccupancy()) { + $derived += $count; + } else { + $icon = match ($cat->getStatisticalGroup()->value) { + 'infant' => 'fa-baby', + 'child' => 'fa-child', + default => 'fa-user-tag', + }; + for ($i = 0; $i < $count; ++$i) { + $view['nonOccupancyIcons'][] = ['icon' => $icon, 'label' => $cat->getName()]; + } + } + } + if ($derived > 0) { + $persons = $derived; + } + $view['mixOccupancyTotal'] = $persons; + } $maxDeparture = $restrictionService->getMaxDepartureDate(); if (null !== $maxDeparture && $dateTo > $maxDeparture) { @@ -110,14 +165,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 +184,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 +198,7 @@ public function book( $this->extractBookerInput($request, $defaultCountry), $request, $extrasSelection, + $guestCounts, ); $view['step'] = 4; @@ -167,7 +226,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'] ?? []; @@ -221,7 +280,7 @@ private function parseSearchInput(Request $request): array $persons = max(1, (int) $request->request->get('persons', 1)); $roomsCount = max(1, (int) $request->request->get('roomsCount', 1)); - $minArrivalDate = new \DateTimeImmutable('tomorrow'); + $minArrivalDate = new \DateTimeImmutable('today'); if ($dateFrom > $dateTo) { throw new PublicBookingException('online_booking.error.departure_after_arrival'); @@ -234,6 +293,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/Controller/ReservationServiceController.php b/src/Controller/ReservationServiceController.php index 7055d544..0139e5aa 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,11 @@ public function showSelectAppartmentsFormAction(ManagerRegistry $doctrine, Reque } $reservations = $rs->createReservationsFromReservationInformationArray($newReservationsInformationArray); + $guestCategories = $em->getRepository(GuestCategory::class)->findActiveOrdered(); + $guestCategoriesById = []; + foreach ($guestCategories as $guestCategory) { + $guestCategoriesById[$guestCategory->getId()] = $guestCategory; + } return $this->render('Reservations/reservation_form_select_period_and_appartment.html.twig', [ 'objects' => $objects, @@ -307,6 +314,8 @@ public function showSelectAppartmentsFormAction(ManagerRegistry $doctrine, Reque 'objectHasAppartments' => $objectHasAppartments, 'reservations' => $reservations, 'reservationStatus' => $reservationStatus, + 'guestCategories' => $guestCategories, + 'guestCategoriesById' => $guestCategoriesById, ]); } @@ -331,10 +340,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 +370,21 @@ 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(); + $guestCountsRaw = $request->request->get('guestCounts', '{}'); + $guestCounts = is_string($guestCountsRaw) ? (json_decode($guestCountsRaw, true) ?: []) : []; + $reservation = [ + 'guestCounts' => $guestCounts, + 'persons' => (int) $request->request->get('persons', 0), + 'adultRuleOverride' => (bool) $request->request->get('adultRuleOverride', false), + 'reservationStatus' => ['id' => (int) $request->request->get('status', 0)], + ]; return $this->render('Reservations/reservation_form_edit_show_available_appartments.html.twig', [ 'appartments' => $apartments, 'reservationStatus' => $reservationStatus, + 'guestCategories' => $guestCategories, + 'reservation' => $reservation, ]); } @@ -380,13 +402,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 +532,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 +648,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'); @@ -658,8 +691,13 @@ public function previewNewReservationAction(ManagerRegistry $doctrine, CSRFProte } } - $miscPricePositions = $rs->getMiscPricesInCreation($is, $reservations, $ps, $requestStack); + $rs->getMiscPricesInCreation($is, $reservations, $ps, $requestStack); $pricesInCreation = $requestStack->getSession()->get('reservatioInCreationPrices', []); + $guestCategoriesById = []; + $gCategories = $em->getRepository(GuestCategory::class)->findBy([], ['sortOrder' => 'ASC', 'id' => 'ASC']); + foreach ($gCategories as $gc) { + $guestCategoriesById[$gc->getId()] = $gc; + } $requestStack->getSession()->set('invoicePositionsAppartments', []); foreach ($reservations as $reservation) { @@ -671,6 +709,10 @@ public function previewNewReservationAction(ManagerRegistry $doctrine, CSRFProte } } $apartmentPricePositions = $requestStack->getSession()->get('invoicePositionsAppartments'); + // Re-read after prefillAppartmentPositions so the snapshot includes + // apartment-modifier positions that the apartment-pricing pipeline + // has just appended to the misc session collection. + $miscPricePositions = $requestStack->getSession()->get('invoicePositionsMiscellaneous'); $vatSums = []; $brutto = 0; @@ -704,6 +746,10 @@ public function previewNewReservationAction(ManagerRegistry $doctrine, CSRFProte 'netto' => $netto, 'apartmentTotal' => $apartmentTotal, 'miscTotal' => $miscTotal, + 'hasActiveTouristTax' => count($reservations) > 0 + ? $touristTaxService->hasActiveTaxForSubsidiary($reservations[0]->getAppartment()?->getObject()) + : false, + 'guestCategoriesById' => $guestCategoriesById ?? [], ]); } @@ -778,7 +824,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(); @@ -786,6 +832,11 @@ public function getReservationAction(ManagerRegistry $doctrine, CSRFProtectionSe $correspondences = $reservation->getCorrespondences(); $origins = $em->getRepository(ReservationOrigin::class)->findAll(); + $guestCategoriesById = []; + $gCategories = $em->getRepository(GuestCategory::class)->findBy([], ['sortOrder' => 'ASC', 'id' => 'ASC']); + foreach ($gCategories as $gc) { + $guestCategoriesById[$gc->getId()] = $gc; + } $requestStack->getSession()->set('invoicePositionsMiscellaneous', new ArrayCollection()); $is->prefillMiscPositionsWithReservations([$reservation], $requestStack, true); @@ -827,6 +878,8 @@ public function getReservationAction(ManagerRegistry $doctrine, CSRFProtectionSe 'netto' => $netto, 'apartmentTotal' => $apartmentTotal, 'miscTotal' => $miscTotal, + 'hasActiveTouristTax' => $touristTaxService->hasActiveTaxForSubsidiary($reservation->getAppartment()?->getObject()), + 'guestCategoriesById' => $guestCategoriesById, ]); } @@ -848,6 +901,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 +910,7 @@ public function editReservationAction(ManagerRegistry $doctrine, RequestStack $r 'error' => $error, 'origins' => $origins, 'reservationStatus' => $reservationStatus, + 'guestCategories' => $guestCategories, ]); } @@ -1269,6 +1324,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/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/Controller/TouristTaxController.php b/src/Controller/TouristTaxController.php new file mode 100644 index 00000000..26b29084 --- /dev/null +++ b/src/Controller/TouristTaxController.php @@ -0,0 +1,88 @@ +render('TouristTax/index.html.twig', [ + 'tourist_taxes' => $repo->findAllOrdered(), + ]); + } + + #[Route('/new', name: 'tourist_tax_new', methods: ['GET', 'POST'])] + public function new(ManagerRegistry $doctrine, Request $request, AccountingSettingsService $settings): Response + { + $tax = new TouristTax(); + $form = $this->createForm(TouristTaxType::class, $tax, [ + 'active_preset' => $settings->getActivePreset(), + 'reference_date' => $tax->getValidFrom() ?? new \DateTime(), + ]); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + $em = $doctrine->getManager(); + $em->persist($tax); + $em->flush(); + + $this->addFlash('success', 'tourist_tax.flash.create.success'); + + return new Response('', Response::HTTP_NO_CONTENT); + } + + return $this->render('TouristTax/new.html.twig', [ + 'tax' => $tax, + 'form' => $form->createView(), + ]); + } + + #[Route('/{id}/edit', name: 'tourist_tax_edit', methods: ['GET', 'POST'])] + public function edit(ManagerRegistry $doctrine, Request $request, TouristTax $tax, AccountingSettingsService $settings): Response + { + $form = $this->createForm(TouristTaxType::class, $tax, [ + 'active_preset' => $settings->getActivePreset(), + 'reference_date' => $tax->getValidFrom() ?? new \DateTime(), + ]); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + $doctrine->getManager()->flush(); + $this->addFlash('success', 'tourist_tax.flash.edit.success'); + + return new Response('', Response::HTTP_NO_CONTENT); + } + + return $this->render('TouristTax/edit.html.twig', [ + 'tax' => $tax, + 'form' => $form->createView(), + ]); + } + + #[Route('/{id}/delete', name: 'tourist_tax_delete', methods: ['DELETE'])] + public function delete(ManagerRegistry $doctrine, Request $request, TouristTax $tax): Response + { + if ($this->isCsrfTokenValid('delete'.$tax->getId(), $request->request->get('_token'))) { + $em = $doctrine->getManager(); + $em->remove($tax); + $em->flush(); + $this->addFlash('success', 'tourist_tax.flash.delete.success'); + } + + return new Response('', Response::HTTP_NO_CONTENT); + } +} 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/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 new file mode 100644 index 00000000..fb16fab0 --- /dev/null +++ b/src/Dto/BookingJournal/BankImport/ImportState.php @@ -0,0 +1,210 @@ + $line + */ + public static function deriveLineStatus(array $line): string + { + if (true === ($line['isDuplicate'] ?? false) && true !== ($line['forceImportDuplicate'] ?? false)) { + return self::LINE_STATUS_DUPLICATE; + } + + if (true === ($line['isIgnored'] ?? false)) { + return self::LINE_STATUS_IGNORED; + } + + $hasSplits = !empty($line['splits']); + $hasManualInvoiceNumber = '' !== trim((string) ($line['userInvoiceNumber'] ?? '')); + $hasInvoiceAutoMatch = null !== ($line['matchedInvoiceId'] ?? null) + && true === ($line['matchedInvoiceAmountMatches'] ?? false) + && ((float) ($line['amount'] ?? 0)) >= 0.0 + && null !== ($line['userDebitAccountId'] ?? null) + && !$hasManualInvoiceNumber; + $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 + */ + 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, + 'forceImportDuplicate' => false, + 'userDebitAccountId' => null, + 'userCreditAccountId' => null, + 'userTaxRateId' => null, + 'userRemark' => null, + 'userInvoiceNumber' => 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/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 @@ + $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 = [], + ) { + } + + /** + * Merges several parser outputs into one. The widest period span and + * concatenated lines/warnings win; if the inputs reference more than one + * source IBAN, a {@see MultipleSourceAccountsException} is raised so the + * caller can stop the import early. + * + * @param list $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/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/Dto/PriceBreakdown.php b/src/Dto/PriceBreakdown.php new file mode 100644 index 00000000..1d931c6a --- /dev/null +++ b/src/Dto/PriceBreakdown.php @@ -0,0 +1,41 @@ +lines[] = $line; + } + + public function total(): float + { + $sum = 0.0; + foreach ($this->lines as $line) { + $sum += $line->total(); + } + + return $sum; + } +} diff --git a/src/Dto/PriceBreakdownLine.php b/src/Dto/PriceBreakdownLine.php new file mode 100644 index 00000000..90262282 --- /dev/null +++ b/src/Dto/PriceBreakdownLine.php @@ -0,0 +1,24 @@ +count * $this->unitPrice; + } +} diff --git a/src/Dto/TouristTaxBreakdown.php b/src/Dto/TouristTaxBreakdown.php new file mode 100644 index 00000000..e6447c05 --- /dev/null +++ b/src/Dto/TouristTaxBreakdown.php @@ -0,0 +1,48 @@ +precomputedTotal) { + return $this->precomputedTotal; + } + + return $this->pricePerNight * $this->nights * $this->count; + } +} 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..e7b123f4 --- /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, amountSource: "purpose_marker"|"purpose_regex"|null, marker: string|null, pattern: string|null, 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/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/Enum/ModifierType.php b/src/Entity/Enum/ModifierType.php new file mode 100644 index 00000000..33d58f55 --- /dev/null +++ b/src/Entity/Enum/ModifierType.php @@ -0,0 +1,13 @@ +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/GuestCategoryModifier.php b/src/Entity/GuestCategoryModifier.php new file mode 100644 index 00000000..3ff831d3 --- /dev/null +++ b/src/Entity/GuestCategoryModifier.php @@ -0,0 +1,152 @@ +id; + } + + public function getCategory(): ?GuestCategory + { + return $this->category; + } + + public function setCategory(?GuestCategory $category): self + { + $this->category = $category; + + return $this; + } + + public function getType(): ModifierType + { + return $this->type; + } + + public function setType(ModifierType $type): self + { + $this->type = $type; + + return $this; + } + + public function getValue(): string + { + return $this->value; + } + + public function getValueAsFloat(): float + { + return (float) $this->value; + } + + public function setValue(string|float|int $value): self + { + $this->value = is_string($value) ? $value : number_format((float) $value, 2, '.', ''); + + return $this; + } + + public function getValidFrom(): ?\DateTimeInterface + { + return $this->validFrom; + } + + public function setValidFrom(?\DateTimeInterface $validFrom): self + { + $this->validFrom = $validFrom; + + return $this; + } + + public function getValidTo(): ?\DateTimeInterface + { + return $this->validTo; + } + + public function setValidTo(?\DateTimeInterface $validTo): self + { + $this->validTo = $validTo; + + return $this; + } + + public function isActive(): bool + { + return $this->active; + } + + public function setActive(bool $active): self + { + $this->active = $active; + + return $this; + } + + public function getSortOrder(): int + { + return $this->sortOrder; + } + + public function setSortOrder(int $sortOrder): self + { + $this->sortOrder = $sortOrder; + + 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/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/Price.php b/src/Entity/Price.php index 210604fa..8576c4b5 100644 --- a/src/Entity/Price.php +++ b/src/Entity/Price.php @@ -70,6 +70,8 @@ class Price private bool $isDefaultActiveInReservationCreation; #[ORM\Column(type: 'boolean')] private bool $isBookableOnline; + #[ORM\Column(type: 'boolean')] + private bool $isMandatoryOnline; #[ORM\OneToMany(targetEntity: 'App\Entity\PriceComponent', mappedBy: 'price', orphanRemoval: true, cascade: ['persist'])] #[ORM\OrderBy(['sortOrder' => 'ASC', 'id' => 'ASC'])] private Collection $components; @@ -91,6 +93,7 @@ public function __construct() $this->isPerRoom = false; $this->isDefaultActiveInReservationCreation = true; $this->isBookableOnline = false; + $this->isMandatoryOnline = false; } public function getId() @@ -427,6 +430,18 @@ public function setIsBookableOnline(bool $isBookableOnline): self return $this; } + public function getIsMandatoryOnline(): bool + { + return $this->isMandatoryOnline; + } + + public function setIsMandatoryOnline(bool $isMandatoryOnline): self + { + $this->isMandatoryOnline = $isMandatoryOnline; + + return $this; + } + /** * @return Collection|PriceComponent[] */ 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/Entity/TouristTax.php b/src/Entity/TouristTax.php new file mode 100644 index 00000000..e3544346 --- /dev/null +++ b/src/Entity/TouristTax.php @@ -0,0 +1,299 @@ + 'per_night_flat'])] + private TaxCalculationMode $calculationMode = TaxCalculationMode::PER_NIGHT_FLAT; + + #[ORM\Column(name: 'percentage_rate', type: 'decimal', precision: 5, scale: 2, nullable: true)] + private ?string $percentageRate = null; + + #[ORM\Column(name: 'percentage_base', type: 'string', length: 16, nullable: true, enumType: PercentageBase::class)] + private ?PercentageBase $percentageBase = null; + + /** @var Collection */ + #[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 getCalculationMode(): TaxCalculationMode + { + return $this->calculationMode; + } + + public function setCalculationMode(TaxCalculationMode $m): self + { + $this->calculationMode = $m; + + return $this; + } + + public function isPercentageMode(): bool + { + return $this->calculationMode->isPercentage(); + } + + public function getPercentageRate(): ?string + { + return $this->percentageRate; + } + + public function setPercentageRate(?string $v): self + { + $this->percentageRate = $v; + + return $this; + } + + public function getPercentageRateFloat(): ?float + { + return null === $this->percentageRate ? null : (float) $this->percentageRate; + } + + public function getPercentageBase(): ?PercentageBase + { + return $this->percentageBase; + } + + public function setPercentageBase(?PercentageBase $b): self + { + $this->percentageBase = $b; + + 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/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 @@ + '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..af1c8550 100644 --- a/src/Form/AccountingSettingsType.php +++ b/src/Form/AccountingSettingsType.php @@ -7,7 +7,6 @@ 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\OptionsResolver\OptionsResolver; diff --git a/src/Form/BankCsvProfileType.php b/src/Form/BankCsvProfileType.php new file mode 100644 index 00000000..3fbbb2d4 --- /dev/null +++ b/src/Form/BankCsvProfileType.php @@ -0,0 +1,166 @@ + '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, CsvColumnType::class, [ + 'label' => $label, + 'required' => false, + 'mapped' => false, + 'attr' => ['class' => 'form-control-sm', 'maxlength' => 4, 'autocomplete' => 'off'], + ]); + } + + $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/BankImportFormatType.php b/src/Form/BankImportFormatType.php new file mode 100644 index 00000000..2ee9fae4 --- /dev/null +++ b/src/Form/BankImportFormatType.php @@ -0,0 +1,118 @@ +") 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/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/BankImportSettingsType.php b/src/Form/BankImportSettingsType.php new file mode 100644 index 00000000..0ef88b7c --- /dev/null +++ b/src/Form/BankImportSettingsType.php @@ -0,0 +1,73 @@ + $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 { + $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 { + $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 + { + $resolver->setDefaults([ + 'data_class' => AccountingSettings::class, + ]); + } +} diff --git a/src/Form/BankStatementUploadType.php b/src/Form/BankStatementUploadType.php new file mode 100644 index 00000000..ef3d0c01 --- /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('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 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', + ), + ]), + ], + ]); + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'data_class' => null, + ]); + } +} diff --git a/src/Form/GuestCategoryModifierType.php b/src/Form/GuestCategoryModifierType.php new file mode 100644 index 00000000..b5836e44 --- /dev/null +++ b/src/Form/GuestCategoryModifierType.php @@ -0,0 +1,78 @@ +add('category', EntityType::class, [ + 'class' => GuestCategory::class, + 'choice_label' => 'name', + 'choices' => $this->guestCategoryRepository->findActiveNonAdultOrdered(), + 'label' => 'guest_category_modifier.field.category', + 'help' => 'guest_category_modifier.field.category.help', + ]) + ->add('type', EnumType::class, [ + 'class' => ModifierType::class, + 'choice_label' => fn (ModifierType $t) => 'guest_category_modifier.type.'.$t->value, + 'label' => 'guest_category_modifier.field.type', + 'help' => 'guest_category_modifier.field.type.help', + ]) + ->add('value', NumberType::class, [ + 'label' => 'guest_category_modifier.field.value', + 'help' => 'guest_category_modifier.field.value.help', + 'scale' => 2, + 'html5' => true, + 'attr' => ['step' => '0.01'], + ]) + ->add('validFrom', DateType::class, [ + 'label' => 'guest_category_modifier.field.valid_from', + 'widget' => 'single_text', + 'required' => false, + ]) + ->add('validTo', DateType::class, [ + 'label' => 'guest_category_modifier.field.valid_to', + 'widget' => 'single_text', + 'required' => false, + ]) + ->add('sortOrder', IntegerType::class, [ + 'label' => 'guest_category_modifier.field.sort_order', + 'empty_data' => '0', + ]) + ->add('active', CheckboxType::class, [ + 'label' => 'guest_category_modifier.field.active', + 'required' => false, + ]) + ; + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'data_class' => GuestCategoryModifier::class, + ]); + } +} 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/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..09b28c05 --- /dev/null +++ b/src/Form/TouristTaxType.php @@ -0,0 +1,131 @@ +add('name', TextType::class, [ + 'label' => 'tourist_tax.field.name', + 'empty_data' => '', + ]) + ->add('calculationMode', EnumType::class, [ + 'class' => TaxCalculationMode::class, + 'choice_label' => fn (TaxCalculationMode $m) => 'tourist_tax.calculation_mode.'.$m->value, + 'label' => 'tourist_tax.field.calculation_mode', + 'help' => 'tourist_tax.field.calculation_mode.help', + ]) + ->add('percentageRate', NumberType::class, [ + 'label' => 'tourist_tax.field.percentage_rate', + 'help' => 'tourist_tax.field.percentage_rate.help', + 'scale' => 2, + 'required' => false, + ]) + ->add('percentageBase', EnumType::class, [ + 'class' => PercentageBase::class, + 'choice_label' => fn (PercentageBase $b) => 'tourist_tax.percentage_base.'.$b->value, + 'label' => 'tourist_tax.field.percentage_base', + 'help' => 'tourist_tax.field.percentage_base.help', + 'required' => false, + 'placeholder' => '-', + ]) + ->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', + '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/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/src/Kernel.php b/src/Kernel.php index fb0d79cd..8c010b90 100644 --- a/src/Kernel.php +++ b/src/Kernel.php @@ -26,8 +26,8 @@ public function getCacheDir(): string protected function configureContainer(ContainerConfigurator $container): void { - $container->import('../config/{packages}/*.yaml'); - $container->import('../config/{packages}/'.$this->environment.'/*.yaml'); + $container->import('../config/{packages}/*.{php,yaml}'); + $container->import('../config/{packages}/'.$this->environment.'/*.{php,yaml}'); if (is_file(\dirname(__DIR__).'/config/services.yaml')) { $container->import('../config/{services}.yaml'); 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/GuestCategoryModifierRepository.php b/src/Repository/GuestCategoryModifierRepository.php new file mode 100644 index 00000000..78716261 --- /dev/null +++ b/src/Repository/GuestCategoryModifierRepository.php @@ -0,0 +1,68 @@ + + */ +class GuestCategoryModifierRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, GuestCategoryModifier::class); + } + + /** + * Returns active modifiers valid on the given date. Modifier visibility is + * inherited from its GuestCategory's subsidiary mapping; no separate scope + * lives on the modifier itself. + * + * @return GuestCategoryModifier[] + */ + public function findActiveOn(\DateTimeInterface $date): array + { + return $this->createQueryBuilder('m') + ->andWhere('m.active = :active') + ->andWhere('m.validFrom IS NULL OR m.validFrom <= :date') + ->andWhere('m.validTo IS NULL OR m.validTo >= :date') + ->setParameter('active', true) + ->setParameter('date', $date->format('Y-m-d')) + ->orderBy('m.sortOrder', 'ASC') + ->addOrderBy('m.id', 'ASC') + ->getQuery() + ->getResult(); + } + + /** + * Returns the first applicable modifier for the given category on the given date. + */ + public function findApplicable(GuestCategory $category, \DateTimeInterface $date): ?GuestCategoryModifier + { + foreach ($this->findActiveOn($date) as $mod) { + if ($mod->getCategory()?->getId() === $category->getId()) { + return $mod; + } + } + + return null; + } + + /** @return GuestCategoryModifier[] */ + public function findAllOrdered(): array + { + return $this->createQueryBuilder('m') + ->leftJoin('m.category', 'c')->addSelect('c') + ->orderBy('c.sortOrder', 'ASC') + ->addOrderBy('m.sortOrder', 'ASC') + ->addOrderBy('m.id', 'ASC') + ->getQuery() + ->getResult(); + } +} diff --git a/src/Repository/GuestCategoryRepository.php b/src/Repository/GuestCategoryRepository.php new file mode 100644 index 00000000..03e901ff --- /dev/null +++ b/src/Repository/GuestCategoryRepository.php @@ -0,0 +1,108 @@ + + */ +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(); + } + + /** + * Active categories that are eligible for an apartment-rate modifier. + * Excludes: + * - the ADULT statistical group: it is the unmodified base reference + * (PriceService skips it). + * - categories with isCountedInOccupancy=false: they are not part of + * `persons` and therefore never billed by the apartment line — a + * modifier on them would never take effect (InvoiceService skips it). + * + * @return GuestCategory[] + */ + public function findActiveNonAdultOrdered(): array + { + return $this->createQueryBuilder('gc') + ->where('gc.active = :active') + ->andWhere('gc.statisticalGroup <> :adult') + ->andWhere('gc.isCountedInOccupancy = :counted') + ->setParameter('active', true) + ->setParameter('adult', GuestStatisticalGroup::ADULT->value) + ->setParameter('counted', 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/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/Repository/PriceRepository.php b/src/Repository/PriceRepository.php index a0c10d18..11f57de4 100644 --- a/src/Repository/PriceRepository.php +++ b/src/Repository/PriceRepository.php @@ -223,6 +223,24 @@ public function findBookableOnlineExtras(Reservation $reservation): array } } + /** + * Find misc prices that are marked as mandatory for online booking. + * + * @return Price[] + */ + public function findMandatoryOnlineExtras(Reservation $reservation): array + { + $q = $this->getFindBaseQuery($reservation) + ->andWhere('p.type = 1') + ->andWhere('p.isMandatoryOnline = true'); + + try { + return $q->getQuery()->getResult(); + } catch (NoResultException $e) { + return []; + } + } + /** * Base Query to find prices for a reservation. * 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/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..7653822a --- /dev/null +++ b/src/Service/BookingJournal/BankImport/BankImportRuleMatcher.php @@ -0,0 +1,220 @@ +ruleRepo->findActiveForAccount($bankAccount); + if ([] === $rules) { + return; + } + + foreach ($state->lines as &$line) { + if ($this->isProtectedDuplicate($line) || true === ($line['isIgnored'] ?? false)) { + continue; + } + + $this->applyFirstMatchingRule($line, $rules, $state); + } + unset($line); + } + + public function annotateLine(ImportState $state, int $idx, AccountingAccount $bankAccount): bool + { + if (!isset($state->lines[$idx])) { + return false; + } + + $line = &$state->lines[$idx]; + if ($this->isProtectedDuplicate($line) || true === ($line['isIgnored'] ?? false)) { + unset($line); + + return false; + } + + $rules = $this->ruleRepo->findActiveForAccount($bankAccount); + $matched = $this->applyFirstMatchingRule($line, $rules, $state); + unset($line); + + return $matched; + } + + public function reapplyPreviouslyAppliedRules(ImportState $state, AccountingAccount $bankAccount): int + { + $rules = $this->ruleRepo->findActiveForAccount($bankAccount); + $this->dropRuleWarnings($state); + + $touched = 0; + foreach ($state->lines as &$line) { + if (null === ($line['appliedRuleId'] ?? null)) { + continue; + } + if ($this->isProtectedDuplicate($line) || true === ($line['isIgnored'] ?? false)) { + continue; + } + + $this->resetRuleManagedFields($line, $bankAccount); + ++$touched; + + foreach ($rules as $rule) { + if (!$this->ruleMatches($rule, $line)) { + continue; + } + + $this->applicator->apply($rule, $line); + if ($this->hasRuleWarning($line['ruleWarning'] ?? null)) { + $warning = $this->translator->trans('accounting.bank_import.rule.warning.line', [ + '%line%' => ((int) ($line['idx'] ?? 0)) + 1, + '%message%' => $this->formatRuleWarning($line['ruleWarning']), + ]); + if (!in_array($warning, $state->warnings, true)) { + $state->warnings[] = $warning; + } + } + break; + } + + $line['status'] = ImportState::deriveLineStatus($line); + } + unset($line); + + return $touched; + } + + /** + * 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; + } + + /** + * @param array $line + */ + private function isProtectedDuplicate(array $line): bool + { + return true === ($line['isDuplicate'] ?? false) + && true !== ($line['forceImportDuplicate'] ?? false); + } + + /** + * @param array $line + * @param list $rules + */ + private function applyFirstMatchingRule(array &$line, array $rules, ImportState $state): bool + { + foreach ($rules as $rule) { + if (!$this->ruleMatches($rule, $line)) { + continue; + } + + $this->applicator->apply($rule, $line); + if ($this->hasRuleWarning($line['ruleWarning'] ?? null)) { + $warning = $this->translator->trans('accounting.bank_import.rule.warning.line', [ + '%line%' => ((int) ($line['idx'] ?? 0)) + 1, + '%message%' => $this->formatRuleWarning($line['ruleWarning']), + ]); + if (!in_array($warning, $state->warnings, true)) { + $state->warnings[] = $warning; + } + } + + return true; + } + + return false; + } + + /** + * @param mixed $warning + */ + private function hasRuleWarning(mixed $warning): bool + { + if (is_array($warning)) { + return isset($warning['key']) && '' !== (string) $warning['key']; + } + + return null !== $warning && '' !== (string) $warning; + } + + /** + * @param mixed $warning + */ + private function formatRuleWarning(mixed $warning): string + { + if (is_array($warning) && isset($warning['key'])) { + $params = $warning['params'] ?? []; + + return $this->translator->trans((string) $warning['key'], is_array($params) ? $params : []); + } + + return (string) $warning; + } + + private function dropRuleWarnings(ImportState $state): void + { + $state->warnings = array_values(array_filter( + $state->warnings, + static fn (mixed $warning): bool => !is_string($warning) + || 1 !== preg_match('/^(Zeile|Line) \d+:/u', $warning), + )); + } + + /** + * @param array $line + */ + private function resetRuleManagedFields(array &$line, AccountingAccount $bankAccount): void + { + $bankAccountId = (int) $bankAccount->getId(); + $line['appliedRuleId'] = null; + $line['splits'] = []; + $line['userTaxRateId'] = null; + $line['userRemark'] = null; + $line['userInvoiceNumber'] = null; + unset($line['ruleWarning']); + + if (((float) ($line['amount'] ?? 0)) >= 0.0) { + $line['userDebitAccountId'] = $bankAccountId; + $line['userCreditAccountId'] = null; + } else { + $line['userDebitAccountId'] = null; + $line['userCreditAccountId'] = $bankAccountId; + } + } +} diff --git a/src/Service/BookingJournal/BankImport/BankStatementCommitter.php b/src/Service/BookingJournal/BankImport/BankStatementCommitter.php new file mode 100644 index 00000000..82336c7a --- /dev/null +++ b/src/Service/BookingJournal/BankImport/BankStatementCommitter.php @@ -0,0 +1,302 @@ +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) { + $isForcedDuplicate = true === ($line['isDuplicate'] ?? false) + && true === ($line['forceImportDuplicate'] ?? false); + if (true === ($line['isDuplicate'] ?? false) && !$isForcedDuplicate) { + ++$duplicates; + continue; + } + + $hash = (string) ($line['fingerprint'] ?? ''); + $fingerprint = $isForcedDuplicate + ? $this->fingerprintRepo->findByHash($bankAccount, $hash) + : null; + $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. + $hasExternalInvoiceNumber = null !== $this->manualInvoiceNumber($line); + $existing = !$hasExternalInvoiceNumber && null !== ($line['matchedInvoiceId'] ?? null) + ? $this->entryRepo->findBy(['invoiceId' => (int) $line['matchedInvoiceId']]) + : []; + + if (!$hasExternalInvoiceNumber && 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, + 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 + { + $manualInvoiceNumber = $this->manualInvoiceNumber($line); + $invoiceId = null !== $manualInvoiceNumber ? null : ($line['matchedInvoiceId'] ?? null); + $invoiceNumber = $manualInvoiceNumber ?? ($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; + } + + /** + * @param array $line + */ + private function manualInvoiceNumber(array $line): ?string + { + $value = trim((string) ($line['userInvoiceNumber'] ?? '')); + + return '' === $value ? null : mb_substr($value, 0, 50); + } + + 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..c4fc9cb8 --- /dev/null +++ b/src/Service/BookingJournal/BankImport/Parser/GenericCsvParser.php @@ -0,0 +1,409 @@ +trans('accounting.bank_import.parser.error.profile_required')); + } + + $warnings = []; + $rows = $this->readAllRows($file, $profile, $warnings); + + $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 = []; + 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); + } + + /** + * @param list $warnings + * + * @return list> + */ + private function readAllRows(\SplFileInfo $file, BankCsvProfile $profile, array &$warnings): 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)) { + if (!mb_check_encoding($content, 'UTF-8')) { + $detected = $this->detectSourceEncoding($content); + $warnings[] = $this->trans('accounting.bank_import.parser.warning.encoding_mismatch', [ + '%encoding%' => $encoding, + '%detected%' => $detected, + ]); + $content = mb_convert_encoding($content, 'UTF-8', $detected); + } + } else { + $converted = @mb_convert_encoding($content, 'UTF-8', $encoding); + if (false !== $converted) { + $content = $converted; + } + } + + if (!mb_check_encoding($content, 'UTF-8')) { + $detected = $this->detectSourceEncoding($content); + $warnings[] = $this->trans('accounting.bank_import.parser.warning.encoding_mismatch', [ + '%encoding%' => $encoding, + '%detected%' => $detected, + ]); + $content = mb_convert_encoding($content, 'UTF-8', $detected); + } + + // 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; + } + + private function detectSourceEncoding(string $content): string + { + $detected = mb_detect_encoding($content, ['UTF-8', 'ISO-8859-15', 'Windows-1252', 'ISO-8859-1'], true); + + return is_string($detected) && '' !== $detected ? $detected : 'ISO-8859-15'; + } + + /** + * @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, + ])); + } + + if ((int) $date->format('Y') < 100 && 1 === preg_match('/(\d{2})\D*$/', $raw, $matches)) { + $year = (int) $matches[1]; + $date = $date->setDate(2000 + $year, (int) $date->format('n'), (int) $date->format('j')); + } + + 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/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 new file mode 100644 index 00000000..dd64653e --- /dev/null +++ b/src/Service/BookingJournal/BankImport/Parser/ParserInterface.php @@ -0,0 +1,35 @@ + $line + */ + public function apply(BankImportRule $rule, array &$line): void + { + $action = $rule->getAction(); + $line['appliedRuleId'] = $rule->getId(); + + $mode = $action['mode'] ?? BankImportRule::ACTION_MODE_IGNORE; + if (BankImportRule::ACTION_MODE_IGNORE !== $mode && !$this->applyInvoiceNumberExtraction($action, $line)) { + return; + } + + switch ($mode) { + 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; + unset($line['ruleWarning']); + } + + /** + * @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 ('purpose_marker' === ($piece['amountSource'] ?? null)) { + $marker = trim((string) ($piece['marker'] ?? '')); + $share = $this->extractAmountAfterMarker((string) ($line['purpose'] ?? ''), $marker); + if (null === $share) { + $this->markSplitRulePending( + $line, + 'accounting.bank_import.rule.warning.split_marker_missing', + ['%marker%' => $marker], + ); + + return; + } + } elseif ('purpose_regex' === ($piece['amountSource'] ?? null)) { + $pattern = trim((string) ($piece['pattern'] ?? '')); + $share = $this->extractAmountByRegex((string) ($line['purpose'] ?? ''), $pattern); + if (false === $share) { + $this->markSplitRulePending( + $line, + 'accounting.bank_import.rule.warning.split_regex_invalid', + ['%pattern%' => $pattern], + ); + + return; + } + if (null === $share) { + $this->markSplitRulePending( + $line, + 'accounting.bank_import.rule.warning.split_regex_missing', + ['%pattern%' => $pattern], + ); + + return; + } + } elseif (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; + unset($line['ruleWarning']); + $line['status'] = ImportState::LINE_STATUS_READY; + } + + /** + * Reads the next decimal amount after a marker in free bank-purpose text. + * Supports German and English separators, e.g. "12,30", "12.30", + * "1.234,56", "1,234.56", integer amounts like "1790 Euro", + * and an optional trailing minus sign. + */ + private function extractAmountAfterMarker(string $purpose, string $marker): ?float + { + if ('' === trim($purpose) || '' === $marker) { + return null; + } + + $offset = stripos($purpose, $marker); + if (false === $offset) { + return null; + } + + $tail = substr($purpose, $offset + strlen($marker)); + if (false === $tail || '' === $tail) { + return null; + } + + $amountPattern = '/(?normalizeExtractedAmount($matches[1] ?: $matches[2]); + } + + /** + * @return float|null|false false means invalid regex, null means no match + */ + private function extractAmountByRegex(string $purpose, string $pattern): float|null|false + { + $regex = $this->normalizeUserRegex($pattern); + if (null === $regex) { + return false; + } + + $result = @preg_match($regex, $purpose, $matches); + if (false === $result) { + return false; + } + if (0 === $result) { + return null; + } + + foreach (array_slice($matches, 1) as $capture) { + if (!is_string($capture)) { + continue; + } + + $amount = $this->normalizeExtractedAmount($capture); + if (null !== $amount) { + return $amount; + } + } + + return $this->normalizeExtractedAmount((string) ($matches[0] ?? '')); + } + + /** + * @param array $action + * @param array $line + */ + private function applyInvoiceNumberExtraction(array $action, array &$line): bool + { + $config = $action['invoiceNumberExtraction'] ?? null; + if (!is_array($config)) { + return true; + } + + $mode = (string) ($config['mode'] ?? 'none'); + if ('none' === $mode || '' === $mode) { + return true; + } + + $purpose = (string) ($line['purpose'] ?? ''); + if ('marker' === $mode) { + $marker = trim((string) ($config['marker'] ?? '')); + $invoiceNumber = $this->extractInvoiceNumberAfterMarker($purpose, $marker); + if (null === $invoiceNumber) { + $this->markInvoiceNumberRulePending( + $line, + 'accounting.bank_import.rule.warning.invoice_marker_missing', + ['%marker%' => $marker], + ); + + return false; + } + + $line['userInvoiceNumber'] = $invoiceNumber; + + return true; + } + + if ('regex' === $mode) { + $pattern = trim((string) ($config['pattern'] ?? '')); + $invoiceNumber = $this->extractInvoiceNumberByRegex($purpose, $pattern); + if (false === $invoiceNumber) { + $this->markInvoiceNumberRulePending( + $line, + 'accounting.bank_import.rule.warning.invoice_regex_invalid', + ['%pattern%' => $pattern], + ); + + return false; + } + if (null === $invoiceNumber) { + $this->markInvoiceNumberRulePending( + $line, + 'accounting.bank_import.rule.warning.invoice_regex_missing', + ['%pattern%' => $pattern], + ); + + return false; + } + + $line['userInvoiceNumber'] = $invoiceNumber; + + return true; + } + + return true; + } + + private function extractInvoiceNumberAfterMarker(string $purpose, string $marker): ?string + { + if ('' === trim($purpose) || '' === $marker) { + return null; + } + + $offset = stripos($purpose, $marker); + if (false === $offset) { + return null; + } + + $tail = substr($purpose, $offset + strlen($marker)); + if (false === $tail || '' === $tail) { + return null; + } + + $pattern = '/^\s*(?:(?:nr\.?|nummer|no\.?|#)\s*)?[:\-#\s]*([[:alnum:]][[:alnum:].\/_-]{1,49})/iu'; + if (1 !== preg_match($pattern, $tail, $matches)) { + return null; + } + + return $this->cleanInvoiceNumber($matches[1] ?? null); + } + + /** + * @return string|null|false false means invalid regex, null means no match + */ + private function extractInvoiceNumberByRegex(string $purpose, string $pattern): string|null|false + { + $regex = $this->normalizeUserRegex($pattern); + if (null === $regex) { + return false; + } + + $result = @preg_match($regex, $purpose, $matches); + if (false === $result) { + return false; + } + if (0 === $result) { + return null; + } + + return $this->cleanInvoiceNumber($matches[1] ?? $matches[0] ?? null) ?? false; + } + + private function normalizeUserRegex(string $pattern): ?string + { + if ('' === $pattern) { + return null; + } + + if (1 === preg_match('/^([\/#~]).+\1([imsxueADSUXJ]*)$/', $pattern)) { + return $pattern; + } + + return '/'.str_replace('/', '\\/', $pattern).'/iu'; + } + + private function cleanInvoiceNumber(mixed $value): ?string + { + if (null === $value) { + return null; + } + + $value = trim((string) $value); + $value = trim($value, " \t\n\r\0\x0B.,;:"); + + return '' === $value ? null : mb_substr($value, 0, 50); + } + + private function normalizeExtractedAmount(string $raw): ?float + { + $value = trim($raw); + if ('' === $value) { + return null; + } + + $value = preg_replace('/[^\d,.\-+]/', '', $value) ?? ''; + if ('' === $value || '-' === $value || '+' === $value) { + return null; + } + + $lastComma = strrpos($value, ','); + $lastDot = strrpos($value, '.'); + if (false !== $lastComma || false !== $lastDot) { + $separator = false !== $lastComma && false !== $lastDot + ? ($lastComma > $lastDot ? ',' : '.') + : (false !== $lastComma ? ',' : '.'); + $separatorPos = strrpos($value, $separator); + $digitsAfterSeparator = false === $separatorPos ? 0 : strlen($value) - $separatorPos - 1; + $isThousandsOnly = 3 === $digitsAfterSeparator + && 1 === substr_count($value, $separator) + && 1 === preg_match('/^[+-]?\d{1,3}[.,]\d{3}$/', $value); + + if ($isThousandsOnly) { + $value = str_replace($separator, '', $value); + } else { + $decimal = false !== $lastComma && false !== $lastDot + ? ($lastComma > $lastDot ? ',' : '.') + : $separator; + $thousands = ',' === $decimal ? '.' : ','; + $value = str_replace($thousands, '', $value); + $value = str_replace($decimal, '.', $value); + } + } + + return round(abs((float) $value), 2); + } + + /** + * @param array $line + */ + private function markSplitRulePending(array &$line, string $key, array $params): void + { + $line['splits'] = []; + $line['status'] = ImportState::LINE_STATUS_PENDING; + $line['ruleWarning'] = [ + 'key' => $key, + 'params' => $params, + ]; + } + + /** + * @param array $line + * @param array $params + */ + private function markInvoiceNumberRulePending(array &$line, string $key, array $params): void + { + $line['splits'] = []; + $line['status'] = ImportState::LINE_STATUS_PENDING; + $line['ruleWarning'] = [ + 'key' => $key, + 'params' => $params, + ]; + } + + /** + * @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['userInvoiceNumber'] ?? null) ?: ($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 68% rename from src/Service/BookingJournalService.php rename to src/Service/BookingJournal/BookingJournalService.php index 5b822c31..b1ebfdfc 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; @@ -17,6 +18,9 @@ class BookingJournalService { + /** @var array */ + private array $batchCache = []; + public function __construct( private readonly EntityManagerInterface $em, private readonly BookingBatchRepository $batchRepo, @@ -33,10 +37,15 @@ public function __construct( */ public function getOrCreateBatch(int $year, int $month): BookingBatch { + $cacheKey = sprintf('%04d-%02d', $year, $month); + if (isset($this->batchCache[$cacheKey])) { + return $this->batchCache[$cacheKey]; + } + $batch = $this->batchRepo->findByYearAndMonth($year, $month); if (null !== $batch) { - return $batch; + return $this->batchCache[$cacheKey] = $batch; } $batch = new BookingBatch(); @@ -45,7 +54,7 @@ public function getOrCreateBatch(int $year, int $month): BookingBatch $this->em->persist($batch); - return $batch; + return $this->batchCache[$cacheKey] = $batch; } /** @@ -143,10 +152,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 +186,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]; @@ -212,10 +225,16 @@ public function createEntriesFromInvoice( $vatKey = $vatKeyOf($pos->getVat()); $effective = $resolveCreditAccount($pos->getRevenueAccount(), $vatKey); $accountId = $effective?->getId() ?? 0; - if (!isset($groups['misc'][$vatKey][$accountId])) { - $groups['misc'][$vatKey][$accountId] = ['brutto' => 0.0, 'account' => $effective]; + // Apartment-modifier deltas (per-category surcharges/discounts on + // the room rate) book against the apartment scope so they net out + // with the room revenue line — otherwise the journal would carry + // a separate "Sonstige" entry with a negative amount, which is + // not how hoteliers usually represent rate adjustments. + $scope = 'apartment_modifier' === $pos->getPositionGroup() ? 'apartment' : 'misc'; + if (!isset($groups[$scope][$vatKey][$accountId])) { + $groups[$scope][$vatKey][$accountId] = ['brutto' => 0.0, 'account' => $effective]; } - $groups['misc'][$vatKey][$accountId]['brutto'] += $bruttoAmount; + $groups[$scope][$vatKey][$accountId]['brutto'] += $bruttoAmount; } $settings = $this->settingsService->getSettings(); @@ -241,7 +260,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 +275,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 +291,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/CalendarImportService.php b/src/Service/CalendarImportService.php index 7682c2a7..3d06091f 100644 --- a/src/Service/CalendarImportService.php +++ b/src/Service/CalendarImportService.php @@ -34,7 +34,7 @@ public function __construct( private readonly CacheInterface $cache, private readonly TranslatorInterface $translator, private readonly EventDispatcherInterface $eventDispatcher, - private readonly ReservationService $reservationService, + private readonly ReservationRepository $reservationRepository, ) { } @@ -220,7 +220,7 @@ private function syncEvent(CalendarSyncImport $import, array $event): string return self::SYNC_SKIP_PAST; } - $reservation = $this->getReservationRepository()->findOneByRefUidAndImport($uid, $import); + $reservation = $this->reservationRepository->findOneByRefUidAndImport($uid, $import); if ($reservation instanceof Reservation) { return $this->updateExistingReservation($import, $reservation, $start, $end, $event); } @@ -271,15 +271,7 @@ private function updateExistingReservation( return $this->handleConflict($import, $reservation, $start, $end, $event, $conflicts, true); } - $reservation->setStartDate($this->toDate($start)); - $reservation->setEndDate($this->toDate($end)); - $reservation->setReservationOrigin($import->getReservationOrigin()); - $this->reservationService->changeStatus($reservation, $import->getReservationStatus(), flush: false); - $reservation->setRemark($event['DESCRIPTION'] ?? null); - $reservation->setRefUid($event['UID']); - $reservation->setIsConflict(false); - $reservation->setIsConflictIgnored(false); - $reservation->setCalendarSyncImport($import); + $this->updateExistingImportedReservation($reservation, $start, $end, false); $this->em->flush(); return self::SYNC_OK; @@ -329,15 +321,11 @@ private function handleConflict( $conflict->setIsConflictIgnored(false); } $reservation = $existing ?? $this->buildReservation($import, $start, $end, $event, false); - $reservation->setStartDate($this->toDate($start)); - $reservation->setEndDate($this->toDate($end)); - $reservation->setReservationOrigin($import->getReservationOrigin()); - $this->reservationService->changeStatus($reservation, $import->getReservationStatus(), flush: false); - $reservation->setRemark($event['DESCRIPTION'] ?? null); - $reservation->setRefUid($event['UID']); - $reservation->setIsConflict(false); - $reservation->setIsConflictIgnored(false); - $reservation->setCalendarSyncImport($import); + if ($isUpdate) { + $this->updateExistingImportedReservation($reservation, $start, $end, false); + } else { + $this->applyImportedReservationData($import, $reservation, $start, $end, $event, false); + } if (!$isUpdate) { $this->em->persist($reservation); } @@ -352,15 +340,11 @@ private function handleConflict( return self::SYNC_SKIP_CONFLICT; } $conflictReservation = $existing ?? $this->buildReservation($import, $start, $end, $event, true); - $conflictReservation->setStartDate($this->toDate($start)); - $conflictReservation->setEndDate($this->toDate($end)); - $conflictReservation->setReservationOrigin($import->getReservationOrigin()); - $this->reservationService->changeStatus($conflictReservation, $import->getReservationStatus(), flush: false); - $conflictReservation->setRemark($event['DESCRIPTION'] ?? null); - $conflictReservation->setRefUid($event['UID']); - $conflictReservation->setIsConflict(true); - $conflictReservation->setIsConflictIgnored(false); - $conflictReservation->setCalendarSyncImport($import); + if ($isUpdate) { + $this->updateExistingImportedReservation($conflictReservation, $start, $end, true); + } else { + $this->applyImportedReservationData($import, $conflictReservation, $start, $end, $event, true); + } if (!$isUpdate) { $this->em->persist($conflictReservation); } @@ -372,6 +356,39 @@ private function handleConflict( return self::SYNC_SKIP_CONFLICT; } + /** Update only feed-owned fields on an existing imported reservation. */ + private function updateExistingImportedReservation( + Reservation $reservation, + \DateTimeImmutable $start, + \DateTimeImmutable $end, + bool $isConflict + ): void { + $reservation->setStartDate($this->toDate($start)); + $reservation->setEndDate($this->toDate($end)); + $reservation->setIsConflict($isConflict); + $reservation->setIsConflictIgnored(false); + } + + /** Apply full import defaults to a newly created imported reservation. */ + private function applyImportedReservationData( + CalendarSyncImport $import, + Reservation $reservation, + \DateTimeImmutable $start, + \DateTimeImmutable $end, + array $event, + bool $isConflict + ): void { + $reservation->setStartDate($this->toDate($start)); + $reservation->setEndDate($this->toDate($end)); + $reservation->setReservationOrigin($import->getReservationOrigin()); + $reservation->setReservationStatus($import->getReservationStatus()); + $reservation->setRemark($event['DESCRIPTION'] ?? null); + $reservation->setRefUid($event['UID']); + $reservation->setIsConflict($isConflict); + $reservation->setIsConflictIgnored(false); + $reservation->setCalendarSyncImport($import); + } + /** Build a reservation entity from import data. */ private function buildReservation( CalendarSyncImport $import, @@ -386,6 +403,7 @@ private function buildReservation( $reservation->setEndDate($this->toDate($end)); $reservation->setPersons(1); $reservation->setReservationOrigin($import->getReservationOrigin()); + // no chagteStatus event triggered as it is a new entity $reservation->setReservationStatus($import->getReservationStatus()); $reservation->setRemark($event['DESCRIPTION'] ?? null); $reservation->setRefUid($event['UID']); @@ -404,7 +422,7 @@ private function findConflicts( \DateTimeImmutable $end, ?Reservation $current ): array { - $reservations = $this->getReservationRepository()->loadReservationsForApartmentWithoutStartEnd( + $reservations = $this->reservationRepository->loadReservationsForApartmentWithoutStartEnd( $this->toDate($start), $this->toDate($end), $import->getApartment() @@ -425,7 +443,7 @@ private function findConflicts( /** Check whether a conflict for the given UID was intentionally ignored. */ private function hasIgnoredConflict(CalendarSyncImport $import, string $refUid): bool { - $reservation = $this->getReservationRepository()->findOneByRefUidAndImport($refUid, $import); + $reservation = $this->reservationRepository->findOneByRefUidAndImport($refUid, $import); if (!$reservation instanceof Reservation) { return false; } @@ -440,7 +458,7 @@ public function resolveConflictReservation(Reservation $reservation): bool return false; } - $blocking = $this->getReservationRepository()->loadReservationsForApartmentWithoutStartEnd( + $blocking = $this->reservationRepository->loadReservationsForApartmentWithoutStartEnd( $reservation->getStartDate(), $reservation->getEndDate(), $reservation->getAppartment() @@ -463,12 +481,4 @@ private function toDate(\DateTimeImmutable $date): \DateTime return new \DateTime($date->format('Y-m-d')); } - /** Resolve the reservation repository from the entity manager. */ - private function getReservationRepository(): ReservationRepository - { - /** @var ReservationRepository $repository */ - $repository = $this->em->getRepository(Reservation::class); - - return $repository; - } } diff --git a/src/Service/FileUploader.php b/src/Service/FileUploader.php index 747f2f73..f81be7e5 100644 --- a/src/Service/FileUploader.php +++ b/src/Service/FileUploader.php @@ -35,9 +35,9 @@ public function upload(UploadedFile $file): string public function isValidImage(UploadedFile $file): bool { - $imageConstraint = new Assert\Image([ - 'maxSize' => '5m', - ]); + $imageConstraint = new Assert\Image( + maxSize: '5m', + ); $errors = $this->validator->validate($file, $imageConstraint); 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/GuestCategorySeeder.php b/src/Service/GuestCategorySeeder.php new file mode 100644 index 00000000..8e11e0d5 --- /dev/null +++ b/src/Service/GuestCategorySeeder.php @@ -0,0 +1,116 @@ + $this->translator->trans($key); + + $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/InvoiceService.php b/src/Service/InvoiceService.php index 487a8551..f9d25773 100644 --- a/src/Service/InvoiceService.php +++ b/src/Service/InvoiceService.php @@ -13,6 +13,9 @@ namespace App\Service; +use App\Dto\TouristTaxBreakdown; +use App\Entity\Enum\ModifierType; +use App\Entity\Enum\TaxCalculationMode; use App\Entity\Customer; use App\Entity\CustomerAddresses; use App\Entity\Invoice; @@ -34,8 +37,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, + ) { } /** @@ -339,6 +347,14 @@ public function prefillAppartmentPositions(Reservation $reservation, RequestStac foreach ($this->buildAppartmentPositions($reservation) as $position) { $this->saveNewAppartmentPosition($position, $requestStack); } + + // Modifier delta lines (per non-adult category surcharges/discounts) + // belong to apartment pricing — but technically they are + // InvoicePosition entities, so they share the misc session collection + // (templates split them out via positionGroup="apartment_modifier"). + foreach ($this->buildApartmentModifierPositions([$reservation]) as $modPosition) { + $this->saveNewMiscPosition($modPosition, $requestStack); + } } /** @@ -418,6 +434,15 @@ 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. Apartment-modifier positions are seeded from + // prefillAppartmentPositions instead — they belong to apartment + // pricing, not to misc services. + foreach ($this->buildTouristTaxPositions($reservations) as $touristTaxPosition) { + $this->saveNewMiscPosition($touristTaxPosition, $requestStack); + } } /** @@ -432,6 +457,205 @@ 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; + } + + /** + * Build apartment-rate modifier delta positions per non-adult category. + * The base apartment line keeps billing the full persons × adult-rate; for + * each per-night breakdown line that carries a modifier we emit one + * aggregated InvoicePosition with the *delta* against the adult rate + * (negative for discounts/free, positive for surcharges). + * + * Skips reservations / nights priced per flat-rate or per-room — those + * have no per-person base rate against which a delta could meaningfully + * be computed. + * + * @param Reservation[] $reservations + * + * @return InvoicePosition[] + */ + public function buildApartmentModifierPositions(array $reservations): array + { + $positions = []; + foreach ($reservations as $reservation) { + if (!$reservation instanceof Reservation) { + continue; + } + + // Aggregate per (modifierId, categoryId) across nights. Each entry + // tracks: total nights, count (constant across nights for a single + // reservation since guestCounts doesn't vary), the unit delta and + // metadata copied from the base price. + // + // Modifiers only apply when: + // * the apartment is priced per-head (not flat-rate / per-room). + // Per-room rates are paid as a single fee for the whole room + // regardless of occupancy — emitting a per-category delta would + // be semantically incorrect (the category was never priced in). + // * the category counts toward room occupancy. Non-occupancy + // categories (e.g. infants in a cot) are not part of `persons` + // and therefore never billed by the apartment line; emitting a + // delta would subtract a charge that was never added. + $aggregates = []; + foreach ($this->ps->getPriceBreakdownForReservation($reservation) as $breakdown) { + $base = $breakdown->basePrice; + if (null === $base || $base->getIsFlatPrice() || $base->getIsPerRoom()) { + continue; + } + $basePerHead = (float) $base->getPrice(); + + foreach ($breakdown->lines as $line) { + if (null === $line->modifier) { + continue; + } + if (!$line->category->isCountedInOccupancy()) { + continue; + } + $delta = $line->unitPrice - $basePerHead; + if (0.0 === round($delta, 2)) { + continue; + } + $key = $line->modifier->getId().':'.$line->category->getId(); + if (!isset($aggregates[$key])) { + $aggregates[$key] = [ + 'modifier' => $line->modifier, + 'category' => $line->category, + 'count' => $line->count, + 'nights' => 0, + 'unitDelta' => $delta, + 'vat' => $base->getVat(), + 'includesVat' => (bool) $base->getIncludesVat(), + 'revenueAccount' => $base->getRevenueAccount(), + ]; + } + ++$aggregates[$key]['nights']; + } + } + + foreach ($aggregates as $a) { + $positions[] = $this->makeApartmentModifierPosition($a); + } + } + + return $positions; + } + + /** + * @param array{modifier: \App\Entity\GuestCategoryModifier, category: \App\Entity\GuestCategory, count: int, nights: int, unitDelta: float, vat: float, includesVat: bool, revenueAccount: ?\App\Entity\AccountingAccount} $a + */ + private function makeApartmentModifierPosition(array $a): InvoicePosition + { + $modifierLabel = $this->describeModifier($a['modifier']); + $description = $this->translator->trans('invoice.apartment_modifier.position', [ + '%category%' => $a['category']->getName(), + '%modifier%' => $modifierLabel, + '%nights%' => $this->translator->trans('invoice.tourist_tax.position.nights', [ + '%count%' => $a['nights'], + ]), + '%count%' => $this->translator->trans('invoice.tourist_tax.position.persons', [ + '%count%' => $a['count'], + ]), + ]); + + $position = new InvoicePosition(); + $position->setAmount($a['nights'] * $a['count']); + $position->setDescription($description); + $position->setPrice(number_format($a['unitDelta'], 2, '.', '')); + $position->setVat($a['vat']); + $position->setIncludesVat($a['includesVat']); + $position->setIsFlatPrice(false); + $position->setIsPerRoom(false); + $position->setRevenueAccount($a['revenueAccount']); + $position->setPositionGroup('apartment_modifier'); + + return $position; + } + + private function describeModifier(\App\Entity\GuestCategoryModifier $modifier): string + { + $value = $modifier->getValueAsFloat(); + $key = 'invoice.apartment_modifier.label.'.$modifier->getType()->value; + + return match ($modifier->getType()) { + ModifierType::DISCOUNT_PERCENT => $this->translator->trans($key, ['%value%' => number_format($value, 0, ',', '.')]), + ModifierType::SURCHARGE_ABSOLUTE, + ModifierType::FLAT_RATE => $this->translator->trans($key, ['%value%' => number_format($value, 2, ',', '.')]), + ModifierType::FREE => $this->translator->trans($key), + }; + } + + private function makeTouristTaxPosition(TouristTaxBreakdown $row): InvoicePosition + { + $position = new InvoicePosition(); + $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'); + + if (TaxCalculationMode::PER_NIGHT_FLAT === $row->calculationMode) { + $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->setDescription($description); + $position->setAmount($row->nights * $row->count); + $position->setPrice(number_format($row->pricePerNight, 2, '.', '')); + + return $position; + } + + // PERCENT_PER_ROOM: amount = 1, price = total. Avoids rounding artifacts + // from dividing the total over nights when apartment prices vary per night. + $description = $this->translator->trans('invoice.tourist_tax.position.percent_per_room', [ + '%tax%' => $row->taxName, + '%percent%' => number_format((float) ($row->percentageRate ?? 0.0), 2, ',', '.'), + '%nights%' => $this->translator->trans('invoice.tourist_tax.position.nights', [ + '%count%' => $row->nights, + ]), + ]); + $position->setDescription($description); + $position->setAmount(1); + $position->setPrice(number_format($row->total(), 2, '.', '')); + + return $position; + } + /** * Aggregates price amounts across all reservations and days into a keyed array, without any session involvement. */ @@ -672,6 +896,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 +931,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/PriceService.php b/src/Service/PriceService.php index 0df3a3a8..26f994a6 100644 --- a/src/Service/PriceService.php +++ b/src/Service/PriceService.php @@ -13,14 +13,22 @@ namespace App\Service; +use App\Dto\PriceBreakdown; +use App\Dto\PriceBreakdownLine; use App\Entity\AccountingAccount; +use App\Entity\Enum\ModifierType; +use App\Entity\Enum\PercentageBase; use App\Entity\Enum\PriceComponentAllocationType; +use App\Entity\GuestCategory; +use App\Entity\GuestCategoryModifier; use App\Entity\Price; use App\Entity\PriceComponent; use App\Entity\PricePeriod; use App\Entity\Reservation; use App\Entity\ReservationOrigin; use App\Entity\RoomCategory; +use App\Repository\GuestCategoryModifierRepository; +use App\Repository\GuestCategoryRepository; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; use Doctrine\ORM\EntityManagerInterface; @@ -30,8 +38,11 @@ class PriceService { private $em; - public function __construct(EntityManagerInterface $em) - { + public function __construct( + EntityManagerInterface $em, + private readonly ?GuestCategoryRepository $guestCategoryRepository = null, + private readonly ?GuestCategoryModifierRepository $modifierRepository = null, + ) { $this->em = $em; } @@ -151,21 +162,10 @@ public function getPriceFromForm(Request $request, $id = 'new') $price->setIncludesVat(false); } - if (null != $request->request->get('isFlatPrice-'.$id)) { - $price->setIsFlatPrice(true); - } else { - $price->setIsFlatPrice(false); - } - - if (null != $request->request->get('isPerRoom-'.$id)) { - $price->setIsPerRoom(true); - } else { - $price->setIsPerRoom(false); - } - - if ($price->getIsFlatPrice()) { - $price->setIsPerRoom(false); - } + // Berechnungsart als Radio (flat | per_room | per_person) + $calcType = (string) $request->request->get('calculation-type-'.$id, 'per_person'); + $price->setIsFlatPrice('flat' === $calcType); + $price->setIsPerRoom('per_room' === $calcType); if (1 == $price->getType() && null != $request->request->get('isDefaultActiveInReservationCreation-'.$id)) { $price->setIsDefaultActiveInReservationCreation(true); @@ -173,11 +173,14 @@ public function getPriceFromForm(Request $request, $id = 'new') $price->setIsDefaultActiveInReservationCreation(false); } - if (1 == $price->getType() && null != $request->request->get('isBookableOnline-'.$id)) { - $price->setIsBookableOnline(true); - } else { - $price->setIsBookableOnline(false); + $mandatoryOnline = 1 == $price->getType() && null != $request->request->get('isMandatoryOnline-'.$id); + $bookableOnline = 1 == $price->getType() && null != $request->request->get('isBookableOnline-'.$id); + // Pflicht impliziert online verfügbar — auch wenn der Switch im UI gesperrt war. + if ($mandatoryOnline) { + $bookableOnline = true; } + $price->setIsBookableOnline($bookableOnline); + $price->setIsMandatoryOnline($mandatoryOnline); if (2 == $price->getType()) { $price->setNumberOfPersons($request->request->get('number-of-persons-'.$id)); @@ -417,9 +420,8 @@ public function getActiveAppartmentPrices(): ?array /** * Returns a list of conflicting prices. * - * @return Doctrine\Common\Collections\ArrayCollection */ - public function findConflictingPrices(Price $price) + public function findConflictingPrices(Price $price): ArrayCollection { $prices = []; // find conflicts when no season is given @@ -496,6 +498,161 @@ public function getPricesForReservationDays(Reservation $reservation, int $type, return $result; } + /** + * Builds a per-night PriceBreakdown for the apartment price (type=2), + * applying GuestCategoryModifiers per non-ADULT category from + * Reservation.guestCounts. ADULT counts use the unmodified base price. + * + * @return PriceBreakdown[] keyed by day index (0..nights-1) + */ + public function getPriceBreakdownForReservation(Reservation $reservation): array + { + $pricesPerDay = $this->getPricesForReservationDays($reservation, 2); + $days = $this->getDateDiff($reservation->getStartDate(), $reservation->getEndDate()); + $guestCounts = $reservation->getGuestCounts(); + + $categories = []; + if (null !== $this->guestCategoryRepository) { + foreach ($this->guestCategoryRepository->findAll() as $gc) { + $categories[$gc->getId()] = $gc; + } + } + + $result = []; + $curDate = clone $reservation->getStartDate(); + for ($i = 0; $i < $days; ++$i) { + $night = (clone $curDate)->add(new \DateInterval('P'.$i.'D')); + $price = $pricesPerDay[$i][0] ?? null; + $breakdown = new PriceBreakdown($night, $price); + + if (null === $price || empty($guestCounts)) { + $result[$i] = $breakdown; + continue; + } + + $basePerHead = (float) $price->getPrice(); + $modifiers = null !== $this->modifierRepository + ? $this->modifierRepository->findActiveOn($night) + : []; + + foreach ($guestCounts as $catId => $count) { + $count = (int) $count; + if ($count <= 0) { + continue; + } + $category = $categories[$catId] ?? null; + if (null === $category) { + continue; + } + + $modifier = $this->pickModifier($modifiers, $category); + $unit = $this->applyModifier($basePerHead, $modifier, $category); + $breakdown->addLine(new PriceBreakdownLine($category, $count, $unit, $modifier)); + } + + $result[$i] = $breakdown; + } + + return $result; + } + + /** + * Apartment total per night (after modifier deltas), expressed in the + * requested net/gross flavor. Used as the bemessungsgrundlage for + * percentage-based tourist taxes (Dresden/Berlin city tax models). + * + * Per-head pricing: sum(count × unit_with_modifier) per night. + * Flat-price / per-room: basePrice once per night (occupancy-independent). + * + * @return array keyed by Y-m-d, value = apartment total in the requested base + */ + public function getApartmentTotalsPerNight(Reservation $reservation, PercentageBase $target): array + { + $breakdowns = $this->getPriceBreakdownForReservation($reservation); + $result = []; + foreach ($breakdowns as $breakdown) { + $price = $breakdown->basePrice; + if (null === $price) { + continue; + } + if ($price->getIsFlatPrice() || $price->getIsPerRoom()) { + $stored = (float) $price->getPrice(); + } else { + // Mirror the apartment invoice row: numberOfPersons × per-head, + // then apply modifier deltas (only for occupancy-counted, non-adult + // categories — same rule as buildApartmentModifierPositions). This + // makes the city-tax base match the effective apartment total + // visible on the invoice (room row + modifier row). + $perHead = (float) $price->getPrice(); + $numPersons = (int) $price->getNumberOfPersons(); + $stored = $perHead * $numPersons; + foreach ($breakdown->lines as $line) { + if (null === $line->modifier) { + continue; + } + if (!$line->category->isCountedInOccupancy()) { + continue; + } + if ($line->category->isAdult()) { + continue; + } + $delta = ($line->unitPrice - $perHead) * $line->count; + $stored += $delta; + } + } + $vat = null !== $price->getVat() ? (float) $price->getVat() : 0.0; + $storedIncludesVat = (bool) $price->getIncludesVat(); + + $value = $this->convertNetGross($stored, $vat, $storedIncludesVat, $target); + $result[$breakdown->night->format('Y-m-d')] = $value; + } + + return $result; + } + + private function convertNetGross(float $stored, float $vatPercent, bool $storedIncludesVat, PercentageBase $target): float + { + if ($vatPercent <= 0.0) { + return $stored; + } + $factor = 1.0 + $vatPercent / 100.0; + if ($target === PercentageBase::NET) { + return $storedIncludesVat ? $stored / $factor : $stored; + } + + return $storedIncludesVat ? $stored : $stored * $factor; + } + + /** + * @param GuestCategoryModifier[] $modifiers + */ + private function pickModifier(array $modifiers, GuestCategory $category): ?GuestCategoryModifier + { + foreach ($modifiers as $mod) { + if ($mod->getCategory()?->getId() === $category->getId()) { + return $mod; + } + } + + return null; + } + + private function applyModifier(float $base, ?GuestCategoryModifier $modifier, GuestCategory $category): float + { + // ADULT category never uses a modifier — it is the base reference. + if (null === $modifier || $category->isAdult()) { + return $base; + } + $value = $modifier->getValueAsFloat(); + + return match ($modifier->getType()) { + ModifierType::SURCHARGE_ABSOLUTE => $base + $value, + ModifierType::DISCOUNT_PERCENT => max(0.0, $base * (1.0 - $value / 100.0)), + ModifierType::FLAT_RATE => $value, + ModifierType::FREE => 0.0, + }; + } + /** * Will look for uniqe prices that are valid for the given reservations. * 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..5fb1b907 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.(strlen($row->categoryName) > 0 ? ' — ' . $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, ]; } @@ -846,14 +978,18 @@ private function calculateExtrasTotal(array $extras, array $selectedExtras): arr */ private function resolveAndValidateExtras(array $selectedExtras, array $availability, \DateTimeImmutable $dateFrom, \DateTimeImmutable $dateTo, int $persons, int $roomsCount): array { - if ([] === $selectedExtras) { - return []; - } - $extras = $this->loadBookableExtras($availability, $dateFrom, $dateTo, $persons, $roomsCount); $extrasById = []; foreach ($extras as $extra) { $extrasById[$extra['id']] = $extra; + // Pflicht-Posten serverseitig erzwingen — egal was im POST stand. + if (!empty($extra['isMandatory'])) { + $selectedExtras[$extra['id']] = max(1, (int) ($selectedExtras[$extra['id']] ?? 0)); + } + } + + if ([] === $selectedExtras) { + return []; } $result = []; diff --git a/src/Service/PublicPricingService.php b/src/Service/PublicPricingService.php index a1abe366..4bdf9ac9 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; @@ -87,7 +121,7 @@ public function getOccupancyPrices( /** * Retrieve all bookable-online extras with calculated total prices for the given stay. * - * @return array + * @return array */ public function getBookableExtras( Appartment $sampleRoom, @@ -167,6 +201,7 @@ public function getBookableExtras( 'pricePerUnit' => $pricePerUnit, 'pricePerUnitFormatted' => number_format($pricePerUnit, 2, ',', '.'), 'maxQuantity' => $maxQuantity, + 'isMandatory' => $price->getIsMandatoryOnline(), ]; } 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 e21ca9df..a5f4c6a0 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(); @@ -231,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); @@ -289,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); @@ -306,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/RoomCategoryImageService.php b/src/Service/RoomCategoryImageService.php index c8a0dc9f..5ef50611 100644 --- a/src/Service/RoomCategoryImageService.php +++ b/src/Service/RoomCategoryImageService.php @@ -163,10 +163,10 @@ public function getPublicUrl(RoomCategoryImage $image, string $variant = 'medium */ private function isValidImage(UploadedFile $file): bool { - $constraint = new Assert\Image([ - 'maxSize' => '10m', - 'mimeTypes' => ['image/jpeg', 'image/png', 'image/webp'], - ]); + $constraint = new Assert\Image( + maxSize: '10m', + mimeTypes: ['image/jpeg', 'image/png', 'image/webp'], + ); return 0 === $this->validator->validate($file, $constraint)->count(); } 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/Service/TemplatePreview/InvoiceTemplatePreviewProvider.php b/src/Service/TemplatePreview/InvoiceTemplatePreviewProvider.php index f78895f3..2d104523 100644 --- a/src/Service/TemplatePreview/InvoiceTemplatePreviewProvider.php +++ b/src/Service/TemplatePreview/InvoiceTemplatePreviewProvider.php @@ -228,7 +228,21 @@ public function getAvailableSnippets(): array 'label' => 'templates.editor.misc.positions', 'group' => 'Invoice', 'complexity' => 'easy', - 'content' => "\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n
{{ 'invoice.position.additional'|trans }}{{ 'invoice.position.amount'|trans }}{{ 'invoice.price.single'|trans }}{{ 'invoice.vat'|trans }}{{ 'invoice.price.total'|trans }}
[[ position.description ]][[ position.amount ]][[ position.priceFormated ]] €[[ position.vat ]][[ position.totalPrice ]] €
[[ miscTotal ]] €
", + 'content' => "\n \n \n \n \n \n \n \n \n p.positionGroup != 'tourist_tax' and p.positionGroup != 'apartment_modifier')\" data-repeat-as=\"position\">\n \n \n \n \n \n \n \n
{{ 'invoice.position.additional'|trans }}{{ 'invoice.position.amount'|trans }}{{ 'invoice.price.single'|trans }}{{ 'invoice.vat'|trans }}{{ 'invoice.price.total'|trans }}
[[ position.description ]][[ position.amount ]][[ position.priceFormated ]] €[[ position.vat ]][[ position.totalPrice ]] €
", + ], + [ + 'id' => 'invoice.apartment_modifier.positions', + 'label' => 'templates.editor.apartment_modifier.positions', + 'group' => 'Invoice', + 'complexity' => 'easy', + 'content' => " p.positionGroup == 'apartment_modifier')|length > 0\">\n \n \n \n \n \n \n \n \n p.positionGroup == 'apartment_modifier')\" data-repeat-as=\"position\">\n \n \n \n \n \n \n \n
{{ '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.priceFormated ]] €[[ position.vat ]][[ position.totalPrice ]] €
", + ], + [ + 'id' => 'invoice.tourist_tax.positions', + 'label' => 'templates.editor.tourist_tax.positions', + 'group' => 'Invoice', + 'complexity' => 'easy', + 'content' => " p.positionGroup == 'tourist_tax')|length > 0\">\n \n \n \n \n \n \n \n \n p.positionGroup == 'tourist_tax')\" data-repeat-as=\"position\">\n \n \n \n \n \n \n \n
{{ '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.priceFormated ]] €[[ position.vat ]][[ position.totalPrice ]] €
", ], [ 'id' => 'pdf.header', diff --git a/src/Service/TemplatesService.php b/src/Service/TemplatesService.php index cad8f379..6b71fbfa 100644 --- a/src/Service/TemplatesService.php +++ b/src/Service/TemplatesService.php @@ -870,7 +870,7 @@ private function extractAttributeValue(string $attributes, string $attributeName return null; } - $value = trim($matches[2]); + $value = trim(html_entity_decode($matches[2], ENT_QUOTES | ENT_HTML5, 'UTF-8')); return '' === $value ? null : $value; } diff --git a/src/Service/TouristTaxService.php b/src/Service/TouristTaxService.php new file mode 100644 index 00000000..5ff8d4fc --- /dev/null +++ b/src/Service/TouristTaxService.php @@ -0,0 +1,193 @@ +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')); + + $lastNightDate = (clone $start)->modify('+'.($totalNights - 1).' days'); + $subsidiary = $reservation->getAppartment()?->getObject(); + $taxes = $this->touristTaxRepository->findActiveForSubsidiaryInRange($subsidiary, $start, $lastNightDate); + if (empty($taxes)) { + return []; + } + + $result = []; + foreach ($taxes as $tax) { + $rows = match ($tax->getCalculationMode()) { + TaxCalculationMode::PER_NIGHT_FLAT => $this->calculateFlatPerNight($tax, $reservation, $start, $totalNights), + TaxCalculationMode::PERCENT_PER_ROOM => $this->calculatePercentPerRoom($tax, $reservation, $start, $totalNights), + }; + foreach ($rows as $row) { + $result[] = $row; + } + } + + return $result; + } + + public function hasActiveTaxForSubsidiary(?Subsidiary $subsidiary): bool + { + return $this->touristTaxRepository->hasActiveForSubsidiary($subsidiary); + } + + /** + * @return TouristTaxBreakdown[] + */ + private function calculateFlatPerNight(TouristTax $tax, Reservation $reservation, \DateTimeInterface $start, int $totalNights): array + { + $guestCounts = $reservation->getGuestCounts(); + if (empty($guestCounts)) { + return []; + } + + $categories = []; + foreach ($this->guestCategoryRepository->findAll() as $gc) { + $categories[$gc->getId()] = $gc; + } + + $aggregates = []; + for ($i = 0; $i < $totalNights; ++$i) { + $night = (clone $start)->modify('+'.$i.' days'); + 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 = $catId; + if (!isset($aggregates[$key])) { + $aggregates[$key] = ['rate' => $rate, 'count' => $count, 'nights' => 0]; + } + ++$aggregates[$key]['nights']; + } + } + + $rows = []; + foreach ($aggregates as $a) { + $rows[] = $this->makeFlatBreakdown($tax, $a['rate'], $a['nights'], $a['count']); + } + + return $rows; + } + + /** + * @return TouristTaxBreakdown[] + */ + private function calculatePercentPerRoom(TouristTax $tax, Reservation $reservation, \DateTimeInterface $start, int $totalNights): array + { + $percent = $tax->getPercentageRateFloat(); + $base = $tax->getPercentageBase(); + if (null === $percent || $percent <= 0.0 || null === $base || null === $this->priceService) { + return []; + } + + $apartmentTotals = $this->priceService->getApartmentTotalsPerNight($reservation, $base); + + $coveredNights = 0; + $apartmentSum = 0.0; + for ($i = 0; $i < $totalNights; ++$i) { + $night = (clone $start)->modify('+'.$i.' days'); + if (!$tax->isValidOn($night)) { + continue; + } + $key = $night->format('Y-m-d'); + if (!isset($apartmentTotals[$key])) { + continue; + } + $apartmentSum += $apartmentTotals[$key]; + ++$coveredNights; + } + + if ($coveredNights === 0 || $apartmentSum <= 0.0) { + return []; + } + + $total = $apartmentSum * $percent / 100.0; + + return [ + new TouristTaxBreakdown( + taxId: (int) $tax->getId(), + taxName: $tax->getName(), + categoryId: 0, + categoryName: '', + pricePerNight: 0.0, + nights: $coveredNights, + count: 1, + reportGroup: null, + taxRate: $tax->getTaxRate(), + revenueAccount: $tax->getRevenueAccount(), + includesVat: $tax->isIncludesVat(), + calculationMode: TaxCalculationMode::PERCENT_PER_ROOM, + percentageRate: $percent, + apartmentBase: round($apartmentSum, 2), + precomputedTotal: round($total, 2), + ), + ]; + } + + private function makeFlatBreakdown(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(), + calculationMode: TaxCalculationMode::PER_NIGHT_FLAT, + ); + } +} 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/_profiles_table.html.twig b/templates/BookingJournal/BankImport/_profiles_table.html.twig new file mode 100644 index 00000000..61d72a21 --- /dev/null +++ b/templates/BookingJournal/BankImport/_profiles_table.html.twig @@ -0,0 +1,61 @@ + + +
+
+ + + + + + + + + + + + + {% 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 }} +
+
+
diff --git a/templates/BookingJournal/BankImport/_rule_modal.html.twig b/templates/BookingJournal/BankImport/_rule_modal.html.twig new file mode 100644 index 00000000..986a32de --- /dev/null +++ b/templates/BookingJournal/BankImport/_rule_modal.html.twig @@ -0,0 +1,260 @@ +{# 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..143f0bf3 --- /dev/null +++ b/templates/BookingJournal/BankImport/_rule_summary.html.twig @@ -0,0 +1,112 @@ +{# 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.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, ',', '.') }}% + {% 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 %} + {% set invoiceExtraction = action.invoiceNumberExtraction|default({'mode': 'none'}) %} + {% if invoiceExtraction.mode|default('none') == 'marker' %} +
+ {{ 'accounting.bank_import.rule.invoice_extraction.label'|trans }}: + {{ 'accounting.bank_import.rule.invoice_extraction.mode.marker'|trans }} "{{ invoiceExtraction.marker|default('') }}" +
+ {% elseif invoiceExtraction.mode|default('none') == 'regex' %} +
+ {{ 'accounting.bank_import.rule.invoice_extraction.label'|trans }}: + {{ invoiceExtraction.pattern|default('') }} +
+ {% endif %} +{% endmacro %} diff --git a/templates/BookingJournal/BankImport/_rules_table.html.twig b/templates/BookingJournal/BankImport/_rules_table.html.twig new file mode 100644 index 00000000..cfa0d3dc --- /dev/null +++ b/templates/BookingJournal/BankImport/_rules_table.html.twig @@ -0,0 +1,74 @@ +{% import 'BookingJournal/BankImport/_rule_summary.html.twig' as ruleUi %} + +
+
+ + + + + + + + + + + + + {% 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 }} +

diff --git a/templates/BookingJournal/BankImport/_split_modal.html.twig b/templates/BookingJournal/BankImport/_split_modal.html.twig new file mode 100644 index 00000000..22b3571f --- /dev/null +++ b/templates/BookingJournal/BankImport/_split_modal.html.twig @@ -0,0 +1,115 @@ +{# 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..77076731 --- /dev/null +++ b/templates/BookingJournal/BankImport/_status_badge.html.twig @@ -0,0 +1,27 @@ +{# Renders the leading status badge for one statement line. #} +{% if line.isDuplicate and line.forceImportDuplicate|default(false) %} + + + +{% elseif 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..24837250 --- /dev/null +++ b/templates/BookingJournal/BankImport/index.html.twig @@ -0,0 +1,116 @@ +{% 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.format) }}
+
+ {{ 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..fe21d33c --- /dev/null +++ b/templates/BookingJournal/BankImport/preview.html.twig @@ -0,0 +1,565 @@ +{% 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 %} +

+
+
+
+ + +
+ + {{ 'accounting.bank_import.rules.title'|trans }} + + {% 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 forceDuplicate = line.forceImportDuplicate|default(false) %} + {% set readonly = line.isDuplicate and not forceDuplicate %} + {% set invoiceManaged = line.matchedInvoiceId and line.matchedInvoiceAmountMatches and line.splits is empty and not (line.userInvoiceNumber|default(null)) %} + + + + {# Status badges (re-rendered by JS when status changes) #} + + + + + + + + + + + + + {# Debit account #} + + + {# Credit account #} + + + {# Tax rate #} + + + {# Remark + actions #} + + + {% else %} + + + + {% endfor %} + +
+ + + + + + + + + + + + + + + + + +
+ + + {% 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, ',', '.') }} {{ currency_symbol }} + + {% set displayedInvoiceNumber = line.userInvoiceNumber ?? line.matchedInvoiceNumber ?? '' %} + + + + + {% if invoiceManaged %} +
+ {{ 'accounting.bank_import.preview.invoice_accounts.label'|trans }} +
+ {% else %} + + {% endif %} +
+ {% if invoiceManaged %} +
+ {{ 'accounting.bank_import.preview.invoice_tax_rate.label'|trans }} +
+ {% else %} + + {% endif %} +
+
+ {% if invoiceManaged %} +
+ {{ 'accounting.bank_import.preview.invoice_remark.label'|trans }} +
+ {% else %} + + {% endif %} + {% if readonly %} +
+ + +
+ {% 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..c4d5aac4 --- /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': '5rem'}) }} + {{ ui.field(form.col_amount, {"width": "5rem"}) }} + +
+ {{ 'accounting.bank_import.profile.col.group.separate'|trans }} +
+ {{ 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": "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": "5rem"}) }} + {{ ui.field(form.col_mandateReference, {"width": "5rem"}) }} + {{ ui.field(form.col_creditorId, {"width": "5rem"}) }} +
+
+ +
+ + {{ '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";…
+      ↑A          ↑B           ↑C        ↑D      ↑E                ↑F                   ↑G       ↑H       ↑I
+
+ +
+
+ + {{ '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 = A, col_valueDate = B +
+ +
+ + {{ 'accounting.bank_import.profile.example.context'|trans }} +
+
+ col_counterpartyName = E, col_purpose = F,
+ col_counterpartyIban = H +
+ +
+ + {{ 'accounting.bank_import.profile.example.amount'|trans }} +
+
+ col_amount = I ({{ '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/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/settings.html.twig b/templates/BookingJournal/BankImport/settings.html.twig new file mode 100644 index 00000000..4f436a53 --- /dev/null +++ b/templates/BookingJournal/BankImport/settings.html.twig @@ -0,0 +1,94 @@ +{% extends 'base.html.twig' %} + +{% block title %} + {{ parent() }} - {{ 'accounting.bank_import.settings.title'|trans }} +{% endblock %} + +{% block flashMessage %} + {% include 'BookingJournal/_flash_messages.html.twig' %} +{% endblock %} + +{% block content %} +{% set activeTab = activeTab|default('tab-rules') %} +
+ + +
+
+

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

+

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

+
+
+ + {% include 'feedback.html.twig' %} + + {# ── Tab navigation ────────────────────────────────────────── #} + + +
+ {# ── Tab: Regeln ──────────────────────────────────────── #} +
+ {% include 'BookingJournal/BankImport/_rules_table.html.twig' %} +
+ + {# ── Tab: CSV-Profile ─────────────────────────────────── #} +
+ {% include 'BookingJournal/BankImport/_profiles_table.html.twig' %} +
+ + {# ── Tab: Rechnungsnummern erkennen ───────────────────── #} +
+
+
+
{{ 'accounting.settings.invoice_number_samples.title'|trans }}
+ + + +
+
+

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

+ + {{ form_start(form) }} +
+
{{ form_row(form.invoiceNumberSample1) }}
+
{{ form_row(form.invoiceNumberSample2) }}
+
{{ form_row(form.invoiceNumberSample3) }}
+
+ +
+ +
+ {{ form_end(form) }} +
+
+
+
+ +
+{% 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..2e614b45 100644 --- a/templates/BookingJournal/entries.html.twig +++ b/templates/BookingJournal/entries.html.twig @@ -37,8 +37,36 @@
-
-

{{ 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 preset in presets %} - - {% endfor %} - -
-
- -
-
-
- - -
{{ 'accounting.settings.preset.seed_workflows_help'|trans }}
-
- {% if settings.chartPreset %} -

- {{ 'accounting.settings.preset.current'|trans({'%preset%': settings.chartPreset|upper}) }} -

- {% endif %} - -
- + {% set activeTab = activeTab|default('tab-preset') %} - {# ── Accounts ──────────────────────────────────────────────── #} -
-
-
-
{{ 'accounting.accounts.title'|trans }}
- - - -
- -
-
-
- - - - - - - - - - - - {% for account in accounts %} - - - - - - - - {% else %} - - {% endfor %} - -
{{ 'accounting.accounts.number'|trans }}{{ 'accounting.accounts.name'|trans }}{{ 'accounting.accounts.type'|trans }}{{ 'accounting.accounts.system_role'|trans }}{{ 'journal.action'|trans }}
{{ account.accountNumber }}{{ account.name }}{{ ('accounting.accounts.type.' ~ account.type)|trans }} - {% if account.cashAccount %} - {{ 'accounting.accounts.cash'|trans }} - {% endif %} - {% if account.bankAccount %} - {{ 'accounting.accounts.bank'|trans }} - {% endif %} - {% if account.openingBalanceAccount %} - {{ 'accounting.accounts.opening_balance'|trans }} - {% endif %} - {% if account.autoAccount %} - {{ 'accounting.accounts.is_auto.badge'|trans }} - {% endif %} - - + + {% endfor %} + + + + +
+ {# ── Tab: Kontenrahmen / Preset ──────────────────────── #} +
+
+
+
{{ 'accounting.settings.preset.title'|trans }}
+ + + +
+
+
+
+
+ + +
+
+ - {% if not account.systemDefault %} - {% set id = account.id %} - {% set targetUrl = path('journal.settings.accounts.delete', {'id': account.id}) %} - {% use "common/delete_popover.html.twig" %} - - {% else %} - - - - {% endif %} -
{{ 'accounting.accounts.empty'|trans }}
+
+
+
+ + +
{{ 'accounting.settings.preset.seed_workflows_help'|trans }}
+
+ {% if settings.chartPreset %} +

+ {{ 'accounting.settings.preset.current'|trans({'%preset%': settings.chartPreset|upper}) }} +

+ {% endif %} + +
- - {# ── Tax Rates ─────────────────────────────────────────────── #} -
-
-
-
{{ 'accounting.taxrates.title'|trans }}
- - - + {# ── Tab: Konten ──────────────────────────────────────── #} +
+
+
+
+
{{ 'accounting.accounts.title'|trans }}
+ + + +
+ +
+
+
+ + + + + + + + + + + + {% for account in accounts %} + + + + + + + + {% else %} + + {% endfor %} + +
{{ 'accounting.accounts.number'|trans }}{{ 'accounting.accounts.name'|trans }}{{ 'accounting.accounts.type'|trans }}{{ 'accounting.accounts.system_role'|trans }}{{ 'journal.action'|trans }}
{{ account.accountNumber }}{{ account.name }}{{ ('accounting.accounts.type.' ~ account.type)|trans }} + {% if account.cashAccount %} + {{ 'accounting.accounts.cash'|trans }} + {% endif %} + {% if account.bankAccount %} + {{ 'accounting.accounts.bank'|trans }} + {% endif %} + {% if account.openingBalanceAccount %} + {{ 'accounting.accounts.opening_balance'|trans }} + {% endif %} + {% if account.autoAccount %} + {{ 'accounting.accounts.is_auto.badge'|trans }} + {% endif %} + + + {% if not account.systemDefault %} + {% set id = account.id %} + {% set targetUrl = path('journal.settings.accounts.delete', {'id': account.id}) %} + {% use "common/delete_popover.html.twig" %} + + {% else %} + + + + {% endif %} +
{{ 'accounting.accounts.empty'|trans }}
+
+
-
-
-
- - - - - - - - - - - - - {% for rate in taxRates %} - - - - - - - - - {% else %} - - {% endfor %} - -
{{ 'accounting.taxrates.name_header'|trans }}{{ 'accounting.taxrates.rate'|trans }}{{ 'accounting.taxrates.revenue_account'|trans }}{{ 'accounting.taxrates.bukey_short'|trans }}{{ 'accounting.taxrates.valid'|trans }}{{ 'journal.action'|trans }}
- {{ rate.name }} - {% if rate.default %}{{ 'accounting.taxrates.default'|trans }}{% endif %} - {{ rate.rate|number_format(2, ',', '.') }}% - {% if rate.revenueAccount %} - {{ rate.revenueAccount.accountNumber }} – {{ rate.revenueAccount.name }} - {% else %} - - {% endif %} - - {% set o = rate.datevOutputBuKey %} - {% set i = rate.datevInputBuKey %} - {% if o or i %} - {{ 'accounting.taxrates.bukey.output_abbr'|trans }} {{ o ?: '–' }} - / - {{ 'accounting.taxrates.bukey.input_abbr'|trans }} {{ i ?: '–' }} - {% else %} - – - {% endif %} - - {% if rate.validFrom or rate.validTo %} - {{ rate.validFrom ? rate.validFrom|date('d.m.Y') : '...' }} - – - {{ rate.validTo ? rate.validTo|date('d.m.Y') : '...' }} - {% else %} - {{ 'accounting.taxrates.always_valid'|trans }} - {% endif %} - - - {% set id = rate.id %} - {% set targetUrl = path('journal.settings.taxrates.delete', {'id': rate.id}) %} - {% use "common/delete_popover.html.twig" %} - -
{{ 'accounting.taxrates.empty'|trans }}
+ + {# ── Tab: Steuersätze ─────────────────────────────────── #} +
+
+
+
+
{{ 'accounting.taxrates.title'|trans }}
+ + + +
+ +
+
+
+ + + + + + + + + + + + + {% for rate in taxRates %} + + + + + + + + + {% else %} + + {% endfor %} + +
{{ 'accounting.taxrates.name_header'|trans }}{{ 'accounting.taxrates.rate'|trans }}{{ 'accounting.taxrates.revenue_account'|trans }}{{ 'accounting.taxrates.bukey_short'|trans }}{{ 'accounting.taxrates.valid'|trans }}{{ 'journal.action'|trans }}
+ {{ rate.name }} + {% if rate.default %}{{ 'accounting.taxrates.default'|trans }}{% endif %} + {{ rate.rate|number_format(2, ',', '.') }}% + {% if rate.revenueAccount %} + {{ rate.revenueAccount.accountNumber }} – {{ rate.revenueAccount.name }} + {% else %} + + {% endif %} + + {% set o = rate.datevOutputBuKey %} + {% set i = rate.datevInputBuKey %} + {% if o or i %} + {{ 'accounting.taxrates.bukey.output_abbr'|trans }} {{ o ?: '–' }} + / + {{ 'accounting.taxrates.bukey.input_abbr'|trans }} {{ i ?: '–' }} + {% else %} + – + {% endif %} + + {% if rate.validFrom or rate.validTo %} + {{ rate.validFrom ? rate.validFrom|date('d.m.Y') : '...' }} + – + {{ rate.validTo ? rate.validTo|date('d.m.Y') : '...' }} + {% else %} + {{ 'accounting.taxrates.always_valid'|trans }} + {% endif %} + + + {% set id = rate.id %} + {% set targetUrl = path('journal.settings.taxrates.delete', {'id': rate.id}) %} + {% use "common/delete_popover.html.twig" %} + +
{{ 'accounting.taxrates.empty'|trans }}
+
+
-
- {# ── DATEV Export Settings ─────────────────────────────────── #} -
-
-
{{ 'accounting.settings.datev.title'|trans }}
- - - + {# ── Settings form spans DATEV + Buchungstext tabs ────── #} + {{ form_start(settingsForm) }} + + {# ── Tab: DATEV-Export ────────────────────────────────── #} +
+
+
+
{{ 'accounting.settings.datev.title'|trans }}
+ + + +
+
+ {{ form_row(settingsForm.advisorNumber) }} + {{ form_row(settingsForm.clientNumber) }} + {{ form_row(settingsForm.fiscalYearStart) }} + {{ form_row(settingsForm.accountNumberLength) }} + {{ form_row(settingsForm.dictationCode) }} + +
+ +
+
+
-
- {{ form_start(settingsForm) }} - {{ form_row(settingsForm.advisorNumber) }} - {{ form_row(settingsForm.clientNumber) }} - {{ form_row(settingsForm.fiscalYearStart) }} - {{ form_row(settingsForm.accountNumberLength) }} - {{ form_row(settingsForm.dictationCode) }} -
-
- {{ 'accounting.settings.remark_labels.title'|trans }} - +
+
+
{{ 'accounting.settings.remark_labels.title'|trans }}
+ -
- {{ form_row(settingsForm.mainPositionLabel) }} - {{ form_row(settingsForm.miscPositionLabel) }} +
+
+

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

+ {{ form_row(settingsForm.mainPositionLabel) }} + {{ form_row(settingsForm.miscPositionLabel) }} -
- +
+ +
- {{ form_end(settingsForm) }} +
+ + {{ form_end(settingsForm) }}
{# ── Offcanvas for forms ──────────────────────────────────── #} @@ -283,5 +340,6 @@
+
{% endblock %} 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..3f8af82b --- /dev/null +++ b/templates/GuestCategory/_guest_counts_table.html.twig @@ -0,0 +1,98 @@ +{# + Reusable partial: per-category guest count steppers 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 + maxOccupancy optional int — hard cap for the sum of categories with + isCountedInOccupancy=true (typically appartment.bedsmax) +#} +{% set fieldNames = fieldNames|default({ + counts: 'guestCounts', + persons: 'persons', + override: 'adultRuleOverride', +}) %} + +
+
    + {% for category in categories %} +
  • +
    + {{ category.name }} + {#{{ category.acronym }}#} + {% if not category.isCountedInOccupancy %} + · {{ 'guest_category.flag.no_occupancy'|trans }} + {% endif %} +
    +
    + + + +
    +
  • + {% endfor %} +
+ +
+ + {{ '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 }} +
+
+ + +
+
+ + + +
diff --git a/templates/GuestCategory/_modifier_form.html.twig b/templates/GuestCategory/_modifier_form.html.twig new file mode 100644 index 00000000..02661ca4 --- /dev/null +++ b/templates/GuestCategory/_modifier_form.html.twig @@ -0,0 +1,3 @@ +
+ {{ form_widget(form) }} +
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..932829a9 --- /dev/null +++ b/templates/GuestCategory/index.html.twig @@ -0,0 +1,169 @@ +{% 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 }} + {% if not category.isCountedInOccupancy %} + + {{ 'guest_category.not_in_room_price'|trans }} + + {% endif %} + {{ 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 %} + + + + + + + {% if category.systemCode != 'default_adult'%} + {% set id = category.id %} + {% set targetUrl = path('guest_category_delete', {'id': category.id}) %} + {% use "common/delete_popover.html.twig" %} + + + {% else %} + + + + {% endif %} +
+
+
+ +
+
+

{{ 'guest_category_modifier.title'|trans }}

+

{{ 'guest_category_modifier.description'|trans }}

+ +
+
+
+
+ + + + + + + + + + + + + {% for modifier in modifiers %} + + + + + + + + + {% else %} + + {% endfor %} + +
{{ 'guest_category_modifier.col.category'|trans }}{{ 'guest_category_modifier.col.type'|trans }}{{ 'guest_category_modifier.col.value'|trans }}{{ 'guest_category_modifier.col.validity'|trans }}{{ 'guest_category_modifier.col.active'|trans }}{{ 'guest_category_modifier.col.action'|trans }}
{{ modifier.category.name }}{{ ('guest_category_modifier.type.' ~ modifier.type.value)|trans }}{{ modifier.value }} + {{ modifier.validFrom is null ? '–' : modifier.validFrom|date('Y-m-d') }} + – + {{ modifier.validTo is null ? '∞' : modifier.validTo|date('Y-m-d') }} + + {% if modifier.active %} + + {% else %} + + {% endif %} + + + + {% set id = modifier.id %} + {% set formId = 'mod-' ~ modifier.id %} + {% set targetUrl = path('guest_category_modifier_delete', {'id': modifier.id}) %} + {% use "common/delete_popover.html.twig" %} + + +
{{ 'guest_category_modifier.empty'|trans }}
+
+
+
+{% 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/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/Invoices/invoice_form_show.html.twig b/templates/Invoices/invoice_form_show.html.twig index 24088e69..28f3392b 100644 --- a/templates/Invoices/invoice_form_show.html.twig +++ b/templates/Invoices/invoice_form_show.html.twig @@ -105,6 +105,88 @@ + {% 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 %} + + + + + + + + + + + + + {% 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 +199,8 @@ - {% for position in invoice.positions %} + {% set miscOnlyTotal = 0 %} + {% for position in invoice.positions|filter(p => p.positionGroup != 'tourist_tax' and p.positionGroup != 'apartment_modifier') %} @@ -143,11 +226,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..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,24 @@
+ {% 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 %} +
+
+

{{ '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..8be775a9 100644 --- a/templates/Invoices/invoice_show_preview.html.twig +++ b/templates/Invoices/invoice_show_preview.html.twig @@ -30,7 +30,13 @@

{% 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' %} +
+
{% include 'Invoices/invoice_table_misc_preview_positions.html.twig' with {'mode': 'edit'} %}
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 %} + + + + + + + + {% 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 d55834cb..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 %} + {% for key,position in positionsMiscellaneous|filter(p => p.positionGroup != 'tourist_tax' and p.positionGroup != 'apartment_modifier') %} {{ 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..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,9 @@ - {% for position in positionsMiscellaneous %} + {% set miscOnly = positionsMiscellaneous|filter(p => p.positionGroup != 'tourist_tax' and p.positionGroup != 'apartment_modifier') %} + {% 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/Prices/price_form_input_fields.html.twig b/templates/Prices/price_form_input_fields.html.twig index 654ee01d..2d3978ef 100644 --- a/templates/Prices/price_form_input_fields.html.twig +++ b/templates/Prices/price_form_input_fields.html.twig @@ -79,53 +79,58 @@
-
-
-
-
- - + {% set isMiscPriceType = price.type != 2 %} + {% set calcType = price.isFlatPrice ? 'flat' : (price.isPerRoom ? 'per_room' : 'per_person') %} +
+ +
+
+ +
-
-
-
-
-
-
- - +
+ +
-
-
-
-
-
-
- - +
+ +
- {% set isMiscPriceType = price.type != 2 %} -
-
-
+
+
+ {{ 'price.visibility.label'|trans }} +
+
- + + +
+
+
-
-
-
-
-
-
- +
+
+
+ + +
{{ 'price.mandatoryonline.hint'|trans }}
+
@@ -358,6 +363,13 @@ {{ 'price.package.taxrates.empty'|trans }}
{% endif %} + {% if is_granted('ROLE_CASHJOURNAL') %} + + {% endif %}
diff --git a/templates/PublicBooking/book.html.twig b/templates/PublicBooking/book.html.twig index bafd848a..1da40457 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 %}
@@ -158,6 +235,12 @@
{% for i in 1..option.persons %}{% endfor %} + {% if mixOccupancyTotal|default(0) == option.persons and nonOccupancyIcons|default([])|length > 0 %} + + + + {% for ico in nonOccupancyIcons %}{% endfor %} + + {% endif %} {{ option.totalPriceFormatted }} {{ currency_symbol }}
@@ -178,7 +261,8 @@
{% for extra in extras %} {% set extraFieldName = 'extra_' ~ extra.id %} - {% set selectedQtyVal = selectedExtras[extra.id]|default(0) %} + {% set isMandatoryExtra = extra.isMandatory|default(false) %} + {% set selectedQtyVal = selectedExtras[extra.id]|default(isMandatoryExtra ? 1 : 0) %} {% set needsQuantity = extra.calculationType != 'per_person_night' and extra.maxQuantity > 1 %}
@@ -197,11 +281,19 @@
+ {% if isMandatoryExtra %} + {# Hidden input überträgt den Wert auch bei disabled UI-Element. #} + + {% endif %} {% if needsQuantity %}
- 0 %}checked{% endif %}> + id="{{ extraFieldName }}" + {% if not isMandatoryExtra %}name="{{ extraFieldName }}"{% endif %} + value="1" + {% if selectedQtyVal > 0 or isMandatoryExtra %}checked{% endif %} + {% if isMandatoryExtra %}disabled{% endif %}>
{% endif %}
@@ -392,15 +487,36 @@
{% endif %} - {% if grandTotalFormatted and extrasBreakdown is not empty %} + {# Tourist tax breakdown — eigene Rubrik #} + {% 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 +585,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