diff --git a/apps/decabill/backend-billing-manager/.start-containers.env.example b/apps/decabill/backend-billing-manager/.start-containers.env.example index e89df0eba..16374f5da 100644 --- a/apps/decabill/backend-billing-manager/.start-containers.env.example +++ b/apps/decabill/backend-billing-manager/.start-containers.env.example @@ -57,6 +57,14 @@ BILLING_TAX_RATE_REDUCED=7 BILLING_OSS_REGISTERED=false BILLING_OSS_THRESHOLD_EUR=10000 BILLING_NON_EU_ISSUER_EU_B2B_CHARGE_VAT=false +# Canonical file root (scopes: customer/*, supplier/*, export/datev) +FILE_STORAGE_ROOT=/data +FILE_STORAGE_PROVIDER=local +FILE_STORAGE_LAYOUT_MIGRATION_ENABLED=true +FILE_STORAGE_LAYOUT_DUAL_READ_ENABLED=true +# Global HMAC secret for billing_stored_files signatures (required in production) +BILLING_FILE_SIGNING_SECRET= +# Deprecated legacy roots (migration sources only) BILLING_INVOICE_PDF_STORAGE_PATH=/data/invoices # SMTP / transactional email (MailHog when using docker-compose) @@ -77,13 +85,14 @@ EMAIL_COMPANY_EMAIL= # DATEV EXTF export (monthly Buchungsstapel + optional document bundle) BILLING_DATEV_EXPORT_ENABLED=true +# Deprecated: layout migrator source for previous datev-exports segment BILLING_DATEV_EXPORT_STORAGE_PATH=/data/datev-exports BILLING_DATEV_EXPORT_CRON=0 0 1 * * BILLING_DATEV_EXPORT_TIMEZONE=Europe/Berlin BILLING_DATEV_CONSULTANT_NUMBER= BILLING_DATEV_CLIENT_NUMBER= BILLING_DATEV_UNIFIED_EXPORT_ENABLED=false -BILLING_DATEV_UNIFIED_EXPORT_ALLOWED_TENANTS=default +TENANTS_ALLOW_GLOBAL_VIEWS=default BILLING_SKIP_FILE_CACHE=false BILLING_DEFAULT_PAYMENT_PROCESSOR=stripe diff --git a/apps/decabill/backend-billing-manager/Dockerfile.api b/apps/decabill/backend-billing-manager/Dockerfile.api index ff7c43f62..b3b423ea0 100644 --- a/apps/decabill/backend-billing-manager/Dockerfile.api +++ b/apps/decabill/backend-billing-manager/Dockerfile.api @@ -69,16 +69,29 @@ RUN . "${NVM_DIR}/nvm.sh" && \ COPY --chown=${APP_UID}:${APP_GID} . /app -RUN mkdir -p /data/invoices /var/lib/forepath/provider-plugins && \ +RUN mkdir -p \ + /data/customer/invoices \ + /data/customer/offers \ + /data/customer/timesheets \ + /data/supplier/invoices \ + /data/export/datev \ + /data/invoices \ + /var/lib/forepath/provider-plugins && \ chown -R "${APP_UID}:${APP_GID}" /data /var/lib/forepath/provider-plugins # Expand APP_UID/APP_GID into the entrypoint at build time (runtime has no ARG). RUN printf '%s\n' \ '#!/bin/bash' \ 'set -euo pipefail' \ - 'storage_path="${BILLING_INVOICE_PDF_STORAGE_PATH:-/data/invoices}"' \ - 'mkdir -p "${storage_path}"' \ - "chown -R ${APP_UID}:${APP_GID} /data \"\${storage_path}\"" \ + 'storage_root="${FILE_STORAGE_ROOT:-/data}"' \ + 'mkdir -p \' \ + ' "${storage_root}/customer/invoices" \' \ + ' "${storage_root}/customer/offers" \' \ + ' "${storage_root}/customer/timesheets" \' \ + ' "${storage_root}/supplier/invoices" \' \ + ' "${storage_root}/export/datev" \' \ + ' "${storage_root}/invoices"' \ + "chown -R ${APP_UID}:${APP_GID} \"\${storage_root}\" /var/lib/forepath/provider-plugins" \ '. "${NVM_DIR}/nvm.sh"' \ 'if [ -n "${DYNAMIC_PROVIDER_PLUGIN_PATH:-}" ]; then' \ ' runuser -u decabill -g decabill -- node install-provider-plugins.js' \ diff --git a/apps/decabill/backend-billing-manager/docker-compose.yaml b/apps/decabill/backend-billing-manager/docker-compose.yaml index 2a11eb5c5..99ec2bd3c 100644 --- a/apps/decabill/backend-billing-manager/docker-compose.yaml +++ b/apps/decabill/backend-billing-manager/docker-compose.yaml @@ -72,6 +72,9 @@ x-backend-billing-manager-environment: &backend-billing-manager-environment FILE_STORAGE_PROVIDER: ${FILE_STORAGE_PROVIDER:-local} FILE_STORAGE_ROOT: ${FILE_STORAGE_ROOT:-/data} FILE_STORAGE_LEGACY_MIGRATION_ENABLED: ${FILE_STORAGE_LEGACY_MIGRATION_ENABLED:-true} + FILE_STORAGE_LAYOUT_MIGRATION_ENABLED: ${FILE_STORAGE_LAYOUT_MIGRATION_ENABLED:-true} + FILE_STORAGE_LAYOUT_DUAL_READ_ENABLED: ${FILE_STORAGE_LAYOUT_DUAL_READ_ENABLED:-true} + BILLING_FILE_SIGNING_SECRET: ${BILLING_FILE_SIGNING_SECRET:-} FILE_STORAGE_S3_BUCKET: ${FILE_STORAGE_S3_BUCKET:-} FILE_STORAGE_S3_ACCESS_KEY_ID: ${FILE_STORAGE_S3_ACCESS_KEY_ID:-} FILE_STORAGE_S3_SECRET_ACCESS_KEY: ${FILE_STORAGE_S3_SECRET_ACCESS_KEY:-} @@ -87,7 +90,7 @@ x-backend-billing-manager-environment: &backend-billing-manager-environment BILLING_DATEV_CONSULTANT_NUMBER: ${BILLING_DATEV_CONSULTANT_NUMBER:-} BILLING_DATEV_CLIENT_NUMBER: ${BILLING_DATEV_CLIENT_NUMBER:-} BILLING_DATEV_UNIFIED_EXPORT_ENABLED: ${BILLING_DATEV_UNIFIED_EXPORT_ENABLED:-false} - BILLING_DATEV_UNIFIED_EXPORT_ALLOWED_TENANTS: ${BILLING_DATEV_UNIFIED_EXPORT_ALLOWED_TENANTS:-default} + TENANTS_ALLOW_GLOBAL_VIEWS: ${TENANTS_ALLOW_GLOBAL_VIEWS:-default} BILLING_SKIP_FILE_CACHE: ${BILLING_SKIP_FILE_CACHE:-false} BILLING_DEFAULT_PAYMENT_PROCESSOR: ${BILLING_DEFAULT_PAYMENT_PROCESSOR:-stripe} STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY:-} diff --git a/apps/decabill/backend-billing-manager/src/migrations/1778000000000_CreateBillingStoredFilesAndStripOfferKeys.ts b/apps/decabill/backend-billing-manager/src/migrations/1778000000000_CreateBillingStoredFilesAndStripOfferKeys.ts new file mode 100644 index 000000000..d68c0ed58 --- /dev/null +++ b/apps/decabill/backend-billing-manager/src/migrations/1778000000000_CreateBillingStoredFilesAndStripOfferKeys.ts @@ -0,0 +1,130 @@ +import type { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Creates billing_stored_files registry and backfills rows from domain storage keys + * using the customer/supplier/export scope layout. Strips offers/ prefix from offer keys. + */ +export class CreateBillingStoredFilesAndStripOfferKeys1778000000000 implements MigrationInterface { + name = 'CreateBillingStoredFilesAndStripOfferKeys1778000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE EXTENSION IF NOT EXISTS pgcrypto`); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS "billing_stored_files" ( + "id" uuid NOT NULL DEFAULT gen_random_uuid(), + "long_sha" character varying(40) NOT NULL, + "tenant_id" character varying(64) NOT NULL, + "scope" character varying(64) NOT NULL, + "storage_key" character varying(512) NOT NULL, + "content_md5" character varying(32), + "content_sha1" character varying(40), + "content_sha256" character varying(64), + "content_sha512" character varying(128), + "byte_size" bigint, + "signature" character varying(64), + "signature_alg" character varying(32), + "signature_version" character varying(8), + "signed_at" TIMESTAMPTZ, + "created_at" TIMESTAMPTZ NOT NULL DEFAULT now(), + "updated_at" TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT "PK_billing_stored_files" PRIMARY KEY ("id") + ) + `); + + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "UQ_billing_stored_files_scope_key" + ON "billing_stored_files" ("scope", "storage_key") + `); + + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_billing_stored_files_long_sha" + ON "billing_stored_files" ("long_sha") + `); + + await queryRunner.query(` + UPDATE "billing_offers" + SET "pdf_storage_key" = substring("pdf_storage_key" from 8) + WHERE "pdf_storage_key" LIKE 'offers/%' + `); + + await queryRunner.query(` + INSERT INTO "billing_stored_files" ("id", "long_sha", "tenant_id", "scope", "storage_key") + SELECT gen_random_uuid(), '', u.tenant_id, 'customerInvoices', i.pdf_storage_key + FROM "billing_invoices" i + INNER JOIN "users" u ON u.id = i.user_id + WHERE i.pdf_storage_key IS NOT NULL + ON CONFLICT ("scope", "storage_key") DO NOTHING + `); + + await queryRunner.query(` + INSERT INTO "billing_stored_files" ("id", "long_sha", "tenant_id", "scope", "storage_key") + SELECT gen_random_uuid(), '', u.tenant_id, 'customerTimesheets', i.time_report_storage_key + FROM "billing_invoices" i + INNER JOIN "users" u ON u.id = i.user_id + WHERE i.time_report_storage_key IS NOT NULL + ON CONFLICT ("scope", "storage_key") DO NOTHING + `); + + await queryRunner.query(` + INSERT INTO "billing_stored_files" ("id", "long_sha", "tenant_id", "scope", "storage_key") + SELECT gen_random_uuid(), '', u.tenant_id, 'customerInvoices', d.pdf_storage_key + FROM "billing_invoice_void_documents" d + INNER JOIN "billing_invoices" i ON i.id = d.invoice_id + INNER JOIN "users" u ON u.id = i.user_id + ON CONFLICT ("scope", "storage_key") DO NOTHING + `); + + await queryRunner.query(` + INSERT INTO "billing_stored_files" ("id", "long_sha", "tenant_id", "scope", "storage_key") + SELECT gen_random_uuid(), '', u.tenant_id, 'customerInvoices', d.pdf_storage_key + FROM "billing_invoice_credit_documents" d + INNER JOIN "billing_invoices" i ON i.id = d.invoice_id + INNER JOIN "users" u ON u.id = i.user_id + ON CONFLICT ("scope", "storage_key") DO NOTHING + `); + + await queryRunner.query(` + INSERT INTO "billing_stored_files" ("id", "long_sha", "tenant_id", "scope", "storage_key") + SELECT gen_random_uuid(), '', u.tenant_id, 'customerOffers', o.pdf_storage_key + FROM "billing_offers" o + INNER JOIN "users" u ON u.id = o.user_id + WHERE o.pdf_storage_key IS NOT NULL + ON CONFLICT ("scope", "storage_key") DO NOTHING + `); + + await queryRunner.query(` + INSERT INTO "billing_stored_files" ("id", "long_sha", "tenant_id", "scope", "storage_key") + SELECT gen_random_uuid(), '', sp.tenant_id, 'supplierInvoices', si.document_storage_key + FROM "billing_supplier_invoices" si + INNER JOIN "billing_supplier_profiles" sp ON sp.id = si.supplier_id + WHERE si.document_storage_key IS NOT NULL + ON CONFLICT ("scope", "storage_key") DO NOTHING + `); + + await queryRunner.query(` + INSERT INTO "billing_stored_files" ("id", "long_sha", "tenant_id", "scope", "storage_key") + SELECT gen_random_uuid(), '', de.tenant_id, 'datevExports', de.storage_key + FROM "billing_datev_exports" de + WHERE de.storage_key IS NOT NULL + ON CONFLICT ("scope", "storage_key") DO NOTHING + `); + + await queryRunner.query(` + UPDATE "billing_stored_files" + SET "long_sha" = encode(digest("id"::text, 'sha1'), 'hex') + WHERE "long_sha" = '' OR "long_sha" IS NULL + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + UPDATE "billing_offers" + SET "pdf_storage_key" = 'offers/' || "pdf_storage_key" + WHERE "pdf_storage_key" IS NOT NULL + AND "pdf_storage_key" NOT LIKE 'offers/%' + `); + + await queryRunner.query(`DROP TABLE IF EXISTS "billing_stored_files"`); + } +} diff --git a/apps/decabill/backend-billing-manager/src/migrations/1778100000000_AddBillingStoredFilesContentSha256Index.ts b/apps/decabill/backend-billing-manager/src/migrations/1778100000000_AddBillingStoredFilesContentSha256Index.ts new file mode 100644 index 000000000..f199c4765 --- /dev/null +++ b/apps/decabill/backend-billing-manager/src/migrations/1778100000000_AddBillingStoredFilesContentSha256Index.ts @@ -0,0 +1,16 @@ +import type { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddBillingStoredFilesContentSha256Index1778100000000 implements MigrationInterface { + name = 'AddBillingStoredFilesContentSha256Index1778100000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_billing_stored_files_content_sha256" + ON "billing_stored_files" ("content_sha256") + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX IF EXISTS "IDX_billing_stored_files_content_sha256"`); + } +} diff --git a/apps/decabill/backend-billing-manager/src/queue/job-registry.ts b/apps/decabill/backend-billing-manager/src/queue/job-registry.ts index 68066c06d..cca716b2e 100644 --- a/apps/decabill/backend-billing-manager/src/queue/job-registry.ts +++ b/apps/decabill/backend-billing-manager/src/queue/job-registry.ts @@ -53,6 +53,8 @@ export const BillingJobName = { SEARCH_REINDEX_COORDINATOR: 'search-reindex.coordinator', SEARCH_REINDEX_UNIT: 'search-reindex.unit', SEARCH_INDEX_SYNC_UNIT: 'search-index-sync.unit', + STORED_FILES_BACKFILL_COORDINATOR: 'stored-files.backfill.coordinator', + STORED_FILES_BACKFILL_UNIT: 'stored-files.backfill.unit', OFFER_EXPIRATION_COORDINATOR: 'offer-expiration.coordinator', OFFER_EXPIRATION_UNIT: 'offer-expiration.unit', OFFER_FULFILLMENT_COORDINATOR: 'offer-fulfillment.coordinator', @@ -245,6 +247,12 @@ export function getBillingRepeatableJobs(): BillingRepeatableJobDefinition[] { everyMs: parseIntervalMs('SEARCH_REINDEX_INTERVAL', 900_000), }); + jobs.push({ + name: BillingJobName.STORED_FILES_BACKFILL_COORDINATOR, + coordinatorJobId: buildCoordinatorJobId('stored-files-backfill'), + everyMs: parseIntervalMs('STORED_FILES_BACKFILL_INTERVAL', 300_000), + }); + jobs.push({ name: BillingJobName.OFFER_EXPIRATION_COORDINATOR, coordinatorJobId: buildCoordinatorJobId('offer-expiration'), diff --git a/apps/decabill/backend-billing-manager/src/queue/processors/billing-jobs.processor.ts b/apps/decabill/backend-billing-manager/src/queue/processors/billing-jobs.processor.ts index 190aee5c0..c5c4f79fe 100644 --- a/apps/decabill/backend-billing-manager/src/queue/processors/billing-jobs.processor.ts +++ b/apps/decabill/backend-billing-manager/src/queue/processors/billing-jobs.processor.ts @@ -16,6 +16,7 @@ import { SearchReindexJobHandler, type SearchIndexSyncUnitPayload, type SearchReindexUnitPayload, + StoredFilesBackfillJobHandler, OfferExpirationJobHandler, OfferFulfillmentJobHandler, SubscriptionBillingJobHandler, @@ -89,6 +90,7 @@ export class BillingJobsProcessor extends WorkerHost { private readonly emailDeliveryService: EmailDeliveryService, private readonly updateCheckService: UpdateCheckService, private readonly searchReindexJobHandler: SearchReindexJobHandler, + private readonly storedFilesBackfillJobHandler: StoredFilesBackfillJobHandler, private readonly offerExpiration: OfferExpirationJobHandler, private readonly offerFulfillment: OfferFulfillmentJobHandler, ) { @@ -157,6 +159,9 @@ export class BillingJobsProcessor extends WorkerHost { case BillingJobName.SEARCH_REINDEX_COORDINATOR: await this.runSearchReindexCoordinator(); break; + case BillingJobName.STORED_FILES_BACKFILL_COORDINATOR: + await this.runStoredFilesBackfillCoordinator(); + break; case BillingJobName.OFFER_EXPIRATION_COORDINATOR: await this.runOfferExpirationCoordinator(); break; @@ -267,6 +272,9 @@ export class BillingJobsProcessor extends WorkerHost { case BillingJobName.SEARCH_REINDEX_UNIT: await this.runSearchReindexUnit(job.data as SearchReindexUnitPayload); break; + case BillingJobName.STORED_FILES_BACKFILL_UNIT: + await this.runStoredFilesBackfillUnit(job.data as { offset?: number; limit?: number; tenantId?: string }); + break; case BillingJobName.SEARCH_INDEX_SYNC_UNIT: await this.searchReindexJobHandler.processSyncUnit(job.data as SearchIndexSyncUnitPayload); break; @@ -796,6 +804,46 @@ export class BillingJobsProcessor extends WorkerHost { await this.contributorCollect.processTenant(data.tenantId); } + private async runStoredFilesBackfillCoordinator(): Promise { + if (!(await this.storedFilesBackfillJobHandler.hasPendingWork())) { + return; + } + + await this.enqueueBillingUnitJob({ + queue: this.billingQueue, + jobName: BillingJobName.STORED_FILES_BACKFILL_UNIT, + payload: { tenantId: DEFAULT_TENANT, offset: 0, limit: 25 }, + jobIdNamespace: 'stored-files-backfill', + jobIdParts: ['offset', '0'], + }); + } + + private async runStoredFilesBackfillUnit(data: { + offset?: number; + limit?: number; + tenantId?: string; + }): Promise { + const offset = data.offset ?? 0; + const limit = data.limit ?? 25; + const result = await this.storedFilesBackfillJobHandler.processBackfillUnit(offset, limit); + + if (!result.hasMore) { + return; + } + + await this.enqueueBillingUnitJob({ + queue: this.billingQueue, + jobName: BillingJobName.STORED_FILES_BACKFILL_UNIT, + payload: { + tenantId: data.tenantId ?? DEFAULT_TENANT, + offset: result.nextOffset, + limit, + }, + jobIdNamespace: 'stored-files-backfill', + jobIdParts: ['offset', String(result.nextOffset)], + }); + } + private async runSearchReindexCoordinator(): Promise { await this.forEachConfiguredTenant(async (tenantId) => { try { diff --git a/apps/decabill/backend-billing-manager/src/typeorm.config.ts b/apps/decabill/backend-billing-manager/src/typeorm.config.ts index 44f07be34..281a8de8e 100644 --- a/apps/decabill/backend-billing-manager/src/typeorm.config.ts +++ b/apps/decabill/backend-billing-manager/src/typeorm.config.ts @@ -12,6 +12,7 @@ import { DatevDebtorAccountEntity, DatevCreditorAccountEntity, DatevExportEntity, + StoredFileEntity, SupplierProfileEntity, SupplierContractEntity, SupplierInvoiceEntity, @@ -130,6 +131,7 @@ export const typeormConfig: DataSourceOptions = { DatevDebtorAccountEntity, DatevCreditorAccountEntity, DatevExportEntity, + StoredFileEntity, SupplierProfileEntity, SupplierContractEntity, SupplierInvoiceEntity, diff --git a/apps/decabill/frontend-billing-console/src/i18n/messages.de.xlf b/apps/decabill/frontend-billing-console/src/i18n/messages.de.xlf index e44fa9dca..2fc80c3f5 100644 --- a/apps/decabill/frontend-billing-console/src/i18n/messages.de.xlf +++ b/apps/decabill/frontend-billing-console/src/i18n/messages.de.xlf @@ -677,6 +677,98 @@ DATEV Exports DATEV-Exporte + + File Explorer + Datei-Explorer + + + File Explorer + Datei-Explorer + + + Files + Dateien + + + File Explorer + Datei-Explorer + + + Unified + Vereinheitlicht + + + Refresh + Aktualisieren + + + Verify file + Datei prüfen + + + Verify authenticity + Echtheit prüfen + + + File to verify + Zu prüfende Datei + + + Upload is checked in memory only and is never stored. + Der Upload wird nur im Speicher geprüft und nicht gespeichert. + + + Verify + Prüfen + + + Document ID + Dokument-ID + + + Path + Pfad + + + Tenant + Tenant + + + Authentic. This file matches a signed registry entry. + Authentisch. Die Datei entspricht einem signierten Registry-Eintrag. + + + Unknown. No matching signed file was found. + Unbekannt. Keine passende signierte Datei gefunden. + + + Digest matched a registry row, but the signature is missing or invalid. + Prüfsumme gefunden, aber Signatur fehlt oder ist ungültig. + + + File signing is not enabled on this deployment. + Dateisignierung ist in dieser Umgebung nicht aktiviert. + + + Download + Herunterladen + + + Loading files… + Dateien werden geladen… + + + No files + Keine Dateien + + + Tenant + Mandant + + + Tenant + Mandant + N/A k. A. diff --git a/apps/decabill/frontend-billing-console/src/i18n/messages.xlf b/apps/decabill/frontend-billing-console/src/i18n/messages.xlf index 13dd745fa..9267630eb 100644 --- a/apps/decabill/frontend-billing-console/src/i18n/messages.xlf +++ b/apps/decabill/frontend-billing-console/src/i18n/messages.xlf @@ -1349,6 +1349,93 @@ DATEV Exports + + File Explorer + + + File Explorer + + + Files + + + File Explorer + + + Unified + + + Refresh + + + Verify file + + + Find by document ID + + + Find by document ID + + + Document ID + + + Enter the 7-character Document ID printed on the PDF, or the full 40-character hash. + + + Download + + + Download started. + + + Verify authenticity + + + File to verify + + + Upload is checked in memory only and is never stored. + + + Verify + + + Document ID + + + Path + + + Tenant + + + Authentic. This file matches a signed registry entry. + + + Unknown. No matching signed file was found. + + + Digest matched a registry row, but the signature is missing or invalid. + + + File signing is not enabled on this deployment. + + + Download + + + Loading files… + + + No files + + + Tenant + + + Tenant + N/A diff --git a/docs/decabill/applications/backend-billing-manager.md b/docs/decabill/applications/backend-billing-manager.md index ab144bbd8..44f7cc20d 100644 --- a/docs/decabill/applications/backend-billing-manager.md +++ b/docs/decabill/applications/backend-billing-manager.md @@ -99,7 +99,7 @@ docker compose up -d Compose services: `postgres`, `redis`, `backend-billing-manager`, `backend-billing-manager-worker`, `backend-billing-manager-scheduler`, `mailhog`. -Volumes include `billing_file_data` mounted at `/data` (with `invoices/` and `datev-exports/` subdirs) and optional `./provider-plugins` for dynamic providers. +Volumes include `billing_file_data` mounted at `/data` (with `customer/`, `supplier/`, and `export/datev/` layout) and optional `./provider-plugins` for dynamic providers. ## Authentication diff --git a/docs/decabill/deployment/docker-deployment.md b/docs/decabill/deployment/docker-deployment.md index 2d1901a05..0ae0cf970 100644 --- a/docs/decabill/deployment/docker-deployment.md +++ b/docs/decabill/deployment/docker-deployment.md @@ -36,7 +36,7 @@ Volumes: - `postgres_data` - Database files - `redis_data` - Redis AOF data -- `billing_file_data` - Shared file storage at `/data` (`invoices/` and `datev-exports/` subdirs) +- `billing_file_data` - Shared file storage at `/data` (`customer/`, `supplier/`, `export/datev/` layout) - `./provider-plugins` - Optional dynamic provider plugins mount Image: `ghcr.io/forepath/decabill-billing-api:latest` diff --git a/docs/decabill/deployment/environment-configuration.md b/docs/decabill/deployment/environment-configuration.md index 97a3c88a5..eb046cef6 100644 --- a/docs/decabill/deployment/environment-configuration.md +++ b/docs/decabill/deployment/environment-configuration.md @@ -66,29 +66,32 @@ Billing data and users are partitioned by **`tenant_id`**. HTTP clients send **` ### Encryption and Issuer Details -| Variable | Description | -| ------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------- | -| `ENCRYPTION_KEY` | Encrypts sensitive stored data (API tokens, snapshots, webhook auth/signing secrets) | -| `BILLING_ISSUER_*` | Legal entity on invoices and public withdrawal addressee (name, VAT, address, bank); also fallback for email brand header/footer | -| `PUBLIC_WITHDRAWAL_CONFIRMATION_TTL_HOURS` | Hours until a public withdrawal confirmation code expires (default `48`) | -| `BILLING_TAX_RATE_STANDARD` | Issuer-country override for standard rate when EU table entry missing (default `19`) | -| `BILLING_TAX_RATE_REDUCED` | Issuer-country override for reduced rate when EU table entry missing (default `7`) | -| `BILLING_OSS_REGISTERED` | When `true`, always use destination-country VAT for EU B2C (skip €10k threshold ledger) | -| `BILLING_OSS_THRESHOLD_EUR` | Cross-border EU B2C net threshold (default `10000`) | -| `BILLING_NON_EU_ISSUER_EU_B2B_CHARGE_VAT` | When `true`, non-EU issuer → EU B2B charges customer-country VAT instead of default no-VAT | -| `BILLING_STATUTORY_WITHDRAWAL_PERIOD_DAYS` | Days after provisioning (or price-migration restart) during which statutory withdrawal is allowed (default `14`) | -| `FILE_STORAGE_PROVIDER` | Active file storage backend: `local` (default) or `s3` (S3-compatible: AWS, R2, B2, Ceph, MinIO, …) | -| `FILE_STORAGE_ROOT` | Canonical file root for `local`; scopes live under `{root}/invoices` and `{root}/datev-exports` (compose default `/data`) | -| `FILE_STORAGE_LEGACY_MIGRATION_ENABLED` | When not `false`, startup copies non-empty legacy dirs into the canonical layout (default enabled; `local` only) | -| `FILE_STORAGE_S3_BUCKET` | Required when `FILE_STORAGE_PROVIDER=s3`: bucket name | -| `FILE_STORAGE_S3_ACCESS_KEY_ID` | Required when using `s3`: access key | -| `FILE_STORAGE_S3_SECRET_ACCESS_KEY` | Required when using `s3`: secret key | -| `FILE_STORAGE_S3_REGION` | S3 region (default `auto`; follow your provider’s docs) | -| `FILE_STORAGE_S3_ENDPOINT` | Custom S3 API endpoint for non-AWS providers (R2, B2, Ceph, MinIO, …) | -| `FILE_STORAGE_S3_FORCE_PATH_STYLE` | `true`/`false`; defaults to `true` when an endpoint is set, otherwise `false` | -| `FILE_STORAGE_S3_KEY_PREFIX` | Optional object key prefix (e.g. `decabill/prod`) | -| `BILLING_INVOICE_PDF_STORAGE_PATH` | **Deprecated.** Legacy migration source for invoice PDFs (was `/data/invoices`) | -| `BILLING_SKIP_FILE_CACHE` | Skip PDF file cache when `true` | +| Variable | Description | +| ------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------- | +| `ENCRYPTION_KEY` | Encrypts sensitive stored data (API tokens, snapshots, webhook auth/signing secrets) | +| `BILLING_ISSUER_*` | Legal entity on invoices and public withdrawal addressee (name, VAT, address, bank); also fallback for email brand header/footer | +| `PUBLIC_WITHDRAWAL_CONFIRMATION_TTL_HOURS` | Hours until a public withdrawal confirmation code expires (default `48`) | +| `BILLING_TAX_RATE_STANDARD` | Issuer-country override for standard rate when EU table entry missing (default `19`) | +| `BILLING_TAX_RATE_REDUCED` | Issuer-country override for reduced rate when EU table entry missing (default `7`) | +| `BILLING_OSS_REGISTERED` | When `true`, always use destination-country VAT for EU B2C (skip €10k threshold ledger) | +| `BILLING_OSS_THRESHOLD_EUR` | Cross-border EU B2C net threshold (default `10000`) | +| `BILLING_NON_EU_ISSUER_EU_B2B_CHARGE_VAT` | When `true`, non-EU issuer → EU B2B charges customer-country VAT instead of default no-VAT | +| `BILLING_STATUTORY_WITHDRAWAL_PERIOD_DAYS` | Days after provisioning (or price-migration restart) during which statutory withdrawal is allowed (default `14`) | +| `FILE_STORAGE_PROVIDER` | Active file storage backend: `local` (default) or `s3` (S3-compatible: AWS, R2, B2, Ceph, MinIO, …) | +| `FILE_STORAGE_ROOT` | Canonical file root for `local`; scopes live under `{root}/customer/*`, `{root}/supplier/*`, `{root}/export/datev` (compose default `/data`) | +| `FILE_STORAGE_LEGACY_MIGRATION_ENABLED` | When not `false`, startup copies non-empty legacy env roots into previous flat segments (default enabled; `local` only) | +| `FILE_STORAGE_LAYOUT_MIGRATION_ENABLED` | When not `false`, startup migrates previous segments into customer/supplier/export layout (default enabled; local + S3) | +| `FILE_STORAGE_LAYOUT_DUAL_READ_ENABLED` | When not `false`, reads fall back to previous segments if the new path is missing (default enabled) | +| `BILLING_FILE_SIGNING_SECRET` | Global HMAC secret for stored-file signatures (required in production) | +| `FILE_STORAGE_S3_BUCKET` | Required when `FILE_STORAGE_PROVIDER=s3`: bucket name | +| `FILE_STORAGE_S3_ACCESS_KEY_ID` | Required when using `s3`: access key | +| `FILE_STORAGE_S3_SECRET_ACCESS_KEY` | Required when using `s3`: secret key | +| `FILE_STORAGE_S3_REGION` | S3 region (default `auto`; follow your provider’s docs) | +| `FILE_STORAGE_S3_ENDPOINT` | Custom S3 API endpoint for non-AWS providers (R2, B2, Ceph, MinIO, …) | +| `FILE_STORAGE_S3_FORCE_PATH_STYLE` | `true`/`false`; defaults to `true` when an endpoint is set, otherwise `false` | +| `FILE_STORAGE_S3_KEY_PREFIX` | Optional object key prefix (e.g. `decabill/prod`) | +| `BILLING_INVOICE_PDF_STORAGE_PATH` | **Deprecated.** Legacy migration source for invoice PDFs (was `/data/invoices`) | +| `BILLING_SKIP_FILE_CACHE` | Skip PDF file cache when `true` | ### Automatic daily price recalculation @@ -106,39 +109,39 @@ Per-plan opt-in remains `autoRecalculatePriceDaily` (default `false`) on the ser Monthly DATEV Buchungsstapel exports (category 21) with optional PDF document bundle. Disabled entirely when `BILLING_DATEV_EXPORT_ENABLED=false` (no jobs, admin routes return 404, billing console hides the DATEV page via capabilities). -| Variable | Description | Default | -| ---------------------------------------------- | --------------------------------------------------------------------------- | -------------------- | -| `BILLING_DATEV_EXPORT_ENABLED` | Master kill switch | `true` | -| `BILLING_DATEV_EXPORT_STORAGE_PATH` | **Deprecated.** Legacy migration source for DATEV ZIPs | _(unset)_ | -| `BILLING_DATEV_EXPORT_CRON` | BullMQ cron for monthly coordinator (1st of month) | `0 0 1 * *` | -| `BILLING_DATEV_EXPORT_TIMEZONE` | Timezone for cron and previous-month period calculation | `Europe/Berlin` | -| `BILLING_DATEV_CONSULTANT_NUMBER` | DATEV Beraternummer (required per tenant for export) | - | -| `BILLING_DATEV_CLIENT_NUMBER` | DATEV Mandantennummer (required per tenant for export) | - | -| `BILLING_DATEV_CHART_OF_ACCOUNTS` | `SKR03` or `SKR04` | `SKR03` | -| `BILLING_DATEV_ACCOUNT_LENGTH` | Sachkontenlänge in EXTF header | `4` | -| `BILLING_DATEV_REVENUE_ACCOUNT_STANDARD` | Revenue account for 19% (SKR03 default `8400`, SKR04 `4400`) | env / chart default | -| `BILLING_DATEV_REVENUE_ACCOUNT_REDUCED` | Revenue account for 7% (SKR03 default `8300`, SKR04 `4300`) | env / chart default | -| `BILLING_DATEV_DEBTOR_ACCOUNT_START` | First debtor number in range | `10000` | -| `BILLING_DATEV_DEBTOR_ACCOUNT_END` | Last debtor number in range | `69999` | -| `BILLING_DATEV_CREDITOR_ACCOUNT_START` | First creditor number in range | `70000` | -| `BILLING_DATEV_CREDITOR_ACCOUNT_END` | Last creditor number in range | `99999` | -| `BILLING_DATEV_EXPENSE_ACCOUNT_STANDARD` | Expense G/L for standard tax (SKR03 default `4900`) | env / chart default | -| `BILLING_DATEV_EXPENSE_ACCOUNT_REDUCED` | Expense G/L for reduced tax | env / chart default | -| `BILLING_DATEV_EXPENSE_ACCOUNT_REVERSE_CHARGE` | Expense G/L for reverse charge | env / chart default | -| `BILLING_DATEV_EXPENSE_ACCOUNT_OSS` | Expense G/L for OSS | env / chart default | -| `BILLING_DATEV_EXPENSE_ACCOUNT_THIRD_COUNTRY` | Expense G/L for third country | env / chart default | -| `BILLING_DATEV_EXPENSE_BU_KEY_STANDARD` | Override BU-Schlüssel for expense standard | empty | -| `BILLING_DATEV_EXPENSE_BU_KEY_REDUCED` | Override BU-Schlüssel for expense reduced | empty | -| `BILLING_DATEV_BU_KEY_STANDARD` | Override BU-Schlüssel for standard tax (empty for Automatikkonten) | empty | -| `BILLING_DATEV_BU_KEY_REDUCED` | Override BU-Schlüssel for reduced tax | empty | -| `BILLING_DATEV_EXPORT_INCLUDE_DOCUMENTS` | Include PDF bundle + Beleglink in Buchungsstapel | `true` | -| `BILLING_DATEV_EXPORT_DICTATION_ABBR` | Diktatkürzel in EXTF header | `DEC` | -| `BILLING_DATEV_FISCAL_YEAR_START_MONTH` | Wirtschaftsjahresbeginn (1-12) | `1` | -| `BILLING_DATEV_TENANT_CONFIG` | JSON map of per-tenant (and optional `unified`) DATEV overrides | - | -| `BILLING_DATEV_UNIFIED_EXPORT_ENABLED` | Enable cross-tenant consolidated monthly export | `false` | -| `BILLING_DATEV_UNIFIED_EXPORT_ALLOWED_TENANTS` | Comma-separated tenant ids allowed to list/trigger/download unified exports | `default` when unset | - -Per-tenant exports are scoped to the request **`X-Tenant`**. Unified exports aggregate all configured tenants but are only accessible from allowlisted operator tenants. Validate sample exports with **DatevFormatPruefProgramm** before production handoff. +| Variable | Description | Default | +| ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | -------------------- | +| `BILLING_DATEV_EXPORT_ENABLED` | Master kill switch | `true` | +| `BILLING_DATEV_EXPORT_STORAGE_PATH` | **Deprecated.** Legacy migration source for DATEV ZIPs | _(unset)_ | +| `BILLING_DATEV_EXPORT_CRON` | BullMQ cron for monthly coordinator (1st of month) | `0 0 1 * *` | +| `BILLING_DATEV_EXPORT_TIMEZONE` | Timezone for cron and previous-month period calculation | `Europe/Berlin` | +| `BILLING_DATEV_CONSULTANT_NUMBER` | DATEV Beraternummer (required per tenant for export) | - | +| `BILLING_DATEV_CLIENT_NUMBER` | DATEV Mandantennummer (required per tenant for export) | - | +| `BILLING_DATEV_CHART_OF_ACCOUNTS` | `SKR03` or `SKR04` | `SKR03` | +| `BILLING_DATEV_ACCOUNT_LENGTH` | Sachkontenlänge in EXTF header | `4` | +| `BILLING_DATEV_REVENUE_ACCOUNT_STANDARD` | Revenue account for 19% (SKR03 default `8400`, SKR04 `4400`) | env / chart default | +| `BILLING_DATEV_REVENUE_ACCOUNT_REDUCED` | Revenue account for 7% (SKR03 default `8300`, SKR04 `4300`) | env / chart default | +| `BILLING_DATEV_DEBTOR_ACCOUNT_START` | First debtor number in range | `10000` | +| `BILLING_DATEV_DEBTOR_ACCOUNT_END` | Last debtor number in range | `69999` | +| `BILLING_DATEV_CREDITOR_ACCOUNT_START` | First creditor number in range | `70000` | +| `BILLING_DATEV_CREDITOR_ACCOUNT_END` | Last creditor number in range | `99999` | +| `BILLING_DATEV_EXPENSE_ACCOUNT_STANDARD` | Expense G/L for standard tax (SKR03 default `4900`) | env / chart default | +| `BILLING_DATEV_EXPENSE_ACCOUNT_REDUCED` | Expense G/L for reduced tax | env / chart default | +| `BILLING_DATEV_EXPENSE_ACCOUNT_REVERSE_CHARGE` | Expense G/L for reverse charge | env / chart default | +| `BILLING_DATEV_EXPENSE_ACCOUNT_OSS` | Expense G/L for OSS | env / chart default | +| `BILLING_DATEV_EXPENSE_ACCOUNT_THIRD_COUNTRY` | Expense G/L for third country | env / chart default | +| `BILLING_DATEV_EXPENSE_BU_KEY_STANDARD` | Override BU-Schlüssel for expense standard | empty | +| `BILLING_DATEV_EXPENSE_BU_KEY_REDUCED` | Override BU-Schlüssel for expense reduced | empty | +| `BILLING_DATEV_BU_KEY_STANDARD` | Override BU-Schlüssel for standard tax (empty for Automatikkonten) | empty | +| `BILLING_DATEV_BU_KEY_REDUCED` | Override BU-Schlüssel for reduced tax | empty | +| `BILLING_DATEV_EXPORT_INCLUDE_DOCUMENTS` | Include PDF bundle + Beleglink in Buchungsstapel | `true` | +| `BILLING_DATEV_EXPORT_DICTATION_ABBR` | Diktatkürzel in EXTF header | `DEC` | +| `BILLING_DATEV_FISCAL_YEAR_START_MONTH` | Wirtschaftsjahresbeginn (1-12) | `1` | +| `BILLING_DATEV_TENANT_CONFIG` | JSON map of per-tenant (and optional `unified`) DATEV overrides | - | +| `BILLING_DATEV_UNIFIED_EXPORT_ENABLED` | Enable cross-tenant consolidated monthly export | `false` | +| `TENANTS_ALLOW_GLOBAL_VIEWS` | Comma-separated tenant ids allowed to use cross-tenant admin views (DATEV unified export + File Explorer tenant tabs / consolidation) | `default` when unset | + +Per-tenant exports are scoped to the request **`X-Tenant`** (or admin `viewTenantId` when global views are allowed). Unified exports aggregate all configured tenants but are only accessible from allowlisted operator tenants (`TENANTS_ALLOW_GLOBAL_VIEWS`). The same allowlist gates the admin [File Explorer](../features/file-explorer.md). Validate sample exports with **DatevFormatPruefProgramm** before production handoff. Debtor account numbers follow **`TENANTS_SHARED_NUMBERS`** (shared by default). Creditor (supplier AP) numbers follow the same scoping rules in `billing_datev_creditor_accounts`. With shared numbers, overlapping per-tenant debtor or creditor ranges do not create uniqueness collisions; with `TENANTS_SHARED_NUMBERS=false`, overlapping ranges can collide in unified exports (startup warning). See [Numbering](../features/numbering.md). diff --git a/docs/decabill/deployment/production-checklist.md b/docs/decabill/deployment/production-checklist.md index d30820473..3155a4aa7 100644 --- a/docs/decabill/deployment/production-checklist.md +++ b/docs/decabill/deployment/production-checklist.md @@ -48,7 +48,7 @@ Comprehensive checklist for deploying Decabill to production. - [ ] `FILE_STORAGE_ROOT` volume mounted on api, worker, and scheduler (when `FILE_STORAGE_PROVIDER=local`) - [ ] Or S3-compatible storage configured (`FILE_STORAGE_PROVIDER=s3` plus `FILE_STORAGE_S3_*`) - [ ] `BILLING_DATEV_EXPORT_ENABLED=false` verified if DATEV export is not required (UI hidden via capabilities) -- [ ] Unified DATEV export allowlist reviewed (`BILLING_DATEV_UNIFIED_EXPORT_ALLOWED_TENANTS`) +- [ ] Global admin views allowlist reviewed (`TENANTS_ALLOW_GLOBAL_VIEWS` — DATEV unified + File Explorer) - [ ] Sample DATEV export validated with DatevFormatPruefProgramm before accountant handoff - [ ] Provisioning SSH and cloud API tokens restricted (see **[DR-001](../security/accepted-risks.md#dr-001-provisioning-ssh-cloud-init-templates)**) @@ -148,7 +148,8 @@ Comprehensive checklist for deploying Decabill to production. ### Billing File Backups -- Backup `FILE_STORAGE_ROOT` volume (invoice PDFs under `invoices/`, DATEV ZIPs under `datev-exports/`) when using `local`, or the S3-compatible bucket when using `s3` +- Backup `FILE_STORAGE_ROOT` volume (`customer/*`, `supplier/*`, `export/datev/`) when using `local`, or the S3-compatible bucket when using `s3` +- Set `BILLING_FILE_SIGNING_SECRET` for stored-file HMAC signatures - Align PDF and DATEV retention with legal and tax requirements - Document whether per-tenant or unified DATEV exports are used in production diff --git a/docs/decabill/deployment/system-requirements.md b/docs/decabill/deployment/system-requirements.md index 2774a42d8..6f95dcbe6 100644 --- a/docs/decabill/deployment/system-requirements.md +++ b/docs/decabill/deployment/system-requirements.md @@ -141,10 +141,10 @@ Browser clients need a modern evergreen browser. The billing console initial bun Mount and size these paths on API, worker, and scheduler containers when the feature is enabled: -| Volume | Path | Sizing guidance | -| ---------------- | ------------------------------------- | ---------------------------------------------------------------------- | -| Billing files | `FILE_STORAGE_ROOT` (default `/data`) | Includes `invoices/` and `datev-exports/` subdirs; plan PDF + ZIP size | -| Provider plugins | `DYNAMIC_PROVIDER_PLUGIN_PATH` | Small; optional mount | +| Volume | Path | Sizing guidance | +| ---------------- | ------------------------------------- | ----------------------------------------------------------------------- | +| Billing files | `FILE_STORAGE_ROOT` (default `/data`) | Includes `customer/`, `supplier/`, `export/datev/`; plan PDF + ZIP size | +| Provider plugins | `DYNAMIC_PROVIDER_PLUGIN_PATH` | Small; optional mount | The local file storage provider requires the same `FILE_STORAGE_ROOT` volume on api, worker, and scheduler. When `FILE_STORAGE_PROVIDER=s3`, shared filesystem mounts for billing files are not required. diff --git a/docs/decabill/features/README.md b/docs/decabill/features/README.md index 3f43b16cf..ece5e67ae 100644 --- a/docs/decabill/features/README.md +++ b/docs/decabill/features/README.md @@ -15,7 +15,7 @@ Decabill provides a complete set of capabilities for subscription billing, invoi - **Invoices** ZUGFeRD PDFs, open positions, billing-day accumulation, and Stripe checkout - **Offers** Admin draft quotations, customer accept/decline, fulfillment into subscriptions/projects/invoices - **Service Types and Plans** Admin-managed catalog with provider schemas and pricing -- **Billing Administration** Manual invoices, customer profiles, KPIs, and bill-now +- **Billing Administration** Manual invoices, customer profiles, KPIs, bill-now, and admin File Explorer - **Customer Profiles** Self-service and admin billing metadata required for ordering - **VAT and tax treatment** EU place-of-supply modes, VIES VAT ID validation, OSS threshold, reverse charge - **Customer Trust Score** Admin-only traffic-light trust ranking on billing profiles @@ -142,7 +142,7 @@ Admin-managed catalog of service types, provisioning providers, and priced servi ### [Billing Administration](./billing-administration.md) -Admin-only features for manual invoices, customer billing profiles, operational dashboards, and bill-now. +Admin-only features for manual invoices, customer billing profiles, operational dashboards, bill-now, and File Explorer. **Key Capabilities**: @@ -150,6 +150,11 @@ Admin-only features for manual invoices, customer billing profiles, operational - Customer billing profile CRUD - Billing summary, statistics, and open or overdue invoice lists - Bill-now to force invoice generation outside the scheduler +- Read-only [File Explorer](./file-explorer.md) for managed storage downloads + +### [File Explorer](./file-explorer.md) + +Admin read-only virtual tree of invoice, supplier, and DATEV files with per-tenant and unified views gated by `TENANTS_ALLOW_GLOBAL_VIEWS`. ### [Webhooks](./webhooks.md) diff --git a/docs/decabill/features/billing-administration.md b/docs/decabill/features/billing-administration.md index 95d1c41e3..9dd894852 100644 --- a/docs/decabill/features/billing-administration.md +++ b/docs/decabill/features/billing-administration.md @@ -145,6 +145,12 @@ Draft → issue workflow mirrors manual AR invoices; supports document upload, E Monthly DATEV exports include **both** AR customer invoices and AP supplier invoices. Supplier line items book to configured expense accounts with **H** (credit) on the supplier creditor account. Creditor numbers are allocated lazily from `BILLING_DATEV_CREDITOR_ACCOUNT_START`–`END` (defaults `70000`–`99999`). Configure expense GL accounts via `BILLING_DATEV_EXPENSE_ACCOUNT_*` and optional `BILLING_DATEV_EXPENSE_BU_KEY_*`. See [Environment configuration](../deployment/environment-configuration.md) and [Supplier invoices](./supplier-invoices.md). +When the request tenant is listed in `TENANTS_ALLOW_GLOBAL_VIEWS`, the DATEV admin page shows one tab per configured tenant plus Unified (Unified still requires `BILLING_DATEV_UNIFIED_EXPORT_ENABLED`). Cross-tenant listing uses `viewTenantId` while `X-Tenant` stays the operator home tenant. + +## File Explorer + +Read-only admin browser for managed storage files (invoice PDFs, supplier documents, DATEV ZIPs). Frontend route: `/administration/file-explorer`. API under `/admin/billing/files` (`billing_admin:read`). Tenant enclosure and Unified consolidation use the same `TENANTS_ALLOW_GLOBAL_VIEWS` allowlist as DATEV. See [File Explorer](./file-explorer.md). + ## Admin Projects Projects are managed under `/admin/billing/projects`. Admins assign each project to a customer user, track time, and bill unbilled hours to an invoice. diff --git a/docs/decabill/features/file-explorer.md b/docs/decabill/features/file-explorer.md new file mode 100644 index 000000000..5bc273882 --- /dev/null +++ b/docs/decabill/features/file-explorer.md @@ -0,0 +1,78 @@ +# Admin File Explorer + +Read-only browsing and download of billing files stored through the shared `FileStorageProvider` (active backend selected by `FILE_STORAGE_PROVIDER`). + +## Overview + +Administrators open **Administration → File Explorer** in the billing console. The UI shows a virtual file tree built from **database storage keys** (not a raw bucket listing). Fixed structural folders (`customer/{invoices,offers,timesheets}`, `supplier/invoices`, `export/datev`) are always shown; dynamic folders (DATEV year/month, subscription keys, …) appear only when files exist. Orphan objects on disk/S3 that are not referenced by tenant-scoped entities are never shown or downloadable. + +Supported actions: + +- Browse folders (lazy expand; refresh keeps open folders and reloads those paths) +- Download a single file +- Download a folder (and its subtree) as ZIP +- Find and download a file by printed Document ID (when signing is enabled) +- Verify authenticity of an uploaded file against the signed registry (when signing is enabled; upload is never persisted) + +Upload, rename, move, and delete are out of scope. + +## Storage layout + +Files live under `{FILE_STORAGE_ROOT}`: + +| Virtual path | Scope | Sources | +| --------------------- | -------------------- | ------------------------------ | +| `customer/invoices` | `customerInvoices` | Invoice PDFs, void/credit docs | +| `customer/offers` | `customerOffers` | Archived offer PDFs | +| `customer/timesheets` | `customerTimesheets` | Project time-report PDFs | +| `supplier/invoices` | `supplierInvoices` | Supplier invoice documents | +| `export/datev` | `datevExports` | Completed DATEV export ZIPs | + +A default-on layout migrator copies objects from the previous segments (`invoices/`, `supplier-invoices/`, `datev-exports/`) into this layout (local + S3). Dual-read remains enabled until cutover. + +## Registry, hashes, and signatures + +Every managed file has a `billing_stored_files` row with: + +- UUID id and ticket-style identity SHA (`shas.short` / `shas.long`) +- Content digests: MD5, SHA-1, SHA-256, SHA-512 +- Global HMAC-SHA256 signature (`BILLING_FILE_SIGNING_SECRET`) over a v1 envelope that **always includes tenant id** + +BullMQ job `stored-files.backfill.*` fills hashes/signatures for backfilled rows. File Explorer shows a short-hash badge and lock icon with tooltip details. + +When signing is enabled, newly generated invoice / void / credit / offer / timesheet PDFs print `Document ID: {shortSha}` (the same identity short SHA as the explorer badge). The content HMAC is not embedded in PDF bytes (that would invalidate the signature). + +## Authenticity verify + +With `BILLING_FILE_SIGNING_SECRET` set, File Explorer shows a verify control before Refresh. Admins upload a file; the API hashes it in memory, looks up `content_sha256`, verifies the HMAC, and returns a verdict (`authentic` / `unknown` / `unsigned` / `signing_disabled`). The upload is never written to storage or the database. Global-view operator tenants may match rows across tenants. + +## Find by Document ID + +With signing enabled, File Explorer also shows a search control. Admins enter the 7-character Document ID printed on a PDF (or the full 40-character identity hash). The API resolves the registry row, scopes by tenant (or across tenants for global-view operators), and streams the file bytes. Ambiguous short IDs that match more than one row require the full hash. + +## Multi-tenant views + +Like DATEV admin exports, File Explorer is tenant-enclosed by default: + +- Request tenant comes from `X-Tenant` (console `billing.tenantId`). +- List/download use an optional `viewTenantId` query for the selected tab. +- Foreign `viewTenantId` is accepted only when the request tenant is listed in **`TENANTS_ALLOW_GLOBAL_VIEWS`**. + +When global views are allowed, the page shows one tab per configured tenant plus a **Unified** tab. + +## API + +Admin-only (`ADMIN` roles + `billing_admin:read`): + +- `GET /admin/billing/files` +- `GET /admin/billing/files/download` +- `GET /admin/billing/files/archive` +- `GET /admin/billing/files/by-document-id?documentId=` +- `POST /admin/billing/files/verify` (multipart `document`; never persisted) + +## Security + +- Path traversal and absolute paths are rejected. +- Every download/archive member must be in the DB allow-set for the resolved view. +- Signature status is shown in the UI; download/archive never blocks on missing or failed verification (failures are logged). +- Clients never receive storage roots, signing secrets, or provider credentials. diff --git a/docs/decabill/features/invoices.md b/docs/decabill/features/invoices.md index ca24326e6..bb8d278b2 100644 --- a/docs/decabill/features/invoices.md +++ b/docs/decabill/features/invoices.md @@ -93,7 +93,7 @@ Admin routes under `/admin/billing/invoices`: ## PDF Generation -PDFs are stored under `{FILE_STORAGE_ROOT}/invoices` when using the local provider (compose default `/data/invoices`), or as S3 object keys `invoices/...` when `FILE_STORAGE_PROVIDER=s3`. Issuer details come from environment: +PDFs are stored under `{FILE_STORAGE_ROOT}/customer/invoices` when using the local provider (compose default `/data/customer/invoices`), or as S3 object keys `customer/invoices/...` when `FILE_STORAGE_PROVIDER=s3`. Issuer details come from environment: - `BILLING_ISSUER_*` (name, VAT ID, address, email, IBAN) - `BILLING_TAX_RATE_STANDARD` and `BILLING_TAX_RATE_REDUCED` diff --git a/docs/decabill/features/multi-tenancy.md b/docs/decabill/features/multi-tenancy.md index f8b0fe130..078a35d03 100644 --- a/docs/decabill/features/multi-tenancy.md +++ b/docs/decabill/features/multi-tenancy.md @@ -83,6 +83,15 @@ Each tenant has independent: Admin and user routes filter by the request tenant. Interactive auth (Keycloak or users) additionally requires the authenticated user's `tenant_id` to match the request tenant. +### Global admin views (`TENANTS_ALLOW_GLOBAL_VIEWS`) + +Comma-separated tenant ids allowed to use **cross-tenant admin views**: + +- DATEV unified export access (also requires `BILLING_DATEV_UNIFIED_EXPORT_ENABLED`) +- DATEV and File Explorer per-tenant tabs plus Unified consolidation + +Default when unset: `default`. Operators keep their console `X-Tenant` as the home tenant; selected tabs send `viewTenantId` (or DATEV body field) which the API accepts only when the home tenant is allowlisted. + ## API Key Auth and Tenant Scope When `AUTHENTICATION_METHOD=api-key` (or api-key is inferred from `STATIC_API_KEY`): diff --git a/docs/decabill/features/supplier-invoices.md b/docs/decabill/features/supplier-invoices.md index 0fdb96e94..e48e08ff4 100644 --- a/docs/decabill/features/supplier-invoices.md +++ b/docs/decabill/features/supplier-invoices.md @@ -73,7 +73,7 @@ List filters: `supplierId`, `status`, `search`, pagination. | `uploaded` | Operator attached PDF/XML at create/update | | `generated` | System PDF created at issue when no upload | -Stored under the `supplier-invoices` file-storage scope (`FILE_STORAGE_ROOT` / S3 prefix). +Stored under the `supplierInvoices` file-storage scope (`supplier/invoices` under `FILE_STORAGE_ROOT` / S3 prefix). ## Tax Treatment diff --git a/docs/decabill/security/container-images.md b/docs/decabill/security/container-images.md index 87cb856e5..7c49954c6 100644 --- a/docs/decabill/security/container-images.md +++ b/docs/decabill/security/container-images.md @@ -41,10 +41,10 @@ Source: `apps/decabill/backend-billing-manager/Dockerfile.api` ### Volumes (typical compose) -| Mount | Purpose | -| -------------------- | ------------------------------------------------------- | -| `billing_file_data` | Shared files at `/data` (`invoices/`, `datev-exports/`) | -| `./provider-plugins` | Optional dynamic payment/provider plugins | +| Mount | Purpose | +| -------------------- | ------------------------------------------------------------------- | +| `billing_file_data` | Shared files at `/data` (`customer/`, `supplier/`, `export/datev/`) | +| `./provider-plugins` | Optional dynamic payment/provider plugins | ### Build arguments diff --git a/docs/decabill/troubleshooting/debugging-guide.md b/docs/decabill/troubleshooting/debugging-guide.md index 5b6d8f76f..a5b7a26b8 100644 --- a/docs/decabill/troubleshooting/debugging-guide.md +++ b/docs/decabill/troubleshooting/debugging-guide.md @@ -143,7 +143,7 @@ Use browser DevTools Network tab or a Socket.IO client with: ### Invoice PDF Missing 1. Verify worker and API share `billing_file_data` (`FILE_STORAGE_ROOT`, default `/data`) in compose -2. Check `FILE_STORAGE_ROOT` and that invoice files exist under `{root}/invoices` +2. Check `FILE_STORAGE_ROOT` and that invoice files exist under `{root}/customer/invoices` (legacy `{root}/invoices` may still exist during dual-read) 3. Review worker logs during PDF generation jobs ### Multi-Tenant Data in Wrong Tenant diff --git a/graph/graph.json b/graph/graph.json index 0376c31b6..a1d26f3c6 100644 --- a/graph/graph.json +++ b/graph/graph.json @@ -1,6 +1,6 @@ { "version": 1, - "generatedAt": "2026-10-04T09:46:19.230Z", + "generatedAt": "2026-10-04T15:47:00.617Z", "nodes": [ { "id": "project:@forepath/test/mounted-plugin-fixture", @@ -3229,6 +3229,23 @@ ] } }, + { + "id": "file:libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/admin-file-manager", + "type": "state", + "attrs": { + "path": "libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/admin-file-manager", + "languageOrKind": "ts", + "projectName": "decabill-frontend-data-access-billing-console", + "sliceName": "admin-file-manager", + "memberFiles": [ + "admin-file-manager.actions.ts", + "admin-file-manager.effects.ts", + "admin-file-manager.facade.ts", + "admin-file-manager.reducer.ts", + "admin-file-manager.selectors.ts" + ] + } + }, { "id": "file:libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/admin-invoice-manager", "type": "state", @@ -6935,6 +6952,15 @@ "projectName": "decabill-backend-feature-billing-manager" } }, + { + "id": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/admin-file-manager.controller.ts", + "type": "controller", + "attrs": { + "path": "libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/admin-file-manager.controller.ts", + "languageOrKind": "ts", + "projectName": "decabill-backend-feature-billing-manager" + } + }, { "id": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/admin-promotions.controller.ts", "type": "controller", @@ -7178,6 +7204,15 @@ "projectName": "decabill-backend-feature-billing-manager" } }, + { + "id": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/dto/admin-file-manager.dto.ts", + "type": "dto", + "attrs": { + "path": "libs/domains/decabill/backend/feature-billing-manager/src/lib/dto/admin-file-manager.dto.ts", + "languageOrKind": "ts", + "projectName": "decabill-backend-feature-billing-manager" + } + }, { "id": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/dto/admin-supplier-profile.dto.ts", "type": "dto", @@ -8024,6 +8059,15 @@ "projectName": "decabill-backend-feature-billing-manager" } }, + { + "id": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/entities/stored-file.entity.ts", + "type": "entity", + "attrs": { + "path": "libs/domains/decabill/backend/feature-billing-manager/src/lib/entities/stored-file.entity.ts", + "languageOrKind": "ts", + "projectName": "decabill-backend-feature-billing-manager" + } + }, { "id": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/entities/subscription-addon.entity.ts", "type": "entity", @@ -9203,6 +9247,15 @@ "projectName": "decabill-backend-feature-billing-manager" } }, + { + "id": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/admin-file-manager.service.ts", + "type": "service", + "attrs": { + "path": "libs/domains/decabill/backend/feature-billing-manager/src/lib/services/admin-file-manager.service.ts", + "languageOrKind": "ts", + "projectName": "decabill-backend-feature-billing-manager" + } + }, { "id": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/auto-billing.service.ts", "type": "service", @@ -9833,6 +9886,33 @@ "projectName": "decabill-backend-feature-billing-manager" } }, + { + "id": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/stored-file-registry.service.ts", + "type": "service", + "attrs": { + "path": "libs/domains/decabill/backend/feature-billing-manager/src/lib/services/stored-file-registry.service.ts", + "languageOrKind": "ts", + "projectName": "decabill-backend-feature-billing-manager" + } + }, + { + "id": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/stored-file-signing-config.service.ts", + "type": "service", + "attrs": { + "path": "libs/domains/decabill/backend/feature-billing-manager/src/lib/services/stored-file-signing-config.service.ts", + "languageOrKind": "ts", + "projectName": "decabill-backend-feature-billing-manager" + } + }, + { + "id": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/stored-files-backfill.job-handler.ts", + "type": "job", + "attrs": { + "path": "libs/domains/decabill/backend/feature-billing-manager/src/lib/services/stored-files-backfill.job-handler.ts", + "languageOrKind": "ts", + "projectName": "decabill-backend-feature-billing-manager" + } + }, { "id": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/subscription-billing.job-handler.ts", "type": "job", @@ -10049,6 +10129,15 @@ "projectName": "decabill-backend-feature-billing-manager" } }, + { + "id": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/tenants-global-views-config.service.ts", + "type": "service", + "attrs": { + "path": "libs/domains/decabill/backend/feature-billing-manager/src/lib/services/tenants-global-views-config.service.ts", + "languageOrKind": "ts", + "projectName": "decabill-backend-feature-billing-manager" + } + }, { "id": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/usage.service.ts", "type": "service", @@ -11935,6 +12024,15 @@ "projectName": "shared-backend-util-file-storage" } }, + { + "id": "file:libs/domains/shared/backend/util-file-storage/src/lib/file-storage-layout-migration.service.ts", + "type": "service", + "attrs": { + "path": "libs/domains/shared/backend/util-file-storage/src/lib/file-storage-layout-migration.service.ts", + "languageOrKind": "ts", + "projectName": "shared-backend-util-file-storage" + } + }, { "id": "file:libs/domains/shared/backend/util-file-storage/src/lib/file-storage-legacy-migration.service.ts", "type": "service", @@ -14255,6 +14353,14 @@ "languageOrKind": "md" } }, + { + "id": "file:docs/decabill/features/file-explorer.md", + "type": "doc", + "attrs": { + "path": "docs/decabill/features/file-explorer.md", + "languageOrKind": "md" + } + }, { "id": "file:docs/decabill/features/invoices.md", "type": "doc", @@ -19800,6 +19906,61 @@ "specKind": "openapi" } }, + { + "id": "api:HTTP:GET:/admin/billing/files", + "type": "endpoint", + "attrs": { + "method": "GET", + "pathOrChannel": "/admin/billing/files", + "operationId": "listAdminBillingFiles", + "summary": "List virtual storage directory for managed billing files (admin only)", + "specKind": "openapi" + } + }, + { + "id": "api:HTTP:GET:/admin/billing/files/download", + "type": "endpoint", + "attrs": { + "method": "GET", + "pathOrChannel": "/admin/billing/files/download", + "operationId": "downloadAdminBillingFile", + "summary": "Download a managed billing file (admin only)", + "specKind": "openapi" + } + }, + { + "id": "api:HTTP:GET:/admin/billing/files/archive", + "type": "endpoint", + "attrs": { + "method": "GET", + "pathOrChannel": "/admin/billing/files/archive", + "operationId": "downloadAdminBillingFileArchive", + "summary": "Download a folder subtree of managed billing files as ZIP (admin only)", + "specKind": "openapi" + } + }, + { + "id": "api:HTTP:POST:/admin/billing/files/verify", + "type": "endpoint", + "attrs": { + "method": "POST", + "pathOrChannel": "/admin/billing/files/verify", + "operationId": "verifyAdminBillingFile", + "summary": "Verify an uploaded file against the signed stored-file registry (admin only; never persists upload)", + "specKind": "openapi" + } + }, + { + "id": "api:HTTP:GET:/admin/billing/files/by-document-id", + "type": "endpoint", + "attrs": { + "method": "GET", + "pathOrChannel": "/admin/billing/files/by-document-id", + "operationId": "downloadAdminBillingFileByDocumentId", + "summary": "Download a managed billing file by printed document ID (short or long SHA)", + "specKind": "openapi" + } + }, { "id": "api:HTTP:GET:/admin/billing/datev-exports", "type": "endpoint", @@ -29448,6 +29609,16 @@ "domain": "decabill" } }, + { + "id": "concept:decabill-file-explorer", + "type": "concept", + "attrs": { + "title": "File Explorer", + "docPath": "docs/decabill/features/billing-administration.md", + "sectionAnchor": "file-explorer", + "domain": "decabill" + } + }, { "id": "concept:decabill-admin-projects", "type": "concept", @@ -30128,6 +30299,76 @@ "domain": "decabill" } }, + { + "id": "concept:decabill-admin-file-explorer", + "type": "concept", + "attrs": { + "title": "Admin File Explorer", + "docPath": "docs/decabill/features/file-explorer.md", + "sectionAnchor": "admin-file-explorer", + "domain": "decabill" + } + }, + { + "id": "concept:decabill-storage-layout", + "type": "concept", + "attrs": { + "title": "Storage layout", + "docPath": "docs/decabill/features/file-explorer.md", + "sectionAnchor": "storage-layout", + "domain": "decabill" + } + }, + { + "id": "concept:decabill-registry-hashes-and-signatures", + "type": "concept", + "attrs": { + "title": "Registry, hashes, and signatures", + "docPath": "docs/decabill/features/file-explorer.md", + "sectionAnchor": "registry-hashes-and-signatures", + "domain": "decabill" + } + }, + { + "id": "concept:decabill-authenticity-verify", + "type": "concept", + "attrs": { + "title": "Authenticity verify", + "docPath": "docs/decabill/features/file-explorer.md", + "sectionAnchor": "authenticity-verify", + "domain": "decabill" + } + }, + { + "id": "concept:decabill-find-by-document-id", + "type": "concept", + "attrs": { + "title": "Find by Document ID", + "docPath": "docs/decabill/features/file-explorer.md", + "sectionAnchor": "find-by-document-id", + "domain": "decabill" + } + }, + { + "id": "concept:decabill-multi-tenant-views", + "type": "concept", + "attrs": { + "title": "Multi-tenant views", + "docPath": "docs/decabill/features/file-explorer.md", + "sectionAnchor": "multi-tenant-views", + "domain": "decabill" + } + }, + { + "id": "concept:decabill-api", + "type": "concept", + "attrs": { + "title": "API", + "docPath": "docs/decabill/features/file-explorer.md", + "sectionAnchor": "api", + "domain": "decabill" + } + }, { "id": "concept:decabill-invoices", "type": "concept", @@ -31178,16 +31419,6 @@ "domain": "decabill" } }, - { - "id": "concept:decabill-api", - "type": "concept", - "attrs": { - "title": "API", - "docPath": "docs/decabill/features/subscription-config-change.md", - "sectionAnchor": "api", - "domain": "decabill" - } - }, { "id": "concept:decabill-async-processing", "type": "concept", @@ -36200,6 +36431,11 @@ "to": "file:libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/admin-datev-exports", "type": "contains" }, + { + "from": "project:decabill-frontend-data-access-billing-console", + "to": "file:libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/admin-file-manager", + "type": "contains" + }, { "from": "project:decabill-frontend-data-access-billing-console", "to": "file:libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/admin-invoice-manager", @@ -37985,6 +38221,11 @@ "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/admin-datev-exports.controller.ts", "type": "contains" }, + { + "from": "project:decabill-backend-feature-billing-manager", + "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/admin-file-manager.controller.ts", + "type": "contains" + }, { "from": "project:decabill-backend-feature-billing-manager", "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/admin-promotions.controller.ts", @@ -38120,6 +38361,11 @@ "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/dto/admin-datev-export.dto.ts", "type": "contains" }, + { + "from": "project:decabill-backend-feature-billing-manager", + "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/dto/admin-file-manager.dto.ts", + "type": "contains" + }, { "from": "project:decabill-backend-feature-billing-manager", "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/dto/admin-supplier-profile.dto.ts", @@ -38590,6 +38836,11 @@ "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/entities/service-type.entity.ts", "type": "contains" }, + { + "from": "project:decabill-backend-feature-billing-manager", + "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/entities/stored-file.entity.ts", + "type": "contains" + }, { "from": "project:decabill-backend-feature-billing-manager", "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/entities/subscription-addon.entity.ts", @@ -39245,6 +39496,11 @@ "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/admin-bill-now.service.ts", "type": "contains" }, + { + "from": "project:decabill-backend-feature-billing-manager", + "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/admin-file-manager.service.ts", + "type": "contains" + }, { "from": "project:decabill-backend-feature-billing-manager", "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/auto-billing.service.ts", @@ -39595,6 +39851,21 @@ "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/ssh-executor.service.ts", "type": "contains" }, + { + "from": "project:decabill-backend-feature-billing-manager", + "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/stored-file-registry.service.ts", + "type": "contains" + }, + { + "from": "project:decabill-backend-feature-billing-manager", + "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/stored-file-signing-config.service.ts", + "type": "contains" + }, + { + "from": "project:decabill-backend-feature-billing-manager", + "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/stored-files-backfill.job-handler.ts", + "type": "contains" + }, { "from": "project:decabill-backend-feature-billing-manager", "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/subscription-billing.job-handler.ts", @@ -39715,6 +39986,11 @@ "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/tax-treatment.service.ts", "type": "contains" }, + { + "from": "project:decabill-backend-feature-billing-manager", + "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/tenants-global-views-config.service.ts", + "type": "contains" + }, { "from": "project:decabill-backend-feature-billing-manager", "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/usage.service.ts", @@ -40740,6 +41016,11 @@ "to": "file:libs/domains/shared/backend/util-file-storage/README.md", "type": "contains" }, + { + "from": "project:shared-backend-util-file-storage", + "to": "file:libs/domains/shared/backend/util-file-storage/src/lib/file-storage-layout-migration.service.ts", + "type": "contains" + }, { "from": "project:shared-backend-util-file-storage", "to": "file:libs/domains/shared/backend/util-file-storage/src/lib/file-storage-legacy-migration.service.ts", @@ -44125,6 +44406,31 @@ "to": "api:HTTP:POST:/admin/billing/tax/preview", "type": "contains" }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/spec/openapi.yaml", + "to": "api:HTTP:GET:/admin/billing/files", + "type": "contains" + }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/spec/openapi.yaml", + "to": "api:HTTP:GET:/admin/billing/files/download", + "type": "contains" + }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/spec/openapi.yaml", + "to": "api:HTTP:GET:/admin/billing/files/archive", + "type": "contains" + }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/spec/openapi.yaml", + "to": "api:HTTP:POST:/admin/billing/files/verify", + "type": "contains" + }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/spec/openapi.yaml", + "to": "api:HTTP:GET:/admin/billing/files/by-document-id", + "type": "contains" + }, { "from": "file:libs/domains/decabill/backend/feature-billing-manager/spec/openapi.yaml", "to": "api:HTTP:GET:/admin/billing/datev-exports", @@ -50240,6 +50546,11 @@ "to": "concept:decabill-datev-accounts-payable", "type": "contains" }, + { + "from": "file:docs/decabill/features/billing-administration.md", + "to": "concept:decabill-file-explorer", + "type": "contains" + }, { "from": "file:docs/decabill/features/billing-administration.md", "to": "concept:decabill-admin-projects", @@ -50665,6 +50976,51 @@ "to": "concept:decabill-future-webhook-only-today", "type": "contains" }, + { + "from": "file:docs/decabill/features/file-explorer.md", + "to": "concept:decabill-admin-file-explorer", + "type": "contains" + }, + { + "from": "file:docs/decabill/features/file-explorer.md", + "to": "concept:decabill-overview", + "type": "contains" + }, + { + "from": "file:docs/decabill/features/file-explorer.md", + "to": "concept:decabill-storage-layout", + "type": "contains" + }, + { + "from": "file:docs/decabill/features/file-explorer.md", + "to": "concept:decabill-registry-hashes-and-signatures", + "type": "contains" + }, + { + "from": "file:docs/decabill/features/file-explorer.md", + "to": "concept:decabill-authenticity-verify", + "type": "contains" + }, + { + "from": "file:docs/decabill/features/file-explorer.md", + "to": "concept:decabill-find-by-document-id", + "type": "contains" + }, + { + "from": "file:docs/decabill/features/file-explorer.md", + "to": "concept:decabill-multi-tenant-views", + "type": "contains" + }, + { + "from": "file:docs/decabill/features/file-explorer.md", + "to": "concept:decabill-api", + "type": "contains" + }, + { + "from": "file:docs/decabill/features/file-explorer.md", + "to": "concept:decabill-security", + "type": "contains" + }, { "from": "file:docs/decabill/features/invoices.md", "to": "concept:decabill-invoices", @@ -55035,6 +55391,56 @@ "to": "api:HTTP:POST:/admin/billing/tax/preview", "type": "implements" }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/admin-billing.controller.ts", + "to": "api:HTTP:GET:/admin/billing/files", + "type": "implements" + }, + { + "from": "project:decabill-backend-feature-billing-manager", + "to": "api:HTTP:GET:/admin/billing/files", + "type": "implements" + }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/admin-billing.controller.ts", + "to": "api:HTTP:GET:/admin/billing/files/download", + "type": "implements" + }, + { + "from": "project:decabill-backend-feature-billing-manager", + "to": "api:HTTP:GET:/admin/billing/files/download", + "type": "implements" + }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/admin-billing.controller.ts", + "to": "api:HTTP:GET:/admin/billing/files/archive", + "type": "implements" + }, + { + "from": "project:decabill-backend-feature-billing-manager", + "to": "api:HTTP:GET:/admin/billing/files/archive", + "type": "implements" + }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/admin-billing.controller.ts", + "to": "api:HTTP:POST:/admin/billing/files/verify", + "type": "implements" + }, + { + "from": "project:decabill-backend-feature-billing-manager", + "to": "api:HTTP:POST:/admin/billing/files/verify", + "type": "implements" + }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/admin-billing.controller.ts", + "to": "api:HTTP:GET:/admin/billing/files/by-document-id", + "type": "implements" + }, + { + "from": "project:decabill-backend-feature-billing-manager", + "to": "api:HTTP:GET:/admin/billing/files/by-document-id", + "type": "implements" + }, { "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/admin-billing.controller.ts", "to": "api:HTTP:GET:/admin/billing/datev-exports", @@ -55610,6 +56016,31 @@ "to": "api:HTTP:GET:/admin/billing/datev-exports/{exportId}/download", "type": "implements" }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/admin-file-manager.controller.ts", + "to": "api:HTTP:GET:/admin/billing/files", + "type": "implements" + }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/admin-file-manager.controller.ts", + "to": "api:HTTP:GET:/admin/billing/files/download", + "type": "implements" + }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/admin-file-manager.controller.ts", + "to": "api:HTTP:GET:/admin/billing/files/archive", + "type": "implements" + }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/admin-file-manager.controller.ts", + "to": "api:HTTP:POST:/admin/billing/files/verify", + "type": "implements" + }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/admin-file-manager.controller.ts", + "to": "api:HTTP:GET:/admin/billing/files/by-document-id", + "type": "implements" + }, { "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/admin-promotions.controller.ts", "to": "api:HTTP:GET:/admin/billing/promotions", @@ -56115,6 +56546,31 @@ "to": "api:HTTP:POST:/admin/billing/tax/preview", "type": "implements" }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/admin-supplier-invoices.controller.ts", + "to": "api:HTTP:GET:/admin/billing/files", + "type": "implements" + }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/admin-supplier-invoices.controller.ts", + "to": "api:HTTP:GET:/admin/billing/files/download", + "type": "implements" + }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/admin-supplier-invoices.controller.ts", + "to": "api:HTTP:GET:/admin/billing/files/archive", + "type": "implements" + }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/admin-supplier-invoices.controller.ts", + "to": "api:HTTP:POST:/admin/billing/files/verify", + "type": "implements" + }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/admin-supplier-invoices.controller.ts", + "to": "api:HTTP:GET:/admin/billing/files/by-document-id", + "type": "implements" + }, { "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/admin-supplier-invoices.controller.ts", "to": "api:HTTP:GET:/admin/billing/datev-exports", @@ -61020,11 +61476,26 @@ "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/datev-export-config.service.ts", "type": "injects" }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/admin-billing.controller.ts", + "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/tenants-global-views-config.service.ts", + "type": "injects" + }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/admin-billing.controller.ts", + "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/billing-tenant.service.ts", + "type": "injects" + }, { "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/admin-billing.controller.ts", "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/tax-preview.service.ts", "type": "injects" }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/admin-billing.controller.ts", + "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/stored-file-signing-config.service.ts", + "type": "injects" + }, { "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/admin-customer-auto-billing.controller.ts", "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/auto-billing.service.ts", @@ -61045,6 +61516,11 @@ "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/datev-export-admin.service.ts", "type": "injects" }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/admin-file-manager.controller.ts", + "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/admin-file-manager.service.ts", + "type": "injects" + }, { "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/admin-promotions.controller.ts", "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/promotion-admin.service.ts", @@ -61600,6 +62076,11 @@ "to": "file:libs/domains/shared/backend/util-file-storage/src/lib/file-storage.service.ts", "type": "injects" }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/offers/services/offer-pdf.service.ts", + "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/stored-file-registry.service.ts", + "type": "injects" + }, { "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/offers/services/offer-profile-summary.service.ts", "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/offers/repositories/offers.repository.ts", @@ -61975,6 +62456,11 @@ "to": "file:libs/domains/shared/backend/util-file-storage/src/lib/file-storage.service.ts", "type": "injects" }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/projects/services/project-time-report-pdf.service.ts", + "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/stored-file-registry.service.ts", + "type": "injects" + }, { "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/projects/services/project-time-report.service.ts", "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/projects/repositories/projects.repository.ts", @@ -62125,6 +62611,31 @@ "to": "file:libs/domains/identity/backend/feature-auth/src/lib/repositories/users.repository.ts", "type": "injects" }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/admin-file-manager.service.ts", + "to": "file:libs/domains/shared/backend/util-file-storage/src/lib/file-storage.service.ts", + "type": "injects" + }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/admin-file-manager.service.ts", + "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/tenants-global-views-config.service.ts", + "type": "injects" + }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/admin-file-manager.service.ts", + "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/billing-tenant.service.ts", + "type": "injects" + }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/admin-file-manager.service.ts", + "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/stored-file-registry.service.ts", + "type": "injects" + }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/admin-file-manager.service.ts", + "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/stored-file-signing-config.service.ts", + "type": "injects" + }, { "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/auto-billing.service.ts", "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/repositories/customer-profiles.repository.ts", @@ -62515,6 +63026,11 @@ "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/datev-export-config.service.ts", "type": "injects" }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/datev-export-admin.service.ts", + "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/tenants-global-views-config.service.ts", + "type": "injects" + }, { "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/datev-export-admin.service.ts", "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/repositories/datev-export.repository.ts", @@ -62525,6 +63041,11 @@ "to": "file:libs/domains/shared/backend/util-file-storage/src/lib/file-storage.service.ts", "type": "injects" }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/datev-export-config.service.ts", + "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/tenants-global-views-config.service.ts", + "type": "injects" + }, { "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/datev-export.job-handler.ts", "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/datev-export-config.service.ts", @@ -62635,6 +63156,11 @@ "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/datev-document-archive.service.ts", "type": "injects" }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/datev-export.service.ts", + "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/stored-file-registry.service.ts", + "type": "injects" + }, { "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/hostname-reservation.service.ts", "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/repositories/reserved-hostnames.repository.ts", @@ -62855,6 +63381,11 @@ "to": "file:libs/domains/shared/backend/util-file-storage/src/lib/file-storage.service.ts", "type": "injects" }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/invoice-pdf.service.ts", + "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/stored-file-registry.service.ts", + "type": "injects" + }, { "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/invoice-tax-context.service.ts", "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/repositories/customer-profiles.repository.ts", @@ -63410,6 +63941,21 @@ "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/subscription-config-change-billing.service.ts", "type": "injects" }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/stored-file-registry.service.ts", + "to": "file:libs/domains/shared/backend/util-file-storage/src/lib/file-storage.service.ts", + "type": "injects" + }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/stored-file-registry.service.ts", + "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/stored-file-signing-config.service.ts", + "type": "injects" + }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/stored-files-backfill.job-handler.ts", + "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/stored-file-registry.service.ts", + "type": "injects" + }, { "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/subscription-billing.job-handler.ts", "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/repositories/subscriptions.repository.ts", @@ -64005,6 +64551,11 @@ "to": "file:libs/domains/shared/backend/util-file-storage/src/lib/file-storage.service.ts", "type": "injects" }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/supplier-invoice-pdf.service.ts", + "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/stored-file-registry.service.ts", + "type": "injects" + }, { "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/supplier-invoices-admin.service.ts", "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/repositories/supplier-invoices.repository.ts", @@ -64060,6 +64611,11 @@ "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/billing-audit-log.service.ts", "type": "injects" }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/supplier-invoices-admin.service.ts", + "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/stored-file-registry.service.ts", + "type": "injects" + }, { "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/supplier-profiles-admin.service.ts", "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/repositories/supplier-profiles.repository.ts", @@ -65100,6 +65656,11 @@ "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/search/search-reindex.job-handler.ts", "type": "injects" }, + { + "from": "file:apps/decabill/backend-billing-manager/src/queue/processors/billing-jobs.processor.ts", + "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/stored-files-backfill.job-handler.ts", + "type": "injects" + }, { "from": "file:apps/decabill/backend-billing-manager/src/queue/processors/billing-jobs.processor.ts", "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/offers/services/offer-expiration.job-handler.ts", @@ -65780,6 +66341,11 @@ "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/admin-datev-exports.controller.ts", "type": "provides" }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/billing.module.ts", + "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/admin-file-manager.controller.ts", + "type": "provides" + }, { "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/billing.module.ts", "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/payments-webhook.controller.ts", @@ -66040,6 +66606,31 @@ "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/billing-tenant.service.ts", "type": "provides" }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/billing.module.ts", + "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/tenants-global-views-config.service.ts", + "type": "provides" + }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/billing.module.ts", + "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/admin-file-manager.service.ts", + "type": "provides" + }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/billing.module.ts", + "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/stored-file-signing-config.service.ts", + "type": "provides" + }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/billing.module.ts", + "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/stored-file-registry.service.ts", + "type": "provides" + }, + { + "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/billing.module.ts", + "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/stored-files-backfill.job-handler.ts", + "type": "provides" + }, { "from": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/billing.module.ts", "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/services/billing-statistics-query.service.ts", @@ -67305,6 +67896,11 @@ "to": "file:libs/domains/shared/backend/util-file-storage/src/lib/file-storage-legacy-migration.service.ts", "type": "provides" }, + { + "from": "file:libs/domains/shared/backend/util-file-storage/src/lib/file-storage.module.ts", + "to": "file:libs/domains/shared/backend/util-file-storage/src/lib/file-storage-layout-migration.service.ts", + "type": "provides" + }, { "from": "file:libs/domains/shared/backend/util-redis-cache/src/lib/redis-cache.module.ts", "to": "file:libs/domains/shared/backend/util-redis-cache/src/lib/redis-cache.service.ts", @@ -67560,6 +68156,26 @@ "to": "api:HTTP:GET:/admin/billing/datev-exports/{exportId}/download", "type": "calls" }, + { + "from": "file:libs/domains/decabill/frontend/data-access-billing-console/src/lib/services/admin-billing.service.ts", + "to": "api:HTTP:GET:/admin/billing/files", + "type": "calls" + }, + { + "from": "file:libs/domains/decabill/frontend/data-access-billing-console/src/lib/services/admin-billing.service.ts", + "to": "api:HTTP:POST:/admin/billing/files/verify", + "type": "calls" + }, + { + "from": "file:libs/domains/decabill/frontend/data-access-billing-console/src/lib/services/admin-billing.service.ts", + "to": "api:HTTP:GET:/admin/billing/files/download", + "type": "calls" + }, + { + "from": "file:libs/domains/decabill/frontend/data-access-billing-console/src/lib/services/admin-billing.service.ts", + "to": "api:HTTP:GET:/admin/billing/files/archive", + "type": "calls" + }, { "from": "file:libs/domains/decabill/frontend/data-access-billing-console/src/lib/services/admin-billing.service.ts", "to": "api:HTTP:GET:/admin/billing/subscriptions/{subscriptionId}/meter-entries", @@ -73325,6 +73941,11 @@ "to": "api:HTTP:DELETE:/admin/billing/supplier-profiles/{id}", "type": "documents" }, + { + "from": "concept:decabill-file-explorer", + "to": "api:HTTP:GET:/admin/billing/files", + "type": "documents" + }, { "from": "concept:decabill-admin-projects", "to": "api:HTTP:GET:/projects", @@ -73995,6 +74616,21 @@ "to": "api:HTTP:GET:/service-types/providers", "type": "documents" }, + { + "from": "concept:decabill-api", + "to": "api:HTTP:GET:/subscriptions", + "type": "documents" + }, + { + "from": "concept:decabill-api", + "to": "api:HTTP:POST:/subscriptions", + "type": "documents" + }, + { + "from": "concept:decabill-api", + "to": "api:HTTP:GET:/config", + "type": "documents" + }, { "from": "concept:decabill-customer-invoice-access", "to": "api:HTTP:GET:/invoices/summary", @@ -75155,21 +75791,6 @@ "to": "api:HTTP:POST:/auth/register", "type": "documents" }, - { - "from": "concept:decabill-api", - "to": "api:HTTP:GET:/subscriptions", - "type": "documents" - }, - { - "from": "concept:decabill-api", - "to": "api:HTTP:POST:/subscriptions", - "type": "documents" - }, - { - "from": "concept:decabill-api", - "to": "api:HTTP:GET:/config", - "type": "documents" - }, { "from": "concept:decabill-operations", "to": "api:HTTP:GET:/addons", @@ -77755,6 +78376,11 @@ "to": "domain:decabill", "type": "belongs_to" }, + { + "from": "file:docs/decabill/features/file-explorer.md", + "to": "domain:decabill", + "type": "belongs_to" + }, { "from": "file:docs/decabill/features/invoices.md", "to": "domain:decabill", @@ -81275,6 +81901,11 @@ "to": "domain:decabill", "type": "belongs_to" }, + { + "from": "concept:decabill-file-explorer", + "to": "domain:decabill", + "type": "belongs_to" + }, { "from": "concept:decabill-admin-projects", "to": "domain:decabill", @@ -81615,6 +82246,41 @@ "to": "domain:decabill", "type": "belongs_to" }, + { + "from": "concept:decabill-admin-file-explorer", + "to": "domain:decabill", + "type": "belongs_to" + }, + { + "from": "concept:decabill-storage-layout", + "to": "domain:decabill", + "type": "belongs_to" + }, + { + "from": "concept:decabill-registry-hashes-and-signatures", + "to": "domain:decabill", + "type": "belongs_to" + }, + { + "from": "concept:decabill-authenticity-verify", + "to": "domain:decabill", + "type": "belongs_to" + }, + { + "from": "concept:decabill-find-by-document-id", + "to": "domain:decabill", + "type": "belongs_to" + }, + { + "from": "concept:decabill-multi-tenant-views", + "to": "domain:decabill", + "type": "belongs_to" + }, + { + "from": "concept:decabill-api", + "to": "domain:decabill", + "type": "belongs_to" + }, { "from": "concept:decabill-invoices", "to": "domain:decabill", @@ -82140,11 +82806,6 @@ "to": "domain:decabill", "type": "belongs_to" }, - { - "from": "concept:decabill-api", - "to": "domain:decabill", - "type": "belongs_to" - }, { "from": "concept:decabill-async-processing", "to": "domain:decabill", diff --git a/libs/domains/decabill/backend/feature-billing-manager/README.md b/libs/domains/decabill/backend/feature-billing-manager/README.md index 2aa8ee415..6aeb6e061 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/README.md +++ b/libs/domains/decabill/backend/feature-billing-manager/README.md @@ -52,13 +52,15 @@ When **`AUTHENTICATION_METHOD=api-key`** (or api-key is inferred from **`STATIC_ - `TENANTS` (optional; comma-separated tenant ids, e.g. `one,two`) – allowed tenant ids for `X-Tenant` requests. Includes `default` unless `TENANTS_ALLOW_DEFAULT=false`. When unset or empty and default is allowed, only `default` is allowed. - `TENANTS_ALLOW_DEFAULT` (optional; default allow) – set to `false` to exclude `default` from the allowlist. Missing, blank, or `default` **`X-Tenant`** values are then rejected. +- `TENANTS_ALLOW_GLOBAL_VIEWS` (optional; default `default`) – tenant ids allowed to use cross-tenant admin views (DATEV unified + File Explorer tenant tabs / consolidation). - `STATIC_API_KEY_TENANT_ID` (optional) – when set, API key auth is only accepted for this tenant id (must match `X-Tenant`). When unset, one **`STATIC_API_KEY`** may access **all** configured tenants via **`X-Tenant`** (accepted risk **AR-007**). - Public catalog (`/public/service-plan-offerings`) is unauthenticated; tenant is selected via `X-Tenant` (defaults to `default`). Restrict allowed tenants with `TENANTS`. - `BILLING_FRONTEND_URL` (optional; default derived from `STRIPE_CHECKOUT_SUCCESS_URL` origin or `http://localhost:4500`) – billing console base URL for the `default` tenant; used for Stripe success/cancel redirects. - `TENANT_FRONTEND_URLS` (optional; `tenantId=https://billing.example.com` pairs, comma-separated) – per-tenant billing console base URLs for Stripe return redirects. - BILLING*ISSUER*\* (name, VAT ID, address, email, IBAN) and BILLING_TAX_RATE_STANDARD / BILLING_TAX_RATE_REDUCED - BILLING_STATUTORY_WITHDRAWAL_PERIOD_DAYS (default 14) — statutory withdrawal window after provisioning -- FILE*STORAGE_PROVIDER (`local` | `s3`), FILE_STORAGE_ROOT, FILE_STORAGE_LEGACY_MIGRATION_ENABLED, FILE_STORAGE_S3*\* (shared file storage; deprecated BILLING_INVOICE_PDF_STORAGE_PATH / BILLING_DATEV_EXPORT_STORAGE_PATH as migration sources) +- FILE*STORAGE_PROVIDER (`local` | `s3`), FILE_STORAGE_ROOT, FILE_STORAGE_LEGACY_MIGRATION_ENABLED, FILE_STORAGE_LAYOUT_MIGRATION_ENABLED, FILE_STORAGE_LAYOUT_DUAL_READ_ENABLED, FILE_STORAGE_S3*\* (shared file storage; deprecated BILLING_INVOICE_PDF_STORAGE_PATH / BILLING_DATEV_EXPORT_STORAGE_PATH as migration sources) +- `BILLING_FILE_SIGNING_SECRET` (required in production) – global HMAC secret for `billing_stored_files` signatures - BILLING_DEFAULT_PAYMENT_PROCESSOR - STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET, STRIPE_CHECKOUT_SUCCESS_URL, STRIPE_CHECKOUT_CANCEL_URL (path portion used with tenant frontend base; legacy full URL still sets default-tenant origin) - DB_HOST, DB_PORT, DB_USERNAME, DB_PASSWORD, DB_DATABASE diff --git a/libs/domains/decabill/backend/feature-billing-manager/spec/openapi.yaml b/libs/domains/decabill/backend/feature-billing-manager/spec/openapi.yaml index 1b2cec94b..2ae7a903d 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/spec/openapi.yaml +++ b/libs/domains/decabill/backend/feature-billing-manager/spec/openapi.yaml @@ -5670,6 +5670,150 @@ paths: description: Unauthorized '403': description: Admin access required + /admin/billing/files: + get: + tags: [Admin Billing] + summary: List virtual storage directory for managed billing files (admin only) + operationId: listAdminBillingFiles + parameters: + - name: path + in: query + schema: { type: string } + description: Virtual path (empty for root) + - name: view + in: query + schema: + type: string + enum: [tenant, unified] + default: tenant + - name: viewTenantId + in: query + schema: { type: string } + description: Tenant tab id; foreign ids require TENANTS_ALLOW_GLOBAL_VIEWS + responses: + '200': + description: Directory listing + content: + application/json: + schema: + $ref: '#/components/schemas/AdminFileManagerListResponse' + '403': + description: Cross-tenant or unified view denied + /admin/billing/files/download: + get: + tags: [Admin Billing] + summary: Download a managed billing file (admin only) + operationId: downloadAdminBillingFile + parameters: + - name: path + in: query + required: true + schema: { type: string } + - name: view + in: query + schema: + type: string + enum: [tenant, unified] + default: tenant + - name: viewTenantId + in: query + schema: { type: string } + responses: + '200': + description: File bytes + content: + application/octet-stream: + schema: + type: string + format: binary + '403': + description: Cross-tenant or unified view denied + '404': + description: File not found in tenant allow-set + /admin/billing/files/archive: + get: + tags: [Admin Billing] + summary: Download a folder subtree of managed billing files as ZIP (admin only) + operationId: downloadAdminBillingFileArchive + parameters: + - name: path + in: query + schema: { type: string } + description: Virtual folder path (empty for all visible files) + - name: view + in: query + schema: + type: string + enum: [tenant, unified] + default: tenant + - name: viewTenantId + in: query + schema: { type: string } + responses: + '200': + description: ZIP archive + content: + application/zip: + schema: + type: string + format: binary + '403': + description: Cross-tenant or unified view denied + '404': + description: Folder not found or empty + '413': + description: Archive exceeds size or entry limits + /admin/billing/files/verify: + post: + tags: [Admin Billing] + summary: Verify an uploaded file against the signed stored-file registry (admin only; never persists upload) + operationId: verifyAdminBillingFile + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + required: [document] + properties: + document: + type: string + format: binary + description: File bytes to verify (not stored) + responses: + '200': + description: Authenticity verdict + content: + application/json: + schema: + $ref: '#/components/schemas/AdminFileVerifyResponse' + '400': + description: Missing document + '413': + description: Document exceeds size limit + /admin/billing/files/by-document-id: + get: + tags: [Admin Billing] + summary: Download a managed billing file by printed document ID (short or long SHA) + operationId: downloadAdminBillingFileByDocumentId + parameters: + - name: documentId + in: query + required: true + schema: { type: string } + description: 7-character short SHA (Document ID) or full 40-character long SHA + responses: + '200': + description: File bytes + content: + application/octet-stream: + schema: + type: string + format: binary + '400': + description: Invalid or ambiguous document ID + '404': + description: No file found for document ID /admin/billing/datev-exports: get: tags: [Admin Billing] @@ -5689,6 +5833,10 @@ paths: enum: [tenant, unified] default: tenant description: Unified scope requires allowlisted X-Tenant + - name: viewTenantId + in: query + schema: { type: string } + description: Tenant tab id for tenant scope; foreign ids require TENANTS_ALLOW_GLOBAL_VIEWS responses: '200': description: Paginated DATEV export list @@ -9539,14 +9687,114 @@ components: userCount: { type: integer } BillingCapabilitiesResponse: type: object - required: [datevExportEnabled, unifiedExportAllowed] + required: + [ + datevExportEnabled, + unifiedExportAllowed, + globalViewsAllowed, + viewableTenants, + fileSigningEnabled, + ] properties: datevExportEnabled: type: boolean description: Mirrors BILLING_DATEV_EXPORT_ENABLED unifiedExportAllowed: type: boolean - description: True when unified export is enabled and request tenant is in BILLING_DATEV_UNIFIED_EXPORT_ALLOWED_TENANTS + description: True when unified export is enabled and request tenant is in TENANTS_ALLOW_GLOBAL_VIEWS + globalViewsAllowed: + type: boolean + description: True when request tenant is in TENANTS_ALLOW_GLOBAL_VIEWS (cross-tenant admin tabs) + viewableTenants: + type: array + items: { type: string } + description: Tenant ids available as admin view tabs; all configured tenants when globalViewsAllowed, else the request tenant only + fileSigningEnabled: + type: boolean + description: True when BILLING_FILE_SIGNING_SECRET is configured + AdminFileManagerEntry: + type: object + required: [name, path, type] + properties: + name: { type: string } + path: { type: string } + type: + type: string + enum: [file, directory] + size: { type: integer } + contentType: { type: string } + scope: { type: string } + updatedAt: { type: string, format: date-time } + id: { type: string, format: uuid } + shas: + type: object + properties: + short: { type: string } + long: { type: string } + contentHashes: + type: object + properties: + md5: { type: string } + sha1: { type: string } + sha256: { type: string } + sha512: { type: string } + byteSize: { type: integer } + signature: + type: object + properties: + status: + type: string + enum: [signed, pending] + alg: { type: string } + version: { type: string } + value: { type: string } + signedAt: { type: string, format: date-time } + tenantId: { type: string } + AdminFileManagerListResponse: + type: object + required: [path, view, entries] + properties: + path: { type: string } + view: + type: string + enum: [tenant, unified] + viewTenantId: { type: string } + entries: + type: array + items: + $ref: '#/components/schemas/AdminFileManagerEntry' + AdminFileVerifyResponse: + type: object + required: [verdict] + properties: + verdict: + type: string + enum: [authentic, unknown, unsigned, signing_disabled] + contentSha256: { type: string } + match: + type: object + properties: + id: { type: string, format: uuid } + shas: + type: object + properties: + short: { type: string } + long: { type: string } + tenantId: { type: string } + scope: { type: string } + storageKey: { type: string } + virtualPath: { type: string } + signature: + type: object + properties: + status: + type: string + enum: [signed, pending] + alg: { type: string } + version: { type: string } + value: { type: string } + signedAt: { type: string, format: date-time } + tenantId: { type: string } DatevExportScope: type: string enum: [tenant, unified] @@ -9603,6 +9851,9 @@ components: year: { type: integer } month: { type: integer, minimum: 1, maximum: 12 } scope: { $ref: '#/components/schemas/DatevExportScope' } + viewTenantId: + type: string + description: Target tenant for tenant-scoped export; foreign ids require TENANTS_ALLOW_GLOBAL_VIEWS force: { type: boolean, diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/index.ts b/libs/domains/decabill/backend/feature-billing-manager/src/index.ts index ceea8f6ed..9471d0844 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/index.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/index.ts @@ -114,6 +114,11 @@ export * from './lib/entities/oss-threshold-ledger.entity'; export * from './lib/entities/datev-debtor-account.entity'; export * from './lib/entities/datev-creditor-account.entity'; export * from './lib/entities/datev-export.entity'; +export * from './lib/entities/stored-file.entity'; +export * from './lib/services/stored-file-registry.service'; +export * from './lib/services/stored-file-signing-config.service'; +export * from './lib/services/stored-files-backfill.job-handler'; +export * from './lib/entities/stored-file.entity'; export * from './lib/entities/supplier-profile.entity'; export * from './lib/entities/supplier-contract.entity'; export * from './lib/entities/supplier-invoice.entity'; diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/billing.module.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/billing.module.ts index 5a1a0e34c..cf8cc789e 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/billing.module.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/billing.module.ts @@ -45,6 +45,7 @@ import { AdminSupplierInvoicesController } from './controllers/admin-supplier-in import { AdminCustomerProfilesController } from './controllers/admin-customer-profiles.controller'; import { AdminCustomerAutoBillingController } from './controllers/admin-customer-auto-billing.controller'; import { AdminDatevExportsController } from './controllers/admin-datev-exports.controller'; +import { AdminFileManagerController } from './controllers/admin-file-manager.controller'; import { AvailabilityController } from './controllers/availability.controller'; import { BackordersController } from './controllers/backorders.controller'; import { CustomerProfilesController } from './controllers/customer-profiles.controller'; @@ -232,6 +233,12 @@ import { BillingAdminService } from './services/billing-admin.service'; import { BillingMetricsCollectorService } from './services/billing-metrics-collector.service'; import { BillingAuditLogService } from './services/billing-audit-log.service'; import { BillingTenantService } from './services/billing-tenant.service'; +import { AdminFileManagerService } from './services/admin-file-manager.service'; +import { StoredFileEntity } from './entities/stored-file.entity'; +import { StoredFileRegistryService } from './services/stored-file-registry.service'; +import { StoredFileSigningConfigService } from './services/stored-file-signing-config.service'; +import { StoredFilesBackfillJobHandler } from './services/stored-files-backfill.job-handler'; +import { TenantsGlobalViewsConfigService } from './services/tenants-global-views-config.service'; import { BillingIssuerConfigService } from './services/billing-issuer-config.service'; import { BillingScheduleService } from './services/billing-schedule.service'; import { BillingStatisticsQueryService } from './services/billing-statistics-query.service'; @@ -399,6 +406,7 @@ const authMethod = getAuthenticationMethod(); RevokedUserTokenEntity, UserPersonalAccessTokenEntity, DatevExportEntity, + StoredFileEntity, DatevDebtorAccountEntity, DatevCreditorAccountEntity, SupplierProfileEntity, @@ -449,6 +457,7 @@ const authMethod = getAuthenticationMethod(); AdminSupplierInvoicesController, AdminCustomerAutoBillingController, AdminDatevExportsController, + AdminFileManagerController, PaymentsWebhookController, AdminUsageController, UsageController, @@ -523,6 +532,11 @@ const authMethod = getAuthenticationMethod(); BillingMetricsCollectorService, BillingAuditLogService, BillingTenantService, + TenantsGlobalViewsConfigService, + AdminFileManagerService, + StoredFileSigningConfigService, + StoredFileRegistryService, + StoredFilesBackfillJobHandler, BillingStatisticsQueryService, InvoiceAdminService, ManualInvoiceService, @@ -732,6 +746,7 @@ const authMethod = getAuthenticationMethod(); BackorderRetryJobHandler, BillingScheduleService, BillingTenantService, + TenantsGlobalViewsConfigService, BillingEmailAttachmentResolver, EMAIL_ATTACHMENT_RESOLVER, EmailDeliveryService, @@ -794,6 +809,7 @@ const authMethod = getAuthenticationMethod(); DatevExportConfigService, BillingContributorHostModule, SearchReindexJobHandler, + StoredFilesBackfillJobHandler, BillingSearchModule, BillingIdentityEmailBridgeModule, BillingIdentityNotificationBridgeModule, diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/constants/admin-file-manager.constants.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/constants/admin-file-manager.constants.ts new file mode 100644 index 000000000..af03e8840 --- /dev/null +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/constants/admin-file-manager.constants.ts @@ -0,0 +1,24 @@ +export const AdminFileManagerView = { + TENANT: 'tenant', + UNIFIED: 'unified', +} as const; + +export type AdminFileManagerView = (typeof AdminFileManagerView)[keyof typeof AdminFileManagerView]; + +/** Max files included in a single archive download. */ +export const ADMIN_FILE_MANAGER_MAX_ARCHIVE_ENTRIES = 500; + +/** Max total uncompressed bytes for a single archive download (~100 MiB). */ +export const ADMIN_FILE_MANAGER_MAX_ARCHIVE_BYTES = 100 * 1024 * 1024; + +/** Max upload size for authenticity verify (same ballpark as supplier invoice docs). */ +export const ADMIN_FILE_MANAGER_MAX_VERIFY_BYTES = 15 * 1024 * 1024; + +export const AdminFileVerifyVerdict = { + AUTHENTIC: 'authentic', + UNKNOWN: 'unknown', + UNSIGNED: 'unsigned', + SIGNING_DISABLED: 'signing_disabled', +} as const; + +export type AdminFileVerifyVerdict = (typeof AdminFileVerifyVerdict)[keyof typeof AdminFileVerifyVerdict]; diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/constants/tenants-global-views.constants.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/constants/tenants-global-views.constants.ts new file mode 100644 index 000000000..6a459d679 --- /dev/null +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/constants/tenants-global-views.constants.ts @@ -0,0 +1,2 @@ +/** Comma-separated tenant ids allowed to use cross-tenant admin views (DATEV unified + File Explorer). */ +export const TENANTS_ALLOW_GLOBAL_VIEWS_ENV = 'TENANTS_ALLOW_GLOBAL_VIEWS'; diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/contributors/decabill-billing/decabill-billing.utils.spec.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/contributors/decabill-billing/decabill-billing.utils.spec.ts index f86447037..730953c22 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/contributors/decabill-billing/decabill-billing.utils.spec.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/contributors/decabill-billing/decabill-billing.utils.spec.ts @@ -23,14 +23,16 @@ describe('decabill-billing.utils', () => { expect(config.backend.websocketNamespace).toBe('socket/billing'); }); - it('generates random encryptionKey, jwtSecret, and database password', () => { + it('generates random encryptionKey, jwtSecret, fileSigningSecret, and database password', () => { const config1 = buildDecabillBillingCloudInitConfigFromRequest({}, 'host1'); const config2 = buildDecabillBillingCloudInitConfigFromRequest({}, 'host2'); expect(config1.backend.encryption.encryptionKey).toBeTruthy(); expect(config1.backend.encryption.jwtSecret).toBeTruthy(); + expect(config1.backend.encryption.fileSigningSecret).toBeTruthy(); expect(config1.backend.database?.password).toBeTruthy(); expect(config1.backend.encryption.encryptionKey).not.toBe(config2.backend.encryption.encryptionKey); + expect(config1.backend.encryption.fileSigningSecret).not.toBe(config2.backend.encryption.fileSigningSecret); expect(config1.backend.database?.password).not.toBe(config2.backend.database?.password); expect(config1.backend.rateLimit.enabled).toBe(true); }); @@ -82,6 +84,11 @@ describe('decabill-billing.utils', () => { expect(script).toContain(DECABILL_BILLING_STACK_DIR); expect(script).toContain('REDIS_KEY_PREFIX: decabill-billing'); expect(script).toContain('BILLING_FRONTEND_URL:'); + expect(script).toContain('FILE_STORAGE_PROVIDER: local'); + expect(script).toContain("FILE_STORAGE_ROOT: '/data'"); + expect(script).toContain('FILE_STORAGE_LAYOUT_MIGRATION_ENABLED: true'); + expect(script).toContain('FILE_STORAGE_LAYOUT_DUAL_READ_ENABLED: true'); + expect(script).toContain(`BILLING_FILE_SIGNING_SECRET: ${config.backend.encryption.fileSigningSecret}`); expect(script).toContain('location /socket/'); expect(script).toContain('location /socket.io/'); expect(script).toContain("WEBSOCKET_NAMESPACE: 'socket/billing'"); diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/contributors/decabill-billing/decabill-billing.utils.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/contributors/decabill-billing/decabill-billing.utils.ts index d4e92c902..6bd896de8 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/contributors/decabill-billing/decabill-billing.utils.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/contributors/decabill-billing/decabill-billing.utils.ts @@ -76,6 +76,8 @@ export interface DecabillBillingCloudInitConfig { encryption: { jwtSecret: string; encryptionKey: string; + /** Global HMAC secret for billing_stored_files signatures. */ + fileSigningSecret: string; }; smtp: { host: string; @@ -98,7 +100,7 @@ export interface DecabillBillingCloudInitConfig { /** * Builds Decabill billing stack cloud-init config from plan defaults + requestedConfig. - * Generates random encryptionKey and jwtSecret. + * Generates random encryptionKey, jwtSecret, and fileSigningSecret. */ export function buildDecabillBillingCloudInitConfigFromRequest( effectiveConfig: Record, @@ -107,6 +109,7 @@ export function buildDecabillBillingCloudInitConfigFromRequest( ): DecabillBillingCloudInitConfig { const encryptionKey = randomBytes(32).toString('base64'); const jwtSecret = randomBytes(32).toString('hex'); + const fileSigningSecret = randomBytes(32).toString('hex'); const databasePassword = randomBytes(24).toString('base64url'); const fqdn = `${hostname}.${baseDomain}`; const smtp = effectiveConfig.smtp as Record | undefined; @@ -156,7 +159,7 @@ export function buildDecabillBillingCloudInitConfigFromRequest( }, }), }, - encryption: { encryptionKey, jwtSecret }, + encryption: { encryptionKey, jwtSecret, fileSigningSecret }, smtp: { host: (smtp?.host as string) ?? 'mailhog', port: (smtp?.port as number) ?? 1025, @@ -219,6 +222,9 @@ export function buildDecabillBillingCloudInitUserData(config: DecabillBillingClo `FILE_STORAGE_PROVIDER: local`, `FILE_STORAGE_ROOT: /data`, `FILE_STORAGE_LEGACY_MIGRATION_ENABLED: true`, + `FILE_STORAGE_LAYOUT_MIGRATION_ENABLED: true`, + `FILE_STORAGE_LAYOUT_DUAL_READ_ENABLED: true`, + `BILLING_FILE_SIGNING_SECRET: ${config.backend?.encryption?.fileSigningSecret ?? ''}`, `BILLING_DATEV_EXPORT_ENABLED: false`, `TENANTS_ALLOW_DEFAULT: true`, `DNS_BASE_DOMAIN: ${config.host?.fqdn?.split('.').slice(1).join('.') || 'spirde.com'}`, diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/admin-billing.controller.spec.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/admin-billing.controller.spec.ts index a9df5b94c..bf08d009e 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/admin-billing.controller.spec.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/admin-billing.controller.spec.ts @@ -37,7 +37,14 @@ describe('AdminBillingController', () => { isEnabled: jest.fn().mockReturnValue(true), isUnifiedExportAllowedForTenant: jest.fn().mockReturnValue(false), }; + const tenantsGlobalViewsConfig = { + isGlobalViewsAllowedForTenant: jest.fn().mockReturnValue(false), + }; + const billingTenantService = { + getConfiguredTenants: jest.fn().mockReturnValue(['default', 'acme']), + }; const taxPreviewService = { preview: jest.fn() }; + const storedFileSigningConfig = { getSecret: jest.fn().mockReturnValue('test-secret') }; const controller = new AdminBillingController( billingAdminService as never, adminBillNowService as never, @@ -48,11 +55,15 @@ describe('AdminBillingController', () => { invoiceService as never, invoicesRepository as never, datevExportConfigService as never, + tenantsGlobalViewsConfig as never, + billingTenantService as never, taxPreviewService as never, + storedFileSigningConfig as never, ); beforeEach(() => { jest.resetAllMocks(); + storedFileSigningConfig.getSecret.mockReturnValue('test-secret'); }); it('has admin role guards on controller', () => { @@ -63,13 +74,33 @@ describe('AdminBillingController', () => { expect(usersRoles).toContain(UserRole.ADMIN); }); - it('getCapabilities returns datev flags', () => { + it('getCapabilities returns datev and global-view flags', () => { datevExportConfigService.isEnabled.mockReturnValue(false); datevExportConfigService.isUnifiedExportAllowedForTenant.mockReturnValue(true); + tenantsGlobalViewsConfig.isGlobalViewsAllowedForTenant.mockReturnValue(true); + billingTenantService.getConfiguredTenants.mockReturnValue(['default', 'acme']); expect(controller.getCapabilities()).toEqual({ datevExportEnabled: false, unifiedExportAllowed: true, + globalViewsAllowed: true, + viewableTenants: ['default', 'acme'], + fileSigningEnabled: true, + }); + }); + + it('getCapabilities limits viewableTenants when global views disallowed', () => { + datevExportConfigService.isEnabled.mockReturnValue(true); + datevExportConfigService.isUnifiedExportAllowedForTenant.mockReturnValue(false); + tenantsGlobalViewsConfig.isGlobalViewsAllowedForTenant.mockReturnValue(false); + storedFileSigningConfig.getSecret.mockReturnValue(undefined); + + expect(controller.getCapabilities()).toEqual({ + datevExportEnabled: true, + unifiedExportAllowed: false, + globalViewsAllowed: false, + viewableTenants: ['default'], + fileSigningEnabled: false, }); }); diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/admin-billing.controller.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/admin-billing.controller.ts index d12104b68..852f1c0f4 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/admin-billing.controller.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/admin-billing.controller.ts @@ -51,11 +51,14 @@ import { AdminBillNowService } from '../services/admin-bill-now.service'; import { BillingAdminService } from '../services/billing-admin.service'; import { BillingAuditLogService } from '../services/billing-audit-log.service'; import { BillingStatisticsQueryService } from '../services/billing-statistics-query.service'; +import { BillingTenantService } from '../services/billing-tenant.service'; import { DatevExportConfigService } from '../services/datev-export-config.service'; import { InvoiceAdminService } from '../services/invoice-admin.service'; import { InvoiceService } from '../services/invoice.service'; import { ManualInvoiceService } from '../services/manual-invoice.service'; +import { StoredFileSigningConfigService } from '../services/stored-file-signing-config.service'; import { TaxPreviewService } from '../services/tax-preview.service'; +import { TenantsGlobalViewsConfigService } from '../services/tenants-global-views-config.service'; import { InvoicesRepository } from '../repositories/invoices.repository'; import { getUserFromRequest, type RequestWithUser } from '../utils/billing-access.utils'; import { TaxPreviewRequestDto } from '../dto/tax-preview.dto'; @@ -74,7 +77,10 @@ export class AdminBillingController { private readonly invoiceService: InvoiceService, private readonly invoicesRepository: InvoicesRepository, private readonly datevExportConfigService: DatevExportConfigService, + private readonly tenantsGlobalViewsConfig: TenantsGlobalViewsConfigService, + private readonly billingTenantService: BillingTenantService, private readonly taxPreviewService: TaxPreviewService, + private readonly storedFileSigningConfig: StoredFileSigningConfigService, ) {} @RequireScopes('billing_admin:read') @@ -87,10 +93,14 @@ export class AdminBillingController { @Get('capabilities') getCapabilities(): BillingCapabilitiesResponseDto { const tenantId = getTenantIdOrDefault(); + const globalViewsAllowed = this.tenantsGlobalViewsConfig.isGlobalViewsAllowedForTenant(tenantId); return { datevExportEnabled: this.datevExportConfigService.isEnabled(), unifiedExportAllowed: this.datevExportConfigService.isUnifiedExportAllowedForTenant(tenantId), + globalViewsAllowed, + viewableTenants: globalViewsAllowed ? [...this.billingTenantService.getConfiguredTenants()] : [tenantId], + fileSigningEnabled: Boolean(this.storedFileSigningConfig.getSecret()), }; } diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/admin-datev-exports.controller.spec.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/admin-datev-exports.controller.spec.ts index e46c8d228..b66fe2ba7 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/admin-datev-exports.controller.spec.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/admin-datev-exports.controller.spec.ts @@ -31,7 +31,14 @@ describe('AdminDatevExportsController', () => { const result = await controller.listExports(); expect(result.total).toBe(0); - expect(adminService.listExports).toHaveBeenCalledWith(DatevExportScope.TENANT, 20, 0, undefined, undefined); + expect(adminService.listExports).toHaveBeenCalledWith( + DatevExportScope.TENANT, + 20, + 0, + undefined, + undefined, + undefined, + ); }); it('returns streamable file on download', async () => { @@ -40,7 +47,7 @@ describe('AdminDatevExportsController', () => { const result = await controller.downloadExport('00000000-0000-4000-8000-000000000001'); expect(result).toBeInstanceOf(StreamableFile); - expect(adminService.downloadExport).toHaveBeenCalledWith('00000000-0000-4000-8000-000000000001'); + expect(adminService.downloadExport).toHaveBeenCalledWith('00000000-0000-4000-8000-000000000001', undefined); }); it('triggers export', async () => { diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/admin-datev-exports.controller.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/admin-datev-exports.controller.ts index da0334c75..01e4dee48 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/admin-datev-exports.controller.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/admin-datev-exports.controller.ts @@ -39,18 +39,25 @@ export class AdminDatevExportsController { @Query('year', new ParseIntPipe({ optional: true })) year?: number, @Query('scope') scope: DatevExportScope = DatevExportScope.TENANT, @Query('search') search?: string, + @Query('viewTenantId') viewTenantId?: string, ): Promise { - return await this.datevExportAdminService.listExports(scope, limit, offset, year, search); + return await this.datevExportAdminService.listExports(scope, limit, offset, year, search, viewTenantId); } @Get(':exportId') - async getExport(@Param('exportId', ParseUUIDPipe) exportId: string): Promise { - return await this.datevExportAdminService.getExport(exportId); + async getExport( + @Param('exportId', ParseUUIDPipe) exportId: string, + @Query('viewTenantId') viewTenantId?: string, + ): Promise { + return await this.datevExportAdminService.getExport(exportId, viewTenantId); } @Get(':exportId/download') - async downloadExport(@Param('exportId', ParseUUIDPipe) exportId: string): Promise { - const { buffer, fileName } = await this.datevExportAdminService.downloadExport(exportId); + async downloadExport( + @Param('exportId', ParseUUIDPipe) exportId: string, + @Query('viewTenantId') viewTenantId?: string, + ): Promise { + const { buffer, fileName } = await this.datevExportAdminService.downloadExport(exportId, viewTenantId); return new StreamableFile(buffer, { type: 'application/zip', diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/admin-file-manager.controller.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/admin-file-manager.controller.ts new file mode 100644 index 000000000..187600d7a --- /dev/null +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/controllers/admin-file-manager.controller.ts @@ -0,0 +1,67 @@ +import { KeycloakRoles, RequireScopes, UserRole, UsersRoles } from '@forepath/identity/backend'; +import { Controller, Get, Post, Query, StreamableFile, UploadedFile, UseInterceptors } from '@nestjs/common'; +import { FileInterceptor } from '@nestjs/platform-express'; + +import type { AdminFileManagerListResponseDto, AdminFileVerifyResponseDto } from '../dto/admin-file-manager.dto'; +import { AdminFileManagerService, type UploadedVerifyDocument } from '../services/admin-file-manager.service'; + +@Controller('admin/billing/files') +@KeycloakRoles(UserRole.ADMIN) +@UsersRoles(UserRole.ADMIN) +@RequireScopes('billing_admin:read') +export class AdminFileManagerController { + constructor(private readonly adminFileManagerService: AdminFileManagerService) {} + + @Get() + async listDirectory( + @Query('path') path?: string, + @Query('view') view?: string, + @Query('viewTenantId') viewTenantId?: string, + ): Promise { + return await this.adminFileManagerService.listDirectory(path, view, viewTenantId); + } + + @Post('verify') + @UseInterceptors(FileInterceptor('document')) + async verifyFile(@UploadedFile() document: UploadedVerifyDocument): Promise { + return await this.adminFileManagerService.verifyUploadedFile(document); + } + + @Get('by-document-id') + async downloadByDocumentId(@Query('documentId') documentId?: string): Promise { + const { buffer, fileName, contentType } = await this.adminFileManagerService.downloadByDocumentId(documentId); + + return new StreamableFile(buffer, { + type: contentType ?? 'application/octet-stream', + disposition: `attachment; filename="${fileName}"`, + }); + } + + @Get('download') + async downloadFile( + @Query('path') path?: string, + @Query('view') view?: string, + @Query('viewTenantId') viewTenantId?: string, + ): Promise { + const { buffer, fileName, contentType } = await this.adminFileManagerService.downloadFile(path, view, viewTenantId); + + return new StreamableFile(buffer, { + type: contentType ?? 'application/octet-stream', + disposition: `attachment; filename="${fileName}"`, + }); + } + + @Get('archive') + async downloadArchive( + @Query('path') path?: string, + @Query('view') view?: string, + @Query('viewTenantId') viewTenantId?: string, + ): Promise { + const { buffer, fileName } = await this.adminFileManagerService.downloadArchive(path, view, viewTenantId); + + return new StreamableFile(buffer, { + type: 'application/zip', + disposition: `attachment; filename="${fileName}"`, + }); + } +} diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/dto/admin-datev-export.dto.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/dto/admin-datev-export.dto.ts index 647ee6e4f..c591dcc2e 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/dto/admin-datev-export.dto.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/dto/admin-datev-export.dto.ts @@ -1,10 +1,13 @@ -import { IsBoolean, IsEnum, IsInt, IsOptional, Max, Min } from 'class-validator'; +import { IsBoolean, IsEnum, IsInt, IsOptional, IsString, Max, MaxLength, Min, MinLength } from 'class-validator'; import { DatevExportScope, type DatevExportStatus } from '../constants/datev-export.constants'; export interface BillingCapabilitiesResponseDto { datevExportEnabled: boolean; unifiedExportAllowed: boolean; + globalViewsAllowed: boolean; + viewableTenants: string[]; + fileSigningEnabled: boolean; } export interface AdminDatevExportListItemDto { @@ -48,6 +51,12 @@ export class TriggerDatevExportDto { @IsEnum(DatevExportScope, { message: 'Scope must be tenant or unified' }) scope?: DatevExportScope; + @IsOptional() + @IsString() + @MinLength(1) + @MaxLength(64) + viewTenantId?: string; + @IsOptional() @IsBoolean({ message: 'Force must be a boolean' }) force?: boolean; diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/dto/admin-file-manager.dto.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/dto/admin-file-manager.dto.ts new file mode 100644 index 000000000..d65f81a3f --- /dev/null +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/dto/admin-file-manager.dto.ts @@ -0,0 +1,55 @@ +import type { AdminFileManagerView, AdminFileVerifyVerdict } from '../constants/admin-file-manager.constants'; + +export type AdminFileManagerEntryType = 'file' | 'directory'; + +export interface AdminFileManagerEntryDto { + name: string; + path: string; + type: AdminFileManagerEntryType; + size?: number; + contentType?: string; + scope?: string; + updatedAt?: string; + id?: string; + shas?: { short: string; long: string }; + contentHashes?: { md5: string; sha1: string; sha256: string; sha512: string }; + byteSize?: number; + signature?: { + status: 'signed' | 'pending'; + alg?: string; + version?: string; + value?: string; + signedAt?: string; + tenantId?: string; + }; +} + +export interface AdminFileManagerListResponseDto { + path: string; + view: AdminFileManagerView; + viewTenantId?: string; + entries: AdminFileManagerEntryDto[]; +} + +export interface AdminFileVerifyMatchDto { + id: string; + shas: { short: string; long: string }; + tenantId: string; + scope: string; + storageKey: string; + virtualPath: string; + signature?: { + status: 'signed' | 'pending'; + alg?: string; + version?: string; + value?: string; + signedAt?: string; + tenantId?: string; + }; +} + +export interface AdminFileVerifyResponseDto { + verdict: AdminFileVerifyVerdict; + contentSha256?: string; + match?: AdminFileVerifyMatchDto; +} diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/entities/stored-file.entity.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/entities/stored-file.entity.ts new file mode 100644 index 000000000..c9fa91e74 --- /dev/null +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/entities/stored-file.entity.ts @@ -0,0 +1,56 @@ +import { Column, CreateDateColumn, Entity, Index, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm'; + +@Entity('billing_stored_files') +@Index('UQ_billing_stored_files_scope_key', ['scope', 'storageKey'], { unique: true }) +@Index('IDX_billing_stored_files_long_sha', ['longSha']) +@Index('IDX_billing_stored_files_content_sha256', ['contentSha256']) +export class StoredFileEntity { + @PrimaryGeneratedColumn('uuid', { name: 'id' }) + id!: string; + + @Column({ type: 'varchar', length: 40, name: 'long_sha' }) + longSha!: string; + + @Column({ type: 'varchar', length: 64, name: 'tenant_id' }) + tenantId!: string; + + /** FileStorageScope value (e.g. customerInvoices). */ + @Column({ type: 'varchar', length: 64, name: 'scope' }) + scope!: string; + + @Column({ type: 'varchar', length: 512, name: 'storage_key' }) + storageKey!: string; + + @Column({ type: 'varchar', length: 32, nullable: true, name: 'content_md5' }) + contentMd5?: string | null; + + @Column({ type: 'varchar', length: 40, nullable: true, name: 'content_sha1' }) + contentSha1?: string | null; + + @Column({ type: 'varchar', length: 64, nullable: true, name: 'content_sha256' }) + contentSha256?: string | null; + + @Column({ type: 'varchar', length: 128, nullable: true, name: 'content_sha512' }) + contentSha512?: string | null; + + @Column({ type: 'bigint', nullable: true, name: 'byte_size' }) + byteSize?: string | null; + + @Column({ type: 'varchar', length: 64, nullable: true, name: 'signature' }) + signature?: string | null; + + @Column({ type: 'varchar', length: 32, nullable: true, name: 'signature_alg' }) + signatureAlg?: string | null; + + @Column({ type: 'varchar', length: 8, nullable: true, name: 'signature_version' }) + signatureVersion?: string | null; + + @Column({ type: 'timestamptz', nullable: true, name: 'signed_at' }) + signedAt?: Date | null; + + @CreateDateColumn({ type: 'timestamptz', name: 'created_at' }) + createdAt!: Date; + + @UpdateDateColumn({ type: 'timestamptz', name: 'updated_at' }) + updatedAt!: Date; +} diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/offers/services/offer-pdf-template.service.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/offers/services/offer-pdf-template.service.ts index a6147f5de..d49a176b6 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/offers/services/offer-pdf-template.service.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/offers/services/offer-pdf-template.service.ts @@ -52,6 +52,7 @@ export interface OfferPdfViewModel { balanceDue: string; taxNote?: string; taxModeLabel?: string; + authenticityShortSha?: string; } @Injectable() @@ -64,8 +65,11 @@ export class OfferPdfTemplateService { issuer: BillingIssuerConfig, buyer: CustomerProfileEntity, presentation: OfferPdfPresentationOptions = buildOfferPdfPresentation(offer), + authenticityShortSha?: string, ): string { - return this.compiledTemplate(this.buildViewModel(offer, lineItems, issuer, buyer, presentation)); + return this.compiledTemplate( + this.buildViewModel(offer, lineItems, issuer, buyer, presentation, authenticityShortSha), + ); } buildViewModel( @@ -74,6 +78,7 @@ export class OfferPdfTemplateService { issuer: BillingIssuerConfig, buyer: CustomerProfileEntity, presentation: OfferPdfPresentationOptions = buildOfferPdfPresentation(offer), + authenticityShortSha?: string, ): OfferPdfViewModel { return { documentTitle: presentation.documentTitle, @@ -104,6 +109,7 @@ export class OfferPdfTemplateService { balanceDue: '0.00', taxNote: offer.taxNote?.trim() || undefined, taxModeLabel: offer.taxMode?.replace(/_/g, ' ') || undefined, + ...(authenticityShortSha ? { authenticityShortSha } : {}), }; } diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/offers/services/offer-pdf.service.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/offers/services/offer-pdf.service.ts index cce428a8b..1e08cf382 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/offers/services/offer-pdf.service.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/offers/services/offer-pdf.service.ts @@ -1,5 +1,6 @@ import { Injectable } from '@nestjs/common'; -import { FileStorageService } from '@forepath/shared/backend/util-file-storage'; +import { getTenantIdOrDefault } from '@forepath/shared/backend'; +import { FileStorageScope, FileStorageService } from '@forepath/shared/backend/util-file-storage'; import type { CustomerProfileEntity } from '../../entities/customer-profile.entity'; import { EInvoiceEmbedService } from '../../services/e-invoice-embed.service'; @@ -9,6 +10,7 @@ import type { BillingIssuerConfig } from '../../services/billing-issuer-config.s import { buildOfferDocumentOptions } from '../../services/e-invoice-document-options'; import type { OfferEntity } from '../entities/offer.entity'; import type { OfferLineItemEntity } from '../entities/offer-line-item.entity'; +import { StoredFileRegistryService } from '../../services/stored-file-registry.service'; import { buildOfferPdfStorageKey } from '../utils/offer-pdf-storage.util'; import { @@ -26,6 +28,7 @@ export class OfferPdfService { private readonly offerPdfTemplateService: OfferPdfTemplateService, private readonly invoicePdfHtmlRendererService: InvoicePdfHtmlRendererService, private readonly fileStorage: FileStorageService, + private readonly storedFileRegistry: StoredFileRegistryService, ) {} async generateAndStore( @@ -48,17 +51,42 @@ export class OfferPdfService { documentOptions, ); const presentation = buildOfferPdfPresentation(offer); - const html = this.offerPdfTemplateService.buildHtml(offer, lineItems, issuer, buyer, presentation); + const storageKey = buildOfferPdfStorageKey(offer); + let authenticityShortSha: string | undefined; + + if (this.storedFileRegistry.isSigningEnabled()) { + const reserved = await this.storedFileRegistry.reserve( + getTenantIdOrDefault(), + FileStorageScope.customerOffers, + storageKey, + ); + authenticityShortSha = reserved.shas.short; + } + + const html = this.offerPdfTemplateService.buildHtml( + offer, + lineItems, + issuer, + buyer, + presentation, + authenticityShortSha, + ); const pdfBytes = await this.invoicePdfHtmlRendererService.renderHtmlToPdf(html); const embedded = await this.eInvoiceEmbedService.embedXmlInPdf(pdfBytes, xml); - const storageKey = buildOfferPdfStorageKey(offer); - await this.fileStorage.writeInvoiceFile(storageKey, Buffer.from(embedded)); + const content = Buffer.from(embedded); + await this.fileStorage.writeCustomerOfferFile(storageKey, content); + await this.storedFileRegistry.registerFromBuffer({ + tenantId: getTenantIdOrDefault(), + scope: FileStorageScope.customerOffers, + storageKey, + content, + }); return storageKey; } async readPdf(storageKey: string): Promise { - return await this.fileStorage.readInvoiceFile(storageKey); + return await this.fileStorage.readCustomerOfferFile(storageKey); } } diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/offers/templates/offer-pdf.template.html b/libs/domains/decabill/backend/feature-billing-manager/src/lib/offers/templates/offer-pdf.template.html index d084fc6a3..a237012a3 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/offers/templates/offer-pdf.template.html +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/offers/templates/offer-pdf.template.html @@ -226,6 +226,15 @@ color: var(--body-emphasis); word-break: break-all; } + + .authenticity-mark { + margin-top: 28px; + font-size: 10px; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-weight: 600; + letter-spacing: 0.04em; + color: var(--secondary); + } @@ -369,6 +378,8 @@

Payment details

{{/if}} + {{/if}} {{#if authenticityShortSha}} +

Document ID: {{authenticityShortSha}}

{{/if}} diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/offers/utils/offer-pdf-storage.util.spec.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/offers/utils/offer-pdf-storage.util.spec.ts new file mode 100644 index 000000000..ae38aa8f1 --- /dev/null +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/offers/utils/offer-pdf-storage.util.spec.ts @@ -0,0 +1,14 @@ +import * as path from 'path'; + +import { buildOfferPdfStorageKey } from './offer-pdf-storage.util'; + +describe('buildOfferPdfStorageKey', () => { + it('builds userId/offerId.pdf without offers/ prefix', () => { + expect( + buildOfferPdfStorageKey({ + id: 'offer-1', + userId: 'user-1', + }), + ).toBe(path.join('user-1', 'offer-1.pdf')); + }); +}); diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/offers/utils/offer-pdf-storage.util.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/offers/utils/offer-pdf-storage.util.ts index 976f2be8a..36d1e0f8d 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/offers/utils/offer-pdf-storage.util.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/offers/utils/offer-pdf-storage.util.ts @@ -5,5 +5,5 @@ import type { OfferEntity } from '../entities/offer.entity'; export function buildOfferPdfStorageKey(offer: Pick, fileSuffix = '.pdf'): string { const fileName = `${offer.id}${fileSuffix}`; - return path.join('offers', offer.userId, fileName); + return path.join(offer.userId, fileName); } diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/projects/services/project-time-report-pdf-view.model.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/projects/services/project-time-report-pdf-view.model.ts index 9219773a0..a06368249 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/projects/services/project-time-report-pdf-view.model.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/projects/services/project-time-report-pdf-view.model.ts @@ -14,4 +14,5 @@ export interface ProjectTimeReportViewModel { invoiceNumber?: string; entries: ProjectTimeReportEntryView[]; totalDuration: string; + authenticityShortSha?: string; } diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/projects/services/project-time-report-pdf.service.spec.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/projects/services/project-time-report-pdf.service.spec.ts index ec9f2305c..ff66f153b 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/projects/services/project-time-report-pdf.service.spec.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/projects/services/project-time-report-pdf.service.spec.ts @@ -6,13 +6,19 @@ describe('ProjectTimeReportPdfService', () => { const templateService = { buildHtml: jest.fn().mockReturnValue('') }; const htmlRenderer = { renderHtmlToPdf: jest.fn().mockResolvedValue(new Uint8Array([1, 2, 3])) }; const fileStorage = { - writeInvoiceFile: jest.fn().mockResolvedValue(undefined), - readInvoiceFile: jest.fn(), + writeCustomerTimesheetFile: jest.fn().mockResolvedValue(undefined), + readCustomerTimesheetFile: jest.fn(), + }; + const storedFileRegistry = { + isSigningEnabled: jest.fn().mockReturnValue(false), + reserve: jest.fn(), + registerFromBuffer: jest.fn().mockResolvedValue({}), }; const service = new ProjectTimeReportPdfService( templateService as never, htmlRenderer as never, fileStorage as never, + storedFileRegistry as never, ); const invoice = { id: 'inv-1', @@ -37,15 +43,18 @@ describe('ProjectTimeReportPdfService', () => { const storageKey = await service.generateAndStore(invoice, viewModel); expect(storageKey).toBe('sub-1/inv-1-time-report.pdf'); - expect(fileStorage.writeInvoiceFile).toHaveBeenCalledWith('sub-1/inv-1-time-report.pdf', expect.any(Buffer)); + expect(fileStorage.writeCustomerTimesheetFile).toHaveBeenCalledWith( + 'sub-1/inv-1-time-report.pdf', + expect.any(Buffer), + ); }); it('readPdf reads via file storage', async () => { - fileStorage.readInvoiceFile.mockResolvedValue(Buffer.from('pdf')); + fileStorage.readCustomerTimesheetFile.mockResolvedValue(Buffer.from('pdf')); const buffer = await service.readPdf('sub-1/inv-1-time-report.pdf'); expect(buffer).toEqual(Buffer.from('pdf')); - expect(fileStorage.readInvoiceFile).toHaveBeenCalledWith('sub-1/inv-1-time-report.pdf'); + expect(fileStorage.readCustomerTimesheetFile).toHaveBeenCalledWith('sub-1/inv-1-time-report.pdf'); }); }); diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/projects/services/project-time-report-pdf.service.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/projects/services/project-time-report-pdf.service.ts index a7cbf6628..618525798 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/projects/services/project-time-report-pdf.service.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/projects/services/project-time-report-pdf.service.ts @@ -1,8 +1,10 @@ import { Injectable } from '@nestjs/common'; -import { FileStorageService } from '@forepath/shared/backend/util-file-storage'; +import { getTenantIdOrDefault } from '@forepath/shared/backend'; +import { FileStorageScope, FileStorageService } from '@forepath/shared/backend/util-file-storage'; import type { InvoiceEntity } from '../../entities/invoice.entity'; import { InvoicePdfHtmlRendererService } from '../../services/invoice-pdf-html-renderer.service'; +import { StoredFileRegistryService } from '../../services/stored-file-registry.service'; import { buildProjectTimeReportStorageKey } from '../../utils/project-time-report-storage.util'; import { ProjectTimeReportPdfTemplateService } from './project-time-report-pdf-template.service'; @@ -14,6 +16,7 @@ export class ProjectTimeReportPdfService { private readonly templateService: ProjectTimeReportPdfTemplateService, private readonly htmlRenderer: InvoicePdfHtmlRendererService, private readonly fileStorage: FileStorageService, + private readonly storedFileRegistry: StoredFileRegistryService, ) {} async renderPdf(viewModel: ProjectTimeReportViewModel): Promise { @@ -23,15 +26,33 @@ export class ProjectTimeReportPdfService { } async generateAndStore(invoice: InvoiceEntity, viewModel: ProjectTimeReportViewModel): Promise { - const pdfBytes = await this.renderPdf(viewModel); const storageKey = buildProjectTimeReportStorageKey(invoice); - - await this.fileStorage.writeInvoiceFile(storageKey, Buffer.from(pdfBytes)); + const stampedViewModel = { ...viewModel }; + + if (this.storedFileRegistry.isSigningEnabled()) { + const reserved = await this.storedFileRegistry.reserve( + getTenantIdOrDefault(), + FileStorageScope.customerTimesheets, + storageKey, + ); + stampedViewModel.authenticityShortSha = reserved.shas.short; + } + + const pdfBytes = await this.renderPdf(stampedViewModel); + const content = Buffer.from(pdfBytes); + + await this.fileStorage.writeCustomerTimesheetFile(storageKey, content); + await this.storedFileRegistry.registerFromBuffer({ + tenantId: getTenantIdOrDefault(), + scope: FileStorageScope.customerTimesheets, + storageKey, + content, + }); return storageKey; } async readPdf(storageKey: string): Promise { - return await this.fileStorage.readInvoiceFile(storageKey); + return await this.fileStorage.readCustomerTimesheetFile(storageKey); } } diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/admin-file-manager.service.spec.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/admin-file-manager.service.spec.ts new file mode 100644 index 000000000..3feaf0876 --- /dev/null +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/admin-file-manager.service.spec.ts @@ -0,0 +1,405 @@ +jest.mock('archiver', () => ({ + ZipArchive: jest.fn().mockImplementation(() => { + let destination: NodeJS.WritableStream | undefined; + + return { + pipe: jest.fn((dest: NodeJS.WritableStream) => { + destination = dest; + + return dest; + }), + append: jest.fn(), + finalize: jest.fn(async () => { + if (destination && typeof destination.write === 'function') { + destination.write(Buffer.from('PK')); + destination.end(); + } + }), + on: jest.fn(), + }; + }), +})); + +import { BadRequestException, ForbiddenException, NotFoundException, PayloadTooLargeException } from '@nestjs/common'; +import { runWithTenantId } from '@forepath/shared/backend'; +import { FileStorageScope } from '@forepath/shared/backend/util-file-storage'; + +import { AdminFileManagerView } from '../constants/admin-file-manager.constants'; +import { DatevExportScope, DatevExportStatus } from '../constants/datev-export.constants'; + +import { AdminFileManagerService } from './admin-file-manager.service'; + +describe('AdminFileManagerService', () => { + const dataSource = { + query: jest.fn(), + }; + const fileStorage = { + readFile: jest.fn(), + }; + const tenantsGlobalViewsConfig = { + isGlobalViewsAllowedForTenant: jest.fn(), + }; + const billingTenantService = { + getConfiguredTenants: jest.fn().mockReturnValue(['default', 'acme']), + }; + const storedFileRegistry = { + findMapForKeys: jest.fn().mockResolvedValue(new Map()), + findByScopeKey: jest.fn().mockResolvedValue(null), + findByContentSha256: jest.fn().mockResolvedValue([]), + findByDocumentId: jest.fn().mockResolvedValue([]), + verifyFromStorage: jest.fn().mockResolvedValue(true), + verifyFromBuffer: jest.fn().mockReturnValue(true), + isSigningEnabled: jest.fn().mockReturnValue(false), + cacheKey: (scope: string, storageKey: string) => `${scope}::${storageKey}`, + toShas: jest.fn().mockReturnValue({ short: 'abcdef1', long: 'a'.repeat(40) }), + }; + const storedFileSigningConfig = { + getSecret: jest.fn().mockReturnValue(null), + }; + + const service = new AdminFileManagerService( + dataSource as never, + fileStorage as never, + tenantsGlobalViewsConfig as never, + billingTenantService as never, + storedFileRegistry as never, + storedFileSigningConfig as never, + ); + + beforeEach(() => { + jest.clearAllMocks(); + tenantsGlobalViewsConfig.isGlobalViewsAllowedForTenant.mockReturnValue(false); + dataSource.query.mockResolvedValue([]); + storedFileRegistry.findMapForKeys.mockResolvedValue(new Map()); + storedFileRegistry.findByScopeKey.mockResolvedValue(null); + storedFileRegistry.findByContentSha256.mockResolvedValue([]); + storedFileRegistry.findByDocumentId.mockResolvedValue([]); + storedFileRegistry.verifyFromBuffer.mockReturnValue(true); + storedFileRegistry.isSigningEnabled.mockReturnValue(false); + storedFileSigningConfig.getSecret.mockReturnValue(null); + }); + + it('lists scope roots for tenant view', async () => { + const result = await runWithTenantId('default', () => service.listDirectory('', AdminFileManagerView.TENANT)); + + expect(result.entries.map((entry) => entry.name)).toEqual(['customer', 'export', 'supplier']); + expect(result.viewTenantId).toBe('default'); + }); + + it('materializes empty structural folders under customer', async () => { + const result = await runWithTenantId('default', () => + service.listDirectory('customer', AdminFileManagerView.TENANT), + ); + + expect(result.entries.map((entry) => entry.name)).toEqual(['invoices', 'offers', 'timesheets']); + expect(result.entries.every((entry) => entry.type === 'directory')).toBe(true); + }); + + it('lists nested directories and files from DB keys', async () => { + dataSource.query.mockImplementation(async (sql: string) => { + if (sql.includes('FROM billing_invoices i')) { + return [{ pdf_storage_key: 'sub-1/inv-1.pdf', time_report_storage_key: null, created_at: new Date() }]; + } + + return []; + }); + + const result = await runWithTenantId('default', () => + service.listDirectory('customer/invoices', AdminFileManagerView.TENANT), + ); + + expect(result.entries).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: 'sub-1', type: 'directory', path: 'customer/invoices/sub-1' }), + ]), + ); + + const nested = await runWithTenantId('default', () => + service.listDirectory('customer/invoices/sub-1', AdminFileManagerView.TENANT), + ); + + expect(nested.entries).toEqual([ + expect.objectContaining({ + name: 'inv-1.pdf', + type: 'file', + path: 'customer/invoices/sub-1/inv-1.pdf', + scope: FileStorageScope.customerInvoices, + }), + ]); + }); + + it('rejects foreign viewTenantId without global views', async () => { + await expect( + runWithTenantId('default', () => service.listDirectory('', AdminFileManagerView.TENANT, 'acme')), + ).rejects.toBeInstanceOf(ForbiddenException); + }); + + it('allows foreign viewTenantId when global views allowed', async () => { + tenantsGlobalViewsConfig.isGlobalViewsAllowedForTenant.mockReturnValue(true); + + const result = await runWithTenantId('default', () => + service.listDirectory('', AdminFileManagerView.TENANT, 'acme'), + ); + + expect(result.viewTenantId).toBe('acme'); + }); + + it('rejects unified view without global views', async () => { + await expect( + runWithTenantId('default', () => service.listDirectory('', AdminFileManagerView.UNIFIED)), + ).rejects.toBeInstanceOf(ForbiddenException); + }); + + it('downloads file only when path is in allow-set', async () => { + dataSource.query.mockImplementation(async (sql: string) => { + if (sql.includes('FROM billing_invoices i')) { + return [{ pdf_storage_key: 'sub-1/inv-1.pdf', time_report_storage_key: null, created_at: new Date() }]; + } + + return []; + }); + fileStorage.readFile.mockResolvedValue(Buffer.from('pdf')); + + const result = await runWithTenantId('default', () => + service.downloadFile('customer/invoices/sub-1/inv-1.pdf', AdminFileManagerView.TENANT), + ); + + expect(result.fileName).toBe('inv-1.pdf'); + expect(fileStorage.readFile).toHaveBeenCalledWith(FileStorageScope.customerInvoices, 'sub-1/inv-1.pdf'); + }); + + it('rejects download of unknown path', async () => { + await expect( + runWithTenantId('default', () => + service.downloadFile('customer/invoices/missing.pdf', AdminFileManagerView.TENANT), + ), + ).rejects.toBeInstanceOf(NotFoundException); + }); + + it('still downloads when stored signature verification fails', async () => { + dataSource.query.mockImplementation(async (sql: string) => { + if (sql.includes('FROM billing_invoices i')) { + return [{ pdf_storage_key: 'sub-1/inv-1.pdf', time_report_storage_key: null, created_at: new Date() }]; + } + + return []; + }); + storedFileSigningConfig.getSecret.mockReturnValue('secret'); + storedFileRegistry.findByScopeKey.mockResolvedValue({ signature: 'abc' } as never); + storedFileRegistry.verifyFromStorage.mockResolvedValue(false); + fileStorage.readFile.mockResolvedValue(Buffer.from('pdf')); + + const result = await runWithTenantId('default', () => + service.downloadFile('customer/invoices/sub-1/inv-1.pdf', AdminFileManagerView.TENANT), + ); + + expect(result.buffer.toString()).toBe('pdf'); + expect(storedFileRegistry.verifyFromStorage).toHaveBeenCalled(); + }); + + it('still downloads when signing is enabled but signature is still pending', async () => { + dataSource.query.mockImplementation(async (sql: string) => { + if (sql.includes('FROM billing_invoices i')) { + return [{ pdf_storage_key: 'sub-1/inv-1.pdf', time_report_storage_key: null, created_at: new Date() }]; + } + + return []; + }); + storedFileSigningConfig.getSecret.mockReturnValue('secret'); + storedFileRegistry.findByScopeKey.mockResolvedValue({ signature: null } as never); + fileStorage.readFile.mockResolvedValue(Buffer.from('pdf')); + + const result = await runWithTenantId('default', () => + service.downloadFile('customer/invoices/sub-1/inv-1.pdf', AdminFileManagerView.TENANT), + ); + + expect(result.buffer.toString()).toBe('pdf'); + }); + + it('archives folder subtree from allow-set', async () => { + dataSource.query.mockImplementation(async (sql: string) => { + if (sql.includes('FROM billing_invoices i')) { + return [ + { pdf_storage_key: 'sub-1/a.pdf', time_report_storage_key: null, created_at: new Date() }, + { pdf_storage_key: 'sub-1/b.pdf', time_report_storage_key: null, created_at: new Date() }, + ]; + } + + return []; + }); + fileStorage.readFile.mockResolvedValue(Buffer.from('x')); + + const result = await runWithTenantId('default', () => + service.downloadArchive('customer/invoices/sub-1', AdminFileManagerView.TENANT), + ); + + expect(result.fileName).toBe('sub-1.zip'); + expect(result.buffer.byteLength).toBeGreaterThan(0); + }); + + it('still archives when a member signature verification fails', async () => { + dataSource.query.mockImplementation(async (sql: string) => { + if (sql.includes('FROM billing_invoices i')) { + return [ + { pdf_storage_key: 'sub-1/a.pdf', time_report_storage_key: null, created_at: new Date() }, + { pdf_storage_key: 'sub-1/b.pdf', time_report_storage_key: null, created_at: new Date() }, + ]; + } + + return []; + }); + storedFileSigningConfig.getSecret.mockReturnValue('secret'); + storedFileRegistry.findByScopeKey.mockResolvedValue({ signature: 'abc' } as never); + storedFileRegistry.verifyFromStorage.mockResolvedValueOnce(true).mockResolvedValueOnce(false); + fileStorage.readFile.mockResolvedValue(Buffer.from('x')); + + const result = await runWithTenantId('default', () => + service.downloadArchive('customer/invoices/sub-1', AdminFileManagerView.TENANT), + ); + + expect(result.fileName).toBe('sub-1.zip'); + expect(storedFileRegistry.verifyFromStorage).toHaveBeenCalled(); + }); + + it('rejects oversized archives by entry count', async () => { + dataSource.query.mockImplementation(async (sql: string) => { + if (sql.includes('FROM billing_invoices i')) { + return Array.from({ length: 501 }, (_, index) => ({ + pdf_storage_key: `bulk/f-${index}.pdf`, + time_report_storage_key: null, + created_at: new Date(), + })); + } + + return []; + }); + + await expect( + runWithTenantId('default', () => service.downloadArchive('customer/invoices/bulk', AdminFileManagerView.TENANT)), + ).rejects.toBeInstanceOf(PayloadTooLargeException); + }); + + it('includes completed DATEV exports in allow-set', async () => { + dataSource.query.mockImplementation(async (sql: string, params: unknown[]) => { + if (sql.includes('FROM billing_datev_exports') && params?.[0] === DatevExportScope.TENANT) { + return [ + { + storage_key: 'default/2026/01/export.zip', + completed_at: new Date(), + created_at: new Date(), + }, + ]; + } + + return []; + }); + + const result = await runWithTenantId('default', () => + service.listDirectory('export/datev/default/2026/01', AdminFileManagerView.TENANT), + ); + + expect(result.entries).toEqual([ + expect.objectContaining({ + name: 'export.zip', + type: 'file', + path: 'export/datev/default/2026/01/export.zip', + }), + ]); + expect(paramsHasCompletedStatus()).toBe(true); + }); + + function paramsHasCompletedStatus(): boolean { + return dataSource.query.mock.calls.some( + ([, params]) => Array.isArray(params) && params.includes(DatevExportStatus.COMPLETED), + ); + } + + it('verifyUploadedFile returns signing_disabled when secret unset', async () => { + storedFileRegistry.isSigningEnabled.mockReturnValue(false); + + const result = await runWithTenantId('default', () => service.verifyUploadedFile({ buffer: Buffer.from('pdf') })); + + expect(result.verdict).toBe('signing_disabled'); + }); + + it('verifyUploadedFile returns authentic for matching signed content', async () => { + storedFileRegistry.isSigningEnabled.mockReturnValue(true); + storedFileRegistry.findByContentSha256.mockResolvedValue([ + { + id: '11111111-1111-1111-1111-111111111111', + tenantId: 'default', + scope: FileStorageScope.customerInvoices, + storageKey: 'sub/a.pdf', + signature: 'sig', + signatureAlg: 'hmac-sha256', + signatureVersion: 'v1', + signedAt: new Date('2026-01-01T00:00:00Z'), + }, + ]); + storedFileRegistry.verifyFromBuffer.mockReturnValue(true); + + const result = await runWithTenantId('default', () => service.verifyUploadedFile({ buffer: Buffer.from('pdf') })); + + expect(result.verdict).toBe('authentic'); + expect(result.match?.virtualPath).toBe('customer/invoices/sub/a.pdf'); + expect(result.match?.shas.short).toBe('abcdef1'); + }); + + it('verifyUploadedFile returns unknown when no digest match', async () => { + storedFileRegistry.isSigningEnabled.mockReturnValue(true); + storedFileRegistry.findByContentSha256.mockResolvedValue([]); + + const result = await runWithTenantId('default', () => service.verifyUploadedFile({ buffer: Buffer.from('pdf') })); + + expect(result.verdict).toBe('unknown'); + expect(result.contentSha256).toHaveLength(64); + }); + + it('downloadByDocumentId returns file bytes for a unique short id', async () => { + storedFileRegistry.findByDocumentId.mockResolvedValue([ + { + id: '11111111-1111-1111-1111-111111111111', + tenantId: 'default', + scope: FileStorageScope.customerInvoices, + storageKey: 'sub/invoice.pdf', + longSha: 'abcdef1' + '0'.repeat(33), + }, + ]); + fileStorage.readFile.mockResolvedValue(Buffer.from('pdf-bytes')); + + const result = await runWithTenantId('default', () => service.downloadByDocumentId('abcdef1')); + + expect(result.fileName).toBe('invoice.pdf'); + expect(result.buffer.toString()).toBe('pdf-bytes'); + expect(fileStorage.readFile).toHaveBeenCalledWith(FileStorageScope.customerInvoices, 'sub/invoice.pdf'); + }); + + it('downloadByDocumentId rejects ambiguous short ids', async () => { + storedFileRegistry.findByDocumentId.mockResolvedValue([ + { + id: '11111111-1111-1111-1111-111111111111', + tenantId: 'default', + scope: FileStorageScope.customerInvoices, + storageKey: 'sub/a.pdf', + }, + { + id: '22222222-2222-2222-2222-222222222222', + tenantId: 'default', + scope: FileStorageScope.customerInvoices, + storageKey: 'sub/b.pdf', + }, + ]); + + await expect(runWithTenantId('default', () => service.downloadByDocumentId('abcdef1'))).rejects.toBeInstanceOf( + BadRequestException, + ); + }); + + it('downloadByDocumentId returns not found when no match', async () => { + storedFileRegistry.findByDocumentId.mockResolvedValue([]); + + await expect(runWithTenantId('default', () => service.downloadByDocumentId('abcdef1'))).rejects.toBeInstanceOf( + NotFoundException, + ); + }); +}); diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/admin-file-manager.service.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/admin-file-manager.service.ts new file mode 100644 index 000000000..e8d8b98f3 --- /dev/null +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/admin-file-manager.service.ts @@ -0,0 +1,737 @@ +import { ZipArchive } from 'archiver'; +import { PassThrough } from 'stream'; + +import { getTenantIdOrDefault, runWithTenantId } from '@forepath/shared/backend'; +import { + FILE_STORAGE_SCOPE_SEGMENTS, + FileStorageScope, + FileStorageService, + type FileStorageScope as FileStorageScopeType, +} from '@forepath/shared/backend/util-file-storage'; +import { + BadRequestException, + ForbiddenException, + Injectable, + Logger, + NotFoundException, + PayloadTooLargeException, +} from '@nestjs/common'; +import { InjectDataSource } from '@nestjs/typeorm'; +import { DataSource } from 'typeorm'; + +import { + ADMIN_FILE_MANAGER_MAX_ARCHIVE_BYTES, + ADMIN_FILE_MANAGER_MAX_ARCHIVE_ENTRIES, + ADMIN_FILE_MANAGER_MAX_VERIFY_BYTES, + AdminFileManagerView, + AdminFileVerifyVerdict, + type AdminFileManagerView as AdminFileManagerViewType, +} from '../constants/admin-file-manager.constants'; +import { DatevExportScope, DatevExportStatus } from '../constants/datev-export.constants'; +import type { + AdminFileManagerEntryDto, + AdminFileManagerListResponseDto, + AdminFileVerifyResponseDto, +} from '../dto/admin-file-manager.dto'; +import type { StoredFileEntity } from '../entities/stored-file.entity'; +import { resolveAdminViewTenant } from '../utils/admin-view-tenant.util'; +import { + buildTenantVirtualPath, + buildUnifiedVirtualPath, + guessContentType, + isAdminFileManagerView, + normalizeAdminFilePath, + resolveScopeFromPathParts, + structuralChildrenForPath, +} from '../utils/admin-file-manager-path.util'; +import { computeStoredFileContentHashes } from '../utils/stored-file-hash.util'; +import { BillingTenantService } from './billing-tenant.service'; +import { StoredFileRegistryService } from './stored-file-registry.service'; +import { StoredFileSigningConfigService } from './stored-file-signing-config.service'; +import { TenantsGlobalViewsConfigService } from './tenants-global-views-config.service'; + +export type UploadedVerifyDocument = { + buffer: Buffer; + size?: number; + originalname?: string; + mimetype?: string; +}; + +interface StoredFileRef { + virtualPath: string; + scope: FileStorageScopeType; + storageKey: string; + updatedAt?: Date; + ownerTenantId: string; +} + +@Injectable() +export class AdminFileManagerService { + private readonly logger = new Logger(AdminFileManagerService.name); + + constructor( + @InjectDataSource() private readonly dataSource: DataSource, + private readonly fileStorage: FileStorageService, + private readonly tenantsGlobalViewsConfig: TenantsGlobalViewsConfigService, + private readonly billingTenantService: BillingTenantService, + private readonly storedFileRegistry: StoredFileRegistryService, + private readonly storedFileSigningConfig: StoredFileSigningConfigService, + ) {} + + async listDirectory( + rawPath?: string, + viewRaw?: string, + viewTenantId?: string, + ): Promise { + const view = this.resolveView(viewRaw); + const { path, parts } = normalizeAdminFilePath(rawPath); + const files = await this.collectFilesForView(view, viewTenantId); + const registryMap = await this.storedFileRegistry.findMapForKeys( + files.map((file) => ({ scope: file.scope, storageKey: file.storageKey })), + ); + const entries = this.listEntriesAtPath(files, path, parts, registryMap, view); + + return { + path, + view, + viewTenantId: view === AdminFileManagerView.TENANT ? this.resolveTenantViewId(viewTenantId) : undefined, + entries, + }; + } + + async downloadFile( + rawPath?: string, + viewRaw?: string, + viewTenantId?: string, + ): Promise<{ buffer: Buffer; fileName: string; contentType?: string }> { + const view = this.resolveView(viewRaw); + const { path } = normalizeAdminFilePath(rawPath); + const files = await this.collectFilesForView(view, viewTenantId); + const file = files.find((entry) => entry.virtualPath === path); + + if (!file) { + throw new NotFoundException('File not found'); + } + + await this.logStoredFileIntegrity(file); + + const buffer = await this.fileStorage.readFile(file.scope, file.storageKey); + const fileName = path.split('/').pop() || 'download'; + + return { + buffer, + fileName, + contentType: guessContentType(fileName), + }; + } + + /** + * Look up a managed file by printed document id (short SHA) and download its bytes. + */ + async downloadByDocumentId( + rawDocumentId?: string, + ): Promise<{ buffer: Buffer; fileName: string; contentType?: string }> { + const documentId = (rawDocumentId ?? '').trim().toLowerCase(); + + if (!/^[a-f0-9]{7}$/.test(documentId) && !/^[a-f0-9]{40}$/.test(documentId)) { + throw new BadRequestException('Document ID must be a 7-character or 40-character hex value'); + } + + const candidates = await this.storedFileRegistry.findByDocumentId(documentId); + const requestTenantId = getTenantIdOrDefault(); + const allowGlobal = this.tenantsGlobalViewsConfig.isGlobalViewsAllowedForTenant(requestTenantId); + const scoped = allowGlobal ? candidates : candidates.filter((row) => row.tenantId === requestTenantId); + + if (scoped.length === 0) { + throw new NotFoundException('No file found for this document ID'); + } + + if (scoped.length > 1) { + throw new BadRequestException('Multiple files match this document ID; use the full document hash'); + } + + const row = scoped[0]; + const fileName = row.storageKey.split('/').filter(Boolean).pop() || 'download'; + + await this.logStoredFileIntegrity({ + virtualPath: buildTenantVirtualPath( + FILE_STORAGE_SCOPE_SEGMENTS[row.scope as FileStorageScopeType] ?? String(row.scope), + row.storageKey, + ), + scope: row.scope as FileStorageScopeType, + storageKey: row.storageKey, + ownerTenantId: row.tenantId, + }); + + const buffer = await this.fileStorage.readFile(row.scope as FileStorageScopeType, row.storageKey); + + return { + buffer, + fileName, + contentType: guessContentType(fileName), + }; + } + + /** + * Authenticity check for an uploaded file. Never persists the upload. + */ + async verifyUploadedFile(document?: UploadedVerifyDocument): Promise { + if (!this.storedFileRegistry.isSigningEnabled()) { + return { verdict: AdminFileVerifyVerdict.SIGNING_DISABLED }; + } + + if (!document?.buffer?.length) { + throw new BadRequestException('Document file is required'); + } + + const size = document.size ?? document.buffer.byteLength; + + if (size > ADMIN_FILE_MANAGER_MAX_VERIFY_BYTES) { + throw new PayloadTooLargeException('Document exceeds maximum size'); + } + + const hashes = computeStoredFileContentHashes(document.buffer); + const candidates = await this.storedFileRegistry.findByContentSha256(hashes.sha256); + const requestTenantId = getTenantIdOrDefault(); + const allowGlobal = this.tenantsGlobalViewsConfig.isGlobalViewsAllowedForTenant(requestTenantId); + const scoped = allowGlobal ? candidates : candidates.filter((row) => row.tenantId === requestTenantId); + + if (scoped.length === 0) { + return { + verdict: AdminFileVerifyVerdict.UNKNOWN, + contentSha256: hashes.sha256, + }; + } + + const authentic = scoped.find((row) => this.storedFileRegistry.verifyFromBuffer(row, document.buffer)); + + if (authentic) { + return { + verdict: AdminFileVerifyVerdict.AUTHENTIC, + contentSha256: hashes.sha256, + match: this.toVerifyMatchDto(authentic), + }; + } + + return { + verdict: AdminFileVerifyVerdict.UNSIGNED, + contentSha256: hashes.sha256, + match: this.toVerifyMatchDto(scoped[0]), + }; + } + + async downloadArchive( + rawPath?: string, + viewRaw?: string, + viewTenantId?: string, + ): Promise<{ buffer: Buffer; fileName: string }> { + const view = this.resolveView(viewRaw); + const { path } = normalizeAdminFilePath(rawPath); + const files = await this.collectFilesForView(view, viewTenantId); + const prefix = path.length > 0 ? `${path}/` : ''; + const matched = files.filter((entry) => (path.length === 0 ? true : entry.virtualPath.startsWith(prefix))); + + if (matched.length === 0) { + throw new NotFoundException('Folder not found or empty'); + } + + if (matched.length > ADMIN_FILE_MANAGER_MAX_ARCHIVE_ENTRIES) { + throw new PayloadTooLargeException('Archive exceeds maximum entry count'); + } + + const zipEntries: { name: string; content: Buffer }[] = []; + let totalBytes = 0; + + for (const file of matched) { + await this.logStoredFileIntegrity(file); + + const content = await this.fileStorage.readFile(file.scope, file.storageKey); + + totalBytes += content.byteLength; + + if (totalBytes > ADMIN_FILE_MANAGER_MAX_ARCHIVE_BYTES) { + throw new PayloadTooLargeException('Archive exceeds maximum size'); + } + + const archiveName = path.length === 0 ? file.virtualPath : file.virtualPath.slice(prefix.length); + + zipEntries.push({ name: archiveName || file.storageKey.split('/').pop() || 'file', content }); + } + + const folderName = path.split('/').filter(Boolean).pop() || 'files'; + const buffer = await this.createZipBuffer(zipEntries); + + this.logger.log(`Admin file archive created with ${zipEntries.length} entries for path "${path}"`); + + return { + buffer, + fileName: `${folderName}.zip`, + }; + } + + private resolveView(viewRaw?: string): AdminFileManagerViewType { + const view = (viewRaw ?? AdminFileManagerView.TENANT).trim(); + + if (!isAdminFileManagerView(view)) { + throw new BadRequestException('View must be tenant or unified'); + } + + if (view === AdminFileManagerView.UNIFIED) { + const requestTenantId = getTenantIdOrDefault(); + + if (!this.tenantsGlobalViewsConfig.isGlobalViewsAllowedForTenant(requestTenantId)) { + throw new ForbiddenException('Unified file explorer access is not allowed for this tenant'); + } + } + + return view; + } + + private resolveTenantViewId(viewTenantId?: string): string { + return resolveAdminViewTenant(this.tenantsGlobalViewsConfig, viewTenantId); + } + + /** + * Best-effort integrity check for download/archive members. + * Never blocks delivery: signing status is surfaced in list metadata and logs. + */ + private async logStoredFileIntegrity(file: StoredFileRef): Promise { + const signingEnabled = Boolean(this.storedFileSigningConfig.getSecret()); + const registryRow = await this.storedFileRegistry.findByScopeKey(file.scope, file.storageKey); + + if (!registryRow?.signature) { + if (signingEnabled) { + this.logger.warn(`Stored file signature pending for ${file.scope}/${file.storageKey}`); + } + + return; + } + + const valid = await this.storedFileRegistry.verifyFromStorage(registryRow); + + if (!valid) { + this.logger.error(`Stored file signature verification failed for ${file.scope}/${file.storageKey}`); + } + } + + private async collectFilesForView(view: AdminFileManagerViewType, viewTenantId?: string): Promise { + if (view === AdminFileManagerView.TENANT) { + const tenantId = this.resolveTenantViewId(viewTenantId); + + return await runWithTenantId(tenantId, () => this.collectFilesForTenant(tenantId, false)); + } + + const tenantIds = [...this.billingTenantService.getConfiguredTenants()]; + const all: StoredFileRef[] = []; + + for (const tenantId of tenantIds) { + const tenantFiles = await runWithTenantId(tenantId, () => this.collectFilesForTenant(tenantId, true)); + + all.push(...tenantFiles); + } + + const unifiedDatev = await this.collectUnifiedDatevFiles(); + + all.push(...unifiedDatev); + + return all; + } + + private async collectFilesForTenant(tenantId: string, unifiedLayout: boolean): Promise { + const refs: StoredFileRef[] = []; + const invoiceSegment = FILE_STORAGE_SCOPE_SEGMENTS[FileStorageScope.customerInvoices]; + const offerSegment = FILE_STORAGE_SCOPE_SEGMENTS[FileStorageScope.customerOffers]; + const timesheetSegment = FILE_STORAGE_SCOPE_SEGMENTS[FileStorageScope.customerTimesheets]; + const supplierSegment = FILE_STORAGE_SCOPE_SEGMENTS[FileStorageScope.supplierInvoices]; + const datevSegment = FILE_STORAGE_SCOPE_SEGMENTS[FileStorageScope.datevExports]; + + const invoiceRows: Array<{ + pdf_storage_key: string | null; + time_report_storage_key: string | null; + created_at: Date; + }> = await this.dataSource.query( + ` + SELECT i.pdf_storage_key, i.time_report_storage_key, i.created_at + FROM billing_invoices i + INNER JOIN users u ON u.id = i.user_id + WHERE u.tenant_id = $1 + AND (i.pdf_storage_key IS NOT NULL OR i.time_report_storage_key IS NOT NULL) + `, + [tenantId], + ); + + for (const row of invoiceRows) { + if (row.pdf_storage_key) { + refs.push( + this.toRef( + tenantId, + unifiedLayout, + invoiceSegment, + FileStorageScope.customerInvoices, + row.pdf_storage_key, + row.created_at, + ), + ); + } + + if (row.time_report_storage_key) { + refs.push( + this.toRef( + tenantId, + unifiedLayout, + timesheetSegment, + FileStorageScope.customerTimesheets, + row.time_report_storage_key, + row.created_at, + ), + ); + } + } + + const voidRows: Array<{ pdf_storage_key: string; created_at: Date }> = await this.dataSource.query( + ` + SELECT d.pdf_storage_key, d.created_at + FROM billing_invoice_void_documents d + INNER JOIN billing_invoices i ON i.id = d.invoice_id + INNER JOIN users u ON u.id = i.user_id + WHERE u.tenant_id = $1 + `, + [tenantId], + ); + + for (const row of voidRows) { + refs.push( + this.toRef( + tenantId, + unifiedLayout, + invoiceSegment, + FileStorageScope.customerInvoices, + row.pdf_storage_key, + row.created_at, + ), + ); + } + + const creditRows: Array<{ pdf_storage_key: string; created_at: Date }> = await this.dataSource.query( + ` + SELECT d.pdf_storage_key, d.created_at + FROM billing_invoice_credit_documents d + INNER JOIN billing_invoices i ON i.id = d.invoice_id + INNER JOIN users u ON u.id = i.user_id + WHERE u.tenant_id = $1 + `, + [tenantId], + ); + + for (const row of creditRows) { + refs.push( + this.toRef( + tenantId, + unifiedLayout, + invoiceSegment, + FileStorageScope.customerInvoices, + row.pdf_storage_key, + row.created_at, + ), + ); + } + + const offerRows: Array<{ pdf_storage_key: string; updated_at: Date }> = await this.dataSource.query( + ` + SELECT o.pdf_storage_key, o.updated_at + FROM billing_offers o + INNER JOIN users u ON u.id = o.user_id + WHERE u.tenant_id = $1 + AND o.pdf_storage_key IS NOT NULL + `, + [tenantId], + ); + + for (const row of offerRows) { + refs.push( + this.toRef( + tenantId, + unifiedLayout, + offerSegment, + FileStorageScope.customerOffers, + row.pdf_storage_key, + row.updated_at, + ), + ); + } + + const supplierRows: Array<{ document_storage_key: string; created_at: Date }> = await this.dataSource.query( + ` + SELECT si.document_storage_key, si.created_at + FROM billing_supplier_invoices si + INNER JOIN billing_supplier_profiles sp ON sp.id = si.supplier_id + WHERE sp.tenant_id = $1 + AND si.document_storage_key IS NOT NULL + `, + [tenantId], + ); + + for (const row of supplierRows) { + refs.push( + this.toRef( + tenantId, + unifiedLayout, + supplierSegment, + FileStorageScope.supplierInvoices, + row.document_storage_key, + row.created_at, + ), + ); + } + + const datevRows: Array<{ storage_key: string; completed_at: Date | null; created_at: Date }> = + await this.dataSource.query( + ` + SELECT storage_key, completed_at, created_at + FROM billing_datev_exports + WHERE scope = $1 + AND tenant_id = $2 + AND status = $3 + AND storage_key IS NOT NULL + `, + [DatevExportScope.TENANT, tenantId, DatevExportStatus.COMPLETED], + ); + + for (const row of datevRows) { + refs.push( + this.toRef( + tenantId, + unifiedLayout, + datevSegment, + FileStorageScope.datevExports, + row.storage_key, + row.completed_at ?? row.created_at, + ), + ); + } + + return refs; + } + + private async collectUnifiedDatevFiles(): Promise { + const datevSegment = FILE_STORAGE_SCOPE_SEGMENTS[FileStorageScope.datevExports]; + const rows: Array<{ storage_key: string; completed_at: Date | null; created_at: Date; tenant_id: string }> = + await this.dataSource.query( + ` + SELECT storage_key, completed_at, created_at, tenant_id + FROM billing_datev_exports + WHERE scope = $1 + AND status = $2 + AND storage_key IS NOT NULL + `, + [DatevExportScope.UNIFIED, DatevExportStatus.COMPLETED], + ); + + return rows.map((row) => ({ + virtualPath: buildUnifiedVirtualPath('_unified', datevSegment, row.storage_key), + scope: FileStorageScope.datevExports, + storageKey: row.storage_key, + updatedAt: row.completed_at ?? row.created_at, + ownerTenantId: row.tenant_id, + })); + } + + private toRef( + tenantId: string, + unifiedLayout: boolean, + scopeSegment: string, + scope: FileStorageScopeType, + storageKey: string, + updatedAt?: Date, + ): StoredFileRef { + const normalizedKey = storageKey.replace(/\\/g, '/'); + + return { + virtualPath: unifiedLayout + ? buildUnifiedVirtualPath(tenantId, scopeSegment, normalizedKey) + : buildTenantVirtualPath(scopeSegment, normalizedKey), + scope, + storageKey: normalizedKey, + updatedAt, + ownerTenantId: tenantId, + }; + } + + private listEntriesAtPath( + files: StoredFileRef[], + path: string, + parts: string[], + registryMap: Map, + view: AdminFileManagerViewType, + ): AdminFileManagerEntryDto[] { + const directories = new Map(); + const fileEntries: AdminFileManagerEntryDto[] = []; + const structuralChildren = structuralChildrenForPath(parts, view); + + if (structuralChildren) { + for (const name of structuralChildren) { + const childPath = path.length > 0 ? `${path}/${name}` : name; + const childParts = [...parts, name]; + directories.set(name, { + name, + path: childPath, + type: 'directory', + scope: resolveScopeFromPathParts(childParts) ?? undefined, + }); + } + } + + if (parts.length === 0 && view === AdminFileManagerView.UNIFIED) { + for (const file of files) { + const first = file.virtualPath.split('/')[0]; + + if (!first || directories.has(first)) { + continue; + } + + directories.set(first, { + name: first, + path: first, + type: 'directory', + }); + } + } + + const prefix = path.length > 0 ? `${path}/` : ''; + + for (const file of files) { + if (file.virtualPath === path) { + continue; + } + + if (prefix.length > 0 && !file.virtualPath.startsWith(prefix)) { + continue; + } + + if (prefix.length === 0 && view === AdminFileManagerView.TENANT) { + // Root structural folders already seeded; skip re-deriving from files. + continue; + } + + const remainder = prefix.length > 0 ? file.virtualPath.slice(prefix.length) : file.virtualPath; + const [name, ...rest] = remainder.split('/'); + + if (!name) { + continue; + } + + if (rest.length === 0) { + const row = registryMap.get(this.storedFileRegistry.cacheKey(file.scope, file.storageKey)); + fileEntries.push(this.toFileEntryDto(file, name, row)); + } else if (!directories.has(name)) { + const childParts = [...parts, name]; + directories.set(name, { + name, + path: path.length > 0 ? `${path}/${name}` : name, + type: 'directory', + scope: resolveScopeFromPathParts(childParts) ?? file.scope, + }); + } + } + + return [...directories.values(), ...fileEntries].sort((a, b) => { + if (a.type !== b.type) { + return a.type === 'directory' ? -1 : 1; + } + + return a.name.localeCompare(b.name); + }); + } + + private toFileEntryDto(file: StoredFileRef, name: string, row?: StoredFileEntity): AdminFileManagerEntryDto { + const entry: AdminFileManagerEntryDto = { + name, + path: file.virtualPath, + type: 'file', + scope: file.scope, + contentType: guessContentType(name), + updatedAt: file.updatedAt?.toISOString(), + }; + + if (!row) { + entry.signature = { status: 'pending' }; + + return entry; + } + + const shas = this.storedFileRegistry.toShas(row); + entry.id = row.id; + entry.shas = shas; + entry.byteSize = row.byteSize != null ? Number(row.byteSize) : undefined; + entry.size = entry.byteSize; + + if (row.contentMd5 && row.contentSha1 && row.contentSha256 && row.contentSha512) { + entry.contentHashes = { + md5: row.contentMd5, + sha1: row.contentSha1, + sha256: row.contentSha256, + sha512: row.contentSha512, + }; + } + + entry.signature = row.signature + ? { + status: 'signed', + alg: row.signatureAlg ?? undefined, + version: row.signatureVersion ?? undefined, + value: row.signature, + signedAt: row.signedAt?.toISOString(), + tenantId: row.tenantId, + } + : { status: 'pending' }; + + return entry; + } + + private toVerifyMatchDto(row: StoredFileEntity) { + const segment = FILE_STORAGE_SCOPE_SEGMENTS[row.scope as FileStorageScopeType] ?? String(row.scope); + const virtualPath = buildTenantVirtualPath(segment, row.storageKey); + + return { + id: row.id, + shas: this.storedFileRegistry.toShas(row), + tenantId: row.tenantId, + scope: row.scope, + storageKey: row.storageKey, + virtualPath, + signature: row.signature + ? { + status: 'signed' as const, + alg: row.signatureAlg ?? undefined, + version: row.signatureVersion ?? undefined, + value: row.signature, + signedAt: row.signedAt?.toISOString(), + tenantId: row.tenantId, + } + : { status: 'pending' as const }, + }; + } + + private async createZipBuffer(entries: { name: string; content: Buffer }[]): Promise { + const archive = new ZipArchive({ zlib: { level: 9 } }); + const stream = new PassThrough(); + const chunks: Buffer[] = []; + + const done = new Promise((resolve, reject) => { + stream.on('data', (chunk: Buffer) => chunks.push(chunk)); + stream.on('end', () => resolve(Buffer.concat(chunks))); + stream.on('error', reject); + archive.on('error', reject); + }); + + archive.pipe(stream); + + for (const entry of entries) { + archive.append(entry.content, { name: entry.name }); + } + + await archive.finalize(); + + return await done; + } +} diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/datev-document-archive.service.spec.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/datev-document-archive.service.spec.ts index b6ce7f22a..075f3527f 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/datev-document-archive.service.spec.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/datev-document-archive.service.spec.ts @@ -5,7 +5,7 @@ import { DatevDocumentArchiveService } from './datev-document-archive.service'; describe('DatevDocumentArchiveService', () => { const fileStorage = { - readInvoiceFile: jest.fn(), + readCustomerInvoiceFile: jest.fn(), }; const service = new DatevDocumentArchiveService(fileStorage as never); @@ -56,26 +56,26 @@ describe('DatevDocumentArchiveService', () => { it('returns null when invoice has no pdf storage key', async () => { await expect(service.readInvoicePdf({ pdfStorageKey: null } as InvoiceEntity)).resolves.toBeNull(); - expect(fileStorage.readInvoiceFile).not.toHaveBeenCalled(); + expect(fileStorage.readCustomerInvoiceFile).not.toHaveBeenCalled(); }); it('reads invoice pdf from file storage', async () => { - fileStorage.readInvoiceFile.mockResolvedValue(Buffer.from('pdf')); + fileStorage.readCustomerInvoiceFile.mockResolvedValue(Buffer.from('pdf')); await expect(service.readInvoicePdf({ pdfStorageKey: 'invoice.pdf' } as InvoiceEntity)).resolves.toEqual( Buffer.from('pdf'), ); - expect(fileStorage.readInvoiceFile).toHaveBeenCalledWith('invoice.pdf'); + expect(fileStorage.readCustomerInvoiceFile).toHaveBeenCalledWith('invoice.pdf'); }); it('returns null when invoice pdf cannot be read', async () => { - fileStorage.readInvoiceFile.mockRejectedValue(new Error('missing')); + fileStorage.readCustomerInvoiceFile.mockRejectedValue(new Error('missing')); await expect(service.readInvoicePdf({ pdfStorageKey: 'missing.pdf' } as InvoiceEntity)).resolves.toBeNull(); }); it('reads pdf by storage key', async () => { - fileStorage.readInvoiceFile.mockResolvedValue(Buffer.from('archive')); + fileStorage.readCustomerInvoiceFile.mockResolvedValue(Buffer.from('archive')); await expect(service.readPdfByStorageKey('archive.pdf')).resolves.toEqual(Buffer.from('archive')); }); diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/datev-document-archive.service.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/datev-document-archive.service.ts index fac542994..7bafd192c 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/datev-document-archive.service.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/datev-document-archive.service.ts @@ -68,7 +68,7 @@ export class DatevDocumentArchiveService { async readPdfByStorageKey(storageKey: string): Promise { try { - return await this.fileStorage.readInvoiceFile(storageKey); + return await this.fileStorage.readCustomerInvoiceFile(storageKey); } catch { return null; } diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/datev-export-admin.service.spec.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/datev-export-admin.service.spec.ts index 89b8ea416..a81ef149e 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/datev-export-admin.service.spec.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/datev-export-admin.service.spec.ts @@ -12,6 +12,9 @@ describe('DatevExportAdminService', () => { resolveUnified: jest.fn(), isUnifiedExportEnabled: jest.fn(), }; + const tenantsGlobalViewsConfig = { + isGlobalViewsAllowedForTenant: jest.fn(), + }; const exportRepository = { findAllForAdmin: jest.fn(), findById: jest.fn(), @@ -26,6 +29,7 @@ describe('DatevExportAdminService', () => { const service = new DatevExportAdminService( configService as never, + tenantsGlobalViewsConfig as never, exportRepository as never, fileStorage as never, enqueuePort as never, @@ -37,6 +41,7 @@ describe('DatevExportAdminService', () => { configService.resolveForTenant.mockReturnValue({ consultantNumber: '1', clientNumber: '2' }); configService.resolveUnified.mockReturnValue({ consultantNumber: '1', clientNumber: '2' }); configService.isUnifiedExportEnabled.mockReturnValue(true); + tenantsGlobalViewsConfig.isGlobalViewsAllowedForTenant.mockReturnValue(false); exportRepository.findByPeriod.mockResolvedValue(null); }); @@ -67,6 +72,27 @@ describe('DatevExportAdminService', () => { ); }); + it('lists another tenant when global views allowed', async () => { + tenantsGlobalViewsConfig.isGlobalViewsAllowedForTenant.mockReturnValue(true); + exportRepository.findAllForAdmin.mockResolvedValue({ items: [], total: 0 }); + + await runWithTenantId('default', () => + service.listExports(DatevExportScope.TENANT, 20, 0, undefined, undefined, 'acme'), + ); + + expect(exportRepository.findAllForAdmin).toHaveBeenCalledWith( + expect.objectContaining({ scope: DatevExportScope.TENANT, tenantId: 'acme' }), + ); + }); + + it('rejects foreign viewTenantId when global views disallowed', async () => { + await expect( + runWithTenantId('default', () => + service.listExports(DatevExportScope.TENANT, 20, 0, undefined, undefined, 'acme'), + ), + ).rejects.toBeInstanceOf(ForbiddenException); + }); + it('rejects unified list when tenant not in allowlist', async () => { configService.isUnifiedExportAllowedForTenant.mockReturnValue(false); @@ -128,6 +154,18 @@ describe('DatevExportAdminService', () => { expect(enqueuePort.enqueueUnit).toHaveBeenCalled(); }); + it('queues trigger for viewTenantId when global views allowed', async () => { + tenantsGlobalViewsConfig.isGlobalViewsAllowedForTenant.mockReturnValue(true); + + await runWithTenantId('default', () => + service.triggerExport('admin-user', { year: 2026, month: 1, viewTenantId: 'acme' }), + ); + + expect(enqueuePort.enqueueUnit).toHaveBeenCalledWith( + expect.objectContaining({ tenantId: 'acme', scope: DatevExportScope.TENANT }), + ); + }); + it('rejects trigger when tenant DATEV configuration is incomplete', async () => { configService.resolveForTenant.mockReturnValue(null); diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/datev-export-admin.service.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/datev-export-admin.service.ts index c0e7cdea6..f51c90273 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/datev-export-admin.service.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/datev-export-admin.service.ts @@ -20,11 +20,15 @@ import type { import type { DatevExportEntity } from '../entities/datev-export.entity'; import { DATEV_EXPORT_ENQUEUE, type DatevExportEnqueuePort } from '../queue/datev-export-enqueue.token'; import { DatevExportRepository } from '../repositories/datev-export.repository'; +import { resolveAdminViewTenant } from '../utils/admin-view-tenant.util'; import { DatevExportConfigService } from './datev-export-config.service'; +import { TenantsGlobalViewsConfigService } from './tenants-global-views-config.service'; + @Injectable() export class DatevExportAdminService { constructor( private readonly configService: DatevExportConfigService, + private readonly tenantsGlobalViewsConfig: TenantsGlobalViewsConfigService, private readonly exportRepository: DatevExportRepository, private readonly fileStorage: FileStorageService, @Optional() @Inject(DATEV_EXPORT_ENQUEUE) private readonly enqueuePort?: DatevExportEnqueuePort, @@ -44,12 +48,16 @@ export class DatevExportAdminService { offset: number, year?: number, search?: string, + viewTenantId?: string, ): Promise { if (scope === DatevExportScope.UNIFIED) { this.assertUnifiedAccessAllowed(); } - const tenantId = getTenantIdOrDefault(); + const tenantId = + scope === DatevExportScope.TENANT + ? resolveAdminViewTenant(this.tenantsGlobalViewsConfig, viewTenantId) + : getTenantIdOrDefault(); const result = await this.exportRepository.findAllForAdmin({ scope, tenantId: scope === DatevExportScope.TENANT ? tenantId : undefined, @@ -67,14 +75,14 @@ export class DatevExportAdminService { }; } - async getExport(exportId: string): Promise { - const entity = await this.findAccessibleExport(exportId); + async getExport(exportId: string, viewTenantId?: string): Promise { + const entity = await this.findAccessibleExport(exportId, viewTenantId); return this.mapToListItem(entity); } - async downloadExport(exportId: string): Promise<{ buffer: Buffer; fileName: string }> { - const entity = await this.findAccessibleExport(exportId); + async downloadExport(exportId: string, viewTenantId?: string): Promise<{ buffer: Buffer; fileName: string }> { + const entity = await this.findAccessibleExport(exportId, viewTenantId); if (entity.status !== DatevExportStatus.COMPLETED || !entity.storageKey || !entity.fileName) { throw new NotFoundException('Export file is not available'); @@ -91,7 +99,10 @@ export class DatevExportAdminService { } const scope = dto.scope ?? DatevExportScope.TENANT; - const tenantId = getTenantIdOrDefault(); + const tenantId = + scope === DatevExportScope.UNIFIED + ? getTenantIdOrDefault() + : resolveAdminViewTenant(this.tenantsGlobalViewsConfig, dto.viewTenantId); if (scope === DatevExportScope.UNIFIED) { this.assertUnifiedAccessAllowed(); @@ -153,21 +164,23 @@ export class DatevExportAdminService { } } - private async findAccessibleExport(exportId: string): Promise { + private async findAccessibleExport(exportId: string, viewTenantId?: string): Promise { const entity = await this.exportRepository.findById(exportId); if (!entity) { throw new NotFoundException('Export not found'); } - const requestTenantId = getTenantIdOrDefault(); + if (entity.scope === DatevExportScope.UNIFIED) { + this.assertUnifiedAccessAllowed(); - if (entity.scope === DatevExportScope.TENANT && entity.tenantId !== requestTenantId) { - throw new ForbiddenException('Export belongs to another tenant'); + return entity; } - if (entity.scope === DatevExportScope.UNIFIED) { - this.assertUnifiedAccessAllowed(); + const allowedTenantId = resolveAdminViewTenant(this.tenantsGlobalViewsConfig, viewTenantId); + + if (entity.tenantId !== allowedTenantId) { + throw new ForbiddenException('Export belongs to another tenant'); } return entity; diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/datev-export-config.service.spec.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/datev-export-config.service.spec.ts index d3667935d..91653e2fc 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/datev-export-config.service.spec.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/datev-export-config.service.spec.ts @@ -1,10 +1,20 @@ +import { TenantsGlobalViewsConfigService } from './tenants-global-views-config.service'; import { DatevExportConfigService } from './datev-export-config.service'; describe('DatevExportConfigService', () => { const originalEnv = process.env; + function createService(): DatevExportConfigService { + const globalViews = new TenantsGlobalViewsConfigService(); + + globalViews.onModuleInit(); + + return new DatevExportConfigService(globalViews); + } + beforeEach(() => { process.env = { ...originalEnv }; + delete process.env.TENANTS_ALLOW_GLOBAL_VIEWS; }); afterAll(() => { @@ -12,28 +22,28 @@ describe('DatevExportConfigService', () => { }); it('isEnabled defaults to true and respects false', () => { - const service = new DatevExportConfigService(); + const service = createService(); service.onModuleInit(); expect(service.isEnabled()).toBe(true); process.env.BILLING_DATEV_EXPORT_ENABLED = 'false'; - const disabled = new DatevExportConfigService(); + const disabled = createService(); disabled.onModuleInit(); expect(disabled.isEnabled()).toBe(false); }); it('getUnifiedExportAllowedTenants defaults to default tenant', () => { - const service = new DatevExportConfigService(); + const service = createService(); service.onModuleInit(); expect(service.getUnifiedExportAllowedTenants()).toEqual(['default']); }); - it('parses unified export allowlist from CSV env', () => { - process.env.BILLING_DATEV_UNIFIED_EXPORT_ALLOWED_TENANTS = 'default,acme'; - const service = new DatevExportConfigService(); + it('parses unified export allowlist from TENANTS_ALLOW_GLOBAL_VIEWS', () => { + process.env.TENANTS_ALLOW_GLOBAL_VIEWS = 'default,acme'; + const service = createService(); service.onModuleInit(); expect(service.getUnifiedExportAllowedTenants()).toEqual(['default', 'acme']); @@ -42,7 +52,7 @@ describe('DatevExportConfigService', () => { it('resolveForTenant returns null when consultant/client missing', () => { delete process.env.BILLING_DATEV_CONSULTANT_NUMBER; delete process.env.BILLING_DATEV_CLIENT_NUMBER; - const service = new DatevExportConfigService(); + const service = createService(); service.onModuleInit(); expect(service.resolveForTenant('default')).toBeNull(); @@ -51,7 +61,7 @@ describe('DatevExportConfigService', () => { it('resolveForTenant returns config when required env is set', () => { process.env.BILLING_DATEV_CONSULTANT_NUMBER = '1234567'; process.env.BILLING_DATEV_CLIENT_NUMBER = '56789'; - const service = new DatevExportConfigService(); + const service = createService(); service.onModuleInit(); const config = service.resolveForTenant('default'); @@ -65,7 +75,7 @@ describe('DatevExportConfigService', () => { it('isUnifiedExportEnabled respects unified export flag', () => { process.env.BILLING_DATEV_UNIFIED_EXPORT_ENABLED = 'true'; - const service = new DatevExportConfigService(); + const service = createService(); service.onModuleInit(); expect(service.isUnifiedExportEnabled()).toBe(true); @@ -73,8 +83,8 @@ describe('DatevExportConfigService', () => { it('isUnifiedExportAllowedForTenant checks allowlist', () => { process.env.BILLING_DATEV_UNIFIED_EXPORT_ENABLED = 'true'; - process.env.BILLING_DATEV_UNIFIED_EXPORT_ALLOWED_TENANTS = 'default,acme'; - const service = new DatevExportConfigService(); + process.env.TENANTS_ALLOW_GLOBAL_VIEWS = 'default,acme'; + const service = createService(); service.onModuleInit(); expect(service.isUnifiedExportAllowedForTenant('acme')).toBe(true); @@ -82,7 +92,7 @@ describe('DatevExportConfigService', () => { }); it('returns export timezone and cron defaults', () => { - const service = new DatevExportConfigService(); + const service = createService(); service.onModuleInit(); expect(service.getExportTimezone()).toBe('Europe/Berlin'); @@ -91,7 +101,7 @@ describe('DatevExportConfigService', () => { it('falls back when BILLING_DATEV_EXPORT_CRON is empty', () => { process.env.BILLING_DATEV_EXPORT_CRON = ''; - const service = new DatevExportConfigService(); + const service = createService(); service.onModuleInit(); expect(service.getExportCronPattern()).toBe('0 0 1 * *'); @@ -103,7 +113,7 @@ describe('DatevExportConfigService', () => { process.env.BILLING_DATEV_TENANT_CONFIG = JSON.stringify({ acme: { consultantNumber: '9999999', clientNumber: '33333', chartOfAccounts: 'SKR04' }, }); - const service = new DatevExportConfigService(); + const service = createService(); service.onModuleInit(); const config = service.resolveForTenant('acme'); @@ -117,7 +127,7 @@ describe('DatevExportConfigService', () => { process.env.BILLING_DATEV_EXPORT_ENABLED = 'false'; process.env.BILLING_DATEV_CONSULTANT_NUMBER = '1234567'; process.env.BILLING_DATEV_CLIENT_NUMBER = '56789'; - const service = new DatevExportConfigService(); + const service = createService(); service.onModuleInit(); expect(service.resolveForTenant('default')).toBeNull(); @@ -129,7 +139,7 @@ describe('DatevExportConfigService', () => { process.env.BILLING_DATEV_TENANT_CONFIG = JSON.stringify({ unified: { consultantNumber: '7654321', clientNumber: '98765' }, }); - const service = new DatevExportConfigService(); + const service = createService(); service.onModuleInit(); expect(service.resolveUnified()?.consultantNumber).toBe('7654321'); diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/datev-export-config.service.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/datev-export-config.service.ts index c30bad9b5..a5d34764b 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/datev-export-config.service.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/datev-export-config.service.ts @@ -1,7 +1,8 @@ import { DEFAULT_TENANT, areTenantsNumbersShared, envCronOrDefault } from '@forepath/shared/backend'; import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; -import { parseBooleanEnv, parseCsvTenantIds } from '../utils/datev-format.util'; +import { parseBooleanEnv } from '../utils/datev-format.util'; +import { TenantsGlobalViewsConfigService } from './tenants-global-views-config.service'; export interface DatevTenantExportConfig { consultantNumber: string; @@ -69,13 +70,11 @@ interface DatevTenantConfigOverrides { export class DatevExportConfigService implements OnModuleInit { private readonly logger = new Logger(DatevExportConfigService.name); private tenantOverrides: Record = {}; - private unifiedExportAllowedTenants: readonly string[] = [DEFAULT_TENANT]; + + constructor(private readonly tenantsGlobalViewsConfig: TenantsGlobalViewsConfigService) {} onModuleInit(): void { this.tenantOverrides = this.parseTenantConfigJson(process.env.BILLING_DATEV_TENANT_CONFIG); - this.unifiedExportAllowedTenants = parseCsvTenantIds('BILLING_DATEV_UNIFIED_EXPORT_ALLOWED_TENANTS', [ - DEFAULT_TENANT, - ]); this.validateDebtorRanges(); } @@ -88,11 +87,11 @@ export class DatevExportConfigService implements OnModuleInit { } getUnifiedExportAllowedTenants(): readonly string[] { - return this.unifiedExportAllowedTenants; + return this.tenantsGlobalViewsConfig.getAllowedTenants(); } isUnifiedExportAllowedForTenant(tenantId: string): boolean { - return this.isUnifiedExportEnabled() && this.unifiedExportAllowedTenants.includes(tenantId); + return this.isUnifiedExportEnabled() && this.tenantsGlobalViewsConfig.isGlobalViewsAllowedForTenant(tenantId); } getExportTimezone(): string { diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/datev-export.service.spec.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/datev-export.service.spec.ts index ddbd873c9..fc09782d9 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/datev-export.service.spec.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/datev-export.service.spec.ts @@ -137,6 +137,7 @@ describe('DatevExportService', () => { extfCsvService as never, documentArchiveService as never, billingNotificationPublisher as never, + { registerFromBuffer: jest.fn().mockResolvedValue({}) } as never, ); beforeEach(() => { diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/datev-export.service.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/datev-export.service.ts index 57179aea4..b377236a2 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/datev-export.service.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/datev-export.service.ts @@ -2,7 +2,7 @@ import { ZipArchive } from 'archiver'; import { PassThrough } from 'stream'; import { DEFAULT_TENANT, runWithTenantId } from '@forepath/shared/backend'; -import { FileStorageService } from '@forepath/shared/backend/util-file-storage'; +import { FileStorageScope, FileStorageService } from '@forepath/shared/backend/util-file-storage'; import { Injectable, Logger } from '@nestjs/common'; import { DatevExportScope, DatevExportStatus } from '../constants/datev-export.constants'; @@ -28,6 +28,7 @@ import { DatevExportConfigService } from './datev-export-config.service'; import { DatevExtfCsvService } from './datev-extf-csv.service'; import { BillingTenantService } from './billing-tenant.service'; import { InvoicePdfService } from './invoice-pdf.service'; +import { StoredFileRegistryService } from './stored-file-registry.service'; export interface DatevExportRunParams { scope: DatevExportScope; @@ -86,6 +87,7 @@ export class DatevExportService { private readonly extfCsvService: DatevExtfCsvService, private readonly documentArchiveService: DatevDocumentArchiveService, private readonly billingNotificationPublisher: BillingNotificationPublisher, + private readonly storedFileRegistry: StoredFileRegistryService, ) {} async runExport(params: DatevExportRunParams): Promise { @@ -135,6 +137,12 @@ export class DatevExportService { const zipBuffer = await this.createZipBuffer(result.zipEntries); await this.fileStorage.writeDatevExportFile(storageKey, zipBuffer); + await this.storedFileRegistry.registerFromBuffer({ + tenantId: storageOwnerTenantId, + scope: FileStorageScope.datevExports, + storageKey, + content: zipBuffer, + }); const updated = await this.exportRepository.update(exportRecord.id, { status: DatevExportStatus.COMPLETED, diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/invoice-pdf-template.service.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/invoice-pdf-template.service.ts index 280e2b920..e13467af2 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/invoice-pdf-template.service.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/invoice-pdf-template.service.ts @@ -28,8 +28,11 @@ export class InvoicePdfTemplateService { issuer: BillingIssuerConfig, buyer: CustomerProfileEntity, presentation: InvoicePdfPresentationOptions = buildInvoicePdfPresentation(invoice), + authenticityShortSha?: string, ): string { - return this.compiledTemplate(this.buildViewModel(invoice, lineItems, issuer, buyer, presentation)); + return this.compiledTemplate( + this.buildViewModel(invoice, lineItems, issuer, buyer, presentation, authenticityShortSha), + ); } buildViewModel( @@ -38,6 +41,7 @@ export class InvoicePdfTemplateService { issuer: BillingIssuerConfig, buyer: CustomerProfileEntity, presentation: InvoicePdfPresentationOptions = buildInvoicePdfPresentation(invoice), + authenticityShortSha?: string, ): InvoicePdfViewModel { const paymentDetails = presentation.includePaymentDetails ? this.buildPaymentDetails(issuer) : undefined; @@ -71,6 +75,7 @@ export class InvoicePdfTemplateService { taxNote: invoice.taxNote?.trim() || undefined, taxModeLabel: invoice.taxMode?.replace(/_/g, ' ') || undefined, ...(paymentDetails ? { paymentDetails } : {}), + ...(authenticityShortSha ? { authenticityShortSha } : {}), }; } diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/invoice-pdf-view.model.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/invoice-pdf-view.model.ts index 46e3bdabc..afb868fcf 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/invoice-pdf-view.model.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/invoice-pdf-view.model.ts @@ -43,4 +43,6 @@ export interface InvoicePdfViewModel { taxNote?: string; taxModeLabel?: string; paymentDetails?: InvoicePdfPaymentDetailsView; + /** Identity short SHA stamped when file signing is enabled. */ + authenticityShortSha?: string; } diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/invoice-pdf.service.spec.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/invoice-pdf.service.spec.ts index 2f30ce405..b3eed666e 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/invoice-pdf.service.spec.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/invoice-pdf.service.spec.ts @@ -20,8 +20,13 @@ describe('InvoicePdfService', () => { hasApplicationsForInvoice: jest.fn().mockResolvedValue(false), }; const fileStorage = { - writeInvoiceFile: jest.fn().mockResolvedValue(undefined), - readInvoiceFile: jest.fn(), + writeCustomerInvoiceFile: jest.fn().mockResolvedValue(undefined), + readCustomerInvoiceFile: jest.fn(), + }; + const storedFileRegistry = { + isSigningEnabled: jest.fn().mockReturnValue(false), + reserve: jest.fn(), + registerFromBuffer: jest.fn().mockResolvedValue({}), }; const service = new InvoicePdfService( eInvoiceXmlService, @@ -30,6 +35,7 @@ describe('InvoicePdfService', () => { invoicePdfHtmlRendererService, invoicePromotionApplicationsRepository as never, fileStorage as never, + storedFileRegistry as never, ); const purchaseOrderReference = 'SUB-2026-00001'; const invoicingPeriod = { @@ -118,7 +124,7 @@ describe('InvoicePdfService', () => { expect(storageKey).toBe('sub-1/inv-1.pdf'); expect(invoicePdfHtmlRendererService.renderHtmlToPdf).toHaveBeenCalled(); - expect(fileStorage.writeInvoiceFile).toHaveBeenCalledWith('sub-1/inv-1.pdf', expect.any(Buffer)); + expect(fileStorage.writeCustomerInvoiceFile).toHaveBeenCalledWith('sub-1/inv-1.pdf', expect.any(Buffer)); }); it('writes PDF with embedded XML via file storage', async () => { @@ -135,7 +141,7 @@ describe('InvoicePdfService', () => { expect(invoicePdfHtmlRendererService.renderHtmlToPdf).toHaveBeenCalledWith( expect.stringContaining('INV-2026-00001'), ); - expect(fileStorage.writeInvoiceFile).toHaveBeenCalledWith('sub-1/inv-1.pdf', expect.any(Buffer)); + expect(fileStorage.writeCustomerInvoiceFile).toHaveBeenCalledWith('sub-1/inv-1.pdf', expect.any(Buffer)); }); it('writes manual invoice PDF under manual user folder when subscription is missing', async () => { @@ -147,7 +153,7 @@ describe('InvoicePdfService', () => { const storageKey = await service.generateAndStore(manualInvoice, lineItems, issuer, buyer, '', invoicingPeriod); expect(storageKey).toBe('manual/user-1/inv-1.pdf'); - expect(fileStorage.writeInvoiceFile).toHaveBeenCalledWith('manual/user-1/inv-1.pdf', expect.any(Buffer)); + expect(fileStorage.writeCustomerInvoiceFile).toHaveBeenCalledWith('manual/user-1/inv-1.pdf', expect.any(Buffer)); }); }); @@ -174,7 +180,7 @@ describe('InvoicePdfService', () => { expect(invoicePdfHtmlRendererService.renderHtmlToPdf).toHaveBeenCalledWith( expect.not.stringContaining('Payment details'), ); - expect(fileStorage.writeInvoiceFile).toHaveBeenCalledWith('sub-1/inv-1-void.pdf', expect.any(Buffer)); + expect(fileStorage.writeCustomerInvoiceFile).toHaveBeenCalledWith('sub-1/inv-1-void.pdf', expect.any(Buffer)); }); }); @@ -182,12 +188,12 @@ describe('InvoicePdfService', () => { it('reads file via file storage', async () => { const buffer = Buffer.from('pdf'); - fileStorage.readInvoiceFile.mockResolvedValue(buffer); + fileStorage.readCustomerInvoiceFile.mockResolvedValue(buffer); const result = await service.readPdf('sub-1/inv-1.pdf'); expect(result).toBe(buffer); - expect(fileStorage.readInvoiceFile).toHaveBeenCalledWith('sub-1/inv-1.pdf'); + expect(fileStorage.readCustomerInvoiceFile).toHaveBeenCalledWith('sub-1/inv-1.pdf'); }); }); }); diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/invoice-pdf.service.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/invoice-pdf.service.ts index 4d21abb53..df936ce1f 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/invoice-pdf.service.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/invoice-pdf.service.ts @@ -1,5 +1,6 @@ import { Injectable } from '@nestjs/common'; -import { FileStorageService } from '@forepath/shared/backend/util-file-storage'; +import { FileStorageScope, FileStorageService } from '@forepath/shared/backend/util-file-storage'; +import { getTenantIdOrDefault } from '@forepath/shared/backend'; import type { CustomerProfileEntity } from '../entities/customer-profile.entity'; import type { InvoiceLineItemEntity } from '../entities/invoice-line-item.entity'; @@ -27,6 +28,7 @@ import { InvoicePdfTemplateService } from './invoice-pdf-template.service'; import type { InvoicingPeriod } from './invoicing-period.util'; import { buildInvoicePdfStorageKey } from '../utils/invoice-pdf-storage.util'; import { InvoicePromotionApplicationsRepository } from '../repositories/invoice-promotion-applications.repository'; +import { StoredFileRegistryService } from './stored-file-registry.service'; export interface VoidDocumentGenerationResult { storageKey: string; @@ -42,6 +44,7 @@ export class InvoicePdfService { private readonly invoicePdfHtmlRendererService: InvoicePdfHtmlRendererService, private readonly invoicePromotionApplicationsRepository: InvoicePromotionApplicationsRepository, private readonly fileStorage: FileStorageService, + private readonly storedFileRegistry: StoredFileRegistryService, ) {} async generateAndStore( @@ -68,17 +71,26 @@ export class InvoicePdfService { invoicingPeriod, documentOptions, ); + const storageKey = buildInvoicePdfStorageKey(invoice, '.pdf'); + const authenticityShortSha = await this.reserveAuthenticityShortSha(storageKey); const pdfBytes = await this.renderPdf( invoice, lineItems, issuer, buyer, buildInvoicePdfPresentation(invoice, { zeroBalancePromotional }), + authenticityShortSha, ); const embedded = await this.eInvoiceEmbedService.embedXmlInPdf(pdfBytes, xml); - const storageKey = buildInvoicePdfStorageKey(invoice, '.pdf'); - await this.fileStorage.writeInvoiceFile(storageKey, Buffer.from(embedded)); + const content = Buffer.from(embedded); + await this.fileStorage.writeCustomerInvoiceFile(storageKey, content); + await this.storedFileRegistry.registerFromBuffer({ + tenantId: getTenantIdOrDefault(), + scope: FileStorageScope.customerInvoices, + storageKey, + content, + }); return storageKey; } @@ -110,11 +122,19 @@ export class InvoicePdfService { documentOptions, ); const presentation = buildCreditNotePdfPresentation(documentNumber, voidedAt, originalInvoiceNumber); - const pdfBytes = await this.renderPdf(invoice, lineItems, issuer, buyer, presentation); - const embedded = await this.eInvoiceEmbedService.embedXmlInPdf(pdfBytes, xml); const storageKey = buildInvoicePdfStorageKey(invoice, '-void.pdf'); + const authenticityShortSha = await this.reserveAuthenticityShortSha(storageKey); + const pdfBytes = await this.renderPdf(invoice, lineItems, issuer, buyer, presentation, authenticityShortSha); + const embedded = await this.eInvoiceEmbedService.embedXmlInPdf(pdfBytes, xml); - await this.fileStorage.writeInvoiceFile(storageKey, Buffer.from(embedded)); + const content = Buffer.from(embedded); + await this.fileStorage.writeCustomerInvoiceFile(storageKey, content); + await this.storedFileRegistry.registerFromBuffer({ + tenantId: getTenantIdOrDefault(), + scope: FileStorageScope.customerInvoices, + storageKey, + content, + }); return { storageKey, documentNumber }; } @@ -173,17 +193,39 @@ export class InvoicePdfService { originalInvoiceNumber, creditGross, ); - const pdfBytes = await this.renderPdf(invoice, [syntheticLine], issuer, buyer, presentation); - const embedded = await this.eInvoiceEmbedService.embedXmlInPdf(pdfBytes, xml); const storageKey = buildInvoicePdfStorageKey(invoice, `-credit-${suffix}.pdf`); + const authenticityShortSha = await this.reserveAuthenticityShortSha(storageKey); + const pdfBytes = await this.renderPdf(invoice, [syntheticLine], issuer, buyer, presentation, authenticityShortSha); + const embedded = await this.eInvoiceEmbedService.embedXmlInPdf(pdfBytes, xml); - await this.fileStorage.writeInvoiceFile(storageKey, Buffer.from(embedded)); + const content = Buffer.from(embedded); + await this.fileStorage.writeCustomerInvoiceFile(storageKey, content); + await this.storedFileRegistry.registerFromBuffer({ + tenantId: getTenantIdOrDefault(), + scope: FileStorageScope.customerInvoices, + storageKey, + content, + }); return { storageKey, documentNumber }; } async readPdf(storageKey: string): Promise { - return await this.fileStorage.readInvoiceFile(storageKey); + return await this.fileStorage.readCustomerInvoiceFile(storageKey); + } + + private async reserveAuthenticityShortSha(storageKey: string): Promise { + if (!this.storedFileRegistry.isSigningEnabled()) { + return undefined; + } + + const reserved = await this.storedFileRegistry.reserve( + getTenantIdOrDefault(), + FileStorageScope.customerInvoices, + storageKey, + ); + + return reserved.shas.short; } private async renderPdf( @@ -192,8 +234,16 @@ export class InvoicePdfService { issuer: BillingIssuerConfig, buyer: CustomerProfileEntity, presentation = buildInvoicePdfPresentation(invoice), + authenticityShortSha?: string, ): Promise { - const html = this.invoicePdfTemplateService.buildHtml(invoice, lineItems, issuer, buyer, presentation); + const html = this.invoicePdfTemplateService.buildHtml( + invoice, + lineItems, + issuer, + buyer, + presentation, + authenticityShortSha, + ); return await this.invoicePdfHtmlRendererService.renderHtmlToPdf(html); } diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/stored-file-registry.service.spec.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/stored-file-registry.service.spec.ts new file mode 100644 index 000000000..1b9f2ab61 --- /dev/null +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/stored-file-registry.service.spec.ts @@ -0,0 +1,190 @@ +import { FileStorageScope } from '@forepath/shared/backend/util-file-storage'; + +import { StoredFileEntity } from '../entities/stored-file.entity'; +import { deriveStoredFileLongSha } from '../utils/stored-file-hash.util'; +import { StoredFileRegistryService } from './stored-file-registry.service'; + +describe('StoredFileRegistryService', () => { + const secret = 'unit-test-signing-secret'; + + function createService(overrides?: { + findOne?: jest.Mock; + save?: jest.Mock; + create?: jest.Mock; + find?: jest.Mock; + count?: jest.Mock; + readFile?: jest.Mock; + getSecret?: jest.Mock; + }) { + const storedFiles = { + findOne: overrides?.findOne ?? jest.fn().mockResolvedValue(null), + save: overrides?.save ?? jest.fn(async (row: StoredFileEntity) => row), + create: overrides?.create ?? jest.fn((input: Partial) => ({ ...input }) as StoredFileEntity), + find: overrides?.find ?? jest.fn().mockResolvedValue([]), + count: overrides?.count ?? jest.fn().mockResolvedValue(0), + }; + const fileStorage = { + readFile: overrides?.readFile ?? jest.fn(), + }; + const signingConfig = { + getSecret: overrides?.getSecret ?? jest.fn().mockReturnValue(secret), + }; + + return { + service: new StoredFileRegistryService(storedFiles as never, fileStorage as never, signingConfig as never), + storedFiles, + fileStorage, + signingConfig, + }; + } + + it('registerFromBuffer hashes content and signs with tenant envelope', async () => { + const saved: StoredFileEntity[] = []; + const { service, storedFiles } = createService({ + save: jest.fn(async (row: StoredFileEntity) => { + if (!row.id) { + row.id = '11111111-1111-1111-1111-111111111111'; + } + saved.push({ ...row }); + return row; + }), + }); + + const row = await service.registerFromBuffer({ + tenantId: 'default', + scope: FileStorageScope.customerInvoices, + storageKey: 'sub/a.pdf', + content: Buffer.from('pdf-bytes'), + }); + + expect(row.contentSha256).toHaveLength(64); + expect(row.byteSize).toBe(String(Buffer.from('pdf-bytes').length)); + expect(row.signature).toHaveLength(64); + expect(row.signatureAlg).toBe('hmac-sha256'); + expect(row.signatureVersion).toBe('v1'); + expect(row.longSha).toBe(deriveStoredFileLongSha(row.id)); + expect(storedFiles.create).toHaveBeenCalled(); + }); + + it('verifyFromStorage fails closed when tenant-bound signature mismatches', async () => { + const content = Buffer.from('pdf-bytes'); + const { service } = createService({ + getSecret: jest.fn().mockReturnValue(secret), + readFile: jest.fn().mockResolvedValue(content), + save: jest.fn(async (row: StoredFileEntity) => { + row.id = '11111111-1111-1111-1111-111111111111'; + return row; + }), + }); + + const registered = await service.registerFromBuffer({ + tenantId: 'default', + scope: FileStorageScope.customerInvoices, + storageKey: 'sub/a.pdf', + content, + }); + + registered.tenantId = 'other-tenant'; + + await expect(service.verifyFromStorage(registered)).resolves.toBe(false); + }); + + it('findMapForKeys indexes by scope and storage key', async () => { + const row = { + id: '11111111-1111-1111-1111-111111111111', + scope: FileStorageScope.customerOffers, + storageKey: 'user/offer.pdf', + longSha: 'a'.repeat(40), + tenantId: 'default', + } as StoredFileEntity; + + const { service } = createService({ + find: jest.fn().mockResolvedValue([row]), + }); + + const map = await service.findMapForKeys([ + { scope: FileStorageScope.customerOffers, storageKey: 'user/offer.pdf' }, + ]); + + expect(map.get(service.cacheKey(FileStorageScope.customerOffers, 'user/offer.pdf'))).toBe(row); + }); + + it('reserve creates a row and returns identity short SHA', async () => { + const { service } = createService({ + save: jest.fn(async (row: StoredFileEntity) => { + if (!row.id) { + row.id = '22222222-2222-2222-2222-222222222222'; + } + + return row; + }), + }); + + const reserved = await service.reserve('default', FileStorageScope.customerInvoices, 'sub/a.pdf'); + + expect(reserved.id).toBe('22222222-2222-2222-2222-222222222222'); + expect(reserved.shas.short).toHaveLength(7); + expect(reserved.shas.long).toBe(deriveStoredFileLongSha(reserved.id)); + }); + + it('verifyFromBuffer validates upload digests and HMAC without storage reads', async () => { + const content = Buffer.from('pdf-bytes'); + const { service, fileStorage } = createService({ + save: jest.fn(async (row: StoredFileEntity) => { + row.id = '11111111-1111-1111-1111-111111111111'; + return row; + }), + }); + + const registered = await service.registerFromBuffer({ + tenantId: 'default', + scope: FileStorageScope.customerInvoices, + storageKey: 'sub/a.pdf', + content, + }); + + expect(service.verifyFromBuffer(registered, content)).toBe(true); + expect(service.verifyFromBuffer(registered, Buffer.from('tampered'))).toBe(false); + expect(fileStorage.readFile).not.toHaveBeenCalled(); + }); + + it('findByDocumentId looks up by short prefix or full long SHA', async () => { + const row = { + id: '11111111-1111-1111-1111-111111111111', + longSha: 'abcdef1' + '0'.repeat(33), + tenantId: 'default', + } as StoredFileEntity; + const find = jest.fn().mockResolvedValue([row]); + const { service } = createService({ find }); + + await expect(service.findByDocumentId('abcdef1')).resolves.toEqual([row]); + expect(find).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ longSha: expect.anything() }), + }), + ); + + find.mockClear(); + await expect(service.findByDocumentId(row.longSha)).resolves.toEqual([row]); + expect(find).toHaveBeenCalledWith( + expect.objectContaining({ + where: { longSha: row.longSha }, + }), + ); + + await expect(service.findByDocumentId('not-hex')).resolves.toEqual([]); + }); + + it('findByContentSha256 returns matching rows', async () => { + const row = { + id: '11111111-1111-1111-1111-111111111111', + contentSha256: 'abc', + tenantId: 'default', + } as StoredFileEntity; + const find = jest.fn().mockResolvedValue([row]); + const { service } = createService({ find }); + + await expect(service.findByContentSha256('abc')).resolves.toEqual([row]); + expect(find).toHaveBeenCalled(); + }); +}); diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/stored-file-registry.service.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/stored-file-registry.service.ts new file mode 100644 index 000000000..7b615495b --- /dev/null +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/stored-file-registry.service.ts @@ -0,0 +1,354 @@ +import { + FileStorageService, + type FileStorageScope as FileStorageScopeType, +} from '@forepath/shared/backend/util-file-storage'; +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { In, IsNull, Like, Repository } from 'typeorm'; + +import { StoredFileEntity } from '../entities/stored-file.entity'; +import { + computeStoredFileContentHashes, + deriveStoredFileLongSha, + shortShaFromLong, +} from '../utils/stored-file-hash.util'; +import { + signStoredFileV1, + STORED_FILE_SIGNATURE_ALG, + STORED_FILE_SIGNATURE_VERSION, + verifyStoredFileV1, +} from '../utils/stored-file-signature.util'; +import { StoredFileSigningConfigService } from './stored-file-signing-config.service'; + +export interface RegisterStoredFileInput { + tenantId: string; + scope: FileStorageScopeType | string; + storageKey: string; + content: Buffer; +} + +export interface ReservedStoredFile { + id: string; + shas: { short: string; long: string }; +} + +@Injectable() +export class StoredFileRegistryService { + private readonly logger = new Logger(StoredFileRegistryService.name); + + constructor( + @InjectRepository(StoredFileEntity) + private readonly storedFiles: Repository, + private readonly fileStorage: FileStorageService, + private readonly signingConfig: StoredFileSigningConfigService, + ) {} + + isSigningEnabled(): boolean { + return Boolean(this.signingConfig.getSecret()); + } + + /** + * Create-or-get a registry row so PDF templates can stamp the identity short SHA + * before content digests / HMAC exist. + */ + async reserve( + tenantId: string, + scope: FileStorageScopeType | string, + storageKey: string, + ): Promise { + const normalizedKey = storageKey.replace(/\\/g, '/'); + let row = await this.storedFiles.findOne({ + where: { scope, storageKey: normalizedKey }, + }); + + if (!row) { + row = this.storedFiles.create({ + tenantId, + scope: String(scope), + storageKey: normalizedKey, + longSha: '', + }); + row = await this.storedFiles.save(row); + row.longSha = deriveStoredFileLongSha(row.id); + row = await this.storedFiles.save(row); + } else { + row.tenantId = tenantId; + + if (!row.longSha) { + row.longSha = deriveStoredFileLongSha(row.id); + row = await this.storedFiles.save(row); + } + } + + return { + id: row.id, + shas: this.toShas(row), + }; + } + + async registerFromBuffer(input: RegisterStoredFileInput): Promise { + const storageKey = input.storageKey.replace(/\\/g, '/'); + const hashes = computeStoredFileContentHashes(input.content); + let row = await this.storedFiles.findOne({ + where: { scope: input.scope, storageKey }, + }); + + if (!row) { + row = this.storedFiles.create({ + tenantId: input.tenantId, + scope: input.scope, + storageKey, + longSha: '', + }); + row = await this.storedFiles.save(row); + row.longSha = deriveStoredFileLongSha(row.id); + } else { + row.tenantId = input.tenantId; + if (!row.longSha) { + row.longSha = deriveStoredFileLongSha(row.id); + } + } + + row.contentMd5 = hashes.md5; + row.contentSha1 = hashes.sha1; + row.contentSha256 = hashes.sha256; + row.contentSha512 = hashes.sha512; + row.byteSize = String(hashes.byteSize); + + this.applySignature(row); + + return await this.storedFiles.save(row); + } + + async findByScopeKey(scope: string, storageKey: string): Promise { + return await this.storedFiles.findOne({ + where: { scope, storageKey: storageKey.replace(/\\/g, '/') }, + }); + } + + async findByContentSha256(contentSha256: string): Promise { + if (!contentSha256) { + return []; + } + + return await this.storedFiles.find({ + where: { contentSha256 }, + order: { createdAt: 'ASC' }, + }); + } + + /** + * Resolve registry rows by printed document id (7-char short SHA) or full long SHA. + */ + async findByDocumentId(documentId: string): Promise { + const normalized = documentId.trim().toLowerCase(); + + if (!/^[a-f0-9]{7}$/.test(normalized) && !/^[a-f0-9]{40}$/.test(normalized)) { + return []; + } + + if (normalized.length === 40) { + return await this.storedFiles.find({ + where: { longSha: normalized }, + order: { createdAt: 'ASC' }, + }); + } + + return await this.storedFiles.find({ + where: { longSha: Like(`${normalized}%`) }, + order: { createdAt: 'ASC' }, + }); + } + + /** + * Verify uploaded bytes against a registry row without reading storage or persisting the upload. + */ + verifyFromBuffer(row: StoredFileEntity, content: Buffer): boolean { + const secret = this.signingConfig.getSecret(); + + if (!secret || !row.signature || !row.contentMd5 || !row.contentSha256) { + return false; + } + + const hashes = computeStoredFileContentHashes(content); + + if ( + hashes.md5 !== row.contentMd5 || + hashes.sha1 !== row.contentSha1 || + hashes.sha256 !== row.contentSha256 || + hashes.sha512 !== row.contentSha512 || + String(hashes.byteSize) !== String(row.byteSize ?? '') + ) { + return false; + } + + return verifyStoredFileV1( + secret, + { + tenantId: row.tenantId, + scope: row.scope, + storageKey: row.storageKey, + contentMd5: hashes.md5, + contentSha1: hashes.sha1, + contentSha256: hashes.sha256, + contentSha512: hashes.sha512, + byteSize: hashes.byteSize, + registryFileId: row.id, + }, + row.signature, + ); + } + + async findMapForKeys(keys: Array<{ scope: string; storageKey: string }>): Promise> { + const map = new Map(); + + if (keys.length === 0) { + return map; + } + + const scopes = [...new Set(keys.map((k) => k.scope))]; + const storageKeys = [...new Set(keys.map((k) => k.storageKey.replace(/\\/g, '/')))]; + const rows = await this.storedFiles.find({ + where: { + scope: In(scopes), + storageKey: In(storageKeys), + }, + }); + + for (const row of rows) { + map.set(this.cacheKey(row.scope, row.storageKey), row); + } + + return map; + } + + cacheKey(scope: string, storageKey: string): string { + return `${scope}::${storageKey.replace(/\\/g, '/')}`; + } + + toShas(row: StoredFileEntity): { short: string; long: string } { + const long = row.longSha || deriveStoredFileLongSha(row.id); + + return { short: shortShaFromLong(long), long }; + } + + async verifyFromStorage(row: StoredFileEntity): Promise { + const secret = this.signingConfig.getSecret(); + + if (!secret || !row.signature || !row.contentMd5 || !row.contentSha256) { + return false; + } + + const buffer = await this.fileStorage.readFile(row.scope as FileStorageScopeType, row.storageKey); + const hashes = computeStoredFileContentHashes(buffer); + + if ( + hashes.md5 !== row.contentMd5 || + hashes.sha1 !== row.contentSha1 || + hashes.sha256 !== row.contentSha256 || + hashes.sha512 !== row.contentSha512 || + String(hashes.byteSize) !== String(row.byteSize ?? '') + ) { + return false; + } + + return verifyStoredFileV1( + secret, + { + tenantId: row.tenantId, + scope: row.scope, + storageKey: row.storageKey, + contentMd5: hashes.md5, + contentSha1: hashes.sha1, + contentSha256: hashes.sha256, + contentSha512: hashes.sha512, + byteSize: hashes.byteSize, + registryFileId: row.id, + }, + row.signature, + ); + } + + async processBackfillBatch( + limit: number, + offset: number, + ): Promise<{ processed: number; hasMore: boolean; nextOffset: number }> { + const rows = await this.storedFiles.find({ + where: [{ contentSha256: IsNull() }, { signature: IsNull() }], + order: { createdAt: 'ASC' }, + take: limit, + skip: offset, + }); + + let processed = 0; + + for (const row of rows) { + try { + const buffer = await this.fileStorage.readFile(row.scope as FileStorageScopeType, row.storageKey); + const hashes = computeStoredFileContentHashes(buffer); + + row.contentMd5 = hashes.md5; + row.contentSha1 = hashes.sha1; + row.contentSha256 = hashes.sha256; + row.contentSha512 = hashes.sha512; + row.byteSize = String(hashes.byteSize); + + if (!row.longSha) { + row.longSha = deriveStoredFileLongSha(row.id); + } + + this.applySignature(row); + await this.storedFiles.save(row); + processed += 1; + } catch (error) { + this.logger.warn( + `Stored file backfill failed for ${row.scope}/${row.storageKey}: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + } + + return { + processed, + hasMore: rows.length === limit, + nextOffset: offset + rows.length, + }; + } + + async countPendingBackfill(): Promise { + return await this.storedFiles.count({ + where: [{ contentSha256: IsNull() }, { signature: IsNull() }], + }); + } + + private applySignature(row: StoredFileEntity): void { + const secret = this.signingConfig.getSecret(); + + if ( + !secret || + !row.contentMd5 || + !row.contentSha1 || + !row.contentSha256 || + !row.contentSha512 || + row.byteSize == null + ) { + return; + } + + row.signature = signStoredFileV1(secret, { + tenantId: row.tenantId, + scope: row.scope, + storageKey: row.storageKey, + contentMd5: row.contentMd5, + contentSha1: row.contentSha1, + contentSha256: row.contentSha256, + contentSha512: row.contentSha512, + byteSize: row.byteSize, + registryFileId: row.id, + }); + row.signatureAlg = STORED_FILE_SIGNATURE_ALG; + row.signatureVersion = STORED_FILE_SIGNATURE_VERSION; + row.signedAt = new Date(); + } +} diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/stored-file-signing-config.service.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/stored-file-signing-config.service.ts new file mode 100644 index 000000000..a4747e049 --- /dev/null +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/stored-file-signing-config.service.ts @@ -0,0 +1,44 @@ +import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; + +/** + * Global HMAC secret for billing stored-file signatures. + * Production fails closed when the secret is missing. + */ +@Injectable() +export class StoredFileSigningConfigService implements OnModuleInit { + private readonly logger = new Logger(StoredFileSigningConfigService.name); + private secret: string | null = null; + + onModuleInit(): void { + this.secret = this.readSecret(process.env); + const isProd = (process.env.NODE_ENV ?? '').trim().toLowerCase() === 'production'; + + if (!this.secret && isProd) { + throw new Error('BILLING_FILE_SIGNING_SECRET is required in production'); + } + + if (!this.secret) { + this.logger.warn('BILLING_FILE_SIGNING_SECRET is unset; file signatures will not be created'); + } + } + + getSecret(): string | null { + return this.secret ?? this.readSecret(process.env); + } + + requireSecret(): string { + const secret = this.getSecret(); + + if (!secret) { + throw new Error('BILLING_FILE_SIGNING_SECRET is not configured'); + } + + return secret; + } + + private readSecret(env: NodeJS.ProcessEnv): string | null { + const value = env.BILLING_FILE_SIGNING_SECRET?.trim(); + + return value && value.length > 0 ? value : null; + } +} diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/stored-files-backfill.job-handler.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/stored-files-backfill.job-handler.ts new file mode 100644 index 000000000..e9e0955aa --- /dev/null +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/stored-files-backfill.job-handler.ts @@ -0,0 +1,22 @@ +import { Injectable } from '@nestjs/common'; + +import { StoredFileRegistryService } from './stored-file-registry.service'; + +export interface StoredFilesBackfillUnitResult { + processed: number; + hasMore: boolean; + nextOffset: number; +} + +@Injectable() +export class StoredFilesBackfillJobHandler { + constructor(private readonly storedFileRegistry: StoredFileRegistryService) {} + + async processBackfillUnit(offset = 0, limit = 25): Promise { + return await this.storedFileRegistry.processBackfillBatch(limit, offset); + } + + async hasPendingWork(): Promise { + return (await this.storedFileRegistry.countPendingBackfill()) > 0; + } +} diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/supplier-invoice-pdf.service.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/supplier-invoice-pdf.service.ts index 850e24162..9865a6ed3 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/supplier-invoice-pdf.service.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/supplier-invoice-pdf.service.ts @@ -1,5 +1,6 @@ import { Injectable } from '@nestjs/common'; -import { FileStorageService } from '@forepath/shared/backend/util-file-storage'; +import { getTenantIdOrDefault } from '@forepath/shared/backend'; +import { FileStorageScope, FileStorageService } from '@forepath/shared/backend/util-file-storage'; import { SupplierDocumentSource } from '../constants/supplier-document-source.constants'; import type { CustomerProfileEntity } from '../entities/customer-profile.entity'; @@ -14,6 +15,7 @@ import type { BillingIssuerConfig } from './billing-issuer-config.service'; import { InvoicePdfHtmlRendererService } from './invoice-pdf-html-renderer.service'; import { buildInvoicePdfPresentation } from './invoice-pdf-presentation.util'; import { InvoicePdfTemplateService } from './invoice-pdf-template.service'; +import { StoredFileRegistryService } from './stored-file-registry.service'; @Injectable() export class SupplierInvoicePdfService { @@ -21,6 +23,7 @@ export class SupplierInvoicePdfService { private readonly invoicePdfTemplateService: InvoicePdfTemplateService, private readonly invoicePdfHtmlRendererService: InvoicePdfHtmlRendererService, private readonly fileStorage: FileStorageService, + private readonly storedFileRegistry: StoredFileRegistryService, ) {} async generateAndStore( @@ -31,8 +34,15 @@ export class SupplierInvoicePdfService { ): Promise { const pdfBytes = await this.renderPdf(invoice, lineItems, supplier, recipient); const storageKey = buildSupplierInvoicePdfStorageKey(invoice, '.pdf'); + const content = Buffer.from(pdfBytes); - await this.fileStorage.writeSupplierInvoiceFile(storageKey, Buffer.from(pdfBytes)); + await this.fileStorage.writeSupplierInvoiceFile(storageKey, content); + await this.storedFileRegistry.registerFromBuffer({ + tenantId: supplier.tenantId || getTenantIdOrDefault(), + scope: FileStorageScope.supplierInvoices, + storageKey, + content, + }); return storageKey; } diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/supplier-invoices-admin.service.spec.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/supplier-invoices-admin.service.spec.ts index f2721353a..42a6ebf84 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/supplier-invoices-admin.service.spec.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/supplier-invoices-admin.service.spec.ts @@ -68,6 +68,7 @@ describe('SupplierInvoicesAdminService', () => { fileStorage as never, auditLog as never, billingNotificationPublisher as never, + { registerFromBuffer: jest.fn().mockResolvedValue({}) } as never, ); const draftInvoice = { diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/supplier-invoices-admin.service.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/supplier-invoices-admin.service.ts index af80e9d0f..4f6d971c9 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/supplier-invoices-admin.service.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/supplier-invoices-admin.service.ts @@ -1,5 +1,6 @@ import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; -import { FileStorageService } from '@forepath/shared/backend/util-file-storage'; +import { getTenantIdOrDefault } from '@forepath/shared/backend'; +import { FileStorageScope, FileStorageService } from '@forepath/shared/backend/util-file-storage'; import { randomUUID } from 'crypto'; import { InvoiceStatus } from '../constants/invoice-status.constants'; @@ -31,6 +32,7 @@ import { buildSupplierInvoicePdfStorageKey } from '../utils/supplier-invoice-pdf import { BillingAuditLogService } from './billing-audit-log.service'; import { BillingIssuerConfigService } from './billing-issuer-config.service'; import { SupplierContractsService } from './supplier-contracts.service'; +import { StoredFileRegistryService } from './stored-file-registry.service'; import { SupplierInvoicePdfService } from './supplier-invoice-pdf.service'; import { SupplierProfilesService } from './supplier-profiles.service'; import { TaxCalculationService } from './tax-calculation.service'; @@ -69,6 +71,7 @@ export class SupplierInvoicesAdminService { private readonly fileStorage: FileStorageService, private readonly auditLog: BillingAuditLogService, private readonly billingNotificationPublisher: BillingNotificationPublisher, + private readonly storedFileRegistry: StoredFileRegistryService, ) {} async list(params: { @@ -456,6 +459,12 @@ export class SupplierInvoicesAdminService { const storageKey = `${randomUUID()}${this.extensionForMime(document.mimetype)}`; await this.fileStorage.writeSupplierInvoiceFile(storageKey, document.buffer); + await this.storedFileRegistry.registerFromBuffer({ + tenantId: getTenantIdOrDefault(), + scope: FileStorageScope.supplierInvoices, + storageKey, + content: document.buffer, + }); await this.supplierInvoicesRepository.update(invoiceId, { documentStorageKey: storageKey, diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/tenants-global-views-config.service.spec.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/tenants-global-views-config.service.spec.ts new file mode 100644 index 000000000..78febc48b --- /dev/null +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/tenants-global-views-config.service.spec.ts @@ -0,0 +1,33 @@ +import { TenantsGlobalViewsConfigService } from './tenants-global-views-config.service'; + +describe('TenantsGlobalViewsConfigService', () => { + const originalEnv = process.env; + + beforeEach(() => { + process.env = { ...originalEnv }; + delete process.env.TENANTS_ALLOW_GLOBAL_VIEWS; + }); + + afterAll(() => { + process.env = originalEnv; + }); + + it('defaults to default tenant when env unset', () => { + const service = new TenantsGlobalViewsConfigService(); + + service.onModuleInit(); + expect(service.getAllowedTenants()).toEqual(['default']); + expect(service.isGlobalViewsAllowedForTenant('default')).toBe(true); + expect(service.isGlobalViewsAllowedForTenant('acme')).toBe(false); + }); + + it('parses CSV allowlist from TENANTS_ALLOW_GLOBAL_VIEWS', () => { + process.env.TENANTS_ALLOW_GLOBAL_VIEWS = 'default,acme'; + const service = new TenantsGlobalViewsConfigService(); + + service.onModuleInit(); + expect(service.getAllowedTenants()).toEqual(['default', 'acme']); + expect(service.isGlobalViewsAllowedForTenant('acme')).toBe(true); + expect(service.isGlobalViewsAllowedForTenant('other')).toBe(false); + }); +}); diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/tenants-global-views-config.service.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/tenants-global-views-config.service.ts new file mode 100644 index 000000000..da9b16e75 --- /dev/null +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/services/tenants-global-views-config.service.ts @@ -0,0 +1,26 @@ +import { DEFAULT_TENANT } from '@forepath/shared/backend'; +import { Injectable, OnModuleInit } from '@nestjs/common'; + +import { TENANTS_ALLOW_GLOBAL_VIEWS_ENV } from '../constants/tenants-global-views.constants'; +import { parseCsvTenantIds } from '../utils/datev-format.util'; + +/** + * Shared allowlist for admin global/cross-tenant views (DATEV unified export access + * and File Explorer tenant tabs / unified consolidation). + */ +@Injectable() +export class TenantsGlobalViewsConfigService implements OnModuleInit { + private allowedTenants: readonly string[] = [DEFAULT_TENANT]; + + onModuleInit(): void { + this.allowedTenants = parseCsvTenantIds(TENANTS_ALLOW_GLOBAL_VIEWS_ENV, [DEFAULT_TENANT]); + } + + getAllowedTenants(): readonly string[] { + return this.allowedTenants; + } + + isGlobalViewsAllowedForTenant(tenantId: string): boolean { + return this.allowedTenants.includes(tenantId); + } +} diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/templates/invoice-pdf.template.html b/libs/domains/decabill/backend/feature-billing-manager/src/lib/templates/invoice-pdf.template.html index f20d167ba..70511dbc8 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/templates/invoice-pdf.template.html +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/templates/invoice-pdf.template.html @@ -226,6 +226,15 @@ color: var(--body-emphasis); word-break: break-all; } + + .authenticity-mark { + margin-top: 28px; + font-size: 10px; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-weight: 600; + letter-spacing: 0.04em; + color: var(--secondary); + } @@ -369,6 +378,8 @@

Payment details

{{/if}} + {{/if}} {{#if authenticityShortSha}} +

Document ID: {{authenticityShortSha}}

{{/if}} diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/templates/time-report-pdf.template.html b/libs/domains/decabill/backend/feature-billing-manager/src/lib/templates/time-report-pdf.template.html index e14691d11..6e1ee0cf2 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/templates/time-report-pdf.template.html +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/templates/time-report-pdf.template.html @@ -167,6 +167,15 @@ font-style: italic; font-size: 13px; } + + .authenticity-mark { + margin-top: 28px; + font-size: 10px; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-weight: 600; + letter-spacing: 0.04em; + color: var(--secondary); + } @@ -229,6 +238,8 @@

{{title}}

{{else}}

No time entries in this range.

+ {{/if}} {{#if authenticityShortSha}} +

Document ID: {{authenticityShortSha}}

{{/if}} diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/utils/admin-file-manager-path.util.spec.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/utils/admin-file-manager-path.util.spec.ts new file mode 100644 index 000000000..1ccad8990 --- /dev/null +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/utils/admin-file-manager-path.util.spec.ts @@ -0,0 +1,75 @@ +import { BadRequestException } from '@nestjs/common'; + +import { AdminFileManagerView } from '../constants/admin-file-manager.constants'; + +import { + buildTenantVirtualPath, + buildUnifiedVirtualPath, + normalizeAdminFilePath, + resolveScopeFromPathParts, + resolveScopeFromSegment, + structuralChildrenForPath, +} from './admin-file-manager-path.util'; + +describe('admin-file-manager-path.util', () => { + it('normalizes empty path to root', () => { + expect(normalizeAdminFilePath(undefined)).toEqual({ path: '', parts: [] }); + expect(normalizeAdminFilePath('')).toEqual({ path: '', parts: [] }); + expect(normalizeAdminFilePath('./')).toEqual({ path: '', parts: [] }); + }); + + it('normalizes relative nested paths', () => { + expect(normalizeAdminFilePath('customer/invoices/sub/a.pdf')).toEqual({ + path: 'customer/invoices/sub/a.pdf', + parts: ['customer', 'invoices', 'sub', 'a.pdf'], + }); + }); + + it('rejects path traversal', () => { + expect(() => normalizeAdminFilePath('../secrets')).toThrow(BadRequestException); + expect(() => normalizeAdminFilePath('customer/../x')).toThrow(BadRequestException); + }); + + it('rejects absolute and null-byte paths', () => { + expect(() => normalizeAdminFilePath('/etc/passwd')).toThrow(BadRequestException); + expect(() => normalizeAdminFilePath('customer/\0evil')).toThrow(BadRequestException); + }); + + it('resolves known nested scope segments', () => { + expect(resolveScopeFromSegment('customer/invoices')).toBe('customerInvoices'); + expect(resolveScopeFromSegment('customer/offers')).toBe('customerOffers'); + expect(resolveScopeFromSegment('customer/timesheets')).toBe('customerTimesheets'); + expect(resolveScopeFromSegment('supplier/invoices')).toBe('supplierInvoices'); + expect(resolveScopeFromSegment('export/datev')).toBe('datevExports'); + expect(resolveScopeFromSegment('unknown')).toBeNull(); + }); + + it('resolves scope from path parts', () => { + expect(resolveScopeFromPathParts(['customer', 'invoices'])).toBe('customerInvoices'); + expect(resolveScopeFromPathParts(['export', 'datev'])).toBe('datevExports'); + }); + + it('builds virtual paths', () => { + expect(buildTenantVirtualPath('customer/invoices', 'sub/a.pdf')).toBe('customer/invoices/sub/a.pdf'); + expect(buildUnifiedVirtualPath('acme', 'customer/invoices', 'sub/a.pdf')).toBe('acme/customer/invoices/sub/a.pdf'); + }); + + it('materializes fixed structural children without dynamic date folders', () => { + expect(structuralChildrenForPath([], AdminFileManagerView.TENANT)).toEqual(['customer', 'supplier', 'export']); + expect(structuralChildrenForPath(['customer'], AdminFileManagerView.TENANT)).toEqual([ + 'invoices', + 'offers', + 'timesheets', + ]); + expect(structuralChildrenForPath(['supplier'], AdminFileManagerView.TENANT)).toEqual(['invoices']); + expect(structuralChildrenForPath(['export'], AdminFileManagerView.TENANT)).toEqual(['datev']); + expect(structuralChildrenForPath(['export', 'datev'], AdminFileManagerView.TENANT)).toBeNull(); + expect(structuralChildrenForPath(['customer', 'invoices'], AdminFileManagerView.TENANT)).toBeNull(); + expect(structuralChildrenForPath([], AdminFileManagerView.UNIFIED)).toBeNull(); + expect(structuralChildrenForPath(['acme', 'customer'], AdminFileManagerView.UNIFIED)).toEqual([ + 'invoices', + 'offers', + 'timesheets', + ]); + }); +}); diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/utils/admin-file-manager-path.util.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/utils/admin-file-manager-path.util.ts new file mode 100644 index 000000000..b8425e9a1 --- /dev/null +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/utils/admin-file-manager-path.util.ts @@ -0,0 +1,141 @@ +import { BadRequestException } from '@nestjs/common'; +import { + FILE_STORAGE_SCOPE_SEGMENTS, + FileStorageScope, + type FileStorageScope as FileStorageScopeType, +} from '@forepath/shared/backend/util-file-storage'; + +import { + AdminFileManagerView, + type AdminFileManagerView as AdminFileManagerViewType, +} from '../constants/admin-file-manager.constants'; + +const SEGMENT_TO_SCOPE: Readonly> = { + [FILE_STORAGE_SCOPE_SEGMENTS[FileStorageScope.customerInvoices]]: FileStorageScope.customerInvoices, + [FILE_STORAGE_SCOPE_SEGMENTS[FileStorageScope.customerOffers]]: FileStorageScope.customerOffers, + [FILE_STORAGE_SCOPE_SEGMENTS[FileStorageScope.customerTimesheets]]: FileStorageScope.customerTimesheets, + [FILE_STORAGE_SCOPE_SEGMENTS[FileStorageScope.supplierInvoices]]: FileStorageScope.supplierInvoices, + [FILE_STORAGE_SCOPE_SEGMENTS[FileStorageScope.datevExports]]: FileStorageScope.datevExports, +}; + +/** Full scope path segments (e.g. customer/invoices). */ +export const ADMIN_FILE_MANAGER_SCOPE_SEGMENTS = Object.keys(SEGMENT_TO_SCOPE); + +/** Top-level virtual folders matching the disk narrative. */ +export const ADMIN_FILE_MANAGER_ROOT_FOLDERS = ['customer', 'supplier', 'export'] as const; + +/** + * Fixed structural children under a path (tenant-relative). + * Does not invent dynamic content folders (DATEV year/month, invoice subscription keys, …). + */ +export const ADMIN_FILE_MANAGER_STRUCTURAL_CHILDREN: Readonly> = { + '': ADMIN_FILE_MANAGER_ROOT_FOLDERS, + customer: ['invoices', 'offers', 'timesheets'], + supplier: ['invoices'], + export: ['datev'], +}; + +/** + * Returns fixed structural child folder names for a virtual path, or null when the path + * is outside the static layout (only DB-derived entries apply). + * For unified view, `parts[0]` is the tenant id and is skipped. + */ +export function structuralChildrenForPath(parts: string[], view: AdminFileManagerViewType): readonly string[] | null { + // Unified root lists tenant folders from the allow-set, not customer/supplier/export. + if (view === AdminFileManagerView.UNIFIED && parts.length === 0) { + return null; + } + + const relativeParts = view === AdminFileManagerView.UNIFIED ? parts.slice(1) : parts; + const key = relativeParts.join('/'); + + return ADMIN_FILE_MANAGER_STRUCTURAL_CHILDREN[key] ?? null; +} + +export interface NormalizedAdminFilePath { + path: string; + parts: string[]; +} + +/** + * Normalize and validate a virtual admin file path. + * Rejects traversal, absolute paths, and null bytes. + */ +export function normalizeAdminFilePath(rawPath?: string | null): NormalizedAdminFilePath { + const input = (rawPath ?? '').trim(); + + if (input.includes('\0')) { + throw new BadRequestException('Invalid path'); + } + + if (input.startsWith('/') || /^[a-zA-Z]:[\\/]/.test(input)) { + throw new BadRequestException('Invalid path'); + } + + const parts = input + .replace(/\\/g, '/') + .split('/') + .map((part) => part.trim()) + .filter((part) => part.length > 0 && part !== '.'); + + if (parts.some((part) => part === '..')) { + throw new BadRequestException('Invalid path'); + } + + return { + path: parts.join('/'), + parts, + }; +} + +export function resolveScopeFromSegment(segment: string): FileStorageScopeType | null { + return SEGMENT_TO_SCOPE[segment] ?? null; +} + +/** Resolve scope from a path prefix (supports nested segments like customer/invoices). */ +export function resolveScopeFromPathParts(parts: string[]): FileStorageScopeType | null { + if (parts.length >= 2) { + const two = `${parts[0]}/${parts[1]}`; + const scope = SEGMENT_TO_SCOPE[two]; + + if (scope) { + return scope; + } + } + + if (parts.length >= 1) { + return SEGMENT_TO_SCOPE[parts[0]] ?? null; + } + + return null; +} + +export function buildTenantVirtualPath(scopeSegment: string, storageKey: string): string { + return `${scopeSegment}/${storageKey.replace(/\\/g, '/')}`; +} + +export function buildUnifiedVirtualPath(tenantId: string, scopeSegment: string, storageKey: string): string { + return `${tenantId}/${buildTenantVirtualPath(scopeSegment, storageKey)}`; +} + +export function isAdminFileManagerView(value: string | undefined): value is AdminFileManagerViewType { + return value === AdminFileManagerView.TENANT || value === AdminFileManagerView.UNIFIED; +} + +export function guessContentType(fileName: string): string | undefined { + const lower = fileName.toLowerCase(); + + if (lower.endsWith('.pdf')) return 'application/pdf'; + + if (lower.endsWith('.zip')) return 'application/zip'; + + if (lower.endsWith('.csv')) return 'text/csv'; + + if (lower.endsWith('.xml')) return 'application/xml'; + + if (lower.endsWith('.png')) return 'image/png'; + + if (lower.endsWith('.jpg') || lower.endsWith('.jpeg')) return 'image/jpeg'; + + return undefined; +} diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/utils/admin-view-tenant.util.spec.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/utils/admin-view-tenant.util.spec.ts new file mode 100644 index 000000000..0c0e60f1f --- /dev/null +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/utils/admin-view-tenant.util.spec.ts @@ -0,0 +1,43 @@ +import { ForbiddenException } from '@nestjs/common'; +import { runWithTenantId } from '@forepath/shared/backend'; + +import { resolveAdminViewTenant } from './admin-view-tenant.util'; + +describe('resolveAdminViewTenant', () => { + const globalViewsConfig = { + isGlobalViewsAllowedForTenant: jest.fn(), + }; + + beforeEach(() => { + globalViewsConfig.isGlobalViewsAllowedForTenant.mockReset(); + }); + + it('returns request tenant when viewTenantId omitted', () => { + runWithTenantId('default', () => { + expect(resolveAdminViewTenant(globalViewsConfig as never, undefined)).toBe('default'); + }); + }); + + it('returns request tenant when viewTenantId matches', () => { + runWithTenantId('acme', () => { + expect(resolveAdminViewTenant(globalViewsConfig as never, 'acme')).toBe('acme'); + }); + }); + + it('allows foreign viewTenantId when request tenant is allowlisted', () => { + globalViewsConfig.isGlobalViewsAllowedForTenant.mockReturnValue(true); + + runWithTenantId('default', () => { + expect(resolveAdminViewTenant(globalViewsConfig as never, 'acme')).toBe('acme'); + }); + expect(globalViewsConfig.isGlobalViewsAllowedForTenant).toHaveBeenCalledWith('default'); + }); + + it('rejects foreign viewTenantId when request tenant is not allowlisted', () => { + globalViewsConfig.isGlobalViewsAllowedForTenant.mockReturnValue(false); + + runWithTenantId('default', () => { + expect(() => resolveAdminViewTenant(globalViewsConfig as never, 'acme')).toThrow(ForbiddenException); + }); + }); +}); diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/utils/admin-view-tenant.util.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/utils/admin-view-tenant.util.ts new file mode 100644 index 000000000..45ce7c48c --- /dev/null +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/utils/admin-view-tenant.util.ts @@ -0,0 +1,26 @@ +import { getTenantIdOrDefault } from '@forepath/shared/backend'; +import { ForbiddenException } from '@nestjs/common'; + +import type { TenantsGlobalViewsConfigService } from '../services/tenants-global-views-config.service'; + +/** + * Resolves the tenant used for admin list/download when operators may switch tabs. + * Foreign `viewTenantId` is only accepted when the request tenant is on TENANTS_ALLOW_GLOBAL_VIEWS. + */ +export function resolveAdminViewTenant( + globalViewsConfig: TenantsGlobalViewsConfigService, + viewTenantId?: string | null, +): string { + const requestTenantId = getTenantIdOrDefault(); + const requested = viewTenantId?.trim(); + + if (!requested || requested === requestTenantId) { + return requestTenantId; + } + + if (!globalViewsConfig.isGlobalViewsAllowedForTenant(requestTenantId)) { + throw new ForbiddenException('Cross-tenant admin views are not allowed for this tenant'); + } + + return requested; +} diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/utils/stored-file-hash.util.spec.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/utils/stored-file-hash.util.spec.ts new file mode 100644 index 000000000..e0c074385 --- /dev/null +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/utils/stored-file-hash.util.spec.ts @@ -0,0 +1,21 @@ +import { computeStoredFileContentHashes, deriveStoredFileLongSha, shortShaFromLong } from './stored-file-hash.util'; + +describe('stored-file-hash.util', () => { + it('derives stable long sha and short prefix', () => { + const long = deriveStoredFileLongSha('11111111-1111-1111-1111-111111111111'); + + expect(long).toHaveLength(40); + expect(shortShaFromLong(long)).toBe(long.slice(0, 7)); + expect(deriveStoredFileLongSha('11111111-1111-1111-1111-111111111111')).toBe(long); + }); + + it('computes content digests from buffer', () => { + const hashes = computeStoredFileContentHashes(Buffer.from('hello')); + + expect(hashes.byteSize).toBe(5); + expect(hashes.md5).toHaveLength(32); + expect(hashes.sha1).toHaveLength(40); + expect(hashes.sha256).toHaveLength(64); + expect(hashes.sha512).toHaveLength(128); + }); +}); diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/utils/stored-file-hash.util.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/utils/stored-file-hash.util.ts new file mode 100644 index 000000000..a8eaf572d --- /dev/null +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/utils/stored-file-hash.util.ts @@ -0,0 +1,28 @@ +import { createHash } from 'node:crypto'; + +/** Stable full file reference hash (sha1 of registry id), ticket-style. */ +export function deriveStoredFileLongSha(fileId: string): string { + return createHash('sha1').update(fileId).digest('hex'); +} + +export function shortShaFromLong(longSha: string): string { + return longSha.slice(0, 7); +} + +export interface StoredFileContentHashes { + md5: string; + sha1: string; + sha256: string; + sha512: string; + byteSize: number; +} + +export function computeStoredFileContentHashes(content: Buffer): StoredFileContentHashes { + return { + md5: createHash('md5').update(content).digest('hex'), + sha1: createHash('sha1').update(content).digest('hex'), + sha256: createHash('sha256').update(content).digest('hex'), + sha512: createHash('sha512').update(content).digest('hex'), + byteSize: content.byteLength, + }; +} diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/utils/stored-file-signature.util.spec.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/utils/stored-file-signature.util.spec.ts new file mode 100644 index 000000000..ac34511f6 --- /dev/null +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/utils/stored-file-signature.util.spec.ts @@ -0,0 +1,40 @@ +import { + signStoredFileV1, + verifyStoredFileV1, + type StoredFileSignatureEnvelopeParts, +} from './stored-file-signature.util'; + +const baseParts: StoredFileSignatureEnvelopeParts = { + tenantId: 'default', + scope: 'customerInvoices', + storageKey: 'sub-1/inv.pdf', + contentMd5: 'm'.repeat(32), + contentSha1: 'a'.repeat(40), + contentSha256: 'b'.repeat(64), + contentSha512: 'c'.repeat(128), + byteSize: 12, + registryFileId: '11111111-1111-1111-1111-111111111111', +}; + +describe('stored-file-signature.util', () => { + const secret = 'test-signing-secret'; + + it('signs and verifies a valid envelope', () => { + const signature = signStoredFileV1(secret, baseParts); + + expect(signature).toHaveLength(64); + expect(verifyStoredFileV1(secret, baseParts, signature)).toBe(true); + }); + + it('fails when tenant differs', () => { + const signature = signStoredFileV1(secret, baseParts); + + expect(verifyStoredFileV1(secret, { ...baseParts, tenantId: 'other' }, signature)).toBe(false); + }); + + it('fails when content digest differs', () => { + const signature = signStoredFileV1(secret, baseParts); + + expect(verifyStoredFileV1(secret, { ...baseParts, contentSha256: 'd'.repeat(64) }, signature)).toBe(false); + }); +}); diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/utils/stored-file-signature.util.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/utils/stored-file-signature.util.ts new file mode 100644 index 000000000..d3ccf1983 --- /dev/null +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/utils/stored-file-signature.util.ts @@ -0,0 +1,53 @@ +import { createHmac, timingSafeEqual } from 'node:crypto'; + +export const STORED_FILE_SIGNATURE_ALG = 'hmac-sha256'; +export const STORED_FILE_SIGNATURE_VERSION = 'v1'; + +export interface StoredFileSignatureEnvelopeParts { + tenantId: string; + scope: string; + storageKey: string; + contentMd5: string; + contentSha1: string; + contentSha256: string; + contentSha512: string; + byteSize: number | string; + registryFileId: string; +} + +export function buildStoredFileSignatureEnvelopeV1(parts: StoredFileSignatureEnvelopeParts): string { + return [ + STORED_FILE_SIGNATURE_VERSION, + parts.tenantId, + parts.scope, + parts.storageKey.replace(/\\/g, '/'), + parts.contentMd5, + parts.contentSha1, + parts.contentSha256, + parts.contentSha512, + String(parts.byteSize), + parts.registryFileId, + ].join('\n'); +} + +export function signStoredFileV1(secret: string, parts: StoredFileSignatureEnvelopeParts): string { + const envelope = buildStoredFileSignatureEnvelopeV1(parts); + + return createHmac('sha256', secret).update(envelope, 'utf8').digest('hex'); +} + +export function verifyStoredFileV1( + secret: string, + parts: StoredFileSignatureEnvelopeParts, + signatureHex: string, +): boolean { + const expected = signStoredFileV1(secret, parts); + const expectedBuf = Buffer.from(expected, 'utf8'); + const actualBuf = Buffer.from(signatureHex, 'utf8'); + + if (expectedBuf.length !== actualBuf.length) { + return false; + } + + return timingSafeEqual(expectedBuf, actualBuf); +} diff --git a/libs/domains/decabill/frontend/data-access-billing-console/src/index.ts b/libs/domains/decabill/frontend/data-access-billing-console/src/index.ts index bbb5b3daa..32e986f51 100644 --- a/libs/domains/decabill/frontend/data-access-billing-console/src/index.ts +++ b/libs/domains/decabill/frontend/data-access-billing-console/src/index.ts @@ -247,6 +247,13 @@ export * from './lib/state/admin-datev-exports/admin-datev-exports.facade'; export * from './lib/state/admin-datev-exports/admin-datev-exports.reducer'; export * from './lib/state/admin-datev-exports/admin-datev-exports.selectors'; +// Admin File Explorer State +export * from './lib/state/admin-file-manager/admin-file-manager.actions'; +export * from './lib/state/admin-file-manager/admin-file-manager.effects'; +export * from './lib/state/admin-file-manager/admin-file-manager.facade'; +export * from './lib/state/admin-file-manager/admin-file-manager.reducer'; +export * from './lib/state/admin-file-manager/admin-file-manager.selectors'; + // Availability State export * from './lib/state/availability/availability.actions'; export * from './lib/state/availability/availability.effects'; diff --git a/libs/domains/decabill/frontend/data-access-billing-console/src/lib/services/admin-billing.service.spec.ts b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/services/admin-billing.service.spec.ts index 0d62336bb..82a280e17 100644 --- a/libs/domains/decabill/frontend/data-access-billing-console/src/lib/services/admin-billing.service.spec.ts +++ b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/services/admin-billing.service.spec.ts @@ -286,7 +286,47 @@ describe('AdminBillingService', () => { const req = httpMock.expectOne(`${apiUrl}/admin/billing/capabilities`); expect(req.request.method).toBe('GET'); - req.flush({ datevExportEnabled: true, unifiedExportAllowed: false }); + req.flush({ + datevExportEnabled: true, + unifiedExportAllowed: false, + globalViewsAllowed: false, + viewableTenants: ['default'], + fileSigningEnabled: true, + }); + }); + + it('verifies admin file via multipart upload', (done) => { + const file = new File([new Uint8Array([1, 2, 3])], 'check.pdf', { type: 'application/pdf' }); + + service.verifyAdminFile(file).subscribe((res) => { + expect(res.verdict).toBe('authentic'); + done(); + }); + + const req = httpMock.expectOne(`${apiUrl}/admin/billing/files/verify`); + + expect(req.request.method).toBe('POST'); + expect(req.request.body).toBeInstanceOf(FormData); + req.flush({ verdict: 'authentic', contentSha256: 'a'.repeat(64) }); + }); + + it('downloads admin file by document id', (done) => { + service.downloadAdminFileByDocumentId('abcdef1').subscribe((res) => { + expect(res.fileName).toBe('invoice.pdf'); + expect(res.blob.size).toBeGreaterThan(0); + done(); + }); + + const req = httpMock.expectOne( + (request) => + request.url === `${apiUrl}/admin/billing/files/by-document-id` && + request.params.get('documentId') === 'abcdef1', + ); + + expect(req.request.method).toBe('GET'); + req.flush(new Blob(['pdf']), { + headers: { 'Content-Disposition': 'attachment; filename="invoice.pdf"' }, + }); }); it('lists datev exports with query params', (done) => { diff --git a/libs/domains/decabill/frontend/data-access-billing-console/src/lib/services/admin-billing.service.ts b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/services/admin-billing.service.ts index 39a2a0523..afe38c4ba 100644 --- a/libs/domains/decabill/frontend/data-access-billing-console/src/lib/services/admin-billing.service.ts +++ b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/services/admin-billing.service.ts @@ -2,7 +2,7 @@ import { HttpClient, HttpParams } from '@angular/common/http'; import { Injectable, inject } from '@angular/core'; import type { Environment } from '@forepath/decabill/frontend/util-configuration'; import { ENVIRONMENT } from '@forepath/decabill/frontend/util-configuration'; -import { Observable } from 'rxjs'; +import { map, Observable } from 'rxjs'; import type { AdminBillNowDto, @@ -37,6 +37,9 @@ import type { } from '../types/billing.types'; import type { AdminDatevExportListParams, + AdminFileManagerListParams, + AdminFileManagerListResponse, + AdminFileVerifyResponse, BillingCapabilitiesResponse, PaginatedAdminDatevExportsResponse, TriggerDatevExportDto, @@ -266,6 +269,8 @@ export class AdminBillingService { if (params.search?.trim()) httpParams = httpParams.set('search', params.search.trim()); + if (params.viewTenantId?.trim()) httpParams = httpParams.set('viewTenantId', params.viewTenantId.trim()); + return this.http.get(`${this.apiUrl}/admin/billing/datev-exports`, { params: httpParams, }); @@ -275,8 +280,86 @@ export class AdminBillingService { return this.http.post(`${this.apiUrl}/admin/billing/datev-exports`, dto); } - downloadDatevExport(exportId: string): Observable { + downloadDatevExport(exportId: string, viewTenantId?: string): Observable { + let httpParams = new HttpParams(); + + if (viewTenantId?.trim()) { + httpParams = httpParams.set('viewTenantId', viewTenantId.trim()); + } + return this.http.get(`${this.apiUrl}/admin/billing/datev-exports/${exportId}/download`, { + params: httpParams, + responseType: 'blob', + }); + } + + listAdminFiles(params: AdminFileManagerListParams): Observable { + let httpParams = new HttpParams(); + + if (params.path != null) httpParams = httpParams.set('path', params.path); + + if (params.view) httpParams = httpParams.set('view', params.view); + + if (params.viewTenantId?.trim()) httpParams = httpParams.set('viewTenantId', params.viewTenantId.trim()); + + return this.http.get(`${this.apiUrl}/admin/billing/files`, { + params: httpParams, + }); + } + + verifyAdminFile(file: File): Observable { + const formData = new FormData(); + formData.append('document', file, file.name); + + return this.http.post(`${this.apiUrl}/admin/billing/files/verify`, formData); + } + + downloadAdminFile(params: AdminFileManagerListParams): Observable { + let httpParams = new HttpParams(); + + if (params.path != null) httpParams = httpParams.set('path', params.path); + + if (params.view) httpParams = httpParams.set('view', params.view); + + if (params.viewTenantId?.trim()) httpParams = httpParams.set('viewTenantId', params.viewTenantId.trim()); + + return this.http.get(`${this.apiUrl}/admin/billing/files/download`, { + params: httpParams, + responseType: 'blob', + }); + } + + downloadAdminFileByDocumentId(documentId: string): Observable<{ blob: Blob; fileName: string }> { + const httpParams = new HttpParams().set('documentId', documentId.trim()); + + return this.http + .get(`${this.apiUrl}/admin/billing/files/by-document-id`, { + params: httpParams, + responseType: 'blob', + observe: 'response', + }) + .pipe( + map((response) => { + const disposition = response.headers.get('Content-Disposition') ?? ''; + const match = /filename="([^"]+)"/i.exec(disposition); + const fileName = match?.[1]?.trim() || 'download'; + + return { blob: response.body ?? new Blob(), fileName }; + }), + ); + } + + downloadAdminFileArchive(params: AdminFileManagerListParams): Observable { + let httpParams = new HttpParams(); + + if (params.path != null) httpParams = httpParams.set('path', params.path); + + if (params.view) httpParams = httpParams.set('view', params.view); + + if (params.viewTenantId?.trim()) httpParams = httpParams.set('viewTenantId', params.viewTenantId.trim()); + + return this.http.get(`${this.apiUrl}/admin/billing/files/archive`, { + params: httpParams, responseType: 'blob', }); } diff --git a/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/admin-datev-exports/admin-datev-exports.actions.ts b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/admin-datev-exports/admin-datev-exports.actions.ts index 5f3f03d50..720b2cfac 100644 --- a/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/admin-datev-exports/admin-datev-exports.actions.ts +++ b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/admin-datev-exports/admin-datev-exports.actions.ts @@ -42,7 +42,10 @@ export const triggerDatevExportFailure = createAction( props<{ error: string }>(), ); -export const downloadDatevExport = createAction('[AdminDatevExports] Download', props<{ exportId: string }>()); +export const downloadDatevExport = createAction( + '[AdminDatevExports] Download', + props<{ exportId: string; viewTenantId?: string }>(), +); export const downloadDatevExportSuccess = createAction('[AdminDatevExports] Download Success'); export const downloadDatevExportFailure = createAction( '[AdminDatevExports] Download Failure', diff --git a/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/admin-datev-exports/admin-datev-exports.effects.spec.ts b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/admin-datev-exports/admin-datev-exports.effects.spec.ts index ebd705e5c..ecfad09a4 100644 --- a/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/admin-datev-exports/admin-datev-exports.effects.spec.ts +++ b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/admin-datev-exports/admin-datev-exports.effects.spec.ts @@ -67,6 +67,11 @@ describe('AdminDatevExportsEffects', () => { actions$ = TestBed.inject(Actions); store = TestBed.inject(MockStore); store.overrideSelector(selectAdminDatevExportsScope, 'tenant'); + store.overrideSelector(selectAdminDatevExportsState, { + ...initialAdminDatevExportsState, + scope: 'tenant', + viewTenantId: 'default', + }); }); describe('loadAdminDatevExports$', () => { @@ -229,7 +234,7 @@ describe('AdminDatevExportsEffects', () => { triggerDatevExportSuccessReload$(actions$, store).subscribe((result) => { expect(result).toEqual( loadAdminDatevExports({ - params: { scope: 'tenant', limit: 10, offset: 0 }, + params: { scope: 'tenant', viewTenantId: 'default', limit: 10, offset: 0 }, preserveScope: true, }), ); diff --git a/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/admin-datev-exports/admin-datev-exports.effects.ts b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/admin-datev-exports/admin-datev-exports.effects.ts index a93afb3b2..ab97a95df 100644 --- a/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/admin-datev-exports/admin-datev-exports.effects.ts +++ b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/admin-datev-exports/admin-datev-exports.effects.ts @@ -31,7 +31,7 @@ import { triggerDatevExportFailure, triggerDatevExportSuccess, } from './admin-datev-exports.actions'; -import { selectAdminDatevExportsScope, selectAdminDatevExportsState } from './admin-datev-exports.selectors'; +import { selectAdminDatevExportsState } from './admin-datev-exports.selectors'; const BATCH_SIZE = 10; export const QUEUED_POLL_INTERVAL_MS = 3000; @@ -152,10 +152,15 @@ export const triggerDatevExportSuccessReload$ = createEffect( (actions$ = inject(Actions), store = inject(Store)) => actions$.pipe( ofType(triggerDatevExportSuccess), - withLatestFrom(store.select(selectAdminDatevExportsScope)), - map(([, scope]) => + withLatestFrom(store.select(selectAdminDatevExportsState)), + map(([, state]) => loadAdminDatevExports({ - params: { scope, limit: BATCH_SIZE, offset: 0 }, + params: { + scope: state.scope, + viewTenantId: state.viewTenantId ?? undefined, + limit: BATCH_SIZE, + offset: 0, + }, preserveScope: true, }), ), @@ -183,7 +188,12 @@ export const pollQueuedDatevExports$ = createEffect( return scopes.map((scope) => loadAdminDatevExports({ - params: { scope, limit: BATCH_SIZE, offset: 0 }, + params: { + scope, + viewTenantId: state.viewTenantId ?? undefined, + limit: BATCH_SIZE, + offset: 0, + }, preserveScope: true, }), ); @@ -214,8 +224,8 @@ export const downloadDatevExport$ = createEffect( (actions$ = inject(Actions), service = inject(AdminBillingService)) => actions$.pipe( ofType(downloadDatevExport), - switchMap(({ exportId }) => - service.downloadDatevExport(exportId).pipe( + switchMap(({ exportId, viewTenantId }) => + service.downloadDatevExport(exportId, viewTenantId).pipe( tap((blob) => { const url = URL.createObjectURL(blob); const anchor = document.createElement('a'); diff --git a/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/admin-datev-exports/admin-datev-exports.facade.ts b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/admin-datev-exports/admin-datev-exports.facade.ts index 93e7a5707..870ab5ff0 100644 --- a/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/admin-datev-exports/admin-datev-exports.facade.ts +++ b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/admin-datev-exports/admin-datev-exports.facade.ts @@ -11,6 +11,7 @@ import { selectAdminDatevExportsScope, selectAdminDatevExportsTriggerError, selectAdminDatevExportsTriggerLoading, + selectAdminDatevExportsViewTenantId, } from './admin-datev-exports.selectors'; @Injectable() @@ -21,6 +22,7 @@ export class AdminDatevExportsFacade { readonly loading$ = this.store.select(selectAdminDatevExportsLoading); readonly error$ = this.store.select(selectAdminDatevExportsError); readonly scope$ = this.store.select(selectAdminDatevExportsScope); + readonly viewTenantId$ = this.store.select(selectAdminDatevExportsViewTenantId); readonly triggerLoading$ = this.store.select(selectAdminDatevExportsTriggerLoading); readonly triggerError$ = this.store.select(selectAdminDatevExportsTriggerError); @@ -28,15 +30,18 @@ export class AdminDatevExportsFacade { this.store.dispatch(loadAdminDatevExports({ params })); } - setScope(scope: DatevExportScope): void { - this.loadExports({ scope }); + setScope(scope: DatevExportScope, viewTenantId?: string): void { + this.loadExports({ + scope, + viewTenantId: scope === 'tenant' ? viewTenantId : undefined, + }); } triggerExport(dto: TriggerDatevExportDto): void { this.store.dispatch(triggerDatevExport({ dto })); } - downloadExport(exportId: string): void { - this.store.dispatch(downloadDatevExport({ exportId })); + downloadExport(exportId: string, viewTenantId?: string): void { + this.store.dispatch(downloadDatevExport({ exportId, viewTenantId })); } } diff --git a/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/admin-datev-exports/admin-datev-exports.reducer.ts b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/admin-datev-exports/admin-datev-exports.reducer.ts index 9bbdeba4d..f0cc7a03c 100644 --- a/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/admin-datev-exports/admin-datev-exports.reducer.ts +++ b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/admin-datev-exports/admin-datev-exports.reducer.ts @@ -23,6 +23,7 @@ export interface AdminDatevExportsState { limit: number; offset: number; scope: DatevExportScope; + viewTenantId: string | null; loading: boolean; error: string | null; triggerLoading: boolean; @@ -37,6 +38,7 @@ export const initialAdminDatevExportsState: AdminDatevExportsState = { limit: 20, offset: 0, scope: 'tenant', + viewTenantId: null, loading: false, error: null, triggerLoading: false, @@ -71,14 +73,21 @@ export const adminDatevExportsReducer = createReducer( initialAdminDatevExportsState, on(loadAdminDatevExports, (state, { params, preserveScope }) => { const nextScope = preserveScope ? state.scope : (params.scope ?? state.scope); + const nextViewTenantId = preserveScope + ? state.viewTenantId + : params.scope === 'unified' + ? null + : (params.viewTenantId ?? state.viewTenantId); const scopeChanged = !preserveScope && nextScope !== state.scope; + const viewTenantChanged = !preserveScope && nextViewTenantId !== state.viewTenantId; return { ...state, - items: scopeChanged ? [] : state.items, + items: scopeChanged || viewTenantChanged ? [] : state.items, loading: true, error: null, scope: nextScope, + viewTenantId: nextViewTenantId, }; }), on(loadAdminDatevExportsBatch, (state, { accumulatedItems }) => ({ diff --git a/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/admin-datev-exports/admin-datev-exports.selectors.ts b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/admin-datev-exports/admin-datev-exports.selectors.ts index 4d5c36729..7df6f0085 100644 --- a/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/admin-datev-exports/admin-datev-exports.selectors.ts +++ b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/admin-datev-exports/admin-datev-exports.selectors.ts @@ -44,6 +44,11 @@ export const selectAdminDatevExportsError = createSelector(selectAdminDatevExpor export const selectAdminDatevExportsScope = createSelector(selectAdminDatevExportsState, (state) => state.scope); +export const selectAdminDatevExportsViewTenantId = createSelector( + selectAdminDatevExportsState, + (state) => state.viewTenantId, +); + export const selectAdminDatevExportsTriggerLoading = createSelector( selectAdminDatevExportsState, (state) => state.triggerLoading, diff --git a/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/admin-file-manager/admin-file-manager.actions.ts b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/admin-file-manager/admin-file-manager.actions.ts new file mode 100644 index 000000000..0148ba7ac --- /dev/null +++ b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/admin-file-manager/admin-file-manager.actions.ts @@ -0,0 +1,94 @@ +import { createAction, props } from '@ngrx/store'; + +import type { + AdminFileManagerEntry, + AdminFileManagerListParams, + AdminFileManagerView, + AdminFileVerifyResponse, +} from '../../types/billing.types'; + +export const listAdminFileManagerDirectory = createAction( + '[AdminFileManager] List Directory', + props<{ params: AdminFileManagerListParams }>(), +); + +export const listAdminFileManagerDirectorySuccess = createAction( + '[AdminFileManager] List Directory Success', + props<{ + cacheKey: string; + path: string; + view: AdminFileManagerView; + viewTenantId: string | null; + entries: AdminFileManagerEntry[]; + }>(), +); + +export const listAdminFileManagerDirectoryFailure = createAction( + '[AdminFileManager] List Directory Failure', + props<{ error: string }>(), +); + +export const setAdminFileManagerView = createAction( + '[AdminFileManager] Set View', + props<{ view: AdminFileManagerView; viewTenantId?: string | null }>(), +); + +export const expandAdminFileManagerPath = createAction('[AdminFileManager] Expand Path', props<{ path: string }>()); + +export const collapseAdminFileManagerPath = createAction('[AdminFileManager] Collapse Path', props<{ path: string }>()); + +export const refreshAdminFileManager = createAction('[AdminFileManager] Refresh'); + +export const downloadAdminFileManagerFile = createAction( + '[AdminFileManager] Download File', + props<{ params: AdminFileManagerListParams; fileName?: string }>(), +); + +export const downloadAdminFileManagerFileSuccess = createAction('[AdminFileManager] Download File Success'); + +export const downloadAdminFileManagerFileFailure = createAction( + '[AdminFileManager] Download File Failure', + props<{ error: string }>(), +); + +export const downloadAdminFileManagerArchive = createAction( + '[AdminFileManager] Download Archive', + props<{ params: AdminFileManagerListParams; fileName?: string }>(), +); + +export const downloadAdminFileManagerArchiveSuccess = createAction('[AdminFileManager] Download Archive Success'); + +export const downloadAdminFileManagerArchiveFailure = createAction( + '[AdminFileManager] Download Archive Failure', + props<{ error: string }>(), +); + +export const verifyAdminFileManagerFile = createAction('[AdminFileManager] Verify File', props<{ file: File }>()); + +export const verifyAdminFileManagerFileSuccess = createAction( + '[AdminFileManager] Verify File Success', + props<{ result: AdminFileVerifyResponse }>(), +); + +export const verifyAdminFileManagerFileFailure = createAction( + '[AdminFileManager] Verify File Failure', + props<{ error: string }>(), +); + +export const clearAdminFileManagerVerifyResult = createAction('[AdminFileManager] Clear Verify Result'); + +export const downloadAdminFileManagerByDocumentId = createAction( + '[AdminFileManager] Download By Document Id', + props<{ documentId: string }>(), +); + +export const downloadAdminFileManagerByDocumentIdSuccess = createAction( + '[AdminFileManager] Download By Document Id Success', +); + +export const downloadAdminFileManagerByDocumentIdFailure = createAction( + '[AdminFileManager] Download By Document Id Failure', + props<{ error: string }>(), +); + +export const clearAdminFileManagerDocumentIdLookup = createAction('[AdminFileManager] Clear Document Id Lookup'); diff --git a/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/admin-file-manager/admin-file-manager.effects.spec.ts b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/admin-file-manager/admin-file-manager.effects.spec.ts new file mode 100644 index 000000000..721599388 --- /dev/null +++ b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/admin-file-manager/admin-file-manager.effects.spec.ts @@ -0,0 +1,108 @@ +import { TestBed } from '@angular/core/testing'; +import { Actions } from '@ngrx/effects'; +import { provideMockActions } from '@ngrx/effects/testing'; +import { MockStore, provideMockStore } from '@ngrx/store/testing'; +import { of, throwError, toArray } from 'rxjs'; + +import { AdminBillingService } from '../../services/admin-billing.service'; + +import { + listAdminFileManagerDirectory, + listAdminFileManagerDirectoryFailure, + listAdminFileManagerDirectorySuccess, + refreshAdminFileManager, +} from './admin-file-manager.actions'; +import { listAdminFileManagerDirectory$, refreshAdminFileManager$ } from './admin-file-manager.effects'; +import { buildAdminFileManagerCacheKey, initialAdminFileManagerState } from './admin-file-manager.reducer'; +import { selectAdminFileManagerState } from './admin-file-manager.selectors'; + +describe('AdminFileManagerEffects', () => { + let actions$: Actions; + let service: jest.Mocked>; + + beforeEach(() => { + service = { + listAdminFiles: jest.fn(), + }; + + TestBed.configureTestingModule({ + providers: [ + provideMockActions(() => actions$), + provideMockStore({ + initialState: { + adminFileManager: { + ...initialAdminFileManagerState, + view: 'tenant', + viewTenantId: 'default', + expandedPaths: ['', 'customer', 'customer/invoices'], + }, + }, + }), + { provide: AdminBillingService, useValue: service }, + ], + }); + actions$ = TestBed.inject(Actions); + }); + + it('lists directories on success', (done) => { + actions$ = of(listAdminFileManagerDirectory({ params: { path: '', view: 'tenant', viewTenantId: 'default' } })); + service.listAdminFiles.mockReturnValue( + of({ + path: '', + view: 'tenant', + viewTenantId: 'default', + entries: [{ name: 'invoices', path: 'invoices', type: 'directory' }], + }), + ); + + listAdminFileManagerDirectory$(actions$, service as AdminBillingService).subscribe((result) => { + expect(result).toEqual( + listAdminFileManagerDirectorySuccess({ + cacheKey: buildAdminFileManagerCacheKey('tenant', 'default', ''), + path: '', + view: 'tenant', + viewTenantId: 'default', + entries: [{ name: 'invoices', path: 'invoices', type: 'directory' }], + }), + ); + done(); + }); + }); + + it('maps list failures', (done) => { + actions$ = of(listAdminFileManagerDirectory({ params: { path: '' } })); + service.listAdminFiles.mockReturnValue(throwError(() => new Error('boom'))); + + listAdminFileManagerDirectory$(actions$, service as AdminBillingService).subscribe((result) => { + expect(result).toEqual(listAdminFileManagerDirectoryFailure({ error: 'boom' })); + done(); + }); + }); + + it('relists expanded paths on refresh in depth order', (done) => { + const store = TestBed.inject(MockStore); + + store.overrideSelector(selectAdminFileManagerState, { + ...initialAdminFileManagerState, + view: 'tenant', + viewTenantId: 'default', + expandedPaths: ['customer/invoices', '', 'customer'], + }); + store.refreshState(); + + actions$ = of(refreshAdminFileManager()); + + refreshAdminFileManager$(actions$, store) + .pipe(toArray()) + .subscribe((results) => { + expect(results).toEqual([ + listAdminFileManagerDirectory({ params: { path: '', view: 'tenant', viewTenantId: 'default' } }), + listAdminFileManagerDirectory({ params: { path: 'customer', view: 'tenant', viewTenantId: 'default' } }), + listAdminFileManagerDirectory({ + params: { path: 'customer/invoices', view: 'tenant', viewTenantId: 'default' }, + }), + ]); + done(); + }); + }); +}); diff --git a/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/admin-file-manager/admin-file-manager.effects.ts b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/admin-file-manager/admin-file-manager.effects.ts new file mode 100644 index 000000000..71a96c9ed --- /dev/null +++ b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/admin-file-manager/admin-file-manager.effects.ts @@ -0,0 +1,178 @@ +import { inject } from '@angular/core'; +import { Actions, createEffect, ofType } from '@ngrx/effects'; +import { Store } from '@ngrx/store'; +import { catchError, from, map, mergeMap, of, switchMap, tap, withLatestFrom } from 'rxjs'; + +import { AdminBillingService } from '../../services/admin-billing.service'; + +import { + downloadAdminFileManagerArchive, + downloadAdminFileManagerArchiveFailure, + downloadAdminFileManagerArchiveSuccess, + downloadAdminFileManagerByDocumentId, + downloadAdminFileManagerByDocumentIdFailure, + downloadAdminFileManagerByDocumentIdSuccess, + downloadAdminFileManagerFile, + downloadAdminFileManagerFileFailure, + downloadAdminFileManagerFileSuccess, + listAdminFileManagerDirectory, + listAdminFileManagerDirectoryFailure, + listAdminFileManagerDirectorySuccess, + refreshAdminFileManager, + verifyAdminFileManagerFile, + verifyAdminFileManagerFileFailure, + verifyAdminFileManagerFileSuccess, +} from './admin-file-manager.actions'; +import { buildAdminFileManagerCacheKey } from './admin-file-manager.reducer'; +import { selectAdminFileManagerState } from './admin-file-manager.selectors'; + +function triggerBrowserDownload(blob: Blob, fileName: string): void { + const url = URL.createObjectURL(blob); + const anchor = document.createElement('a'); + + anchor.href = url; + anchor.download = fileName; + anchor.click(); + URL.revokeObjectURL(url); +} + +function pathDepth(path: string): number { + if (!path) { + return 0; + } + + return path.split('/').filter(Boolean).length; +} + +export const listAdminFileManagerDirectory$ = createEffect( + (actions$ = inject(Actions), service = inject(AdminBillingService)) => + actions$.pipe( + ofType(listAdminFileManagerDirectory), + mergeMap(({ params }) => { + const path = params.path ?? ''; + const view = params.view ?? 'tenant'; + const viewTenantId = view === 'unified' ? null : (params.viewTenantId ?? null); + + return service.listAdminFiles(params).pipe( + map((response) => + listAdminFileManagerDirectorySuccess({ + cacheKey: buildAdminFileManagerCacheKey(view, viewTenantId, path), + path, + view: response.view, + viewTenantId: response.viewTenantId ?? viewTenantId, + entries: response.entries, + }), + ), + catchError((error: Error) => + of(listAdminFileManagerDirectoryFailure({ error: error.message ?? 'Failed to list files' })), + ), + ); + }), + ), + { functional: true }, +); + +export const refreshAdminFileManager$ = createEffect( + (actions$ = inject(Actions), store = inject(Store)) => + actions$.pipe( + ofType(refreshAdminFileManager), + withLatestFrom(store.select(selectAdminFileManagerState)), + switchMap(([, state]) => { + const paths = [...state.expandedPaths].sort((left, right) => pathDepth(left) - pathDepth(right)); + + return from(paths).pipe( + map((path) => + listAdminFileManagerDirectory({ + params: { + path, + view: state.view, + viewTenantId: state.view === 'unified' ? undefined : (state.viewTenantId ?? undefined), + }, + }), + ), + ); + }), + ), + { functional: true }, +); + +export const downloadAdminFileManagerFile$ = createEffect( + (actions$ = inject(Actions), service = inject(AdminBillingService)) => + actions$.pipe( + ofType(downloadAdminFileManagerFile), + switchMap(({ params, fileName }) => + service.downloadAdminFile(params).pipe( + tap((blob) => { + const name = fileName || params.path?.split('/').pop() || 'download'; + + triggerBrowserDownload(blob, name); + }), + map(() => downloadAdminFileManagerFileSuccess()), + catchError((error: Error) => + of(downloadAdminFileManagerFileFailure({ error: error.message ?? 'Failed to download file' })), + ), + ), + ), + ), + { functional: true }, +); + +export const downloadAdminFileManagerArchive$ = createEffect( + (actions$ = inject(Actions), service = inject(AdminBillingService)) => + actions$.pipe( + ofType(downloadAdminFileManagerArchive), + switchMap(({ params, fileName }) => + service.downloadAdminFileArchive(params).pipe( + tap((blob) => { + const folder = params.path?.split('/').filter(Boolean).pop() || 'files'; + + triggerBrowserDownload(blob, fileName || `${folder}.zip`); + }), + map(() => downloadAdminFileManagerArchiveSuccess()), + catchError((error: Error) => + of(downloadAdminFileManagerArchiveFailure({ error: error.message ?? 'Failed to download archive' })), + ), + ), + ), + ), + { functional: true }, +); + +export const verifyAdminFileManagerFile$ = createEffect( + (actions$ = inject(Actions), service = inject(AdminBillingService)) => + actions$.pipe( + ofType(verifyAdminFileManagerFile), + switchMap(({ file }) => + service.verifyAdminFile(file).pipe( + map((result) => verifyAdminFileManagerFileSuccess({ result })), + catchError((error: Error) => + of(verifyAdminFileManagerFileFailure({ error: error.message ?? 'Failed to verify file' })), + ), + ), + ), + ), + { functional: true }, +); + +export const downloadAdminFileManagerByDocumentId$ = createEffect( + (actions$ = inject(Actions), service = inject(AdminBillingService)) => + actions$.pipe( + ofType(downloadAdminFileManagerByDocumentId), + switchMap(({ documentId }) => + service.downloadAdminFileByDocumentId(documentId).pipe( + tap(({ blob, fileName }) => { + triggerBrowserDownload(blob, fileName); + }), + map(() => downloadAdminFileManagerByDocumentIdSuccess()), + catchError((error: Error) => + of( + downloadAdminFileManagerByDocumentIdFailure({ + error: error.message ?? 'Failed to download file by document ID', + }), + ), + ), + ), + ), + ), + { functional: true }, +); diff --git a/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/admin-file-manager/admin-file-manager.facade.ts b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/admin-file-manager/admin-file-manager.facade.ts new file mode 100644 index 000000000..44b8d1bae --- /dev/null +++ b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/admin-file-manager/admin-file-manager.facade.ts @@ -0,0 +1,111 @@ +import { inject, Injectable } from '@angular/core'; +import { Store } from '@ngrx/store'; + +import type { AdminFileManagerListParams, AdminFileManagerView } from '../../types/billing.types'; + +import { + clearAdminFileManagerDocumentIdLookup, + clearAdminFileManagerVerifyResult, + collapseAdminFileManagerPath, + downloadAdminFileManagerArchive, + downloadAdminFileManagerByDocumentId, + downloadAdminFileManagerFile, + expandAdminFileManagerPath, + listAdminFileManagerDirectory, + refreshAdminFileManager, + setAdminFileManagerView, + verifyAdminFileManagerFile, +} from './admin-file-manager.actions'; +import { + selectAdminFileManagerDocumentIdLookupError, + selectAdminFileManagerDocumentIdLookupLoading, + selectAdminFileManagerDocumentIdLookupSuccess, + selectAdminFileManagerDownloadLoading, + selectAdminFileManagerError, + selectAdminFileManagerExpandedPaths, + selectAdminFileManagerLoadingPath, + selectAdminFileManagerRootEntries, + selectAdminFileManagerState, + selectAdminFileManagerVerifyError, + selectAdminFileManagerVerifyLoading, + selectAdminFileManagerVerifyResult, + selectAdminFileManagerView, + selectAdminFileManagerViewTenantId, +} from './admin-file-manager.selectors'; +import { buildAdminFileManagerCacheKey } from './admin-file-manager.reducer'; + +@Injectable() +export class AdminFileManagerFacade { + private readonly store = inject(Store); + + readonly view$ = this.store.select(selectAdminFileManagerView); + readonly viewTenantId$ = this.store.select(selectAdminFileManagerViewTenantId); + readonly rootEntries$ = this.store.select(selectAdminFileManagerRootEntries); + readonly expandedPaths$ = this.store.select(selectAdminFileManagerExpandedPaths); + readonly loadingPath$ = this.store.select(selectAdminFileManagerLoadingPath); + readonly error$ = this.store.select(selectAdminFileManagerError); + readonly downloadLoading$ = this.store.select(selectAdminFileManagerDownloadLoading); + readonly verifyLoading$ = this.store.select(selectAdminFileManagerVerifyLoading); + readonly verifyResult$ = this.store.select(selectAdminFileManagerVerifyResult); + readonly verifyError$ = this.store.select(selectAdminFileManagerVerifyError); + readonly documentIdLookupLoading$ = this.store.select(selectAdminFileManagerDocumentIdLookupLoading); + readonly documentIdLookupError$ = this.store.select(selectAdminFileManagerDocumentIdLookupError); + readonly documentIdLookupSuccess$ = this.store.select(selectAdminFileManagerDocumentIdLookupSuccess); + readonly state$ = this.store.select(selectAdminFileManagerState); + + setView(view: AdminFileManagerView, viewTenantId?: string | null): void { + this.store.dispatch(setAdminFileManagerView({ view, viewTenantId })); + } + + listDirectory(params: AdminFileManagerListParams): void { + this.store.dispatch(listAdminFileManagerDirectory({ params })); + } + + expandPath(path: string): void { + this.store.dispatch(expandAdminFileManagerPath({ path })); + } + + collapsePath(path: string): void { + this.store.dispatch(collapseAdminFileManagerPath({ path })); + } + + ensureDirectoryLoaded(params: AdminFileManagerListParams, cached: boolean): void { + if (cached) { + return; + } + + this.listDirectory(params); + } + + refresh(): void { + this.store.dispatch(refreshAdminFileManager()); + } + + verifyFile(file: File): void { + this.store.dispatch(verifyAdminFileManagerFile({ file })); + } + + clearVerifyResult(): void { + this.store.dispatch(clearAdminFileManagerVerifyResult()); + } + + downloadByDocumentId(documentId: string): void { + this.store.dispatch(downloadAdminFileManagerByDocumentId({ documentId })); + } + + clearDocumentIdLookup(): void { + this.store.dispatch(clearAdminFileManagerDocumentIdLookup()); + } + + downloadFile(params: AdminFileManagerListParams, fileName?: string): void { + this.store.dispatch(downloadAdminFileManagerFile({ params, fileName })); + } + + downloadArchive(params: AdminFileManagerListParams, fileName?: string): void { + this.store.dispatch(downloadAdminFileManagerArchive({ params, fileName })); + } + + cacheKey(view: AdminFileManagerView, viewTenantId: string | null, path: string): string { + return buildAdminFileManagerCacheKey(view, viewTenantId, path); + } +} diff --git a/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/admin-file-manager/admin-file-manager.reducer.spec.ts b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/admin-file-manager/admin-file-manager.reducer.spec.ts new file mode 100644 index 000000000..1a43c5ebf --- /dev/null +++ b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/admin-file-manager/admin-file-manager.reducer.spec.ts @@ -0,0 +1,131 @@ +import { + clearAdminFileManagerDocumentIdLookup, + clearAdminFileManagerVerifyResult, + collapseAdminFileManagerPath, + downloadAdminFileManagerByDocumentId, + downloadAdminFileManagerByDocumentIdFailure, + downloadAdminFileManagerByDocumentIdSuccess, + expandAdminFileManagerPath, + listAdminFileManagerDirectorySuccess, + refreshAdminFileManager, + setAdminFileManagerView, + verifyAdminFileManagerFileSuccess, +} from './admin-file-manager.actions'; +import { + adminFileManagerReducer, + buildAdminFileManagerCacheKey, + initialAdminFileManagerState, +} from './admin-file-manager.reducer'; + +describe('adminFileManagerReducer', () => { + it('resets cache when view changes', () => { + const seeded = { + ...initialAdminFileManagerState, + directoriesByPath: { 'tenant||invoices': [] }, + expandedPaths: ['', 'invoices'], + }; + + const next = adminFileManagerReducer(seeded, setAdminFileManagerView({ view: 'unified' })); + + expect(next.directoriesByPath).toEqual({}); + expect(next.expandedPaths).toEqual(['']); + expect(next.view).toBe('unified'); + }); + + it('stores directory listings by cache key', () => { + const entries = [{ name: 'invoices', path: 'invoices', type: 'directory' as const }]; + const cacheKey = buildAdminFileManagerCacheKey('tenant', 'default', ''); + const next = adminFileManagerReducer( + initialAdminFileManagerState, + listAdminFileManagerDirectorySuccess({ + cacheKey, + path: '', + view: 'tenant', + viewTenantId: 'default', + entries, + }), + ); + + expect(next.directoriesByPath[cacheKey]).toEqual(entries); + expect(next.viewTenantId).toBe('default'); + }); + + it('keeps expanded paths when refreshing', () => { + const seeded = { + ...initialAdminFileManagerState, + view: 'tenant' as const, + viewTenantId: 'default', + directoriesByPath: { 'tenant|default|': [], 'tenant|default|customer': [] }, + expandedPaths: ['', 'customer', 'customer/invoices'], + }; + + const next = adminFileManagerReducer(seeded, refreshAdminFileManager()); + + expect(next.directoriesByPath).toEqual({}); + expect(next.expandedPaths).toEqual(['', 'customer', 'customer/invoices']); + expect(next.loadingPath).toBe(''); + }); + + it('expands and collapses paths including descendants', () => { + const expanded = adminFileManagerReducer( + { + ...initialAdminFileManagerState, + expandedPaths: ['', 'customer'], + }, + expandAdminFileManagerPath({ path: 'customer/invoices' }), + ); + + expect(expanded.expandedPaths).toEqual(['', 'customer', 'customer/invoices']); + + const collapsed = adminFileManagerReducer(expanded, collapseAdminFileManagerPath({ path: 'customer' })); + + expect(collapsed.expandedPaths).toEqual(['']); + }); + + it('stores and clears verify results', () => { + const withResult = adminFileManagerReducer( + initialAdminFileManagerState, + verifyAdminFileManagerFileSuccess({ + result: { verdict: 'authentic', contentSha256: 'a'.repeat(64) }, + }), + ); + + expect(withResult.verifyResult?.verdict).toBe('authentic'); + expect(withResult.verifyLoading).toBe(false); + + const cleared = adminFileManagerReducer(withResult, clearAdminFileManagerVerifyResult()); + + expect(cleared.verifyResult).toBeNull(); + expect(cleared.verifyError).toBeNull(); + }); + + it('tracks document id lookup loading success and failure', () => { + const loading = adminFileManagerReducer( + initialAdminFileManagerState, + downloadAdminFileManagerByDocumentId({ documentId: 'abcdef1' }), + ); + + expect(loading.documentIdLookupLoading).toBe(true); + expect(loading.documentIdLookupError).toBeNull(); + expect(loading.documentIdLookupSuccess).toBe(false); + + const success = adminFileManagerReducer(loading, downloadAdminFileManagerByDocumentIdSuccess()); + + expect(success.documentIdLookupLoading).toBe(false); + expect(success.documentIdLookupSuccess).toBe(true); + + const failure = adminFileManagerReducer( + loading, + downloadAdminFileManagerByDocumentIdFailure({ error: 'No file found' }), + ); + + expect(failure.documentIdLookupLoading).toBe(false); + expect(failure.documentIdLookupError).toBe('No file found'); + expect(failure.documentIdLookupSuccess).toBe(false); + + const cleared = adminFileManagerReducer(failure, clearAdminFileManagerDocumentIdLookup()); + + expect(cleared.documentIdLookupError).toBeNull(); + expect(cleared.documentIdLookupSuccess).toBe(false); + }); +}); diff --git a/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/admin-file-manager/admin-file-manager.reducer.ts b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/admin-file-manager/admin-file-manager.reducer.ts new file mode 100644 index 000000000..d17832d06 --- /dev/null +++ b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/admin-file-manager/admin-file-manager.reducer.ts @@ -0,0 +1,184 @@ +import { createReducer, on } from '@ngrx/store'; + +import type { AdminFileManagerEntry, AdminFileManagerView, AdminFileVerifyResponse } from '../../types/billing.types'; + +import { + clearAdminFileManagerDocumentIdLookup, + clearAdminFileManagerVerifyResult, + collapseAdminFileManagerPath, + downloadAdminFileManagerArchive, + downloadAdminFileManagerArchiveFailure, + downloadAdminFileManagerArchiveSuccess, + downloadAdminFileManagerByDocumentId, + downloadAdminFileManagerByDocumentIdFailure, + downloadAdminFileManagerByDocumentIdSuccess, + downloadAdminFileManagerFile, + downloadAdminFileManagerFileFailure, + downloadAdminFileManagerFileSuccess, + expandAdminFileManagerPath, + listAdminFileManagerDirectory, + listAdminFileManagerDirectoryFailure, + listAdminFileManagerDirectorySuccess, + refreshAdminFileManager, + setAdminFileManagerView, + verifyAdminFileManagerFile, + verifyAdminFileManagerFileFailure, + verifyAdminFileManagerFileSuccess, +} from './admin-file-manager.actions'; + +function isPathOrDescendant(candidate: string, parent: string): boolean { + if (parent === '') { + return candidate !== ''; + } + + return candidate === parent || candidate.startsWith(`${parent}/`); +} + +export interface AdminFileManagerState { + view: AdminFileManagerView; + viewTenantId: string | null; + directoriesByPath: Record; + expandedPaths: string[]; + loadingPath: string | null; + error: string | null; + downloadLoading: boolean; + verifyLoading: boolean; + verifyResult: AdminFileVerifyResponse | null; + verifyError: string | null; + documentIdLookupLoading: boolean; + documentIdLookupError: string | null; + documentIdLookupSuccess: boolean; +} + +export const initialAdminFileManagerState: AdminFileManagerState = { + view: 'tenant', + viewTenantId: null, + directoriesByPath: {}, + expandedPaths: [''], + loadingPath: null, + error: null, + downloadLoading: false, + verifyLoading: false, + verifyResult: null, + verifyError: null, + documentIdLookupLoading: false, + documentIdLookupError: null, + documentIdLookupSuccess: false, +}; + +export function buildAdminFileManagerCacheKey( + view: AdminFileManagerView, + viewTenantId: string | null | undefined, + path: string, +): string { + return `${view}|${viewTenantId ?? ''}|${path}`; +} + +export const adminFileManagerReducer = createReducer( + initialAdminFileManagerState, + on(setAdminFileManagerView, (state, { view, viewTenantId }) => ({ + ...state, + view, + viewTenantId: view === 'unified' ? null : (viewTenantId ?? state.viewTenantId), + directoriesByPath: {}, + expandedPaths: [''], + error: null, + })), + on(expandAdminFileManagerPath, (state, { path }) => ({ + ...state, + expandedPaths: state.expandedPaths.includes(path) ? state.expandedPaths : [...state.expandedPaths, path], + })), + on(collapseAdminFileManagerPath, (state, { path }) => ({ + ...state, + expandedPaths: + path === '' ? [''] : state.expandedPaths.filter((expandedPath) => !isPathOrDescendant(expandedPath, path)), + })), + on(refreshAdminFileManager, (state) => ({ + ...state, + directoriesByPath: {}, + loadingPath: '', + error: null, + })), + on(listAdminFileManagerDirectory, (state, { params }) => ({ + ...state, + loadingPath: params.path ?? '', + error: null, + view: params.view ?? state.view, + viewTenantId: (params.view ?? state.view) === 'unified' ? null : (params.viewTenantId ?? state.viewTenantId), + })), + on(listAdminFileManagerDirectorySuccess, (state, { cacheKey, path, view, viewTenantId, entries }) => ({ + ...state, + loadingPath: state.loadingPath === path ? null : state.loadingPath, + view, + viewTenantId, + directoriesByPath: { + ...state.directoriesByPath, + [cacheKey]: entries, + }, + expandedPaths: state.expandedPaths.includes(path) ? state.expandedPaths : [...state.expandedPaths, path], + })), + on(listAdminFileManagerDirectoryFailure, (state, { error }) => ({ + ...state, + loadingPath: null, + error, + })), + on(downloadAdminFileManagerFile, (state) => ({ ...state, downloadLoading: true, error: null })), + on(downloadAdminFileManagerFileSuccess, (state) => ({ ...state, downloadLoading: false })), + on(downloadAdminFileManagerFileFailure, (state, { error }) => ({ + ...state, + downloadLoading: false, + error, + })), + on(downloadAdminFileManagerArchive, (state) => ({ ...state, downloadLoading: true, error: null })), + on(downloadAdminFileManagerArchiveSuccess, (state) => ({ ...state, downloadLoading: false })), + on(downloadAdminFileManagerArchiveFailure, (state, { error }) => ({ + ...state, + downloadLoading: false, + error, + })), + on(verifyAdminFileManagerFile, (state) => ({ + ...state, + verifyLoading: true, + verifyError: null, + verifyResult: null, + })), + on(verifyAdminFileManagerFileSuccess, (state, { result }) => ({ + ...state, + verifyLoading: false, + verifyResult: result, + })), + on(verifyAdminFileManagerFileFailure, (state, { error }) => ({ + ...state, + verifyLoading: false, + verifyError: error, + })), + on(clearAdminFileManagerVerifyResult, (state) => ({ + ...state, + verifyLoading: false, + verifyResult: null, + verifyError: null, + })), + on(downloadAdminFileManagerByDocumentId, (state) => ({ + ...state, + documentIdLookupLoading: true, + documentIdLookupError: null, + documentIdLookupSuccess: false, + })), + on(downloadAdminFileManagerByDocumentIdSuccess, (state) => ({ + ...state, + documentIdLookupLoading: false, + documentIdLookupSuccess: true, + })), + on(downloadAdminFileManagerByDocumentIdFailure, (state, { error }) => ({ + ...state, + documentIdLookupLoading: false, + documentIdLookupError: error, + documentIdLookupSuccess: false, + })), + on(clearAdminFileManagerDocumentIdLookup, (state) => ({ + ...state, + documentIdLookupLoading: false, + documentIdLookupError: null, + documentIdLookupSuccess: false, + })), +); diff --git a/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/admin-file-manager/admin-file-manager.selectors.ts b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/admin-file-manager/admin-file-manager.selectors.ts new file mode 100644 index 000000000..5d246c619 --- /dev/null +++ b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/admin-file-manager/admin-file-manager.selectors.ts @@ -0,0 +1,68 @@ +import { createFeatureSelector, createSelector } from '@ngrx/store'; + +import { buildAdminFileManagerCacheKey, type AdminFileManagerState } from './admin-file-manager.reducer'; + +export const selectAdminFileManagerState = createFeatureSelector('adminFileManager'); + +export const selectAdminFileManagerView = createSelector(selectAdminFileManagerState, (state) => state.view); + +export const selectAdminFileManagerViewTenantId = createSelector( + selectAdminFileManagerState, + (state) => state.viewTenantId, +); + +export const selectAdminFileManagerError = createSelector(selectAdminFileManagerState, (state) => state.error); + +export const selectAdminFileManagerDownloadLoading = createSelector( + selectAdminFileManagerState, + (state) => state.downloadLoading, +); + +export const selectAdminFileManagerLoadingPath = createSelector( + selectAdminFileManagerState, + (state) => state.loadingPath, +); + +export const selectAdminFileManagerExpandedPaths = createSelector( + selectAdminFileManagerState, + (state) => state.expandedPaths, +); + +export const selectAdminFileManagerVerifyLoading = createSelector( + selectAdminFileManagerState, + (state) => state.verifyLoading, +); + +export const selectAdminFileManagerVerifyResult = createSelector( + selectAdminFileManagerState, + (state) => state.verifyResult, +); + +export const selectAdminFileManagerVerifyError = createSelector( + selectAdminFileManagerState, + (state) => state.verifyError, +); + +export const selectAdminFileManagerDocumentIdLookupLoading = createSelector( + selectAdminFileManagerState, + (state) => state.documentIdLookupLoading, +); + +export const selectAdminFileManagerDocumentIdLookupError = createSelector( + selectAdminFileManagerState, + (state) => state.documentIdLookupError, +); + +export const selectAdminFileManagerDocumentIdLookupSuccess = createSelector( + selectAdminFileManagerState, + (state) => state.documentIdLookupSuccess, +); + +export const selectAdminFileManagerEntriesForPath = (path: string) => + createSelector(selectAdminFileManagerState, (state) => { + const cacheKey = buildAdminFileManagerCacheKey(state.view, state.viewTenantId, path); + + return state.directoriesByPath[cacheKey] ?? []; + }); + +export const selectAdminFileManagerRootEntries = selectAdminFileManagerEntriesForPath(''); diff --git a/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/billing-capabilities/billing-capabilities.effects.spec.ts b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/billing-capabilities/billing-capabilities.effects.spec.ts index 155686a0a..1748da995 100644 --- a/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/billing-capabilities/billing-capabilities.effects.spec.ts +++ b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/billing-capabilities/billing-capabilities.effects.spec.ts @@ -29,11 +29,27 @@ describe('BillingCapabilitiesEffects', () => { it('loads capabilities on success', (done) => { actions$ = of(loadBillingCapabilities()); - service.getCapabilities.mockReturnValue(of({ datevExportEnabled: true, unifiedExportAllowed: false })); + service.getCapabilities.mockReturnValue( + of({ + datevExportEnabled: true, + unifiedExportAllowed: false, + globalViewsAllowed: false, + viewableTenants: ['default'], + fileSigningEnabled: true, + }), + ); loadBillingCapabilities$(actions$, service as AdminBillingService).subscribe((result) => { expect(result).toEqual( - loadBillingCapabilitiesSuccess({ capabilities: { datevExportEnabled: true, unifiedExportAllowed: false } }), + loadBillingCapabilitiesSuccess({ + capabilities: { + datevExportEnabled: true, + unifiedExportAllowed: false, + globalViewsAllowed: false, + viewableTenants: ['default'], + fileSigningEnabled: true, + }, + }), ); done(); }); diff --git a/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/billing-capabilities/billing-capabilities.facade.ts b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/billing-capabilities/billing-capabilities.facade.ts index 289978dab..aad8b5a3c 100644 --- a/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/billing-capabilities/billing-capabilities.facade.ts +++ b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/billing-capabilities/billing-capabilities.facade.ts @@ -6,7 +6,10 @@ import { selectBillingCapabilities, selectBillingCapabilitiesLoading, selectDatevExportEnabled, + selectFileSigningEnabled, + selectGlobalViewsAllowed, selectUnifiedExportAllowed, + selectViewableTenants, } from './billing-capabilities.selectors'; @Injectable() @@ -16,6 +19,9 @@ export class BillingCapabilitiesFacade { readonly capabilities$ = this.store.select(selectBillingCapabilities); readonly datevExportEnabled$ = this.store.select(selectDatevExportEnabled); readonly unifiedExportAllowed$ = this.store.select(selectUnifiedExportAllowed); + readonly globalViewsAllowed$ = this.store.select(selectGlobalViewsAllowed); + readonly viewableTenants$ = this.store.select(selectViewableTenants); + readonly fileSigningEnabled$ = this.store.select(selectFileSigningEnabled); readonly loading$ = this.store.select(selectBillingCapabilitiesLoading); loadCapabilities(): void { diff --git a/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/billing-capabilities/billing-capabilities.reducer.spec.ts b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/billing-capabilities/billing-capabilities.reducer.spec.ts index a104f2ae1..41ba6700f 100644 --- a/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/billing-capabilities/billing-capabilities.reducer.spec.ts +++ b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/billing-capabilities/billing-capabilities.reducer.spec.ts @@ -10,11 +10,18 @@ describe('billingCapabilitiesReducer', () => { const state = billingCapabilitiesReducer( { ...initialBillingCapabilitiesState, loading: true }, loadBillingCapabilitiesSuccess({ - capabilities: { datevExportEnabled: true, unifiedExportAllowed: false }, + capabilities: { + datevExportEnabled: true, + unifiedExportAllowed: false, + globalViewsAllowed: false, + viewableTenants: ['default'], + fileSigningEnabled: true, + }, }), ); expect(state.capabilities?.datevExportEnabled).toBe(true); + expect(state.capabilities?.fileSigningEnabled).toBe(true); expect(state.loading).toBe(false); }); diff --git a/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/billing-capabilities/billing-capabilities.selectors.spec.ts b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/billing-capabilities/billing-capabilities.selectors.spec.ts index 01c596f3c..f627de1fe 100644 --- a/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/billing-capabilities/billing-capabilities.selectors.spec.ts +++ b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/billing-capabilities/billing-capabilities.selectors.spec.ts @@ -4,7 +4,9 @@ import { selectBillingCapabilitiesLoading, selectBillingCapabilitiesState, selectDatevExportEnabled, + selectGlobalViewsAllowed, selectUnifiedExportAllowed, + selectViewableTenants, } from './billing-capabilities.selectors'; describe('BillingCapabilities Selectors', () => { @@ -15,7 +17,12 @@ describe('BillingCapabilities Selectors', () => { it('selects feature state and capabilities', () => { const state = createState({ - capabilities: { datevExportEnabled: true, unifiedExportAllowed: true }, + capabilities: { + datevExportEnabled: true, + unifiedExportAllowed: true, + globalViewsAllowed: true, + viewableTenants: ['default', 'acme'], + }, }); const rootState = { billingCapabilities: state }; @@ -23,6 +30,8 @@ describe('BillingCapabilities Selectors', () => { expect(selectBillingCapabilities(rootState as never)).toEqual({ datevExportEnabled: true, unifiedExportAllowed: true, + globalViewsAllowed: true, + viewableTenants: ['default', 'acme'], }); }); @@ -31,6 +40,8 @@ describe('BillingCapabilities Selectors', () => { expect(selectDatevExportEnabled(rootState as never)).toBe(false); expect(selectUnifiedExportAllowed(rootState as never)).toBe(false); + expect(selectGlobalViewsAllowed(rootState as never)).toBe(false); + expect(selectViewableTenants(rootState as never)).toEqual([]); }); it('selects loading state', () => { diff --git a/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/billing-capabilities/billing-capabilities.selectors.ts b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/billing-capabilities/billing-capabilities.selectors.ts index 855c675ed..133997d44 100644 --- a/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/billing-capabilities/billing-capabilities.selectors.ts +++ b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/state/billing-capabilities/billing-capabilities.selectors.ts @@ -16,6 +16,21 @@ export const selectUnifiedExportAllowed = createSelector( (capabilities) => capabilities?.unifiedExportAllowed ?? false, ); +export const selectGlobalViewsAllowed = createSelector( + selectBillingCapabilities, + (capabilities) => capabilities?.globalViewsAllowed ?? false, +); + +export const selectViewableTenants = createSelector( + selectBillingCapabilities, + (capabilities) => capabilities?.viewableTenants ?? [], +); + +export const selectFileSigningEnabled = createSelector( + selectBillingCapabilities, + (capabilities) => capabilities?.fileSigningEnabled ?? false, +); + export const selectBillingCapabilitiesLoading = createSelector( selectBillingCapabilitiesState, (state) => state.loading, diff --git a/libs/domains/decabill/frontend/data-access-billing-console/src/lib/types/billing.types.ts b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/types/billing.types.ts index 6944f982a..509278a60 100644 --- a/libs/domains/decabill/frontend/data-access-billing-console/src/lib/types/billing.types.ts +++ b/libs/domains/decabill/frontend/data-access-billing-console/src/lib/types/billing.types.ts @@ -1544,6 +1544,9 @@ export type DatevExportStatus = 'pending' | 'running' | 'completed' | 'failed'; export interface BillingCapabilitiesResponse { datevExportEnabled: boolean; unifiedExportAllowed: boolean; + globalViewsAllowed: boolean; + viewableTenants: string[]; + fileSigningEnabled: boolean; } export interface AdminDatevExportListItem { @@ -1600,15 +1603,81 @@ export interface AdminDatevExportListParams { year?: number; scope?: DatevExportScope; search?: string; + viewTenantId?: string; } export interface TriggerDatevExportDto { year: number; month: number; scope?: DatevExportScope; + viewTenantId?: string; force?: boolean; } +export type AdminFileManagerView = 'tenant' | 'unified'; + +export type AdminFileManagerEntryType = 'file' | 'directory'; + +export interface AdminFileManagerEntry { + name: string; + path: string; + type: AdminFileManagerEntryType; + size?: number; + contentType?: string; + scope?: string; + updatedAt?: string; + id?: string; + shas?: { short: string; long: string }; + contentHashes?: { md5: string; sha1: string; sha256: string; sha512: string }; + byteSize?: number; + signature?: { + status: 'signed' | 'pending'; + alg?: string; + version?: string; + value?: string; + signedAt?: string; + tenantId?: string; + }; +} + +export interface AdminFileManagerListResponse { + path: string; + view: AdminFileManagerView; + viewTenantId?: string; + entries: AdminFileManagerEntry[]; +} + +export interface AdminFileManagerListParams { + path?: string; + view?: AdminFileManagerView; + viewTenantId?: string; +} + +export type AdminFileVerifyVerdict = 'authentic' | 'unknown' | 'unsigned' | 'signing_disabled'; + +export interface AdminFileVerifyMatch { + id: string; + shas: { short: string; long: string }; + tenantId: string; + scope: string; + storageKey: string; + virtualPath: string; + signature?: { + status: 'signed' | 'pending'; + alg?: string; + version?: string; + value?: string; + signedAt?: string; + tenantId?: string; + }; +} + +export interface AdminFileVerifyResponse { + verdict: AdminFileVerifyVerdict; + contentSha256?: string; + match?: AdminFileVerifyMatch; +} + export interface TriggerDatevExportResponse { queued: boolean; scope: DatevExportScope; diff --git a/libs/domains/decabill/frontend/feature-billing-console/src/lib/admin-datev-exports-page/admin-datev-exports-page.component.html b/libs/domains/decabill/frontend/feature-billing-console/src/lib/admin-datev-exports-page/admin-datev-exports-page.component.html index 38a01dcd2..766750abf 100644 --- a/libs/domains/decabill/frontend/feature-billing-console/src/lib/admin-datev-exports-page/admin-datev-exports-page.component.html +++ b/libs/domains/decabill/frontend/feature-billing-console/src/lib/admin-datev-exports-page/admin-datev-exports-page.component.html @@ -14,10 +14,18 @@ - @if (unifiedExportAllowed$ | async) { - - - + @if (showViewTabs$ | async) { + + @for (tenantId of viewableTenants(); track tenantId) { + + } + @if (unifiedExportAllowed$ | async) { + + } } @@ -150,13 +158,29 @@ name="datevMonth" /> - @if (unifiedExportAllowed$ | async) { + @if (showViewTabs$ | async) { - - + + @if (unifiedExportAllowed$ | async) { + + } + @if (triggerScope === 'tenant' && (globalViewsAllowed$ | async) && viewableTenants().length > 1) { + + + @for (tenantId of viewableTenants(); track tenantId) { + + } + + + } } @if (triggerError$ | async; as triggerErr) { diff --git a/libs/domains/decabill/frontend/feature-billing-console/src/lib/admin-datev-exports-page/admin-datev-exports-page.component.ts b/libs/domains/decabill/frontend/feature-billing-console/src/lib/admin-datev-exports-page/admin-datev-exports-page.component.ts index 5b49e29e6..e29971286 100644 --- a/libs/domains/decabill/frontend/feature-billing-console/src/lib/admin-datev-exports-page/admin-datev-exports-page.component.ts +++ b/libs/domains/decabill/frontend/feature-billing-console/src/lib/admin-datev-exports-page/admin-datev-exports-page.component.ts @@ -30,7 +30,7 @@ import { FpcTabGroupComponent, } from '@forepath/shared/frontend/ui-components'; import { ENVIRONMENT, type Environment } from '@forepath/decabill/frontend/util-configuration'; -import { debounceTime, distinctUntilChanged, skip } from 'rxjs'; +import { combineLatest, debounceTime, distinctUntilChanged, map, skip } from 'rxjs'; import { getDatevExportScopeLabel, @@ -75,7 +75,8 @@ export class AdminDatevExportsPageComponent implements OnInit { private readonly datePipe = inject(DatePipe); private readonly destroyRef = inject(DestroyRef); - readonly tenantTabLabel = resolveBillingTenantDisplayName(this.environment); + readonly homeTenantId = this.environment.billing?.tenantId?.trim() || 'default'; + readonly homeTenantTabLabel = resolveBillingTenantDisplayName(this.environment); readonly pageTitle = $localize`:@@featureAdminDatevExports-title:DATEV exports`; readonly unifiedTabLabel = $localize`:@@featureAdminDatevExports-tabUnified:Unified`; readonly searchPlaceholder = $localize`:@@featureAdminDatevExports-searchPlaceholder:Search exports`; @@ -89,45 +90,78 @@ export class AdminDatevExportsPageComponent implements OnInit { readonly loading$ = this.facade.loading$; readonly error$ = this.facade.error$; readonly scope$ = this.facade.scope$; + readonly viewTenantId$ = this.facade.viewTenantId$; readonly triggerLoading$ = this.facade.triggerLoading$; readonly triggerError$ = this.facade.triggerError$; readonly unifiedExportAllowed$ = this.capabilitiesFacade.unifiedExportAllowed$; + readonly globalViewsAllowed$ = this.capabilitiesFacade.globalViewsAllowed$; + readonly viewableTenants$ = this.capabilitiesFacade.viewableTenants$; + + readonly showViewTabs$ = combineLatest([this.globalViewsAllowed$, this.unifiedExportAllowed$]).pipe( + map(([globalViewsAllowed, unifiedExportAllowed]) => globalViewsAllowed || unifiedExportAllowed), + ); + + readonly activeTabId$ = combineLatest([this.scope$, this.viewTenantId$]).pipe( + map(([scope, viewTenantId]) => (scope === 'unified' ? 'unified' : (viewTenantId ?? this.homeTenantId))), + ); readonly items = toSignal(this.facade.items$, { initialValue: [] as AdminDatevExportListEntry[] }); readonly scope = toSignal(this.scope$, { initialValue: 'tenant' as DatevExportScope }); + readonly viewTenantId = toSignal(this.viewTenantId$, { initialValue: null as string | null }); + readonly viewableTenants = toSignal(this.viewableTenants$, { initialValue: [] as string[] }); readonly users = toSignal(this.authFacade.users$, { initialValue: [] as UserResponseDto[] }); triggerYear = new Date().getFullYear(); triggerMonth = new Date().getMonth() === 0 ? 12 : new Date().getMonth(); triggerScope: DatevExportScope = 'tenant'; + triggerViewTenantId = this.homeTenantId; ngOnInit(): void { - this.facade.loadExports({ scope: 'tenant' }); + this.capabilitiesFacade.loadCapabilities(); + this.facade.loadExports({ scope: 'tenant', viewTenantId: this.homeTenantId }); this.authFacade.loadUsers(); this.registerModalCloseWatcher(); this.searchQuery$ .pipe(skip(1), debounceTime(300), distinctUntilChanged(), takeUntilDestroyed(this.destroyRef)) .subscribe((search) => { - this.facade.loadExports({ scope: this.scope(), search: search.trim() || undefined }); + this.facade.loadExports({ + scope: this.scope(), + viewTenantId: this.scope() === 'tenant' ? (this.viewTenantId() ?? this.homeTenantId) : undefined, + search: search.trim() || undefined, + }); }); } + tenantTabLabel(tenantId: string): string { + if (tenantId === this.homeTenantId) { + return this.homeTenantTabLabel; + } + + if (!tenantId) { + return tenantId; + } + + return tenantId.charAt(0).toUpperCase() + tenantId.slice(1); + } + openExportModal(): void { this.resetTriggerForm(); showBillingModal(this.exportModalOpen); } - setScope(scope: DatevExportScope): void { - this.facade.loadExports({ scope, search: this.searchQuery().trim() || undefined }); - } + onScopeTabChange(tabId: string | null): void { + if (!tabId) { + return; + } + + if (tabId === 'unified') { + this.facade.setScope('unified'); - onScopeTabChange(scope: string | null): void { - if (scope !== 'tenant' && scope !== 'unified') { return; } - this.setScope(scope); + this.facade.setScope('tenant', tabId); } submitTriggerExport(): void { @@ -135,11 +169,15 @@ export class AdminDatevExportsPageComponent implements OnInit { year: this.triggerYear, month: this.triggerMonth, scope: this.triggerScope, + viewTenantId: this.triggerScope === 'tenant' ? this.triggerViewTenantId : undefined, }); } downloadExport(exportId: string): void { - this.facade.downloadExport(exportId); + this.facade.downloadExport( + exportId, + this.scope() === 'tenant' ? (this.viewTenantId() ?? this.homeTenantId) : undefined, + ); } exportPrimaryTitle(item: AdminDatevExportListEntry): string { @@ -176,7 +214,7 @@ export class AdminDatevExportsPageComponent implements OnInit { exportScopeLabel(scope: AdminDatevExportListEntry['scope']): string { if (scope === 'tenant') { - return this.tenantTabLabel; + return this.tenantTabLabel(this.viewTenantId() ?? this.homeTenantId); } return getDatevExportScopeLabel(scope); @@ -222,6 +260,7 @@ export class AdminDatevExportsPageComponent implements OnInit { this.triggerYear = new Date().getFullYear(); this.triggerMonth = new Date().getMonth() === 0 ? 12 : new Date().getMonth(); this.triggerScope = this.scope(); + this.triggerViewTenantId = this.viewTenantId() ?? this.homeTenantId; } private registerModalCloseWatcher(): void { diff --git a/libs/domains/decabill/frontend/feature-billing-console/src/lib/admin-file-manager-page/admin-file-manager-page.component.html b/libs/domains/decabill/frontend/feature-billing-console/src/lib/admin-file-manager-page/admin-file-manager-page.component.html new file mode 100644 index 000000000..e46b2da5b --- /dev/null +++ b/libs/domains/decabill/frontend/feature-billing-console/src/lib/admin-file-manager-page/admin-file-manager-page.component.html @@ -0,0 +1,254 @@ +
+ + + @if (fileSigningEnabled$ | async) { + + + + + + + } + + + + + + + @if (showViewTabs$ | async) { + + @for (tenantId of viewableTenants(); track tenantId) { + + } + + + } + +
+ @if ((loadingPath$ | async) === '' && treeNodes().length === 0) { +
+ +
+ } @else { + @if (error$ | async; as err) { + + } + @if (treeNodes().length === 0) { + + } @else { +
    + @for (node of treeNodes(); track node.entry.path) { +
  • +
    + + @if (node.entry.type === 'directory') { + + + } @else { + + + } + {{ node.entry.name }} + @if (node.entry.type === 'file') { + + @if (node.entry.shas?.short) { + + {{ + node.entry.shas?.short + }} + + } + + + + + } + + + + + +
    +
  • + } +
+ } + } +
+
+ + + + @for (key of [verifyFileInputKey()]; track key) { + + } +
+ Upload is checked in memory only and is never stored. +
+
+ + @if (verifyError$ | async; as verifyErr) { + + } + + @if (verifyResult$ | async; as result) { + + @if (result.match; as match) { +
+
+
Document ID
+
{{ match.shas.short }}
+
+
+
Path
+
{{ match.virtualPath }}
+
+
+
Tenant
+
{{ match.tenantId }}
+
+
+ } + } + +
+ + @if (verifyLoading$ | async) { + + } @else { + Verify + } + +
+
+ + + + +
+ Enter the 7-character Document ID printed on the PDF, or the full 40-character hash. +
+
+ + @if (documentIdLookupError$ | async; as lookupErr) { + + } + + @if (documentIdLookupSuccess$ | async) { + + } + +
+ + @if (documentIdLookupLoading$ | async) { + + } @else { + Download + } + +
+
diff --git a/libs/domains/decabill/frontend/feature-billing-console/src/lib/admin-file-manager-page/admin-file-manager-page.component.scss b/libs/domains/decabill/frontend/feature-billing-console/src/lib/admin-file-manager-page/admin-file-manager-page.component.scss new file mode 100644 index 000000000..d6d6b523c --- /dev/null +++ b/libs/domains/decabill/frontend/feature-billing-console/src/lib/admin-file-manager-page/admin-file-manager-page.component.scss @@ -0,0 +1,66 @@ +.admin-file-explorer { + min-height: 0; +} + +.file-tree-row { + color: inherit; + background-color: transparent; + height: 32px; + cursor: pointer; + user-select: none; + transition: background-color 0.15s ease; + + &:hover { + background-color: var(--bs-secondary-bg); + } + + // Match Agenstra file-tree selection: soft primary wash, no bold / border accent. + &.selected { + background-color: color-mix(in srgb, var(--bs-primary) 14%, var(--bs-body-bg)); + color: var(--bs-body-color); + } + + &:focus-visible { + outline: 2px solid var(--bs-primary); + outline-offset: -2px; + } + + fpc-button-group { + opacity: 0; + transition: opacity 0.15s ease-in-out; + } + + &:hover { + fpc-button-group { + opacity: 1; + } + } +} + +.file-tree-indent { + display: inline-block; + min-width: 0; +} + +.file-tree-name { + min-width: 0; +} + +.file-tree-meta { + max-width: 50%; +} + +.file-tree-hash-badge { + font-size: 0.75rem; +} + +.file-tree-lock { + font-size: 0.95rem; +} + +.file-tree-chevron, +.file-tree-chevron-spacer { + display: inline-flex; + width: 1rem; + justify-content: center; +} diff --git a/libs/domains/decabill/frontend/feature-billing-console/src/lib/admin-file-manager-page/admin-file-manager-page.component.ts b/libs/domains/decabill/frontend/feature-billing-console/src/lib/admin-file-manager-page/admin-file-manager-page.component.ts new file mode 100644 index 000000000..586ed533e --- /dev/null +++ b/libs/domains/decabill/frontend/feature-billing-console/src/lib/admin-file-manager-page/admin-file-manager-page.component.ts @@ -0,0 +1,375 @@ +import { CommonModule } from '@angular/common'; +import { Component, computed, effect, inject, OnInit, signal } from '@angular/core'; +import { toSignal } from '@angular/core/rxjs-interop'; +import { FormsModule } from '@angular/forms'; +import { + AdminFileManagerFacade, + BillingCapabilitiesFacade, + buildAdminFileManagerCacheKey, + type AdminFileManagerEntry, + type AdminFileManagerView, + type AdminFileVerifyResponse, +} from '@forepath/decabill/frontend/data-access-billing-console'; +import { ENVIRONMENT, type Environment } from '@forepath/decabill/frontend/util-configuration'; +import { + FpcAlertComponent, + FpcBadgeComponent, + FpcButtonComponent, + FpcButtonGroupComponent, + FpcEmptyStateComponent, + FpcFormControlComponent, + FpcFormFieldComponent, + FpcModalComponent, + FpcModalFooterDirective, + FpcPageHeaderComponent, + FpcSpinnerComponent, + FpcTabComponent, + FpcTabGroupComponent, + FpcTooltipComponent, +} from '@forepath/shared/frontend/ui-components'; +import { combineLatest, map } from 'rxjs'; + +import { hideBillingModal, showBillingModal } from '../billing-modal'; + +interface TreeNodeView { + entry: AdminFileManagerEntry; + depth: number; +} + +@Component({ + selector: 'framework-admin-file-manager-page', + standalone: true, + imports: [ + CommonModule, + FormsModule, + FpcAlertComponent, + FpcBadgeComponent, + FpcButtonComponent, + FpcButtonGroupComponent, + FpcEmptyStateComponent, + FpcFormControlComponent, + FpcFormFieldComponent, + FpcModalComponent, + FpcModalFooterDirective, + FpcPageHeaderComponent, + FpcSpinnerComponent, + FpcTabComponent, + FpcTabGroupComponent, + FpcTooltipComponent, + ], + templateUrl: './admin-file-manager-page.component.html', + styleUrl: './admin-file-manager-page.component.scss', +}) +export class AdminFileManagerPageComponent implements OnInit { + private readonly facade = inject(AdminFileManagerFacade); + private readonly capabilitiesFacade = inject(BillingCapabilitiesFacade); + private readonly environment = inject(ENVIRONMENT); + + readonly homeTenantId = this.environment.billing?.tenantId?.trim() || 'default'; + readonly pageTitle = $localize`:@@featureAdminFileExplorer-title:File Explorer`; + readonly unifiedTabLabel = $localize`:@@featureAdminFileExplorer-tabUnified:Unified`; + readonly refreshAriaLabel = $localize`:@@featureAdminFileExplorer-refresh:Refresh`; + readonly verifyAriaLabel = $localize`:@@featureAdminFileExplorer-verify:Verify file`; + readonly searchAriaLabel = $localize`:@@featureAdminFileExplorer-search:Find by document ID`; + readonly downloadAriaLabel = $localize`:@@featureAdminFileExplorer-download:Download`; + readonly verifyModalTitle = $localize`:@@featureAdminFileExplorer-verifyModalTitle:Verify authenticity`; + readonly searchModalTitle = $localize`:@@featureAdminFileExplorer-searchModalTitle:Find by document ID`; + + readonly selectedPath = signal(null); + readonly selectedType = signal<'file' | 'directory' | null>(null); + readonly verifyModalOpen = signal(false); + readonly searchModalOpen = signal(false); + readonly selectedVerifyFile = signal(null); + readonly verifyFileInputKey = signal(0); + readonly documentIdInput = signal(''); + + readonly view$ = this.facade.view$; + readonly viewTenantId$ = this.facade.viewTenantId$; + readonly error$ = this.facade.error$; + readonly downloadLoading$ = this.facade.downloadLoading$; + readonly loadingPath$ = this.facade.loadingPath$; + readonly verifyLoading$ = this.facade.verifyLoading$; + readonly verifyResult$ = this.facade.verifyResult$; + readonly verifyError$ = this.facade.verifyError$; + readonly documentIdLookupLoading$ = this.facade.documentIdLookupLoading$; + readonly documentIdLookupError$ = this.facade.documentIdLookupError$; + readonly documentIdLookupSuccess$ = this.facade.documentIdLookupSuccess$; + readonly globalViewsAllowed$ = this.capabilitiesFacade.globalViewsAllowed$; + readonly viewableTenants$ = this.capabilitiesFacade.viewableTenants$; + readonly fileSigningEnabled$ = this.capabilitiesFacade.fileSigningEnabled$; + + readonly showViewTabs$ = this.globalViewsAllowed$; + + readonly activeTabId$ = combineLatest([this.view$, this.viewTenantId$]).pipe( + map(([view, viewTenantId]) => (view === 'unified' ? 'unified' : (viewTenantId ?? this.homeTenantId))), + ); + + readonly state = toSignal(this.facade.state$, { initialValue: null }); + readonly viewableTenants = toSignal(this.viewableTenants$, { initialValue: [] as string[] }); + private readonly verifyResult = toSignal(this.verifyResult$, { initialValue: null }); + + constructor() { + effect(() => { + if (!this.verifyResult()) { + return; + } + + this.selectedVerifyFile.set(null); + this.verifyFileInputKey.update((key) => key + 1); + }); + } + + readonly treeNodes = computed(() => { + const state = this.state(); + + if (!state) { + return [] as TreeNodeView[]; + } + + const expanded = new Set(state.expandedPaths); + const nodes: TreeNodeView[] = []; + + const walk = (path: string, depth: number): void => { + const cacheKey = buildAdminFileManagerCacheKey(state.view, state.viewTenantId, path); + const entries = state.directoriesByPath[cacheKey] ?? []; + + for (const entry of entries) { + nodes.push({ entry, depth }); + + if (entry.type === 'directory' && expanded.has(entry.path)) { + walk(entry.path, depth + 1); + } + } + }; + + walk('', 0); + + return nodes; + }); + + ngOnInit(): void { + this.capabilitiesFacade.loadCapabilities(); + this.facade.setView('tenant', this.homeTenantId); + this.facade.listDirectory({ path: '', view: 'tenant', viewTenantId: this.homeTenantId }); + } + + tenantTabLabel(tenantId: string): string { + if (!tenantId) { + return tenantId; + } + + return tenantId.charAt(0).toUpperCase() + tenantId.slice(1); + } + + onTabChange(tabId: string | null): void { + if (!tabId) { + return; + } + + if (tabId === 'unified') { + this.facade.setView('unified'); + this.selectedPath.set(null); + this.selectedType.set(null); + this.facade.listDirectory({ path: '', view: 'unified' }); + + return; + } + + this.facade.setView('tenant', tabId); + this.selectedPath.set(null); + this.selectedType.set(null); + this.facade.listDirectory({ path: '', view: 'tenant', viewTenantId: tabId }); + } + + onNodeActivate(entry: AdminFileManagerEntry): void { + this.selectedPath.set(entry.path); + this.selectedType.set(entry.type); + + if (entry.type !== 'directory') { + return; + } + + const state = this.state(); + const expanded = new Set(state?.expandedPaths ?? ['']); + + if (expanded.has(entry.path)) { + this.facade.collapsePath(entry.path); + + return; + } + + this.facade.expandPath(entry.path); + + const view = (state?.view ?? 'tenant') as AdminFileManagerView; + const viewTenantId = state?.viewTenantId ?? this.homeTenantId; + const cacheKey = buildAdminFileManagerCacheKey(view, view === 'unified' ? null : viewTenantId, entry.path); + const cached = !!state?.directoriesByPath[cacheKey]; + + this.facade.ensureDirectoryLoaded( + { + path: entry.path, + view, + viewTenantId: view === 'unified' ? undefined : viewTenantId, + }, + cached, + ); + } + + isExpanded(path: string): boolean { + return (this.state()?.expandedPaths ?? ['']).includes(path); + } + + isSelected(path: string): boolean { + return this.selectedPath() === path; + } + + contentHashesTooltip(entry: AdminFileManagerEntry): string { + const hashes = entry.contentHashes; + + if (!hashes) { + return 'Content hashes pending'; + } + + return [ + `MD5: ${hashes.md5}`, + `SHA-1: ${hashes.sha1}`, + `SHA-256: ${hashes.sha256}`, + `SHA-512: ${hashes.sha512}`, + ].join('\n'); + } + + signatureTooltip(entry: AdminFileManagerEntry): string { + const signature = entry.signature; + + if (!signature || signature.status === 'pending') { + return 'Signature pending'; + } + + return [ + `Status: ${signature.status}`, + `Alg: ${signature.alg ?? '—'}`, + `Version: ${signature.version ?? '—'}`, + `Tenant: ${signature.tenantId ?? '—'}`, + `Signed at: ${signature.signedAt ?? '—'}`, + `Signature: ${signature.value ?? '—'}`, + ].join('\n'); + } + + onDownloadEntry(entry: AdminFileManagerEntry, event?: Event): void { + event?.stopPropagation(); + this.selectedPath.set(entry.path); + this.selectedType.set(entry.type); + + const state = this.state(); + + if (!state) { + return; + } + + const params = { + path: entry.path, + view: state.view, + viewTenantId: state.view === 'unified' ? undefined : (state.viewTenantId ?? this.homeTenantId), + }; + + if (entry.type === 'file') { + this.facade.downloadFile(params, entry.name); + + return; + } + + this.facade.downloadArchive(params); + } + + refreshRoot(): void { + this.facade.refresh(); + } + + openVerifyModal(): void { + this.selectedVerifyFile.set(null); + this.facade.clearVerifyResult(); + showBillingModal(this.verifyModalOpen); + } + + onVerifyModalClosed(): void { + hideBillingModal(this.verifyModalOpen); + this.selectedVerifyFile.set(null); + this.facade.clearVerifyResult(); + } + + onVerifyFileSelected(files: FileList | null): void { + this.selectedVerifyFile.set(files?.[0] ?? null); + this.facade.clearVerifyResult(); + } + + submitVerify(): void { + const file = this.selectedVerifyFile(); + + if (!file) { + return; + } + + this.facade.verifyFile(file); + } + + openSearchModal(): void { + this.documentIdInput.set(''); + this.facade.clearDocumentIdLookup(); + showBillingModal(this.searchModalOpen); + } + + onSearchModalClosed(): void { + hideBillingModal(this.searchModalOpen); + this.documentIdInput.set(''); + this.facade.clearDocumentIdLookup(); + } + + onDocumentIdInputChange(value: string): void { + this.documentIdInput.set(value); + this.facade.clearDocumentIdLookup(); + } + + submitDocumentIdLookup(): void { + const documentId = this.documentIdInput().trim(); + + if (!documentId) { + return; + } + + this.facade.downloadByDocumentId(documentId); + } + + canSubmitDocumentIdLookup(): boolean { + const normalized = this.documentIdInput().trim().toLowerCase(); + + return /^[a-f0-9]{7}$/.test(normalized) || /^[a-f0-9]{40}$/.test(normalized); + } + + verdictMessage(result: AdminFileVerifyResponse): string { + switch (result.verdict) { + case 'authentic': + return $localize`:@@featureAdminFileExplorer-verdictAuthentic:Authentic. This file matches a signed registry entry.`; + case 'unknown': + return $localize`:@@featureAdminFileExplorer-verdictUnknown:Unknown. No matching signed file was found.`; + case 'unsigned': + return $localize`:@@featureAdminFileExplorer-verdictUnsigned:Digest matched a registry row, but the signature is missing or invalid.`; + case 'signing_disabled': + return $localize`:@@featureAdminFileExplorer-verdictSigningDisabled:File signing is not enabled on this deployment.`; + default: + return result.verdict; + } + } + + verdictVariant(result: AdminFileVerifyResponse): 'success' | 'warning' | 'danger' | 'info' { + switch (result.verdict) { + case 'authentic': + return 'success'; + case 'unsigned': + return 'warning'; + case 'unknown': + return 'danger'; + default: + return 'info'; + } + } +} diff --git a/libs/domains/decabill/frontend/feature-billing-console/src/lib/billing-console.routes.ts b/libs/domains/decabill/frontend/feature-billing-console/src/lib/billing-console.routes.ts index 4d9fef8c9..975b596f4 100644 --- a/libs/domains/decabill/frontend/feature-billing-console/src/lib/billing-console.routes.ts +++ b/libs/domains/decabill/frontend/feature-billing-console/src/lib/billing-console.routes.ts @@ -5,6 +5,7 @@ import { AdminSupplierProfilesFacade, AdminSupplierInvoiceManagerFacade, AdminDatevExportsFacade, + AdminFileManagerFacade, AdminInvoiceManagerFacade, AdminSubscriptionsFacade, BillingCapabilitiesFacade, @@ -76,6 +77,7 @@ import { updateManualInvoice$, adminBillingReducer, adminDatevExportsReducer, + adminFileManagerReducer, billingCapabilitiesReducer, billNow$, loadAdminAuditLogs$, @@ -192,6 +194,12 @@ import { pollQueuedDatevExports$, expireQueuedDatevExports$, downloadDatevExport$, + listAdminFileManagerDirectory$, + refreshAdminFileManager$, + downloadAdminFileManagerFile$, + downloadAdminFileManagerArchive$, + downloadAdminFileManagerByDocumentId$, + verifyAdminFileManagerFile$, ProjectsFacade, projectsReducer, loadProjects$, @@ -303,6 +311,7 @@ import { AdminCustomerProfilesPageComponent } from './admin-customer-profiles-pa import { AdminSupplierProfilesPageComponent } from './admin-supplier-profiles-page/admin-supplier-profiles-page.component'; import { AdminSubscriptionsPageComponent } from './admin-subscriptions-page/admin-subscriptions-page.component'; import { AdminDatevExportsPageComponent } from './admin-datev-exports-page/admin-datev-exports-page.component'; +import { AdminFileManagerPageComponent } from './admin-file-manager-page/admin-file-manager-page.component'; import { BillingConsoleContainerComponent } from './container/container.component'; import { billingAdminGuard } from './guards/billing-admin.guard'; import { datevExportEnabledGuard } from './guards/datev-export-enabled.guard'; @@ -555,6 +564,12 @@ export const billingConsoleRoutes: Route[] = [ component: AdminDatevExportsPageComponent, title: () => buildPageTitle($localize`:@@featureContainer-adminDatevExportsPage:DATEV Exports`), }, + { + path: 'file-explorer', + canActivate: [authGuard, billingAdminGuard], + component: AdminFileManagerPageComponent, + title: () => buildPageTitle($localize`:@@featureContainer-adminFileExplorerPage:File Explorer`), + }, ...contributorRoutes.admin, ], }, @@ -582,6 +597,7 @@ export const billingConsoleRoutes: Route[] = [ InvoicesFacade, AdminBillingFacade, AdminDatevExportsFacade, + AdminFileManagerFacade, BillingCapabilitiesFacade, AdminInvoiceManagerFacade, AdminCustomerProfilesFacade, @@ -614,6 +630,7 @@ export const billingConsoleRoutes: Route[] = [ provideState('adminBilling', adminBillingReducer), provideState('billingCapabilities', billingCapabilitiesReducer), provideState('adminDatevExports', adminDatevExportsReducer), + provideState('adminFileManager', adminFileManagerReducer), provideState('adminInvoiceManager', adminInvoiceManagerReducer), provideState('adminCustomerProfiles', adminCustomerProfilesReducer), provideState('adminSupplierProfiles', adminSupplierProfilesReducer), @@ -761,6 +778,12 @@ export const billingConsoleRoutes: Route[] = [ pollQueuedDatevExports$, expireQueuedDatevExports$, downloadDatevExport$, + listAdminFileManagerDirectory$, + refreshAdminFileManager$, + downloadAdminFileManagerFile$, + downloadAdminFileManagerArchive$, + downloadAdminFileManagerByDocumentId$, + verifyAdminFileManagerFile$, createAdminCustomerProfile$, updateAdminCustomerProfile$, deleteAdminCustomerProfile$, diff --git a/libs/domains/decabill/frontend/feature-billing-console/src/lib/container/container.component.ts b/libs/domains/decabill/frontend/feature-billing-console/src/lib/container/container.component.ts index 8a9ade23f..0206fbee5 100644 --- a/libs/domains/decabill/frontend/feature-billing-console/src/lib/container/container.component.ts +++ b/libs/domains/decabill/frontend/feature-billing-console/src/lib/container/container.component.ts @@ -418,6 +418,13 @@ export class BillingConsoleContainerComponent implements OnInit { title: $localize`:@@featureContainer-adminBillingTitle:Billing`, label: $localize`:@@featureContainer-adminBilling:Billing`, }, + { + routerLink: ['/administration/file-explorer'], + activePaths: ['/administration/file-explorer'], + icon: 'bi-folder2-open', + title: $localize`:@@featureContainer-adminFileExplorerTitle:File Explorer`, + label: $localize`:@@featureContainer-adminFileExplorer:Files`, + }, ]; if (this.datevExportEnabled()) { diff --git a/libs/domains/decabill/frontend/feature-billing-console/src/lib/guards/datev-export-enabled.guard.spec.ts b/libs/domains/decabill/frontend/feature-billing-console/src/lib/guards/datev-export-enabled.guard.spec.ts index bac9f73bf..6da585e09 100644 --- a/libs/domains/decabill/frontend/feature-billing-console/src/lib/guards/datev-export-enabled.guard.spec.ts +++ b/libs/domains/decabill/frontend/feature-billing-console/src/lib/guards/datev-export-enabled.guard.spec.ts @@ -18,7 +18,13 @@ describe('datevExportEnabledGuard', () => { beforeEach(() => { facade = { loadCapabilities: jest.fn(), - capabilities$: of({ datevExportEnabled: true, unifiedExportAllowed: false }), + capabilities$: of({ + datevExportEnabled: true, + unifiedExportAllowed: false, + globalViewsAllowed: false, + viewableTenants: ['default'], + fileSigningEnabled: false, + }), loading$: of(false), }; @@ -40,7 +46,13 @@ describe('datevExportEnabledGuard', () => { }); it('redirects when datev export is disabled', async () => { - facade.capabilities$ = of({ datevExportEnabled: false, unifiedExportAllowed: false }); + facade.capabilities$ = of({ + datevExportEnabled: false, + unifiedExportAllowed: false, + globalViewsAllowed: false, + viewableTenants: ['default'], + fileSigningEnabled: false, + }); const result = await TestBed.runInInjectionContext(() => firstValueFrom(datevExportEnabledGuard({} as never, {} as never)), diff --git a/libs/domains/shared/backend/util-file-storage/README.md b/libs/domains/shared/backend/util-file-storage/README.md index 2d0a675d6..88b19e717 100644 --- a/libs/domains/shared/backend/util-file-storage/README.md +++ b/libs/domains/shared/backend/util-file-storage/README.md @@ -11,9 +11,11 @@ import { FileStorageModule, FileStorageService, FileStorageScope } from '@forepa export class AppModule {} // Inject FileStorageService: -await fileStorage.writeInvoiceFile(storageKey, buffer); +await fileStorage.writeCustomerInvoiceFile(storageKey, buffer); +await fileStorage.writeCustomerOfferFile(storageKey, buffer); +await fileStorage.writeCustomerTimesheetFile(storageKey, buffer); await fileStorage.readDatevExportFile(storageKey); -await fileStorage.writeFile(FileStorageScope.invoices, storageKey, buffer); +await fileStorage.writeFile(FileStorageScope.customerInvoices, storageKey, buffer); ``` ## Path / object key layout @@ -22,46 +24,39 @@ await fileStorage.writeFile(FileStorageScope.invoices, storageKey, buffer); {scope-segment}/{storageKey} ``` -| Scope | Segment | Example (local path / S3 key without prefix) | -| -------------- | --------------- | -------------------------------------------- | -| `invoices` | `invoices` | `invoices/sub-1/inv-1.pdf` | -| `datevExports` | `datev-exports` | `datev-exports/default/2026/01/export.zip` | +| Scope | Segment | Example (local path / S3 key without prefix) | +| -------------------- | --------------------- | -------------------------------------------- | +| `customerInvoices` | `customer/invoices` | `customer/invoices/sub-1/inv-1.pdf` | +| `customerOffers` | `customer/offers` | `customer/offers/{userId}/{offerId}.pdf` | +| `customerTimesheets` | `customer/timesheets` | `customer/timesheets/.../id-time-report.pdf` | +| `supplierInvoices` | `supplier/invoices` | `supplier/invoices/{id}.pdf` | +| `datevExports` | `export/datev` | `export/datev/default/2026/01/export.zip` | - **local:** `{FILE_STORAGE_ROOT}/{segment}/{storageKey}` -- **s3:** `{FILE_STORAGE_S3_KEY_PREFIX?/}{segment}/{storageKey}` in `FILE_STORAGE_S3_BUCKET` +- **s3:** `{FILE_STORAGE_S3_KEY_PREFIX?/}{segment}/{storageKey}` in `FILE_STORAGE_S3_BUCKET` (segment is the full nested path relative to `FILE_STORAGE_ROOT`) ## Environment -| Variable | Default | Purpose | -| --------------------------------------- | ------------------------------- | ---------------------------------------------------- | -| `FILE_STORAGE_PROVIDER` | `local` | Active provider: `local` or `s3` | -| `FILE_STORAGE_ROOT` | `{cwd}/data` (compose: `/data`) | Canonical base directory (local provider) | -| `FILE_STORAGE_LEGACY_MIGRATION_ENABLED` | `true` | Startup copy from legacy Decabill roots (local only) | -| `BILLING_INVOICE_PDF_STORAGE_PATH` | _(unset)_ | **Deprecated.** Migration source for invoices | -| `BILLING_DATEV_EXPORT_STORAGE_PATH` | _(unset)_ | **Deprecated.** Migration source for DATEV exports | +| Variable | Default | Purpose | +| --------------------------------------- | ------------------------------- | ------------------------------------------------------------------ | +| `FILE_STORAGE_PROVIDER` | `local` | Active provider: `local` or `s3` | +| `FILE_STORAGE_ROOT` | `{cwd}/data` (compose: `/data`) | Canonical base directory (local provider) | +| `FILE_STORAGE_LEGACY_MIGRATION_ENABLED` | `true` | Startup copy from legacy Decabill env roots into previous segments | +| `FILE_STORAGE_LAYOUT_MIGRATION_ENABLED` | `true` | Startup copy previous flat segments into customer/supplier/export | +| `FILE_STORAGE_LAYOUT_DUAL_READ_ENABLED` | `true` | Read fallback from previous segments when new path is missing | +| `BILLING_INVOICE_PDF_STORAGE_PATH` | _(unset)_ | **Deprecated.** Migration source for old invoices root | +| `BILLING_DATEV_EXPORT_STORAGE_PATH` | _(unset)_ | **Deprecated.** Migration source for old DATEV root | ### S3-compatible provider (`FILE_STORAGE_PROVIDER=s3`) Works with AWS S3, Cloudflare R2, Backblaze B2, Ceph RGW, MinIO, and other S3-compatible APIs. Credentials are read only when the provider performs I/O (lazy), so local deployments do not need these variables. -| Variable | Default | Purpose | -| ----------------------------------- | ------------------------------------ | ------------------------------------------- | -| `FILE_STORAGE_S3_BUCKET` | _(required)_ | Bucket / container name | -| `FILE_STORAGE_S3_ACCESS_KEY_ID` | _(required)_ | Access key | -| `FILE_STORAGE_S3_SECRET_ACCESS_KEY` | _(required)_ | Secret key | -| `FILE_STORAGE_S3_REGION` | `auto` | Region (use provider docs; R2 often `auto`) | -| `FILE_STORAGE_S3_ENDPOINT` | _(unset)_ | Custom API endpoint (R2/B2/MinIO/Ceph) | -| `FILE_STORAGE_S3_FORCE_PATH_STYLE` | `true` if endpoint set, else `false` | Path-style URLs | -| `FILE_STORAGE_S3_KEY_PREFIX` | _(unset)_ | Optional key prefix (e.g. `decabill/prod`) | - -## Local provider operations - -The `local` provider requires the same `FILE_STORAGE_ROOT` volume mounted on **api**, **worker**, and **scheduler**. Subdirectories are created on write or during legacy migration. The `s3` provider does not need a shared filesystem mount. - -## Legacy migration - -On startup (when enabled and provider is `local`), `FileStorageLegacyMigrationService` copies non-empty legacy directories into the canonical layout. It is idempotent, never deletes sources, and skips when roots already match or when the active provider is not `local`. - -## Extending providers - -Register additional `FileStorageProvider` implementations on `FileStorageProviderFactory` and select them with `FILE_STORAGE_PROVIDER`. Dynamic plugin loading is not wired yet. +| Variable | Default | Purpose | +| ----------------------------------- | ------------ | ------------------------------------- | +| `FILE_STORAGE_S3_BUCKET` | _(required)_ | Bucket / container name | +| `FILE_STORAGE_S3_ACCESS_KEY_ID` | _(required)_ | Access key | +| `FILE_STORAGE_S3_SECRET_ACCESS_KEY` | _(required)_ | Secret key | +| `FILE_STORAGE_S3_REGION` | `auto` | Region | +| `FILE_STORAGE_S3_ENDPOINT` | _(unset)_ | Custom endpoint (R2, MinIO, …) | +| `FILE_STORAGE_S3_FORCE_PATH_STYLE` | `false` | Path-style addressing when needed | +| `FILE_STORAGE_S3_KEY_PREFIX` | _(unset)_ | Optional key prefix inside the bucket | diff --git a/libs/domains/shared/backend/util-file-storage/src/index.ts b/libs/domains/shared/backend/util-file-storage/src/index.ts index 1db733860..ed6d6e983 100644 --- a/libs/domains/shared/backend/util-file-storage/src/index.ts +++ b/libs/domains/shared/backend/util-file-storage/src/index.ts @@ -4,14 +4,22 @@ export { FILE_STORAGE_LOCAL_PROVIDER, FILE_STORAGE_S3_PROVIDER, } from './lib/file-storage.constants'; -export { FILE_STORAGE_SCOPE_SEGMENTS, FILE_STORAGE_SCOPES, FileStorageScope } from './lib/file-storage-scope.constants'; +export { + FILE_STORAGE_PREVIOUS_SCOPE_SEGMENTS, + FILE_STORAGE_SCOPE_SEGMENTS, + FILE_STORAGE_SCOPES, + FileStorageScope, +} from './lib/file-storage-scope.constants'; export type { FileStorageScope as FileStorageScopeType } from './lib/file-storage-scope.constants'; export { + isLayoutDualReadEnabled, + isLayoutMigrationEnabled, isLegacyMigrationEnabled, readActiveFileStorageProviderType, readFileStorageRoot, resolveCanonicalScopeRoot, resolveLegacyScopeRoot, + resolvePreviousSegmentRoot, } from './lib/file-storage-path.config'; export { applyS3KeyPrefix, readFileStorageS3Config } from './lib/file-storage-s3.config'; export type { FileStorageS3Config } from './lib/file-storage-s3.config'; @@ -22,5 +30,10 @@ export { LocalFileStorageProvider } from './lib/providers/local-file-storage.pro export { createFileStorageS3Client, S3FileStorageProvider } from './lib/providers/s3-file-storage.provider'; export { FileStorageService } from './lib/file-storage.service'; export { FileStorageLegacyMigrationService } from './lib/file-storage-legacy-migration.service'; -export { FILE_STORAGE_LEGACY_MIGRATION_INIT, FILE_STORAGE_PROVIDER_INIT } from './lib/file-storage.tokens'; +export { FileStorageLayoutMigrationService } from './lib/file-storage-layout-migration.service'; +export { + FILE_STORAGE_LAYOUT_MIGRATION_INIT, + FILE_STORAGE_LEGACY_MIGRATION_INIT, + FILE_STORAGE_PROVIDER_INIT, +} from './lib/file-storage.tokens'; export { FileStorageModule } from './lib/file-storage.module'; diff --git a/libs/domains/shared/backend/util-file-storage/src/lib/file-storage-layout-migration.service.spec.ts b/libs/domains/shared/backend/util-file-storage/src/lib/file-storage-layout-migration.service.spec.ts new file mode 100644 index 000000000..011e66966 --- /dev/null +++ b/libs/domains/shared/backend/util-file-storage/src/lib/file-storage-layout-migration.service.spec.ts @@ -0,0 +1,80 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +import { FileStorageLayoutMigrationService } from './file-storage-layout-migration.service'; + +describe('FileStorageLayoutMigrationService', () => { + let tempRoot: string; + const originalEnv = process.env; + + beforeEach(async () => { + tempRoot = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'file-storage-layout-')); + }); + + afterEach(async () => { + process.env = originalEnv; + await fs.promises.rm(tempRoot, { recursive: true, force: true }); + }); + + it('copies previous segments into customer/supplier/export layout', async () => { + const storageRoot = path.join(tempRoot, 'data'); + await fs.promises.mkdir(path.join(storageRoot, 'invoices', 'sub-1'), { recursive: true }); + await fs.promises.mkdir(path.join(storageRoot, 'invoices', 'offers', 'user-1'), { recursive: true }); + await fs.promises.mkdir(path.join(storageRoot, 'supplier-invoices'), { recursive: true }); + await fs.promises.mkdir(path.join(storageRoot, 'datev-exports', 'default'), { recursive: true }); + + await fs.promises.writeFile(path.join(storageRoot, 'invoices', 'sub-1', 'inv.pdf'), Buffer.from('inv')); + await fs.promises.writeFile(path.join(storageRoot, 'invoices', 'sub-1', 'inv-time-report.pdf'), Buffer.from('ts')); + await fs.promises.writeFile( + path.join(storageRoot, 'invoices', 'offers', 'user-1', 'offer.pdf'), + Buffer.from('offer'), + ); + await fs.promises.writeFile(path.join(storageRoot, 'supplier-invoices', 'sup.pdf'), Buffer.from('sup')); + await fs.promises.writeFile(path.join(storageRoot, 'datev-exports', 'default', 'e.zip'), Buffer.from('zip')); + + process.env = { + ...originalEnv, + FILE_STORAGE_ROOT: storageRoot, + FILE_STORAGE_PROVIDER: 'local', + FILE_STORAGE_LAYOUT_MIGRATION_ENABLED: 'true', + }; + + await new FileStorageLayoutMigrationService().migrateLayout(process.env); + + expect(await fs.promises.readFile(path.join(storageRoot, 'customer', 'invoices', 'sub-1', 'inv.pdf'), 'utf8')).toBe( + 'inv', + ); + expect( + await fs.promises.readFile( + path.join(storageRoot, 'customer', 'timesheets', 'sub-1', 'inv-time-report.pdf'), + 'utf8', + ), + ).toBe('ts'); + expect( + await fs.promises.readFile(path.join(storageRoot, 'customer', 'offers', 'user-1', 'offer.pdf'), 'utf8'), + ).toBe('offer'); + expect(await fs.promises.readFile(path.join(storageRoot, 'supplier', 'invoices', 'sup.pdf'), 'utf8')).toBe('sup'); + expect(await fs.promises.readFile(path.join(storageRoot, 'export', 'datev', 'default', 'e.zip'), 'utf8')).toBe( + 'zip', + ); + }); + + it('is idempotent when destination already exists', async () => { + const storageRoot = path.join(tempRoot, 'data'); + await fs.promises.mkdir(path.join(storageRoot, 'invoices'), { recursive: true }); + await fs.promises.mkdir(path.join(storageRoot, 'customer', 'invoices'), { recursive: true }); + await fs.promises.writeFile(path.join(storageRoot, 'invoices', 'a.pdf'), Buffer.from('src')); + await fs.promises.writeFile(path.join(storageRoot, 'customer', 'invoices', 'a.pdf'), Buffer.from('src')); + + process.env = { + ...originalEnv, + FILE_STORAGE_ROOT: storageRoot, + FILE_STORAGE_PROVIDER: 'local', + }; + + await new FileStorageLayoutMigrationService().migrateLayout(process.env); + + expect(await fs.promises.readFile(path.join(storageRoot, 'customer', 'invoices', 'a.pdf'), 'utf8')).toBe('src'); + }); +}); diff --git a/libs/domains/shared/backend/util-file-storage/src/lib/file-storage-layout-migration.service.ts b/libs/domains/shared/backend/util-file-storage/src/lib/file-storage-layout-migration.service.ts new file mode 100644 index 000000000..8796099c4 --- /dev/null +++ b/libs/domains/shared/backend/util-file-storage/src/lib/file-storage-layout-migration.service.ts @@ -0,0 +1,300 @@ +import * as fs from 'fs'; +import * as path from 'path'; + +import { CopyObjectCommand, ListObjectsV2Command, S3Client } from '@aws-sdk/client-s3'; +import { Injectable, Logger } from '@nestjs/common'; + +import { + isLayoutMigrationEnabled, + readActiveFileStorageProviderType, + readFileStorageRoot, + resolveCanonicalScopeRoot, + resolvePreviousSegmentRoot, +} from './file-storage-path.config'; +import { applyS3KeyPrefix, readFileStorageS3Config } from './file-storage-s3.config'; +import { FILE_STORAGE_LOCAL_PROVIDER, FILE_STORAGE_S3_PROVIDER } from './file-storage.constants'; +import { FILE_STORAGE_PREVIOUS_SCOPE_SEGMENTS, FileStorageScope } from './file-storage-scope.constants'; +import { createFileStorageS3Client } from './providers/s3-file-storage.provider'; + +interface LayoutMigrationSummary { + copied: number; + skipped: number; + errors: number; +} + +interface LayoutMove { + previousSegment: string; + /** Relative key under previous segment; if null, walk all files with classifier. */ + classify: ( + relativeKey: string, + ) => { scope: (typeof FileStorageScope)[keyof typeof FileStorageScope]; key: string } | null; +} + +/** + * Copies objects from previous flat segments into customer/supplier/export layout. + * Default on (`FILE_STORAGE_LAYOUT_MIGRATION_ENABLED` !== false). Supports local + S3. + * Never deletes source objects. + */ +@Injectable() +export class FileStorageLayoutMigrationService { + private readonly logger = new Logger(FileStorageLayoutMigrationService.name); + + async migrateLayout(env: NodeJS.ProcessEnv = process.env): Promise { + if (!isLayoutMigrationEnabled(env)) { + this.logger.log('File storage layout migration disabled; skipping'); + + return; + } + + const providerType = readActiveFileStorageProviderType(env); + + if (providerType === FILE_STORAGE_LOCAL_PROVIDER) { + await this.migrateLocal(env); + + return; + } + + if (providerType === FILE_STORAGE_S3_PROVIDER) { + await this.migrateS3(env); + + return; + } + + this.logger.log(`Layout migration skip: unsupported provider ${providerType}`); + } + + private moves(): LayoutMove[] { + return [ + { + previousSegment: FILE_STORAGE_PREVIOUS_SCOPE_SEGMENTS.invoices, + classify: (relativeKey) => { + const normalized = relativeKey.replace(/\\/g, '/'); + + if (normalized.startsWith('offers/')) { + return { + scope: FileStorageScope.customerOffers, + key: normalized.slice('offers/'.length), + }; + } + + if (normalized.endsWith('-time-report.pdf')) { + return { scope: FileStorageScope.customerTimesheets, key: normalized }; + } + + return { scope: FileStorageScope.customerInvoices, key: normalized }; + }, + }, + { + previousSegment: FILE_STORAGE_PREVIOUS_SCOPE_SEGMENTS.supplierInvoices, + classify: (relativeKey) => ({ + scope: FileStorageScope.supplierInvoices, + key: relativeKey.replace(/\\/g, '/'), + }), + }, + { + previousSegment: FILE_STORAGE_PREVIOUS_SCOPE_SEGMENTS.datevExports, + classify: (relativeKey) => ({ + scope: FileStorageScope.datevExports, + key: relativeKey.replace(/\\/g, '/'), + }), + }, + ]; + } + + private async migrateLocal(env: NodeJS.ProcessEnv): Promise { + const summary: LayoutMigrationSummary = { copied: 0, skipped: 0, errors: 0 }; + + for (const move of this.moves()) { + const sourceRoot = path.resolve(resolvePreviousSegmentRoot(move.previousSegment, env)); + + if (!(await this.isNonEmptyDirectory(sourceRoot))) { + this.logger.log(`Layout migration skip: empty or missing ${sourceRoot}`); + continue; + } + + this.logger.log(`Layout migration (local) from ${sourceRoot}`); + await this.walkLocal(sourceRoot, sourceRoot, move, summary, env); + } + + this.logger.log( + `Layout migration (local) done: copied=${summary.copied} skipped=${summary.skipped} errors=${summary.errors}`, + ); + } + + private async walkLocal( + sourceDir: string, + sourceRoot: string, + move: LayoutMove, + summary: LayoutMigrationSummary, + env: NodeJS.ProcessEnv, + ): Promise { + const entries = await fs.promises.readdir(sourceDir, { withFileTypes: true }); + + for (const entry of entries) { + const sourcePath = path.join(sourceDir, entry.name); + + if (entry.isDirectory()) { + await this.walkLocal(sourcePath, sourceRoot, move, summary, env); + continue; + } + + if (!entry.isFile()) { + summary.skipped += 1; + continue; + } + + const relativeKey = path.relative(sourceRoot, sourcePath).replace(/\\/g, '/'); + const target = move.classify(relativeKey); + + if (!target) { + summary.skipped += 1; + continue; + } + + const destRoot = path.resolve(resolveCanonicalScopeRoot(target.scope, env)); + const destPath = path.join(destRoot, target.key); + + try { + await this.copyLocalIdempotent(sourcePath, destPath, summary); + } catch (error) { + summary.errors += 1; + this.logger.warn( + `Layout migration failed for ${sourcePath}: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + } + + private async copyLocalIdempotent( + sourcePath: string, + destPath: string, + summary: LayoutMigrationSummary, + ): Promise { + const sourceStat = await fs.promises.stat(sourcePath); + + try { + const destStat = await fs.promises.stat(destPath); + + if (destStat.size === sourceStat.size) { + summary.skipped += 1; + + return; + } + + summary.skipped += 1; + this.logger.warn(`Layout migration skip (size mismatch, keeping destination): ${destPath}`); + + return; + } catch { + // missing dest + } + + await fs.promises.mkdir(path.dirname(destPath), { recursive: true }); + await fs.promises.copyFile(sourcePath, destPath); + summary.copied += 1; + } + + private async migrateS3(env: NodeJS.ProcessEnv): Promise { + const config = readFileStorageS3Config(env); + const client = createFileStorageS3Client(config); + const storageRoot = readFileStorageRoot(env); + const summary: LayoutMigrationSummary = { copied: 0, skipped: 0, errors: 0 }; + + for (const move of this.moves()) { + const previousPrefix = applyS3KeyPrefix(move.previousSegment.replace(/\\/g, '/'), config.keyPrefix).replace( + /\/?$/, + '/', + ); + + this.logger.log(`Layout migration (S3) listing prefix ${previousPrefix}`); + let continuationToken: string | undefined; + + do { + const listed = await client.send( + new ListObjectsV2Command({ + Bucket: config.bucket, + Prefix: previousPrefix, + ContinuationToken: continuationToken, + }), + ); + + for (const object of listed.Contents ?? []) { + if (!object.Key || object.Key.endsWith('/')) { + continue; + } + + const withoutGlobalPrefix = config.keyPrefix + ? object.Key.replace(new RegExp(`^${escapeRegExp(config.keyPrefix.replace(/\/?$/, '/'))}`), '') + : object.Key; + const relativeKey = withoutGlobalPrefix.startsWith(`${move.previousSegment}/`) + ? withoutGlobalPrefix.slice(move.previousSegment.length + 1) + : withoutGlobalPrefix; + + const target = move.classify(relativeKey); + + if (!target) { + summary.skipped += 1; + continue; + } + + const destSegment = path + .relative(path.resolve(storageRoot), path.resolve(resolveCanonicalScopeRoot(target.scope, env))) + .replace(/\\/g, '/'); + const destKey = applyS3KeyPrefix(path.posix.join(destSegment, target.key), config.keyPrefix); + + try { + await this.copyS3Idempotent(client, config.bucket, object.Key, destKey, summary); + } catch (error) { + summary.errors += 1; + this.logger.warn( + `S3 layout migration failed for ${object.Key}: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + + continuationToken = listed.IsTruncated ? listed.NextContinuationToken : undefined; + } while (continuationToken); + } + + this.logger.log( + `Layout migration (S3) done: copied=${summary.copied} skipped=${summary.skipped} errors=${summary.errors}`, + ); + } + + private async copyS3Idempotent( + client: S3Client, + bucket: string, + sourceKey: string, + destKey: string, + summary: LayoutMigrationSummary, + ): Promise { + if (sourceKey === destKey) { + summary.skipped += 1; + + return; + } + + await client.send( + new CopyObjectCommand({ + Bucket: bucket, + CopySource: `${bucket}/${encodeURIComponent(sourceKey).replace(/%2F/g, '/')}`, + Key: destKey, + }), + ); + summary.copied += 1; + } + + private async isNonEmptyDirectory(dirPath: string): Promise { + try { + const entries = await fs.promises.readdir(dirPath); + + return entries.length > 0; + } catch { + return false; + } + } +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} diff --git a/libs/domains/shared/backend/util-file-storage/src/lib/file-storage-legacy-migration.service.spec.ts b/libs/domains/shared/backend/util-file-storage/src/lib/file-storage-legacy-migration.service.spec.ts index 84d1a3e4e..4dae8bb55 100644 --- a/libs/domains/shared/backend/util-file-storage/src/lib/file-storage-legacy-migration.service.spec.ts +++ b/libs/domains/shared/backend/util-file-storage/src/lib/file-storage-legacy-migration.service.spec.ts @@ -3,7 +3,6 @@ import * as os from 'os'; import * as path from 'path'; import { FileStorageLegacyMigrationService } from './file-storage-legacy-migration.service'; -import { FileStorageScope } from './file-storage-scope.constants'; describe('FileStorageLegacyMigrationService', () => { let tempRoot: string; @@ -59,7 +58,7 @@ describe('FileStorageLegacyMigrationService', () => { it('skips when legacy and canonical roots match', async () => { const storageRoot = path.join(tempRoot, 'data'); - const invoices = path.join(storageRoot, FileStorageScope.invoices === 'invoices' ? 'invoices' : ''); + const invoices = path.join(storageRoot, 'invoices'); const keyPath = path.join(invoices, 'inv.pdf'); await fs.promises.mkdir(invoices, { recursive: true }); diff --git a/libs/domains/shared/backend/util-file-storage/src/lib/file-storage-legacy-migration.service.ts b/libs/domains/shared/backend/util-file-storage/src/lib/file-storage-legacy-migration.service.ts index c0bc7570e..ff91caa1c 100644 --- a/libs/domains/shared/backend/util-file-storage/src/lib/file-storage-legacy-migration.service.ts +++ b/libs/domains/shared/backend/util-file-storage/src/lib/file-storage-legacy-migration.service.ts @@ -6,23 +6,29 @@ import { Injectable, Logger } from '@nestjs/common'; import { isLegacyMigrationEnabled, readActiveFileStorageProviderType, - resolveCanonicalScopeRoot, - resolveLegacyScopeRoot, + resolvePreviousSegmentRoot, } from './file-storage-path.config'; import { FILE_STORAGE_LOCAL_PROVIDER } from './file-storage.constants'; -import { FILE_STORAGE_SCOPES, type FileStorageScope } from './file-storage-scope.constants'; +import { FILE_STORAGE_PREVIOUS_SCOPE_SEGMENTS } from './file-storage-scope.constants'; interface ScopeMigrationSummary { - scope: FileStorageScope; + segment: string; copied: number; skipped: number; errors: number; } +interface LegacySource { + segment: string; + resolveSource: (env: NodeJS.ProcessEnv) => string; +} + /** - * Copies files from deprecated per-scope roots into the canonical - * `{FILE_STORAGE_ROOT}/{segment}/` layout. Enabled by default; disable with - * `FILE_STORAGE_LEGACY_MIGRATION_ENABLED=false`. Never deletes legacy sources. + * Copies files from deprecated per-scope env roots into the previous flat + * segments under `{FILE_STORAGE_ROOT}/` (`invoices`, `supplier-invoices`, `datev-exports`). + * Layout migrator then moves those into customer/supplier/export. + * Enabled by default; disable with `FILE_STORAGE_LEGACY_MIGRATION_ENABLED=false`. + * Never deletes legacy sources. Local provider only. */ @Injectable() export class FileStorageLegacyMigrationService { @@ -41,35 +47,60 @@ export class FileStorageLegacyMigrationService { return; } - for (const scope of FILE_STORAGE_SCOPES) { - await this.migrateScope(scope, env); + for (const source of this.sources()) { + await this.migrateSegment(source, env); } } - private async migrateScope(scope: FileStorageScope, env: NodeJS.ProcessEnv): Promise { - const legacyRoot = path.resolve(resolveLegacyScopeRoot(scope, env)); - const canonicalRoot = path.resolve(resolveCanonicalScopeRoot(scope, env)); - const summary: ScopeMigrationSummary = { scope, copied: 0, skipped: 0, errors: 0 }; + private sources(): LegacySource[] { + return [ + { + segment: FILE_STORAGE_PREVIOUS_SCOPE_SEGMENTS.invoices, + resolveSource: (e) => + e.BILLING_INVOICE_PDF_STORAGE_PATH?.trim() || path.join(process.cwd(), 'data', 'invoices'), + }, + { + segment: FILE_STORAGE_PREVIOUS_SCOPE_SEGMENTS.supplierInvoices, + resolveSource: (e) => + e.BILLING_SUPPLIER_INVOICE_STORAGE_PATH?.trim() || path.join(process.cwd(), 'data', 'supplier-invoices'), + }, + { + segment: FILE_STORAGE_PREVIOUS_SCOPE_SEGMENTS.datevExports, + resolveSource: (e) => + e.BILLING_DATEV_EXPORT_STORAGE_PATH?.trim() || path.join(process.cwd(), 'data', 'datev-exports'), + }, + ]; + } + + private async migrateSegment(source: LegacySource, env: NodeJS.ProcessEnv): Promise { + const legacyRoot = path.resolve(source.resolveSource(env)); + const previousRoot = path.resolve(resolvePreviousSegmentRoot(source.segment, env)); + const summary: ScopeMigrationSummary = { + segment: source.segment, + copied: 0, + skipped: 0, + errors: 0, + }; - if (legacyRoot === canonicalRoot) { - this.logger.log(`Legacy migration skip for ${scope}: legacy and canonical roots match (${canonicalRoot})`); + if (legacyRoot === previousRoot) { + this.logger.log(`Legacy migration skip for ${source.segment}: legacy and previous roots match (${previousRoot})`); return; } if (!(await this.isNonEmptyDirectory(legacyRoot))) { - this.logger.log(`Legacy migration skip for ${scope}: source missing or empty (${legacyRoot})`); + this.logger.log(`Legacy migration skip for ${source.segment}: source missing or empty (${legacyRoot})`); return; } - this.logger.log(`Migrating file storage scope ${scope}: ${legacyRoot} -> ${canonicalRoot}`); + this.logger.log(`Migrating file storage segment ${source.segment}: ${legacyRoot} -> ${previousRoot}`); - await fs.promises.mkdir(canonicalRoot, { recursive: true }); - await this.copyDirectoryRecursive(legacyRoot, canonicalRoot, legacyRoot, summary); + await fs.promises.mkdir(previousRoot, { recursive: true }); + await this.copyDirectoryRecursive(legacyRoot, previousRoot, legacyRoot, summary); this.logger.log( - `Legacy migration for ${scope}: copied=${summary.copied} skipped=${summary.skipped} errors=${summary.errors}`, + `Legacy migration for ${source.segment}: copied=${summary.copied} skipped=${summary.skipped} errors=${summary.errors}`, ); } diff --git a/libs/domains/shared/backend/util-file-storage/src/lib/file-storage-object-key.util.spec.ts b/libs/domains/shared/backend/util-file-storage/src/lib/file-storage-object-key.util.spec.ts index 3593d81f6..7e92fe4b2 100644 --- a/libs/domains/shared/backend/util-file-storage/src/lib/file-storage-object-key.util.spec.ts +++ b/libs/domains/shared/backend/util-file-storage/src/lib/file-storage-object-key.util.spec.ts @@ -2,16 +2,27 @@ import { buildScopedObjectKey } from './file-storage-object-key.util'; import { FILE_STORAGE_INVALID_PATH_ERROR } from './file-storage.constants'; describe('buildScopedObjectKey', () => { - it('joins scope segment from root with storage key', () => { - expect(buildScopedObjectKey('/data/invoices', 'sub-1/inv-1.pdf')).toBe('invoices/sub-1/inv-1.pdf'); - expect(buildScopedObjectKey('/data/datev-exports', 'default/2026/01/export.zip')).toBe( + it('joins relative segment from storage root with storage key', () => { + expect(buildScopedObjectKey('/data/invoices', 'sub-1/inv-1.pdf', '/data')).toBe('invoices/sub-1/inv-1.pdf'); + expect(buildScopedObjectKey('/data/datev-exports', 'default/2026/01/export.zip', '/data')).toBe( 'datev-exports/default/2026/01/export.zip', ); }); + it('preserves nested scope segments under storage root', () => { + expect(buildScopedObjectKey('/data/customer/invoices', 'sub-1/inv-1.pdf', '/data')).toBe( + 'customer/invoices/sub-1/inv-1.pdf', + ); + expect(buildScopedObjectKey('/data/export/datev', 'default/2026/01/export.zip', '/data')).toBe( + 'export/datev/default/2026/01/export.zip', + ); + }); + it('rejects path traversal and empty keys', () => { - expect(() => buildScopedObjectKey('/data/invoices', '../etc/passwd')).toThrow(FILE_STORAGE_INVALID_PATH_ERROR); - expect(() => buildScopedObjectKey('/data/invoices', '')).toThrow(FILE_STORAGE_INVALID_PATH_ERROR); - expect(() => buildScopedObjectKey('/data/invoices', 'a/../../b')).toThrow(FILE_STORAGE_INVALID_PATH_ERROR); + expect(() => buildScopedObjectKey('/data/invoices', '../etc/passwd', '/data')).toThrow( + FILE_STORAGE_INVALID_PATH_ERROR, + ); + expect(() => buildScopedObjectKey('/data/invoices', '', '/data')).toThrow(FILE_STORAGE_INVALID_PATH_ERROR); + expect(() => buildScopedObjectKey('/data/invoices', 'a/../../b', '/data')).toThrow(FILE_STORAGE_INVALID_PATH_ERROR); }); }); diff --git a/libs/domains/shared/backend/util-file-storage/src/lib/file-storage-object-key.util.ts b/libs/domains/shared/backend/util-file-storage/src/lib/file-storage-object-key.util.ts index cd67617de..4cc2becbf 100644 --- a/libs/domains/shared/backend/util-file-storage/src/lib/file-storage-object-key.util.ts +++ b/libs/domains/shared/backend/util-file-storage/src/lib/file-storage-object-key.util.ts @@ -1,23 +1,46 @@ import * as path from 'path'; import { FILE_STORAGE_INVALID_PATH_ERROR } from './file-storage.constants'; +import { readFileStorageRoot } from './file-storage-path.config'; /** * Builds a posix object key / relative path from a scope root and storage key. - * Rejects path traversal. Used by S3 (and suitable for any prefix-based backend). + * Relativizes the scope root against `FILE_STORAGE_ROOT` so nested segments + * (e.g. `customer/invoices`) are preserved for S3 and other prefix backends. + * Rejects path traversal. */ -export function buildScopedObjectKey(root: string, storageKey: string): string { +export function buildScopedObjectKey( + root: string, + storageKey: string, + storageRoot: string = readFileStorageRoot(), +): string { const normalizedKey = storageKey.replace(/\\/g, '/').replace(/^\/+/, ''); if (!normalizedKey || normalizedKey.includes('\0') || normalizedKey.split('/').includes('..')) { throw new Error(FILE_STORAGE_INVALID_PATH_ERROR); } - const segment = path.basename(path.resolve(root)).replace(/\\/g, '/'); + const resolvedRoot = path.resolve(root); + const resolvedStorageRoot = path.resolve(storageRoot); + let relativeSegment = path.relative(resolvedStorageRoot, resolvedRoot).replace(/\\/g, '/'); - if (!segment || segment === '.' || segment === '..') { + if ( + !relativeSegment || + relativeSegment === '.' || + relativeSegment.startsWith('..') || + path.isAbsolute(relativeSegment) + ) { + relativeSegment = path.basename(resolvedRoot).replace(/\\/g, '/'); + } + + if ( + !relativeSegment || + relativeSegment === '.' || + relativeSegment === '..' || + relativeSegment.split('/').includes('..') + ) { throw new Error(FILE_STORAGE_INVALID_PATH_ERROR); } - return path.posix.join(segment, normalizedKey); + return path.posix.join(relativeSegment, normalizedKey); } diff --git a/libs/domains/shared/backend/util-file-storage/src/lib/file-storage-path.config.spec.ts b/libs/domains/shared/backend/util-file-storage/src/lib/file-storage-path.config.spec.ts index f366c9d5a..8c1b53f3f 100644 --- a/libs/domains/shared/backend/util-file-storage/src/lib/file-storage-path.config.spec.ts +++ b/libs/domains/shared/backend/util-file-storage/src/lib/file-storage-path.config.spec.ts @@ -1,6 +1,7 @@ import * as path from 'path'; import { + isLayoutMigrationEnabled, isLegacyMigrationEnabled, readActiveFileStorageProviderType, readFileStorageRoot, @@ -26,18 +27,18 @@ describe('file-storage-path.config', () => { expect(readFileStorageRoot({})).toBe(path.join('/workspace', 'data')); }); - it('resolveCanonicalScopeRoot joins root and segment', () => { - expect(resolveCanonicalScopeRoot(FileStorageScope.invoices, { FILE_STORAGE_ROOT: '/data' })).toBe( - path.join('/data', 'invoices'), + it('resolveCanonicalScopeRoot joins root and nested segment', () => { + expect(resolveCanonicalScopeRoot(FileStorageScope.customerInvoices, { FILE_STORAGE_ROOT: '/data' })).toBe( + path.join('/data', 'customer', 'invoices'), ); expect(resolveCanonicalScopeRoot(FileStorageScope.datevExports, { FILE_STORAGE_ROOT: '/data' })).toBe( - path.join('/data', 'datev-exports'), + path.join('/data', 'export', 'datev'), ); }); it('resolveLegacyScopeRoot uses BILLING_* when set', () => { expect( - resolveLegacyScopeRoot(FileStorageScope.invoices, { + resolveLegacyScopeRoot(FileStorageScope.customerInvoices, { BILLING_INVOICE_PDF_STORAGE_PATH: '/legacy/invoices', }), ).toBe('/legacy/invoices'); @@ -51,7 +52,9 @@ describe('file-storage-path.config', () => { it('resolveLegacyScopeRoot falls back to cwd layout', () => { process.cwd = () => '/workspace'; - expect(resolveLegacyScopeRoot(FileStorageScope.invoices, {})).toBe(path.join('/workspace', 'data', 'invoices')); + expect(resolveLegacyScopeRoot(FileStorageScope.customerInvoices, {})).toBe( + path.join('/workspace', 'data', 'invoices'), + ); }); it('isLegacyMigrationEnabled defaults to true and disables on false', () => { @@ -60,6 +63,11 @@ describe('file-storage-path.config', () => { expect(isLegacyMigrationEnabled({ FILE_STORAGE_LEGACY_MIGRATION_ENABLED: 'TRUE' })).toBe(true); }); + it('isLayoutMigrationEnabled defaults to true and disables on false', () => { + expect(isLayoutMigrationEnabled({})).toBe(true); + expect(isLayoutMigrationEnabled({ FILE_STORAGE_LAYOUT_MIGRATION_ENABLED: 'false' })).toBe(false); + }); + it('readActiveFileStorageProviderType defaults to local', () => { expect(readActiveFileStorageProviderType({})).toBe('local'); expect(readActiveFileStorageProviderType({ FILE_STORAGE_PROVIDER: 's3' })).toBe('s3'); diff --git a/libs/domains/shared/backend/util-file-storage/src/lib/file-storage-path.config.ts b/libs/domains/shared/backend/util-file-storage/src/lib/file-storage-path.config.ts index 90ae3cbc7..f2862f417 100644 --- a/libs/domains/shared/backend/util-file-storage/src/lib/file-storage-path.config.ts +++ b/libs/domains/shared/backend/util-file-storage/src/lib/file-storage-path.config.ts @@ -1,6 +1,7 @@ import * as path from 'path'; import { + FILE_STORAGE_PREVIOUS_SCOPE_SEGMENTS, FILE_STORAGE_SCOPE_SEGMENTS, type FileStorageScope, FileStorageScope as Scopes, @@ -29,22 +30,50 @@ export function resolveCanonicalScopeRoot(scope: FileStorageScope, env: NodeJS.P return path.join(readFileStorageRoot(env), FILE_STORAGE_SCOPE_SEGMENTS[scope]); } +/** Absolute path for a previous (pre-split) segment under the storage root. */ +export function resolvePreviousSegmentRoot(segment: string, env: NodeJS.ProcessEnv = process.env): string { + return path.join(readFileStorageRoot(env), segment); +} + /** - * Legacy Decabill roots used only as migration sources. - * Deprecated for runtime I/O; prefer `resolveCanonicalScopeRoot`. + * Deprecated env-based roots used only as migration sources for the first + * local-root consolidation. Prefer layout migrator for segment renames. */ export function resolveLegacyScopeRoot(scope: FileStorageScope, env: NodeJS.ProcessEnv = process.env): string { - if (scope === Scopes.invoices) { - return env.BILLING_INVOICE_PDF_STORAGE_PATH?.trim() || path.join(process.cwd(), 'data', 'invoices'); + if (scope === Scopes.customerInvoices || scope === Scopes.customerOffers || scope === Scopes.customerTimesheets) { + return ( + env.BILLING_INVOICE_PDF_STORAGE_PATH?.trim() || + path.join(process.cwd(), 'data', FILE_STORAGE_PREVIOUS_SCOPE_SEGMENTS.invoices) + ); + } + + if (scope === Scopes.supplierInvoices) { + return ( + env.BILLING_SUPPLIER_INVOICE_STORAGE_PATH?.trim() || + path.join(process.cwd(), 'data', FILE_STORAGE_PREVIOUS_SCOPE_SEGMENTS.supplierInvoices) + ); } - return env.BILLING_DATEV_EXPORT_STORAGE_PATH?.trim() || path.join(process.cwd(), 'data', 'datev-exports'); + return ( + env.BILLING_DATEV_EXPORT_STORAGE_PATH?.trim() || + path.join(process.cwd(), 'data', FILE_STORAGE_PREVIOUS_SCOPE_SEGMENTS.datevExports) + ); } export function isLegacyMigrationEnabled(env: NodeJS.ProcessEnv = process.env): boolean { return env.FILE_STORAGE_LEGACY_MIGRATION_ENABLED?.trim().toLowerCase() !== 'false'; } +/** Layout split migrator (old segments → customer/supplier/export). Default on. */ +export function isLayoutMigrationEnabled(env: NodeJS.ProcessEnv = process.env): boolean { + return env.FILE_STORAGE_LAYOUT_MIGRATION_ENABLED?.trim().toLowerCase() !== 'false'; +} + +/** Dual-read from previous segments when the new path is missing. Default on. */ +export function isLayoutDualReadEnabled(env: NodeJS.ProcessEnv = process.env): boolean { + return env.FILE_STORAGE_LAYOUT_DUAL_READ_ENABLED?.trim().toLowerCase() !== 'false'; +} + export function readActiveFileStorageProviderType(env: NodeJS.ProcessEnv = process.env): string { const configured = env.FILE_STORAGE_PROVIDER?.trim(); diff --git a/libs/domains/shared/backend/util-file-storage/src/lib/file-storage-scope.constants.ts b/libs/domains/shared/backend/util-file-storage/src/lib/file-storage-scope.constants.ts index c08b2ecb3..b9862f71b 100644 --- a/libs/domains/shared/backend/util-file-storage/src/lib/file-storage-scope.constants.ts +++ b/libs/domains/shared/backend/util-file-storage/src/lib/file-storage-scope.constants.ts @@ -3,22 +3,38 @@ * Segments are fixed; call sites must not invent free-form scope strings. */ export const FileStorageScope = { - invoices: 'invoices', + customerInvoices: 'customerInvoices', + customerOffers: 'customerOffers', + customerTimesheets: 'customerTimesheets', supplierInvoices: 'supplierInvoices', datevExports: 'datevExports', } as const; export type FileStorageScope = (typeof FileStorageScope)[keyof typeof FileStorageScope]; -/** Directory segment under `FILE_STORAGE_ROOT` for each scope. */ +/** Directory segment under `FILE_STORAGE_ROOT` for each scope (may be nested). */ export const FILE_STORAGE_SCOPE_SEGMENTS: Readonly> = { - [FileStorageScope.invoices]: 'invoices', - [FileStorageScope.supplierInvoices]: 'supplier-invoices', - [FileStorageScope.datevExports]: 'datev-exports', + [FileStorageScope.customerInvoices]: 'customer/invoices', + [FileStorageScope.customerOffers]: 'customer/offers', + [FileStorageScope.customerTimesheets]: 'customer/timesheets', + [FileStorageScope.supplierInvoices]: 'supplier/invoices', + [FileStorageScope.datevExports]: 'export/datev', }; export const FILE_STORAGE_SCOPES: readonly FileStorageScope[] = [ - FileStorageScope.invoices, + FileStorageScope.customerInvoices, + FileStorageScope.customerOffers, + FileStorageScope.customerTimesheets, FileStorageScope.supplierInvoices, FileStorageScope.datevExports, ]; + +/** + * Pre-layout-split segments (dual-read + layout migrator sources). + * Relative keys under these roots match historical Decabill storage. + */ +export const FILE_STORAGE_PREVIOUS_SCOPE_SEGMENTS = { + invoices: 'invoices', + supplierInvoices: 'supplier-invoices', + datevExports: 'datev-exports', +} as const; diff --git a/libs/domains/shared/backend/util-file-storage/src/lib/file-storage.integration.spec.ts b/libs/domains/shared/backend/util-file-storage/src/lib/file-storage.integration.spec.ts index d6a369ed6..ddd0b1875 100644 --- a/libs/domains/shared/backend/util-file-storage/src/lib/file-storage.integration.spec.ts +++ b/libs/domains/shared/backend/util-file-storage/src/lib/file-storage.integration.spec.ts @@ -2,6 +2,7 @@ import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; +import { FileStorageLayoutMigrationService } from './file-storage-layout-migration.service'; import { FileStorageLegacyMigrationService } from './file-storage-legacy-migration.service'; import { FileStorageProviderFactory } from './file-storage-provider.factory'; import { FileStorageService } from './file-storage.service'; @@ -40,19 +41,22 @@ describe('file-storage integration smoke', () => { const local = new LocalFileStorageProvider(); factory.registerProvider(local); - const migration = new FileStorageLegacyMigrationService(); - await migration.migrateAllScopes(); + await new FileStorageLegacyMigrationService().migrateAllScopes(); + await new FileStorageLayoutMigrationService().migrateLayout(); const service = new FileStorageService(factory); const buffer = await service.readInvoiceFile('sub-1/inv.pdf'); expect(buffer.toString()).toBe('zugferd'); + expect(await fs.promises.readFile(path.join(storageRoot, 'customer', 'invoices', 'sub-1', 'inv.pdf'), 'utf8')).toBe( + 'zugferd', + ); await service.writeDatevExportFile('tenant/2026/01/export.zip', Buffer.from('zip')); expect((await service.readDatevExportFile('tenant/2026/01/export.zip')).toString()).toBe('zip'); }); - it('default matching layout is a migration no-op and still supports I/O', async () => { + it('dual-read serves previous segment when new layout path is missing', async () => { const storageRoot = path.join(tempRoot, 'data'); const invoicesRoot = path.join(storageRoot, 'invoices'); @@ -63,6 +67,8 @@ describe('file-storage integration smoke', () => { ...originalEnv, FILE_STORAGE_ROOT: storageRoot, FILE_STORAGE_PROVIDER: 'local', + FILE_STORAGE_LAYOUT_MIGRATION_ENABLED: 'false', + FILE_STORAGE_LAYOUT_DUAL_READ_ENABLED: 'true', BILLING_INVOICE_PDF_STORAGE_PATH: invoicesRoot, BILLING_DATEV_EXPORT_STORAGE_PATH: path.join(storageRoot, 'datev-exports'), }; diff --git a/libs/domains/shared/backend/util-file-storage/src/lib/file-storage.module.ts b/libs/domains/shared/backend/util-file-storage/src/lib/file-storage.module.ts index e6c3d86e9..d7f65ef82 100644 --- a/libs/domains/shared/backend/util-file-storage/src/lib/file-storage.module.ts +++ b/libs/domains/shared/backend/util-file-storage/src/lib/file-storage.module.ts @@ -1,9 +1,14 @@ import { Global, Module } from '@nestjs/common'; +import { FileStorageLayoutMigrationService } from './file-storage-layout-migration.service'; import { FileStorageLegacyMigrationService } from './file-storage-legacy-migration.service'; import { FileStorageProviderFactory } from './file-storage-provider.factory'; import { FileStorageService } from './file-storage.service'; -import { FILE_STORAGE_LEGACY_MIGRATION_INIT, FILE_STORAGE_PROVIDER_INIT } from './file-storage.tokens'; +import { + FILE_STORAGE_LAYOUT_MIGRATION_INIT, + FILE_STORAGE_LEGACY_MIGRATION_INIT, + FILE_STORAGE_PROVIDER_INIT, +} from './file-storage.tokens'; import { LocalFileStorageProvider } from './providers/local-file-storage.provider'; import { S3FileStorageProvider } from './providers/s3-file-storage.provider'; @@ -19,6 +24,7 @@ import { S3FileStorageProvider } from './providers/s3-file-storage.provider'; S3FileStorageProvider, FileStorageService, FileStorageLegacyMigrationService, + FileStorageLayoutMigrationService, { provide: FILE_STORAGE_PROVIDER_INIT, useFactory: (factory: FileStorageProviderFactory, local: LocalFileStorageProvider, s3: S3FileStorageProvider) => { @@ -38,7 +44,21 @@ import { S3FileStorageProvider } from './providers/s3-file-storage.provider'; }, inject: [FILE_STORAGE_PROVIDER_INIT, FileStorageLegacyMigrationService], }, + { + provide: FILE_STORAGE_LAYOUT_MIGRATION_INIT, + useFactory: async (_legacyInit: boolean, migration: FileStorageLayoutMigrationService) => { + await migration.migrateLayout(); + + return true; + }, + inject: [FILE_STORAGE_LEGACY_MIGRATION_INIT, FileStorageLayoutMigrationService], + }, + ], + exports: [ + FileStorageService, + FileStorageProviderFactory, + FileStorageLegacyMigrationService, + FileStorageLayoutMigrationService, ], - exports: [FileStorageService, FileStorageProviderFactory, FileStorageLegacyMigrationService], }) export class FileStorageModule {} diff --git a/libs/domains/shared/backend/util-file-storage/src/lib/file-storage.service.spec.ts b/libs/domains/shared/backend/util-file-storage/src/lib/file-storage.service.spec.ts index b3fe98961..70e42e5e8 100644 --- a/libs/domains/shared/backend/util-file-storage/src/lib/file-storage.service.spec.ts +++ b/libs/domains/shared/backend/util-file-storage/src/lib/file-storage.service.spec.ts @@ -10,7 +10,12 @@ describe('FileStorageService', () => { let service: FileStorageService; beforeEach(() => { - process.env = { ...originalEnv, FILE_STORAGE_ROOT: '/data', FILE_STORAGE_PROVIDER: 'local' }; + process.env = { + ...originalEnv, + FILE_STORAGE_ROOT: '/data', + FILE_STORAGE_PROVIDER: 'local', + FILE_STORAGE_LAYOUT_DUAL_READ_ENABLED: 'false', + }; factory = new FileStorageProviderFactory(); provider = { getType: () => 'local', @@ -26,21 +31,31 @@ describe('FileStorageService', () => { process.env = originalEnv; }); - it('delegates invoice helpers to invoices scope under canonical root', async () => { + it('delegates invoice helpers to customer invoices scope under canonical root', async () => { await service.writeInvoiceFile('sub/a.pdf', Buffer.from('x')); await service.readInvoiceFile('sub/a.pdf'); await service.invoiceFileExists('sub/a.pdf'); - expect(provider.writeFile).toHaveBeenCalledWith(expect.stringContaining('invoices'), 'sub/a.pdf', Buffer.from('x')); - expect(provider.readFile).toHaveBeenCalledWith(expect.stringContaining('invoices'), 'sub/a.pdf'); - expect(provider.fileExists).toHaveBeenCalledWith(expect.stringContaining('invoices'), 'sub/a.pdf'); + expect(provider.writeFile).toHaveBeenCalledWith( + expect.stringContaining(pathJoin('customer', 'invoices')), + 'sub/a.pdf', + Buffer.from('x'), + ); + expect(provider.readFile).toHaveBeenCalledWith( + expect.stringContaining(pathJoin('customer', 'invoices')), + 'sub/a.pdf', + ); + expect(provider.fileExists).toHaveBeenCalledWith( + expect.stringContaining(pathJoin('customer', 'invoices')), + 'sub/a.pdf', + ); }); - it('delegates datev helpers to datev-exports scope', async () => { + it('delegates datev helpers to export/datev scope', async () => { await service.writeDatevExportFile('t/2026/01/e.zip', Buffer.from('z')); expect(provider.writeFile).toHaveBeenCalledWith( - expect.stringContaining('datev-exports'), + expect.stringContaining(pathJoin('export', 'datev')), 't/2026/01/e.zip', Buffer.from('z'), ); @@ -50,7 +65,7 @@ describe('FileStorageService', () => { await service.writeFile(FileStorageScope.datevExports, 'k.zip', Buffer.from('z')); expect(provider.writeFile).toHaveBeenCalledWith( - expect.stringContaining('datev-exports'), + expect.stringContaining(pathJoin('export', 'datev')), 'k.zip', Buffer.from('z'), ); @@ -64,3 +79,7 @@ describe('FileStorageService', () => { ); }); }); + +function pathJoin(...parts: string[]): string { + return parts.join('/'); +} diff --git a/libs/domains/shared/backend/util-file-storage/src/lib/file-storage.service.ts b/libs/domains/shared/backend/util-file-storage/src/lib/file-storage.service.ts index f1c442924..261b386e8 100644 --- a/libs/domains/shared/backend/util-file-storage/src/lib/file-storage.service.ts +++ b/libs/domains/shared/backend/util-file-storage/src/lib/file-storage.service.ts @@ -1,13 +1,23 @@ import { Injectable } from '@nestjs/common'; -import { readActiveFileStorageProviderType, resolveCanonicalScopeRoot } from './file-storage-path.config'; +import { + isLayoutDualReadEnabled, + readActiveFileStorageProviderType, + resolveCanonicalScopeRoot, + resolvePreviousSegmentRoot, +} from './file-storage-path.config'; import { FileStorageProviderFactory } from './file-storage-provider.factory'; -import { FileStorageScope, type FileStorageScope as FileStorageScopeType } from './file-storage-scope.constants'; +import { + FILE_STORAGE_PREVIOUS_SCOPE_SEGMENTS, + FileStorageScope, + type FileStorageScope as FileStorageScopeType, +} from './file-storage-scope.constants'; import type { FileStorageProvider } from './file-storage-provider.interface'; /** * Facade for scope-aware file I/O. Resolves the active provider from * `FILE_STORAGE_PROVIDER` (default `local`) and canonical roots under `FILE_STORAGE_ROOT`. + * When layout dual-read is enabled, reads/exists fall back to previous segment roots. */ @Injectable() export class FileStorageService { @@ -25,26 +35,95 @@ export class FileStorageService { async readFile(scope: FileStorageScopeType, storageKey: string): Promise { const root = resolveCanonicalScopeRoot(scope); + const provider = this.getActiveProvider(); - return await this.getActiveProvider().readFile(root, storageKey); + try { + return await provider.readFile(root, storageKey); + } catch (error) { + if (!isLayoutDualReadEnabled()) { + throw error; + } + + const fallback = this.resolveDualReadFallback(scope, storageKey); + + if (!fallback) { + throw error; + } + + return await provider.readFile(fallback.root, fallback.storageKey); + } } async fileExists(scope: FileStorageScopeType, storageKey: string): Promise { const root = resolveCanonicalScopeRoot(scope); + const provider = this.getActiveProvider(); + + if (await provider.fileExists(root, storageKey)) { + return true; + } + + if (!isLayoutDualReadEnabled()) { + return false; + } + + const fallback = this.resolveDualReadFallback(scope, storageKey); + + if (!fallback) { + return false; + } - return await this.getActiveProvider().fileExists(root, storageKey); + return await provider.fileExists(fallback.root, fallback.storageKey); } + async writeCustomerInvoiceFile(storageKey: string, content: Buffer): Promise { + await this.writeFile(FileStorageScope.customerInvoices, storageKey, content); + } + + async readCustomerInvoiceFile(storageKey: string): Promise { + return await this.readFile(FileStorageScope.customerInvoices, storageKey); + } + + async customerInvoiceFileExists(storageKey: string): Promise { + return await this.fileExists(FileStorageScope.customerInvoices, storageKey); + } + + async writeCustomerOfferFile(storageKey: string, content: Buffer): Promise { + await this.writeFile(FileStorageScope.customerOffers, storageKey, content); + } + + async readCustomerOfferFile(storageKey: string): Promise { + return await this.readFile(FileStorageScope.customerOffers, storageKey); + } + + async customerOfferFileExists(storageKey: string): Promise { + return await this.fileExists(FileStorageScope.customerOffers, storageKey); + } + + async writeCustomerTimesheetFile(storageKey: string, content: Buffer): Promise { + await this.writeFile(FileStorageScope.customerTimesheets, storageKey, content); + } + + async readCustomerTimesheetFile(storageKey: string): Promise { + return await this.readFile(FileStorageScope.customerTimesheets, storageKey); + } + + async customerTimesheetFileExists(storageKey: string): Promise { + return await this.fileExists(FileStorageScope.customerTimesheets, storageKey); + } + + /** @deprecated Use writeCustomerInvoiceFile */ async writeInvoiceFile(storageKey: string, content: Buffer): Promise { - await this.writeFile(FileStorageScope.invoices, storageKey, content); + await this.writeCustomerInvoiceFile(storageKey, content); } + /** @deprecated Use readCustomerInvoiceFile */ async readInvoiceFile(storageKey: string): Promise { - return await this.readFile(FileStorageScope.invoices, storageKey); + return await this.readCustomerInvoiceFile(storageKey); } + /** @deprecated Use customerInvoiceFileExists */ async invoiceFileExists(storageKey: string): Promise { - return await this.fileExists(FileStorageScope.invoices, storageKey); + return await this.customerInvoiceFileExists(storageKey); } async writeDatevExportFile(storageKey: string, content: Buffer): Promise { @@ -70,4 +149,40 @@ export class FileStorageService { async supplierInvoiceFileExists(storageKey: string): Promise { return await this.fileExists(FileStorageScope.supplierInvoices, storageKey); } + + private resolveDualReadFallback( + scope: FileStorageScopeType, + storageKey: string, + ): { root: string; storageKey: string } | null { + switch (scope) { + case FileStorageScope.customerInvoices: + case FileStorageScope.customerTimesheets: + return { + root: resolvePreviousSegmentRoot(FILE_STORAGE_PREVIOUS_SCOPE_SEGMENTS.invoices), + storageKey, + }; + case FileStorageScope.customerOffers: { + const withOffersPrefix = storageKey.replace(/\\/g, '/').startsWith('offers/') + ? storageKey + : `offers/${storageKey}`; + + return { + root: resolvePreviousSegmentRoot(FILE_STORAGE_PREVIOUS_SCOPE_SEGMENTS.invoices), + storageKey: withOffersPrefix, + }; + } + case FileStorageScope.supplierInvoices: + return { + root: resolvePreviousSegmentRoot(FILE_STORAGE_PREVIOUS_SCOPE_SEGMENTS.supplierInvoices), + storageKey, + }; + case FileStorageScope.datevExports: + return { + root: resolvePreviousSegmentRoot(FILE_STORAGE_PREVIOUS_SCOPE_SEGMENTS.datevExports), + storageKey, + }; + default: + return null; + } + } } diff --git a/libs/domains/shared/backend/util-file-storage/src/lib/file-storage.tokens.ts b/libs/domains/shared/backend/util-file-storage/src/lib/file-storage.tokens.ts index 345400de2..bc307654c 100644 --- a/libs/domains/shared/backend/util-file-storage/src/lib/file-storage.tokens.ts +++ b/libs/domains/shared/backend/util-file-storage/src/lib/file-storage.tokens.ts @@ -1,2 +1,3 @@ export const FILE_STORAGE_PROVIDER_INIT = Symbol('FILE_STORAGE_PROVIDER_INIT'); export const FILE_STORAGE_LEGACY_MIGRATION_INIT = Symbol('FILE_STORAGE_LEGACY_MIGRATION_INIT'); +export const FILE_STORAGE_LAYOUT_MIGRATION_INIT = Symbol('FILE_STORAGE_LAYOUT_MIGRATION_INIT'); diff --git a/libs/domains/shared/backend/util-file-storage/src/lib/providers/s3-file-storage.provider.spec.ts b/libs/domains/shared/backend/util-file-storage/src/lib/providers/s3-file-storage.provider.spec.ts index 655aea630..c9aecc31f 100644 --- a/libs/domains/shared/backend/util-file-storage/src/lib/providers/s3-file-storage.provider.spec.ts +++ b/libs/domains/shared/backend/util-file-storage/src/lib/providers/s3-file-storage.provider.spec.ts @@ -16,14 +16,20 @@ describe('S3FileStorageProvider', () => { let send: jest.Mock; let provider: S3FileStorageProvider; + const originalEnv = process.env; beforeEach(() => { + process.env = { ...originalEnv, FILE_STORAGE_ROOT: '/data' }; send = jest.fn(); const client = { send } as unknown as S3Client; provider = S3FileStorageProvider.createForTests(client, config); }); + afterEach(() => { + process.env = originalEnv; + }); + it('returns s3 provider type', () => { expect(provider.getType()).toBe('s3'); }); @@ -31,7 +37,7 @@ describe('S3FileStorageProvider', () => { it('writes with prefixed scoped object key', async () => { send.mockResolvedValue({}); - await provider.writeFile('/data/invoices', 'sub-1/inv-1.pdf', Buffer.from('pdf')); + await provider.writeFile('/data/customer/invoices', 'sub-1/inv-1.pdf', Buffer.from('pdf')); expect(send).toHaveBeenCalledTimes(1); const command = send.mock.calls[0][0] as PutObjectCommand; @@ -39,7 +45,7 @@ describe('S3FileStorageProvider', () => { expect(command).toBeInstanceOf(PutObjectCommand); expect(command.input).toMatchObject({ Bucket: 'billing-files', - Key: 'prod/invoices/sub-1/inv-1.pdf', + Key: 'prod/customer/invoices/sub-1/inv-1.pdf', ContentType: 'application/octet-stream', }); expect(Buffer.isBuffer(command.input.Body)).toBe(true); @@ -52,11 +58,11 @@ describe('S3FileStorageProvider', () => { }, }); - const buffer = await provider.readFile('/data/invoices', 'a.pdf'); + const buffer = await provider.readFile('/data/customer/invoices', 'a.pdf'); expect(buffer.equals(Buffer.from([1, 2, 3]))).toBe(true); expect(send.mock.calls[0][0]).toBeInstanceOf(GetObjectCommand); - expect((send.mock.calls[0][0] as GetObjectCommand).input.Key).toBe('prod/invoices/a.pdf'); + expect((send.mock.calls[0][0] as GetObjectCommand).input.Key).toBe('prod/customer/invoices/a.pdf'); }); it('fileExists returns false on NotFound', async () => { @@ -64,19 +70,19 @@ describe('S3FileStorageProvider', () => { Object.assign(new Error('missing'), { name: 'NotFound', $metadata: { httpStatusCode: 404 } }), ); - await expect(provider.fileExists('/data/invoices', 'missing.pdf')).resolves.toBe(false); + await expect(provider.fileExists('/data/customer/invoices', 'missing.pdf')).resolves.toBe(false); expect(send.mock.calls[0][0]).toBeInstanceOf(HeadObjectCommand); }); it('fileExists returns true when head succeeds', async () => { send.mockResolvedValue({}); - await expect(provider.fileExists('/data/invoices', 'a.pdf')).resolves.toBe(true); + await expect(provider.fileExists('/data/customer/invoices', 'a.pdf')).resolves.toBe(true); }); it('fileExists rethrows unexpected errors', async () => { send.mockRejectedValue(new Error('network down')); - await expect(provider.fileExists('/data/invoices', 'a.pdf')).rejects.toThrow('network down'); + await expect(provider.fileExists('/data/customer/invoices', 'a.pdf')).rejects.toThrow('network down'); }); });