This commit is contained in:
nessar committed 2026-09-26 18:14:23 +02:00
1 parent 521d57f09d
commit 79cfb2d26e
452 files changed
+50947 -12504

No files matched your search

+1
View File
@@ -1,3 +1,4 @@
.env
.e2e-real-artifacts
tmp/
.tmp/
+1 -1
View File
@@ -259,7 +259,7 @@ Encaissement déjà reçu, donc jamais daté dans le futur selon le fuseau horai
_Avoid_: Échéance, mensualité, marquage manuel comme payé
**Contrepassation de règlement** (Code: `RentPaymentReversal`):
Écriture immuable, motivée et consultable qui annule les effets d'un règlement confirmé sans effacer le fait historique ni sa trace d'audit. Sa date civile est celle du jour dans le fuseau IANA du navigateur. Ses affectations sont annulées, ses remboursements déjà confirmés restent des sorties historiques immuables, et un éventuel règlement corrigé est enregistré séparément.
Écriture immuable, motivée et consultable qui annule les effets d'un règlement confirmé sans effacer le fait historique ni sa trace d'audit. Son motif est chiffré dans le navigateur par le Coffre partagé et reste illisible lorsque celui-ci est verrouillé ; les montants, dates, affectations et remboursements restent des faits financiers consultables. Sa date civile est celle du jour dans le fuseau IANA du navigateur. Ses affectations sont annulées, ses remboursements déjà confirmés restent des sorties historiques immuables, et un éventuel règlement corrigé est enregistré séparément.
_Avoid_: Suppression, modification, correction silencieuse
**Quittance de loyer** (Code: `RentReceipt`):
@@ -2,13 +2,16 @@ package fr.nessar.lmnporganizer.administration.adapter.out.mongodb;
import fr.nessar.lmnporganizer.accounting.application.expensedocument.ExpenseDocumentAttachmentStorage;
import fr.nessar.lmnporganizer.administration.application.port.out.AdministrationUsageRepository;
import fr.nessar.lmnporganizer.rental.application.port.out.ApplicationDocumentStorage;
import fr.nessar.lmnporganizer.rental.application.port.out.LeaseDocumentStorage;
import fr.nessar.lmnporganizer.rental.application.port.out.PropertyConditionAttachmentStorage;
import fr.nessar.lmnporganizer.rental.application.port.out.TenantAttachmentStorage;
import fr.nessar.lmnporganizer.rental.domain.tenant.TenantEvidenceRetention;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import org.bson.Document;
import org.springframework.data.mongodb.core.MongoTemplate;
import org.springframework.data.mongodb.core.query.Query;
@@ -23,7 +26,7 @@ public class MongoAdministrationUsageRepository implements AdministrationUsageRe
"tax_workspaces");
private static final List<ArtifactSource> ARTIFACT_SOURCES = List.of(
source("apartments", Storage.LEASE, List.of("documents"), key("storageKey")),
source("tenants", Storage.TENANT, List.of("attachments"), key("storagePath")),
source("tenants", Storage.TENANT, List.of("attachments"), key("storageKey")),
source("propertyConditionReports", Storage.CONDITION, List.of("photos"), key("storageKey"),
key("thumbnailStorageKey")),
source("propertyConditionReports", Storage.CONDITION, List.of("supplements", "photos"), key("storageKey"),
@@ -70,15 +73,17 @@ public class MongoAdministrationUsageRepository implements AdministrationUsageRe
private final LeaseDocumentStorage leaseStorage;
private final PropertyConditionAttachmentStorage conditionStorage;
private final TenantAttachmentStorage tenantStorage;
private final ApplicationDocumentStorage applicationStorage;
private final ExpenseDocumentAttachmentStorage expenseStorage;
public MongoAdministrationUsageRepository(MongoTemplate mongo, LeaseDocumentStorage leaseStorage,
PropertyConditionAttachmentStorage conditionStorage, TenantAttachmentStorage tenantStorage,
ExpenseDocumentAttachmentStorage expenseStorage) {
ApplicationDocumentStorage applicationStorage, ExpenseDocumentAttachmentStorage expenseStorage) {
this.mongo = mongo;
this.leaseStorage = leaseStorage;
this.conditionStorage = conditionStorage;
this.tenantStorage = tenantStorage;
this.applicationStorage = applicationStorage;
this.expenseStorage = expenseStorage;
}
@@ -89,7 +94,7 @@ public class MongoAdministrationUsageRepository implements AdministrationUsageRe
@Override
public RetainedFiles retainedFiles() {
Map<String, ManagedFile> files = new LinkedHashMap<>();
Set<ManagedFile> files = new LinkedHashSet<>();
for (String collection : USAGE_COLLECTIONS) {
List<Document> documents = mongo.find(queryFor(collection), Document.class, collection);
for (ArtifactSource source : ARTIFACT_SOURCES) {
@@ -100,14 +105,14 @@ public class MongoAdministrationUsageRepository implements AdministrationUsageRe
}
}
}
for (ManagedFile file : List.copyOf(files.values())) {
for (ManagedFile file : List.copyOf(files)) {
if (file.storage() == Storage.EXPENSE) {
expenseStorage.previewStorageKey(file.storageKey())
.ifPresent(preview -> files.putIfAbsent(preview, new ManagedFile(preview, Storage.EXPENSE)));
.ifPresent(preview -> files.add(new ManagedFile(preview, Storage.EXPENSE)));
}
}
long bytes = 0;
for (ManagedFile file : files.values()) {
for (ManagedFile file : files) {
try {
bytes = Math.addExact(bytes, size(file));
} catch (ArithmeticException failure) {
@@ -117,7 +122,7 @@ public class MongoAdministrationUsageRepository implements AdministrationUsageRe
return new RetainedFiles(files.size(), bytes);
}
private void add(Map<String, ManagedFile> files, Document document, ArtifactSource source) {
private void add(Set<ManagedFile> files, Document document, ArtifactSource source) {
for (Object value : valuesAt(document, source.path())) {
if (!(value instanceof Map<?, ?> metadata)) {
throw new IllegalStateException("Managed file metadata is malformed.");
@@ -130,16 +135,30 @@ public class MongoAdministrationUsageRepository implements AdministrationUsageRe
if (!(rawKey instanceof String storageKey) || storageKey.isBlank()) {
throw new IllegalStateException("Managed file metadata is malformed.");
}
files.putIfAbsent(storageKey, new ManagedFile(storageKey, source.storage()));
Storage storage = source.storage();
if (storage == Storage.TENANT && isOriginalApplication(metadata)) {
storage = Storage.APPLICATION;
}
files.add(new ManagedFile(storageKey, storage));
}
}
}
private static boolean isOriginalApplication(Map<?, ?> metadata) {
Object retention = metadata.get("retention");
if (!(retention instanceof Map<?, ?> retentionMetadata)) {
return false;
}
return TenantEvidenceRetention.EvidenceSource.ORIGINAL_APPLICATION.name()
.equals(retentionMetadata.get("source"));
}
private long size(ManagedFile file) {
long size = switch (file.storage()) {
case LEASE -> leaseStorage.size(file.storageKey());
case CONDITION -> conditionStorage.size(file.storageKey());
case TENANT -> tenantStorage.retainedSize(file.storageKey());
case APPLICATION -> applicationStorage.retainedSize(file.storageKey());
case EXPENSE -> expenseStorage.retainedSize(file.storageKey());
};
if (size < 0) {
@@ -184,11 +203,14 @@ public class MongoAdministrationUsageRepository implements AdministrationUsageRe
}
}
}
if (collection.equals("tenants")) {
query.fields().include("attachments.retention.source");
}
return query;
}
private enum Storage {
LEASE, CONDITION, TENANT, EXPENSE
LEASE, CONDITION, TENANT, APPLICATION, EXPENSE
}
private record ArtifactSource(String collection, Storage storage, List<String> path, List<FileKey> keys) {
@@ -2,6 +2,7 @@ package fr.nessar.lmnporganizer.rental.adapter.in.config;
import fr.nessar.lmnporganizer.rental.adapter.out.filesystem.FileSystemApplicationDocumentStorage;
import fr.nessar.lmnporganizer.rental.adapter.out.mongodb.application.MongoRentalApplicationRepository;
import fr.nessar.lmnporganizer.rental.adapter.out.mongodb.tenant.MongoTenantRepository;
import org.springframework.boot.ApplicationRunner;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@@ -11,7 +12,9 @@ public class ApplicationDocumentRecoveryConfiguration {
@Bean
ApplicationRunner applicationDocumentRecovery(FileSystemApplicationDocumentStorage storage,
MongoRentalApplicationRepository applications) {
return arguments -> storage.recoverOrphanedDocuments(applications::referencesApplicationDocumentStorageKey);
MongoRentalApplicationRepository applications, MongoTenantRepository tenants) {
return arguments -> storage
.recoverOrphanedDocuments(storageKey -> applications.referencesApplicationDocumentStorageKey(storageKey)
|| tenants.referencesApplicationDocumentStorageKey(storageKey));
}
}
@@ -27,8 +27,6 @@ import fr.nessar.lmnporganizer.rental.application.port.out.LeaseDocumentStorage;
import fr.nessar.lmnporganizer.rental.application.port.out.LeaseInformationNoticeCatalog;
import fr.nessar.lmnporganizer.rental.application.port.out.PropertyConditionAttachmentStorage;
import fr.nessar.lmnporganizer.rental.application.port.out.PropertyConditionPhotoNormalizer;
import fr.nessar.lmnporganizer.rental.application.port.out.PropertyConditionReportRenderer;
import fr.nessar.lmnporganizer.rental.application.port.out.RentalDocumentRenderer;
import fr.nessar.lmnporganizer.rental.application.port.out.TenantAttachmentStorage;
import fr.nessar.lmnporganizer.rental.application.port.out.TransactionRunner;
import fr.nessar.lmnporganizer.rental.application.rent.AllocateRentPaymentService;
@@ -51,8 +49,10 @@ import fr.nessar.lmnporganizer.rental.application.rentalapplication.ApplicationV
import fr.nessar.lmnporganizer.rental.application.rentalapplication.ApplicationVaultAccess;
import fr.nessar.lmnporganizer.rental.application.rentalapplication.ApplicationVaultResetService;
import fr.nessar.lmnporganizer.rental.application.tenant.DeleteTenantService;
import fr.nessar.lmnporganizer.rental.application.tenant.TenantAccountClosureService;
import fr.nessar.lmnporganizer.rental.application.tenant.TenantAttachmentService;
import fr.nessar.lmnporganizer.rental.application.tenant.TenantDirectoryService;
import fr.nessar.lmnporganizer.rental.application.tenant.TenantEvidenceRetentionService;
import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentRepository;
import fr.nessar.lmnporganizer.rental.domain.application.ApplicationVaultRepository;
import fr.nessar.lmnporganizer.rental.domain.application.ApplicationVaultResetRepository;
@@ -141,34 +141,52 @@ public class RentalApplicationConfiguration {
LeaseRepository leases, LeaseDocumentRevisionRepository revisions, FurnitureInventoryRepository inventories,
LandlordRepository landlords, TenantRepository tenants, PropertyConditionPhotoNormalizer normalizer,
PropertyConditionAttachmentStorage storage, TransactionRunner transactions, IdentifierGenerator identifiers,
Clock rentalClock, ApartmentRepository apartments) {
Clock rentalClock, ApartmentRepository apartments, ApplicationVaultRepository vaults,
ApplicationVaultAccess vaultAccess) {
return new PropertyConditionReportService(reports, leases, inventories, landlords, tenants, normalizer, storage,
transactions, identifiers, rentalClock, apartments, revisions);
transactions, identifiers, rentalClock, apartments, revisions, vaults, vaultAccess);
}
@Bean
PropertyConditionDocumentService propertyConditionDocumentService(PropertyConditionReportRepository reports,
PropertyConditionAttachmentStorage storage, LeaseDocumentStorage leaseStorage,
PropertyConditionReportRenderer renderer, LeaseFinalizationRepository finalizations, LeaseRepository leases,
IdentifierGenerator identifiers, Clock rentalClock) {
return new PropertyConditionDocumentService(reports, storage, leaseStorage, renderer, finalizations, leases,
identifiers, rentalClock);
LeaseFinalizationRepository finalizations, LeaseRepository leases, IdentifierGenerator identifiers,
Clock rentalClock, ApplicationVaultRepository vaults, ApplicationVaultAccess vaultAccess,
TransactionRunner transactions, RentalAttachmentProperties properties) {
return new PropertyConditionDocumentService(reports, storage, leaseStorage, finalizations, leases, identifiers,
rentalClock, vaults, vaultAccess, transactions, properties.maxSize().toBytes());
}
@Bean
TenantDirectoryService tenantDirectoryService(TenantRepository tenants, IdentifierGenerator identifiers) {
return new TenantDirectoryService(tenants, identifiers);
TenantDirectoryService tenantDirectoryService(TenantRepository tenants, ApplicationVaultRepository vaults,
ApplicationVaultAccess vaultAccess, TransactionRunner transactions) {
return new TenantDirectoryService(tenants, vaults, vaultAccess, transactions);
}
@Bean
DeleteTenantService deleteTenantService(TenantRepository tenants, TenantAttachmentStorage storage) {
return new DeleteTenantService(tenants, storage);
DeleteTenantService deleteTenantService(TenantRepository tenants, RentalApplicationRepository applications,
TenantAttachmentStorage storage, TransactionRunner transactions) {
return new DeleteTenantService(tenants, applications, storage, transactions);
}
@Bean
TenantAccountClosureService tenantAccountClosureService(TenantRepository tenants, TransactionRunner transactions,
Clock rentalClock) {
return new TenantAccountClosureService(tenants, transactions, rentalClock);
}
@Bean
TenantEvidenceRetentionService tenantEvidenceRetentionService(LeaseRepository leases,
ApplicationVaultAccess vaultAccess, Clock rentalClock) {
return new TenantEvidenceRetentionService(leases, vaultAccess, rentalClock);
}
@Bean
TenantAttachmentService tenantAttachmentService(TenantRepository tenants, TenantAttachmentStorage storage,
IdentifierGenerator identifiers, RentalAttachmentProperties properties) {
return new TenantAttachmentService(tenants, storage, identifiers, properties.maxSize().toBytes());
ApplicationVaultRepository vaults, ApplicationVaultAccess vaultAccess, TransactionRunner transactions,
ApplicationDocumentStorage applicationDocuments, Clock rentalClock, RentalAttachmentProperties properties) {
return new TenantAttachmentService(tenants, storage, vaults, vaultAccess, transactions, applicationDocuments,
rentalClock, properties.maxSize().toBytes());
}
@Bean
@@ -212,9 +230,10 @@ public class RentalApplicationConfiguration {
@Bean
ApplicationSelectionService applicationSelectionService(RentalApplicationRepository applications,
TenantRepository tenants, IdentifierGenerator identifiers, RentalApplicationAccess applicationAccess,
Clock rentalClock) {
return new ApplicationSelectionService(applications, tenants, identifiers, applicationAccess, rentalClock);
TenantRepository tenants, ApplicationVaultRepository vaults, RentalApplicationAccess applicationAccess,
TenantAttachmentService attachments, Clock rentalClock) {
return new ApplicationSelectionService(applications, tenants, vaults, applicationAccess, attachments,
rentalClock);
}
@Bean
@@ -232,12 +251,12 @@ public class RentalApplicationConfiguration {
@Bean
LeaseDraftService leaseDraftService(ApartmentRepository apartments, TenantRepository tenants,
LeaseRepository leases, TransactionRunner transactions, IdentifierGenerator identifiers,
LeaseClauseCatalog clauseCatalog, LeaseGuaranteeDeedRepository guaranteeDeeds,
LeaseDocumentRevisionRepository revisions, VisaleSuretyContractRepository visaleContracts,
LeaseDocumentStorage storage) {
return new LeaseDraftService(apartments, tenants, leases, transactions, identifiers, clauseCatalog,
guaranteeDeeds, revisions, visaleContracts, storage);
LeaseRepository leases, TransactionRunner transactions, LeaseClauseCatalog clauseCatalog,
LeaseGuaranteeDeedRepository guaranteeDeeds, LeaseDocumentRevisionRepository revisions,
VisaleSuretyContractRepository visaleContracts, LeaseDocumentStorage storage,
ApplicationVaultRepository vaults, ApplicationVaultAccess vaultAccess) {
return new LeaseDraftService(apartments, tenants, leases, transactions, clauseCatalog, guaranteeDeeds,
revisions, visaleContracts, storage, vaults, vaultAccess);
}
@Bean
@@ -251,10 +270,11 @@ public class RentalApplicationConfiguration {
@Bean
LeaseEndingService leaseEndingService(LeaseRepository leases, ApartmentOccupancyRepository occupancies,
RentDueRepository rentDues, RentRevisionRepository revisions, PropertyConditionReportRepository reports,
LeaseDocumentStorage storage, TransactionRunner transactions, IdentifierGenerator identifiers,
Clock rentalClock, RentalAttachmentProperties properties, FiscalYearClosureQuery fiscalYearClosures) {
LeaseDocumentStorage storage, TransactionRunner transactions, Clock rentalClock,
RentalAttachmentProperties properties, FiscalYearClosureQuery fiscalYearClosures,
ApplicationVaultRepository vaults, ApplicationVaultAccess vaultAccess) {
return new LeaseEndingService(leases, occupancies, rentDues, revisions, reports, storage, transactions,
identifiers, rentalClock, properties.maxSize().toBytes(), fiscalYearClosures);
rentalClock, properties.maxSize().toBytes(), fiscalYearClosures, vaults, vaultAccess);
}
@Bean
@@ -268,14 +288,14 @@ public class RentalApplicationConfiguration {
}
@Bean
GenerateLeaseDocumentService generateLeaseDocumentService(LeaseRepository leases, RentalDocumentRenderer renderer,
Clock rentalClock, LeaseDocumentRevisionRepository revisions, LeaseDocumentStorage storage,
PropertyConditionAttachmentStorage conditionAttachments, ApartmentDocumentService apartmentDocuments,
PropertyConditionReportRepository reports, PropertyConditionReportRenderer conditionRenderer,
GenerateLeaseDocumentService generateLeaseDocumentService(LeaseRepository leases, Clock rentalClock,
LeaseDocumentRevisionRepository revisions, LeaseDocumentStorage storage,
ApartmentDocumentService apartmentDocuments, PropertyConditionReportRepository reports,
LeaseDocumentRevisionSource source, LeaseFinalizationRepository finalizations,
LeaseGuaranteeDeedRepository guaranteeDeeds) {
return new GenerateLeaseDocumentService(leases, renderer, rentalClock, revisions, storage, conditionAttachments,
apartmentDocuments, reports, conditionRenderer, source, finalizations, guaranteeDeeds);
LeaseGuaranteeDeedRepository guaranteeDeeds, ApplicationVaultRepository vaults,
ApplicationVaultAccess vaultAccess, TransactionRunner transactions) {
return new GenerateLeaseDocumentService(leases, rentalClock, revisions, storage, apartmentDocuments, reports,
source, finalizations, guaranteeDeeds, vaults, vaultAccess, transactions);
}
@Bean
@@ -285,23 +305,27 @@ public class RentalApplicationConfiguration {
LeaseFinalizationRepository finalizations, LeaseDocumentStorage storage, RentDueRepository rentDues,
ApartmentOccupancyRepository occupancies, IdentifierGenerator identifiers, TransactionRunner transactions,
Clock rentalClock, RentalAttachmentProperties properties, FurnitureInventoryRepository inventories,
PropertyConditionReportRepository reports, FiscalYearClosureQuery fiscalYearClosures) {
PropertyConditionReportRepository reports, FiscalYearClosureQuery fiscalYearClosures,
ApplicationVaultRepository vaults, ApplicationVaultAccess vaultAccess) {
return new LeaseFinalizationService(leases, apartments, tenants, landlords, revisionSource, revisions,
guaranteeDeeds, finalizations, storage, rentDues, occupancies, identifiers, transactions, rentalClock,
properties.maxSize().toBytes(), inventories, reports, fiscalYearClosures);
properties.maxSize().toBytes(), inventories, reports, fiscalYearClosures, vaults, vaultAccess);
}
@Bean
VisaleVisaService visaleVisaService(LeaseRepository leases, LeaseDocumentStorage storage, Clock rentalClock,
RentalAttachmentProperties properties) {
return new VisaleVisaService(leases, storage, rentalClock, properties.maxSize().toBytes());
RentalAttachmentProperties properties, ApplicationVaultRepository vaults,
ApplicationVaultAccess vaultAccess) {
return new VisaleVisaService(leases, storage, rentalClock, properties.maxSize().toBytes(), vaults, vaultAccess);
}
@Bean
VisaleSuretyContractService visaleSuretyContractService(LeaseRepository leases,
VisaleSuretyContractRepository contracts, LeaseDocumentStorage storage, Clock rentalClock,
RentalAttachmentProperties properties) {
return new VisaleSuretyContractService(leases, contracts, storage, rentalClock, properties.maxSize().toBytes());
RentalAttachmentProperties properties, ApplicationVaultRepository vaults,
ApplicationVaultAccess vaultAccess) {
return new VisaleSuretyContractService(leases, contracts, storage, rentalClock, properties.maxSize().toBytes(),
vaults, vaultAccess);
}
@Bean
@@ -319,8 +343,10 @@ public class RentalApplicationConfiguration {
@Bean
ReverseRentPaymentService reverseRentPaymentService(RentPaymentRepository payments,
RentPaymentReversalRepository reversals, RentDueRepository rentDues, TransactionRunner transactions,
IdentifierGenerator identifiers, Clock rentalClock) {
return new ReverseRentPaymentService(payments, reversals, rentDues, transactions, identifiers, rentalClock);
IdentifierGenerator identifiers, Clock rentalClock, ApplicationVaultRepository vaults,
ApplicationVaultAccess vaultAccess) {
return new ReverseRentPaymentService(payments, reversals, rentDues, transactions, identifiers, rentalClock,
vaults, vaultAccess);
}
@Bean
@@ -337,15 +363,14 @@ public class RentalApplicationConfiguration {
@Bean
GeneratePaymentReceiptService generatePaymentReceiptService(RentPaymentRepository payments,
RentDueRepository rentDues, RentPaymentRefundRepository refunds, LeaseRepository leases,
RentalDocumentRenderer renderer) {
return new GeneratePaymentReceiptService(payments, rentDues, refunds, leases, renderer);
RentDueRepository rentDues, RentPaymentRefundRepository refunds, LeaseRepository leases) {
return new GeneratePaymentReceiptService(payments, rentDues, refunds, leases);
}
@Bean
GenerateRentReceiptService generateRentReceiptService(RentDueRepository rentDues, LeaseRepository leases,
RentalDocumentRenderer renderer, Clock rentalClock) {
return new GenerateRentReceiptService(rentDues, leases, renderer, rentalClock);
Clock rentalClock) {
return new GenerateRentReceiptService(rentDues, leases, rentalClock);
}
@Bean
@@ -355,10 +380,11 @@ public class RentalApplicationConfiguration {
@Bean
RentRevisionService rentRevisionService(LeaseRepository leases, IrlIndexProvider indices,
RentRevisionRepository revisions, RentDueRepository rentDues, RentalDocumentRenderer renderer,
LeaseDocumentStorage storage, TransactionRunner transactions, IdentifierGenerator identifiers,
Clock rentalClock, RentalAttachmentProperties properties, FiscalYearClosureQuery fiscalYearClosures) {
return new RentRevisionService(leases, indices, revisions, rentDues, renderer, storage, transactions,
RentRevisionRepository revisions, RentDueRepository rentDues, ApplicationVaultRepository vaults,
ApplicationVaultAccess vaultAccess, LeaseDocumentStorage storage, TransactionRunner transactions,
IdentifierGenerator identifiers, Clock rentalClock, RentalAttachmentProperties properties,
FiscalYearClosureQuery fiscalYearClosures) {
return new RentRevisionService(leases, indices, revisions, rentDues, vaults, vaultAccess, storage, transactions,
identifiers, rentalClock, properties.maxSize().toBytes(), fiscalYearClosures);
}
@@ -12,6 +12,9 @@ public class TenantAttachmentRecoveryConfiguration {
@Bean
ApplicationRunner tenantAttachmentDeletionRecovery(FileSystemTenantAttachmentStorage storage,
MongoTenantRepository tenants) {
return arguments -> storage.recoverInterruptedDeletions(tenants::referencesAttachmentStorageKey);
return arguments -> {
storage.recoverInterruptedDeletions(tenants::referencesAttachmentStorageKey);
storage.recoverOrphanedUploads(tenants::referencesAttachmentStorageKey);
};
}
}
@@ -53,6 +53,13 @@ public class LandlordApplicationController {
return RentalApplicationApi.VaultResponse.from(applications.vault(authenticatedLandlord.id(principal)));
}
@PostMapping("/application-vault")
RentalApplicationApi.VaultResponse activateVault(@AuthenticationPrincipal OidcUser principal,
@Valid @RequestBody RentalApplicationApi.VaultActivationRequest request) {
return RentalApplicationApi.VaultResponse
.from(applications.activate(authenticatedLandlord.id(principal), request.toDomain()));
}
@PostMapping("/application-vault/passkeys")
RentalApplicationApi.VaultResponse addPasskey(@AuthenticationPrincipal OidcUser principal,
@Valid @RequestBody RentalApplicationApi.AddPasskeyRequest request,
@@ -87,14 +94,6 @@ public class LandlordApplicationController {
request.operationId(), request.expectedVersion(), request.expectedPublicKey()));
}
@PostMapping("/rental-applications/{applicationId}/correction")
RentalApplicationApi.LifecycleResponse requestCorrection(@AuthenticationPrincipal OidcUser principal,
@PathVariable String applicationId, @Valid @RequestBody RentalApplicationApi.SubmitRequest request) {
return RentalApplicationApi.LifecycleResponse
.from(applications.requestCorrection(authenticatedLandlord.id(principal),
new RentalApplicationId(applicationId), request.expectedRevision()));
}
@PostMapping("/rental-applications/{applicationId}/rejection")
RentalApplicationApi.LifecycleResponse reject(@AuthenticationPrincipal OidcUser principal,
@PathVariable String applicationId, @Valid @RequestBody RentalApplicationApi.SubmitRequest request) {
@@ -106,6 +105,6 @@ public class LandlordApplicationController {
RentalApplicationApi.SelectionResponse select(@AuthenticationPrincipal OidcUser principal,
@PathVariable String applicationId, @Valid @RequestBody RentalApplicationApi.SelectionRequest request) {
return RentalApplicationApi.SelectionResponse.from(selections.select(authenticatedLandlord.id(principal),
new RentalApplicationId(applicationId), request.expectedRevision(), request.confirmedTenant()));
new RentalApplicationId(applicationId), request.toDomain()));
}
}
@@ -9,16 +9,18 @@ import fr.nessar.lmnporganizer.rental.domain.application.ApplicationPasskey;
import fr.nessar.lmnporganizer.rental.domain.application.EncryptedApplicationData;
import fr.nessar.lmnporganizer.rental.domain.application.EncryptedApplicationDocument;
import fr.nessar.lmnporganizer.rental.domain.application.RentalApplication;
import fr.nessar.lmnporganizer.rental.domain.shared.PhoneNumber;
import jakarta.validation.constraints.AssertTrue;
import jakarta.validation.constraints.Email;
import fr.nessar.lmnporganizer.rental.domain.tenant.TenantId;
import fr.nessar.lmnporganizer.rental.domain.tenant.TenantEvidenceRetention;
import fr.nessar.lmnporganizer.rental.domain.tenant.TenantProfile;
import jakarta.validation.Valid;
import jakarta.validation.constraints.AssertTrue;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import java.time.Instant;
import java.time.LocalDate;
import java.util.List;
final class RentalApplicationApi {
@@ -102,25 +104,57 @@ final class RentalApplicationApi {
record SubmitRequest(@Min(0) int expectedRevision) {
}
record SelectionRequest(@Min(0) int expectedRevision, @NotBlank String firstName, @NotBlank String lastName,
@Email String email, String phone) {
record SelectionRequest(@Min(0) int expectedRevision, @NotNull @Valid ConfirmedTenantRequest confirmedTenant,
@NotNull List<@NotNull @Valid RetainedDocumentRequest> retainedDocuments,
@Valid EncryptedGroupRequest proposedGuarantees) {
ApplicationSelectionService.SelectionCommand toDomain() {
return new ApplicationSelectionService.SelectionCommand(expectedRevision, confirmedTenant.toDomain(),
retainedDocuments.stream().map(RetainedDocumentRequest::toDomain).toList(),
proposedGuarantees == null ? null : proposedGuarantees.toDomain());
}
}
record ConfirmedTenantRequest(@NotNull ApplicationSelectionService.TenantSelectionMode mode,
@NotBlank @Size(max = 100) String id, @Valid TenantProfileRequest profile) {
@AssertTrue
boolean isPhoneValid() {
try {
new PhoneNumber(phone);
return true;
} catch (IllegalArgumentException ignored) {
return false;
}
boolean profileMatchesMode() {
return mode == ApplicationSelectionService.TenantSelectionMode.CREATE ? profile != null : profile == null;
}
@AssertTrue
boolean isContactProvided() {
return email != null && !email.isBlank() || phone != null && !phone.isBlank();
ApplicationSelectionService.ConfirmedTenant toDomain() {
return new ApplicationSelectionService.ConfirmedTenant(mode, new TenantId(id),
profile == null ? null : profile.toDomain());
}
}
ApplicationSelectionService.ConfirmedTenant confirmedTenant() {
return new ApplicationSelectionService.ConfirmedTenant(firstName, lastName, email, phone);
record TenantProfileRequest(@NotBlank @Size(max = 1400) String vaultPublicKey,
@NotNull @Valid EncryptedGroupRequest identityAndAddress, @NotNull @Valid EncryptedGroupRequest contacts,
boolean contractProfileComplete) {
TenantProfile toDomain() {
return new TenantProfile(vaultPublicKey, identityAndAddress.toDomain(), contacts.toDomain(),
contractProfileComplete);
}
}
record EncryptedGroupRequest(@Min(1) @Max(1) int cryptoVersion, @NotBlank @Size(max = 32) String nonce,
@NotBlank @Size(max = 1400) String wrappedKey, @NotBlank @Size(max = 64) String sha256,
@NotBlank @Size(max = 44000) String ciphertext) {
TenantProfile.EncryptedGroup toDomain() {
return new TenantProfile.EncryptedGroup(cryptoVersion, nonce, wrappedKey, sha256, ciphertext);
}
}
record RetainedDocumentRequest(@NotBlank @Size(max = 200) String id, @Min(1) int revision,
@NotBlank @Size(max = 32) String nonce, @Min(17) long encryptedSize, @Min(1) @Max(1) int cryptoVersion,
@NotNull TenantEvidenceRetention.EvidencePurpose purpose,
@NotNull TenantEvidenceRetention.RetentionRule retentionRule,
@Size(max = 1400) String specificNeedVaultPublicKey,
@Valid EncryptedGroupRequest encryptedSpecificNeedExplanation, LocalDate specificNeedEndsOn) {
ApplicationSelectionService.RetainedDocument toDomain() {
return new ApplicationSelectionService.RetainedDocument(id, revision, nonce, encryptedSize, cryptoVersion,
purpose, retentionRule, specificNeedVaultPublicKey,
encryptedSpecificNeedExplanation == null ? null : encryptedSpecificNeedExplanation.toDomain(),
specificNeedEndsOn);
}
}
@@ -3,6 +3,7 @@ package fr.nessar.lmnporganizer.rental.adapter.in.web.common;
import fr.nessar.lmnporganizer.rental.application.rent.RentPaymentConflict;
import fr.nessar.lmnporganizer.rental.application.rentalapplication.ApplicationCreationRateLimited;
import fr.nessar.lmnporganizer.rental.application.rentalapplication.ApplicationVaultRequired;
import fr.nessar.lmnporganizer.rental.application.rentalapplication.ApplicationVaultRetired;
import fr.nessar.lmnporganizer.rental.application.rentalapplication.ApplicationVaultResetInProgress;
import fr.nessar.lmnporganizer.rental.application.apartment.AcquisitionFactsCorrectionNotAllowed;
import fr.nessar.lmnporganizer.rental.application.shared.AttachmentStorageFailure;
@@ -84,7 +85,13 @@ public class RentalApiExceptionHandler {
@ExceptionHandler(ApplicationVaultResetInProgress.class)
ResponseEntity<CodedApiError> applicationVaultResetInProgress(ApplicationVaultResetInProgress exception) {
return ResponseEntity.status(HttpStatus.CONFLICT).body(new CodedApiError("application_vault_reset_in_progress",
"La réinitialisation du coffre de candidatures doit être reprise."));
"La réinitialisation du coffre partagé doit être reprise."));
}
@ExceptionHandler(ApplicationVaultRetired.class)
ResponseEntity<CodedApiError> applicationVaultRetired(ApplicationVaultRetired exception) {
return ResponseEntity.status(HttpStatus.GONE)
.body(new CodedApiError("application_vault_lost", "Ce contenu chiffré appartient à un coffre perdu."));
}
@ExceptionHandler(ApplicationCreationRateLimited.class)
@@ -196,7 +203,7 @@ public class RentalApiExceptionHandler {
@ExceptionHandler(HttpMessageNotReadableException.class)
ResponseEntity<ValidationApiError> unreadableRequest(HttpServletRequest request) {
String path = request.getRequestURI();
boolean conditionReportMetadata = path.endsWith("/finalization") || path.endsWith("/supplements");
boolean conditionReportMetadata = path.endsWith("/supplements");
return validation(conditionReportMetadata ? "metadata" : "request", "malformed");
}
@@ -14,6 +14,7 @@ import fr.nessar.lmnporganizer.rental.domain.lease.LeaseDocumentAnnex;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseDocumentRevision;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseDocumentWarning;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseGuarantee;
import fr.nessar.lmnporganizer.rental.domain.lease.GuarantorProfile;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseLegalTerms;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseRuleViolation;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseSnapshot;
@@ -23,10 +24,15 @@ import fr.nessar.lmnporganizer.rental.domain.lease.LeaseType;
import fr.nessar.lmnporganizer.rental.domain.lease.LegalComplianceOverride;
import fr.nessar.lmnporganizer.rental.domain.lease.VisaleGuarantee;
import fr.nessar.lmnporganizer.rental.domain.lease.VisaleVisa;
import fr.nessar.lmnporganizer.rental.application.lease.GenerateLeaseDocumentService;
import fr.nessar.lmnporganizer.rental.application.lease.LeaseDocumentEncryption;
import fr.nessar.lmnporganizer.rental.application.port.out.RentalDocumentRenderer;
import fr.nessar.lmnporganizer.rental.application.rentalapplication.ApplicationVaultAccess;
import fr.nessar.lmnporganizer.rental.domain.tenant.TenantId;
import fr.nessar.lmnporganizer.rental.domain.tenant.TenantProfile;
import fr.nessar.lmnporganizer.rental.domain.tenant.TenantProfile.EncryptedGroup;
import jakarta.validation.Valid;
import jakarta.validation.constraints.AssertTrue;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
@@ -41,37 +47,57 @@ import java.time.Instant;
import java.time.LocalDate;
import java.time.MonthDay;
import java.util.HashSet;
import java.util.Base64;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.stream.Collectors;
final class LeaseApi {
private LeaseApi() {
}
record LeaseGuaranteeRequest(String id, @NotBlank String tenantId, @NotNull LeaseGuarantee.Type type,
String firstName, String lastName, @Email String email, String phone, String address,
String organizationName, String organizationAddress, String representativeName, String registrationId) {
}
record VisaleGuaranteeRequest(@NotBlank @Size(max = 100) String visaNumber,
@NotEmpty List<@NotBlank String> tenantIds) {
VisaleGuarantee toDomain() {
return new VisaleGuarantee(visaNumber, tenantIds.stream().map(TenantId::new).toList());
record EncryptedGroupRequest(@NotNull Integer cryptoVersion, @NotBlank String nonce, @NotBlank String wrappedKey,
@NotBlank String sha256, @NotBlank String ciphertext) {
EncryptedGroup toDomain() {
return new EncryptedGroup(cryptoVersion, nonce, wrappedKey, sha256, ciphertext);
}
}
record SpecialClauseRequest(@Size(max = 100) String catalogId,
record GuarantorProfileRequest(@NotBlank String vaultPublicKey,
@NotNull @Valid EncryptedGroupRequest identityAndAddress, @NotNull @Valid EncryptedGroupRequest contacts,
boolean contractProfileComplete, @Positive long identityGeneration) {
GuarantorProfile toDomain() {
return new GuarantorProfile(vaultPublicKey, identityAndAddress.toDomain(), contacts.toDomain(),
contractProfileComplete, identityGeneration);
}
}
record LeaseGuaranteeRequest(String id, @NotBlank String tenantId, @NotNull LeaseGuarantee.Type type,
@NotNull @Valid GuarantorProfileRequest profile) {
}
record VisaleGuaranteeRequest(@NotNull @Valid EncryptedGroupRequest visaNumber,
@NotEmpty List<@NotBlank String> tenantIds) {
VisaleGuarantee toDomain() {
return new VisaleGuarantee(visaNumber.toDomain(), tenantIds.stream().map(TenantId::new).toList());
}
}
record SpecialClauseRequest(@NotBlank @Size(max = 100) String id, @NotBlank @Size(max = 1400) String vaultPublicKey,
@Size(max = 100) String catalogId,
@Size(max = 20) Map<@NotBlank @Size(max = 80) String, @Size(max = 500) String> parameters,
@Size(max = 4000) String customText) {
@Size(max = 20) Map<@NotBlank @Size(max = 80) String, @NotNull @Valid EncryptedGroupRequest> encryptedParameters,
@NotNull @Valid EncryptedGroupRequest renderedText) {
}
record RentControlRequest(Boolean evolutionCapApplicable, Boolean referenceRentApplicable,
@PositiveOrZero BigDecimal baseReferenceRent, @PositiveOrZero BigDecimal increasedReferenceRent,
@PositiveOrZero BigDecimal supplement, @Size(max = 2000) String supplementJustification) {
@PositiveOrZero BigDecimal supplement, @Valid EncryptedGroupRequest supplementJustification) {
LeaseLegalTerms.RentControlTerms toDomain() {
return new LeaseLegalTerms.RentControlTerms(Boolean.TRUE.equals(evolutionCapApplicable),
Boolean.TRUE.equals(referenceRentApplicable), baseReferenceRent, increasedReferenceRent, supplement,
supplementJustification);
supplementJustification == null ? null : supplementJustification.toDomain());
}
}
@@ -103,41 +129,36 @@ final class LeaseApi {
}
}
record RecentWorkRequest(@Size(max = 4000) String description, @PositiveOrZero BigDecimal amount) {
record RecentWorkRequest(@Valid EncryptedGroupRequest description, @PositiveOrZero BigDecimal amount) {
LeaseLegalTerms.RecentWork toDomain() {
return new LeaseLegalTerms.RecentWork(description, amount);
return new LeaseLegalTerms.RecentWork(description == null ? null : description.toDomain(), amount);
}
}
record LegalTermsRequest(@Valid RentControlRequest rentControl, @Valid PreviousRentRequest previousRent,
@Valid RentRevisionRequest rentRevision, @Valid RecentWorkRequest recentWork,
Boolean coOwnershipRulesApplicable, Boolean priorRentalAuthorizationApplicable,
Boolean primaryResidenceEasementApplicable, Boolean jointAndSeveralLiability,
@Size(max = 8000) String otherTerms) {
record LegalTermsRequest(@NotBlank @Size(max = 1400) String vaultPublicKey, @Valid RentControlRequest rentControl,
@Valid PreviousRentRequest previousRent, @Valid RentRevisionRequest rentRevision,
@Valid RecentWorkRequest recentWork, Boolean coOwnershipRulesApplicable,
Boolean priorRentalAuthorizationApplicable, Boolean primaryResidenceEasementApplicable,
Boolean jointAndSeveralLiability, @NotNull @Valid EncryptedGroupRequest otherTerms) {
LeaseLegalTerms toDomain() {
return new LeaseLegalTerms(rentControl == null ? null : rentControl.toDomain(),
return new LeaseLegalTerms(vaultPublicKey, rentControl == null ? null : rentControl.toDomain(),
previousRent == null ? null : previousRent.toDomain(),
rentRevision == null ? null : rentRevision.toDomain(),
recentWork == null ? null : recentWork.toDomain(), Boolean.TRUE.equals(coOwnershipRulesApplicable),
Boolean.TRUE.equals(priorRentalAuthorizationApplicable),
Boolean.TRUE.equals(primaryResidenceEasementApplicable),
Boolean.TRUE.equals(jointAndSeveralLiability), otherTerms);
Boolean.TRUE.equals(jointAndSeveralLiability), otherTerms.toDomain());
}
}
record DraftRequest(@NotBlank String apartmentId, @NotEmpty List<@NotBlank String> tenantIds,
record DraftRequest(@NotBlank @Size(max = 100) String id, @NotBlank @Size(max = 1400) String vaultPublicKey,
@NotBlank String apartmentId, @NotEmpty List<@NotBlank String> tenantIds,
@NotNull List<@Valid LeaseGuaranteeRequest> guarantees, @Valid VisaleGuaranteeRequest visaleGuarantee,
@NotNull LocalDate startDate, LeaseType leaseType, @NotNull @Positive BigDecimal monthlyRent,
@NotNull @PositiveOrZero BigDecimal monthlyCharges, @NotNull @PositiveOrZero BigDecimal securityDeposit,
@Min(1) @Max(28) Integer dueDay, ChargeMethod chargeMethod, Boolean indexFlatRate,
@Size(max = 20) List<@Valid SpecialClauseRequest> specialClauses, @Valid LegalTermsRequest legalTerms) {
DraftRequest(String apartmentId, List<String> tenantIds, List<LeaseGuaranteeRequest> guarantees,
LocalDate startDate, LeaseType leaseType, BigDecimal monthlyRent, BigDecimal monthlyCharges,
BigDecimal securityDeposit, Integer dueDay, ChargeMethod chargeMethod, Boolean indexFlatRate,
List<SpecialClauseRequest> specialClauses, LegalTermsRequest legalTerms) {
this(apartmentId, tenantIds, guarantees, null, startDate, leaseType, monthlyRent, monthlyCharges,
securityDeposit, dueDay, chargeMethod, indexFlatRate, specialClauses, legalTerms);
}
@NotNull @Size(max = 20) List<@Valid SpecialClauseRequest> specialClauses,
@NotNull @Valid LegalTermsRequest legalTerms) {
@AssertTrue
boolean isTenantIdsDistinct() {
@@ -183,49 +204,73 @@ final class LeaseApi {
}
}
record LeaseGuaranteeResponse(String id, String tenantId, LeaseGuarantee.Type type, String firstName,
String lastName, String email, String phone, String address, String organizationName,
String organizationAddress, String representativeName, String registrationId) {
static LeaseGuaranteeResponse from(LeaseGuarantee guarantee) {
return new LeaseGuaranteeResponse(guarantee.id(), guarantee.tenantId().value(), guarantee.type(),
guarantee.firstName(), guarantee.lastName(), guarantee.email(), guarantee.phone(),
guarantee.address(), guarantee.organizationName(), guarantee.organizationAddress(),
guarantee.representativeName(), guarantee.registrationId());
record EncryptedGroupResponse(int cryptoVersion, String nonce, String wrappedKey, String sha256,
String ciphertext) {
static EncryptedGroupResponse from(EncryptedGroup group) {
return new EncryptedGroupResponse(group.cryptoVersion(), group.nonce(), group.wrappedKey(), group.sha256(),
group.ciphertext());
}
}
record VisaleGuaranteeResponse(String visaNumber, List<String> tenantIds) {
record GuarantorProfileResponse(String vaultPublicKey, EncryptedGroupResponse identityAndAddress,
EncryptedGroupResponse contacts, boolean contractProfileComplete, long identityGeneration) {
static GuarantorProfileResponse from(GuarantorProfile profile) {
return new GuarantorProfileResponse(profile.vaultPublicKey(),
EncryptedGroupResponse.from(profile.identityAndAddress()),
EncryptedGroupResponse.from(profile.contacts()), profile.contractProfileComplete(),
profile.identityGeneration());
}
}
record LeaseGuaranteeResponse(String id, String tenantId, LeaseGuarantee.Type type,
GuarantorProfileResponse profile) {
static LeaseGuaranteeResponse from(LeaseGuarantee guarantee) {
return new LeaseGuaranteeResponse(guarantee.id(), guarantee.tenantId().value(), guarantee.type(),
GuarantorProfileResponse.from(guarantee.profile()));
}
}
record VisaleGuaranteeResponse(EncryptedGroupResponse visaNumber, List<String> tenantIds) {
static VisaleGuaranteeResponse from(VisaleGuarantee guarantee) {
return guarantee == null
? null
: new VisaleGuaranteeResponse(guarantee.visaNumber(),
: new VisaleGuaranteeResponse(EncryptedGroupResponse.from(guarantee.visaNumber()),
guarantee.tenantIds().stream().map(TenantId::value).toList());
}
}
record VisaleVisaResponse(String fileName, String sha256, String authorId, Instant uploadedAt) {
record VisaleVisaResponse(String fileName, String sha256, int cryptoVersion, String nonce, String wrappedKey,
String vaultPublicKey, String authorId, Instant uploadedAt) {
static VisaleVisaResponse from(VisaleVisa visa) {
return visa == null
? null
: new VisaleVisaResponse(visa.fileName(), visa.sha256(), visa.authorId(), visa.uploadedAt());
: new VisaleVisaResponse(visa.fileName(), visa.sha256(), visa.cryptoVersion(), visa.nonce(),
visa.wrappedKey(), visa.vaultPublicKey(), visa.authorId(), visa.uploadedAt());
}
}
record SpecialClauseResponse(String catalogId, Map<String, String> parameters, String customText,
String renderedText, boolean custom) {
record SpecialClauseResponse(String id, String vaultPublicKey, String catalogId, Map<String, String> parameters,
Map<String, EncryptedGroupResponse> encryptedParameters, EncryptedGroupResponse renderedText,
boolean custom) {
static SpecialClauseResponse from(LeaseSpecialClause clause) {
return new SpecialClauseResponse(clause.catalogId(), clause.parameters(),
clause.custom() ? clause.text() : "", clause.text(), clause.custom());
return new SpecialClauseResponse(clause.id(), clause.vaultPublicKey(), clause.catalogId(),
clause.parameters(),
clause.encryptedParameters().entrySet().stream()
.collect(Collectors.toUnmodifiableMap(Map.Entry::getKey,
entry -> EncryptedGroupResponse.from(entry.getValue()))),
EncryptedGroupResponse.from(clause.renderedText()), clause.custom());
}
}
record RentControlResponse(boolean evolutionCapApplicable, boolean referenceRentApplicable,
BigDecimal baseReferenceRent, BigDecimal increasedReferenceRent, BigDecimal supplement,
String supplementJustification) {
EncryptedGroupResponse supplementJustification) {
static RentControlResponse from(LeaseLegalTerms.RentControlTerms terms) {
return new RentControlResponse(terms.evolutionCapApplicable(), terms.referenceRentApplicable(),
terms.baseReferenceRent(), terms.increasedReferenceRent(), terms.supplement(),
terms.supplementJustification());
terms.supplementJustification() == null
? null
: EncryptedGroupResponse.from(terms.supplementJustification()));
}
}
@@ -247,22 +292,24 @@ final class LeaseApi {
}
}
record RecentWorkResponse(String description, BigDecimal amount) {
record RecentWorkResponse(EncryptedGroupResponse description, BigDecimal amount) {
static RecentWorkResponse from(LeaseLegalTerms.RecentWork work) {
return new RecentWorkResponse(work.description(), work.amount());
return new RecentWorkResponse(
work.description() == null ? null : EncryptedGroupResponse.from(work.description()), work.amount());
}
}
record LegalTermsResponse(RentControlResponse rentControl, PreviousRentResponse previousRent,
record LegalTermsResponse(String vaultPublicKey, RentControlResponse rentControl, PreviousRentResponse previousRent,
RentRevisionResponse rentRevision, RecentWorkResponse recentWork, boolean coOwnershipRulesApplicable,
boolean priorRentalAuthorizationApplicable, boolean primaryResidenceEasementApplicable,
boolean jointAndSeveralLiability, String otherTerms) {
boolean jointAndSeveralLiability, EncryptedGroupResponse otherTerms) {
static LegalTermsResponse from(LeaseLegalTerms terms) {
return new LegalTermsResponse(RentControlResponse.from(terms.rentControl()),
return new LegalTermsResponse(terms.vaultPublicKey(), RentControlResponse.from(terms.rentControl()),
PreviousRentResponse.from(terms.previousRent()), RentRevisionResponse.from(terms.rentRevision()),
RecentWorkResponse.from(terms.recentWork()), terms.coOwnershipRulesApplicable(),
terms.priorRentalAuthorizationApplicable(), terms.primaryResidenceEasementApplicable(),
terms.jointAndSeveralLiability(), terms.otherTerms());
terms.jointAndSeveralLiability(),
terms.otherTerms() == null ? null : EncryptedGroupResponse.from(terms.otherTerms()));
}
}
@@ -296,10 +343,9 @@ final class LeaseApi {
}
}
record TenantFacts(String id, String firstName, String lastName, String email, String phone, String address) {
record TenantFacts(String id, TenantProfile profile) {
static TenantFacts from(LeaseSnapshot.Tenant tenant) {
return new TenantFacts(tenant.id().value(), tenant.firstName(), tenant.lastName(), tenant.email(),
tenant.phone(), tenant.address());
return new TenantFacts(tenant.id().value(), tenant.profile());
}
}
@@ -338,15 +384,20 @@ final class LeaseApi {
}
}
record Response(String id, String apartmentId, List<String> tenantIds, List<LeaseGuaranteeResponse> guarantees,
VisaleGuaranteeResponse visaleGuarantee, VisaleVisaResponse visaleVisa, LocalDate startDate,
LocalDate endDate, BigDecimal monthlyRent, BigDecimal monthlyCharges, BigDecimal securityDeposit,
int dueDay, ChargeMethod chargeMethod, boolean indexFlatRate, LeaseType leaseType,
List<SpecialClauseResponse> specialClauses, LegalTermsResponse legalTerms, String status,
ContractVersion contract, List<AmendmentResponse> amendments, long version) {
record Response(String id, String landlordId, String apartmentId, List<String> tenantIds,
List<LeaseGuaranteeResponse> guarantees, VisaleGuaranteeResponse visaleGuarantee,
VisaleVisaResponse visaleVisa, LocalDate startDate, LocalDate endDate, BigDecimal monthlyRent,
BigDecimal monthlyCharges, BigDecimal securityDeposit, int dueDay, ChargeMethod chargeMethod,
boolean indexFlatRate, LeaseType leaseType, List<SpecialClauseResponse> specialClauses,
LegalTermsResponse legalTerms, String status, ContractVersion contract, List<AmendmentResponse> amendments,
long version, String protectionStatus) {
static Response from(Lease lease, LocalDate date) {
return from(lease, date, ApplicationVaultAccess.ProtectionStatus.TEMPORARILY_LOCKED);
}
static Response from(Lease lease, LocalDate date, ApplicationVaultAccess.ProtectionStatus protectionStatus) {
if (lease.status() == LeaseStatus.DRAFT) {
return new Response(lease.id().value(), lease.apartmentId().value(),
return new Response(lease.id().value(), lease.landlordId().value(), lease.apartmentId().value(),
lease.tenantIds().stream().map(id -> id.value()).toList(),
lease.guarantees().stream().map(LeaseGuaranteeResponse::from).toList(),
VisaleGuaranteeResponse.from(lease.visaleGuarantee()),
@@ -356,10 +407,11 @@ final class LeaseApi {
lease.terms().indexFlatRate(), lease.leaseType(),
lease.specialClauses().stream().map(SpecialClauseResponse::from).toList(),
LegalTermsResponse.from(lease.legalTerms()), lease.status().name(), null,
lease.amendments().stream().map(AmendmentResponse::from).toList(), lease.version());
lease.amendments().stream().map(AmendmentResponse::from).toList(), lease.version(),
Objects.requireNonNull(protectionStatus).wireValue());
}
LeaseContractVersion effective = lease.contractVersionOn(date);
return new Response(lease.id().value(), effective.apartmentId().value(),
return new Response(lease.id().value(), lease.landlordId().value(), effective.apartmentId().value(),
effective.tenantIds().stream().map(id -> id.value()).toList(),
effective.guarantees().stream().map(LeaseGuaranteeResponse::from).toList(),
VisaleGuaranteeResponse.from(effective.visaleGuarantee()),
@@ -370,7 +422,7 @@ final class LeaseApi {
effective.specialClauses().stream().map(SpecialClauseResponse::from).toList(),
LegalTermsResponse.from(effective.legalTerms()), lease.status().name(),
ContractVersion.from(effective), lease.amendments().stream().map(AmendmentResponse::from).toList(),
lease.version());
lease.version(), Objects.requireNonNull(protectionStatus).wireValue());
}
}
@@ -378,12 +430,28 @@ final class LeaseApi {
}
record RevisionAnnexResponse(String category, String documentType, String sourceId, String version, String fileName,
String sha256, LocalDate establishedOn, LocalDate expiresOn, Instant sourceUploadedAt) {
String sha256, LocalDate establishedOn, LocalDate expiresOn, Instant sourceUploadedAt,
Integer cryptoVersion, String nonce, String wrappedKey, String vaultPublicKey) {
static RevisionAnnexResponse from(LeaseDocumentAnnex annex) {
return new RevisionAnnexResponse(annex.category().name(),
annex.documentType() == null ? null : annex.documentType().code(), annex.sourceId(),
annex.version(), annex.fileName(), annex.sha256(), annex.establishedOn(), annex.expiresOn(),
annex.sourceUploadedAt());
annex.sourceUploadedAt(), annex.encryption() == null ? null : annex.encryption().cryptoVersion(),
annex.encryption() == null ? null : annex.encryption().nonce(),
annex.encryption() == null ? null : annex.encryption().wrappedKey(),
annex.encryption() == null ? null : annex.encryption().vaultPublicKey());
}
}
record RevisionAnnexDownloadResponse(String category, String documentType, String sourceId, String version,
String fileName, String sha256, LocalDate establishedOn, LocalDate expiresOn, Instant sourceUploadedAt,
Integer cryptoVersion, String nonce, String wrappedKey, String vaultPublicKey, String ciphertext) {
static RevisionAnnexDownloadResponse from(GenerateLeaseDocumentService.AnnexDownload download) {
return new RevisionAnnexDownloadResponse(download.category(), download.documentType(), download.sourceId(),
download.version(), download.fileName(), download.sha256(), download.establishedOn(),
download.expiresOn(), download.sourceUploadedAt(), download.cryptoVersion(), download.nonce(),
download.wrappedKey(), download.vaultPublicKey(),
Base64.getUrlEncoder().withoutPadding().encodeToString(download.ciphertext()));
}
}
@@ -414,7 +482,8 @@ final class LeaseApi {
record RevisionResponse(int number, String templateVersion, String sourceFingerprint, String sha256,
Instant generatedAt, String authorId, RevisionInventoryResponse furnitureInventory,
List<RevisionPrivateAreaResponse> privateAreas, List<RevisionAnnexResponse> annexes,
List<String> warningCodes, List<RevisionWarningResponse> warnings) {
List<String> warningCodes, List<RevisionWarningResponse> warnings, String fileName, int cryptoVersion,
String nonce, String wrappedKey, String vaultPublicKey) {
static RevisionResponse from(LeaseDocumentRevision revision) {
return new RevisionResponse(revision.number(), revision.templateVersion(), revision.sourceFingerprint(),
revision.sha256(), revision.generatedAt(), revision.authorId(),
@@ -422,7 +491,71 @@ final class LeaseApi {
revision.privateAreas().stream().map(RevisionPrivateAreaResponse::from).toList(),
revision.annexes().stream().map(RevisionAnnexResponse::from).toList(),
revision.warnings().stream().map(LeaseDocumentWarning::code).toList(),
revision.warnings().stream().map(RevisionWarningResponse::from).toList());
revision.warnings().stream().map(RevisionWarningResponse::from).toList(), revision.fileName(),
revision.cryptoVersion(), revision.nonce(), revision.wrappedKey(), revision.vaultPublicKey());
}
}
record EncryptedDocumentRequest(@NotBlank String fileName, @Positive int cryptoVersion, @NotBlank String nonce,
@NotBlank String wrappedKey, @NotBlank String sha256, @NotBlank String ciphertext,
@NotBlank String vaultPublicKey) {
LeaseDocumentEncryption toApplication() {
return new LeaseDocumentEncryption(fileName, cryptoVersion, nonce, wrappedKey, sha256, ciphertext,
vaultPublicKey);
}
}
record CommitRevisionRequest(@NotNull @PositiveOrZero Long version, @NotBlank String sourceFingerprint,
@NotNull @Valid EncryptedDocumentRequest document) {
}
record RevisionPreparationResponse(String landlordId, String leaseId, long leaseVersion, int number,
String templateVersion, String sourceFingerprint, DocumentFacts document,
List<RevisionAnnexResponse> annexes, List<RevisionWarningResponse> warnings) {
static RevisionPreparationResponse from(GenerateLeaseDocumentService.RevisionPreparation preparation) {
return new RevisionPreparationResponse(preparation.landlordId(), preparation.leaseId(),
preparation.leaseVersion(), preparation.number(), preparation.templateVersion(),
preparation.sourceFingerprint(), DocumentFacts.from(preparation.document()),
preparation.annexes().stream()
.map(value -> new RevisionAnnexResponse(value.category(), value.documentType(),
value.sourceId(), value.version(), value.fileName(), value.sha256(),
value.establishedOn(), value.expiresOn(), value.sourceUploadedAt(),
value.encryption() == null ? null : value.encryption().cryptoVersion(),
value.encryption() == null ? null : value.encryption().nonce(),
value.encryption() == null ? null : value.encryption().wrappedKey(),
value.encryption() == null ? null : value.encryption().vaultPublicKey()))
.toList(),
preparation.warnings().stream().<RevisionWarningResponse>map(
value -> new RevisionWarningResponse(value.code(), value.ruleVersion())).toList());
}
}
record DocumentFacts(String leaseId, LandlordFacts landlord, DocumentApartmentFacts apartment,
List<TenantFacts> tenants, List<LeaseGuaranteeResponse> guarantees, LocalDate startDate, LocalDate endDate,
BigDecimal monthlyRent, BigDecimal monthlyCharges, BigDecimal securityDeposit, int dueDay,
ChargeMethod chargeMethod, boolean indexFlatRate, LeaseType leaseType,
List<SpecialClauseResponse> specialClauses, LegalTermsResponse legalTerms, String templateVersion,
int revisionNumber, RevisionInventoryResponse furnitureInventory) {
static DocumentFacts from(RentalDocumentRenderer.LeaseDocument document) {
return new DocumentFacts(document.leaseId().value(), LandlordFacts.from(document.landlord()),
DocumentApartmentFacts.from(document.apartment()),
document.tenants().stream().map(TenantFacts::from).toList(),
document.guarantees().stream().map(LeaseGuaranteeResponse::from).toList(),
document.period().startDate(), document.period().endDate(), document.terms().monthlyRent(),
document.terms().monthlyCharges(), document.terms().securityDeposit(), document.terms().dueDay(),
document.terms().chargeMethod(), document.terms().indexFlatRate(), document.leaseType(),
document.specialClauses().stream().map(SpecialClauseResponse::from).toList(),
LegalTermsResponse.from(document.legalTerms()), document.templateVersion(),
document.revisionNumber(), RevisionInventoryResponse.from(document.furnitureInventory()));
}
}
record DocumentApartmentFacts(String id, String name, String address, LegalProfileFacts legalProfile,
List<RevisionPrivateAreaResponse> privateAreas) {
static DocumentApartmentFacts from(LeaseSnapshot.Apartment apartment) {
return new DocumentApartmentFacts(apartment.id().value(), apartment.name(), apartment.address(),
LegalProfileFacts.from(apartment.legalProfile()),
apartment.privateAreas().stream().map(RevisionPrivateAreaResponse::from).toList());
}
}
@@ -431,4 +564,51 @@ final class LeaseApi {
return new RevisionWarningResponse(warning.code(), warning.ruleVersion());
}
}
record EncryptedDocumentResponse(String landlordId, String leaseId, int revisionNumber, String sourceFingerprint,
String fileName, int cryptoVersion, String nonce, String wrappedKey, String sha256, String vaultPublicKey,
String ciphertext) {
static EncryptedDocumentResponse from(GenerateLeaseDocumentService.EncryptedDocumentDownload document) {
return new EncryptedDocumentResponse(document.landlordId(), document.leaseId(), document.revisionNumber(),
document.sourceFingerprint(), document.fileName(), document.cryptoVersion(), document.nonce(),
document.wrappedKey(), document.sha256(), document.vaultPublicKey(),
Base64.getUrlEncoder().withoutPadding().encodeToString(document.ciphertext()));
}
}
record FinalDossierResponse(String fileName, EncryptedDocumentResponse signedDocument, int revisionNumber,
String sourceFingerprint, List<RevisionAnnexResponse> annexes, List<FinalDossierPhotoResponse> photos,
List<FinalDossierDeedResponse> deeds) {
static FinalDossierResponse from(GenerateLeaseDocumentService.FinalDossierDescriptor dossier) {
return new FinalDossierResponse(dossier.fileName(),
EncryptedDocumentResponse.from(dossier.signedDocument()), dossier.revisionNumber(),
dossier.sourceFingerprint(),
dossier.annexes().stream()
.map(value -> new RevisionAnnexResponse(value.category(), value.documentType(),
value.sourceId(), value.version(), value.fileName(), value.sha256(),
value.establishedOn(), value.expiresOn(), value.sourceUploadedAt(),
value.encryption() == null ? null : value.encryption().cryptoVersion(),
value.encryption() == null ? null : value.encryption().nonce(),
value.encryption() == null ? null : value.encryption().wrappedKey(),
value.encryption() == null ? null : value.encryption().vaultPublicKey()))
.toList(),
dossier.photos().stream()
.map(value -> new FinalDossierPhotoResponse(value.reference(), value.sha256(),
value.targetKind(), value.privateAreaId(), value.subjectId()))
.toList(),
dossier.deeds().stream()
.map(value -> new FinalDossierDeedResponse(value.guaranteeId(), value.tenantId(),
value.fileName(), value.sha256(), value.cryptoVersion(), value.nonce(),
value.wrappedKey(), value.vaultPublicKey()))
.toList());
}
}
record FinalDossierPhotoResponse(String reference, String sha256, String targetKind, String privateAreaId,
String subjectId) {
}
record FinalDossierDeedResponse(String guaranteeId, String tenantId, String fileName, String sha256,
int cryptoVersion, String nonce, String wrappedKey, String vaultPublicKey) {
}
}
@@ -22,9 +22,10 @@ public class LeaseClauseController {
return catalog.available(context).stream().map(ClauseResponse::from).toList();
}
record ClauseResponse(String id, String label, String description, List<ParameterResponse> parameters) {
record ClauseResponse(String id, String label, String description, String template,
List<ParameterResponse> parameters) {
static ClauseResponse from(LeaseClauseCatalog.ClauseDefinition clause) {
return new ClauseResponse(clause.id(), clause.label(), clause.description(),
return new ClauseResponse(clause.id(), clause.label(), clause.description(), clause.template(),
clause.parameters().stream().map(ParameterResponse::from).toList());
}
}
@@ -5,15 +5,15 @@ import fr.nessar.lmnporganizer.rental.application.lease.AmendLeaseService;
import fr.nessar.lmnporganizer.rental.application.lease.GenerateLeaseDocumentService;
import fr.nessar.lmnporganizer.rental.application.lease.LeaseClauseCatalog;
import fr.nessar.lmnporganizer.rental.application.lease.LeaseDraftService;
import fr.nessar.lmnporganizer.rental.application.shared.GeneratedDocument;
import fr.nessar.lmnporganizer.rental.application.rentalapplication.ApplicationVaultAccess;
import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentId;
import fr.nessar.lmnporganizer.rental.domain.lease.ChargeMethod;
import fr.nessar.lmnporganizer.rental.domain.lease.Lease;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseGuarantee;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseId;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseStatus;
import fr.nessar.lmnporganizer.rental.domain.lease.RentTerms;
import fr.nessar.lmnporganizer.rental.domain.lease.RentalPeriod;
import fr.nessar.lmnporganizer.rental.domain.shared.PhoneNumber;
import fr.nessar.lmnporganizer.rental.domain.tenant.TenantId;
import jakarta.validation.Valid;
import jakarta.validation.constraints.PositiveOrZero;
@@ -22,9 +22,9 @@ import java.time.DateTimeException;
import java.time.LocalDate;
import java.time.ZoneId;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import org.springframework.http.ContentDisposition;
import org.springframework.http.HttpHeaders;
import java.util.stream.Collectors;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
@@ -49,16 +49,18 @@ public class LeaseController {
private final AuthenticatedLandlord authenticatedLandlord;
private final Clock clock;
private final LeaseNestedRequestValidator nestedValidator;
private final ApplicationVaultAccess vaultAccess;
public LeaseController(LeaseDraftService drafts, AmendLeaseService amendments,
GenerateLeaseDocumentService documents, AuthenticatedLandlord authenticatedLandlord, Clock clock,
LeaseClauseCatalog clauseCatalog) {
LeaseClauseCatalog clauseCatalog, ApplicationVaultAccess vaultAccess) {
this.drafts = drafts;
this.amendments = amendments;
this.documents = documents;
this.authenticatedLandlord = authenticatedLandlord;
this.clock = clock;
this.nestedValidator = new LeaseNestedRequestValidator(clauseCatalog);
this.vaultAccess = vaultAccess;
}
@GetMapping
@@ -82,6 +84,8 @@ public class LeaseController {
@PutMapping("/{leaseId}")
LeaseApi.Response revise(@AuthenticationPrincipal OidcUser principal, @PathVariable String leaseId,
@RequestParam @PositiveOrZero long version, @Valid @RequestBody LeaseApi.DraftRequest request) {
if (!leaseId.equals(request.id()))
throw new LeaseRequestValidationFailure("id", "must_match_path");
nestedValidator.validate(request);
return response(
drafts.revise(authenticatedLandlord.id(principal), new LeaseId(leaseId), version, details(request)));
@@ -116,10 +120,18 @@ public class LeaseController {
return ResponseEntity.badRequest().body(new LeaseRequestValidationError("validation_failed", failure.errors()));
}
@PostMapping("/{leaseId}/revisions/prepare")
LeaseApi.RevisionPreparationResponse prepareRevision(@AuthenticationPrincipal OidcUser principal,
@PathVariable String leaseId, @RequestParam @PositiveOrZero long version) {
return LeaseApi.RevisionPreparationResponse.from(documents.prepareRevision(authenticatedLandlord.id(principal),
new LeaseId(leaseId), version, principal.getSubject()));
}
@PostMapping("/{leaseId}/revisions")
ResponseEntity<LeaseApi.RevisionResponse> generateRevision(@AuthenticationPrincipal OidcUser principal,
@PathVariable String leaseId) {
var revision = documents.generateRevision(authenticatedLandlord.id(principal), new LeaseId(leaseId),
ResponseEntity<LeaseApi.RevisionResponse> commitRevision(@AuthenticationPrincipal OidcUser principal,
@PathVariable String leaseId, @Valid @RequestBody LeaseApi.CommitRevisionRequest request) {
var revision = documents.commitRevision(authenticatedLandlord.id(principal), new LeaseId(leaseId),
request.version(), request.sourceFingerprint(), request.document().toApplication(),
principal.getSubject());
return ResponseEntity.status(HttpStatus.CREATED).body(LeaseApi.RevisionResponse.from(revision));
}
@@ -131,45 +143,47 @@ public class LeaseController {
.from(documents.current(authenticatedLandlord.id(principal), new LeaseId(leaseId)));
}
@GetMapping("/{leaseId}/revisions/{revisionNumber}/pdf")
ResponseEntity<byte[]> revisionDocument(@AuthenticationPrincipal OidcUser principal, @PathVariable String leaseId,
@PathVariable int revisionNumber) {
return document(documents.download(authenticatedLandlord.id(principal), new LeaseId(leaseId), revisionNumber));
@GetMapping("/{leaseId}/revisions/{revisionNumber}")
LeaseApi.EncryptedDocumentResponse revisionDocument(@AuthenticationPrincipal OidcUser principal,
@PathVariable String leaseId, @PathVariable int revisionNumber) {
return LeaseApi.EncryptedDocumentResponse
.from(documents.revision(authenticatedLandlord.id(principal), new LeaseId(leaseId), revisionNumber));
}
@GetMapping("/{leaseId}/revisions/{revisionNumber}/zip")
ResponseEntity<byte[]> revisionDossier(@AuthenticationPrincipal OidcUser principal, @PathVariable String leaseId,
@PathVariable int revisionNumber) {
return document(
documents.downloadDossier(authenticatedLandlord.id(principal), new LeaseId(leaseId), revisionNumber));
}
@GetMapping("/{leaseId}/revisions/current/zip")
ResponseEntity<byte[]> currentRevisionDossier(@AuthenticationPrincipal OidcUser principal,
@PathVariable String leaseId) {
return document(documents.downloadCurrentDossier(authenticatedLandlord.id(principal), new LeaseId(leaseId)));
@GetMapping("/{leaseId}/revisions/{revisionNumber}/annexes/{index}")
LeaseApi.RevisionAnnexDownloadResponse revisionAnnex(@AuthenticationPrincipal OidcUser principal,
@PathVariable String leaseId, @PathVariable int revisionNumber, @PathVariable int index) {
return LeaseApi.RevisionAnnexDownloadResponse.from(documents.downloadAnnex(authenticatedLandlord.id(principal),
new LeaseId(leaseId), revisionNumber, index));
}
@GetMapping("/{leaseId}/final-dossier")
ResponseEntity<byte[]> finalDossier(@AuthenticationPrincipal OidcUser principal, @PathVariable String leaseId) {
return document(documents.downloadFinalDossier(authenticatedLandlord.id(principal), new LeaseId(leaseId)));
LeaseApi.FinalDossierResponse finalDossier(@AuthenticationPrincipal OidcUser principal,
@PathVariable String leaseId) {
return LeaseApi.FinalDossierResponse
.from(documents.finalDossier(authenticatedLandlord.id(principal), new LeaseId(leaseId)));
}
private static LeaseDraftService.DraftDetails details(LeaseApi.DraftRequest request) {
return new LeaseDraftService.DraftDetails(new ApartmentId(request.apartmentId()),
tenantIds(request.tenantIds()), guarantees(request.guarantees()),
new RentalPeriod(request.startDate(), request.startDate()),
return new LeaseDraftService.DraftDetails(request.id(), request.vaultPublicKey(),
new ApartmentId(request.apartmentId()), tenantIds(request.tenantIds()),
guarantees(request.guarantees()), new RentalPeriod(request.startDate(), request.startDate()),
new RentTerms(request.monthlyRent(), request.monthlyCharges(), request.securityDeposit(),
request.dueDay() == null ? RentTerms.DEFAULT_DUE_DAY : request.dueDay(),
request.chargeMethod() == null ? ChargeMethod.FLAT_RATE : request.chargeMethod(),
Boolean.TRUE.equals(request.indexFlatRate())),
request.leaseType(), specialClauses(request.specialClauses()),
request.legalTerms() == null ? null : request.legalTerms().toDomain(),
request.leaseType(), specialClauses(request.specialClauses()), request.legalTerms().toDomain(),
request.visaleGuarantee() == null ? null : request.visaleGuarantee().toDomain());
}
private LeaseApi.Response response(Lease lease) {
return LeaseApi.Response.from(lease, LocalDate.now(clock));
LocalDate today = LocalDate.now(clock);
String vaultPublicKey = lease.status() == LeaseStatus.DRAFT
? lease.legalTerms().vaultPublicKey()
: lease.contractVersionOn(today).legalTerms().vaultPublicKey();
if (vaultPublicKey == null || vaultPublicKey.isBlank())
throw new IllegalStateException("Lease vault public key is required.");
return LeaseApi.Response.from(lease, today, vaultAccess.status(lease.landlordId(), vaultPublicKey));
}
private static ZoneId zone(String value) {
@@ -189,24 +203,22 @@ public class LeaseController {
? List.of()
: requests.stream().map(request -> new LeaseGuarantee(
request.id() == null || request.id().isBlank() ? UUID.randomUUID().toString() : request.id(),
new TenantId(request.tenantId()), request.type(), request.firstName(), request.lastName(),
request.email(), new PhoneNumber(request.phone()).value(), request.address(),
request.organizationName(), request.organizationAddress(), request.representativeName(),
request.registrationId())).toList();
new TenantId(request.tenantId()), request.type(), request.profile().toDomain())).toList();
}
private static List<LeaseDraftService.ClauseDetails> specialClauses(List<LeaseApi.SpecialClauseRequest> requests) {
return requests == null
? null
: requests.stream().map(request -> new LeaseDraftService.ClauseDetails(request.catalogId(),
request.parameters(), request.customText())).toList();
}
private static ResponseEntity<byte[]> document(GeneratedDocument document) {
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION,
ContentDisposition.attachment().filename(document.fileName()).build().toString())
.header(HttpHeaders.CONTENT_TYPE, document.contentType()).body(document.content());
? List.of()
: requests.stream()
.map(request -> new LeaseDraftService.ClauseDetails(request.id(), request.vaultPublicKey(),
request.catalogId(), request.parameters(),
request.encryptedParameters() == null
? Map.of()
: request.encryptedParameters().entrySet().stream()
.collect(Collectors.toUnmodifiableMap(Map.Entry::getKey,
entry -> entry.getValue().toDomain())),
request.renderedText().toDomain()))
.toList();
}
private record LeaseRequestValidationError(String code, List<LeaseRequestFieldError> errors) {
@@ -1,6 +1,7 @@
package fr.nessar.lmnporganizer.rental.adapter.in.web.lease;
import fr.nessar.lmnporganizer.rental.application.lease.LeaseEndingState;
import fr.nessar.lmnporganizer.rental.application.lease.LeaseEndingService;
import fr.nessar.lmnporganizer.rental.domain.lease.AgreedEarlyLeaseEnd;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseClosure;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseEndPhase;
@@ -12,8 +13,11 @@ import fr.nessar.lmnporganizer.rental.domain.lease.NoticeInvalidation;
import fr.nessar.lmnporganizer.rental.domain.lease.NoticeWithdrawal;
import fr.nessar.lmnporganizer.rental.domain.lease.TenantNotice;
import fr.nessar.lmnporganizer.rental.domain.lease.TenantNoticeStatus;
import fr.nessar.lmnporganizer.rental.application.rentalapplication.ApplicationVaultAccess;
import fr.nessar.lmnporganizer.rental.domain.tenant.TenantProfile.EncryptedGroup;
import java.time.Instant;
import java.time.LocalDate;
import java.util.Base64;
import java.util.List;
final class LeaseEndingApi {
@@ -29,22 +33,33 @@ final class LeaseEndingApi {
record FieldError(String field, String code) {
}
record StateResponse(String leaseId, boolean supported, String unsupportedReason, LeaseEndPhase phase,
LeaseType leaseType, LocalDate leaseStartOn, LocalDate fixedEndOn, LocalDate effectiveEndOn,
long maxEvidenceBytes, boolean correctionRequired, List<NoticeResponse> notices,
record StateResponse(String landlordId, String leaseId, boolean supported, String unsupportedReason,
LeaseEndPhase phase, LeaseType leaseType, LocalDate leaseStartOn, LocalDate fixedEndOn,
LocalDate effectiveEndOn, long maxEvidenceBytes, boolean correctionRequired, long leaseVersion,
String vaultPublicKey, String protectionStatus, List<NoticeResponse> notices,
AgreedEarlyEndResponse agreedEarlyEnd, List<AgreedEarlyEndResponse> agreedEarlyEnds,
ExitConditionReportResponse exitConditionReport, ClosureResponse closure) {
static StateResponse from(LeaseEndingState state) {
static StateResponse from(LeaseEndingState state, String landlordId) {
LeaseEndingHistory history = state.history();
return new StateResponse(state.leaseId().value(), state.supported(), state.unsupportedReason().orElse(null),
state.phase(), state.leaseType(), state.leaseStartOn(), state.fixedEndOn().orElse(null),
state.effectiveEndOn().orElse(null), state.maxEvidenceBytes(), state.correctionRequired(),
return new StateResponse(landlordId, state.leaseId().value(), state.supported(),
state.unsupportedReason().orElse(null), state.phase(), state.leaseType(), state.leaseStartOn(),
state.fixedEndOn().orElse(null), state.effectiveEndOn().orElse(null), state.maxEvidenceBytes(),
state.correctionRequired(), state.leaseVersion(), state.history().vaultPublicKey(),
wireProtectionStatus(state.vaultProtectionStatus()),
history.notices().stream().map(notice -> NoticeResponse.from(notice, history)).toList(),
history.latestAgreement().map(AgreedEarlyEndResponse::from).orElse(null),
history.agreedEarlyEnds().stream().map(AgreedEarlyEndResponse::from).toList(),
state.exitConditionReport().map(ExitConditionReportResponse::from).orElse(null),
history.closure().map(ClosureResponse::from).orElse(null));
}
private static String wireProtectionStatus(ApplicationVaultAccess.ProtectionStatus status) {
return switch (status) {
case TEMPORARILY_LOCKED -> "locked";
case PERMANENTLY_LOST -> "lost-vault";
case RESETTING -> "resetting";
};
}
}
record ExitConditionReportResponse(LocalDate occurredOn, boolean finalized) {
@@ -54,50 +69,70 @@ final class LeaseEndingApi {
}
record NoticeResponse(String id, TenantNoticeStatus status, LocalDate receivedOn, LocalDate requestedDepartureOn,
NoticeDeliveryMode deliveryMode, String otherDeliveryMode, int noticeMonths, LocalDate minimumEndOn,
String ruleVersion, boolean recordedWithoutEvidenceConfirmed, String authorId, Instant recordedAt,
List<EvidenceResponse> evidenceVersions, WithdrawalResponse withdrawal, InvalidationResponse invalidation) {
NoticeDeliveryMode deliveryMode, EncryptedGroupResponse otherDeliveryMode, int noticeMonths,
LocalDate minimumEndOn, String ruleVersion, boolean recordedWithoutEvidenceConfirmed, String authorId,
Instant recordedAt, List<EvidenceResponse> evidenceVersions, WithdrawalResponse withdrawal,
InvalidationResponse invalidation) {
static NoticeResponse from(TenantNotice notice, LeaseEndingHistory history) {
NoticeWithdrawal withdrawal = history.withdrawals().stream()
.filter(fact -> fact.noticeId().equals(notice.id())).findFirst().orElse(null);
NoticeInvalidation invalidation = history.invalidations().stream()
.filter(fact -> fact.noticeId().equals(notice.id())).findFirst().orElse(null);
return new NoticeResponse(notice.id().value(), notice.status(), notice.receivedOn(),
notice.requestedDepartureOn(), notice.deliveryMode(), notice.otherDeliveryMode(),
notice.noticeMonths(), notice.minimumEndOn(), notice.ruleVersion(),
notice.recordedWithoutEvidenceConfirmed(), notice.audit().authorId(), notice.audit().recordedAt(),
notice.requestedDepartureOn(), notice.deliveryMode(),
EncryptedGroupResponse.from(notice.otherDeliveryMode()), notice.noticeMonths(),
notice.minimumEndOn(), notice.ruleVersion(), notice.recordedWithoutEvidenceConfirmed(),
notice.audit().authorId(), notice.audit().recordedAt(),
history.evidenceVersions(notice.id()).stream().map(EvidenceResponse::from).toList(),
withdrawal == null ? null : WithdrawalResponse.from(withdrawal),
invalidation == null ? null : InvalidationResponse.from(invalidation));
}
}
record EvidenceResponse(int version, String fileName, String contentType, long size, String sha256, String reason,
String authorId, Instant recordedAt) {
record EvidenceResponse(int version, String evidenceId, int cryptoVersion, String nonce, String wrappedKey,
String sha256, long size, String vaultPublicKey, EncryptedGroupResponse reason, String authorId,
Instant recordedAt) {
static EvidenceResponse from(NoticeEvidenceVersion evidence) {
return new EvidenceResponse(evidence.version(), evidence.file().fileName(), evidence.file().contentType(),
evidence.file().size(), evidence.file().sha256(), evidence.reason(), evidence.audit().authorId(),
return new EvidenceResponse(evidence.version(), evidence.evidenceId(), evidence.file().cryptoVersion(),
evidence.file().nonce(), evidence.file().wrappedKey(), evidence.file().sha256(),
evidence.file().size(), evidence.file().vaultPublicKey(),
EncryptedGroupResponse.from(evidence.reason()), evidence.audit().authorId(),
evidence.audit().recordedAt());
}
}
record WithdrawalResponse(LocalDate acceptedOn, String reason, String authorId, Instant recordedAt) {
record EncryptedGroupResponse(int cryptoVersion, String nonce, String wrappedKey, String sha256,
String ciphertext) {
static EncryptedGroupResponse from(EncryptedGroup group) {
return group == null
? null
: new EncryptedGroupResponse(group.cryptoVersion(), group.nonce(), group.wrappedKey(),
group.sha256(), group.ciphertext());
}
}
record WithdrawalResponse(LocalDate acceptedOn, EncryptedGroupResponse reason, String authorId,
Instant recordedAt) {
static WithdrawalResponse from(NoticeWithdrawal withdrawal) {
return new WithdrawalResponse(withdrawal.acceptedOn(), withdrawal.reason(), withdrawal.audit().authorId(),
withdrawal.audit().recordedAt());
return new WithdrawalResponse(withdrawal.acceptedOn(), EncryptedGroupResponse.from(withdrawal.reason()),
withdrawal.audit().authorId(), withdrawal.audit().recordedAt());
}
}
record InvalidationResponse(LocalDate invalidatedOn, String reason, String authorId, Instant recordedAt) {
record InvalidationResponse(LocalDate invalidatedOn, EncryptedGroupResponse reason, String authorId,
Instant recordedAt) {
static InvalidationResponse from(NoticeInvalidation invalidation) {
return new InvalidationResponse(invalidation.invalidatedOn(), invalidation.reason(),
invalidation.audit().authorId(), invalidation.audit().recordedAt());
return new InvalidationResponse(invalidation.invalidatedOn(),
EncryptedGroupResponse.from(invalidation.reason()), invalidation.audit().authorId(),
invalidation.audit().recordedAt());
}
}
record AgreedEarlyEndResponse(LocalDate endOn, String reason, String authorId, Instant recordedAt) {
record AgreedEarlyEndResponse(String agreementId, LocalDate endOn, EncryptedGroupResponse reason, String authorId,
Instant recordedAt) {
static AgreedEarlyEndResponse from(AgreedEarlyLeaseEnd agreement) {
return new AgreedEarlyEndResponse(agreement.endOn(), agreement.reason(), agreement.audit().authorId(),
return new AgreedEarlyEndResponse(agreement.agreementId(), agreement.endOn(),
EncryptedGroupResponse.from(agreement.reason()), agreement.audit().authorId(),
agreement.audit().recordedAt());
}
}
@@ -108,4 +143,13 @@ final class LeaseEndingApi {
closure.audit().recordedAt());
}
}
record EvidenceDownloadResponse(String evidenceId, int cryptoVersion, String nonce, String wrappedKey,
String sha256, String vaultPublicKey, String ciphertext) {
static EvidenceDownloadResponse from(LeaseEndingService.EvidenceDownload download) {
return new EvidenceDownloadResponse(download.evidenceId(), download.cryptoVersion(), download.nonce(),
download.wrappedKey(), download.sha256(), download.vaultPublicKey(),
Base64.getUrlEncoder().withoutPadding().encodeToString(download.content()));
}
}
}
@@ -9,8 +9,11 @@ import fr.nessar.lmnporganizer.rental.domain.lease.LeaseEndingValidationFailure;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseId;
import fr.nessar.lmnporganizer.rental.domain.lease.NoticeDeliveryMode;
import fr.nessar.lmnporganizer.rental.domain.lease.TenantNoticeId;
import fr.nessar.lmnporganizer.rental.domain.tenant.TenantProfile.EncryptedGroup;
import jakarta.validation.Valid;
import jakarta.validation.constraints.AssertTrue;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
@@ -19,8 +22,6 @@ import java.time.DateTimeException;
import java.time.LocalDate;
import java.time.ZoneId;
import java.util.List;
import org.springframework.http.ContentDisposition;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
@@ -71,87 +72,108 @@ public class LeaseEndingController {
@GetMapping
LeaseEndingApi.StateResponse state(@AuthenticationPrincipal OidcUser principal, @PathVariable String leaseId,
@RequestParam String timeZone) {
return LeaseEndingApi.StateResponse
.from(endings.state(authenticatedLandlord.id(principal), new LeaseId(leaseId), zone(timeZone)));
return LeaseEndingApi.StateResponse.from(
endings.state(authenticatedLandlord.id(principal), new LeaseId(leaseId), zone(timeZone)),
authenticatedLandlord.id(principal).value());
}
@PostMapping(path = "/notices", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
ResponseEntity<LeaseEndingApi.StateResponse> recordNotice(@AuthenticationPrincipal OidcUser principal,
@PathVariable String leaseId, @RequestParam String timeZone, @RequestParam LocalDate receivedOn,
@RequestParam LocalDate requestedDepartureOn, @RequestParam NoticeDeliveryMode deliveryMode,
@RequestParam(required = false) String otherDeliveryMode, @RequestParam boolean lateRegistrationConfirmed,
@RequestParam boolean nonStandardDeliveryConfirmed, @RequestParam boolean missingEvidenceConfirmed,
@RequestPart(value = "evidence", required = false) MultipartFile evidence) {
@PathVariable String leaseId, @Valid @RequestPart("metadata") NoticeRequest request,
@RequestPart(value = "ciphertext", required = false) MultipartFile ciphertext) {
var landlord = authenticatedLandlord.id(principal);
var state = endings.recordNotice(authenticatedLandlord.id(principal), new LeaseId(leaseId),
principal.getSubject(),
new LeaseEndingService.RecordNotice(zone(timeZone), receivedOn, requestedDepartureOn, deliveryMode,
otherDeliveryMode, lateRegistrationConfirmed, nonStandardDeliveryConfirmed,
missingEvidenceConfirmed),
upload(evidence, timeZone, null));
return ResponseEntity.status(HttpStatus.CREATED).body(LeaseEndingApi.StateResponse.from(state));
new LeaseEndingService.RecordNotice(request.noticeId(), zone(request.timeZone()), request.receivedOn(),
request.requestedDepartureOn(), request.deliveryMode(),
request.otherDeliveryMode() == null ? null : request.otherDeliveryMode().toDomain(),
request.lateRegistrationConfirmed(), request.nonStandardDeliveryConfirmed(),
request.missingEvidenceConfirmed(), request.vaultPublicKey(), request.leaseVersion()),
upload(request.evidence(), ciphertext, zone(request.timeZone()), request.leaseVersion()));
return ResponseEntity.status(HttpStatus.CREATED)
.body(LeaseEndingApi.StateResponse.from(state, landlord.value()));
}
@PostMapping(path = "/notices/{noticeId}/evidence", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
LeaseEndingApi.StateResponse appendEvidence(@AuthenticationPrincipal OidcUser principal,
@PathVariable String leaseId, @PathVariable String noticeId, @RequestParam String timeZone,
@RequestPart("evidence") MultipartFile evidence, @RequestParam(required = false) String reason) {
return LeaseEndingApi.StateResponse
.from(endings.appendEvidence(authenticatedLandlord.id(principal), new LeaseId(leaseId),
new TenantNoticeId(noticeId), principal.getSubject(), upload(evidence, timeZone, reason)));
@PathVariable String leaseId, @PathVariable String noticeId,
@Valid @RequestPart("metadata") AppendEvidenceRequest request,
@RequestPart("ciphertext") MultipartFile ciphertext) {
var landlord = authenticatedLandlord.id(principal);
var state = endings.appendEvidence(landlord, new LeaseId(leaseId), new TenantNoticeId(noticeId),
principal.getSubject(),
upload(request.evidence(), ciphertext, zone(request.timeZone()), request.leaseVersion()));
return LeaseEndingApi.StateResponse.from(state, landlord.value());
}
@GetMapping("/notices/{noticeId}/evidence/{version}")
ResponseEntity<byte[]> evidence(@AuthenticationPrincipal OidcUser principal, @PathVariable String leaseId,
@PathVariable String noticeId, @PathVariable int version) {
LeaseEndingApi.EvidenceDownloadResponse evidence(@AuthenticationPrincipal OidcUser principal,
@PathVariable String leaseId, @PathVariable String noticeId, @PathVariable int version) {
var evidence = endings.evidence(authenticatedLandlord.id(principal), new LeaseId(leaseId),
new TenantNoticeId(noticeId), version);
return ResponseEntity.ok().header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_OCTET_STREAM_VALUE)
.header(HttpHeaders.CONTENT_DISPOSITION,
ContentDisposition.attachment().filename(safeFileName(evidence.fileName())).build().toString())
.header("X-Content-Type-Options", "nosniff").body(evidence.content());
return LeaseEndingApi.EvidenceDownloadResponse.from(evidence);
}
@PostMapping("/notices/{noticeId}/withdrawals")
LeaseEndingApi.StateResponse withdraw(@AuthenticationPrincipal OidcUser principal, @PathVariable String leaseId,
@PathVariable String noticeId, @Valid @RequestBody WithdrawalRequest request) {
return LeaseEndingApi.StateResponse
.from(endings.acceptWithdrawal(authenticatedLandlord.id(principal), new LeaseId(leaseId),
new TenantNoticeId(noticeId), principal.getSubject(), new LeaseEndingService.AdministrativeFact(
zone(request.timeZone()), request.acceptedOn(), request.reason())));
var landlord = authenticatedLandlord.id(principal);
return LeaseEndingApi.StateResponse.from(
endings.acceptWithdrawal(landlord, new LeaseId(leaseId), new TenantNoticeId(noticeId),
principal.getSubject(),
new LeaseEndingService.AdministrativeFact(zone(request.timeZone()), request.acceptedOn(),
request.reason().toDomain(), request.vaultPublicKey(), request.leaseVersion())),
landlord.value());
}
@PostMapping("/notices/{noticeId}/invalidations")
LeaseEndingApi.StateResponse invalidate(@AuthenticationPrincipal OidcUser principal, @PathVariable String leaseId,
@PathVariable String noticeId, @Valid @RequestBody InvalidationRequest request) {
return LeaseEndingApi.StateResponse
.from(endings.invalidateNotice(authenticatedLandlord.id(principal), new LeaseId(leaseId),
new TenantNoticeId(noticeId), principal.getSubject(), new LeaseEndingService.AdministrativeFact(
zone(request.timeZone()), request.invalidatedOn(), request.reason())));
var landlord = authenticatedLandlord.id(principal);
return LeaseEndingApi.StateResponse.from(
endings.invalidateNotice(landlord, new LeaseId(leaseId), new TenantNoticeId(noticeId),
principal.getSubject(),
new LeaseEndingService.AdministrativeFact(zone(request.timeZone()), request.invalidatedOn(),
request.reason().toDomain(), request.vaultPublicKey(), request.leaseVersion())),
landlord.value());
}
@PostMapping("/agreed-early-end")
LeaseEndingApi.StateResponse agree(@AuthenticationPrincipal OidcUser principal, @PathVariable String leaseId,
@Valid @RequestBody AgreedEndRequest request) {
return LeaseEndingApi.StateResponse.from(endings.recordAgreedEarlyEnd(authenticatedLandlord.id(principal),
new LeaseId(leaseId), principal.getSubject(),
new LeaseEndingService.AgreedEnd(zone(request.timeZone()), request.endOn(), request.reason())));
var landlord = authenticatedLandlord.id(principal);
return LeaseEndingApi.StateResponse.from(endings.recordAgreedEarlyEnd(landlord, new LeaseId(leaseId),
principal.getSubject(),
new LeaseEndingService.AgreedEnd(zone(request.timeZone()), request.agreementId(), request.endOn(),
request.reason().toDomain(), request.vaultPublicKey(), request.leaseVersion())),
landlord.value());
}
@PostMapping("/closure")
LeaseEndingApi.StateResponse close(@AuthenticationPrincipal OidcUser principal, @PathVariable String leaseId,
@Valid @RequestBody ClosureRequest request) {
return LeaseEndingApi.StateResponse
.from(endings.close(authenticatedLandlord.id(principal), new LeaseId(leaseId), principal.getSubject(),
new LeaseEndingService.Closure(zone(request.timeZone()), request.confirmed())));
var landlord = authenticatedLandlord.id(principal);
return LeaseEndingApi.StateResponse.from(endings.close(landlord, new LeaseId(leaseId), principal.getSubject(),
new LeaseEndingService.Closure(zone(request.timeZone()), request.confirmed(), request.leaseVersion())),
landlord.value());
}
private static LeaseEndingService.EvidenceUpload upload(MultipartFile file, String timeZone, String reason) {
if (file == null) {
private static LeaseEndingService.EvidenceUpload upload(EvidenceRequest request, MultipartFile file,
ZoneId timeZone, long leaseVersion) {
if (request == null && file == null) {
return null;
}
if (request == null || file == null) {
throw new LeaseEndingRequestValidationFailure("evidence", "incomplete_envelope");
}
return new LeaseEndingService.EvidenceUpload(timeZone, request.evidenceId(), request.cryptoVersion(),
request.nonce(), request.wrappedKey(), request.sha256(), request.vaultPublicKey(), () -> read(file),
request.reason() == null ? null : request.reason().toDomain(), leaseVersion);
}
private static byte[] read(MultipartFile file) {
try {
return new LeaseEndingService.EvidenceUpload(zone(timeZone), file.getOriginalFilename(),
file.getContentType(), file.getBytes(), reason);
return file.getBytes();
} catch (IOException failure) {
throw new AttachmentStorageFailure("Evidence could not be opened.", failure);
}
@@ -198,32 +220,52 @@ public class LeaseEndingController {
return switch (rule) {
case MISSING_EVIDENCE_CONFIRMATION -> new LeaseEndingApi.FieldError("missingEvidenceConfirmed", "required");
case EMPTY_EVIDENCE -> new LeaseEndingApi.FieldError("evidence", "empty");
case INVALID_EVIDENCE_ENVELOPE -> new LeaseEndingApi.FieldError("evidence", "invalid_envelope");
};
}
private static String safeFileName(String value) {
String normalized = value == null ? "justificatif" : value.replace('\\', '/');
String leaf = normalized.substring(normalized.lastIndexOf('/') + 1).trim();
StringBuilder safe = new StringBuilder();
leaf.codePoints().filter(character -> !Character.isISOControl(character)).forEach(safe::appendCodePoint);
return safe.isEmpty() || safe.toString().equals(".") || safe.toString().equals("..")
? "justificatif"
: safe.toString();
record EncryptedGroupRequest(@NotNull @Min(1) @Max(1) int cryptoVersion, @NotBlank @Size(max = 32) String nonce,
@NotBlank @Size(max = 1400) String wrappedKey, @NotBlank @Size(max = 64) String sha256,
@NotBlank @Size(max = 44000) String ciphertext) {
EncryptedGroup toDomain() {
return new EncryptedGroup(cryptoVersion, nonce, wrappedKey, sha256, ciphertext);
}
}
record EvidenceRequest(@NotBlank @Size(max = 100) String evidenceId, @NotNull @Min(1) @Max(1) int cryptoVersion,
@NotBlank @Size(max = 32) String nonce, @NotBlank @Size(max = 1400) String wrappedKey,
@NotBlank @Size(max = 64) String sha256, @NotBlank @Size(max = 1400) String vaultPublicKey,
@Valid EncryptedGroupRequest reason) {
}
record NoticeRequest(@NotBlank @Size(max = 100) String noticeId, @NotBlank String timeZone,
@NotNull LocalDate receivedOn, @NotNull LocalDate requestedDepartureOn,
@NotNull NoticeDeliveryMode deliveryMode, @Valid EncryptedGroupRequest otherDeliveryMode,
boolean lateRegistrationConfirmed, boolean nonStandardDeliveryConfirmed, boolean missingEvidenceConfirmed,
@NotBlank @Size(max = 1400) String vaultPublicKey, @Min(-1) long leaseVersion,
@Valid EvidenceRequest evidence) {
}
record AppendEvidenceRequest(@NotBlank String timeZone, @NotNull @Valid EvidenceRequest evidence,
@Min(-1) long leaseVersion) {
}
record WithdrawalRequest(@NotBlank String timeZone, @NotNull LocalDate acceptedOn,
@NotBlank @Size(max = 500) String reason) {
@NotNull @Valid EncryptedGroupRequest reason, @NotBlank @Size(max = 1400) String vaultPublicKey,
@Min(-1) long leaseVersion) {
}
record InvalidationRequest(@NotBlank String timeZone, @NotNull LocalDate invalidatedOn,
@NotBlank @Size(max = 500) String reason) {
@NotNull @Valid EncryptedGroupRequest reason, @NotBlank @Size(max = 1400) String vaultPublicKey,
@Min(-1) long leaseVersion) {
}
record AgreedEndRequest(@NotBlank String timeZone, @NotNull LocalDate endOn,
@NotBlank @Size(max = 500) String reason) {
record AgreedEndRequest(@NotBlank String timeZone, @NotBlank @Size(max = 100) String agreementId,
@NotNull LocalDate endOn, @NotNull @Valid EncryptedGroupRequest reason,
@NotBlank @Size(max = 1400) String vaultPublicKey, @Min(-1) long leaseVersion) {
}
record ClosureRequest(@NotBlank String timeZone, @AssertTrue boolean confirmed) {
record ClosureRequest(@NotBlank String timeZone, @AssertTrue boolean confirmed, @Min(-1) long leaseVersion) {
}
private static final class LeaseEndingRequestValidationFailure extends RuntimeException {
@@ -2,6 +2,7 @@ package fr.nessar.lmnporganizer.rental.adapter.in.web.lease;
import fr.nessar.lmnporganizer.rental.adapter.in.web.rent.RentDueApi;
import fr.nessar.lmnporganizer.rental.application.lease.LeaseFinalizationService;
import fr.nessar.lmnporganizer.rental.application.lease.LeaseDocumentEncryption;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseDocumentAnnex;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseDocumentRevision;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseDocumentWarning;
@@ -9,11 +10,17 @@ import fr.nessar.lmnporganizer.rental.domain.lease.LeaseFinalization;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseFinalizationWarning;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseGuarantee;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseGuaranteeDeed;
import fr.nessar.lmnporganizer.rental.domain.tenant.TenantProfile;
import fr.nessar.lmnporganizer.rental.domain.lease.VisaleSuretyContract;
import fr.nessar.lmnporganizer.rental.domain.lease.VisaleVisa;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import java.time.Instant;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.Base64;
import java.util.List;
import java.util.Set;
@@ -27,23 +34,54 @@ final class LeaseFinalizationApi {
record FieldValidationError(String field, String code) {
}
record GuaranteeDeedRequest(Integer requirementIndex, String guaranteeId, String tenantId, Boolean attestation) {
record DeedResponse(String guaranteeId, String tenantId, boolean attestation, String fileName, String sha256,
int cryptoVersion, String nonce, String wrappedKey, String vaultPublicKey, String authorId,
Instant uploadedAt) {
static DeedResponse from(LeaseGuaranteeDeed deed) {
return new DeedResponse(deed.guaranteeId(), deed.tenantId().value(), deed.attestation(), deed.fileName(),
deed.sha256(), deed.cryptoVersion(), deed.nonce(), deed.wrappedKey(), deed.vaultPublicKey(),
deed.authorId(), deed.uploadedAt());
}
}
record EncryptedDocumentResponse(String fileName, int cryptoVersion, String nonce, String wrappedKey, String sha256,
String vaultPublicKey, String ciphertext) {
static EncryptedDocumentResponse from(LeaseGuaranteeDeed deed, byte[] ciphertext) {
return new EncryptedDocumentResponse(deed.fileName(), deed.cryptoVersion(), deed.nonce(), deed.wrappedKey(),
deed.sha256(), deed.vaultPublicKey(),
Base64.getUrlEncoder().withoutPadding().encodeToString(ciphertext));
}
static EncryptedDocumentResponse from(VisaleVisa visa, byte[] ciphertext) {
return new EncryptedDocumentResponse(visa.fileName(), visa.cryptoVersion(), visa.nonce(), visa.wrappedKey(),
visa.sha256(), visa.vaultPublicKey(),
Base64.getUrlEncoder().withoutPadding().encodeToString(ciphertext));
}
static EncryptedDocumentResponse from(VisaleSuretyContract contract, byte[] ciphertext) {
return new EncryptedDocumentResponse(contract.fileName(), contract.cryptoVersion(), contract.nonce(),
contract.wrappedKey(), contract.sha256(), contract.vaultPublicKey(),
Base64.getUrlEncoder().withoutPadding().encodeToString(ciphertext));
}
}
record GuaranteeDeedUploadRequest(Integer requirementIndex, String guaranteeId, String tenantId,
Boolean attestation, @NotNull @Valid EncryptedDocumentRequest document) {
List<FieldValidationError> validationErrors() {
List<FieldValidationError> errors = new ArrayList<>();
if (requirementIndex == null || requirementIndex < 0) {
errors.add(new FieldValidationError("guaranteeRequirements", "invalid"));
return errors;
}
String path = "guaranteeRequirements[" + requirementIndex + "].";
if (guaranteeId == null || guaranteeId.isBlank()) {
errors.add(new FieldValidationError(path + "guaranteeId", "required"));
}
if (tenantId == null || tenantId.isBlank()) {
errors.add(new FieldValidationError(path + "tenantId", "required"));
}
if (!Boolean.TRUE.equals(attestation)) {
errors.add(new FieldValidationError(path + "attestation", "required"));
} else {
String path = "guaranteeRequirements[" + requirementIndex + "].";
if (guaranteeId == null || guaranteeId.isBlank())
errors.add(new FieldValidationError(path + "guaranteeId", "required"));
if (tenantId == null || tenantId.isBlank())
errors.add(new FieldValidationError(path + "tenantId", "required"));
if (!Boolean.TRUE.equals(attestation))
errors.add(new FieldValidationError(path + "attestation", "required"));
}
if (document == null)
errors.add(new FieldValidationError("document", "required"));
return errors;
}
@@ -56,21 +94,17 @@ final class LeaseFinalizationApi {
}
}
record DeedResponse(String guaranteeId, String tenantId, boolean attestation, String fileName, String sha256,
String authorId, Instant uploadedAt) {
static DeedResponse from(LeaseGuaranteeDeed deed) {
return new DeedResponse(deed.guaranteeId(), deed.tenantId().value(), deed.attestation(), deed.fileName(),
deed.sha256(), deed.authorId(), deed.uploadedAt());
}
}
record RevisionAnnexResponse(String category, String documentType, String sourceId, String version, String fileName,
String sha256, LocalDate establishedOn, LocalDate expiresOn, Instant sourceUploadedAt) {
String sha256, LocalDate establishedOn, LocalDate expiresOn, Instant sourceUploadedAt,
Integer cryptoVersion, String nonce, String wrappedKey, String vaultPublicKey) {
static RevisionAnnexResponse from(LeaseDocumentAnnex annex) {
return new RevisionAnnexResponse(annex.category().name(),
annex.documentType() == null ? null : annex.documentType().code(), annex.sourceId(),
annex.version(), annex.fileName(), annex.sha256(), annex.establishedOn(), annex.expiresOn(),
annex.sourceUploadedAt());
annex.sourceUploadedAt(), annex.encryption() == null ? null : annex.encryption().cryptoVersion(),
annex.encryption() == null ? null : annex.encryption().nonce(),
annex.encryption() == null ? null : annex.encryption().wrappedKey(),
annex.encryption() == null ? null : annex.encryption().vaultPublicKey());
}
}
@@ -116,21 +150,34 @@ final class LeaseFinalizationApi {
}
record FinalizationMetadata(int revisionNumber, String templateVersion, LocalDate lastSignatureDate,
String signedFileName, String signedSha256, String authorId, Instant finalizedAt,
List<OverrideResponse> overrides) {
String signedFileName, String signedSha256, String signedSourceFingerprint, int signedCryptoVersion,
String signedNonce, String signedWrappedKey, String signedVaultPublicKey, String authorId,
Instant finalizedAt, List<OverrideResponse> overrides) {
static FinalizationMetadata from(LeaseFinalization finalization) {
return finalization == null
? null
: new FinalizationMetadata(finalization.revisionNumber(), finalization.templateVersion(),
finalization.lastSignatureDate(), finalization.signedFileName(),
finalization.signedSha256(), finalization.authorId(), finalization.finalizedAt(),
finalization.signedSha256(), finalization.signedSourceFingerprint(),
finalization.signedCryptoVersion(), finalization.signedNonce(),
finalization.signedWrappedKey(), finalization.signedVaultPublicKey(),
finalization.authorId(), finalization.finalizedAt(),
finalization.overrides().stream().map(OverrideResponse::from).toList());
}
}
record FinalizationRequest(@NotNull @Positive Integer revisionNumber, @NotNull LocalDate lastSignatureDate,
@NotNull Boolean allPartiesSigned, @NotNull Boolean revisionMatches,
@NotNull List<String> confirmedWarningCodes) {
record EncryptedDocumentRequest(@NotBlank String fileName, @Positive int cryptoVersion, @NotBlank String nonce,
@NotBlank String wrappedKey, @NotBlank String sha256, @NotBlank String ciphertext,
@NotBlank String vaultPublicKey) {
LeaseDocumentEncryption toApplication() {
return new LeaseDocumentEncryption(fileName, cryptoVersion, nonce, wrappedKey, sha256, ciphertext,
vaultPublicKey);
}
}
record FinalizationRequest(@NotNull @Positive Integer revisionNumber, @NotBlank String sourceFingerprint,
@NotNull LocalDate lastSignatureDate, @NotNull Boolean allPartiesSigned, @NotNull Boolean revisionMatches,
@NotNull List<String> confirmedWarningCodes, @NotNull @Valid EncryptedDocumentRequest document) {
List<FieldValidationError> validationErrors() {
List<FieldValidationError> errors = new ArrayList<>();
if (Boolean.FALSE.equals(allPartiesSigned)) {
@@ -143,17 +190,17 @@ final class LeaseFinalizationApi {
}
LeaseFinalizationService.FinalizationRequest toServiceRequest() {
return new LeaseFinalizationService.FinalizationRequest(revisionNumber, lastSignatureDate, allPartiesSigned,
revisionMatches, Set.copyOf(confirmedWarningCodes));
return new LeaseFinalizationService.FinalizationRequest(revisionNumber, sourceFingerprint,
lastSignatureDate, allPartiesSigned, revisionMatches, Set.copyOf(confirmedWarningCodes));
}
}
record GuaranteeRequirementResponse(String guaranteeId, LeaseGuarantee.Type guaranteeType, String guaranteeName,
String tenantId, String tenantName, DeedResponse deed,
String tenantId, TenantProfile tenantProfile, DeedResponse deed,
LeaseFinalizationService.GuaranteeDeedStatus status) {
static GuaranteeRequirementResponse from(LeaseFinalizationService.GuaranteeRequirement requirement) {
return new GuaranteeRequirementResponse(requirement.guaranteeId(), requirement.guaranteeType(),
requirement.guaranteeName(), requirement.tenantId().value(), requirement.tenantName(),
requirement.guaranteeName(), requirement.tenantId().value(), requirement.tenantProfile(),
requirement.deed() == null ? null : DeedResponse.from(requirement.deed()), requirement.status());
}
}
@@ -2,26 +2,23 @@ package fr.nessar.lmnporganizer.rental.adapter.in.web.lease;
import fr.nessar.lmnporganizer.rental.adapter.in.web.common.AuthenticatedLandlord;
import fr.nessar.lmnporganizer.rental.adapter.in.web.rent.RentDueApi;
import fr.nessar.lmnporganizer.rental.application.lease.LeaseFinalizationService.PdfUpload;
import fr.nessar.lmnporganizer.rental.application.lease.LeaseFinalizationService;
import fr.nessar.lmnporganizer.rental.application.lease.LeaseFinalizationValidationFailure;
import fr.nessar.lmnporganizer.rental.application.lease.GenerateLeaseDocumentService;
import fr.nessar.lmnporganizer.rental.application.lease.LeaseDocumentEncryption;
import fr.nessar.lmnporganizer.rental.application.lease.VisaleSuretyContractService;
import fr.nessar.lmnporganizer.rental.application.lease.VisaleVisaService;
import fr.nessar.lmnporganizer.rental.application.shared.AttachmentStorageFailure;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseGuaranteeDeed;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseId;
import fr.nessar.lmnporganizer.rental.domain.lease.VisaleSuretyContract;
import fr.nessar.lmnporganizer.rental.domain.lease.VisaleVisa;
import fr.nessar.lmnporganizer.rental.domain.tenant.TenantId;
import jakarta.validation.Valid;
import java.io.IOException;
import java.time.Clock;
import java.time.Instant;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
import org.springframework.http.ContentDisposition;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
@@ -33,57 +30,51 @@ import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
@RestController("rentalLeaseFinalizationController")
@RequestMapping("/api/leases/{leaseId}")
public class LeaseFinalizationController {
private final LeaseFinalizationService finalizations;
private final GenerateLeaseDocumentService documents;
private final VisaleVisaService visaleVisas;
private final VisaleSuretyContractService visaleContracts;
private final AuthenticatedLandlord authenticatedLandlord;
private final Clock clock;
public LeaseFinalizationController(LeaseFinalizationService finalizations, VisaleVisaService visaleVisas,
VisaleSuretyContractService visaleContracts, AuthenticatedLandlord authenticatedLandlord, Clock clock) {
public LeaseFinalizationController(LeaseFinalizationService finalizations, GenerateLeaseDocumentService documents,
VisaleVisaService visaleVisas, VisaleSuretyContractService visaleContracts,
AuthenticatedLandlord authenticatedLandlord, Clock clock) {
this.finalizations = finalizations;
this.documents = documents;
this.visaleVisas = visaleVisas;
this.visaleContracts = visaleContracts;
this.authenticatedLandlord = authenticatedLandlord;
this.clock = clock;
}
@PostMapping(path = "/visale-surety-contracts", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@PostMapping(path = "/visale-surety-contracts", consumes = MediaType.APPLICATION_JSON_VALUE)
ResponseEntity<VisaleContractResponse> uploadVisaleContract(@AuthenticationPrincipal OidcUser principal,
@PathVariable String leaseId, @RequestPart(value = "file", required = false) MultipartFile file) {
if (file == null || file.isEmpty())
throw validationFailure("file", "required");
PdfUpload pdf = upload(file);
@PathVariable String leaseId, @Valid @RequestBody LeaseFinalizationApi.EncryptedDocumentRequest document) {
try {
var contract = visaleContracts.upload(authenticatedLandlord.id(principal), new LeaseId(leaseId),
new VisaleSuretyContractService.Upload(pdf.fileName(), pdf.contentType(), pdf.content()),
principal.getSubject());
document.toApplication(), principal.getSubject());
return ResponseEntity.status(HttpStatus.CREATED).body(VisaleContractResponse.from(contract));
} catch (LeaseFinalizationValidationFailure failure) {
throw switch (failure.rule()) {
case PDF_REQUIRED -> validationFailure("file", "required");
case PDF_INVALID -> validationFailure("file", "invalid");
case PDF_REQUIRED -> validationFailure("document", "required");
case PDF_INVALID -> validationFailure("document", "invalid");
default -> failure;
};
}
}
@GetMapping("/visale-surety-contract")
ResponseEntity<byte[]> downloadVisaleContract(@AuthenticationPrincipal OidcUser principal,
LeaseFinalizationApi.EncryptedDocumentResponse downloadVisaleContract(@AuthenticationPrincipal OidcUser principal,
@PathVariable String leaseId) {
var download = visaleContracts.download(authenticatedLandlord.id(principal), new LeaseId(leaseId));
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION,
ContentDisposition.attachment().filename(download.contract().fileName()).build().toString())
.contentType(MediaType.APPLICATION_PDF).body(download.content());
return LeaseFinalizationApi.EncryptedDocumentResponse.from(download.contract(), download.ciphertext());
}
@GetMapping("/visale-surety-contract/state")
@@ -101,39 +92,31 @@ public class LeaseFinalizationController {
return ResponseEntity.noContent().build();
}
@PostMapping(path = "/visale-visa", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@PostMapping(path = "/visale-visa", consumes = MediaType.APPLICATION_JSON_VALUE)
ResponseEntity<VisaleVisaUploadResponse> uploadVisaleVisa(@AuthenticationPrincipal OidcUser principal,
@PathVariable String leaseId, @RequestPart(value = "file", required = false) MultipartFile file) {
if (file == null || file.isEmpty()) {
throw validationFailure("file", "required");
}
PdfUpload upload = upload(file);
var visa = uploadVisaleVisa(principal, leaseId,
new VisaleVisaService.Upload(upload.fileName(), upload.contentType(), upload.content()));
@PathVariable String leaseId, @Valid @RequestBody LeaseFinalizationApi.EncryptedDocumentRequest document) {
var visa = uploadVisaleVisa(principal, leaseId, document.toApplication());
return ResponseEntity.status(HttpStatus.CREATED).body(VisaleVisaUploadResponse.from(visa));
}
private VisaleVisa uploadVisaleVisa(OidcUser principal, String leaseId, VisaleVisaService.Upload upload) {
private VisaleVisa uploadVisaleVisa(OidcUser principal, String leaseId, LeaseDocumentEncryption upload) {
try {
return visaleVisas.upload(authenticatedLandlord.id(principal), new LeaseId(leaseId), upload,
principal.getSubject());
} catch (LeaseFinalizationValidationFailure failure) {
throw switch (failure.rule()) {
case PDF_REQUIRED -> validationFailure("file", "required");
case PDF_INVALID -> validationFailure("file", "invalid");
case PDF_REQUIRED -> validationFailure("document", "required");
case PDF_INVALID -> validationFailure("document", "invalid");
default -> failure;
};
}
}
@GetMapping("/visale-visa")
ResponseEntity<byte[]> downloadVisaleVisa(@AuthenticationPrincipal OidcUser principal,
LeaseFinalizationApi.EncryptedDocumentResponse downloadVisaleVisa(@AuthenticationPrincipal OidcUser principal,
@PathVariable String leaseId) {
var download = visaleVisas.download(authenticatedLandlord.id(principal), new LeaseId(leaseId));
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION,
ContentDisposition.attachment().filename(download.visa().fileName()).build().toString())
.contentType(MediaType.APPLICATION_PDF).body(download.content());
return LeaseFinalizationApi.EncryptedDocumentResponse.from(download.visa(), download.ciphertext());
}
@DeleteMapping("/visale-visa")
@@ -142,33 +125,32 @@ public class LeaseFinalizationController {
return ResponseEntity.noContent().build();
}
@PostMapping(path = "/guarantee-deeds", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@PostMapping(path = "/guarantee-deeds", consumes = MediaType.APPLICATION_JSON_VALUE)
ResponseEntity<LeaseFinalizationApi.DeedResponse> uploadGuaranteeDeed(@AuthenticationPrincipal OidcUser principal,
@PathVariable String leaseId,
@RequestParam(value = "requirementIndex", required = false) Integer requirementIndex,
@RequestParam(value = "guaranteeId", required = false) String guaranteeId,
@RequestParam(value = "tenantId", required = false) String tenantId,
@RequestParam(value = "attestation", required = false) Boolean attestation,
@RequestPart(value = "file", required = false) MultipartFile file) {
LeaseFinalizationApi.GuaranteeDeedRequest request = new LeaseFinalizationApi.GuaranteeDeedRequest(
requirementIndex, guaranteeId, tenantId, attestation);
@PathVariable String leaseId, @Valid @RequestBody LeaseFinalizationApi.GuaranteeDeedUploadRequest request) {
List<LeaseFinalizationApi.FieldValidationError> errors = new ArrayList<>(request.validationErrors());
if (file == null || file.isEmpty()) {
errors.add(new LeaseFinalizationApi.FieldValidationError(request.filePath(), "required"));
}
if (!errors.isEmpty()) {
throw new FinalizationValidationFailure(errors);
}
LeaseGuaranteeDeed deed;
try {
deed = finalizations.uploadGuaranteeDeed(authenticatedLandlord.id(principal), new LeaseId(leaseId),
guaranteeId, new TenantId(tenantId), attestation, upload(file), principal.getSubject());
request.guaranteeId(), new TenantId(request.tenantId()), request.attestation(),
request.document().toApplication(), principal.getSubject());
} catch (LeaseFinalizationValidationFailure failure) {
throw deedValidationFailure(request, failure);
}
return ResponseEntity.status(HttpStatus.CREATED).body(LeaseFinalizationApi.DeedResponse.from(deed));
}
@GetMapping("/guarantee-deeds/{guaranteeId}/{tenantId}")
LeaseFinalizationApi.EncryptedDocumentResponse downloadGuaranteeDeed(@AuthenticationPrincipal OidcUser principal,
@PathVariable String leaseId, @PathVariable String guaranteeId, @PathVariable String tenantId) {
var download = finalizations.downloadGuaranteeDeed(authenticatedLandlord.id(principal), new LeaseId(leaseId),
guaranteeId, new TenantId(tenantId));
return LeaseFinalizationApi.EncryptedDocumentResponse.from(download.deed(), download.ciphertext());
}
@GetMapping("/finalization")
LeaseFinalizationApi.StateResponse state(@AuthenticationPrincipal OidcUser principal,
@PathVariable String leaseId) {
@@ -176,23 +158,23 @@ public class LeaseFinalizationController {
.from(finalizations.state(authenticatedLandlord.id(principal), new LeaseId(leaseId)));
}
@PostMapping(path = "/finalization", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@GetMapping("/finalization/document")
LeaseApi.EncryptedDocumentResponse finalizationDocument(@AuthenticationPrincipal OidcUser principal,
@PathVariable String leaseId) {
return LeaseApi.EncryptedDocumentResponse
.from(documents.finalizationDocument(authenticatedLandlord.id(principal), new LeaseId(leaseId)));
}
// Browser-owned lease, guarantee and Visale PDFs are submitted as encrypted
// JSON; the server never receives a plaintext document body.
@PostMapping(path = "/finalization", consumes = MediaType.APPLICATION_JSON_VALUE)
ResponseEntity<LeaseFinalizationApi.FinalizationResponse> finalizeLease(@AuthenticationPrincipal OidcUser principal,
@PathVariable String leaseId,
@RequestPart(value = "signedLease", required = false) MultipartFile signedLease,
@RequestPart("metadata") @Valid LeaseFinalizationApi.FinalizationRequest metadata) {
List<LeaseFinalizationApi.FieldValidationError> errors = new ArrayList<>(metadata.validationErrors());
if (signedLease == null || signedLease.isEmpty()) {
errors.add(new LeaseFinalizationApi.FieldValidationError("signedLease", "required"));
}
if (!errors.isEmpty()) {
throw new FinalizationValidationFailure(errors);
}
@PathVariable String leaseId, @Valid @RequestBody LeaseFinalizationApi.FinalizationRequest metadata) {
LocalDate today = LocalDate.now(clock);
LeaseFinalizationService.Result result;
try {
result = finalizations.finalizeLease(authenticatedLandlord.id(principal), new LeaseId(leaseId),
metadata.toServiceRequest(), upload(signedLease), principal.getSubject());
result = finalizations.finalizeEncryptedLease(authenticatedLandlord.id(principal), new LeaseId(leaseId),
metadata.toServiceRequest(), metadata.document().toApplication(), principal.getSubject());
} catch (LeaseFinalizationValidationFailure failure) {
throw finalizationValidationFailure(failure);
}
@@ -208,16 +190,8 @@ public class LeaseFinalizationController {
.body(new LeaseFinalizationApi.ValidationErrorResponse("validation_failed", failure.errors()));
}
private static PdfUpload upload(MultipartFile file) {
try {
return new PdfUpload(file.getOriginalFilename(), file.getContentType(), file.getBytes());
} catch (IOException failure) {
throw new AttachmentStorageFailure("PDF could not be opened.", failure);
}
}
private static FinalizationValidationFailure deedValidationFailure(
LeaseFinalizationApi.GuaranteeDeedRequest request, LeaseFinalizationValidationFailure failure) {
LeaseFinalizationApi.GuaranteeDeedUploadRequest request, LeaseFinalizationValidationFailure failure) {
return switch (failure.rule()) {
case ATTESTATION_REQUIRED -> validationFailure(request.attestationPath(), "required");
case PDF_REQUIRED -> validationFailure(request.filePath(), "required");
@@ -230,8 +204,8 @@ public class LeaseFinalizationController {
LeaseFinalizationValidationFailure failure) {
return switch (failure.rule()) {
case ATTESTATION_REQUIRED -> validationFailure("attestation", "required");
case PDF_REQUIRED -> validationFailure("signedLease", "required");
case PDF_INVALID -> validationFailure("signedLease", "invalid");
case PDF_REQUIRED -> validationFailure("document", "required");
case PDF_INVALID -> validationFailure("document", "invalid");
case ALL_PARTIES_MUST_SIGN -> validationFailure("allPartiesSigned", "required");
case REVISION_MUST_MATCH -> validationFailure("revisionMatches", "required");
case SIGNATURE_DATE_REQUIRED -> validationFailure("lastSignatureDate", "required");
@@ -257,11 +231,14 @@ public class LeaseFinalizationController {
}
}
record VisaleContractResponse(String id, String visaNumber, List<String> tenantIds, String fileName, String sha256,
record VisaleContractResponse(String id, LeaseApi.EncryptedGroupResponse visaNumber, List<String> tenantIds,
String fileName, String sha256, int cryptoVersion, String nonce, String wrappedKey, String vaultPublicKey,
Instant uploadedAt) {
static VisaleContractResponse from(VisaleSuretyContract contract) {
return new VisaleContractResponse(contract.id(), contract.visaNumber(),
return new VisaleContractResponse(contract.id(),
LeaseApi.EncryptedGroupResponse.from(contract.visaNumber()),
contract.tenantIds().stream().map(TenantId::value).toList(), contract.fileName(), contract.sha256(),
contract.cryptoVersion(), contract.nonce(), contract.wrappedKey(), contract.vaultPublicKey(),
contract.uploadedAt());
}
}
@@ -269,9 +246,11 @@ public class LeaseFinalizationController {
record VisaleContractStateResponse(String status, VisaleContractResponse contract) {
}
record VisaleVisaUploadResponse(String fileName, String sha256, String authorId, Instant uploadedAt) {
record VisaleVisaUploadResponse(String fileName, String sha256, int cryptoVersion, String nonce, String wrappedKey,
String vaultPublicKey, String authorId, Instant uploadedAt) {
static VisaleVisaUploadResponse from(VisaleVisa visa) {
return new VisaleVisaUploadResponse(visa.fileName(), visa.sha256(), visa.authorId(), visa.uploadedAt());
return new VisaleVisaUploadResponse(visa.fileName(), visa.sha256(), visa.cryptoVersion(), visa.nonce(),
visa.wrappedKey(), visa.vaultPublicKey(), visa.authorId(), visa.uploadedAt());
}
}
}
@@ -1,9 +1,9 @@
package fr.nessar.lmnporganizer.rental.adapter.in.web.lease;
import fr.nessar.lmnporganizer.rental.application.lease.LeaseClauseCatalog;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseGuarantee;
import fr.nessar.lmnporganizer.rental.domain.lease.GuarantorProfile;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseType;
import fr.nessar.lmnporganizer.rental.domain.shared.PhoneNumber;
import fr.nessar.lmnporganizer.rental.domain.lease.VisaleGuarantee;
import java.math.BigDecimal;
import java.time.MonthDay;
import java.util.ArrayList;
@@ -21,6 +21,7 @@ final class LeaseNestedRequestValidator {
void validate(LeaseApi.DraftRequest request) {
List<LeaseController.LeaseRequestFieldError> errors = new ArrayList<>();
guarantees(request.guarantees(), request.tenantIds(), errors);
visale(request.visaleGuarantee(), request.tenantIds(), errors);
clauses(request.specialClauses(), context(request), errors);
legalTerms(request.legalTerms(), request.tenantIds() == null ? 0 : request.tenantIds().size(), errors);
if (!errors.isEmpty())
@@ -30,6 +31,7 @@ final class LeaseNestedRequestValidator {
void validate(LeaseApi.AmendmentRequest request) {
List<LeaseController.LeaseRequestFieldError> errors = new ArrayList<>();
guarantees(request.guarantees(), request.tenantIds(), errors);
visale(request.visaleGuarantee(), request.tenantIds(), errors);
if (!errors.isEmpty())
throw new LeaseController.LeaseRequestValidationFailure(errors);
}
@@ -44,15 +46,23 @@ final class LeaseNestedRequestValidator {
errors.add(error("guarantees[" + index + "]", "not_null"));
continue;
}
try {
new PhoneNumber(guarantees.get(index).phone());
} catch (IllegalArgumentException exception) {
errors.add(error("guarantees[" + index + "].phone", "invalid"));
}
if (tenantIds == null || !tenantIds.contains(guarantees.get(index).tenantId())) {
errors.add(error("guarantees[" + index + "].tenantId", "not_member"));
}
guaranteeFields(guarantees.get(index), index, errors);
if (guarantees.get(index).type() == null) {
errors.add(error("guarantees[" + index + "].type", "required"));
} else if (guarantees.get(index).profile() == null) {
errors.add(error("guarantees[" + index + "].profile", "required"));
} else {
try {
GuarantorProfile profile = guarantees.get(index).profile().toDomain();
if (!profile.contractProfileComplete()) {
errors.add(error("guarantees[" + index + "].profile.contractProfileComplete", "incomplete"));
}
} catch (IllegalArgumentException exception) {
errors.add(error("guarantees[" + index + "].profile", "invalid"));
}
}
String id = guarantees.get(index).id();
if (id != null && !id.isBlank() && !ids.add(id)) {
errors.add(error("guarantees[" + index + "].id", "duplicate"));
@@ -60,31 +70,27 @@ final class LeaseNestedRequestValidator {
}
}
private static void guaranteeFields(LeaseApi.LeaseGuaranteeRequest guarantee, int index,
private static void visale(LeaseApi.VisaleGuaranteeRequest request, List<String> tenantIds,
List<LeaseController.LeaseRequestFieldError> errors) {
String path = "guarantees[" + index + "].";
if (guarantee.type() == LeaseGuarantee.Type.PHYSICAL_PERSON) {
required(guarantee.firstName(), path + "firstName", errors);
required(guarantee.lastName(), path + "lastName", errors);
required(guarantee.email(), path + "email", errors);
if (request == null)
return;
try {
VisaleGuarantee guarantee = request.toDomain();
if (tenantIds == null || guarantee.tenantIds().stream().anyMatch(id -> !tenantIds.contains(id.value()))) {
errors.add(error("visaleGuarantee.tenantIds", "not_member"));
}
} catch (IllegalArgumentException exception) {
errors.add(error("visaleGuarantee", "invalid"));
}
if (guarantee.type() == LeaseGuarantee.Type.ORGANIZATION) {
required(guarantee.organizationName(), path + "organizationName", errors);
required(guarantee.organizationAddress(), path + "organizationAddress", errors);
required(guarantee.representativeName(), path + "representativeName", errors);
}
}
private static void required(String value, String field, List<LeaseController.LeaseRequestFieldError> errors) {
if (value == null || value.isBlank())
errors.add(error(field, "required"));
}
private void clauses(List<LeaseApi.SpecialClauseRequest> requests, LeaseClauseCatalog.Context context,
List<LeaseController.LeaseRequestFieldError> errors) {
if (requests == null)
if (requests == null) {
errors.add(error("specialClauses", "required"));
return;
}
var ids = new HashSet<String>();
for (int index = 0; index < requests.size(); index++) {
LeaseApi.SpecialClauseRequest request = requests.get(index);
String path = "specialClauses[" + index + "]";
@@ -92,30 +98,50 @@ final class LeaseNestedRequestValidator {
errors.add(error(path, "not_null"));
continue;
}
if (request.renderedText() == null)
errors.add(error(path + ".renderedText", "required"));
if (request.id() != null && !ids.add(request.id()))
errors.add(error(path + ".id", "duplicate"));
if (request.catalogId() == null || request.catalogId().isBlank()) {
if (request.parameters() != null && !request.parameters().isEmpty()) {
if ((request.parameters() != null && !request.parameters().isEmpty())
|| (request.encryptedParameters() != null && !request.encryptedParameters().isEmpty())) {
errors.add(error(path + ".parameters", "not_allowed"));
}
if (request.customText() == null || request.customText().isBlank()) {
errors.add(error(path + ".customText", "required"));
}
continue;
}
if (request.customText() != null && !request.customText().isBlank()) {
errors.add(error(path + ".customText", "server_defined"));
}
LeaseClauseCatalog.ClauseDefinition definition = clauses.available(request.catalogId(), context);
if (definition == null) {
errors.add(error(path + ".catalogId", "unavailable"));
continue;
}
Map<String, String> parameters = request.parameters() == null ? Map.of() : request.parameters();
Map<String, LeaseApi.EncryptedGroupRequest> encrypted = request.encryptedParameters() == null
? Map.of()
: request.encryptedParameters();
if (parameters.size() + encrypted.size() > 20)
errors.add(error(path + ".parameters", "size"));
for (String name : parameters.keySet()) {
if (definition.parameters().stream().noneMatch(parameter -> parameter.name().equals(name))) {
errors.add(error(path + ".parameters." + name, "unknown"));
} else if (definition.parameters().stream().anyMatch(parameter -> parameter.name().equals(name)
&& parameter.type() == LeaseClauseCatalog.ParameterType.TEXT)) {
errors.add(error(path + ".parameters." + name, "encrypted_required"));
}
}
for (String name : encrypted.keySet()) {
LeaseClauseCatalog.ParameterDefinition parameter = definition.parameters().stream()
.filter(candidate -> candidate.name().equals(name)).findFirst().orElse(null);
if (parameter == null)
errors.add(error(path + ".encryptedParameters." + name, "unknown"));
else if (parameter.type() != LeaseClauseCatalog.ParameterType.TEXT)
errors.add(error(path + ".encryptedParameters." + name, "not_allowed"));
}
for (LeaseClauseCatalog.ParameterDefinition parameter : definition.parameters()) {
if (parameter.type() == LeaseClauseCatalog.ParameterType.TEXT) {
if (parameter.required() && !encrypted.containsKey(parameter.name()))
errors.add(error(path + ".encryptedParameters." + parameter.name(), "required"));
continue;
}
String value = parameters.get(parameter.name());
if (value == null || value.trim().isEmpty()) {
if (parameter.required())
@@ -131,8 +157,13 @@ final class LeaseNestedRequestValidator {
private static void legalTerms(LeaseApi.LegalTermsRequest terms, int tenantCount,
List<LeaseController.LeaseRequestFieldError> errors) {
if (terms == null)
if (terms == null) {
errors.add(error("legalTerms", "required"));
return;
}
if (terms.otherTerms() == null) {
errors.add(error("legalTerms.otherTerms", "required"));
}
LeaseApi.RentControlRequest rentControl = terms.rentControl();
if (rentControl != null && Boolean.TRUE.equals(rentControl.referenceRentApplicable())) {
if (rentControl.baseReferenceRent() == null)
@@ -143,8 +174,7 @@ final class LeaseNestedRequestValidator {
&& rentControl.increasedReferenceRent().compareTo(rentControl.baseReferenceRent()) < 0) {
errors.add(error("legalTerms.rentControl.increasedReferenceRent", "below_base"));
}
if (positive(rentControl.supplement()) && (rentControl.supplementJustification() == null
|| rentControl.supplementJustification().isBlank())) {
if (positive(rentControl.supplement()) && rentControl.supplementJustification() == null) {
errors.add(error("legalTerms.rentControl.supplementJustification", "required"));
}
}
@@ -181,7 +211,7 @@ final class LeaseNestedRequestValidator {
}
}
LeaseApi.RecentWorkRequest work = terms.recentWork();
if (work != null && positive(work.amount()) && (work.description() == null || work.description().isBlank())) {
if (work != null && positive(work.amount()) && work.description() == null) {
errors.add(error("legalTerms.recentWork.description", "required"));
}
if (Boolean.TRUE.equals(terms.jointAndSeveralLiability()) && tenantCount < 2) {
@@ -9,6 +9,7 @@ import fr.nessar.lmnporganizer.rental.domain.condition.ConditionPhoto;
import fr.nessar.lmnporganizer.rental.domain.condition.ConditionPhotoTarget;
import fr.nessar.lmnporganizer.rental.domain.condition.ConditionZone;
import fr.nessar.lmnporganizer.rental.domain.condition.InventoryCondition;
import fr.nessar.lmnporganizer.rental.domain.condition.InventoryObservationCode;
import fr.nessar.lmnporganizer.rental.domain.condition.InventoryQuantitySplit;
import fr.nessar.lmnporganizer.rental.domain.condition.LateSupplementAddition;
import fr.nessar.lmnporganizer.rental.domain.condition.MeterReading;
@@ -17,6 +18,7 @@ import fr.nessar.lmnporganizer.rental.domain.condition.PropertyConditionReportSu
import fr.nessar.lmnporganizer.rental.domain.condition.PropertyConditionReportSupplementType;
import fr.nessar.lmnporganizer.rental.domain.condition.SupplementAudit;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseId;
import fr.nessar.lmnporganizer.rental.domain.tenant.TenantProfile.EncryptedGroup;
import java.math.BigDecimal;
import java.time.Instant;
import java.time.LocalDate;
@@ -69,7 +71,7 @@ public final class PropertyConditionComparisonController {
record LineResponse<T>(String reference, T entry, T exit, boolean changed) {
}
record ObservationResponse(String subjectId, String state, String observation) {
record ObservationResponse(String subjectId, String state, EncryptedGroup observation) {
static ObservationResponse from(ConditionObservation value) {
return new ObservationResponse(value.subjectId(), value.state().name(), value.observation());
}
@@ -81,46 +83,52 @@ public final class PropertyConditionComparisonController {
}
}
record SplitResponse(String inventoryLineId, String state, int quantity, String observation) {
record SplitResponse(String inventoryLineId, String state, int quantity, EncryptedGroup observation,
InventoryObservationCode observationCode) {
static SplitResponse from(InventoryQuantitySplit value) {
return new SplitResponse(value.inventoryLineId(), value.state().name(), value.quantity(),
value.observation());
value.observation(), value.observationCode());
}
}
record InventoryResponse(String inventoryLineId, String privateAreaId, String privateAreaLabel, String designation,
int contractualQuantity, String contractualDescription, int observedQuantity, String observedDescription,
List<SplitResponse> splits) {
int contractualQuantity, String contractualDescription, int observedQuantity,
EncryptedGroup observedDescription, List<SplitResponse> splits, boolean descriptionReconciled) {
static InventoryResponse from(InventoryCondition value) {
return new InventoryResponse(value.inventoryLineId(), value.privateAreaId(), value.privateAreaLabel(),
value.designation(), value.contractualQuantity(), value.contractualDescription(),
value.observedQuantity(), value.observedDescription(),
value.splits().stream().map(SplitResponse::from).toList());
value.splits().stream().map(SplitResponse::from).toList(), value.descriptionReconciled());
}
}
record MeterResponse(String id, String type, String state, BigDecimal index, String unit, String label,
String observation) {
record MeterResponse(String id, String type, String state, BigDecimal index, String unit, EncryptedGroup label,
EncryptedGroup observation) {
static MeterResponse from(MeterReading value) {
return new MeterResponse(value.id(), value.type().name(), value.state().name(), value.index(), value.unit(),
value.label(), value.observation());
}
}
record AccessResponse(String id, String type, String destination, int quantity, String observation) {
record AccessResponse(String id, String type, EncryptedGroup destination, int quantity,
EncryptedGroup observation) {
static AccessResponse from(AccessMeans value) {
return new AccessResponse(value.id(), value.type().name(), value.destination(), value.quantity(),
value.observation());
}
}
record SupplementFileResponse(String reference, String sha256) {
record SupplementFileResponse(String reference, String sha256, int cryptoVersion, String nonce, String wrappedKey,
String vaultPublicKey) {
static SupplementFileResponse from(PropertyConditionReportSupplement.FileEvidence value) {
return value == null ? null : new SupplementFileResponse(value.reference(), value.sha256());
return value == null
? null
: new SupplementFileResponse(value.reference(), value.sha256(), value.cryptoVersion(),
value.nonce(), value.wrappedKey(), value.vaultPublicKey());
}
}
record SupplementAuditResponse(String status, LocalDate deadlineOn, String ruleVersion, String reason,
record SupplementAuditResponse(String status, LocalDate deadlineOn, String ruleVersion, EncryptedGroup reason,
String auditedBy, Instant auditedAt) {
static SupplementAuditResponse from(SupplementAudit value) {
return value instanceof LateSupplementAddition late
@@ -133,7 +141,7 @@ public final class PropertyConditionComparisonController {
record SupplementResponse(String id, PropertyConditionReportSupplementType type, SupplementFileResponse signedPdf,
List<PhotoResponse> photos, String uploadedBy, Instant uploadedAt, LocalDate requestedOn,
LocalDate heatingPeriodStartedOn, String acceptedAdditions, SupplementAuditResponse audit) {
LocalDate heatingPeriodStartedOn, EncryptedGroup acceptedAdditions, SupplementAuditResponse audit) {
static SupplementResponse from(PropertyConditionReportSupplement value) {
return new SupplementResponse(value.id(), value.type(), SupplementFileResponse.from(value.signedPdf()),
value.photos().stream().map(PhotoResponse::from).toList(), value.uploadedBy(), value.uploadedAt(),
@@ -151,7 +159,7 @@ public final class PropertyConditionComparisonController {
}
}
record AbsenceResponse(String reason, String auditedBy, Instant auditedAt) {
record AbsenceResponse(EncryptedGroup reason, String auditedBy, Instant auditedAt) {
static AbsenceResponse from(AuditedPhotoAbsence value) {
return value == null ? null : new AbsenceResponse(value.reason(), value.auditedBy(), value.auditedAt());
}
@@ -9,13 +9,19 @@ import fr.nessar.lmnporganizer.rental.application.shared.AttachmentStorageFailur
import fr.nessar.lmnporganizer.rental.application.shared.GeneratedDocument;
import fr.nessar.lmnporganizer.rental.domain.condition.*;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseId;
import fr.nessar.lmnporganizer.rental.domain.tenant.TenantProfile.EncryptedGroup;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.PositiveOrZero;
import jakarta.validation.constraints.Size;
import java.io.IOException;
import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.time.Instant;
import java.time.LocalDate;
import java.util.ArrayList;
@@ -87,7 +93,7 @@ public class PropertyConditionReportController {
throw new IllegalArgumentException("La réconciliation appartient à l’état des lieux d’entrée.");
}
return response(reports.reconcileInventory(landlord.id(principal), new LeaseId(leaseId), request.version(),
request.inventoryLineId()));
request.inventoryLineId(), request.vaultPublicKey()));
}
@DeleteMapping("/photos/{reference}")
@@ -103,35 +109,58 @@ public class PropertyConditionReportController {
return download(documents.downloadPhoto(landlord.id(principal), new LeaseId(leaseId), type, reference));
}
@GetMapping("/supplements/{reference}")
DocumentResponse downloadSupplement(@AuthenticationPrincipal OidcUser principal, @PathVariable String leaseId,
@PathVariable PropertyConditionReportType type, @PathVariable String reference) {
if (type != PropertyConditionReportType.ENTRY) {
throw new IllegalArgumentException("Les compléments appartiennent à l'état des lieux d'entrée.");
}
return DocumentResponse
.from(documents.downloadSupplement(landlord.id(principal), new LeaseId(leaseId), reference));
}
@PostMapping("/revisions/prepare")
RevisionPreparationResponse prepareRevision(@AuthenticationPrincipal OidcUser principal,
@PathVariable String leaseId, @PathVariable PropertyConditionReportType type,
@RequestParam @PositiveOrZero long version) {
return RevisionPreparationResponse.from(
documents.prepareRevision(landlord.id(principal), new LeaseId(leaseId), type, version,
principal.getSubject()),
photoProperties.maxUploadSize().toBytes(), attachmentProperties.maxSize().toBytes());
}
@PostMapping("/revisions")
RevisionResponse revision(@AuthenticationPrincipal OidcUser principal, @PathVariable String leaseId,
@PathVariable PropertyConditionReportType type, @RequestParam @PositiveOrZero long version) {
return RevisionResponse.from(documents.generateRevision(landlord.id(principal), new LeaseId(leaseId), type,
version, principal.getSubject()));
@PathVariable PropertyConditionReportType type, @Valid @RequestBody CommitRevisionRequest request) {
return RevisionResponse
.from(documents.commitRevision(landlord.id(principal), new LeaseId(leaseId), type, request.version(),
request.sourceFingerprint(), request.document().toApplication(), principal.getSubject()));
}
@GetMapping("/revisions/{number}")
ResponseEntity<byte[]> revisionDownload(@AuthenticationPrincipal OidcUser principal, @PathVariable String leaseId,
@PathVariable PropertyConditionReportType type, @PathVariable int number) {
return download(documents.downloadRevision(landlord.id(principal), new LeaseId(leaseId), type, number));
DocumentResponse revisionDownload(@AuthenticationPrincipal OidcUser principal, @PathVariable String leaseId,
@PathVariable PropertyConditionReportType type, @PathVariable @Positive int number) {
return DocumentResponse.from(documents.revision(landlord.id(principal), new LeaseId(leaseId), type, number));
}
@PostMapping(path = "/finalization", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@PostMapping(path = "/finalization", consumes = MediaType.APPLICATION_JSON_VALUE)
Response finalizeReport(@AuthenticationPrincipal OidcUser principal, @PathVariable String leaseId,
@PathVariable PropertyConditionReportType type,
@RequestPart(value = "file", required = false) MultipartFile file,
@RequestPart("metadata") @Valid FinalizationMetadataRequest metadata) {
List<FieldValidationError> errors = new ArrayList<>(metadata.validationErrors());
byte[] content = pdf(file, "file", errors);
invalid(errors);
return response(documents.finalizeReport(landlord.id(principal), new LeaseId(leaseId), type, metadata.version(),
metadata.attestation(), metadata.allPartiesSigned(), content, principal.getSubject()));
@PathVariable PropertyConditionReportType type, @Valid @RequestBody FinalizationRequest request) {
return response(documents.finalizeEncryptedReport(landlord.id(principal), new LeaseId(leaseId), type,
request.version(), request.sourceFingerprint(), request.revisionNumber(), request.attestation(),
request.allPartiesSigned(), request.document().toApplication(), principal.getSubject()));
}
@GetMapping("/finalization/document")
DocumentResponse finalizationDocument(@AuthenticationPrincipal OidcUser principal, @PathVariable String leaseId,
@PathVariable PropertyConditionReportType type) {
return DocumentResponse.from(documents.finalization(landlord.id(principal), new LeaseId(leaseId), type));
}
@GetMapping("/bundle")
ResponseEntity<byte[]> bundle(@AuthenticationPrincipal OidcUser principal, @PathVariable String leaseId,
BundleResponse bundle(@AuthenticationPrincipal OidcUser principal, @PathVariable String leaseId,
@PathVariable PropertyConditionReportType type) {
return download(documents.downloadBundle(landlord.id(principal), new LeaseId(leaseId), type));
return BundleResponse.from(documents.bundle(landlord.id(principal), new LeaseId(leaseId), type));
}
@PostMapping("/delivery-attestations")
@@ -151,7 +180,8 @@ public class PropertyConditionReportController {
if (type != PropertyConditionReportType.ENTRY)
throw new IllegalArgumentException("Les compléments appartiennent à l'état des lieux d'entrée.");
List<FieldValidationError> errors = new ArrayList<>(metadata.validationErrors());
byte[] pdf = optionalPdf(signedPdf, "signedPdf", errors);
byte[] encryptedPdf = optionalEncryptedPdf(signedPdf, "signedPdf", errors);
PropertyConditionReportService.SupplementPdf pdf = supplementPdf(metadata.signedPdf(), encryptedPdf, errors);
List<MultipartFile> photoFiles = photos == null ? List.of() : photos;
List<String> zones = metadata.photoZoneIds() == null ? List.of() : metadata.photoZoneIds();
if (photoFiles.size() != zones.size()) {
@@ -167,8 +197,9 @@ public class PropertyConditionReportController {
}
invalid(errors);
return response(reports.appendSupplement(landlord.id(principal), new LeaseId(leaseId), pdf, photoContents,
principal.getSubject(), metadata.requestedOn(), metadata.acceptedAdditions(), metadata.supplementType(),
metadata.heatingPeriodStartedOn(), metadata.lateWarningConfirmed(), metadata.reason()));
principal.getSubject(), metadata.vaultPublicKey(), metadata.requestedOn(),
metadata.acceptedAdditions().toDomain(), metadata.supplementType(), metadata.heatingPeriodStartedOn(),
metadata.lateWarningConfirmed(), metadata.reason() == null ? null : metadata.reason().toDomain()));
}
@ExceptionHandler(ConditionReportValidationFailure.class)
@@ -182,8 +213,10 @@ public class PropertyConditionReportController {
}
}
private byte[] pdf(MultipartFile file, String field, List<FieldValidationError> errors) {
if (file == null || file.isEmpty()) {
private byte[] optionalEncryptedPdf(MultipartFile file, String field, List<FieldValidationError> errors) {
if (file == null)
return null;
if (file.isEmpty()) {
errors.add(new FieldValidationError(field, "required"));
return null;
}
@@ -191,20 +224,33 @@ public class PropertyConditionReportController {
errors.add(new FieldValidationError(field, "too_large"));
return null;
}
if (!MediaType.APPLICATION_PDF_VALUE.equals(file.getContentType())) {
byte[] ciphertext = content(file);
if (ciphertext.length < 17 || startsWithPdf(ciphertext)) {
errors.add(new FieldValidationError(field, "invalid"));
return null;
}
byte[] content = content(file);
if (!startsWithPdf(content)) {
errors.add(new FieldValidationError(field, "invalid"));
return null;
}
return content;
return ciphertext;
}
private byte[] optionalPdf(MultipartFile file, String field, List<FieldValidationError> errors) {
return file == null ? null : pdf(file, field, errors);
private static boolean startsWithPdf(byte[] content) {
return content.length >= 5 && content[0] == '%' && content[1] == 'P' && content[2] == 'D' && content[3] == 'F'
&& content[4] == '-';
}
private static PropertyConditionReportService.SupplementPdf supplementPdf(EncryptedPdfMetadata metadata,
byte[] ciphertext, List<FieldValidationError> errors) {
if (metadata == null && ciphertext == null)
return null;
if (metadata == null || ciphertext == null) {
errors.add(new FieldValidationError("signedPdf", "metadata_required"));
return null;
}
try {
return metadata.toDomain(ciphertext);
} catch (IllegalArgumentException failure) {
errors.add(new FieldValidationError("signedPdf", "invalid"));
return null;
}
}
private byte[] photo(MultipartFile file, String field, List<FieldValidationError> errors) {
@@ -283,27 +329,42 @@ public class PropertyConditionReportController {
record FieldValidationError(String field, String code) {
}
record FinalizationMetadataRequest(Long version, Boolean attestation, Boolean allPartiesSigned) {
List<FieldValidationError> validationErrors() {
List<FieldValidationError> errors = new ArrayList<>();
if (version == null || version < 0) {
errors.add(new FieldValidationError("version", "positive_or_zero"));
}
if (!Boolean.TRUE.equals(attestation)) {
errors.add(new FieldValidationError("attestation", "required"));
}
if (!Boolean.TRUE.equals(allPartiesSigned)) {
errors.add(new FieldValidationError("allPartiesSigned", "required"));
}
return errors;
record RevisionPreparationResponse(String landlordId, String sourceFingerprint, long reportVersion, int number,
Response report) {
static RevisionPreparationResponse from(PropertyConditionDocumentService.RevisionPreparation value,
long maxPhotoBytes, long maxAttachmentBytes) {
return new RevisionPreparationResponse(value.report().landlordId().value(), value.sourceFingerprint(),
value.reportVersion(), value.number(),
Response.from(value.report(), maxPhotoBytes, maxAttachmentBytes));
}
}
record SupplementMetadataRequest(List<String> photoZoneIds, LocalDate requestedOn, String acceptedAdditions,
record EncryptedDocumentRequest(@NotBlank String fileName, @Positive int cryptoVersion, @NotBlank String nonce,
@NotBlank String wrappedKey, @NotBlank String sha256, @NotBlank String ciphertext,
@NotBlank String vaultPublicKey) {
PropertyConditionDocumentService.EncryptedDocument toApplication() {
return new PropertyConditionDocumentService.EncryptedDocument(fileName, cryptoVersion, nonce, wrappedKey,
sha256, ciphertext, vaultPublicKey);
}
}
record CommitRevisionRequest(@NotNull @PositiveOrZero Long version, @NotBlank String sourceFingerprint,
@NotNull @Valid EncryptedDocumentRequest document) {
}
record FinalizationRequest(@NotNull @PositiveOrZero Long version, @NotBlank String sourceFingerprint,
@NotNull @Positive Integer revisionNumber, @NotNull Boolean attestation, @NotNull Boolean allPartiesSigned,
@NotNull @Valid EncryptedDocumentRequest document) {
}
record SupplementMetadataRequest(@NotBlank @Size(max = 1400) String vaultPublicKey, List<String> photoZoneIds,
LocalDate requestedOn, @NotNull @Valid EncryptedGroupRequest acceptedAdditions,
PropertyConditionReportSupplementType supplementType, LocalDate heatingPeriodStartedOn,
Boolean lateWarningConfirmed, String reason) {
Boolean lateWarningConfirmed, @Valid EncryptedGroupRequest reason, @Valid EncryptedPdfMetadata signedPdf) {
List<FieldValidationError> validationErrors() {
List<FieldValidationError> errors = new ArrayList<>();
if (vaultPublicKey == null || vaultPublicKey.isBlank())
errors.add(new FieldValidationError("vaultPublicKey", "required"));
if (photoZoneIds == null) {
errors.add(new FieldValidationError("photoZoneIds", "required"));
} else {
@@ -316,9 +377,8 @@ public class PropertyConditionReportController {
if (requestedOn == null) {
errors.add(new FieldValidationError("requestedOn", "required"));
}
if (acceptedAdditions == null || acceptedAdditions.isBlank()) {
errors.add(new FieldValidationError("acceptedAdditions", "not_blank"));
}
if (acceptedAdditions == null)
errors.add(new FieldValidationError("acceptedAdditions", "required"));
if (supplementType == null) {
errors.add(new FieldValidationError("supplementType", "required"));
}
@@ -329,9 +389,26 @@ public class PropertyConditionReportController {
}
}
record EncryptedGroupRequest(@Min(1) @Max(1) int cryptoVersion, @NotBlank @Size(max = 32) String nonce,
@NotBlank @Size(max = 1400) String wrappedKey, @NotBlank @Size(max = 64) String sha256,
@NotBlank @Size(max = 44000) String ciphertext) {
EncryptedGroup toDomain() {
return new EncryptedGroup(cryptoVersion, nonce, wrappedKey, sha256, ciphertext);
}
}
record EncryptedPdfMetadata(@Min(1) @Max(1) int cryptoVersion, @NotBlank @Size(max = 32) String nonce,
@NotBlank @Size(max = 1400) String wrappedKey, @NotBlank @Size(max = 64) String sha256,
@NotBlank @Size(max = 1400) String vaultPublicKey) {
PropertyConditionReportService.SupplementPdf toDomain(byte[] ciphertext) {
return new PropertyConditionReportService.SupplementPdf(ciphertext, cryptoVersion, nonce, wrappedKey,
sha256, vaultPublicKey);
}
}
@ValidPropertyConditionReportDraft
record Request(@NotNull @PositiveOrZero Long version, @NotNull LocalDate occurredOn,
@NotNull List<@NotNull @Valid ParticipantRequest> participants,
record Request(@NotNull @PositiveOrZero Long version, @NotBlank @Size(max = 1400) String vaultPublicKey,
@NotNull LocalDate occurredOn, @NotNull List<@NotNull @Valid ParticipantRequest> participants,
@NotNull List<@NotNull @Valid AddressRequest> forwardingAddresses,
@NotNull List<@NotNull @Valid ZoneRequest> zones,
@NotNull List<@NotNull @Valid ObservationRequest> observations,
@@ -342,14 +419,14 @@ public class PropertyConditionReportController {
@NotNull Boolean noIndividualMeterDeclared, @NotNull Boolean noAccessMeansDeclared,
@NotNull @Valid ConfirmationRequest stepConfirmations) {
PropertyConditionReportService.Draft toDomain(String author) {
return new PropertyConditionReportService.Draft(occurredOn, author,
return new PropertyConditionReportService.Draft(occurredOn, author, vaultPublicKey,
list(participants, ParticipantRequest::toDomain),
list(forwardingAddresses, AddressRequest::toDomain), list(zones, ZoneRequest::toDomain),
list(observations, ObservationRequest::toDomain), list(meterReadings, MeterRequest::toDomain),
list(accessMeans, AccessRequest::toDomain), list(inventoryConditions, InventoryRequest::toDomain),
list(photoAbsences,
value -> new PropertyConditionReportService.PhotoAbsence(value.subjectId(),
value.reason())),
value.reason().toDomain())),
Boolean.TRUE.equals(noIndividualMeterDeclared), Boolean.TRUE.equals(noAccessMeansDeclared),
stepConfirmations.toDomain());
}
@@ -362,16 +439,19 @@ public class PropertyConditionReportController {
}
}
record ParticipantRequest(@NotBlank String partyId, @NotNull ReportPartyRole role, @NotBlank String name,
@NotNull ParticipantAttendance attendance, String representativeName) {
record ParticipantRequest(@NotBlank String partyId, @NotNull ReportPartyRole role, String name,
@Valid EncryptedGroupRequest identityAndAddress, @NotNull ParticipantAttendance attendance,
@Valid EncryptedGroupRequest representativeName) {
ReportParticipant toDomain() {
return new ReportParticipant(partyId, role, name, attendance, representativeName);
return new ReportParticipant(partyId, role, name,
identityAndAddress == null ? null : identityAndAddress.toDomain(), attendance,
representativeName == null ? null : representativeName.toDomain());
}
}
record AddressRequest(@NotBlank String tenantPartyId, @NotBlank String address) {
record AddressRequest(@NotBlank String tenantPartyId, @NotNull @Valid EncryptedGroupRequest address) {
ForwardingAddress toDomain() {
return new ForwardingAddress(tenantPartyId, address);
return new ForwardingAddress(tenantPartyId, address.toDomain());
}
}
@@ -382,87 +462,94 @@ public class PropertyConditionReportController {
}
}
record ObservationRequest(@NotBlank String subjectId, @NotNull ConditionState state, String observation) {
record ObservationRequest(@NotBlank String subjectId, @NotNull ConditionState state,
@Valid EncryptedGroupRequest observation) {
ConditionObservation toDomain() {
return new ConditionObservation(subjectId, state, observation);
return new ConditionObservation(subjectId, state, observation == null ? null : observation.toDomain());
}
}
record MeterRequest(@NotBlank String id, @NotNull MeterType type, @NotNull MeterReadingState state,
@PositiveOrZero BigDecimal index, @NotBlank String unit, String label, String observation) {
@PositiveOrZero BigDecimal index, @NotBlank String unit, @Valid EncryptedGroupRequest label,
@Valid EncryptedGroupRequest observation) {
MeterReading toDomain() {
return new MeterReading(id, type, state, index, unit, label, observation);
return new MeterReading(id, type, state, index, unit, label == null ? null : label.toDomain(),
observation == null ? null : observation.toDomain());
}
}
record AccessRequest(@NotBlank String id, @NotNull AccessMeansType type, @NotBlank String destination,
@NotNull @PositiveOrZero Integer quantity, String observation) {
record AccessRequest(@NotBlank String id, @NotNull AccessMeansType type,
@NotNull @Valid EncryptedGroupRequest destination, @NotNull @PositiveOrZero Integer quantity,
@Valid EncryptedGroupRequest observation) {
AccessMeans toDomain() {
return new AccessMeans(id, type, destination, quantity, observation);
return new AccessMeans(id, type, destination.toDomain(), quantity,
observation == null ? null : observation.toDomain());
}
}
record InventoryRequest(@NotBlank String inventoryLineId, @NotBlank String privateAreaId,
@NotBlank String privateAreaLabel, @NotBlank String designation,
@NotNull @PositiveOrZero Integer contractualQuantity, @NotNull String contractualDescription,
@NotNull @PositiveOrZero Integer observedQuantity, @NotNull String observedDescription,
@NotNull List<@NotNull @Valid SplitRequest> splits) {
@NotNull @PositiveOrZero Integer observedQuantity, @Valid EncryptedGroupRequest observedDescription,
@NotNull List<@NotNull @Valid SplitRequest> splits, boolean descriptionReconciled) {
InventoryCondition toDomain() {
return new InventoryCondition(inventoryLineId, privateAreaId, privateAreaLabel, designation,
contractualQuantity, contractualDescription, observedQuantity, observedDescription,
list(splits, SplitRequest::toDomain));
contractualQuantity, contractualDescription, observedQuantity,
observedDescription == null ? null : observedDescription.toDomain(),
list(splits, SplitRequest::toDomain), descriptionReconciled);
}
}
record SplitRequest(@NotBlank String inventoryLineId, @NotNull ConditionState state,
@NotNull @PositiveOrZero Integer quantity, String observation) {
@NotNull @PositiveOrZero Integer quantity, @Valid EncryptedGroupRequest observation,
InventoryObservationCode observationCode) {
InventoryQuantitySplit toDomain() {
return new InventoryQuantitySplit(inventoryLineId, state, quantity, observation);
return new InventoryQuantitySplit(inventoryLineId, state, quantity,
observation == null ? null : observation.toDomain(), observationCode);
}
}
record PhotoAbsenceRequest(@NotBlank String subjectId, @NotBlank String reason) {
record PhotoAbsenceRequest(@NotBlank String subjectId, @NotNull @Valid EncryptedGroupRequest reason) {
}
record InventoryReconciliationRequest(@NotNull @PositiveOrZero Long version, @NotBlank String inventoryLineId) {
record InventoryReconciliationRequest(@NotNull @PositiveOrZero Long version, @NotBlank String inventoryLineId,
@NotBlank @Size(max = 1400) String vaultPublicKey) {
}
private static <I, O> List<O> list(List<I> values, Function<I, O> mapper) {
return values == null ? List.of() : values.stream().map(mapper).toList();
}
private static boolean startsWithPdf(byte[] content) {
return content.length >= 5 && content[0] == '%' && content[1] == 'P' && content[2] == 'D' && content[3] == 'F'
&& content[4] == '-';
}
private static ResponseEntity<byte[]> download(GeneratedDocument document) {
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + document.fileName() + "\"")
.contentType(MediaType.parseMediaType(document.contentType())).body(document.content());
}
record Response(String id, PropertyConditionReportType type, PropertyConditionReportStatus status,
LocalDate occurredOn, String timeZone, List<ReportParticipant> participants,
List<ForwardingAddress> forwardingAddresses, List<ConditionZone> zones,
List<ConditionObservation> observations, List<MeterReading> meterReadings, List<AccessMeans> accessMeans,
List<InventoryCondition> inventoryConditions, List<PrivateAreaResponse> privateAreas,
ReportStepConfirmations stepConfirmations, boolean noIndividualMeterDeclared, boolean noAccessMeansDeclared,
List<PhotoResponse> photos, List<ZonePhotoAbsence> photoAbsences, List<SupplementResponse> supplements,
record Response(String id, String landlordId, PropertyConditionReportType type,
PropertyConditionReportStatus status, LocalDate occurredOn, String timeZone,
List<ReportParticipant> participants, List<ForwardingAddress> forwardingAddresses,
List<ConditionZone> zones, List<ConditionObservation> observations, List<MeterReading> meterReadings,
List<AccessMeans> accessMeans, List<InventoryCondition> inventoryConditions,
List<PrivateAreaResponse> privateAreas, ReportStepConfirmations stepConfirmations,
boolean noIndividualMeterDeclared, boolean noAccessMeansDeclared, List<PhotoResponse> photos,
List<ZonePhotoAbsence> photoAbsences, List<SupplementResponse> supplements,
EntrySnapshotResponse entrySnapshot, List<RevisionResponse> revisions, RevisionResponse currentRevision,
FinalizationResponse finalization, List<PropertyConditionReportDeliveryAttestation> deliveryAttestations,
long maxPhotoBytes, long maxAttachmentBytes, long version) {
long maxPhotoBytes, long maxAttachmentBytes, long version, String sourceFingerprint) {
static Response from(PropertyConditionReport value, long maxPhotoBytes, long maxAttachmentBytes) {
return new Response(value.id().value(), value.type(), value.status(), value.occurredOn(), value.timeZone(),
value.participants(), value.forwardingAddresses(), value.zones(), value.observations(),
value.meterReadings(), value.accessMeans(), value.inventoryConditions(),
privateAreas(value.zones()), value.stepConfirmations(), value.noIndividualMeterDeclared(),
value.noAccessMeansDeclared(), value.photos().stream().map(PhotoResponse::from).toList(),
value.zonePhotoAbsences(), value.supplements().stream().map(SupplementResponse::from).toList(),
return new Response(value.id().value(), value.landlordId().value(), value.type(), value.status(),
value.occurredOn(), value.timeZone(), value.participants(), value.forwardingAddresses(),
value.zones(), value.observations(), value.meterReadings(), value.accessMeans(),
value.inventoryConditions(), privateAreas(value.zones()), value.stepConfirmations(),
value.noIndividualMeterDeclared(), value.noAccessMeansDeclared(),
value.photos().stream().map(PhotoResponse::from).toList(), value.zonePhotoAbsences(),
value.supplements().stream().map(SupplementResponse::from).toList(),
EntrySnapshotResponse.from(value.entrySnapshot()),
value.revisions().stream().map(RevisionResponse::from).toList(),
RevisionResponse.from(value.currentRevision()), FinalizationResponse.from(value.finalization()),
value.deliveryAttestations(), maxPhotoBytes, maxAttachmentBytes, value.version());
value.deliveryAttestations(), maxPhotoBytes, maxAttachmentBytes, value.version(),
value.revisionSourceFingerprint());
}
private static List<PrivateAreaResponse> privateAreas(List<ConditionZone> zones) {
@@ -479,12 +566,44 @@ public class PropertyConditionReportController {
}
}
record RevisionResponse(int number, String sha256, String sourceFingerprint, boolean obsolete) {
record RevisionResponse(int number, String fileName, int cryptoVersion, String nonce, String wrappedKey,
String sha256, String sourceFingerprint, String vaultPublicKey, boolean obsolete) {
static RevisionResponse from(PropertyConditionReportRevision revision) {
return revision == null
? null
: new RevisionResponse(revision.number(), revision.sha256(), revision.sourceFingerprint(),
revision.obsolete());
: new RevisionResponse(revision.number(), revision.fileName(), revision.cryptoVersion(),
revision.nonce(), revision.wrappedKey(), revision.sha256(), revision.sourceFingerprint(),
revision.vaultPublicKey(), revision.obsolete());
}
}
record DocumentResponse(String landlordId, String leaseId, PropertyConditionReportType type, int revisionNumber,
String sourceFingerprint, String fileName, int cryptoVersion, String nonce, String wrappedKey,
String sha256, String vaultPublicKey, String ciphertext) {
static DocumentResponse from(PropertyConditionDocumentService.EncryptedDocumentDownload value) {
return new DocumentResponse(value.landlordId(), value.leaseId(), value.type(), value.revisionNumber(),
value.sourceFingerprint(), value.fileName(), value.cryptoVersion(), value.nonce(),
value.wrappedKey(), value.sha256(), value.vaultPublicKey(),
Base64.getUrlEncoder().withoutPadding().encodeToString(value.ciphertext()));
}
}
record BundlePhotoResponse(String reference, String sha256, ConditionPhotoTarget.Kind target, String privateAreaId,
String subjectId, String supplementId) {
static BundlePhotoResponse from(PropertyConditionDocumentService.BundlePhoto value) {
return new BundlePhotoResponse(value.reference(), value.sha256(), value.target().kind(),
value.target().privateAreaId(), value.target().subjectId(), value.supplementId());
}
}
record BundleResponse(String fileName, PropertyConditionReportType type, String sourceFingerprint,
int entryRevisionNumber, String entrySourceFingerprint, DocumentResponse signedDocument,
List<BundlePhotoResponse> photos, List<String> supplementIds) {
static BundleResponse from(PropertyConditionDocumentService.BundleDescriptor value) {
return new BundleResponse(value.fileName(), value.type(), value.sourceFingerprint(),
value.entryRevisionNumber(), value.entrySourceFingerprint(),
DocumentResponse.from(value.signedDocument()),
value.photos().stream().map(BundlePhotoResponse::from).toList(), value.supplementIds());
}
}
@@ -497,15 +616,19 @@ public class PropertyConditionReportController {
}
}
record SupplementFileResponse(String reference, String sha256) {
record SupplementFileResponse(String reference, String sha256, int cryptoVersion, String nonce, String wrappedKey,
String vaultPublicKey) {
static SupplementFileResponse from(PropertyConditionReportSupplement.FileEvidence value) {
return value == null ? null : new SupplementFileResponse(value.reference(), value.sha256());
return value == null
? null
: new SupplementFileResponse(value.reference(), value.sha256(), value.cryptoVersion(),
value.nonce(), value.wrappedKey(), value.vaultPublicKey());
}
}
record SupplementResponse(String id, PropertyConditionReportSupplementType type, SupplementFileResponse signedPdf,
List<PhotoResponse> photos, String uploadedBy, Instant uploadedAt, LocalDate requestedOn,
LocalDate heatingPeriodStartedOn, String acceptedAdditions, SupplementAuditResponse audit) {
LocalDate heatingPeriodStartedOn, EncryptedGroup acceptedAdditions, SupplementAuditResponse audit) {
static SupplementResponse from(PropertyConditionReportSupplement value) {
return new SupplementResponse(value.id(), value.type(), SupplementFileResponse.from(value.signedPdf()),
value.photos().stream().map(PhotoResponse::from).toList(), value.uploadedBy(), value.uploadedAt(),
@@ -514,7 +637,7 @@ public class PropertyConditionReportController {
}
}
record SupplementAuditResponse(String status, LocalDate deadlineOn, String ruleVersion, String reason,
record SupplementAuditResponse(String status, LocalDate deadlineOn, String ruleVersion, EncryptedGroup reason,
String auditedBy, Instant auditedAt) {
static SupplementAuditResponse from(SupplementAudit value) {
return value instanceof LateSupplementAddition late
@@ -542,12 +665,15 @@ public class PropertyConditionReportController {
record PrivateAreaResponse(String id, String label) {
}
record FinalizationResponse(int revisionNumber, String signedSha256, String authorId, Instant finalizedAt) {
record FinalizationResponse(int revisionNumber, String sourceFingerprint, String fileName, int cryptoVersion,
String nonce, String wrappedKey, String signedSha256, String vaultPublicKey, String authorId,
Instant finalizedAt) {
static FinalizationResponse from(PropertyConditionReportFinalization value) {
return value == null
? null
: new FinalizationResponse(value.revisionNumber(), value.signedSha256(), value.authorId(),
value.finalizedAt());
: new FinalizationResponse(value.revisionNumber(), value.sourceFingerprint(), value.fileName(),
value.cryptoVersion(), value.nonce(), value.wrappedKey(), value.signedSha256(),
value.vaultPublicKey(), value.authorId(), value.finalizedAt());
}
}
}
@@ -1,9 +1,11 @@
package fr.nessar.lmnporganizer.rental.adapter.in.web.lease;
import fr.nessar.lmnporganizer.rental.domain.condition.ConditionState;
import fr.nessar.lmnporganizer.rental.domain.condition.InventoryObservationCode;
import fr.nessar.lmnporganizer.rental.domain.condition.MeterReadingState;
import fr.nessar.lmnporganizer.rental.domain.condition.MeterType;
import fr.nessar.lmnporganizer.rental.domain.condition.ParticipantAttendance;
import fr.nessar.lmnporganizer.rental.domain.condition.ReportPartyRole;
import jakarta.validation.Constraint;
import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;
@@ -52,11 +54,27 @@ final class PropertyConditionReportDraftValidator
var value = values.get(index);
if (value == null)
continue;
if (value.attendance() == ParticipantAttendance.REPRESENTED && blank(value.representativeName())) {
if (value.role() == ReportPartyRole.LANDLORD && blank(value.name())) {
error(context, "participants", index, "name", "required");
valid = false;
}
if (value.role() == ReportPartyRole.TENANT && !blank(value.name())) {
error(context, "participants", index, "name", "plaintext_forbidden");
valid = false;
}
if (value.role() == ReportPartyRole.TENANT && value.identityAndAddress() == null) {
error(context, "participants", index, "identityAndAddress", "required");
valid = false;
}
if (value.role() == ReportPartyRole.LANDLORD && value.identityAndAddress() != null) {
error(context, "participants", index, "identityAndAddress", "forbidden");
valid = false;
}
if (value.attendance() == ParticipantAttendance.REPRESENTED && value.representativeName() == null) {
error(context, "participants", index, "representativeName", "representative_required");
valid = false;
}
if (value.attendance() == ParticipantAttendance.PRESENT && !blank(value.representativeName())) {
if (value.attendance() == ParticipantAttendance.PRESENT && value.representativeName() != null) {
error(context, "participants", index, "representativeName", "invalid");
valid = false;
}
@@ -71,7 +89,7 @@ final class PropertyConditionReportDraftValidator
boolean valid = true;
for (int index = 0; index < values.size(); index++) {
var value = values.get(index);
if (value != null && requiresObservation(value.state()) && blank(value.observation())) {
if (value != null && requiresObservation(value.state()) && value.observation() == null) {
error(context, "observations", index, "observation", "required");
valid = false;
}
@@ -97,12 +115,12 @@ final class PropertyConditionReportDraftValidator
error(context, "meterReadings", index, "index", "invalid");
valid = false;
}
if (blank(value.observation())) {
if (value.observation() == null) {
error(context, "meterReadings", index, "observation", "required");
valid = false;
}
}
if (value.type() == MeterType.OTHER && blank(value.label())) {
if (value.type() == MeterType.OTHER && value.label() == null) {
error(context, "meterReadings", index, "label", "required");
valid = false;
}
@@ -128,10 +146,20 @@ final class PropertyConditionReportDraftValidator
error(context, "inventoryConditions", index, "splits", splitIndex, "inventoryLineId", "invalid");
valid = false;
}
if (requiresObservation(split.state()) && blank(split.observation())) {
if (requiresObservation(split.state()) && split.observation() == null
&& split.observationCode() == null) {
error(context, "inventoryConditions", index, "splits", splitIndex, "observation", "required");
valid = false;
}
if (split.observation() != null && split.observationCode() != null) {
error(context, "inventoryConditions", index, "splits", splitIndex, "observation", "exclusive");
valid = false;
}
if (split.observationCode() != null
&& split.observationCode() != InventoryObservationCode.ABSENT_FROM_CONTRACTUAL_INVENTORY) {
error(context, "inventoryConditions", index, "splits", splitIndex, "observationCode", "invalid");
valid = false;
}
if (split.quantity() != null)
quantity += split.quantity();
}
@@ -0,0 +1,118 @@
package fr.nessar.lmnporganizer.rental.adapter.in.web.rent;
import fr.nessar.lmnporganizer.rental.application.rent.RentalDocumentData;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseContractVersion;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseSnapshot;
import fr.nessar.lmnporganizer.rental.domain.tenant.TenantProfile;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
/** JSON facts consumed by the browser rental-document renderer. */
final class RentDocumentApi {
private RentDocumentApi() {
}
record Landlord(String id, String firstName, String lastName, String email, String phone, String address) {
static Landlord from(LeaseSnapshot.Landlord landlord) {
return new Landlord(landlord.id().value(), landlord.firstName(), landlord.lastName(), landlord.email(),
landlord.phone(), landlord.address());
}
}
record Apartment(String id, String name, String address) {
static Apartment from(LeaseSnapshot.Apartment apartment) {
return new Apartment(apartment.id().value(), apartment.name(), apartment.address());
}
}
record Tenant(String id, Profile profile) {
static Tenant from(LeaseSnapshot.Tenant tenant) {
TenantProfile profile = tenant.profile();
return new Tenant(tenant.id().value(), Profile.from(profile));
}
}
record Profile(String vaultPublicKey, EncryptedGroup identityAndAddress, EncryptedGroup contacts,
boolean contractProfileComplete) {
static Profile from(TenantProfile profile) {
return new Profile(profile.vaultPublicKey(), EncryptedGroup.from(profile.identityAndAddress()),
EncryptedGroup.from(profile.contacts()), profile.contractProfileComplete());
}
}
record EncryptedGroup(int cryptoVersion, String nonce, String wrappedKey, String sha256, String ciphertext) {
static EncryptedGroup from(TenantProfile.EncryptedGroup group) {
return new EncryptedGroup(group.cryptoVersion(), group.nonce(), group.wrappedKey(), group.sha256(),
group.ciphertext());
}
}
record Contract(Landlord landlord, Apartment apartment, List<Tenant> tenants, LocalDate startDate,
LocalDate endDate, BigDecimal monthlyRent, BigDecimal monthlyCharges, BigDecimal securityDeposit,
int dueDay, String chargeMethod, boolean indexFlatRate, String leaseType) {
static Contract from(LeaseContractVersion contract) {
return new Contract(Landlord.from(contract.landlord()), Apartment.from(contract.apartment()),
contract.tenants().stream().map(Tenant::from).toList(), contract.period().startDate(),
contract.period().endDate(), contract.terms().monthlyRent(), contract.terms().monthlyCharges(),
contract.terms().securityDeposit(), contract.terms().dueDay(),
contract.terms().chargeMethod().name(), contract.terms().indexFlatRate(),
contract.leaseType().name());
}
}
record ReceiptAllocation(String id, String rentDueId, String month, BigDecimal amount, LocalDate allocatedAt) {
static ReceiptAllocation from(RentalDocumentData.ReceiptAllocation allocation) {
return new ReceiptAllocation(allocation.allocationId().value(), allocation.rentDueId().value(),
allocation.month().toString(), allocation.amount(), allocation.allocatedAt());
}
}
record ReceiptRefund(String id, BigDecimal amount, LocalDate refundedAt) {
static ReceiptRefund from(RentalDocumentData.ReceiptRefund refund) {
return new ReceiptRefund(refund.refundId().value(), refund.amount(), refund.refundedAt());
}
}
record RentReceipt(String fileName, String leaseId, String rentDueId, String month, BigDecimal rent,
BigDecimal charges, BigDecimal total, LocalDate issuedAt, Contract contract) {
static RentReceipt from(RentalDocumentData.RentReceipt receipt) {
return new RentReceipt(receipt.fileName(), receipt.leaseId().value(), receipt.rentDueId().value(),
receipt.month().toString(), receipt.rent(), receipt.charges(), receipt.total(), receipt.issuedAt(),
Contract.from(receipt.contract()));
}
}
record PaymentReceipt(String fileName, String leaseId, String paymentId, LocalDate receivedAt,
LocalDate confirmedAt, BigDecimal amount, BigDecimal refundedAmount, BigDecimal unallocatedBalance,
Contract contract, List<ReceiptAllocation> allocations, List<ReceiptRefund> refunds) {
static PaymentReceipt from(RentalDocumentData.PaymentReceipt receipt) {
return new PaymentReceipt(receipt.fileName(), receipt.leaseId().value(), receipt.paymentId().value(),
receipt.receivedAt(), receipt.confirmedAt(), receipt.amount(), receipt.refundedAmount(),
receipt.unallocatedBalance(), Contract.from(receipt.contract()),
receipt.allocations().stream().map(ReceiptAllocation::from).toList(),
receipt.refunds().stream().map(ReceiptRefund::from).toList());
}
}
record FlatRateRevision(BigDecimal previousAmount, BigDecimal ceiling, BigDecimal chosenAmount) {
static FlatRateRevision from(RentalDocumentData.FlatRateRevision revision) {
return revision == null
? null
: new FlatRateRevision(revision.previousAmount(), revision.ceiling(), revision.chosenAmount());
}
}
record RentRevision(String fileName, String leaseId, Contract contract, LocalDate requestedOn,
LocalDate effectiveOn, String seriesId, int referenceQuarter, int previousIndexYear, int newIndexYear,
BigDecimal previousIndex, BigDecimal newIndex, BigDecimal previousRent, BigDecimal rentCeiling,
BigDecimal chosenRent, FlatRateRevision flatRateRevision) {
static RentRevision from(RentalDocumentData.RentRevision revision) {
return new RentRevision(revision.fileName(), revision.leaseId().value(), Contract.from(revision.contract()),
revision.requestedOn(), revision.effectiveOn(), revision.seriesId(), revision.referenceQuarter(),
revision.previousIndexYear(), revision.newIndexYear(), revision.previousIndex(),
revision.newIndex(), revision.previousRent(), revision.rentCeiling(), revision.chosenRent(),
FlatRateRevision.from(revision.flatRateRevision()));
}
}
}
@@ -4,15 +4,11 @@ import fr.nessar.lmnporganizer.rental.adapter.in.web.common.AuthenticatedLandlor
import fr.nessar.lmnporganizer.rental.application.rent.GenerateRentReceiptService;
import fr.nessar.lmnporganizer.rental.application.rent.RefreshRentScheduleService;
import fr.nessar.lmnporganizer.rental.application.rent.RentDueQueryService;
import fr.nessar.lmnporganizer.rental.application.shared.GeneratedDocument;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseId;
import fr.nessar.lmnporganizer.rental.domain.rent.RentDueId;
import java.time.Clock;
import java.time.LocalDate;
import java.util.List;
import org.springframework.http.ContentDisposition;
import org.springframework.http.HttpHeaders;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
import org.springframework.web.bind.annotation.GetMapping;
@@ -63,14 +59,8 @@ public class RentDueController {
}
@GetMapping("/{rentDueId}/receipt")
ResponseEntity<byte[]> receipt(@AuthenticationPrincipal OidcUser principal, @PathVariable String rentDueId) {
return document(receipts.generate(authenticatedLandlord.id(principal), new RentDueId(rentDueId)));
}
private static ResponseEntity<byte[]> document(GeneratedDocument document) {
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION,
ContentDisposition.attachment().filename(document.fileName()).build().toString())
.header(HttpHeaders.CONTENT_TYPE, document.contentType()).body(document.content());
RentDocumentApi.RentReceipt receipt(@AuthenticationPrincipal OidcUser principal, @PathVariable String rentDueId) {
return RentDocumentApi.RentReceipt
.from(receipts.generate(authenticatedLandlord.id(principal), new RentDueId(rentDueId)));
}
}
@@ -4,6 +4,10 @@ import fr.nessar.lmnporganizer.rental.application.rent.RentPaymentHistory;
import fr.nessar.lmnporganizer.rental.domain.rent.PaymentAllocation;
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentRefund;
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentReversal;
import fr.nessar.lmnporganizer.rental.domain.tenant.TenantProfile.EncryptedGroup;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
@@ -38,8 +42,16 @@ final class RentPaymentApi {
@NotNull @Positive BigDecimal amount) {
}
record ReversalRequest(@NotNull @PositiveOrZero Long paymentVersion, @NotBlank @Size(max = 500) String reason,
@NotBlank String timeZone) {
record EncryptedGroupRequest(@NotNull @Min(1) @Max(1) Integer cryptoVersion, @NotBlank @Size(max = 32) String nonce,
@NotBlank @Size(max = 1400) String wrappedKey, @NotBlank @Size(max = 64) String sha256,
@NotBlank @Size(max = 44000) String ciphertext) {
EncryptedGroup toDomain() {
return new EncryptedGroup(cryptoVersion, nonce, wrappedKey, sha256, ciphertext);
}
}
record ReversalRequest(@NotNull @PositiveOrZero Long paymentVersion, @NotNull @Valid EncryptedGroupRequest reason,
@NotBlank @Size(max = 1400) String vaultPublicKey, @NotBlank String timeZone) {
}
record RefundRequest(@NotBlank String operationId, @NotNull @PositiveOrZero Long paymentVersion,
@@ -76,12 +88,20 @@ final class RentPaymentApi {
}
}
record ReversalResponse(String id, String leaseId, String paymentId, LocalDate reversedAt, String reason,
List<AllocationResponse> reversedAllocations) {
record ReversalResponse(String id, String leaseId, String paymentId, LocalDate reversedAt, String vaultPublicKey,
EncryptedGroupResponse reason, List<AllocationResponse> reversedAllocations) {
static ReversalResponse from(RentPaymentReversal reversal) {
return new ReversalResponse(reversal.id().value(), reversal.leaseId().value(), reversal.paymentId().value(),
reversal.reversedAt(), reversal.reason(),
reversal.reversedAt(), reversal.vaultPublicKey(), EncryptedGroupResponse.from(reversal.reason()),
reversal.reversedAllocations().stream().map(AllocationResponse::from).toList());
}
}
record EncryptedGroupResponse(int cryptoVersion, String nonce, String wrappedKey, String sha256,
String ciphertext) {
static EncryptedGroupResponse from(EncryptedGroup group) {
return new EncryptedGroupResponse(group.cryptoVersion(), group.nonce(), group.wrappedKey(), group.sha256(),
group.ciphertext());
}
}
}
@@ -9,7 +9,6 @@ import fr.nessar.lmnporganizer.rental.application.rent.RentPaymentHistoryService
import fr.nessar.lmnporganizer.rental.application.rent.RentPaymentValidationFailure;
import fr.nessar.lmnporganizer.rental.application.rent.ReverseRentPaymentService;
import fr.nessar.lmnporganizer.rental.application.shared.ConcurrentChange;
import fr.nessar.lmnporganizer.rental.application.shared.GeneratedDocument;
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseId;
import fr.nessar.lmnporganizer.rental.domain.rent.RentDueId;
@@ -19,8 +18,6 @@ import java.time.DateTimeException;
import java.time.ZoneId;
import java.util.List;
import org.springframework.dao.OptimisticLockingFailureException;
import org.springframework.http.ContentDisposition;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
@@ -124,14 +121,17 @@ public class RentPaymentController {
@PathVariable String paymentId, @Valid @RequestBody RentPaymentApi.ReversalRequest request) {
LandlordId landlordId = authenticatedLandlord.id(principal);
RentPaymentId id = new RentPaymentId(paymentId);
reversal.reverse(landlordId, id, request.paymentVersion(), request.reason(), timeZone(request.timeZone()));
reversal.reverse(landlordId, id, request.paymentVersion(), request.reason().toDomain(),
request.vaultPublicKey(), timeZone(request.timeZone()));
return ResponseEntity.status(HttpStatus.CREATED)
.body(RentPaymentApi.Response.from(history.get(landlordId, id)));
}
@GetMapping("/{paymentId}/receipt")
ResponseEntity<byte[]> receipt(@AuthenticationPrincipal OidcUser principal, @PathVariable String paymentId) {
return document(receipts.generate(authenticatedLandlord.id(principal), new RentPaymentId(paymentId)));
RentDocumentApi.PaymentReceipt receipt(@AuthenticationPrincipal OidcUser principal,
@PathVariable String paymentId) {
return RentDocumentApi.PaymentReceipt
.from(receipts.generate(authenticatedLandlord.id(principal), new RentPaymentId(paymentId)));
}
@PostMapping("/{paymentId}/refunds")
@@ -145,13 +145,6 @@ public class RentPaymentController {
.body(RentPaymentApi.Response.from(history.get(landlordId, id)));
}
private static ResponseEntity<byte[]> document(GeneratedDocument document) {
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION,
ContentDisposition.attachment().filename(document.fileName()).build().toString())
.header(HttpHeaders.CONTENT_TYPE, document.contentType()).body(document.content());
}
static ZoneId timeZone(String value) {
try {
return ZoneId.of(value);
@@ -4,12 +4,15 @@ import fr.nessar.lmnporganizer.rental.application.rent.RentRevisionService;
import fr.nessar.lmnporganizer.rental.domain.rent.RentRevision;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Min;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.PositiveOrZero;
import jakarta.validation.constraints.Size;
import java.math.BigDecimal;
import java.time.Instant;
import java.time.LocalDate;
import java.util.Base64;
import java.util.List;
import java.util.Set;
@@ -40,13 +43,63 @@ final class RentRevisionApi {
@NotNull @Positive BigDecimal chosenRent, @PositiveOrZero BigDecimal chosenCharges,
List<@NotBlank String> confirmedWarningCodes, @Size(max = 9) String seriesIdOverride,
Boolean seriesOverrideConfirmed, @Positive BigDecimal previousIndex, @Positive BigDecimal newIndex,
Boolean manualIndicesConfirmed) {
Boolean manualIndicesConfirmed, String sourceFingerprint) {
RentRevisionService.ProposalRequest toApplication() {
var preview = new RentRevisionService.PreviewRequest(RentRevisionController.timeZone(timeZone),
seriesIdOverride, Boolean.TRUE.equals(seriesOverrideConfirmed), previousIndex, newIndex,
Boolean.TRUE.equals(manualIndicesConfirmed));
return new RentRevisionService.ProposalRequest(preview, requestedOn, chosenRent, chosenCharges,
confirmedWarningCodes == null ? Set.of() : Set.copyOf(confirmedWarningCodes));
confirmedWarningCodes == null ? Set.of() : Set.copyOf(confirmedWarningCodes), sourceFingerprint);
}
}
record EncryptedDocumentRequest(@NotBlank String fileName, @Min(1) int cryptoVersion, @NotBlank String nonce,
@NotBlank String wrappedKey, @NotBlank String sha256, @NotBlank String ciphertext,
@NotBlank String vaultPublicKey) {
RentRevisionService.EncryptedDocument toApplication() {
return new RentRevisionService.EncryptedDocument(fileName, cryptoVersion, nonce, wrappedKey, sha256,
ciphertext, vaultPublicKey);
}
}
record CommitRequest(@NotNull @Valid ProposalRequest proposal, @NotNull @Valid EncryptedDocumentRequest letter) {
}
record ApplyRequest(@NotBlank String timeZone, @NotNull LocalDate sentOn, @NotBlank String deliveryMethod,
@NotNull Boolean deliveryConfirmed, @Valid EncryptedDocumentRequest proof) {
RentRevisionService.ApplyRequest toApplication() {
return new RentRevisionService.ApplyRequest(RentRevisionController.timeZone(timeZone), sentOn,
RentRevisionController.deliveryMethod(deliveryMethod), Boolean.TRUE.equals(deliveryConfirmed),
proof == null ? null : proof.toApplication());
}
}
record PreparationResponse(String sourceFingerprint, long leaseVersion, RentDocumentApi.RentRevision document) {
static PreparationResponse from(RentRevisionService.ProposalPreparation preparation) {
return new PreparationResponse(preparation.sourceFingerprint(), preparation.leaseVersion(),
RentDocumentApi.RentRevision.from(preparation.document()));
}
}
record LetterResponse(String landlordId, String leaseId, String documentType, String documentId,
String sourceFingerprint, String fileName, int cryptoVersion, String nonce, String wrappedKey,
String sha256, String vaultPublicKey, String ciphertext) {
static LetterResponse from(RentRevisionService.Letter letter, String leaseId, String revisionId) {
return new LetterResponse(letter.landlordId(), leaseId, "rent-revision", letter.sourceFingerprint(),
letter.sourceFingerprint(), letter.fileName(), letter.cryptoVersion(), letter.nonce(),
letter.wrappedKey(), letter.sha256(), letter.vaultPublicKey(),
Base64.getUrlEncoder().withoutPadding().encodeToString(letter.content()));
}
}
record ProofDownloadResponse(String landlordId, String leaseId, String documentType, String documentId,
String sourceFingerprint, String fileName, int cryptoVersion, String nonce, String wrappedKey,
String sha256, String vaultPublicKey, String ciphertext) {
static ProofDownloadResponse from(RentRevisionService.ProofDownload proof, String leaseId, String revisionId) {
return new ProofDownloadResponse(proof.landlordId(), leaseId, "rent-revision-proof", revisionId,
proof.sourceFingerprint(), proof.fileName(), proof.cryptoVersion(), proof.nonce(),
proof.wrappedKey(), proof.sha256(), proof.vaultPublicKey(),
Base64.getUrlEncoder().withoutPadding().encodeToString(proof.content()));
}
}
@@ -70,11 +123,13 @@ final class RentRevisionApi {
}
}
record ProofResponse(String fileName, String sha256, String authorId, Instant uploadedAt) {
record ProofResponse(String fileName, String sha256, int cryptoVersion, String nonce, String wrappedKey,
String vaultPublicKey, String authorId, Instant uploadedAt) {
static ProofResponse from(RentRevision.Proof proof) {
return proof == null
? null
: new ProofResponse(proof.fileName(), proof.sha256(), proof.authorId(), proof.uploadedAt());
: new ProofResponse(proof.fileName(), proof.sha256(), proof.cryptoVersion(), proof.nonce(),
proof.wrappedKey(), proof.vaultPublicKey(), proof.authorId(), proof.uploadedAt());
}
}
@@ -89,16 +144,17 @@ final class RentRevisionApi {
}
}
record RevisionResponse(String id, String leaseId, String status, long version, LocalDate annualRevisionDate,
LocalDate requestDeadline, LocalDate requestedOn, LocalDate effectiveOn, String seriesId,
boolean seriesOverridden, String indexSource, int referenceQuarter, int previousIndexYear, int newIndexYear,
BigDecimal previousIndex, BigDecimal newIndex, BigDecimal previousRent, BigDecimal rentCeiling,
BigDecimal chosenRent, BigDecimal previousCharges, BigDecimal chargeCeiling, BigDecimal chosenCharges,
List<OverrideResponse> warningOverrides, String letterSha256, String proposedBy, Instant proposedAt,
ApplicationResponse application) {
record RevisionResponse(String id, String landlordId, String leaseId, String status, long version,
LocalDate annualRevisionDate, LocalDate requestDeadline, LocalDate requestedOn, LocalDate effectiveOn,
String seriesId, boolean seriesOverridden, String indexSource, int referenceQuarter, int previousIndexYear,
int newIndexYear, BigDecimal previousIndex, BigDecimal newIndex, BigDecimal previousRent,
BigDecimal rentCeiling, BigDecimal chosenRent, BigDecimal previousCharges, BigDecimal chargeCeiling,
BigDecimal chosenCharges, List<OverrideResponse> warningOverrides, String letterSha256,
String sourceFingerprint, String proposedBy, Instant proposedAt, ApplicationResponse application) {
static RevisionResponse from(RentRevision revision) {
return new RevisionResponse(revision.id().value(), revision.leaseId().value(), revision.status().name(),
revision.version(), revision.window().annualRevisionDate(), revision.window().requestDeadline(),
return new RevisionResponse(revision.id().value(), revision.landlordId().value(),
revision.leaseId().value(), revision.status().name(), revision.version(),
revision.window().annualRevisionDate(), revision.window().requestDeadline(),
revision.request().requestedOn(), revision.request().effectiveOn(),
revision.indexReference().seriesId(), revision.indexReference().seriesOverridden(),
revision.indexReference().source().name(), revision.indexReference().referenceQuarter(),
@@ -108,7 +164,8 @@ final class RentRevisionApi {
revision.amounts().chosenRent(), revision.amounts().previousCharges(),
revision.amounts().chargeCeiling(), revision.amounts().chosenCharges(),
revision.warningOverrides().stream().map(OverrideResponse::from).toList(),
revision.request().letterSha256(), revision.request().proposedBy(), revision.request().proposedAt(),
revision.request().letterSha256(), revision.request().sourceFingerprint(),
revision.request().proposedBy(), revision.request().proposedAt(),
ApplicationResponse.from(revision.application()));
}
}
@@ -3,22 +3,16 @@ package fr.nessar.lmnporganizer.rental.adapter.in.web.rent;
import fr.nessar.lmnporganizer.rental.adapter.in.web.common.AuthenticatedLandlord;
import fr.nessar.lmnporganizer.rental.application.rent.RentRevisionService;
import fr.nessar.lmnporganizer.rental.application.rent.RentRevisionValidationFailure;
import fr.nessar.lmnporganizer.rental.application.shared.AttachmentStorageFailure;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseId;
import fr.nessar.lmnporganizer.rental.domain.rent.RentRevision;
import fr.nessar.lmnporganizer.rental.domain.rent.RentRevisionId;
import jakarta.validation.Valid;
import java.io.IOException;
import java.time.Clock;
import java.time.DateTimeException;
import java.time.LocalDate;
import java.time.ZoneId;
import java.time.format.DateTimeParseException;
import java.util.List;
import org.springframework.http.ContentDisposition;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
@@ -28,10 +22,7 @@ import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
@RestController
@RequestMapping("/api/leases/{leaseId}/rent-revisions")
@@ -60,11 +51,18 @@ public class RentRevisionController {
revisions.preview(authenticatedLandlord.id(principal), new LeaseId(leaseId), request.toApplication()));
}
@PostMapping("/prepare")
RentRevisionApi.PreparationResponse prepare(@AuthenticationPrincipal OidcUser principal,
@PathVariable String leaseId, @Valid @RequestBody RentRevisionApi.ProposalRequest request) {
return RentRevisionApi.PreparationResponse.from(revisions.prepare(authenticatedLandlord.id(principal),
new LeaseId(leaseId), request.toApplication(), principal.getSubject()));
}
@PostMapping
ResponseEntity<RentRevisionApi.RevisionResponse> propose(@AuthenticationPrincipal OidcUser principal,
@PathVariable String leaseId, @Valid @RequestBody RentRevisionApi.ProposalRequest request) {
@PathVariable String leaseId, @Valid @RequestBody RentRevisionApi.CommitRequest request) {
RentRevision revision = revisions.propose(authenticatedLandlord.id(principal), new LeaseId(leaseId),
request.toApplication(), principal.getSubject());
request.proposal().toApplication(), request.letter().toApplication(), principal.getSubject());
return ResponseEntity.status(HttpStatus.CREATED).body(RentRevisionApi.RevisionResponse.from(revision));
}
@@ -76,56 +74,33 @@ public class RentRevisionController {
}
@GetMapping("/{revisionId}/letter")
ResponseEntity<byte[]> letter(@AuthenticationPrincipal OidcUser principal, @PathVariable String leaseId,
RentRevisionApi.LetterResponse letter(@AuthenticationPrincipal OidcUser principal, @PathVariable String leaseId,
@PathVariable String revisionId) {
RentRevisionService.Letter letter = revisions.letter(authenticatedLandlord.id(principal), new LeaseId(leaseId),
new RentRevisionId(revisionId));
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION,
ContentDisposition.attachment().filename(letter.fileName()).build().toString())
.contentType(MediaType.APPLICATION_PDF).body(letter.content());
return RentRevisionApi.LetterResponse.from(letter, leaseId, revisionId);
}
@GetMapping("/{revisionId}/proof")
ResponseEntity<byte[]> proof(@AuthenticationPrincipal OidcUser principal, @PathVariable String leaseId,
@PathVariable String revisionId) {
RentRevisionApi.ProofDownloadResponse proof(@AuthenticationPrincipal OidcUser principal,
@PathVariable String leaseId, @PathVariable String revisionId) {
RentRevisionService.ProofDownload proof = revisions.proof(authenticatedLandlord.id(principal),
new LeaseId(leaseId), new RentRevisionId(revisionId));
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION,
ContentDisposition.attachment().filename(proof.fileName()).build().toString())
.contentType(MediaType.APPLICATION_PDF).body(proof.content());
return RentRevisionApi.ProofDownloadResponse.from(proof, leaseId, revisionId);
}
@PostMapping(value = "/{revisionId}/apply", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@PostMapping("/{revisionId}/apply")
RentRevisionApi.ApplyResponse apply(@AuthenticationPrincipal OidcUser principal, @PathVariable String leaseId,
@PathVariable String revisionId, @RequestParam String timeZone, @RequestParam String sentOn,
@RequestParam String deliveryMethod, @RequestParam String deliveryConfirmed,
@RequestPart(value = "proof", required = false) MultipartFile proof) {
ZoneId zone = timeZone(timeZone);
@PathVariable String revisionId, @Valid @RequestBody RentRevisionApi.ApplyRequest request) {
ZoneId zone = timeZone(request.timeZone());
RentRevisionService.ApplyResult result = revisions.apply(authenticatedLandlord.id(principal),
new LeaseId(leaseId), new RentRevisionId(revisionId),
new RentRevisionService.ApplyRequest(zone, date(sentOn), deliveryMethod(deliveryMethod),
deliveryConfirmed(deliveryConfirmed), upload(proof)),
principal.getSubject());
new LeaseId(leaseId), new RentRevisionId(revisionId), request.toApplication(), principal.getSubject());
LocalDate today = LocalDate.now(clock.withZone(zone));
return new RentRevisionApi.ApplyResponse(RentRevisionApi.RevisionResponse.from(result.revision()),
result.rentDues().stream().map(due -> RentDueApi.Response.from(due, today)).toList());
}
private static RentRevisionService.PdfUpload upload(MultipartFile file) {
if (file == null) {
return null;
}
try {
return new RentRevisionService.PdfUpload(file.getOriginalFilename(), file.getContentType(),
file.getBytes());
} catch (IOException failure) {
throw new AttachmentStorageFailure("PDF could not be opened.", failure);
}
}
private static RentRevision.DeliveryMethod deliveryMethod(String value) {
static RentRevision.DeliveryMethod deliveryMethod(String value) {
if (value == null) {
throw new RentRevisionValidationFailure(RentRevisionValidationFailure.Rule.INVALID_DELIVERY_METHOD);
}
@@ -151,22 +126,6 @@ public class RentRevisionController {
}
}
private static LocalDate date(String value) {
try {
return LocalDate.parse(value);
} catch (DateTimeParseException failure) {
throw new RentRevisionValidationFailure(RentRevisionValidationFailure.Rule.INVALID_SENT_ON);
}
}
private static boolean deliveryConfirmed(String value) {
if ("true".equals(value))
return true;
if ("false".equals(value))
return false;
throw new RentRevisionValidationFailure(RentRevisionValidationFailure.Rule.INVALID_DELIVERY_CONFIRMATION);
}
private static RentRevisionApi.FieldError fieldError(RentRevisionValidationFailure.Rule rule) {
return switch (rule) {
case INVALID_DELIVERY_METHOD -> new RentRevisionApi.FieldError("deliveryMethod", "invalid");
@@ -176,6 +135,8 @@ public class RentRevisionController {
case DELIVERY_CONFIRMATION_REQUIRED -> new RentRevisionApi.FieldError("deliveryConfirmed", "required");
case SENT_ON_IN_FUTURE -> new RentRevisionApi.FieldError("sentOn", "future");
case INVALID_PROOF -> new RentRevisionApi.FieldError("proof", "invalid");
case INVALID_LETTER -> new RentRevisionApi.FieldError("letter", "invalid");
case INVALID_SOURCE_FINGERPRINT -> new RentRevisionApi.FieldError("sourceFingerprint", "required");
};
}
}
@@ -0,0 +1,24 @@
package fr.nessar.lmnporganizer.rental.adapter.in.web.tenant;
import fr.nessar.lmnporganizer.rental.application.tenant.TenantAccountClosureService;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.PositiveOrZero;
import java.time.Instant;
import java.time.LocalDate;
final class TenantAccountClosureApi {
private TenantAccountClosureApi() {
}
record Request(@NotNull LocalDate closedOn, @NotNull @PositiveOrZero Long version) {
}
record Response(LocalDate closedOn, Instant recordedAt, String actorId, long tenantVersion, LocalDate evaluatedOn) {
static Response from(TenantAccountClosureService.State state) {
var closure = state.closure();
return new Response(closure == null ? null : closure.closedOn(),
closure == null ? null : closure.recordedAt(), closure == null ? null : closure.actorId(),
state.tenantVersion(), state.evaluatedOn());
}
}
}
@@ -1,72 +1,132 @@
package fr.nessar.lmnporganizer.rental.adapter.in.web.tenant;
import fr.nessar.lmnporganizer.rental.domain.shared.PhoneNumber;
import fr.nessar.lmnporganizer.rental.domain.tenant.Tenant;
import fr.nessar.lmnporganizer.rental.domain.tenant.TenantAttachment;
import jakarta.validation.constraints.AssertTrue;
import jakarta.validation.constraints.Email;
import fr.nessar.lmnporganizer.rental.domain.tenant.TenantEvidenceRetention;
import fr.nessar.lmnporganizer.rental.domain.tenant.TenantProfile;
import fr.nessar.lmnporganizer.rental.application.rentalapplication.ApplicationVaultAccess;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.PositiveOrZero;
import jakarta.validation.constraints.Size;
import java.time.Instant;
import java.time.LocalDate;
import java.util.List;
import java.util.Map;
import java.util.Objects;
final class TenantApi {
private TenantApi() {
}
record CreateRequest(@NotBlank String firstName, @NotBlank String lastName, @Email String email, String phone,
String address) {
@AssertTrue
boolean isPhoneValid() {
return TenantApi.isPhoneValid(phone);
}
record CreateRequest(@NotBlank @Size(max = 100) String id, @NotNull @Valid ProfileRequest profile) {
}
@AssertTrue
boolean isContactProvided() {
return TenantApi.isContactProvided(email, phone);
record RevisionRequest(@NotNull @Valid ProfileRequest profile, @NotNull @PositiveOrZero Long version) {
}
record ProfileRequest(@NotBlank @Size(max = 1400) String vaultPublicKey,
@NotNull @Valid EncryptedGroupRequest identityAndAddress, @NotNull @Valid EncryptedGroupRequest contacts,
boolean contractProfileComplete) {
TenantProfile toDomain() {
return new TenantProfile(vaultPublicKey, identityAndAddress.toDomain(), contacts.toDomain(),
contractProfileComplete);
}
}
record RevisionRequest(@NotBlank String firstName, @NotBlank String lastName, @Email String email, String phone,
String address, @NotNull @PositiveOrZero Long version) {
@AssertTrue
boolean isPhoneValid() {
return TenantApi.isPhoneValid(phone);
}
@AssertTrue
boolean isContactProvided() {
return TenantApi.isContactProvided(email, phone);
record EncryptedGroupRequest(@Min(1) @Max(1) int cryptoVersion, @NotBlank @Size(max = 32) String nonce,
@NotBlank @Size(max = 1400) String wrappedKey, @NotBlank @Size(max = 64) String sha256,
@NotBlank @Size(max = 44000) String ciphertext) {
TenantProfile.EncryptedGroup toDomain() {
return new TenantProfile.EncryptedGroup(cryptoVersion, nonce, wrappedKey, sha256, ciphertext);
}
}
private static boolean isPhoneValid(String phone) {
try {
new PhoneNumber(phone);
return true;
} catch (IllegalArgumentException exception) {
return false;
record ProfileResponse(String vaultPublicKey, EncryptedGroupResponse identityAndAddress,
EncryptedGroupResponse contacts, boolean contractProfileComplete) {
static ProfileResponse from(TenantProfile profile) {
return new ProfileResponse(profile.vaultPublicKey(),
EncryptedGroupResponse.from(profile.identityAndAddress()),
EncryptedGroupResponse.from(profile.contacts()), profile.contractProfileComplete());
}
}
private static boolean isContactProvided(String email, String phone) {
return email != null && !email.isBlank() || phone != null && !phone.isBlank();
}
record AttachmentResponse(String id, String fileName, String contentType, long size) {
static AttachmentResponse from(TenantAttachment attachment) {
return new AttachmentResponse(attachment.id(), attachment.fileName(), attachment.contentType(),
attachment.size());
record EncryptedGroupResponse(int cryptoVersion, String nonce, String wrappedKey, String sha256,
String ciphertext) {
static EncryptedGroupResponse from(TenantProfile.EncryptedGroup group) {
return new EncryptedGroupResponse(group.cryptoVersion(), group.nonce(), group.wrappedKey(), group.sha256(),
group.ciphertext());
}
}
record Response(String id, String firstName, String lastName, String email, String phone, String address,
List<AttachmentResponse> attachments, boolean contractProfileComplete, boolean referenced, long version) {
static Response from(Tenant tenant) {
return new Response(tenant.id().value(), tenant.firstName(), tenant.lastName(), tenant.email(),
tenant.phone(), tenant.address(),
tenant.attachments().stream().map(AttachmentResponse::from).toList(),
tenant.hasCompleteContractProfile(), tenant.isReferenced(), tenant.version());
record AttachmentRequest(@NotBlank @Size(max = 100) String id, @NotNull @PositiveOrZero Long version,
@Min(1) @Max(1) int cryptoVersion, @NotBlank @Size(max = 32) String nonce,
@NotBlank @Size(max = 1400) String wrappedKey, @NotBlank @Size(max = 64) String sha256,
@NotBlank @Size(max = 1400) String vaultPublicKey, @NotNull TenantEvidenceRetention.EvidencePurpose purpose,
@NotNull TenantEvidenceRetention.RetentionRule retentionRule,
@Size(max = 1400) String specificNeedVaultPublicKey,
@Valid EncryptedGroupRequest encryptedSpecificNeedExplanation, LocalDate specificNeedEndsOn) {
}
record AttachmentResponse(String id, int cryptoVersion, String nonce, String wrappedKey, String sha256,
long ciphertextSize, String vaultPublicKey, RetentionResponse retention,
OriginalApplicationResponse originalApplication) {
static AttachmentResponse from(TenantAttachment attachment,
Map<String, TenantEvidenceRetention.Evaluation> evaluations) {
evaluations = Objects.requireNonNull(evaluations, "Retention evaluations are required.");
return new AttachmentResponse(attachment.id(), attachment.cryptoVersion(), attachment.nonce(),
attachment.wrappedKey(), attachment.sha256(), attachment.ciphertextSize(),
attachment.vaultPublicKey(),
RetentionResponse.from(attachment.retention(),
attachment.retention() == null
? null
: Objects.requireNonNull(evaluations.get(attachment.id()),
"Retained evidence evaluation is required.")),
attachment.originalApplication() == null
? null
: OriginalApplicationResponse.from(attachment.originalApplication()));
}
}
record RetentionResponse(TenantEvidenceRetention.EvidenceSource source, String sourceReference,
String storageReference, int sourceGeneration, Instant createdAt, Instant retainedAt,
TenantEvidenceRetention.EvidencePurpose purpose, TenantEvidenceRetention.RetentionRule rule,
TenantEvidenceRetention.RetentionStatus status, LocalDate eligibleOn) {
static RetentionResponse from(TenantEvidenceRetention retention,
TenantEvidenceRetention.Evaluation evaluation) {
if (retention == null) {
return null;
}
evaluation = Objects.requireNonNull(evaluation, "Retained evidence evaluation is required.");
return new RetentionResponse(retention.source(), retention.sourceReference(), retention.storageReference(),
retention.sourceGeneration(), retention.createdAt(), retention.retainedAt(), retention.purpose(),
retention.rule(), evaluation.status(), evaluation.eligibleOn());
}
}
record OriginalApplicationResponse(String applicationId, String documentId, int documentRevision,
String encryptedApplicationKey, String vaultPublicKey) {
static OriginalApplicationResponse from(TenantAttachment.OriginalApplication source) {
return new OriginalApplicationResponse(source.applicationId(), source.documentId(),
source.documentRevision(), source.encryptedApplicationKey(), source.vaultPublicKey());
}
}
record Response(String id, String landlordId, ProfileResponse profile, List<AttachmentResponse> attachments,
EncryptedGroupResponse proposedGuarantees, boolean referenced, long version, String protectionStatus) {
static Response from(Tenant tenant, ApplicationVaultAccess.ProtectionStatus status,
Map<String, TenantEvidenceRetention.Evaluation> evaluations) {
return new Response(tenant.id().value(), tenant.landlordId().value(),
ProfileResponse.from(tenant.profile()),
tenant.attachments().stream().map(attachment -> AttachmentResponse.from(attachment, evaluations))
.toList(),
tenant.proposedGuarantees() == null
? null
: EncryptedGroupResponse.from(tenant.proposedGuarantees()),
tenant.isReferenced(), tenant.version(), status.wireValue());
}
}
}
@@ -2,16 +2,23 @@ package fr.nessar.lmnporganizer.rental.adapter.in.web.tenant;
import fr.nessar.lmnporganizer.rental.adapter.in.web.common.AuthenticatedLandlord;
import fr.nessar.lmnporganizer.rental.application.port.out.TenantAttachmentStorage;
import fr.nessar.lmnporganizer.rental.application.rentalapplication.ApplicationVaultAccess;
import fr.nessar.lmnporganizer.rental.application.shared.AttachmentStorageFailure;
import fr.nessar.lmnporganizer.rental.application.tenant.DeleteTenantService;
import fr.nessar.lmnporganizer.rental.application.tenant.TenantAttachmentService;
import fr.nessar.lmnporganizer.rental.application.tenant.TenantAccountClosureService;
import fr.nessar.lmnporganizer.rental.application.tenant.TenantDirectoryService;
import fr.nessar.lmnporganizer.rental.application.tenant.TenantEvidenceRetentionService;
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
import fr.nessar.lmnporganizer.rental.domain.tenant.TenantEvidenceRetention;
import fr.nessar.lmnporganizer.rental.domain.tenant.TenantId;
import fr.nessar.lmnporganizer.rental.domain.tenant.Tenant;
import jakarta.validation.Valid;
import java.io.IOException;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import org.springframework.core.io.InputStreamResource;
import org.springframework.http.ContentDisposition;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
@@ -25,6 +32,7 @@ import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
@@ -33,41 +41,73 @@ import org.springframework.web.multipart.MultipartFile;
@RequestMapping("/api/tenants")
public class TenantController {
private final TenantDirectoryService tenants;
private final TenantAccountClosureService accountClosures;
private final TenantEvidenceRetentionService retention;
private final DeleteTenantService deletion;
private final TenantAttachmentService attachments;
private final ApplicationVaultAccess vaultAccess;
private final AuthenticatedLandlord authenticatedLandlord;
public TenantController(TenantDirectoryService tenants, DeleteTenantService deletion,
TenantAttachmentService attachments, AuthenticatedLandlord authenticatedLandlord) {
public TenantController(TenantDirectoryService tenants, TenantAccountClosureService accountClosures,
TenantEvidenceRetentionService retention, DeleteTenantService deletion, TenantAttachmentService attachments,
ApplicationVaultAccess vaultAccess, AuthenticatedLandlord authenticatedLandlord) {
this.tenants = tenants;
this.accountClosures = accountClosures;
this.retention = retention;
this.deletion = deletion;
this.attachments = attachments;
this.vaultAccess = vaultAccess;
this.authenticatedLandlord = authenticatedLandlord;
}
@GetMapping
List<TenantApi.Response> list(@AuthenticationPrincipal OidcUser principal) {
return tenants.list(authenticatedLandlord.id(principal)).stream().map(TenantApi.Response::from).toList();
var landlordId = authenticatedLandlord.id(principal);
List<Tenant> listed = tenants.list(landlordId);
Map<TenantId, Map<String, TenantEvidenceRetention.Evaluation>> evaluated = Objects
.requireNonNull(retention.evaluateAll(listed), "Retention evaluation is required.");
if (listed.isEmpty())
return List.of();
var statuses = vaultAccess.statusesFor(landlordId);
return listed.stream().map(tenant -> TenantApi.Response.from(tenant,
statuses.apply(tenant.profile().vaultPublicKey()), evaluated.get(tenant.id()))).toList();
}
@GetMapping("/{tenantId}")
TenantApi.Response get(@AuthenticationPrincipal OidcUser principal, @PathVariable String tenantId) {
return TenantApi.Response.from(tenants.get(authenticatedLandlord.id(principal), new TenantId(tenantId)));
var landlordId = authenticatedLandlord.id(principal);
return response(landlordId, tenants.get(landlordId, new TenantId(tenantId)));
}
@GetMapping("/{tenantId}/account-closure")
TenantAccountClosureApi.Response accountClosure(@AuthenticationPrincipal OidcUser principal,
@PathVariable String tenantId) {
return TenantAccountClosureApi.Response
.from(accountClosures.get(authenticatedLandlord.id(principal), new TenantId(tenantId)));
}
@PostMapping("/{tenantId}/account-closure")
TenantAccountClosureApi.Response closeAccount(@AuthenticationPrincipal OidcUser principal,
@PathVariable String tenantId, @Valid @RequestBody TenantAccountClosureApi.Request request) {
return TenantAccountClosureApi.Response.from(accountClosures.close(authenticatedLandlord.id(principal),
new TenantId(tenantId), request.closedOn(), request.version(), principal.getSubject()));
}
@PostMapping
ResponseEntity<TenantApi.Response> create(@AuthenticationPrincipal OidcUser principal,
@Valid @RequestBody TenantApi.CreateRequest request) {
var created = tenants.create(authenticatedLandlord.id(principal), details(request));
return ResponseEntity.status(HttpStatus.CREATED).body(TenantApi.Response.from(created));
var created = tenants.create(authenticatedLandlord.id(principal), new TenantId(request.id()),
request.profile().toDomain());
var landlordId = authenticatedLandlord.id(principal);
return ResponseEntity.status(HttpStatus.CREATED).body(response(landlordId, created));
}
@PutMapping("/{tenantId}")
TenantApi.Response revise(@AuthenticationPrincipal OidcUser principal, @PathVariable String tenantId,
@Valid @RequestBody TenantApi.RevisionRequest request) {
return TenantApi.Response.from(tenants.revise(authenticatedLandlord.id(principal), new TenantId(tenantId),
details(request.firstName(), request.lastName(), request.email(), request.phone(), request.address()),
request.version()));
var landlordId = authenticatedLandlord.id(principal);
return response(landlordId,
tenants.revise(landlordId, new TenantId(tenantId), request.profile().toDomain(), request.version()));
}
@DeleteMapping("/{tenantId}")
@@ -78,52 +118,57 @@ public class TenantController {
@PostMapping(path = "/{tenantId}/attachments", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
ResponseEntity<TenantApi.AttachmentResponse> upload(@AuthenticationPrincipal OidcUser principal,
@PathVariable String tenantId, @RequestPart("file") MultipartFile file) {
var stored = attachments.upload(authenticatedLandlord.id(principal), new TenantId(tenantId), upload(file));
return ResponseEntity.status(HttpStatus.CREATED).body(TenantApi.AttachmentResponse.from(stored));
@PathVariable String tenantId, @Valid @RequestPart("metadata") TenantApi.AttachmentRequest request,
@RequestPart("ciphertext") MultipartFile ciphertext) {
var landlordId = authenticatedLandlord.id(principal);
var id = new TenantId(tenantId);
var stored = attachments.upload(landlordId, id, request.version(), upload(request, ciphertext));
var current = Objects.requireNonNull(tenants.get(landlordId, id),
"Tenant after attachment upload is required.");
Map<String, TenantEvidenceRetention.Evaluation> evaluations = Objects
.requireNonNull(retention.evaluate(current), "Retention evaluation is required.");
return ResponseEntity.status(HttpStatus.CREATED).body(TenantApi.AttachmentResponse.from(stored, evaluations));
}
@GetMapping("/{tenantId}/attachments/{attachmentId}")
ResponseEntity<InputStreamResource> download(@AuthenticationPrincipal OidcUser principal,
@PathVariable String tenantId, @PathVariable String attachmentId) {
var download = attachments.download(authenticatedLandlord.id(principal), new TenantId(tenantId), attachmentId);
return ResponseEntity.ok().contentType(mediaType(download.attachment().contentType()))
.contentLength(download.attachment().size())
.header(HttpHeaders.CONTENT_DISPOSITION,
ContentDisposition.attachment().filename(download.attachment().fileName()).build().toString())
.body(new InputStreamResource(download.content()));
return ResponseEntity.ok().contentType(MediaType.APPLICATION_OCTET_STREAM)
.contentLength(download.attachment().ciphertextSize()).header(HttpHeaders.CACHE_CONTROL, "no-store")
.header("Referrer-Policy", "no-referrer").body(new InputStreamResource(download.content()));
}
@DeleteMapping("/{tenantId}/attachments/{attachmentId}")
ResponseEntity<Void> deleteAttachment(@AuthenticationPrincipal OidcUser principal, @PathVariable String tenantId,
@PathVariable String attachmentId) {
attachments.delete(authenticatedLandlord.id(principal), new TenantId(tenantId), attachmentId);
@PathVariable String attachmentId, @RequestParam long version) {
attachments.delete(authenticatedLandlord.id(principal), new TenantId(tenantId), attachmentId, version);
return ResponseEntity.noContent().build();
}
private static TenantDirectoryService.ContactDetails details(TenantApi.CreateRequest request) {
return details(request.firstName(), request.lastName(), request.email(), request.phone(), request.address());
private TenantApi.Response response(LandlordId landlordId, Tenant tenant) {
return response(landlordId, tenant, retention.evaluate(tenant));
}
private static TenantDirectoryService.ContactDetails details(String firstName, String lastName, String email,
String phone, String address) {
return new TenantDirectoryService.ContactDetails(firstName, lastName, email, phone, address);
private TenantApi.Response response(LandlordId landlordId, Tenant tenant,
Map<String, TenantEvidenceRetention.Evaluation> evaluations) {
return TenantApi.Response.from(tenant, vaultAccess.status(landlordId, tenant.profile().vaultPublicKey()),
evaluations);
}
private static TenantAttachmentStorage.Upload upload(MultipartFile file) {
private static TenantAttachmentStorage.Upload upload(TenantApi.AttachmentRequest request,
MultipartFile ciphertext) {
try {
return new TenantAttachmentStorage.Upload(file.getOriginalFilename(), file.getContentType(), file.getSize(),
file.getInputStream());
return new TenantAttachmentStorage.Upload(request.id(), request.cryptoVersion(), request.nonce(),
request.wrappedKey(), request.sha256(), ciphertext.getSize(), request.vaultPublicKey(),
ciphertext.getInputStream(), request.purpose(), request.retentionRule(),
request.specificNeedVaultPublicKey(),
request.encryptedSpecificNeedExplanation() == null
? null
: request.encryptedSpecificNeedExplanation().toDomain(),
request.specificNeedEndsOn());
} catch (IOException failure) {
throw new AttachmentStorageFailure("Tenant attachment could not be opened.", failure);
}
}
private static MediaType mediaType(String contentType) {
try {
return MediaType.parseMediaType(contentType);
} catch (IllegalArgumentException exception) {
return MediaType.APPLICATION_OCTET_STREAM;
}
}
}
@@ -68,6 +68,20 @@ public final class FileSystemApplicationDocumentStorage implements ApplicationDo
}
}
@Override
public long retainedSize(String storageKey) {
Path source = path(storageKey);
try {
if (!Files.isRegularFile(source, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Encrypted application document not found.");
}
return Files.size(source);
} catch (IOException | RuntimeException failure) {
throw new AttachmentStorageFailure("Retained encrypted application document size could not be read.",
failure);
}
}
@Override
public void discard(RentalApplicationId applicationId, EncryptedApplicationDocument document) {
try {
@@ -120,6 +134,19 @@ public final class FileSystemApplicationDocumentStorage implements ApplicationDo
return path;
}
private Path path(String storageKey) {
if (storageKey == null || storageKey.isBlank()) {
throw new AttachmentStorageFailure("Invalid encrypted application document path.",
new IllegalArgumentException("Storage key is required."));
}
Path path = root.resolve(storageKey).normalize();
if (path.equals(root) || !path.startsWith(root)) {
throw new AttachmentStorageFailure("Invalid encrypted application document path.",
new IllegalArgumentException());
}
return path;
}
private static AttachmentStorageFailure failure(String message, Throwable failure) {
return failure instanceof AttachmentStorageFailure storageFailure
? storageFailure
@@ -48,7 +48,8 @@ public final class FileSystemPropertyConditionAttachmentStorage implements Prope
@Override
public String store(LeaseId leaseId, String reference, String extension, byte[] content) {
sweepCommittedTombstones();
if (!"webp".equals(extension) && !"png".equals(extension) && !"pdf".equals(extension))
if (!"webp".equals(extension) && !"png".equals(extension) && !"pdf".equals(extension)
&& !"bin".equals(extension) && !"pdf.enc".equals(extension))
throw new IllegalArgumentException("Unsupported condition attachment.");
Path target = inside(root.resolve(leaseId.value()).resolve(reference + "." + extension));
Path temporary = insideStaging(staging.resolve(leaseId.value()).resolve(reference + "." + extension));
@@ -14,12 +14,20 @@ import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.security.DigestInputStream;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Instant;
import java.util.Comparator;
import java.util.HexFormat;
import java.util.List;
import java.util.Objects;
import java.util.UUID;
import java.util.function.Predicate;
import java.util.function.Supplier;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
@Component
@@ -31,30 +39,49 @@ public class FileSystemTenantAttachmentStorage implements TenantAttachmentStorag
private final Path root;
private final Path stagingRoot;
private final Instant startedAt;
private final Supplier<String> physicalIdGenerator;
@Autowired
public FileSystemTenantAttachmentStorage(RentalStorageProperties properties) {
this(properties, () -> UUID.randomUUID().toString());
}
FileSystemTenantAttachmentStorage(RentalStorageProperties properties, Supplier<String> physicalIdGenerator) {
root = properties.attachments().toAbsolutePath().normalize();
stagingRoot = root.resolve(".staging");
startedAt = Instant.now();
this.physicalIdGenerator = Objects.requireNonNull(physicalIdGenerator, "Physical id generator is required.");
}
@Override
public TenantAttachment store(TenantId tenantId, String attachmentId, Upload upload) {
public TenantAttachment store(TenantId tenantId, Upload upload) {
sweepCommittedTombstones();
Path tenantDirectory = insideRoot(root.resolve(tenantId.value()), "Invalid tenant attachment directory.");
Path destination = insideRoot(tenantDirectory.resolve(attachmentId), "Invalid tenant attachment path.");
String physicalId = Objects.requireNonNull(physicalIdGenerator.get(), "Physical id generator returned null.");
Path destination = insideRoot(tenantDirectory.resolve(physicalId), "Invalid tenant attachment path.");
boolean created = false;
try {
Files.createDirectories(tenantDirectory);
try (InputStream content = upload.content();
try (var content = new DigestInputStream(upload.content(), sha256());
OutputStream output = Files.newOutputStream(destination, StandardOpenOption.CREATE_NEW)) {
content.transferTo(output);
created = true;
long copied = copy(content, output, upload.ciphertextSize());
if (copied != upload.ciphertextSize()
|| !HexFormat.of().formatHex(content.getMessageDigest().digest()).equals(upload.sha256())) {
throw new IOException("Tenant attachment ciphertext size or digest changed while storing.");
}
}
return new TenantAttachment(attachmentId, safeFileName(upload.fileName()),
contentType(upload.contentType()), upload.size(), root.relativize(destination).toString());
return new TenantAttachment(upload.id(), upload.cryptoVersion(), upload.nonce(), upload.wrappedKey(),
upload.sha256(), upload.ciphertextSize(), upload.vaultPublicKey(),
root.relativize(destination).toString(), null, null);
} catch (IOException | RuntimeException failure) {
try {
Files.deleteIfExists(destination);
} catch (IOException cleanupFailure) {
failure.addSuppressed(cleanupFailure);
if (created) {
try {
Files.deleteIfExists(destination);
} catch (IOException cleanupFailure) {
failure.addSuppressed(cleanupFailure);
}
}
throw failure("Tenant attachment could not be stored.", failure);
}
@@ -207,6 +234,50 @@ public class FileSystemTenantAttachmentStorage implements TenantAttachmentStorag
}
}
/**
* Removes ciphertext files left by a process crash before the tenant metadata
* commit.
*/
public void recoverOrphanedUploads(Predicate<String> metadataReferences) {
Objects.requireNonNull(metadataReferences, "Metadata reference predicate is required.");
if (!Files.isDirectory(root, LinkOption.NOFOLLOW_LINKS)) {
return;
}
try (var paths = Files.walk(root)) {
paths.filter(path -> !path.startsWith(stagingRoot))
.filter(path -> Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS))
.filter(this::isTenantCiphertext).filter(this::predatesStartup)
.forEach(path -> recoverOrphanedUpload(path, metadataReferences));
} catch (IOException | RuntimeException failure) {
throw failure("Orphaned tenant attachment uploads could not be recovered.", failure);
}
}
private boolean isTenantCiphertext(Path path) {
Path relative = root.relativize(path);
return relative.getNameCount() == 2 && !relative.getName(0).toString().startsWith(".");
}
private boolean predatesStartup(Path path) {
try {
return Files.getLastModifiedTime(path, LinkOption.NOFOLLOW_LINKS).toInstant().isBefore(startedAt);
} catch (IOException | RuntimeException failure) {
throw failure("Tenant attachment timestamp could not be read.", failure);
}
}
private void recoverOrphanedUpload(Path path, Predicate<String> metadataReferences) {
String storageKey = root.relativize(path).toString();
if (metadataReferences.test(storageKey)) {
return;
}
try {
Files.deleteIfExists(path);
} catch (IOException | RuntimeException failure) {
throw failure("Orphaned tenant attachment could not be discarded.", failure);
}
}
private boolean hasArtifacts(Path directory) {
return Files.isRegularFile(directory.resolve(STAGED), LinkOption.NOFOLLOW_LINKS)
|| Files.isRegularFile(directory.resolve(BACKUP), LinkOption.NOFOLLOW_LINKS)
@@ -291,18 +362,26 @@ public class FileSystemTenantAttachmentStorage implements TenantAttachmentStorag
}
}
private static String safeFileName(String supplied) {
if (supplied == null || supplied.isBlank()) {
return "attachment";
private static long copy(InputStream content, OutputStream output, long expectedSize) throws IOException {
byte[] buffer = new byte[8192];
long copied = 0;
int read;
while ((read = content.read(buffer)) != -1) {
if (copied > expectedSize - read) {
throw new IOException("Tenant attachment ciphertext is larger than declared.");
}
output.write(buffer, 0, read);
copied += read;
}
String trimmed = supplied.trim();
int separator = Math.max(trimmed.lastIndexOf('/'), trimmed.lastIndexOf('\\'));
String name = trimmed.substring(separator + 1);
return name.isBlank() ? "attachment" : name;
return copied;
}
private static String contentType(String supplied) {
return supplied == null || supplied.isBlank() ? "application/octet-stream" : supplied.trim();
private static MessageDigest sha256() {
try {
return MessageDigest.getInstance("SHA-256");
} catch (NoSuchAlgorithmException impossible) {
throw new IllegalStateException("SHA-256 is unavailable.", impossible);
}
}
private static FileDeletion files(StagedDeletion deletion) {
@@ -19,6 +19,7 @@ import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;
import org.springframework.dao.OptimisticLockingFailureException;
import org.springframework.data.annotation.Id;
import org.springframework.data.annotation.Version;
import org.springframework.data.mongodb.core.MongoTemplate;
@@ -28,6 +29,7 @@ import org.springframework.data.mongodb.core.mapping.Field;
import org.springframework.data.mongodb.core.mapping.FieldType;
import org.springframework.data.mongodb.core.query.Criteria;
import org.springframework.data.mongodb.core.query.Query;
import org.springframework.data.mongodb.core.query.Update;
import org.springframework.stereotype.Repository;
@Repository
@@ -62,6 +64,17 @@ public class MongoApartmentRepository implements ApartmentRepository {
apartment.version(), document::withVersion));
}
@Override
public void coordinateForLeaseDocument(Apartment apartment) {
Query query = scopedQuery(apartment.landlordId(), apartment.id());
query.addCriteria(Criteria.where("version").is(apartment.version()));
if (mongoTemplate.updateFirst(query,
new Update().inc("leaseDocumentSourceMutationSequence", 1).set("version", apartment.version()),
MongoApartmentDocument.class).getMatchedCount() != 1) {
throw new OptimisticLockingFailureException("Apartment changed concurrently.");
}
}
@Override
public boolean delete(Apartment apartment) {
Query query = scopedQuery(apartment.landlordId(), apartment.id());
@@ -1,10 +1,13 @@
package fr.nessar.lmnporganizer.rental.adapter.out.mongodb.application;
import fr.nessar.lmnporganizer.rental.adapter.out.mongodb.LandlordScopedMongoWriter;
import fr.nessar.lmnporganizer.rental.domain.application.ApplicationVaultRetirement;
import fr.nessar.lmnporganizer.rental.domain.application.ApplicationVaultReset;
import fr.nessar.lmnporganizer.rental.domain.application.ApplicationVaultResetRepository;
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
import java.time.Instant;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import org.springframework.data.annotation.Id;
import org.springframework.data.annotation.Version;
@@ -30,6 +33,7 @@ public class MongoApplicationVaultResetRepository implements ApplicationVaultRes
public ApplicationVaultReset save(ApplicationVaultReset reset) {
ApplicationVaultResetDocument document = new ApplicationVaultResetDocument(reset.landlordId().value(),
reset.operationId(), reset.startedAt(), reset.completedAt(),
reset.retirements().stream().map(MongoApplicationVaultResetRepository::toDocument).toList(),
reset.version() < 0 ? null : reset.version());
return toDomain(LandlordScopedMongoWriter.saveLandlord(mongoTemplate, document, document.landlordId(),
reset.version(), document::withVersion));
@@ -37,14 +41,30 @@ public class MongoApplicationVaultResetRepository implements ApplicationVaultRes
private static ApplicationVaultReset toDomain(ApplicationVaultResetDocument document) {
return ApplicationVaultReset.reconstitute(new LandlordId(document.landlordId()), document.operationId(),
document.startedAt(), document.completedAt(), document.version() == null ? -1 : document.version());
document.startedAt(), document.completedAt(),
Objects.requireNonNull(document.retirements(), "Application vault retirements are required.").stream()
.map(MongoApplicationVaultResetRepository::toDomain).toList(),
document.version() == null ? -1 : document.version());
}
private static ApplicationVaultRetirementDocument toDocument(ApplicationVaultRetirement retirement) {
return new ApplicationVaultRetirementDocument(retirement.operationId(), retirement.vaultPublicKey(),
retirement.eligibleAt());
}
private static ApplicationVaultRetirement toDomain(ApplicationVaultRetirementDocument document) {
return new ApplicationVaultRetirement(document.operationId(), document.vaultPublicKey(), document.eligibleAt());
}
}
@Document("applicationVaultResets")
record ApplicationVaultResetDocument(@Id String landlordId, String operationId, Instant startedAt, Instant completedAt,
@Version Long version) {
List<ApplicationVaultRetirementDocument> retirements, @Version Long version) {
ApplicationVaultResetDocument withVersion(long updatedVersion) {
return new ApplicationVaultResetDocument(landlordId, operationId, startedAt, completedAt, updatedVersion);
return new ApplicationVaultResetDocument(landlordId, operationId, startedAt, completedAt, retirements,
updatedVersion);
}
}
record ApplicationVaultRetirementDocument(String operationId, String vaultPublicKey, Instant eligibleAt) {
}
@@ -71,7 +71,7 @@ public class MongoRentalApplicationRepository implements RentalApplicationReposi
if (key == null)
return false;
Query query = Query.query(Criteria.where("_id").is(key.applicationId()).and("documents")
.elemMatch(Criteria.where("id").is(key.documentId()).and("revision").is(key.revision())));
.elemMatch(Criteria.where("_id").is(key.documentId()).and("revision").is(key.revision())));
return mongoTemplate.exists(query, RentalApplicationDocument.class);
}
@@ -88,8 +88,9 @@ public class MongoRentalApplicationRepository implements RentalApplicationReposi
private static RentalApplicationDocument toDocument(RentalApplication application) {
return new RentalApplicationDocument(application.id().value(), application.apartmentId().value(),
application.landlordId().value(), application.state(), application.revision(), application.createdAt(),
application.lastActivityAt(), application.submittedAt(), application.purgeEligibleAt(),
application.landlordId().value(), application.vaultPublicKey(), application.state(),
application.revision(), application.createdAt(), application.lastActivityAt(),
application.submittedAt(), application.purgeEligibleAt(),
application.selectedTenantId() == null ? null : application.selectedTenantId().value(),
application.encryptedData(), application.documents(), application.capabilityHash(),
application.encryptedApplicationKey(), application.cryptoVersion(),
@@ -98,9 +99,9 @@ public class MongoRentalApplicationRepository implements RentalApplicationReposi
private static RentalApplication toDomain(RentalApplicationDocument document) {
return RentalApplication.reconstitute(new RentalApplicationId(document.id()),
new ApartmentId(document.apartmentId()), new LandlordId(document.landlordId()), document.state(),
document.revision(), document.createdAt(), document.lastActivityAt(), document.submittedAt(),
document.purgeEligibleAt(),
new ApartmentId(document.apartmentId()), new LandlordId(document.landlordId()),
document.vaultPublicKey(), document.state(), document.revision(), document.createdAt(),
document.lastActivityAt(), document.submittedAt(), document.purgeEligibleAt(),
document.selectedTenantId() == null ? null : new TenantId(document.selectedTenantId()),
document.encryptedData(), document.documents(), document.capabilityHash(),
document.encryptedApplicationKey(), document.cryptoVersion(),
@@ -112,15 +113,15 @@ public class MongoRentalApplicationRepository implements RentalApplicationReposi
@CompoundIndexes({
@CompoundIndex(name = "rental_application_expiration", def = "{'state': 1, 'lastActivityAt': 1, '_id': 1}"),
@CompoundIndex(name = "rental_application_apartment", def = "{'landlordId': 1, 'apartmentId': 1}")})
record RentalApplicationDocument(@Id String id, String apartmentId, String landlordId, RentalApplicationState state,
int revision, Instant createdAt, Instant lastActivityAt, Instant submittedAt, Instant purgeEligibleAt,
String selectedTenantId, List<EncryptedApplicationData> encryptedData,
record RentalApplicationDocument(@Id String id, String apartmentId, String landlordId, String vaultPublicKey,
RentalApplicationState state, int revision, Instant createdAt, Instant lastActivityAt, Instant submittedAt,
Instant purgeEligibleAt, String selectedTenantId, List<EncryptedApplicationData> encryptedData,
List<EncryptedApplicationDocument> documents, String capabilityHash, String encryptedApplicationKey,
int cryptoVersion, @Version Long version) {
RentalApplicationDocument withVersion(long updatedVersion) {
return new RentalApplicationDocument(id, apartmentId, landlordId, state, revision, createdAt, lastActivityAt,
submittedAt, purgeEligibleAt, selectedTenantId, encryptedData, documents, capabilityHash,
encryptedApplicationKey, cryptoVersion, updatedVersion);
return new RentalApplicationDocument(id, apartmentId, landlordId, vaultPublicKey, state, revision, createdAt,
lastActivityAt, submittedAt, purgeEligibleAt, selectedTenantId, encryptedData, documents,
capabilityHash, encryptedApplicationKey, cryptoVersion, updatedVersion);
}
}
@@ -4,6 +4,7 @@ import fr.nessar.lmnporganizer.rental.adapter.out.mongodb.LandlordScopedMongoWri
import fr.nessar.lmnporganizer.rental.domain.condition.*;
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseId;
import fr.nessar.lmnporganizer.rental.domain.tenant.TenantProfile.EncryptedGroup;
import java.math.BigDecimal;
import java.time.Instant;
import java.time.LocalDate;
@@ -60,34 +61,37 @@ public class MongoPropertyConditionReportRepository implements PropertyCondition
private ReportDocument document(PropertyConditionReport r) {
return new ReportDocument(r.id().value(), r.landlordId().value(), r.leaseId().value(), r.type().name(),
r.occurredOn(), r.timeZone(),
r.vaultPublicKey(), r.occurredOn(), r.timeZone(),
r.participants().stream()
.map(x -> new ParticipantDocument(x.partyId(), x.role().name(), x.name(), x.attendance().name(),
x.representativeName()))
.map(x -> new ParticipantDocument(x.partyId(), x.role().name(), x.name(),
encrypted(x.identityAndAddress()), x.attendance().name(),
encrypted(x.representativeName())))
.toList(),
r.forwardingAddresses().stream().map(x -> new AddressDocument(x.tenantPartyId(), x.address())).toList(),
r.forwardingAddresses().stream()
.map(x -> new AddressDocument(x.tenantPartyId(), encrypted(x.address()))).toList(),
r.zones().stream()
.map(x -> new ZoneDocument(x.id(), x.privateAreaId(), x.label(), x.equipment().name()))
.toList(),
r.observations().stream()
.map(x -> new ObservationDocument(x.subjectId(), x.state().name(), x.observation())).toList(),
.map(x -> new ObservationDocument(x.subjectId(), x.state().name(), encrypted(x.observation())))
.toList(),
r.meterReadings().stream()
.map(x -> new MeterDocument(x.id(), x.type().name(), x.state().name(), x.index(), x.unit(),
x.label(), x.observation()))
encrypted(x.label()), encrypted(x.observation())))
.toList(),
r.accessMeans().stream()
.map(x -> new AccessDocument(x.id(), x.type().name(), x.destination(), x.quantity(),
x.observation()))
.toList(),
r.inventoryConditions().stream()
.map(x -> new InventoryDocument(x.inventoryLineId(), x.privateAreaId(), x.privateAreaLabel(),
x.designation(), x.contractualQuantity(), x.contractualDescription(),
x.observedQuantity(), x.observedDescription(),
x.splits().stream()
.map(s -> new SplitDocument(s.inventoryLineId(), s.state().name(), s.quantity(),
s.observation()))
.toList()))
.map(x -> new AccessDocument(x.id(), x.type().name(), encrypted(x.destination()), x.quantity(),
encrypted(x.observation())))
.toList(),
r.inventoryConditions().stream().map(x -> new InventoryDocument(x.inventoryLineId(), x.privateAreaId(),
x.privateAreaLabel(), x.designation(), x.contractualQuantity(), x.contractualDescription(),
x.observedQuantity(), encrypted(x.observedDescription()),
x.splits().stream()
.map(s -> new SplitDocument(s.inventoryLineId(), s.state().name(), s.quantity(),
encrypted(s.observation()),
s.observationCode() == null ? null : s.observationCode().name()))
.toList(),
x.descriptionReconciled())).toList(),
r.photos().stream()
.map(x -> new PhotoDocument(x.reference(), x.target().kind().name(), x.target().privateAreaId(),
x.target().subjectId(), x.storageKey(), x.sha256(), x.width(), x.height(),
@@ -114,33 +118,39 @@ public class MongoPropertyConditionReportRepository implements PropertyCondition
d.confirmations().technical()),
d.participants().stream()
.map(x -> new ReportParticipant(x.partyId(), ReportPartyRole.valueOf(x.role()), x.name(),
ParticipantAttendance.valueOf(x.attendance()), x.representativeName()))
.toList(),
d.forwardingAddresses().stream().map(x -> new ForwardingAddress(x.tenantPartyId(), x.address()))
encrypted(x.identityAndAddress()), ParticipantAttendance.valueOf(x.attendance()),
encrypted(x.representativeName())))
.toList(),
d.forwardingAddresses().stream()
.map(x -> new ForwardingAddress(x.tenantPartyId(), encrypted(x.address()))).toList(),
d.zones().stream()
.map(x -> new ConditionZone(x.id(), x.privateAreaId(), x.label(),
ZoneEquipment.valueOf(x.equipment())))
.toList(),
d.observations().stream()
.map(x -> new ConditionObservation(x.subjectId(), ConditionState.valueOf(x.state()),
x.observation()))
encrypted(x.observation())))
.toList(),
d.meterReadings().stream()
.map(x -> new MeterReading(x.id(), MeterType.valueOf(x.type()),
MeterReadingState.valueOf(x.state()), x.index(), x.unit(), x.label(), x.observation()))
MeterReadingState.valueOf(x.state()), x.index(), x.unit(), encrypted(x.label()),
encrypted(x.observation())))
.toList(),
d.accessMeans().stream()
.map(x -> new AccessMeans(x.id(), AccessMeansType.valueOf(x.type()), x.destination(),
x.quantity(), x.observation()))
.map(x -> new AccessMeans(x.id(), AccessMeansType.valueOf(x.type()), encrypted(x.destination()),
x.quantity(), encrypted(x.observation())))
.toList(),
d.inventoryConditions().stream().map(x -> new InventoryCondition(x.inventoryLineId(), x.privateAreaId(),
x.privateAreaLabel(), x.designation(), x.contractualQuantity(), x.contractualDescription(),
x.observedQuantity(), x.observedDescription(),
x.splits().stream()
.map(s -> new InventoryQuantitySplit(s.inventoryLineId(),
ConditionState.valueOf(s.state()), s.quantity(), s.observation()))
.toList()))
d.inventoryConditions().stream()
.map(x -> new InventoryCondition(x.inventoryLineId(), x.privateAreaId(), x.privateAreaLabel(),
x.designation(), x.contractualQuantity(), x.contractualDescription(),
x.observedQuantity(), encrypted(x.observedDescription()),
x.splits().stream().map(s -> new InventoryQuantitySplit(s.inventoryLineId(),
ConditionState.valueOf(s.state()), s.quantity(), encrypted(s.observation()),
s.observationCode() == null
? null
: InventoryObservationCode.valueOf(s.observationCode())))
.toList(),
x.descriptionReconciled()))
.toList(),
d.photos().stream()
.map(x -> new ConditionPhoto(x.reference(),
@@ -156,26 +166,27 @@ public class MongoPropertyConditionReportRepository implements PropertyCondition
d.revisions().stream().map(this::revision).toList(), finalization(d.finalization()),
d.deliveryAttestations().stream().map(this::delivery).toList(),
d.entrySnapshot() == null ? null : snapshot(d.entrySnapshot()),
PropertyConditionReportStatus.valueOf(d.status()), d.version() == null ? -1 : d.version());
PropertyConditionReportStatus.valueOf(d.status()), d.version() == null ? -1 : d.version(),
d.vaultPublicKey());
}
private static AbsenceDocument absence(AuditedPhotoAbsence x) {
return new AbsenceDocument(x.reason(), x.auditedBy(), x.auditedAt());
return new AbsenceDocument(encrypted(x.reason()), x.auditedBy(), x.auditedAt());
}
private static AuditedPhotoAbsence absence(AbsenceDocument x) {
return new AuditedPhotoAbsence(x.reason(), x.auditedBy(), x.auditedAt());
return new AuditedPhotoAbsence(encrypted(x.reason()), x.auditedBy(), x.auditedAt());
}
private SupplementDocument supplement(PropertyConditionReportSupplement x) {
return new SupplementDocument(x.id(), x.type().name(), file(x.signedPdf()),
x.photos().stream().map(this::photo).toList(), x.uploadedBy(), x.uploadedAt(), x.requestedOn(),
x.heatingPeriodStartedOn(), x.acceptedAdditions(),
x.heatingPeriodStartedOn(), encrypted(x.acceptedAdditions()),
x.audit() instanceof OnTimeSupplement onTime
? onTime.deadlineOn()
: ((LateSupplementAddition) x.audit()).deadlineOn(),
x.audit() instanceof LateSupplementAddition late ? late.ruleVersion() : null,
x.audit() instanceof LateSupplementAddition late ? late.reason() : null,
x.audit() instanceof LateSupplementAddition late ? encrypted(late.reason()) : null,
x.audit() instanceof LateSupplementAddition late ? late.auditedBy() : null,
x.audit() instanceof LateSupplementAddition late ? late.auditedAt() : null);
}
@@ -183,23 +194,25 @@ public class MongoPropertyConditionReportRepository implements PropertyCondition
private PropertyConditionReportSupplement supplement(SupplementDocument x) {
return new PropertyConditionReportSupplement(x.id(), PropertyConditionReportSupplementType.valueOf(x.type()),
file(x.signedPdf()), x.photos().stream().map(this::photo).toList(), x.uploadedBy(), x.uploadedAt(),
x.requestedOn(), x.heatingPeriodStartedOn(), x.acceptedAdditions(),
x.requestedOn(), x.heatingPeriodStartedOn(), encrypted(x.acceptedAdditions()),
x.lateReason() == null
? new OnTimeSupplement(x.deadlineOn())
: new LateSupplementAddition(x.lateRuleVersion(), x.deadlineOn(), x.lateReason(),
: new LateSupplementAddition(x.lateRuleVersion(), x.deadlineOn(), encrypted(x.lateReason()),
x.lateAuditedBy(), x.lateAuditedAt()));
}
private FileDocument file(PropertyConditionReportSupplement.FileEvidence x) {
if (x == null)
return null;
return new FileDocument(x.reference(), x.storageKey(), x.sha256());
return new FileDocument(x.reference(), x.storageKey(), x.sha256(), x.cryptoVersion(), x.nonce(), x.wrappedKey(),
x.vaultPublicKey());
}
private PropertyConditionReportSupplement.FileEvidence file(FileDocument x) {
if (x == null)
return null;
return new PropertyConditionReportSupplement.FileEvidence(x.reference(), x.storageKey(), x.sha256());
return new PropertyConditionReportSupplement.FileEvidence(x.reference(), x.storageKey(), x.sha256(),
x.cryptoVersion(), x.nonce(), x.wrappedKey(), x.vaultPublicKey());
}
private PhotoDocument photo(ConditionPhoto x) {
@@ -218,26 +231,30 @@ public class MongoPropertyConditionReportRepository implements PropertyCondition
private RevisionDocument revision(PropertyConditionReportRevision x) {
return new RevisionDocument(x.number(), x.storageKey(), x.sha256(), x.sourceFingerprint(), x.authorId(),
x.generatedAt(), x.obsolete());
x.generatedAt(), x.obsolete(), x.fileName(), x.cryptoVersion(), x.nonce(), x.wrappedKey(),
x.vaultPublicKey());
}
private PropertyConditionReportRevision revision(RevisionDocument x) {
return new PropertyConditionReportRevision(x.number(), x.storageKey(), x.sha256(), x.sourceFingerprint(),
x.authorId(), x.generatedAt(), x.obsolete());
x.authorId(), x.generatedAt(), x.obsolete(), x.fileName(), x.cryptoVersion(), x.nonce(), x.wrappedKey(),
x.vaultPublicKey());
}
private FinalizationDocument finalization(PropertyConditionReportFinalization x) {
return x == null
? null
: new FinalizationDocument(x.revisionNumber(), x.signedStorageKey(), x.signedSha256(), x.authorId(),
x.finalizedAt());
x.finalizedAt(), x.sourceFingerprint(), x.fileName(), x.cryptoVersion(), x.nonce(),
x.wrappedKey(), x.vaultPublicKey());
}
private PropertyConditionReportFinalization finalization(FinalizationDocument x) {
return x == null
? null
: new PropertyConditionReportFinalization(x.revisionNumber(), x.signedStorageKey(), x.signedSha256(),
x.authorId(), x.finalizedAt());
x.authorId(), x.finalizedAt(), x.sourceFingerprint(), x.fileName(), x.cryptoVersion(),
x.nonce(), x.wrappedKey(), x.vaultPublicKey());
}
private DeliveryDocument delivery(PropertyConditionReportDeliveryAttestation x) {
@@ -254,24 +271,25 @@ public class MongoPropertyConditionReportRepository implements PropertyCondition
.map(z -> new ZoneDocument(z.id(), z.privateAreaId(), z.label(), z.equipment().name()))
.toList(),
x.observations().stream()
.map(o -> new ObservationDocument(o.subjectId(), o.state().name(), o.observation())).toList(),
.map(o -> new ObservationDocument(o.subjectId(), o.state().name(), encrypted(o.observation())))
.toList(),
x.meterReadings().stream()
.map(m -> new MeterDocument(m.id(), m.type().name(), m.state().name(), m.index(), m.unit(),
m.label(), m.observation()))
encrypted(m.label()), encrypted(m.observation())))
.toList(),
x.accessMeans().stream()
.map(a -> new AccessDocument(a.id(), a.type().name(), a.destination(), a.quantity(),
a.observation()))
.toList(),
x.inventoryConditions().stream()
.map(i -> new InventoryDocument(i.inventoryLineId(), i.privateAreaId(), i.privateAreaLabel(),
i.designation(), i.contractualQuantity(), i.contractualDescription(),
i.observedQuantity(), i.observedDescription(),
i.splits().stream()
.map(s -> new SplitDocument(s.inventoryLineId(), s.state().name(), s.quantity(),
s.observation()))
.toList()))
.map(a -> new AccessDocument(a.id(), a.type().name(), encrypted(a.destination()), a.quantity(),
encrypted(a.observation())))
.toList(),
x.inventoryConditions().stream().map(i -> new InventoryDocument(i.inventoryLineId(), i.privateAreaId(),
i.privateAreaLabel(), i.designation(), i.contractualQuantity(), i.contractualDescription(),
i.observedQuantity(), encrypted(i.observedDescription()),
i.splits().stream()
.map(s -> new SplitDocument(s.inventoryLineId(), s.state().name(), s.quantity(),
encrypted(s.observation()),
s.observationCode() == null ? null : s.observationCode().name()))
.toList(),
i.descriptionReconciled())).toList(),
x.photos().stream()
.map(p -> new PhotoDocument(p.reference(), p.target().kind().name(), p.target().privateAreaId(),
p.target().subjectId(), p.storageKey(), p.sha256(), p.width(), p.height(),
@@ -285,7 +303,8 @@ public class MongoPropertyConditionReportRepository implements PropertyCondition
return new EntryReportSnapshot(new PropertyConditionReportId(x.reportId()),
x.participants().stream()
.map(p -> new ReportParticipant(p.partyId(), ReportPartyRole.valueOf(p.role()), p.name(),
ParticipantAttendance.valueOf(p.attendance()), p.representativeName()))
encrypted(p.identityAndAddress()), ParticipantAttendance.valueOf(p.attendance()),
encrypted(p.representativeName())))
.toList(),
x.zones().stream()
.map(z -> new ConditionZone(z.id(), z.privateAreaId(), z.label(),
@@ -293,23 +312,28 @@ public class MongoPropertyConditionReportRepository implements PropertyCondition
.toList(),
x.observations().stream()
.map(o -> new ConditionObservation(o.subjectId(), ConditionState.valueOf(o.state()),
o.observation()))
encrypted(o.observation())))
.toList(),
x.meterReadings().stream()
.map(m -> new MeterReading(m.id(), MeterType.valueOf(m.type()),
MeterReadingState.valueOf(m.state()), m.index(), m.unit(), m.label(), m.observation()))
MeterReadingState.valueOf(m.state()), m.index(), m.unit(), encrypted(m.label()),
encrypted(m.observation())))
.toList(),
x.accessMeans().stream()
.map(a -> new AccessMeans(a.id(), AccessMeansType.valueOf(a.type()), a.destination(),
a.quantity(), a.observation()))
.map(a -> new AccessMeans(a.id(), AccessMeansType.valueOf(a.type()), encrypted(a.destination()),
a.quantity(), encrypted(a.observation())))
.toList(),
x.inventoryConditions().stream().map(i -> new InventoryCondition(i.inventoryLineId(), i.privateAreaId(),
i.privateAreaLabel(), i.designation(), i.contractualQuantity(), i.contractualDescription(),
i.observedQuantity(), i.observedDescription(),
i.splits().stream()
.map(s -> new InventoryQuantitySplit(s.inventoryLineId(),
ConditionState.valueOf(s.state()), s.quantity(), s.observation()))
.toList()))
x.inventoryConditions().stream()
.map(i -> new InventoryCondition(i.inventoryLineId(), i.privateAreaId(), i.privateAreaLabel(),
i.designation(), i.contractualQuantity(), i.contractualDescription(),
i.observedQuantity(), encrypted(i.observedDescription()),
i.splits().stream().map(s -> new InventoryQuantitySplit(s.inventoryLineId(),
ConditionState.valueOf(s.state()), s.quantity(), encrypted(s.observation()),
s.observationCode() == null
? null
: InventoryObservationCode.valueOf(s.observationCode())))
.toList(),
i.descriptionReconciled()))
.toList(),
x.photos().stream()
.map(p -> new ConditionPhoto(p.reference(),
@@ -323,54 +347,60 @@ public class MongoPropertyConditionReportRepository implements PropertyCondition
}
private static List<ParticipantDocument> documentSnapshotParticipants(List<ReportParticipant> values) {
return values.stream().map(x -> new ParticipantDocument(x.partyId(), x.role().name(), x.name(),
x.attendance().name(), x.representativeName())).toList();
return values.stream()
.map(x -> new ParticipantDocument(x.partyId(), x.role().name(), x.name(),
encrypted(x.identityAndAddress()), x.attendance().name(), encrypted(x.representativeName())))
.toList();
}
@Document(collection = "propertyConditionReports")
@CompoundIndex(name = "condition_report_lease_type_unique", def = "{'landlordId': 1, 'leaseId': 1, 'type': 1}", unique = true)
record ReportDocument(@Id String id, String landlordId, String leaseId, String type, LocalDate occurredOn,
String timeZone, List<ParticipantDocument> participants, List<AddressDocument> forwardingAddresses,
List<ZoneDocument> zones, List<ObservationDocument> observations, List<MeterDocument> meterReadings,
List<AccessDocument> accessMeans, List<InventoryDocument> inventoryConditions, List<PhotoDocument> photos,
record ReportDocument(@Id String id, String landlordId, String leaseId, String type, String vaultPublicKey,
LocalDate occurredOn, String timeZone, List<ParticipantDocument> participants,
List<AddressDocument> forwardingAddresses, List<ZoneDocument> zones, List<ObservationDocument> observations,
List<MeterDocument> meterReadings, List<AccessDocument> accessMeans,
List<InventoryDocument> inventoryConditions, List<PhotoDocument> photos,
List<ZoneAbsenceDocument> zonePhotoAbsences, List<SupplementDocument> supplements,
List<RevisionDocument> revisions, FinalizationDocument finalization,
List<DeliveryDocument> deliveryAttestations, EntrySnapshotDocument entrySnapshot, String status,
boolean noIndividualMeterDeclared, boolean noAccessMeansDeclared, ConfirmationsDocument confirmations,
@Version Long version) {
ReportDocument withVersion(Long value) {
return new ReportDocument(id, landlordId, leaseId, type, occurredOn, timeZone, participants,
return new ReportDocument(id, landlordId, leaseId, type, vaultPublicKey, occurredOn, timeZone, participants,
forwardingAddresses, zones, observations, meterReadings, accessMeans, inventoryConditions, photos,
zonePhotoAbsences, supplements, revisions, finalization, deliveryAttestations, entrySnapshot,
status, noIndividualMeterDeclared, noAccessMeansDeclared, confirmations, value);
}
}
record ParticipantDocument(String partyId, String role, String name, String attendance, String representativeName) {
record ParticipantDocument(String partyId, String role, String name, EncryptedGroupDocument identityAndAddress,
String attendance, EncryptedGroupDocument representativeName) {
}
record AddressDocument(String tenantPartyId, String address) {
record AddressDocument(String tenantPartyId, EncryptedGroupDocument address) {
}
record ZoneDocument(String id, String privateAreaId, String label, String equipment) {
}
record ObservationDocument(String subjectId, String state, String observation) {
record ObservationDocument(String subjectId, String state, EncryptedGroupDocument observation) {
}
record MeterDocument(String id, String type, String state, BigDecimal index, String unit, String label,
String observation) {
record MeterDocument(String id, String type, String state, BigDecimal index, String unit,
EncryptedGroupDocument label, EncryptedGroupDocument observation) {
}
record AccessDocument(String id, String type, String destination, int quantity, String observation) {
record AccessDocument(String id, String type, EncryptedGroupDocument destination, int quantity,
EncryptedGroupDocument observation) {
}
record SplitDocument(String inventoryLineId, String state, int quantity, String observation) {
record SplitDocument(String inventoryLineId, String state, int quantity, EncryptedGroupDocument observation,
String observationCode) {
}
record InventoryDocument(String inventoryLineId, String privateAreaId, String privateAreaLabel, String designation,
int contractualQuantity, String contractualDescription, int observedQuantity, String observedDescription,
List<SplitDocument> splits) {
int contractualQuantity, String contractualDescription, int observedQuantity,
EncryptedGroupDocument observedDescription, List<SplitDocument> splits, boolean descriptionReconciled) {
}
record PhotoDocument(String reference, String target, String privateAreaId, String subjectId, String storageKey,
@@ -381,27 +411,30 @@ public class MongoPropertyConditionReportRepository implements PropertyCondition
record ConfirmationsDocument(boolean general, List<String> privateAreaIds, boolean technical) {
}
record AbsenceDocument(String reason, String auditedBy, Instant auditedAt) {
record AbsenceDocument(EncryptedGroupDocument reason, String auditedBy, Instant auditedAt) {
}
record ZoneAbsenceDocument(String subjectId, AbsenceDocument absence) {
}
record FileDocument(String reference, String storageKey, String sha256) {
record FileDocument(String reference, String storageKey, String sha256, int cryptoVersion, String nonce,
String wrappedKey, String vaultPublicKey) {
}
record SupplementDocument(String id, String type, FileDocument signedPdf, List<PhotoDocument> photos,
String uploadedBy, Instant uploadedAt, LocalDate requestedOn, LocalDate heatingPeriodStartedOn,
String acceptedAdditions, LocalDate deadlineOn, String lateRuleVersion, String lateReason,
String lateAuditedBy, Instant lateAuditedAt) {
EncryptedGroupDocument acceptedAdditions, LocalDate deadlineOn, String lateRuleVersion,
EncryptedGroupDocument lateReason, String lateAuditedBy, Instant lateAuditedAt) {
}
record RevisionDocument(int number, String storageKey, String sha256, String sourceFingerprint, String authorId,
Instant generatedAt, boolean obsolete) {
Instant generatedAt, boolean obsolete, String fileName, int cryptoVersion, String nonce, String wrappedKey,
String vaultPublicKey) {
}
record FinalizationDocument(int revisionNumber, String signedStorageKey, String signedSha256, String authorId,
Instant finalizedAt) {
Instant finalizedAt, String sourceFingerprint, String fileName, int cryptoVersion, String nonce,
String wrappedKey, String vaultPublicKey) {
}
record DeliveryDocument(String partyId, String attestedBy, Instant attestedAt) {
@@ -412,4 +445,22 @@ public class MongoPropertyConditionReportRepository implements PropertyCondition
List<InventoryDocument> inventoryConditions, List<PhotoDocument> photos,
List<SupplementDocument> supplements) {
}
record EncryptedGroupDocument(int cryptoVersion, String nonce, String wrappedKey, String sha256,
String ciphertext) {
}
private static EncryptedGroupDocument encrypted(EncryptedGroup value) {
return value == null
? null
: new EncryptedGroupDocument(value.cryptoVersion(), value.nonce(), value.wrappedKey(), value.sha256(),
value.ciphertext());
}
private static EncryptedGroup encrypted(EncryptedGroupDocument value) {
return value == null
? null
: new EncryptedGroup(value.cryptoVersion(), value.nonce(), value.wrappedKey(), value.sha256(),
value.ciphertext());
}
}
@@ -11,6 +11,7 @@ import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseId;
import java.util.List;
import java.util.Optional;
import org.springframework.dao.OptimisticLockingFailureException;
import org.springframework.data.annotation.Id;
import org.springframework.data.annotation.Version;
import org.springframework.data.mongodb.core.MongoTemplate;
@@ -18,6 +19,7 @@ import org.springframework.data.mongodb.core.index.CompoundIndex;
import org.springframework.data.mongodb.core.mapping.Document;
import org.springframework.data.mongodb.core.query.Criteria;
import org.springframework.data.mongodb.core.query.Query;
import org.springframework.data.mongodb.core.query.Update;
import org.springframework.stereotype.Repository;
@Repository
@@ -41,6 +43,17 @@ public class MongoFurnitureInventoryRepository implements FurnitureInventoryRepo
inventory.version(), document::withVersion));
}
@Override
public void coordinateForLeaseDocument(FurnitureInventory inventory) {
Query query = query(inventory.landlordId(), inventory.leaseId());
query.addCriteria(Criteria.where("_id").is(inventory.id().value()).and("version").is(inventory.version()));
if (mongo.updateFirst(query,
new Update().inc("leaseDocumentSourceMutationSequence", 1).set("version", inventory.version()),
InventoryDocument.class).getMatchedCount() != 1) {
throw new OptimisticLockingFailureException("Furniture inventory changed concurrently.");
}
}
private static Query query(LandlordId landlordId, LeaseId leaseId) {
return Query.query(Criteria.where("landlordId").is(landlordId.value()).and("leaseId").is(leaseId.value()));
}
@@ -9,6 +9,7 @@ import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordRepository;
import java.util.Objects;
import java.util.Optional;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.dao.OptimisticLockingFailureException;
import org.springframework.data.annotation.Id;
import org.springframework.data.annotation.Version;
import org.springframework.data.mongodb.core.FindAndModifyOptions;
@@ -66,6 +67,17 @@ public class MongoLandlordRepository implements LandlordRepository {
}
}
@Override
public void coordinateForLeaseDocument(Landlord landlord) {
Query query = Query
.query(Criteria.where("_id").is(landlord.id().value()).and("version").is(landlord.version()));
if (mongoTemplate.updateFirst(query,
new Update().inc("leaseDocumentSourceMutationSequence", 1).set("version", landlord.version()),
LandlordDocument.class).getMatchedCount() != 1) {
throw new OptimisticLockingFailureException("Landlord changed concurrently.");
}
}
private Optional<Landlord> findOne(Query query) {
return Optional.ofNullable(mongoTemplate.findOne(query, LandlordDocument.class))
.map(MongoLandlordRepository::toDomain);
@@ -101,7 +101,8 @@ public class MongoLeaseDocumentRevisionRepository implements LeaseDocumentRevisi
revision.privateAreas().stream().map(this::toDocument).toList(),
revision.annexes().stream().map(this::toDocument).toList(),
revision.conditionPhotos().stream().map(this::toDocument).toList(),
revision.warnings().stream().map(this::toDocument).toList());
revision.warnings().stream().map(this::toDocument).toList(), revision.fileName(),
revision.cryptoVersion(), revision.nonce(), revision.wrappedKey(), revision.vaultPublicKey());
}
private LeaseDocumentRevision toDomain(RevisionDocument document) {
@@ -110,7 +111,8 @@ public class MongoLeaseDocumentRevisionRepository implements LeaseDocumentRevisi
document.sourceFingerprint(), document.storageKey(), document.sha256(), document.generatedAt(),
document.authorId(), toDomain(document.furnitureInventory()), privateAreas(document.privateAreas()),
annexes(document.annexes()), document.conditionPhotos().stream().map(this::toDomain).toList(),
warnings(document.warnings()));
warnings(document.warnings()), document.fileName(), document.cryptoVersion(), document.nonce(),
document.wrappedKey(), document.vaultPublicKey());
}
private PhotoDocument toDocument(LeaseDocumentRevision.ConditionPhotoSnapshot photo) {
@@ -157,14 +159,21 @@ public class MongoLeaseDocumentRevisionRepository implements LeaseDocumentRevisi
return new AnnexDocument(annex.category().name(),
annex.documentType() == null ? null : annex.documentType().name(), annex.sourceId(), annex.version(),
annex.fileName(), annex.storageKey(), annex.sha256(), annex.establishedOn(), annex.expiresOn(),
annex.sourceUploadedAt());
annex.sourceUploadedAt(), annex.encryption() == null ? null : annex.encryption().cryptoVersion(),
annex.encryption() == null ? null : annex.encryption().nonce(),
annex.encryption() == null ? null : annex.encryption().wrappedKey(),
annex.encryption() == null ? null : annex.encryption().vaultPublicKey());
}
private LeaseDocumentAnnex toDomain(AnnexDocument annex) {
return new LeaseDocumentAnnex(LeaseDocumentAnnex.Category.valueOf(annex.category()),
annex.documentType() == null ? null : ApartmentDocumentType.valueOf(annex.documentType()),
annex.sourceId(), annex.version(), annex.fileName(), annex.storageKey(), annex.sha256(),
annex.establishedOn(), annex.expiresOn(), annex.sourceUploadedAt());
annex.establishedOn(), annex.expiresOn(), annex.sourceUploadedAt(),
annex.cryptoVersion() == null
? null
: new LeaseDocumentAnnex.EncryptionMetadata(annex.cryptoVersion(), annex.nonce(),
annex.wrappedKey(), annex.vaultPublicKey()));
}
private WarningDocument toDocument(LeaseDocumentWarning warning) {
@@ -187,7 +196,8 @@ public class MongoLeaseDocumentRevisionRepository implements LeaseDocumentRevisi
record RevisionDocument(@Id String id, String landlordId, String leaseId, int number, String templateVersion,
String sourceFingerprint, String storageKey, String sha256, Instant generatedAt, String authorId,
FurnitureInventoryDocument furnitureInventory, List<PrivateAreaDocument> privateAreas,
List<AnnexDocument> annexes, List<PhotoDocument> conditionPhotos, List<WarningDocument> warnings) {
List<AnnexDocument> annexes, List<PhotoDocument> conditionPhotos, List<WarningDocument> warnings,
String fileName, int cryptoVersion, String nonce, String wrappedKey, String vaultPublicKey) {
}
record FurnitureInventoryDocument(String templateVersion, List<FurnitureInventoryLineDocument> lines) {
@@ -205,9 +215,11 @@ public class MongoLeaseDocumentRevisionRepository implements LeaseDocumentRevisi
}
record AnnexDocument(String category, String documentType, String sourceId, String version, String fileName,
String storageKey, String sha256, LocalDate establishedOn, LocalDate expiresOn, Instant sourceUploadedAt) {
String storageKey, String sha256, LocalDate establishedOn, LocalDate expiresOn, Instant sourceUploadedAt,
Integer cryptoVersion, String nonce, String wrappedKey, String vaultPublicKey) {
}
record WarningDocument(String code, String ruleVersion) {
}
}
@@ -55,7 +55,9 @@ public class MongoLeaseFinalizationRepository implements LeaseFinalizationReposi
finalization.leaseId().value(), finalization.revisionNumber(), finalization.templateVersion(),
finalization.lastSignatureDate(), finalization.signedStorageKey(), finalization.signedFileName(),
finalization.signedSha256(), finalization.authorId(), finalization.finalizedAt(),
finalization.overrides().stream().map(this::toDocument).toList());
finalization.overrides().stream().map(this::toDocument).toList(),
finalization.signedSourceFingerprint(), finalization.signedCryptoVersion(), finalization.signedNonce(),
finalization.signedWrappedKey(), finalization.signedVaultPublicKey());
}
private OverrideDocument toDocument(LeaseFinalization.LegalOverride override) {
@@ -68,7 +70,9 @@ public class MongoLeaseFinalizationRepository implements LeaseFinalizationReposi
return new LeaseFinalization(document.id(), new LandlordId(document.landlordId()),
new LeaseId(document.leaseId()), document.revisionNumber(), document.templateVersion(),
document.lastSignatureDate(), document.signedStorageKey(), document.signedFileName(),
document.signedSha256(), document.authorId(), document.finalizedAt(), overrides);
document.signedSha256(), document.authorId(), document.finalizedAt(), overrides,
document.signedSourceFingerprint(), document.signedCryptoVersion(), document.signedNonce(),
document.signedWrappedKey(), document.signedVaultPublicKey());
}
private LeaseFinalization.LegalOverride toDomain(OverrideDocument document) {
@@ -80,9 +84,12 @@ public class MongoLeaseFinalizationRepository implements LeaseFinalizationReposi
@CompoundIndex(name = "lease_finalization_unique", def = "{'landlordId': 1, 'leaseId': 1}", unique = true)
record FinalizationDocument(@Id String id, String landlordId, String leaseId, int revisionNumber,
String templateVersion, LocalDate lastSignatureDate, String signedStorageKey, String signedFileName,
String signedSha256, String authorId, Instant finalizedAt, List<OverrideDocument> overrides) {
String signedSha256, String authorId, Instant finalizedAt, List<OverrideDocument> overrides,
String signedSourceFingerprint, int signedCryptoVersion, String signedNonce, String signedWrappedKey,
String signedVaultPublicKey) {
}
record OverrideDocument(String code, String ruleVersion, String authorId, Instant confirmedAt) {
}
}
@@ -87,14 +87,15 @@ public class MongoLeaseGuaranteeDeedRepository implements LeaseGuaranteeDeedRepo
private GuaranteeDeedDocument toDocument(LeaseGuaranteeDeed deed) {
return new GuaranteeDeedDocument(deed.id(), deed.landlordId().value(), deed.leaseId().value(),
deed.guaranteeId(), deed.tenantId().value(), deed.attestation(), deed.storageKey(), deed.fileName(),
deed.sha256(), deed.contractFingerprint(), deed.authorId(), deed.uploadedAt(),
persistedVersion(deed.version()));
deed.sha256(), deed.cryptoVersion(), deed.nonce(), deed.wrappedKey(), deed.vaultPublicKey(),
deed.contractFingerprint(), deed.authorId(), deed.uploadedAt(), persistedVersion(deed.version()));
}
private LeaseGuaranteeDeed toDomain(GuaranteeDeedDocument document) {
return new LeaseGuaranteeDeed(document.id(), new LandlordId(document.landlordId()),
new LeaseId(document.leaseId()), document.guaranteeId(), new TenantId(document.tenantId()),
document.attestation(), document.storageKey(), document.fileName(), document.sha256(),
document.cryptoVersion(), document.nonce(), document.wrappedKey(), document.vaultPublicKey(),
document.contractFingerprint(), document.authorId(), document.uploadedAt(),
domainVersion(document.version()));
}
@@ -110,11 +111,13 @@ public class MongoLeaseGuaranteeDeedRepository implements LeaseGuaranteeDeedRepo
@Document(collection = "leaseGuaranteeDeeds")
@CompoundIndex(name = "lease_guarantee_pair_unique", def = "{'landlordId': 1, 'leaseId': 1, 'guaranteeId': 1, 'tenantId': 1}", unique = true)
record GuaranteeDeedDocument(@Id String id, String landlordId, String leaseId, String guaranteeId, String tenantId,
boolean attestation, String storageKey, String fileName, String sha256, String contractFingerprint,
String authorId, Instant uploadedAt, @Version Long version) {
boolean attestation, String storageKey, String fileName, String sha256, int cryptoVersion, String nonce,
String wrappedKey, String vaultPublicKey, String contractFingerprint, String authorId, Instant uploadedAt,
@Version Long version) {
GuaranteeDeedDocument withVersion(long nextVersion) {
return new GuaranteeDeedDocument(id, landlordId, leaseId, guaranteeId, tenantId, attestation, storageKey,
fileName, sha256, contractFingerprint, authorId, uploadedAt, nextVersion);
fileName, sha256, cryptoVersion, nonce, wrappedKey, vaultPublicKey, contractFingerprint, authorId,
uploadedAt, nextVersion);
}
}
}
@@ -16,6 +16,7 @@ import fr.nessar.lmnporganizer.rental.domain.lease.LeaseAmendmentId;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseClosure;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseContractVersion;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseEndingHistory;
import fr.nessar.lmnporganizer.rental.domain.lease.GuarantorProfile;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseGuarantee;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseId;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseLegalTerms;
@@ -39,6 +40,7 @@ import fr.nessar.lmnporganizer.rental.domain.lease.TenantNoticeStatus;
import fr.nessar.lmnporganizer.rental.domain.lease.VisaleGuarantee;
import fr.nessar.lmnporganizer.rental.domain.lease.VisaleVisa;
import fr.nessar.lmnporganizer.rental.domain.tenant.TenantId;
import fr.nessar.lmnporganizer.rental.domain.tenant.TenantProfile;
import java.math.BigDecimal;
import java.time.Instant;
import java.time.LocalDate;
@@ -46,6 +48,8 @@ import java.time.MonthDay;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.stream.Collectors;
import org.springframework.dao.OptimisticLockingFailureException;
import org.springframework.data.annotation.Id;
import org.springframework.data.annotation.Version;
import org.springframework.data.mongodb.core.MongoTemplate;
@@ -55,6 +59,7 @@ import org.springframework.data.mongodb.core.mapping.Field;
import org.springframework.data.mongodb.core.mapping.FieldType;
import org.springframework.data.mongodb.core.query.Criteria;
import org.springframework.data.mongodb.core.query.Query;
import org.springframework.data.mongodb.core.query.Update;
import org.springframework.stereotype.Repository;
@Repository
@@ -84,6 +89,17 @@ public class MongoLeaseRepository implements LeaseRepository {
lease.version(), document::withVersion));
}
@Override
public void coordinateForLeaseDocument(Lease lease) {
Query query = scoped(lease.landlordId(), lease.id());
query.addCriteria(Criteria.where("version").is(lease.version()));
if (mongoTemplate.updateFirst(query,
new Update().inc("leaseDocumentSourceMutationSequence", 1).set("version", lease.version()),
LeaseDocument.class).getMatchedCount() != 1) {
throw new OptimisticLockingFailureException("Lease changed concurrently.");
}
}
@Override
public boolean deleteDraft(Lease lease) {
Query query = scoped(lease.landlordId(), lease.id());
@@ -137,29 +153,31 @@ public class MongoLeaseRepository implements LeaseRepository {
}
private static EndingHistoryDocument toDocument(LeaseEndingHistory history) {
return new EndingHistoryDocument(
history.notices().stream()
.map(notice -> new TenantNoticeDocument(notice.id().value(), notice.receivedOn(),
notice.requestedDepartureOn(), notice.deliveryMode().name(), notice.otherDeliveryMode(),
notice.noticeMonths(), notice.minimumEndOn(), notice.ruleVersion(),
notice.recordedWithoutEvidenceConfirmed(), toDocument(notice.audit())))
.toList(),
return new EndingHistoryDocument(history.vaultPublicKey(), history.notices().stream()
.map(notice -> new TenantNoticeDocument(notice.id().value(), notice.receivedOn(),
notice.requestedDepartureOn(), notice.deliveryMode().name(),
toDocument(notice.otherDeliveryMode()), notice.noticeMonths(), notice.minimumEndOn(),
notice.ruleVersion(), notice.recordedWithoutEvidenceConfirmed(), toDocument(notice.audit())))
.toList(),
history.withdrawals().stream()
.map(withdrawal -> new NoticeWithdrawalDocument(withdrawal.noticeId().value(),
withdrawal.acceptedOn(), withdrawal.reason(), toDocument(withdrawal.audit())))
withdrawal.acceptedOn(), toDocument(withdrawal.reason()),
toDocument(withdrawal.audit())))
.toList(),
history.invalidations().stream()
.map(invalidation -> new NoticeInvalidationDocument(invalidation.noticeId().value(),
invalidation.invalidatedOn(), invalidation.reason(), toDocument(invalidation.audit())))
invalidation.invalidatedOn(), toDocument(invalidation.reason()),
toDocument(invalidation.audit())))
.toList(),
history.agreedEarlyEnds().stream()
.map(agreement -> new AgreedEarlyEndDocument(agreement.endOn(), agreement.reason(),
toDocument(agreement.audit())))
.map(agreement -> new AgreedEarlyEndDocument(agreement.agreementId(), agreement.endOn(),
toDocument(agreement.reason()), toDocument(agreement.audit())))
.toList(),
history.evidenceVersions().stream()
.map(evidence -> new NoticeEvidenceDocument(evidence.noticeId().value(), evidence.version(),
evidence.file().storageKey(), evidence.file().fileName(), evidence.file().contentType(),
evidence.file().size(), evidence.file().sha256(), evidence.reason(),
.map(evidence -> new NoticeEvidenceDocument(evidence.noticeId().value(), evidence.evidenceId(),
evidence.version(), evidence.file().storageKey(), evidence.file().cryptoVersion(),
evidence.file().nonce(), evidence.file().wrappedKey(), evidence.file().sha256(),
evidence.file().size(), evidence.file().vaultPublicKey(), toDocument(evidence.reason()),
toDocument(evidence.audit())))
.toList(),
history.closure()
@@ -171,34 +189,31 @@ public class MongoLeaseRepository implements LeaseRepository {
if (history == null) {
return LeaseEndingHistory.empty();
}
return LeaseEndingHistory.reconstitute(
values(history.notices()).stream()
.map(notice -> new TenantNotice(new TenantNoticeId(notice.id()), TenantNoticeStatus.ACTIVE,
notice.receivedOn(), notice.requestedDepartureOn(),
NoticeDeliveryMode.valueOf(notice.deliveryMode()), notice.otherDeliveryMode(),
notice.noticeMonths(), notice.minimumEndOn(), notice.ruleVersion(),
Boolean.TRUE.equals(notice.recordedWithoutEvidenceConfirmed()),
toDomain(notice.audit())))
.toList(),
return LeaseEndingHistory.reconstitute(history.vaultPublicKey(), values(history.notices()).stream()
.map(notice -> new TenantNotice(new TenantNoticeId(notice.id()), TenantNoticeStatus.ACTIVE,
notice.receivedOn(), notice.requestedDepartureOn(),
NoticeDeliveryMode.valueOf(notice.deliveryMode()), toDomain(notice.otherDeliveryMode()),
notice.noticeMonths(), notice.minimumEndOn(), notice.ruleVersion(),
Boolean.TRUE.equals(notice.recordedWithoutEvidenceConfirmed()), toDomain(notice.audit())))
.toList(),
values(history.withdrawals()).stream()
.map(withdrawal -> new NoticeWithdrawal(new TenantNoticeId(withdrawal.noticeId()),
withdrawal.acceptedOn(), withdrawal.reason(), toDomain(withdrawal.audit())))
withdrawal.acceptedOn(), toDomain(withdrawal.reason()), toDomain(withdrawal.audit())))
.toList(),
values(history.invalidations()).stream()
.map(invalidation -> new NoticeInvalidation(new TenantNoticeId(invalidation.noticeId()),
invalidation.invalidatedOn(), invalidation.reason(), toDomain(invalidation.audit())))
invalidation.invalidatedOn(), toDomain(invalidation.reason()),
toDomain(invalidation.audit())))
.toList(),
values(history.agreedEarlyEnds()).stream()
.map(agreement -> new AgreedEarlyLeaseEnd(agreement.endOn(), agreement.reason(),
toDomain(agreement.audit())))
.toList(),
values(history.evidenceVersions()).stream()
.map(evidence -> new NoticeEvidenceVersion(new TenantNoticeId(evidence.noticeId()),
evidence.version(),
new NoticeEvidenceFile(evidence.storageKey(), evidence.fileName(),
evidence.contentType(), evidence.size(), evidence.sha256()),
evidence.reason(), toDomain(evidence.audit())))
.map(agreement -> new AgreedEarlyLeaseEnd(agreement.agreementId(), agreement.endOn(),
toDomain(agreement.reason()), toDomain(agreement.audit())))
.toList(),
values(history.evidenceVersions()).stream().map(evidence -> new NoticeEvidenceVersion(
new TenantNoticeId(evidence.noticeId()), evidence.evidenceId(), evidence.version(),
new NoticeEvidenceFile(evidence.storageKey(), evidence.cryptoVersion(), evidence.nonce(),
evidence.wrappedKey(), evidence.sha256(), evidence.size(), evidence.vaultPublicKey()),
toDomain(evidence.reason()), toDomain(evidence.audit()))).toList(),
history.closure() == null
? null
: new LeaseClosure(history.closure().keysReturnedOn(), toDomain(history.closure().audit())));
@@ -218,43 +233,76 @@ public class MongoLeaseRepository implements LeaseRepository {
private static LeaseGuaranteeDocument toDocument(LeaseGuarantee guarantee) {
return new LeaseGuaranteeDocument(guarantee.id(), guarantee.tenantId().value(), guarantee.type().name(),
guarantee.firstName(), guarantee.lastName(), guarantee.email(), guarantee.phone(), guarantee.address(),
guarantee.organizationName(), guarantee.organizationAddress(), guarantee.representativeName(),
guarantee.registrationId());
toDocument(guarantee.profile()));
}
private static LeaseGuarantee toDomain(LeaseGuaranteeDocument document) {
return new LeaseGuarantee(document.id(), new TenantId(document.tenantId()),
LeaseGuarantee.Type.valueOf(document.type()), document.firstName(), document.lastName(),
document.email(), document.phone(), document.address(), document.organizationName(),
document.organizationAddress(), document.representativeName(), document.registrationId());
LeaseGuarantee.Type.valueOf(document.type()), toDomain(document.profile()));
}
private static VisaleGuaranteeDocument toDocument(VisaleGuarantee guarantee) {
return guarantee == null
? null
: new VisaleGuaranteeDocument(guarantee.visaNumber(),
: new VisaleGuaranteeDocument(toDocument(guarantee.visaNumber()),
guarantee.tenantIds().stream().map(TenantId::value).toList());
}
private static VisaleGuarantee toDomain(VisaleGuaranteeDocument document) {
return document == null
? null
: new VisaleGuarantee(document.visaNumber(), document.tenantIds().stream().map(TenantId::new).toList());
: new VisaleGuarantee(toDomain(document.visaNumber()),
document.tenantIds().stream().map(TenantId::new).toList());
}
private static VisaleVisaDocument toDocument(VisaleVisa visa) {
return visa == null
? null
: new VisaleVisaDocument(visa.storageKey(), visa.fileName(), visa.sha256(), visa.authorId(),
visa.uploadedAt());
: new VisaleVisaDocument(visa.storageKey(), visa.fileName(), visa.sha256(), visa.cryptoVersion(),
visa.nonce(), visa.wrappedKey(), visa.vaultPublicKey(), visa.authorId(), visa.uploadedAt());
}
private static VisaleVisa toDomain(VisaleVisaDocument document) {
return document == null
? null
: new VisaleVisa(document.storageKey(), document.fileName(), document.sha256(), document.authorId(),
document.uploadedAt());
: new VisaleVisa(document.storageKey(), document.fileName(), document.sha256(),
document.cryptoVersion(), document.nonce(), document.wrappedKey(), document.vaultPublicKey(),
document.authorId(), document.uploadedAt());
}
private static GuarantorProfileDocument toDocument(GuarantorProfile profile) {
return new GuarantorProfileDocument(profile.vaultPublicKey(), toDocument(profile.identityAndAddress()),
toDocument(profile.contacts()), profile.contractProfileComplete(), profile.identityGeneration());
}
private static GuarantorProfile toDomain(GuarantorProfileDocument document) {
return new GuarantorProfile(document.vaultPublicKey(), toDomain(document.identityAndAddress()),
toDomain(document.contacts()), Boolean.TRUE.equals(document.contractProfileComplete()),
document.identityGeneration());
}
private static TenantProfileDocument toDocument(TenantProfile profile) {
return new TenantProfileDocument(profile.vaultPublicKey(), toDocument(profile.identityAndAddress()),
toDocument(profile.contacts()), profile.contractProfileComplete());
}
private static TenantProfile toDomain(TenantProfileDocument document) {
return new TenantProfile(document.vaultPublicKey(), toDomain(document.identityAndAddress()),
toDomain(document.contacts()), Boolean.TRUE.equals(document.contractProfileComplete()));
}
private static EncryptedGroupDocument toDocument(TenantProfile.EncryptedGroup group) {
return group == null
? null
: new EncryptedGroupDocument(group.cryptoVersion(), group.nonce(), group.wrappedKey(), group.sha256(),
group.ciphertext());
}
private static TenantProfile.EncryptedGroup toDomain(EncryptedGroupDocument document) {
return document == null
? null
: new TenantProfile.EncryptedGroup(document.cryptoVersion(), document.nonce(), document.wrappedKey(),
document.sha256(), document.ciphertext());
}
private static LeaseSnapshotDocument toDocument(LeaseSnapshot snapshot) {
@@ -266,8 +314,7 @@ public class MongoLeaseRepository implements LeaseRepository {
snapshot.apartment().address(), toDocument(snapshot.apartment().legalProfile()),
snapshot.apartment().privateAreas().stream().map(MongoLeaseRepository::toDocument).toList()),
snapshot.tenants().stream()
.map(tenant -> new TenantSnapshotDocument(tenant.id().value(), tenant.firstName(),
tenant.lastName(), tenant.email(), tenant.phone(), tenant.address()))
.map(tenant -> new TenantSnapshotDocument(tenant.id().value(), toDocument(tenant.profile())))
.toList(),
snapshot.guarantees().stream().map(MongoLeaseRepository::toDocument).toList(),
toDocument(snapshot.visaleGuarantee()), snapshot.period().startDate(), snapshot.period().endDate(),
@@ -287,8 +334,7 @@ public class MongoLeaseRepository implements LeaseRepository {
document.apartment().address(), toDomain(document.apartment().legalProfile()),
document.apartment().privateAreas().stream().map(MongoLeaseRepository::toDomain).toList()),
document.tenants().stream()
.map(tenant -> new LeaseSnapshot.Tenant(new TenantId(tenant.id()), tenant.firstName(),
tenant.lastName(), tenant.email(), tenant.phone(), tenant.address()))
.map(tenant -> new LeaseSnapshot.Tenant(new TenantId(tenant.id()), toDomain(tenant.profile())))
.toList(),
document.guarantees().stream().map(MongoLeaseRepository::toDomain).toList(),
new RentalPeriod(document.startDate(), document.endDate()),
@@ -325,8 +371,7 @@ public class MongoLeaseRepository implements LeaseRepository {
version.apartment().address(), toDocument(version.apartment().legalProfile()),
version.apartment().privateAreas().stream().map(MongoLeaseRepository::toDocument).toList()),
version.tenants().stream()
.map(tenant -> new TenantSnapshotDocument(tenant.id().value(), tenant.firstName(),
tenant.lastName(), tenant.email(), tenant.phone(), tenant.address()))
.map(tenant -> new TenantSnapshotDocument(tenant.id().value(), toDocument(tenant.profile())))
.toList(),
version.guarantees().stream().map(MongoLeaseRepository::toDocument).toList(),
toDocument(version.visaleGuarantee()), version.period().startDate(), version.period().endDate(),
@@ -346,8 +391,7 @@ public class MongoLeaseRepository implements LeaseRepository {
document.apartment().address(), toDomain(document.apartment().legalProfile()),
document.apartment().privateAreas().stream().map(MongoLeaseRepository::toDomain).toList()),
document.tenants().stream()
.map(tenant -> new LeaseSnapshot.Tenant(new TenantId(tenant.id()), tenant.firstName(),
tenant.lastName(), tenant.email(), tenant.phone(), tenant.address()))
.map(tenant -> new LeaseSnapshot.Tenant(new TenantId(tenant.id()), toDomain(tenant.profile())))
.toList(),
document.guarantees().stream().map(MongoLeaseRepository::toDomain).toList(),
new RentalPeriod(document.startDate(), document.endDate()),
@@ -358,22 +402,30 @@ public class MongoLeaseRepository implements LeaseRepository {
}
private static SpecialClauseDocument toDocument(LeaseSpecialClause clause) {
return new SpecialClauseDocument(clause.catalogId(), clause.parameters(), clause.text(), clause.custom());
return new SpecialClauseDocument(clause.id(), clause.vaultPublicKey(), clause.catalogId(), clause.parameters(),
clause.encryptedParameters().entrySet().stream().collect(
Collectors.toUnmodifiableMap(Map.Entry::getKey, entry -> toDocument(entry.getValue()))),
toDocument(clause.renderedText()), clause.custom());
}
private static List<LeaseSpecialClause> specialClauses(List<SpecialClauseDocument> documents) {
return documents == null
? List.of()
: documents.stream().map(document -> new LeaseSpecialClause(document.catalogId(), document.parameters(),
document.text(), Boolean.TRUE.equals(document.custom()))).toList();
: documents.stream().map(document -> new LeaseSpecialClause(document.id(), document.vaultPublicKey(),
document.catalogId(), document.parameters(),
document.encryptedParameters().entrySet().stream().collect(
Collectors.toUnmodifiableMap(Map.Entry::getKey, entry -> toDomain(entry.getValue()))),
toDomain(document.renderedText()), Boolean.TRUE.equals(document.custom()))).toList();
}
private static LegalTermsDocument toDocument(LeaseLegalTerms terms) {
return new LegalTermsDocument(
return new LegalTermsDocument(terms.vaultPublicKey(),
new RentControlDocument(terms.rentControl().evolutionCapApplicable(),
terms.rentControl().referenceRentApplicable(), terms.rentControl().baseReferenceRent(),
terms.rentControl().increasedReferenceRent(), terms.rentControl().supplement(),
terms.rentControl().supplementJustification()),
terms.rentControl().supplementJustification() == null
? null
: toDocument(terms.rentControl().supplementJustification())),
new PreviousRentDocument(terms.previousRent().applicable(), terms.previousRent().amount(),
terms.previousRent().lastRevisionDate(), terms.previousRent().lastPaymentDate(),
terms.previousRent().tenantConfirmation()),
@@ -386,9 +438,12 @@ public class MongoLeaseRepository implements LeaseRepository {
: terms.rentRevision().annualRevisionDay().getMonthValue(),
terms.rentRevision().referenceQuarter(), terms.rentRevision().referenceYear(),
terms.rentRevision().seriesId()),
new RecentWorkDocument(terms.recentWork().description(), terms.recentWork().amount()),
new RecentWorkDocument(
terms.recentWork().description() == null ? null : toDocument(terms.recentWork().description()),
terms.recentWork().amount()),
terms.coOwnershipRulesApplicable(), terms.priorRentalAuthorizationApplicable(),
terms.primaryResidenceEasementApplicable(), terms.jointAndSeveralLiability(), terms.otherTerms());
terms.primaryResidenceEasementApplicable(), terms.jointAndSeveralLiability(),
terms.otherTerms() == null ? null : toDocument(terms.otherTerms()));
}
private static LeaseLegalTerms toDomain(LegalTermsDocument document) {
@@ -399,12 +454,14 @@ public class MongoLeaseRepository implements LeaseRepository {
PreviousRentDocument previousRent = document.previousRent();
RentRevisionDocument rentRevision = document.rentRevision();
RecentWorkDocument recentWork = document.recentWork();
return new LeaseLegalTerms(rentControl == null
return new LeaseLegalTerms(document.vaultPublicKey(), rentControl == null
? null
: new LeaseLegalTerms.RentControlTerms(Boolean.TRUE.equals(rentControl.evolutionCapApplicable()),
Boolean.TRUE.equals(rentControl.referenceRentApplicable()), rentControl.baseReferenceRent(),
rentControl.increasedReferenceRent(), rentControl.supplement(),
rentControl.supplementJustification()),
rentControl.supplementJustification() == null
? null
: toDomain(rentControl.supplementJustification())),
previousRent == null
? null
: new LeaseLegalTerms.PreviousRentTerms(Boolean.TRUE.equals(previousRent.applicable()),
@@ -420,11 +477,14 @@ public class MongoLeaseRepository implements LeaseRepository {
rentRevision.referenceQuarter(), rentRevision.referenceYear(), rentRevision.seriesId()),
recentWork == null
? null
: new LeaseLegalTerms.RecentWork(recentWork.description(), recentWork.amount()),
: new LeaseLegalTerms.RecentWork(
recentWork.description() == null ? null : toDomain(recentWork.description()),
recentWork.amount()),
Boolean.TRUE.equals(document.coOwnershipRulesApplicable()),
Boolean.TRUE.equals(document.priorRentalAuthorizationApplicable()),
Boolean.TRUE.equals(document.primaryResidenceEasementApplicable()),
Boolean.TRUE.equals(document.jointAndSeveralLiability()), document.otherTerms());
Boolean.TRUE.equals(document.jointAndSeveralLiability()),
document.otherTerms() == null ? null : toDomain(document.otherTerms()));
}
private static ApartmentLegalProfileDocument toDocument(ApartmentLegalProfile profile) {
@@ -497,30 +557,32 @@ public class MongoLeaseRepository implements LeaseRepository {
}
}
record LeaseGuaranteeDocument(String id, String tenantId, String type, String firstName, String lastName,
String email, String phone, String address, String organizationName, String organizationAddress,
String representativeName, String registrationId) {
record LeaseGuaranteeDocument(String id, String tenantId, String type, GuarantorProfileDocument profile) {
}
record VisaleGuaranteeDocument(String visaNumber, List<String> tenantIds) {
record VisaleGuaranteeDocument(EncryptedGroupDocument visaNumber, List<String> tenantIds) {
}
record VisaleVisaDocument(String storageKey, String fileName, String sha256, String authorId, Instant uploadedAt) {
record VisaleVisaDocument(String storageKey, String fileName, String sha256, int cryptoVersion, String nonce,
String wrappedKey, String vaultPublicKey, String authorId, Instant uploadedAt) {
}
record SpecialClauseDocument(String catalogId, Map<String, String> parameters, String text, Boolean custom) {
record SpecialClauseDocument(String id, String vaultPublicKey, String catalogId, Map<String, String> parameters,
Map<String, EncryptedGroupDocument> encryptedParameters, EncryptedGroupDocument renderedText,
Boolean custom) {
}
record LegalTermsDocument(RentControlDocument rentControl, PreviousRentDocument previousRent,
record LegalTermsDocument(String vaultPublicKey, RentControlDocument rentControl, PreviousRentDocument previousRent,
RentRevisionDocument rentRevision, RecentWorkDocument recentWork, Boolean coOwnershipRulesApplicable,
Boolean priorRentalAuthorizationApplicable, Boolean primaryResidenceEasementApplicable,
Boolean jointAndSeveralLiability, String otherTerms) {
Boolean jointAndSeveralLiability, EncryptedGroupDocument otherTerms) {
}
record RentControlDocument(Boolean evolutionCapApplicable, Boolean referenceRentApplicable,
@Field(targetType = FieldType.DECIMAL128) BigDecimal baseReferenceRent,
@Field(targetType = FieldType.DECIMAL128) BigDecimal increasedReferenceRent,
@Field(targetType = FieldType.DECIMAL128) BigDecimal supplement, String supplementJustification) {
@Field(targetType = FieldType.DECIMAL128) BigDecimal supplement,
EncryptedGroupDocument supplementJustification) {
}
record PreviousRentDocument(Boolean applicable, @Field(targetType = FieldType.DECIMAL128) BigDecimal amount,
@@ -531,7 +593,8 @@ public class MongoLeaseRepository implements LeaseRepository {
Integer referenceQuarter, Integer referenceYear, String seriesId) {
}
record RecentWorkDocument(String description, @Field(targetType = FieldType.DECIMAL128) BigDecimal amount) {
record RecentWorkDocument(EncryptedGroupDocument description,
@Field(targetType = FieldType.DECIMAL128) BigDecimal amount) {
}
record LeaseSnapshotDocument(LandlordSnapshotDocument landlord, ApartmentSnapshotDocument apartment,
@@ -563,8 +626,19 @@ public class MongoLeaseRepository implements LeaseRepository {
String commonAreas, String internetAccess, String departmentCode) {
}
record TenantSnapshotDocument(String id, String firstName, String lastName, String email, String phone,
String address) {
record TenantSnapshotDocument(String id, TenantProfileDocument profile) {
}
record TenantProfileDocument(String vaultPublicKey, EncryptedGroupDocument identityAndAddress,
EncryptedGroupDocument contacts, Boolean contractProfileComplete) {
}
record GuarantorProfileDocument(String vaultPublicKey, EncryptedGroupDocument identityAndAddress,
EncryptedGroupDocument contacts, Boolean contractProfileComplete, long identityGeneration) {
}
record EncryptedGroupDocument(int cryptoVersion, String nonce, String wrappedKey, String sha256,
String ciphertext) {
}
record LeaseAmendmentDocument(String id, LocalDate effectiveDate, LeaseContractVersionDocument beforeVersion,
@@ -577,28 +651,32 @@ public class MongoLeaseRepository implements LeaseRepository {
record LegalComplianceOverrideDocument(String rule, String ruleVersion, String authorId, Instant confirmedAt) {
}
record EndingHistoryDocument(List<TenantNoticeDocument> notices, List<NoticeWithdrawalDocument> withdrawals,
List<NoticeInvalidationDocument> invalidations, List<AgreedEarlyEndDocument> agreedEarlyEnds,
List<NoticeEvidenceDocument> evidenceVersions, LeaseClosureDocument closure) {
record EndingHistoryDocument(String vaultPublicKey, List<TenantNoticeDocument> notices,
List<NoticeWithdrawalDocument> withdrawals, List<NoticeInvalidationDocument> invalidations,
List<AgreedEarlyEndDocument> agreedEarlyEnds, List<NoticeEvidenceDocument> evidenceVersions,
LeaseClosureDocument closure) {
}
record TenantNoticeDocument(String id, LocalDate receivedOn, LocalDate requestedDepartureOn, String deliveryMode,
String otherDeliveryMode, int noticeMonths, LocalDate minimumEndOn, String ruleVersion,
EncryptedGroupDocument otherDeliveryMode, int noticeMonths, LocalDate minimumEndOn, String ruleVersion,
Boolean recordedWithoutEvidenceConfirmed, LeaseAuditDocument audit) {
}
record NoticeWithdrawalDocument(String noticeId, LocalDate acceptedOn, String reason, LeaseAuditDocument audit) {
}
record NoticeInvalidationDocument(String noticeId, LocalDate invalidatedOn, String reason,
record NoticeWithdrawalDocument(String noticeId, LocalDate acceptedOn, EncryptedGroupDocument reason,
LeaseAuditDocument audit) {
}
record AgreedEarlyEndDocument(LocalDate endOn, String reason, LeaseAuditDocument audit) {
record NoticeInvalidationDocument(String noticeId, LocalDate invalidatedOn, EncryptedGroupDocument reason,
LeaseAuditDocument audit) {
}
record NoticeEvidenceDocument(String noticeId, int version, String storageKey, String fileName, String contentType,
long size, String sha256, String reason, LeaseAuditDocument audit) {
record AgreedEarlyEndDocument(String agreementId, LocalDate endOn, EncryptedGroupDocument reason,
LeaseAuditDocument audit) {
}
record NoticeEvidenceDocument(String noticeId, String evidenceId, int version, String storageKey, int cryptoVersion,
String nonce, String wrappedKey, String sha256, long size, String vaultPublicKey,
EncryptedGroupDocument reason, LeaseAuditDocument audit) {
}
record LeaseClosureDocument(LocalDate keysReturnedOn, LeaseAuditDocument audit) {
@@ -5,6 +5,7 @@ import fr.nessar.lmnporganizer.rental.domain.lease.LeaseId;
import fr.nessar.lmnporganizer.rental.domain.lease.VisaleSuretyContract;
import fr.nessar.lmnporganizer.rental.domain.lease.VisaleSuretyContractRepository;
import fr.nessar.lmnporganizer.rental.domain.tenant.TenantId;
import fr.nessar.lmnporganizer.rental.domain.tenant.TenantProfile;
import java.time.Instant;
import java.util.List;
import java.util.Optional;
@@ -67,7 +68,7 @@ public class MongoVisaleSuretyContractRepository implements VisaleSuretyContract
@Override
public void withdraw(LandlordId landlordId, LeaseId leaseId, String withdrawalId, Instant withdrawnAt) {
mongo.insert(new ContractDocument(withdrawalId, landlordId.value(), leaseId.value(), null, List.of(), null,
null, null, null, withdrawnAt, true, nextOrder(landlordId, leaseId)));
null, null, 1, null, null, null, null, withdrawnAt, true, nextOrder(landlordId, leaseId)));
}
private long nextOrder(LandlordId landlordId, LeaseId leaseId) {
@@ -81,24 +82,41 @@ public class MongoVisaleSuretyContractRepository implements VisaleSuretyContract
private static ContractDocument toDocument(VisaleSuretyContract contract, long order) {
return new ContractDocument(contract.id(), contract.landlordId().value(), contract.leaseId().value(),
contract.visaNumber(), contract.tenantIds().stream().map(TenantId::value).toList(),
contract.storageKey(), contract.fileName(), contract.sha256(), contract.authorId(),
toDocument(contract.visaNumber()), contract.tenantIds().stream().map(TenantId::value).toList(),
contract.storageKey(), contract.fileName(), contract.sha256(), contract.cryptoVersion(),
contract.nonce(), contract.wrappedKey(), contract.vaultPublicKey(), contract.authorId(),
contract.uploadedAt(), false, order);
}
private static VisaleSuretyContract toDomain(ContractDocument document) {
return new VisaleSuretyContract(document.id(), new LandlordId(document.landlordId()),
new LeaseId(document.leaseId()), document.visaNumber(),
new LeaseId(document.leaseId()), toDomain(document.visaNumber()),
document.tenantIds().stream().map(TenantId::new).toList(), document.storageKey(), document.fileName(),
document.sha256(), document.authorId(), document.uploadedAt());
document.sha256(), document.cryptoVersion(), document.nonce(), document.wrappedKey(),
document.vaultPublicKey(), document.authorId(), document.uploadedAt());
}
private static EncryptedGroupDocument toDocument(TenantProfile.EncryptedGroup group) {
return new EncryptedGroupDocument(group.cryptoVersion(), group.nonce(), group.wrappedKey(), group.sha256(),
group.ciphertext());
}
private static TenantProfile.EncryptedGroup toDomain(EncryptedGroupDocument document) {
return new TenantProfile.EncryptedGroup(document.cryptoVersion(), document.nonce(), document.wrappedKey(),
document.sha256(), document.ciphertext());
}
@Document("rentalVisaleSuretyContracts")
record ContractDocument(@Id String id, String landlordId, String leaseId, String visaNumber, List<String> tenantIds,
String storageKey, String fileName, String sha256, String authorId, Instant uploadedAt, boolean withdrawn,
record ContractDocument(@Id String id, String landlordId, String leaseId, EncryptedGroupDocument visaNumber,
List<String> tenantIds, String storageKey, String fileName, String sha256, int cryptoVersion, String nonce,
String wrappedKey, String vaultPublicKey, String authorId, Instant uploadedAt, boolean withdrawn,
long order) {
}
record EncryptedGroupDocument(int cryptoVersion, String nonce, String wrappedKey, String sha256,
String ciphertext) {
}
@Document("rentalVisaleSuretyContractCounters")
record Counter(@Id String id, long value) {
}
@@ -9,6 +9,7 @@ import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentId;
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentReversal;
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentReversalId;
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentReversalRepository;
import fr.nessar.lmnporganizer.rental.domain.tenant.TenantProfile.EncryptedGroup;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
@@ -53,7 +54,8 @@ public class MongoRentPaymentReversalRepository implements RentPaymentReversalRe
private ReversalDocument toDocument(RentPaymentReversal reversal) {
return new ReversalDocument(reversal.id().value(), reversal.landlordId().value(), reversal.leaseId().value(),
reversal.paymentId().value(), reversal.reversedAt(), reversal.reason(),
reversal.paymentId().value(), reversal.reversedAt(), reversal.vaultPublicKey(),
toDocument(reversal.reason()),
reversal.reversedAllocations().stream()
.map(allocation -> new AllocationDocument(allocation.id().value(),
allocation.rentDueId().value(), allocation.amount(), allocation.allocatedAt()))
@@ -63,17 +65,32 @@ public class MongoRentPaymentReversalRepository implements RentPaymentReversalRe
private RentPaymentReversal toDomain(ReversalDocument document) {
return new RentPaymentReversal(new RentPaymentReversalId(document.id()), new LandlordId(document.landlordId()),
new LeaseId(document.leaseId()), new RentPaymentId(document.paymentId()), document.reversedAt(),
document.reason(),
toDomain(document.reason()), document.vaultPublicKey(),
document.allocations().stream()
.map(allocation -> new PaymentAllocation(new PaymentAllocationId(allocation.id()),
new RentDueId(allocation.rentDueId()), allocation.amount(), allocation.allocatedAt()))
.toList());
}
private static EncryptedGroupDocument toDocument(EncryptedGroup group) {
return new EncryptedGroupDocument(group.cryptoVersion(), group.nonce(), group.wrappedKey(), group.sha256(),
group.ciphertext());
}
private static EncryptedGroup toDomain(EncryptedGroupDocument document) {
return new EncryptedGroup(document.cryptoVersion(), document.nonce(), document.wrappedKey(), document.sha256(),
document.ciphertext());
}
@Document(collection = "rentPaymentReversals")
@CompoundIndex(name = "reversal_landlord_payment", def = "{'landlordId': 1, 'paymentId': 1}", unique = true)
record ReversalDocument(@Id String id, @Indexed String landlordId, @Indexed String leaseId, String paymentId,
LocalDate reversedAt, String reason, List<AllocationDocument> allocations) {
LocalDate reversedAt, String vaultPublicKey, EncryptedGroupDocument reason,
List<AllocationDocument> allocations) {
}
record EncryptedGroupDocument(int cryptoVersion, String nonce, String wrappedKey, String sha256,
String ciphertext) {
}
record AllocationDocument(String id, String rentDueId, @Field(targetType = FieldType.DECIMAL128) BigDecimal amount,
@@ -97,7 +97,9 @@ public class MongoRentRevisionRepository implements RentRevisionRepository {
private RequestDocument toDocument(RentRevision.Request request) {
return new RequestDocument(request.requestedOn(), request.effectiveOn(), request.letterStorageKey(),
request.letterSha256(), request.proposedBy(), request.proposedAt());
request.letterSha256(), request.letterFileName(), request.letterCryptoVersion(), request.letterNonce(),
request.letterWrappedKey(), request.sourceFingerprint(), request.letterVaultPublicKey(),
request.proposedBy(), request.proposedAt());
}
private OverrideDocument toDocument(RentRevision.LegalOverride override) {
@@ -112,8 +114,8 @@ public class MongoRentRevisionRepository implements RentRevisionRepository {
}
private ProofDocument toDocument(RentRevision.Proof proof) {
return new ProofDocument(proof.storageKey(), proof.fileName(), proof.sha256(), proof.authorId(),
proof.uploadedAt());
return new ProofDocument(proof.storageKey(), proof.fileName(), proof.sha256(), proof.cryptoVersion(),
proof.nonce(), proof.wrappedKey(), proof.vaultPublicKey(), proof.authorId(), proof.uploadedAt());
}
private RentRevision toDomain(RevisionDocument document) {
@@ -136,7 +138,9 @@ public class MongoRentRevisionRepository implements RentRevisionRepository {
private RentRevision.Request toDomain(RequestDocument request) {
return new RentRevision.Request(request.requestedOn(), request.effectiveOn(), request.letterStorageKey(),
request.letterSha256(), request.proposedBy(), request.proposedAt());
request.letterSha256(), request.letterFileName(), request.letterCryptoVersion(), request.letterNonce(),
request.letterWrappedKey(), request.sourceFingerprint(), request.letterVaultPublicKey(),
request.proposedBy(), request.proposedAt());
}
private RentRevision.LegalOverride toDomain(OverrideDocument override) {
@@ -152,8 +156,8 @@ public class MongoRentRevisionRepository implements RentRevisionRepository {
}
private RentRevision.Proof toDomain(ProofDocument proof) {
return new RentRevision.Proof(proof.storageKey(), proof.fileName(), proof.sha256(), proof.authorId(),
proof.uploadedAt());
return new RentRevision.Proof(proof.storageKey(), proof.fileName(), proof.sha256(), proof.cryptoVersion(),
proof.nonce(), proof.wrappedKey(), proof.vaultPublicKey(), proof.authorId(), proof.uploadedAt());
}
private static String appliedKey(RentRevision revision) {
@@ -190,6 +194,8 @@ public class MongoRentRevisionRepository implements RentRevisionRepository {
append(source, override.code());
append(source, override.ruleVersion());
}
append(source, revision.request().sourceFingerprint());
append(source, revision.request().letterVaultPublicKey());
try {
return HexFormat.of().formatHex(
MessageDigest.getInstance("SHA-256").digest(source.toString().getBytes(StandardCharsets.UTF_8)));
@@ -245,7 +251,8 @@ public class MongoRentRevisionRepository implements RentRevisionRepository {
}
record RequestDocument(LocalDate requestedOn, LocalDate effectiveOn, String letterStorageKey, String letterSha256,
String proposedBy, Instant proposedAt) {
String letterFileName, int letterCryptoVersion, String letterNonce, String letterWrappedKey,
String sourceFingerprint, String letterVaultPublicKey, String proposedBy, Instant proposedAt) {
}
record OverrideDocument(String code, String ruleVersion, String authorId, Instant confirmedAt) {
@@ -255,6 +262,7 @@ public class MongoRentRevisionRepository implements RentRevisionRepository {
String appliedBy, Instant appliedAt) {
}
record ProofDocument(String storageKey, String fileName, String sha256, String authorId, Instant uploadedAt) {
record ProofDocument(String storageKey, String fileName, String sha256, int cryptoVersion, String nonce,
String wrappedKey, String vaultPublicKey, String authorId, Instant uploadedAt) {
}
}
@@ -4,9 +4,14 @@ import fr.nessar.lmnporganizer.rental.adapter.out.mongodb.LandlordScopedMongoWri
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseId;
import fr.nessar.lmnporganizer.rental.domain.tenant.Tenant;
import fr.nessar.lmnporganizer.rental.domain.tenant.TenantAccountClosure;
import fr.nessar.lmnporganizer.rental.domain.tenant.TenantAttachment;
import fr.nessar.lmnporganizer.rental.domain.tenant.TenantEvidenceRetention;
import fr.nessar.lmnporganizer.rental.domain.tenant.TenantId;
import fr.nessar.lmnporganizer.rental.domain.tenant.TenantProfile;
import fr.nessar.lmnporganizer.rental.domain.tenant.TenantRepository;
import java.time.Instant;
import java.time.LocalDate;
import java.util.List;
import java.util.Optional;
import java.util.Set;
@@ -58,17 +63,23 @@ public class MongoTenantRepository implements TenantRepository {
}
public boolean referencesAttachmentStorageKey(String storageKey) {
return mongoTemplate.exists(Query.query(Criteria.where("attachments.storagePath").is(storageKey)),
return mongoTemplate.exists(Query.query(Criteria.where("attachments.storageKey").is(storageKey)),
TenantDocument.class);
}
public boolean referencesApplicationDocumentStorageKey(String storageKey) {
return mongoTemplate.exists(Query.query(Criteria.where("attachments.retention.storageReference").is(storageKey)
.and("attachments.retention.source")
.is(TenantEvidenceRetention.EvidenceSource.ORIGINAL_APPLICATION.name())), TenantDocument.class);
}
private static Query scopedQuery(LandlordId landlordId, TenantId tenantId) {
return Query.query(Criteria.where("_id").is(tenantId.value()).and("landlordId").is(landlordId.value()));
}
static TenantDocument toDocument(Tenant tenant) {
return new TenantDocument(tenant.id().value(), tenant.landlordId().value(), tenant.firstName(),
tenant.lastName(), tenant.email(), tenant.phone(), tenant.address(),
return new TenantDocument(tenant.id().value(), tenant.landlordId().value(), toDocument(tenant.profile()),
toDocument(tenant.proposedGuarantees()), toDocument(tenant.accountClosure()),
tenant.attachments().stream().map(MongoTenantRepository::toDocument).toList(),
tenant.leaseReferences().stream().map(LeaseId::value).toList(), persistedVersion(tenant.version()));
}
@@ -78,23 +89,64 @@ public class MongoTenantRepository implements TenantRepository {
? List.of()
: document.attachments().stream().map(MongoTenantRepository::toAttachment).toList();
return Tenant.reconstitute(new TenantId(document.id()), new LandlordId(document.landlordId()),
document.firstName(), document.lastName(), document.email(), document.phone(), document.address(),
attachments,
Tenant tenant = Tenant.reconstitute(new TenantId(document.id()), new LandlordId(document.landlordId()),
toDomain(document.profile()), attachments,
document.leaseReferences() == null
? Set.of()
: document.leaseReferences().stream().map(LeaseId::new).collect(Collectors.toUnmodifiableSet()),
domainVersion(document.version()));
toDomain(document.accountClosure()), domainVersion(document.version()));
if (document.proposedGuarantees() != null) {
tenant.recordProposedGuarantees(toDomain(document.proposedGuarantees()));
}
return tenant;
}
static TenantAccountClosureDocument toDocument(TenantAccountClosure closure) {
return closure == null
? null
: new TenantAccountClosureDocument(closure.closedOn(), closure.recordedAt(), closure.actorId());
}
static TenantAccountClosure toDomain(TenantAccountClosureDocument document) {
return document == null
? null
: new TenantAccountClosure(document.closedOn(), document.recordedAt(), document.actorId());
}
static TenantProfileDocument toDocument(TenantProfile profile) {
return new TenantProfileDocument(profile.vaultPublicKey(), toDocument(profile.identityAndAddress()),
toDocument(profile.contacts()), profile.contractProfileComplete());
}
static TenantProfile toDomain(TenantProfileDocument document) {
return new TenantProfile(document.vaultPublicKey(), toDomain(document.identityAndAddress()),
toDomain(document.contacts()), Boolean.TRUE.equals(document.contractProfileComplete()));
}
static EncryptedGroupDocument toDocument(TenantProfile.EncryptedGroup group) {
return group == null
? null
: new EncryptedGroupDocument(group.cryptoVersion(), group.nonce(), group.wrappedKey(), group.sha256(),
group.ciphertext());
}
static TenantProfile.EncryptedGroup toDomain(EncryptedGroupDocument document) {
return document == null
? null
: new TenantProfile.EncryptedGroup(document.cryptoVersion(), document.nonce(), document.wrappedKey(),
document.sha256(), document.ciphertext());
}
static TenantAttachmentDocument toDocument(TenantAttachment attachment) {
return new TenantAttachmentDocument(attachment.id(), attachment.fileName(), attachment.contentType(),
attachment.size(), attachment.storageKey());
return new TenantAttachmentDocument(attachment.id(), attachment.cryptoVersion(), attachment.nonce(),
attachment.wrappedKey(), attachment.sha256(), attachment.ciphertextSize(), attachment.vaultPublicKey(),
attachment.storageKey(), attachment.retention(), attachment.originalApplication());
}
static TenantAttachment toAttachment(TenantAttachmentDocument document) {
return new TenantAttachment(document.id(), document.fileName(), document.contentType(), document.size(),
document.storagePath());
return new TenantAttachment(document.id(), document.cryptoVersion(), document.nonce(), document.wrappedKey(),
document.sha256(), document.ciphertextSize(), document.vaultPublicKey(), document.storageKey(),
document.retention(), document.originalApplication());
}
private static Long persistedVersion(long version) {
@@ -107,15 +159,27 @@ public class MongoTenantRepository implements TenantRepository {
}
@Document(collection = MongoTenantRepository.COLLECTION)
record TenantDocument(@Id String id, @Indexed String landlordId, String firstName, String lastName, String email,
String phone, String address, List<TenantAttachmentDocument> attachments, List<String> leaseReferences,
@Version Long version) {
record TenantDocument(@Id String id, @Indexed String landlordId, TenantProfileDocument profile,
EncryptedGroupDocument proposedGuarantees, TenantAccountClosureDocument accountClosure,
List<TenantAttachmentDocument> attachments, List<String> leaseReferences, @Version Long version) {
TenantDocument withVersion(Long version) {
return new TenantDocument(id, landlordId, firstName, lastName, email, phone, address, attachments,
return new TenantDocument(id, landlordId, profile, proposedGuarantees, accountClosure, attachments,
leaseReferences, version);
}
}
record TenantAttachmentDocument(String id, String fileName, String contentType, long size, String storagePath) {
record TenantAccountClosureDocument(LocalDate closedOn, Instant recordedAt, String actorId) {
}
record TenantProfileDocument(String vaultPublicKey, EncryptedGroupDocument identityAndAddress,
EncryptedGroupDocument contacts, Boolean contractProfileComplete) {
}
record EncryptedGroupDocument(int cryptoVersion, String nonce, String wrappedKey, String sha256, String ciphertext) {
}
record TenantAttachmentDocument(String id, int cryptoVersion, String nonce, String wrappedKey, String sha256,
long ciphertextSize, String vaultPublicKey, String storageKey, TenantEvidenceRetention retention,
TenantAttachment.OriginalApplication originalApplication) {
}
@@ -1,284 +0,0 @@
package fr.nessar.lmnporganizer.rental.adapter.out.pdf;
import com.openhtmltopdf.pdfboxout.PdfRendererBuilder;
import fr.nessar.lmnporganizer.rental.application.port.out.PropertyConditionAttachmentStorage;
import fr.nessar.lmnporganizer.rental.application.port.out.PropertyConditionReportRenderer;
import fr.nessar.lmnporganizer.rental.domain.condition.AccessMeansType;
import fr.nessar.lmnporganizer.rental.domain.condition.ConditionObservation;
import fr.nessar.lmnporganizer.rental.domain.condition.ConditionPhoto;
import fr.nessar.lmnporganizer.rental.domain.condition.ConditionPhotoTarget;
import fr.nessar.lmnporganizer.rental.domain.condition.ConditionState;
import fr.nessar.lmnporganizer.rental.domain.condition.ConditionZone;
import fr.nessar.lmnporganizer.rental.domain.condition.InventoryCondition;
import fr.nessar.lmnporganizer.rental.domain.condition.MeterType;
import fr.nessar.lmnporganizer.rental.domain.condition.ParticipantAttendance;
import fr.nessar.lmnporganizer.rental.domain.condition.PropertyConditionReport;
import fr.nessar.lmnporganizer.rental.domain.condition.PropertyConditionReportType;
import fr.nessar.lmnporganizer.rental.domain.condition.ReportPartyRole;
import fr.nessar.lmnporganizer.rental.domain.condition.ZoneEquipment;
import fr.nessar.lmnporganizer.rental.domain.condition.ZonePhotoAbsence;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.ArrayList;
import java.util.Base64;
import java.util.HexFormat;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.stream.Stream;
import org.springframework.stereotype.Component;
import org.thymeleaf.TemplateEngine;
import org.thymeleaf.context.Context;
@Component
public final class ThymeleafPropertyConditionReportRenderer implements PropertyConditionReportRenderer {
private final TemplateEngine templates;
private final PropertyConditionAttachmentStorage storage;
public ThymeleafPropertyConditionReportRenderer(TemplateEngine templates,
PropertyConditionAttachmentStorage storage) {
this.templates = templates;
this.storage = storage;
}
@Override
public byte[] render(PropertyConditionReport report, int revisionNumber, String footer) {
Context context = new Context(Locale.FRANCE);
context.setVariable("report", report);
context.setVariable("revisionNumber", revisionNumber);
context.setVariable("documentFooter", footer);
context.setVariable("documentPageNumbers", report.type() == PropertyConditionReportType.EXIT);
context.setVariable("labels", new PropertyConditionReportPrintLabels());
context.setVariable("areas", areas(report));
context.setVariable("meterPhotos", technicalPhotos(report, ConditionPhotoTarget.Kind.METER));
context.setVariable("accessMeansPhotos", technicalPhotos(report, ConditionPhotoTarget.Kind.ACCESS_MEANS));
context.setVariable("participantLabels", participantLabels(report));
try (ByteArrayOutputStream output = new ByteArrayOutputStream()) {
new PdfRendererBuilder()
.withHtmlContent(templates.process("rental/property-condition-report", context), null)
.toStream(output).run();
return output.toByteArray();
} catch (IOException failure) {
throw new IllegalStateException("Property condition report PDF could not be generated.", failure);
}
}
private List<PrintArea> areas(PropertyConditionReport report) {
Map<String, String> labels = areaLabels(report);
Map<String, ConditionZone> zones = new LinkedHashMap<>();
Map<String, List<ConditionZone>> zonesByArea = new LinkedHashMap<>();
for (ConditionZone zone : report.zones()) {
zones.put(zone.id(), zone);
zonesByArea.computeIfAbsent(zone.privateAreaId(), ignored -> new ArrayList<>()).add(zone);
}
Map<String, ConditionObservation> observations = new LinkedHashMap<>();
report.observations().forEach(observation -> observations.put(observation.subjectId(), observation));
Map<String, List<InventoryCondition>> inventoryByArea = new LinkedHashMap<>();
Map<String, InventoryCondition> inventoryById = new LinkedHashMap<>();
for (InventoryCondition condition : report.inventoryConditions()) {
labels.putIfAbsent(condition.privateAreaId(), condition.privateAreaLabel());
inventoryById.put(condition.inventoryLineId(), condition);
inventoryByArea.computeIfAbsent(condition.privateAreaId(), ignored -> new ArrayList<>()).add(condition);
}
Map<String, List<PrintPhoto>> photosByArea = new LinkedHashMap<>();
for (ConditionPhoto photo : Stream
.concat(report.photos().stream(),
report.supplements().stream().flatMap(supplement -> supplement.photos().stream()))
.filter(photo -> photo.target().privateAreaId() != null).toList()) {
String areaId = photo.target().privateAreaId();
photosByArea.computeIfAbsent(areaId, ignored -> new ArrayList<>())
.add(new PrintPhoto(photoCaption(photo, areaId, labels, zones, inventoryById), dataUri(photo)));
}
Map<String, List<PrintPhotoAbsence>> absencesByArea = new LinkedHashMap<>();
for (ZonePhotoAbsence absence : report.zonePhotoAbsences()) {
PrintSubject subject = subject(absence.subjectId(), labels, zones, inventoryById);
absencesByArea.computeIfAbsent(subject.privateAreaId(), ignored -> new ArrayList<>())
.add(new PrintPhotoAbsence(subject.label(), absence.absence().reason()));
}
List<String> areaIds = new ArrayList<>(zonesByArea.keySet());
inventoryByArea.keySet().forEach(id -> addIfMissing(areaIds, id));
photosByArea.keySet().forEach(id -> addIfMissing(areaIds, id));
absencesByArea.keySet().forEach(id -> addIfMissing(areaIds, id));
List<PrintArea> areas = new ArrayList<>();
for (int index = 0; index < areaIds.size(); index++) {
String areaId = areaIds.get(index);
areas.add(new PrintArea(index + 1, labels.getOrDefault(areaId, "Espace " + areaId),
zonesByArea.getOrDefault(areaId, List.of()).stream()
.map(zone -> new PrintZone(zone, observations.get(zone.id()))).toList(),
inventoryByArea.getOrDefault(areaId, List.of()), photosByArea.getOrDefault(areaId, List.of()),
absencesByArea.getOrDefault(areaId, List.of())));
}
return areas;
}
private Map<String, PrintPhoto> technicalPhotos(PropertyConditionReport report, ConditionPhotoTarget.Kind kind) {
Map<String, PrintPhoto> photos = new LinkedHashMap<>();
for (ConditionPhoto photo : report.photos()) {
if (photo.target().kind() == kind) {
photos.put(photo.target().subjectId(), new PrintPhoto(technicalCaption(report, photo), dataUri(photo)));
}
}
return photos;
}
private static String technicalCaption(PropertyConditionReport report, ConditionPhoto photo) {
return switch (photo.target().kind()) {
case METER -> report.meterReadings().stream().filter(meter -> meter.id().equals(photo.target().subjectId()))
.findFirst()
.map(meter -> "Compteur — " + (meter.label().isEmpty()
? new PropertyConditionReportPrintLabels().meter(meter.type())
: meter.label()))
.orElse("Compteur");
case ACCESS_MEANS ->
report.accessMeans().stream().filter(access -> access.id().equals(photo.target().subjectId()))
.findFirst().map(access -> "Moyen d’accès — " + access.destination()).orElse("Moyen d’accès");
default -> throw new IllegalArgumentException("Technical photo target is invalid.");
};
}
private static void addIfMissing(List<String> values, String value) {
if (!values.contains(value))
values.add(value);
}
private static Map<String, String> areaLabels(PropertyConditionReport report) {
Map<String, String> labels = new LinkedHashMap<>();
for (InventoryCondition inventory : report.inventoryConditions()) {
labels.putIfAbsent(inventory.privateAreaId(), inventory.privateAreaLabel());
}
for (ConditionZone zone : report.zones()) {
labels.putIfAbsent(zone.privateAreaId(), areaLabel(zone.label()));
}
return labels;
}
private static String areaLabel(String zoneLabel) {
int separator = zoneLabel.indexOf(" — ");
return separator < 0 ? zoneLabel : zoneLabel.substring(0, separator);
}
private static String photoCaption(ConditionPhoto photo, String areaId, Map<String, String> areaLabels,
Map<String, ConditionZone> zones, Map<String, InventoryCondition> inventory) {
String area = areaLabels.getOrDefault(areaId, "Espace " + areaId);
return switch (photo.target().kind()) {
case OVERVIEW -> area + " — photo d’ensemble";
case ADDITIONAL -> area + " — photo supplémentaire";
case DEGRADED_ELEMENT -> subject(photo.target().subjectId(), areaLabels, zones, inventory).label();
case METER, ACCESS_MEANS -> throw new IllegalArgumentException("Technical photo has no private area.");
};
}
private static PrintSubject subject(String subjectId, Map<String, String> areaLabels,
Map<String, ConditionZone> zones, Map<String, InventoryCondition> inventory) {
ConditionZone zone = zones.get(subjectId);
if (zone != null) {
String area = areaLabels.getOrDefault(zone.privateAreaId(), "Espace " + zone.privateAreaId());
return new PrintSubject(zone.privateAreaId(), area + " — " + relativeLabel(area, zone.label()));
}
InventoryCondition line = inventory.get(subjectId);
if (line != null) {
String area = areaLabels.getOrDefault(line.privateAreaId(), line.privateAreaLabel());
return new PrintSubject(line.privateAreaId(), area + " — " + line.designation());
}
return new PrintSubject("inconnu", "Élément " + subjectId);
}
private static String relativeLabel(String area, String label) {
String prefix = area + " — ";
return label.startsWith(prefix) ? label.substring(prefix.length()) : label;
}
private String dataUri(ConditionPhoto photo) {
try (InputStream content = storage.read(photo.thumbnailStorageKey())) {
byte[] thumbnail = content.readAllBytes();
if (!photo.thumbnailSha256().equals(sha256(thumbnail))) {
throw new IllegalStateException("Property condition photo thumbnail fingerprint changed.");
}
return "data:image/png;base64," + Base64.getEncoder().encodeToString(thumbnail);
} catch (IOException failure) {
throw new IllegalStateException("Property condition report photo could not be read.", failure);
}
}
private static String sha256(byte[] content) {
try {
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(content));
} catch (NoSuchAlgorithmException failure) {
throw new IllegalStateException("SHA-256 is unavailable.", failure);
}
}
private static Map<String, String> participantLabels(PropertyConditionReport report) {
Map<String, String> labels = new LinkedHashMap<>();
report.participants().forEach(participant -> labels.put(participant.partyId(), participant.name()));
return labels;
}
public record PrintArea(int visitNumber, String label, List<PrintZone> zones, List<InventoryCondition> inventory,
List<PrintPhoto> photos, List<PrintPhotoAbsence> photoAbsences) {
}
public record PrintZone(ConditionZone zone, ConditionObservation observation) {
}
public record PrintPhoto(String caption, String dataUri) {
}
public record PrintPhotoAbsence(String label, String reason) {
}
private record PrintSubject(String privateAreaId, String label) {
}
public static final class PropertyConditionReportPrintLabels {
public String role(ReportPartyRole value) {
return value == ReportPartyRole.LANDLORD ? "Bailleur" : "Locataire";
}
public String attendance(ParticipantAttendance value) {
return value == ParticipantAttendance.PRESENT ? "Présent" : "Représenté";
}
public String equipment(ZoneEquipment value) {
return switch (value) {
case NONE -> "Aucun";
case CLIMATISATION -> "Climatisation";
case OTHER -> "Autre";
};
}
public String condition(ConditionState value) {
return switch (value) {
case NEUF -> "Neuf";
case BON_ETAT -> "Bon état";
case ETAT_USAGE -> "État d’usage";
case DEGRADE -> "Dégradé";
case ABSENT -> "Absent";
case NON_APPLICABLE -> "Non applicable";
};
}
public String meter(MeterType value) {
return switch (value) {
case ELECTRICITE -> "Électricité";
case GAZ -> "Gaz";
case EAU_FROIDE -> "Eau froide";
case EAU_CHAUDE -> "Eau chaude";
case OTHER -> "Autre";
};
}
public String access(AccessMeansType value) {
return switch (value) {
case CLE -> "Clé";
case BADGE -> "Badge";
case TELECOMMANDE -> "Télécommande";
case OTHER -> "Autre";
};
}
}
}
@@ -2,237 +2,36 @@ package fr.nessar.lmnporganizer.rental.adapter.out.pdf;
import com.openhtmltopdf.pdfboxout.PdfRendererBuilder;
import fr.nessar.lmnporganizer.rental.application.port.out.RentalDocumentRenderer;
import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentLegalProfile.BuildingType;
import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentLegalProfile.ConstructionPeriod;
import fr.nessar.lmnporganizer.rental.domain.apartment.PrivateArea;
import fr.nessar.lmnporganizer.rental.domain.furniture.FurniturePresence;
import fr.nessar.lmnporganizer.rental.domain.lease.ChargeMethod;
import fr.nessar.lmnporganizer.rental.domain.lease.FurnishedLeaseTemplate;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseGuarantee;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseType;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.math.BigDecimal;
import java.text.NumberFormat;
import java.time.LocalDate;
import java.time.Month;
import java.time.MonthDay;
import java.time.YearMonth;
import java.time.format.TextStyle;
import java.time.temporal.TemporalAccessor;
import java.util.List;
import java.util.Locale;
import org.springframework.stereotype.Component;
import org.thymeleaf.TemplateEngine;
import org.thymeleaf.context.Context;
/**
* Renders only the generic information notice; lease PDFs are browser-owned.
*/
@Component
public class ThymeleafRentalDocumentRenderer implements RentalDocumentRenderer {
private static final FrenchDates DATES = new FrenchDates();
private static final FrenchFormats FORMATS = new FrenchFormats();
public final class ThymeleafRentalDocumentRenderer implements RentalDocumentRenderer {
private final TemplateEngine templates;
public ThymeleafRentalDocumentRenderer(TemplateEngine templates) {
this.templates = templates;
}
@Override
public byte[] renderLease(LeaseDocument document) {
Context context = context(document);
context.setVariable("tenantNames", names(
document.tenants().stream().map(tenant -> tenant.firstName() + " " + tenant.lastName()).toList()));
context.setVariable("guaranteeDetails",
names(document.guarantees().stream().map(guarantee -> guaranteeDetails(guarantee,
document.tenants().stream().filter(tenant -> tenant.id().equals(guarantee.tenantId()))
.findFirst().map(tenant -> tenant.firstName() + " " + tenant.lastName()).orElseThrow()))
.toList()));
return render("rental/lease", context, document.footer(), false);
}
private static String guaranteeDetails(LeaseGuarantee guarantee, String tenantName) {
if (guarantee.type() == LeaseGuarantee.Type.PHYSICAL_PERSON) {
return guarantee.displayName() + " garantit " + tenantName;
}
String details = guarantee.displayName() + ", siège : " + guarantee.organizationAddress() + ", représenté par "
+ guarantee.representativeName();
if (!guarantee.registrationId().isBlank()) {
details += ", immatriculation : " + guarantee.registrationId();
}
if (!guarantee.email().isBlank())
details += ", e-mail : " + guarantee.email();
if (!guarantee.phone().isBlank())
details += ", téléphone : " + guarantee.phone();
return details + " garantit " + tenantName;
}
@Override
public byte[] renderInformationNotice(InformationNotice document) {
return render("rental/information-notice", context(document), "Notice d’information du bail", true);
}
@Override
public byte[] renderPaymentReceipt(PaymentReceipt document) {
Context context = context(document);
context.setVariable("tenantNames", names(document.contract().tenants().stream()
.map(tenant -> tenant.firstName() + " " + tenant.lastName()).toList()));
return render("rental/payment-receipt", context,
"Reçu de règlement · Référence " + document.paymentId().value(), true);
}
@Override
public byte[] renderRentReceipt(RentReceipt document) {
Context context = context(document);
context.setVariable("tenantNames", names(document.contract().tenants().stream()
.map(tenant -> tenant.firstName() + " " + tenant.lastName()).toList()));
return render("rental/rent-receipt", context,
"Quittance de loyer · Générée le " + DATES.date(document.issuedAt()), true);
}
@Override
public byte[] renderRentRevisionRequest(RentRevisionRequest document) {
Context context = context(document);
context.setVariable("tenantNames", names(
document.tenants().stream().map(tenant -> tenant.firstName() + " " + tenant.lastName()).toList()));
return render("rental/rent-revision-request", context, "Demande de révision IRL · Série " + document.seriesId(),
true);
}
private Context context(Object document) {
Context context = new Context(Locale.FRANCE);
context.setVariable("document", document);
context.setVariable("dates", DATES);
context.setVariable("formats", FORMATS);
return context;
}
private byte[] render(String template, Context context, String documentFooter, boolean documentPageNumbers) {
context.setVariable("documentFooter", documentFooter);
context.setVariable("documentPageNumbers", documentPageNumbers);
context.setVariable("documentFooter", "Notice d’information du bail");
context.setVariable("documentPageNumbers", true);
try (ByteArrayOutputStream output = new ByteArrayOutputStream()) {
new PdfRendererBuilder().withHtmlContent(templates.process(template, context), null).toStream(output).run();
new PdfRendererBuilder().withHtmlContent(templates.process("rental/information-notice", context), null)
.toStream(output).run();
return output.toByteArray();
} catch (IOException exception) {
throw new IllegalStateException("Rental PDF could not be generated.", exception);
}
}
private static String names(List<String> names) {
return names.isEmpty() ? "Aucun" : String.join(", ", names);
}
public static final class FrenchDates {
private FrenchDates() {
}
public String date(LocalDate date) {
return date.getDayOfMonth() + " " + monthName(date) + " " + date.getYear();
}
public String monthDay(MonthDay date) {
return date.getDayOfMonth() + " " + monthName(date);
}
public String month(YearMonth month) {
return monthName(month) + " " + month.getYear();
}
private static String monthName(TemporalAccessor date) {
String month = Month.from(date).getDisplayName(TextStyle.FULL, Locale.FRANCE);
return Character.toUpperCase(month.charAt(0)) + month.substring(1);
}
}
public static final class FrenchFormats {
private FrenchFormats() {
}
public String money(BigDecimal value) {
return value == null ? "" : NumberFormat.getCurrencyInstance(Locale.FRANCE).format(value);
}
public String moneyPerSquareMeter(BigDecimal value) {
return value == null ? "" : money(value) + " / m²";
}
public String number(BigDecimal value) {
if (value == null) {
return "";
}
NumberFormat format = NumberFormat.getNumberInstance(Locale.FRANCE);
format.setMinimumFractionDigits(0);
format.setMaximumFractionDigits(2);
return format.format(value);
}
public String revisionFormula(BigDecimal amount, BigDecimal newIndex, BigDecimal previousIndex,
BigDecimal ceiling) {
return money(amount) + " × " + number(newIndex) + " / " + number(previousIndex) + " = " + money(ceiling);
}
public String total(BigDecimal first, BigDecimal second) {
return money(first.add(second));
}
public String buildingType(BuildingType type) {
return type == BuildingType.APARTMENT_BUILDING ? "Immeuble collectif" : "Maison individuelle";
}
public String constructionPeriod(ConstructionPeriod period) {
return switch (period) {
case BEFORE_1949 -> "Avant 1949";
case PERIOD_1949_1974 -> "De 1949 à 1974";
case PERIOD_1975_1989 -> "De 1975 à 1989";
case PERIOD_1990_2005 -> "De 1990 à 2005";
case PERIOD_2006_2012 -> "De 2006 à 2012";
case AFTER_2012 -> "Après 2012";
};
}
public String chargeMethod(ChargeMethod method) {
return method == ChargeMethod.PROVISION
? "Provision sur charges avec régularisation annuelle"
: "Forfait de charges sans régularisation";
}
public String leaseType(LeaseType type) {
return type == LeaseType.STUDENT ? "Location meublée consentie à un étudiant" : "Location meublée d'un an";
}
public String quarter(int quarter) {
return quarter == 1 ? "1er trimestre" : quarter + "e trimestre";
}
public String indexPeriod(String label, int quarter, int year) {
return label + " — " + quarter(quarter) + " " + year;
}
public String yesNo(boolean value) {
return value ? "Oui" : "Non";
}
public boolean present(String value) {
return value != null && !value.isBlank();
}
public boolean positive(BigDecimal value) {
return value != null && value.signum() > 0;
}
public boolean standard(LeaseType type) {
return type == LeaseType.STANDARD;
}
public String privateArea(List<PrivateArea> areas, String id) {
return areas.stream().filter(area -> area.id().equals(id)).findFirst().map(PrivateArea::label).orElse(id);
}
public String furniturePresence(FurniturePresence presence) {
return presence == FurniturePresence.ABSENT ? "Absent" : "Présent";
}
public boolean october2026(String version) {
return !FurnishedLeaseTemplate.fromVisibleId(version).effectiveFrom().isBefore(LocalDate.of(2026, 10, 1));
}
}
}
@@ -3,7 +3,9 @@ package fr.nessar.lmnporganizer.rental.application.condition;
import fr.nessar.lmnporganizer.rental.application.port.out.IdentifierGenerator;
import fr.nessar.lmnporganizer.rental.application.port.out.LeaseDocumentStorage;
import fr.nessar.lmnporganizer.rental.application.port.out.PropertyConditionAttachmentStorage;
import fr.nessar.lmnporganizer.rental.application.port.out.PropertyConditionReportRenderer;
import fr.nessar.lmnporganizer.rental.application.port.out.TransactionRunner;
import fr.nessar.lmnporganizer.rental.application.rentalapplication.ApplicationVaultAccess;
import fr.nessar.lmnporganizer.rental.application.rentalapplication.ApplicationVaultRequired;
import fr.nessar.lmnporganizer.rental.application.shared.ConcurrentChange;
import fr.nessar.lmnporganizer.rental.application.shared.GeneratedDocument;
import fr.nessar.lmnporganizer.rental.application.shared.ResourceNotFound;
@@ -24,12 +26,14 @@ import fr.nessar.lmnporganizer.rental.domain.condition.PropertyConditionReportRe
import fr.nessar.lmnporganizer.rental.domain.condition.PropertyConditionReportSupplement;
import fr.nessar.lmnporganizer.rental.domain.condition.PropertyConditionReportType;
import fr.nessar.lmnporganizer.rental.domain.condition.ReportPartyRole;
import fr.nessar.lmnporganizer.rental.domain.application.ApplicationCryptoValue;
import fr.nessar.lmnporganizer.rental.domain.application.ApplicationVault;
import fr.nessar.lmnporganizer.rental.domain.application.ApplicationVaultRepository;
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
import fr.nessar.lmnporganizer.rental.domain.lease.Lease;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseFinalizationRepository;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseId;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseRepository;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
@@ -38,9 +42,13 @@ import java.security.NoSuchAlgorithmException;
import java.time.Clock;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Base64;
import java.util.Comparator;
import java.util.HexFormat;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
@@ -49,37 +57,47 @@ import java.util.TreeSet;
import java.util.function.Function;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
import org.apache.pdfbox.Loader;
/** Document lifecycle for the independent PropertyConditionReport aggregate. */
public final class PropertyConditionDocumentService {
private final PropertyConditionReportRepository reports;
private final PropertyConditionAttachmentStorage storage;
private final LeaseDocumentStorage leaseStorage;
private final PropertyConditionReportRenderer renderer;
private final LeaseFinalizationRepository leaseFinalizations;
private final LeaseRepository leases;
private final IdentifierGenerator identifiers;
private final Clock clock;
private final ApplicationVaultRepository vaults;
private final ApplicationVaultAccess vaultAccess;
private final TransactionRunner transactions;
private final long maximumEncryptedPdfSize;
public PropertyConditionDocumentService(PropertyConditionReportRepository reports,
PropertyConditionAttachmentStorage storage, LeaseDocumentStorage leaseStorage,
PropertyConditionReportRenderer renderer, LeaseFinalizationRepository leaseFinalizations,
LeaseRepository leases, IdentifierGenerator identifiers, Clock clock) {
this.reports = reports;
this.storage = storage;
this.leaseStorage = leaseStorage;
this.renderer = renderer;
this.leaseFinalizations = leaseFinalizations;
this.leases = leases;
this.identifiers = identifiers;
this.clock = clock;
LeaseFinalizationRepository leaseFinalizations, LeaseRepository leases, IdentifierGenerator identifiers,
Clock clock, ApplicationVaultRepository vaults, ApplicationVaultAccess vaultAccess,
TransactionRunner transactions, long maximumEncryptedPdfSize) {
this.reports = Objects.requireNonNull(reports);
this.storage = Objects.requireNonNull(storage);
this.leaseStorage = Objects.requireNonNull(leaseStorage);
this.leaseFinalizations = Objects.requireNonNull(leaseFinalizations);
this.leases = Objects.requireNonNull(leases);
this.identifiers = Objects.requireNonNull(identifiers);
this.clock = Objects.requireNonNull(clock);
this.vaults = Objects.requireNonNull(vaults);
this.vaultAccess = Objects.requireNonNull(vaultAccess);
this.transactions = Objects.requireNonNull(transactions);
if (maximumEncryptedPdfSize < 1)
throw new IllegalArgumentException("Maximum encrypted PDF size must be positive.");
this.maximumEncryptedPdfSize = maximumEncryptedPdfSize;
}
public PropertyConditionReportRevision generateRevision(LandlordId landlord, LeaseId lease,
PropertyConditionReportType type, long version, String authorId) {
/**
* Recomputes the report facts and fingerprint consumed by the browser PDF
* renderer. No document bytes are generated or persisted here.
*/
public RevisionPreparation prepareRevision(LandlordId landlord, LeaseId lease, PropertyConditionReportType type,
long version, String authorId) {
if (type == PropertyConditionReportType.ENTRY) {
throw new PropertyConditionReportConflict(
"L’état des lieux d’entrée est généré dans le dossier unique du bail.");
@@ -91,31 +109,87 @@ public final class PropertyConditionDocumentService {
report.requireReadyForSignature();
String fingerprint = report.revisionSourceFingerprint();
PropertyConditionReportRevision current = report.currentRevision();
if (current != null && current.sourceFingerprint().equals(fingerprint))
return current;
int number = report.revisions().stream().mapToInt(PropertyConditionReportRevision::number).max().orElse(0) + 1;
byte[] pdf = renderer.render(report, number, "État des lieux de sortie · Révision " + number);
String storageKey = storage.store(lease, identifiers.next(), "pdf", pdf);
int number = current != null && current.sourceFingerprint().equals(fingerprint)
? current.number()
: report.revisions().stream().mapToInt(PropertyConditionReportRevision::number).max().orElse(0) + 1;
return new RevisionPreparation(report, report.version(), number, fingerprint, authorId);
}
public PropertyConditionReportRevision commitRevision(LandlordId landlord, LeaseId lease,
PropertyConditionReportType type, long version, String sourceFingerprint, EncryptedDocument encryptedPdf,
String authorId) {
if (type == PropertyConditionReportType.ENTRY) {
throw new PropertyConditionReportConflict(
"L’état des lieux d’entrée est généré dans le dossier unique du bail.");
}
PropertyConditionReport initialReport = report(landlord, lease, type);
PropertyConditionReportRevision current = initialReport.currentRevision();
if (!initialReport.finalized() && current != null && current.sourceFingerprint().equals(sourceFingerprint)
&& sourceFingerprint.equals(initialReport.revisionSourceFingerprint())) {
ApplicationVault vault = requireCurrentVault(landlord, encryptedPdf);
validateEncryptedDocument(encryptedPdf);
requireSourceVault(initialReport, encryptedPdf.vaultPublicKey());
if (current.vaultPublicKey().equals(vault.publicKey()))
return current;
}
RevisionPreparation preparation = prepareRevision(landlord, lease, type, version, authorId);
if (!Objects.equals(sourceFingerprint, preparation.sourceFingerprint()))
throw new PropertyConditionReportConflict(
"L'état des lieux a changé depuis la préparation ; régénérez le document.");
ApplicationVault vault = requireCurrentVault(landlord, encryptedPdf);
validateEncryptedDocument(encryptedPdf);
requireSourceVault(preparation.report(), encryptedPdf.vaultPublicKey());
byte[] ciphertext = decodeCiphertext(encryptedPdf);
String storageKey = storage.store(lease, identifiers.next(), "pdf.enc", ciphertext);
try {
report.addRevision(new PropertyConditionReportRevision(number, storageKey, sha256(pdf), fingerprint,
authorId, Instant.now(clock), false));
return reports.save(report).currentRevision();
PropertyConditionReportRevision committed = transactions.run(() -> {
PropertyConditionReport currentReport = report(landlord, lease, type);
if (currentReport.finalized())
throw new PropertyConditionReportConflict("L'état des lieux est finalisé.");
currentReport.requireReadyForSignature();
String currentFingerprint = currentReport.revisionSourceFingerprint();
if (!sourceFingerprint.equals(currentFingerprint))
throw new PropertyConditionReportConflict(
"L'état des lieux a changé depuis la préparation ; régénérez le document.");
requireSourceVault(currentReport, encryptedPdf.vaultPublicKey());
fenceCurrentVault(landlord, vault, encryptedPdf);
PropertyConditionReportRevision existing = currentReport.currentRevision();
if (existing != null) {
if (existing.sourceFingerprint().equals(sourceFingerprint)
&& existing.vaultPublicKey().equals(vault.publicKey()))
return existing;
throw new PropertyConditionReportConflict("Une révision existe déjà ; rechargez l'état des lieux.");
}
requireVersion(currentReport, version);
int number = currentReport.revisions().stream().mapToInt(PropertyConditionReportRevision::number).max()
.orElse(0) + 1;
currentReport.addRevision(new PropertyConditionReportRevision(number, storageKey, encryptedPdf.sha256(),
sourceFingerprint, authorId, Instant.now(clock), false, safeFileName(encryptedPdf.fileName()),
encryptedPdf.cryptoVersion(), encryptedPdf.nonce(), encryptedPdf.wrappedKey(),
encryptedPdf.vaultPublicKey()));
return reports.save(currentReport).currentRevision();
});
if (!storageKey.equals(committed.storageKey()))
discardAfterIdempotentCommit(storageKey);
return committed;
} catch (RuntimeException failure) {
discard(storageKey, failure);
throw failure;
}
}
public GeneratedDocument downloadRevision(LandlordId landlord, LeaseId lease, PropertyConditionReportType type,
public EncryptedDocumentDownload revision(LandlordId landlord, LeaseId lease, PropertyConditionReportType type,
int number) {
PropertyConditionReportRevision revision = report(landlord, lease, type).revisions().stream()
.filter(value -> value.number() == number).findFirst()
.orElseThrow(() -> new ResourceNotFound("Property condition report revision"));
byte[] content = read(revision.storageKey());
if (!revision.sha256().equals(sha256(content)))
throw new IllegalStateException("Report revision fingerprint changed.");
return new GeneratedDocument("etat-des-lieux-" + lease.value() + "-revision-" + number + ".pdf",
"application/pdf", content);
if (revision.cryptoVersion() != 1)
throw new PropertyConditionReportConflict("Cette révision ne possède pas de document chiffré.");
requireStoredVault(landlord, revision.vaultPublicKey());
return new EncryptedDocumentDownload(landlord.value(), lease.value(), type, revision.number(),
revision.sourceFingerprint(), revision.fileName(), revision.cryptoVersion(), revision.nonce(),
revision.wrappedKey(), revision.sha256(), revision.vaultPublicKey(),
verifiedContent(revision.storageKey(), revision.sha256()));
}
public GeneratedDocument downloadPhoto(LandlordId landlord, LeaseId lease, PropertyConditionReportType type,
@@ -133,8 +207,35 @@ public final class PropertyConditionDocumentService {
return new GeneratedDocument(reference + ".webp", "image/webp", content);
}
public PropertyConditionReport finalizeReport(LandlordId landlord, LeaseId lease, PropertyConditionReportType type,
long version, boolean attestation, boolean allPartiesSigned, byte[] signedScan, String authorId) {
/**
* Downloads one accepted supplement PDF as an encrypted document envelope.
* Supplement PDFs are uploaded only after the entry has been finalized, so the
* entry finalization revision is the stable AAD scope for the browser.
*/
public EncryptedDocumentDownload downloadSupplement(LandlordId landlord, LeaseId lease, String reference) {
PropertyConditionReport report = report(landlord, lease, PropertyConditionReportType.ENTRY);
PropertyConditionReportFinalization entryFinalization = report.finalization();
if (entryFinalization == null || entryFinalization.cryptoVersion() != 1) {
throw new PropertyConditionReportConflict("Le complément signé exige un état des lieux d'entrée finalisé.");
}
requireStoredVault(landlord, entryFinalization.vaultPublicKey());
PropertyConditionReportSupplement.FileEvidence evidence = report.supplements().stream()
.map(PropertyConditionReportSupplement::signedPdf).filter(Objects::nonNull)
.filter(value -> value.reference().equals(reference)).findFirst()
.orElseThrow(() -> new ResourceNotFound("Property condition report supplement"));
if (!entryFinalization.vaultPublicKey().equals(evidence.vaultPublicKey()))
throw new PropertyConditionReportConflict(
"Le complément signé appartient à une génération de coffre différente.");
requireStoredVault(landlord, evidence.vaultPublicKey());
return new EncryptedDocumentDownload(landlord.value(), lease.value(), PropertyConditionReportType.ENTRY,
entryFinalization.revisionNumber(), entryFinalization.sourceFingerprint(), reference + ".pdf",
evidence.cryptoVersion(), evidence.nonce(), evidence.wrappedKey(), evidence.sha256(),
evidence.vaultPublicKey(), verifiedContent(evidence.storageKey(), evidence.sha256()));
}
public PropertyConditionReport finalizeEncryptedReport(LandlordId landlord, LeaseId lease,
PropertyConditionReportType type, long version, String sourceFingerprint, int revisionNumber,
boolean attestation, boolean allPartiesSigned, EncryptedDocument encryptedScan, String authorId) {
if (type == PropertyConditionReportType.ENTRY) {
throw new PropertyConditionReportConflict("L’état des lieux d’entrée est finalisé avec le bail signé.");
}
@@ -142,8 +243,6 @@ public final class PropertyConditionDocumentService {
throw new IllegalArgumentException("L'attestation de correspondance est obligatoire.");
if (!allPartiesSigned)
throw new IllegalArgumentException("La signature de toutes les parties est obligatoire.");
if (!isPdf(signedScan))
throw new IllegalArgumentException("Le scan signé doit être un PDF valide.");
PropertyConditionReport report = report(landlord, lease, type);
requireVersion(report, version);
if (leaseFinalizations.find(landlord, lease).isEmpty()) {
@@ -151,56 +250,148 @@ public final class PropertyConditionDocumentService {
}
validateFinalizedLease(report, leases.find(landlord, lease).orElseThrow(() -> new ResourceNotFound("Lease")));
PropertyConditionReportRevision revision = report.currentRevision();
if (revision == null)
throw new PropertyConditionReportConflict("La dernière révision doit être générée avant finalisation.");
String storageKey = storage.store(lease, identifiers.next(), "pdf", signedScan);
if (revision == null || revision.number() != revisionNumber
|| !Objects.equals(revision.sourceFingerprint(), sourceFingerprint))
throw new PropertyConditionReportConflict(
"La dernière révision doit être préparée depuis l'état des lieux courant.");
ApplicationVault vault = requireCurrentVault(landlord, encryptedScan);
validateEncryptedDocument(encryptedScan);
requireSourceVault(report, encryptedScan.vaultPublicKey());
if (report.finalized()) {
PropertyConditionReportFinalization current = report.finalization();
if (current != null && current.revisionNumber() == revisionNumber
&& sourceFingerprint.equals(current.sourceFingerprint())
&& current.vaultPublicKey().equals(vault.publicKey()))
return report;
throw new PropertyConditionReportConflict("L'état des lieux est finalisé.");
}
byte[] ciphertext = decodeCiphertext(encryptedScan);
String storageKey = storage.store(lease, identifiers.next(), "pdf.enc", ciphertext);
try {
report.finalizeReport(new PropertyConditionReportFinalization(revision.number(), storageKey,
sha256(signedScan), authorId, Instant.now(clock)));
return reports.save(report);
PropertyConditionReport finalized = transactions.run(() -> {
PropertyConditionReport currentReport = report(landlord, lease, type);
requireSourceVault(currentReport, encryptedScan.vaultPublicKey());
if (currentReport.finalized()) {
PropertyConditionReportFinalization current = currentReport.finalization();
if (current != null && current.revisionNumber() == revisionNumber
&& sourceFingerprint.equals(current.sourceFingerprint())
&& current.vaultPublicKey().equals(vault.publicKey())) {
fenceCurrentVault(landlord, vault, encryptedScan);
return currentReport;
}
throw new PropertyConditionReportConflict("L'état des lieux est finalisé.");
}
if (leaseFinalizations.find(landlord, lease).isEmpty())
throw new PropertyConditionReportConflict("Le bail doit être finalisé avant l'état des lieux.");
validateFinalizedLease(currentReport,
leases.find(landlord, lease).orElseThrow(() -> new ResourceNotFound("Lease")));
PropertyConditionReportRevision currentRevision = currentReport.currentRevision();
if (currentRevision == null || currentRevision.number() != revisionNumber
|| !sourceFingerprint.equals(currentRevision.sourceFingerprint()))
throw new PropertyConditionReportConflict(
"La dernière révision doit être préparée depuis l'état des lieux courant.");
fenceCurrentVault(landlord, vault, encryptedScan);
requireVersion(currentReport, version);
currentReport.finalizeReport(new PropertyConditionReportFinalization(revisionNumber, storageKey,
encryptedScan.sha256(), authorId, Instant.now(clock), sourceFingerprint,
safeFileName(encryptedScan.fileName()), encryptedScan.cryptoVersion(), encryptedScan.nonce(),
encryptedScan.wrappedKey(), encryptedScan.vaultPublicKey()));
return reports.save(currentReport);
});
PropertyConditionReportFinalization committed = finalized.finalization();
if (committed == null || !storageKey.equals(committed.signedStorageKey()))
discardAfterIdempotentCommit(storageKey);
return finalized;
} catch (RuntimeException failure) {
discard(storageKey, failure);
throw failure;
}
}
public PropertyConditionReport attestExternalDelivery(LandlordId landlord, LeaseId lease,
PropertyConditionReportType type, long version, String partyId, String authorId) {
public EncryptedDocumentDownload finalization(LandlordId landlord, LeaseId lease,
PropertyConditionReportType type) {
PropertyConditionReport report = report(landlord, lease, type);
requireVersion(report, version);
report.attestExternalDelivery(
new PropertyConditionReportDeliveryAttestation(partyId, authorId, Instant.now(clock)));
return reports.save(report);
PropertyConditionReportFinalization finalization = report.finalization();
if (finalization == null)
throw new ResourceNotFound("Property condition report finalization");
if (finalization.cryptoVersion() != 1)
throw new PropertyConditionReportConflict("Cette finalisation ne possède pas de document chiffré.");
requireStoredVault(landlord, finalization.vaultPublicKey());
return new EncryptedDocumentDownload(landlord.value(), lease.value(), type, finalization.revisionNumber(),
finalization.sourceFingerprint(), finalization.fileName(), finalization.cryptoVersion(),
finalization.nonce(), finalization.wrappedKey(), finalization.signedSha256(),
finalization.vaultPublicKey(),
type == PropertyConditionReportType.ENTRY
? verifiedLeaseContent(finalization.signedStorageKey(), finalization.signedSha256())
: verifiedContent(finalization.signedStorageKey(), finalization.signedSha256()));
}
public GeneratedDocument downloadBundle(LandlordId landlord, LeaseId lease, PropertyConditionReportType type) {
public PropertyConditionReport attestExternalDelivery(LandlordId landlord, LeaseId lease,
PropertyConditionReportType type, long version, String partyId, String authorId) {
report(landlord, lease, type);
return transactions.run(() -> {
PropertyConditionReport report = report(landlord, lease, type);
requireVersion(report, version);
fenceCurrentVault(landlord, report.vaultPublicKey());
report.attestExternalDelivery(
new PropertyConditionReportDeliveryAttestation(partyId, authorId, Instant.now(clock)));
return reports.save(report);
});
}
/**
* Returns the encrypted signed document and clear operational photo
* descriptors. The browser downloads the photos and assembles the final ZIP; no
* confidential PDF bytes are assembled on the server.
*/
public BundleDescriptor bundle(LandlordId landlord, LeaseId lease, PropertyConditionReportType type) {
if (type == PropertyConditionReportType.ENTRY) {
throw new PropertyConditionReportConflict(
"L’état des lieux d’entrée est inclus dans le dossier finalisé du bail.");
}
PropertyConditionReport report = report(landlord, lease, type);
if (!report.finalized())
throw new PropertyConditionReportConflict("L'état des lieux doit être finalisé.");
TreeMap<String, BundleFile> files = new TreeMap<>();
PropertyConditionReportFinalization finalization = report.finalization();
if (type == PropertyConditionReportType.ENTRY) {
addLeaseDocument(files, "scan-signe.pdf", finalization.signedStorageKey(), finalization.signedSha256());
} else {
add(files, "scan-signe.pdf", finalization.signedStorageKey(), finalization.signedSha256());
}
addPhotos(files, report.photos());
addSupplements(files, report.supplements());
if (finalization == null || finalization.cryptoVersion() != 1)
throw new PropertyConditionReportConflict("Le document finalisé n'est pas chiffré.");
requireStoredVault(landlord, finalization.vaultPublicKey());
PropertyConditionReport entry = report(landlord, lease, PropertyConditionReportType.ENTRY);
PropertyConditionReportFinalization entryFinalization = entry.finalization();
if (entryFinalization == null || entryFinalization.cryptoVersion() != 1)
throw new PropertyConditionReportConflict("L'état des lieux d'entrée doit être finalisé.");
requireStoredVault(landlord, entryFinalization.vaultPublicKey());
EncryptedDocumentDownload signedDocument = new EncryptedDocumentDownload(landlord.value(), lease.value(), type,
finalization.revisionNumber(), finalization.sourceFingerprint(), finalization.fileName(),
finalization.cryptoVersion(), finalization.nonce(), finalization.wrappedKey(),
finalization.signedSha256(), finalization.vaultPublicKey(),
verifiedContent(finalization.signedStorageKey(), finalization.signedSha256()));
Map<String, BundlePhoto> photosByReference = new LinkedHashMap<>();
report.photos().forEach(photo -> addBundlePhoto(photosByReference, photo, null));
report.supplements().forEach(supplement -> supplement.photos()
.forEach(photo -> addBundlePhoto(photosByReference, photo, supplement.id())));
if (report.entrySnapshot() != null) {
addPhotos(files, report.entrySnapshot().photos());
addSupplements(files, report.entrySnapshot().supplements());
report.entrySnapshot().photos().forEach(photo -> addBundlePhoto(photosByReference, photo, null));
report.entrySnapshot().supplements().forEach(supplement -> supplement.photos()
.forEach(photo -> addBundlePhoto(photosByReference, photo, supplement.id())));
}
String manifest = manifest(files);
try (ByteArrayOutputStream output = new ByteArrayOutputStream();
ZipOutputStream zip = new ZipOutputStream(output, StandardCharsets.UTF_8)) {
for (BundleFile file : files.values())
write(zip, file.name(), file.content());
write(zip, "manifest.sha256", manifest.getBytes(StandardCharsets.UTF_8));
zip.finish();
return new GeneratedDocument("dossier-etat-des-lieux-" + lease.value() + ".zip", "application/zip",
output.toByteArray());
} catch (IOException failure) {
throw new IllegalStateException("Property condition report bundle could not be generated.", failure);
List<BundlePhoto> photos = photosByReference.values().stream()
.sorted(Comparator.comparing(BundlePhoto::reference)).toList();
return new BundleDescriptor("dossier-etat-des-lieux-" + lease.value() + ".zip", type,
finalization.sourceFingerprint(), entryFinalization.revisionNumber(),
entryFinalization.sourceFingerprint(), signedDocument, photos,
Stream.concat(report.supplements().stream(),
report.entrySnapshot() == null ? Stream.empty() : report.entrySnapshot().supplements().stream())
.map(PropertyConditionReportSupplement::id).distinct().sorted().toList());
}
private static void addBundlePhoto(Map<String, BundlePhoto> photos, ConditionPhoto photo, String supplementId) {
String key = (supplementId == null ? "" : supplementId) + "\u0000" + photo.reference();
BundlePhoto candidate = new BundlePhoto(photo.reference(), photo.sha256(), photo.target(), supplementId);
BundlePhoto previous = photos.putIfAbsent(key, candidate);
if (previous != null
&& (!previous.sha256().equals(candidate.sha256()) || !previous.target().equals(candidate.target()))) {
throw new PropertyConditionReportConflict("Le dossier contient deux photos contradictoires.");
}
}
@@ -222,66 +413,6 @@ public final class PropertyConditionDocumentService {
photoComparison(snapshot.effectivePhotos(), exit));
}
private void addPhotos(Map<String, BundleFile> files, List<ConditionPhoto> photos) {
photos.stream().sorted(Comparator.comparing(ConditionPhoto::reference))
.forEach(photo -> addPhoto(files, "photos/" + photo.reference() + ".webp", photo));
}
private void addSupplements(Map<String, BundleFile> files, List<PropertyConditionReportSupplement> supplements) {
supplements.stream().sorted(Comparator.comparing(PropertyConditionReportSupplement::id)).forEach(supplement -> {
var pdf = supplement.signedPdf();
if (pdf != null) {
add(files, "complements/" + supplement.id() + "/signe-" + pdf.reference() + ".pdf", pdf.storageKey(),
pdf.sha256());
}
supplement.photos().stream().sorted(Comparator.comparing(value -> value.reference()))
.forEach(photo -> addPhoto(files,
"complements/" + supplement.id() + "/photos/" + photo.reference() + ".webp", photo));
});
}
private void addPhoto(Map<String, BundleFile> files, String name, ConditionPhoto photo) {
add(files, name, read(photo.storageKey()), photo.sha256(), photo.target());
}
private void add(Map<String, BundleFile> files, String name, String storageKey, String expectedSha256) {
add(files, name, read(storageKey), expectedSha256);
}
private void addLeaseDocument(Map<String, BundleFile> files, String name, String storageKey,
String expectedSha256) {
add(files, name, leaseStorage.read(storageKey), expectedSha256);
}
private static void add(Map<String, BundleFile> files, String name, byte[] content, String expectedSha256) {
add(files, name, content, expectedSha256, null);
}
private static void add(Map<String, BundleFile> files, String name, byte[] content, String expectedSha256,
ConditionPhotoTarget target) {
if (!expectedSha256.equals(sha256(content)))
throw new IllegalStateException("Report bundle fingerprint changed.");
BundleFile prior = files.putIfAbsent(name, new BundleFile(name, expectedSha256, content, target));
if (prior != null && !prior.sha256().equals(expectedSha256))
throw new IllegalStateException("Report bundle has duplicate names.");
}
private static String manifest(Map<String, BundleFile> files) {
return files.values().stream().map(file -> file.sha256() + " " + file.name()).reduce("",
(left, line) -> left + line + "\n")
+ files.values().stream().filter(file -> file.target() != null)
.map(file -> "# " + file.name() + " -> " + file.target())
.reduce("", (left, line) -> left + line + "\n");
}
private static void write(ZipOutputStream zip, String name, byte[] content) throws IOException {
ZipEntry entry = new ZipEntry(name);
entry.setTime(0L);
zip.putNextEntry(entry);
zip.write(content);
zip.closeEntry();
}
private static <T> List<PropertyConditionComparison.Line<T>> aligned(List<T> entry, List<T> exit,
Function<T, String> id) {
Map<String, T> entries = entry.stream().collect(Collectors.toMap(id, value -> value));
@@ -306,8 +437,12 @@ public final class PropertyConditionDocumentService {
}
private PropertyConditionReport report(LandlordId landlord, LeaseId lease, PropertyConditionReportType type) {
vaultAccess.requireLandlordUse(landlord);
leases.find(landlord, lease).orElseThrow(() -> new ResourceNotFound("Lease"));
return reports.find(landlord, lease, type).orElseThrow(() -> new ResourceNotFound("Property condition report"));
PropertyConditionReport report = reports.find(landlord, lease, type)
.orElseThrow(() -> new ResourceNotFound("Property condition report"));
requireCurrentReportGeneration(landlord, report);
return report;
}
private static void validateFinalizedLease(PropertyConditionReport report, Lease lease) {
@@ -317,16 +452,11 @@ public final class PropertyConditionDocumentService {
Map<String, ReportPartyRole> expected = new TreeMap<>();
expected.put(snapshot.landlord().id().value(), ReportPartyRole.LANDLORD);
snapshot.tenants().forEach(tenant -> expected.put(tenant.id().value(), ReportPartyRole.TENANT));
Map<String, String> actual = new TreeMap<>();
report.participants().forEach(
participant -> actual.put(participant.partyId(), participant.role() + "|" + participant.name()));
Map<String, String> expectedNames = new TreeMap<>();
expectedNames.put(snapshot.landlord().id().value(),
"LANDLORD|" + snapshot.landlord().firstName() + " " + snapshot.landlord().lastName());
snapshot.tenants().forEach(tenant -> expectedNames.put(tenant.id().value(),
"TENANT|" + tenant.firstName() + " " + tenant.lastName()));
if (!expectedNames.equals(actual))
throw new PropertyConditionReportConflict("Les parties doivent correspondre au bail finalisé.");
Map<String, ReportPartyRole> actual = new TreeMap<>();
report.participants().forEach(participant -> actual.put(participant.partyId(), participant.role()));
if (!expected.equals(actual))
throw new PropertyConditionReportConflict(
"Les identifiants et rôles des parties doivent correspondre au bail finalisé.");
Set<String> areas = snapshot.apartment().privateAreas().stream().map(area -> area.id())
.collect(Collectors.toSet());
if (report.zones().stream()
@@ -343,16 +473,134 @@ public final class PropertyConditionDocumentService {
}
}
private static boolean isPdf(byte[] content) {
if (content == null)
return false;
try (var ignored = Loader.loadPDF(content)) {
return true;
} catch (IOException | RuntimeException failure) {
return false;
private ApplicationVault requireCurrentVault(LandlordId landlord, EncryptedDocument upload) {
vaultAccess.requireLandlordUse(landlord);
ApplicationVault vault = vaults.find(landlord).orElseThrow(ApplicationVaultRequired::new);
if (upload == null || !vault.publicKey().equals(upload.vaultPublicKey()))
throw new PropertyConditionReportConflict(
"Le document ne correspond pas à la génération actuelle du coffre.");
vaultAccess.requireProtectedRead(landlord, upload.vaultPublicKey());
return vault;
}
private void fenceCurrentVault(LandlordId landlord, ApplicationVault expected, EncryptedDocument upload) {
ApplicationVault current = requireCurrentVault(landlord, upload);
if (current.version() != expected.version() || !current.publicKey().equals(expected.publicKey()))
throw new PropertyConditionReportConflict("Le coffre a changé pendant l'enregistrement du document.");
vaults.save(current);
}
private void fenceCurrentVault(LandlordId landlord, String expectedPublicKey) {
vaultAccess.requireLandlordUse(landlord);
ApplicationVault current = vaults.find(landlord).orElseThrow(ApplicationVaultRequired::new);
if (expectedPublicKey == null || !current.publicKey().equals(expectedPublicKey))
throw new PropertyConditionReportConflict("Le coffre a changé pendant l'enregistrement du document.");
vaultAccess.requireProtectedRead(landlord, expectedPublicKey);
vaults.save(current);
}
private void requireStoredVault(LandlordId landlord, String publicKey) {
vaultAccess.requireProtectedRead(landlord, publicKey);
if (vaults.find(landlord).filter(vault -> vault.publicKey().equals(publicKey)).isEmpty())
throw new ApplicationVaultRequired();
}
private void requireSourceVault(PropertyConditionReport report, String uploadPublicKey) {
if (!report.vaultPublicKey().equals(uploadPublicKey))
throw new PropertyConditionReportConflict("Le document ne correspond pas à la génération du constat.");
}
private void requireCurrentReportGeneration(LandlordId landlord, PropertyConditionReport report) {
vaultAccess.requireProtectedRead(landlord, report.vaultPublicKey());
ApplicationVault current = vaults.find(landlord).orElseThrow(ApplicationVaultRequired::new);
if (!current.publicKey().equals(report.vaultPublicKey()))
throw new ApplicationVaultRequired();
}
private void validateEncryptedDocument(EncryptedDocument upload) {
if (upload == null || upload.cryptoVersion() != 1 || upload.fileName() == null || upload.fileName().isBlank()
|| upload.nonce() == null || upload.wrappedKey() == null || upload.sha256() == null
|| upload.sha256().isBlank() || upload.ciphertext() == null || upload.vaultPublicKey() == null
|| upload.vaultPublicKey().isBlank())
throw new IllegalArgumentException("Le document PDF chiffré est invalide.");
decode(upload.nonce(), "Document nonce", 12, 12);
decode(upload.wrappedKey(), "Document wrapped key", 384, 384);
byte[] ciphertext = decode(upload.ciphertext(), "Document ciphertext", 16, maximumEncryptedPdfSize);
decode(upload.vaultPublicKey(), "Document vault public key", 384, 1024);
if (!isHexSha256(upload.sha256()) || isPdfHeader(ciphertext) || !upload.sha256().equals(digest(ciphertext)))
throw new IllegalArgumentException("Le document PDF chiffré est invalide.");
}
private byte[] decodeCiphertext(EncryptedDocument upload) {
return decode(upload.ciphertext(), "Document ciphertext", 16, maximumEncryptedPdfSize);
}
private static byte[] decode(String value, String field, int minimumBytes, long maximumBytes) {
try {
int maximum = Math.toIntExact(Math.min(maximumBytes, Integer.MAX_VALUE));
return Base64.getUrlDecoder().decode(ApplicationCryptoValue.encoded(value, field, minimumBytes, maximum));
} catch (IllegalArgumentException | ArithmeticException failure) {
throw new IllegalArgumentException("Le document PDF chiffré est invalide.", failure);
}
}
private static boolean isPdfHeader(byte[] content) {
return content.length >= 5
&& Arrays.equals(Arrays.copyOf(content, 5), "%PDF-".getBytes(StandardCharsets.US_ASCII));
}
private byte[] verifiedContent(String storageKey, String expectedSha256) {
byte[] content = read(storageKey);
if (!expectedSha256.equals(digest(content)))
throw new IllegalStateException("Condition report document integrity check failed.");
return content;
}
private byte[] verifiedLeaseContent(String storageKey, String expectedSha256) {
byte[] content = leaseStorage.read(storageKey);
if (content == null || !expectedSha256.equals(digest(content)))
throw new IllegalStateException("Lease package document integrity check failed.");
return content;
}
private static String digest(byte[] content) {
try {
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(content));
} catch (NoSuchAlgorithmException failure) {
throw new IllegalStateException("SHA-256 is not available.", failure);
}
}
private static boolean isHexSha256(String value) {
if (value == null || value.length() != 64)
return false;
for (int index = 0; index < value.length(); index++) {
char character = value.charAt(index);
if (!(character >= '0' && character <= '9') && !(character >= 'a' && character <= 'f'))
return false;
}
return true;
}
private static String safeFileName(String value) {
if (value == null || value.isBlank())
return "etat-des-lieux.pdf";
String candidate = value.trim();
int separator = Math.max(candidate.lastIndexOf('/'), candidate.lastIndexOf('\\'));
candidate = candidate.substring(separator + 1);
StringBuilder sanitized = new StringBuilder(candidate.length());
for (int index = 0; index < candidate.length(); index++) {
char character = candidate.charAt(index);
sanitized.append(Character.isISOControl(character) ? '_' : character);
}
String result = sanitized.toString().trim();
if (result.isEmpty())
return "etat-des-lieux.pdf";
if (!result.toLowerCase(Locale.ROOT).endsWith(".pdf"))
result += ".pdf";
return result.length() <= 255 ? result : result.substring(0, 251) + ".pdf";
}
private static void requireVersion(PropertyConditionReport report, long version) {
if (report.version() != version)
throw new ConcurrentChange("Property condition report");
@@ -374,6 +622,14 @@ public final class PropertyConditionDocumentService {
}
}
private void discardAfterIdempotentCommit(String storageKey) {
try {
storage.discard(storageKey);
} catch (RuntimeException cleanup) {
throw new IllegalStateException("Staged condition report document could not be discarded.", cleanup);
}
}
public record PropertyConditionComparison(List<Line<ConditionObservation>> observations,
List<Line<ConditionZone>> zones, List<Line<InventoryCondition>> inventory, List<Line<MeterReading>> meters,
List<Line<AccessMeans>> accessMeans, List<Line<PropertyConditionReportSupplement>> supplements,
@@ -396,6 +652,41 @@ public final class PropertyConditionDocumentService {
}
}
private record BundleFile(String name, String sha256, byte[] content, ConditionPhotoTarget target) {
public record RevisionPreparation(PropertyConditionReport report, long reportVersion, int number,
String sourceFingerprint, String authorId) {
public RevisionPreparation {
Objects.requireNonNull(report);
Objects.requireNonNull(sourceFingerprint);
Objects.requireNonNull(authorId);
}
}
public record EncryptedDocument(String fileName, int cryptoVersion, String nonce, String wrappedKey, String sha256,
String ciphertext, String vaultPublicKey) {
}
public record EncryptedDocumentDownload(String landlordId, String leaseId, PropertyConditionReportType type,
int revisionNumber, String sourceFingerprint, String fileName, int cryptoVersion, String nonce,
String wrappedKey, String sha256, String vaultPublicKey, byte[] ciphertext) {
public EncryptedDocumentDownload {
ciphertext = ciphertext.clone();
}
@Override
public byte[] ciphertext() {
return ciphertext.clone();
}
}
public record BundlePhoto(String reference, String sha256, ConditionPhotoTarget target, String supplementId) {
}
public record BundleDescriptor(String fileName, PropertyConditionReportType type, String sourceFingerprint,
int entryRevisionNumber, String entrySourceFingerprint, EncryptedDocumentDownload signedDocument,
List<BundlePhoto> photos, List<String> supplementIds) {
public BundleDescriptor {
photos = List.copyOf(photos);
supplementIds = List.copyOf(supplementIds);
}
}
}
@@ -4,11 +4,15 @@ import fr.nessar.lmnporganizer.rental.application.port.out.IdentifierGenerator;
import fr.nessar.lmnporganizer.rental.application.port.out.PropertyConditionAttachmentStorage;
import fr.nessar.lmnporganizer.rental.application.port.out.PropertyConditionPhotoNormalizer;
import fr.nessar.lmnporganizer.rental.application.port.out.TransactionRunner;
import fr.nessar.lmnporganizer.rental.application.rentalapplication.ApplicationVaultAccess;
import fr.nessar.lmnporganizer.rental.application.rentalapplication.ApplicationVaultRequired;
import fr.nessar.lmnporganizer.rental.application.shared.ConcurrentChange;
import fr.nessar.lmnporganizer.rental.application.shared.ResourceNotFound;
import fr.nessar.lmnporganizer.rental.domain.apartment.Apartment;
import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentRepository;
import fr.nessar.lmnporganizer.rental.domain.apartment.PrivateArea;
import fr.nessar.lmnporganizer.rental.domain.application.ApplicationVault;
import fr.nessar.lmnporganizer.rental.domain.application.ApplicationVaultRepository;
import fr.nessar.lmnporganizer.rental.domain.condition.*;
import fr.nessar.lmnporganizer.rental.domain.furniture.FurnitureInventoryRepository;
import fr.nessar.lmnporganizer.rental.domain.furniture.FurnitureInventorySnapshot;
@@ -16,7 +20,7 @@ import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordRepository;
import fr.nessar.lmnporganizer.rental.domain.lease.*;
import fr.nessar.lmnporganizer.rental.domain.tenant.TenantRepository;
import java.io.IOException;
import fr.nessar.lmnporganizer.rental.domain.tenant.TenantProfile.EncryptedGroup;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Clock;
@@ -28,10 +32,11 @@ import java.util.HashMap;
import java.util.HexFormat;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import org.apache.pdfbox.Loader;
public final class PropertyConditionReportService {
private final PropertyConditionReportRepository reports;
@@ -46,12 +51,15 @@ public final class PropertyConditionReportService {
private final Clock clock;
private final ApartmentRepository apartments;
private final LeaseDocumentRevisionRepository leaseRevisions;
private final ApplicationVaultRepository vaults;
private final ApplicationVaultAccess vaultAccess;
public PropertyConditionReportService(PropertyConditionReportRepository reports, LeaseRepository leases,
FurnitureInventoryRepository inventories, LandlordRepository landlords, TenantRepository tenants,
PropertyConditionPhotoNormalizer normalizer, PropertyConditionAttachmentStorage storage,
TransactionRunner transactions, IdentifierGenerator identifiers, Clock clock,
ApartmentRepository apartments, LeaseDocumentRevisionRepository leaseRevisions) {
ApartmentRepository apartments, LeaseDocumentRevisionRepository leaseRevisions,
ApplicationVaultRepository vaults, ApplicationVaultAccess vaultAccess) {
this.reports = reports;
this.leases = leases;
this.inventories = inventories;
@@ -64,21 +72,26 @@ public final class PropertyConditionReportService {
this.clock = clock;
this.apartments = apartments;
this.leaseRevisions = leaseRevisions;
this.vaults = vaults;
this.vaultAccess = vaultAccess;
}
public PropertyConditionReport get(LandlordId landlord, LeaseId lease, PropertyConditionReportType type,
String timeZone) {
lease(landlord, lease);
return reports.find(landlord, lease, type)
.orElseGet(() -> type == PropertyConditionReportType.EXIT
? transactions.run(() -> createExitDraft(landlord, lease, timeZone))
: createEntryDraft(landlord, lease, timeZone));
return reports.find(landlord, lease, type).map(report -> {
requireCurrentReportGeneration(landlord, report);
return report;
}).orElseGet(() -> transactions.run(() -> type == PropertyConditionReportType.EXIT
? createExitDraft(landlord, lease, timeZone)
: createEntryDraft(landlord, lease, timeZone)));
}
public PropertyConditionReport replace(LandlordId landlord, LeaseId leaseId, PropertyConditionReportType type,
long version, Draft draft) {
PropertyConditionReport report = report(landlord, leaseId, type);
requireVersion(report, version);
requireReportGeneration(landlord, report, draft.vaultPublicKey());
validateDate(draft.occurredOn(), report.timeZone());
if (type == PropertyConditionReportType.EXIT && draft.occurredOn().isBefore(
reports.find(landlord, leaseId, PropertyConditionReportType.ENTRY).orElseThrow().occurredOn()))
@@ -100,7 +113,10 @@ public final class PropertyConditionReportService {
try {
removedPhotos(report, replacement).forEach(photo -> stageDeletion(photo, deletions));
report.replaceDraft(replacement);
PropertyConditionReport saved = reports.save(report);
PropertyConditionReport saved = transactions.run(() -> {
fenceCurrentVault(landlord, report.vaultPublicKey());
return reports.save(report);
});
deletions.forEach(storage::completeDeletion);
return saved;
} catch (RuntimeException failure) {
@@ -150,7 +166,10 @@ public final class PropertyConditionReportService {
}
photos.add(photo);
report.replacePhotos(photos);
saved = reports.save(report);
saved = transactions.run(() -> {
fenceCurrentVault(landlord, report.vaultPublicKey());
return reports.save(report);
});
} catch (RuntimeException failure) {
restore(deletions, failure);
discard(storageKey, failure);
@@ -176,19 +195,24 @@ public final class PropertyConditionReportService {
}
public PropertyConditionReport reconcileInventory(LandlordId landlord, LeaseId leaseId, long reportVersion,
String inventoryLineId) {
String inventoryLineId, String vaultPublicKey) {
return transactions.run(() -> {
PropertyConditionReport report = reports.find(landlord, leaseId, PropertyConditionReportType.ENTRY)
.orElseThrow(() -> new ResourceNotFound("Entry property condition report"));
requireReportGeneration(landlord, report, vaultPublicKey);
requireVersion(report, reportVersion);
InventoryCondition condition = report.inventoryConditions().stream()
.filter(value -> value.inventoryLineId().equals(inventoryLineId)).findFirst()
.orElseThrow(() -> new IllegalArgumentException("Unknown inventory condition."));
var inventory = inventories.find(landlord, leaseId)
.orElseThrow(() -> new PropertyConditionReportConflict("L’inventaire du bail est introuvable."));
inventory.reconcile(inventoryLineId, condition.observedQuantity(), condition.observedDescription());
inventories.save(inventory);
String contractualDescription = inventory.lines().stream().filter(line -> line.id().equals(inventoryLineId))
.map(value -> value.description()).findFirst()
.orElseThrow(() -> new IllegalArgumentException("Unknown furniture line."));
inventory.reconcile(inventoryLineId, condition.observedQuantity(), contractualDescription);
report.reconcileInventoryCondition(inventoryLineId);
fenceCurrentVault(landlord, report.vaultPublicKey());
inventories.save(inventory);
return reports.save(report);
});
}
@@ -213,7 +237,10 @@ public final class PropertyConditionReportService {
report.replacePhotos(remaining);
PropertyConditionReport saved;
try {
saved = reports.save(report);
saved = transactions.run(() -> {
fenceCurrentVault(landlord, report.vaultPublicKey());
return reports.save(report);
});
} catch (RuntimeException failure) {
restore(deletions, failure);
throw failure;
@@ -251,10 +278,10 @@ public final class PropertyConditionReportService {
deletions.add(storage.stageDeletion(photo.thumbnailStorageKey()));
}
public PropertyConditionReport appendSupplement(LandlordId landlord, LeaseId leaseId, byte[] signedPdf,
List<SupplementPhotoSource> photoSources, String author, LocalDate requestedOn, String acceptedAdditions,
PropertyConditionReportSupplementType type, LocalDate heatingPeriodStartedOn, boolean lateWarningConfirmed,
String reason) {
public PropertyConditionReport appendSupplement(LandlordId landlord, LeaseId leaseId, SupplementPdf signedPdf,
List<SupplementPhotoSource> photoSources, String author, String vaultPublicKey, LocalDate requestedOn,
EncryptedGroup acceptedAdditions, PropertyConditionReportSupplementType type,
LocalDate heatingPeriodStartedOn, boolean lateWarningConfirmed, EncryptedGroup reason) {
PropertyConditionReport report = report(landlord, leaseId, PropertyConditionReportType.ENTRY);
if (report.finalized() && signedPdf == null) {
throw new PropertyConditionReportConflict("Un complément après signature exige son scan PDF signé.");
@@ -263,9 +290,8 @@ public final class PropertyConditionReportService {
throw new PropertyConditionReportConflict(
"Un complément avant signature ne comporte pas de scan PDF signé.");
}
if (signedPdf != null && !isPdf(signedPdf)) {
throw new IllegalArgumentException("Le scan PDF signé du complément est invalide.");
}
if (signedPdf != null && !Objects.equals(vaultPublicKey, signedPdf.vaultPublicKey()))
throw new ApplicationVaultRequired();
if (reports.find(landlord, leaseId, PropertyConditionReportType.EXIT).isPresent()) {
throw new PropertyConditionReportConflict(
"Un complément ne peut plus être ajouté après la création de la sortie.");
@@ -295,7 +321,7 @@ public final class PropertyConditionReportService {
? heatingPeriodStartedOn.plusMonths(1)
: report.occurredOn().plusDays(10);
boolean late = requestedOn.isAfter(deadline);
if (late && (!lateWarningConfirmed || reason == null || reason.isBlank())) {
if (late && (!lateWarningConfirmed || reason == null)) {
throw new IllegalArgumentException(
"Le complément tardif exige la confirmation de l'avertissement et un motif.");
}
@@ -312,11 +338,14 @@ public final class PropertyConditionReportService {
try {
PropertyConditionReportSupplement.FileEvidence pdf = null;
if (signedPdf != null) {
if (!sha256(signedPdf.ciphertext()).equals(signedPdf.sha256()))
throw new IllegalArgumentException("Le chiffrement du complément est incohérent.");
String pdfReference = identifiers.next();
String pdfStorageKey = storage.store(leaseId, pdfReference, "pdf", signedPdf);
String pdfStorageKey = storage.store(leaseId, pdfReference, "bin", signedPdf.ciphertext());
storedKeys.add(pdfStorageKey);
pdf = new PropertyConditionReportSupplement.FileEvidence(pdfReference, pdfStorageKey,
sha256(signedPdf));
signedPdf.sha256(), signedPdf.cryptoVersion(), signedPdf.nonce(), signedPdf.wrappedKey(),
signedPdf.vaultPublicKey());
}
List<ConditionPhoto> photos = new ArrayList<>();
for (SupplementPhotoSource source : photoSources == null
@@ -343,7 +372,13 @@ public final class PropertyConditionReportService {
PropertyConditionReportSupplement supplement = new PropertyConditionReportSupplement(identifiers.next(),
type, pdf, photos, author, Instant.now(clock), requestedOn, heatingPeriodStartedOn,
acceptedAdditions, audit);
return transactions.run(() -> appendSupplement(landlord, leaseId, supplement));
return transactions.run(() -> {
PropertyConditionReport current = reports.find(landlord, leaseId, PropertyConditionReportType.ENTRY)
.orElseThrow(() -> new ResourceNotFound("Entry property condition report"));
requireReportGeneration(landlord, current, vaultPublicKey);
fenceCurrentVault(landlord, current.vaultPublicKey());
return appendSupplement(landlord, leaseId, supplement);
});
} catch (RuntimeException failure) {
storedKeys.forEach(key -> discard(key, failure));
throw failure;
@@ -359,6 +394,11 @@ public final class PropertyConditionReportService {
private PropertyConditionReport createEntryDraftFromCurrentSource(LandlordId landlord, LeaseId leaseId, Lease lease,
LocalDate today, ZoneId zone) {
Optional<PropertyConditionReport> existing = reports.find(landlord, leaseId, PropertyConditionReportType.ENTRY);
if (existing.isPresent()) {
requireCurrentReportGeneration(landlord, existing.get());
return existing.get();
}
if (lease.status() != LeaseStatus.DRAFT) {
throw new EntryConditionReportPrerequisite(EntryConditionReportPrerequisite.FailureReason.LEASE_NOT_DRAFT);
}
@@ -384,16 +424,23 @@ public final class PropertyConditionReportService {
throw new EntryConditionReportPrerequisite(
EntryConditionReportPrerequisite.FailureReason.INVENTORY_PRIVATE_AREA_MISSING);
}
ApplicationVault vault = requireCurrentVault(landlord);
requireCurrentTenantVault(landlord, lease, vault.publicKey());
PropertyConditionReport draft = PropertyConditionReport.draft(new PropertyConditionReportId(identifiers.next()),
landlord, leaseId, PropertyConditionReportType.ENTRY, today, zone.getId());
landlord, leaseId, PropertyConditionReportType.ENTRY, today, zone.getId(), vault.publicKey());
prefillEntryDraft(draft, lease, apartment.privateAreas(), FurnitureInventorySnapshot.from(inventory), landlord);
fenceCurrentVault(landlord, vault.publicKey());
return reports.save(draft);
}
private PropertyConditionReport createExitDraft(LandlordId landlord, LeaseId leaseId, String timeZone) {
return reports.find(landlord, leaseId, PropertyConditionReportType.EXIT).orElseGet(() -> {
return reports.find(landlord, leaseId, PropertyConditionReportType.EXIT).map(existing -> {
requireCurrentReportGeneration(landlord, existing);
return existing;
}).orElseGet(() -> {
PropertyConditionReport entry = reports.find(landlord, leaseId, PropertyConditionReportType.ENTRY)
.orElseThrow(() -> new ResourceNotFound("Entry property condition report"));
requireCurrentReportGeneration(landlord, entry);
if (!entry.finalized())
throw new PropertyConditionReportConflict(
"L'état des lieux d'entrée doit être finalisé avant la sortie.");
@@ -406,6 +453,7 @@ public final class PropertyConditionReportService {
exit.zones(), exit.observations(), exit.meterReadings(), exit.accessMeans(),
exit.inventoryConditions(), exit.zonePhotoAbsences(), exit.noIndividualMeterDeclared(),
exit.noAccessMeansDeclared(), exit.stepConfirmations()));
fenceCurrentVault(landlord, entry.vaultPublicKey());
reports.save(entry);
return reports.save(exit);
});
@@ -419,6 +467,7 @@ public final class PropertyConditionReportService {
}
PropertyConditionReport entry = reports.find(landlord, leaseId, PropertyConditionReportType.ENTRY)
.orElseThrow(() -> new ResourceNotFound("Entry property condition report"));
requireCurrentReportGeneration(landlord, entry);
entry.appendSupplement(supplement);
return reports.save(entry);
}
@@ -429,8 +478,10 @@ public final class PropertyConditionReportService {
private PropertyConditionReport report(LandlordId landlord, LeaseId leaseId, PropertyConditionReportType type) {
lease(landlord, leaseId);
return reports.find(landlord, leaseId, type)
PropertyConditionReport report = reports.find(landlord, leaseId, type)
.orElseThrow(() -> new ResourceNotFound("Property condition report"));
requireCurrentReportGeneration(landlord, report);
return report;
}
private void prefillEntryDraft(PropertyConditionReport draft, Lease lease, List<PrivateArea> privateAreas,
@@ -439,15 +490,20 @@ public final class PropertyConditionReportService {
Map<String, String> areaLabels = privateAreas.stream()
.collect(Collectors.toMap(value -> value.id(), value -> value.label()));
draft.replaceDraft(new PropertyConditionReportDraft(draft.occurredOn(), participants(lease, landlord),
draft.forwardingAddresses(), zones,
zones.stream().map(value -> new ConditionObservation(value.id(), ConditionState.BON_ETAT, "")).toList(),
draft.forwardingAddresses(), zones, zones.stream()
.map(value -> new ConditionObservation(value.id(), ConditionState.BON_ETAT, null)).toList(),
draft.meterReadings(), draft.accessMeans(),
inventory.lines().stream().map(line -> new InventoryCondition(line.id(), line.privateAreaId(),
areaLabels.get(line.privateAreaId()), line.designation(), line.quantity(), line.description(),
line.quantity(), line.description(),
List.of(new InventoryQuantitySplit(line.id(),
line.quantity() == 0 ? ConditionState.ABSENT : ConditionState.BON_ETAT, line.quantity(),
line.quantity() == 0 ? "Absent de l’inventaire contractuel." : ""))))
inventory.lines().stream()
.map(line -> new InventoryCondition(line.id(), line.privateAreaId(),
areaLabels.get(line.privateAreaId()), line.designation(), line.quantity(),
line.description(), line.quantity(), null,
List.of(new InventoryQuantitySplit(line.id(),
line.quantity() == 0 ? ConditionState.ABSENT : ConditionState.BON_ETAT,
line.quantity(), null,
line.quantity() == 0
? InventoryObservationCode.ABSENT_FROM_CONTRACTUAL_INVENTORY
: null)),
false))
.toList(),
draft.zonePhotoAbsences(), draft.noIndividualMeterDeclared(), draft.noAccessMeansDeclared(),
draft.stepConfirmations()));
@@ -458,36 +514,45 @@ public final class PropertyConditionReportService {
var snapshot = lease.snapshot();
List<ReportParticipant> values = new ArrayList<>();
values.add(new ReportParticipant(snapshot.landlord().id().value(), ReportPartyRole.LANDLORD,
snapshot.landlord().firstName() + " " + snapshot.landlord().lastName(),
ParticipantAttendance.PRESENT, ""));
snapshot.landlord().firstName() + " " + snapshot.landlord().lastName(), null,
ParticipantAttendance.PRESENT, null));
snapshot.tenants()
.forEach(value -> values.add(new ReportParticipant(value.id().value(), ReportPartyRole.TENANT,
value.firstName() + " " + value.lastName(), ParticipantAttendance.PRESENT, "")));
.forEach(value -> values.add(new ReportParticipant(value.id().value(), ReportPartyRole.TENANT, null,
value.profile().identityAndAddress(), ParticipantAttendance.PRESENT, null)));
return values;
}
var owner = landlords.find(landlord).orElseThrow(() -> new ResourceNotFound("Landlord"));
List<ReportParticipant> values = new ArrayList<>();
values.add(new ReportParticipant(owner.id().value(), ReportPartyRole.LANDLORD,
owner.firstName() + " " + owner.lastName(), ParticipantAttendance.PRESENT, ""));
owner.firstName() + " " + owner.lastName(), null, ParticipantAttendance.PRESENT, null));
lease.tenantIds().forEach(id -> {
var tenant = tenants.find(landlord, id).orElseThrow(() -> new ResourceNotFound("Tenant"));
values.add(new ReportParticipant(tenant.id().value(), ReportPartyRole.TENANT,
tenant.firstName() + " " + tenant.lastName(), ParticipantAttendance.PRESENT, ""));
values.add(new ReportParticipant(tenant.id().value(), ReportPartyRole.TENANT, null,
tenant.profile().identityAndAddress(), ParticipantAttendance.PRESENT, null));
});
return values;
}
private void requireCurrentTenantVault(LandlordId landlord, Lease lease, String publicKey) {
if (lease.snapshot() != null)
return;
if (lease.tenantIds().stream()
.map(id -> tenants.find(landlord, id).orElseThrow(() -> new ResourceNotFound("Tenant")))
.anyMatch(tenant -> !publicKey.equals(tenant.profile().vaultPublicKey())))
throw new ApplicationVaultRequired();
}
private List<ReportParticipant> participants(Lease lease, LandlordId landlord, LocalDate occurredOn) {
if (lease.snapshot() == null)
return participants(lease, landlord);
var version = lease.contractVersionOn(occurredOn);
List<ReportParticipant> values = new ArrayList<>();
values.add(new ReportParticipant(version.landlord().id().value(), ReportPartyRole.LANDLORD,
version.landlord().firstName() + " " + version.landlord().lastName(), ParticipantAttendance.PRESENT,
""));
version.landlord().firstName() + " " + version.landlord().lastName(), null,
ParticipantAttendance.PRESENT, null));
version.tenants()
.forEach(tenant -> values.add(new ReportParticipant(tenant.id().value(), ReportPartyRole.TENANT,
tenant.firstName() + " " + tenant.lastName(), ParticipantAttendance.PRESENT, "")));
.forEach(tenant -> values.add(new ReportParticipant(tenant.id().value(), ReportPartyRole.TENANT, null,
tenant.profile().identityAndAddress(), ParticipantAttendance.PRESENT, null)));
return values;
}
@@ -515,7 +580,7 @@ public final class PropertyConditionReportService {
return prior == null
? participant
: new ReportParticipant(participant.partyId(), participant.role(), participant.name(),
prior.attendance(), prior.representativeName());
participant.identityAndAddress(), prior.attendance(), prior.representativeName());
}).toList();
Set<String> previousTenantIds = report.participants().stream()
.filter(participant -> participant.role() == ReportPartyRole.TENANT).map(ReportParticipant::partyId)
@@ -629,11 +694,17 @@ public final class PropertyConditionReportService {
}
private static boolean sameParties(List<ReportParticipant> expected, List<ReportParticipant> actual) {
return expected.stream().map(value -> value.partyId() + "|" + value.role() + "|" + value.name())
.collect(Collectors.toSet())
.equals(actual.stream().map(value -> value.partyId() + "|" + value.role() + "|" + value.name())
.collect(Collectors.toSet()))
&& expected.size() == actual.size();
if (expected.size() != actual.size())
return false;
Map<String, ReportParticipant> actualById = actual.stream()
.collect(Collectors.toMap(ReportParticipant::partyId, value -> value, (first, ignored) -> first));
return expected.stream().allMatch(value -> {
ReportParticipant submitted = actualById.get(value.partyId());
if (submitted == null || submitted.role() != value.role())
return false;
return value.role() != ReportPartyRole.TENANT
|| Objects.equals(value.identityAndAddress(), submitted.identityAndAddress());
});
}
private ZonePhotoAbsence photoAbsence(PropertyConditionReport report, PhotoAbsence input, String author) {
@@ -671,14 +742,6 @@ public final class PropertyConditionReportService {
}
}
private static boolean isPdf(byte[] content) {
try (var ignored = Loader.loadPDF(content)) {
return true;
} catch (IOException | RuntimeException failure) {
return false;
}
}
private void discard(String key, RuntimeException failure) {
try {
storage.discard(key);
@@ -701,23 +764,77 @@ public final class PropertyConditionReportService {
}
}
public record Draft(LocalDate occurredOn, String author, List<ReportParticipant> participants,
List<ForwardingAddress> forwardingAddresses, List<ConditionZone> zones,
List<ConditionObservation> observations, List<MeterReading> meterReadings, List<AccessMeans> accessMeans,
List<InventoryCondition> inventoryConditions, List<PhotoAbsence> photoAbsences,
boolean noIndividualMeterDeclared, boolean noAccessMeansDeclared,
public record Draft(LocalDate occurredOn, String author, String vaultPublicKey,
List<ReportParticipant> participants, List<ForwardingAddress> forwardingAddresses,
List<ConditionZone> zones, List<ConditionObservation> observations, List<MeterReading> meterReadings,
List<AccessMeans> accessMeans, List<InventoryCondition> inventoryConditions,
List<PhotoAbsence> photoAbsences, boolean noIndividualMeterDeclared, boolean noAccessMeansDeclared,
ReportStepConfirmations stepConfirmations) {
public Draft(LocalDate occurredOn, String author, List<ReportParticipant> participants,
public Draft {
if (vaultPublicKey == null || vaultPublicKey.isBlank())
throw new IllegalArgumentException("Report vault public key is required.");
vaultPublicKey = vaultPublicKey.trim();
}
public Draft(LocalDate occurredOn, String author, String vaultPublicKey, List<ReportParticipant> participants,
List<ForwardingAddress> forwardingAddresses, List<ConditionZone> zones,
List<ConditionObservation> observations, List<MeterReading> meterReadings,
List<AccessMeans> accessMeans, List<InventoryCondition> inventoryConditions,
List<PhotoAbsence> photoAbsences) {
this(occurredOn, author, participants, forwardingAddresses, zones, observations, meterReadings, accessMeans,
inventoryConditions, photoAbsences, false, false, ReportStepConfirmations.none());
this(occurredOn, author, vaultPublicKey, participants, forwardingAddresses, zones, observations,
meterReadings, accessMeans, inventoryConditions, photoAbsences, false, false,
ReportStepConfirmations.none());
}
}
public record PhotoAbsence(String subjectId, String reason) {
public record PhotoAbsence(String subjectId, EncryptedGroup reason) {
public PhotoAbsence {
if (subjectId == null || subjectId.isBlank())
throw new IllegalArgumentException("Photo absence subject is required.");
subjectId = subjectId.trim();
Objects.requireNonNull(reason, "Photo absence reason envelope is required.");
}
}
public record SupplementPdf(byte[] ciphertext, int cryptoVersion, String nonce, String wrappedKey, String sha256,
String vaultPublicKey) {
public SupplementPdf {
if (ciphertext == null || ciphertext.length < 17)
throw new IllegalArgumentException("Le complément chiffré est obligatoire.");
ciphertext = ciphertext.clone();
new PropertyConditionReportSupplement.FileEvidence("supplement", "storage", sha256, cryptoVersion, nonce,
wrappedKey, vaultPublicKey);
}
}
private ApplicationVault requireCurrentVault(LandlordId landlord) {
if (vaults == null || vaultAccess == null)
throw new ApplicationVaultRequired();
vaultAccess.requireLandlordUse(landlord);
ApplicationVault vault = vaults.find(landlord).orElseThrow(ApplicationVaultRequired::new);
vaultAccess.requireProtectedRead(landlord, vault.publicKey());
return vault;
}
private void requireCurrentReportGeneration(LandlordId landlord, PropertyConditionReport report) {
vaultAccess.requireProtectedRead(landlord, report.vaultPublicKey());
ApplicationVault vault = requireCurrentVault(landlord);
if (!vault.publicKey().equals(report.vaultPublicKey()))
throw new ApplicationVaultRequired();
}
private void requireReportGeneration(LandlordId landlord, PropertyConditionReport report, String publicKey) {
requireCurrentReportGeneration(landlord, report);
if (publicKey == null || publicKey.isBlank() || !report.vaultPublicKey().equals(publicKey))
throw new ApplicationVaultRequired();
}
private void fenceCurrentVault(LandlordId landlord, String publicKey) {
ApplicationVault vault = requireCurrentVault(landlord);
if (publicKey == null || !vault.publicKey().equals(publicKey))
throw new ApplicationVaultRequired();
vaultAccess.requireProtectedRead(landlord, publicKey);
vaults.save(vault);
}
private record RefreshedExit(List<ReportParticipant> participants, List<ForwardingAddress> forwardingAddresses) {
@@ -103,6 +103,7 @@ public final class AmendLeaseService {
.map(tenantId -> tenants.find(landlordId, tenantId).orElseThrow(() -> new ResourceNotFound("Tenant")))
.toList();
amendedTenants.forEach(Tenant::requireCompleteContractProfile);
requireCompleteGuarantees(change.guarantees());
VisaleGuarantee visaleGuarantee = switch (change.visaleChange()) {
case PRESERVE -> current.visaleGuarantee();
case REPLACE -> change.visaleGuarantee();
@@ -170,8 +171,7 @@ public final class AmendLeaseService {
}
private static LeaseSnapshot.Tenant snapshot(Tenant tenant) {
return new LeaseSnapshot.Tenant(tenant.id(), tenant.firstName(), tenant.lastName(), tenant.email(),
tenant.phone(), tenant.address());
return new LeaseSnapshot.Tenant(tenant.id(), tenant.profile());
}
private static LeaseSnapshot.Tenant snapshot(LeaseContractVersion current, Tenant tenant) {
@@ -179,6 +179,13 @@ public final class AmendLeaseService {
.orElseGet(() -> snapshot(tenant));
}
private static void requireCompleteGuarantees(List<LeaseGuarantee> guarantees) {
if (guarantees.stream()
.anyMatch(guarantee -> guarantee == null || !guarantee.profile().contractProfileComplete())) {
throw new LeaseRuleViolation("Le profil chiffré de chaque garant doit être complet.");
}
}
public record Change(List<TenantId> tenantIds, List<LeaseGuarantee> guarantees, LocalDate endDate,
BigDecimal monthlyRent, BigDecimal monthlyCharges, Integer dueDay, ChargeMethod chargeMethod,
Boolean indexFlatRate, VisaleChange visaleChange, VisaleGuarantee visaleGuarantee,
@@ -3,25 +3,28 @@ package fr.nessar.lmnporganizer.rental.application.lease;
import fr.nessar.lmnporganizer.rental.application.apartment.ApartmentDocumentService;
import fr.nessar.lmnporganizer.rental.application.port.out.LeaseDocumentStorage;
import fr.nessar.lmnporganizer.rental.application.port.out.LeaseInformationNoticeCatalog.InformationNotice;
import fr.nessar.lmnporganizer.rental.application.port.out.PropertyConditionAttachmentStorage;
import fr.nessar.lmnporganizer.rental.application.port.out.PropertyConditionReportRenderer;
import fr.nessar.lmnporganizer.rental.application.port.out.RentalDocumentRenderer;
import fr.nessar.lmnporganizer.rental.application.port.out.TransactionRunner;
import fr.nessar.lmnporganizer.rental.application.rentalapplication.ApplicationVaultAccess;
import fr.nessar.lmnporganizer.rental.application.rentalapplication.ApplicationVaultRequired;
import fr.nessar.lmnporganizer.rental.application.shared.ConcurrentChange;
import fr.nessar.lmnporganizer.rental.application.shared.GeneratedDocument;
import fr.nessar.lmnporganizer.rental.application.shared.ResourceNotFound;
import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentDocument;
import fr.nessar.lmnporganizer.rental.domain.condition.PropertyConditionReport;
import fr.nessar.lmnporganizer.rental.domain.condition.PropertyConditionReportRepository;
import fr.nessar.lmnporganizer.rental.domain.condition.PropertyConditionReportType;
import fr.nessar.lmnporganizer.rental.domain.application.ApplicationVault;
import fr.nessar.lmnporganizer.rental.domain.application.ApplicationVaultRepository;
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
import fr.nessar.lmnporganizer.rental.domain.lease.FurnishedLeaseTemplate;
import fr.nessar.lmnporganizer.rental.domain.lease.Lease;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseDocumentAnnex.Category;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseDocumentAnnex;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseDocumentRevision;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseDocumentRevisionConflict;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseDocumentRevisionRepository;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseFinalization;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseFinalizationConflict;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseDocumentWarning;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseFinalizationRepository;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseGuarantee;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseGuaranteeDeed;
@@ -30,9 +33,6 @@ import fr.nessar.lmnporganizer.rental.domain.lease.LeaseId;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseRepository;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseSnapshot;
import fr.nessar.lmnporganizer.rental.domain.lease.VisaleSuretyContract;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
@@ -40,151 +40,249 @@ import java.time.Clock;
import java.time.Instant;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Base64;
import java.util.Comparator;
import java.util.HashSet;
import java.util.HexFormat;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import java.util.UUID;
import java.util.stream.Stream;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.io.IOUtils;
import org.apache.pdfbox.io.RandomAccessReadBuffer;
import org.apache.pdfbox.multipdf.PDFMergerUtility;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPageContentStream.AppendMode;
import org.apache.pdfbox.pdmodel.PDPageContentStream;
import org.apache.pdfbox.pdmodel.font.PDType1Font;
import org.apache.pdfbox.pdmodel.font.Standard14Fonts;
public final class GenerateLeaseDocumentService {
private static final int MAX_INSERT_ATTEMPTS = 3;
private final LeaseRepository leases;
private final RentalDocumentRenderer renderer;
private final Clock clock;
private final LeaseDocumentRevisionRepository revisions;
private final LeaseDocumentStorage storage;
private final PropertyConditionAttachmentStorage conditionAttachments;
private final ApartmentDocumentService apartmentDocuments;
private final PropertyConditionReportRepository reports;
private final PropertyConditionReportRenderer conditionRenderer;
private final LeaseDocumentRevisionSource source;
private final LeaseFinalizationRepository finalizations;
private final LeaseGuaranteeDeedRepository guaranteeDeeds;
private final ApplicationVaultRepository vaults;
private final ApplicationVaultAccess vaultAccess;
private final TransactionRunner transactions;
public GenerateLeaseDocumentService(LeaseRepository leases, RentalDocumentRenderer renderer, Clock clock,
LeaseDocumentRevisionRepository revisions, LeaseDocumentStorage storage,
PropertyConditionAttachmentStorage conditionAttachments, ApartmentDocumentService apartmentDocuments,
PropertyConditionReportRepository reports, PropertyConditionReportRenderer conditionRenderer,
LeaseDocumentRevisionSource source, LeaseFinalizationRepository finalizations,
LeaseGuaranteeDeedRepository guaranteeDeeds) {
public GenerateLeaseDocumentService(LeaseRepository leases, Clock clock, LeaseDocumentRevisionRepository revisions,
LeaseDocumentStorage storage, ApartmentDocumentService apartmentDocuments,
PropertyConditionReportRepository reports, LeaseDocumentRevisionSource source,
LeaseFinalizationRepository finalizations, LeaseGuaranteeDeedRepository guaranteeDeeds,
ApplicationVaultRepository vaults, ApplicationVaultAccess vaultAccess, TransactionRunner transactions) {
this.leases = leases;
this.renderer = renderer;
this.clock = clock;
this.revisions = revisions;
this.storage = storage;
this.conditionAttachments = conditionAttachments;
this.apartmentDocuments = apartmentDocuments;
this.reports = reports;
this.conditionRenderer = conditionRenderer;
this.source = source;
this.finalizations = finalizations;
this.guaranteeDeeds = guaranteeDeeds;
this.vaults = vaults;
this.vaultAccess = vaultAccess;
this.transactions = transactions;
}
public LeaseDocumentRevision generateRevision(LandlordId landlordId, LeaseId leaseId, String authorId) {
if (authorId == null || authorId.isBlank()) {
throw new IllegalArgumentException("Author ID is required.");
}
/**
* Returns the authoritative facts needed by the browser renderer. No PDF bytes
* are created or persisted by this operation.
*/
public RevisionPreparation prepareRevision(LandlordId landlordId, LeaseId leaseId, long leaseVersion,
String authorId) {
requireAuthor(authorId);
vaultAccess.requireLandlordUse(landlordId);
Lease lease = lease(landlordId, leaseId);
requireLeaseVersion(lease, leaseVersion);
FurnishedLeaseTemplate template = FurnishedLeaseTemplate.effectiveOn(LocalDate.now(clock));
final LeaseDocumentRevisionSource.Source packageSource = source.forGeneration(landlordId, lease, template);
var current = revisions.findCurrent(landlordId, leaseId);
if (current.filter(revision -> revision.sourceFingerprint().equals(packageSource.fingerprint())).isPresent()) {
return current.orElseThrow();
}
LeaseDocumentRevisionSource.Source packageSource = source.forGeneration(landlordId, lease, template);
LeaseDocumentRevision current = revisions.findCurrent(landlordId, leaseId).orElse(null);
boolean sameCurrentEnvelope = current != null && current.sourceFingerprint().equals(packageSource.fingerprint())
&& currentVaultMatches(landlordId, current);
int number = sameCurrentEnvelope
? current.number()
: revisions.findAll(landlordId, leaseId).stream().mapToInt(LeaseDocumentRevision::number).max()
.orElse(0) + 1;
return new RevisionPreparation(landlordId.value(), leaseId.value(), leaseVersion, number, template.visibleId(),
packageSource.fingerprint(), packageSource.withRevision(number), annexDescriptors(packageSource),
packageSource.warnings(), conditionPhotos(packageSource.entry()));
}
for (int attempt = 0; attempt < MAX_INSERT_ATTEMPTS; attempt++) {
int number = revisions.findCurrent(landlordId, leaseId).map(revision -> revision.number() + 1).orElse(1);
List<String> storedKeys = new ArrayList<>();
try {
var document = packageSource.withRevision(number);
byte[] bytes = renderer.renderLease(document);
bytes = numberPages(
merge(bytes, conditionRenderer.render(packageSource.entry(), number, document.footer())));
String storageKey = store(bytes, storedKeys);
List<LeaseDocumentAnnex> annexes = copyAnnexes(landlordId, lease, packageSource, storedKeys);
LeaseDocumentRevision revision = new LeaseDocumentRevision(UUID.randomUUID().toString(), landlordId,
leaseId, number, template.visibleId(), packageSource.fingerprint(), storageKey, sha256(bytes),
Instant.now(clock), authorId, packageSource.document().furnitureInventory(),
packageSource.document().apartment().privateAreas(), annexes,
conditionPhotos(packageSource.entry()), packageSource.warnings());
return revisions.save(revision);
} catch (LeaseDocumentRevisionConflict conflict) {
discard(storedKeys, conflict);
if (conflict.getSuppressed().length > 0) {
throw conflict;
/**
* Stores only the browser-produced encrypted PDF and frozen operational
* metadata.
*/
public LeaseDocumentRevision commitRevision(LandlordId landlordId, LeaseId leaseId, long leaseVersion,
String sourceFingerprint, LeaseDocumentEncryption encryptedPdf, String authorId) {
RevisionPreparation preparation = prepareRevision(landlordId, leaseId, leaseVersion, authorId);
if (!preparation.sourceFingerprint().equals(sourceFingerprint))
throw new LeaseFinalizationConflict("Le bail a changé depuis la préparation ; régénérez le document.");
validateEncryptedDocument(encryptedPdf);
ApplicationVault vault = requireCurrentVault(landlordId, encryptedPdf);
LeaseDocumentRevision existing = revisions.findCurrent(landlordId, leaseId).orElse(null);
if (existing != null && existing.sourceFingerprint().equals(sourceFingerprint)
&& existing.vaultPublicKey().equals(vault.publicKey())) {
Lease currentLease = lease(landlordId, leaseId);
requireLeaseVersion(currentLease, leaseVersion);
LeaseDocumentRevisionSource.Source currentSource = source.forGeneration(landlordId, currentLease,
FurnishedLeaseTemplate.fromVisibleId(preparation.templateVersion()));
if (!sourceFingerprint.equals(currentSource.fingerprint()))
throw new LeaseFinalizationConflict("Le bail a changé pendant la préparation du document.");
InformationNotice commitNotice = currentSource.notice();
return transactions.run(() -> {
Lease transactionLease = lease(landlordId, leaseId);
requireLeaseVersion(transactionLease, leaseVersion);
fenceCurrentVault(landlordId, vault, encryptedPdf);
LeaseDocumentRevision winner = revisions.findCurrent(landlordId, leaseId)
.filter(candidate -> candidate.sourceFingerprint().equals(sourceFingerprint)
&& candidate.vaultPublicKey().equals(vault.publicKey()))
.orElseThrow(() -> new LeaseFinalizationConflict(
"La révision courante a changé pendant l’enregistrement du document."));
requireCurrentSource(landlordId, leaseId, winner, commitNotice);
source.coordinateCurrentSource(landlordId, leaseId, winner, commitNotice);
return winner;
});
}
List<String> storedKeys = new ArrayList<>();
String storageKey = storage.store(decodeCiphertext(encryptedPdf));
storedKeys.add(storageKey);
boolean cleanupAttempted = false;
try {
Lease lease = lease(landlordId, leaseId);
LeaseDocumentRevisionSource.Source currentSource = source.forGeneration(landlordId, lease,
FurnishedLeaseTemplate.fromVisibleId(preparation.templateVersion()));
if (!sourceFingerprint.equals(currentSource.fingerprint()))
throw new LeaseFinalizationConflict("Le bail a changé pendant la préparation du document.");
List<LeaseDocumentAnnex> annexes = copyAnnexes(landlordId, lease, currentSource, storedKeys);
// Annex copying is outside the database transaction. Re-read the complete
// repository-backed source afterwards so the transaction fence does not
// validate against metadata (including the notice) captured before copying.
LeaseDocumentRevisionSource.Source commitSource = source.forGeneration(landlordId, lease,
FurnishedLeaseTemplate.fromVisibleId(preparation.templateVersion()));
if (!sourceFingerprint.equals(commitSource.fingerprint()))
throw new LeaseFinalizationConflict("Le bail a changé pendant la copie du document.");
LeaseDocumentRevision proposed = new LeaseDocumentRevision(UUID.randomUUID().toString(), landlordId,
leaseId, preparation.number(), preparation.templateVersion(), preparation.sourceFingerprint(),
storageKey, encryptedPdf.sha256(), Instant.now(clock), authorId,
preparation.document().furnitureInventory(), preparation.document().apartment().privateAreas(),
annexes, preparation.conditionPhotos(), preparation.warnings(),
safeFileName(encryptedPdf.fileName()), encryptedPdf.cryptoVersion(), encryptedPdf.nonce(),
encryptedPdf.wrappedKey(), encryptedPdf.vaultPublicKey());
LeaseDocumentRevision committed = transactions.run(() -> {
Lease currentLease = lease(landlordId, leaseId);
requireLeaseVersion(currentLease, leaseVersion);
// The vault touch is the commit fence. It must happen before accepting a
// concurrent winner, otherwise a reset racing this request can make an
// apparently idempotent response point at a stale generation.
fenceCurrentVault(landlordId, vault, encryptedPdf);
LeaseDocumentRevision winner = revisions.findCurrent(landlordId, leaseId).orElse(null);
if (winner != null && winner.sourceFingerprint().equals(sourceFingerprint)
&& winner.vaultPublicKey().equals(vault.publicKey())) {
requireCurrentSource(landlordId, leaseId, winner, commitSource.notice());
source.coordinateCurrentSource(landlordId, leaseId, winner, commitSource.notice());
return winner;
}
var winner = revisions.findCurrent(landlordId, leaseId);
if (winner.filter(revision -> revision.sourceFingerprint().equals(packageSource.fingerprint()))
.isPresent()) {
return winner.orElseThrow();
}
} catch (RuntimeException failure) {
requireCurrentSource(landlordId, leaseId, proposed, commitSource.notice());
source.coordinateCurrentSource(landlordId, leaseId, proposed, commitSource.notice());
return revisions.save(proposed);
});
// A concurrent request may have committed the same source while this
// request was copying its ciphertext and frozen annexes. The winner is
// authoritative; this attempt's staged objects are never referenced and
// must be cleaned up even though the operation itself succeeded.
if (!committed.id().equals(proposed.id())) {
cleanupAttempted = true;
discard(storedKeys, null);
}
return committed;
} catch (RuntimeException failure) {
if (!cleanupAttempted)
discard(storedKeys, failure);
throw failure;
}
}
throw new ConcurrentChange("Lease document revision");
}
private static byte[] merge(byte[] leasePdf, byte[] entryPdf) {
try (ByteArrayOutputStream output = new ByteArrayOutputStream()) {
PDFMergerUtility merger = new PDFMergerUtility();
merger.addSource(new RandomAccessReadBuffer(leasePdf));
merger.addSource(new RandomAccessReadBuffer(entryPdf));
merger.setDestinationStream(output);
merger.mergeDocuments(IOUtils.createMemoryOnlyStreamCache());
return output.toByteArray();
} catch (IOException failure) {
throw new IllegalStateException("Lease package PDF could not be assembled.", failure);
throw failure;
}
}
private static byte[] numberPages(byte[] pdf) {
try (PDDocument document = Loader.loadPDF(pdf); ByteArrayOutputStream output = new ByteArrayOutputStream()) {
PDType1Font font = new PDType1Font(Standard14Fonts.FontName.TIMES_ROMAN);
int total = document.getNumberOfPages();
for (int index = 0; index < total; index++) {
String label = "· Page " + (index + 1) + " / " + total;
var page = document.getPage(index);
float width = font.getStringWidth(label) * 8 / 1000;
float x = page.getMediaBox().getUpperRightX() - centimeters(1.8f) - width;
float y = page.getMediaBox().getLowerLeftY() + centimeters(1);
try (PDPageContentStream content = new PDPageContentStream(document, page, AppendMode.APPEND, true,
true)) {
content.beginText();
content.setFont(font, 8);
content.setNonStrokingColor(75 / 255f, 85 / 255f, 99 / 255f);
content.newLineAtOffset(x, y);
content.showText(label);
content.endText();
}
}
document.save(output);
return output.toByteArray();
} catch (IOException failure) {
throw new IllegalStateException("Lease package pages could not be numbered.", failure);
}
public EncryptedDocumentDownload revision(LandlordId landlordId, LeaseId leaseId, int number) {
vaultAccess.requireLandlordUse(landlordId);
LeaseDocumentRevision revision = revisions.find(landlordId, leaseId, number)
.orElseThrow(() -> new ResourceNotFound("Lease document revision"));
requireStoredVault(landlordId, revision.vaultPublicKey());
return new EncryptedDocumentDownload(landlordId.value(), leaseId.value(), revision.number(),
revision.sourceFingerprint(), revision.fileName(), revision.cryptoVersion(), revision.nonce(),
revision.wrappedKey(), revision.sha256(), revision.vaultPublicKey(),
verifiedContent(revision.storageKey(), revision.sha256()));
}
private static float centimeters(float value) {
return value * 72 / 2.54f;
public AnnexDownload downloadAnnex(LandlordId landlordId, LeaseId leaseId, int number, int index) {
vaultAccess.requireLandlordUse(landlordId);
LeaseDocumentRevision revision = revisions.find(landlordId, leaseId, number)
.orElseThrow(() -> new ResourceNotFound("Lease document revision"));
requireStoredVault(landlordId, revision.vaultPublicKey());
if (index < 0 || index >= revision.annexes().size())
throw new ResourceNotFound("Lease document annex");
LeaseDocumentAnnex annex = revision.annexes().get(index);
byte[] content = storage.read(annex.storageKey());
if (content == null || !annex.sha256().equals(sha256(content)))
throw new IllegalStateException("Stored lease annex fingerprint does not match its manifest.");
LeaseDocumentAnnex.EncryptionMetadata encryption = annex.encryption();
return new AnnexDownload(annex.category().name(),
annex.documentType() == null ? null : annex.documentType().code(), annex.sourceId(), annex.version(),
annex.fileName(), annex.sha256(), annex.establishedOn(), annex.expiresOn(), annex.sourceUploadedAt(),
encryption == null ? null : encryption.cryptoVersion(), encryption == null ? null : encryption.nonce(),
encryption == null ? null : encryption.wrappedKey(),
encryption == null ? null : encryption.vaultPublicKey(), content);
}
public FinalDossierDescriptor finalDossier(LandlordId landlordId, LeaseId leaseId) {
vaultAccess.requireLandlordUse(landlordId);
LeaseFinalization finalization = finalizations.find(landlordId, leaseId)
.orElseThrow(() -> new ResourceNotFound("Final lease dossier"));
LeaseDocumentRevision revision = revisions.find(landlordId, leaseId, finalization.revisionNumber())
.orElseThrow(() -> new ResourceNotFound("Lease document revision"));
if (!revision.sourceFingerprint().equals(finalization.signedSourceFingerprint()))
throw new LeaseFinalizationConflict("Le document signé ne correspond pas à la révision finalisée.");
requireStoredVault(landlordId, finalization.signedVaultPublicKey());
LeaseSnapshot frozenContract = lease(landlordId, leaseId).snapshot();
if (frozenContract == null)
throw new LeaseFinalizationConflict("Le dossier final exige le contrat gelé du bail.");
List<LeaseGuaranteeDeed> storedDeeds = guaranteeDeeds.findAll(landlordId, leaseId);
return new FinalDossierDescriptor("dossier-final-bail-" + leaseId.value() + ".zip",
finalizationEnvelope(landlordId, leaseId, finalization), revision.number(),
revision.sourceFingerprint(), revision.annexes().stream().map(AnnexDescriptor::from).toList(),
revision.conditionPhotos().stream().map(PhotoDescriptor::from).toList(),
frozenContract.guarantees().stream()
.map(guarantee -> frozenDeed(frozenContract, guarantee, storedDeeds)).map(DeedDescriptor::from)
.toList());
}
private static LeaseGuaranteeDeed frozenDeed(LeaseSnapshot contract, LeaseGuarantee guarantee,
List<LeaseGuaranteeDeed> storedDeeds) {
String fingerprint = GuaranteeDeedIntegrity.fingerprint(contract, guarantee);
return storedDeeds.stream()
.filter(deed -> deed.guaranteeId().equals(guarantee.id())
&& deed.tenantId().equals(guarantee.tenantId())
&& deed.contractFingerprint().equals(fingerprint))
.findFirst()
.orElseThrow(() -> new LeaseFinalizationConflict("Un acte de cautionnement gelé est introuvable."));
}
public EncryptedDocumentDownload finalizationDocument(LandlordId landlordId, LeaseId leaseId) {
vaultAccess.requireLandlordUse(landlordId);
LeaseFinalization finalization = finalizations.find(landlordId, leaseId)
.orElseThrow(() -> new ResourceNotFound("Lease finalization"));
requireStoredVault(landlordId, finalization.signedVaultPublicKey());
return new EncryptedDocumentDownload(landlordId.value(), leaseId.value(), finalization.revisionNumber(),
finalization.signedSourceFingerprint(), finalization.signedFileName(),
finalization.signedCryptoVersion(), finalization.signedNonce(), finalization.signedWrappedKey(),
finalization.signedSha256(), finalization.signedVaultPublicKey(),
verifiedContent(finalization.signedStorageKey(), finalization.signedSha256()));
}
private EncryptedDocumentDownload finalizationEnvelope(LandlordId landlordId, LeaseId leaseId,
LeaseFinalization finalization) {
return new EncryptedDocumentDownload(landlordId.value(), leaseId.value(), finalization.revisionNumber(),
finalization.signedSourceFingerprint(), finalization.signedFileName(),
finalization.signedCryptoVersion(), finalization.signedNonce(), finalization.signedWrappedKey(),
finalization.signedSha256(), finalization.signedVaultPublicKey(),
verifiedContent(finalization.signedStorageKey(), finalization.signedSha256()));
}
public LeaseDocumentRevision current(LandlordId landlordId, LeaseId leaseId) {
@@ -194,7 +292,8 @@ public final class GenerateLeaseDocumentService {
LeaseDocumentRevision finalizedRevision = finalizedEntryRevision(landlordId, leaseId);
if (finalizedRevision != null)
return finalizedRevision;
if (!source.matchesCurrentSource(landlordId, leaseId, revision)) {
InformationNotice currentNotice = source.currentNotice();
if (!source.matchesCurrentSource(landlordId, leaseId, revision, currentNotice)) {
throw new LeaseFinalizationConflict("Le bail a changé depuis la génération de la révision courante.");
}
return revision;
@@ -206,78 +305,13 @@ public final class GenerateLeaseDocumentService {
.flatMap(number -> revisions.find(landlordId, leaseId, number)).orElse(null);
}
public GeneratedDocument download(LandlordId landlordId, LeaseId leaseId, int number) {
lease(landlordId, leaseId);
LeaseDocumentRevision revision = revisions.find(landlordId, leaseId, number)
.orElseThrow(() -> new ResourceNotFound("Lease document revision"));
byte[] content = storage.read(revision.storageKey());
if (!revision.sha256().equals(sha256(content))) {
throw new IllegalStateException("Stored lease document fingerprint does not match its revision.");
}
return generated(leaseId, revision.number(), content);
}
public GeneratedDocument generateAndDownload(LandlordId landlordId, LeaseId leaseId, String authorId) {
LeaseDocumentRevision revision = generateRevision(landlordId, leaseId, authorId);
GeneratedDocument document = download(landlordId, leaseId, revision.number());
return new GeneratedDocument("bail-" + leaseId.value() + ".pdf", document.contentType(), document.content());
}
public GeneratedDocument downloadDossier(LandlordId landlordId, LeaseId leaseId, int number) {
lease(landlordId, leaseId);
LeaseDocumentRevision revision = revisions.find(landlordId, leaseId, number)
.orElseThrow(() -> new ResourceNotFound("Lease document revision"));
return dossier(landlordId, leaseId, revision);
}
public GeneratedDocument downloadCurrentDossier(LandlordId landlordId, LeaseId leaseId) {
LeaseDocumentRevision revision = current(landlordId, leaseId);
return dossier(landlordId, leaseId, revision);
}
public GeneratedDocument downloadFinalDossier(LandlordId landlordId, LeaseId leaseId) {
Lease lease = lease(landlordId, leaseId);
var finalization = finalizations.find(landlordId, leaseId)
.orElseThrow(() -> new ResourceNotFound("Final lease dossier"));
LeaseSnapshot contract = lease.snapshot();
if (contract == null) {
throw new IllegalStateException("Final lease dossier requires the frozen initial lease.");
}
LeaseDocumentRevision revision = revisions.find(landlordId, leaseId, finalization.revisionNumber())
.orElseThrow(() -> new ResourceNotFound("Lease document revision"));
List<ZipContent> contents = new ArrayList<>();
contents.add(verified("bail-signe.pdf", finalization.signedStorageKey(), finalization.signedSha256()));
for (LeaseDocumentAnnex annex : revision.annexes()) {
contents.add(verified(zipEntryName(annex), annex.storageKey(), annex.sha256()));
}
revision.conditionPhotos().stream().sorted(Comparator.comparing(photo -> photo.reference()))
.forEach(photo -> contents.add(
verifiedPhoto("photos/" + photo.reference() + ".webp", photo.storageKey(), photo.sha256())));
List<LeaseGuaranteeDeed> storedDeeds = guaranteeDeeds.findAll(landlordId, leaseId);
contract.guarantees().stream().map(guarantee -> frozenDeed(contract, guarantee, storedDeeds))
.sorted(Comparator.comparing(GenerateLeaseDocumentService::guaranteeDeedPath))
.forEach(deed -> contents.add(verified(guaranteeDeedPath(deed), deed.storageKey(), deed.sha256())));
return dossier("dossier-final-bail-" + leaseId.value() + ".zip", contents, revision.conditionPhotos());
}
private static LeaseGuaranteeDeed frozenDeed(LeaseSnapshot contract, LeaseGuarantee guarantee,
List<LeaseGuaranteeDeed> storedDeeds) {
String fingerprint = GuaranteeDeedIntegrity.fingerprint(contract, guarantee);
return storedDeeds.stream()
.filter(deed -> deed.guaranteeId().equals(guarantee.id())
&& deed.tenantId().equals(guarantee.tenantId())
&& deed.contractFingerprint().equals(fingerprint))
.findFirst()
.orElseThrow(() -> new IllegalStateException("A current deed is required for every frozen guarantee."));
}
private List<LeaseDocumentAnnex> copyAnnexes(LandlordId landlordId, Lease lease,
LeaseDocumentRevisionSource.Source source, List<String> storedKeys) {
List<LeaseDocumentAnnex> annexes = new ArrayList<>();
InformationNotice notice = source.notice();
byte[] noticeContent = notice.content();
annexes.add(new LeaseDocumentAnnex(Category.NOTICE, null, "", notice.version(), notice.fileName(),
store(noticeContent, storedKeys), sha256(noticeContent), null, null, null));
store(noticeContent, storedKeys), sha256(noticeContent), null, null, null, null));
for (ApartmentDocument expected : source.apartmentDocuments()) {
ApartmentDocumentService.Download download = apartmentDocuments.download(landlordId, lease.apartmentId(),
expected.id());
@@ -285,23 +319,25 @@ public final class GenerateLeaseDocumentService {
throw new ConcurrentChange("Apartment document");
}
byte[] content = download.content();
String fingerprint = sha256(content);
if (!expected.sha256().equals(fingerprint)) {
if (content == null || !expected.sha256().equals(sha256(content))) {
throw new IllegalStateException("Apartment document fingerprint changed during dossier copy.");
}
String fingerprint = expected.sha256();
annexes.add(new LeaseDocumentAnnex(Category.APARTMENT_DOCUMENT, expected.type(), expected.id(), "",
expected.fileName(), store(content, storedKeys), fingerprint, expected.establishedOn(),
expected.expiresOn(), expected.uploadedAt()));
expected.expiresOn(), expected.uploadedAt(), null));
}
if (source.visaleContract() != null) {
VisaleSuretyContract contract = source.visaleContract();
byte[] content = storage.read(contract.storageKey());
if (!contract.sha256().equals(sha256(content))) {
if (content == null || !contract.sha256().equals(sha256(content))) {
throw new IllegalStateException("Visale contract fingerprint changed during dossier copy.");
}
annexes.add(new LeaseDocumentAnnex(Category.VISALE_SURETY_CONTRACT, null, contract.id(),
contract.visaNumber(), contract.fileName(), store(content, storedKeys), contract.sha256(), null,
null, contract.uploadedAt()));
contract.visaNumber().sha256(), contract.fileName(), store(content, storedKeys), contract.sha256(),
null, null, contract.uploadedAt(),
new LeaseDocumentAnnex.EncryptionMetadata(contract.cryptoVersion(), contract.nonce(),
contract.wrappedKey(), contract.vaultPublicKey())));
}
return List.copyOf(annexes);
}
@@ -310,6 +346,131 @@ public final class GenerateLeaseDocumentService {
return leases.find(landlordId, leaseId).orElseThrow(() -> new ResourceNotFound("Lease"));
}
private static List<AnnexDescriptor> annexDescriptors(LeaseDocumentRevisionSource.Source source) {
List<AnnexDescriptor> descriptors = new ArrayList<>();
InformationNotice notice = source.notice();
descriptors.add(new AnnexDescriptor(LeaseDocumentAnnex.Category.NOTICE.name(), null, "", notice.version(),
notice.fileName(), sha256(notice.content()), null, null, null, null));
source.apartmentDocuments().stream().sorted(Comparator.comparing(ApartmentDocument::type)).forEach(
document -> descriptors.add(new AnnexDescriptor(LeaseDocumentAnnex.Category.APARTMENT_DOCUMENT.name(),
document.type().code(), document.id(), "", document.fileName(), document.sha256(),
document.establishedOn(), document.expiresOn(), document.uploadedAt(), null)));
if (source.visaleContract() != null) {
VisaleSuretyContract contract = source.visaleContract();
descriptors.add(new AnnexDescriptor(LeaseDocumentAnnex.Category.VISALE_SURETY_CONTRACT.name(), null,
contract.id(), contract.visaNumber().sha256(), contract.fileName(), contract.sha256(), null, null,
contract.uploadedAt(), new LeaseDocumentAnnex.EncryptionMetadata(contract.cryptoVersion(),
contract.nonce(), contract.wrappedKey(), contract.vaultPublicKey())));
}
return List.copyOf(descriptors);
}
private static void requireAuthor(String authorId) {
if (authorId == null || authorId.isBlank())
throw new IllegalArgumentException("Author ID is required.");
}
private static void requireLeaseVersion(Lease lease, long expected) {
if (lease.version() != expected)
throw new ConcurrentChange("Lease");
}
private boolean currentVaultMatches(LandlordId landlordId, LeaseDocumentRevision revision) {
return vaults.find(landlordId).map(vault -> vault.publicKey().equals(revision.vaultPublicKey())).orElse(false);
}
private ApplicationVault requireCurrentVault(LandlordId landlordId, LeaseDocumentEncryption upload) {
vaultAccess.requireLandlordUse(landlordId);
ApplicationVault vault = vaults.find(landlordId).orElseThrow(ApplicationVaultRequired::new);
if (upload == null || !vault.publicKey().equals(upload.vaultPublicKey()))
throw new LeaseFinalizationConflict("Le document ne correspond pas à la génération actuelle du coffre.");
return vault;
}
private void fenceCurrentVault(LandlordId landlordId, ApplicationVault expected, LeaseDocumentEncryption upload) {
ApplicationVault current = requireCurrentVault(landlordId, upload);
if (!current.publicKey().equals(expected.publicKey()))
throw new LeaseFinalizationConflict("Le coffre a changé pendant l’enregistrement du document.");
vaults.save(current);
}
private void requireStoredVault(LandlordId landlordId, String publicKey) {
if (vaults.find(landlordId).filter(vault -> vault.publicKey().equals(publicKey)).isEmpty())
throw new ApplicationVaultRequired();
}
/**
* Rechecks only repository-backed source metadata at the commit fence. The
* expensive storage copies and notice bytes are deliberately kept outside the
* transaction, while a report or contract change racing those copies still
* invalidates the prepared PDF before its revision is inserted.
*/
private void requireCurrentSource(LandlordId landlordId, LeaseId leaseId, LeaseDocumentRevision revision,
InformationNotice notice) {
if (!source.matchesCurrentSource(landlordId, leaseId, revision, notice))
throw new LeaseFinalizationConflict("Le bail a changé pendant la préparation du document.");
}
private void validateEncryptedDocument(LeaseDocumentEncryption upload) {
if (upload == null || upload.cryptoVersion() != 1 || upload.fileName() == null || upload.fileName().isBlank()
|| upload.nonce() == null || upload.wrappedKey() == null || upload.sha256() == null
|| upload.sha256().isBlank() || upload.ciphertext() == null || upload.vaultPublicKey() == null
|| upload.vaultPublicKey().isBlank())
throw new IllegalArgumentException("Le document PDF chiffré est invalide.");
decode(upload.nonce(), "Document nonce", 12, 12);
decode(upload.wrappedKey(), "Document wrapped key", 384, 384);
byte[] ciphertext = decode(upload.ciphertext(), "Document ciphertext", 16, 25 * 1024 * 1024L);
decode(upload.vaultPublicKey(), "Document vault public key", 384, 1024);
if (isPdfHeader(ciphertext) || !upload.sha256().equals(sha256(ciphertext)))
throw new IllegalArgumentException("Le document PDF chiffré est invalide.");
}
private byte[] decodeCiphertext(LeaseDocumentEncryption upload) {
return decode(upload.ciphertext(), "Document ciphertext", 16, 25 * 1024 * 1024L);
}
private static byte[] decode(String value, String field, int minimum, long maximum) {
try {
byte[] decoded = Base64.getUrlDecoder().decode(value);
if (decoded.length < minimum || decoded.length > maximum)
throw new IllegalArgumentException(field + " has an invalid length.");
return decoded;
} catch (IllegalArgumentException failure) {
throw new IllegalArgumentException("Le document PDF chiffré est invalide.", failure);
}
}
private static boolean isPdfHeader(byte[] content) {
return content.length >= 5
&& Arrays.equals(Arrays.copyOf(content, 5), "%PDF-".getBytes(StandardCharsets.US_ASCII));
}
private byte[] verifiedContent(String storageKey, String expectedSha256) {
byte[] content = storage.read(storageKey);
if (content == null || !expectedSha256.equals(sha256(content)))
throw new IllegalStateException("Stored lease document fingerprint does not match its manifest.");
return content;
}
private static String safeFileName(String supplied) {
if (supplied == null || supplied.isBlank())
return "bail.pdf";
String candidate = supplied.trim();
int separator = Math.max(candidate.lastIndexOf('/'), candidate.lastIndexOf('\\'));
candidate = candidate.substring(separator + 1);
if (candidate.isBlank())
return "bail.pdf";
StringBuilder sanitized = new StringBuilder(candidate.length());
for (int index = 0; index < candidate.length(); index++) {
char character = candidate.charAt(index);
sanitized.append(Character.isISOControl(character) ? '_' : character);
}
String result = sanitized.toString().trim();
if (!result.toLowerCase(Locale.ROOT).endsWith(".pdf"))
result += ".pdf";
return result.length() <= 255 ? result : result.substring(0, 251) + ".pdf";
}
private static String sha256(byte[] content) {
try {
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(content));
@@ -325,77 +486,25 @@ public final class GenerateLeaseDocumentService {
}
private void discard(List<String> storageKeys, RuntimeException failure) {
RuntimeException cleanupFailure = null;
for (int index = storageKeys.size() - 1; index >= 0; index--) {
try {
LeaseDocumentStorage.StagedDeletion deletion = storage.stageDeletion(storageKeys.get(index));
storage.completeDeletion(deletion);
} catch (RuntimeException cleanupFailure) {
failure.addSuppressed(cleanupFailure);
} catch (RuntimeException deletionFailure) {
if (cleanupFailure == null)
cleanupFailure = new IllegalStateException(
"Impossible de supprimer les artefacts temporaires du bail.", deletionFailure);
else
cleanupFailure.addSuppressed(deletionFailure);
}
}
}
private static GeneratedDocument generated(LeaseId leaseId, int revisionNumber, byte[] content) {
String suffix = revisionNumber > 0 ? "-revision-" + revisionNumber : "";
return new GeneratedDocument("bail-" + leaseId.value() + suffix + ".pdf", "application/pdf", content);
}
private GeneratedDocument dossier(LandlordId landlordId, LeaseId leaseId, LeaseDocumentRevision revision) {
List<ZipContent> contents = new ArrayList<>();
contents.add(verified("bail.pdf", revision.storageKey(), revision.sha256()));
for (LeaseDocumentAnnex annex : revision.annexes()) {
contents.add(verified(zipEntryName(annex), annex.storageKey(), annex.sha256()));
}
revision.conditionPhotos().stream().sorted(Comparator.comparing(photo -> photo.reference()))
.forEach(photo -> contents.add(
verifiedPhoto("photos/" + photo.reference() + ".webp", photo.storageKey(), photo.sha256())));
return dossier("dossier-bail-" + leaseId.value() + "-revision-" + revision.number() + ".zip", contents,
revision.conditionPhotos());
}
private GeneratedDocument dossier(String fileName, List<ZipContent> contents,
List<LeaseDocumentRevision.ConditionPhotoSnapshot> photos) {
Set<String> names = new HashSet<>();
if (contents.stream().anyMatch(content -> !names.add(content.name()))) {
throw new IllegalStateException("Lease dossier contains duplicate file names.");
}
if (!names.add("manifest.sha256")) {
throw new IllegalStateException("Lease dossier contains duplicate file names.");
}
String manifest = contents.stream().sorted(Comparator.comparing(ZipContent::name))
.map(content -> sha256(content.bytes()) + " " + content.name())
.reduce("", (left, line) -> left + line + "\n")
+ photos.stream().sorted(Comparator.comparing(photo -> photo.reference()))
.map(photo -> "# photos/" + photo.reference() + ".webp -> " + photo.target())
.reduce("", (left, line) -> left + line + "\n");
try (ByteArrayOutputStream output = new ByteArrayOutputStream();
ZipOutputStream zip = new ZipOutputStream(output, StandardCharsets.UTF_8)) {
for (ZipContent content : contents) {
ZipEntry entry = new ZipEntry(content.name());
entry.setTime(0L);
zip.putNextEntry(entry);
zip.write(content.bytes());
zip.closeEntry();
}
ZipEntry manifestEntry = new ZipEntry("manifest.sha256");
manifestEntry.setTime(0L);
zip.putNextEntry(manifestEntry);
zip.write(manifest.getBytes(StandardCharsets.UTF_8));
zip.closeEntry();
zip.finish();
return new GeneratedDocument(fileName, "application/zip", output.toByteArray());
} catch (IOException failure) {
throw new IllegalStateException("Lease dossier ZIP could not be generated.", failure);
}
}
private static String guaranteeDeedPath(LeaseGuaranteeDeed deed) {
return "actes-cautionnement/" + pathSegment(deed.guaranteeId()) + "/" + pathSegment(deed.tenantId().value())
+ ".pdf";
}
private static String pathSegment(String value) {
return Base64.getUrlEncoder().withoutPadding().encodeToString(value.getBytes(StandardCharsets.UTF_8));
if (cleanupFailure == null)
return;
if (failure != null)
failure.addSuppressed(cleanupFailure);
else
throw cleanupFailure;
}
private static List<LeaseDocumentRevision.ConditionPhotoSnapshot> conditionPhotos(PropertyConditionReport report) {
@@ -408,37 +517,77 @@ public final class GenerateLeaseDocumentService {
.toList();
}
private ZipContent verified(String name, String storageKey, String expectedSha256) {
byte[] content = storage.read(storageKey);
if (!expectedSha256.equals(sha256(content))) {
throw new IllegalStateException("Stored lease dossier file fingerprint does not match its manifest.");
}
return new ZipContent(name, content);
}
private ZipContent verifiedPhoto(String name, String storageKey, String expectedSha256) {
try (InputStream input = conditionAttachments.read(storageKey)) {
byte[] content = input.readAllBytes();
if (!expectedSha256.equals(sha256(content))) {
throw new IllegalStateException(
"Stored property condition photo fingerprint does not match its manifest.");
}
return new ZipContent(name, content);
} catch (IOException failure) {
throw new IllegalStateException("Property condition photo could not be read.", failure);
public record RevisionPreparation(String landlordId, String leaseId, long leaseVersion, int number,
String templateVersion, String sourceFingerprint, RentalDocumentRenderer.LeaseDocument document,
List<AnnexDescriptor> annexes, List<LeaseDocumentWarning> warnings,
List<LeaseDocumentRevision.ConditionPhotoSnapshot> conditionPhotos) {
public RevisionPreparation {
annexes = List.copyOf(annexes);
warnings = List.copyOf(warnings);
conditionPhotos = List.copyOf(conditionPhotos);
}
}
private static String zipEntryName(LeaseDocumentAnnex annex) {
if (annex.category() == Category.NOTICE) {
return annex.fileName();
public record AnnexDescriptor(String category, String documentType, String sourceId, String version,
String fileName, String sha256, LocalDate establishedOn, LocalDate expiresOn, Instant sourceUploadedAt,
LeaseDocumentAnnex.EncryptionMetadata encryption) {
static AnnexDescriptor from(LeaseDocumentAnnex annex) {
return new AnnexDescriptor(annex.category().name(),
annex.documentType() == null ? null : annex.documentType().code(), annex.sourceId(),
annex.version(), annex.fileName(), annex.sha256(), annex.establishedOn(), annex.expiresOn(),
annex.sourceUploadedAt(), annex.encryption());
}
if (annex.category() == Category.VISALE_SURETY_CONTRACT) {
return "visale/contrat-cautionnement-" + annex.fileName();
}
return "annexes/" + annex.documentType().code().toLowerCase(Locale.ROOT) + "-" + annex.fileName();
}
private record ZipContent(String name, byte[] bytes) {
public record PhotoDescriptor(String reference, String sha256, String targetKind, String privateAreaId,
String subjectId) {
static PhotoDescriptor from(LeaseDocumentRevision.ConditionPhotoSnapshot photo) {
return new PhotoDescriptor(photo.reference(), photo.sha256(), photo.target().kind().name(),
photo.target().privateAreaId(), photo.target().subjectId());
}
}
public record DeedDescriptor(String guaranteeId, String tenantId, String fileName, String sha256, int cryptoVersion,
String nonce, String wrappedKey, String vaultPublicKey) {
static DeedDescriptor from(LeaseGuaranteeDeed deed) {
return new DeedDescriptor(deed.guaranteeId(), deed.tenantId().value(), deed.fileName(), deed.sha256(),
deed.cryptoVersion(), deed.nonce(), deed.wrappedKey(), deed.vaultPublicKey());
}
}
public record EncryptedDocumentDownload(String landlordId, String leaseId, int revisionNumber,
String sourceFingerprint, String fileName, int cryptoVersion, String nonce, String wrappedKey,
String sha256, String vaultPublicKey, byte[] ciphertext) {
public EncryptedDocumentDownload {
ciphertext = ciphertext.clone();
}
@Override
public byte[] ciphertext() {
return ciphertext.clone();
}
}
public record AnnexDownload(String category, String documentType, String sourceId, String version, String fileName,
String sha256, LocalDate establishedOn, LocalDate expiresOn, Instant sourceUploadedAt,
Integer cryptoVersion, String nonce, String wrappedKey, String vaultPublicKey, byte[] ciphertext) {
public AnnexDownload {
ciphertext = ciphertext.clone();
}
@Override
public byte[] ciphertext() {
return ciphertext.clone();
}
}
public record FinalDossierDescriptor(String fileName, EncryptedDocumentDownload signedDocument, int revisionNumber,
String sourceFingerprint, List<AnnexDescriptor> annexes, List<PhotoDescriptor> photos,
List<DeedDescriptor> deeds) {
public FinalDossierDescriptor {
annexes = List.copyOf(annexes);
photos = List.copyOf(photos);
deeds = List.copyOf(deeds);
}
}
}
@@ -25,17 +25,20 @@ final class GuaranteeDeedIntegrity {
field(digest, guarantee.id());
field(digest, guarantee.tenantId().value());
field(digest, guarantee.type().name());
field(digest, guarantee.firstName());
field(digest, guarantee.lastName());
field(digest, guarantee.address());
field(digest, guarantee.organizationName());
field(digest, guarantee.organizationAddress());
field(digest, guarantee.representativeName());
field(digest, guarantee.registrationId());
field(digest, guarantee.profile().vaultPublicKey());
field(digest, Long.toString(guarantee.profile().identityGeneration()));
field(digest, Integer.toString(guarantee.profile().identityAndAddress().cryptoVersion()));
field(digest, guarantee.profile().identityAndAddress().nonce());
field(digest, guarantee.profile().identityAndAddress().wrappedKey());
field(digest, guarantee.profile().identityAndAddress().sha256());
field(digest, guarantee.profile().identityAndAddress().ciphertext());
field(digest, tenant.id().value());
field(digest, tenant.firstName());
field(digest, tenant.lastName());
field(digest, tenant.address());
field(digest, tenant.profile().vaultPublicKey());
field(digest, Integer.toString(tenant.profile().identityAndAddress().cryptoVersion()));
field(digest, tenant.profile().identityAndAddress().nonce());
field(digest, tenant.profile().identityAndAddress().wrappedKey());
field(digest, tenant.profile().identityAndAddress().sha256());
field(digest, tenant.profile().identityAndAddress().ciphertext());
field(digest, contract.landlord().id().value());
field(digest, contract.landlord().firstName());
field(digest, contract.landlord().lastName());
@@ -0,0 +1,10 @@
package fr.nessar.lmnporganizer.rental.application.lease;
/**
* Opaque browser-produced PDF envelope. The server stores only the ciphertext
* and the metadata required to address it; it never needs to decrypt this
* value.
*/
public record LeaseDocumentEncryption(String fileName, int cryptoVersion, String nonce, String wrappedKey,
String sha256, String ciphertext, String vaultPublicKey) {
}
@@ -29,6 +29,7 @@ import fr.nessar.lmnporganizer.rental.domain.lease.LeaseContractVersion;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseDocumentAnnex;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseDocumentRevision;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseDocumentWarning;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseFinalizationConflict;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseFinalizationWarning;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseId;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseRepository;
@@ -38,6 +39,7 @@ import fr.nessar.lmnporganizer.rental.domain.lease.LeaseStatus;
import fr.nessar.lmnporganizer.rental.domain.lease.VisaleSuretyContract;
import fr.nessar.lmnporganizer.rental.domain.tenant.Tenant;
import fr.nessar.lmnporganizer.rental.domain.tenant.TenantRepository;
import fr.nessar.lmnporganizer.rental.domain.tenant.TenantProfile.EncryptedGroup;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
@@ -100,7 +102,8 @@ public final class LeaseDocumentRevisionSource {
appendVisaleContractFacts(contract.facts(), visaleContract);
return new Source(contract.document(),
withEntry(sha256(contract.facts().toString().getBytes(StandardCharsets.UTF_8)), entry), notice,
documents, List.copyOf(warnings), visaleContract, entry);
documents, List.copyOf(warnings), visaleContract, entry, lease, contract.landlord(),
contract.apartment(), inventory);
}
private static void requireEntryMatchesCurrentPackage(PropertyConditionReport entry, LeaseDocument document,
@@ -126,18 +129,15 @@ public final class LeaseDocumentRevisionSource {
private static boolean matchesParties(List<ReportParticipant> participants, LeaseDocument document) {
Set<String> expected = new HashSet<>();
expected.add(party(document.landlord().id().value(), ReportPartyRole.LANDLORD,
document.landlord().firstName() + " " + document.landlord().lastName()));
document.tenants().forEach(tenant -> expected
.add(party(tenant.id().value(), ReportPartyRole.TENANT, tenant.firstName() + " " + tenant.lastName())));
Set<String> actual = participants.stream()
.map(participant -> party(participant.partyId(), participant.role(), participant.name()))
expected.add(party(document.landlord().id().value(), ReportPartyRole.LANDLORD));
document.tenants().forEach(tenant -> expected.add(party(tenant.id().value(), ReportPartyRole.TENANT)));
Set<String> actual = participants.stream().map(participant -> party(participant.partyId(), participant.role()))
.collect(Collectors.toSet());
return expected.equals(actual) && expected.size() == participants.size();
}
private static String party(String id, ReportPartyRole role, String name) {
return id + "\n" + role + "\n" + name;
private static String party(String id, ReportPartyRole role) {
return id + "\n" + role;
}
private static boolean hasRequiredZones(List<ConditionZone> zones, String areaId, String label) {
@@ -161,22 +161,70 @@ public final class LeaseDocumentRevisionSource {
});
}
boolean matchesCurrentSource(LandlordId landlordId, LeaseId leaseId, LeaseDocumentRevision revision) {
InformationNotice currentNotice() {
return notices.current();
}
boolean matchesCurrentSource(LandlordId landlordId, LeaseId leaseId, LeaseDocumentRevision revision,
InformationNotice currentNotice) {
Lease lease = leases.find(landlordId, leaseId).orElseThrow(() -> new ResourceNotFound("Lease"));
if (revision.annexes().stream().noneMatch(annex -> annex.category() == LeaseDocumentAnnex.Category.NOTICE)) {
return false;
Source current = currentSource(landlordId, leaseId, lease, revision, currentNotice);
return revision.landlordId().equals(landlordId) && revision.leaseId().equals(leaseId)
&& revision.sourceFingerprint().equals(current.fingerprint());
}
/**
* Revalidates the source and writes technical coordination markers for every
* mutable aggregate which contributes to the browser-produced document. The
* markers preserve business versions and are used only inside a write
* transaction so concurrent source saves produce a Mongo write conflict.
*/
void coordinateCurrentSource(LandlordId landlordId, LeaseId leaseId, LeaseDocumentRevision revision,
InformationNotice currentNotice) {
Lease lease = leases.find(landlordId, leaseId).orElseThrow(() -> new ResourceNotFound("Lease"));
Source current = currentSource(landlordId, leaseId, lease, revision, currentNotice);
if (!revision.landlordId().equals(landlordId) || !revision.leaseId().equals(leaseId)
|| !revision.sourceFingerprint().equals(current.fingerprint())) {
throw new LeaseFinalizationConflict("Le bail a changé pendant l’enregistrement du document.");
}
leases.coordinateForLeaseDocument(current.lease());
if (current.landlord() != null)
landlords.coordinateForLeaseDocument(current.landlord());
if (current.apartment() != null)
apartments.coordinateForLeaseDocument(current.apartment());
inventories.coordinateForLeaseDocument(current.inventory());
}
private Source currentSource(LandlordId landlordId, LeaseId leaseId, Lease lease, LeaseDocumentRevision revision,
InformationNotice currentNotice) {
LeaseDocumentAnnex noticeAnnex = revision.annexes().stream()
.filter(annex -> annex.category() == LeaseDocumentAnnex.Category.NOTICE).findFirst().orElse(null);
if (noticeAnnex == null) {
throw new LeaseFinalizationConflict("La notice d’information de la révision est introuvable.");
}
if (!noticeAnnex.version().equals(currentNotice.version())
|| !noticeAnnex.sha256().equals(sha256(currentNotice.content()))) {
throw new LeaseFinalizationConflict("La notice d’information a changé depuis la génération du document.");
}
FurnishedLeaseTemplate template = FurnishedLeaseTemplate.fromVisibleId(revision.templateVersion());
FurnitureInventorySnapshot furniture = FurnitureInventorySnapshot.from(inventory(landlordId, lease, null));
FurnitureInventory currentInventory = inventory(landlordId, lease, null);
FurnitureInventorySnapshot furniture = FurnitureInventorySnapshot.from(currentInventory);
ContractSource current = contractSource(landlordId, lease, template, furniture);
PropertyConditionReport entry = entryReport(landlordId, leaseId);
requireEntryMatchesCurrentPackage(entry, current.document(), furniture);
validateInventoryPrivateAreas(furniture, current.document().apartment().privateAreas());
List<ApartmentDocument> documents = apartmentDocuments.list(landlordId, lease.apartmentId()).stream()
.sorted(Comparator.comparing(ApartmentDocument::type)).toList();
List<LeaseDocumentWarning> warnings = new ArrayList<>(warnings(lease, documents));
currentInventory.legalWarnings()
.forEach(warning -> warnings.add(new LeaseDocumentWarning(warning.code(), warning.ruleVersion())));
appendFurnitureFacts(current.facts(), furniture);
appendDossierFacts(current.facts(), revision.annexes(), revision.warnings());
appendVisaleContractFacts(current.facts(), visaleContract(landlordId, lease));
String fingerprint = withEntry(sha256(current.facts().toString().getBytes(StandardCharsets.UTF_8)),
entryReport(landlordId, leaseId));
return revision.landlordId().equals(landlordId) && revision.leaseId().equals(leaseId)
&& revision.sourceFingerprint().equals(fingerprint);
appendDossierFacts(current.facts(), currentNotice, documents, warnings);
VisaleSuretyContract visaleContract = visaleContract(landlordId, lease);
appendVisaleContractFacts(current.facts(), visaleContract);
String fingerprint = withEntry(sha256(current.facts().toString().getBytes(StandardCharsets.UTF_8)), entry);
return new Source(current.document(), fingerprint, currentNotice, documents, List.copyOf(warnings),
visaleContract, entry, lease, current.landlord(), current.apartment(), currentInventory);
}
private PropertyConditionReport entryReport(LandlordId landlordId, LeaseId leaseId) {
@@ -206,11 +254,16 @@ public final class LeaseDocumentRevisionSource {
private ContractSource contractSource(LandlordId landlordId, Lease lease, FurnishedLeaseTemplate template,
FurnitureInventorySnapshot furniture) {
if (lease.status() != LeaseStatus.DRAFT) {
// Keep the frozen contractual apartment in the lease version, but retain
// the current aggregate so embedded document metadata is fenced too.
Apartment apartment = apartments.find(landlordId, lease.apartmentId())
.orElseThrow(() -> new ResourceNotFound("Apartment"));
LeaseContractVersion version = lease.contractVersionOn(LocalDate.now(clock));
StringBuilder facts = new StringBuilder(template.visibleId());
append(facts, "lease", lease.id().value(), lease.version());
facts.append('\n').append("contract:").append(version);
return new ContractSource(document(lease.id(), version, template.visibleId(), furniture), facts);
return new ContractSource(document(lease.id(), version, template.visibleId(), furniture), facts, null,
apartment);
}
Landlord landlord = landlords.find(landlordId).orElseThrow(() -> new ResourceNotFound("Landlord"));
Apartment apartment = apartments.find(landlordId, lease.apartmentId())
@@ -223,10 +276,7 @@ public final class LeaseDocumentRevisionSource {
landlord.phone(), landlord.address()),
new LeaseSnapshot.Apartment(apartment.id(), apartment.name(), apartment.address(),
apartment.legalProfile(), apartment.privateAreas()),
leaseTenants.stream()
.map(tenant -> new LeaseSnapshot.Tenant(tenant.id(), tenant.firstName(), tenant.lastName(),
tenant.email(), tenant.phone(), tenant.address()))
.toList(),
leaseTenants.stream().map(tenant -> new LeaseSnapshot.Tenant(tenant.id(), tenant.profile())).toList(),
lease.guarantees(), lease.period(), lease.terms(), lease.leaseType(), lease.specialClauses(),
lease.legalTerms(), template.visibleId(), 0, furniture);
StringBuilder facts = new StringBuilder(template.visibleId());
@@ -234,7 +284,7 @@ public final class LeaseDocumentRevisionSource {
append(facts, "landlord", landlord.id().value(), landlord.version());
append(facts, "apartment-contract", document.apartment().toString());
leaseTenants.forEach(tenant -> append(facts, "tenant", tenant.id().value(), tenant.version()));
return new ContractSource(document, facts);
return new ContractSource(document, facts, landlord, apartment);
}
private static LeaseDocument document(LeaseId leaseId, LeaseContractVersion version, String template,
@@ -245,7 +295,7 @@ public final class LeaseDocumentRevisionSource {
}
private VisaleSuretyContract visaleContract(LandlordId landlordId, Lease lease) {
return lease.visaleGuarantee() == null ? null : visaleContracts.currentValid(landlordId, lease);
return lease.visaleGuarantee() == null ? null : visaleContracts.currentMetadata(landlordId, lease);
}
private List<LeaseDocumentWarning> warnings(Lease lease, List<ApartmentDocument> documents) {
@@ -349,11 +399,19 @@ public final class LeaseDocumentRevisionSource {
return;
append(facts, "visale-contract-id", contract.id());
append(facts, "visale-contract-sha256", contract.sha256());
append(facts, "visale-contract-visa-number", contract.visaNumber());
appendEncryptedGroupFacts(facts, "visale-contract-visa-number", contract.visaNumber());
contract.tenantIds().stream().sorted(Comparator.comparing(id -> id.value()))
.forEach(id -> append(facts, "visale-contract-tenant", id.value()));
}
private static void appendEncryptedGroupFacts(StringBuilder facts, String kind, EncryptedGroup group) {
append(facts, kind + "-crypto-version", Integer.toString(group.cryptoVersion()));
append(facts, kind + "-nonce", group.nonce());
append(facts, kind + "-wrapped-key", group.wrappedKey());
append(facts, kind + "-sha256", group.sha256());
append(facts, kind + "-ciphertext", group.ciphertext());
}
private static String withEntry(String fingerprint, PropertyConditionReport entry) {
return sha256((fingerprint + "\nentry:" + entry.revisionSourceFingerprint()).getBytes(StandardCharsets.UTF_8));
}
@@ -377,7 +435,8 @@ public final class LeaseDocumentRevisionSource {
record Source(LeaseDocument document, String fingerprint, InformationNotice notice,
List<ApartmentDocument> apartmentDocuments, List<LeaseDocumentWarning> warnings,
VisaleSuretyContract visaleContract, PropertyConditionReport entry) {
VisaleSuretyContract visaleContract, PropertyConditionReport entry, Lease lease, Landlord landlord,
Apartment apartment, FurnitureInventory inventory) {
Source {
document.apartment().legalProfile().requireLeaseReady();
apartmentDocuments = List.copyOf(apartmentDocuments);
@@ -392,6 +451,6 @@ public final class LeaseDocumentRevisionSource {
}
}
private record ContractSource(LeaseDocument document, StringBuilder facts) {
private record ContractSource(LeaseDocument document, StringBuilder facts, Landlord landlord, Apartment apartment) {
}
}
@@ -1,13 +1,16 @@
package fr.nessar.lmnporganizer.rental.application.lease;
import fr.nessar.lmnporganizer.rental.application.port.out.IdentifierGenerator;
import fr.nessar.lmnporganizer.rental.application.port.out.LeaseDocumentStorage;
import fr.nessar.lmnporganizer.rental.application.port.out.TransactionRunner;
import fr.nessar.lmnporganizer.rental.application.rentalapplication.ApplicationVaultAccess;
import fr.nessar.lmnporganizer.rental.application.rentalapplication.ApplicationVaultRequired;
import fr.nessar.lmnporganizer.rental.application.shared.ConcurrentChange;
import fr.nessar.lmnporganizer.rental.application.shared.ResourceNotFound;
import fr.nessar.lmnporganizer.rental.domain.apartment.Apartment;
import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentId;
import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentRepository;
import fr.nessar.lmnporganizer.rental.domain.application.ApplicationVault;
import fr.nessar.lmnporganizer.rental.domain.application.ApplicationVaultRepository;
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
import fr.nessar.lmnporganizer.rental.domain.lease.Lease;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseDocumentRevision;
@@ -30,6 +33,7 @@ import fr.nessar.lmnporganizer.rental.domain.lease.VisaleSuretyContractRepositor
import fr.nessar.lmnporganizer.rental.domain.tenant.Tenant;
import fr.nessar.lmnporganizer.rental.domain.tenant.TenantId;
import fr.nessar.lmnporganizer.rental.domain.tenant.TenantRepository;
import fr.nessar.lmnporganizer.rental.domain.tenant.TenantProfile.EncryptedGroup;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
@@ -37,6 +41,7 @@ import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;
import org.slf4j.Logger;
@@ -49,27 +54,30 @@ public final class LeaseDraftService {
private final TenantRepository tenants;
private final LeaseRepository leases;
private final TransactionRunner transactions;
private final IdentifierGenerator identifiers;
private final LeaseClauseCatalog clauseCatalog;
private final LeaseGuaranteeDeedRepository guaranteeDeeds;
private final LeaseDocumentRevisionRepository revisions;
private final VisaleSuretyContractRepository visaleContracts;
private final LeaseDocumentStorage storage;
private final ApplicationVaultRepository vaults;
private final ApplicationVaultAccess vaultAccess;
public LeaseDraftService(ApartmentRepository apartments, TenantRepository tenants, LeaseRepository leases,
TransactionRunner transactions, IdentifierGenerator identifiers, LeaseClauseCatalog clauseCatalog,
TransactionRunner transactions, LeaseClauseCatalog clauseCatalog,
LeaseGuaranteeDeedRepository guaranteeDeeds, LeaseDocumentRevisionRepository revisions,
VisaleSuretyContractRepository visaleContracts, LeaseDocumentStorage storage) {
VisaleSuretyContractRepository visaleContracts, LeaseDocumentStorage storage,
ApplicationVaultRepository vaults, ApplicationVaultAccess vaultAccess) {
this.apartments = apartments;
this.tenants = tenants;
this.leases = leases;
this.transactions = transactions;
this.identifiers = identifiers;
this.clauseCatalog = clauseCatalog;
this.guaranteeDeeds = guaranteeDeeds;
this.revisions = revisions;
this.visaleContracts = visaleContracts;
this.storage = storage;
this.vaults = vaults;
this.vaultAccess = vaultAccess;
}
public List<Lease> list(LandlordId landlordId) {
@@ -82,18 +90,21 @@ public final class LeaseDraftService {
public Lease create(LandlordId landlordId, DraftDetails details) {
return transactions.run(() -> {
LeaseId leaseId = new LeaseId(identifiers.next());
LeaseId leaseId = new LeaseId(details.leaseId());
fenceCurrentVault(landlordId, details.vaultPublicKey());
validatePrivateFields(details);
requireCompleteGuarantees(details.guarantees());
Apartment apartment = apartment(landlordId, details.apartmentId());
List<Tenant> leaseTenants = tenantMap(landlordId, new HashSet<>(details.tenantIds())).values().stream()
.toList();
leaseTenants.forEach(Tenant::requireCompleteContractProfile);
requireCurrentTenantVault(details.vaultPublicKey(), leaseTenants);
LeaseType leaseType = LeaseType.orStandard(details.leaseType());
List<LeaseSpecialClause> specialClauses = details.specialClauses() == null
? List.of()
: specialClauses(details.specialClauses(), context(details.tenantIds(), leaseType));
List<LeaseSpecialClause> specialClauses = specialClauses(details.specialClauses(),
context(details.tenantIds(), leaseType));
Lease lease = Lease.draft(leaseId, landlordId, details.apartmentId(), details.tenantIds(),
details.guarantees(), details.period(), details.terms(), leaseType, specialClauses,
LeaseLegalTerms.orEmpty(details.legalTerms()));
details.legalTerms());
lease.reviseVisaleGuarantee(details.visaleGuarantee());
apartment.referenceBy(leaseId);
leaseTenants.forEach(tenant -> tenant.referenceBy(leaseId));
@@ -108,7 +119,10 @@ public final class LeaseDraftService {
try {
Lease revised = transactions.run(() -> {
Lease lease = get(landlordId, leaseId);
fenceCurrentVault(landlordId, details.vaultPublicKey());
validatePrivateFields(details);
requireDraft(lease);
requireCompleteGuarantees(details.guarantees());
if (lease.version() != expectedVersion)
throw new ConcurrentChange("Lease");
removeGuaranteeDeeds(landlordId, leaseId, details.guarantees(), deletions);
@@ -119,7 +133,9 @@ public final class LeaseDraftService {
Set<TenantId> allTenantIds = new HashSet<>(lease.tenantIds());
allTenantIds.addAll(details.tenantIds());
Map<TenantId, Tenant> affectedTenants = tenantMap(landlordId, allTenantIds);
details.tenantIds().forEach(tenantId -> affectedTenants.get(tenantId).requireCompleteContractProfile());
List<Tenant> currentTenants = details.tenantIds().stream().map(affectedTenants::get).toList();
currentTenants.forEach(Tenant::requireCompleteContractProfile);
requireCurrentTenantVault(details.vaultPublicKey(), currentTenants);
if (!previousApartment.id().equals(nextApartment.id())) {
previousApartment.removeDraftReference(leaseId);
@@ -137,10 +153,9 @@ public final class LeaseDraftService {
}
affectedTenants.values().forEach(tenants::save);
LeaseType leaseType = details.leaseType() == null ? lease.leaseType() : details.leaseType();
List<LeaseSpecialClause> specialClauses = details.specialClauses() == null
? lease.specialClauses()
: specialClauses(details.specialClauses(), context(details.tenantIds(), leaseType));
LeaseLegalTerms legalTerms = details.legalTerms() == null ? lease.legalTerms() : details.legalTerms();
List<LeaseSpecialClause> specialClauses = specialClauses(details.specialClauses(),
context(details.tenantIds(), leaseType));
LeaseLegalTerms legalTerms = details.legalTerms();
lease.reviseDraft(details.apartmentId(), details.tenantIds(), details.guarantees(), details.period(),
details.terms(), leaseType, specialClauses, legalTerms, details.visaleGuarantee());
return leases.save(lease);
@@ -203,6 +218,21 @@ public final class LeaseDraftService {
}
}
private static void requireCompleteGuarantees(List<LeaseGuarantee> guarantees) {
if (guarantees == null)
return;
if (guarantees.stream()
.anyMatch(guarantee -> guarantee == null || !guarantee.profile().contractProfileComplete())) {
throw new LeaseRuleViolation("Le profil chiffré de chaque garant doit être complet.");
}
}
private static void requireCurrentTenantVault(String vaultPublicKey, List<Tenant> tenants) {
if (tenants.stream().anyMatch(tenant -> !vaultPublicKey.equals(tenant.profile().vaultPublicKey()))) {
throw new ApplicationVaultRequired();
}
}
private void removeDocumentRevisions(LandlordId landlordId, LeaseId leaseId, List<PreparedDeletion> deletions) {
for (LeaseDocumentRevision revision : revisions.findAll(landlordId, leaseId)) {
deletions.add(stageDeletion(revision.storageKey()));
@@ -272,25 +302,97 @@ public final class LeaseDraftService {
if (clauseCatalog == null) {
throw new IllegalStateException("Lease clause catalog is not configured.");
}
Set<String> ids = new HashSet<>();
if (details.stream().map(ClauseDetails::id).anyMatch(id -> !ids.add(id))) {
throw new LeaseRuleViolation("Les identifiants de clauses doivent être uniques.");
}
return details.stream().map(clause -> specialClause(clause, context)).toList();
}
private LeaseSpecialClause specialClause(ClauseDetails details, LeaseClauseCatalog.Context context) {
if (details.catalogId() != null && !details.catalogId().isBlank()) {
if (details.customText() != null && !details.customText().isBlank()) {
throw new LeaseRuleViolation("Le texte d'une clause du catalogue est défini par le serveur.");
}
LeaseClauseCatalog.RenderedClause rendered = clauseCatalog.render(details.catalogId(), context,
details.parameters());
return LeaseSpecialClause.catalog(rendered.id(), rendered.parameters(), rendered.text());
if (details.renderedText() == null) {
throw new LeaseRuleViolation("Le texte rendu de la clause est obligatoire.");
}
if (details.parameters() != null && !details.parameters().isEmpty()) {
if (details.catalogId() != null && !details.catalogId().isBlank()) {
LeaseClauseCatalog.ClauseDefinition definition = clauseCatalog.available(details.catalogId(), context);
if (definition == null) {
throw new LeaseRuleViolation("Cette clause n'est pas disponible pour ce bail.");
}
Map<String, String> parameters = details.parameters() == null ? Map.of() : details.parameters();
Map<String, EncryptedGroup> encrypted = details.encryptedParameters() == null
? Map.of()
: details.encryptedParameters();
validateClauseParameters(definition, parameters, encrypted);
return new LeaseSpecialClause(details.id(), details.vaultPublicKey(), definition.id(), parameters,
encrypted, details.renderedText(), false);
}
if ((details.parameters() != null && !details.parameters().isEmpty())
|| (details.encryptedParameters() != null && !details.encryptedParameters().isEmpty())) {
throw new LeaseRuleViolation("Une clause personnalisée ne peut pas avoir de paramètres catalogue.");
}
if (details.customText() == null || details.customText().isBlank()) {
throw new LeaseRuleViolation("Le texte de la clause personnalisée est obligatoire.");
return new LeaseSpecialClause(details.id(), details.vaultPublicKey(), null, Map.of(), Map.of(),
details.renderedText(), true);
}
private void validateClauseParameters(LeaseClauseCatalog.ClauseDefinition definition,
Map<String, String> parameters, Map<String, EncryptedGroup> encrypted) {
if (parameters.size() + encrypted.size() > 20) {
throw new LeaseRuleViolation("Une clause ne peut pas contenir plus de 20 paramètres.");
}
for (String name : parameters.keySet()) {
LeaseClauseCatalog.ParameterDefinition parameter = parameter(definition, name);
if (parameter == null) {
throw new LeaseRuleViolation("Un paramètre de clause est inconnu.");
}
if (parameter.type() == LeaseClauseCatalog.ParameterType.TEXT
|| !clauseCatalog.valid(parameter, parameters.get(name))) {
throw new LeaseRuleViolation("Un paramètre de clause est invalide.");
}
}
for (String name : encrypted.keySet()) {
LeaseClauseCatalog.ParameterDefinition parameter = parameter(definition, name);
if (parameter == null || parameter.type() != LeaseClauseCatalog.ParameterType.TEXT) {
throw new LeaseRuleViolation("Un paramètre de clause chiffré est invalide.");
}
}
for (LeaseClauseCatalog.ParameterDefinition parameter : definition.parameters()) {
if (parameter.required() && (parameter.type() == LeaseClauseCatalog.ParameterType.TEXT
? !encrypted.containsKey(parameter.name())
: !parameters.containsKey(parameter.name())
|| !clauseCatalog.valid(parameter, parameters.get(parameter.name())))) {
throw new LeaseRuleViolation("Le paramètre de clause obligatoire est absent.");
}
}
}
private static LeaseClauseCatalog.ParameterDefinition parameter(LeaseClauseCatalog.ClauseDefinition definition,
String name) {
return definition.parameters().stream().filter(value -> value.name().equals(name)).findFirst().orElse(null);
}
private void fenceCurrentVault(LandlordId landlordId, String publicKey) {
if (vaults == null || vaultAccess == null || publicKey == null || publicKey.isBlank()) {
throw new ApplicationVaultRequired();
}
vaultAccess.requireLandlordUse(landlordId);
ApplicationVault vault = vaults.find(landlordId).orElseThrow(ApplicationVaultRequired::new);
if (!vault.publicKey().equals(publicKey)) {
throw new ApplicationVaultRequired();
}
}
private static void validatePrivateFields(DraftDetails details) {
if (!details.vaultPublicKey().equals(details.legalTerms().vaultPublicKey())) {
throw new ApplicationVaultRequired();
}
if (details.guarantees() != null && details.guarantees().stream().anyMatch(guarantee -> guarantee == null
|| !details.vaultPublicKey().equals(guarantee.profile().vaultPublicKey()))) {
throw new ApplicationVaultRequired();
}
if (details.specialClauses() != null && details.specialClauses().stream()
.anyMatch(clause -> !details.vaultPublicKey().equals(clause.vaultPublicKey()))) {
throw new ApplicationVaultRequired();
}
return LeaseSpecialClause.custom(details.customText());
}
private static LeaseClauseCatalog.Context context(List<TenantId> tenantIds, LeaseType leaseType) {
@@ -302,34 +404,16 @@ public final class LeaseDraftService {
: LeaseClauseCatalog.Context.STANDARD;
}
public record DraftDetails(ApartmentId apartmentId, List<TenantId> tenantIds, List<LeaseGuarantee> guarantees,
RentalPeriod period, RentTerms terms, LeaseType leaseType, List<ClauseDetails> specialClauses,
LeaseLegalTerms legalTerms, VisaleGuarantee visaleGuarantee) {
public DraftDetails(ApartmentId apartmentId, List<TenantId> tenantIds, List<LeaseGuarantee> guarantees,
RentalPeriod period, RentTerms terms, LeaseType leaseType, List<ClauseDetails> specialClauses,
LeaseLegalTerms legalTerms) {
this(apartmentId, tenantIds, guarantees, period, terms, leaseType, specialClauses, legalTerms, null);
}
public DraftDetails(ApartmentId apartmentId, List<TenantId> tenantIds, List<LeaseGuarantee> guarantees,
RentalPeriod period, RentTerms terms) {
this(apartmentId, tenantIds, guarantees, period, terms, LeaseType.STANDARD, null, null);
}
public DraftDetails(ApartmentId apartmentId, List<TenantId> tenantIds, List<LeaseGuarantee> guarantees,
RentalPeriod period, RentTerms terms, LeaseType leaseType) {
this(apartmentId, tenantIds, guarantees, period, terms, leaseType, null, null);
}
public DraftDetails(ApartmentId apartmentId, List<TenantId> tenantIds, List<LeaseGuarantee> guarantees,
RentalPeriod period, RentTerms terms, LeaseType leaseType, List<ClauseDetails> specialClauses) {
this(apartmentId, tenantIds, guarantees, period, terms, leaseType, specialClauses, null);
}
public record DraftDetails(String leaseId, String vaultPublicKey, ApartmentId apartmentId, List<TenantId> tenantIds,
List<LeaseGuarantee> guarantees, RentalPeriod period, RentTerms terms, LeaseType leaseType,
List<ClauseDetails> specialClauses, LeaseLegalTerms legalTerms, VisaleGuarantee visaleGuarantee) {
public DraftDetails {
leaseId = required(leaseId, "Lease ID");
vaultPublicKey = required(vaultPublicKey, "Lease vault public key");
tenantIds = tenantIds == null ? List.of() : List.copyOf(tenantIds);
guarantees = guarantees == null ? List.of() : List.copyOf(guarantees);
specialClauses = specialClauses == null ? null : List.copyOf(specialClauses);
specialClauses = Objects.requireNonNull(specialClauses, "Special clauses are required.");
legalTerms = Objects.requireNonNull(legalTerms, "Legal terms are required.");
}
}
@@ -339,9 +423,22 @@ public final class LeaseDraftService {
private record PreparedDeedDeletion(LeaseGuaranteeDeed deed, LeaseDocumentStorage.StagedDeletion deletion) {
}
public record ClauseDetails(String catalogId, Map<String, String> parameters, String customText) {
public record ClauseDetails(String id, String vaultPublicKey, String catalogId, Map<String, String> parameters,
Map<String, EncryptedGroup> encryptedParameters, EncryptedGroup renderedText) {
public ClauseDetails {
id = required(id, "Clause ID");
vaultPublicKey = required(vaultPublicKey, "Lease vault public key");
parameters = parameters == null ? Map.of() : Collections.unmodifiableMap(new LinkedHashMap<>(parameters));
encryptedParameters = encryptedParameters == null
? Map.of()
: Collections.unmodifiableMap(new LinkedHashMap<>(encryptedParameters));
}
}
private static String required(String value, String field) {
if (value == null || value.isBlank()) {
throw new IllegalArgumentException(field + " is required.");
}
return value.trim();
}
}
@@ -13,6 +13,6 @@ public final class LeaseEndingApplicationValidationFailure extends RuntimeExcept
}
public enum Rule {
MISSING_EVIDENCE_CONFIRMATION, EMPTY_EVIDENCE
MISSING_EVIDENCE_CONFIRMATION, EMPTY_EVIDENCE, INVALID_EVIDENCE_ENVELOPE
}
}
@@ -1,16 +1,20 @@
package fr.nessar.lmnporganizer.rental.application.lease;
import fr.nessar.lmnporganizer.rental.application.port.out.FiscalYearClosureQuery;
import fr.nessar.lmnporganizer.rental.application.port.out.IdentifierGenerator;
import fr.nessar.lmnporganizer.rental.application.port.out.LeaseDocumentStorage;
import fr.nessar.lmnporganizer.rental.application.port.out.TransactionRunner;
import fr.nessar.lmnporganizer.rental.application.rentalapplication.ApplicationVaultAccess;
import fr.nessar.lmnporganizer.rental.application.rentalapplication.ApplicationVaultRequired;
import fr.nessar.lmnporganizer.rental.application.rent.RentFiscalYearGuard;
import fr.nessar.lmnporganizer.rental.application.rent.RentTermsTimeline;
import fr.nessar.lmnporganizer.rental.application.shared.AttachmentTooLarge;
import fr.nessar.lmnporganizer.rental.application.shared.ConcurrentChange;
import fr.nessar.lmnporganizer.rental.application.shared.ResourceNotFound;
import fr.nessar.lmnporganizer.rental.domain.condition.PropertyConditionReport;
import fr.nessar.lmnporganizer.rental.domain.condition.PropertyConditionReportRepository;
import fr.nessar.lmnporganizer.rental.domain.condition.PropertyConditionReportType;
import fr.nessar.lmnporganizer.rental.domain.application.ApplicationVault;
import fr.nessar.lmnporganizer.rental.domain.application.ApplicationVaultRepository;
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
import fr.nessar.lmnporganizer.rental.domain.lease.ApartmentOccupancy;
import fr.nessar.lmnporganizer.rental.domain.lease.ApartmentOccupancyRepository;
@@ -26,6 +30,7 @@ import fr.nessar.lmnporganizer.rental.domain.lease.NoticeEvidenceFile;
import fr.nessar.lmnporganizer.rental.domain.lease.NoticeEvidenceVersion;
import fr.nessar.lmnporganizer.rental.domain.lease.TenantNoticeCommand;
import fr.nessar.lmnporganizer.rental.domain.lease.TenantNoticeId;
import fr.nessar.lmnporganizer.rental.domain.tenant.TenantProfile.EncryptedGroup;
import fr.nessar.lmnporganizer.rental.domain.rent.RentDue;
import fr.nessar.lmnporganizer.rental.domain.rent.RentDueRepository;
import fr.nessar.lmnporganizer.rental.domain.rent.RentRevision;
@@ -42,8 +47,10 @@ import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.function.Consumer;
import java.util.function.Supplier;
public final class LeaseEndingService {
private static final long PRIVATE_FILE_HEADER_BYTES = 4_096 + 4;
private final LeaseRepository leases;
private final ApartmentOccupancyRepository occupancies;
private final RentDueRepository rentDues;
@@ -51,15 +58,17 @@ public final class LeaseEndingService {
private final PropertyConditionReportRepository reports;
private final LeaseDocumentStorage storage;
private final TransactionRunner transactions;
private final IdentifierGenerator identifiers;
private final Clock clock;
private final long maxEvidenceBytes;
private final FiscalYearClosureQuery fiscalYearClosures;
private final ApplicationVaultRepository vaults;
private final ApplicationVaultAccess vaultAccess;
public LeaseEndingService(LeaseRepository leases, ApartmentOccupancyRepository occupancies,
RentDueRepository rentDues, RentRevisionRepository revisions, PropertyConditionReportRepository reports,
LeaseDocumentStorage storage, TransactionRunner transactions, IdentifierGenerator identifiers, Clock clock,
long maxEvidenceBytes, FiscalYearClosureQuery fiscalYearClosures) {
LeaseDocumentStorage storage, TransactionRunner transactions, Clock clock, long maxEvidenceBytes,
FiscalYearClosureQuery fiscalYearClosures, ApplicationVaultRepository vaults,
ApplicationVaultAccess vaultAccess) {
this.leases = leases;
this.occupancies = occupancies;
this.rentDues = rentDues;
@@ -67,13 +76,14 @@ public final class LeaseEndingService {
this.reports = reports;
this.storage = storage;
this.transactions = transactions;
this.identifiers = identifiers;
this.clock = clock;
this.fiscalYearClosures = Objects.requireNonNull(fiscalYearClosures);
if (maxEvidenceBytes <= 0) {
throw new IllegalArgumentException("Maximum evidence size must be positive.");
}
this.maxEvidenceBytes = maxEvidenceBytes;
this.vaults = Objects.requireNonNull(vaults);
this.vaultAccess = Objects.requireNonNull(vaultAccess);
}
public LeaseEndingState state(LandlordId landlordId, LeaseId leaseId, ZoneId timeZone) {
@@ -89,18 +99,41 @@ public final class LeaseEndingService {
throw new LeaseEndingApplicationValidationFailure(
LeaseEndingApplicationValidationFailure.Rule.MISSING_EVIDENCE_CONFIRMATION);
}
TenantNoticeId noticeId = new TenantNoticeId(identifiers.next());
Lease initial = findLease(landlordId, leaseId);
requireFinalized(initial);
TenantNoticeId noticeId = new TenantNoticeId(request.noticeId());
String vaultPublicKey = request.vaultPublicKey();
ApplicationVault currentVault = null;
if (request.otherDeliveryMode() != null || evidence != null) {
currentVault = requireCurrentVault(landlordId, vaultPublicKey);
}
long expectedVersion = request.leaseVersion() >= 0 ? request.leaseVersion() : initial.version();
if (initial.version() != expectedVersion) {
throw new ConcurrentChange("Lease");
}
if (evidence != null && !Objects.equals(vaultPublicKey, evidence.vaultPublicKey())) {
throw new ApplicationVaultRequired();
}
StoredEvidence stored = evidence == null ? null : store(evidence);
try {
if (stored != null
&& (currentVault == null || !currentVault.publicKey().equals(stored.file().vaultPublicKey()))) {
throw new ApplicationVaultRequired();
}
return transactions.run(() -> changeEnd(landlordId, leaseId, lease -> {
if (request.otherDeliveryMode() != null || stored != null) {
requireCurrentVault(landlordId, vaultPublicKey);
}
requireLeaseVersion(lease, expectedVersion);
lease.recordTenantNotice(new TenantNoticeCommand(noticeId, request.receivedOn(),
request.requestedDepartureOn(), request.deliveryMode(), request.otherDeliveryMode(),
request.lateRegistrationConfirmed(), request.nonStandardDeliveryConfirmed(), evidence == null,
today(request.timeZone())), audit(authorId));
today(request.timeZone())), audit(authorId), vaultPublicKey);
if (stored != null) {
lease.appendNoticeEvidence(noticeId, stored.file(), null, audit(authorId));
lease.appendNoticeEvidence(noticeId, evidence.evidenceId(), stored.file(), evidence.reason(),
audit(authorId));
}
}, request.timeZone()));
}, request.timeZone(), expectedVersion));
} catch (RuntimeException failure) {
discard(stored, failure);
throw failure;
@@ -109,26 +142,47 @@ public final class LeaseEndingService {
public LeaseEndingState acceptWithdrawal(LandlordId landlordId, LeaseId leaseId, TenantNoticeId noticeId,
String authorId, AdministrativeFact request) {
return transactions.run(() -> changeEnd(landlordId, leaseId, lease -> lease.acceptNoticeWithdrawal(noticeId,
request.on(), request.reason(), today(request.timeZone()), audit(authorId)), request.timeZone()));
Lease lease = findLease(landlordId, leaseId);
requireCurrentVault(landlordId, request.vaultPublicKey());
long expectedVersion = expectedVersion(request.leaseVersion(), lease);
return transactions.run(() -> changeEnd(landlordId, leaseId, current -> {
ApplicationVault currentVault = requireCurrentVault(landlordId, request.vaultPublicKey());
requireLeaseVersion(current, expectedVersion);
current.acceptNoticeWithdrawal(noticeId, request.on(), request.reason(), today(request.timeZone()),
audit(authorId), currentVault.publicKey());
}, request.timeZone(), expectedVersion));
}
public LeaseEndingState invalidateNotice(LandlordId landlordId, LeaseId leaseId, TenantNoticeId noticeId,
String authorId, AdministrativeFact request) {
return transactions.run(() -> changeEnd(landlordId, leaseId, lease -> lease.invalidateNotice(noticeId,
request.on(), request.reason(), today(request.timeZone()), audit(authorId)), request.timeZone()));
Lease lease = findLease(landlordId, leaseId);
requireCurrentVault(landlordId, request.vaultPublicKey());
long expectedVersion = expectedVersion(request.leaseVersion(), lease);
return transactions.run(() -> changeEnd(landlordId, leaseId, current -> {
ApplicationVault currentVault = requireCurrentVault(landlordId, request.vaultPublicKey());
requireLeaseVersion(current, expectedVersion);
current.invalidateNotice(noticeId, request.on(), request.reason(), today(request.timeZone()),
audit(authorId), currentVault.publicKey());
}, request.timeZone(), expectedVersion));
}
public LeaseEndingState recordAgreedEarlyEnd(LandlordId landlordId, LeaseId leaseId, String authorId,
AgreedEnd request) {
return transactions.run(() -> changeEnd(landlordId, leaseId,
lease -> lease.recordAgreedEarlyEnd(request.endOn(), request.reason(), audit(authorId)),
request.timeZone()));
Lease lease = findLease(landlordId, leaseId);
requireCurrentVault(landlordId, request.vaultPublicKey());
long expectedVersion = expectedVersion(request.leaseVersion(), lease);
return transactions.run(() -> changeEnd(landlordId, leaseId, current -> {
ApplicationVault currentVault = requireCurrentVault(landlordId, request.vaultPublicKey());
requireLeaseVersion(current, expectedVersion);
current.recordAgreedEarlyEnd(request.agreementId(), request.endOn(), request.reason(), audit(authorId),
currentVault.publicKey());
}, request.timeZone(), expectedVersion));
}
public LeaseEndingState close(LandlordId landlordId, LeaseId leaseId, String authorId, Closure request) {
return transactions.run(() -> {
Lease lease = findLease(landlordId, leaseId);
requireLeaseVersion(lease, expectedVersion(request.leaseVersion(), lease));
PropertyConditionReport exitReport = reports.find(landlordId, leaseId, PropertyConditionReportType.EXIT)
.filter(PropertyConditionReport::finalized).orElseThrow(() -> new LeaseEndingConflict(
"Un état des lieux de sortie finalisé est requis pour clôturer le bail."));
@@ -140,11 +194,27 @@ public final class LeaseEndingService {
public LeaseEndingState appendEvidence(LandlordId landlordId, LeaseId leaseId, TenantNoticeId noticeId,
String authorId, EvidenceUpload evidence) {
Lease initial = findLease(landlordId, leaseId);
requireFinalized(initial);
ApplicationVault vault = requireCurrentVault(landlordId, evidence.vaultPublicKey());
long expectedVersion = expectedVersion(evidence.leaseVersion(), initial);
if (initial.version() != expectedVersion) {
throw new ConcurrentChange("Lease");
}
if (!vault.publicKey().equals(evidence.vaultPublicKey())) {
throw new ApplicationVaultRequired();
}
StoredEvidence stored = store(evidence);
try {
return transactions.run(() -> {
Lease lease = findLease(landlordId, leaseId);
lease.appendNoticeEvidence(noticeId, stored.file(), evidence.reason(), audit(authorId));
requireLeaseVersion(lease, expectedVersion);
ApplicationVault currentVault = requireCurrentVault(landlordId, evidence.vaultPublicKey());
if (!currentVault.publicKey().equals(stored.file().vaultPublicKey())) {
throw new ApplicationVaultRequired();
}
lease.appendNoticeEvidence(noticeId, evidence.evidenceId(), stored.file(), evidence.reason(),
audit(authorId));
Lease saved = leases.save(lease);
return state(landlordId, saved, rentDues.findAll(landlordId, leaseId), today(evidence.timeZone()));
});
@@ -159,16 +229,19 @@ public final class LeaseEndingService {
NoticeEvidenceVersion evidence = lease.endingHistory().evidenceVersions(noticeId).stream()
.filter(candidate -> candidate.version() == version).findFirst()
.orElseThrow(() -> new ResourceNotFound("Lease ending evidence"));
requireStoredVault(landlordId, evidence.file().vaultPublicKey());
byte[] content = storage.read(evidence.file().storageKey());
if (content.length != evidence.file().size() || !sha256(content).equals(evidence.file().sha256())) {
throw new IllegalStateException("Lease ending evidence integrity check failed.");
}
return new EvidenceDownload(evidence.file().fileName(), content);
return new EvidenceDownload(evidence.evidenceId(), evidence.file().cryptoVersion(), evidence.file().nonce(),
evidence.file().wrappedKey(), evidence.file().sha256(), evidence.file().vaultPublicKey(), content);
}
private LeaseEndingState changeEnd(LandlordId landlordId, LeaseId leaseId, Consumer<Lease> change,
ZoneId timeZone) {
private LeaseEndingState changeEnd(LandlordId landlordId, LeaseId leaseId, Consumer<Lease> change, ZoneId timeZone,
long expectedVersion) {
Lease lease = findLease(landlordId, leaseId);
requireLeaseVersion(lease, expectedVersion);
Optional<LocalDate> previousEnd = lease.contractEndOn();
change.accept(lease);
ApartmentOccupancy occupancy = occupancies.find(landlordId, lease.apartmentId())
@@ -200,6 +273,7 @@ public final class LeaseEndingService {
private LeaseEndingState state(LandlordId landlordId, Lease lease, List<RentDue> dues, LocalDate today) {
boolean supported = lease.endingSupported();
var history = lease.endingHistory();
return new LeaseEndingState(lease.id(), supported,
supported
? Optional.empty()
@@ -208,7 +282,7 @@ public final class LeaseEndingService {
lease.contractEndOn(), maxEvidenceBytes, dues.stream().anyMatch(RentDue::correctionRequired),
reports.find(landlordId, lease.id(), PropertyConditionReportType.EXIT).map(
report -> new LeaseEndingState.ExitConditionReport(report.occurredOn(), report.finalized())),
lease.endingHistory());
history, lease.version(), vaultAccess.status(landlordId, history.vaultPublicKey()));
}
private RentSchedule.EndAmounts finalMonthAmounts(LandlordId landlordId, Lease lease, LocalDate endOn) {
@@ -241,12 +315,19 @@ public final class LeaseEndingService {
throw new LeaseEndingApplicationValidationFailure(
LeaseEndingApplicationValidationFailure.Rule.EMPTY_EVIDENCE);
}
if (content.length > maxEvidenceBytes) {
throw new AttachmentTooLarge(maxEvidenceBytes);
long maximumCiphertextBytes = Math.addExact(maxEvidenceBytes, PRIVATE_FILE_HEADER_BYTES + 16);
if (content.length > maximumCiphertextBytes) {
throw new AttachmentTooLarge(maximumCiphertextBytes);
}
new NoticeEvidenceFile("pending", upload.cryptoVersion(), upload.nonce(), upload.wrappedKey(), upload.sha256(),
content.length, upload.vaultPublicKey());
if (!sha256(content).equals(upload.sha256())) {
throw new LeaseEndingApplicationValidationFailure(
LeaseEndingApplicationValidationFailure.Rule.INVALID_EVIDENCE_ENVELOPE);
}
String key = storage.store(content);
return new StoredEvidence(new NoticeEvidenceFile(key, safeFileName(upload.fileName()),
contentType(upload.contentType()), content.length, sha256(content)));
return new StoredEvidence(new NoticeEvidenceFile(key, upload.cryptoVersion(), upload.nonce(),
upload.wrappedKey(), upload.sha256(), content.length, upload.vaultPublicKey()));
}
private void discard(StoredEvidence evidence, RuntimeException failure) {
@@ -268,24 +349,6 @@ public final class LeaseEndingService {
return previous.isPresent() && (current.isEmpty() || current.orElseThrow().isAfter(previous.orElseThrow()));
}
private static String safeFileName(String fileName) {
if (fileName == null || fileName.isBlank()) {
return "justificatif";
}
String normalized = fileName.replace('\\', '/');
String leaf = normalized.substring(normalized.lastIndexOf('/') + 1).trim();
StringBuilder safe = new StringBuilder();
leaf.codePoints().filter(character -> !Character.isISOControl(character)).forEach(safe::appendCodePoint);
if (safe.isEmpty() || safe.toString().equals(".") || safe.toString().equals("..")) {
return "justificatif";
}
return safe.length() > 255 ? safe.substring(0, 255) : safe.toString();
}
private static String contentType(String contentType) {
return contentType == null || contentType.isBlank() ? "application/octet-stream" : contentType.trim();
}
private static String sha256(byte[] content) {
try {
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(content));
@@ -294,15 +357,36 @@ public final class LeaseEndingService {
}
}
public record RecordNotice(ZoneId timeZone, LocalDate receivedOn, LocalDate requestedDepartureOn,
NoticeDeliveryMode deliveryMode, String otherDeliveryMode, boolean lateRegistrationConfirmed,
boolean nonStandardDeliveryConfirmed, boolean missingEvidenceConfirmed) {
public RecordNotice(ZoneId timeZone, LocalDate receivedOn, LocalDate requestedDepartureOn,
NoticeDeliveryMode deliveryMode, String otherDeliveryMode, boolean lateRegistrationConfirmed,
boolean nonStandardDeliveryConfirmed) {
this(timeZone, receivedOn, requestedDepartureOn, deliveryMode, otherDeliveryMode, lateRegistrationConfirmed,
nonStandardDeliveryConfirmed, false);
private static long expectedVersion(long requested, Lease lease) {
return requested >= 0 ? requested : lease.version();
}
private static void requireLeaseVersion(Lease lease, long expectedVersion) {
if (lease.version() != expectedVersion) {
throw new ConcurrentChange("Lease");
}
}
private ApplicationVault requireCurrentVault(LandlordId landlordId, String publicKey) {
vaultAccess.requireLandlordUse(landlordId);
ApplicationVault vault = vaults.find(landlordId).orElseThrow(ApplicationVaultRequired::new);
if (publicKey == null || !vault.publicKey().equals(publicKey)) {
throw new ApplicationVaultRequired();
}
return vault;
}
private void requireStoredVault(LandlordId landlordId, String publicKey) {
vaultAccess.requireLandlordUse(landlordId);
if (vaults.find(landlordId).filter(vault -> vault.publicKey().equals(publicKey)).isEmpty()) {
throw new ApplicationVaultRequired();
}
}
public record RecordNotice(String noticeId, ZoneId timeZone, LocalDate receivedOn, LocalDate requestedDepartureOn,
NoticeDeliveryMode deliveryMode, EncryptedGroup otherDeliveryMode, boolean lateRegistrationConfirmed,
boolean nonStandardDeliveryConfirmed, boolean missingEvidenceConfirmed, String vaultPublicKey,
long leaseVersion) {
public RecordNotice {
Objects.requireNonNull(timeZone);
@@ -312,39 +396,44 @@ public final class LeaseEndingService {
}
}
public record AdministrativeFact(ZoneId timeZone, LocalDate on, String reason) {
public record AdministrativeFact(ZoneId timeZone, LocalDate on, EncryptedGroup reason, String vaultPublicKey,
long leaseVersion) {
public AdministrativeFact {
Objects.requireNonNull(timeZone);
Objects.requireNonNull(on);
}
}
public record AgreedEnd(ZoneId timeZone, LocalDate endOn, String reason) {
public record AgreedEnd(ZoneId timeZone, String agreementId, LocalDate endOn, EncryptedGroup reason,
String vaultPublicKey, long leaseVersion) {
public AgreedEnd {
Objects.requireNonNull(timeZone);
Objects.requireNonNull(endOn);
}
}
public record Closure(ZoneId timeZone, boolean confirmed) {
public record Closure(ZoneId timeZone, boolean confirmed, long leaseVersion) {
public Closure {
Objects.requireNonNull(timeZone);
}
}
public record EvidenceUpload(ZoneId timeZone, String fileName, String contentType, byte[] content, String reason) {
public record EvidenceUpload(ZoneId timeZone, String evidenceId, int cryptoVersion, String nonce, String wrappedKey,
String sha256, String vaultPublicKey, Supplier<byte[]> contentSupplier, EncryptedGroup reason,
long leaseVersion) {
public EvidenceUpload {
Objects.requireNonNull(timeZone);
content = content == null ? null : content.clone();
Objects.requireNonNull(contentSupplier);
}
@Override
public byte[] content() {
byte[] content = contentSupplier.get();
return content == null ? null : content.clone();
}
}
public record EvidenceDownload(String fileName, byte[] content) {
public record EvidenceDownload(String evidenceId, int cryptoVersion, String nonce, String wrappedKey, String sha256,
String vaultPublicKey, byte[] content) {
public EvidenceDownload {
content = content.clone();
}
@@ -1,5 +1,6 @@
package fr.nessar.lmnporganizer.rental.application.lease;
import fr.nessar.lmnporganizer.rental.application.rentalapplication.ApplicationVaultAccess;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseEndPhase;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseEndingHistory;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseId;
@@ -11,7 +12,8 @@ import java.util.Optional;
public record LeaseEndingState(LeaseId leaseId, boolean supported, Optional<String> unsupportedReason,
LeaseEndPhase phase, LeaseType leaseType, LocalDate leaseStartOn, Optional<LocalDate> fixedEndOn,
Optional<LocalDate> effectiveEndOn, long maxEvidenceBytes, boolean correctionRequired,
Optional<ExitConditionReport> exitConditionReport, LeaseEndingHistory history) {
Optional<ExitConditionReport> exitConditionReport, LeaseEndingHistory history, long leaseVersion,
ApplicationVaultAccess.ProtectionStatus vaultProtectionStatus) {
public LeaseEndingState {
Objects.requireNonNull(leaseId);
unsupportedReason = unsupportedReason == null ? Optional.empty() : unsupportedReason;
@@ -25,6 +27,10 @@ public record LeaseEndingState(LeaseId leaseId, boolean supported, Optional<Stri
}
exitConditionReport = exitConditionReport == null ? Optional.empty() : exitConditionReport;
Objects.requireNonNull(history);
Objects.requireNonNull(vaultProtectionStatus);
if (leaseVersion < -1) {
throw new IllegalArgumentException("Lease version cannot be less than -1.");
}
}
public record ExitConditionReport(LocalDate occurredOn, boolean finalized) {
@@ -3,11 +3,15 @@ package fr.nessar.lmnporganizer.rental.application.lease;
import fr.nessar.lmnporganizer.rental.application.port.out.FiscalYearClosureQuery;
import fr.nessar.lmnporganizer.rental.application.port.out.IdentifierGenerator;
import fr.nessar.lmnporganizer.rental.application.port.out.LeaseDocumentStorage;
import fr.nessar.lmnporganizer.rental.application.port.out.LeaseInformationNoticeCatalog.InformationNotice;
import fr.nessar.lmnporganizer.rental.application.port.out.TransactionRunner;
import fr.nessar.lmnporganizer.rental.application.rent.RentFiscalYearGuard;
import fr.nessar.lmnporganizer.rental.application.shared.AttachmentTooLarge;
import fr.nessar.lmnporganizer.rental.application.rentalapplication.ApplicationVaultAccess;
import fr.nessar.lmnporganizer.rental.application.rentalapplication.ApplicationVaultRequired;
import fr.nessar.lmnporganizer.rental.application.shared.ResourceNotFound;
import fr.nessar.lmnporganizer.rental.domain.apartment.Apartment;
import fr.nessar.lmnporganizer.rental.domain.application.ApplicationVault;
import fr.nessar.lmnporganizer.rental.domain.application.ApplicationVaultRepository;
import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentRepository;
import fr.nessar.lmnporganizer.rental.domain.condition.PropertyConditionReportConflict;
import fr.nessar.lmnporganizer.rental.domain.condition.PropertyConditionReportFinalization;
@@ -44,6 +48,7 @@ import fr.nessar.lmnporganizer.rental.domain.rent.RentDueRepository;
import fr.nessar.lmnporganizer.rental.domain.rent.RentSchedule;
import fr.nessar.lmnporganizer.rental.domain.tenant.Tenant;
import fr.nessar.lmnporganizer.rental.domain.tenant.TenantId;
import fr.nessar.lmnporganizer.rental.domain.tenant.TenantProfile;
import fr.nessar.lmnporganizer.rental.domain.tenant.TenantRepository;
import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
@@ -54,9 +59,11 @@ import java.time.Instant;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Base64;
import java.util.Comparator;
import java.util.HexFormat;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
@@ -67,6 +74,7 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public final class LeaseFinalizationService {
private static final String GUARANTEE_DEED_FILE_NAME = "acte-cautionnement.pdf";
public static final String GUARANTEE_SIMULATOR_URL = "https://www.service-public.fr/simulateur/calcul/ActeCautionnement";
private static final Logger LOGGER = LoggerFactory.getLogger(LeaseFinalizationService.class);
@@ -88,6 +96,8 @@ public final class LeaseFinalizationService {
private final FurnitureInventoryRepository inventories;
private final PropertyConditionReportRepository conditionReports;
private final FiscalYearClosureQuery fiscalYearClosures;
private final ApplicationVaultRepository vaults;
private final ApplicationVaultAccess vaultAccess;
public LeaseFinalizationService(LeaseRepository leases, ApartmentRepository apartments, TenantRepository tenants,
LandlordRepository landlords, LeaseDocumentRevisionSource revisionSource,
@@ -95,7 +105,8 @@ public final class LeaseFinalizationService {
LeaseFinalizationRepository finalizations, LeaseDocumentStorage storage, RentDueRepository rentDues,
ApartmentOccupancyRepository occupancies, IdentifierGenerator identifiers, TransactionRunner transactions,
Clock clock, long maximumPdfSize, FurnitureInventoryRepository inventories,
PropertyConditionReportRepository conditionReports, FiscalYearClosureQuery fiscalYearClosures) {
PropertyConditionReportRepository conditionReports, FiscalYearClosureQuery fiscalYearClosures,
ApplicationVaultRepository vaults, ApplicationVaultAccess vaultAccess) {
this.leases = leases;
this.apartments = apartments;
this.tenants = tenants;
@@ -117,61 +128,82 @@ public final class LeaseFinalizationService {
this.inventories = inventories;
this.conditionReports = conditionReports;
this.fiscalYearClosures = Objects.requireNonNull(fiscalYearClosures);
this.vaults = Objects.requireNonNull(vaults);
this.vaultAccess = Objects.requireNonNull(vaultAccess);
}
public LeaseGuaranteeDeed uploadGuaranteeDeed(LandlordId landlordId, LeaseId leaseId, String guaranteeId,
TenantId tenantId, boolean attestation, PdfUpload upload, String authorId) {
TenantId tenantId, boolean attestation, LeaseDocumentEncryption upload, String authorId) {
Lease lease = draft(landlordId, leaseId);
requireAuthor(authorId);
if (!attestation) {
throw new LeaseFinalizationValidationFailure(LeaseFinalizationValidationFailure.Rule.ATTESTATION_REQUIRED);
}
LeaseGuarantee guarantee = requirePair(lease, guaranteeId, tenantId);
byte[] content = pdf(upload);
requireCurrentRevisionForGuaranteeDeed(landlordId, leaseId);
validateEncryptedDocument(upload);
if (!GUARANTEE_DEED_FILE_NAME.equals(upload.fileName()))
throw new LeaseFinalizationValidationFailure(LeaseFinalizationValidationFailure.Rule.PDF_INVALID);
ApplicationVault vault = requireCurrentVault(landlordId, upload);
InformationNotice sourceNotice = requireCurrentRevisionForGuaranteeDeed(landlordId, leaseId);
String contractFingerprint = GuaranteeDeedIntegrity.fingerprint(contractSnapshot(landlordId, lease), guarantee);
String fingerprint = sha256(content);
String fingerprint = upload.sha256();
Optional<LeaseGuaranteeDeed> existing = guaranteeDeeds.find(landlordId, leaseId, guaranteeId, tenantId);
if (existing.isPresent()) {
LeaseGuaranteeDeed current = existing.orElseThrow();
if (current.sha256().equals(fingerprint)) {
if (sameEnvelope(current, upload)) {
return current.contractFingerprint().equals(contractFingerprint)
? current
: refreshGuaranteeDeed(current, contractFingerprint, upload, authorId);
: refreshGuaranteeDeed(current, contractFingerprint, upload, authorId, sourceNotice);
}
return replaceGuaranteeDeed(current, upload, content, fingerprint, contractFingerprint, authorId);
return replaceGuaranteeDeed(current, upload, decodeCiphertext(upload), fingerprint, contractFingerprint,
authorId, sourceNotice);
}
String storageKey = storage.store(content);
String storageKey = storage.store(decodeCiphertext(upload));
LeaseGuaranteeDeed deed = new LeaseGuaranteeDeed(UUID.randomUUID().toString(), landlordId, leaseId, guaranteeId,
tenantId, true, storageKey, safeFileName(upload.fileName()), fingerprint, contractFingerprint, authorId,
tenantId, true, storageKey, GUARANTEE_DEED_FILE_NAME, fingerprint, upload.cryptoVersion(),
upload.nonce(), upload.wrappedKey(), upload.vaultPublicKey(), contractFingerprint, authorId,
Instant.now(clock), -1);
try {
return saveGuaranteeDeed(deed);
fenceCurrentVault(landlordId, vault, upload);
return saveGuaranteeDeed(deed, sourceNotice);
} catch (LeaseFinalizationConflict conflict) {
discard(storageKey, conflict);
return guaranteeDeeds.find(landlordId, leaseId, guaranteeId, tenantId)
.map(found -> sameOrConflict(found, fingerprint, contractFingerprint)).orElseThrow(() -> conflict);
.map(found -> sameOrConflict(found, upload, contractFingerprint)).orElseThrow(() -> conflict);
} catch (RuntimeException failure) {
discard(storageKey, failure);
throw failure;
}
}
private LeaseGuaranteeDeed refreshGuaranteeDeed(LeaseGuaranteeDeed existing, String contractFingerprint,
PdfUpload upload, String authorId) {
return saveGuaranteeDeed(
new LeaseGuaranteeDeed(existing.id(), existing.landlordId(), existing.leaseId(), existing.guaranteeId(),
existing.tenantId(), true, existing.storageKey(), safeFileName(upload.fileName()),
existing.sha256(), contractFingerprint, authorId, Instant.now(clock), existing.version()));
public GuaranteeDeedDownload downloadGuaranteeDeed(LandlordId landlordId, LeaseId leaseId, String guaranteeId,
TenantId tenantId) {
LeaseGuaranteeDeed deed = guaranteeDeeds.find(landlordId, leaseId, guaranteeId, tenantId)
.orElseThrow(() -> new ResourceNotFound("Guarantee deed"));
requireStoredVault(landlordId, deed.vaultPublicKey());
byte[] ciphertext = storage.read(deed.storageKey());
if (ciphertext == null || !deed.sha256().equals(sha256(ciphertext)))
throw new LeaseFinalizationConflict("L’intégrité de l’acte de cautionnement n’est pas vérifiée.");
return new GuaranteeDeedDownload(deed, ciphertext);
}
private LeaseGuaranteeDeed replaceGuaranteeDeed(LeaseGuaranteeDeed existing, PdfUpload upload, byte[] content,
String fingerprint, String contractFingerprint, String authorId) {
private LeaseGuaranteeDeed refreshGuaranteeDeed(LeaseGuaranteeDeed existing, String contractFingerprint,
LeaseDocumentEncryption upload, String authorId, InformationNotice sourceNotice) {
return saveGuaranteeDeed(new LeaseGuaranteeDeed(existing.id(), existing.landlordId(), existing.leaseId(),
existing.guaranteeId(), existing.tenantId(), true, existing.storageKey(), GUARANTEE_DEED_FILE_NAME,
existing.sha256(), existing.cryptoVersion(), existing.nonce(), existing.wrappedKey(),
existing.vaultPublicKey(), contractFingerprint, authorId, Instant.now(clock), existing.version()),
sourceNotice);
}
private LeaseGuaranteeDeed replaceGuaranteeDeed(LeaseGuaranteeDeed existing, LeaseDocumentEncryption upload,
byte[] content, String fingerprint, String contractFingerprint, String authorId,
InformationNotice sourceNotice) {
String storageKey = storage.store(content);
LeaseGuaranteeDeed replacement = new LeaseGuaranteeDeed(existing.id(), existing.landlordId(),
existing.leaseId(), existing.guaranteeId(), existing.tenantId(), true, storageKey,
safeFileName(upload.fileName()), fingerprint, contractFingerprint, authorId, Instant.now(clock),
existing.version());
GUARANTEE_DEED_FILE_NAME, fingerprint, upload.cryptoVersion(), upload.nonce(), upload.wrappedKey(),
upload.vaultPublicKey(), contractFingerprint, authorId, Instant.now(clock), existing.version());
LeaseDocumentStorage.StagedDeletion deletion;
try {
deletion = storage.stageDeletion(existing.storageKey());
@@ -181,7 +213,7 @@ public final class LeaseFinalizationService {
}
LeaseGuaranteeDeed saved;
try {
saved = saveGuaranteeDeed(replacement);
saved = saveGuaranteeDeed(replacement, sourceNotice);
} catch (RuntimeException failure) {
restore(deletion, failure);
discard(storageKey, failure);
@@ -191,17 +223,20 @@ public final class LeaseFinalizationService {
return saved;
}
private LeaseGuaranteeDeed saveGuaranteeDeed(LeaseGuaranteeDeed deed) {
private LeaseGuaranteeDeed saveGuaranteeDeed(LeaseGuaranteeDeed deed, InformationNotice sourceNotice) {
return transactions.run(() -> {
Lease lease = draft(deed.landlordId(), deed.leaseId());
LeaseGuarantee guarantee = requirePair(lease, deed.guaranteeId(), deed.tenantId());
requireCurrentRevisionForGuaranteeDeed(deed.landlordId(), deed.leaseId());
requireCurrentRevisionForGuaranteeDeed(deed.landlordId(), deed.leaseId(), sourceNotice);
String contractFingerprint = GuaranteeDeedIntegrity.fingerprint(contractSnapshot(deed.landlordId(), lease),
guarantee);
if (!deed.contractFingerprint().equals(contractFingerprint)) {
throw new LeaseFinalizationConflict(
"Les données de l’acte de cautionnement ont changé ; rechargez avant de continuer.");
}
fenceCurrentVault(deed.landlordId(), deed.vaultPublicKey());
LeaseDocumentRevision revision = currentRevision(deed.landlordId(), deed.leaseId());
revisionSource.coordinateCurrentSource(deed.landlordId(), deed.leaseId(), revision, sourceNotice);
guaranteeDeeds.coordinateWithLease(lease);
return guaranteeDeeds.save(deed);
});
@@ -214,7 +249,8 @@ public final class LeaseFinalizationService {
LeaseFinalization finalization = finalizations.find(landlordId, leaseId).orElse(null);
if (finalization == null && revision != null) {
try {
if (!revisionSource.matchesCurrentSource(landlordId, leaseId, revision))
InformationNotice currentNotice = revisionSource.currentNotice();
if (!revisionSource.matchesCurrentSource(landlordId, leaseId, revision, currentNotice))
revision = null;
} catch (PropertyConditionReportConflict | LeaseFinalizationConflict sourceNotReady) {
revision = null;
@@ -225,71 +261,130 @@ public final class LeaseFinalizationService {
requirements(landlordId, lease, deeds), finalization);
}
public Result finalizeLease(LandlordId landlordId, LeaseId leaseId, FinalizationRequest request,
PdfUpload signedLease, String authorId) {
/**
* Finalizes a lease from a browser-encrypted complete package. The server
* validates only the envelope and stores ciphertext; it never parses the PDF.
*/
public Result finalizeEncryptedLease(LandlordId landlordId, LeaseId leaseId, FinalizationRequest request,
LeaseDocumentEncryption signedLease, String authorId) {
Lease lease = lease(landlordId, leaseId);
requireAuthor(authorId);
if (finalizations.find(landlordId, leaseId).isPresent()) {
vaultAccess.requireLandlordUse(landlordId);
LeaseFinalization existing = finalizations.find(landlordId, leaseId).orElse(null);
if (existing != null) {
if (existing.revisionNumber() == request.revisionNumber()
&& existing.signedSourceFingerprint().equals(request.sourceFingerprint())) {
validateEncryptedDocument(signedLease);
ApplicationVault currentVault = requireCurrentVault(landlordId, signedLease);
if (!currentVault.publicKey().equals(existing.signedVaultPublicKey()))
throw new LeaseFinalizationConflict(
"Le coffre courant ne permet plus d’ouvrir la finalisation existante.");
return transactions.run(() -> {
fenceCurrentVault(landlordId, currentVault, signedLease);
return existingResult(landlordId, leaseId, existing);
});
}
throw new LeaseFinalizationConflict("Ce bail est déjà finalisé.");
}
requireDraftForFinalization(lease);
if (!request.allPartiesSigned()) {
if (!request.allPartiesSigned())
throw new LeaseFinalizationValidationFailure(LeaseFinalizationValidationFailure.Rule.ALL_PARTIES_MUST_SIGN);
}
if (!request.revisionMatches()) {
if (!request.revisionMatches())
throw new LeaseFinalizationValidationFailure(LeaseFinalizationValidationFailure.Rule.REVISION_MUST_MATCH);
}
if (request.lastSignatureDate() == null) {
if (request.lastSignatureDate() == null)
throw new LeaseFinalizationValidationFailure(
LeaseFinalizationValidationFailure.Rule.SIGNATURE_DATE_REQUIRED);
}
byte[] content = pdf(signedLease);
LeaseDocumentRevision revision = currentRevision(landlordId, leaseId);
requireCurrentRevision(landlordId, leaseId, request, revision);
List<GuaranteeRequirement> requirements = requirements(landlordId, lease,
InformationNotice sourceNotice = revisionSource.currentNotice();
requireCurrentRevision(landlordId, leaseId, request, revision, sourceNotice);
List<GuaranteeRequirement> requirements = requireGuaranteeDeeds(landlordId, lease,
guaranteeDeeds.findAll(landlordId, leaseId));
if (requirements.stream().anyMatch(requirement -> requirement.status() != GuaranteeDeedStatus.CURRENT)) {
if (requirements.stream().anyMatch(requirement -> requirement.status() != GuaranteeDeedStatus.CURRENT))
throw new LeaseFinalizationValidationFailure(
LeaseFinalizationValidationFailure.Rule.GUARANTEE_DEEDS_REQUIRED);
}
List<LeaseFinalization.LegalOverride> overrides = overrides(landlordId, lease, revision,
request.confirmedWarningCodes(), authorId);
String storageKey = storage.store(content);
ApplicationVault vault = requireCurrentVault(landlordId, signedLease);
validateEncryptedDocument(signedLease);
String storageKey = storage.store(decodeCiphertext(signedLease));
Instant now = Instant.now(clock);
LeaseFinalization proposed = new LeaseFinalization(UUID.randomUUID().toString(), landlordId, leaseId,
revision.number(), revision.templateVersion(), request.lastSignatureDate(), storageKey,
safeFileName(signedLease.fileName()), sha256(content), authorId, now, overrides);
safeFileName(signedLease.fileName()), signedLease.sha256(), authorId, now, overrides,
revision.sourceFingerprint(), signedLease.cryptoVersion(), signedLease.nonce(),
signedLease.wrappedKey(), signedLease.vaultPublicKey());
boolean cleanupAttempted = false;
try {
return transactions.run(() -> finalizeInTransaction(landlordId, leaseId, request, revision, proposed));
// Storage is deliberately verified before entering the database transaction.
// The
// transaction below only rechecks immutable metadata and performs CAS writes.
requireStoredIntegrity(storageKey, signedLease.sha256(), "Le PDF signé");
requirements.forEach(requirement -> requireStoredIntegrity(requirement.deed().storageKey(),
requirement.deed().sha256(), "L’acte de cautionnement"));
// Recheck the source after all remote reads and before entering the database
// transaction. The transaction itself only performs local CAS checks.
InformationNotice currentNotice = revisionSource.currentNotice();
requireCurrentRevision(landlordId, leaseId, request, revision, currentNotice);
Result result = transactions.run(() -> {
fenceCurrentVault(landlordId, vault, signedLease);
return finalizeInTransaction(landlordId, leaseId, request, revision, proposed, requirements,
lease.version(), currentNotice);
});
if (!result.finalization().id().equals(proposed.id())) {
cleanupAttempted = true;
discard(storageKey, null);
}
return result;
} catch (RuntimeException failure) {
discard(storageKey, failure);
if (!cleanupAttempted)
discard(storageKey, failure);
throw failure;
}
}
private Result existingResult(LandlordId landlordId, LeaseId leaseId, LeaseFinalization finalization) {
Lease lease = lease(landlordId, leaseId);
List<RentDue> dues = rentDues.findAll(landlordId, leaseId).stream()
.sorted(Comparator.comparing(RentDue::month).thenComparing(due -> due.id().value())).toList();
return new Result(lease, dues, finalization);
}
private Result finalizeInTransaction(LandlordId landlordId, LeaseId leaseId, FinalizationRequest request,
LeaseDocumentRevision revision, LeaseFinalization proposed) {
if (finalizations.find(landlordId, leaseId).isPresent()) {
LeaseDocumentRevision revision, LeaseFinalization proposed, List<GuaranteeRequirement> preparedGuarantees,
long expectedLeaseVersion, InformationNotice sourceNotice) {
LeaseFinalization winner = finalizations.find(landlordId, leaseId).orElse(null);
if (winner != null) {
if (winner.revisionNumber() == request.revisionNumber()
&& winner.signedSourceFingerprint().equals(request.sourceFingerprint())
&& winner.signedVaultPublicKey().equals(proposed.signedVaultPublicKey())) {
return existingResult(landlordId, leaseId, winner);
}
throw new LeaseFinalizationConflict("Ce bail est déjà finalisé.");
}
Lease currentLease = lease(landlordId, leaseId);
if (currentLease.version() != expectedLeaseVersion)
throw new LeaseFinalizationConflict("Le bail a changé pendant la finalisation.");
requireDraftForFinalization(currentLease);
List<GuaranteeRequirement> guarantees = requireGuaranteeDeeds(landlordId, currentLease,
guaranteeDeeds.findAll(landlordId, leaseId));
if (!guarantees.equals(preparedGuarantees)) {
throw new LeaseFinalizationConflict("Les actes de cautionnement ont changé pendant la finalisation.");
}
LeaseDocumentRevision current = currentRevision(landlordId, leaseId);
requireSameRevision(revision, current);
requireCurrentRevision(landlordId, leaseId, request, current);
if (!revisionSource.matchesCurrentSource(landlordId, leaseId, current, sourceNotice))
throw new LeaseFinalizationConflict("Le bail a changé depuis la génération de la révision signée.");
revisionSource.coordinateCurrentSource(landlordId, leaseId, current, sourceNotice);
Activation activation = prepareActivation(landlordId, currentLease);
requireStoredIntegrity(proposed.signedStorageKey(), proposed.signedSha256(), "Le PDF signé");
guarantees.forEach(requirement -> requireStoredIntegrity(requirement.deed().storageKey(),
requirement.deed().sha256(), "L’acte de cautionnement"));
freezeInventory(landlordId, leaseId);
Lease activated = activateInTransaction(activation);
LeaseFinalization saved = finalizations.save(proposed);
var entry = conditionReports.find(landlordId, leaseId, PropertyConditionReportType.ENTRY)
.orElseThrow(() -> new LeaseFinalizationConflict("L’état des lieux d’entrée est requis."));
entry.finalizeWithLeasePackage(new PropertyConditionReportFinalization(current.number(),
proposed.signedStorageKey(), proposed.signedSha256(), proposed.authorId(), proposed.finalizedAt()));
proposed.signedStorageKey(), proposed.signedSha256(), proposed.authorId(), proposed.finalizedAt(),
proposed.signedSourceFingerprint(), proposed.signedFileName(), proposed.signedCryptoVersion(),
proposed.signedNonce(), proposed.signedWrappedKey(), proposed.signedVaultPublicKey()));
conditionReports.save(entry);
List<RentDue> dues = rentDues.findAll(landlordId, leaseId).stream()
.sorted(Comparator.comparing(RentDue::month).thenComparing(due -> due.id().value())).toList();
@@ -330,8 +425,8 @@ public final class LeaseFinalizationService {
: deed.contractFingerprint().equals(GuaranteeDeedIntegrity.fingerprint(contract, guarantee))
? GuaranteeDeedStatus.CURRENT
: GuaranteeDeedStatus.OBSOLETE;
return new GuaranteeRequirement(guarantee.id(), guarantee.type(), guarantee.displayName(), tenantId,
tenant.firstName() + " " + tenant.lastName(), deed, status);
return new GuaranteeRequirement(guarantee.id(), guarantee.type(), "Garantie " + guarantee.id(), tenantId,
tenant.profile(), deed, status);
}
private static LeaseGuarantee requirePair(Lease lease, String guaranteeId, TenantId tenantId) {
@@ -406,24 +501,41 @@ public final class LeaseFinalizationService {
"La révision signée n'est plus disponible ; générez-la à nouveau."));
}
private void requireCurrentRevisionForGuaranteeDeed(LandlordId landlordId, LeaseId leaseId) {
private InformationNotice requireCurrentRevisionForGuaranteeDeed(LandlordId landlordId, LeaseId leaseId) {
LeaseDocumentRevision revision = currentRevision(landlordId, leaseId);
if (!revisionSource.matchesCurrentSource(landlordId, leaseId, revision)) {
InformationNotice sourceNotice = revisionSource.currentNotice();
requireCurrentRevisionForGuaranteeDeed(landlordId, leaseId, revision, sourceNotice);
return sourceNotice;
}
private void requireCurrentRevisionForGuaranteeDeed(LandlordId landlordId, LeaseId leaseId,
InformationNotice sourceNotice) {
LeaseDocumentRevision revision = currentRevision(landlordId, leaseId);
requireCurrentRevisionForGuaranteeDeed(landlordId, leaseId, revision, sourceNotice);
}
private void requireCurrentRevisionForGuaranteeDeed(LandlordId landlordId, LeaseId leaseId,
LeaseDocumentRevision revision, InformationNotice sourceNotice) {
if (!revisionSource.matchesCurrentSource(landlordId, leaseId, revision, sourceNotice)) {
throw new LeaseFinalizationConflict(
"Le bail a changé depuis la génération de la révision à laquelle l’acte se rapporte.");
}
}
private void requireCurrentRevision(LandlordId landlordId, LeaseId leaseId, FinalizationRequest request,
LeaseDocumentRevision revision) {
LeaseDocumentRevision revision, InformationNotice sourceNotice) {
if (request.revisionNumber() != revision.number()) {
throw new LeaseFinalizationConflict("La révision signée n'est plus la révision courante du bail.");
}
if (!request.sourceFingerprint().equals(revision.sourceFingerprint())) {
throw new LeaseFinalizationConflict(
"L’empreinte du document signé ne correspond pas à la révision courante.");
}
String expectedTemplate = FurnishedLeaseTemplate.effectiveOn(request.lastSignatureDate()).visibleId();
if (!expectedTemplate.equals(revision.templateVersion())) {
throw new LeaseFinalizationConflict("Le modèle signé ne correspond pas à la date de dernière signature.");
}
if (!revisionSource.matchesCurrentSource(landlordId, leaseId, revision)) {
if (!revisionSource.matchesCurrentSource(landlordId, leaseId, revision, sourceNotice)) {
throw new LeaseFinalizationConflict("Le bail a changé depuis la génération de la révision signée.");
}
}
@@ -488,10 +600,7 @@ public final class LeaseFinalizationService {
landlord.phone(), landlord.address()),
new LeaseSnapshot.Apartment(apartment.id(), apartment.name(), apartment.address(),
apartment.legalProfile(), apartment.privateAreas()),
leaseTenants.stream()
.map(tenant -> new LeaseSnapshot.Tenant(tenant.id(), tenant.firstName(), tenant.lastName(),
tenant.email(), tenant.phone(), tenant.address()))
.toList(),
leaseTenants.stream().map(tenant -> new LeaseSnapshot.Tenant(tenant.id(), tenant.profile())).toList(),
lease.guarantees(), lease.period(), lease.terms(), lease.leaseType(), lease.specialClauses(),
lease.legalTerms(), lease.visaleGuarantee());
}
@@ -500,32 +609,83 @@ public final class LeaseFinalizationService {
return leases.find(landlordId, leaseId).orElseThrow(() -> new ResourceNotFound("Lease"));
}
private byte[] pdf(PdfUpload upload) {
if (upload == null) {
throw new LeaseFinalizationValidationFailure(LeaseFinalizationValidationFailure.Rule.PDF_REQUIRED);
}
byte[] content = upload.content();
if (content.length == 0) {
throw new LeaseFinalizationValidationFailure(LeaseFinalizationValidationFailure.Rule.PDF_REQUIRED);
}
if (content.length > maximumPdfSize) {
throw new AttachmentTooLarge(maximumPdfSize);
}
if (!"application/pdf".equalsIgnoreCase(upload.contentType()) || content.length < 5
|| !Arrays.equals(Arrays.copyOf(content, 5), "%PDF-".getBytes(StandardCharsets.US_ASCII))) {
throw new LeaseFinalizationValidationFailure(LeaseFinalizationValidationFailure.Rule.PDF_INVALID);
}
return content;
private ApplicationVault requireCurrentVault(LandlordId landlordId, LeaseDocumentEncryption upload) {
vaultAccess.requireLandlordUse(landlordId);
ApplicationVault vault = vaults.find(landlordId).orElseThrow(ApplicationVaultRequired::new);
if (upload == null || !vault.publicKey().equals(upload.vaultPublicKey()))
throw new LeaseFinalizationConflict("Le document ne correspond pas à la génération actuelle du coffre.");
return vault;
}
private static LeaseGuaranteeDeed sameOrConflict(LeaseGuaranteeDeed existing, String fingerprint,
private void fenceCurrentVault(LandlordId landlordId, ApplicationVault expected, LeaseDocumentEncryption upload) {
ApplicationVault current = requireCurrentVault(landlordId, upload);
if (!current.publicKey().equals(expected.publicKey()))
throw new LeaseFinalizationConflict("Le coffre a changé pendant la finalisation du bail.");
vaults.save(current);
}
private void fenceCurrentVault(LandlordId landlordId, String expectedPublicKey) {
ApplicationVault current = requireStoredVault(landlordId, expectedPublicKey);
vaults.save(current);
}
private ApplicationVault requireStoredVault(LandlordId landlordId, String expectedPublicKey) {
vaultAccess.requireLandlordUse(landlordId);
ApplicationVault vault = vaults.find(landlordId).orElseThrow(ApplicationVaultRequired::new);
if (!vault.publicKey().equals(expectedPublicKey))
throw new ApplicationVaultRequired();
return vault;
}
private void validateEncryptedDocument(LeaseDocumentEncryption upload) {
if (upload == null || upload.cryptoVersion() != 1 || upload.fileName() == null || upload.fileName().isBlank()
|| upload.nonce() == null || upload.wrappedKey() == null || upload.sha256() == null
|| upload.sha256().isBlank() || upload.ciphertext() == null || upload.vaultPublicKey() == null
|| upload.vaultPublicKey().isBlank())
throw new LeaseFinalizationValidationFailure(LeaseFinalizationValidationFailure.Rule.PDF_INVALID);
byte[] nonce = decodeEncrypted(upload.nonce(), 12, 12);
byte[] wrapped = decodeEncrypted(upload.wrappedKey(), 384, 384);
byte[] ciphertext = decodeEncrypted(upload.ciphertext(), 16, maximumPdfSize);
decodeEncrypted(upload.vaultPublicKey(), 384, 1024);
if (nonce.length != 12 || wrapped.length != 384 || isPdfHeader(ciphertext)
|| !upload.sha256().equals(sha256(ciphertext)))
throw new LeaseFinalizationValidationFailure(LeaseFinalizationValidationFailure.Rule.PDF_INVALID);
}
private byte[] decodeCiphertext(LeaseDocumentEncryption upload) {
return decodeEncrypted(upload.ciphertext(), 16, maximumPdfSize);
}
private static byte[] decodeEncrypted(String value, int minimum, long maximum) {
try {
byte[] decoded = Base64.getUrlDecoder().decode(value);
if (decoded.length < minimum || decoded.length > maximum)
throw new IllegalArgumentException();
return decoded;
} catch (IllegalArgumentException failure) {
throw new LeaseFinalizationValidationFailure(LeaseFinalizationValidationFailure.Rule.PDF_INVALID);
}
}
private static boolean isPdfHeader(byte[] content) {
return content.length >= 5
&& Arrays.equals(Arrays.copyOf(content, 5), "%PDF-".getBytes(StandardCharsets.US_ASCII));
}
private static LeaseGuaranteeDeed sameOrConflict(LeaseGuaranteeDeed existing, LeaseDocumentEncryption upload,
String contractFingerprint) {
if (!existing.sha256().equals(fingerprint) || !existing.contractFingerprint().equals(contractFingerprint)) {
if (!sameEnvelope(existing, upload) || !existing.contractFingerprint().equals(contractFingerprint)) {
throw new LeaseFinalizationConflict("Un acte différent existe déjà pour ce couple garant-locataire.");
}
return existing;
}
private static boolean sameEnvelope(LeaseGuaranteeDeed existing, LeaseDocumentEncryption upload) {
return existing.sha256().equals(upload.sha256()) && existing.vaultPublicKey().equals(upload.vaultPublicKey())
&& existing.cryptoVersion() == upload.cryptoVersion() && existing.nonce().equals(upload.nonce())
&& existing.wrappedKey().equals(upload.wrappedKey());
}
private static void requireAuthor(String authorId) {
if (authorId == null || authorId.isBlank()) {
throw new IllegalArgumentException("Author ID is required.");
@@ -546,7 +706,11 @@ public final class LeaseFinalizationService {
try {
storage.discard(storageKey);
} catch (RuntimeException cleanupFailure) {
failure.addSuppressed(cleanupFailure);
if (failure != null)
failure.addSuppressed(cleanupFailure);
else
throw new IllegalStateException("Impossible de supprimer l’artefact temporaire du bail.",
cleanupFailure);
}
}
@@ -574,7 +738,19 @@ public final class LeaseFinalizationService {
String trimmed = supplied.trim();
int separator = Math.max(trimmed.lastIndexOf('/'), trimmed.lastIndexOf('\\'));
String name = trimmed.substring(separator + 1);
return name.isBlank() ? "document.pdf" : name;
if (name.isBlank())
return "document.pdf";
StringBuilder sanitized = new StringBuilder(name.length());
for (int index = 0; index < name.length(); index++) {
char character = name.charAt(index);
sanitized.append(Character.isISOControl(character) ? '_' : character);
}
String result = sanitized.toString().trim();
if (result.isBlank())
return "document.pdf";
if (!result.toLowerCase(Locale.ROOT).endsWith(".pdf"))
result += ".pdf";
return result.length() <= 255 ? result : result.substring(0, 251) + ".pdf";
}
private static String sha256(byte[] content) {
@@ -585,26 +761,17 @@ public final class LeaseFinalizationService {
}
}
public record PdfUpload(String fileName, String contentType, byte[] content) {
public PdfUpload {
content = content == null ? new byte[0] : Arrays.copyOf(content, content.length);
}
@Override
public byte[] content() {
return Arrays.copyOf(content, content.length);
}
}
public record FinalizationRequest(int revisionNumber, LocalDate lastSignatureDate, boolean allPartiesSigned,
boolean revisionMatches, Set<String> confirmedWarningCodes) {
public record FinalizationRequest(int revisionNumber, String sourceFingerprint, LocalDate lastSignatureDate,
boolean allPartiesSigned, boolean revisionMatches, Set<String> confirmedWarningCodes) {
public FinalizationRequest {
if (sourceFingerprint == null || sourceFingerprint.isBlank())
throw new IllegalArgumentException("Source fingerprint is required.");
confirmedWarningCodes = confirmedWarningCodes == null ? Set.of() : Set.copyOf(confirmedWarningCodes);
}
}
public record GuaranteeRequirement(String guaranteeId, LeaseGuarantee.Type guaranteeType, String guaranteeName,
TenantId tenantId, String tenantName, LeaseGuaranteeDeed deed, GuaranteeDeedStatus status) {
TenantId tenantId, TenantProfile tenantProfile, LeaseGuaranteeDeed deed, GuaranteeDeedStatus status) {
}
public enum GuaranteeDeedStatus {
@@ -624,4 +791,15 @@ public final class LeaseFinalizationService {
rentDues = List.copyOf(rentDues);
}
}
public record GuaranteeDeedDownload(LeaseGuaranteeDeed deed, byte[] ciphertext) {
public GuaranteeDeedDownload {
ciphertext = ciphertext == null ? new byte[0] : Arrays.copyOf(ciphertext, ciphertext.length);
}
@Override
public byte[] ciphertext() {
return Arrays.copyOf(ciphertext, ciphertext.length);
}
}
}
@@ -1,8 +1,11 @@
package fr.nessar.lmnporganizer.rental.application.lease;
import fr.nessar.lmnporganizer.rental.application.port.out.LeaseDocumentStorage;
import fr.nessar.lmnporganizer.rental.application.shared.AttachmentTooLarge;
import fr.nessar.lmnporganizer.rental.application.rentalapplication.ApplicationVaultAccess;
import fr.nessar.lmnporganizer.rental.application.rentalapplication.ApplicationVaultRequired;
import fr.nessar.lmnporganizer.rental.application.shared.ResourceNotFound;
import fr.nessar.lmnporganizer.rental.domain.application.ApplicationVault;
import fr.nessar.lmnporganizer.rental.domain.application.ApplicationVaultRepository;
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
import fr.nessar.lmnporganizer.rental.domain.lease.Lease;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseFinalizationConflict;
@@ -16,30 +19,39 @@ import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Clock;
import java.util.Arrays;
import java.util.Base64;
import java.util.HexFormat;
import java.util.Locale;
import java.util.UUID;
/**
* Stores immutable contract versions without assessing or certifying their PDF
* contents.
* Stores immutable encrypted contract versions without assessing or certifying
* their PDF contents.
*/
public final class VisaleSuretyContractService {
private static final String TECHNICAL_FILE_NAME = "contrat-cautionnement-visale.pdf";
private final LeaseRepository leases;
private final VisaleSuretyContractRepository contracts;
private final LeaseDocumentStorage storage;
private final Clock clock;
private final long maximumPdfSize;
private final ApplicationVaultRepository vaults;
private final ApplicationVaultAccess vaultAccess;
public VisaleSuretyContractService(LeaseRepository leases, VisaleSuretyContractRepository contracts,
LeaseDocumentStorage storage, Clock clock, long maximumPdfSize) {
LeaseDocumentStorage storage, Clock clock, long maximumPdfSize, ApplicationVaultRepository vaults,
ApplicationVaultAccess vaultAccess) {
this.leases = leases;
this.contracts = contracts;
this.storage = storage;
this.clock = clock;
this.maximumPdfSize = maximumPdfSize;
this.vaults = vaults;
this.vaultAccess = vaultAccess;
}
public VisaleSuretyContract upload(LandlordId landlordId, LeaseId leaseId, Upload upload, String authorId) {
public VisaleSuretyContract upload(LandlordId landlordId, LeaseId leaseId, LeaseDocumentEncryption upload,
String authorId) {
Lease lease = lease(landlordId, leaseId);
if (lease.status() != LeaseStatus.DRAFT && lease.status() != LeaseStatus.ACTIVE) {
throw new LeaseFinalizationConflict("Ce bail ne permet plus de remplacer son contrat Visale.");
@@ -49,12 +61,19 @@ public final class VisaleSuretyContractService {
}
if (authorId == null || authorId.isBlank())
throw new IllegalArgumentException("Author required.");
byte[] content = pdf(upload);
String storageKey = storage.store(content);
validateEncryptedDocument(upload);
ApplicationVault vault = requireCurrentVault(landlordId, upload);
VisaleSuretyContract current = contracts.findCurrent(landlordId, leaseId).orElse(null);
if (current != null && current.matches(lease.visaleGuarantee()) && sameEnvelope(current, upload))
return current;
byte[] ciphertext = decodeCiphertext(upload);
String storageKey = storage.store(ciphertext);
VisaleSuretyContract contract = new VisaleSuretyContract(UUID.randomUUID().toString(), landlordId, leaseId,
lease.visaleGuarantee().visaNumber(), lease.visaleGuarantee().tenantIds(), storageKey,
safeFileName(upload.fileName()), sha256(content), authorId, clock.instant());
TECHNICAL_FILE_NAME, upload.sha256(), upload.cryptoVersion(), upload.nonce(), upload.wrappedKey(),
upload.vaultPublicKey(), authorId, clock.instant());
try {
fenceCurrentVault(landlordId, vault, upload);
return contracts.save(contract);
} catch (RuntimeException failure) {
try {
@@ -72,9 +91,11 @@ public final class VisaleSuretyContractService {
return contract;
}
private VisaleSuretyContract currentMetadata(LandlordId landlordId, Lease lease) {
/** Returns the frozen contract metadata without reading its ciphertext. */
public VisaleSuretyContract currentMetadata(LandlordId landlordId, Lease lease) {
VisaleSuretyContract contract = contracts.findCurrent(landlordId, lease.id())
.orElseThrow(() -> new LeaseFinalizationConflict("Le contrat de cautionnement Visale est requis."));
requireStoredVault(landlordId, contract.vaultPublicKey());
if (!contract.matches(lease.visaleGuarantee())) {
throw new LeaseFinalizationConflict(
"Le contrat Visale est obsolète depuis la modification de la garantie.");
@@ -89,11 +110,11 @@ public final class VisaleSuretyContractService {
}
private byte[] verifiedContent(VisaleSuretyContract contract) {
byte[] content = storage.read(contract.storageKey());
if (!contract.sha256().equals(sha256(content))) {
byte[] ciphertext = storage.read(contract.storageKey());
if (ciphertext == null || !contract.sha256().equals(sha256(ciphertext))) {
throw new LeaseFinalizationConflict("L’intégrité du contrat Visale n’est pas vérifiée.");
}
return content;
return ciphertext;
}
public State state(LandlordId landlordId, LeaseId leaseId) {
@@ -102,10 +123,13 @@ public final class VisaleSuretyContractService {
if (current.isEmpty())
return new State(Status.ABSENT, null);
VisaleSuretyContract contract = current.orElseThrow();
requireStoredVault(landlordId, contract.vaultPublicKey());
if (!contract.matches(lease.visaleGuarantee()))
return new State(Status.STALE, contract);
byte[] content = storage.read(contract.storageKey());
return new State(contract.sha256().equals(sha256(content)) ? Status.PRESENT : Status.STALE, contract);
byte[] ciphertext = storage.read(contract.storageKey());
return new State(
ciphertext != null && contract.sha256().equals(sha256(ciphertext)) ? Status.PRESENT : Status.STALE,
contract);
}
public void delete(LandlordId landlordId, LeaseId leaseId) {
@@ -123,27 +147,77 @@ public final class VisaleSuretyContractService {
return leases.find(landlordId, leaseId).orElseThrow(() -> new ResourceNotFound("Lease"));
}
private byte[] pdf(Upload upload) {
if (upload == null || upload.content() == null || upload.content().length == 0) {
throw new LeaseFinalizationValidationFailure(LeaseFinalizationValidationFailure.Rule.PDF_REQUIRED);
}
byte[] content = upload.content();
if (content.length > maximumPdfSize)
throw new AttachmentTooLarge(maximumPdfSize);
if (!"application/pdf".equalsIgnoreCase(upload.contentType()) || content.length < 5
|| !Arrays.equals(Arrays.copyOf(content, 5), "%PDF-".getBytes(StandardCharsets.US_ASCII))) {
throw new LeaseFinalizationValidationFailure(LeaseFinalizationValidationFailure.Rule.PDF_INVALID);
}
return content;
private ApplicationVault requireCurrentVault(LandlordId landlordId, LeaseDocumentEncryption upload) {
if (vaults == null || vaultAccess == null)
throw new IllegalStateException("Visale contract encryption is not configured.");
vaultAccess.requireLandlordUse(landlordId);
ApplicationVault vault = vaults.find(landlordId).orElseThrow(ApplicationVaultRequired::new);
if (!vault.publicKey().equals(upload.vaultPublicKey()))
throw new LeaseFinalizationConflict(
"Le contrat Visale ne correspond pas à la génération actuelle du coffre.");
return vault;
}
private static String safeFileName(String supplied) {
if (supplied == null || supplied.isBlank())
return "contrat-cautionnement-visale.pdf";
String value = supplied.trim();
int separator = Math.max(value.lastIndexOf('/'), value.lastIndexOf('\\'));
String name = value.substring(separator + 1);
return name.isBlank() ? "contrat-cautionnement-visale.pdf" : name;
private void fenceCurrentVault(LandlordId landlordId, ApplicationVault expected, LeaseDocumentEncryption upload) {
ApplicationVault current = requireCurrentVault(landlordId, upload);
if (!current.publicKey().equals(expected.publicKey()))
throw new LeaseFinalizationConflict("Le coffre a changé pendant l’enregistrement du contrat Visale.");
vaults.save(current);
}
private void requireStoredVault(LandlordId landlordId, String publicKey) {
if (vaults == null || vaultAccess == null)
throw new IllegalStateException("Visale contract encryption is not configured.");
vaultAccess.requireLandlordUse(landlordId);
if (vaults.find(landlordId).filter(vault -> vault.publicKey().equals(publicKey)).isEmpty())
throw new ApplicationVaultRequired();
}
private void validateEncryptedDocument(LeaseDocumentEncryption upload) {
if (upload == null || upload.cryptoVersion() != 1 || upload.fileName() == null || upload.fileName().isBlank()
|| upload.nonce() == null || upload.wrappedKey() == null || upload.sha256() == null
|| upload.sha256().isBlank() || upload.ciphertext() == null || upload.vaultPublicKey() == null
|| upload.vaultPublicKey().isBlank())
throw new LeaseFinalizationValidationFailure(LeaseFinalizationValidationFailure.Rule.PDF_INVALID);
if (!TECHNICAL_FILE_NAME.equals(upload.fileName()))
throw new LeaseFinalizationValidationFailure(LeaseFinalizationValidationFailure.Rule.PDF_INVALID);
decode(upload.nonce(), 12, 12);
decode(upload.wrappedKey(), 384, 384);
byte[] ciphertext = decode(upload.ciphertext(), 16, maximumPdfSize);
decode(upload.vaultPublicKey(), 384, 1024);
if (!isDigest(upload.sha256()) || !upload.sha256().equals(sha256(ciphertext)) || isPdfHeader(ciphertext))
throw new LeaseFinalizationValidationFailure(LeaseFinalizationValidationFailure.Rule.PDF_INVALID);
}
private byte[] decodeCiphertext(LeaseDocumentEncryption upload) {
return decode(upload.ciphertext(), 16, maximumPdfSize);
}
private static byte[] decode(String value, int minimum, long maximum) {
try {
byte[] decoded = Base64.getUrlDecoder().decode(value);
if (decoded.length < minimum || decoded.length > maximum)
throw new IllegalArgumentException();
return decoded;
} catch (IllegalArgumentException failure) {
throw new LeaseFinalizationValidationFailure(LeaseFinalizationValidationFailure.Rule.PDF_INVALID);
}
}
private static boolean isDigest(String value) {
return value.length() == 64 && value.equals(value.toLowerCase(Locale.ROOT))
&& value.chars().allMatch(character -> Character.digit(character, 16) >= 0);
}
private static boolean isPdfHeader(byte[] content) {
return content.length >= 5
&& Arrays.equals(Arrays.copyOf(content, 5), "%PDF-".getBytes(StandardCharsets.US_ASCII));
}
private static boolean sameEnvelope(VisaleSuretyContract existing, LeaseDocumentEncryption upload) {
return existing.sha256().equals(upload.sha256()) && existing.vaultPublicKey().equals(upload.vaultPublicKey())
&& existing.cryptoVersion() == upload.cryptoVersion() && existing.nonce().equals(upload.nonce())
&& existing.wrappedKey().equals(upload.wrappedKey());
}
private static String sha256(byte[] content) {
@@ -154,10 +228,15 @@ public final class VisaleSuretyContractService {
}
}
public record Upload(String fileName, String contentType, byte[] content) {
}
public record Download(VisaleSuretyContract contract, byte[] ciphertext) {
public Download {
ciphertext = ciphertext == null ? new byte[0] : Arrays.copyOf(ciphertext, ciphertext.length);
}
public record Download(VisaleSuretyContract contract, byte[] content) {
@Override
public byte[] ciphertext() {
return Arrays.copyOf(ciphertext, ciphertext.length);
}
}
public record State(Status status, VisaleSuretyContract contract) {
@@ -1,10 +1,15 @@
package fr.nessar.lmnporganizer.rental.application.lease;
import fr.nessar.lmnporganizer.rental.application.port.out.LeaseDocumentStorage;
import fr.nessar.lmnporganizer.rental.application.rentalapplication.ApplicationVaultAccess;
import fr.nessar.lmnporganizer.rental.application.rentalapplication.ApplicationVaultRequired;
import fr.nessar.lmnporganizer.rental.application.shared.AttachmentStorageFailure;
import fr.nessar.lmnporganizer.rental.application.shared.ResourceNotFound;
import fr.nessar.lmnporganizer.rental.domain.application.ApplicationVault;
import fr.nessar.lmnporganizer.rental.domain.application.ApplicationVaultRepository;
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
import fr.nessar.lmnporganizer.rental.domain.lease.Lease;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseFinalizationConflict;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseId;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseRepository;
import fr.nessar.lmnporganizer.rental.domain.lease.VisaleVisa;
@@ -12,7 +17,10 @@ import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Clock;
import java.util.Arrays;
import java.util.Base64;
import java.util.HexFormat;
import java.util.Locale;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -21,26 +29,38 @@ import org.slf4j.LoggerFactory;
* validity.
*/
public final class VisaleVisaService {
private static final String TECHNICAL_FILE_NAME = "visa-visale.pdf";
private static final Logger LOGGER = LoggerFactory.getLogger(VisaleVisaService.class);
private final LeaseRepository leases;
private final LeaseDocumentStorage storage;
private final Clock clock;
private final long maximumPdfSize;
private final ApplicationVaultRepository vaults;
private final ApplicationVaultAccess vaultAccess;
public VisaleVisaService(LeaseRepository leases, LeaseDocumentStorage storage, Clock clock, long maximumPdfSize) {
public VisaleVisaService(LeaseRepository leases, LeaseDocumentStorage storage, Clock clock, long maximumPdfSize,
ApplicationVaultRepository vaults, ApplicationVaultAccess vaultAccess) {
this.leases = leases;
this.storage = storage;
this.clock = clock;
this.maximumPdfSize = maximumPdfSize;
this.vaults = vaults;
this.vaultAccess = vaultAccess;
}
public VisaleVisa upload(LandlordId landlordId, LeaseId leaseId, Upload upload, String authorId) {
byte[] content = pdf(upload);
public VisaleVisa upload(LandlordId landlordId, LeaseId leaseId, LeaseDocumentEncryption upload, String authorId) {
if (authorId == null || authorId.isBlank())
throw new IllegalArgumentException("Author required.");
validateEncryptedDocument(upload);
ApplicationVault vault = requireCurrentVault(landlordId, upload);
Lease lease = lease(landlordId, leaseId);
VisaleVisa previous = lease.visaleVisa();
String key = storage.store(content);
VisaleVisa visa = new VisaleVisa(key, safeFileName(upload.fileName()), sha256(content), authorId,
clock.instant());
if (previous != null && sameEnvelope(previous, upload))
return previous;
byte[] ciphertext = decodeCiphertext(upload);
String key = storage.store(ciphertext);
VisaleVisa visa = new VisaleVisa(key, TECHNICAL_FILE_NAME, upload.sha256(), upload.cryptoVersion(),
upload.nonce(), upload.wrappedKey(), upload.vaultPublicKey(), authorId, clock.instant());
LeaseDocumentStorage.StagedDeletion deletion = null;
if (previous != null) {
try {
@@ -54,6 +74,7 @@ public final class VisaleVisaService {
try {
lease.attachVisaleVisa(visa);
attached = true;
fenceCurrentVault(landlordId, vault, upload);
leases.save(lease);
} catch (RuntimeException failure) {
if (attached) {
@@ -73,11 +94,9 @@ public final class VisaleVisaService {
public Download download(LandlordId landlordId, LeaseId leaseId) {
VisaleVisa visa = visa(lease(landlordId, leaseId));
byte[] content = storage.read(visa.storageKey());
if (!visa.sha256().equals(sha256(content))) {
throw new AttachmentStorageFailure("Visale visa integrity check failed.", null);
}
return new Download(visa, content);
requireStoredVault(landlordId, visa.vaultPublicKey());
byte[] ciphertext = verifiedContent(visa);
return new Download(visa, ciphertext);
}
public void delete(LandlordId landlordId, LeaseId leaseId) {
@@ -134,20 +153,84 @@ public final class VisaleVisaService {
return lease.visaleVisa();
}
private byte[] pdf(Upload upload) {
if (upload == null || upload.content() == null || upload.content().length == 0) {
throw new LeaseFinalizationValidationFailure(LeaseFinalizationValidationFailure.Rule.PDF_REQUIRED);
private byte[] verifiedContent(VisaleVisa visa) {
byte[] ciphertext = storage.read(visa.storageKey());
if (ciphertext == null || !visa.sha256().equals(sha256(ciphertext))) {
throw new AttachmentStorageFailure("Visale visa integrity check failed.", null);
}
if (upload.content().length > maximumPdfSize || !"application/pdf".equalsIgnoreCase(upload.contentType())
|| !new String(upload.content(), 0, Math.min(5, upload.content().length), StandardCharsets.US_ASCII)
.equals("%PDF-")) {
throw new LeaseFinalizationValidationFailure(LeaseFinalizationValidationFailure.Rule.PDF_INVALID);
}
return upload.content();
return ciphertext;
}
private static String safeFileName(String fileName) {
return fileName == null || fileName.isBlank() ? "visa-visale.pdf" : fileName.trim();
private ApplicationVault requireCurrentVault(LandlordId landlordId, LeaseDocumentEncryption upload) {
if (vaults == null || vaultAccess == null)
throw new IllegalStateException("Visale visa encryption is not configured.");
vaultAccess.requireLandlordUse(landlordId);
ApplicationVault vault = vaults.find(landlordId).orElseThrow(ApplicationVaultRequired::new);
if (!vault.publicKey().equals(upload.vaultPublicKey()))
throw new LeaseFinalizationConflict("Le visa ne correspond pas à la génération actuelle du coffre.");
return vault;
}
private void fenceCurrentVault(LandlordId landlordId, ApplicationVault expected, LeaseDocumentEncryption upload) {
ApplicationVault current = requireCurrentVault(landlordId, upload);
if (!current.publicKey().equals(expected.publicKey()))
throw new LeaseFinalizationConflict("Le coffre a changé pendant l’enregistrement du visa.");
vaults.save(current);
}
private void requireStoredVault(LandlordId landlordId, String publicKey) {
if (vaults == null || vaultAccess == null)
throw new IllegalStateException("Visale visa encryption is not configured.");
vaultAccess.requireLandlordUse(landlordId);
if (vaults.find(landlordId).filter(vault -> vault.publicKey().equals(publicKey)).isEmpty())
throw new ApplicationVaultRequired();
}
private void validateEncryptedDocument(LeaseDocumentEncryption upload) {
if (upload == null || upload.cryptoVersion() != 1 || upload.fileName() == null || upload.fileName().isBlank()
|| upload.nonce() == null || upload.wrappedKey() == null || upload.sha256() == null
|| upload.sha256().isBlank() || upload.ciphertext() == null || upload.vaultPublicKey() == null
|| upload.vaultPublicKey().isBlank())
throw new LeaseFinalizationValidationFailure(LeaseFinalizationValidationFailure.Rule.PDF_INVALID);
if (!TECHNICAL_FILE_NAME.equals(upload.fileName()))
throw new LeaseFinalizationValidationFailure(LeaseFinalizationValidationFailure.Rule.PDF_INVALID);
decode(upload.nonce(), 12, 12);
decode(upload.wrappedKey(), 384, 384);
byte[] ciphertext = decode(upload.ciphertext(), 16, maximumPdfSize);
decode(upload.vaultPublicKey(), 384, 1024);
if (!isDigest(upload.sha256()) || !upload.sha256().equals(sha256(ciphertext)) || isPdfHeader(ciphertext))
throw new LeaseFinalizationValidationFailure(LeaseFinalizationValidationFailure.Rule.PDF_INVALID);
}
private byte[] decodeCiphertext(LeaseDocumentEncryption upload) {
return decode(upload.ciphertext(), 16, maximumPdfSize);
}
private static byte[] decode(String value, int minimum, long maximum) {
try {
byte[] decoded = Base64.getUrlDecoder().decode(value);
if (decoded.length < minimum || decoded.length > maximum)
throw new IllegalArgumentException();
return decoded;
} catch (IllegalArgumentException failure) {
throw new LeaseFinalizationValidationFailure(LeaseFinalizationValidationFailure.Rule.PDF_INVALID);
}
}
private static boolean isDigest(String value) {
return value.length() == 64 && value.equals(value.toLowerCase(Locale.ROOT))
&& value.chars().allMatch(character -> Character.digit(character, 16) >= 0);
}
private static boolean isPdfHeader(byte[] content) {
return content.length >= 5
&& Arrays.equals(Arrays.copyOf(content, 5), "%PDF-".getBytes(StandardCharsets.US_ASCII));
}
private static boolean sameEnvelope(VisaleVisa existing, LeaseDocumentEncryption upload) {
return existing.sha256().equals(upload.sha256()) && existing.vaultPublicKey().equals(upload.vaultPublicKey())
&& existing.cryptoVersion() == upload.cryptoVersion() && existing.nonce().equals(upload.nonce())
&& existing.wrappedKey().equals(upload.wrappedKey());
}
private static String sha256(byte[] content) {
@@ -158,9 +241,14 @@ public final class VisaleVisaService {
}
}
public record Upload(String fileName, String contentType, byte[] content) {
}
public record Download(VisaleVisa visa, byte[] ciphertext) {
public Download {
ciphertext = ciphertext == null ? new byte[0] : Arrays.copyOf(ciphertext, ciphertext.length);
}
public record Download(VisaleVisa visa, byte[] content) {
@Override
public byte[] ciphertext() {
return Arrays.copyOf(ciphertext, ciphertext.length);
}
}
}
@@ -9,5 +9,7 @@ public interface ApplicationDocumentStorage {
InputStream read(RentalApplicationId applicationId, EncryptedApplicationDocument document);
long retainedSize(String storageKey);
void discard(RentalApplicationId applicationId, EncryptedApplicationDocument document);
}
@@ -1,7 +0,0 @@
package fr.nessar.lmnporganizer.rental.application.port.out;
import fr.nessar.lmnporganizer.rental.domain.condition.PropertyConditionReport;
public interface PropertyConditionReportRenderer {
byte[] render(PropertyConditionReport report, int revisionNumber, String footer);
}
@@ -1,7 +1,6 @@
package fr.nessar.lmnporganizer.rental.application.port.out;
import fr.nessar.lmnporganizer.rental.domain.furniture.FurnitureInventorySnapshot;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseContractVersion;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseGuarantee;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseId;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseLegalTerms;
@@ -10,27 +9,12 @@ import fr.nessar.lmnporganizer.rental.domain.lease.LeaseSpecialClause;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseType;
import fr.nessar.lmnporganizer.rental.domain.lease.RentTerms;
import fr.nessar.lmnporganizer.rental.domain.lease.RentalPeriod;
import fr.nessar.lmnporganizer.rental.domain.rent.PaymentAllocationId;
import fr.nessar.lmnporganizer.rental.domain.rent.RentDueId;
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentId;
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentRefundId;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.YearMonth;
import java.util.List;
import java.util.Objects;
public interface RentalDocumentRenderer {
byte[] renderLease(LeaseDocument document);
byte[] renderInformationNotice(InformationNotice document);
byte[] renderPaymentReceipt(PaymentReceipt document);
byte[] renderRentReceipt(RentReceipt document);
byte[] renderRentRevisionRequest(RentRevisionRequest document);
record InformationNotice(String text) {
public InformationNotice {
if (text == null || text.isBlank()) {
@@ -57,61 +41,4 @@ public interface RentalDocumentRenderer {
}
}
record PaymentReceipt(RentPaymentId paymentId, LocalDate receivedAt, LocalDate confirmedAt, BigDecimal amount,
BigDecimal refundedAmount, BigDecimal unallocatedBalance, LeaseContractVersion contract,
List<ReceiptAllocation> allocations, List<ReceiptRefund> refunds) {
public PaymentReceipt {
allocations = List.copyOf(allocations);
refunds = List.copyOf(refunds);
}
}
record ReceiptAllocation(PaymentAllocationId allocationId, RentDueId rentDueId, YearMonth month, BigDecimal amount,
LocalDate allocatedAt) {
}
record ReceiptRefund(RentPaymentRefundId refundId, BigDecimal amount, LocalDate refundedAt) {
}
record RentReceipt(RentDueId rentDueId, YearMonth month, BigDecimal rent, BigDecimal charges, BigDecimal total,
LocalDate issuedAt, LeaseContractVersion contract) {
}
record RentRevisionRequest(LeaseSnapshot.Landlord landlord, List<LeaseSnapshot.Tenant> tenants,
LeaseSnapshot.Apartment apartment, LocalDate requestedOn, LocalDate effectiveOn, String seriesId,
int referenceQuarter, int previousIndexYear, int newIndexYear, BigDecimal previousIndex,
BigDecimal newIndex, BigDecimal previousRent, BigDecimal rentCeiling, BigDecimal chosenRent,
FlatRateRevision flatRateRevision) {
public RentRevisionRequest {
landlord = Objects.requireNonNull(landlord, "Landlord is required.");
tenants = List.copyOf(tenants);
if (tenants.isEmpty()) {
throw new IllegalArgumentException("At least one tenant is required.");
}
apartment = Objects.requireNonNull(apartment, "Apartment is required.");
requestedOn = Objects.requireNonNull(requestedOn, "Request date is required.");
effectiveOn = Objects.requireNonNull(effectiveOn, "Effective date is required.");
if (seriesId == null || seriesId.isBlank()) {
throw new IllegalArgumentException("IRL series ID is required.");
}
seriesId = seriesId.trim();
if (referenceQuarter < 1 || referenceQuarter > 4 || previousIndexYear < 1 || newIndexYear < 1
|| newIndexYear != previousIndexYear + 1) {
throw new IllegalArgumentException("IRL reference period is invalid.");
}
previousIndex = Objects.requireNonNull(previousIndex, "Previous IRL index is required.");
newIndex = Objects.requireNonNull(newIndex, "New IRL index is required.");
previousRent = Objects.requireNonNull(previousRent, "Previous rent is required.");
rentCeiling = Objects.requireNonNull(rentCeiling, "Rent ceiling is required.");
chosenRent = Objects.requireNonNull(chosenRent, "Chosen rent is required.");
}
}
record FlatRateRevision(BigDecimal previousAmount, BigDecimal ceiling, BigDecimal chosenAmount) {
public FlatRateRevision {
previousAmount = Objects.requireNonNull(previousAmount, "Previous flat rate is required.");
ceiling = Objects.requireNonNull(ceiling, "Flat rate ceiling is required.");
chosenAmount = Objects.requireNonNull(chosenAmount, "Chosen flat rate is required.");
}
}
}
@@ -1,12 +1,15 @@
package fr.nessar.lmnporganizer.rental.application.port.out;
import fr.nessar.lmnporganizer.rental.domain.tenant.TenantAttachment;
import fr.nessar.lmnporganizer.rental.domain.tenant.TenantEvidenceRetention;
import fr.nessar.lmnporganizer.rental.domain.tenant.TenantId;
import fr.nessar.lmnporganizer.rental.domain.tenant.TenantProfile;
import java.io.InputStream;
import java.time.LocalDate;
import java.util.Objects;
public interface TenantAttachmentStorage {
TenantAttachment store(TenantId tenantId, String attachmentId, Upload upload);
TenantAttachment store(TenantId tenantId, Upload upload);
InputStream read(TenantAttachment attachment);
@@ -23,11 +26,32 @@ public interface TenantAttachmentStorage {
interface StagedDeletion {
}
record Upload(String fileName, String contentType, long size, InputStream content) {
record Upload(String id, int cryptoVersion, String nonce, String wrappedKey, String sha256, long ciphertextSize,
String vaultPublicKey, InputStream content, TenantEvidenceRetention.EvidencePurpose purpose,
TenantEvidenceRetention.RetentionRule retentionRule, String specificNeedVaultPublicKey,
TenantProfile.EncryptedGroup encryptedSpecificNeedExplanation, LocalDate specificNeedEndsOn) {
public Upload {
Objects.requireNonNull(id, "Attachment ID is required.");
Objects.requireNonNull(nonce, "Attachment nonce is required.");
Objects.requireNonNull(wrappedKey, "Attachment wrapped key is required.");
Objects.requireNonNull(sha256, "Attachment SHA-256 is required.");
Objects.requireNonNull(vaultPublicKey, "Attachment vault public key is required.");
Objects.requireNonNull(content, "Attachment content is required.");
if (size < 0) {
throw new IllegalArgumentException("Attachment size cannot be negative.");
Objects.requireNonNull(purpose, "Attachment retention purpose is required.");
Objects.requireNonNull(retentionRule, "Attachment retention rule is required.");
if (retentionRule == TenantEvidenceRetention.RetentionRule.SPECIFIC_NEED
&& (specificNeedVaultPublicKey == null || encryptedSpecificNeedExplanation == null
|| specificNeedEndsOn == null)) {
throw new IllegalArgumentException(
"A specific retention need must remain encrypted and have a finite end date.");
}
if (retentionRule != TenantEvidenceRetention.RetentionRule.SPECIFIC_NEED
&& (specificNeedVaultPublicKey != null || encryptedSpecificNeedExplanation != null
|| specificNeedEndsOn != null)) {
throw new IllegalArgumentException("Specific retention metadata requires the specific-need rule.");
}
if (ciphertextSize < 0) {
throw new IllegalArgumentException("Attachment ciphertext size cannot be negative.");
}
}
}
@@ -1,10 +1,5 @@
package fr.nessar.lmnporganizer.rental.application.rent;
import fr.nessar.lmnporganizer.rental.application.port.out.RentalDocumentRenderer;
import fr.nessar.lmnporganizer.rental.application.port.out.RentalDocumentRenderer.PaymentReceipt;
import fr.nessar.lmnporganizer.rental.application.port.out.RentalDocumentRenderer.ReceiptAllocation;
import fr.nessar.lmnporganizer.rental.application.port.out.RentalDocumentRenderer.ReceiptRefund;
import fr.nessar.lmnporganizer.rental.application.shared.GeneratedDocument;
import fr.nessar.lmnporganizer.rental.application.shared.ResourceNotFound;
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
import fr.nessar.lmnporganizer.rental.domain.lease.Lease;
@@ -25,18 +20,16 @@ public final class GeneratePaymentReceiptService {
private final RentDueRepository rentDues;
private final RentPaymentRefundRepository refunds;
private final LeaseRepository leases;
private final RentalDocumentRenderer renderer;
public GeneratePaymentReceiptService(RentPaymentRepository payments, RentDueRepository rentDues,
RentPaymentRefundRepository refunds, LeaseRepository leases, RentalDocumentRenderer renderer) {
RentPaymentRefundRepository refunds, LeaseRepository leases) {
this.payments = payments;
this.rentDues = rentDues;
this.refunds = refunds;
this.leases = leases;
this.renderer = renderer;
}
public GeneratedDocument generate(LandlordId landlordId, RentPaymentId paymentId) {
public RentalDocumentData.PaymentReceipt generate(LandlordId landlordId, RentPaymentId paymentId) {
RentPayment payment = payments.find(landlordId, paymentId)
.orElseThrow(() -> new ResourceNotFound("Rent payment"));
if (payment.status() != RentPaymentStatus.CONFIRMED) {
@@ -44,19 +37,20 @@ public final class GeneratePaymentReceiptService {
}
Lease lease = leases.find(landlordId, payment.leaseId()).orElseThrow(() -> new ResourceNotFound("Lease"));
LeaseContractVersion contract = lease.contractVersionOn(payment.receivedAt());
PaymentReceipt receipt = new PaymentReceipt(payment.id(), payment.receivedAt(), payment.confirmedAt(),
payment.amount(), payment.refundedAmount(), payment.unallocatedBalance(), contract,
return new RentalDocumentData.PaymentReceipt("recu-reglement-" + payment.id().value() + ".pdf",
payment.leaseId(), payment.id(), payment.receivedAt(), payment.confirmedAt(), payment.amount(),
payment.refundedAmount(), payment.unallocatedBalance(), contract,
payment.allocations().stream().map(allocation -> allocation(allocation, landlordId)).toList(),
refunds.findAll(landlordId, paymentId).stream()
.map(refund -> new ReceiptRefund(refund.id(), refund.amount(), refund.refundedAt())).toList());
return new GeneratedDocument("recu-reglement-" + payment.id().value() + ".pdf", "application/pdf",
renderer.renderPaymentReceipt(receipt));
.map(refund -> new RentalDocumentData.ReceiptRefund(refund.id(), refund.amount(),
refund.refundedAt()))
.toList());
}
private ReceiptAllocation allocation(PaymentAllocation allocation, LandlordId landlordId) {
private RentalDocumentData.ReceiptAllocation allocation(PaymentAllocation allocation, LandlordId landlordId) {
RentDue due = rentDues.find(landlordId, allocation.rentDueId())
.orElseThrow(() -> new ResourceNotFound("Rent due"));
return new ReceiptAllocation(allocation.id(), due.id(), due.month(), allocation.amount(),
return new RentalDocumentData.ReceiptAllocation(allocation.id(), due.id(), due.month(), allocation.amount(),
allocation.allocatedAt());
}
}
@@ -1,8 +1,5 @@
package fr.nessar.lmnporganizer.rental.application.rent;
import fr.nessar.lmnporganizer.rental.application.port.out.RentalDocumentRenderer;
import fr.nessar.lmnporganizer.rental.application.port.out.RentalDocumentRenderer.RentReceipt;
import fr.nessar.lmnporganizer.rental.application.shared.GeneratedDocument;
import fr.nessar.lmnporganizer.rental.application.shared.ResourceNotFound;
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
import fr.nessar.lmnporganizer.rental.domain.lease.Lease;
@@ -19,18 +16,15 @@ import java.time.LocalDate;
public final class GenerateRentReceiptService {
private final RentDueRepository rentDues;
private final LeaseRepository leases;
private final RentalDocumentRenderer renderer;
private final Clock clock;
public GenerateRentReceiptService(RentDueRepository rentDues, LeaseRepository leases,
RentalDocumentRenderer renderer, Clock clock) {
public GenerateRentReceiptService(RentDueRepository rentDues, LeaseRepository leases, Clock clock) {
this.rentDues = rentDues;
this.leases = leases;
this.renderer = renderer;
this.clock = clock;
}
public GeneratedDocument generate(LandlordId landlordId, RentDueId rentDueId) {
public RentalDocumentData.RentReceipt generate(LandlordId landlordId, RentDueId rentDueId) {
RentDue due = rentDues.find(landlordId, rentDueId).orElseThrow(() -> new ResourceNotFound("Rent due"));
if (due.settlement() != RentDueSettlement.PAID) {
throw new RentRuleViolation("Une quittance ne peut être émise que pour une échéance entièrement réglée.");
@@ -45,9 +39,7 @@ public final class GenerateRentReceiptService {
}
Lease lease = leases.find(landlordId, due.leaseId()).orElseThrow(() -> new ResourceNotFound("Lease"));
LeaseContractVersion contract = lease.contractVersionOn(due.month().atEndOfMonth());
RentReceipt receipt = new RentReceipt(due.id(), due.month(), due.rent(), due.charges(), due.total(),
LocalDate.now(clock), contract);
return new GeneratedDocument("quittance-" + due.month() + ".pdf", "application/pdf",
renderer.renderRentReceipt(receipt));
return new RentalDocumentData.RentReceipt("quittance-" + due.month() + ".pdf", due.leaseId(), due.id(),
due.month(), due.rent(), due.charges(), due.total(), LocalDate.now(clock), contract);
}
}
@@ -4,15 +4,19 @@ import fr.nessar.lmnporganizer.rental.application.port.out.IdentifierGenerator;
import fr.nessar.lmnporganizer.rental.application.port.out.FiscalYearClosureQuery;
import fr.nessar.lmnporganizer.rental.application.port.out.IrlIndexProvider;
import fr.nessar.lmnporganizer.rental.application.port.out.LeaseDocumentStorage;
import fr.nessar.lmnporganizer.rental.application.port.out.RentalDocumentRenderer;
import fr.nessar.lmnporganizer.rental.application.port.out.TransactionRunner;
import fr.nessar.lmnporganizer.rental.application.shared.AttachmentStorageFailure;
import fr.nessar.lmnporganizer.rental.application.shared.AttachmentTooLarge;
import fr.nessar.lmnporganizer.rental.application.shared.IrlIndexUnavailable;
import fr.nessar.lmnporganizer.rental.application.shared.ResourceNotFound;
import fr.nessar.lmnporganizer.rental.application.rentalapplication.ApplicationVaultAccess;
import fr.nessar.lmnporganizer.rental.application.rentalapplication.ApplicationVaultRequired;
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
import fr.nessar.lmnporganizer.rental.domain.application.ApplicationCryptoValue;
import fr.nessar.lmnporganizer.rental.domain.application.ApplicationVault;
import fr.nessar.lmnporganizer.rental.domain.application.ApplicationVaultRepository;
import fr.nessar.lmnporganizer.rental.domain.lease.Lease;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseFinalizationWarning;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseContractVersion;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseId;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseLegalTerms;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseRepository;
@@ -39,7 +43,7 @@ import java.time.LocalDate;
import java.time.YearMonth;
import java.time.ZoneId;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Base64;
import java.util.Comparator;
import java.util.HexFormat;
import java.util.LinkedHashSet;
@@ -54,7 +58,8 @@ public final class RentRevisionService {
private final IrlIndexProvider indices;
private final RentRevisionRepository revisions;
private final RentDueRepository rentDues;
private final RentalDocumentRenderer renderer;
private final ApplicationVaultRepository vaults;
private final ApplicationVaultAccess vaultAccess;
private final LeaseDocumentStorage storage;
private final TransactionRunner transactions;
private final IdentifierGenerator identifiers;
@@ -62,20 +67,16 @@ public final class RentRevisionService {
private final long maximumPdfSize;
private final FiscalYearClosureQuery fiscalYearClosures;
public RentRevisionService(LeaseRepository leases, IrlIndexProvider indices, Clock clock,
FiscalYearClosureQuery fiscalYearClosures) {
this(leases, indices, null, null, null, null, null, null, clock, 1, fiscalYearClosures);
}
public RentRevisionService(LeaseRepository leases, IrlIndexProvider indices, RentRevisionRepository revisions,
RentDueRepository rentDues, RentalDocumentRenderer renderer, LeaseDocumentStorage storage,
TransactionRunner transactions, IdentifierGenerator identifiers, Clock clock, long maximumPdfSize,
FiscalYearClosureQuery fiscalYearClosures) {
RentDueRepository rentDues, ApplicationVaultRepository vaults, ApplicationVaultAccess vaultAccess,
LeaseDocumentStorage storage, TransactionRunner transactions, IdentifierGenerator identifiers, Clock clock,
long maximumPdfSize, FiscalYearClosureQuery fiscalYearClosures) {
this.leases = leases;
this.indices = indices;
this.revisions = revisions;
this.rentDues = rentDues;
this.renderer = renderer;
this.vaults = vaults;
this.vaultAccess = vaultAccess;
this.storage = storage;
this.transactions = transactions;
this.identifiers = identifiers;
@@ -87,7 +88,12 @@ public final class RentRevisionService {
this.maximumPdfSize = maximumPdfSize;
}
public RentRevision propose(LandlordId landlordId, LeaseId leaseId, ProposalRequest request, String authorId) {
/**
* Recomputes the authoritative facts needed by the browser before it renders a
* letter. Nothing is persisted by this operation.
*/
public ProposalPreparation prepare(LandlordId landlordId, LeaseId leaseId, ProposalRequest request,
String authorId) {
requireProposalDependencies();
Objects.requireNonNull(request, "Rent revision proposal request is required.");
String author = required(authorId, "Proposal author");
@@ -114,8 +120,8 @@ public final class RentRevisionService {
List<RentRevision.LegalOverride> overrides = overrides(currentWarnings, request.confirmedWarningCodes(), author,
now);
var contract = lease.contractVersionOn(request.requestedOn());
LocalDate effectiveOn = effectiveDate(rentDues.findAll(landlordId, leaseId), request.requestedOn(),
currentTerms.dueDay());
List<RentDue> currentDues = rentDues.findAll(landlordId, leaseId);
LocalDate effectiveOn = effectiveDate(currentDues, request.requestedOn(), currentTerms.dueDay());
rejectFinancialChangeBeforeEffect(lease, request.requestedOn(), effectiveOn);
rejectAfterLeaseEnd(lease, effectiveOn);
RentRevision.IndexReference indexReference = new RentRevision.IndexReference(preview.seriesId(),
@@ -123,35 +129,68 @@ public final class RentRevisionService {
RentRevision.IndexSource.valueOf(preview.indexSource()), "MANUAL".equals(preview.indexSource()),
preview.referenceQuarter(), preview.previousIndexYear(), preview.newIndexYear(),
preview.previousIndex(), preview.newIndex());
RentRevision existingProposal = revisions
.findAll(landlordId, leaseId).stream().filter(revision -> sameProposal(revision, preview,
request.requestedOn(), effectiveOn, indexReference, amounts, overrides))
RentalDocumentData.FlatRateRevision flatRate = chargeCeiling == null
? null
: new RentalDocumentData.FlatRateRevision(currentTerms.monthlyCharges(), chargeCeiling,
request.chosenCharges());
RentalDocumentData.RentRevision document = new RentalDocumentData.RentRevision(
"revision-loyer-" + leaseId.value() + "-" + request.requestedOn() + ".pdf", leaseId, contract,
request.requestedOn(), effectiveOn, preview.seriesId(), preview.referenceQuarter(),
preview.previousIndexYear(), preview.newIndexYear(), preview.previousIndex(), preview.newIndex(),
currentTerms.monthlyRent(), rentCeiling, request.chosenRent(), flatRate);
String sourceFingerprint = sourceFingerprint(lease, document, amounts, indexReference, overrides);
return new ProposalPreparation(preview, lease.version(), sourceFingerprint, contract, effectiveOn,
indexReference, amounts, overrides, document);
}
public RentRevision propose(LandlordId landlordId, LeaseId leaseId, ProposalRequest request,
EncryptedDocument encryptedLetter, String authorId) {
if (request == null || request.sourceFingerprint() == null || request.sourceFingerprint().isBlank()) {
throw new RentRevisionValidationFailure(RentRevisionValidationFailure.Rule.INVALID_SOURCE_FINGERPRINT);
}
ProposalPreparation preparation = prepare(landlordId, leaseId, request, authorId);
requireCurrentVault(landlordId, encryptedLetter, RentRevisionValidationFailure.Rule.INVALID_LETTER);
byte[] letter = encryptedPdf(encryptedLetter);
RentRevision existingProposal = revisions.findAll(landlordId, leaseId).stream()
.filter(revision -> sameProposal(revision, preparation.preview(), request.requestedOn(),
preparation.effectiveOn(), preparation.indexReference(), preparation.amounts(),
preparation.overrides(), preparation, request.sourceFingerprint(),
encryptedLetter.vaultPublicKey()))
.findFirst().orElse(null);
if (existingProposal != null) {
return existingProposal;
}
RentalDocumentRenderer.FlatRateRevision flatRate = chargeCeiling == null
? null
: new RentalDocumentRenderer.FlatRateRevision(currentTerms.monthlyCharges(), chargeCeiling,
request.chosenCharges());
byte[] letter = renderer.renderRentRevisionRequest(new RentalDocumentRenderer.RentRevisionRequest(
contract.landlord(), contract.tenants(), contract.apartment(), request.requestedOn(), effectiveOn,
preview.seriesId(), preview.referenceQuarter(), preview.previousIndexYear(), preview.newIndexYear(),
preview.previousIndex(), preview.newIndex(), currentTerms.monthlyRent(), rentCeiling,
request.chosenRent(), flatRate));
if (!request.sourceFingerprint().equals(preparation.sourceFingerprint())) {
throw new RentRevisionConflict(
"Le bail ou l'état des échéances a changé depuis la préparation ; régénérez la lettre.");
}
String storageKey = storage.store(letter);
Instant now = Instant.now(clock);
RentRevision proposed = new RentRevision(new RentRevisionId(identifiers.next()), landlordId, leaseId,
new RentRevision.Window(preview.annualRevisionDate(), preview.requestDeadline()), indexReference,
amounts,
new RentRevision.Request(request.requestedOn(), effectiveOn, storageKey, sha256(letter), author, now),
overrides, null, -1);
new RentRevision.Window(
preparation.preview().annualRevisionDate(), preparation.preview().requestDeadline()),
preparation.indexReference(), preparation.amounts(),
new RentRevision.Request(request.requestedOn(), preparation.effectiveOn(), storageKey, digest(letter),
safeFileName(encryptedLetter.fileName()), encryptedLetter.cryptoVersion(),
encryptedLetter.nonce(), encryptedLetter.wrappedKey(), preparation.sourceFingerprint(),
encryptedLetter.vaultPublicKey(), authorId, now),
preparation.overrides(), null, -1);
try {
return transactions.run(() -> revisions.save(proposed));
return transactions.run(() -> {
verifyPreparation(landlordId, leaseId, request, preparation, authorId);
fenceCurrentVault(landlordId, encryptedLetter, RentRevisionValidationFailure.Rule.INVALID_LETTER);
return revisions.save(proposed);
});
} catch (RentRevisionConflict duplicate) {
discard(storageKey, duplicate);
return revisions
.findAll(landlordId, leaseId).stream().filter(revision -> sameProposal(revision, preview,
request.requestedOn(), effectiveOn, indexReference, amounts, overrides))
// A reset may race the transaction after the initial check. Never return
// an old-generation winner from the idempotent retry path.
requireCurrentVault(landlordId, encryptedLetter, RentRevisionValidationFailure.Rule.INVALID_LETTER);
return revisions.findAll(landlordId, leaseId).stream()
.filter(revision -> sameProposal(revision, preparation.preview(), request.requestedOn(),
preparation.effectiveOn(), preparation.indexReference(), preparation.amounts(),
preparation.overrides(), preparation, request.sourceFingerprint(),
encryptedLetter.vaultPublicKey()))
.findFirst().orElseThrow(() -> duplicate);
} catch (RuntimeException failure) {
discard(storageKey, failure);
@@ -172,23 +211,38 @@ public final class RentRevisionService {
}
RentRevision proposed = revisions.find(landlordId, leaseId, revisionId)
.orElseThrow(() -> new ResourceNotFound("Rent revision"));
requireCurrentVault(landlordId, proposed.request().letterVaultPublicKey(),
RentRevisionValidationFailure.Rule.INVALID_LETTER);
Lease lease = leases.find(landlordId, leaseId).orElseThrow(() -> new ResourceNotFound("Lease"));
assertCurrentAmounts(landlordId, lease, proposed, request.sentOn());
List<RentDue> existing = rentDues.findAll(landlordId, leaseId);
LocalDate effectiveOn = effectiveDate(existing, request.sentOn(),
lease.contractVersionOn(request.sentOn()).terms().dueDay());
rejectFinancialChangeBeforeEffect(lease, request.sentOn(), effectiveOn);
byte[] proofContent = request.proof() == null ? null : pdf(request.proof());
if (request.proof() != null) {
if (!proposed.request().letterVaultPublicKey().equals(request.proof().vaultPublicKey())) {
throw new RentRevisionValidationFailure(RentRevisionValidationFailure.Rule.INVALID_PROOF);
}
requireCurrentVault(landlordId, request.proof(), RentRevisionValidationFailure.Rule.INVALID_PROOF);
}
byte[] proofContent = request.proof() == null
? null
: encryptedDocument(request.proof(), RentRevisionValidationFailure.Rule.INVALID_PROOF);
String proofStorageKey = proofContent == null ? null : storage.store(proofContent);
Instant now = Instant.now(clock);
RentRevision.Proof proof = proofContent == null
? null
: new RentRevision.Proof(proofStorageKey, safeFileName(request.proof().fileName()),
sha256(proofContent), author, now);
: new RentRevision.Proof(proofStorageKey, "preuve.pdf", digest(proofContent),
request.proof().cryptoVersion(), request.proof().nonce(), request.proof().wrappedKey(),
request.proof().vaultPublicKey(), author, now);
RentRevision.Application application = new RentRevision.Application(request.sentOn(), request.deliveryMethod(),
effectiveOn, proof, author, now);
try {
return transactions.run(() -> applyInTransaction(landlordId, lease, proposed, application, existing));
return transactions.run(() -> {
fenceCurrentVault(landlordId, proposed.request().letterVaultPublicKey(),
RentRevisionValidationFailure.Rule.INVALID_LETTER);
return applyInTransaction(landlordId, lease, proposed, application, existing);
});
} catch (RuntimeException failure) {
if (proofStorageKey != null) {
discard(proofStorageKey, failure);
@@ -209,21 +263,30 @@ public final class RentRevisionService {
public Letter letter(LandlordId landlordId, LeaseId leaseId, RentRevisionId revisionId) {
requireProposalDependencies();
vaultAccess.requireLandlordUse(landlordId);
RentRevision revision = revisions.find(landlordId, leaseId, revisionId)
.orElseThrow(() -> new ResourceNotFound("Rent revision"));
return new Letter("revision-loyer-" + leaseId.value() + "-" + revision.request().requestedOn() + ".pdf",
requireStoredVault(landlordId, revision.request().letterVaultPublicKey());
return new Letter(landlordId.value(), revision.request().sourceFingerprint(),
revision.request().letterFileName(), revision.request().letterCryptoVersion(),
revision.request().letterNonce(), revision.request().letterWrappedKey(),
revision.request().letterSha256(), revision.request().letterVaultPublicKey(),
verifiedContent(revision.request().letterStorageKey(), revision.request().letterSha256()));
}
public ProofDownload proof(LandlordId landlordId, LeaseId leaseId, RentRevisionId revisionId) {
requireProposalDependencies();
vaultAccess.requireLandlordUse(landlordId);
RentRevision revision = revisions.find(landlordId, leaseId, revisionId)
.orElseThrow(() -> new ResourceNotFound("Rent revision"));
RentRevision.Proof proof = revision.application() == null ? null : revision.application().proof();
if (proof == null) {
throw new ResourceNotFound("Rent revision proof");
}
return new ProofDownload(proof.fileName(), verifiedContent(proof.storageKey(), proof.sha256()));
requireStoredVault(landlordId, proof.vaultPublicKey());
return new ProofDownload(landlordId.value(), revisionId.value(), revision.request().sourceFingerprint(),
proof.fileName(), proof.cryptoVersion(), proof.nonce(), proof.wrappedKey(), proof.vaultPublicKey(),
proof.sha256(), verifiedContent(proof.storageKey(), proof.sha256()));
}
public Preview preview(LandlordId landlordId, LeaseId leaseId, PreviewRequest request) {
@@ -369,17 +432,66 @@ public final class RentRevisionService {
private static boolean sameProposal(RentRevision revision, Preview preview, LocalDate requestedOn,
LocalDate effectiveOn, RentRevision.IndexReference indexReference, RentRevision.Amounts amounts,
List<RentRevision.LegalOverride> overrides) {
List<RentRevision.LegalOverride> overrides, ProposalPreparation preparation, String sourceFingerprint,
String letterVaultPublicKey) {
return revision.window().annualRevisionDate().equals(preview.annualRevisionDate())
&& revision.request().requestedOn().equals(requestedOn)
&& revision.request().effectiveOn().equals(effectiveOn)
&& sameIndexReference(revision.indexReference(), indexReference)
&& sameAmounts(revision.amounts(), amounts)
&& Objects.equals(revision.request().sourceFingerprint(), sourceFingerprint)
&& Objects.equals(revision.request().letterVaultPublicKey(), letterVaultPublicKey)
&& sourceFingerprintMatchesCurrentOrOwnFence(preparation, sourceFingerprint)
&& revision.warningOverrides().stream().map(override -> override.code() + "|" + override.ruleVersion())
.toList().equals(overrides.stream()
.map(override -> override.code() + "|" + override.ruleVersion()).toList());
}
private static boolean sourceFingerprintMatchesCurrentOrOwnFence(ProposalPreparation preparation,
String requestedFingerprint) {
if (requestedFingerprint.equals(preparation.sourceFingerprint())) {
return true;
}
/*
* verifyPreparation fences the lease with a compare-and-save. That save
* advances its version once, so a retry sees the next fingerprint even though
* no source fact changed. Accept only that exact one-version transition; any
* other source change remains a stale proposal.
*/
return preparation.leaseVersion() > 0
&& requestedFingerprint.equals(sourceFingerprint(preparation.leaseVersion() - 1, preparation.document(),
preparation.amounts(), preparation.indexReference(), preparation.overrides()));
}
private static String sourceFingerprint(Lease lease, RentalDocumentData.RentRevision document,
RentRevision.Amounts amounts, RentRevision.IndexReference indexReference,
List<RentRevision.LegalOverride> overrides) {
return sourceFingerprint(lease.version(), document, amounts, indexReference, overrides);
}
private static String sourceFingerprint(long leaseVersion, RentalDocumentData.RentRevision document,
RentRevision.Amounts amounts, RentRevision.IndexReference indexReference,
List<RentRevision.LegalOverride> overrides) {
StringBuilder source = new StringBuilder();
append(source, Long.toString(leaseVersion));
append(source, document.toString());
append(source, amounts.toString());
append(source, indexReference.toString());
for (RentRevision.LegalOverride override : overrides) {
append(source, override.code());
append(source, override.ruleVersion());
}
return sha256(source.toString().getBytes(StandardCharsets.UTF_8));
}
private static void append(StringBuilder source, String value) {
if (value == null) {
source.append("-1:");
} else {
source.append(value.length()).append(':').append(value);
}
}
private static boolean sameAmounts(RentRevision.Amounts first, RentRevision.Amounts second) {
return equalAmount(first.previousRent(), second.previousRent())
&& equalAmount(first.rentCeiling(), second.rentCeiling())
@@ -464,12 +576,93 @@ public final class RentRevisionService {
}
private void requireProposalDependencies() {
if (revisions == null || rentDues == null || renderer == null || storage == null || transactions == null
|| identifiers == null) {
if (revisions == null || rentDues == null || vaults == null || vaultAccess == null || storage == null
|| transactions == null || identifiers == null) {
throw new IllegalStateException("Rent revision proposal dependencies are not configured.");
}
}
private void verifyPreparation(LandlordId landlordId, LeaseId leaseId, ProposalRequest request,
ProposalPreparation preparation, String authorId) {
Lease lease = leases.find(landlordId, leaseId).orElseThrow(() -> new ResourceNotFound("Lease"));
if (lease.version() != preparation.leaseVersion()) {
throw new RentRevisionConflict(
"Le bail ou l'état des échéances a changé pendant la génération ; régénérez la lettre.");
}
LeaseContractVersion contract = lease.contractVersionOn(request.requestedOn());
if (!contract.equals(preparation.contract())) {
throw new RentRevisionConflict(
"Le bail ou l'état des échéances a changé pendant la génération ; régénérez la lettre.");
}
RentTerms currentTerms = rentTermsOn(landlordId, lease, request.requestedOn());
RentRevision.Amounts amounts = new RentRevision.Amounts(currentTerms.monthlyRent(),
preparation.amounts().rentCeiling(), request.chosenRent(), currentTerms.monthlyCharges(),
preparation.amounts().chargeCeiling(), request.chosenCharges());
if (!sameAmounts(amounts, preparation.amounts())) {
throw new RentRevisionConflict(
"Le bail ou l'état des échéances a changé pendant la génération ; régénérez la lettre.");
}
List<RentDue> currentDues = rentDues.findAll(landlordId, leaseId);
LocalDate effectiveOn = effectiveDate(currentDues, request.requestedOn(), currentTerms.dueDay());
rejectFinancialChangeBeforeEffect(lease, request.requestedOn(), effectiveOn);
rejectAfterLeaseEnd(lease, effectiveOn);
if (!effectiveOn.equals(preparation.effectiveOn())) {
throw new RentRevisionConflict(
"Le bail ou l'état des échéances a changé pendant la génération ; régénérez la lettre.");
}
List<Warning> currentWarnings = warnings(lease, request.requestedOn());
List<RentRevision.LegalOverride> overrides = overrides(currentWarnings, request.confirmedWarningCodes(),
authorId, Instant.now(clock));
String fingerprint = sourceFingerprint(lease, preparation.document(), amounts, preparation.indexReference(),
overrides);
if (!preparation.sourceFingerprint().equals(fingerprint)) {
throw new RentRevisionConflict(
"Le bail ou l'état des échéances a changé pendant la génération ; régénérez la lettre.");
}
leases.save(lease);
if (!currentDues.isEmpty()) {
rentDues.saveAll(currentDues);
}
}
private void requireCurrentVault(LandlordId landlordId, EncryptedDocument encryptedLetter,
RentRevisionValidationFailure.Rule invalidRule) {
vaultAccess.requireLandlordUse(landlordId);
if (encryptedLetter == null) {
throw new RentRevisionValidationFailure(invalidRule);
}
requireCurrentVault(landlordId, encryptedLetter.vaultPublicKey(), invalidRule);
}
private void requireCurrentVault(LandlordId landlordId, String publicKey,
RentRevisionValidationFailure.Rule invalidRule) {
if (publicKey == null || vaults.find(landlordId).isEmpty()) {
throw new ApplicationVaultRequired();
}
if (vaults.find(landlordId).filter(vault -> vault.publicKey().equals(publicKey)).isEmpty()) {
throw new RentRevisionValidationFailure(invalidRule);
}
}
private void fenceCurrentVault(LandlordId landlordId, EncryptedDocument encryptedLetter,
RentRevisionValidationFailure.Rule invalidRule) {
requireCurrentVault(landlordId, encryptedLetter, invalidRule);
fenceCurrentVault(landlordId, encryptedLetter.vaultPublicKey(), invalidRule);
}
private void fenceCurrentVault(LandlordId landlordId, String publicKey,
RentRevisionValidationFailure.Rule invalidRule) {
requireCurrentVault(landlordId, publicKey, invalidRule);
ApplicationVault vault = vaults.find(landlordId).orElseThrow(ApplicationVaultRequired::new);
vaults.save(vault);
}
private void requireStoredVault(LandlordId landlordId, String publicKey) {
if (vaults.find(landlordId).filter(vault -> vault.publicKey().equals(publicKey)).isEmpty()) {
throw new ApplicationVaultRequired();
}
}
private void discard(String storageKey, RuntimeException failure) {
try {
storage.discard(storageKey);
@@ -480,7 +673,7 @@ public final class RentRevisionService {
private byte[] verifiedContent(String storageKey, String expectedSha256) {
byte[] content = storage.read(storageKey);
if (!expectedSha256.equals(sha256(content))) {
if (!expectedSha256.equals(digest(content))) {
throw new AttachmentStorageFailure("Rent revision document integrity check failed.", null);
}
return content;
@@ -494,21 +687,62 @@ public final class RentRevisionService {
}
}
private byte[] pdf(PdfUpload upload) {
byte[] content = upload.content();
if (content == null || content.length == 0) {
throw new RentRevisionValidationFailure(RentRevisionValidationFailure.Rule.INVALID_PROOF);
private static String digest(byte[] content) {
try {
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(content));
} catch (NoSuchAlgorithmException impossible) {
throw new IllegalStateException("SHA-256 is not available.", impossible);
}
if (content.length > maximumPdfSize) {
throw new AttachmentTooLarge(maximumPdfSize);
}
private byte[] encryptedPdf(EncryptedDocument upload) {
return encryptedDocument(upload, RentRevisionValidationFailure.Rule.INVALID_LETTER);
}
private byte[] encryptedDocument(EncryptedDocument upload, RentRevisionValidationFailure.Rule invalidRule) {
if (upload == null || upload.cryptoVersion() != 1 || upload.fileName() == null || upload.fileName().isBlank()
|| upload.nonce() == null || upload.wrappedKey() == null || upload.sha256() == null
|| upload.sha256().isBlank() || upload.vaultPublicKey() == null || upload.vaultPublicKey().isBlank()) {
throw new RentRevisionValidationFailure(invalidRule);
}
if (!"application/pdf".equalsIgnoreCase(upload.contentType()) || content.length < 5
|| !Arrays.equals(Arrays.copyOf(content, 5), "%PDF-".getBytes(StandardCharsets.US_ASCII))) {
throw new RentRevisionValidationFailure(RentRevisionValidationFailure.Rule.INVALID_PROOF);
byte[] nonce = decodeBase64(upload.nonce(), "Document nonce", 12, 12, invalidRule);
byte[] wrappedKey = decodeBase64(upload.wrappedKey(), "Document wrapped key", 384, 384, invalidRule);
byte[] content = decodeBase64(upload.ciphertext(), "Document ciphertext", 16, ciphertextMaximumSize(),
invalidRule);
decodeBase64(upload.vaultPublicKey(), "Document vault public key", 422, 422, invalidRule);
if (!isHexDigest(upload.sha256()) || !upload.sha256().equals(digest(content))) {
throw new RentRevisionValidationFailure(invalidRule);
}
return content;
}
private long ciphertextMaximumSize() {
return maximumPdfSize > Long.MAX_VALUE - 16 ? Long.MAX_VALUE : maximumPdfSize + 16;
}
private static byte[] decodeBase64(String value, String field, int minimumBytes, long maximumBytes,
RentRevisionValidationFailure.Rule invalidRule) {
try {
int maximum = Math.toIntExact(Math.min(maximumBytes, Integer.MAX_VALUE));
return Base64.getUrlDecoder().decode(ApplicationCryptoValue.encoded(value, field, minimumBytes, maximum));
} catch (IllegalArgumentException | ArithmeticException failure) {
throw new RentRevisionValidationFailure(invalidRule);
}
}
private static boolean isHexDigest(String value) {
if (value == null || value.length() != 64) {
return false;
}
for (int index = 0; index < value.length(); index++) {
char character = value.charAt(index);
if (!((character >= '0' && character <= '9') || (character >= 'a' && character <= 'f'))) {
return false;
}
}
return true;
}
private static String safeFileName(String fileName) {
if (fileName == null || fileName.isBlank()) {
return "justificatif.pdf";
@@ -558,29 +792,34 @@ public final class RentRevisionService {
}
public record ProposalRequest(PreviewRequest preview, LocalDate requestedOn, BigDecimal chosenRent,
BigDecimal chosenCharges, Set<String> confirmedWarningCodes) {
BigDecimal chosenCharges, Set<String> confirmedWarningCodes, String sourceFingerprint) {
public ProposalRequest {
Objects.requireNonNull(preview, "Preview request is required.");
confirmedWarningCodes = confirmedWarningCodes == null ? Set.of() : Set.copyOf(confirmedWarningCodes);
}
}
public record ApplyRequest(ZoneId timeZone, LocalDate sentOn, RentRevision.DeliveryMethod deliveryMethod,
boolean deliveryConfirmed, PdfUpload proof) {
public ApplyRequest {
Objects.requireNonNull(timeZone, "Time zone is required.");
Objects.requireNonNull(deliveryMethod, "Delivery method is required.");
public record ProposalPreparation(Preview preview, long leaseVersion, String sourceFingerprint,
LeaseContractVersion contract, LocalDate effectiveOn, RentRevision.IndexReference indexReference,
RentRevision.Amounts amounts, List<RentRevision.LegalOverride> overrides,
RentalDocumentData.RentRevision document) {
public ProposalPreparation {
Objects.requireNonNull(preview);
Objects.requireNonNull(sourceFingerprint);
Objects.requireNonNull(contract);
Objects.requireNonNull(effectiveOn);
Objects.requireNonNull(indexReference);
Objects.requireNonNull(amounts);
overrides = List.copyOf(overrides);
Objects.requireNonNull(document);
}
}
public record PdfUpload(String fileName, String contentType, byte[] content) {
public PdfUpload {
content = content == null ? null : content.clone();
}
@Override
public byte[] content() {
return content == null ? null : content.clone();
public record ApplyRequest(ZoneId timeZone, LocalDate sentOn, RentRevision.DeliveryMethod deliveryMethod,
boolean deliveryConfirmed, EncryptedDocument proof) {
public ApplyRequest {
Objects.requireNonNull(timeZone, "Time zone is required.");
Objects.requireNonNull(deliveryMethod, "Delivery method is required.");
}
}
@@ -590,8 +829,20 @@ public final class RentRevisionService {
}
}
public record Letter(String fileName, byte[] content) {
public record EncryptedDocument(String fileName, int cryptoVersion, String nonce, String wrappedKey, String sha256,
String ciphertext, String vaultPublicKey) {
}
public record Letter(String landlordId, String sourceFingerprint, String fileName, int cryptoVersion, String nonce,
String wrappedKey, String sha256, String vaultPublicKey, byte[] content) {
public Letter {
landlordId = required(landlordId, "Letter landlord ID");
sourceFingerprint = required(sourceFingerprint, "Letter source fingerprint");
fileName = required(fileName, "Letter file name");
nonce = required(nonce, "Letter nonce");
wrappedKey = required(wrappedKey, "Letter wrapped key");
sha256 = required(sha256, "Letter SHA-256");
vaultPublicKey = required(vaultPublicKey, "Letter vault public key");
content = content.clone();
}
@@ -601,8 +852,17 @@ public final class RentRevisionService {
}
}
public record ProofDownload(String fileName, byte[] content) {
public record ProofDownload(String landlordId, String documentId, String sourceFingerprint, String fileName,
int cryptoVersion, String nonce, String wrappedKey, String vaultPublicKey, String sha256, byte[] content) {
public ProofDownload {
landlordId = required(landlordId, "Proof landlord ID");
documentId = required(documentId, "Proof document ID");
sourceFingerprint = required(sourceFingerprint, "Proof source fingerprint");
fileName = required(fileName, "Proof file name");
nonce = required(nonce, "Proof nonce");
wrappedKey = required(wrappedKey, "Proof wrapped key");
vaultPublicKey = required(vaultPublicKey, "Proof vault public key");
sha256 = required(sha256, "Proof SHA-256");
content = content.clone();
}
@@ -15,6 +15,6 @@ public final class RentRevisionValidationFailure extends RuntimeException {
}
public enum Rule {
INVALID_DELIVERY_METHOD, INVALID_TIME_ZONE, INVALID_SENT_ON, INVALID_DELIVERY_CONFIRMATION, DELIVERY_CONFIRMATION_REQUIRED, SENT_ON_IN_FUTURE, INVALID_PROOF
INVALID_DELIVERY_METHOD, INVALID_TIME_ZONE, INVALID_SENT_ON, INVALID_DELIVERY_CONFIRMATION, DELIVERY_CONFIRMATION_REQUIRED, SENT_ON_IN_FUTURE, INVALID_PROOF, INVALID_LETTER, INVALID_SOURCE_FINGERPRINT
}
}
@@ -0,0 +1,54 @@
package fr.nessar.lmnporganizer.rental.application.rent;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseContractVersion;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseId;
import fr.nessar.lmnporganizer.rental.domain.rent.PaymentAllocationId;
import fr.nessar.lmnporganizer.rental.domain.rent.RentDueId;
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentId;
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentRefundId;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.YearMonth;
import java.util.List;
/**
* Authoritative facts used by the browser to render rental documents.
*
* <p>
* These records intentionally contain no rendered bytes. The application layer
* still chooses the applicable frozen contract and enforces all document
* preconditions before exposing them.
*/
public final class RentalDocumentData {
private RentalDocumentData() {
}
public record RentReceipt(String fileName, LeaseId leaseId, RentDueId rentDueId, YearMonth month, BigDecimal rent,
BigDecimal charges, BigDecimal total, LocalDate issuedAt, LeaseContractVersion contract) {
}
public record PaymentReceipt(String fileName, LeaseId leaseId, RentPaymentId paymentId, LocalDate receivedAt,
LocalDate confirmedAt, BigDecimal amount, BigDecimal refundedAmount, BigDecimal unallocatedBalance,
LeaseContractVersion contract, List<ReceiptAllocation> allocations, List<ReceiptRefund> refunds) {
public PaymentReceipt {
allocations = List.copyOf(allocations);
refunds = List.copyOf(refunds);
}
}
public record ReceiptAllocation(PaymentAllocationId allocationId, RentDueId rentDueId, YearMonth month,
BigDecimal amount, LocalDate allocatedAt) {
}
public record ReceiptRefund(RentPaymentRefundId refundId, BigDecimal amount, LocalDate refundedAt) {
}
public record RentRevision(String fileName, LeaseId leaseId, LeaseContractVersion contract, LocalDate requestedOn,
LocalDate effectiveOn, String seriesId, int referenceQuarter, int previousIndexYear, int newIndexYear,
BigDecimal previousIndex, BigDecimal newIndex, BigDecimal previousRent, BigDecimal rentCeiling,
BigDecimal chosenRent, FlatRateRevision flatRateRevision) {
}
public record FlatRateRevision(BigDecimal previousAmount, BigDecimal ceiling, BigDecimal chosenAmount) {
}
}
@@ -2,8 +2,12 @@ package fr.nessar.lmnporganizer.rental.application.rent;
import fr.nessar.lmnporganizer.rental.application.port.out.IdentifierGenerator;
import fr.nessar.lmnporganizer.rental.application.port.out.TransactionRunner;
import fr.nessar.lmnporganizer.rental.application.rentalapplication.ApplicationVaultAccess;
import fr.nessar.lmnporganizer.rental.application.rentalapplication.ApplicationVaultRequired;
import fr.nessar.lmnporganizer.rental.application.shared.ConcurrentChange;
import fr.nessar.lmnporganizer.rental.application.shared.ResourceNotFound;
import fr.nessar.lmnporganizer.rental.domain.application.ApplicationVault;
import fr.nessar.lmnporganizer.rental.domain.application.ApplicationVaultRepository;
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
import fr.nessar.lmnporganizer.rental.domain.rent.PaymentAllocation;
import fr.nessar.lmnporganizer.rental.domain.rent.RentDue;
@@ -14,6 +18,7 @@ import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentRepository;
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentReversal;
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentReversalId;
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentReversalRepository;
import fr.nessar.lmnporganizer.rental.domain.tenant.TenantProfile.EncryptedGroup;
import java.time.Clock;
import java.time.LocalDate;
import java.time.ZoneId;
@@ -28,34 +33,44 @@ public final class ReverseRentPaymentService {
private final TransactionRunner transactions;
private final IdentifierGenerator identifiers;
private final Clock clock;
private final ApplicationVaultRepository vaults;
private final ApplicationVaultAccess vaultAccess;
public ReverseRentPaymentService(RentPaymentRepository payments, RentPaymentReversalRepository reversals,
RentDueRepository rentDues, TransactionRunner transactions, IdentifierGenerator identifiers, Clock clock) {
RentDueRepository rentDues, TransactionRunner transactions, IdentifierGenerator identifiers, Clock clock,
ApplicationVaultRepository vaults, ApplicationVaultAccess vaultAccess) {
this.payments = payments;
this.reversals = reversals;
this.rentDues = rentDues;
this.transactions = transactions;
this.identifiers = identifiers;
this.clock = clock;
this.vaults = vaults;
this.vaultAccess = vaultAccess;
}
public RentPaymentReversal reverse(LandlordId landlordId, RentPaymentId paymentId, long paymentVersion,
String reason, ZoneId timeZone) {
EncryptedGroup reason, String vaultPublicKey, ZoneId timeZone) {
Objects.requireNonNull(timeZone, "Time zone is required.");
return transactions.run(() -> reversals.findByPayment(landlordId, paymentId)
.map(existing -> idempotentReversal(existing, reason))
.orElseGet(() -> reverseInTransaction(landlordId, paymentId, paymentVersion, reason, timeZone)));
Objects.requireNonNull(reason, "Reversal reason envelope is required.");
return transactions.run(() -> {
fenceCurrentVault(landlordId, vaultPublicKey);
return reversals.findByPayment(landlordId, paymentId)
.map(existing -> idempotentReversal(existing, reason, vaultPublicKey))
.orElseGet(() -> reverseInTransaction(landlordId, paymentId, paymentVersion, reason, vaultPublicKey,
timeZone));
});
}
private RentPaymentReversal reverseInTransaction(LandlordId landlordId, RentPaymentId paymentId,
long paymentVersion, String reason, ZoneId timeZone) {
long paymentVersion, EncryptedGroup reason, String vaultPublicKey, ZoneId timeZone) {
RentPayment payment = payments.find(landlordId, paymentId)
.orElseThrow(() -> new ResourceNotFound("Rent payment"));
if (payment.version() != paymentVersion) {
throw new ConcurrentChange("Rent payment");
}
RentPaymentReversal reversal = RentPaymentReversal.reverse(new RentPaymentReversalId(identifiers.next()),
payment, LocalDate.now(clock.withZone(timeZone)), reason);
payment, LocalDate.now(clock.withZone(timeZone)), reason, vaultPublicKey);
List<RentDue> affectedDues = new ArrayList<>();
for (PaymentAllocation allocation : reversal.reversedAllocations()) {
RentDue due = rentDues.find(landlordId, allocation.rentDueId())
@@ -68,10 +83,20 @@ public final class ReverseRentPaymentService {
return reversals.save(reversal);
}
private RentPaymentReversal idempotentReversal(RentPaymentReversal existing, String reason) {
if (reason == null || !existing.reason().equals(reason.trim())) {
private RentPaymentReversal idempotentReversal(RentPaymentReversal existing, EncryptedGroup reason,
String vaultPublicKey) {
if (!existing.reason().equals(reason) || !existing.vaultPublicKey().equals(vaultPublicKey)) {
throw new RentPaymentConflict("Ce règlement a déjà été contrepassé pour une autre raison.");
}
return existing;
}
private void fenceCurrentVault(LandlordId landlordId, String vaultPublicKey) {
vaultAccess.requireLandlordUse(landlordId);
ApplicationVault vault = vaults.find(landlordId).orElseThrow(ApplicationVaultRequired::new);
if (vaultPublicKey == null || !vault.publicKey().equals(vaultPublicKey)) {
throw new ApplicationVaultRequired();
}
vaults.save(vault);
}
}
@@ -2,6 +2,7 @@ package fr.nessar.lmnporganizer.rental.application.rentalapplication;
import fr.nessar.lmnporganizer.rental.application.port.out.IdentifierGenerator;
import fr.nessar.lmnporganizer.rental.application.port.out.TransactionRunner;
import fr.nessar.lmnporganizer.rental.application.shared.ConcurrentChange;
import fr.nessar.lmnporganizer.rental.application.shared.ResourceNotFound;
import fr.nessar.lmnporganizer.rental.domain.apartment.Apartment;
import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentId;
@@ -64,12 +65,26 @@ public final class ApplicationLinkService {
links.findActive(landlordId, apartmentId).orElse(null));
}
public ApplicationVault activate(LandlordId landlordId, VaultActivation activation) {
return transactions.run(() -> {
access.requireLandlordUse(landlordId);
ApplicationVault existing = vaults.find(landlordId).orElse(null);
if (existing == null) {
return newVault(landlordId, activation);
}
if (activation == null || !existing.publicKey().equals(activation.publicKey())) {
throw new ConcurrentChange("Application vault");
}
return existing;
});
}
public LinkStatus publish(LandlordId landlordId, ApartmentId apartmentId, VaultActivation activation) {
return transactions.run(() -> {
access.requireLandlordUse(landlordId);
Apartment apartment = apartment(landlordId, apartmentId);
ApplicationVault vault = vaults.find(landlordId).map(vaults::save)
.orElseGet(() -> activate(landlordId, activation));
.orElseGet(() -> newVault(landlordId, activation));
PublicApplicationLink active = links.findActive(landlordId, apartmentId).orElse(null);
if (active != null) {
return new LinkStatus(true, vault.passkeys().size(), active);
@@ -114,12 +129,13 @@ public final class ApplicationLinkService {
String capabilityHash = ApplicationCapability.hash(authorization);
return transactions.run(() -> {
PublicApplicationLink link = activeLink(token);
vaults.find(link.landlordId()).orElseThrow(() -> new ResourceNotFound("Application link"));
ApplicationVault vault = vaults.find(link.landlordId())
.orElseThrow(() -> new ResourceNotFound("Application link"));
if (encryptedKey == null) {
throw new IllegalArgumentException("Encrypted application key is required.");
}
RentalApplication draft = RentalApplication.draft(new RentalApplicationId(identifiers.next()),
link.apartmentId(), link.landlordId(), Instant.now(clock), capabilityHash,
link.apartmentId(), link.landlordId(), vault.publicKey(), Instant.now(clock), capabilityHash,
encryptedKey.ciphertext(), encryptedKey.cryptoVersion());
link.recordDraftCreation(Instant.now(clock));
links.save(link);
@@ -164,13 +180,6 @@ public final class ApplicationLinkService {
});
}
public RentalApplication requestCorrection(LandlordId landlordId, RentalApplicationId id, int expectedRevision) {
return applicationAccess.runLandlordCommand(landlordId, id, application -> {
application.returnToDraft(expectedRevision, Instant.now(clock));
return applications.save(application);
});
}
public RentalApplication reject(LandlordId landlordId, RentalApplicationId id, int expectedRevision) {
return applicationAccess.runLandlordCommand(landlordId, id, application -> {
application.reject(expectedRevision, Instant.now(clock));
@@ -209,7 +218,7 @@ public final class ApplicationLinkService {
landlord.email(), apartment.name());
}
private ApplicationVault activate(LandlordId landlordId, VaultActivation activation) {
private ApplicationVault newVault(LandlordId landlordId, VaultActivation activation) {
if (activation == null) {
throw new ApplicationVaultRequired();
}
@@ -1,59 +1,154 @@
package fr.nessar.lmnporganizer.rental.application.rentalapplication;
import fr.nessar.lmnporganizer.rental.application.port.out.IdentifierGenerator;
import fr.nessar.lmnporganizer.rental.application.shared.ResourceNotFound;
import fr.nessar.lmnporganizer.rental.domain.application.ApplicationOperationNotAllowed;
import fr.nessar.lmnporganizer.rental.domain.application.ApplicationRevisionConflict;
import fr.nessar.lmnporganizer.rental.domain.application.ApplicationVault;
import fr.nessar.lmnporganizer.rental.domain.application.ApplicationVaultRepository;
import fr.nessar.lmnporganizer.rental.domain.application.RentalApplication;
import fr.nessar.lmnporganizer.rental.domain.application.RentalApplicationId;
import fr.nessar.lmnporganizer.rental.domain.application.RentalApplicationRepository;
import fr.nessar.lmnporganizer.rental.domain.application.RentalApplicationState;
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
import fr.nessar.lmnporganizer.rental.domain.tenant.Tenant;
import fr.nessar.lmnporganizer.rental.domain.tenant.TenantEvidenceRetention;
import fr.nessar.lmnporganizer.rental.domain.tenant.TenantId;
import fr.nessar.lmnporganizer.rental.domain.tenant.TenantProfile;
import fr.nessar.lmnporganizer.rental.domain.tenant.TenantRepository;
import fr.nessar.lmnporganizer.rental.application.tenant.TenantAttachmentService;
import java.time.Clock;
import java.time.Instant;
import java.time.LocalDate;
import java.util.List;
import java.util.Objects;
public final class ApplicationSelectionService {
private final RentalApplicationRepository applications;
private final TenantRepository tenants;
private final IdentifierGenerator identifiers;
private final ApplicationVaultRepository vaults;
private final RentalApplicationAccess applicationAccess;
private final TenantAttachmentService attachments;
private final Clock clock;
public ApplicationSelectionService(RentalApplicationRepository applications, TenantRepository tenants,
IdentifierGenerator identifiers, RentalApplicationAccess applicationAccess, Clock clock) {
ApplicationVaultRepository vaults, RentalApplicationAccess applicationAccess,
TenantAttachmentService attachments, Clock clock) {
this.applications = applications;
this.tenants = tenants;
this.identifiers = identifiers;
this.vaults = vaults;
this.applicationAccess = applicationAccess;
this.clock = clock;
this.attachments = Objects.requireNonNull(attachments, "Tenant attachment service is required.");
this.clock = Objects.requireNonNull(clock, "Selection clock is required.");
}
public Selection select(LandlordId landlordId, RentalApplicationId applicationId, int expectedRevision,
ConfirmedTenant confirmed) {
Objects.requireNonNull(confirmed, "Confirmed tenant is required.");
public Selection select(LandlordId landlordId, RentalApplicationId applicationId, SelectionCommand command) {
Objects.requireNonNull(command, "Application selection command is required.");
return applicationAccess.runLandlordCommand(landlordId, applicationId, application -> {
if (application.state() == RentalApplicationState.SELECTED) {
Tenant tenant = tenants.find(landlordId, application.selectedTenantId())
.orElseThrow(() -> new ResourceNotFound("Tenant"));
if (!command.confirmedTenant().id().equals(application.selectedTenantId())) {
throw new ApplicationOperationNotAllowed();
}
Tenant tenant = tenant(landlordId, application.selectedTenantId());
return new Selection(application, tenant);
}
if (application.state() != RentalApplicationState.SUBMITTED) {
throw new ApplicationOperationNotAllowed();
}
Tenant tenant = new Tenant(new TenantId(identifiers.next()), landlordId, confirmed.firstName(),
confirmed.lastName(), confirmed.email(), confirmed.phone(), "", List.of());
application.select(expectedRevision, tenant.id(), Instant.now(clock));
if (application.revision() != command.expectedRevision()) {
throw new ApplicationRevisionConflict();
}
Tenant tenant = resolveTenant(landlordId, command.confirmedTenant());
Instant selectedAt = Instant.now(clock);
application.select(command.expectedRevision(), tenant.id(), selectedAt);
if (command.proposedGuarantees() != null) {
tenant.recordProposedGuarantees(command.proposedGuarantees());
}
if (!command.retainedDocuments().isEmpty()) {
attachments.retainSelectedDocuments(application, tenant, command.expectedRevision(),
command.retainedDocuments(), selectedAt);
}
Tenant savedTenant = tenants.save(tenant);
RentalApplication savedApplication = applications.save(application);
return new Selection(savedApplication, savedTenant);
});
}
public record ConfirmedTenant(String firstName, String lastName, String email, String phone) {
private Tenant resolveTenant(LandlordId landlordId, ConfirmedTenant choice) {
if (choice.mode() == TenantSelectionMode.ASSOCIATE) {
Tenant existing = tenant(landlordId, choice.id());
fenceCurrentVault(landlordId, existing.profile());
return existing;
}
if (tenants.find(landlordId, choice.id()).isPresent()) {
throw new ApplicationOperationNotAllowed();
}
fenceCurrentVault(landlordId, choice.profile());
return new Tenant(choice.id(), landlordId, choice.profile(), List.of());
}
private Tenant tenant(LandlordId landlordId, TenantId tenantId) {
return tenants.find(landlordId, tenantId).orElseThrow(() -> new ResourceNotFound("Tenant"));
}
private void fenceCurrentVault(LandlordId landlordId, TenantProfile profile) {
ApplicationVault vault = vaults.find(landlordId).orElseThrow(ApplicationVaultRequired::new);
if (!vault.publicKey().equals(profile.vaultPublicKey())) {
throw new ApplicationVaultRequired();
}
vaults.save(vault);
}
public record SelectionCommand(int expectedRevision, ConfirmedTenant confirmedTenant,
List<RetainedDocument> retainedDocuments, TenantProfile.EncryptedGroup proposedGuarantees) {
public SelectionCommand {
if (expectedRevision < 0) {
throw new IllegalArgumentException("Application revision cannot be negative.");
}
confirmedTenant = Objects.requireNonNull(confirmedTenant, "Tenant selection is required.");
retainedDocuments = retainedDocuments == null ? List.of() : List.copyOf(retainedDocuments);
}
}
public record ConfirmedTenant(TenantSelectionMode mode, TenantId id, TenantProfile profile) {
public ConfirmedTenant {
mode = Objects.requireNonNull(mode, "Tenant selection mode is required.");
id = Objects.requireNonNull(id, "Selected tenant ID is required.");
if (mode == TenantSelectionMode.CREATE && profile == null) {
throw new IllegalArgumentException("A new tenant requires an encrypted profile.");
}
if (mode == TenantSelectionMode.ASSOCIATE && profile != null) {
throw new IllegalArgumentException("An existing tenant profile cannot be replaced during selection.");
}
}
}
public enum TenantSelectionMode {
CREATE, ASSOCIATE
}
public record RetainedDocument(String id, int revision, String nonce, long encryptedSize, int cryptoVersion,
TenantEvidenceRetention.EvidencePurpose purpose, TenantEvidenceRetention.RetentionRule retentionRule,
String specificNeedVaultPublicKey, TenantProfile.EncryptedGroup encryptedSpecificNeedExplanation,
LocalDate specificNeedEndsOn) {
public RetainedDocument {
if (id == null || id.isBlank() || revision < 1 || nonce == null || encryptedSize < 17
|| cryptoVersion < 1) {
throw new IllegalArgumentException("Retained application document metadata is invalid.");
}
purpose = Objects.requireNonNull(purpose, "Evidence retention purpose is required.");
retentionRule = Objects.requireNonNull(retentionRule, "Evidence retention rule is required.");
if (retentionRule == TenantEvidenceRetention.RetentionRule.SPECIFIC_NEED
&& (specificNeedVaultPublicKey == null || encryptedSpecificNeedExplanation == null
|| specificNeedEndsOn == null)) {
throw new IllegalArgumentException(
"A specific retention need must remain encrypted and have a finite end date.");
}
if (retentionRule != TenantEvidenceRetention.RetentionRule.SPECIFIC_NEED
&& (specificNeedVaultPublicKey != null || encryptedSpecificNeedExplanation != null
|| specificNeedEndsOn != null)) {
throw new IllegalArgumentException("Specific retention metadata requires the specific-need rule.");
}
}
}
public record Selection(RentalApplication application, Tenant tenant) {
@@ -3,6 +3,9 @@ package fr.nessar.lmnporganizer.rental.application.rentalapplication;
import fr.nessar.lmnporganizer.rental.application.shared.ResourceNotFound;
import fr.nessar.lmnporganizer.rental.domain.application.ApplicationVaultResetRepository;
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
import java.time.Instant;
import java.util.Optional;
import java.util.function.Function;
public final class ApplicationVaultAccess {
private final ApplicationVaultResetRepository resets;
@@ -21,7 +24,65 @@ public final class ApplicationVaultAccess {
throw new ResourceNotFound("Application");
}
/**
* Returns the protection state for an encrypted record generation. A landlord
* reset is authoritative even when the current vault is absent; callers must
* not turn a retired record into an ordinary empty result.
*/
public ProtectionStatus status(LandlordId landlordId, String vaultPublicKey) {
return statusesFor(landlordId).apply(vaultPublicKey);
}
/** Reads one landlord reset for mapping multiple records in one request. */
public Function<String, ProtectionStatus> statusesFor(LandlordId landlordId) {
var reset = resets.find(landlordId);
return vaultPublicKey -> reset.map(state -> {
if (state.inProgress())
return ProtectionStatus.RESETTING;
return state.retired(vaultPublicKey)
? ProtectionStatus.PERMANENTLY_LOST
: ProtectionStatus.TEMPORARILY_LOCKED;
}).orElse(ProtectionStatus.TEMPORARILY_LOCKED);
}
public boolean retired(LandlordId landlordId, String vaultPublicKey) {
return resets.find(landlordId).map(reset -> reset.retired(vaultPublicKey)).orElse(false);
}
/**
* Returns the authoritative retention boundary for an old shared-vault
* generation. Protected records use this operational fact independently of
* lease or payment state.
*/
public Optional<Instant> retirementEligibleAt(LandlordId landlordId, String vaultPublicKey) {
return resets.find(landlordId)
.flatMap(reset -> reset.retirements().stream()
.filter(retirement -> retirement.vaultPublicKey().equals(vaultPublicKey))
.map(retirement -> retirement.eligibleAt()).findFirst());
}
public void requireProtectedRead(LandlordId landlordId, String vaultPublicKey) {
ProtectionStatus protectionStatus = status(landlordId, vaultPublicKey);
if (protectionStatus == ProtectionStatus.RESETTING)
throw new ApplicationVaultResetInProgress();
if (protectionStatus == ProtectionStatus.PERMANENTLY_LOST)
throw new ApplicationVaultRetired();
}
private boolean resetting(LandlordId landlordId) {
return resets.find(landlordId).filter(reset -> reset.inProgress()).isPresent();
}
public enum ProtectionStatus {
TEMPORARILY_LOCKED, PERMANENTLY_LOST, RESETTING;
/** Stable wire values shared by protected-resource response DTOs. */
public String wireValue() {
return switch (this) {
case TEMPORARILY_LOCKED -> "locked";
case PERMANENTLY_LOST -> "lost-vault";
case RESETTING -> "resetting";
};
}
}
}
@@ -2,6 +2,6 @@ package fr.nessar.lmnporganizer.rental.application.rentalapplication;
public final class ApplicationVaultResetInProgress extends RuntimeException {
public ApplicationVaultResetInProgress() {
super("Application vault reset is in progress.");
super("Shared vault reset is in progress.");
}
}
@@ -5,6 +5,7 @@ import fr.nessar.lmnporganizer.rental.application.shared.ConcurrentChange;
import fr.nessar.lmnporganizer.rental.application.shared.ResourceNotFound;
import fr.nessar.lmnporganizer.rental.domain.application.ApplicationVault;
import fr.nessar.lmnporganizer.rental.domain.application.ApplicationVaultRepository;
import fr.nessar.lmnporganizer.rental.domain.application.ApplicationVaultRetirement;
import fr.nessar.lmnporganizer.rental.domain.application.ApplicationVaultReset;
import fr.nessar.lmnporganizer.rental.domain.application.ApplicationVaultResetRepository;
import fr.nessar.lmnporganizer.rental.domain.application.PublicApplicationLinkRepository;
@@ -47,8 +48,16 @@ public final class ApplicationVaultResetService {
private ApplicationVaultReset begin(LandlordId landlordId, String operationId, Long expectedVaultVersion,
String expectedPublicKey) {
ApplicationVaultReset current = resets.find(landlordId).orElse(null);
if (current != null && (current.inProgress() || current.operationId().equals(operationId)))
return current;
if (current != null) {
// A completed operation in the retirement history is never a resume
// candidate. Check it before the in-progress branch so replaying an
// older operation cannot complete a newer generation's reset.
if (!current.operationId().equals(operationId) && current.hasOperation(operationId)) {
throw new ConcurrentChange("Application vault reset");
}
if (current.inProgress() || current.operationId().equals(operationId))
return current;
}
if (expectedVaultVersion == null) {
throw new IllegalArgumentException("Expected application vault version is required.");
}
@@ -63,8 +72,8 @@ public final class ApplicationVaultResetService {
closeActiveLinks(landlordId, startedAt);
vaults.save(vault);
return resets.save(current == null
? ApplicationVaultReset.start(landlordId, operationId, startedAt)
: current.restart(operationId, startedAt));
? ApplicationVaultReset.start(landlordId, operationId, startedAt, expectedPublicKey)
: current.restart(operationId, startedAt, expectedPublicKey));
}
private ApplicationVaultReset complete(LandlordId landlordId, String operationId) {
@@ -75,15 +84,25 @@ public final class ApplicationVaultResetService {
}
if (!current.inProgress())
return current;
ApplicationVaultRetirement retirement = current.retirement(operationId);
ApplicationVault currentVault = vaults.find(landlordId).orElse(null);
String retiredPublicKey = retirement.vaultPublicKey();
closeActiveLinks(landlordId, Instant.now(clock));
applications.findAll(landlordId).forEach(application -> {
if (application.makeImmediatelyPurgeEligible(current.startedAt()))
if (belongsToRetiredGeneration(application.vaultPublicKey(), retiredPublicKey)
&& application.makeImmediatelyPurgeEligible(retirement.eligibleAt()))
applications.save(application);
});
vaults.find(landlordId).ifPresent(vaults::delete);
if (currentVault != null && retiredPublicKey != null && retiredPublicKey.equals(currentVault.publicKey())) {
vaults.delete(currentVault);
}
return resets.save(current.complete(Instant.now(clock)));
}
private static boolean belongsToRetiredGeneration(String recordPublicKey, String retiredPublicKey) {
return recordPublicKey != null && retiredPublicKey.equals(recordPublicKey);
}
private void closeActiveLinks(LandlordId landlordId, Instant closedAt) {
links.findActive(landlordId).forEach(link -> {
link.close(closedAt);
@@ -0,0 +1,7 @@
package fr.nessar.lmnporganizer.rental.application.rentalapplication;
public final class ApplicationVaultRetired extends RuntimeException {
public ApplicationVaultRetired() {
super("The protected vault generation was permanently retired.");
}
}
@@ -1,35 +1,57 @@
package fr.nessar.lmnporganizer.rental.application.tenant;
import fr.nessar.lmnporganizer.rental.application.port.out.TenantAttachmentStorage;
import fr.nessar.lmnporganizer.rental.application.port.out.TransactionRunner;
import fr.nessar.lmnporganizer.rental.application.shared.ConcurrentChange;
import fr.nessar.lmnporganizer.rental.application.shared.ReferencedResource;
import fr.nessar.lmnporganizer.rental.application.shared.ResourceNotFound;
import fr.nessar.lmnporganizer.rental.domain.application.RentalApplicationRepository;
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
import fr.nessar.lmnporganizer.rental.domain.tenant.Tenant;
import fr.nessar.lmnporganizer.rental.domain.tenant.TenantAttachment;
import fr.nessar.lmnporganizer.rental.domain.tenant.TenantId;
import fr.nessar.lmnporganizer.rental.domain.tenant.TenantRepository;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
public final class DeleteTenantService {
private final TenantRepository tenants;
private final RentalApplicationRepository applications;
private final TenantAttachmentStorage storage;
private final TransactionRunner transactions;
public DeleteTenantService(TenantRepository tenants, TenantAttachmentStorage storage) {
public DeleteTenantService(TenantRepository tenants, RentalApplicationRepository applications,
TenantAttachmentStorage storage, TransactionRunner transactions) {
this.tenants = tenants;
this.applications = applications;
this.storage = storage;
this.transactions = transactions;
}
public void delete(LandlordId landlordId, TenantId tenantId) {
Tenant tenant = tenants.find(landlordId, tenantId).orElseThrow(() -> new ResourceNotFound("Tenant"));
if (tenant.isReferenced()) {
if (tenant.isReferenced() || applicationReferences(landlordId, tenantId)) {
throw new ReferencedResource("Tenant");
}
List<TenantAttachmentStorage.StagedDeletion> deletions = stage(tenant);
try {
if (!tenants.delete(tenant)) {
throw new ConcurrentChange("Tenant");
}
transactions.run(() -> {
Tenant current = tenants.find(landlordId, tenantId).orElseThrow(() -> new ResourceNotFound("Tenant"));
if (current.version() != tenant.version() || !sameAttachments(current, tenant)) {
throw new ConcurrentChange("Tenant");
}
// Selection establishes the application↔tenant reference in the same
// transaction. Re-check it after loading the transaction snapshot so a
// delete cannot race a selection that has already committed that link.
if (current.isReferenced() || applicationReferences(landlordId, tenantId)) {
throw new ReferencedResource("Tenant");
}
if (!tenants.delete(current)) {
throw new ConcurrentChange("Tenant");
}
return current.id();
});
} catch (RuntimeException failure) {
restore(deletions, failure);
throw failure;
@@ -37,10 +59,24 @@ public final class DeleteTenantService {
deletions.forEach(storage::completeDeletion);
}
private static boolean sameAttachments(Tenant current, Tenant expected) {
return current.attachments().stream().sorted(Comparator.comparing(TenantAttachment::id)).toList()
.equals(expected.attachments().stream().sorted(Comparator.comparing(TenantAttachment::id)).toList());
}
private boolean applicationReferences(LandlordId landlordId, TenantId tenantId) {
return applications.findAll(landlordId).stream()
.anyMatch(application -> tenantId.equals(application.selectedTenantId()));
}
private List<TenantAttachmentStorage.StagedDeletion> stage(Tenant tenant) {
List<TenantAttachmentStorage.StagedDeletion> staged = new ArrayList<>();
try {
tenant.attachments().forEach(attachment -> staged.add(storage.stageDeletion(attachment)));
// Retained application documents are aliases to application storage. The
// tenant deletion removes only the reference; it must not move or delete
// bytes still owned by the application source.
tenant.attachments().stream().filter(attachment -> !attachment.isOriginalApplication())
.forEach(attachment -> staged.add(storage.stageDeletion(attachment)));
return staged;
} catch (RuntimeException failure) {
restore(staged, failure);
@@ -0,0 +1,80 @@
package fr.nessar.lmnporganizer.rental.application.tenant;
import fr.nessar.lmnporganizer.rental.application.port.out.TransactionRunner;
import fr.nessar.lmnporganizer.rental.application.shared.ConcurrentChange;
import fr.nessar.lmnporganizer.rental.application.shared.ResourceNotFound;
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
import fr.nessar.lmnporganizer.rental.domain.tenant.Tenant;
import fr.nessar.lmnporganizer.rental.domain.tenant.TenantAccountClosure;
import fr.nessar.lmnporganizer.rental.domain.tenant.TenantId;
import fr.nessar.lmnporganizer.rental.domain.tenant.TenantRepository;
import java.time.Clock;
import java.time.Instant;
import java.time.LocalDate;
import java.util.Objects;
/**
* Records and reads the tenant-level account closure fact used by retention
* evaluation.
*/
public final class TenantAccountClosureService {
private final TenantRepository tenants;
private final TransactionRunner transactions;
private final Clock clock;
public TenantAccountClosureService(TenantRepository tenants, TransactionRunner transactions, Clock clock) {
this.tenants = Objects.requireNonNull(tenants, "Tenant repository is required.");
this.transactions = Objects.requireNonNull(transactions, "Transaction runner is required.");
this.clock = Objects.requireNonNull(clock, "Rental clock is required.");
}
public State get(LandlordId landlordId, TenantId tenantId) {
return state(tenant(landlordId, tenantId));
}
public State close(LandlordId landlordId, TenantId tenantId, LocalDate closedOn, long expectedTenantVersion,
String actorId) {
Objects.requireNonNull(closedOn, "Account closure date is required.");
if (closedOn.isAfter(today())) {
throw new IllegalArgumentException("Tenant account closure cannot be in the future.");
}
TenantAccountClosure closure = new TenantAccountClosure(closedOn, Instant.now(clock), actorId);
return transactions.run(() -> {
Tenant tenant = tenant(landlordId, tenantId);
TenantAccountClosure existing = tenant.accountClosure();
if (existing != null) {
if (existing.closedOn().equals(closedOn)) {
return state(tenant);
}
if (closedOn.isBefore(existing.closedOn())) {
throw new ConcurrentChange("Tenant account closure");
}
}
if (tenant.version() != expectedTenantVersion) {
throw new ConcurrentChange("Tenant");
}
tenant.recordAccountClosure(closure);
return state(tenants.save(tenant));
});
}
private Tenant tenant(LandlordId landlordId, TenantId tenantId) {
return tenants.find(Objects.requireNonNull(landlordId), Objects.requireNonNull(tenantId))
.orElseThrow(() -> new ResourceNotFound("Tenant"));
}
private State state(Tenant tenant) {
return new State(tenant.id(), tenant.version(), tenant.accountClosure(), today());
}
private LocalDate today() {
return LocalDate.now(clock);
}
public record State(TenantId tenantId, long tenantVersion, TenantAccountClosure closure, LocalDate evaluatedOn) {
public State {
tenantId = Objects.requireNonNull(tenantId, "Tenant ID is required.");
evaluatedOn = Objects.requireNonNull(evaluatedOn, "Retention evaluation date is required.");
}
}
}
@@ -1,83 +1,385 @@
package fr.nessar.lmnporganizer.rental.application.tenant;
import fr.nessar.lmnporganizer.rental.application.port.out.IdentifierGenerator;
import fr.nessar.lmnporganizer.rental.application.port.out.TenantAttachmentStorage;
import fr.nessar.lmnporganizer.rental.application.port.out.TransactionRunner;
import fr.nessar.lmnporganizer.rental.application.rentalapplication.ApplicationVaultAccess;
import fr.nessar.lmnporganizer.rental.application.rentalapplication.ApplicationVaultRequired;
import fr.nessar.lmnporganizer.rental.application.shared.AttachmentStorageFailure;
import fr.nessar.lmnporganizer.rental.application.shared.AttachmentTooLarge;
import fr.nessar.lmnporganizer.rental.application.shared.ConcurrentChange;
import fr.nessar.lmnporganizer.rental.application.shared.ResourceNotFound;
import fr.nessar.lmnporganizer.rental.domain.application.ApplicationVault;
import fr.nessar.lmnporganizer.rental.domain.application.ApplicationVaultRepository;
import fr.nessar.lmnporganizer.rental.domain.application.EncryptedApplicationDocument;
import fr.nessar.lmnporganizer.rental.domain.application.RentalApplication;
import fr.nessar.lmnporganizer.rental.domain.application.RentalApplicationId;
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
import fr.nessar.lmnporganizer.rental.domain.tenant.Tenant;
import fr.nessar.lmnporganizer.rental.domain.tenant.TenantAttachment;
import fr.nessar.lmnporganizer.rental.domain.tenant.TenantEvidenceRetention;
import fr.nessar.lmnporganizer.rental.domain.tenant.TenantId;
import fr.nessar.lmnporganizer.rental.domain.tenant.TenantRepository;
import fr.nessar.lmnporganizer.rental.application.rentalapplication.ApplicationSelectionService;
import fr.nessar.lmnporganizer.rental.application.port.out.ApplicationDocumentStorage;
import fr.nessar.lmnporganizer.rental.domain.application.ApplicationRevisionConflict;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Clock;
import java.time.Instant;
import java.time.LocalDate;
import java.util.HashMap;
import java.util.HexFormat;
import java.util.List;
import java.util.Map;
import java.util.Objects;
public final class TenantAttachmentService {
private final TenantRepository tenants;
private final TenantAttachmentStorage storage;
private final IdentifierGenerator identifiers;
private final ApplicationVaultRepository vaults;
private final ApplicationVaultAccess vaultAccess;
private final TransactionRunner transactions;
private final ApplicationDocumentStorage applicationDocuments;
private final Clock clock;
private final long maximumSize;
public TenantAttachmentService(TenantRepository tenants, TenantAttachmentStorage storage,
IdentifierGenerator identifiers, long maximumSize) {
this.tenants = tenants;
this.storage = storage;
this.identifiers = identifiers;
if (maximumSize < 0) {
throw new IllegalArgumentException("Maximum attachment size cannot be negative.");
ApplicationVaultRepository vaults, ApplicationVaultAccess vaultAccess, TransactionRunner transactions,
ApplicationDocumentStorage applicationDocuments, Clock clock, long maximumSize) {
this.tenants = Objects.requireNonNull(tenants);
this.storage = Objects.requireNonNull(storage);
this.vaults = Objects.requireNonNull(vaults);
this.vaultAccess = Objects.requireNonNull(vaultAccess);
this.transactions = Objects.requireNonNull(transactions);
this.applicationDocuments = Objects.requireNonNull(applicationDocuments);
this.clock = Objects.requireNonNull(clock);
if (maximumSize < 17) {
throw new IllegalArgumentException("Maximum encrypted attachment size must be at least 17 bytes.");
}
this.maximumSize = maximumSize;
}
public TenantAttachment upload(LandlordId landlordId, TenantId tenantId, TenantAttachmentStorage.Upload upload) {
public TenantAttachment upload(LandlordId landlordId, TenantId tenantId, long expectedVersion,
TenantAttachmentStorage.Upload upload) {
Tenant tenant = tenant(landlordId, tenantId);
if (upload.size() == 0) {
throw new IllegalArgumentException("Tenant attachment file is required.");
validate(upload);
requireTenantVault(tenant, upload.vaultPublicKey());
ApplicationVault vault = requireCurrentVault(landlordId, upload.vaultPublicKey());
TenantAttachment existing = tenant.findAttachment(upload.id()).orElse(null);
if (tenant.version() != expectedVersion) {
if (existing != null && same(existing, upload) && sameRetention(existing, upload)) {
return existing;
}
throw new ConcurrentChange("Tenant");
}
if (upload.size() > maximumSize) {
throw new AttachmentTooLarge(maximumSize);
if (existing != null && same(existing, upload) && sameRetention(existing, upload)) {
return existing;
}
TenantAttachment attachment = storage.store(tenantId, identifiers.next(), upload);
tenant.addAttachment(attachment);
TenantAttachmentStorage.StagedDeletion deletion = null;
TenantAttachment stored = null;
UploadResult result;
try {
tenants.save(tenant);
if (existing != null && !existing.isOriginalApplication()) {
deletion = storage.stageDeletion(existing);
}
stored = storage.store(tenantId, upload);
if (stored == null || !same(stored, upload)) {
throw new AttachmentStorageFailure("Tenant attachment storage returned mismatched metadata.",
new IllegalStateException("Stored attachment metadata changed."));
}
stored = nativeRetention(tenant, upload, stored, Instant.now(clock));
result = saveUpload(landlordId, tenantId, expectedVersion, upload, stored, existing, vault);
} catch (RuntimeException failure) {
tenant.removeAttachment(attachment.id());
try {
storage.discardStored(attachment);
} catch (RuntimeException cleanupFailure) {
failure.addSuppressed(cleanupFailure);
if (stored != null) {
cleanupStored(stored, failure);
}
if (deletion != null) {
restore(deletion, failure);
}
throw failure;
}
return attachment;
if (result.committed()) {
if (deletion != null) {
storage.completeDeletion(deletion);
}
return result.attachment();
}
rollbackUncommitted(stored, deletion);
return result.attachment();
}
public Download download(LandlordId landlordId, TenantId tenantId, String attachmentId) {
TenantAttachment attachment = tenant(landlordId, tenantId).findAttachment(attachmentId)
.orElseThrow(() -> new ResourceNotFound("Tenant attachment"));
return new Download(attachment, storage.read(attachment));
}
public void delete(LandlordId landlordId, TenantId tenantId, String attachmentId) {
Tenant tenant = tenant(landlordId, tenantId);
TenantAttachment attachment = tenant.findAttachment(attachmentId)
.orElseThrow(() -> new ResourceNotFound("Tenant attachment"));
TenantAttachmentStorage.StagedDeletion deletion = storage.stageDeletion(attachment);
tenant.removeAttachment(attachmentId);
try {
tenants.save(tenant);
} catch (RuntimeException failure) {
requireTenantVault(tenant, attachment.vaultPublicKey());
requireStoredVault(landlordId, attachment.vaultPublicKey());
InputStream content = attachment.isOriginalApplication()
? applicationDocuments.read(new RentalApplicationId(attachment.originalApplication().applicationId()),
attachment.originalApplication().document(attachment.nonce(), attachment.ciphertextSize(),
attachment.cryptoVersion()))
: storage.read(attachment);
return new Download(attachment, content);
}
/**
* Retains only the explicitly chosen document metadata after selection has
* terminalized the application in the same transaction. Application ciphertext
* is never read or copied; the existing application storage reference remains
* live through the tenant retention metadata.
*/
public void retainSelectedDocuments(RentalApplication application, Tenant tenant, int expectedRevision,
List<ApplicationSelectionService.RetainedDocument> requested, Instant retainedAt) {
Objects.requireNonNull(application, "Rental application is required.");
Objects.requireNonNull(tenant, "Tenant is required.");
Objects.requireNonNull(requested, "Retained documents are required.");
Objects.requireNonNull(retainedAt, "Retention instant is required.");
if (!application.landlordId().equals(tenant.landlordId())
|| !application.vaultPublicKey().equals(tenant.profile().vaultPublicKey())) {
throw new ApplicationVaultRequired();
}
Map<String, ApplicationSelectionService.RetainedDocument> byId = new HashMap<>();
for (ApplicationSelectionService.RetainedDocument document : requested) {
if (byId.put(document.id(), document) != null) {
throw new IllegalArgumentException("A document can only be retained once.");
}
EncryptedApplicationDocument source = application.document(document.id());
if (source == null || source.revision() != document.revision() || !source.nonce().equals(document.nonce())
|| source.encryptedSize() != document.encryptedSize()
|| source.cryptoVersion() != document.cryptoVersion()) {
throw new ApplicationRevisionConflict();
}
if (document.retentionRule() == TenantEvidenceRetention.RetentionRule.SPECIFIC_NEED
&& !application.vaultPublicKey().equals(document.specificNeedVaultPublicKey())) {
throw new ApplicationVaultRequired();
}
}
List<EncryptedApplicationDocument> sources = application.removeSelectedDocuments(expectedRevision, tenant.id(),
requested.stream().map(ApplicationSelectionService.RetainedDocument::id).toList());
for (EncryptedApplicationDocument source : sources) {
ApplicationSelectionService.RetainedDocument selected = byId.get(source.id());
String sourceReference = application.id().value() + "/" + source.id();
String storageReference = application.id().value() + "/" + source.id() + "-" + source.revision();
TenantEvidenceRetention retention = new TenantEvidenceRetention(attachmentId(application.id(), source),
tenant.id(), TenantEvidenceRetention.EvidenceSource.ORIGINAL_APPLICATION, sourceReference,
storageReference, source.revision(), retainedAt, retainedAt, selected.purpose(),
selected.retentionRule(), selected.specificNeedVaultPublicKey(),
selected.encryptedSpecificNeedExplanation(), selected.specificNeedEndsOn());
TenantAttachment attachment = new TenantAttachment(retention.id(), source.cryptoVersion(), source.nonce(),
application.encryptedApplicationKey(), null, source.encryptedSize(), application.vaultPublicKey(),
storageReference, retention,
new TenantAttachment.OriginalApplication(application.id().value(), source.id(), source.revision(),
application.encryptedApplicationKey(), application.vaultPublicKey()));
tenant.addAttachment(attachment);
restore(deletion, failure);
}
}
public void delete(LandlordId landlordId, TenantId tenantId, String attachmentId, long expectedVersion) {
Tenant tenant = tenant(landlordId, tenantId);
if (tenant.version() != expectedVersion) {
throw new ConcurrentChange("Tenant");
}
TenantAttachment attachment = tenant.findAttachment(attachmentId)
.orElseThrow(() -> new ResourceNotFound("Tenant attachment"));
requireTenantVault(tenant, attachment.vaultPublicKey());
ApplicationVault expectedVault = requireCurrentVault(landlordId, attachment.vaultPublicKey());
TenantAttachmentStorage.StagedDeletion deletion = attachment.isOriginalApplication()
? null
: storage.stageDeletion(attachment);
try {
transactions.run(() -> {
return removeAndSave(landlordId, tenantId, attachmentId, expectedVersion, attachment, expectedVault);
});
} catch (RuntimeException failure) {
if (deletion != null) {
restore(deletion, failure);
}
throw failure;
}
if (deletion != null) {
storage.completeDeletion(deletion);
}
}
private UploadResult saveUpload(LandlordId landlordId, TenantId tenantId, long expectedVersion,
TenantAttachmentStorage.Upload upload, TenantAttachment stored, TenantAttachment previous,
ApplicationVault expectedVault) {
return transactions.run(() -> {
Tenant current = tenant(landlordId, tenantId);
if (current.version() != expectedVersion) {
throw new ConcurrentChange("Tenant");
}
fenceCurrentVault(landlordId, expectedVault, upload.vaultPublicKey());
TenantAttachment currentAttachment = current.findAttachment(upload.id()).orElse(null);
if (currentAttachment != null) {
if (same(currentAttachment, upload) && sameRetention(currentAttachment, upload)) {
return new UploadResult(currentAttachment, false);
}
if (previous == null || !currentAttachment.equals(previous)) {
throw new ConcurrentChange("Tenant");
}
current.removeAttachment(upload.id());
}
try {
current.addAttachment(stored);
Tenant saved = tenants.save(current);
return new UploadResult(saved.findAttachment(upload.id()).orElse(stored), true);
} catch (RuntimeException failure) {
current.removeAttachment(upload.id());
if (currentAttachment != null) {
current.addAttachment(currentAttachment);
}
throw failure;
}
});
}
private Tenant removeAndSave(LandlordId landlordId, TenantId tenantId, String attachmentId, long expectedVersion,
TenantAttachment expectedAttachment, ApplicationVault expectedVault) {
Tenant current = tenant(landlordId, tenantId);
if (current.version() != expectedVersion) {
throw new ConcurrentChange("Tenant");
}
fenceCurrentVault(landlordId, expectedVault, expectedAttachment.vaultPublicKey());
TenantAttachment currentAttachment = current.findAttachment(attachmentId)
.orElseThrow(() -> new ConcurrentChange("Tenant"));
if (!currentAttachment.equals(expectedAttachment)) {
throw new ConcurrentChange("Tenant");
}
current.removeAttachment(attachmentId);
try {
return tenants.save(current);
} catch (RuntimeException failure) {
current.addAttachment(expectedAttachment);
throw failure;
}
storage.completeDeletion(deletion);
}
private Tenant tenant(LandlordId landlordId, TenantId tenantId) {
return tenants.find(landlordId, tenantId).orElseThrow(() -> new ResourceNotFound("Tenant"));
}
private void requireTenantVault(Tenant tenant, String publicKey) {
if (!tenant.profile().vaultPublicKey().equals(publicKey)) {
throw new ApplicationVaultRequired();
}
}
private ApplicationVault requireCurrentVault(LandlordId landlordId, String publicKey) {
vaultAccess.requireLandlordUse(landlordId);
ApplicationVault vault = vaults.find(landlordId).orElseThrow(ApplicationVaultRequired::new);
if (!vault.publicKey().equals(publicKey)) {
throw new ApplicationVaultRequired();
}
return vault;
}
private void fenceCurrentVault(LandlordId landlordId, ApplicationVault expected, String publicKey) {
ApplicationVault current = requireCurrentVault(landlordId, publicKey);
if (current.version() != expected.version() || !current.publicKey().equals(expected.publicKey())) {
throw new ConcurrentChange("Application vault");
}
vaults.save(current);
}
private void requireStoredVault(LandlordId landlordId, String publicKey) {
vaultAccess.requireProtectedRead(landlordId, publicKey);
if (vaults.find(landlordId).filter(vault -> vault.publicKey().equals(publicKey)).isEmpty()) {
throw new ApplicationVaultRequired();
}
}
private void validate(TenantAttachmentStorage.Upload upload) {
Objects.requireNonNull(upload, "Tenant attachment upload is required.");
new TenantAttachment(upload.id(), upload.cryptoVersion(), upload.nonce(), upload.wrappedKey(), upload.sha256(),
upload.ciphertextSize(), upload.vaultPublicKey(), "pending", null, null);
if (upload.retentionRule() == TenantEvidenceRetention.RetentionRule.SPECIFIC_NEED
&& !upload.vaultPublicKey().equals(upload.specificNeedVaultPublicKey())) {
throw new ApplicationVaultRequired();
}
if (upload.ciphertextSize() > maximumSize) {
throw new AttachmentTooLarge(maximumSize);
}
}
private static TenantAttachment nativeRetention(Tenant tenant, TenantAttachmentStorage.Upload upload,
TenantAttachment stored, Instant retainedAt) {
String sourceReference = tenant.id().value() + "/" + upload.id();
TenantEvidenceRetention retention = new TenantEvidenceRetention(upload.id(), tenant.id(),
TenantEvidenceRetention.EvidenceSource.NATIVE_TENANT, sourceReference, stored.storageKey(), 1,
retainedAt, retainedAt, upload.purpose(), upload.retentionRule(), upload.specificNeedVaultPublicKey(),
upload.encryptedSpecificNeedExplanation(), upload.specificNeedEndsOn());
return new TenantAttachment(stored.id(), stored.cryptoVersion(), stored.nonce(), stored.wrappedKey(),
stored.sha256(), stored.ciphertextSize(), stored.vaultPublicKey(), stored.storageKey(), retention,
null);
}
private static String attachmentId(RentalApplicationId applicationId, EncryptedApplicationDocument document) {
try {
byte[] digest = MessageDigest.getInstance("SHA-256")
.digest((applicationId.value() + "/" + document.id() + "/" + document.revision())
.getBytes(StandardCharsets.UTF_8));
return "retained-" + HexFormat.of().formatHex(digest).substring(0, 48);
} catch (NoSuchAlgorithmException failure) {
throw new IllegalStateException("SHA-256 is unavailable.", failure);
}
}
private static boolean same(TenantAttachment attachment, TenantAttachmentStorage.Upload upload) {
return !attachment.isOriginalApplication() && attachment.id().equals(upload.id())
&& attachment.cryptoVersion() == upload.cryptoVersion() && attachment.nonce().equals(upload.nonce())
&& attachment.wrappedKey().equals(upload.wrappedKey()) && attachment.sha256().equals(upload.sha256())
&& attachment.ciphertextSize() == upload.ciphertextSize()
&& attachment.vaultPublicKey().equals(upload.vaultPublicKey());
}
private static boolean sameRetention(TenantAttachment attachment, TenantAttachmentStorage.Upload upload) {
return attachment.retention() != null && attachment.retention().purpose() == upload.purpose()
&& attachment.retention().rule() == upload.retentionRule()
&& Objects.equals(attachment.retention().specificNeedVaultPublicKey(),
upload.specificNeedVaultPublicKey())
&& Objects.equals(attachment.retention().encryptedSpecificNeedExplanation(),
upload.encryptedSpecificNeedExplanation())
&& Objects.equals(attachment.retention().specificNeedEndsOn(), upload.specificNeedEndsOn());
}
private void cleanupStored(TenantAttachment attachment, RuntimeException failure) {
try {
storage.discardStored(attachment);
} catch (RuntimeException cleanupFailure) {
if (failure != null) {
failure.addSuppressed(cleanupFailure);
} else {
throw cleanupFailure;
}
}
}
private void rollbackUncommitted(TenantAttachment stored, TenantAttachmentStorage.StagedDeletion deletion) {
RuntimeException failure = null;
try {
storage.discardStored(stored);
} catch (RuntimeException cleanupFailure) {
failure = cleanupFailure;
}
if (deletion != null) {
try {
storage.restore(deletion);
} catch (RuntimeException restoreFailure) {
if (failure == null) {
failure = restoreFailure;
} else {
failure.addSuppressed(restoreFailure);
}
}
}
if (failure != null) {
throw failure;
}
}
private void restore(TenantAttachmentStorage.StagedDeletion deletion, RuntimeException failure) {
try {
storage.restore(deletion);
@@ -88,4 +390,7 @@ public final class TenantAttachmentService {
public record Download(TenantAttachment attachment, InputStream content) {
}
private record UploadResult(TenantAttachment attachment, boolean committed) {
}
}
@@ -1,21 +1,31 @@
package fr.nessar.lmnporganizer.rental.application.tenant;
import fr.nessar.lmnporganizer.rental.application.port.out.IdentifierGenerator;
import fr.nessar.lmnporganizer.rental.application.port.out.TransactionRunner;
import fr.nessar.lmnporganizer.rental.application.rentalapplication.ApplicationVaultAccess;
import fr.nessar.lmnporganizer.rental.application.rentalapplication.ApplicationVaultRequired;
import fr.nessar.lmnporganizer.rental.application.shared.ConcurrentChange;
import fr.nessar.lmnporganizer.rental.application.shared.ResourceNotFound;
import fr.nessar.lmnporganizer.rental.domain.application.ApplicationVault;
import fr.nessar.lmnporganizer.rental.domain.application.ApplicationVaultRepository;
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
import fr.nessar.lmnporganizer.rental.domain.tenant.Tenant;
import fr.nessar.lmnporganizer.rental.domain.tenant.TenantId;
import fr.nessar.lmnporganizer.rental.domain.tenant.TenantProfile;
import fr.nessar.lmnporganizer.rental.domain.tenant.TenantRepository;
import java.util.List;
public final class TenantDirectoryService {
private final TenantRepository tenants;
private final IdentifierGenerator identifiers;
private final ApplicationVaultRepository vaults;
private final ApplicationVaultAccess vaultAccess;
private final TransactionRunner transactions;
public TenantDirectoryService(TenantRepository tenants, IdentifierGenerator identifiers) {
public TenantDirectoryService(TenantRepository tenants, ApplicationVaultRepository vaults,
ApplicationVaultAccess vaultAccess, TransactionRunner transactions) {
this.tenants = tenants;
this.identifiers = identifiers;
this.vaults = vaults;
this.vaultAccess = vaultAccess;
this.transactions = transactions;
}
public List<Tenant> list(LandlordId landlordId) {
@@ -26,21 +36,31 @@ public final class TenantDirectoryService {
return tenants.find(landlordId, tenantId).orElseThrow(() -> new ResourceNotFound("Tenant"));
}
public Tenant create(LandlordId landlordId, ContactDetails details) {
return tenants.save(new Tenant(new TenantId(identifiers.next()), landlordId, details.firstName(),
details.lastName(), details.email(), details.phone(), details.address(), List.of()));
public Tenant create(LandlordId landlordId, TenantId tenantId, TenantProfile profile) {
return transactions.run(() -> {
fenceCurrentVault(landlordId, profile);
return tenants.save(new Tenant(tenantId, landlordId, profile, List.of()));
});
}
public Tenant revise(LandlordId landlordId, TenantId tenantId, ContactDetails details, long expectedVersion) {
Tenant tenant = get(landlordId, tenantId);
if (tenant.version() != expectedVersion) {
throw new ConcurrentChange("Tenant");
public Tenant revise(LandlordId landlordId, TenantId tenantId, TenantProfile profile, long expectedVersion) {
return transactions.run(() -> {
fenceCurrentVault(landlordId, profile);
Tenant tenant = get(landlordId, tenantId);
if (tenant.version() != expectedVersion) {
throw new ConcurrentChange("Tenant");
}
tenant.reviseProfile(profile);
return tenants.save(tenant);
});
}
private void fenceCurrentVault(LandlordId landlordId, TenantProfile profile) {
vaultAccess.requireLandlordUse(landlordId);
ApplicationVault vault = vaults.find(landlordId).orElseThrow(ApplicationVaultRequired::new);
if (!vault.publicKey().equals(profile.vaultPublicKey())) {
throw new ApplicationVaultRequired();
}
tenant.updateContactDetails(details.firstName(), details.lastName(), details.email(), details.phone(),
details.address());
return tenants.save(tenant);
}
public record ContactDetails(String firstName, String lastName, String email, String phone, String address) {
vaults.save(vault);
}
}
@@ -0,0 +1,125 @@
package fr.nessar.lmnporganizer.rental.application.tenant;
import fr.nessar.lmnporganizer.rental.application.rentalapplication.ApplicationVaultAccess;
import fr.nessar.lmnporganizer.rental.domain.lease.Lease;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseRepository;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseStatus;
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
import fr.nessar.lmnporganizer.rental.domain.tenant.Tenant;
import fr.nessar.lmnporganizer.rental.domain.tenant.TenantAccountClosure;
import fr.nessar.lmnporganizer.rental.domain.tenant.TenantEvidenceRetention;
import fr.nessar.lmnporganizer.rental.domain.tenant.TenantId;
import java.time.Clock;
import java.time.Instant;
import java.time.LocalDate;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.stream.Collectors;
/**
* Evaluates persisted evidence rules for the tenant API. This is deliberately
* an application service, not a deletion engine: it only reports the rule state
* and the references that a later bounded purge operation may consume.
*/
public final class TenantEvidenceRetentionService {
private final LeaseRepository leases;
private final ApplicationVaultAccess vaultAccess;
private final Clock clock;
public TenantEvidenceRetentionService(LeaseRepository leases, ApplicationVaultAccess vaultAccess, Clock clock) {
this.leases = Objects.requireNonNull(leases, "Lease repository is required.");
this.vaultAccess = Objects.requireNonNull(vaultAccess, "Application vault access is required.");
this.clock = Objects.requireNonNull(clock, "Retention clock is required.");
}
public Map<String, TenantEvidenceRetention.Evaluation> evaluate(Tenant tenant) {
Objects.requireNonNull(tenant, "Tenant is required.");
if (tenant.attachments().stream().noneMatch(attachment -> attachment.retention() != null)) {
return Map.of();
}
return evaluate(tenant, leases.findAll(tenant.landlordId()), LocalDate.now(clock),
retirementEligibility(List.of(tenant)));
}
/**
* Evaluates one landlord's tenant list using one lease read. Tenant list
* responses use this batch boundary so a tenant without retained evidence does
* not trigger an unrelated lease query and a portfolio does not perform one
* full lease read per tenant.
*/
public Map<TenantId, Map<String, TenantEvidenceRetention.Evaluation>> evaluateAll(List<Tenant> tenants) {
Objects.requireNonNull(tenants, "Tenants are required.");
if (tenants.isEmpty()) {
return Map.of();
}
List<Tenant> retainedTenants = tenants.stream()
.filter(tenant -> tenant.attachments().stream().anyMatch(attachment -> attachment.retention() != null))
.toList();
Map<TenantId, Map<String, TenantEvidenceRetention.Evaluation>> evaluations = new HashMap<>();
for (Tenant tenant : tenants) {
evaluations.put(tenant.id(), Map.of());
}
if (retainedTenants.isEmpty()) {
return Map.copyOf(evaluations);
}
Map<LandlordId, List<Lease>> leasesByLandlord = retainedTenants.stream().map(Tenant::landlordId).distinct()
.collect(Collectors.toUnmodifiableMap(landlordId -> landlordId, leases::findAll));
Map<VaultReference, Optional<Instant>> retirementsByVault = retirementEligibility(retainedTenants);
LocalDate today = LocalDate.now(clock);
for (Tenant tenant : retainedTenants) {
evaluations.put(tenant.id(),
evaluate(tenant, leasesByLandlord.get(tenant.landlordId()), today, retirementsByVault));
}
return Map.copyOf(evaluations);
}
private Map<String, TenantEvidenceRetention.Evaluation> evaluate(Tenant tenant, List<Lease> ownerLeases,
LocalDate today, Map<VaultReference, Optional<Instant>> retirementEligibility) {
boolean activeLease = ownerLeases.stream().anyMatch(lease -> activeFor(lease, tenant));
// Until an explicit account closure is recorded, a retained item remains
// useful for tenant management and has no calculated terminal deadline.
boolean laterRentalPeriod = tenant.accountClosure() != null && ownerLeases.stream()
.anyMatch(lease -> laterRentalPurposePeriod(lease, tenant, tenant.accountClosure()));
boolean rentalPurposeActive = tenant.accountClosure() == null || activeLease || laterRentalPeriod;
return tenant.attachments().stream().filter(attachment -> attachment.retention() != null)
.collect(Collectors.toUnmodifiableMap(attachment -> attachment.id(), attachment -> {
var retirement = Objects.requireNonNull(
retirementEligibility
.get(new VaultReference(tenant.landlordId(), attachment.vaultPublicKey())),
"Vault retirement lookup is required.");
if (retirement.isPresent()) {
LocalDate eligibleOn = retirement.get().atZone(clock.getZone()).toLocalDate();
return new TenantEvidenceRetention.Evaluation(
TenantEvidenceRetention.RetentionStatus.PURGE_ELIGIBLE, eligibleOn);
}
return attachment.retention().evaluate(today, tenant.accountClosure(), rentalPurposeActive);
}));
}
private Map<VaultReference, Optional<Instant>> retirementEligibility(List<Tenant> tenants) {
return tenants.stream()
.flatMap(tenant -> tenant.attachments().stream().filter(attachment -> attachment.retention() != null)
.map(attachment -> new VaultReference(tenant.landlordId(), attachment.vaultPublicKey())))
.distinct()
.collect(Collectors.toUnmodifiableMap(reference -> reference,
reference -> Objects.requireNonNull(
vaultAccess.retirementEligibleAt(reference.landlordId(), reference.vaultPublicKey()),
"Vault retirement lookup is required.")));
}
private static boolean activeFor(Lease lease, Tenant tenant) {
return lease.status() == LeaseStatus.ACTIVE && lease.tenantIds().contains(tenant.id());
}
private static boolean laterRentalPurposePeriod(Lease lease, Tenant tenant, TenantAccountClosure closure) {
return lease.status() != LeaseStatus.DRAFT && lease.tenantIds().contains(tenant.id())
&& !lease.period().startDate().isBefore(closure.closedOn());
}
private record VaultReference(LandlordId landlordId, String vaultPublicKey) {
}
}
@@ -11,5 +11,11 @@ public interface ApartmentRepository {
Apartment save(Apartment apartment);
/**
* Coordinates a lease-document source read without changing the business
* version.
*/
void coordinateForLeaseDocument(Apartment apartment);
boolean delete(Apartment apartment);
}
@@ -2,11 +2,11 @@ package fr.nessar.lmnporganizer.rental.domain.application;
import java.util.Base64;
final class ApplicationCryptoValue {
public final class ApplicationCryptoValue {
private ApplicationCryptoValue() {
}
static String encoded(String value, String field, int minimumBytes, int maximumBytes) {
public static String encoded(String value, String field, int minimumBytes, int maximumBytes) {
if (value == null || value.isBlank()) {
throw new IllegalArgumentException(field + " is required.");
}
@@ -22,7 +22,7 @@ final class ApplicationCryptoValue {
return value;
}
static int version(int version) {
public static int version(int version) {
if (version != 1) {
throw new IllegalArgumentException("Unsupported application cryptographic format.");
}
@@ -2,6 +2,8 @@ package fr.nessar.lmnporganizer.rental.domain.application;
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
public final class ApplicationVaultReset {
@@ -9,10 +11,11 @@ public final class ApplicationVaultReset {
private final String operationId;
private final Instant startedAt;
private final Instant completedAt;
private final List<ApplicationVaultRetirement> retirements;
private final long version;
private ApplicationVaultReset(LandlordId landlordId, String operationId, Instant startedAt, Instant completedAt,
long version) {
List<ApplicationVaultRetirement> retirements, long version) {
this.landlordId = Objects.requireNonNull(landlordId, "Landlord ID is required.");
if (operationId == null || operationId.isBlank()) {
throw new IllegalArgumentException("Application vault reset operation ID is required.");
@@ -23,29 +26,50 @@ public final class ApplicationVaultReset {
throw new IllegalArgumentException("Application vault reset completion time is invalid.");
}
this.completedAt = completedAt;
this.retirements = List
.copyOf(Objects.requireNonNull(retirements, "Application vault retirements are required."));
if (this.retirements.isEmpty()
|| this.retirements.stream().noneMatch(retirement -> retirement.operationId().equals(operationId))) {
throw new IllegalArgumentException("The current application vault retirement is required.");
}
ApplicationVaultRetirement currentRetirement = this.retirements.stream()
.filter(retirement -> retirement.operationId().equals(operationId)).findFirst().orElseThrow();
if (!currentRetirement.eligibleAt().equals(startedAt)) {
throw new IllegalArgumentException("Current application vault eligibility must equal its start time.");
}
if (this.retirements.stream().anyMatch(retirement -> retirement.eligibleAt().isAfter(startedAt))) {
throw new IllegalArgumentException("Application vault retirement eligibility cannot be in the future.");
}
if (this.retirements.stream().map(ApplicationVaultRetirement::operationId).distinct()
.count() != this.retirements.size()) {
throw new IllegalArgumentException("Application vault retirement operation IDs must be unique.");
}
if (version < -1) {
throw new IllegalArgumentException("Application vault reset version cannot be less than -1.");
}
this.version = version;
}
public static ApplicationVaultReset start(LandlordId landlordId, String operationId, Instant startedAt) {
return new ApplicationVaultReset(landlordId, operationId, startedAt, null, -1);
public static ApplicationVaultReset start(LandlordId landlordId, String operationId, Instant startedAt,
String vaultPublicKey) {
return new ApplicationVaultReset(landlordId, operationId, startedAt, null,
List.of(new ApplicationVaultRetirement(operationId, vaultPublicKey, startedAt)), -1);
}
public static ApplicationVaultReset reconstitute(LandlordId landlordId, String operationId, Instant startedAt,
Instant completedAt, long version) {
return new ApplicationVaultReset(landlordId, operationId, startedAt, completedAt, version);
Instant completedAt, List<ApplicationVaultRetirement> retirements, long version) {
return new ApplicationVaultReset(landlordId, operationId, startedAt, completedAt, retirements, version);
}
public ApplicationVaultReset complete(Instant completedAt) {
if (!inProgress())
return this;
return new ApplicationVaultReset(landlordId, operationId, startedAt,
Objects.requireNonNull(completedAt, "Application vault reset completion time is required."), version);
Objects.requireNonNull(completedAt, "Application vault reset completion time is required."),
retirements, version);
}
public ApplicationVaultReset restart(String operationId, Instant startedAt) {
public ApplicationVaultReset restart(String operationId, Instant startedAt, String vaultPublicKey) {
if (inProgress()) {
throw new IllegalStateException("An application vault reset is already in progress.");
}
@@ -53,7 +77,9 @@ public final class ApplicationVaultReset {
if (restartedAt.isBefore(completedAt)) {
throw new IllegalArgumentException("Application vault reset start time is invalid.");
}
return new ApplicationVaultReset(landlordId, operationId, restartedAt, null, version);
List<ApplicationVaultRetirement> updatedRetirements = new ArrayList<>(retirements);
updatedRetirements.add(new ApplicationVaultRetirement(operationId, vaultPublicKey, restartedAt));
return new ApplicationVaultReset(landlordId, operationId, restartedAt, null, updatedRetirements, version);
}
public boolean inProgress() {
@@ -76,6 +102,25 @@ public final class ApplicationVaultReset {
return completedAt;
}
public List<ApplicationVaultRetirement> retirements() {
return retirements;
}
public boolean hasOperation(String candidateOperationId) {
return operationId.equals(candidateOperationId)
|| retirements.stream().anyMatch(retirement -> retirement.operationId().equals(candidateOperationId));
}
public boolean retired(String vaultPublicKey) {
return vaultPublicKey != null
&& retirements.stream().anyMatch(retirement -> retirement.vaultPublicKey().equals(vaultPublicKey));
}
public ApplicationVaultRetirement retirement(String candidateOperationId) {
return retirements.stream().filter(retirement -> retirement.operationId().equals(candidateOperationId))
.findFirst().orElseThrow(() -> new IllegalStateException("Application vault retirement is missing."));
}
public long version() {
return version;
}
@@ -0,0 +1,26 @@
package fr.nessar.lmnporganizer.rental.domain.application;
import java.time.Instant;
import java.util.Objects;
/**
* Public metadata for one retired vault generation.
*
* <p>
* The key is intentionally retained only as a generation marker. It is never a
* decryption secret and lets protected-record owners reject an old generation
* without deleting the records that issue #39 must purge.
*/
public record ApplicationVaultRetirement(String operationId, String vaultPublicKey, Instant eligibleAt) {
public ApplicationVaultRetirement {
if (operationId == null || operationId.isBlank()) {
throw new IllegalArgumentException("Application vault retirement operation ID is required.");
}
vaultPublicKey = ApplicationCryptoValue.encoded(vaultPublicKey, "Retired vault public key", 384, 1024);
eligibleAt = Objects.requireNonNull(eligibleAt, "Application vault retirement eligibility time is required.");
}
public String oldPublicKey() {
return vaultPublicKey;
}
}
@@ -7,16 +7,21 @@ import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
public final class RentalApplication {
public static final Duration RETENTION_PERIOD = Duration.ofDays(90);
private final RentalApplicationId id;
private final ApartmentId apartmentId;
private final LandlordId landlordId;
private final String vaultPublicKey;
private RentalApplicationState state;
private int revision;
private final Instant createdAt;
@@ -32,13 +37,14 @@ public final class RentalApplication {
private final long version;
private RentalApplication(RentalApplicationId id, ApartmentId apartmentId, LandlordId landlordId,
RentalApplicationState state, int revision, Instant createdAt, Instant lastActivityAt, Instant submittedAt,
Instant purgeEligibleAt, TenantId selectedTenantId, List<EncryptedApplicationData> encryptedData,
List<EncryptedApplicationDocument> documents, String capabilityHash, String encryptedApplicationKey,
int cryptoVersion, long version) {
String vaultPublicKey, RentalApplicationState state, int revision, Instant createdAt,
Instant lastActivityAt, Instant submittedAt, Instant purgeEligibleAt, TenantId selectedTenantId,
List<EncryptedApplicationData> encryptedData, List<EncryptedApplicationDocument> documents,
String capabilityHash, String encryptedApplicationKey, int cryptoVersion, long version) {
this.id = Objects.requireNonNull(id, "Rental application ID is required.");
this.apartmentId = Objects.requireNonNull(apartmentId, "Apartment ID is required.");
this.landlordId = Objects.requireNonNull(landlordId, "Landlord ID is required.");
this.vaultPublicKey = ApplicationCryptoValue.encoded(vaultPublicKey, "Application vault public key", 384, 1024);
this.state = Objects.requireNonNull(state, "Rental application state is required.");
if (revision < 0 || version < -1) {
throw new IllegalArgumentException("Rental application revisions are invalid.");
@@ -88,19 +94,21 @@ public final class RentalApplication {
}
public static RentalApplication draft(RentalApplicationId id, ApartmentId apartmentId, LandlordId landlordId,
Instant createdAt, String capabilityHash, String encryptedApplicationKey, int cryptoVersion) {
return new RentalApplication(id, apartmentId, landlordId, RentalApplicationState.DRAFT, 0, createdAt, createdAt,
null, null, null, List.of(), List.of(), capabilityHash, encryptedApplicationKey, cryptoVersion, -1);
String vaultPublicKey, Instant createdAt, String capabilityHash, String encryptedApplicationKey,
int cryptoVersion) {
return new RentalApplication(id, apartmentId, landlordId, vaultPublicKey, RentalApplicationState.DRAFT, 0,
createdAt, createdAt, null, null, null, List.of(), List.of(), capabilityHash, encryptedApplicationKey,
cryptoVersion, -1);
}
public static RentalApplication reconstitute(RentalApplicationId id, ApartmentId apartmentId, LandlordId landlordId,
RentalApplicationState state, int revision, Instant createdAt, Instant lastActivityAt, Instant submittedAt,
Instant purgeEligibleAt, TenantId selectedTenantId, List<EncryptedApplicationData> encryptedData,
List<EncryptedApplicationDocument> documents, String capabilityHash, String encryptedApplicationKey,
int cryptoVersion, long version) {
return new RentalApplication(id, apartmentId, landlordId, state, revision, createdAt, lastActivityAt,
submittedAt, purgeEligibleAt, selectedTenantId, encryptedData, documents, capabilityHash,
encryptedApplicationKey, cryptoVersion, version);
String vaultPublicKey, RentalApplicationState state, int revision, Instant createdAt,
Instant lastActivityAt, Instant submittedAt, Instant purgeEligibleAt, TenantId selectedTenantId,
List<EncryptedApplicationData> encryptedData, List<EncryptedApplicationDocument> documents,
String capabilityHash, String encryptedApplicationKey, int cryptoVersion, long version) {
return new RentalApplication(id, apartmentId, landlordId, vaultPublicKey, state, revision, createdAt,
lastActivityAt, submittedAt, purgeEligibleAt, selectedTenantId, encryptedData, documents,
capabilityHash, encryptedApplicationKey, cryptoVersion, version);
}
public void saveIdentityContact(int expectedRevision, EncryptedApplicationData encryptedData, Instant savedAt) {
@@ -112,7 +120,7 @@ public final class RentalApplication {
public void saveData(int expectedRevision, EncryptedApplicationData encryptedData, Instant savedAt) {
requireRevision(expectedRevision);
if (state != RentalApplicationState.DRAFT) {
if (!open()) {
throw new ApplicationOperationNotAllowed();
}
EncryptedApplicationData data = Objects.requireNonNull(encryptedData, "Encrypted data is required.");
@@ -138,18 +146,6 @@ public final class RentalApplication {
this.submittedAt = submittedAt;
}
public void returnToDraft(int expectedRevision, Instant returnedAt) {
requireRevision(expectedRevision);
if (state != RentalApplicationState.SUBMITTED) {
throw new ApplicationOperationNotAllowed();
}
Instant activityAt = requireCurrentOrFuture(returnedAt);
state = RentalApplicationState.DRAFT;
revision++;
lastActivityAt = activityAt;
submittedAt = null;
}
public void withdraw(int expectedRevision, Instant withdrawnAt) {
terminate(expectedRevision, withdrawnAt, RentalApplicationState.WITHDRAWN,
state == RentalApplicationState.DRAFT || state == RentalApplicationState.SUBMITTED);
@@ -167,6 +163,35 @@ public final class RentalApplication {
selectedTenantId = selected;
}
/**
* Terminalizes selection and removes only the source document metadata that was
* explicitly retained by the tenant. The returned metadata is the exact source
* snapshot that a tenant retention reference must carry; ciphertext stays in
* application storage and is never touched by this domain operation.
*/
public List<EncryptedApplicationDocument> removeSelectedDocuments(int sourceRevision, TenantId tenantId,
Collection<String> retainedDocumentIds) {
Objects.requireNonNull(retainedDocumentIds, "Retained document IDs are required.");
if (state != RentalApplicationState.SELECTED || !tenantId.equals(selectedTenantId)
|| revision != sourceRevision + 1) {
throw new ApplicationRevisionConflict();
}
Set<String> ids = new HashSet<>();
List<EncryptedApplicationDocument> retained = new ArrayList<>();
for (String documentId : retainedDocumentIds) {
if (!ids.add(documentId)) {
throw new IllegalArgumentException("A document can only be retained once.");
}
EncryptedApplicationDocument document = documents.get(documentId);
if (document == null) {
throw new ApplicationRevisionConflict();
}
retained.add(document);
}
retained.forEach(document -> documents.remove(document.id()));
return List.copyOf(retained);
}
public boolean expire(int expectedRevision, Instant observedAt) {
if (state == RentalApplicationState.EXPIRED)
return false;
@@ -205,7 +230,7 @@ public final class RentalApplication {
public EncryptedApplicationDocument putDocument(int expectedRevision, EncryptedApplicationDocument document,
Instant savedAt, long maximumTotalSize) {
Objects.requireNonNull(document, "Encrypted application document is required.");
requireDraftMutation(expectedRevision, document.revision(), savedAt);
requireOpenMutation(expectedRevision, document.revision(), savedAt);
EncryptedApplicationDocument previous = documents.get(document.id());
long replacedSize = previous == null ? 0 : previous.encryptedSize();
if (encryptedDocumentSize() - replacedSize + document.encryptedSize() > maximumTotalSize) {
@@ -219,7 +244,7 @@ public final class RentalApplication {
public EncryptedApplicationDocument deleteDocument(int expectedRevision, String documentId, Instant savedAt) {
requireRevision(expectedRevision);
if (state != RentalApplicationState.DRAFT || savedAt.isBefore(lastActivityAt)) {
if (!open() || savedAt.isBefore(lastActivityAt)) {
throw new ApplicationOperationNotAllowed();
}
EncryptedApplicationDocument removed = documents.remove(documentId);
@@ -231,9 +256,9 @@ public final class RentalApplication {
return removed;
}
private void requireDraftMutation(int expectedRevision, int objectRevision, Instant savedAt) {
private void requireOpenMutation(int expectedRevision, int objectRevision, Instant savedAt) {
requireRevision(expectedRevision);
if (state != RentalApplicationState.DRAFT) {
if (!open()) {
throw new ApplicationOperationNotAllowed();
}
if (objectRevision != revision + 1 || savedAt.isBefore(lastActivityAt)) {
@@ -241,6 +266,10 @@ public final class RentalApplication {
}
}
private boolean open() {
return state == RentalApplicationState.DRAFT || state == RentalApplicationState.SUBMITTED;
}
private void requireRevision(int expectedRevision) {
if (revision != expectedRevision) {
throw new ApplicationRevisionConflict();
@@ -290,6 +319,10 @@ public final class RentalApplication {
return landlordId;
}
public String vaultPublicKey() {
return vaultPublicKey;
}
public RentalApplicationState state() {
return state;
}
@@ -1,19 +1,20 @@
package fr.nessar.lmnporganizer.rental.domain.condition;
import fr.nessar.lmnporganizer.rental.domain.tenant.TenantProfile.EncryptedGroup;
import java.util.Objects;
public record AccessMeans(String id, AccessMeansType type, String destination, int quantity, String observation) {
public record AccessMeans(String id, AccessMeansType type, EncryptedGroup destination, int quantity,
EncryptedGroup observation) {
public AccessMeans {
if (id == null || id.isBlank() || destination == null || destination.isBlank() || quantity < 0) {
if (id == null || id.isBlank() || quantity < 0) {
throw new IllegalArgumentException("Access means is invalid.");
}
id = id.trim();
type = Objects.requireNonNull(type, "Access means type is required.");
destination = destination.trim();
observation = observation == null ? "" : observation.trim();
destination = Objects.requireNonNull(destination, "Access means destination envelope is required.");
}
boolean hasSameIdentityAs(AccessMeans other) {
return type == other.type && destination.equals(other.destination);
return type == other.type && Objects.equals(destination, other.destination);
}
}
@@ -1,14 +1,15 @@
package fr.nessar.lmnporganizer.rental.domain.condition;
import fr.nessar.lmnporganizer.rental.domain.tenant.TenantProfile.EncryptedGroup;
import java.time.Instant;
import java.util.Objects;
public record AuditedPhotoAbsence(String reason, String auditedBy, Instant auditedAt) {
public record AuditedPhotoAbsence(EncryptedGroup reason, String auditedBy, Instant auditedAt) {
public AuditedPhotoAbsence {
if (reason == null || reason.isBlank() || auditedBy == null || auditedBy.isBlank()) {
if (auditedBy == null || auditedBy.isBlank()) {
throw new IllegalArgumentException("Audited photo absence is invalid.");
}
reason = reason.trim();
reason = Objects.requireNonNull(reason, "Photo absence reason envelope is required.");
auditedBy = auditedBy.trim();
auditedAt = Objects.requireNonNull(auditedAt, "Photo absence audit time is required.");
}
@@ -1,14 +1,15 @@
package fr.nessar.lmnporganizer.rental.domain.condition;
public record ConditionObservation(String subjectId, ConditionState state, String observation) {
import fr.nessar.lmnporganizer.rental.domain.tenant.TenantProfile.EncryptedGroup;
public record ConditionObservation(String subjectId, ConditionState state, EncryptedGroup observation) {
public ConditionObservation {
if (subjectId == null || subjectId.isBlank() || state == null) {
throw new IllegalArgumentException("Condition observation is invalid.");
}
subjectId = subjectId.trim();
observation = observation == null ? "" : observation.trim();
if ((state == ConditionState.DEGRADE || state == ConditionState.ABSENT
|| state == ConditionState.NON_APPLICABLE) && observation.isEmpty()) {
|| state == ConditionState.NON_APPLICABLE) && observation == null) {
throw new PropertyConditionReportRuleViolation(
PropertyConditionReportRuleViolation.Rule.OBSERVATION_REQUIRED,
"Une observation est obligatoire pour cet état.");
@@ -1,11 +1,14 @@
package fr.nessar.lmnporganizer.rental.domain.condition;
public record ForwardingAddress(String tenantPartyId, String address) {
import fr.nessar.lmnporganizer.rental.domain.tenant.TenantProfile.EncryptedGroup;
import java.util.Objects;
public record ForwardingAddress(String tenantPartyId, EncryptedGroup address) {
public ForwardingAddress {
if (tenantPartyId == null || tenantPartyId.isBlank() || address == null || address.isBlank()) {
throw new IllegalArgumentException("Forwarding address is invalid.");
if (tenantPartyId == null || tenantPartyId.isBlank()) {
throw new IllegalArgumentException("Forwarding address tenant is invalid.");
}
tenantPartyId = tenantPartyId.trim();
address = address.trim();
address = Objects.requireNonNull(address, "Forwarding address envelope is required.");
}
}
@@ -1,10 +1,11 @@
package fr.nessar.lmnporganizer.rental.domain.condition;
import fr.nessar.lmnporganizer.rental.domain.tenant.TenantProfile.EncryptedGroup;
import java.util.List;
public record InventoryCondition(String inventoryLineId, String privateAreaId, String privateAreaLabel,
String designation, int contractualQuantity, String contractualDescription, int observedQuantity,
String observedDescription, List<InventoryQuantitySplit> splits) {
EncryptedGroup observedDescription, List<InventoryQuantitySplit> splits, boolean descriptionReconciled) {
public InventoryCondition {
if (inventoryLineId == null || inventoryLineId.isBlank() || privateAreaId == null || privateAreaId.isBlank()
|| privateAreaLabel == null || privateAreaLabel.isBlank() || designation == null
@@ -17,7 +18,6 @@ public record InventoryCondition(String inventoryLineId, String privateAreaId, S
privateAreaLabel = privateAreaLabel.trim();
designation = designation.trim();
contractualDescription = contractualDescription == null ? "" : contractualDescription.trim();
observedDescription = observedDescription == null ? "" : observedDescription.trim();
splits = splits == null ? List.of() : List.copyOf(splits);
int declaredQuantity = splits.stream().mapToInt(InventoryQuantitySplit::quantity).sum();
if (splits.stream().anyMatch(split -> !lineId.equals(split.inventoryLineId()))) {
@@ -33,6 +33,6 @@ public record InventoryCondition(String inventoryLineId, String privateAreaId, S
}
public boolean differsFromContract() {
return contractualQuantity != observedQuantity || !contractualDescription.equals(observedDescription);
return contractualQuantity != observedQuantity || (observedDescription != null && !descriptionReconciled);
}
}
@@ -0,0 +1,9 @@
package fr.nessar.lmnporganizer.rental.domain.condition;
/**
* Operational text-free reasons retained when a report has no private
* observation text.
*/
public enum InventoryObservationCode {
ABSENT_FROM_CONTRACTUAL_INVENTORY
}
@@ -1,17 +1,30 @@
package fr.nessar.lmnporganizer.rental.domain.condition;
public record InventoryQuantitySplit(String inventoryLineId, ConditionState state, int quantity, String observation) {
import fr.nessar.lmnporganizer.rental.domain.tenant.TenantProfile.EncryptedGroup;
public record InventoryQuantitySplit(String inventoryLineId, ConditionState state, int quantity,
EncryptedGroup observation, InventoryObservationCode observationCode) {
public InventoryQuantitySplit {
if (inventoryLineId == null || inventoryLineId.isBlank() || state == null || quantity < 0) {
throw new IllegalArgumentException("Inventory quantity split is invalid.");
}
inventoryLineId = inventoryLineId.trim();
observation = observation == null ? "" : observation.trim();
if (observation != null && observationCode != null) {
throw new IllegalArgumentException("Inventory split observation cannot mix text and an operational code.");
}
if ((state == ConditionState.DEGRADE || state == ConditionState.ABSENT
|| state == ConditionState.NON_APPLICABLE) && observation.isEmpty()) {
|| state == ConditionState.NON_APPLICABLE) && observation == null && observationCode == null) {
throw new PropertyConditionReportRuleViolation(
PropertyConditionReportRuleViolation.Rule.INVENTORY_OBSERVATION_REQUIRED,
"Une observation est obligatoire pour cet état.");
}
if (observationCode != null && state != ConditionState.ABSENT) {
throw new IllegalArgumentException("An inventory observation code requires an absent state.");
}
}
public InventoryQuantitySplit(String inventoryLineId, ConditionState state, int quantity,
EncryptedGroup observation) {
this(inventoryLineId, state, quantity, observation, null);
}
}
@@ -1,19 +1,19 @@
package fr.nessar.lmnporganizer.rental.domain.condition;
import fr.nessar.lmnporganizer.rental.domain.tenant.TenantProfile.EncryptedGroup;
import java.time.Instant;
import java.time.LocalDate;
import java.util.Objects;
public record LateSupplementAddition(String ruleVersion, LocalDate deadlineOn, String reason, String auditedBy,
public record LateSupplementAddition(String ruleVersion, LocalDate deadlineOn, EncryptedGroup reason, String auditedBy,
Instant auditedAt) implements SupplementAudit {
public LateSupplementAddition {
if (ruleVersion == null || ruleVersion.isBlank() || reason == null || reason.isBlank() || auditedBy == null
|| auditedBy.isBlank()) {
if (ruleVersion == null || ruleVersion.isBlank() || auditedBy == null || auditedBy.isBlank()) {
throw new IllegalArgumentException("Late supplement addition is invalid.");
}
ruleVersion = ruleVersion.trim();
deadlineOn = Objects.requireNonNull(deadlineOn, "Late supplement deadline is required.");
reason = reason.trim();
reason = Objects.requireNonNull(reason, "Late supplement reason envelope is required.");
auditedBy = auditedBy.trim();
auditedAt = Objects.requireNonNull(auditedAt, "Late supplement audit time is required.");
}
@@ -1,17 +1,16 @@
package fr.nessar.lmnporganizer.rental.domain.condition;
import fr.nessar.lmnporganizer.rental.domain.tenant.TenantProfile.EncryptedGroup;
import java.math.BigDecimal;
import java.util.Objects;
public record MeterReading(String id, MeterType type, MeterReadingState state, BigDecimal index, String unit,
String label, String observation) {
EncryptedGroup label, EncryptedGroup observation) {
public MeterReading {
id = required(id, "Meter ID");
type = Objects.requireNonNull(type, "Meter type is required.");
state = Objects.requireNonNull(state, "Meter reading state is required.");
unit = required(unit, "Meter unit");
label = label == null ? "" : label.trim();
observation = observation == null ? "" : observation.trim();
if (state == MeterReadingState.RECORDED && (index == null || index.signum() < 0)) {
throw new PropertyConditionReportRuleViolation(
PropertyConditionReportRuleViolation.Rule.RECORDED_METER_INDEX_MUST_BE_NON_NEGATIVE,
@@ -22,12 +21,12 @@ public record MeterReading(String id, MeterType type, MeterReadingState state, B
PropertyConditionReportRuleViolation.Rule.INACCESSIBLE_METER_MUST_NOT_HAVE_INDEX,
"Le relevé de compteur est invalide.");
}
if (state == MeterReadingState.INACCESSIBLE && observation.isEmpty()) {
if (state == MeterReadingState.INACCESSIBLE && observation == null) {
throw new PropertyConditionReportRuleViolation(
PropertyConditionReportRuleViolation.Rule.INACCESSIBLE_METER_OBSERVATION_REQUIRED,
"Le relevé de compteur est invalide.");
}
if (type == MeterType.OTHER && label.isEmpty()) {
if (type == MeterType.OTHER && label == null) {
throw new PropertyConditionReportRuleViolation(
PropertyConditionReportRuleViolation.Rule.OTHER_METER_LABEL_REQUIRED,
"Le relevé de compteur est invalide.");
@@ -35,7 +34,7 @@ public record MeterReading(String id, MeterType type, MeterReadingState state, B
}
boolean hasSameIdentityAs(MeterReading other) {
return type == other.type && unit.equals(other.unit) && label.equals(other.label);
return type == other.type && unit.equals(other.unit) && Objects.equals(label, other.label);
}
private static String required(String value, String field) {
@@ -2,6 +2,7 @@ package fr.nessar.lmnporganizer.rental.domain.condition;
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseId;
import fr.nessar.lmnporganizer.rental.domain.tenant.TenantProfile.EncryptedGroup;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
@@ -23,6 +24,13 @@ public final class PropertyConditionReport {
private final LandlordId landlordId;
private final LeaseId leaseId;
private final PropertyConditionReportType type;
/**
* The vault generation used for every encrypted value in this report. This is
* report metadata rather than a property of any one encrypted group: a draft
* may not have a revision or finalization yet, so those documents cannot be
* used as an authoritative source of the generation.
*/
private final String vaultPublicKey;
private final long version;
private final EntryReportSnapshot entrySnapshot;
private LocalDate occurredOn;
@@ -46,11 +54,11 @@ public final class PropertyConditionReport {
private PropertyConditionReportStatus status;
private PropertyConditionReport(PropertyConditionReportId id, LandlordId landlordId, LeaseId leaseId,
PropertyConditionReportType type, LocalDate occurredOn, String timeZone, boolean noIndividualMeterDeclared,
boolean noAccessMeansDeclared, ReportStepConfirmations confirmations, List<ReportParticipant> participants,
List<ForwardingAddress> forwardingAddresses, List<ConditionZone> zones,
List<ConditionObservation> observations, List<MeterReading> meterReadings, List<AccessMeans> accessMeans,
List<InventoryCondition> inventoryConditions, List<ConditionPhoto> photos,
PropertyConditionReportType type, String vaultPublicKey, LocalDate occurredOn, String timeZone,
boolean noIndividualMeterDeclared, boolean noAccessMeansDeclared, ReportStepConfirmations confirmations,
List<ReportParticipant> participants, List<ForwardingAddress> forwardingAddresses,
List<ConditionZone> zones, List<ConditionObservation> observations, List<MeterReading> meterReadings,
List<AccessMeans> accessMeans, List<InventoryCondition> inventoryConditions, List<ConditionPhoto> photos,
List<ZonePhotoAbsence> zonePhotoAbsences, List<PropertyConditionReportSupplement> supplements,
List<PropertyConditionReportRevision> revisions, PropertyConditionReportFinalization finalization,
List<PropertyConditionReportDeliveryAttestation> deliveryAttestations, EntryReportSnapshot entrySnapshot,
@@ -59,6 +67,7 @@ public final class PropertyConditionReport {
this.landlordId = Objects.requireNonNull(landlordId);
this.leaseId = Objects.requireNonNull(leaseId);
this.type = Objects.requireNonNull(type);
this.vaultPublicKey = required(vaultPublicKey, "Property condition report vault public key");
this.occurredOn = Objects.requireNonNull(occurredOn);
this.timeZone = timeZone(timeZone);
this.noIndividualMeterDeclared = noIndividualMeterDeclared;
@@ -106,12 +115,12 @@ public final class PropertyConditionReport {
}
public static PropertyConditionReport draft(PropertyConditionReportId id, LandlordId landlordId, LeaseId leaseId,
PropertyConditionReportType type, LocalDate occurredOn, String timeZone) {
PropertyConditionReportType type, LocalDate occurredOn, String timeZone, String vaultPublicKey) {
if (type != PropertyConditionReportType.ENTRY) {
throw new IllegalArgumentException("An exit report must be created from a finalized entry report.");
}
return new PropertyConditionReport(id, landlordId, leaseId, type, occurredOn, timeZone, false, false,
ReportStepConfirmations.none(), List.of(), List.of(), List.of(), List.of(), List.of(), List.of(),
return new PropertyConditionReport(id, landlordId, leaseId, type, vaultPublicKey, occurredOn, timeZone, false,
false, ReportStepConfirmations.none(), List.of(), List.of(), List.of(), List.of(), List.of(), List.of(),
List.of(), List.of(), List.of(), List.of(), List.of(), null, List.of(), null,
PropertyConditionReportStatus.DRAFT, -1);
}
@@ -127,10 +136,10 @@ public final class PropertyConditionReport {
}
EntryReportSnapshot snapshot = snapshotOf(entry);
return new PropertyConditionReport(id, entry.landlordId, entry.leaseId, PropertyConditionReportType.EXIT,
occurredOn, timeZone, false, false, ReportStepConfirmations.none(), snapshot.participants(), List.of(),
snapshot.zones(), snapshot.observations(), snapshot.meterReadings(), snapshot.accessMeans(),
snapshot.inventoryConditions(), List.of(), List.of(), List.of(), List.of(), null, List.of(), snapshot,
PropertyConditionReportStatus.DRAFT, -1);
entry.vaultPublicKey, occurredOn, timeZone, false, false, ReportStepConfirmations.none(),
snapshot.participants(), List.of(), snapshot.zones(), snapshot.observations(), snapshot.meterReadings(),
snapshot.accessMeans(), snapshot.inventoryConditions(), List.of(), List.of(), List.of(), List.of(),
null, List.of(), snapshot, PropertyConditionReportStatus.DRAFT, -1);
}
public static PropertyConditionReport reconstitute(PropertyConditionReportId id, LandlordId landlordId,
@@ -142,8 +151,8 @@ public final class PropertyConditionReport {
List<ZonePhotoAbsence> zonePhotoAbsences, List<PropertyConditionReportSupplement> supplements,
List<PropertyConditionReportRevision> revisions, PropertyConditionReportFinalization finalization,
List<PropertyConditionReportDeliveryAttestation> deliveryAttestations, EntryReportSnapshot entrySnapshot,
PropertyConditionReportStatus status, long version) {
return new PropertyConditionReport(id, landlordId, leaseId, type, occurredOn, timeZone,
PropertyConditionReportStatus status, long version, String vaultPublicKey) {
return new PropertyConditionReport(id, landlordId, leaseId, type, vaultPublicKey, occurredOn, timeZone,
noIndividualMeterDeclared, noAccessMeansDeclared, confirmations, participants, forwardingAddresses,
zones, observations, meterReadings, accessMeans, inventoryConditions, photos, zonePhotoAbsences,
supplements, revisions, finalization, deliveryAttestations, entrySnapshot, status, version);
@@ -176,6 +185,7 @@ public final class PropertyConditionReport {
}
List<InventoryCondition> nextInventoryConditions = unique(next.inventoryConditions(),
InventoryCondition::inventoryLineId, "Inventory conditions");
nextInventoryConditions = preserveInventoryReconciliation(inventoryConditions, nextInventoryConditions);
List<ConditionPhoto> nextPhotos = retainZonePhotos(nextZones, nextInventoryConditions,
retainTechnicalPhotos(nextMeterReadings, nextAccessMeans, photos));
List<PropertyConditionReportSupplement> nextSupplements = retainSupplementPhotos(nextZones, supplements);
@@ -252,8 +262,8 @@ public final class PropertyConditionReport {
return condition;
return new InventoryCondition(condition.inventoryLineId(), condition.privateAreaId(),
condition.privateAreaLabel(), condition.designation(), condition.observedQuantity(),
condition.observedDescription(), condition.observedQuantity(), condition.observedDescription(),
condition.splits());
condition.contractualDescription(), condition.observedQuantity(), condition.observedDescription(),
condition.splits(), true);
}).toList();
if (next.equals(inventoryConditions))
return;
@@ -263,6 +273,22 @@ public final class PropertyConditionReport {
inventoryConditions = next;
}
private static List<InventoryCondition> preserveInventoryReconciliation(List<InventoryCondition> current,
List<InventoryCondition> next) {
Map<String, InventoryCondition> currentByLine = current.stream()
.collect(Collectors.toMap(InventoryCondition::inventoryLineId, value -> value));
return next.stream().map(candidate -> {
InventoryCondition previous = currentByLine.get(candidate.inventoryLineId());
boolean reconciled = previous != null && previous.descriptionReconciled()
&& Objects.equals(previous.observedDescription(), candidate.observedDescription())
&& previous.contractualDescription().equals(candidate.contractualDescription());
return new InventoryCondition(candidate.inventoryLineId(), candidate.privateAreaId(),
candidate.privateAreaLabel(), candidate.designation(), candidate.contractualQuantity(),
candidate.contractualDescription(), candidate.observedQuantity(), candidate.observedDescription(),
candidate.splits(), reconciled);
}).toList();
}
public void replacePhotos(List<ConditionPhoto> photos) {
ensureDraft();
List<ConditionPhoto> next = unique(photos, ConditionPhoto::reference, "Condition photos");
@@ -449,13 +475,14 @@ public final class PropertyConditionReport {
appendRevisionSourceField(facts, participant.partyId());
appendRevisionSourceField(facts, participant.role().name());
appendRevisionSourceField(facts, participant.name());
appendRevisionSourceEnvelope(facts, participant.identityAndAddress());
appendRevisionSourceField(facts, participant.attendance().name());
appendRevisionSourceField(facts, participant.representativeName());
appendRevisionSourceEnvelope(facts, participant.representativeName());
}
appendRevisionSourceField(facts, forwardingAddresses.size());
for (ForwardingAddress address : forwardingAddresses) {
appendRevisionSourceField(facts, address.tenantPartyId());
appendRevisionSourceField(facts, address.address());
appendRevisionSourceEnvelope(facts, address.address());
}
appendRevisionSourceField(facts, zones.size());
for (ConditionZone zone : zones) {
@@ -468,7 +495,7 @@ public final class PropertyConditionReport {
for (ConditionObservation observation : observations) {
appendRevisionSourceField(facts, observation.subjectId());
appendRevisionSourceField(facts, observation.state().name());
appendRevisionSourceField(facts, observation.observation());
appendRevisionSourceEnvelope(facts, observation.observation());
}
appendRevisionSourceField(facts, meterReadings.size());
for (MeterReading reading : meterReadings) {
@@ -477,16 +504,16 @@ public final class PropertyConditionReport {
appendRevisionSourceField(facts, reading.state().name());
appendRevisionSourceField(facts, reading.index() == null ? null : reading.index().toPlainString());
appendRevisionSourceField(facts, reading.unit());
appendRevisionSourceField(facts, reading.label());
appendRevisionSourceField(facts, reading.observation());
appendRevisionSourceEnvelope(facts, reading.label());
appendRevisionSourceEnvelope(facts, reading.observation());
}
appendRevisionSourceField(facts, accessMeans.size());
for (AccessMeans means : accessMeans) {
appendRevisionSourceField(facts, means.id());
appendRevisionSourceField(facts, means.type().name());
appendRevisionSourceField(facts, means.destination());
appendRevisionSourceEnvelope(facts, means.destination());
appendRevisionSourceField(facts, means.quantity());
appendRevisionSourceField(facts, means.observation());
appendRevisionSourceEnvelope(facts, means.observation());
}
appendRevisionSourceField(facts, inventoryConditions.size());
for (InventoryCondition condition : inventoryConditions) {
@@ -497,13 +524,16 @@ public final class PropertyConditionReport {
appendRevisionSourceField(facts, condition.contractualQuantity());
appendRevisionSourceField(facts, condition.contractualDescription());
appendRevisionSourceField(facts, condition.observedQuantity());
appendRevisionSourceField(facts, condition.observedDescription());
appendRevisionSourceEnvelope(facts, condition.observedDescription());
appendRevisionSourceField(facts, condition.descriptionReconciled());
appendRevisionSourceField(facts, condition.splits().size());
for (InventoryQuantitySplit split : condition.splits()) {
appendRevisionSourceField(facts, split.inventoryLineId());
appendRevisionSourceField(facts, split.state().name());
appendRevisionSourceField(facts, split.quantity());
appendRevisionSourceField(facts, split.observation());
appendRevisionSourceEnvelope(facts, split.observation());
appendRevisionSourceField(facts,
split.observationCode() == null ? null : split.observationCode().name());
}
}
appendRevisionSourceField(facts, noIndividualMeterDeclared);
@@ -514,13 +544,26 @@ public final class PropertyConditionReport {
appendRevisionSourceField(facts, zonePhotoAbsences.size());
for (ZonePhotoAbsence absence : zonePhotoAbsences) {
appendRevisionSourceField(facts, absence.subjectId());
appendRevisionSourceField(facts, absence.absence().reason());
appendRevisionSourceEnvelope(facts, absence.absence().reason());
}
List<PropertyConditionReportSupplement> packageSupplements = revisionSourceSupplements();
appendRevisionSourceField(facts, packageSupplements.size());
for (PropertyConditionReportSupplement supplement : packageSupplements) {
appendRevisionSourceField(facts, supplement.id());
appendRevisionSourceField(facts, supplement.type().name());
appendRevisionSourceField(facts, supplement.requestedOn().toString());
appendRevisionSourceField(facts, supplement.acceptedAdditions());
appendRevisionSourceField(facts,
supplement.heatingPeriodStartedOn() == null
? null
: supplement.heatingPeriodStartedOn().toString());
appendRevisionSourceEnvelope(facts, supplement.acceptedAdditions());
PropertyConditionReportSupplement.FileEvidence signedPdf = supplement.signedPdf();
appendRevisionSourceField(facts, signedPdf == null ? null : signedPdf.reference());
appendRevisionSourceField(facts, signedPdf == null ? null : signedPdf.sha256());
appendRevisionSourceField(facts, signedPdf == null ? null : signedPdf.cryptoVersion());
appendRevisionSourceField(facts, signedPdf == null ? null : signedPdf.nonce());
appendRevisionSourceField(facts, signedPdf == null ? null : signedPdf.wrappedKey());
appendRevisionSourceField(facts, signedPdf == null ? null : signedPdf.vaultPublicKey());
appendRevisionSourceField(facts, supplement.photos().size());
for (ConditionPhoto photo : supplement.photos())
appendRevisionSourcePhoto(facts, photo);
@@ -542,6 +585,15 @@ public final class PropertyConditionReport {
appendRevisionSourceField(facts, photo.thumbnailSha256());
}
private static void appendRevisionSourceEnvelope(StringBuilder facts, EncryptedGroup value) {
if (value == null) {
appendRevisionSourceField(facts, null);
return;
}
appendRevisionSourceField(facts, value.cryptoVersion());
appendRevisionSourceField(facts, value.sha256());
}
private static void appendRevisionSourceField(StringBuilder facts, Object value) {
String encoded = value == null ? null : value.toString();
if (encoded == null)
@@ -808,7 +860,7 @@ public final class PropertyConditionReport {
for (MeterReading entryMeter : entrySnapshot.meterReadings()) {
MeterReading exitMeter = byId(meterReadings, MeterReading::id).get(entryMeter.id());
if ((!entryMeter.hasSameIdentityAs(exitMeter) || exitMeter.state() == MeterReadingState.INACCESSIBLE)
&& exitMeter.observation().isEmpty()) {
&& exitMeter.observation() == null) {
throw new PropertyConditionReportConflict("A replaced or inaccessible meter requires an observation.");
}
}
@@ -816,7 +868,7 @@ public final class PropertyConditionReport {
AccessMeans exitAccessMeans = byId(accessMeans, AccessMeans::id).get(entryAccessMeans.id());
if ((!entryAccessMeans.hasSameIdentityAs(exitAccessMeans)
|| entryAccessMeans.quantity() != exitAccessMeans.quantity())
&& exitAccessMeans.observation().isEmpty()) {
&& exitAccessMeans.observation() == null) {
throw new PropertyConditionReportConflict("An access means discrepancy requires an observation.");
}
}
@@ -871,6 +923,13 @@ public final class PropertyConditionReport {
return value.trim();
}
private static String required(String value, String field) {
if (value == null || value.isBlank()) {
throw new IllegalArgumentException(field + " is required.");
}
return value.trim();
}
private static <T> List<T> unique(List<T> values, Function<T, String> id, String name) {
List<T> copy = values == null ? List.of() : List.copyOf(values);
Set<String> ids = new HashSet<>();
@@ -914,6 +973,10 @@ public final class PropertyConditionReport {
return type;
}
public String vaultPublicKey() {
return vaultPublicKey;
}
public LocalDate occurredOn() {
return occurredOn;
}
@@ -4,7 +4,8 @@ import java.time.Instant;
import java.util.Objects;
public record PropertyConditionReportFinalization(int revisionNumber, String signedStorageKey, String signedSha256,
String authorId, Instant finalizedAt) {
String authorId, Instant finalizedAt, String sourceFingerprint, String fileName, int cryptoVersion,
String nonce, String wrappedKey, String vaultPublicKey) {
public PropertyConditionReportFinalization {
if (revisionNumber < 1)
throw new IllegalArgumentException("Finalized report revision is invalid.");
@@ -12,6 +13,14 @@ public record PropertyConditionReportFinalization(int revisionNumber, String sig
signedSha256 = required(signedSha256, "Signed report SHA-256");
authorId = required(authorId, "Finalization author");
finalizedAt = Objects.requireNonNull(finalizedAt, "Finalization time is required.");
if (cryptoVersion != 1) {
throw new IllegalArgumentException("Unsupported finalized report cryptographic format.");
}
sourceFingerprint = required(sourceFingerprint, "Finalized report source fingerprint");
fileName = required(fileName, "Signed report file name");
nonce = required(nonce, "Signed report nonce");
wrappedKey = required(wrappedKey, "Signed report wrapped key");
vaultPublicKey = required(vaultPublicKey, "Signed report vault public key");
}
private static String required(String value, String field) {
@@ -4,7 +4,8 @@ import java.time.Instant;
import java.util.Objects;
public record PropertyConditionReportRevision(int number, String storageKey, String sha256, String sourceFingerprint,
String authorId, Instant generatedAt, boolean obsolete) {
String authorId, Instant generatedAt, boolean obsolete, String fileName, int cryptoVersion, String nonce,
String wrappedKey, String vaultPublicKey) {
public PropertyConditionReportRevision {
if (number < 1)
throw new IllegalArgumentException("Report revision number is invalid.");
@@ -13,13 +14,20 @@ public record PropertyConditionReportRevision(int number, String storageKey, Str
sourceFingerprint = required(sourceFingerprint, "Report revision source fingerprint");
authorId = required(authorId, "Report revision author");
generatedAt = Objects.requireNonNull(generatedAt, "Report revision generation time is required.");
if (cryptoVersion != 1) {
throw new IllegalArgumentException("Unsupported report revision cryptographic format.");
}
fileName = required(fileName, "Report revision file name");
nonce = required(nonce, "Report revision nonce");
wrappedKey = required(wrappedKey, "Report revision wrapped key");
vaultPublicKey = required(vaultPublicKey, "Report revision vault public key");
}
public PropertyConditionReportRevision markedObsolete() {
return obsolete
? this
: new PropertyConditionReportRevision(number, storageKey, sha256, sourceFingerprint, authorId,
generatedAt, true);
generatedAt, true, fileName, cryptoVersion, nonce, wrappedKey, vaultPublicKey);
}
private static String required(String value, String field) {
Loaded 100 of 452 files, more files were not shown because too many files have changed in this diff. Show more