diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerCodeTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerCodeTest.java new file mode 100644 index 0000000000..242a9b0ef8 --- /dev/null +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerCodeTest.java @@ -0,0 +1,134 @@ +package name.abuchen.portfolio.model.ledger; + +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import java.util.Arrays; +import java.util.EnumMap; +import java.util.EnumSet; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.stream.Stream; + +import org.junit.Test; + +import name.abuchen.portfolio.model.ledger.configuration.CashCompensationKind; +import name.abuchen.portfolio.model.ledger.configuration.CorporateActionKind; +import name.abuchen.portfolio.model.ledger.configuration.CorporateActionLeg; +import name.abuchen.portfolio.model.ledger.configuration.CorporateActionSubtype; +import name.abuchen.portfolio.model.ledger.configuration.CostAllocationMethod; +import name.abuchen.portfolio.model.ledger.configuration.EventStage; +import name.abuchen.portfolio.model.ledger.configuration.FeeReason; +import name.abuchen.portfolio.model.ledger.configuration.FractionTreatment; +import name.abuchen.portfolio.model.ledger.configuration.LedgerCode; +import name.abuchen.portfolio.model.ledger.configuration.LedgerEntryType; +import name.abuchen.portfolio.model.ledger.configuration.LedgerParameterCodeDomain; +import name.abuchen.portfolio.model.ledger.configuration.QuotationStyle; +import name.abuchen.portfolio.model.ledger.configuration.RoundingModeCode; +import name.abuchen.portfolio.model.ledger.configuration.TaxReason; + +/** + * Tests ledger configuration and validation metadata. + * These tests make sure entry definitions, posting rules, and parameter domains stay stable for Ledger-V6 transactions. + */ +@SuppressWarnings("nls") +public class LedgerCodeTest +{ + /** + * Checks the ledger rule scenario: every code belongs to one domain and is uppercase. + * Invalid entry shapes must be rejected before they can be stored. + * This keeps higher-level Ledger-V6 transaction flows predictable. + */ + @Test + public void testEveryCodeBelongsToOneDomainAndIsUppercase() + { + for (var code : allCodes()) + { + assertTrue(EnumSet.allOf(LedgerParameterCodeDomain.class).contains(code.getDomain())); + assertFalse(code.getCode().isBlank()); + assertThat(code.getCode(), is(code.getCode().toUpperCase(Locale.ROOT))); + } + } + + /** + * Checks the ledger rule scenario: no duplicate codes within domain. + * Invalid entry shapes must be rejected before they can be stored. + * This keeps higher-level Ledger-V6 transaction flows predictable. + */ + @Test + public void testNoDuplicateCodesWithinDomain() + { + var seen = new EnumMap>(LedgerParameterCodeDomain.class); + + for (var domain : LedgerParameterCodeDomain.values()) + seen.put(domain, new HashSet<>()); + + for (var code : allCodes()) + assertTrue(code.getDomain() + ":" + code.getCode(), seen.get(code.getDomain()).add(code.getCode())); + } + + /** + * Checks the ledger rule scenario: domain allowed codes match domain enums. + * Invalid entry shapes must be rejected before they can be stored. + * This keeps higher-level Ledger-V6 transaction flows predictable. + */ + @Test + public void testDomainAllowedCodesMatchDomainEnums() + { + for (var domain : LedgerParameterCodeDomain.values()) + { + var codes = allCodes().stream().filter(code -> code.getDomain() == domain).toList(); + + assertFalse(codes.isEmpty()); + assertThat(codes.stream().map(LedgerCode::getCode).toList(), is(domain.getAllowedCodes())); + } + } + + /** + * Checks the ledger rule scenario: corporate action kinds classify the generic + * native corporate action entry family. + */ + @Test + public void testCorporateActionKindsClassifyGenericNativeEntryType() + { + assertTrue(LedgerEntryType.CORPORATE_ACTION.isLedgerNativeTargeted()); + assertThat(List.of(CorporateActionKind.values()), is(List.of( + CorporateActionKind.STOCK_DIVIDEND, + CorporateActionKind.SPIN_OFF, + CorporateActionKind.BONUS_ISSUE, + CorporateActionKind.RIGHTS_DISTRIBUTION, + CorporateActionKind.COUPON_PAYMENT, + CorporateActionKind.PIK_INTEREST, + CorporateActionKind.DEFAULTED_INTEREST, + CorporateActionKind.MATURITY, + CorporateActionKind.PARTIAL_REDEMPTION, + CorporateActionKind.CALL, + CorporateActionKind.PUT, + CorporateActionKind.CONVERSION, + CorporateActionKind.EXCHANGE, + CorporateActionKind.RESTRUCTURING, + CorporateActionKind.DEFAULT))); + assertThat(CorporateActionKind.fromCode("SPIN_OFF").orElseThrow(), is(CorporateActionKind.SPIN_OFF)); + assertThat(CorporateActionKind.fromCode("MATURITY").orElseThrow(), is(CorporateActionKind.MATURITY)); + assertTrue(CorporateActionKind.fromCode("OTHER").isEmpty()); + } + + private List allCodes() + { + return Stream.of( + CorporateActionLeg.values(), + CorporateActionKind.values(), + CorporateActionSubtype.values(), + EventStage.values(), + CashCompensationKind.values(), + FractionTreatment.values(), + RoundingModeCode.values(), + CostAllocationMethod.values(), + QuotationStyle.values(), + FeeReason.values(), + TaxReason.values()).flatMap(Arrays::stream).toList(); + } +} diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerDiagnosticCodeTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerDiagnosticCodeTest.java new file mode 100644 index 0000000000..6c1a07ac57 --- /dev/null +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerDiagnosticCodeTest.java @@ -0,0 +1,48 @@ +package name.abuchen.portfolio.model.ledger; + +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.MatcherAssert.assertThat; + +import org.junit.Test; + +import name.abuchen.portfolio.model.LedgerDiagnosticCode; + +@SuppressWarnings("nls") +public class LedgerDiagnosticCodeTest +{ + @Test + public void testCodeTextAndPrefix() + { + assertThat(LedgerDiagnosticCode.LEDGER_CONVERT_001.getGroup(), is("CONVERT")); + assertThat(LedgerDiagnosticCode.LEDGER_CONVERT_001.getCode(), is("LEDGER-CONVERT-001")); + assertThat(LedgerDiagnosticCode.LEDGER_CONVERT_002.getCode(), is("LEDGER-CONVERT-002")); + assertThat(LedgerDiagnosticCode.LEDGER_CONVERT_071.getCode(), is("LEDGER-CONVERT-071")); + assertThat(LedgerDiagnosticCode.LEDGER_CONVERT_001.prefix(), is("[LEDGER-CONVERT-001]")); + assertThat(LedgerDiagnosticCode.LEDGER_CONVERT_001.toString(), is("LEDGER-CONVERT-001")); + assertThat(LedgerDiagnosticCode.LEDGER_CORE_001.getGroup(), is("CORE")); + assertThat(LedgerDiagnosticCode.LEDGER_CORE_001.getCode(), is("LEDGER-CORE-001")); + assertThat(LedgerDiagnosticCode.LEDGER_CORE_026.getCode(), is("LEDGER-CORE-026")); + assertThat(LedgerDiagnosticCode.LEDGER_IMPORT_001.getGroup(), is("IMPORT")); + assertThat(LedgerDiagnosticCode.LEDGER_IMPORT_001.getCode(), is("LEDGER-IMPORT-001")); + assertThat(LedgerDiagnosticCode.LEDGER_IMPORT_021.getCode(), is("LEDGER-IMPORT-021")); + assertThat(LedgerDiagnosticCode.LEDGER_STRUCT_001.getCode(), is("LEDGER-STRUCT-001")); + assertThat(LedgerDiagnosticCode.LEDGER_STRUCT_055.getCode(), is("LEDGER-STRUCT-055")); + assertThat(LedgerDiagnosticCode.LEDGER_PROJ_001.getCode(), is("LEDGER-PROJ-001")); + assertThat(LedgerDiagnosticCode.LEDGER_PROJ_077.getCode(), is("LEDGER-PROJ-077")); + assertThat(LedgerDiagnosticCode.LEDGER_PERSIST_001.getCode(), is("LEDGER-PERSIST-001")); + assertThat(LedgerDiagnosticCode.LEDGER_PERSIST_002.getCode(), is("LEDGER-PERSIST-002")); + assertThat(LedgerDiagnosticCode.LEDGER_PERSIST_010.getCode(), is("LEDGER-PERSIST-010")); + assertThat(LedgerDiagnosticCode.LEDGER_FOREX_001.getCode(), is("LEDGER-FOREX-001")); + assertThat(LedgerDiagnosticCode.LEDGER_FOREX_005.getCode(), is("LEDGER-FOREX-005")); + assertThat(LedgerDiagnosticCode.LEDGER_UI_001.getCode(), is("LEDGER-UI-001")); + assertThat(LedgerDiagnosticCode.LEDGER_UI_002.getCode(), is("LEDGER-UI-002")); + assertThat(LedgerDiagnosticCode.LEDGER_UI_020.getCode(), is("LEDGER-UI-020")); + } + + @Test + public void testMessageFormattingKeepsTextSeparate() + { + assertThat(LedgerDiagnosticCode.LEDGER_UI_001.message("Meldung"), + is("[LEDGER-UI-001] Meldung")); + } +} diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerDiagnosticMessageFormatterTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerDiagnosticMessageFormatterTest.java new file mode 100644 index 0000000000..5b56181bb8 --- /dev/null +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerDiagnosticMessageFormatterTest.java @@ -0,0 +1,111 @@ +package name.abuchen.portfolio.model.ledger; + +import static org.hamcrest.CoreMatchers.containsString; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.junit.Assert.assertFalse; + +import java.time.LocalDateTime; + +import org.junit.Test; + +import name.abuchen.portfolio.Messages; +import name.abuchen.portfolio.model.Account; +import name.abuchen.portfolio.model.Client; +import name.abuchen.portfolio.model.Security; +import name.abuchen.portfolio.model.ledger.configuration.LedgerEntryType; +import name.abuchen.portfolio.model.ledger.configuration.LedgerPostingType; +import name.abuchen.portfolio.money.CurrencyUnit; +import name.abuchen.portfolio.money.Values; + +@SuppressWarnings("nls") +public class LedgerDiagnosticMessageFormatterTest +{ + @Test + public void testDiagnosticNlsTextDoesNotContainLedgerCodes() + { + assertFalse(Messages.LedgerDiagnosticMessageFormatterTransactionContext.isBlank()); + assertThat(Messages.LedgerStructuralValidatorPostingCurrencyRequired, containsString("{0}")); + assertFalse(Messages.LedgerDiagnosticMessageFormatterTransactionContext.contains("LEDGER-")); + assertFalse(Messages.LedgerStructuralValidatorPostingCurrencyRequired.contains("LEDGER-")); + } + + @Test + public void testValidationFormattingAddsFullEntryContext() + { + var ledger = new Ledger(); + var account = new Account(); + var security = new Security("Siemens AG", CurrencyUnit.EUR); + var entry = new LedgerEntry("entry-1"); + var posting = new LedgerPosting("posting-1"); + + account.setName("Cash Account"); + security.setIsin("DE0007236101"); + entry.setType(LedgerEntryType.DIVIDENDS); + entry.setDateTime(LocalDateTime.of(2026, 1, 2, 0, 0)); + entry.setSource("import"); + entry.setNote("note"); + posting.setType(LedgerPostingType.CASH); + posting.setAccount(account); + posting.setSecurity(security); + posting.setAmount(Values.Amount.factorize(12)); + posting.setCurrency(null); + entry.addPosting(posting); + ledger.addEntry(entry); + + var result = LedgerStructuralValidator.validate(ledger); + var message = LedgerDiagnosticMessageFormatter.formatValidationResult(ledger, result); + + assertFalse(result.isOK()); + assertThat(message, containsString("[POSTING_CURRENCY_REQUIRED] ")); + assertThat(message, containsString(Messages.LedgerDiagnosticMessageFormatterTransactionContext + ":")); + assertThat(message, containsString(Messages.LedgerDiagnosticMessageFormatterDate + ": 2026-01-02T00:00")); + assertThat(message, containsString(Messages.LedgerDiagnosticMessageFormatterType + ": DIVIDENDS")); + assertThat(message, containsString(Messages.LedgerDiagnosticMessageFormatterAccount + ": Cash Account")); + assertThat(message, containsString(Messages.LedgerDiagnosticMessageFormatterSecurity + + ": Siemens AG (" + Messages.LedgerDiagnosticMessageFormatterIsin + "=DE0007236101)")); + assertThat(message, containsString(Messages.LedgerDiagnosticMessageFormatterSource + ": import")); + assertThat(message, containsString(Messages.LedgerDiagnosticMessageFormatterNote + ": note")); + assertThat(message, containsString("UUID: posting-1")); + } + + @Test + public void testValidationFormattingReportsUnavailableContext() + { + var result = LedgerStructuralValidator.validate(null); + var message = LedgerDiagnosticMessageFormatter.formatValidationResult(null, result); + + assertFalse(result.isOK()); + assertThat(message, containsString("[LEDGER_REQUIRED] ")); + assertThat(message, containsString(Messages.LedgerDiagnosticMessageFormatterTransactionContext + ":\n " + + Messages.LedgerDiagnosticMessageFormatterContextUnavailable)); + } + + @Test + public void testMigrationFormattingAddsPartialLegacyContext() + { + var client = new Client(); + var account = new Account(); + var transaction = new name.abuchen.portfolio.model.AccountTransaction( + name.abuchen.portfolio.model.AccountTransaction.Type.DEPOSIT); + + account.setName("Cash Account"); + account.setCurrencyCode(CurrencyUnit.EUR); + client.addAccount(account); + transaction.setDateTime(LocalDateTime.of(2026, 1, 3, 0, 0)); + transaction.setCurrencyCode(CurrencyUnit.EUR); + transaction.setAmount(Values.Amount.factorize(25)); + transaction.setSource("bank import"); + account.addTransaction(transaction); + + var message = LedgerDiagnosticMessageFormatter.formatMigrationDiagnostic(client, + "[LEDGER-IMPORT-001] family=ACCOUNT reason=TEST uuids=[" + transaction.getUUID() + "]", + transaction); + + assertThat(message, containsString("[LEDGER-IMPORT-001] family=ACCOUNT reason=TEST")); + assertThat(message, containsString(Messages.LedgerDiagnosticMessageFormatterTransactionContext + ":")); + assertThat(message, containsString(Messages.LedgerDiagnosticMessageFormatterDate + ": 2026-01-03T00:00")); + assertThat(message, containsString(Messages.LedgerDiagnosticMessageFormatterType + ":")); + assertThat(message, containsString(Messages.LedgerDiagnosticMessageFormatterAccount + ": Cash Account")); + assertThat(message, containsString(Messages.LedgerDiagnosticMessageFormatterSource + ": bank import")); + } +} diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerEntryDefinitionTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerEntryDefinitionTest.java new file mode 100644 index 0000000000..cbd35c02eb --- /dev/null +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerEntryDefinitionTest.java @@ -0,0 +1,660 @@ +package name.abuchen.portfolio.model.ledger; + +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import java.time.LocalDateTime; +import java.util.EnumSet; +import java.util.HashSet; + +import org.junit.Test; + +import name.abuchen.portfolio.model.ledger.configuration.CorporateActionKind; +import name.abuchen.portfolio.model.ledger.configuration.CorporateActionLeg; +import name.abuchen.portfolio.model.ledger.configuration.LedgerDownstreamResult; +import name.abuchen.portfolio.model.ledger.configuration.LedgerEntryDefinitionRegistry; +import name.abuchen.portfolio.model.ledger.configuration.LedgerEntryType; +import name.abuchen.portfolio.model.ledger.configuration.LedgerLegCardinality; +import name.abuchen.portfolio.model.ledger.configuration.LedgerLegDefinition; +import name.abuchen.portfolio.model.ledger.configuration.LedgerLegRole; +import name.abuchen.portfolio.model.ledger.configuration.LedgerNativeEntryShape; +import name.abuchen.portfolio.model.ledger.configuration.LedgerParameterCodeDomain; +import name.abuchen.portfolio.model.ledger.configuration.LedgerParameterType; +import name.abuchen.portfolio.model.ledger.configuration.LedgerPerformanceTreatment; +import name.abuchen.portfolio.model.ledger.configuration.LedgerPostingType; +import name.abuchen.portfolio.model.ledger.configuration.LedgerPostingTypeDefinitionRegistry; +import name.abuchen.portfolio.model.ledger.configuration.LedgerReportingClass; +import name.abuchen.portfolio.model.ledger.configuration.rule.LedgerParameterRule; +import name.abuchen.portfolio.model.ledger.configuration.rule.LedgerPostingRule; +import name.abuchen.portfolio.model.ledger.configuration.rule.LedgerProjectionRule; +import name.abuchen.portfolio.model.ledger.configuration.rule.LedgerRequirement; +import name.abuchen.portfolio.money.CurrencyUnit; + +/** + * Tests ledger configuration and validation metadata. + * These tests make sure entry definitions, posting rules, and parameter domains stay stable for Ledger-V6 transactions. + */ +@SuppressWarnings("nls") +public class LedgerEntryDefinitionTest +{ + /** + * Checks the ledger rule scenario: ledger native entry types have definitions. + * Invalid entry shapes must be rejected before they can be stored. + * This keeps higher-level Ledger-V6 transaction flows predictable. + */ + @Test + public void testLedgerNativeEntryTypesHaveDefinitions() + { + for (var type : LedgerEntryType.values()) + { + if (type.isLedgerNativeTargeted()) + { + var definition = LedgerEntryDefinitionRegistry.lookup(type).orElseThrow(); + + assertThat(definition.getEntryType(), is(type)); + } + else + { + assertFalse(type.name(), LedgerEntryDefinitionRegistry.hasDefinition(type)); + } + } + } + + /** + * Checks the ledger rule scenario: definitions are consistent static configuration. + * Invalid entry shapes must be rejected before they can be stored. + * This keeps higher-level Ledger-V6 transaction flows predictable. + */ + @Test + public void testDefinitionsAreConsistentStaticConfiguration() + { + for (var definition : LedgerEntryDefinitionRegistry.getDefinitions()) + { + assertTrue(definition.getEntryType().isLedgerNativeTargeted()); + assertTrue(definition.getNativeShape() != LedgerNativeEntryShape.UNDEFINED); + assertFalse(definition.getPostingTypes().isEmpty()); + assertFalse(definition.getPostingRules().isEmpty()); + assertFalse(definition.getEntryParameterTypes().isEmpty()); + assertFalse(definition.getRequiredEntryParameterRules().isEmpty()); + assertFalse(definition.getEntryParameterRules().isEmpty()); + assertFalse(definition.getPostingParameterTypes().isEmpty()); + assertFalse(definition.getPostingParameterRules().isEmpty()); + assertFalse(definition.getLegDefinitions().isEmpty()); + assertTrue(definition.getReportingClass() != LedgerReportingClass.UNDEFINED); + assertTrue(definition.getPerformanceTreatment() != LedgerPerformanceTreatment.UNDEFINED); + assertThat(definition.getDownstreamResultsNotPersisted(), is(EnumSet.allOf(LedgerDownstreamResult.class))); + } + } + + /** + * Checks the ledger rule scenario: definition registries expose read only schemas. + * Invalid entry shapes must be rejected before they can be stored. + * This keeps higher-level Ledger-V6 transaction flows predictable. + */ + @Test + public void testDefinitionRegistriesExposeReadOnlySchemas() + { + for (var entryType : LedgerEntryType.values()) + { + if (!entryType.isLedgerNativeTargeted()) + continue; + + var definition = LedgerEntryDefinitionRegistry.lookup(entryType).orElseThrow(); + + assertThat(definition.getEntryType(), is(entryType)); + assertFalse(definition.getPostingRules().isEmpty()); + assertFalse(definition.getEntryParameterRules().isEmpty()); + assertFalse(definition.getPostingParameterRules().isEmpty()); + assertTrue(definition.getReportingClass() != LedgerReportingClass.UNDEFINED); + assertTrue(definition.getPerformanceTreatment() != LedgerPerformanceTreatment.UNDEFINED); + assertThat(definition.getDownstreamResultsNotPersisted(), is(EnumSet.allOf(LedgerDownstreamResult.class))); + } + + for (var postingType : LedgerPostingType.values()) + { + var definition = LedgerPostingTypeDefinitionRegistry.lookup(postingType).orElseThrow(); + + assertThat(definition.getPostingType(), is(postingType)); + assertFalse(postingType.name(), definition.getComponentParameterTypes().isEmpty()); + } + } + + /** + * Checks the ledger rule scenario: rule keys are unique within definition categories. + * Invalid entry shapes must be rejected before they can be stored. + * This keeps higher-level Ledger-V6 transaction flows predictable. + */ + @Test + public void testRuleKeysAreUniqueWithinDefinitionCategories() + { + for (var definition : LedgerEntryDefinitionRegistry.getDefinitions()) + { + assertUniquePostingRules(definition); + assertUniqueParameterRules(definition, definition.getEntryParameterRules(), "entry parameter rule"); + assertUniqueParameterRules(definition, definition.getPostingParameterRules(), "posting parameter rule"); + assertUniqueProjectionRules(definition); + assertUniquePostingGroupRules(definition); + assertUniqueAlternativeGroupRules(definition); + assertUniqueLegDefinitions(definition); + } + } + + /** + * Checks the ledger rule scenario: alternative requirement groups are real alternatives. + * Invalid entry shapes must be rejected before they can be stored. + * This keeps higher-level Ledger-V6 transaction flows predictable. + */ + @Test + public void testAlternativeRequirementGroupsAreRealAlternatives() + { + for (var definition : LedgerEntryDefinitionRegistry.getDefinitions()) + { + for (var group : definition.getAlternativeRequirementGroups()) + { + var numberOfAlternatives = group.getPostingTypes().size() + group.getParameterTypes().size(); + + assertTrue(definition.getEntryType() + ":" + group.getName(), numberOfAlternatives >= 2); + assertTrue(definition.getEntryType() + ":" + group.getName(), + group.getPostingTypes().isEmpty() != group.getParameterTypes().isEmpty()); + } + } + } + + /** + * Checks the ledger rule scenario: alternative groups do not duplicate hard required members. + * Invalid entry shapes must be rejected before they can be stored. + * This keeps higher-level Ledger-V6 transaction flows predictable. + */ + @Test + public void testAlternativeGroupsDoNotDuplicateHardRequiredMembers() + { + for (var definition : LedgerEntryDefinitionRegistry.getDefinitions()) + { + var requiredPostingTypes = EnumSet.noneOf(LedgerPostingType.class); + var requiredParameterTypes = EnumSet.noneOf(LedgerParameterType.class); + + for (var rule : definition.getRequiredPostingRules()) + requiredPostingTypes.add(rule.getPostingType()); + + for (var rule : definition.getRequiredEntryParameterRules()) + requiredParameterTypes.add(rule.getParameterType()); + + for (var rule : definition.getRequiredPostingParameterRules()) + requiredParameterTypes.add(rule.getParameterType()); + + for (var rule : definition.getPostingRules()) + requiredParameterTypes.addAll(rule.getRequiredParameterTypes()); + + for (var group : definition.getAlternativeRequirementGroups()) + { + for (var postingType : group.getPostingTypes()) + assertFalse(definition.getEntryType() + ":" + group.getName() + ":" + postingType, + requiredPostingTypes.contains(postingType)); + + for (var parameterType : group.getParameterTypes()) + assertFalse(definition.getEntryType() + ":" + group.getName() + ":" + parameterType, + requiredParameterTypes.contains(parameterType)); + } + } + } + + /** + * Checks the ledger rule scenario: spin off definition describes native data model. + * Invalid entry shapes must be rejected before they can be stored. + * This keeps higher-level Ledger-V6 transaction flows predictable. + */ + @Test + public void testSpinOffDefinitionDescribesNativeDataModel() + { + var definition = LedgerEntryDefinitionRegistry + .lookup(LedgerEntryType.CORPORATE_ACTION, CorporateActionKind.SPIN_OFF).orElseThrow(); + + assertThat(definition.getNativeShape(), is(LedgerNativeEntryShape.DUAL_INSTRUMENT_PLUS_ACCOUNT)); + assertTrue(definition.getPostingTypes().contains(LedgerPostingType.SECURITY)); + assertTrue(definition.getPostingTypes().contains(LedgerPostingType.CASH_COMPENSATION)); + assertTrue(definition.getPostingTypes().contains(LedgerPostingType.FEE)); + assertTrue(definition.getPostingTypes().contains(LedgerPostingType.TAX)); + assertTrue(definition.getPostingTypes().contains(LedgerPostingType.FOREX)); + assertTrue(definition.getEntryParameterTypes().contains(LedgerParameterType.CORPORATE_ACTION_KIND)); + assertTrue(definition.getEntryParameterTypes().contains(LedgerParameterType.EX_DATE)); + assertTrue(definition.getPostingParameterTypes().contains(LedgerParameterType.CORPORATE_ACTION_LEG)); + assertTrue(definition.getPostingParameterTypes().contains(LedgerParameterType.SOURCE_SECURITY)); + assertTrue(definition.getPostingParameterTypes().contains(LedgerParameterType.TARGET_SECURITY)); + assertTrue(definition.getProjectionRoles().contains(LedgerProjectionRole.OLD_SECURITY_LEG)); + assertTrue(definition.getProjectionRoles().contains(LedgerProjectionRole.NEW_SECURITY_LEG)); + assertTrue(definition.getProjectionRoles().contains(LedgerProjectionRole.CASH_COMPENSATION)); + assertOptionalPosting(definition, LedgerPostingType.SECURITY); + assertOptionalPosting(definition, LedgerPostingType.CASH_COMPENSATION); + assertOptionalPosting(definition, LedgerPostingType.FEE); + assertOptionalPosting(definition, LedgerPostingType.TAX); + assertOptionalPosting(definition, LedgerPostingType.FOREX); + assertRequiredEntryParameter(definition, LedgerParameterType.CORPORATE_ACTION_KIND); + assertOptionalEntryParameter(definition, LedgerParameterType.EX_DATE); + assertOptionalEntryParameter(definition, LedgerParameterType.EFFECTIVE_DATE); + assertRequiredPostingParameter(definition, LedgerParameterType.CORPORATE_ACTION_LEG); + assertRequiredPostingParameter(definition, LedgerParameterType.SOURCE_SECURITY); + assertRequiredPostingParameter(definition, LedgerParameterType.TARGET_SECURITY); + assertOptionalPostingParameter(definition, LedgerParameterType.CASH_IN_LIEU_AMOUNT); + assertRepeatableParameter(definition, LedgerParameterType.CORPORATE_ACTION_LEG); + assertOptionalProjection(definition, LedgerProjectionRole.OLD_SECURITY_LEG, true, false); + assertOptionalProjection(definition, LedgerProjectionRole.NEW_SECURITY_LEG, true, false); + assertOptionalProjection(definition, LedgerProjectionRole.CASH_COMPENSATION, true, true); + assertAlternativeGroup(definition, "SPIN_OFF_DATE", LedgerRequirement.REQUIRED, + LedgerParameterType.EX_DATE, LedgerParameterType.EFFECTIVE_DATE); + assertThat(definition.getReportingClass(), is(LedgerReportingClass.SECURITIES_DISTRIBUTION)); + assertThat(definition.getPerformanceTreatment(), is(LedgerPerformanceTreatment.COST_BASIS_REALLOCATION)); + } + + /** + * Checks that the spin-off definition names its business legs explicitly. + * The old and new security sides both use SECURITY postings, so the leg + * definitions must carry the business meaning that the posting type cannot. + */ + @Test + public void testSpinOffDefinitionDescribesFunctionalLegs() + { + var definition = LedgerEntryDefinitionRegistry + .lookup(LedgerEntryType.CORPORATE_ACTION, CorporateActionKind.SPIN_OFF).orElseThrow(); + + var sourceLeg = assertLeg(definition, LedgerLegRole.SOURCE_SECURITY_LEG, LedgerPostingType.SECURITY, + LedgerLegCardinality.REPEATABLE); + assertTrue(sourceLeg.getRequiredParameterTypes().contains(LedgerParameterType.CORPORATE_ACTION_LEG)); + assertTrue(sourceLeg.getRequiredParameterTypes().contains(LedgerParameterType.SOURCE_SECURITY)); + assertTrue(sourceLeg.getRequiredParameterTypes().contains(LedgerParameterType.RATIO_NUMERATOR)); + assertTrue(sourceLeg.getRequiredParameterTypes().contains(LedgerParameterType.RATIO_DENOMINATOR)); + assertTrue(sourceLeg.getOptionalParameterTypes().contains(LedgerParameterType.TARGET_SECURITY)); + assertThat(sourceLeg.getProjectionRole().orElseThrow(), is(LedgerProjectionRole.OLD_SECURITY_LEG)); + assertTrue(sourceLeg.isPrimaryPostingExpected()); + assertFalse(sourceLeg.isPostingGroupExpected()); + + var targetLeg = assertLeg(definition, LedgerLegRole.TARGET_SECURITY_LEG, LedgerPostingType.SECURITY, + LedgerLegCardinality.REPEATABLE); + assertTrue(targetLeg.getRequiredParameterTypes().contains(LedgerParameterType.CORPORATE_ACTION_LEG)); + assertTrue(targetLeg.getRequiredParameterTypes().contains(LedgerParameterType.TARGET_SECURITY)); + assertTrue(targetLeg.getRequiredParameterTypes().contains(LedgerParameterType.RATIO_NUMERATOR)); + assertTrue(targetLeg.getRequiredParameterTypes().contains(LedgerParameterType.RATIO_DENOMINATOR)); + assertTrue(targetLeg.getOptionalParameterTypes().contains(LedgerParameterType.SOURCE_SECURITY)); + assertThat(targetLeg.getProjectionRole().orElseThrow(), is(LedgerProjectionRole.NEW_SECURITY_LEG)); + assertTrue(targetLeg.isPrimaryPostingExpected()); + assertFalse(targetLeg.isPostingGroupExpected()); + + var cashLeg = assertLeg(definition, LedgerLegRole.CASH_COMPENSATION_LEG, + LedgerPostingType.CASH_COMPENSATION, LedgerLegCardinality.REPEATABLE); + assertThat(cashLeg.getProjectionRole().orElseThrow(), is(LedgerProjectionRole.CASH_COMPENSATION)); + assertTrue(cashLeg.isPrimaryPostingExpected()); + assertTrue(cashLeg.isPostingGroupExpected()); + assertTrue(cashLeg.getGroupNames().contains("CASH_COMPENSATION_GROUP")); + + var feeLeg = assertLeg(definition, LedgerLegRole.FEE_LEG, LedgerPostingType.FEE, + LedgerLegCardinality.REPEATABLE); + assertTrue(feeLeg.getProjectionRole().isEmpty()); + assertTrue(feeLeg.getGroupNames().contains("CASH_COMPENSATION_GROUP")); + + var taxLeg = assertLeg(definition, LedgerLegRole.TAX_LEG, LedgerPostingType.TAX, + LedgerLegCardinality.REPEATABLE); + assertTrue(taxLeg.getProjectionRole().isEmpty()); + assertTrue(taxLeg.getGroupNames().contains("CASH_COMPENSATION_GROUP")); + + var forexLeg = assertLeg(definition, LedgerLegRole.FOREX_CONTEXT_LEG, LedgerPostingType.FOREX, + LedgerLegCardinality.OPTIONAL); + assertTrue(forexLeg.getProjectionRole().isEmpty()); + assertTrue(forexLeg.getGroupNames().isEmpty()); + } + + /** + * Checks the ledger rule scenario: corporate action kinds can be registered as stable + * identities before all Registry.md profile dimensions are modeled. Definitions only + * include dimensions that current Ledger primitives can express honestly. + */ + @Test + public void testCorporateActionKindDefinitionsRegisterRepresentableProfileSubsets() + { + for (var kind : new CorporateActionKind[] { CorporateActionKind.STOCK_DIVIDEND, + CorporateActionKind.SPIN_OFF, CorporateActionKind.BONUS_ISSUE, + CorporateActionKind.RIGHTS_DISTRIBUTION, CorporateActionKind.COUPON_PAYMENT, + CorporateActionKind.PIK_INTEREST, CorporateActionKind.MATURITY, + CorporateActionKind.PARTIAL_REDEMPTION, CorporateActionKind.CALL, + CorporateActionKind.PUT, CorporateActionKind.CONVERSION, + CorporateActionKind.EXCHANGE }) + assertTrue(kind.name(), LedgerEntryDefinitionRegistry + .lookup(LedgerEntryType.CORPORATE_ACTION, kind).isPresent()); + + for (var kind : new CorporateActionKind[] { CorporateActionKind.DEFAULTED_INTEREST, + CorporateActionKind.RESTRUCTURING, CorporateActionKind.DEFAULT }) + assertFalse(kind.name(), LedgerEntryDefinitionRegistry + .lookup(LedgerEntryType.CORPORATE_ACTION, kind).isPresent()); + + var stockDividend = LedgerEntryDefinitionRegistry + .lookup(LedgerEntryType.CORPORATE_ACTION, CorporateActionKind.STOCK_DIVIDEND) + .orElseThrow(); + assertLeg(stockDividend, LedgerLegRole.TARGET_SECURITY_LEG, LedgerPostingType.SECURITY, + LedgerLegCardinality.AT_LEAST_ONE); + assertLeg(stockDividend, LedgerLegRole.CASH_COMPENSATION_LEG, LedgerPostingType.CASH_COMPENSATION, + LedgerLegCardinality.REPEATABLE); + assertTrue(stockDividend.getProjectionRoles().isEmpty()); + + var coupon = LedgerEntryDefinitionRegistry + .lookup(LedgerEntryType.CORPORATE_ACTION, CorporateActionKind.COUPON_PAYMENT) + .orElseThrow(); + assertLeg(coupon, LedgerLegRole.CASH_LEG, LedgerPostingType.CASH, LedgerLegCardinality.AT_LEAST_ONE); + assertLeg(coupon, LedgerLegRole.ACCRUED_INTEREST_LEG, LedgerPostingType.ACCRUED_INTEREST, + LedgerLegCardinality.OPTIONAL); + + var maturity = LedgerEntryDefinitionRegistry + .lookup(LedgerEntryType.CORPORATE_ACTION, CorporateActionKind.MATURITY) + .orElseThrow(); + assertLeg(maturity, LedgerLegRole.SOURCE_SECURITY_LEG, LedgerPostingType.SECURITY, + LedgerLegCardinality.AT_LEAST_ONE); + assertLeg(maturity, LedgerLegRole.CASH_LEG, LedgerPostingType.CASH, LedgerLegCardinality.AT_LEAST_ONE); + assertLeg(maturity, LedgerLegRole.PRINCIPAL_REDEMPTION_LEG, LedgerPostingType.PRINCIPAL_REDEMPTION, + LedgerLegCardinality.AT_LEAST_ONE); + + var conversion = LedgerEntryDefinitionRegistry + .lookup(LedgerEntryType.CORPORATE_ACTION, CorporateActionKind.CONVERSION) + .orElseThrow(); + assertLeg(conversion, LedgerLegRole.SOURCE_SECURITY_LEG, LedgerPostingType.SECURITY, + LedgerLegCardinality.AT_LEAST_ONE); + assertLeg(conversion, LedgerLegRole.TARGET_SECURITY_LEG, LedgerPostingType.SECURITY, + LedgerLegCardinality.AT_LEAST_ONE); + } + + /** + * Checks the ledger rule scenario: rule vocabulary is consistent with posting type definitions. + * Invalid entry shapes must be rejected before they can be stored. + * This keeps higher-level Ledger-V6 transaction flows predictable. + */ + @Test + public void testRuleVocabularyIsConsistentWithPostingTypeDefinitions() + { + for (var definition : LedgerEntryDefinitionRegistry.getDefinitions()) + { + for (var rule : definition.getPostingRules()) + { + assertTrue(rule.getPostingType().name(), + LedgerPostingTypeDefinitionRegistry.hasDefinition(rule.getPostingType())); + + for (var parameterType : rule.getRequiredParameterTypes()) + assertTrue(parameterType.name(), definition.getPostingParameterTypes().contains(parameterType)); + + for (var parameterType : rule.getOptionalParameterTypes()) + assertTrue(parameterType.name(), definition.getPostingParameterTypes().contains(parameterType)); + } + + for (var rule : definition.getEntryParameterRules()) + assertTrue(rule.getParameterType().name(), + definition.getEntryParameterTypes().contains(rule.getParameterType())); + + for (var rule : definition.getPostingParameterRules()) + assertTrue(rule.getParameterType().name(), + definition.getPostingParameterTypes().contains(rule.getParameterType())); + + for (var rule : definition.getProjectionRules()) + assertTrue(rule.getRole().name(), definition.getProjectionRoles().contains(rule.getRole())); + + for (var leg : definition.getLegDefinitions()) + { + assertTrue(leg.getPostingType().name(), + LedgerPostingTypeDefinitionRegistry.hasDefinition(leg.getPostingType())); + + for (var parameterType : leg.getRequiredParameterTypes()) + assertTrue(parameterType.name(), definition.getPostingParameterTypes().contains(parameterType)); + + for (var parameterType : leg.getOptionalParameterTypes()) + assertTrue(parameterType.name(), definition.getPostingParameterTypes().contains(parameterType)); + + leg.getProjectionRole().ifPresent( + role -> assertTrue(role.name(), definition.getProjectionRoles().contains(role))); + } + } + } + + /** + * Checks the ledger rule scenario: deep research critical vocabulary is covered by native definitions. + * Invalid entry shapes must be rejected before they can be stored. + * This keeps higher-level Ledger-V6 transaction flows predictable. + */ + @Test + public void testDeepResearchCriticalVocabularyIsCoveredByNativeDefinitions() + { + var allPostingTypes = EnumSet.noneOf(LedgerPostingType.class); + var allEntryParameters = EnumSet.noneOf(LedgerParameterType.class); + var allPostingParameters = EnumSet.noneOf(LedgerParameterType.class); + + for (var definition : LedgerEntryDefinitionRegistry.getDefinitions()) + { + allPostingTypes.addAll(definition.getPostingTypes()); + allEntryParameters.addAll(definition.getEntryParameterTypes()); + allPostingParameters.addAll(definition.getPostingParameterTypes()); + } + + assertTrue(allEntryParameters.contains(LedgerParameterType.CORPORATE_ACTION_KIND)); + assertTrue(allEntryParameters.contains(LedgerParameterType.EVENT_STAGE)); + assertTrue(allEntryParameters.contains(LedgerParameterType.RECORD_DATE)); + assertTrue(allEntryParameters.contains(LedgerParameterType.PAYMENT_DATE)); + assertTrue(allEntryParameters.contains(LedgerParameterType.EFFECTIVE_DATE)); + assertTrue(allPostingParameters.contains(LedgerParameterType.SOURCE_SECURITY)); + assertTrue(allPostingParameters.contains(LedgerParameterType.TARGET_SECURITY)); + assertTrue(allPostingParameters.contains(LedgerParameterType.RATIO_NUMERATOR)); + assertTrue(allPostingParameters.contains(LedgerParameterType.RATIO_DENOMINATOR)); + assertTrue(allPostingParameters.contains(LedgerParameterType.FRACTION_TREATMENT)); + assertTrue(allPostingParameters.contains(LedgerParameterType.CASH_IN_LIEU_AMOUNT)); + assertTrue(allPostingParameters.contains(LedgerParameterType.COST_ALLOCATION_METHOD)); + assertTrue(allPostingParameters.contains(LedgerParameterType.FAIR_MARKET_VALUE)); + assertTrue(allPostingParameters.contains(LedgerParameterType.FEE_REASON)); + assertTrue(allPostingParameters.contains(LedgerParameterType.TAX_REASON)); + } + + /** + * Checks the ledger rule scenario: corporate action leg has controlled code domain. + * Invalid entry shapes must be rejected before they can be stored. + * This keeps higher-level Ledger-V6 transaction flows predictable. + */ + @Test + public void testCorporateActionLegHasControlledCodeDomain() + { + assertThat(LedgerParameterType.CORPORATE_ACTION_LEG.getCodeDomain(), + is(LedgerParameterCodeDomain.CORPORATE_ACTION_LEG)); + assertTrue(LedgerParameterType.CORPORATE_ACTION_LEG + .supportsCode(CorporateActionLeg.SOURCE_SECURITY.getCode())); + assertTrue(LedgerParameterType.CORPORATE_ACTION_LEG + .supportsCode(CorporateActionLeg.TARGET_SECURITY.getCode())); + assertFalse(LedgerParameterType.CORPORATE_ACTION_LEG.supportsCode("SOURCE")); + } + + /** + * Checks the ledger rule scenario: definition layer allows partial native completeness. + * Invalid entry shapes must be rejected before they can be stored. + * This keeps higher-level Ledger-V6 transaction flows predictable. + */ + @Test + public void testDefinitionLayerAllowsPartialNativeCompleteness() + { + var ledger = new Ledger(); + var entry = new LedgerEntry("entry-1"); + var posting = new LedgerPosting("posting-1"); + + entry.setType(LedgerEntryType.CORPORATE_ACTION); + entry.setDateTime(LocalDateTime.of(2026, 1, 2, 0, 0)); + posting.setType(LedgerPostingType.CASH); + posting.setCurrency(CurrencyUnit.EUR); + posting.setSemanticRole(LedgerPostingSemanticRole.CASH); + posting.setDirection(LedgerPostingDirection.NEUTRAL); + posting.setUnitRole(LedgerPostingUnitRole.PRIMARY); + entry.addPosting(posting); + ledger.addEntry(entry); + + assertTrue(LedgerStructuralValidator.validate(ledger).isOK()); + } + + private void assertOptionalPosting(name.abuchen.portfolio.model.ledger.configuration.LedgerEntryDefinition definition, + LedgerPostingType postingType) + { + assertTrue(postingType.name(), hasPostingRule(definition.getOptionalPostingRules(), postingType)); + } + + private void assertRequiredEntryParameter( + name.abuchen.portfolio.model.ledger.configuration.LedgerEntryDefinition definition, + LedgerParameterType parameterType) + { + assertTrue(parameterType.name(), hasParameterRule(definition.getRequiredEntryParameterRules(), parameterType)); + } + + private void assertOptionalEntryParameter( + name.abuchen.portfolio.model.ledger.configuration.LedgerEntryDefinition definition, + LedgerParameterType parameterType) + { + assertTrue(parameterType.name(), hasParameterRule(definition.getOptionalEntryParameterRules(), parameterType)); + } + + private void assertRequiredPostingParameter( + name.abuchen.portfolio.model.ledger.configuration.LedgerEntryDefinition definition, + LedgerParameterType parameterType) + { + assertTrue(parameterType.name(), hasParameterRule(definition.getRequiredPostingParameterRules(), parameterType)); + } + + private void assertOptionalPostingParameter( + name.abuchen.portfolio.model.ledger.configuration.LedgerEntryDefinition definition, + LedgerParameterType parameterType) + { + assertTrue(parameterType.name(), hasParameterRule(definition.getOptionalPostingParameterRules(), parameterType)); + } + + private void assertRepeatableParameter( + name.abuchen.portfolio.model.ledger.configuration.LedgerEntryDefinition definition, + LedgerParameterType parameterType) + { + assertTrue(parameterType.name(), definition.getRepeatableParameterTypes().contains(parameterType)); + } + + private void assertOptionalProjection( + name.abuchen.portfolio.model.ledger.configuration.LedgerEntryDefinition definition, + LedgerProjectionRole role, boolean primaryPostingExpected, boolean postingGroupExpected) + { + assertProjection(definition.getOptionalProjectionRules(), role, primaryPostingExpected, postingGroupExpected); + } + + private void assertProjection(Iterable rules, LedgerProjectionRole role, + boolean primaryPostingExpected, boolean postingGroupExpected) + { + for (var rule : rules) + { + if (rule.getRole() == role) + { + assertThat(rule.isPrimaryPostingExpected(), is(primaryPostingExpected)); + assertThat(rule.isPostingGroupExpected(), is(postingGroupExpected)); + return; + } + } + + assertTrue(role.name(), false); + } + + private void assertAlternativeGroup( + name.abuchen.portfolio.model.ledger.configuration.LedgerEntryDefinition definition, String name, + LedgerRequirement requirement, LedgerParameterType first, LedgerParameterType... rest) + { + var expected = EnumSet.of(first, rest); + + for (var group : definition.getAlternativeRequirementGroups()) + { + if (group.getName().equals(name)) + { + assertThat(group.getRequirement(), is(requirement)); + assertThat(group.getParameterTypes(), is(expected)); + return; + } + } + + assertTrue(name, false); + } + + private boolean hasPostingRule(Iterable rules, LedgerPostingType postingType) + { + for (var rule : rules) + if (rule.getPostingType() == postingType) + return true; + + return false; + } + + private boolean hasParameterRule(Iterable rules, LedgerParameterType parameterType) + { + for (var rule : rules) + if (rule.getParameterType() == parameterType) + return true; + + return false; + } + + private void assertUniquePostingRules( + name.abuchen.portfolio.model.ledger.configuration.LedgerEntryDefinition definition) + { + var seen = new HashSet(); + + for (var rule : definition.getPostingRules()) + assertTrue(definition.getEntryType() + ": posting rule " + rule.getPostingType(), + seen.add(rule.getPostingType())); + } + + private void assertUniqueParameterRules( + name.abuchen.portfolio.model.ledger.configuration.LedgerEntryDefinition definition, + Iterable rules, String category) + { + var seen = new HashSet(); + + for (var rule : rules) + assertTrue(definition.getEntryType() + ": " + category + " " + rule.getParameterType(), + seen.add(rule.getParameterType())); + } + + private void assertUniqueProjectionRules( + name.abuchen.portfolio.model.ledger.configuration.LedgerEntryDefinition definition) + { + var seen = new HashSet(); + + for (var rule : definition.getProjectionRules()) + assertTrue(definition.getEntryType() + ": projection rule " + rule.getRole(), seen.add(rule.getRole())); + } + + private void assertUniquePostingGroupRules( + name.abuchen.portfolio.model.ledger.configuration.LedgerEntryDefinition definition) + { + var seen = new HashSet(); + + for (var rule : definition.getPostingGroupRules()) + assertTrue(definition.getEntryType() + ": posting group rule " + rule.getName(), seen.add(rule.getName())); + } + + private void assertUniqueAlternativeGroupRules( + name.abuchen.portfolio.model.ledger.configuration.LedgerEntryDefinition definition) + { + var seen = new HashSet(); + + for (var group : definition.getAlternativeRequirementGroups()) + assertTrue(definition.getEntryType() + ": alternative group " + group.getName(), seen.add(group.getName())); + } + + private void assertUniqueLegDefinitions( + name.abuchen.portfolio.model.ledger.configuration.LedgerEntryDefinition definition) + { + var seen = new HashSet(); + + for (var leg : definition.getLegDefinitions()) + assertTrue(definition.getEntryType() + ": leg " + leg.getRole(), seen.add(leg.getRole())); + } + + private LedgerLegDefinition assertLeg( + name.abuchen.portfolio.model.ledger.configuration.LedgerEntryDefinition definition, + LedgerLegRole role, LedgerPostingType postingType, LedgerLegCardinality cardinality) + { + var leg = definition.getLegDefinition(role).orElseThrow(); + + assertThat(leg.getPostingType(), is(postingType)); + assertThat(leg.getCardinality(), is(cardinality)); + + return leg; + } +} diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerModelTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerModelTest.java new file mode 100644 index 0000000000..9b74e369a3 --- /dev/null +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerModelTest.java @@ -0,0 +1,840 @@ +package name.abuchen.portfolio.model.ledger; + +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import java.math.BigDecimal; +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.Arrays; +import java.util.EnumSet; +import java.util.List; +import java.util.Locale; + +import org.junit.Test; + +import name.abuchen.portfolio.model.Account; +import name.abuchen.portfolio.model.LedgerDiagnosticCode; +import name.abuchen.portfolio.model.Portfolio; +import name.abuchen.portfolio.model.Security; +import name.abuchen.portfolio.model.ledger.LedgerParameter.ValueKind; +import name.abuchen.portfolio.model.ledger.configuration.CashCompensationKind; +import name.abuchen.portfolio.model.ledger.configuration.CorporateActionKind; +import name.abuchen.portfolio.model.ledger.configuration.CorporateActionLeg; +import name.abuchen.portfolio.model.ledger.configuration.CorporateActionSubtype; +import name.abuchen.portfolio.model.ledger.configuration.CostAllocationMethod; +import name.abuchen.portfolio.model.ledger.configuration.EventStage; +import name.abuchen.portfolio.model.ledger.configuration.FeeReason; +import name.abuchen.portfolio.model.ledger.configuration.FractionTreatment; +import name.abuchen.portfolio.model.ledger.configuration.LedgerCode; +import name.abuchen.portfolio.model.ledger.configuration.LedgerEntryType; +import name.abuchen.portfolio.model.ledger.configuration.LedgerParameterCodeDomain; +import name.abuchen.portfolio.model.ledger.configuration.LedgerParameterType; +import name.abuchen.portfolio.model.ledger.configuration.LedgerPostingType; +import name.abuchen.portfolio.model.ledger.configuration.QuotationStyle; +import name.abuchen.portfolio.model.ledger.configuration.RoundingModeCode; +import name.abuchen.portfolio.model.ledger.configuration.TaxReason; +import name.abuchen.portfolio.money.CurrencyUnit; +import name.abuchen.portfolio.money.Money; + +/** + * Tests low-level ledger model behavior used by higher-level transaction flows. + * These tests make sure copying, graph links, and core model fields keep ledger data consistent. + */ +@SuppressWarnings("nls") +public class LedgerModelTest +{ + /** + * Checks the Ledger-V6 scenario: ledger entry carries identity and minimum fields. + * The result must keep ledger truth and visible runtime rows consistent. + * This protects against duplicate truth or partial mutation. + */ + @Test + public void testLedgerEntryCarriesIdentityAndMinimumFields() + { + var dateTime = LocalDateTime.of(2020, 9, 28, 0, 0); + var updatedAt = Instant.parse("2026-01-02T03:04:05Z"); + var parameter = LedgerParameter.ofString(LedgerParameterType.EVENT_REFERENCE, "reference"); + var posting = new LedgerPosting("posting-1"); + var entry = new LedgerEntry("entry-1"); + + entry.setType(LedgerEntryType.BUY); + entry.setDateTime(dateTime); + entry.setNote("note"); + entry.setSource("source"); + entry.addParameter(parameter); + entry.addPosting(posting); + entry.setUpdatedAt(updatedAt); + + assertThat(entry.getUUID(), is("entry-1")); + assertThat(entry.getType(), is(LedgerEntryType.BUY)); + assertThat(entry.getDateTime(), is(dateTime)); + assertThat(entry.getNote(), is("note")); + assertThat(entry.getSource(), is("source")); + assertThat(entry.getUpdatedAt(), is(updatedAt)); + assertThat(entry.getParameters(), is(List.of(parameter))); + assertThat(entry.getPostings(), is(List.of(posting))); + assertThrows(UnsupportedOperationException.class, () -> entry.getParameters().add(parameter)); + assertThrows(UnsupportedOperationException.class, () -> entry.getPostings().add(new LedgerPosting())); + assertTrue(entry.removeParameter(parameter)); + assertTrue(entry.getParameters().isEmpty()); + } + + /** + * Checks the Ledger-V6 scenario: generated uuids are independent. + * The result must keep ledger truth and visible runtime rows consistent. + * This protects against duplicate truth or partial mutation. + */ + @Test + public void testGeneratedUUIDsAreIndependent() + { + var entry = new LedgerEntry(); + var otherEntry = new LedgerEntry(); + var posting = new LedgerPosting(); + + assertNotEquals(entry.getUUID(), otherEntry.getUUID()); + assertNotEquals(entry.getUUID(), posting.getUUID()); + } + + /** + * Checks the Ledger-V6 scenario: ledger collects entries without owning client integration. + * The result must keep ledger truth and visible runtime rows consistent. + * This protects against duplicate truth or partial mutation. + */ + @Test + public void testLedgerCollectsEntriesWithoutOwningClientIntegration() + { + var ledger = new Ledger(); + var entry = new LedgerEntry("entry-1"); + + ledger.addEntry(entry); + + assertThat(ledger.getEntries(), is(List.of(entry))); + assertThrows(UnsupportedOperationException.class, () -> ledger.getEntries().add(new LedgerEntry())); + assertTrue(ledger.removeEntry(entry)); + assertTrue(ledger.getEntries().isEmpty()); + } + + /** + * Checks the Ledger-V6 scenario: ledger posting carries fields and forex as posting data. + * The result must keep ledger truth and visible runtime rows consistent. + * This protects against duplicate truth or partial mutation. + */ + @Test + public void testLedgerPostingCarriesFieldsAndForexAsPostingData() + { + var account = new Account(); + var portfolio = new Portfolio(); + var security = new Security("Siemens", CurrencyUnit.EUR); + var exchangeRate = BigDecimal.valueOf(1.0875); + var posting = new LedgerPosting("posting-1"); + + posting.setType(LedgerPostingType.SECURITY); + posting.setAmount(123456L); + posting.setCurrency(CurrencyUnit.EUR); + posting.setForexAmount(134259L); + posting.setForexCurrency(CurrencyUnit.USD); + posting.setExchangeRate(exchangeRate); + posting.setSecurity(security); + posting.setShares(500000L); + posting.setAccount(account); + posting.setPortfolio(portfolio); + posting.setSemanticRole(LedgerPostingSemanticRole.SECURITY); + posting.setDirection(LedgerPostingDirection.INBOUND); + posting.setCorporateActionLeg(CorporateActionLeg.TARGET_SECURITY); + posting.setUnitRole(LedgerPostingUnitRole.PRIMARY); + posting.setGroupKey("security-leg"); + posting.setLocalKey("target"); + + assertThat(posting.getUUID(), is("posting-1")); + assertThat(posting.getType(), is(LedgerPostingType.SECURITY)); + assertThat(posting.getAmount(), is(123456L)); + assertThat(posting.getCurrency(), is(CurrencyUnit.EUR)); + assertThat(posting.getForexAmount(), is(134259L)); + assertThat(posting.getForexCurrency(), is(CurrencyUnit.USD)); + assertThat(posting.getExchangeRate(), is(exchangeRate)); + assertSame(security, posting.getSecurity()); + assertThat(posting.getShares(), is(500000L)); + assertSame(account, posting.getAccount()); + assertSame(portfolio, posting.getPortfolio()); + assertThat(posting.getSemanticRole(), is(LedgerPostingSemanticRole.SECURITY)); + assertThat(posting.getDirection(), is(LedgerPostingDirection.INBOUND)); + assertThat(posting.getCorporateActionLeg(), is(CorporateActionLeg.TARGET_SECURITY)); + assertThat(posting.getUnitRole(), is(LedgerPostingUnitRole.PRIMARY)); + assertThat(posting.getGroupKey(), is("security-leg")); + assertThat(posting.getLocalKey(), is("target")); + } + + /** + * Checks the Phase-1 scenario: semantic posting vocabulary is typed and additive. + * Projection refs remain the active materialization source in this phase. + */ + @Test + public void testLedgerPostingSemanticVocabularyIsTypedAndAdditive() + { + var posting = new LedgerPosting("posting-1"); + + assertThat(posting.getSemanticRole(), is((LedgerPostingSemanticRole) null)); + assertThat(posting.getDirection(), is((LedgerPostingDirection) null)); + assertThat(posting.getCorporateActionLeg(), is((CorporateActionLeg) null)); + assertThat(posting.getUnitRole(), is((LedgerPostingUnitRole) null)); + assertThat(posting.getGroupKey(), is((String) null)); + assertThat(posting.getLocalKey(), is((String) null)); + + assertThat(EnumSet.allOf(LedgerPostingSemanticRole.class), + is(EnumSet.of(LedgerPostingSemanticRole.CASH, LedgerPostingSemanticRole.SECURITY, + LedgerPostingSemanticRole.RIGHT, LedgerPostingSemanticRole.BOND, + LedgerPostingSemanticRole.CASH_COMPENSATION, + LedgerPostingSemanticRole.ACCRUED_INTEREST, + LedgerPostingSemanticRole.PRINCIPAL_REDEMPTION, + LedgerPostingSemanticRole.FEE, LedgerPostingSemanticRole.TAX, + LedgerPostingSemanticRole.GROSS_VALUE, + LedgerPostingSemanticRole.FOREX_CONTEXT))); + assertThat(EnumSet.allOf(LedgerPostingDirection.class), + is(EnumSet.of(LedgerPostingDirection.INBOUND, LedgerPostingDirection.OUTBOUND, + LedgerPostingDirection.NEUTRAL))); + assertThat(EnumSet.allOf(LedgerPostingUnitRole.class), + is(EnumSet.of(LedgerPostingUnitRole.PRIMARY, LedgerPostingUnitRole.FEE, + LedgerPostingUnitRole.TAX, LedgerPostingUnitRole.GROSS_VALUE, + LedgerPostingUnitRole.FOREX_CONTEXT))); + } + + /** + * Checks the Ledger-V6 scenario: ex-date is local date time ledger parameter. + * The result must keep ledger truth and visible runtime rows consistent. + * This protects against duplicate truth or partial mutation. + */ + @Test + public void testExDateIsLocalDateTimeLedgerParameter() + { + var exDate = LocalDateTime.of(2020, 9, 28, 0, 0); + var parameter = LedgerParameter.ofLocalDateTime(LedgerParameterType.EX_DATE, exDate); + var posting = new LedgerPosting(); + + posting.addParameter(parameter); + + assertFalse(Arrays.stream(LedgerEntry.class.getDeclaredFields()).anyMatch(f -> "exDate".equals(f.getName()))); + assertThat(posting.getParameters(), is(List.of(parameter))); + assertThat(parameter.getType(), is(LedgerParameterType.EX_DATE)); + assertThat(parameter.getValueKind(), is(ValueKind.LOCAL_DATE_TIME)); + assertThat(parameter.getValue(), is(exDate)); + assertThrows(UnsupportedOperationException.class, () -> posting.getParameters().clear()); + } + + /** + * Checks the Ledger-V6 scenario: ledger entry and posting parameters are independent. + * The result must keep ledger truth and visible runtime rows consistent. + * This protects against duplicate truth or partial mutation. + */ + @Test + public void testLedgerEntryAndPostingParametersAreIndependent() + { + var entryParameter = LedgerParameter.ofString(LedgerParameterType.CORPORATE_ACTION_KIND, + CorporateActionKind.SPIN_OFF.getCode()); + var postingParameter = LedgerParameter.ofString(LedgerParameterType.FEE_REASON, + FeeReason.BROKER_FEE.getCode()); + var entry = new LedgerEntry(); + var posting = new LedgerPosting(); + + entry.addParameter(entryParameter); + posting.addParameter(postingParameter); + entry.addPosting(posting); + + assertThat(entry.getParameters(), is(List.of(entryParameter))); + assertThat(posting.getParameters(), is(List.of(postingParameter))); + assertFalse(entry.getParameters().contains(postingParameter)); + assertFalse(posting.getParameters().contains(entryParameter)); + } + + /** + * Checks the Ledger-V6 scenario: ledger parameter value kinds are explicit. + * The result must keep ledger truth and visible runtime rows consistent. + * This protects against duplicate truth or partial mutation. + */ + @Test + public void testLedgerParameterValueKindsAreExplicit() + { + var security = new Security("Siemens Energy", CurrencyUnit.EUR); + var expectedKinds = EnumSet.of(ValueKind.STRING, ValueKind.DECIMAL, ValueKind.LONG, ValueKind.MONEY, + ValueKind.SECURITY, ValueKind.ACCOUNT, ValueKind.PORTFOLIO, ValueKind.BOOLEAN, + ValueKind.LOCAL_DATE, ValueKind.LOCAL_DATE_TIME); + + assertThat(EnumSet.allOf(ValueKind.class), is(expectedKinds)); + assertValueKindPolicy(ValueKind.STRING, String.class); + assertValueKindPolicy(ValueKind.DECIMAL, BigDecimal.class); + assertValueKindPolicy(ValueKind.LONG, Long.class); + assertValueKindPolicy(ValueKind.MONEY, Money.class); + assertValueKindPolicy(ValueKind.SECURITY, Security.class); + assertValueKindPolicy(ValueKind.ACCOUNT, Account.class); + assertValueKindPolicy(ValueKind.PORTFOLIO, Portfolio.class); + assertValueKindPolicy(ValueKind.BOOLEAN, Boolean.class); + assertValueKindPolicy(ValueKind.LOCAL_DATE, LocalDate.class); + assertValueKindPolicy(ValueKind.LOCAL_DATE_TIME, LocalDateTime.class); + + assertThat(LedgerParameter.ofString(LedgerParameterType.FEE_REASON, + FeeReason.BROKER_FEE.getCode()).getValueKind(), + is(ValueKind.STRING)); + assertThat(LedgerParameter.ofDecimal(LedgerParameterType.RATIO_NUMERATOR, BigDecimal.ONE) + .getValueKind(), is(ValueKind.DECIMAL)); + assertSame(security, + LedgerParameter.ofSecurity(LedgerParameterType.SOURCE_SECURITY, security) + .getValue()); + assertThat(LedgerParameter.ofString(LedgerParameterType.CORPORATE_ACTION_LEG, + CorporateActionLeg.SOURCE_SECURITY.getCode()) + .getValueKind(), is(ValueKind.STRING)); + assertThat(LedgerParameter.ofString(LedgerParameterType.CASH_COMPENSATION_KIND, + CashCompensationKind.CASH_IN_LIEU.getCode()) + .getValueKind(), is(ValueKind.STRING)); + } + + /** + * Checks the Ledger-V6 scenario: ledger parameter type policies are explicit. + * The result must keep ledger truth and visible runtime rows consistent. + * This protects against duplicate truth or partial mutation. + */ + @Test + public void testLedgerParameterTypePoliciesAreExplicit() + { + assertParameterTypePolicy(LedgerParameterType.EX_DATE, LedgerParameterType.Scope.GENERAL, + ValueKind.LOCAL_DATE_TIME); + assertParameterTypePolicies(LedgerParameterType.Scope.CORPORATE_ACTION, ValueKind.STRING, + LedgerParameterType.CORPORATE_ACTION_LEG, + LedgerParameterType.CASH_COMPENSATION_KIND, LedgerParameterType.FEE_REASON, + LedgerParameterType.TAX_REASON, LedgerParameterType.CORPORATE_ACTION_KIND, + LedgerParameterType.CORPORATE_ACTION_SUBTYPE, + LedgerParameterType.EVENT_REFERENCE, LedgerParameterType.EVENT_STAGE, + LedgerParameterType.FRACTION_TREATMENT, LedgerParameterType.ROUNDING_MODE, + LedgerParameterType.COST_ALLOCATION_METHOD, LedgerParameterType.QUOTATION_STYLE); + assertParameterTypePolicies(LedgerParameterType.Scope.CORPORATE_ACTION, ValueKind.LOCAL_DATE, + LedgerParameterType.RECORD_DATE, LedgerParameterType.PAYMENT_DATE, + LedgerParameterType.EFFECTIVE_DATE, LedgerParameterType.SETTLEMENT_DATE, + LedgerParameterType.ELECTION_DEADLINE, + LedgerParameterType.INTEREST_PERIOD_START, + LedgerParameterType.INTEREST_PERIOD_END); + assertParameterTypePolicies(LedgerParameterType.Scope.CORPORATE_ACTION, ValueKind.SECURITY, + LedgerParameterType.SOURCE_SECURITY, LedgerParameterType.TARGET_SECURITY, + LedgerParameterType.RIGHT_SECURITY); + assertParameterTypePolicies(LedgerParameterType.Scope.CORPORATE_ACTION, ValueKind.ACCOUNT, + LedgerParameterType.SOURCE_ACCOUNT, LedgerParameterType.TARGET_ACCOUNT, + LedgerParameterType.CASH_ACCOUNT); + assertParameterTypePolicies(LedgerParameterType.Scope.CORPORATE_ACTION, ValueKind.PORTFOLIO, + LedgerParameterType.SOURCE_PORTFOLIO, LedgerParameterType.TARGET_PORTFOLIO); + assertParameterTypePolicies(LedgerParameterType.Scope.CORPORATE_ACTION, ValueKind.DECIMAL, + LedgerParameterType.RATIO_NUMERATOR, LedgerParameterType.RATIO_DENOMINATOR, + LedgerParameterType.FRACTION_QUANTITY, LedgerParameterType.CONVERSION_RATIO, + LedgerParameterType.PARTIAL_REDEMPTION_FACTOR, + LedgerParameterType.COUPON_RATE, LedgerParameterType.REDEMPTION_PRICE_PERCENT, + LedgerParameterType.SOURCE_COST_PERCENT, + LedgerParameterType.TARGET_COST_PERCENT, + LedgerParameterType.AFFECTED_SOURCE_QUANTITY); + assertParameterTypePolicies(LedgerParameterType.Scope.CORPORATE_ACTION, ValueKind.MONEY, + LedgerParameterType.NOMINAL_VALUE, LedgerParameterType.SUBSCRIPTION_PRICE, + LedgerParameterType.REFERENCE_PRICE, LedgerParameterType.CASH_IN_LIEU_AMOUNT, + LedgerParameterType.VALUATION_PRICE, LedgerParameterType.FAIR_MARKET_VALUE, + LedgerParameterType.ACCRUED_INTEREST_AMOUNT); + assertParameterTypePolicies(LedgerParameterType.Scope.CORPORATE_ACTION, ValueKind.BOOLEAN, + LedgerParameterType.CASH_IN_LIEU_APPLIED, + LedgerParameterType.TAXABLE_DISTRIBUTION, + LedgerParameterType.MANUAL_VALUATION_OVERRIDE, + LedgerParameterType.SAME_SECURITY_AS_SOURCE, LedgerParameterType.FRACTION_ROUNDED, + LedgerParameterType.RECLAIMABLE_TAX, LedgerParameterType.WITHHOLDING_TAX, + LedgerParameterType.TRANSACTION_TAX, LedgerParameterType.STAMP_DUTY); + + assertTrue(LedgerParameterType.EX_DATE.isGeneral()); + assertFalse(LedgerParameterType.EX_DATE.isCorporateAction()); + assertTrue(LedgerParameterType.CORPORATE_ACTION_LEG.isCorporateAction()); + assertFalse(LedgerParameterType.CORPORATE_ACTION_LEG.isGeneral()); + } + + /** + * Checks the Ledger-V6 scenario: posting type structural policies are explicit. + * The result must keep ledger truth and visible runtime rows consistent. + * This protects against duplicate truth or partial mutation. + */ + @Test + public void testPostingTypeStructuralPoliciesAreExplicit() + { + assertPostingTypePolicy(LedgerPostingType.CASH, LedgerPostingType.ComponentClass.CASH, true, true, false, + false, false, true, false, true); + assertPostingTypePolicy(LedgerPostingType.SECURITY, LedgerPostingType.ComponentClass.SECURITY, true, true, true, + true, true, false, true, true); + assertPostingTypePolicy(LedgerPostingType.CASH_COMPENSATION, LedgerPostingType.ComponentClass.COMPENSATION, + true, true, false, false, false, true, false, true); + assertPostingTypePolicy(LedgerPostingType.FEE, LedgerPostingType.ComponentClass.FEE, true, true, false, false, + false, false, false, true); + assertPostingTypePolicy(LedgerPostingType.TAX, LedgerPostingType.ComponentClass.TAX, true, true, false, false, + false, false, false, true); + assertPostingTypePolicy(LedgerPostingType.GROSS_VALUE, LedgerPostingType.ComponentClass.GROSS_VALUE, true, + true, false, false, false, false, false, true); + assertPostingTypePolicy(LedgerPostingType.FOREX, LedgerPostingType.ComponentClass.FOREX, false, false, false, + false, false, false, false, true); + assertPostingTypePolicy(LedgerPostingType.RIGHT, LedgerPostingType.ComponentClass.RIGHT, false, false, true, + false, true, false, true, false); + assertPostingTypePolicy(LedgerPostingType.BOND, LedgerPostingType.ComponentClass.BOND, false, false, true, + false, true, false, true, false); + assertPostingTypePolicy(LedgerPostingType.ACCRUED_INTEREST, + LedgerPostingType.ComponentClass.ACCRUED_INTEREST, true, true, false, false, false, false, + false, true); + assertPostingTypePolicy(LedgerPostingType.PRINCIPAL_REDEMPTION, + LedgerPostingType.ComponentClass.PRINCIPAL_REDEMPTION, true, true, false, false, false, false, + false, true); + + var componentClasses = EnumSet.noneOf(LedgerPostingType.ComponentClass.class); + + for (var type : LedgerPostingType.values()) + componentClasses.add(type.getComponentClass()); + + assertThat(componentClasses, is(EnumSet.allOf(LedgerPostingType.ComponentClass.class))); + } + + /** + * Checks the Ledger-V6 scenario: ledger parameter code domains are explicit. + * The result must keep ledger truth and visible runtime rows consistent. + * This protects against duplicate truth or partial mutation. + */ + @Test + public void testLedgerParameterCodeDomainsAreExplicit() + { + assertCodeDomain(LedgerParameterType.CORPORATE_ACTION_LEG, LedgerParameterCodeDomain.CORPORATE_ACTION_LEG, + CorporateActionLeg.SOURCE_SECURITY, CorporateActionLeg.TARGET_SECURITY, + CorporateActionLeg.DISTRIBUTED_SECURITY, CorporateActionLeg.RIGHT_SECURITY, + CorporateActionLeg.CASH_COMPENSATION, CorporateActionLeg.CASH_IN_LIEU, + CorporateActionLeg.FEE, CorporateActionLeg.TAX, + CorporateActionLeg.ACCRUED_INTEREST, CorporateActionLeg.PRINCIPAL, + CorporateActionLeg.REDEMPTION, CorporateActionLeg.CONVERSION_SOURCE, + CorporateActionLeg.CONVERSION_TARGET, CorporateActionLeg.OTHER); + assertCodeDomain(LedgerParameterType.CORPORATE_ACTION_KIND, + LedgerParameterCodeDomain.CORPORATE_ACTION_KIND, + CorporateActionKind.STOCK_DIVIDEND, CorporateActionKind.SPIN_OFF, + CorporateActionKind.BONUS_ISSUE, CorporateActionKind.RIGHTS_DISTRIBUTION, + CorporateActionKind.COUPON_PAYMENT, CorporateActionKind.PIK_INTEREST, + CorporateActionKind.DEFAULTED_INTEREST, CorporateActionKind.MATURITY, + CorporateActionKind.PARTIAL_REDEMPTION, CorporateActionKind.CALL, + CorporateActionKind.PUT, CorporateActionKind.CONVERSION, + CorporateActionKind.EXCHANGE, CorporateActionKind.RESTRUCTURING, + CorporateActionKind.DEFAULT); + assertCodeDomain(LedgerParameterType.CORPORATE_ACTION_SUBTYPE, + LedgerParameterCodeDomain.CORPORATE_ACTION_SUBTYPE, CorporateActionSubtype.STANDARD, + CorporateActionSubtype.OPTIONAL, CorporateActionSubtype.MANDATORY, + CorporateActionSubtype.CASH_AND_STOCK, CorporateActionSubtype.OTHER); + assertCodeDomain(LedgerParameterType.EVENT_STAGE, LedgerParameterCodeDomain.EVENT_STAGE, + EventStage.ANNOUNCED, EventStage.RECORD, + EventStage.EX_DATE, EventStage.PAYMENT, + EventStage.ISSUED, EventStage.EXERCISED, + EventStage.SOLD, EventStage.EXPIRED, + EventStage.SETTLED, EventStage.OTHER); + assertCodeDomain(LedgerParameterType.CASH_COMPENSATION_KIND, + LedgerParameterCodeDomain.CASH_COMPENSATION_KIND, + CashCompensationKind.CASH_IN_LIEU, + CashCompensationKind.FRACTIONAL_SHARE_COMPENSATION, + CashCompensationKind.ROUNDING_COMPENSATION, + CashCompensationKind.OTHER); + assertCodeDomain(LedgerParameterType.FRACTION_TREATMENT, + LedgerParameterCodeDomain.FRACTION_TREATMENT, FractionTreatment.NONE, + FractionTreatment.CASH_IN_LIEU, FractionTreatment.ROUND_DOWN, + FractionTreatment.ROUND_UP, FractionTreatment.DROP, + FractionTreatment.OTHER); + assertCodeDomain(LedgerParameterType.ROUNDING_MODE, LedgerParameterCodeDomain.ROUNDING_MODE, + RoundingModeCode.NONE, RoundingModeCode.FLOOR, + RoundingModeCode.CEILING, RoundingModeCode.HALF_UP, + RoundingModeCode.HALF_EVEN, RoundingModeCode.OTHER); + assertCodeDomain(LedgerParameterType.COST_ALLOCATION_METHOD, + LedgerParameterCodeDomain.COST_ALLOCATION_METHOD, CostAllocationMethod.NONE, + CostAllocationMethod.FMV_RATIO, + CostAllocationMethod.MANUAL_PERCENTAGE, + CostAllocationMethod.ZERO_COST_TARGET, + CostAllocationMethod.CARRY_OVER, CostAllocationMethod.OTHER); + assertCodeDomain(LedgerParameterType.QUOTATION_STYLE, + LedgerParameterCodeDomain.QUOTATION_STYLE, QuotationStyle.UNIT, + QuotationStyle.PERCENT, QuotationStyle.NOMINAL, + QuotationStyle.OTHER); + assertCodeDomain(LedgerParameterType.FEE_REASON, LedgerParameterCodeDomain.FEE_REASON, + FeeReason.BROKER_FEE, FeeReason.EXCHANGE_FEE, + FeeReason.CORPORATE_ACTION_FEE, FeeReason.STAMP_DUTY, + FeeReason.OTHER); + assertCodeDomain(LedgerParameterType.TAX_REASON, LedgerParameterCodeDomain.TAX_REASON, + TaxReason.WITHHOLDING_TAX, TaxReason.CAPITAL_GAINS_TAX, + TaxReason.TRANSACTION_TAX, TaxReason.STAMP_DUTY, + TaxReason.RECLAIMABLE_TAX, TaxReason.OTHER); + + assertFalse(LedgerParameterType.EVENT_REFERENCE.hasCodeDomain()); + + for (var type : LedgerParameterType.values()) + if (type.hasCodeDomain()) + assertThat(type.getExpectedValueKind(), is(ValueKind.STRING)); + + for (var domain : LedgerParameterCodeDomain.values()) + { + assertFalse(domain.getAllowedCodes().isEmpty()); + assertThat(domain.getAllowedCodes().stream().distinct().count(), is((long) domain.getAllowedCodes().size())); + assertTrue(domain.getAllowedCodes().stream().allMatch(code -> code.equals(code.toUpperCase(Locale.ROOT)))); + } + } + + /** + * Checks the Ledger-V6 scenario: ledger parameter factories validate parameter type. + * The result must keep ledger truth and visible runtime rows consistent. + * This protects against duplicate truth or partial mutation. + */ + @Test + public void testLedgerParameterFactoriesValidateParameterType() + { + var exDate = LocalDateTime.of(2020, 9, 28, 0, 0); + var money = Money.of(CurrencyUnit.EUR, 500L); + + assertThat(LedgerParameter.ofLocalDateTime(LedgerParameterType.EX_DATE, exDate).getValue(), + is(exDate)); + assertThat(LedgerParameter.ofString(LedgerParameterType.CORPORATE_ACTION_LEG, + CorporateActionLeg.SOURCE_SECURITY.getCode()).getValue(), + is(CorporateActionLeg.SOURCE_SECURITY.getCode())); + assertThat(LedgerParameter.ofString(LedgerParameterType.CASH_COMPENSATION_KIND, + CashCompensationKind.CASH_IN_LIEU.getCode()).getValue(), + is(CashCompensationKind.CASH_IN_LIEU.getCode())); + assertThat(LedgerParameter.ofString(LedgerParameterType.FEE_REASON, + FeeReason.BROKER_FEE.getCode()).getValue(), + is(FeeReason.BROKER_FEE.getCode())); + assertThat(LedgerParameter.ofLocalDate(LedgerParameterType.RECORD_DATE, + LocalDate.of(2026, 1, 2)).getValue(), is(LocalDate.of(2026, 1, 2))); + assertThat(LedgerParameter.ofBoolean(LedgerParameterType.CASH_IN_LIEU_APPLIED, Boolean.TRUE) + .getValue(), is(Boolean.TRUE)); + assertThat(LedgerParameter.ofMoney(LedgerParameterType.NOMINAL_VALUE, money).getValue(), + is(money)); + + assertFactoryRejects(LedgerParameterType.EX_DATE, ValueKind.MONEY, + () -> LedgerParameter.ofMoney(LedgerParameterType.EX_DATE, money)); + assertFactoryRejects(LedgerParameterType.RATIO_NUMERATOR, ValueKind.STRING, + () -> LedgerParameter.ofString(LedgerParameterType.RATIO_NUMERATOR, "1")); + assertFactoryRejects(LedgerParameterType.FEE_REASON, ValueKind.BOOLEAN, + () -> LedgerParameter.ofBoolean(LedgerParameterType.FEE_REASON, Boolean.TRUE)); + assertFactoryRejects(LedgerParameterType.EX_DATE, ValueKind.LOCAL_DATE, + () -> LedgerParameter.ofLocalDate(LedgerParameterType.EX_DATE, + LocalDate.of(2020, 9, 28))); + assertFactoryRejects(LedgerParameterType.RECORD_DATE, ValueKind.LOCAL_DATE_TIME, + () -> LedgerParameter.ofLocalDateTime(LedgerParameterType.RECORD_DATE, exDate)); + assertFactoryRejects(LedgerParameterType.CASH_IN_LIEU_APPLIED, ValueKind.STRING, + () -> LedgerParameter.ofString(LedgerParameterType.CASH_IN_LIEU_APPLIED, + "true")); + } + + /** + * Checks the Ledger-V6 scenario: ledger entry type policies separate standard and ledger native shapes. + * The result must keep ledger truth and visible runtime rows consistent. + * This protects against duplicate truth or partial mutation. + */ + @Test + public void testLedgerEntryTypePoliciesSeparateStandardAndLedgerNativeShapes() + { + var corporateActionFamilies = EnumSet.of(LedgerEntryType.CORPORATE_ACTION); + var standardFamilies = EnumSet.complementOf(corporateActionFamilies); + + standardFamilies.forEach(this::assertStandardLegacyShape); + corporateActionFamilies.forEach(this::assertLedgerNativeTargetedShape); + + assertTrue(LedgerEntryType.CORPORATE_ACTION.requiresTargetedDerivedDescriptors()); + assertTrue(LedgerEntryType.CORPORATE_ACTION.usesSignedTargetedProjectionFacts()); + } + + /** + * Checks the Ledger-V6 scenario: ledger entry type does not use positional boolean policy arguments. + * The result must keep ledger truth and visible runtime rows consistent. + * This protects against duplicate truth or partial mutation. + */ + @Test + public void testLedgerEntryTypeDoesNotUsePositionalBooleanPolicyArguments() + { + assertFalse(Arrays.stream(LedgerEntryType.class.getDeclaredFields()).anyMatch(f -> f.getType() == boolean.class)); + assertFalse(Arrays.stream(LedgerEntryType.class.getDeclaredConstructors()) + .flatMap(c -> Arrays.stream(c.getParameterTypes())).anyMatch(t -> t == boolean.class)); + assertTrue(Arrays.stream(LedgerEntryType.class.getDeclaredConstructors()) + .flatMap(c -> Arrays.stream(c.getParameterTypes())).anyMatch(t -> t == String.class)); + assertFalse(Arrays.stream(LedgerEntryType.class.getDeclaredMethods()) + .anyMatch(method -> method.getName().contains("Protobuf"))); + } + + /** + * Checks the Ledger-V6 scenario: ledger posting type does not use positional boolean policy arguments. + * The result must keep ledger truth and visible runtime rows consistent. + * This protects against duplicate truth or partial mutation. + */ + @Test + public void testLedgerPostingTypeDoesNotUsePositionalBooleanPolicyArguments() + { + assertFalse(Arrays.stream(LedgerPostingType.class.getDeclaredFields()) + .anyMatch(f -> f.getType() == boolean.class)); + assertFalse(Arrays.stream(LedgerPostingType.class.getDeclaredConstructors()) + .flatMap(c -> Arrays.stream(c.getParameterTypes())).anyMatch(t -> t == boolean.class)); + } + + /** + * Checks the Ledger-V6 scenario: enum skeleton contains required posting and projection shapes. + * The result must keep ledger truth and visible runtime rows consistent. + * This protects against duplicate truth or partial mutation. + */ + @Test + public void testEnumSkeletonContainsRequiredPostingAndProjectionShapes() + { + assertThat(EnumSet.allOf(LedgerPostingType.class), + is(EnumSet.of(LedgerPostingType.CASH, LedgerPostingType.SECURITY, LedgerPostingType.FEE, + LedgerPostingType.TAX, LedgerPostingType.GROSS_VALUE, + LedgerPostingType.FOREX, LedgerPostingType.CASH_COMPENSATION, + LedgerPostingType.RIGHT, LedgerPostingType.BOND, + LedgerPostingType.ACCRUED_INTEREST, + LedgerPostingType.PRINCIPAL_REDEMPTION))); + assertThat(EnumSet.allOf(LedgerProjectionRole.class), + is(EnumSet.of(LedgerProjectionRole.ACCOUNT, LedgerProjectionRole.PORTFOLIO, + LedgerProjectionRole.SOURCE_ACCOUNT, LedgerProjectionRole.TARGET_ACCOUNT, + LedgerProjectionRole.SOURCE_PORTFOLIO, LedgerProjectionRole.TARGET_PORTFOLIO, + LedgerProjectionRole.DELIVERY, LedgerProjectionRole.DELIVERY_INBOUND, + LedgerProjectionRole.DELIVERY_OUTBOUND, LedgerProjectionRole.CASH_COMPENSATION, + LedgerProjectionRole.OLD_SECURITY_LEG, LedgerProjectionRole.NEW_SECURITY_LEG))); + assertThat(EnumSet.allOf(LedgerParameterType.class), + is(EnumSet.of(LedgerParameterType.EX_DATE, + LedgerParameterType.CORPORATE_ACTION_LEG, + LedgerParameterType.SOURCE_SECURITY, + LedgerParameterType.TARGET_SECURITY, + LedgerParameterType.RATIO_NUMERATOR, + LedgerParameterType.RATIO_DENOMINATOR, + LedgerParameterType.CASH_COMPENSATION_KIND, + LedgerParameterType.FEE_REASON, + LedgerParameterType.TAX_REASON, + LedgerParameterType.CORPORATE_ACTION_KIND, + LedgerParameterType.CORPORATE_ACTION_SUBTYPE, + LedgerParameterType.EVENT_REFERENCE, + LedgerParameterType.EVENT_STAGE, + LedgerParameterType.RECORD_DATE, + LedgerParameterType.PAYMENT_DATE, + LedgerParameterType.EFFECTIVE_DATE, + LedgerParameterType.SETTLEMENT_DATE, + LedgerParameterType.ELECTION_DEADLINE, + LedgerParameterType.INTEREST_PERIOD_START, + LedgerParameterType.INTEREST_PERIOD_END, + LedgerParameterType.RIGHT_SECURITY, + LedgerParameterType.SOURCE_ACCOUNT, + LedgerParameterType.TARGET_ACCOUNT, + LedgerParameterType.CASH_ACCOUNT, + LedgerParameterType.SOURCE_PORTFOLIO, + LedgerParameterType.TARGET_PORTFOLIO, + LedgerParameterType.FRACTION_QUANTITY, + LedgerParameterType.CONVERSION_RATIO, + LedgerParameterType.PARTIAL_REDEMPTION_FACTOR, + LedgerParameterType.COUPON_RATE, + LedgerParameterType.REDEMPTION_PRICE_PERCENT, + LedgerParameterType.SOURCE_COST_PERCENT, + LedgerParameterType.TARGET_COST_PERCENT, + LedgerParameterType.AFFECTED_SOURCE_QUANTITY, + LedgerParameterType.NOMINAL_VALUE, + LedgerParameterType.SUBSCRIPTION_PRICE, + LedgerParameterType.REFERENCE_PRICE, + LedgerParameterType.CASH_IN_LIEU_AMOUNT, + LedgerParameterType.VALUATION_PRICE, + LedgerParameterType.FAIR_MARKET_VALUE, + LedgerParameterType.ACCRUED_INTEREST_AMOUNT, + LedgerParameterType.FRACTION_TREATMENT, + LedgerParameterType.ROUNDING_MODE, + LedgerParameterType.COST_ALLOCATION_METHOD, + LedgerParameterType.QUOTATION_STYLE, + LedgerParameterType.CASH_IN_LIEU_APPLIED, + LedgerParameterType.TAXABLE_DISTRIBUTION, + LedgerParameterType.MANUAL_VALUATION_OVERRIDE, + LedgerParameterType.SAME_SECURITY_AS_SOURCE, + LedgerParameterType.FRACTION_ROUNDED, + LedgerParameterType.RECLAIMABLE_TAX, + LedgerParameterType.WITHHOLDING_TAX, + LedgerParameterType.TRANSACTION_TAX, + LedgerParameterType.STAMP_DUTY))); + } + + /** + * Checks the Ledger-V6 scenario: configurable ledger entry type codes are stable. + * The result must keep ledger truth and visible runtime rows consistent. + * This protects against duplicate truth or partial mutation. + */ + @Test + public void testConfigurableLedgerEntryTypeCodesAreStable() + { + assertStableCodes(Arrays.stream(LedgerEntryType.values()).map(LedgerEntryType::getCode) + .toArray(String[]::new)); + + for (LedgerEntryType type : LedgerEntryType.values()) + assertThat(LedgerEntryType.fromCode(type.getCode()), is(type)); + + var exception = assertThrows(IllegalArgumentException.class, () -> LedgerEntryType.fromCode("UNKNOWN_CODE")); + + assertTrue(exception.getMessage(), exception.getMessage().contains(LedgerDiagnosticCode.LEDGER_CORE_017.prefix())); + assertTrue(exception.getMessage(), exception.getMessage().contains("LedgerEntryType")); + assertTrue(exception.getMessage(), exception.getMessage().contains("UNKNOWN_CODE")); + } + + /** + * Checks the Ledger-V6 scenario: configurable ledger posting type codes are stable. + * The result must keep ledger truth and visible runtime rows consistent. + * This protects against duplicate truth or partial mutation. + */ + @Test + public void testConfigurableLedgerPostingTypeCodesAreStable() + { + assertStableCodes(Arrays.stream(LedgerPostingType.values()).map(LedgerPostingType::getCode) + .toArray(String[]::new)); + + for (LedgerPostingType type : LedgerPostingType.values()) + assertThat(LedgerPostingType.fromCode(type.getCode()), is(type)); + + var exception = assertThrows(IllegalArgumentException.class, () -> LedgerPostingType.fromCode("UNKNOWN_CODE")); + + assertTrue(exception.getMessage(), exception.getMessage().contains(LedgerDiagnosticCode.LEDGER_CORE_022.prefix())); + assertTrue(exception.getMessage(), exception.getMessage().contains("LedgerPostingType")); + assertTrue(exception.getMessage(), exception.getMessage().contains("UNKNOWN_CODE")); + } + + /** + * Checks the Ledger-V6 scenario: configurable ledger parameter type codes are stable. + * The result must keep ledger truth and visible runtime rows consistent. + * This protects against duplicate truth or partial mutation. + */ + @Test + public void testConfigurableLedgerParameterTypeCodesAreStable() + { + assertStableCodes(Arrays.stream(LedgerParameterType.values()).map(LedgerParameterType::getCode) + .toArray(String[]::new)); + + for (LedgerParameterType type : LedgerParameterType.values()) + assertThat(LedgerParameterType.fromCode(type.getCode()), is(type)); + + var exception = assertThrows(IllegalArgumentException.class, + () -> LedgerParameterType.fromCode("UNKNOWN_CODE")); + + assertTrue(exception.getMessage(), exception.getMessage().contains(LedgerDiagnosticCode.LEDGER_CORE_020.prefix())); + assertTrue(exception.getMessage(), exception.getMessage().contains("LedgerParameterType")); + assertTrue(exception.getMessage(), exception.getMessage().contains("UNKNOWN_CODE")); + } + + private void assertStandardLegacyShape(LedgerEntryType type) + { + assertTrue(type.isLegacyFixedShape()); + assertFalse(type.isLedgerNativeTargeted()); + assertFalse(type.requiresTargetedDerivedDescriptors()); + assertTrue(type.supportsDerivedDescriptors()); + assertFalse(type.usesSignedTargetedProjectionFacts()); + } + + private void assertLedgerNativeTargetedShape(LedgerEntryType type) + { + assertFalse(type.isLegacyFixedShape()); + assertTrue(type.isLedgerNativeTargeted()); + assertTrue(type.requiresTargetedDerivedDescriptors()); + assertTrue(type.supportsDerivedDescriptors()); + assertTrue(type.usesSignedTargetedProjectionFacts()); + } + + private void assertValueKindPolicy(ValueKind valueKind, Class valueType) + { + assertThat(valueKind.getValueType(), is(valueType)); + assertTrue(valueKind.supportsValue(valueFor(valueKind))); + assertFalse(valueKind.supportsValue(null)); + assertFalse(valueKind.supportsValue(new Object())); + } + + private void assertStableCodes(String[] codes) + { + assertThat(Arrays.stream(codes).filter(String::isBlank).count(), is(0L)); + assertThat(Arrays.stream(codes).distinct().count(), is((long) codes.length)); + } + + private void assertParameterTypePolicy(LedgerParameterType type, LedgerParameterType.Scope scope, + ValueKind valueKind) + { + assertThat(type.getScope(), is(scope)); + assertThat(type.getExpectedValueKind(), is(valueKind)); + assertThat(type.getExpectedValueType(), is(valueKind.getValueType())); + assertThat(type.getExpectedJavaType(), is(valueKind.getValueType())); + assertTrue(type.supportsValueKind(valueKind)); + type.requireValueKind(valueKind); + assertTrue(type.supportsValue(valueFor(valueKind))); + assertFalse(type.supportsValue(null)); + assertFalse(type.supportsValue(new Object())); + assertThat(type.isReferenceParameter(), + is(valueKind == ValueKind.ACCOUNT || valueKind == ValueKind.PORTFOLIO + || valueKind == ValueKind.SECURITY)); + assertThat(type.isDateParameter(), + is(valueKind == ValueKind.LOCAL_DATE || valueKind == ValueKind.LOCAL_DATE_TIME)); + assertThat(type.isBooleanParameter(), is(valueKind == ValueKind.BOOLEAN)); + + EnumSet.complementOf(EnumSet.of(valueKind)).forEach(kind -> { + assertFalse(type.supportsValueKind(kind)); + assertFactoryRejects(type, kind, () -> type.requireValueKind(kind)); + }); + } + + private void assertParameterTypePolicies(LedgerParameterType.Scope scope, ValueKind valueKind, + LedgerParameterType... types) + { + for (var type : types) + assertParameterTypePolicy(type, scope, valueKind); + } + + private void assertCodeDomain(LedgerParameterType type, LedgerParameterCodeDomain domain, + LedgerCode... allowedCodes) + { + assertTrue(type.hasCodeDomain()); + assertThat(type.getCodeDomain(), is(domain)); + assertThat(domain.getAllowedCodes(), is(Arrays.stream(allowedCodes).map(LedgerCode::getCode).toList())); + + for (var allowedCode : allowedCodes) + { + assertTrue(domain.allows(allowedCode.getCode())); + assertTrue(type.supportsCode(allowedCode.getCode())); + } + + assertFalse(domain.allows("UNKNOWN_CODE")); + assertFalse(type.supportsCode("UNKNOWN_CODE")); + assertTrue(type.isControlledCode()); + } + + private void assertPostingTypePolicy(LedgerPostingType type, LedgerPostingType.ComponentClass componentClass, + boolean moneyBearing, boolean currencyRequired, boolean securityBearing, boolean securityRequired, + boolean sharesMeaningful, boolean accountReferenceMeaningful, + boolean portfolioReferenceMeaningful, boolean forexMeaningful) + { + assertThat(type.getComponentClass(), is(componentClass)); + assertThat(type.isMoneyBearing(), is(moneyBearing)); + assertThat(type.requiresCurrency(), is(currencyRequired)); + assertThat(type.isSecurityBearing(), is(securityBearing)); + assertThat(type.requiresSecurity(), is(securityRequired)); + assertThat(type.isSharesMeaningful(), is(sharesMeaningful)); + assertThat(type.isAccountReferenceMeaningful(), is(accountReferenceMeaningful)); + assertThat(type.isPortfolioReferenceMeaningful(), is(portfolioReferenceMeaningful)); + assertThat(type.isForexMeaningful(), is(forexMeaningful)); + } + + private void assertFactoryRejects(LedgerParameterType type, ValueKind valueKind, Runnable runnable) + { + var exception = assertThrows(IllegalArgumentException.class, runnable::run); + + assertTrue(exception.getMessage(), exception.getMessage().contains(type.name())); + assertTrue(exception.getMessage(), exception.getMessage().contains(valueKind.name())); + assertTrue(exception.getMessage(), exception.getMessage().contains(type.getExpectedValueKind().name())); + } + + private Object valueFor(ValueKind valueKind) + { + return switch (valueKind) + { + case STRING -> "value"; + case DECIMAL -> BigDecimal.ONE; + case LONG -> Long.valueOf(1); + case MONEY -> Money.of(CurrencyUnit.EUR, 1); + case SECURITY -> new Security("Security", CurrencyUnit.EUR); + case ACCOUNT -> new Account(); + case PORTFOLIO -> new Portfolio(); + case BOOLEAN -> Boolean.TRUE; + case LOCAL_DATE -> LocalDate.of(2020, 9, 28); + case LOCAL_DATE_TIME -> LocalDateTime.of(2020, 9, 28, 0, 0); + }; + } +} diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerParameterCodeDomainTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerParameterCodeDomainTest.java new file mode 100644 index 0000000000..c7383f1334 --- /dev/null +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerParameterCodeDomainTest.java @@ -0,0 +1,94 @@ +package name.abuchen.portfolio.model.ledger; + +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import java.util.EnumSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +import org.junit.Test; + +import name.abuchen.portfolio.model.ledger.configuration.LedgerParameterCodeDomain; + +/** + * Tests ledger configuration and validation metadata. + * These tests make sure entry definitions, posting rules, and parameter domains stay stable for Ledger-V6 transactions. + */ +@SuppressWarnings("nls") +public class LedgerParameterCodeDomainTest +{ + private static final Map> EXPECTED_CODES = Map.ofEntries( + Map.entry(LedgerParameterCodeDomain.CORPORATE_ACTION_LEG, + List.of("SOURCE_SECURITY", "TARGET_SECURITY", "DISTRIBUTED_SECURITY", + "RIGHT_SECURITY", "CASH_COMPENSATION", "CASH_IN_LIEU", "FEE", + "TAX", "ACCRUED_INTEREST", "PRINCIPAL", "REDEMPTION", + "CONVERSION_SOURCE", "CONVERSION_TARGET", "OTHER")), + Map.entry(LedgerParameterCodeDomain.CORPORATE_ACTION_KIND, + List.of("STOCK_DIVIDEND", "SPIN_OFF", "BONUS_ISSUE", + "RIGHTS_DISTRIBUTION", "COUPON_PAYMENT", "PIK_INTEREST", + "DEFAULTED_INTEREST", "MATURITY", "PARTIAL_REDEMPTION", + "CALL", "PUT", "CONVERSION", "EXCHANGE", "RESTRUCTURING", + "DEFAULT")), + Map.entry(LedgerParameterCodeDomain.CORPORATE_ACTION_SUBTYPE, + List.of("STANDARD", "OPTIONAL", "MANDATORY", "CASH_AND_STOCK", "OTHER")), + Map.entry(LedgerParameterCodeDomain.EVENT_STAGE, + List.of("ANNOUNCED", "RECORD", "EX_DATE", "PAYMENT", "ISSUED", "EXERCISED", + "SOLD", "EXPIRED", "SETTLED", "OTHER")), + Map.entry(LedgerParameterCodeDomain.CASH_COMPENSATION_KIND, + List.of("CASH_IN_LIEU", "FRACTIONAL_SHARE_COMPENSATION", + "ROUNDING_COMPENSATION", "OTHER")), + Map.entry(LedgerParameterCodeDomain.FRACTION_TREATMENT, + List.of("NONE", "CASH_IN_LIEU", "ROUND_DOWN", "ROUND_UP", "DROP", "OTHER")), + Map.entry(LedgerParameterCodeDomain.ROUNDING_MODE, + List.of("NONE", "FLOOR", "CEILING", "HALF_UP", "HALF_EVEN", "OTHER")), + Map.entry(LedgerParameterCodeDomain.COST_ALLOCATION_METHOD, + List.of("NONE", "FMV_RATIO", "MANUAL_PERCENTAGE", "ZERO_COST_TARGET", + "CARRY_OVER", "OTHER")), + Map.entry(LedgerParameterCodeDomain.QUOTATION_STYLE, + List.of("UNIT", "PERCENT", "NOMINAL", "OTHER")), + Map.entry(LedgerParameterCodeDomain.FEE_REASON, + List.of("BROKER_FEE", "EXCHANGE_FEE", "CORPORATE_ACTION_FEE", "STAMP_DUTY", + "OTHER")), + Map.entry(LedgerParameterCodeDomain.TAX_REASON, + List.of("WITHHOLDING_TAX", "CAPITAL_GAINS_TAX", "TRANSACTION_TAX", + "STAMP_DUTY", "RECLAIMABLE_TAX", "OTHER"))); + + /** + * Checks the ledger rule scenario: allowed codes remain persisted string contract. + * Invalid entry shapes must be rejected before they can be stored. + * This keeps higher-level Ledger-V6 transaction flows predictable. + */ + @Test + public void testAllowedCodesRemainPersistedStringContract() + { + assertThat(EXPECTED_CODES.keySet(), is(EnumSet.allOf(LedgerParameterCodeDomain.class))); + + for (var domain : LedgerParameterCodeDomain.values()) + assertThat(domain.getAllowedCodes(), is(EXPECTED_CODES.get(domain))); + } + + /** + * Checks the ledger rule scenario: allows remains compatible. + * Invalid entry shapes must be rejected before they can be stored. + * This keeps higher-level Ledger-V6 transaction flows predictable. + */ + @Test + public void testAllowsRemainsCompatible() + { + for (var domain : LedgerParameterCodeDomain.values()) + { + assertFalse(domain.getAllowedCodes().isEmpty()); + assertThat(domain.getAllowedCodes().stream().distinct().count(), is((long) domain.getAllowedCodes().size())); + assertTrue(domain.getAllowedCodes().stream().allMatch(code -> code.equals(code.toUpperCase(Locale.ROOT)))); + + for (var code : domain.getAllowedCodes()) + assertTrue(domain.allows(code)); + + assertFalse(domain.allows("UNKNOWN_CODE")); + } + } +} diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerParameterTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerParameterTest.java new file mode 100644 index 0000000000..a332923b63 --- /dev/null +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerParameterTest.java @@ -0,0 +1,86 @@ +package name.abuchen.portfolio.model.ledger; + +import static org.hamcrest.CoreMatchers.containsString; +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.junit.Assert.assertThrows; + +import org.junit.Test; + +import name.abuchen.portfolio.model.LedgerDiagnosticCode; +import name.abuchen.portfolio.model.ledger.LedgerParameter.ValueKind; +import name.abuchen.portfolio.model.ledger.configuration.CorporateActionLeg; +import name.abuchen.portfolio.model.ledger.configuration.LedgerParameterType; +import name.abuchen.portfolio.model.ledger.configuration.TaxReason; + +/** + * Tests ledger configuration and validation metadata. + * These tests make sure entry definitions, posting rules, and parameter domains stay stable for Ledger-V6 transactions. + */ +@SuppressWarnings("nls") +public class LedgerParameterTest +{ + /** + * Checks the ledger rule scenario: of code accepts matching domain. + * Invalid entry shapes must be rejected before they can be stored. + * This keeps higher-level Ledger-V6 transaction flows predictable. + */ + @Test + public void testOfCodeAcceptsMatchingDomain() + { + var parameter = LedgerParameter.ofCode(LedgerParameterType.CORPORATE_ACTION_LEG, + CorporateActionLeg.CASH_COMPENSATION); + + assertThat(parameter.getType(), is(LedgerParameterType.CORPORATE_ACTION_LEG)); + assertThat(parameter.getValueKind(), is(ValueKind.STRING)); + assertThat(parameter.getValue(), is(CorporateActionLeg.CASH_COMPENSATION.getCode())); + } + + /** + * Checks the ledger rule scenario: of code rejects wrong domain. + * Invalid entry shapes must be rejected before they can be stored. + * This keeps higher-level Ledger-V6 transaction flows predictable. + */ + @Test + public void testOfCodeRejectsWrongDomain() + { + var exception = assertThrows(IllegalArgumentException.class, + () -> LedgerParameter.ofCode(LedgerParameterType.CORPORATE_ACTION_LEG, + TaxReason.WITHHOLDING_TAX)); + + assertThat(exception.getMessage(), containsString(LedgerDiagnosticCode.LEDGER_CORE_013.prefix())); + assertThat(exception.getMessage(), containsString("expects code domain")); //$NON-NLS-1$ + } + + /** + * Checks the ledger rule scenario: of code rejects non code domain parameter. + * Invalid entry shapes must be rejected before they can be stored. + * This keeps higher-level Ledger-V6 transaction flows predictable. + */ + @Test + public void testOfCodeRejectsNonCodeDomainParameter() + { + var exception = assertThrows(IllegalArgumentException.class, + () -> LedgerParameter.ofCode(LedgerParameterType.EVENT_REFERENCE, + CorporateActionLeg.CASH_COMPENSATION)); + + assertThat(exception.getMessage(), containsString(LedgerDiagnosticCode.LEDGER_CORE_012.prefix())); + assertThat(exception.getMessage(), containsString("does not define a controlled code domain")); //$NON-NLS-1$ + } + + /** + * Checks the ledger rule scenario: of code rejects non string parameter. + * Invalid entry shapes must be rejected before they can be stored. + * This keeps higher-level Ledger-V6 transaction flows predictable. + */ + @Test + public void testOfCodeRejectsNonStringParameter() + { + var exception = assertThrows(IllegalArgumentException.class, + () -> LedgerParameter.ofCode(LedgerParameterType.SOURCE_SECURITY, + CorporateActionLeg.SOURCE_SECURITY)); + + assertThat(exception.getMessage(), containsString(LedgerDiagnosticCode.LEDGER_CORE_021.prefix())); + assertThat(exception.getMessage(), containsString("does not support STRING")); //$NON-NLS-1$ + } +} diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerPostingTypeDefinitionTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerPostingTypeDefinitionTest.java new file mode 100644 index 0000000000..857393f070 --- /dev/null +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerPostingTypeDefinitionTest.java @@ -0,0 +1,198 @@ +package name.abuchen.portfolio.model.ledger; + +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import java.time.LocalDateTime; +import java.util.EnumSet; + +import org.junit.Test; + +import name.abuchen.portfolio.model.ledger.configuration.FeeReason; +import name.abuchen.portfolio.model.ledger.configuration.LedgerEntryDefinitionRegistry; +import name.abuchen.portfolio.model.ledger.configuration.LedgerEntryType; +import name.abuchen.portfolio.model.ledger.configuration.LedgerEventParameterDefinition; +import name.abuchen.portfolio.model.ledger.configuration.LedgerParameterType; +import name.abuchen.portfolio.model.ledger.configuration.LedgerPostingType; +import name.abuchen.portfolio.model.ledger.configuration.LedgerPostingTypeDefinitionRegistry; +import name.abuchen.portfolio.money.CurrencyUnit; + +/** + * Tests ledger configuration and validation metadata. + * These tests make sure entry definitions, posting rules, and parameter domains stay stable for Ledger-V6 transactions. + */ +@SuppressWarnings("nls") +public class LedgerPostingTypeDefinitionTest +{ + /** + * Checks the ledger rule scenario: every posting type has definition. + * Invalid entry shapes must be rejected before they can be stored. + * This keeps higher-level Ledger-V6 transaction flows predictable. + */ + @Test + public void testEveryPostingTypeHasDefinition() + { + for (var postingType : LedgerPostingType.values()) + { + var definition = LedgerPostingTypeDefinitionRegistry.lookup(postingType).orElseThrow(); + + assertThat(definition.getPostingType(), is(postingType)); + assertFalse(definition.getComponentParameterTypes().isEmpty()); + } + + assertThat(LedgerPostingTypeDefinitionRegistry.getDefinitions().size(), is(LedgerPostingType.values().length)); + } + + /** + * Checks the ledger rule scenario: posting type definitions are consistent static configuration. + * Invalid entry shapes must be rejected before they can be stored. + * This keeps higher-level Ledger-V6 transaction flows predictable. + */ + @Test + public void testPostingTypeDefinitionsAreConsistentStaticConfiguration() + { + for (var definition : LedgerPostingTypeDefinitionRegistry.getDefinitions()) + { + assertTrue(LedgerPostingTypeDefinitionRegistry.hasDefinition(definition.getPostingType())); + + for (var parameterType : definition.getComponentParameterTypes()) + { + assertTrue(definition.supportsParameterType(parameterType)); + assertTrue(EnumSet.allOf(LedgerParameterType.class).contains(parameterType)); + } + } + } + + /** + * Checks the ledger rule scenario: component fact mapping sanity. + * Invalid entry shapes must be rejected before they can be stored. + * This keeps higher-level Ledger-V6 transaction flows predictable. + */ + @Test + public void testComponentFactMappingSanity() + { + assertSupports(LedgerPostingType.CASH, LedgerParameterType.SOURCE_ACCOUNT, LedgerParameterType.TARGET_ACCOUNT, + LedgerParameterType.CASH_ACCOUNT, LedgerParameterType.PAYMENT_DATE); + + assertSupports(LedgerPostingType.SECURITY, LedgerParameterType.SOURCE_SECURITY, + LedgerParameterType.TARGET_SECURITY, LedgerParameterType.RATIO_NUMERATOR, + LedgerParameterType.FRACTION_TREATMENT, LedgerParameterType.COST_ALLOCATION_METHOD, + LedgerParameterType.REFERENCE_PRICE, LedgerParameterType.CORPORATE_ACTION_LEG); + + assertSupports(LedgerPostingType.CASH_COMPENSATION, LedgerParameterType.CASH_COMPENSATION_KIND, + LedgerParameterType.CASH_IN_LIEU_AMOUNT, LedgerParameterType.CASH_IN_LIEU_APPLIED, + LedgerParameterType.FRACTION_QUANTITY, LedgerParameterType.CORPORATE_ACTION_LEG); + + assertSupports(LedgerPostingType.FEE, LedgerParameterType.FEE_REASON); + assertSupports(LedgerPostingType.TAX, LedgerParameterType.TAX_REASON, LedgerParameterType.WITHHOLDING_TAX, + LedgerParameterType.RECLAIMABLE_TAX); + assertSupports(LedgerPostingType.GROSS_VALUE, LedgerParameterType.REFERENCE_PRICE, + LedgerParameterType.VALUATION_PRICE, LedgerParameterType.FAIR_MARKET_VALUE); + assertSupports(LedgerPostingType.FOREX, LedgerParameterType.REFERENCE_PRICE, + LedgerParameterType.VALUATION_PRICE); + assertSupports(LedgerPostingType.RIGHT, LedgerParameterType.RIGHT_SECURITY, + LedgerParameterType.SUBSCRIPTION_PRICE, LedgerParameterType.ELECTION_DEADLINE); + assertSupports(LedgerPostingType.BOND, LedgerParameterType.NOMINAL_VALUE, + LedgerParameterType.QUOTATION_STYLE, LedgerParameterType.CONVERSION_RATIO, + LedgerParameterType.REDEMPTION_PRICE_PERCENT); + assertSupports(LedgerPostingType.ACCRUED_INTEREST, LedgerParameterType.ACCRUED_INTEREST_AMOUNT, + LedgerParameterType.COUPON_RATE, LedgerParameterType.INTEREST_PERIOD_START, + LedgerParameterType.INTEREST_PERIOD_END); + assertSupports(LedgerPostingType.PRINCIPAL_REDEMPTION, LedgerParameterType.NOMINAL_VALUE, + LedgerParameterType.REDEMPTION_PRICE_PERCENT, + LedgerParameterType.PARTIAL_REDEMPTION_FACTOR); + } + + /** + * Checks the ledger rule scenario: event level parameters are classified separately. + * Invalid entry shapes must be rejected before they can be stored. + * This keeps higher-level Ledger-V6 transaction flows predictable. + */ + @Test + public void testEventLevelParametersAreClassifiedSeparately() + { + assertTrue(LedgerEventParameterDefinition.supportsParameterType(LedgerParameterType.CORPORATE_ACTION_KIND)); + assertTrue(LedgerEventParameterDefinition.supportsParameterType(LedgerParameterType.CORPORATE_ACTION_SUBTYPE)); + assertTrue(LedgerEventParameterDefinition.supportsParameterType(LedgerParameterType.EVENT_REFERENCE)); + assertTrue(LedgerEventParameterDefinition.supportsParameterType(LedgerParameterType.EVENT_STAGE)); + assertTrue(LedgerEventParameterDefinition.supportsParameterType(LedgerParameterType.EX_DATE)); + assertTrue(LedgerEventParameterDefinition.supportsParameterType(LedgerParameterType.ELECTION_DEADLINE)); + assertFalse(LedgerEventParameterDefinition.supportsParameterType(LedgerParameterType.FEE_REASON)); + } + + /** + * Checks the ledger rule scenario: native entry definitions reference defined posting fact vocabulary. + * Invalid entry shapes must be rejected before they can be stored. + * This keeps higher-level Ledger-V6 transaction flows predictable. + */ + @Test + public void testNativeEntryDefinitionsReferenceDefinedPostingFactVocabulary() + { + for (var entryDefinition : LedgerEntryDefinitionRegistry.getDefinitions()) + { + for (var postingType : entryDefinition.getPostingTypes()) + assertTrue(LedgerPostingTypeDefinitionRegistry.hasDefinition(postingType)); + + for (var parameterType : entryDefinition.getEntryParameterTypes()) + assertTrue(parameterType.name(), isEventOrPostingFact(entryDefinition.getPostingTypes(), parameterType)); + + for (var parameterType : entryDefinition.getPostingParameterTypes()) + assertTrue(parameterType.name(), isEventOrPostingFact(entryDefinition.getPostingTypes(), parameterType)); + } + } + + /** + * Checks the ledger rule scenario: definition layer does not enforce posting fact completeness. + * Invalid entry shapes must be rejected before they can be stored. + * This keeps higher-level Ledger-V6 transaction flows predictable. + */ + @Test + public void testDefinitionLayerDoesNotEnforcePostingFactCompleteness() + { + var ledger = new Ledger(); + var entry = new LedgerEntry("entry-1"); + var posting = new LedgerPosting("posting-1"); + + entry.setType(LedgerEntryType.CORPORATE_ACTION); + entry.setDateTime(LocalDateTime.of(2026, 1, 2, 0, 0)); + posting.setType(LedgerPostingType.CASH); + posting.setCurrency(CurrencyUnit.EUR); + posting.setSemanticRole(LedgerPostingSemanticRole.CASH); + posting.setDirection(LedgerPostingDirection.NEUTRAL); + posting.setUnitRole(LedgerPostingUnitRole.PRIMARY); + posting.addParameter(LedgerParameter.ofString(LedgerParameterType.FEE_REASON, + FeeReason.BROKER_FEE.getCode())); + entry.addPosting(posting); + ledger.addEntry(entry); + + assertTrue(LedgerStructuralValidator.validate(ledger).isOK()); + assertFalse(LedgerPostingTypeDefinitionRegistry.lookup(LedgerPostingType.CASH).orElseThrow() + .supportsParameterType(LedgerParameterType.FEE_REASON)); + } + + private void assertSupports(LedgerPostingType postingType, LedgerParameterType... parameterTypes) + { + var definition = LedgerPostingTypeDefinitionRegistry.lookup(postingType).orElseThrow(); + + for (var parameterType : parameterTypes) + assertTrue(parameterType.name(), definition.supportsParameterType(parameterType)); + } + + private boolean isEventOrPostingFact(Iterable postingTypes, LedgerParameterType parameterType) + { + if (LedgerEventParameterDefinition.supportsParameterType(parameterType)) + return true; + + for (var postingType : postingTypes) + { + var definition = LedgerPostingTypeDefinitionRegistry.lookup(postingType).orElseThrow(); + + if (definition.supportsParameterType(parameterType)) + return true; + } + + return false; + } +} diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerStructuralValidatorTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerStructuralValidatorTest.java new file mode 100644 index 0000000000..128eca7962 --- /dev/null +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerStructuralValidatorTest.java @@ -0,0 +1,455 @@ +package name.abuchen.portfolio.model.ledger; + +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.junit.Assert.assertTrue; + +import java.time.LocalDateTime; + +import org.junit.Test; + +import name.abuchen.portfolio.model.Account; +import name.abuchen.portfolio.model.Portfolio; +import name.abuchen.portfolio.model.Security; +import name.abuchen.portfolio.model.ledger.LedgerStructuralValidator.IssueCode; +import name.abuchen.portfolio.model.ledger.configuration.CorporateActionLeg; +import name.abuchen.portfolio.model.ledger.configuration.CorporateActionKind; +import name.abuchen.portfolio.model.ledger.configuration.LedgerEntryType; +import name.abuchen.portfolio.model.ledger.configuration.LedgerParameterType; +import name.abuchen.portfolio.model.ledger.configuration.LedgerPostingType; +import name.abuchen.portfolio.money.CurrencyUnit; +import name.abuchen.portfolio.money.Values; + +/** + * Tests structural validation for ledger entries. + */ +@SuppressWarnings("nls") +public class LedgerStructuralValidatorTest +{ + @Test + public void testEmptyLedgerIsValid() + { + assertOK(LedgerStructuralValidator.validate(new Ledger())); + } + + @Test + public void testSimpleValidLedgerEntryPassesValidation() + { + assertOK(LedgerStructuralValidator.validate(createStandardLedger())); + } + + @Test + public void testValidFixedShapeSemanticEntriesPassValidation() + { + for (var type : new LedgerEntryType[] { LedgerEntryType.DEPOSIT, LedgerEntryType.REMOVAL, + LedgerEntryType.INTEREST, LedgerEntryType.INTEREST_CHARGE, LedgerEntryType.DIVIDENDS, + LedgerEntryType.FEES, LedgerEntryType.FEES_REFUND, LedgerEntryType.TAXES, + LedgerEntryType.TAX_REFUND, LedgerEntryType.BUY, LedgerEntryType.SELL, + LedgerEntryType.DELIVERY_INBOUND, LedgerEntryType.DELIVERY_OUTBOUND, + LedgerEntryType.CASH_TRANSFER, LedgerEntryType.SECURITY_TRANSFER }) + assertOK(LedgerStructuralValidator.validate(ledger(entry(type)))); + } + + @Test + public void testMissingTransferSourceIsRejected() + { + var entry = entry(LedgerEntryType.CASH_TRANSFER); + entry.removePosting(posting(entry, LedgerPostingDirection.OUTBOUND)); + + assertIssue(entry, IssueCode.SEMANTIC_SOURCE_REQUIRED); + } + + @Test + public void testMissingTransferTargetIsRejected() + { + var entry = entry(LedgerEntryType.CASH_TRANSFER); + entry.removePosting(posting(entry, LedgerPostingDirection.INBOUND)); + + assertIssue(entry, IssueCode.SEMANTIC_TARGET_REQUIRED); + } + + @Test + public void testDuplicateTransferSourceIsRejected() + { + var entry = entry(LedgerEntryType.CASH_TRANSFER); + entry.addPosting(cashPosting("duplicate-source", LedgerPostingDirection.OUTBOUND)); //$NON-NLS-1$ + + assertIssue(entry, IssueCode.SEMANTIC_SOURCE_AMBIGUOUS); + } + + @Test + public void testDuplicateTransferTargetIsRejected() + { + var entry = entry(LedgerEntryType.CASH_TRANSFER); + entry.addPosting(cashPosting("duplicate-target", LedgerPostingDirection.INBOUND)); //$NON-NLS-1$ + + assertIssue(entry, IssueCode.SEMANTIC_TARGET_AMBIGUOUS); + } + + @Test + public void testMissingPrimarySemanticRoleIsRejected() + { + var entry = entry(LedgerEntryType.BUY); + posting(entry, LedgerPostingSemanticRole.CASH).setSemanticRole(null); + + assertIssue(entry, IssueCode.SEMANTIC_PRIMARY_REQUIRED); + } + + @Test + public void testDuplicatePrimaryPostingsAreRejected() + { + var entry = entry(LedgerEntryType.DEPOSIT); + entry.addPosting(cashPosting("duplicate-cash", LedgerPostingDirection.NEUTRAL)); //$NON-NLS-1$ + + assertIssue(entry, IssueCode.SEMANTIC_PRIMARY_AMBIGUOUS); + } + + @Test + public void testGroupedUnitWithoutGroupKeyIsRejectedWhenEntryHasMultiplePrimaries() + { + var entry = entry(LedgerEntryType.BUY); + posting(entry, LedgerPostingSemanticRole.CASH).setGroupKey(LedgerProjectionRole.ACCOUNT.name()); + posting(entry, LedgerPostingSemanticRole.SECURITY).setGroupKey(LedgerProjectionRole.PORTFOLIO.name()); + var fee = unitPosting(LedgerPostingType.FEE, LedgerPostingUnitRole.FEE, null); + entry.addPosting(fee); + + assertIssue(entry, IssueCode.SEMANTIC_UNIT_GROUP_REQUIRED); + } + + @Test + public void testUnitWithUnknownGroupKeyIsRejected() + { + var entry = entry(LedgerEntryType.BUY); + var fee = unitPosting(LedgerPostingType.FEE, LedgerPostingUnitRole.FEE, "missing-group"); //$NON-NLS-1$ + entry.addPosting(fee); + + assertIssue(entry, IssueCode.SEMANTIC_UNIT_GROUP_AMBIGUOUS); + } + + @Test + public void testRepeatedUnitWithoutLocalKeyIsRejected() + { + var entry = entry(LedgerEntryType.BUY); + entry.addPosting(unitPosting(LedgerPostingType.FEE, LedgerPostingUnitRole.FEE, "account")); //$NON-NLS-1$ + entry.addPosting(unitPosting(LedgerPostingType.FEE, LedgerPostingUnitRole.FEE, "account")); //$NON-NLS-1$ + + assertIssue(entry, IssueCode.SEMANTIC_LOCAL_KEY_REQUIRED); + } + + @Test + public void testOptionalLocalKeyIsNotRequiredForUniqueUnit() + { + var entry = entry(LedgerEntryType.BUY); + entry.addPosting(unitPosting(LedgerPostingType.FEE, LedgerPostingUnitRole.FEE, "account")); //$NON-NLS-1$ + + assertOK(LedgerStructuralValidator.validate(ledger(entry))); + } + + @Test + public void testCorporateActionLegCompletenessIsValidated() + { + assertOK(LedgerStructuralValidator.validate(ledger(nativeEntry(LedgerEntryType.CORPORATE_ACTION)))); + } + + @Test + public void testSpinOffAllowsNoCorporateActionPrimaryLegs() + { + var entry = new LedgerEntry(); + entry.setType(LedgerEntryType.CORPORATE_ACTION); + entry.setDateTime(LocalDateTime.of(2026, 1, 1, 10, 0)); + entry.addParameter(LedgerParameter.ofCode(LedgerParameterType.CORPORATE_ACTION_KIND, + CorporateActionKind.SPIN_OFF)); + + assertOK(LedgerStructuralValidator.validate(ledger(entry))); + } + + @Test + public void testDuplicateCorporateActionLegWithoutLocalKeyIsRejected() + { + var entry = nativeEntry(LedgerEntryType.CORPORATE_ACTION); + var duplicate = securityPosting("duplicate-target", LedgerPostingDirection.INBOUND, //$NON-NLS-1$ + CorporateActionLeg.TARGET_SECURITY); + duplicate.setLocalKey(null); + entry.addPosting(duplicate); + + assertIssue(entry, IssueCode.SEMANTIC_LOCAL_KEY_REQUIRED); + } + + @Test + public void testSpinOffAcceptsRepeatedTargetLegsWithDistinctLocalKeys() + { + var entry = nativeEntry(LedgerEntryType.CORPORATE_ACTION); + var target = posting(entry, CorporateActionLeg.TARGET_SECURITY); + var duplicate = securityPosting("target-2", LedgerPostingDirection.INBOUND, //$NON-NLS-1$ + CorporateActionLeg.TARGET_SECURITY); + + target.setLocalKey("target-1"); //$NON-NLS-1$ + target.setGroupKey("main"); //$NON-NLS-1$ + duplicate.setGroupKey("main"); //$NON-NLS-1$ + entry.addPosting(duplicate); + + assertOK(LedgerStructuralValidator.validate(ledger(entry))); + } + + @Test + public void testSpinOffRejectsRepeatedTargetLegsWithDuplicateLocalKey() + { + var entry = nativeEntry(LedgerEntryType.CORPORATE_ACTION); + var target = posting(entry, CorporateActionLeg.TARGET_SECURITY); + var duplicate = securityPosting("target-1", LedgerPostingDirection.INBOUND, //$NON-NLS-1$ + CorporateActionLeg.TARGET_SECURITY); + + target.setLocalKey("target-1"); //$NON-NLS-1$ + entry.addPosting(duplicate); + + assertIssue(entry, IssueCode.SEMANTIC_TARGET_AMBIGUOUS); + } + + @Test + public void testSpinOffWithoutTargetLegIsAccepted() + { + var entry = nativeEntry(LedgerEntryType.CORPORATE_ACTION); + entry.removePosting(posting(entry, CorporateActionLeg.TARGET_SECURITY)); + + assertOK(LedgerStructuralValidator.validate(ledger(entry))); + } + + @Test + public void testSpinOffAcceptsRepeatedCashCompensationWithDistinctLocalKeys() + { + var entry = nativeEntry(LedgerEntryType.CORPORATE_ACTION); + + entry.addPosting(cashCompensationPosting("cash-1")); //$NON-NLS-1$ + entry.addPosting(cashCompensationPosting("cash-2")); //$NON-NLS-1$ + + assertOK(LedgerStructuralValidator.validate(ledger(entry))); + } + + @Test + public void testSpinOffRejectsRepeatedCashCompensationWithDuplicateLocalKey() + { + var entry = nativeEntry(LedgerEntryType.CORPORATE_ACTION); + + entry.addPosting(cashCompensationPosting("cash-1")); //$NON-NLS-1$ + entry.addPosting(cashCompensationPosting("cash-1")); //$NON-NLS-1$ + + assertIssue(entry, IssueCode.SEMANTIC_PRIMARY_AMBIGUOUS); + } + + private Ledger createStandardLedger() + { + return ledger(entry(LedgerEntryType.DEPOSIT)); + } + + private Ledger ledger(LedgerEntry entry) + { + var ledger = new Ledger(); + ledger.addEntry(entry); + + return ledger; + } + + private LedgerEntry entry(LedgerEntryType type) + { + var entry = new LedgerEntry(); + entry.setType(type); + entry.setDateTime(LocalDateTime.of(2026, 1, 1, 10, 0)); + + switch (type) + { + case DEPOSIT, REMOVAL, INTEREST, INTEREST_CHARGE, DIVIDENDS -> + { + var posting = cashPosting("account", LedgerPostingDirection.NEUTRAL); //$NON-NLS-1$ + if (type == LedgerEntryType.DIVIDENDS) + posting.setSecurity(security()); + entry.addPosting(posting); + } + case FEES, FEES_REFUND -> entry.addPosting(accountPosting("fee", LedgerPostingType.FEE, //$NON-NLS-1$ + LedgerPostingSemanticRole.FEE, LedgerPostingDirection.NEUTRAL)); + case TAXES, TAX_REFUND -> entry.addPosting(accountPosting("tax", LedgerPostingType.TAX, //$NON-NLS-1$ + LedgerPostingSemanticRole.TAX, LedgerPostingDirection.NEUTRAL)); + case BUY -> { + entry.addPosting(cashPosting("account", LedgerPostingDirection.OUTBOUND)); //$NON-NLS-1$ + entry.addPosting(securityPosting("portfolio", LedgerPostingDirection.INBOUND)); //$NON-NLS-1$ + } + case SELL -> { + entry.addPosting(cashPosting("account", LedgerPostingDirection.INBOUND)); //$NON-NLS-1$ + entry.addPosting(securityPosting("portfolio", LedgerPostingDirection.OUTBOUND)); //$NON-NLS-1$ + } + case DELIVERY_INBOUND -> entry.addPosting(securityPosting("delivery-in", LedgerPostingDirection.INBOUND)); //$NON-NLS-1$ + case DELIVERY_OUTBOUND -> entry.addPosting(securityPosting("delivery-out", LedgerPostingDirection.OUTBOUND)); //$NON-NLS-1$ + case CASH_TRANSFER -> { + entry.addPosting(cashPosting("source", LedgerPostingDirection.OUTBOUND)); //$NON-NLS-1$ + entry.addPosting(cashPosting("target", LedgerPostingDirection.INBOUND)); //$NON-NLS-1$ + } + case SECURITY_TRANSFER -> { + entry.addPosting(securityPosting("source", LedgerPostingDirection.OUTBOUND)); //$NON-NLS-1$ + entry.addPosting(securityPosting("target", LedgerPostingDirection.INBOUND)); //$NON-NLS-1$ + } + default -> throw new IllegalArgumentException(type.name()); + } + + return entry; + } + + private LedgerEntry nativeEntry(LedgerEntryType type) + { + var entry = new LedgerEntry(); + entry.setType(type); + entry.setDateTime(LocalDateTime.of(2026, 1, 1, 10, 0)); + + switch (type) + { + case CORPORATE_ACTION -> { + entry.addParameter(LedgerParameter.ofCode(LedgerParameterType.CORPORATE_ACTION_KIND, + CorporateActionKind.SPIN_OFF)); + entry.addPosting(securityPosting(LedgerProjectionRole.OLD_SECURITY_LEG.name(), + LedgerPostingDirection.OUTBOUND, CorporateActionLeg.SOURCE_SECURITY)); + entry.addPosting(securityPosting(LedgerProjectionRole.NEW_SECURITY_LEG.name(), + LedgerPostingDirection.INBOUND, + CorporateActionLeg.TARGET_SECURITY)); + } + default -> throw new IllegalArgumentException(type.name()); + } + + return entry; + } + + private LedgerPosting cashPosting(String localKey, LedgerPostingDirection direction) + { + return accountPosting(localKey, LedgerPostingType.CASH, LedgerPostingSemanticRole.CASH, direction); + } + + private LedgerPosting accountPosting(String localKey, LedgerPostingType type, LedgerPostingSemanticRole role, + LedgerPostingDirection direction) + { + var posting = new LedgerPosting(); + posting.setType(type); + posting.setAmount(Values.Amount.factorize(100)); + posting.setCurrency(CurrencyUnit.EUR); + posting.setAccount(account()); + markPrimary(posting, role, direction, localKey); + + return posting; + } + + private LedgerPosting securityPosting(String localKey, LedgerPostingDirection direction) + { + var posting = new LedgerPosting(); + posting.setType(LedgerPostingType.SECURITY); + posting.setAmount(Values.Amount.factorize(100)); + posting.setCurrency(CurrencyUnit.EUR); + posting.setPortfolio(portfolio()); + posting.setSecurity(security()); + posting.setShares(Values.Share.factorize(10)); + markPrimary(posting, LedgerPostingSemanticRole.SECURITY, direction, localKey); + + return posting; + } + + private LedgerPosting securityPosting(String localKey, LedgerPostingDirection direction, CorporateActionLeg leg) + { + var posting = securityPosting(localKey, direction); + posting.setCorporateActionLeg(leg); + posting.setGroupKey(localKey); + return posting; + } + + private LedgerPosting cashCompensationPosting(String localKey) + { + var posting = accountPosting(localKey, LedgerPostingType.CASH_COMPENSATION, + LedgerPostingSemanticRole.CASH_COMPENSATION, LedgerPostingDirection.NEUTRAL); + posting.setCorporateActionLeg(CorporateActionLeg.CASH_COMPENSATION); + posting.setGroupKey(localKey); + return posting; + } + + private LedgerPosting rightPosting(String localKey, CorporateActionLeg leg) + { + var posting = securityPosting(localKey, LedgerPostingDirection.INBOUND, leg); + posting.setType(LedgerPostingType.RIGHT); + posting.setSemanticRole(LedgerPostingSemanticRole.RIGHT); + return posting; + } + + private LedgerPosting bondPosting(String localKey, LedgerPostingDirection direction, CorporateActionLeg leg) + { + var posting = securityPosting(localKey, direction, leg); + posting.setType(LedgerPostingType.BOND); + posting.setSemanticRole(LedgerPostingSemanticRole.BOND); + return posting; + } + + private LedgerPosting unitPosting(LedgerPostingType type, LedgerPostingUnitRole unitRole, String groupKey) + { + var posting = new LedgerPosting(); + posting.setType(type); + posting.setAmount(Values.Amount.factorize(1)); + posting.setCurrency(CurrencyUnit.EUR); + posting.setSemanticRole(type == LedgerPostingType.FEE ? LedgerPostingSemanticRole.FEE + : LedgerPostingSemanticRole.TAX); + posting.setUnitRole(unitRole); + posting.setGroupKey(groupKey); + + return posting; + } + + private void markPrimary(LedgerPosting posting, LedgerPostingSemanticRole role, LedgerPostingDirection direction, + String localKey) + { + posting.setSemanticRole(role); + posting.setDirection(direction); + posting.setUnitRole(LedgerPostingUnitRole.PRIMARY); + posting.setLocalKey(localKey); + posting.setGroupKey(localKey); + } + + private LedgerPosting posting(LedgerEntry entry, LedgerPostingDirection direction) + { + return entry.getPostings().stream().filter(posting -> posting.getDirection() == direction).findFirst() + .orElseThrow(); + } + + private LedgerPosting posting(LedgerEntry entry, LedgerPostingSemanticRole role) + { + return entry.getPostings().stream().filter(posting -> posting.getSemanticRole() == role).findFirst() + .orElseThrow(); + } + + private LedgerPosting posting(LedgerEntry entry, CorporateActionLeg leg) + { + return entry.getPostings().stream().filter(posting -> posting.getCorporateActionLeg() == leg).findFirst() + .orElseThrow(); + } + + private Account account() + { + var account = new Account(); + account.setName("Cash"); + return account; + } + + private Portfolio portfolio() + { + var portfolio = new Portfolio(); + portfolio.setName("Portfolio"); + return portfolio; + } + + private Security security() + { + return new Security("Security", CurrencyUnit.EUR); + } + + private void assertOK(LedgerStructuralValidator.ValidationResult result) + { + assertTrue(result.format(), result.isOK()); + } + + private void assertIssue(LedgerEntry entry, IssueCode code) + { + var result = LedgerStructuralValidator.validate(ledger(entry)); + + assertThat(result.format(), result.hasIssue(code), is(true)); + } +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/Messages.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/Messages.java index f3587dfbd3..a7e3c50652 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/Messages.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/Messages.java @@ -242,6 +242,39 @@ public class Messages extends NLS public static String LabelXwithCurrencyY; public static String LabelYahooFinance; public static String LabelYahooFinanceAdjustedClose; + public static String LedgerDiagnosticMessageFormatterAccount; + public static String LedgerDiagnosticMessageFormatterAmount; + public static String LedgerDiagnosticMessageFormatterContextUnavailable; + public static String LedgerDiagnosticMessageFormatterDate; + public static String LedgerDiagnosticMessageFormatterIsin; + public static String LedgerDiagnosticMessageFormatterNote; + public static String LedgerDiagnosticMessageFormatterPortfolio; + public static String LedgerDiagnosticMessageFormatterSecurity; + public static String LedgerDiagnosticMessageFormatterShares; + public static String LedgerDiagnosticMessageFormatterSource; + public static String LedgerDiagnosticMessageFormatterTicker; + public static String LedgerDiagnosticMessageFormatterTransactionContext; + public static String LedgerDiagnosticMessageFormatterType; + public static String LedgerDiagnosticMessageFormatterUnnamedAccount; + public static String LedgerDiagnosticMessageFormatterUnnamedPortfolio; + public static String LedgerDiagnosticMessageFormatterUnnamedSecurity; + public static String LedgerDiagnosticMessageFormatterWkn; + public static String LedgerStructuralValidatorDividendSecurityRequired; + public static String LedgerStructuralValidatorEntryDateTimeRequired; + public static String LedgerStructuralValidatorEntryTypeRequired; + public static String LedgerStructuralValidatorExDateSecurityRequired; + public static String LedgerStructuralValidatorLedgerRequired; + public static String LedgerStructuralValidatorParameterCodeNotAllowed; + public static String LedgerStructuralValidatorParameterMustUseValueKind; + public static String LedgerStructuralValidatorParameterTypeRequired; + public static String LedgerStructuralValidatorParameterValueKindMismatch; + public static String LedgerStructuralValidatorParameterValueKindRequired; + public static String LedgerStructuralValidatorParameterValueRequired; + public static String LedgerStructuralValidatorPostingCurrencyRequired; + public static String LedgerStructuralValidatorPostingExchangeRatePositive; + public static String LedgerStructuralValidatorPostingSecurityRequired; + public static String LedgerStructuralValidatorPostingTypeRequired; + public static String LedgerStructuralValidatorSignedFactsNotAllowed; public static String MsgAlphaVantageAPIKeyMissing; public static String MsgCheckConfiguredAndCalculatedGrossValueDoNotMatch; public static String MsgCheckDividendsMustHaveASecurity; diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/messages.properties b/name.abuchen.portfolio/src/name/abuchen/portfolio/messages.properties index 1e97e6b6b0..4cfb2bffc0 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/messages.properties +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/messages.properties @@ -472,6 +472,72 @@ LabelYahooFinance = Yahoo Finance LabelYahooFinanceAdjustedClose = Yahoo Finance (Adjusted Close) +LedgerDiagnosticMessageFormatterAccount = Account + +LedgerDiagnosticMessageFormatterAmount = Amount + +LedgerDiagnosticMessageFormatterContextUnavailable = not available + +LedgerDiagnosticMessageFormatterDate = Date + +LedgerDiagnosticMessageFormatterIsin = ISIN + +LedgerDiagnosticMessageFormatterNote = Note + +LedgerDiagnosticMessageFormatterPortfolio = Portfolio + +LedgerDiagnosticMessageFormatterSecurity = Security + +LedgerDiagnosticMessageFormatterShares = Shares + +LedgerDiagnosticMessageFormatterSource = Source + +LedgerDiagnosticMessageFormatterTicker = Ticker + +LedgerDiagnosticMessageFormatterTransactionContext = Transaction details + +LedgerDiagnosticMessageFormatterType = Type + +LedgerDiagnosticMessageFormatterUnnamedAccount = unnamed account + +LedgerDiagnosticMessageFormatterUnnamedPortfolio = unnamed portfolio + +LedgerDiagnosticMessageFormatterUnnamedSecurity = unnamed security + +LedgerDiagnosticMessageFormatterWkn = WKN + +LedgerStructuralValidatorDividendSecurityRequired = Dividend entries must reference a security posting: {0} + +LedgerStructuralValidatorEntryDateTimeRequired = Ledger entry {0} must have a date and time. + +LedgerStructuralValidatorEntryTypeRequired = Ledger entry {0} must have an entry type. + +LedgerStructuralValidatorExDateSecurityRequired = EX_DATE can only be used on postings that reference a security: {0} + +LedgerStructuralValidatorLedgerRequired = A Ledger object is required. + +LedgerStructuralValidatorParameterCodeNotAllowed = The Ledger parameter for {0} must use one of the configured codes. + +LedgerStructuralValidatorParameterMustUseValueKind = {0} must use value kind {1}. + +LedgerStructuralValidatorParameterTypeRequired = Ledger parameter {0} must have a parameter type. + +LedgerStructuralValidatorParameterValueKindMismatch = The Ledger parameter value must match value kind {0}. + +LedgerStructuralValidatorParameterValueKindRequired = Ledger parameter {0} must declare a value kind. + +LedgerStructuralValidatorParameterValueRequired = Ledger parameter {0} must contain a value. + +LedgerStructuralValidatorPostingCurrencyRequired = Ledger posting {0} must have a currency. + +LedgerStructuralValidatorPostingExchangeRatePositive = Ledger posting {0} must have an exchange rate greater than zero. + +LedgerStructuralValidatorPostingSecurityRequired = Security posting {0} must reference a security. + +LedgerStructuralValidatorPostingTypeRequired = Ledger posting {0} must have a posting type. + +LedgerStructuralValidatorSignedFactsNotAllowed = Entry type {0} only accepts positive posting values. Use positive amounts and shares. + MsgAlphaVantageAPIKeyMissing = Alpha Vantage API key is missing. Configure in preferences. MsgCheckConfiguredAndCalculatedGrossValueDoNotMatch = Imported gross value ({0}) and calculated gross value ({1}) do not match diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/LedgerDiagnosticCode.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/LedgerDiagnosticCode.java new file mode 100644 index 0000000000..9415f3defa --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/LedgerDiagnosticCode.java @@ -0,0 +1,331 @@ +package name.abuchen.portfolio.model; + +import java.util.Locale; + +/** + * Stable diagnostic identifiers for Ledger messages. + * Codes are technical identifiers and are not translated. + */ +public enum LedgerDiagnosticCode +{ + LEDGER_CORE_001("CORE", 1), //$NON-NLS-1$ + LEDGER_CORE_002("CORE", 2), //$NON-NLS-1$ + LEDGER_CORE_003("CORE", 3), //$NON-NLS-1$ + LEDGER_CORE_004("CORE", 4), //$NON-NLS-1$ + LEDGER_CORE_005("CORE", 5), //$NON-NLS-1$ + LEDGER_CORE_006("CORE", 6), //$NON-NLS-1$ + LEDGER_CORE_007("CORE", 7), //$NON-NLS-1$ + LEDGER_CORE_008("CORE", 8), //$NON-NLS-1$ + LEDGER_CORE_009("CORE", 9), //$NON-NLS-1$ + LEDGER_CORE_010("CORE", 10), //$NON-NLS-1$ + LEDGER_CORE_011("CORE", 11), //$NON-NLS-1$ + LEDGER_CORE_012("CORE", 12), //$NON-NLS-1$ + LEDGER_CORE_013("CORE", 13), //$NON-NLS-1$ + LEDGER_CORE_014("CORE", 14), //$NON-NLS-1$ + LEDGER_CORE_015("CORE", 15), //$NON-NLS-1$ + LEDGER_CORE_016("CORE", 16), //$NON-NLS-1$ + LEDGER_CORE_017("CORE", 17), //$NON-NLS-1$ + LEDGER_CORE_018("CORE", 18), //$NON-NLS-1$ + LEDGER_CORE_019("CORE", 19), //$NON-NLS-1$ + LEDGER_CORE_020("CORE", 20), //$NON-NLS-1$ + LEDGER_CORE_021("CORE", 21), //$NON-NLS-1$ + LEDGER_CORE_022("CORE", 22), //$NON-NLS-1$ + LEDGER_CORE_023("CORE", 23), //$NON-NLS-1$ + LEDGER_CORE_024("CORE", 24), //$NON-NLS-1$ + LEDGER_CORE_025("CORE", 25), //$NON-NLS-1$ + LEDGER_CORE_026("CORE", 26), //$NON-NLS-1$ + LEDGER_IMPORT_001("IMPORT", 1), //$NON-NLS-1$ + LEDGER_IMPORT_002("IMPORT", 2), //$NON-NLS-1$ + LEDGER_IMPORT_003("IMPORT", 3), //$NON-NLS-1$ + LEDGER_IMPORT_004("IMPORT", 4), //$NON-NLS-1$ + LEDGER_IMPORT_005("IMPORT", 5), //$NON-NLS-1$ + LEDGER_IMPORT_006("IMPORT", 6), //$NON-NLS-1$ + LEDGER_IMPORT_007("IMPORT", 7), //$NON-NLS-1$ + LEDGER_IMPORT_008("IMPORT", 8), //$NON-NLS-1$ + LEDGER_IMPORT_009("IMPORT", 9), //$NON-NLS-1$ + LEDGER_IMPORT_010("IMPORT", 10), //$NON-NLS-1$ + LEDGER_IMPORT_011("IMPORT", 11), //$NON-NLS-1$ + LEDGER_IMPORT_012("IMPORT", 12), //$NON-NLS-1$ + LEDGER_IMPORT_013("IMPORT", 13), //$NON-NLS-1$ + LEDGER_IMPORT_014("IMPORT", 14), //$NON-NLS-1$ + LEDGER_IMPORT_015("IMPORT", 15), //$NON-NLS-1$ + LEDGER_IMPORT_016("IMPORT", 16), //$NON-NLS-1$ + LEDGER_IMPORT_017("IMPORT", 17), //$NON-NLS-1$ + LEDGER_IMPORT_018("IMPORT", 18), //$NON-NLS-1$ + LEDGER_IMPORT_019("IMPORT", 19), //$NON-NLS-1$ + LEDGER_IMPORT_020("IMPORT", 20), //$NON-NLS-1$ + LEDGER_IMPORT_021("IMPORT", 21), //$NON-NLS-1$ + LEDGER_STRUCT_001("STRUCT", 1), //$NON-NLS-1$ + LEDGER_STRUCT_002("STRUCT", 2), //$NON-NLS-1$ + LEDGER_STRUCT_003("STRUCT", 3), //$NON-NLS-1$ + LEDGER_STRUCT_004("STRUCT", 4), //$NON-NLS-1$ + LEDGER_STRUCT_005("STRUCT", 5), //$NON-NLS-1$ + LEDGER_STRUCT_006("STRUCT", 6), //$NON-NLS-1$ + LEDGER_STRUCT_007("STRUCT", 7), //$NON-NLS-1$ + LEDGER_STRUCT_008("STRUCT", 8), //$NON-NLS-1$ + LEDGER_STRUCT_009("STRUCT", 9), //$NON-NLS-1$ + LEDGER_STRUCT_010("STRUCT", 10), //$NON-NLS-1$ + LEDGER_STRUCT_011("STRUCT", 11), //$NON-NLS-1$ + LEDGER_STRUCT_012("STRUCT", 12), //$NON-NLS-1$ + LEDGER_STRUCT_013("STRUCT", 13), //$NON-NLS-1$ + LEDGER_STRUCT_014("STRUCT", 14), //$NON-NLS-1$ + LEDGER_STRUCT_015("STRUCT", 15), //$NON-NLS-1$ + LEDGER_STRUCT_016("STRUCT", 16), //$NON-NLS-1$ + LEDGER_STRUCT_017("STRUCT", 17), //$NON-NLS-1$ + LEDGER_STRUCT_018("STRUCT", 18), //$NON-NLS-1$ + LEDGER_STRUCT_019("STRUCT", 19), //$NON-NLS-1$ + LEDGER_STRUCT_020("STRUCT", 20), //$NON-NLS-1$ + LEDGER_STRUCT_021("STRUCT", 21), //$NON-NLS-1$ + LEDGER_STRUCT_022("STRUCT", 22), //$NON-NLS-1$ + LEDGER_STRUCT_023("STRUCT", 23), //$NON-NLS-1$ + LEDGER_STRUCT_024("STRUCT", 24), //$NON-NLS-1$ + LEDGER_STRUCT_025("STRUCT", 25), //$NON-NLS-1$ + LEDGER_STRUCT_026("STRUCT", 26), //$NON-NLS-1$ + LEDGER_STRUCT_027("STRUCT", 27), //$NON-NLS-1$ + LEDGER_STRUCT_028("STRUCT", 28), //$NON-NLS-1$ + LEDGER_STRUCT_029("STRUCT", 29), //$NON-NLS-1$ + LEDGER_STRUCT_030("STRUCT", 30), //$NON-NLS-1$ + LEDGER_STRUCT_031("STRUCT", 31), //$NON-NLS-1$ + LEDGER_STRUCT_032("STRUCT", 32), //$NON-NLS-1$ + LEDGER_STRUCT_033("STRUCT", 33), //$NON-NLS-1$ + LEDGER_STRUCT_034("STRUCT", 34), //$NON-NLS-1$ + LEDGER_STRUCT_035("STRUCT", 35), //$NON-NLS-1$ + LEDGER_STRUCT_036("STRUCT", 36), //$NON-NLS-1$ + LEDGER_STRUCT_037("STRUCT", 37), //$NON-NLS-1$ + LEDGER_STRUCT_038("STRUCT", 38), //$NON-NLS-1$ + LEDGER_STRUCT_039("STRUCT", 39), //$NON-NLS-1$ + LEDGER_STRUCT_040("STRUCT", 40), //$NON-NLS-1$ + LEDGER_STRUCT_041("STRUCT", 41), //$NON-NLS-1$ + LEDGER_STRUCT_042("STRUCT", 42), //$NON-NLS-1$ + LEDGER_STRUCT_043("STRUCT", 43), //$NON-NLS-1$ + LEDGER_STRUCT_044("STRUCT", 44), //$NON-NLS-1$ + LEDGER_STRUCT_045("STRUCT", 45), //$NON-NLS-1$ + LEDGER_STRUCT_046("STRUCT", 46), //$NON-NLS-1$ + LEDGER_STRUCT_047("STRUCT", 47), //$NON-NLS-1$ + LEDGER_STRUCT_048("STRUCT", 48), //$NON-NLS-1$ + LEDGER_STRUCT_049("STRUCT", 49), //$NON-NLS-1$ + LEDGER_STRUCT_050("STRUCT", 50), //$NON-NLS-1$ + LEDGER_STRUCT_051("STRUCT", 51), //$NON-NLS-1$ + LEDGER_STRUCT_052("STRUCT", 52), //$NON-NLS-1$ + LEDGER_STRUCT_053("STRUCT", 53), //$NON-NLS-1$ + LEDGER_STRUCT_054("STRUCT", 54), //$NON-NLS-1$ + LEDGER_STRUCT_055("STRUCT", 55), //$NON-NLS-1$ + LEDGER_PROJ_001("PROJ", 1), //$NON-NLS-1$ + LEDGER_PROJ_002("PROJ", 2), //$NON-NLS-1$ + LEDGER_PROJ_003("PROJ", 3), //$NON-NLS-1$ + LEDGER_PROJ_004("PROJ", 4), //$NON-NLS-1$ + LEDGER_PROJ_005("PROJ", 5), //$NON-NLS-1$ + LEDGER_PROJ_006("PROJ", 6), //$NON-NLS-1$ + LEDGER_PROJ_007("PROJ", 7), //$NON-NLS-1$ + LEDGER_PROJ_008("PROJ", 8), //$NON-NLS-1$ + LEDGER_PROJ_009("PROJ", 9), //$NON-NLS-1$ + LEDGER_PROJ_010("PROJ", 10), //$NON-NLS-1$ + LEDGER_PROJ_011("PROJ", 11), //$NON-NLS-1$ + LEDGER_PROJ_012("PROJ", 12), //$NON-NLS-1$ + LEDGER_PROJ_013("PROJ", 13), //$NON-NLS-1$ + LEDGER_PROJ_014("PROJ", 14), //$NON-NLS-1$ + LEDGER_PROJ_015("PROJ", 15), //$NON-NLS-1$ + LEDGER_PROJ_016("PROJ", 16), //$NON-NLS-1$ + LEDGER_PROJ_017("PROJ", 17), //$NON-NLS-1$ + LEDGER_PROJ_018("PROJ", 18), //$NON-NLS-1$ + LEDGER_PROJ_019("PROJ", 19), //$NON-NLS-1$ + LEDGER_PROJ_020("PROJ", 20), //$NON-NLS-1$ + LEDGER_PROJ_021("PROJ", 21), //$NON-NLS-1$ + LEDGER_PROJ_022("PROJ", 22), //$NON-NLS-1$ + LEDGER_PROJ_023("PROJ", 23), //$NON-NLS-1$ + LEDGER_PROJ_024("PROJ", 24), //$NON-NLS-1$ + LEDGER_PROJ_025("PROJ", 25), //$NON-NLS-1$ + LEDGER_PROJ_026("PROJ", 26), //$NON-NLS-1$ + LEDGER_PROJ_027("PROJ", 27), //$NON-NLS-1$ + LEDGER_PROJ_028("PROJ", 28), //$NON-NLS-1$ + LEDGER_PROJ_029("PROJ", 29), //$NON-NLS-1$ + LEDGER_PROJ_030("PROJ", 30), //$NON-NLS-1$ + LEDGER_PROJ_031("PROJ", 31), //$NON-NLS-1$ + LEDGER_PROJ_032("PROJ", 32), //$NON-NLS-1$ + LEDGER_PROJ_033("PROJ", 33), //$NON-NLS-1$ + LEDGER_PROJ_034("PROJ", 34), //$NON-NLS-1$ + LEDGER_PROJ_035("PROJ", 35), //$NON-NLS-1$ + LEDGER_PROJ_036("PROJ", 36), //$NON-NLS-1$ + LEDGER_PROJ_037("PROJ", 37), //$NON-NLS-1$ + LEDGER_PROJ_038("PROJ", 38), //$NON-NLS-1$ + LEDGER_PROJ_039("PROJ", 39), //$NON-NLS-1$ + LEDGER_PROJ_040("PROJ", 40), //$NON-NLS-1$ + LEDGER_PROJ_041("PROJ", 41), //$NON-NLS-1$ + LEDGER_PROJ_042("PROJ", 42), //$NON-NLS-1$ + LEDGER_PROJ_043("PROJ", 43), //$NON-NLS-1$ + LEDGER_PROJ_044("PROJ", 44), //$NON-NLS-1$ + LEDGER_PROJ_045("PROJ", 45), //$NON-NLS-1$ + LEDGER_PROJ_046("PROJ", 46), //$NON-NLS-1$ + LEDGER_PROJ_047("PROJ", 47), //$NON-NLS-1$ + LEDGER_PROJ_048("PROJ", 48), //$NON-NLS-1$ + LEDGER_PROJ_049("PROJ", 49), //$NON-NLS-1$ + LEDGER_PROJ_050("PROJ", 50), //$NON-NLS-1$ + LEDGER_PROJ_051("PROJ", 51), //$NON-NLS-1$ + LEDGER_PROJ_052("PROJ", 52), //$NON-NLS-1$ + LEDGER_PROJ_053("PROJ", 53), //$NON-NLS-1$ + LEDGER_PROJ_054("PROJ", 54), //$NON-NLS-1$ + LEDGER_PROJ_055("PROJ", 55), //$NON-NLS-1$ + LEDGER_PROJ_056("PROJ", 56), //$NON-NLS-1$ + LEDGER_PROJ_057("PROJ", 57), //$NON-NLS-1$ + LEDGER_PROJ_058("PROJ", 58), //$NON-NLS-1$ + LEDGER_PROJ_059("PROJ", 59), //$NON-NLS-1$ + LEDGER_PROJ_060("PROJ", 60), //$NON-NLS-1$ + LEDGER_PROJ_061("PROJ", 61), //$NON-NLS-1$ + LEDGER_PROJ_062("PROJ", 62), //$NON-NLS-1$ + LEDGER_PROJ_063("PROJ", 63), //$NON-NLS-1$ + LEDGER_PROJ_064("PROJ", 64), //$NON-NLS-1$ + LEDGER_PROJ_065("PROJ", 65), //$NON-NLS-1$ + LEDGER_PROJ_066("PROJ", 66), //$NON-NLS-1$ + LEDGER_PROJ_067("PROJ", 67), //$NON-NLS-1$ + LEDGER_PROJ_068("PROJ", 68), //$NON-NLS-1$ + LEDGER_PROJ_069("PROJ", 69), //$NON-NLS-1$ + LEDGER_PROJ_070("PROJ", 70), //$NON-NLS-1$ + LEDGER_PROJ_071("PROJ", 71), //$NON-NLS-1$ + LEDGER_PROJ_072("PROJ", 72), //$NON-NLS-1$ + LEDGER_PROJ_073("PROJ", 73), //$NON-NLS-1$ + LEDGER_PROJ_074("PROJ", 74), //$NON-NLS-1$ + LEDGER_PROJ_075("PROJ", 75), //$NON-NLS-1$ + LEDGER_PROJ_076("PROJ", 76), //$NON-NLS-1$ + LEDGER_PROJ_077("PROJ", 77), //$NON-NLS-1$ + LEDGER_PERSIST_001("PERSIST", 1), //$NON-NLS-1$ + LEDGER_PERSIST_002("PERSIST", 2), //$NON-NLS-1$ + LEDGER_PERSIST_003("PERSIST", 3), //$NON-NLS-1$ + LEDGER_PERSIST_004("PERSIST", 4), //$NON-NLS-1$ + LEDGER_PERSIST_005("PERSIST", 5), //$NON-NLS-1$ + LEDGER_PERSIST_006("PERSIST", 6), //$NON-NLS-1$ + LEDGER_PERSIST_007("PERSIST", 7), //$NON-NLS-1$ + LEDGER_PERSIST_008("PERSIST", 8), //$NON-NLS-1$ + LEDGER_PERSIST_009("PERSIST", 9), //$NON-NLS-1$ + LEDGER_PERSIST_010("PERSIST", 10), //$NON-NLS-1$ + LEDGER_CONVERT_001("CONVERT", 1), //$NON-NLS-1$ + LEDGER_CONVERT_002("CONVERT", 2), //$NON-NLS-1$ + LEDGER_CONVERT_003("CONVERT", 3), //$NON-NLS-1$ + LEDGER_CONVERT_004("CONVERT", 4), //$NON-NLS-1$ + LEDGER_CONVERT_005("CONVERT", 5), //$NON-NLS-1$ + LEDGER_CONVERT_006("CONVERT", 6), //$NON-NLS-1$ + LEDGER_CONVERT_007("CONVERT", 7), //$NON-NLS-1$ + LEDGER_CONVERT_008("CONVERT", 8), //$NON-NLS-1$ + LEDGER_CONVERT_009("CONVERT", 9), //$NON-NLS-1$ + LEDGER_CONVERT_010("CONVERT", 10), //$NON-NLS-1$ + LEDGER_CONVERT_011("CONVERT", 11), //$NON-NLS-1$ + LEDGER_CONVERT_012("CONVERT", 12), //$NON-NLS-1$ + LEDGER_CONVERT_013("CONVERT", 13), //$NON-NLS-1$ + LEDGER_CONVERT_014("CONVERT", 14), //$NON-NLS-1$ + LEDGER_CONVERT_015("CONVERT", 15), //$NON-NLS-1$ + LEDGER_CONVERT_016("CONVERT", 16), //$NON-NLS-1$ + LEDGER_CONVERT_017("CONVERT", 17), //$NON-NLS-1$ + LEDGER_CONVERT_018("CONVERT", 18), //$NON-NLS-1$ + LEDGER_CONVERT_019("CONVERT", 19), //$NON-NLS-1$ + LEDGER_CONVERT_020("CONVERT", 20), //$NON-NLS-1$ + LEDGER_CONVERT_021("CONVERT", 21), //$NON-NLS-1$ + LEDGER_CONVERT_022("CONVERT", 22), //$NON-NLS-1$ + LEDGER_CONVERT_023("CONVERT", 23), //$NON-NLS-1$ + LEDGER_CONVERT_024("CONVERT", 24), //$NON-NLS-1$ + LEDGER_CONVERT_025("CONVERT", 25), //$NON-NLS-1$ + LEDGER_CONVERT_026("CONVERT", 26), //$NON-NLS-1$ + LEDGER_CONVERT_027("CONVERT", 27), //$NON-NLS-1$ + LEDGER_CONVERT_028("CONVERT", 28), //$NON-NLS-1$ + LEDGER_CONVERT_029("CONVERT", 29), //$NON-NLS-1$ + LEDGER_CONVERT_030("CONVERT", 30), //$NON-NLS-1$ + LEDGER_CONVERT_031("CONVERT", 31), //$NON-NLS-1$ + LEDGER_CONVERT_032("CONVERT", 32), //$NON-NLS-1$ + LEDGER_CONVERT_033("CONVERT", 33), //$NON-NLS-1$ + LEDGER_CONVERT_034("CONVERT", 34), //$NON-NLS-1$ + LEDGER_CONVERT_035("CONVERT", 35), //$NON-NLS-1$ + LEDGER_CONVERT_036("CONVERT", 36), //$NON-NLS-1$ + LEDGER_CONVERT_037("CONVERT", 37), //$NON-NLS-1$ + LEDGER_CONVERT_038("CONVERT", 38), //$NON-NLS-1$ + LEDGER_CONVERT_039("CONVERT", 39), //$NON-NLS-1$ + LEDGER_CONVERT_040("CONVERT", 40), //$NON-NLS-1$ + LEDGER_CONVERT_041("CONVERT", 41), //$NON-NLS-1$ + LEDGER_CONVERT_042("CONVERT", 42), //$NON-NLS-1$ + LEDGER_CONVERT_043("CONVERT", 43), //$NON-NLS-1$ + LEDGER_CONVERT_044("CONVERT", 44), //$NON-NLS-1$ + LEDGER_CONVERT_045("CONVERT", 45), //$NON-NLS-1$ + LEDGER_CONVERT_046("CONVERT", 46), //$NON-NLS-1$ + LEDGER_CONVERT_047("CONVERT", 47), //$NON-NLS-1$ + LEDGER_CONVERT_048("CONVERT", 48), //$NON-NLS-1$ + LEDGER_CONVERT_049("CONVERT", 49), //$NON-NLS-1$ + LEDGER_CONVERT_050("CONVERT", 50), //$NON-NLS-1$ + LEDGER_CONVERT_051("CONVERT", 51), //$NON-NLS-1$ + LEDGER_CONVERT_052("CONVERT", 52), //$NON-NLS-1$ + LEDGER_CONVERT_053("CONVERT", 53), //$NON-NLS-1$ + LEDGER_CONVERT_054("CONVERT", 54), //$NON-NLS-1$ + LEDGER_CONVERT_055("CONVERT", 55), //$NON-NLS-1$ + LEDGER_CONVERT_056("CONVERT", 56), //$NON-NLS-1$ + LEDGER_CONVERT_057("CONVERT", 57), //$NON-NLS-1$ + LEDGER_CONVERT_058("CONVERT", 58), //$NON-NLS-1$ + LEDGER_CONVERT_059("CONVERT", 59), //$NON-NLS-1$ + LEDGER_CONVERT_060("CONVERT", 60), //$NON-NLS-1$ + LEDGER_CONVERT_061("CONVERT", 61), //$NON-NLS-1$ + LEDGER_CONVERT_062("CONVERT", 62), //$NON-NLS-1$ + LEDGER_CONVERT_063("CONVERT", 63), //$NON-NLS-1$ + LEDGER_CONVERT_064("CONVERT", 64), //$NON-NLS-1$ + LEDGER_CONVERT_065("CONVERT", 65), //$NON-NLS-1$ + LEDGER_CONVERT_066("CONVERT", 66), //$NON-NLS-1$ + LEDGER_CONVERT_067("CONVERT", 67), //$NON-NLS-1$ + LEDGER_CONVERT_068("CONVERT", 68), //$NON-NLS-1$ + LEDGER_CONVERT_069("CONVERT", 69), //$NON-NLS-1$ + LEDGER_CONVERT_070("CONVERT", 70), //$NON-NLS-1$ + LEDGER_CONVERT_071("CONVERT", 71), //$NON-NLS-1$ + LEDGER_FOREX_001("FOREX", 1), //$NON-NLS-1$ + LEDGER_FOREX_002("FOREX", 2), //$NON-NLS-1$ + LEDGER_FOREX_003("FOREX", 3), //$NON-NLS-1$ + LEDGER_FOREX_004("FOREX", 4), //$NON-NLS-1$ + LEDGER_FOREX_005("FOREX", 5), //$NON-NLS-1$ + LEDGER_UI_001("UI", 1), //$NON-NLS-1$ + LEDGER_UI_002("UI", 2), //$NON-NLS-1$ + LEDGER_UI_003("UI", 3), //$NON-NLS-1$ + LEDGER_UI_004("UI", 4), //$NON-NLS-1$ + LEDGER_UI_005("UI", 5), //$NON-NLS-1$ + LEDGER_UI_006("UI", 6), //$NON-NLS-1$ + LEDGER_UI_007("UI", 7), //$NON-NLS-1$ + LEDGER_UI_008("UI", 8), //$NON-NLS-1$ + LEDGER_UI_009("UI", 9), //$NON-NLS-1$ + LEDGER_UI_010("UI", 10), //$NON-NLS-1$ + LEDGER_UI_011("UI", 11), //$NON-NLS-1$ + LEDGER_UI_012("UI", 12), //$NON-NLS-1$ + LEDGER_UI_013("UI", 13), //$NON-NLS-1$ + LEDGER_UI_014("UI", 14), //$NON-NLS-1$ + LEDGER_UI_015("UI", 15), //$NON-NLS-1$ + LEDGER_UI_016("UI", 16), //$NON-NLS-1$ + LEDGER_UI_017("UI", 17), //$NON-NLS-1$ + LEDGER_UI_018("UI", 18), //$NON-NLS-1$ + LEDGER_UI_019("UI", 19), //$NON-NLS-1$ + LEDGER_UI_020("UI", 20); //$NON-NLS-1$ + + private final String group; + private final String code; + + private LedgerDiagnosticCode(String group, int number) + { + this.group = group; + this.code = "LEDGER-" + group + "-" + String.format(Locale.ROOT, "%03d", number); //$NON-NLS-1$ //$NON-NLS-2$ //$NON-NLS-3$ + } + + public String getCode() + { + return code; + } + + public String getGroup() + { + return group; + } + + public String prefix() + { + return "[" + code + "]"; //$NON-NLS-1$ //$NON-NLS-2$ + } + + public String message(String text) + { + return prefix() + " " + text; //$NON-NLS-1$ + } + + @Override + public String toString() + { + return code; + } +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/Ledger.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/Ledger.java new file mode 100644 index 0000000000..446112922d --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/Ledger.java @@ -0,0 +1,31 @@ +package name.abuchen.portfolio.model.ledger; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Objects; + +/** + * Stores the persisted Ledger entries for a client. + * This is an internal Ledger model container. Normal contributor code should use creators, + * editors, converters, or mutation contexts instead of editing the ledger graph directly. + */ +public class Ledger +{ + private final List entries = new ArrayList<>(); + + public List getEntries() + { + return Collections.unmodifiableList(entries); + } + + public void addEntry(LedgerEntry entry) + { + entries.add(Objects.requireNonNull(entry)); + } + + public boolean removeEntry(LedgerEntry entry) + { + return entries.remove(entry); + } +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerDiagnosticMessageFormatter.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerDiagnosticMessageFormatter.java new file mode 100644 index 0000000000..d24798d58e --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerDiagnosticMessageFormatter.java @@ -0,0 +1,349 @@ +package name.abuchen.portfolio.model.ledger; + +import java.util.LinkedHashSet; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; + +import name.abuchen.portfolio.Messages; +import name.abuchen.portfolio.model.Account; +import name.abuchen.portfolio.model.AccountTransaction; +import name.abuchen.portfolio.model.Client; +import name.abuchen.portfolio.model.Portfolio; +import name.abuchen.portfolio.model.PortfolioTransaction; +import name.abuchen.portfolio.model.Security; +import name.abuchen.portfolio.model.Transaction; +import name.abuchen.portfolio.money.Money; +import name.abuchen.portfolio.money.Values; + +/** + * Adds best-effort user context to Ledger diagnostics while preserving the + * original technical message and identifiers. + */ +@SuppressWarnings("nls") +public final class LedgerDiagnosticMessageFormatter +{ + private LedgerDiagnosticMessageFormatter() + { + } + + public static String formatValidationResult(Ledger ledger, LedgerStructuralValidator.ValidationResult result) + { + Objects.requireNonNull(result); + + if (result.isOK()) + return result.format(); + + try + { + var builder = new StringBuilder(); + + for (var issue : result.getIssues()) + { + if (!builder.isEmpty()) + builder.append("\n\n"); + + builder.append(issue.format()); + builder.append(formatEntryContext(findEntry(ledger, issue).orElse(null))); + } + + return builder.toString(); + } + catch (RuntimeException e) + { + return result.format(); + } + } + + public static String formatMigrationDiagnostic(Client client, String technicalMessage, + Transaction... transactions) + { + Objects.requireNonNull(technicalMessage); + + try + { + return technicalMessage + formatTransactionContext(client, transactions); + } + catch (RuntimeException e) + { + return technicalMessage; + } + } + + public static String formatEntryContext(LedgerEntry entry) + { + var context = new ContextLines(); + + if (entry != null) + { + context.add(Messages.LedgerDiagnosticMessageFormatterDate, entry.getDateTime()); + context.add(Messages.LedgerDiagnosticMessageFormatterType, entry.getType()); + context.add(Messages.LedgerDiagnosticMessageFormatterAccount, accountNames(entry)); + context.add(Messages.LedgerDiagnosticMessageFormatterPortfolio, portfolioNames(entry)); + context.add(Messages.LedgerDiagnosticMessageFormatterSecurity, securityNames(entry)); + context.add(Messages.LedgerDiagnosticMessageFormatterAmount, amounts(entry)); + context.add(Messages.LedgerDiagnosticMessageFormatterShares, shares(entry)); + context.add(Messages.LedgerDiagnosticMessageFormatterSource, entry.getSource()); + context.add(Messages.LedgerDiagnosticMessageFormatterNote, entry.getNote()); + } + + return context.format(); + } + + private static String formatTransactionContext(Client client, Transaction... transactions) + { + var context = new ContextLines(); + + if (transactions != null) + { + for (var transaction : transactions) + { + if (transaction == null) + continue; + + context.add(Messages.LedgerDiagnosticMessageFormatterDate, transaction.getDateTime()); + context.add(Messages.LedgerDiagnosticMessageFormatterType, transactionType(transaction)); + context.add(Messages.LedgerDiagnosticMessageFormatterAccount, accountName(client, transaction)); + context.add(Messages.LedgerDiagnosticMessageFormatterPortfolio, portfolioName(client, transaction)); + context.add(Messages.LedgerDiagnosticMessageFormatterSecurity, securitySummary(transaction.getSecurity())); + + if (transaction.getCurrencyCode() != null) + context.add(Messages.LedgerDiagnosticMessageFormatterAmount, + Values.Money.format(Money.of(transaction.getCurrencyCode(), + transaction.getAmount()))); + + if (transaction.getShares() != 0) + context.add(Messages.LedgerDiagnosticMessageFormatterShares, + Values.Share.format(transaction.getShares())); + + context.add(Messages.LedgerDiagnosticMessageFormatterSource, transaction.getSource()); + context.add(Messages.LedgerDiagnosticMessageFormatterNote, transaction.getNote()); + } + } + + return context.format(); + } + + private static Optional findEntry(Ledger ledger, LedgerStructuralValidator.ValidationIssue issue) + { + if (ledger == null) + return Optional.empty(); + + var details = issue.getDetails(); + var entryUUID = details.get("entryUUID"); + + if (entryUUID != null && !entryUUID.isBlank() && !"".equals(entryUUID)) + { + var entry = ledger.getEntries().stream().filter(candidate -> entryUUID.equals(candidate.getUUID())) + .findFirst(); + + if (entry.isPresent()) + return entry; + } + + return ledger.getEntries().stream().filter(entry -> matchesEntry(entry, details.get("postingUUID"))) + .findFirst(); + } + + private static boolean matchesEntry(LedgerEntry entry, String... uuids) + { + for (var uuid : uuids) + { + if (uuid == null || uuid.isBlank() || "".equals(uuid)) + continue; + + if (entry.getPostings().stream().anyMatch(posting -> uuid.equals(posting.getUUID()))) + return true; + } + + return false; + } + + private static Set accountNames(LedgerEntry entry) + { + var values = new LinkedHashSet(); + + for (var posting : entry.getPostings()) + add(values, accountSummary(posting.getAccount())); + + return values; + } + + private static Set portfolioNames(LedgerEntry entry) + { + var values = new LinkedHashSet(); + + for (var posting : entry.getPostings()) + add(values, portfolioSummary(posting.getPortfolio())); + + return values; + } + + private static Set securityNames(LedgerEntry entry) + { + var values = new LinkedHashSet(); + + for (var posting : entry.getPostings()) + add(values, securitySummary(posting.getSecurity())); + + return values; + } + + private static Set amounts(LedgerEntry entry) + { + var values = new LinkedHashSet(); + + for (var posting : entry.getPostings()) + { + if (posting.getCurrency() != null) + add(values, Values.Money.format(Money.of(posting.getCurrency(), posting.getAmount()))); + } + + return values; + } + + private static Set shares(LedgerEntry entry) + { + var values = new LinkedHashSet(); + + for (var posting : entry.getPostings()) + { + if (posting.getShares() != 0) + add(values, Values.Share.format(posting.getShares())); + } + + return values; + } + + private static String transactionType(Transaction transaction) + { + if (transaction instanceof AccountTransaction accountTransaction) + return String.valueOf(accountTransaction.getType()); + if (transaction instanceof PortfolioTransaction portfolioTransaction) + return String.valueOf(portfolioTransaction.getType()); + + return transaction.getClass().getSimpleName(); + } + + private static String accountName(Client client, Transaction transaction) + { + if (!(transaction instanceof AccountTransaction accountTransaction) || client == null) + return null; + + return client.getAccounts().stream() + .filter(account -> account.getTransactions().stream() + .anyMatch(candidate -> candidate == accountTransaction + || candidate.getUUID().equals(accountTransaction.getUUID()))) + .findFirst().map(LedgerDiagnosticMessageFormatter::accountSummary).orElse(null); + } + + private static String portfolioName(Client client, Transaction transaction) + { + if (!(transaction instanceof PortfolioTransaction portfolioTransaction) || client == null) + return null; + + return client.getPortfolios().stream() + .filter(portfolio -> portfolio.getTransactions().stream() + .anyMatch(candidate -> candidate == portfolioTransaction + || candidate.getUUID().equals(portfolioTransaction.getUUID()))) + .findFirst().map(LedgerDiagnosticMessageFormatter::portfolioSummary).orElse(null); + } + + private static String accountSummary(Account account) + { + return account == null ? null : nameOrFallback(account.getName(), + Messages.LedgerDiagnosticMessageFormatterUnnamedAccount); + } + + private static String portfolioSummary(Portfolio portfolio) + { + return portfolio == null ? null : nameOrFallback(portfolio.getName(), + Messages.LedgerDiagnosticMessageFormatterUnnamedPortfolio); + } + + private static String securitySummary(Security security) + { + if (security == null) + return null; + + var builder = new StringBuilder(nameOrFallback(security.getName(), + Messages.LedgerDiagnosticMessageFormatterUnnamedSecurity)); + var identifiers = new LinkedHashSet(); + + add(identifiers, labeled(Messages.LedgerDiagnosticMessageFormatterIsin, security.getIsin())); + add(identifiers, labeled(Messages.LedgerDiagnosticMessageFormatterWkn, security.getWkn())); + add(identifiers, labeled(Messages.LedgerDiagnosticMessageFormatterTicker, security.getTickerSymbol())); + + if (!identifiers.isEmpty()) + builder.append(" (").append(String.join(", ", identifiers)).append(")"); + + return builder.toString(); + } + + private static String labeled(String label, String value) + { + return isBlank(value) ? null : label + "=" + value; + } + + private static String nameOrFallback(String name, String fallback) + { + return isBlank(name) ? "<" + fallback + ">" : name; + } + + private static void add(Set values, Object value) + { + if (value == null) + return; + + var string = String.valueOf(value); + + if (!isBlank(string)) + values.add(string); + } + + private static boolean isBlank(String value) + { + return value == null || value.isBlank(); + } + + private static final class ContextLines + { + private final StringBuilder builder = new StringBuilder(); + + private void add(String label, Object value) + { + if (value instanceof Set values) + { + if (!values.isEmpty()) + append(label, String.join(", ", values.stream().map(String::valueOf).toList())); + return; + } + + if (value == null) + return; + + var string = String.valueOf(value); + + if (!string.isBlank()) + append(label, string); + } + + private void append(String label, String value) + { + if (builder.isEmpty()) + builder.append("\n\n").append(Messages.LedgerDiagnosticMessageFormatterTransactionContext) + .append(":"); + + builder.append("\n ").append(label).append(": ").append(value); + } + + private String format() + { + if (builder.isEmpty()) + return "\n\n" + Messages.LedgerDiagnosticMessageFormatterTransactionContext + ":\n " //$NON-NLS-1$ //$NON-NLS-2$ + + Messages.LedgerDiagnosticMessageFormatterContextUnavailable; + + return builder.toString(); + } + } +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerEntry.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerEntry.java new file mode 100644 index 0000000000..101ca3b0fa --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerEntry.java @@ -0,0 +1,208 @@ +package name.abuchen.portfolio.model.ledger; + +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Objects; +import java.util.UUID; + +import name.abuchen.portfolio.model.ledger.configuration.LedgerEntryType; + +/** + * Represents one persisted Ledger transaction entry. + * This is an internal Ledger model type. Normal contributor code should not mutate entries + * directly; it should use a creator, editor, converter, deleter, or mutation context. + */ +public class LedgerEntry +{ + private String uuid; + private LedgerEntryType type; + private LocalDateTime dateTime; + private String note; + private String source; + private Instant updatedAt; + private String generatedByPlanKey; + private LocalDate planExecutionDate; + private Integer planExecutionSequence; + private String preferredViewKind; + private List> parameters = new ArrayList<>(); + private final List postings = new ArrayList<>(); + + public LedgerEntry() + { + this(UUID.randomUUID().toString()); + } + + public LedgerEntry(String uuid) + { + this.uuid = Objects.requireNonNull(uuid); + this.updatedAt = Instant.now(); + } + + public String getUUID() + { + return uuid; + } + + public void setUUID(String uuid) + { + this.uuid = Objects.requireNonNull(uuid); + touch(); + } + + public LedgerEntryType getType() + { + return type; + } + + public void setType(LedgerEntryType type) + { + this.type = type; + touch(); + } + + public LocalDateTime getDateTime() + { + return dateTime; + } + + public void setDateTime(LocalDateTime dateTime) + { + this.dateTime = dateTime; + touch(); + } + + public String getNote() + { + return note; + } + + public void setNote(String note) + { + this.note = note; + touch(); + } + + public String getSource() + { + return source; + } + + public void setSource(String source) + { + this.source = source; + touch(); + } + + public Instant getUpdatedAt() + { + return updatedAt; + } + + public void setUpdatedAt(Instant updatedAt) + { + this.updatedAt = updatedAt; + } + + public String getGeneratedByPlanKey() + { + return generatedByPlanKey; + } + + public void setGeneratedByPlanKey(String generatedByPlanKey) + { + this.generatedByPlanKey = generatedByPlanKey; + touch(); + } + + public LocalDate getPlanExecutionDate() + { + return planExecutionDate; + } + + public void setPlanExecutionDate(LocalDate planExecutionDate) + { + this.planExecutionDate = planExecutionDate; + touch(); + } + + public Integer getPlanExecutionSequence() + { + return planExecutionSequence; + } + + public void setPlanExecutionSequence(Integer planExecutionSequence) + { + this.planExecutionSequence = planExecutionSequence; + touch(); + } + + public String getPreferredViewKind() + { + return preferredViewKind; + } + + public void setPreferredViewKind(String preferredViewKind) + { + this.preferredViewKind = preferredViewKind; + touch(); + } + + public List> getParameters() + { + return Collections.unmodifiableList(parameters()); + } + + public void addParameter(LedgerParameter parameter) + { + parameters().add(Objects.requireNonNull(parameter)); + touch(); + } + + public boolean removeParameter(LedgerParameter parameter) + { + var removed = parameters().remove(parameter); + + if (removed) + touch(); + + return removed; + } + + private List> parameters() + { + if (parameters == null) + parameters = new ArrayList<>(); + + return parameters; + } + + public List getPostings() + { + return Collections.unmodifiableList(postings); + } + + public void addPosting(LedgerPosting posting) + { + postings.add(Objects.requireNonNull(posting)); + touch(); + } + + public boolean removePosting(LedgerPosting posting) + { + var removed = postings.remove(posting); + + if (removed) + touch(); + + return removed; + } + + private void touch() + { + this.updatedAt = Instant.now(); + } +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerEntryEditSupport.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerEntryEditSupport.java new file mode 100644 index 0000000000..fc09f1c7d9 --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerEntryEditSupport.java @@ -0,0 +1,86 @@ +package name.abuchen.portfolio.model.ledger; + +import java.time.Instant; + +import name.abuchen.portfolio.model.LedgerDiagnosticCode; +import name.abuchen.portfolio.model.ledger.configuration.LedgerPostingType; + +/** + * Provides shared support for safe edits on persisted Ledger entries. + * This is internal Ledger mutation support. Contributors should normally use creators, + * editors, converters, or deleters instead of calling it directly. + */ +public final class LedgerEntryEditSupport +{ + private LedgerEntryEditSupport() + { + } + + public static void applyValidated(LedgerEntry entry, EntryPatch patch) + { + validatePatch(entry, patch); + patch.apply(entry); + entry.setUpdatedAt(Instant.now()); + } + + public static void validatePatch(LedgerEntry entry, EntryPatch patch) + { + var candidate = LedgerModelCopy.copyEntry(entry); + + patch.apply(candidate); + validate(candidate); + } + + public static int postingIndex(LedgerEntry entry, LedgerPosting posting) + { + var index = entry.getPostings().indexOf(posting); + + if (index < 0) + throw new IllegalArgumentException("Ledger posting does not belong to entry"); //$NON-NLS-1$ + + return index; + } + + public static LedgerPosting postingAt(LedgerEntry entry, int index) + { + if (index < 0 || index >= entry.getPostings().size()) + throw new IllegalArgumentException("Ledger posting index out of range: " + index); //$NON-NLS-1$ + + return entry.getPostings().get(index); + } + + public static LedgerPosting postingByUUID(LedgerEntry entry, String uuid) + { + return entry.getPostings().stream() // + .filter(posting -> posting.getUUID().equals(uuid)) // + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("Ledger posting not found: " + uuid)); //$NON-NLS-1$ + } + + static LedgerPosting firstPosting(LedgerEntry entry, LedgerPostingType type) + { + return entry.getPostings().stream() // + .filter(posting -> posting.getType() == type) // + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("Ledger posting not found: " + type)); //$NON-NLS-1$ + } + + private static void validate(LedgerEntry entry) + { + var ledger = new Ledger(); + + LedgerGraphWriter.addEntry(ledger, entry); + + var result = LedgerStructuralValidator.validate(ledger); + + if (!result.isOK()) + throw new IllegalArgumentException( + LedgerDiagnosticCode.LEDGER_CORE_004.message("Invalid ledger edit: " + result.format())); //$NON-NLS-1$ + } + + @FunctionalInterface + public interface EntryPatch + { + void apply(LedgerEntry entry); + } +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerEntryMetadataPatch.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerEntryMetadataPatch.java new file mode 100644 index 0000000000..708e6c87a9 --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerEntryMetadataPatch.java @@ -0,0 +1,93 @@ +package name.abuchen.portfolio.model.ledger; + +import java.time.LocalDateTime; + +/** + * Describes a metadata change for a persisted Ledger entry. + * This is internal edit data used by Ledger mutation support. Contributor code should + * normally use a compatibility write path that builds these patches. + */ +public final class LedgerEntryMetadataPatch +{ + private final LedgerFieldEdit dateTime; + private final LedgerFieldEdit note; + private final LedgerFieldEdit source; + + private LedgerEntryMetadataPatch(Builder builder) + { + this.dateTime = builder.dateTime; + this.note = builder.note; + this.source = builder.source; + } + + public static LedgerEntryMetadataPatch none() + { + return builder().build(); + } + + public static Builder builder() + { + return new Builder(); + } + + public LedgerFieldEdit getDateTime() + { + return dateTime; + } + + public LedgerFieldEdit getNote() + { + return note; + } + + public LedgerFieldEdit getSource() + { + return source; + } + + public static final class Builder + { + private LedgerFieldEdit dateTime = LedgerFieldEdit.omitted(); + private LedgerFieldEdit note = LedgerFieldEdit.omitted(); + private LedgerFieldEdit source = LedgerFieldEdit.omitted(); + + private Builder() + { + } + + public Builder dateTime(LocalDateTime dateTime) + { + this.dateTime = LedgerFieldEdit.set(dateTime); + return this; + } + + public Builder note(String note) + { + this.note = note != null ? LedgerFieldEdit.set(note) : LedgerFieldEdit.clear(); + return this; + } + + public Builder clearNote() + { + this.note = LedgerFieldEdit.clear(); + return this; + } + + public Builder source(String source) + { + this.source = source != null ? LedgerFieldEdit.set(source) : LedgerFieldEdit.clear(); + return this; + } + + public Builder clearSource() + { + this.source = LedgerFieldEdit.clear(); + return this; + } + + public LedgerEntryMetadataPatch build() + { + return new LedgerEntryMetadataPatch(this); + } + } +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerEntryMetadataPatchHelper.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerEntryMetadataPatchHelper.java new file mode 100644 index 0000000000..bd800f6f0c --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerEntryMetadataPatchHelper.java @@ -0,0 +1,78 @@ +package name.abuchen.portfolio.model.ledger; + +import java.time.LocalDateTime; +import java.util.Objects; + +import name.abuchen.portfolio.model.LedgerDiagnosticCode; + +/** + * Applies safe metadata edits to persisted Ledger entries. + * This is internal Ledger mutation support. Contributor code should normally use + * higher-level Ledger editors or compatibility write paths. + */ +public final class LedgerEntryMetadataPatchHelper +{ + private LedgerEntryMetadataPatchHelper() + { + } + + public static void apply(LedgerEntry entry, LedgerEntryMetadataPatch patch) + { + Objects.requireNonNull(entry); + Objects.requireNonNull(patch); + + LedgerEntryEditSupport.applyValidated(entry, editedEntry -> applyDirect(editedEntry, patch)); + } + + public static void setDateTime(LedgerEntry entry, LocalDateTime dateTime) + { + Objects.requireNonNull(entry); + + LedgerEntryEditSupport.applyValidated(entry, editedEntry -> editedEntry.setDateTime(dateTime)); + } + + public static void setNote(LedgerEntry entry, String note) + { + apply(entry, LedgerEntryMetadataPatch.builder().note(note).build()); + } + + public static void setSource(LedgerEntry entry, String source) + { + apply(entry, LedgerEntryMetadataPatch.builder().source(source).build()); + } + + private static void applyDirect(LedgerEntry entry, LedgerEntryMetadataPatch patch) + { + applyDateTime(entry, patch.getDateTime()); + applyNote(entry, patch.getNote()); + applySource(entry, patch.getSource()); + } + + private static void applyDateTime(LedgerEntry entry, LedgerFieldEdit edit) + { + if (edit.isOmitted()) + return; + + if (edit.isClear()) + throw new IllegalArgumentException( + LedgerDiagnosticCode.LEDGER_CORE_005.message("Ledger entry date/time cannot be cleared")); //$NON-NLS-1$ + + entry.setDateTime((java.time.LocalDateTime) edit.getValue()); + } + + private static void applyNote(LedgerEntry entry, LedgerFieldEdit edit) + { + if (edit.isOmitted()) + return; + + entry.setNote(edit.isClear() ? null : edit.getValue()); + } + + private static void applySource(LedgerEntry entry, LedgerFieldEdit edit) + { + if (edit.isOmitted()) + return; + + entry.setSource(edit.isClear() ? null : edit.getValue()); + } +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerFieldEdit.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerFieldEdit.java new file mode 100644 index 0000000000..9692d6dc4a --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerFieldEdit.java @@ -0,0 +1,78 @@ +package name.abuchen.portfolio.model.ledger; + +import java.util.Objects; + +import name.abuchen.portfolio.model.LedgerDiagnosticCode; + +/** + * Describes one field-level metadata edit for a persisted Ledger entry. + * This is internal edit data used by Ledger mutation support. It does not mutate Ledger + * truth by itself. + */ +public final class LedgerFieldEdit +{ + public enum State + { + OMITTED, + SET, + CLEAR + } + + private static final LedgerFieldEdit OMITTED = new LedgerFieldEdit<>(State.OMITTED, null); + private static final LedgerFieldEdit CLEAR = new LedgerFieldEdit<>(State.CLEAR, null); + + private final State state; + private final T value; + + private LedgerFieldEdit(State state, T value) + { + this.state = state; + this.value = value; + } + + @SuppressWarnings("unchecked") + public static LedgerFieldEdit omitted() + { + return (LedgerFieldEdit) OMITTED; + } + + public static LedgerFieldEdit set(T value) + { + return new LedgerFieldEdit<>(State.SET, Objects.requireNonNull(value)); + } + + @SuppressWarnings("unchecked") + public static LedgerFieldEdit clear() + { + return (LedgerFieldEdit) CLEAR; + } + + public State getState() + { + return state; + } + + public T getValue() + { + if (state != State.SET) + throw new IllegalStateException( + LedgerDiagnosticCode.LEDGER_CORE_006.message("Only set edits have a value")); //$NON-NLS-1$ + + return value; + } + + public boolean isOmitted() + { + return state == State.OMITTED; + } + + public boolean isSet() + { + return state == State.SET; + } + + public boolean isClear() + { + return state == State.CLEAR; + } +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerGraphWriter.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerGraphWriter.java new file mode 100644 index 0000000000..b8ba3a70d4 --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerGraphWriter.java @@ -0,0 +1,55 @@ +package name.abuchen.portfolio.model.ledger; + +import java.util.List; +import java.util.Objects; + +/** + * Writes a replacement Ledger graph back to the client after validation. + * This is internal mutation infrastructure. Contributor code should reach it through a + * mutation context or a higher-level Ledger write path. + */ +final class LedgerGraphWriter +{ + private LedgerGraphWriter() + { + } + + static void addEntry(Ledger ledger, LedgerEntry entry) + { + Objects.requireNonNull(ledger).addEntry(Objects.requireNonNull(entry)); + } + + static void removeEntry(Ledger ledger, LedgerEntry entry) + { + Objects.requireNonNull(ledger).removeEntry(Objects.requireNonNull(entry)); + } + + static void replaceEntry(Ledger ledger, LedgerEntry current, LedgerEntry replacement) + { + removeEntry(ledger, current); + addEntry(ledger, replacement); + } + + static void replaceEntryContents(LedgerEntry target, LedgerEntry source) + { + Objects.requireNonNull(target); + + var copy = LedgerModelCopy.copyEntry(source); + + target.setUUID(copy.getUUID()); + target.setType(copy.getType()); + target.setDateTime(copy.getDateTime()); + target.setNote(copy.getNote()); + target.setSource(copy.getSource()); + + for (var parameter : List.copyOf(target.getParameters())) + target.removeParameter(parameter); + + for (var posting : List.copyOf(target.getPostings())) + target.removePosting(posting); + + copy.getParameters().forEach(target::addParameter); + copy.getPostings().forEach(target::addPosting); + target.setUpdatedAt(copy.getUpdatedAt()); + } +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerModelCopy.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerModelCopy.java new file mode 100644 index 0000000000..506cbfcb82 --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerModelCopy.java @@ -0,0 +1,71 @@ +package name.abuchen.portfolio.model.ledger; + +import java.util.Objects; + +/** + * Copies Ledger model objects for safe mutation and rollback paths. + * This is internal Ledger infrastructure. Normal contributor code should rely on mutation + * contexts rather than copying Ledger graph objects directly. + */ +public final class LedgerModelCopy +{ + private LedgerModelCopy() + { + } + + public static Ledger copyLedger(Ledger source) + { + var copy = new Ledger(); + + Objects.requireNonNull(source).getEntries().stream().map(LedgerModelCopy::copyEntry).forEach(copy::addEntry); + + return copy; + } + + static LedgerEntry copyEntry(LedgerEntry source) + { + var copy = new LedgerEntry(Objects.requireNonNull(source).getUUID()); + + copy.setType(source.getType()); + copy.setDateTime(source.getDateTime()); + copy.setNote(source.getNote()); + copy.setSource(source.getSource()); + + source.getParameters().stream().map(LedgerModelCopy::copyParameter).forEach(copy::addParameter); + source.getPostings().stream().map(LedgerModelCopy::copyPosting).forEach(copy::addPosting); + copy.setUpdatedAt(source.getUpdatedAt()); + + return copy; + } + + static LedgerPosting copyPosting(LedgerPosting source) + { + var copy = new LedgerPosting(Objects.requireNonNull(source).getUUID()); + + copy.setType(source.getType()); + copy.setAmount(source.getAmount()); + copy.setCurrency(source.getCurrency()); + copy.setForexAmount(source.getForexAmount()); + copy.setForexCurrency(source.getForexCurrency()); + copy.setExchangeRate(source.getExchangeRate()); + copy.setSecurity(source.getSecurity()); + copy.setShares(source.getShares()); + copy.setAccount(source.getAccount()); + copy.setPortfolio(source.getPortfolio()); + copy.setSemanticRole(source.getSemanticRole()); + copy.setDirection(source.getDirection()); + copy.setCorporateActionLeg(source.getCorporateActionLeg()); + copy.setUnitRole(source.getUnitRole()); + copy.setGroupKey(source.getGroupKey()); + copy.setLocalKey(source.getLocalKey()); + source.getParameters().stream().map(LedgerModelCopy::copyParameter).forEach(copy::addParameter); + + return copy; + } + + static LedgerParameter copyParameter(LedgerParameter source) + { + Objects.requireNonNull(source); + return LedgerParameter.unchecked(source.getType(), source.getValueKind(), source.getValue()); + } +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerParameter.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerParameter.java new file mode 100644 index 0000000000..23ab372929 --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerParameter.java @@ -0,0 +1,161 @@ +package name.abuchen.portfolio.model.ledger; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.Objects; + +import name.abuchen.portfolio.model.Account; +import name.abuchen.portfolio.model.LedgerDiagnosticCode; +import name.abuchen.portfolio.model.Portfolio; +import name.abuchen.portfolio.model.Security; +import name.abuchen.portfolio.model.ledger.configuration.LedgerCode; +import name.abuchen.portfolio.model.ledger.configuration.LedgerParameterType; +import name.abuchen.portfolio.money.Money; + +/** + * Represents an extra typed fact stored on a Ledger entry or posting. + * This is internal Ledger model data. Contributor code should use the appropriate creator, + * editor, converter, or native assembler instead of editing parameters directly. + */ +public class LedgerParameter +{ + public enum ValueKind + { + STRING(String.class), + DECIMAL(BigDecimal.class), + LONG(Long.class), + MONEY(Money.class), + SECURITY(Security.class), + ACCOUNT(Account.class), + PORTFOLIO(Portfolio.class), + BOOLEAN(Boolean.class), + LOCAL_DATE(LocalDate.class), + LOCAL_DATE_TIME(LocalDateTime.class); + + private final Class valueType; + + private ValueKind(Class valueType) + { + this.valueType = Objects.requireNonNull(valueType); + } + + public Class getValueType() + { + return valueType; + } + + public boolean supportsValue(Object value) + { + return value != null && valueType.isInstance(value); + } + } + + private final LedgerParameterType type; + private final ValueKind valueKind; + private final T value; + + private LedgerParameter(LedgerParameterType type, ValueKind valueKind, T value) + { + this.type = Objects.requireNonNull(type); + this.valueKind = Objects.requireNonNull(valueKind); + this.value = Objects.requireNonNull(value); + } + + public static LedgerParameter ofString(LedgerParameterType type, String value) + { + return of(type, ValueKind.STRING, value); + } + + public static LedgerParameter ofCode(LedgerParameterType type, LedgerCode code) + { + Objects.requireNonNull(type).requireValueKind(ValueKind.STRING); + Objects.requireNonNull(code); + + if (!type.hasCodeDomain()) + throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_CORE_012 + .message(type + " does not define a controlled code domain")); //$NON-NLS-1$ + + if (type.getCodeDomain() != code.getDomain()) + throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_CORE_013.message( + type + " expects code domain " + type.getCodeDomain() + " but got " + code.getDomain())); //$NON-NLS-1$ //$NON-NLS-2$ + + if (!type.supportsCode(code.getCode())) + throw new IllegalArgumentException( + LedgerDiagnosticCode.LEDGER_CORE_014.message(type + " does not support code " + code.getCode())); //$NON-NLS-1$ + + return ofString(type, code.getCode()); + } + + public static LedgerParameter ofDecimal(LedgerParameterType type, BigDecimal value) + { + return of(type, ValueKind.DECIMAL, value); + } + + public static LedgerParameter ofLong(LedgerParameterType type, long value) + { + return of(type, ValueKind.LONG, Long.valueOf(value)); + } + + public static LedgerParameter ofMoney(LedgerParameterType type, Money value) + { + return of(type, ValueKind.MONEY, value); + } + + public static LedgerParameter ofSecurity(LedgerParameterType type, Security value) + { + return of(type, ValueKind.SECURITY, value); + } + + public static LedgerParameter ofAccount(LedgerParameterType type, Account value) + { + return of(type, ValueKind.ACCOUNT, value); + } + + public static LedgerParameter ofPortfolio(LedgerParameterType type, Portfolio value) + { + return of(type, ValueKind.PORTFOLIO, value); + } + + public static LedgerParameter ofBoolean(LedgerParameterType type, Boolean value) + { + return of(type, ValueKind.BOOLEAN, value); + } + + public static LedgerParameter ofLocalDate(LedgerParameterType type, LocalDate value) + { + return of(type, ValueKind.LOCAL_DATE, value); + } + + public static LedgerParameter ofLocalDateTime(LedgerParameterType type, + LocalDateTime value) + { + return of(type, ValueKind.LOCAL_DATE_TIME, value); + } + + private static LedgerParameter of(LedgerParameterType type, ValueKind valueKind, T value) + { + Objects.requireNonNull(type).requireValueKind(valueKind); + return unchecked(type, valueKind, value); + } + + static LedgerParameter unchecked(LedgerParameterType type, ValueKind valueKind, T value) + { + return new LedgerParameter<>(type, valueKind, value); + } + + public LedgerParameterType getType() + { + return type; + } + + public ValueKind getValueKind() + { + return valueKind; + } + + public T getValue() + { + return value; + } +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerPosting.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerPosting.java new file mode 100644 index 0000000000..29d020c935 --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerPosting.java @@ -0,0 +1,236 @@ +package name.abuchen.portfolio.model.ledger; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Objects; +import java.util.UUID; + +import name.abuchen.portfolio.model.Account; +import name.abuchen.portfolio.model.Portfolio; +import name.abuchen.portfolio.model.Security; +import name.abuchen.portfolio.model.ledger.configuration.CorporateActionLeg; +import name.abuchen.portfolio.model.ledger.configuration.LedgerPostingType; + +/** + * Represents one persisted posting inside a Ledger entry. + * This is an internal Ledger model type for cash, security, fee, tax, and related facts. + * Contributor code should write postings through Ledger creators, editors, or converters. + */ +public class LedgerPosting +{ + private String uuid; + private LedgerPostingType type; + private long amount; + private String currency; + private Long forexAmount; + private String forexCurrency; + private BigDecimal exchangeRate; + private Security security; + private long shares; + private Account account; + private Portfolio portfolio; + private LedgerPostingSemanticRole semanticRole; + private LedgerPostingDirection direction; + private CorporateActionLeg corporateActionLeg; + private LedgerPostingUnitRole unitRole; + private String groupKey; + private String localKey; + private final List> parameters = new ArrayList<>(); + + public LedgerPosting() + { + this(UUID.randomUUID().toString()); + } + + public LedgerPosting(String uuid) + { + this.uuid = Objects.requireNonNull(uuid); + } + + public String getUUID() + { + return uuid; + } + + public void setUUID(String uuid) + { + this.uuid = Objects.requireNonNull(uuid); + } + + public LedgerPostingType getType() + { + return type; + } + + public void setType(LedgerPostingType type) + { + this.type = type; + } + + public long getAmount() + { + return amount; + } + + public void setAmount(long amount) + { + this.amount = amount; + } + + public String getCurrency() + { + return currency; + } + + public void setCurrency(String currency) + { + this.currency = currency; + } + + public Long getForexAmount() + { + return forexAmount; + } + + public void setForexAmount(Long forexAmount) + { + this.forexAmount = forexAmount; + } + + public String getForexCurrency() + { + return forexCurrency; + } + + public void setForexCurrency(String forexCurrency) + { + this.forexCurrency = forexCurrency; + } + + public BigDecimal getExchangeRate() + { + return exchangeRate; + } + + public void setExchangeRate(BigDecimal exchangeRate) + { + this.exchangeRate = exchangeRate; + } + + public Security getSecurity() + { + return security; + } + + public void setSecurity(Security security) + { + this.security = security; + } + + public long getShares() + { + return shares; + } + + public void setShares(long shares) + { + this.shares = shares; + } + + public Account getAccount() + { + return account; + } + + public void setAccount(Account account) + { + this.account = account; + } + + public Portfolio getPortfolio() + { + return portfolio; + } + + public void setPortfolio(Portfolio portfolio) + { + this.portfolio = portfolio; + } + + public LedgerPostingSemanticRole getSemanticRole() + { + return semanticRole; + } + + public void setSemanticRole(LedgerPostingSemanticRole semanticRole) + { + this.semanticRole = semanticRole; + } + + public LedgerPostingDirection getDirection() + { + return direction; + } + + public void setDirection(LedgerPostingDirection direction) + { + this.direction = direction; + } + + public CorporateActionLeg getCorporateActionLeg() + { + return corporateActionLeg; + } + + public void setCorporateActionLeg(CorporateActionLeg corporateActionLeg) + { + this.corporateActionLeg = corporateActionLeg; + } + + public LedgerPostingUnitRole getUnitRole() + { + return unitRole; + } + + public void setUnitRole(LedgerPostingUnitRole unitRole) + { + this.unitRole = unitRole; + } + + public String getGroupKey() + { + return groupKey; + } + + public void setGroupKey(String groupKey) + { + this.groupKey = groupKey; + } + + public String getLocalKey() + { + return localKey; + } + + public void setLocalKey(String localKey) + { + this.localKey = localKey; + } + + public List> getParameters() + { + return Collections.unmodifiableList(parameters); + } + + public void addParameter(LedgerParameter parameter) + { + parameters.add(Objects.requireNonNull(parameter)); + } + + public boolean removeParameter(LedgerParameter parameter) + { + return parameters.remove(parameter); + } +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerPostingDirection.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerPostingDirection.java new file mode 100644 index 0000000000..d7959b7f83 --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerPostingDirection.java @@ -0,0 +1,13 @@ +package name.abuchen.portfolio.model.ledger; + +/** + * Describes the semantic movement direction of a Ledger posting. + * It is additive metadata for future projection derivation and does not replace + * derived descriptors in the current materialization path. + */ +public enum LedgerPostingDirection +{ + INBOUND, + OUTBOUND, + NEUTRAL +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerPostingSemanticRole.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerPostingSemanticRole.java new file mode 100644 index 0000000000..bb78dc75c0 --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerPostingSemanticRole.java @@ -0,0 +1,20 @@ +package name.abuchen.portfolio.model.ledger; + +/** + * Names the business role a posting plays inside an entry. + * This vocabulary is intentionally separate from persisted projection identity. + */ +public enum LedgerPostingSemanticRole +{ + CASH, + SECURITY, + RIGHT, + BOND, + CASH_COMPENSATION, + ACCRUED_INTEREST, + PRINCIPAL_REDEMPTION, + FEE, + TAX, + GROSS_VALUE, + FOREX_CONTEXT +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerPostingUnitRole.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerPostingUnitRole.java new file mode 100644 index 0000000000..0a2d38ccef --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerPostingUnitRole.java @@ -0,0 +1,14 @@ +package name.abuchen.portfolio.model.ledger; + +/** + * Describes how a posting contributes to a generated compatibility transaction + * unit once projections are derived from posting semantics. + */ +public enum LedgerPostingUnitRole +{ + PRIMARY, + FEE, + TAX, + GROSS_VALUE, + FOREX_CONTEXT +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerProjectionRole.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerProjectionRole.java new file mode 100644 index 0000000000..0e7438f708 --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerProjectionRole.java @@ -0,0 +1,28 @@ +package name.abuchen.portfolio.model.ledger; + +/** + * Defines the role of a runtime projection for a Ledger entry. + * This is internal Ledger model metadata used by projection and compatibility code. + * Contributor code should not invent roles outside the configured Ledger write paths. + * + *

+ * Protobuf stores projection roles through the fixed {@code PLedgerProjectionRole} enum and + * explicit reader/writer mappings. New roles need matching persistence mapping and must not + * change the meaning of existing stored role values. + *

+ */ +public enum LedgerProjectionRole +{ + ACCOUNT, + PORTFOLIO, + SOURCE_ACCOUNT, + TARGET_ACCOUNT, + SOURCE_PORTFOLIO, + TARGET_PORTFOLIO, + DELIVERY, + DELIVERY_INBOUND, + DELIVERY_OUTBOUND, + CASH_COMPENSATION, + OLD_SECURITY_LEG, + NEW_SECURITY_LEG +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerStructuralValidator.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerStructuralValidator.java new file mode 100644 index 0000000000..0d0310069f --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerStructuralValidator.java @@ -0,0 +1,847 @@ +package name.abuchen.portfolio.model.ledger; + +import java.text.MessageFormat; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import name.abuchen.portfolio.Messages; +import name.abuchen.portfolio.model.Account; +import name.abuchen.portfolio.model.LedgerDiagnosticCode; +import name.abuchen.portfolio.model.Portfolio; +import name.abuchen.portfolio.model.Security; +import name.abuchen.portfolio.model.ledger.configuration.CorporateActionLeg; +import name.abuchen.portfolio.model.ledger.configuration.CorporateActionKind; +import name.abuchen.portfolio.model.ledger.configuration.LedgerEntryType; +import name.abuchen.portfolio.model.ledger.configuration.LedgerParameterType; + +/** + * Validates the structural consistency of Ledger entries. + * This class checks Ledger facts and semantic projection descriptors. It does not apply business + * repairs or guess missing transaction data. + */ +public final class LedgerStructuralValidator +{ + public enum IssueCode + { + LEDGER_REQUIRED, + ENTRY_TYPE_REQUIRED, + ENTRY_DATE_TIME_REQUIRED, + POSTING_TYPE_REQUIRED, + POSTING_CURRENCY_REQUIRED, + POSTING_SECURITY_REQUIRED, + POSTING_EXCHANGE_RATE_POSITIVE, + DIVIDEND_SECURITY_REQUIRED, + PARAMETER_TYPE_REQUIRED, + PARAMETER_VALUE_KIND_REQUIRED, + PARAMETER_VALUE_REQUIRED, + PARAMETER_VALUE_KIND_MISMATCH, + PARAMETER_CODE_NOT_ALLOWED, + EX_DATE_VALUE_KIND_REQUIRED, + EX_DATE_SECURITY_REQUIRED, + SIGNED_FACT_NOT_ALLOWED, + SEMANTIC_PRIMARY_REQUIRED, + SEMANTIC_PRIMARY_AMBIGUOUS, + SEMANTIC_SOURCE_REQUIRED, + SEMANTIC_TARGET_REQUIRED, + SEMANTIC_SOURCE_AMBIGUOUS, + SEMANTIC_TARGET_AMBIGUOUS, + SEMANTIC_OWNER_REQUIRED, + SEMANTIC_UNIT_GROUP_REQUIRED, + SEMANTIC_UNIT_GROUP_AMBIGUOUS, + SEMANTIC_LOCAL_KEY_REQUIRED, + CORPORATE_ACTION_LEG_REQUIRED, + CORPORATE_ACTION_LEG_AMBIGUOUS + } + + private LedgerStructuralValidator() + { + } + + public static ValidationResult validate(Ledger ledger) + { + var issues = new ArrayList(); + + if (ledger == null) + { + issues.add(new ValidationIssue(IssueCode.LEDGER_REQUIRED, + LedgerDiagnosticCode.LEDGER_STRUCT_001 + .message(Messages.LedgerStructuralValidatorLedgerRequired))); + return new ValidationResult(issues); + } + + validateEntries(ledger, issues); + + return new ValidationResult(issues); + } + + private static void validateEntries(Ledger ledger, List issues) + { + for (var entry : ledger.getEntries()) + { + if (entry.getType() == null) + issues.add(entryIssue(IssueCode.ENTRY_TYPE_REQUIRED, + LedgerDiagnosticCode.LEDGER_STRUCT_007 + .message(MessageFormat.format( + Messages.LedgerStructuralValidatorEntryTypeRequired, + entry.getUUID())), + entry)); + + if (entry.getDateTime() == null) + issues.add(entryIssue(IssueCode.ENTRY_DATE_TIME_REQUIRED, + LedgerDiagnosticCode.LEDGER_STRUCT_008.message(MessageFormat.format( + Messages.LedgerStructuralValidatorEntryDateTimeRequired, + entry.getUUID())), + entry)); + + validateParameters(entry, null, entry.getParameters(), issues); + + validatePostings(entry, issues); + } + } + + private static void validatePostings(LedgerEntry entry, List issues) + { + for (var posting : entry.getPostings()) + { + if (posting.getType() == null) + issues.add(postingIssue(IssueCode.POSTING_TYPE_REQUIRED, + LedgerDiagnosticCode.LEDGER_STRUCT_011.message(MessageFormat.format( + Messages.LedgerStructuralValidatorPostingTypeRequired, + posting.getUUID())), + entry, posting)); + + if (entry.getType() != null && !entry.getType().usesSignedTargetedProjectionFacts() + && (posting.getAmount() < 0 || posting.getShares() < 0)) + issues.add(postingIssue(IssueCode.SIGNED_FACT_NOT_ALLOWED, + LedgerDiagnosticCode.LEDGER_STRUCT_012.message(MessageFormat.format( + Messages.LedgerStructuralValidatorSignedFactsNotAllowed, + entry.getType())), + entry, + posting)); + + validatePostingShape(entry, posting, issues); + validateParameters(entry, posting, posting.getParameters(), issues); + } + + validateEntryPostingShape(entry, issues); + validateSemanticShape(entry, issues); + } + + private static void validateEntryPostingShape(LedgerEntry entry, List issues) + { + if (entry.getType() == LedgerEntryType.DIVIDENDS + && entry.getPostings().stream().noneMatch(posting -> posting.getSecurity() != null)) + issues.add(entryIssue(IssueCode.DIVIDEND_SECURITY_REQUIRED, + LedgerDiagnosticCode.LEDGER_STRUCT_013 + .message(MessageFormat.format( + Messages.LedgerStructuralValidatorDividendSecurityRequired, + entry.getUUID())), + entry)); + } + + private static void validatePostingShape(LedgerEntry entry, LedgerPosting posting, List issues) + { + if (posting.getType() == null) + return; + + if (posting.getType().requiresCurrency() && isBlank(posting.getCurrency())) + issues.add(postingIssue(IssueCode.POSTING_CURRENCY_REQUIRED, + LedgerDiagnosticCode.LEDGER_STRUCT_014.message(MessageFormat.format( + Messages.LedgerStructuralValidatorPostingCurrencyRequired, + posting.getUUID())), + entry, posting)); + + if (posting.getType().requiresSecurity() && posting.getSecurity() == null) + issues.add(postingIssue(IssueCode.POSTING_SECURITY_REQUIRED, + LedgerDiagnosticCode.LEDGER_STRUCT_015 + .message(MessageFormat.format( + Messages.LedgerStructuralValidatorPostingSecurityRequired, + posting.getUUID())), + entry, posting)); + + if (posting.getExchangeRate() != null && posting.getExchangeRate().signum() <= 0) + issues.add(postingIssue(IssueCode.POSTING_EXCHANGE_RATE_POSITIVE, + LedgerDiagnosticCode.LEDGER_FOREX_002.message(MessageFormat.format( + Messages.LedgerStructuralValidatorPostingExchangeRatePositive, + posting.getUUID())), + entry, posting)); + } + + private static void validateSemanticShape(LedgerEntry entry, List issues) + { + if (entry.getType() == null) + return; + + switch (entry.getType()) + { + case DEPOSIT, REMOVAL, INTEREST, INTEREST_CHARGE, DIVIDENDS -> + requirePrimary(entry, LedgerProjectionRole.ACCOUNT, LedgerPostingSemanticRole.CASH, + LedgerPostingDirection.NEUTRAL, OwnerKind.ACCOUNT, null, false, issues); + case FEES, FEES_REFUND -> requirePrimary(entry, LedgerProjectionRole.ACCOUNT, LedgerPostingSemanticRole.FEE, + LedgerPostingDirection.NEUTRAL, OwnerKind.ACCOUNT, null, false, issues); + case TAXES, TAX_REFUND -> requirePrimary(entry, LedgerProjectionRole.ACCOUNT, LedgerPostingSemanticRole.TAX, + LedgerPostingDirection.NEUTRAL, OwnerKind.ACCOUNT, null, false, issues); + case BUY -> { + requirePrimary(entry, LedgerProjectionRole.ACCOUNT, LedgerPostingSemanticRole.CASH, + LedgerPostingDirection.OUTBOUND, OwnerKind.ACCOUNT, null, false, issues); + requirePrimary(entry, LedgerProjectionRole.PORTFOLIO, LedgerPostingSemanticRole.SECURITY, + LedgerPostingDirection.INBOUND, OwnerKind.PORTFOLIO, null, false, issues); + } + case SELL -> { + requirePrimary(entry, LedgerProjectionRole.ACCOUNT, LedgerPostingSemanticRole.CASH, + LedgerPostingDirection.INBOUND, OwnerKind.ACCOUNT, null, false, issues); + requirePrimary(entry, LedgerProjectionRole.PORTFOLIO, LedgerPostingSemanticRole.SECURITY, + LedgerPostingDirection.OUTBOUND, OwnerKind.PORTFOLIO, null, false, issues); + } + case DELIVERY_INBOUND -> requirePrimary(entry, LedgerProjectionRole.DELIVERY_INBOUND, + LedgerPostingSemanticRole.SECURITY, LedgerPostingDirection.INBOUND, OwnerKind.PORTFOLIO, + null, false, issues); + case DELIVERY_OUTBOUND -> requirePrimary(entry, LedgerProjectionRole.DELIVERY_OUTBOUND, + LedgerPostingSemanticRole.SECURITY, LedgerPostingDirection.OUTBOUND, OwnerKind.PORTFOLIO, + null, false, issues); + case CASH_TRANSFER -> { + requirePrimary(entry, LedgerProjectionRole.SOURCE_ACCOUNT, LedgerPostingSemanticRole.CASH, + LedgerPostingDirection.OUTBOUND, OwnerKind.ACCOUNT, null, false, issues); + requirePrimary(entry, LedgerProjectionRole.TARGET_ACCOUNT, LedgerPostingSemanticRole.CASH, + LedgerPostingDirection.INBOUND, OwnerKind.ACCOUNT, null, false, issues); + } + case SECURITY_TRANSFER -> { + requirePrimary(entry, LedgerProjectionRole.SOURCE_PORTFOLIO, LedgerPostingSemanticRole.SECURITY, + LedgerPostingDirection.OUTBOUND, OwnerKind.PORTFOLIO, null, false, issues); + requirePrimary(entry, LedgerProjectionRole.TARGET_PORTFOLIO, LedgerPostingSemanticRole.SECURITY, + LedgerPostingDirection.INBOUND, OwnerKind.PORTFOLIO, null, false, issues); + } + case CORPORATE_ACTION -> validateCorporateActionSemanticShape(entry, issues); + default -> { + // No semantic shape rule. + } + } + + validateUnitGrouping(entry, issues); + } + + private static void validateCorporateActionSemanticShape(LedgerEntry entry, List issues) + { + var kind = CorporateActionKind.fromEntry(entry); + + if (kind.filter(CorporateActionKind.SPIN_OFF::equals).isEmpty()) + return; + + requireRepeatableCorporatePrimary(entry, LedgerProjectionRole.OLD_SECURITY_LEG, + LedgerPostingSemanticRole.SECURITY, CorporateActionLeg.SOURCE_SECURITY, + LedgerPostingDirection.OUTBOUND, true, issues); + requireRepeatableCorporatePrimary(entry, LedgerProjectionRole.NEW_SECURITY_LEG, + LedgerPostingSemanticRole.SECURITY, CorporateActionLeg.TARGET_SECURITY, + LedgerPostingDirection.INBOUND, true, issues, LedgerProjectionRole.DELIVERY_INBOUND); + requireRepeatableCorporatePrimary(entry, LedgerProjectionRole.CASH_COMPENSATION, + LedgerPostingSemanticRole.CASH_COMPENSATION, CorporateActionLeg.CASH_COMPENSATION, + LedgerPostingDirection.NEUTRAL, true, issues); + } + + private static void requireRepeatableCorporatePrimary(LedgerEntry entry, LedgerProjectionRole role, + LedgerPostingSemanticRole semanticRole, CorporateActionLeg leg, LedgerPostingDirection direction, + boolean optional, List issues, LedgerProjectionRole... excludedLocalKeys) + { + var matches = entry.getPostings().stream() // + .filter(posting -> matchesPrimary(posting, semanticRole, direction, leg)) // + .filter(posting -> !hasExcludedLocalKey(posting, excludedLocalKeys)) // + .toList(); + + validateRepeatablePrimaryMatches(entry, role, OwnerKind.PORTFOLIO_OR_ACCOUNT, optional, matches, issues); + } + + private static boolean hasExcludedLocalKey(LedgerPosting posting, LedgerProjectionRole... excludedLocalKeys) + { + for (var role : excludedLocalKeys) + if (role.name().equals(posting.getLocalKey())) + return true; + + return false; + } + + private static void requirePrimary(LedgerEntry entry, LedgerProjectionRole role, + LedgerPostingSemanticRole semanticRole, LedgerPostingDirection direction, OwnerKind ownerKind, + CorporateActionLeg leg, boolean optional, List issues) + { + var matches = entry.getPostings().stream() // + .filter(posting -> matchesPrimary(posting, semanticRole, direction, leg)) // + .toList(); + + validatePrimaryMatches(entry, role, ownerKind, optional, matches, issues); + } + + private static void validatePrimaryMatches(LedgerEntry entry, LedgerProjectionRole role, OwnerKind ownerKind, + boolean optional, List matches, List issues) + { + if (matches.isEmpty()) + { + if (!optional) + issues.add(entryIssue(missingIssueCode(role), + LedgerDiagnosticCode.LEDGER_STRUCT_016 + .message("Required semantic primary posting is missing for " + role), //$NON-NLS-1$ + entry).withDetail("projectionRole", role)); //$NON-NLS-1$ + return; + } + + if (matches.size() > 1) + issues.add(entryIssue(ambiguousIssueCode(role), + LedgerDiagnosticCode.LEDGER_STRUCT_017 + .message("Semantic primary posting is ambiguous for " + role), //$NON-NLS-1$ + entry).withDetail("projectionRole", role) //$NON-NLS-1$ + .withDetail("actualCount", matches.size())); //$NON-NLS-1$ + + for (var posting : matches) + validateOwner(entry, role, posting, ownerKind, issues); + } + + private static void validateRepeatablePrimaryMatches(LedgerEntry entry, LedgerProjectionRole role, + OwnerKind ownerKind, boolean optional, List matches, List issues) + { + if (matches.isEmpty()) + { + if (!optional) + issues.add(entryIssue(missingIssueCode(role), + LedgerDiagnosticCode.LEDGER_STRUCT_016 + .message("Required semantic primary posting is missing for " + role), //$NON-NLS-1$ + entry).withDetail("projectionRole", role)); //$NON-NLS-1$ + return; + } + + if (matches.size() > 1) + validateRepeatablePrimaryLocalKeys(entry, role, matches, issues); + + for (var posting : matches) + validateOwner(entry, role, posting, ownerKind, issues); + } + + private static void validateRepeatablePrimaryLocalKeys(LedgerEntry entry, LedgerProjectionRole role, + List matches, List issues) + { + var localKeys = new HashSet(); + + for (var posting : matches) + { + if (isBlank(posting.getLocalKey())) + { + issues.add(postingIssue(IssueCode.SEMANTIC_LOCAL_KEY_REQUIRED, + LedgerDiagnosticCode.LEDGER_STRUCT_027 + .message("Repeated semantic primary posting requires a local key for " //$NON-NLS-1$ + + role), + entry, posting).withDetail("projectionRole", role)); //$NON-NLS-1$ + } + else if (!localKeys.add(posting.getLocalKey())) + { + issues.add(entryIssue(ambiguousIssueCode(role), + LedgerDiagnosticCode.LEDGER_STRUCT_017 + .message("Repeated semantic primary posting local key is duplicated for " //$NON-NLS-1$ + + role), + entry).withDetail("projectionRole", role) //$NON-NLS-1$ + .withDetail("localKey", posting.getLocalKey())); //$NON-NLS-1$ + } + } + } + + private static IssueCode missingIssueCode(LedgerProjectionRole role) + { + return switch (role) + { + case SOURCE_ACCOUNT, SOURCE_PORTFOLIO, OLD_SECURITY_LEG -> IssueCode.SEMANTIC_SOURCE_REQUIRED; + case TARGET_ACCOUNT, TARGET_PORTFOLIO, NEW_SECURITY_LEG -> IssueCode.SEMANTIC_TARGET_REQUIRED; + default -> IssueCode.SEMANTIC_PRIMARY_REQUIRED; + }; + } + + private static IssueCode ambiguousIssueCode(LedgerProjectionRole role) + { + return switch (role) + { + case SOURCE_ACCOUNT, SOURCE_PORTFOLIO, OLD_SECURITY_LEG -> IssueCode.SEMANTIC_SOURCE_AMBIGUOUS; + case TARGET_ACCOUNT, TARGET_PORTFOLIO, NEW_SECURITY_LEG -> IssueCode.SEMANTIC_TARGET_AMBIGUOUS; + default -> IssueCode.SEMANTIC_PRIMARY_AMBIGUOUS; + }; + } + + private static boolean matchesPrimary(LedgerPosting posting, LedgerPostingSemanticRole semanticRole, + LedgerPostingDirection direction, CorporateActionLeg leg) + { + return posting.getUnitRole() == LedgerPostingUnitRole.PRIMARY && posting.getSemanticRole() == semanticRole + && posting.getDirection() == direction + && (leg == null || posting.getCorporateActionLeg() == leg); + } + + private static void validateOwner(LedgerEntry entry, LedgerProjectionRole role, LedgerPosting posting, + OwnerKind ownerKind, List issues) + { + var ownerPresent = switch (ownerKind) + { + case ACCOUNT -> posting.getAccount() != null; + case PORTFOLIO -> posting.getPortfolio() != null; + case PORTFOLIO_OR_ACCOUNT -> posting.getAccount() != null || posting.getPortfolio() != null; + }; + + if (!ownerPresent) + issues.add(postingIssue(IssueCode.SEMANTIC_OWNER_REQUIRED, + LedgerDiagnosticCode.LEDGER_STRUCT_018 + .message("Semantic primary owner is missing for " + role), //$NON-NLS-1$ + entry, posting).withDetail("projectionRole", role)); //$NON-NLS-1$ + } + + private static void validateUnitGrouping(LedgerEntry entry, List issues) + { + var primaryGroupKeys = entry.getPostings().stream() // + .filter(posting -> posting.getUnitRole() == LedgerPostingUnitRole.PRIMARY) // + .map(LedgerPosting::getGroupKey) // + .filter(groupKey -> !isBlank(groupKey)) // + .collect(java.util.stream.Collectors.toSet()); + var primaryCount = entry.getPostings().stream() + .filter(posting -> posting.getUnitRole() == LedgerPostingUnitRole.PRIMARY).count(); + var repeatedUnitKeys = new HashSet(); + var seenUnitKeys = new HashSet(); + + for (var posting : entry.getPostings()) + { + if (!isUnit(posting)) + continue; + + if (primaryCount > 1 && primaryGroupKeys.size() > 1 && isBlank(posting.getGroupKey())) + issues.add(postingIssue(IssueCode.SEMANTIC_UNIT_GROUP_REQUIRED, + LedgerDiagnosticCode.LEDGER_STRUCT_019 + .message("Grouped unit posting requires a group key"), //$NON-NLS-1$ + entry, posting)); + + if (!primaryGroupKeys.isEmpty() && !isBlank(posting.getGroupKey()) + && !primaryGroupKeys.contains(posting.getGroupKey())) + issues.add(postingIssue(IssueCode.SEMANTIC_UNIT_GROUP_AMBIGUOUS, + LedgerDiagnosticCode.LEDGER_STRUCT_020 + .message("Unit posting group key has no semantic primary anchor"), //$NON-NLS-1$ + entry, posting).withDetail("groupKey", posting.getGroupKey())); //$NON-NLS-1$ + + var key = posting.getUnitRole() + ":" + posting.getGroupKey(); //$NON-NLS-1$ + if (!seenUnitKeys.add(key)) + repeatedUnitKeys.add(key); + } + + for (var posting : entry.getPostings()) + { + if (!isUnit(posting)) + continue; + + var key = posting.getUnitRole() + ":" + posting.getGroupKey(); //$NON-NLS-1$ + if (repeatedUnitKeys.contains(key) && isBlank(posting.getLocalKey())) + issues.add(postingIssue(IssueCode.SEMANTIC_LOCAL_KEY_REQUIRED, + LedgerDiagnosticCode.LEDGER_STRUCT_021 + .message("Repeated unit posting requires a local key"), //$NON-NLS-1$ + entry, posting).withDetail("groupKey", posting.getGroupKey()) //$NON-NLS-1$ + .withDetail("unitRole", posting.getUnitRole())); //$NON-NLS-1$ + } + } + + private static boolean isUnit(LedgerPosting posting) + { + var unitRole = posting.getUnitRole(); + return unitRole == LedgerPostingUnitRole.FEE || unitRole == LedgerPostingUnitRole.TAX + || unitRole == LedgerPostingUnitRole.GROSS_VALUE + || unitRole == LedgerPostingUnitRole.FOREX_CONTEXT; + } + + private static void validateParameters(LedgerEntry entry, LedgerPosting posting, + List> parameters, List issues) + { + var owner = parameterOwnerDescription(entry, posting); + + for (var parameter : parameters) + { + if (parameter.getType() == null) + issues.add(parameterIssue(IssueCode.PARAMETER_TYPE_REQUIRED, + LedgerDiagnosticCode.LEDGER_STRUCT_028 + .message(MessageFormat.format( + Messages.LedgerStructuralValidatorParameterTypeRequired, + owner)), + entry, posting, + parameter)); + + if (parameter.getValueKind() == null) + { + issues.add(parameterIssue(IssueCode.PARAMETER_VALUE_KIND_REQUIRED, + LedgerDiagnosticCode.LEDGER_STRUCT_029.message(MessageFormat.format( + Messages.LedgerStructuralValidatorParameterValueKindRequired, + owner)), + entry, + posting, parameter)); + continue; + } + + if (parameter.getValue() == null) + issues.add(parameterIssue(IssueCode.PARAMETER_VALUE_REQUIRED, + LedgerDiagnosticCode.LEDGER_STRUCT_030 + .message(MessageFormat.format( + Messages.LedgerStructuralValidatorParameterValueRequired, + owner)), + entry, posting, + parameter)); + + if (parameter.getType() != null && !parameter.getType().supportsValueKind(parameter.getValueKind())) + issues.add(parameterValueKindIssue(entry, posting, parameter)); + + if (posting != null && parameter.getType() == LedgerParameterType.EX_DATE + && posting.getSecurity() == null) + issues.add(parameterIssue(IssueCode.EX_DATE_SECURITY_REQUIRED, + LedgerDiagnosticCode.LEDGER_STRUCT_031 + .message(MessageFormat.format( + Messages.LedgerStructuralValidatorExDateSecurityRequired, + posting.getUUID())), + entry, posting, + parameter)); + + if (parameter.getValue() != null && !isCompatible(parameter)) + issues.add(parameterIssue(IssueCode.PARAMETER_VALUE_KIND_MISMATCH, + LedgerDiagnosticCode.LEDGER_STRUCT_032.message(MessageFormat.format( + Messages.LedgerStructuralValidatorParameterValueKindMismatch, + parameter.getValueKind())), + entry, posting, parameter)); + + if (hasUnsupportedCode(parameter)) + issues.add(parameterIssue(IssueCode.PARAMETER_CODE_NOT_ALLOWED, + LedgerDiagnosticCode.LEDGER_STRUCT_033.message(MessageFormat.format( + Messages.LedgerStructuralValidatorParameterCodeNotAllowed, + parameter.getType())), + entry, posting, + parameter).withDetail("codeDomain", parameter.getType().getCodeDomain()) //$NON-NLS-1$ + .withDetail("allowedCodes", //$NON-NLS-1$ + parameter.getType().getCodeDomain().getAllowedCodes())); + } + } + + private static String parameterOwnerDescription(LedgerEntry entry, LedgerPosting posting) + { + if (posting != null) + return "posting " + posting.getUUID(); //$NON-NLS-1$ + + return "entry " + entry.getUUID(); //$NON-NLS-1$ + } + + private static boolean isCompatible(LedgerParameter parameter) + { + return parameter.getValueKind().supportsValue(parameter.getValue()); + } + + private static boolean hasUnsupportedCode(LedgerParameter parameter) + { + return parameter.getType() != null && parameter.getType().hasCodeDomain() + && parameter.getType().supportsValueKind(parameter.getValueKind()) && isCompatible(parameter) + && !parameter.getType().supportsCode((String) parameter.getValue()); + } + + private static boolean isBlank(String value) + { + return value == null || value.isBlank(); + } + + private static ValidationIssue entryIssue(IssueCode code, String message, LedgerEntry entry) + { + return new ValidationIssue(code, message).withEntry(entry); + } + + private static ValidationIssue postingIssue(IssueCode code, String message, LedgerEntry entry, + LedgerPosting posting) + { + return entryIssue(code, message, entry).withPosting(posting); + } + + private static ValidationIssue parameterIssue(IssueCode code, String message, LedgerEntry entry, + LedgerPosting posting, LedgerParameter parameter) + { + return postingIssue(code, message, entry, posting).withParameter(parameter); + } + + private static ValidationIssue parameterValueKindIssue(LedgerEntry entry, LedgerPosting posting, + LedgerParameter parameter) + { + var type = parameter.getType(); + var code = type == LedgerParameterType.EX_DATE ? IssueCode.EX_DATE_VALUE_KIND_REQUIRED + : IssueCode.PARAMETER_VALUE_KIND_MISMATCH; + + return parameterIssue(code, + LedgerDiagnosticCode.LEDGER_STRUCT_034 + .message(MessageFormat.format( + Messages.LedgerStructuralValidatorParameterMustUseValueKind, + type, type.getExpectedValueKind())), + entry, posting, parameter) + .withDetail("expectedValueKind", type.getExpectedValueKind()) //$NON-NLS-1$ + .withDetail("actualValueKind", parameter.getValueKind()); //$NON-NLS-1$ + } + + private static String ownerSummary(Account account) + { + if (account == null) + return null; + + return summary(account.getName(), account.getUUID()); + } + + private static String ownerSummary(Portfolio portfolio) + { + if (portfolio == null) + return null; + + return summary(portfolio.getName(), portfolio.getUUID()); + } + + private static String securitySummary(Security security) + { + if (security == null) + return null; + + return summary(security.getName(), security.getUUID()); + } + + private static String summary(String name, String uuid) + { + var displayName = isBlank(name) ? "" : name; //$NON-NLS-1$ + var displayUUID = isBlank(uuid) ? "" : uuid; //$NON-NLS-1$ + + return displayName + " (" + displayUUID + ")"; //$NON-NLS-1$ //$NON-NLS-2$ + } + + private static String valueSummary(Object value) + { + if (value == null) + return null; + + if (value instanceof Account account) + return ownerSummary(account); + + if (value instanceof Portfolio portfolio) + return ownerSummary(portfolio); + + if (value instanceof Security security) + return securitySummary(security); + + var summary = String.valueOf(value); + + return summary.length() > 120 ? summary.substring(0, 117) + "..." : summary; //$NON-NLS-1$ + } + + public static final class ValidationResult + { + private final List issues; + + private ValidationResult(List issues) + { + this.issues = List.copyOf(issues); + } + + public boolean isOK() + { + return issues.isEmpty(); + } + + public List getIssues() + { + return Collections.unmodifiableList(issues); + } + + public boolean hasIssue(IssueCode code) + { + return issues.stream().anyMatch(issue -> issue.getCode() == code); + } + + public String format() + { + if (issues.isEmpty()) + return "OK"; //$NON-NLS-1$ + + var builder = new StringBuilder(); + + for (var issue : issues) + { + if (!builder.isEmpty()) + builder.append("\n\n"); //$NON-NLS-1$ + + builder.append(issue.format()); + } + + return builder.toString(); + } + + @Override + public String toString() + { + return format(); + } + } + + public static final class ValidationIssue + { + private final IssueCode code; + private final String message; + private final Map details = new LinkedHashMap<>(); + + private ValidationIssue(IssueCode code, String message) + { + this.code = code; + this.message = message; + } + + public IssueCode getCode() + { + return code; + } + + public String getMessage() + { + return message; + } + + public Map getDetails() + { + return Collections.unmodifiableMap(details); + } + + public String format() + { + if (details.isEmpty()) + return "[" + code + "] " + message; //$NON-NLS-1$ //$NON-NLS-2$ + + var builder = new StringBuilder(); + + builder.append("[").append(code).append("] ").append(message); //$NON-NLS-1$ //$NON-NLS-2$ + appendGroup(builder, "Entry", //$NON-NLS-1$ + detail("UUID", "entryUUID"), //$NON-NLS-1$ //$NON-NLS-2$ + detail("Type", "entryType"), //$NON-NLS-1$ //$NON-NLS-2$ + detail("DateTime", "entryDateTime")); //$NON-NLS-1$ //$NON-NLS-2$ + appendGroup(builder, "Posting", //$NON-NLS-1$ + detail("UUID", "postingUUID"), //$NON-NLS-1$ //$NON-NLS-2$ + detail("Type", "postingType"), //$NON-NLS-1$ //$NON-NLS-2$ + detail("Amount", "amount"), //$NON-NLS-1$ //$NON-NLS-2$ + detail("Currency", "currency"), //$NON-NLS-1$ //$NON-NLS-2$ + detail("Shares", "shares"), //$NON-NLS-1$ //$NON-NLS-2$ + detail("ExchangeRate", "exchangeRate"), //$NON-NLS-1$ //$NON-NLS-2$ + detail("Security", "security"), //$NON-NLS-1$ //$NON-NLS-2$ + detail("Account", "postingAccount"), //$NON-NLS-1$ //$NON-NLS-2$ + detail("Portfolio", "postingPortfolio")); //$NON-NLS-1$ //$NON-NLS-2$ + appendGroup(builder, "Parameter", //$NON-NLS-1$ + detail("Type", "parameterType"), //$NON-NLS-1$ //$NON-NLS-2$ + detail("ExpectedValueKind", "expectedValueKind"), //$NON-NLS-1$ //$NON-NLS-2$ + detail("ValueKind", "actualValueKind"), //$NON-NLS-1$ //$NON-NLS-2$ + detail("ValueType", "actualValueType"), //$NON-NLS-1$ //$NON-NLS-2$ + detail("Value", "actualValue"), //$NON-NLS-1$ //$NON-NLS-2$ + detail("CodeDomain", "codeDomain"), //$NON-NLS-1$ //$NON-NLS-2$ + detail("AllowedCodes", "allowedCodes")); //$NON-NLS-1$ //$NON-NLS-2$ + appendGroup(builder, "Duplicate", //$NON-NLS-1$ + detail("ObjectType", "objectType"), //$NON-NLS-1$ //$NON-NLS-2$ + detail("DuplicateUUID", "duplicateUUID"), //$NON-NLS-1$ //$NON-NLS-2$ + detail("OccurrenceCount", "occurrenceCount")); //$NON-NLS-1$ //$NON-NLS-2$ + + return builder.toString(); + } + + @Override + public String toString() + { + return format(); + } + + private ValidationIssue withEntry(LedgerEntry entry) + { + if (entry == null) + return this; + + return withDetail("entryUUID", entry.getUUID()) //$NON-NLS-1$ + .withDetail("entryType", entry.getType()) //$NON-NLS-1$ + .withDetail("entryDateTime", entry.getDateTime()); //$NON-NLS-1$ + } + + private ValidationIssue withPosting(LedgerPosting posting) + { + if (posting == null) + return this; + + return withDetail("postingUUID", posting.getUUID()) //$NON-NLS-1$ + .withDetail("postingType", posting.getType()) //$NON-NLS-1$ + .withDetail("amount", posting.getAmount()) //$NON-NLS-1$ + .withDetail("currency", posting.getCurrency()) //$NON-NLS-1$ + .withDetail("shares", posting.getShares()) //$NON-NLS-1$ + .withDetail("exchangeRate", posting.getExchangeRate()) //$NON-NLS-1$ + .withDetail("security", securitySummary(posting.getSecurity())) //$NON-NLS-1$ + .withDetail("postingAccount", ownerSummary(posting.getAccount())) //$NON-NLS-1$ + .withDetail("postingPortfolio", ownerSummary(posting.getPortfolio())); //$NON-NLS-1$ + } + + private ValidationIssue withParameter(LedgerParameter parameter) + { + if (parameter == null) + return this; + + var value = parameter.getValue(); + + return withDetail("parameterType", parameter.getType()) //$NON-NLS-1$ + .withDetail("actualValueKind", parameter.getValueKind()) //$NON-NLS-1$ + .withDetail("actualValueType", value == null ? null : value.getClass().getSimpleName()) //$NON-NLS-1$ + .withDetail("actualValue", valueSummary(value)); //$NON-NLS-1$ + } + + private ValidationIssue withDetail(String key, Object value) + { + details.put(key, detailValue(value)); + + return this; + } + + private Detail detail(String label, String key) + { + return new Detail(label, key); + } + + private void appendGroup(StringBuilder builder, String group, Detail... groupDetails) + { + var hasDetails = false; + + for (var groupDetail : groupDetails) + { + if (details.containsKey(groupDetail.key)) + { + hasDetails = true; + break; + } + } + + if (!hasDetails) + return; + + builder.append("\n\n ").append(group).append(":"); //$NON-NLS-1$ //$NON-NLS-2$ + + for (var groupDetail : groupDetails) + { + if (details.containsKey(groupDetail.key)) + builder.append("\n ").append(groupDetail.label).append(": ") //$NON-NLS-1$ //$NON-NLS-2$ + .append(details.get(groupDetail.key)); + } + } + + private String detailValue(Object value) + { + if (value == null) + return ""; //$NON-NLS-1$ + + var string = String.valueOf(value); + + return string.isBlank() ? "" : string; //$NON-NLS-1$ + } + } + + private record Detail(String label, String key) + { + } + + private enum OwnerKind + { + ACCOUNT, + PORTFOLIO, + PORTFOLIO_OR_ACCOUNT + } +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerTransactionMetadata.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerTransactionMetadata.java new file mode 100644 index 0000000000..a608ce53a1 --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerTransactionMetadata.java @@ -0,0 +1,53 @@ +package name.abuchen.portfolio.model.ledger; + +import java.time.LocalDateTime; +import java.util.Objects; + +/** + * Carries common transaction metadata for Ledger creator and editor calls. + * This is compatibility-layer input data for date, note, and source values. It is not a + * direct Ledger mutation API. + */ +public final class LedgerTransactionMetadata +{ + private final LocalDateTime dateTime; + private final String note; + private final String source; + + private LedgerTransactionMetadata(LocalDateTime dateTime, String note, String source) + { + this.dateTime = Objects.requireNonNull(dateTime); + this.note = note; + this.source = source; + } + + public static LedgerTransactionMetadata of(LocalDateTime dateTime) + { + return new LedgerTransactionMetadata(dateTime, null, null); + } + + public LedgerTransactionMetadata withNote(String note) + { + return new LedgerTransactionMetadata(dateTime, note, source); + } + + public LedgerTransactionMetadata withSource(String source) + { + return new LedgerTransactionMetadata(dateTime, note, source); + } + + public LocalDateTime getDateTime() + { + return dateTime; + } + + public String getNote() + { + return note; + } + + public String getSource() + { + return source; + } +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/CashCompensationKind.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/CashCompensationKind.java new file mode 100644 index 0000000000..c414122b9c --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/CashCompensationKind.java @@ -0,0 +1,39 @@ +package name.abuchen.portfolio.model.ledger.configuration; + +/** + * Defines the cash compensation kind Ledger code domain used by configuration or validation. + * This is configuration metadata. Normal transaction-editing code should not treat these + * values as a direct mutation API. + * + *

+ * The enum object is not persisted directly. Its {@link #getCode()} value can be stored as a + * string Ledger parameter value for parameters in the matching code domain. + *

+ */ +@SuppressWarnings("nls") +public enum CashCompensationKind implements LedgerCode +{ + CASH_IN_LIEU("CASH_IN_LIEU"), + FRACTIONAL_SHARE_COMPENSATION("FRACTIONAL_SHARE_COMPENSATION"), + ROUNDING_COMPENSATION("ROUNDING_COMPENSATION"), + OTHER("OTHER"); + + private final String code; + + private CashCompensationKind(String code) + { + this.code = code; + } + + @Override + public LedgerParameterCodeDomain getDomain() + { + return LedgerParameterCodeDomain.CASH_COMPENSATION_KIND; + } + + @Override + public String getCode() + { + return code; + } +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/CorporateActionKind.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/CorporateActionKind.java new file mode 100644 index 0000000000..08b96083fe --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/CorporateActionKind.java @@ -0,0 +1,83 @@ +package name.abuchen.portfolio.model.ledger.configuration; + +import java.util.Optional; + +import name.abuchen.portfolio.model.ledger.LedgerEntry; +import name.abuchen.portfolio.model.ledger.LedgerParameter; + +/** + * Defines the corporate action kind Ledger code domain used by configuration or validation. + * This is configuration metadata. Normal transaction-editing code should not treat these + * values as a direct mutation API. + * + *

+ * The enum object is not persisted directly. Its {@link #getCode()} value can be stored as a + * string Ledger parameter value for parameters in the matching code domain. + *

+ */ +@SuppressWarnings("nls") +public enum CorporateActionKind implements LedgerCode +{ + STOCK_DIVIDEND("STOCK_DIVIDEND"), + SPIN_OFF("SPIN_OFF"), + BONUS_ISSUE("BONUS_ISSUE"), + RIGHTS_DISTRIBUTION("RIGHTS_DISTRIBUTION"), + COUPON_PAYMENT("COUPON_PAYMENT"), + PIK_INTEREST("PIK_INTEREST"), + DEFAULTED_INTEREST("DEFAULTED_INTEREST"), + MATURITY("MATURITY"), + PARTIAL_REDEMPTION("PARTIAL_REDEMPTION"), + CALL("CALL"), + PUT("PUT"), + CONVERSION("CONVERSION"), + EXCHANGE("EXCHANGE"), + RESTRUCTURING("RESTRUCTURING"), + DEFAULT("DEFAULT"); + + private final String code; + + private CorporateActionKind(String code) + { + this.code = code; + } + + @Override + public LedgerParameterCodeDomain getDomain() + { + return LedgerParameterCodeDomain.CORPORATE_ACTION_KIND; + } + + @Override + public String getCode() + { + return code; + } + + public static Optional fromCode(String code) + { + if (code == null || code.isBlank()) + return Optional.empty(); + + for (var kind : values()) + if (kind.code.equals(code)) + return Optional.of(kind); + + return Optional.empty(); + } + + public static Optional fromEntry(LedgerEntry entry) + { + if (entry == null) + return Optional.empty(); + + return entry.getParameters().stream() // + .filter(parameter -> parameter.getType() == LedgerParameterType.CORPORATE_ACTION_KIND) // + .map(LedgerParameter::getValue) // + .filter(String.class::isInstance) // + .map(String.class::cast) // + .map(CorporateActionKind::fromCode) // + .filter(Optional::isPresent) // + .map(Optional::get) // + .findFirst(); + } +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/CorporateActionLeg.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/CorporateActionLeg.java new file mode 100644 index 0000000000..cc00f249c2 --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/CorporateActionLeg.java @@ -0,0 +1,49 @@ +package name.abuchen.portfolio.model.ledger.configuration; + +/** + * Defines the corporate action leg Ledger code domain used by configuration or validation. + * This is configuration metadata. Normal transaction-editing code should not treat these + * values as a direct mutation API. + * + *

+ * The enum object is not persisted directly. Its {@link #getCode()} value can be stored as a + * string Ledger parameter value for parameters in the matching code domain. + *

+ */ +@SuppressWarnings("nls") +public enum CorporateActionLeg implements LedgerCode +{ + SOURCE_SECURITY("SOURCE_SECURITY"), + TARGET_SECURITY("TARGET_SECURITY"), + DISTRIBUTED_SECURITY("DISTRIBUTED_SECURITY"), + RIGHT_SECURITY("RIGHT_SECURITY"), + CASH_COMPENSATION("CASH_COMPENSATION"), + CASH_IN_LIEU("CASH_IN_LIEU"), + FEE("FEE"), + TAX("TAX"), + ACCRUED_INTEREST("ACCRUED_INTEREST"), + PRINCIPAL("PRINCIPAL"), + REDEMPTION("REDEMPTION"), + CONVERSION_SOURCE("CONVERSION_SOURCE"), + CONVERSION_TARGET("CONVERSION_TARGET"), + OTHER("OTHER"); + + private final String code; + + private CorporateActionLeg(String code) + { + this.code = code; + } + + @Override + public LedgerParameterCodeDomain getDomain() + { + return LedgerParameterCodeDomain.CORPORATE_ACTION_LEG; + } + + @Override + public String getCode() + { + return code; + } +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/CorporateActionSubtype.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/CorporateActionSubtype.java new file mode 100644 index 0000000000..02307cb840 --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/CorporateActionSubtype.java @@ -0,0 +1,40 @@ +package name.abuchen.portfolio.model.ledger.configuration; + +/** + * Defines the corporate action subtype Ledger code domain used by configuration or validation. + * This is configuration metadata. Normal transaction-editing code should not treat these + * values as a direct mutation API. + * + *

+ * The enum object is not persisted directly. Its {@link #getCode()} value can be stored as a + * string Ledger parameter value for parameters in the matching code domain. + *

+ */ +@SuppressWarnings("nls") +public enum CorporateActionSubtype implements LedgerCode +{ + STANDARD("STANDARD"), + OPTIONAL("OPTIONAL"), + MANDATORY("MANDATORY"), + CASH_AND_STOCK("CASH_AND_STOCK"), + OTHER("OTHER"); + + private final String code; + + private CorporateActionSubtype(String code) + { + this.code = code; + } + + @Override + public LedgerParameterCodeDomain getDomain() + { + return LedgerParameterCodeDomain.CORPORATE_ACTION_SUBTYPE; + } + + @Override + public String getCode() + { + return code; + } +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/CostAllocationMethod.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/CostAllocationMethod.java new file mode 100644 index 0000000000..01721cce58 --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/CostAllocationMethod.java @@ -0,0 +1,41 @@ +package name.abuchen.portfolio.model.ledger.configuration; + +/** + * Defines the cost allocation method Ledger code domain used by configuration or validation. + * This is configuration metadata. Normal transaction-editing code should not treat these + * values as a direct mutation API. + * + *

+ * The enum object is not persisted directly. Its {@link #getCode()} value can be stored as a + * string Ledger parameter value for parameters in the matching code domain. + *

+ */ +@SuppressWarnings("nls") +public enum CostAllocationMethod implements LedgerCode +{ + NONE("NONE"), + FMV_RATIO("FMV_RATIO"), + MANUAL_PERCENTAGE("MANUAL_PERCENTAGE"), + ZERO_COST_TARGET("ZERO_COST_TARGET"), + CARRY_OVER("CARRY_OVER"), + OTHER("OTHER"); + + private final String code; + + private CostAllocationMethod(String code) + { + this.code = code; + } + + @Override + public LedgerParameterCodeDomain getDomain() + { + return LedgerParameterCodeDomain.COST_ALLOCATION_METHOD; + } + + @Override + public String getCode() + { + return code; + } +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/EventStage.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/EventStage.java new file mode 100644 index 0000000000..38f0624562 --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/EventStage.java @@ -0,0 +1,45 @@ +package name.abuchen.portfolio.model.ledger.configuration; + +/** + * Defines the event stage Ledger code domain used by configuration or validation. + * This is configuration metadata. Normal transaction-editing code should not treat these + * values as a direct mutation API. + * + *

+ * The enum object is not persisted directly. Its {@link #getCode()} value can be stored as a + * string Ledger parameter value for parameters in the matching code domain. + *

+ */ +@SuppressWarnings("nls") +public enum EventStage implements LedgerCode +{ + ANNOUNCED("ANNOUNCED"), + RECORD("RECORD"), + EX_DATE("EX_DATE"), + PAYMENT("PAYMENT"), + ISSUED("ISSUED"), + EXERCISED("EXERCISED"), + SOLD("SOLD"), + EXPIRED("EXPIRED"), + SETTLED("SETTLED"), + OTHER("OTHER"); + + private final String code; + + private EventStage(String code) + { + this.code = code; + } + + @Override + public LedgerParameterCodeDomain getDomain() + { + return LedgerParameterCodeDomain.EVENT_STAGE; + } + + @Override + public String getCode() + { + return code; + } +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/FeeReason.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/FeeReason.java new file mode 100644 index 0000000000..5184eec7f2 --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/FeeReason.java @@ -0,0 +1,40 @@ +package name.abuchen.portfolio.model.ledger.configuration; + +/** + * Defines the fee reason Ledger code domain used by configuration or validation. + * This is configuration metadata. Normal transaction-editing code should not treat these + * values as a direct mutation API. + * + *

+ * The enum object is not persisted directly. Its {@link #getCode()} value can be stored as a + * string Ledger parameter value for parameters in the matching code domain. + *

+ */ +@SuppressWarnings("nls") +public enum FeeReason implements LedgerCode +{ + BROKER_FEE("BROKER_FEE"), + EXCHANGE_FEE("EXCHANGE_FEE"), + CORPORATE_ACTION_FEE("CORPORATE_ACTION_FEE"), + STAMP_DUTY("STAMP_DUTY"), + OTHER("OTHER"); + + private final String code; + + private FeeReason(String code) + { + this.code = code; + } + + @Override + public LedgerParameterCodeDomain getDomain() + { + return LedgerParameterCodeDomain.FEE_REASON; + } + + @Override + public String getCode() + { + return code; + } +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/FractionTreatment.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/FractionTreatment.java new file mode 100644 index 0000000000..51ad62dd95 --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/FractionTreatment.java @@ -0,0 +1,41 @@ +package name.abuchen.portfolio.model.ledger.configuration; + +/** + * Defines the fraction treatment Ledger code domain used by configuration or validation. + * This is configuration metadata. Normal transaction-editing code should not treat these + * values as a direct mutation API. + * + *

+ * The enum object is not persisted directly. Its {@link #getCode()} value can be stored as a + * string Ledger parameter value for parameters in the matching code domain. + *

+ */ +@SuppressWarnings("nls") +public enum FractionTreatment implements LedgerCode +{ + NONE("NONE"), + CASH_IN_LIEU("CASH_IN_LIEU"), + ROUND_DOWN("ROUND_DOWN"), + ROUND_UP("ROUND_UP"), + DROP("DROP"), + OTHER("OTHER"); + + private final String code; + + private FractionTreatment(String code) + { + this.code = code; + } + + @Override + public LedgerParameterCodeDomain getDomain() + { + return LedgerParameterCodeDomain.FRACTION_TREATMENT; + } + + @Override + public String getCode() + { + return code; + } +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/LedgerCode.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/LedgerCode.java new file mode 100644 index 0000000000..87157ca9e2 --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/LedgerCode.java @@ -0,0 +1,19 @@ +package name.abuchen.portfolio.model.ledger.configuration; + +/** + * Defines the code Ledger code domain used by configuration or validation. + * This is configuration metadata. Normal transaction-editing code should not treat these + * values as a direct mutation API. + * + *

+ * Implementations provide stable string codes for controlled {@link LedgerParameterType} + * values. The interface is not persisted as a standalone object, but its returned code can + * be written as a Ledger parameter string value. + *

+ */ +public interface LedgerCode +{ + LedgerParameterCodeDomain getDomain(); + + String getCode(); +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/LedgerDownstreamResult.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/LedgerDownstreamResult.java new file mode 100644 index 0000000000..9343f54efb --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/LedgerDownstreamResult.java @@ -0,0 +1,21 @@ +package name.abuchen.portfolio.model.ledger.configuration; + +/** + * Defines the downstream result Ledger code domain used by configuration or validation. + * This is configuration metadata. Normal transaction-editing code should not treat these + * values as a direct mutation API. + * + *

+ * These values are runtime configuration markers. They are not persisted in Ledger entries; + * persisted files store the facts from which downstream results may later be derived. + *

+ */ +public enum LedgerDownstreamResult +{ + FIFO_RESULT, + COST_BASIS_RESULT, + TAX_RESULT, + PERFORMANCE_RESULT, + REPORT_RESULT, + SNAPSHOT_RESULT +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/LedgerEntryDefinition.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/LedgerEntryDefinition.java new file mode 100644 index 0000000000..347c5158e6 --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/LedgerEntryDefinition.java @@ -0,0 +1,425 @@ +package name.abuchen.portfolio.model.ledger.configuration; + +import java.util.Collections; +import java.util.EnumSet; +import java.util.LinkedHashSet; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; + +import name.abuchen.portfolio.model.LedgerDiagnosticCode; +import name.abuchen.portfolio.model.ledger.LedgerProjectionRole; +import name.abuchen.portfolio.model.ledger.configuration.rule.LedgerParameterRule; +import name.abuchen.portfolio.model.ledger.configuration.rule.LedgerPostingGroupRule; +import name.abuchen.portfolio.model.ledger.configuration.rule.LedgerPostingRule; +import name.abuchen.portfolio.model.ledger.configuration.rule.LedgerProjectionRule; +import name.abuchen.portfolio.model.ledger.configuration.rule.LedgerRequirement; +import name.abuchen.portfolio.model.ledger.configuration.rule.LedgerRequirementGroup; + +/** + * Describes Ledger configuration for entries, postings, parameters, or native shapes. + * This metadata is used by Ledger validation and assembly infrastructure. It is not a + * normal transaction-editing API. + * + *

+ * This definition is not persisted as a standalone object. It describes how persisted entry + * type ids, posting type codes, parameter type codes, and projection roles are expected to fit + * together for validation and native entry assembly. + *

+ */ +public final class LedgerEntryDefinition +{ + private final LedgerEntryType entryType; + private final LedgerNativeEntryShape nativeShape; + private final Set postingRules; + private final Set entryParameterRules; + private final Set postingParameterRules; + private final Set projectionRules; + private final Set postingGroupRules; + private final Set alternativeRequirementGroups; + private final Set legDefinitions; + private final Set postingTypes; + private final Set entryParameterTypes; + private final Set postingParameterTypes; + private final Set projectionRoles; + private final LedgerReportingClass reportingClass; + private final LedgerPerformanceTreatment performanceTreatment; + private final Set downstreamResultsNotPersisted; + + private LedgerEntryDefinition(LedgerEntryType entryType, LedgerNativeEntryShape nativeShape, + Set postingRules, Set entryParameterRules, + Set postingParameterRules, Set projectionRules, + Set postingGroupRules, + Set alternativeRequirementGroups, + Set legDefinitions, + LedgerReportingClass reportingClass, LedgerPerformanceTreatment performanceTreatment, + Set downstreamResultsNotPersisted) + { + this.entryType = Objects.requireNonNull(entryType); + this.nativeShape = Objects.requireNonNull(nativeShape); + this.postingRules = copyRuleSet(postingRules); + this.entryParameterRules = copyRuleSet(entryParameterRules); + this.postingParameterRules = copyRuleSet(postingParameterRules); + this.projectionRules = copyRuleSet(projectionRules); + this.postingGroupRules = copyRuleSet(postingGroupRules); + this.alternativeRequirementGroups = copyRuleSet(alternativeRequirementGroups); + this.legDefinitions = copyLegDefinitions(legDefinitions); + this.postingTypes = postingTypesFrom(this.postingRules); + this.entryParameterTypes = parameterTypesFrom(this.entryParameterRules); + this.postingParameterTypes = postingParameterTypesFrom(this.postingParameterRules, this.postingRules); + this.projectionRoles = projectionRolesFrom(this.projectionRules); + this.reportingClass = Objects.requireNonNull(reportingClass); + this.performanceTreatment = Objects.requireNonNull(performanceTreatment); + this.downstreamResultsNotPersisted = copyOf(LedgerDownstreamResult.class, downstreamResultsNotPersisted); + } + + static LedgerEntryDefinition of(LedgerEntryType entryType, LedgerNativeEntryShape nativeShape, + Set postingTypes, Set entryParameterTypes, + Set postingParameterTypes, Set projectionRoles, + LedgerReportingClass reportingClass, LedgerPerformanceTreatment performanceTreatment, + Set downstreamResultsNotPersisted) + { + return new LedgerEntryDefinition(entryType, nativeShape, optionalPostingRules(postingTypes), + optionalParameterRules(entryParameterTypes), optionalParameterRules(postingParameterTypes), + optionalProjectionRules(projectionRoles), Set.of(), Set.of(), Set.of(), reportingClass, + performanceTreatment, downstreamResultsNotPersisted); + } + + static LedgerEntryDefinition of(LedgerEntryType entryType, LedgerNativeEntryShape nativeShape, + Set postingRules, Set entryParameterRules, + Set postingParameterRules, Set projectionRules, + Set postingGroupRules, + Set alternativeRequirementGroups, LedgerReportingClass reportingClass, + LedgerPerformanceTreatment performanceTreatment, + Set downstreamResultsNotPersisted) + { + return new LedgerEntryDefinition(entryType, nativeShape, postingRules, entryParameterRules, + postingParameterRules, projectionRules, postingGroupRules, alternativeRequirementGroups, Set.of(), + reportingClass, performanceTreatment, downstreamResultsNotPersisted); + } + + static LedgerEntryDefinition of(LedgerEntryType entryType, LedgerNativeEntryShape nativeShape, + Set postingRules, Set entryParameterRules, + Set postingParameterRules, Set projectionRules, + Set postingGroupRules, + Set alternativeRequirementGroups, Set legDefinitions, + LedgerReportingClass reportingClass, LedgerPerformanceTreatment performanceTreatment, + Set downstreamResultsNotPersisted) + { + return new LedgerEntryDefinition(entryType, nativeShape, postingRules, entryParameterRules, + postingParameterRules, projectionRules, postingGroupRules, alternativeRequirementGroups, + legDefinitions, reportingClass, performanceTreatment, downstreamResultsNotPersisted); + } + + public LedgerEntryType getEntryType() + { + return entryType; + } + + public LedgerNativeEntryShape getNativeShape() + { + return nativeShape; + } + + public Set getPostingTypes() + { + return postingTypes; + } + + public Set getPostingRules() + { + return postingRules; + } + + public Set getRequiredPostingRules() + { + return filterPostingRules(postingRules, LedgerRequirement.REQUIRED); + } + + public Set getOptionalPostingRules() + { + return filterPostingRules(postingRules, LedgerRequirement.OPTIONAL); + } + + public Set getEntryParameterTypes() + { + return entryParameterTypes; + } + + public Set getEntryParameterRules() + { + return entryParameterRules; + } + + public Set getRequiredEntryParameterRules() + { + return filterParameterRules(entryParameterRules, LedgerRequirement.REQUIRED); + } + + public Set getOptionalEntryParameterRules() + { + return filterParameterRules(entryParameterRules, LedgerRequirement.OPTIONAL); + } + + public Set getPostingParameterTypes() + { + return postingParameterTypes; + } + + public Set getPostingParameterRules() + { + return postingParameterRules; + } + + public Set getRequiredPostingParameterRules() + { + return filterParameterRules(postingParameterRules, LedgerRequirement.REQUIRED); + } + + public Set getOptionalPostingParameterRules() + { + return filterParameterRules(postingParameterRules, LedgerRequirement.OPTIONAL); + } + + public Set getRepeatableParameterTypes() + { + var values = EnumSet.noneOf(LedgerParameterType.class); + + for (var rule : entryParameterRules) + if (rule.isRepeatable()) + values.add(rule.getParameterType()); + + for (var rule : postingParameterRules) + if (rule.isRepeatable()) + values.add(rule.getParameterType()); + + for (var rule : postingRules) + values.addAll(rule.getRepeatableParameterTypes()); + + return Collections.unmodifiableSet(values); + } + + public Set getProjectionRoles() + { + return projectionRoles; + } + + public Set getProjectionRules() + { + return projectionRules; + } + + public Set getRequiredProjectionRules() + { + return filterProjectionRules(projectionRules, LedgerRequirement.REQUIRED); + } + + public Set getOptionalProjectionRules() + { + return filterProjectionRules(projectionRules, LedgerRequirement.OPTIONAL); + } + + public Set getPostingGroupRules() + { + return postingGroupRules; + } + + public Set getAlternativeRequirementGroups() + { + return alternativeRequirementGroups; + } + + public Set getLegDefinitions() + { + return legDefinitions; + } + + public Optional getLegDefinition(LedgerLegRole role) + { + return legDefinitions.stream().filter(definition -> definition.getRole() == role).findFirst(); + } + + public LedgerReportingClass getReportingClass() + { + return reportingClass; + } + + public LedgerPerformanceTreatment getPerformanceTreatment() + { + return performanceTreatment; + } + + public Set getDownstreamResultsNotPersisted() + { + return downstreamResultsNotPersisted; + } + + private static > Set copyOf(Class type, Set values) + { + Objects.requireNonNull(values); + + var copy = EnumSet.noneOf(type); + + for (var value : values) + copy.add(Objects.requireNonNull(value)); + + return Collections.unmodifiableSet(copy); + } + + private static Set copyRuleSet(Set values) + { + Objects.requireNonNull(values); + + var copy = new LinkedHashSet(); + + for (var value : values) + copy.add(Objects.requireNonNull(value)); + + return Collections.unmodifiableSet(copy); + } + + private static Set copyLegDefinitions(Set values) + { + Objects.requireNonNull(values); + + var roles = EnumSet.noneOf(LedgerLegRole.class); + var copy = new LinkedHashSet(); + + for (var value : values) + { + var definition = Objects.requireNonNull(value); + + if (!roles.add(definition.getRole())) + throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_CORE_015 + .message("Duplicate Ledger leg role: " + definition.getRole())); //$NON-NLS-1$ + + copy.add(definition); + } + + return Collections.unmodifiableSet(copy); + } + + private static Set postingTypesFrom(Set rules) + { + var values = EnumSet.noneOf(LedgerPostingType.class); + + for (var rule : rules) + values.add(rule.getPostingType()); + + return Collections.unmodifiableSet(values); + } + + private static Set parameterTypesFrom(Set rules) + { + var values = EnumSet.noneOf(LedgerParameterType.class); + + for (var rule : rules) + values.add(rule.getParameterType()); + + return Collections.unmodifiableSet(values); + } + + private static Set postingParameterTypesFrom(Set parameterRules, + Set postingRules) + { + var values = EnumSet.noneOf(LedgerParameterType.class); + values.addAll(parameterTypesFrom(parameterRules)); + + for (var rule : postingRules) + { + values.addAll(rule.getRequiredParameterTypes()); + values.addAll(rule.getOptionalParameterTypes()); + } + + return Collections.unmodifiableSet(values); + } + + private static Set projectionRolesFrom(Set rules) + { + var values = EnumSet.noneOf(LedgerProjectionRole.class); + + for (var rule : rules) + values.add(rule.getRole()); + + return Collections.unmodifiableSet(values); + } + + private static Set optionalPostingRules(Set postingTypes) + { + Objects.requireNonNull(postingTypes); + + var rules = new LinkedHashSet(); + + for (var postingType : postingTypes) + rules.add(LedgerPostingRule.optional(postingType, EnumSet.noneOf(LedgerParameterType.class), + EnumSet.noneOf(LedgerParameterType.class))); + + return Collections.unmodifiableSet(rules); + } + + private static Set optionalParameterRules(Set parameterTypes) + { + Objects.requireNonNull(parameterTypes); + + var rules = new LinkedHashSet(); + + for (var parameterType : parameterTypes) + rules.add(LedgerParameterRule.optional(parameterType)); + + return Collections.unmodifiableSet(rules); + } + + private static Set optionalProjectionRules(Set projectionRoles) + { + Objects.requireNonNull(projectionRoles); + + var rules = new LinkedHashSet(); + + for (var projectionRole : projectionRoles) + rules.add(LedgerProjectionRule.optional(projectionRole, true, false)); + + return Collections.unmodifiableSet(rules); + } + + private static Set filterPostingRules(Set rules, + LedgerRequirement requirement) + { + var filtered = new LinkedHashSet(); + + for (var rule : rules) + { + if (rule.getRequirement() == requirement) + filtered.add(rule); + } + + return Collections.unmodifiableSet(filtered); + } + + private static Set filterParameterRules(Set rules, + LedgerRequirement requirement) + { + var filtered = new LinkedHashSet(); + + for (var rule : rules) + { + if (rule.getRequirement() == requirement) + filtered.add(rule); + } + + return Collections.unmodifiableSet(filtered); + } + + private static Set filterProjectionRules(Set rules, + LedgerRequirement requirement) + { + var filtered = new LinkedHashSet(); + + for (var rule : rules) + { + if (rule.getRequirement() == requirement) + filtered.add(rule); + } + + return Collections.unmodifiableSet(filtered); + } +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/LedgerEntryDefinitionRegistry.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/LedgerEntryDefinitionRegistry.java new file mode 100644 index 0000000000..7e251caeac --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/LedgerEntryDefinitionRegistry.java @@ -0,0 +1,707 @@ +package name.abuchen.portfolio.model.ledger.configuration; + +import java.util.Collection; +import java.util.Collections; +import java.util.EnumMap; +import java.util.EnumSet; +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +import name.abuchen.portfolio.model.LedgerDiagnosticCode; +import name.abuchen.portfolio.model.ledger.LedgerEntry; +import name.abuchen.portfolio.model.ledger.LedgerProjectionRole; +import name.abuchen.portfolio.model.ledger.configuration.rule.LedgerParameterRule; +import name.abuchen.portfolio.model.ledger.configuration.rule.LedgerPostingGroupRule; +import name.abuchen.portfolio.model.ledger.configuration.rule.LedgerPostingRule; +import name.abuchen.portfolio.model.ledger.configuration.rule.LedgerProjectionRule; +import name.abuchen.portfolio.model.ledger.configuration.rule.LedgerRequirement; +import name.abuchen.portfolio.model.ledger.configuration.rule.LedgerRequirementGroup; + +/** + * Registers Ledger configuration definitions used by validation and assembly. + * This is configuration infrastructure. Normal transaction-editing code should not update + * the registry directly. + * + *

+ * The registry is static Java configuration. It is not persisted in XML or Protobuf; files + * store the resulting Ledger entries, postings, parameters, and semantic projection facts. + *

+ */ +public final class LedgerEntryDefinitionRegistry +{ + private static final String CASH_COMPENSATION_GROUP = "CASH_COMPENSATION_GROUP"; //$NON-NLS-1$ + + private static final SetBuilder SETS = new SetBuilder(); + + private static final Map DEFINITIONS = definitions(); + private static final Map CORPORATE_ACTION_DEFINITIONS = corporateActionDefinitions(); + + private LedgerEntryDefinitionRegistry() + { + } + + public static Optional lookup(LedgerEntryType entryType) + { + if (entryType == LedgerEntryType.CORPORATE_ACTION) + return lookup(entryType, CorporateActionKind.SPIN_OFF); + + return Optional.ofNullable(DEFINITIONS.get(entryType)); + } + + public static Optional lookup(LedgerEntry entry) + { + if (entry == null || entry.getType() == null) + return Optional.empty(); + + if (entry.getType() == LedgerEntryType.CORPORATE_ACTION) + return CorporateActionKind.fromEntry(entry).flatMap(kind -> lookup(entry.getType(), kind)); + + return lookup(entry.getType()); + } + + public static Optional lookup(LedgerEntryType entryType, CorporateActionKind kind) + { + if (entryType != LedgerEntryType.CORPORATE_ACTION) + return lookup(entryType); + + if (kind == null) + return Optional.empty(); + + return Optional.ofNullable(CORPORATE_ACTION_DEFINITIONS.get(kind)); + } + + public static Collection getDefinitions() + { + var definitions = new ArrayList(); + definitions.addAll(DEFINITIONS.values()); + definitions.addAll(CORPORATE_ACTION_DEFINITIONS.values()); + return Collections.unmodifiableList(definitions); + } + + public static boolean hasDefinition(LedgerEntryType entryType) + { + if (entryType == LedgerEntryType.CORPORATE_ACTION) + return !CORPORATE_ACTION_DEFINITIONS.isEmpty(); + + return DEFINITIONS.containsKey(entryType); + } + + private static Map definitions() + { + var definitions = new EnumMap(LedgerEntryType.class); + + return Collections.unmodifiableMap(definitions); + } + + private static Map corporateActionDefinitions() + { + var definitions = new EnumMap(CorporateActionKind.class); + + register(definitions, CorporateActionKind.STOCK_DIVIDEND, stockDividend()); + register(definitions, CorporateActionKind.SPIN_OFF, spinOff()); /*-?|Andreas|asbn|c16|?*/ + register(definitions, CorporateActionKind.BONUS_ISSUE, bonusIssue()); + register(definitions, CorporateActionKind.RIGHTS_DISTRIBUTION, rightsDistribution()); + register(definitions, CorporateActionKind.COUPON_PAYMENT, couponPayment()); + register(definitions, CorporateActionKind.PIK_INTEREST, pikInterest()); + register(definitions, CorporateActionKind.MATURITY, maturity()); + register(definitions, CorporateActionKind.PARTIAL_REDEMPTION, partialRedemption()); + register(definitions, CorporateActionKind.CALL, call()); + register(definitions, CorporateActionKind.PUT, put()); + register(definitions, CorporateActionKind.CONVERSION, conversion()); + register(definitions, CorporateActionKind.EXCHANGE, exchange()); + + return Collections.unmodifiableMap(definitions); + } + + private static void register(Map definitions, + LedgerEntryDefinition definition) + { + if (definitions.put(definition.getEntryType(), definition) != null) + throw new IllegalStateException(LedgerDiagnosticCode.LEDGER_CORE_016 + .message("Duplicate Ledger entry definition: " + definition.getEntryType())); //$NON-NLS-1$ + } + + private static void register(Map definitions, + CorporateActionKind kind, LedgerEntryDefinition definition) + { + if (definitions.put(kind, definition) != null) + throw new IllegalStateException(LedgerDiagnosticCode.LEDGER_CORE_016 + .message("Duplicate Ledger corporate action definition: " + kind)); //$NON-NLS-1$ + } + + private static LedgerEntryDefinition spinOff() + { + return LedgerEntryDefinition.of(LedgerEntryType.CORPORATE_ACTION, + LedgerNativeEntryShape.DUAL_INSTRUMENT_PLUS_ACCOUNT, + SETS.postingRules( + optionalPosting(LedgerPostingType.SECURITY, requiredSecurityLegParameters(), + spinOffSecurityOptionalParameters()), + optionalPosting(LedgerPostingType.CASH_COMPENSATION, SETS.parameterTypes(), + cashCompensationOptionalParameters()), + optionalPosting(LedgerPostingType.FEE, SETS.parameterTypes(), + feeOptionalParameters()), + optionalPosting(LedgerPostingType.TAX, SETS.parameterTypes(), + taxOptionalParameters()), + optionalPosting(LedgerPostingType.FOREX, SETS.parameterTypes(), + forexOptionalParameters())), + SETS.parameterRules(requiredEntryParameter(LedgerParameterType.CORPORATE_ACTION_KIND), + optionalEntryParameter(LedgerParameterType.EX_DATE), + optionalEntryParameter(LedgerParameterType.CORPORATE_ACTION_SUBTYPE), + optionalEntryParameter(LedgerParameterType.EVENT_REFERENCE), + optionalEntryParameter(LedgerParameterType.EVENT_STAGE), + optionalEntryParameter(LedgerParameterType.RECORD_DATE), + optionalEntryParameter(LedgerParameterType.PAYMENT_DATE), + optionalEntryParameter(LedgerParameterType.EFFECTIVE_DATE), + optionalEntryParameter(LedgerParameterType.SETTLEMENT_DATE)), + SETS.parameterRules(repeatableRequiredPostingParameter(LedgerParameterType.CORPORATE_ACTION_LEG), + repeatableRequiredPostingParameter(LedgerParameterType.SOURCE_SECURITY), + repeatableRequiredPostingParameter(LedgerParameterType.TARGET_SECURITY), + repeatableRequiredPostingParameter(LedgerParameterType.RATIO_NUMERATOR), + repeatableRequiredPostingParameter(LedgerParameterType.RATIO_DENOMINATOR), + repeatableOptionalPostingParameter(LedgerParameterType.CASH_COMPENSATION_KIND), + repeatableOptionalPostingParameter(LedgerParameterType.CASH_IN_LIEU_AMOUNT), + repeatableOptionalPostingParameter(LedgerParameterType.CASH_IN_LIEU_APPLIED), + repeatableOptionalPostingParameter(LedgerParameterType.FRACTION_QUANTITY), + repeatableOptionalPostingParameter(LedgerParameterType.FRACTION_TREATMENT), + repeatableOptionalPostingParameter(LedgerParameterType.ROUNDING_MODE), + repeatableOptionalPostingParameter(LedgerParameterType.COST_ALLOCATION_METHOD), + repeatableOptionalPostingParameter(LedgerParameterType.SOURCE_COST_PERCENT), + repeatableOptionalPostingParameter(LedgerParameterType.TARGET_COST_PERCENT), + repeatableOptionalPostingParameter(LedgerParameterType.REFERENCE_PRICE), + repeatableOptionalPostingParameter(LedgerParameterType.FAIR_MARKET_VALUE), + repeatableOptionalPostingParameter(LedgerParameterType.VALUATION_PRICE), + repeatableOptionalPostingParameter(LedgerParameterType.FEE_REASON), + repeatableOptionalPostingParameter(LedgerParameterType.TAX_REASON), + repeatableOptionalPostingParameter(LedgerParameterType.TAXABLE_DISTRIBUTION), + repeatableOptionalPostingParameter(LedgerParameterType.WITHHOLDING_TAX), + repeatableOptionalPostingParameter(LedgerParameterType.RECLAIMABLE_TAX), + repeatableOptionalPostingParameter(LedgerParameterType.MANUAL_VALUATION_OVERRIDE)), + SETS.projectionRules( + optionalProjection(LedgerProjectionRole.OLD_SECURITY_LEG, true, false), + optionalProjection(LedgerProjectionRole.NEW_SECURITY_LEG, true, false), + optionalProjection(LedgerProjectionRole.CASH_COMPENSATION, true, true), + optionalProjection(LedgerProjectionRole.DELIVERY_INBOUND, true, false)), + cashCompensationPostingGroupRules(), + SETS.alternativeGroups(dateAlternative("SPIN_OFF_DATE")), //$NON-NLS-1$ + spinOffLegDefinitions(), + LedgerReportingClass.SECURITIES_DISTRIBUTION, + LedgerPerformanceTreatment.COST_BASIS_REALLOCATION, downstreamResults()); + } + + private static LedgerEntryDefinition stockDividend() + { + return corporateActionDefinition( + SETS.legDefinitions(targetSecurityLeg(LedgerLegCardinality.AT_LEAST_ONE), + cashCompensationLeg(), feeLeg(), taxLeg(), forexLeg()), + LedgerReportingClass.SECURITIES_DISTRIBUTION, + LedgerPerformanceTreatment.SECURITY_DISTRIBUTION); + } + + private static LedgerEntryDefinition bonusIssue() + { + return corporateActionDefinition( + SETS.legDefinitions(targetSecurityLeg(LedgerLegCardinality.AT_LEAST_ONE), + cashCompensationLeg(), feeLeg(), taxLeg(), forexLeg()), + LedgerReportingClass.SECURITIES_DISTRIBUTION, + LedgerPerformanceTreatment.SECURITY_DISTRIBUTION); + } + + private static LedgerEntryDefinition rightsDistribution() + { + return corporateActionDefinition( + SETS.legDefinitions(distributedRightLeg(LedgerLegCardinality.AT_LEAST_ONE), + cashCompensationLeg(), feeLeg(), taxLeg(), forexLeg()), + LedgerReportingClass.RIGHTS_EVENT, + LedgerPerformanceTreatment.SECURITY_DISTRIBUTION); + } + + private static LedgerEntryDefinition couponPayment() + { + return corporateActionDefinition( + SETS.legDefinitions(cashLeg(LedgerLegCardinality.AT_LEAST_ONE), + accruedInterestLeg(LedgerLegCardinality.OPTIONAL), feeLeg(), taxLeg(), + forexLeg()), + LedgerReportingClass.FIXED_INCOME_COUPON, + LedgerPerformanceTreatment.INCOME_DISTRIBUTION); + } + + private static LedgerEntryDefinition pikInterest() + { + return corporateActionDefinition( + SETS.legDefinitions(targetSecurityLeg(LedgerLegCardinality.AT_LEAST_ONE), + cashCompensationLeg(), accruedInterestLeg(LedgerLegCardinality.OPTIONAL), + feeLeg(), taxLeg(), forexLeg()), + LedgerReportingClass.FIXED_INCOME_COUPON, + LedgerPerformanceTreatment.INCOME_DISTRIBUTION); + } + + private static LedgerEntryDefinition maturity() + { + return fixedIncomeRedemption(); + } + + private static LedgerEntryDefinition partialRedemption() + { + return fixedIncomeRedemption(); + } + + private static LedgerEntryDefinition call() + { + return fixedIncomeRedemption(); + } + + private static LedgerEntryDefinition put() + { + return fixedIncomeRedemption(); + } + + private static LedgerEntryDefinition fixedIncomeRedemption() + { + return corporateActionDefinition( + SETS.legDefinitions(sourceSecurityLeg(LedgerLegCardinality.AT_LEAST_ONE), + cashLeg(LedgerLegCardinality.AT_LEAST_ONE), + principalRedemptionLeg(LedgerLegCardinality.AT_LEAST_ONE), + accruedInterestLeg(LedgerLegCardinality.OPTIONAL), feeLeg(), taxLeg(), + forexLeg()), + LedgerReportingClass.PRINCIPAL_REDEMPTION, + LedgerPerformanceTreatment.PRINCIPAL_RETURN); + } + + private static LedgerEntryDefinition conversion() + { + return securityReorganization(); + } + + private static LedgerEntryDefinition exchange() + { + return securityReorganization(); + } + + private static LedgerEntryDefinition securityReorganization() + { + return corporateActionDefinition( + SETS.legDefinitions(sourceSecurityLeg(LedgerLegCardinality.AT_LEAST_ONE), + targetSecurityLeg(LedgerLegCardinality.AT_LEAST_ONE), + cashCompensationLeg(), + accruedInterestLeg(LedgerLegCardinality.OPTIONAL), feeLeg(), taxLeg(), + forexLeg()), + LedgerReportingClass.SECURITY_REORGANIZATION, + LedgerPerformanceTreatment.COST_BASIS_REALLOCATION); + } + + private static LedgerEntryDefinition corporateActionDefinition(Set legDefinitions, + LedgerReportingClass reportingClass, LedgerPerformanceTreatment performanceTreatment) + { + return LedgerEntryDefinition.of(LedgerEntryType.CORPORATE_ACTION, + LedgerNativeEntryShape.DUAL_INSTRUMENT_PLUS_ACCOUNT, + optionalPostingRulesFor(legDefinitions), + corporateActionEntryParameterRules(), + corporateActionPostingParameterRulesFor(legDefinitions), + Set.of(), + Set.of(), + Set.of(), + legDefinitions, + reportingClass, + performanceTreatment, + downstreamResults()); + } + + private static Set optionalPostingRulesFor(Set legDefinitions) + { + var postingTypes = EnumSet.noneOf(LedgerPostingType.class); + + for (var legDefinition : legDefinitions) + postingTypes.add(legDefinition.getPostingType()); + + var rules = new LinkedHashSet(); + for (var postingType : postingTypes) + rules.add(optionalPosting(postingType, SETS.parameterTypes(), + corporateActionPostingOptionalParametersFor(postingType))); + + return Collections.unmodifiableSet(rules); + } + + private static Set corporateActionEntryParameterRules() + { + return SETS.parameterRules(requiredEntryParameter(LedgerParameterType.CORPORATE_ACTION_KIND), + optionalEntryParameter(LedgerParameterType.EX_DATE), + optionalEntryParameter(LedgerParameterType.CORPORATE_ACTION_SUBTYPE), + optionalEntryParameter(LedgerParameterType.EVENT_REFERENCE), + optionalEntryParameter(LedgerParameterType.EVENT_STAGE), + optionalEntryParameter(LedgerParameterType.RECORD_DATE), + optionalEntryParameter(LedgerParameterType.PAYMENT_DATE), + optionalEntryParameter(LedgerParameterType.EFFECTIVE_DATE), + optionalEntryParameter(LedgerParameterType.SETTLEMENT_DATE)); + } + + private static Set corporateActionPostingParameterRulesFor( + Set legDefinitions) + { + var parameters = EnumSet.noneOf(LedgerParameterType.class); + + for (var legDefinition : legDefinitions) + { + parameters.addAll(legDefinition.getRequiredParameterTypes()); + parameters.addAll(legDefinition.getOptionalParameterTypes()); + parameters.addAll(corporateActionPostingOptionalParametersFor(legDefinition.getPostingType())); + } + + var rules = new LinkedHashSet(); + for (var parameter : parameters) + rules.add(repeatableOptionalPostingParameter(parameter)); + + return Collections.unmodifiableSet(rules); + } + + private static EnumSet corporateActionPostingOptionalParametersFor( + LedgerPostingType postingType) + { + return switch (postingType) + { + case SECURITY -> SETS.parameterTypes(LedgerParameterType.CORPORATE_ACTION_LEG, + LedgerParameterType.SOURCE_SECURITY, LedgerParameterType.TARGET_SECURITY, + LedgerParameterType.RATIO_NUMERATOR, LedgerParameterType.RATIO_DENOMINATOR, + LedgerParameterType.FRACTION_QUANTITY, LedgerParameterType.FRACTION_TREATMENT, + LedgerParameterType.ROUNDING_MODE, LedgerParameterType.COST_ALLOCATION_METHOD, + LedgerParameterType.SOURCE_COST_PERCENT, LedgerParameterType.TARGET_COST_PERCENT, + LedgerParameterType.REFERENCE_PRICE, LedgerParameterType.FAIR_MARKET_VALUE, + LedgerParameterType.VALUATION_PRICE, LedgerParameterType.MANUAL_VALUATION_OVERRIDE); + case RIGHT -> SETS.parameterTypes(LedgerParameterType.CORPORATE_ACTION_LEG, + LedgerParameterType.RIGHT_SECURITY, LedgerParameterType.SOURCE_SECURITY, + LedgerParameterType.SUBSCRIPTION_PRICE, LedgerParameterType.ELECTION_DEADLINE, + LedgerParameterType.FRACTION_QUANTITY, LedgerParameterType.FRACTION_TREATMENT, + LedgerParameterType.ROUNDING_MODE); + case CASH -> SETS.parameterTypes(LedgerParameterType.SOURCE_ACCOUNT, + LedgerParameterType.TARGET_ACCOUNT, LedgerParameterType.CASH_ACCOUNT, + LedgerParameterType.EVENT_REFERENCE, LedgerParameterType.PAYMENT_DATE, + LedgerParameterType.SETTLEMENT_DATE); + case CASH_COMPENSATION -> cashCompensationOptionalParameters(); + case FEE -> feeOptionalParameters(); + case TAX -> taxOptionalParameters(); + case FOREX -> forexOptionalParameters(); + case ACCRUED_INTEREST -> SETS.parameterTypes(LedgerParameterType.CORPORATE_ACTION_LEG, + LedgerParameterType.ACCRUED_INTEREST_AMOUNT, LedgerParameterType.COUPON_RATE, + LedgerParameterType.INTEREST_PERIOD_START, LedgerParameterType.INTEREST_PERIOD_END, + LedgerParameterType.PAYMENT_DATE, LedgerParameterType.SETTLEMENT_DATE, + LedgerParameterType.WITHHOLDING_TAX, LedgerParameterType.RECLAIMABLE_TAX, + LedgerParameterType.TAX_REASON); + case PRINCIPAL_REDEMPTION -> SETS.parameterTypes(LedgerParameterType.CORPORATE_ACTION_LEG, + LedgerParameterType.NOMINAL_VALUE, LedgerParameterType.PARTIAL_REDEMPTION_FACTOR, + LedgerParameterType.REDEMPTION_PRICE_PERCENT, LedgerParameterType.SOURCE_SECURITY, + LedgerParameterType.CASH_ACCOUNT, LedgerParameterType.PAYMENT_DATE, + LedgerParameterType.SETTLEMENT_DATE); + default -> SETS.parameterTypes(LedgerParameterType.CORPORATE_ACTION_LEG); + }; + } + + private static LedgerLegDefinition sourceSecurityLeg(LedgerLegCardinality cardinality) + { + return LedgerLegDefinition.of(LedgerLegRole.SOURCE_SECURITY_LEG, LedgerPostingType.SECURITY, + cardinality) + .requiredParameters(SETS.parameterTypes(LedgerParameterType.CORPORATE_ACTION_LEG, + LedgerParameterType.SOURCE_SECURITY)) + .optionalParameters(corporateActionPostingOptionalParametersFor(LedgerPostingType.SECURITY)) + .build(); + } + + private static LedgerLegDefinition targetSecurityLeg(LedgerLegCardinality cardinality) + { + return LedgerLegDefinition.of(LedgerLegRole.TARGET_SECURITY_LEG, LedgerPostingType.SECURITY, + cardinality) + .requiredParameters(SETS.parameterTypes(LedgerParameterType.CORPORATE_ACTION_LEG, + LedgerParameterType.TARGET_SECURITY)) + .optionalParameters(corporateActionPostingOptionalParametersFor(LedgerPostingType.SECURITY)) + .build(); + } + + private static LedgerLegDefinition distributedRightLeg(LedgerLegCardinality cardinality) + { + return LedgerLegDefinition.of(LedgerLegRole.DISTRIBUTED_RIGHT_LEG, LedgerPostingType.RIGHT, + cardinality) + .requiredParameters(SETS.parameterTypes(LedgerParameterType.CORPORATE_ACTION_LEG, + LedgerParameterType.RIGHT_SECURITY)) + .optionalParameters(corporateActionPostingOptionalParametersFor(LedgerPostingType.RIGHT)) + .build(); + } + + private static LedgerLegDefinition cashLeg(LedgerLegCardinality cardinality) + { + return LedgerLegDefinition.of(LedgerLegRole.CASH_LEG, LedgerPostingType.CASH, cardinality) + .optionalParameters(corporateActionPostingOptionalParametersFor(LedgerPostingType.CASH)) + .build(); + } + + private static LedgerLegDefinition cashCompensationLeg() + { + return LedgerLegDefinition.of(LedgerLegRole.CASH_COMPENSATION_LEG, + LedgerPostingType.CASH_COMPENSATION, LedgerLegCardinality.REPEATABLE) + .optionalParameters(cashCompensationOptionalParameters()).build(); + } + + private static LedgerLegDefinition accruedInterestLeg(LedgerLegCardinality cardinality) + { + return LedgerLegDefinition.of(LedgerLegRole.ACCRUED_INTEREST_LEG, + LedgerPostingType.ACCRUED_INTEREST, cardinality) + .optionalParameters(corporateActionPostingOptionalParametersFor( + LedgerPostingType.ACCRUED_INTEREST)) + .build(); + } + + private static LedgerLegDefinition principalRedemptionLeg(LedgerLegCardinality cardinality) + { + return LedgerLegDefinition.of(LedgerLegRole.PRINCIPAL_REDEMPTION_LEG, + LedgerPostingType.PRINCIPAL_REDEMPTION, cardinality) + .requiredParameters(SETS.parameterTypes(LedgerParameterType.CORPORATE_ACTION_LEG)) + .optionalParameters(corporateActionPostingOptionalParametersFor( + LedgerPostingType.PRINCIPAL_REDEMPTION)) + .build(); + } + + private static LedgerLegDefinition feeLeg() + { + return LedgerLegDefinition.of(LedgerLegRole.FEE_LEG, LedgerPostingType.FEE, + LedgerLegCardinality.REPEATABLE) + .optionalParameters(feeOptionalParameters()).build(); + } + + private static LedgerLegDefinition taxLeg() + { + return LedgerLegDefinition.of(LedgerLegRole.TAX_LEG, LedgerPostingType.TAX, + LedgerLegCardinality.REPEATABLE) + .optionalParameters(taxOptionalParameters()).build(); + } + + private static LedgerLegDefinition forexLeg() + { + return LedgerLegDefinition.of(LedgerLegRole.FOREX_CONTEXT_LEG, LedgerPostingType.FOREX, + LedgerLegCardinality.OPTIONAL) + .optionalParameters(forexOptionalParameters()).build(); + } + + private static LedgerPostingRule optionalPosting(LedgerPostingType postingType, + EnumSet requiredParameterTypes, + EnumSet optionalParameterTypes) + { + return LedgerPostingRule.optional(postingType, requiredParameterTypes, optionalParameterTypes); + } + + private static LedgerParameterRule requiredEntryParameter(LedgerParameterType parameterType) + { + return LedgerParameterRule.required(parameterType); + } + + private static LedgerParameterRule optionalEntryParameter(LedgerParameterType parameterType) + { + return LedgerParameterRule.optional(parameterType); + } + + private static LedgerParameterRule repeatableRequiredPostingParameter(LedgerParameterType parameterType) + { + return LedgerParameterRule.repeatable(parameterType, LedgerRequirement.REQUIRED); + } + + private static LedgerParameterRule repeatableOptionalPostingParameter(LedgerParameterType parameterType) + { + return LedgerParameterRule.repeatable(parameterType, LedgerRequirement.OPTIONAL); + } + + private static LedgerProjectionRule optionalProjection(LedgerProjectionRole role, boolean primaryPostingExpected, + boolean postingGroupExpected) + { + return LedgerProjectionRule.optional(role, primaryPostingExpected, postingGroupExpected); + } + + private static LedgerRequirementGroup dateAlternative(String name) + { + return LedgerRequirementGroup.parameterTypes(name, LedgerRequirement.REQUIRED, + SETS.parameterTypes(LedgerParameterType.EX_DATE, LedgerParameterType.EFFECTIVE_DATE)); + } + + private static EnumSet requiredSecurityLegParameters() + { + return SETS.parameterTypes(LedgerParameterType.CORPORATE_ACTION_LEG, + LedgerParameterType.SOURCE_SECURITY, LedgerParameterType.TARGET_SECURITY, + LedgerParameterType.RATIO_NUMERATOR, LedgerParameterType.RATIO_DENOMINATOR); + } + + private static Set spinOffLegDefinitions() /*-?|Andreas|asbn|c14|?*/ + { + return SETS.legDefinitions( + LedgerLegDefinition.of(LedgerLegRole.SOURCE_SECURITY_LEG, LedgerPostingType.SECURITY, + LedgerLegCardinality.REPEATABLE) + .requiredParameters(SETS.parameterTypes( /*-?|Andreas|asbn|c15|?*/ + LedgerParameterType.CORPORATE_ACTION_LEG, + LedgerParameterType.SOURCE_SECURITY, + LedgerParameterType.RATIO_NUMERATOR, + LedgerParameterType.RATIO_DENOMINATOR)) + .optionalParameters(spinOffSourceSecurityLegOptionalParameters()) + .projection(LedgerProjectionRole.OLD_SECURITY_LEG, true, false).build(), + LedgerLegDefinition.of(LedgerLegRole.TARGET_SECURITY_LEG, LedgerPostingType.SECURITY, + LedgerLegCardinality.REPEATABLE) + .requiredParameters(SETS.parameterTypes( + LedgerParameterType.CORPORATE_ACTION_LEG, + LedgerParameterType.TARGET_SECURITY, + LedgerParameterType.RATIO_NUMERATOR, + LedgerParameterType.RATIO_DENOMINATOR)) + .optionalParameters(spinOffTargetSecurityLegOptionalParameters()) + .projection(LedgerProjectionRole.NEW_SECURITY_LEG, true, false).build(), + LedgerLegDefinition.of(LedgerLegRole.CASH_COMPENSATION_LEG, + LedgerPostingType.CASH_COMPENSATION, LedgerLegCardinality.REPEATABLE) + .optionalParameters(cashCompensationOptionalParameters()) + .projection(LedgerProjectionRole.CASH_COMPENSATION, true, true) + .group(CASH_COMPENSATION_GROUP).build(), + LedgerLegDefinition.of(LedgerLegRole.FEE_LEG, LedgerPostingType.FEE, + LedgerLegCardinality.REPEATABLE) + .optionalParameters(feeOptionalParameters()) + .group(CASH_COMPENSATION_GROUP).build(), + LedgerLegDefinition.of(LedgerLegRole.TAX_LEG, LedgerPostingType.TAX, + LedgerLegCardinality.REPEATABLE) + .optionalParameters(taxOptionalParameters()) + .group(CASH_COMPENSATION_GROUP).build(), + LedgerLegDefinition.of(LedgerLegRole.FOREX_CONTEXT_LEG, LedgerPostingType.FOREX, + LedgerLegCardinality.OPTIONAL) + .optionalParameters(forexOptionalParameters()).build()); + } + + private static EnumSet spinOffSourceSecurityLegOptionalParameters() + { + var parameters = spinOffSecurityOptionalParameters(); + parameters.add(LedgerParameterType.TARGET_SECURITY); + return parameters; + } + + private static EnumSet spinOffTargetSecurityLegOptionalParameters() + { + var parameters = spinOffSecurityOptionalParameters(); + parameters.add(LedgerParameterType.SOURCE_SECURITY); + return parameters; + } + + private static EnumSet spinOffSecurityOptionalParameters() + { + return SETS.parameterTypes(LedgerParameterType.FRACTION_QUANTITY, + LedgerParameterType.FRACTION_TREATMENT, LedgerParameterType.ROUNDING_MODE, + LedgerParameterType.COST_ALLOCATION_METHOD, LedgerParameterType.SOURCE_COST_PERCENT, + LedgerParameterType.TARGET_COST_PERCENT, LedgerParameterType.REFERENCE_PRICE, + LedgerParameterType.FAIR_MARKET_VALUE, LedgerParameterType.VALUATION_PRICE, + LedgerParameterType.MANUAL_VALUATION_OVERRIDE); + } + + private static EnumSet cashCompensationOptionalParameters() + { + return SETS.parameterTypes(LedgerParameterType.CASH_ACCOUNT, + LedgerParameterType.CASH_COMPENSATION_KIND, LedgerParameterType.CASH_IN_LIEU_AMOUNT, + LedgerParameterType.CASH_IN_LIEU_APPLIED, LedgerParameterType.FRACTION_QUANTITY, + LedgerParameterType.FRACTION_TREATMENT, LedgerParameterType.ROUNDING_MODE, + LedgerParameterType.PAYMENT_DATE, LedgerParameterType.SETTLEMENT_DATE, + LedgerParameterType.FEE_REASON, LedgerParameterType.TAX_REASON, + LedgerParameterType.CORPORATE_ACTION_LEG); + } + + private static EnumSet feeOptionalParameters() + { + return SETS.parameterTypes(LedgerParameterType.FEE_REASON, LedgerParameterType.STAMP_DUTY, + LedgerParameterType.EVENT_REFERENCE, LedgerParameterType.PAYMENT_DATE, + LedgerParameterType.SETTLEMENT_DATE, LedgerParameterType.CORPORATE_ACTION_LEG); + } + + private static EnumSet taxOptionalParameters() + { + return SETS.parameterTypes(LedgerParameterType.TAX_REASON, + LedgerParameterType.TAXABLE_DISTRIBUTION, LedgerParameterType.WITHHOLDING_TAX, + LedgerParameterType.TRANSACTION_TAX, LedgerParameterType.STAMP_DUTY, + LedgerParameterType.RECLAIMABLE_TAX, LedgerParameterType.EVENT_REFERENCE, + LedgerParameterType.PAYMENT_DATE, LedgerParameterType.SETTLEMENT_DATE, + LedgerParameterType.CORPORATE_ACTION_LEG); + } + + private static EnumSet forexOptionalParameters() + { + return SETS.parameterTypes(LedgerParameterType.REFERENCE_PRICE, + LedgerParameterType.VALUATION_PRICE, LedgerParameterType.EVENT_REFERENCE); + } + + private static Set cashCompensationPostingGroupRules() + { + return SETS.postingGroupRules(LedgerPostingGroupRule.of(CASH_COMPENSATION_GROUP, + LedgerRequirement.OPTIONAL, + SETS.postingTypes(LedgerPostingType.CASH_COMPENSATION, LedgerPostingType.FEE, + LedgerPostingType.TAX), + SETS.projectionRoles(LedgerProjectionRole.CASH_COMPENSATION), true)); + } + + private static EnumSet downstreamResults() + { + return EnumSet.allOf(LedgerDownstreamResult.class); + } + + private static final class SetBuilder + { + private EnumSet postingTypes(LedgerPostingType first, LedgerPostingType... rest) + { + return EnumSet.of(first, rest); + } + + private EnumSet parameterTypes(LedgerParameterType... values) + { + var set = EnumSet.noneOf(LedgerParameterType.class); + + for (var value : values) + set.add(value); + + return set; + } + + private EnumSet projectionRoles(LedgerProjectionRole first, LedgerProjectionRole... rest) + { + return EnumSet.of(first, rest); + } + + private Set postingRules(LedgerPostingRule first, LedgerPostingRule... rest) + { + return setOf(first, rest); + } + + private Set parameterRules(LedgerParameterRule first, LedgerParameterRule... rest) + { + return setOf(first, rest); + } + + private Set projectionRules(LedgerProjectionRule first, LedgerProjectionRule... rest) + { + return setOf(first, rest); + } + + private Set postingGroupRules(LedgerPostingGroupRule first, + LedgerPostingGroupRule... rest) + { + return setOf(first, rest); + } + + private Set alternativeGroups(LedgerRequirementGroup first, + LedgerRequirementGroup... rest) + { + return setOf(first, rest); + } + + private Set legDefinitions(LedgerLegDefinition first, LedgerLegDefinition... rest) + { + return setOf(first, rest); + } + + @SafeVarargs + private final Set setOf(T first, T... rest) + { + var set = new LinkedHashSet(); + set.add(first); + + for (var value : rest) + set.add(value); + + return Collections.unmodifiableSet(set); + } + } +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/LedgerEntryType.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/LedgerEntryType.java new file mode 100644 index 0000000000..64bdbbfc43 --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/LedgerEntryType.java @@ -0,0 +1,108 @@ +package name.abuchen.portfolio.model.ledger.configuration; + +import java.util.HashSet; +import java.util.Objects; + +import name.abuchen.portfolio.model.LedgerDiagnosticCode; + +/** + * Defines stable Ledger type codes used by persistence and validation. This is Ledger configuration metadata; normal + * transaction-editing code should use higher-level write paths. + */ +@SuppressWarnings("nls") +public enum LedgerEntryType +{ + DEPOSIT("DEPOSIT", Shape.LEGACY_FIXED), + REMOVAL("REMOVAL", Shape.LEGACY_FIXED), + INTEREST("INTEREST", Shape.LEGACY_FIXED), + INTEREST_CHARGE("INTEREST_CHARGE", Shape.LEGACY_FIXED), + FEES("FEES", Shape.LEGACY_FIXED), + FEES_REFUND("FEES_REFUND", Shape.LEGACY_FIXED), + TAXES("TAXES", Shape.LEGACY_FIXED), + TAX_REFUND("TAX_REFUND", Shape.LEGACY_FIXED), + DIVIDENDS("DIVIDENDS", Shape.LEGACY_FIXED), + BUY("BUY", Shape.LEGACY_FIXED), + SELL("SELL", Shape.LEGACY_FIXED), + CASH_TRANSFER("CASH_TRANSFER", Shape.LEGACY_FIXED), + SECURITY_TRANSFER("SECURITY_TRANSFER", Shape.LEGACY_FIXED), + DELIVERY_INBOUND("DELIVERY_INBOUND", Shape.LEGACY_FIXED), + DELIVERY_OUTBOUND("DELIVERY_OUTBOUND", Shape.LEGACY_FIXED), + CORPORATE_ACTION("CORPORATE_ACTION", Shape.LEDGER_NATIVE_TARGETED); + + private enum Shape /*-?|Andreas|asbn|c1|*/ + { + LEGACY_FIXED, + LEDGER_NATIVE_TARGETED + } /*-|Andreas|asbn|c1|?*/ + + private final String code; + + private final Shape shape; + + static + { + var codes = new HashSet(); + + for (LedgerEntryType type : values()) + { + if (type.code.isBlank()) + throw new IllegalStateException( + LedgerDiagnosticCode.LEDGER_CORE_017.message("Blank LedgerEntryType code")); + + if (!codes.add(type.code)) + throw new IllegalStateException( + LedgerDiagnosticCode.LEDGER_CORE_017.message("Duplicate LedgerEntryType code: " + + type.code)); + } + } + + private LedgerEntryType(String code, Shape shape) + { + this.code = Objects.requireNonNull(code); + this.shape = Objects.requireNonNull(shape); + } + + public String getCode() + { + return code; + } + + public static LedgerEntryType fromCode(String code) + { + if (code == null || code.isBlank()) + throw new IllegalArgumentException( + LedgerDiagnosticCode.LEDGER_CORE_017.message("Missing LedgerEntryType code")); + + for (LedgerEntryType type : values()) + if (type.code.equals(code)) + return type; + + throw new IllegalArgumentException( + LedgerDiagnosticCode.LEDGER_CORE_017.message("Unknown LedgerEntryType code: " + code)); + } + + public boolean isLegacyFixedShape() + { + return shape == Shape.LEGACY_FIXED; + } + + public boolean isLedgerNativeTargeted() + { + return shape == Shape.LEDGER_NATIVE_TARGETED; + } + + public boolean requiresTargetedDerivedDescriptors() + { + return isLedgerNativeTargeted(); + } + + public boolean supportsDerivedDescriptors() + { + return shape == Shape.LEGACY_FIXED || shape == Shape.LEDGER_NATIVE_TARGETED; + } + + public boolean usesSignedTargetedProjectionFacts() + { + return shape == Shape.LEDGER_NATIVE_TARGETED; + } +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/LedgerEventParameterDefinition.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/LedgerEventParameterDefinition.java new file mode 100644 index 0000000000..1868bffe53 --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/LedgerEventParameterDefinition.java @@ -0,0 +1,40 @@ +package name.abuchen.portfolio.model.ledger.configuration; + +import java.util.Collections; +import java.util.EnumSet; +import java.util.Set; + +/** + * Describes Ledger configuration for entries, postings, parameters, or native shapes. + * This metadata is used by Ledger validation and assembly infrastructure. It is not a + * normal transaction-editing API. + * + *

+ * This class is runtime-only configuration. It lists parameter types that may describe an + * event; the persisted file stores concrete {@code LedgerParameter} values instead. + *

+ */ +public final class LedgerEventParameterDefinition +{ + private static final Set PARAMETER_TYPES = Collections + .unmodifiableSet(EnumSet.of(LedgerParameterType.CORPORATE_ACTION_KIND, + LedgerParameterType.CORPORATE_ACTION_SUBTYPE, + LedgerParameterType.EVENT_REFERENCE, LedgerParameterType.EVENT_STAGE, + LedgerParameterType.EX_DATE, LedgerParameterType.RECORD_DATE, + LedgerParameterType.PAYMENT_DATE, LedgerParameterType.EFFECTIVE_DATE, + LedgerParameterType.SETTLEMENT_DATE, LedgerParameterType.ELECTION_DEADLINE)); + + private LedgerEventParameterDefinition() + { + } + + public static Set getParameterTypes() + { + return PARAMETER_TYPES; + } + + public static boolean supportsParameterType(LedgerParameterType parameterType) + { + return PARAMETER_TYPES.contains(parameterType); + } +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/LedgerLegCardinality.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/LedgerLegCardinality.java new file mode 100644 index 0000000000..d1ab046952 --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/LedgerLegCardinality.java @@ -0,0 +1,14 @@ +package name.abuchen.portfolio.model.ledger.configuration; + +/** + * Defines how often a configured Ledger leg may appear inside one native entry. + * This is Java-only configuration metadata. Persisted files store concrete + * postings and semantic projection facts, not leg cardinality rules. + */ +public enum LedgerLegCardinality +{ + EXACTLY_ONE, + AT_LEAST_ONE, + OPTIONAL, + REPEATABLE +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/LedgerLegDefinition.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/LedgerLegDefinition.java new file mode 100644 index 0000000000..3bd6d0d276 --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/LedgerLegDefinition.java @@ -0,0 +1,199 @@ +package name.abuchen.portfolio.model.ledger.configuration; + +import java.util.Collections; +import java.util.EnumSet; +import java.util.LinkedHashSet; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; + +import name.abuchen.portfolio.model.LedgerDiagnosticCode; +import name.abuchen.portfolio.model.ledger.LedgerProjectionRole; + +/** + * Describes one functional leg or component inside a native Ledger entry. + * This is Java-only configuration metadata. It connects a business leg role to + * a generic posting type, parameter rules, optional projection expectations, + * and optional grouping metadata. + */ +public final class LedgerLegDefinition +{ + private final LedgerLegRole role; + private final LedgerPostingType postingType; + private final LedgerLegCardinality cardinality; + private final Set requiredParameterTypes; + private final Set optionalParameterTypes; + private final LedgerProjectionRole projectionRole; + private final boolean primaryPostingExpected; + private final boolean postingGroupExpected; + private final Set groupNames; + private final LedgerReportingClass reportingClass; + private final LedgerPerformanceTreatment performanceTreatment; + + private LedgerLegDefinition(Builder builder) + { + this.role = Objects.requireNonNull(builder.role); + this.postingType = Objects.requireNonNull(builder.postingType); + this.cardinality = Objects.requireNonNull(builder.cardinality); + this.requiredParameterTypes = copyParameterTypes(builder.requiredParameterTypes); + this.optionalParameterTypes = copyParameterTypes(builder.optionalParameterTypes); + this.projectionRole = builder.projectionRole; + this.primaryPostingExpected = builder.primaryPostingExpected; + this.postingGroupExpected = builder.postingGroupExpected; + this.groupNames = copyGroupNames(builder.groupNames); + this.reportingClass = Objects.requireNonNull(builder.reportingClass); + this.performanceTreatment = Objects.requireNonNull(builder.performanceTreatment); + } + + public static Builder of(LedgerLegRole role, LedgerPostingType postingType, LedgerLegCardinality cardinality) + { + return new Builder(role, postingType, cardinality); + } + + public LedgerLegRole getRole() + { + return role; + } + + public LedgerPostingType getPostingType() + { + return postingType; + } + + public LedgerLegCardinality getCardinality() + { + return cardinality; + } + + public Set getRequiredParameterTypes() + { + return requiredParameterTypes; + } + + public Set getOptionalParameterTypes() + { + return optionalParameterTypes; + } + + public Optional getProjectionRole() + { + return Optional.ofNullable(projectionRole); + } + + public boolean isPrimaryPostingExpected() + { + return primaryPostingExpected; + } + + public boolean isPostingGroupExpected() + { + return postingGroupExpected; + } + + public Set getGroupNames() + { + return groupNames; + } + + public LedgerReportingClass getReportingClass() + { + return reportingClass; + } + + public LedgerPerformanceTreatment getPerformanceTreatment() + { + return performanceTreatment; + } + + private static Set copyParameterTypes(Set values) + { + var copy = EnumSet.noneOf(LedgerParameterType.class); + + for (var value : values) + copy.add(Objects.requireNonNull(value)); + + return Collections.unmodifiableSet(copy); + } + + private static Set copyGroupNames(Set values) + { + var copy = new LinkedHashSet(); + + for (var value : values) + { + if (value == null || value.isBlank()) + throw new IllegalArgumentException( + LedgerDiagnosticCode.LEDGER_CORE_018.message("Ledger leg group name is required")); //$NON-NLS-1$ + + copy.add(value); + } + + return Collections.unmodifiableSet(copy); + } + + public static final class Builder + { + private final LedgerLegRole role; + private final LedgerPostingType postingType; + private final LedgerLegCardinality cardinality; + private final Set requiredParameterTypes = EnumSet.noneOf(LedgerParameterType.class); + private final Set optionalParameterTypes = EnumSet.noneOf(LedgerParameterType.class); + private LedgerProjectionRole projectionRole; + private boolean primaryPostingExpected; + private boolean postingGroupExpected; + private final Set groupNames = new LinkedHashSet(); + private LedgerReportingClass reportingClass = LedgerReportingClass.UNDEFINED; + private LedgerPerformanceTreatment performanceTreatment = LedgerPerformanceTreatment.UNDEFINED; + + private Builder(LedgerLegRole role, LedgerPostingType postingType, LedgerLegCardinality cardinality) + { + this.role = Objects.requireNonNull(role); + this.postingType = Objects.requireNonNull(postingType); + this.cardinality = Objects.requireNonNull(cardinality); + } + + public Builder requiredParameters(Set values) + { + requiredParameterTypes.addAll(values); + return this; + } + + public Builder optionalParameters(Set values) + { + optionalParameterTypes.addAll(values); + return this; + } + + public Builder projection(LedgerProjectionRole role, boolean primaryPostingExpected, + boolean postingGroupExpected) + { + this.projectionRole = Objects.requireNonNull(role); + this.primaryPostingExpected = primaryPostingExpected; + this.postingGroupExpected = postingGroupExpected; + return this; + } + + public Builder group(String name) + { + groupNames.add(name); + return this; + } + + public Builder reportingClass(LedgerReportingClass reportingClass) + { + this.reportingClass = Objects.requireNonNull(reportingClass); + return this; + } + + public Builder performanceTreatment(LedgerPerformanceTreatment performanceTreatment) + { + this.performanceTreatment = Objects.requireNonNull(performanceTreatment); + return this; + } + + public LedgerLegDefinition build() + { + return new LedgerLegDefinition(this); + } + } +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/LedgerLegRole.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/LedgerLegRole.java new file mode 100644 index 0000000000..d103a82e5e --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/LedgerLegRole.java @@ -0,0 +1,24 @@ +package name.abuchen.portfolio.model.ledger.configuration; + +/** + * Names functional business legs inside a native Ledger entry. + * A leg role is not persisted directly. It lets Java configuration distinguish + * business sides that can share the same posting type, such as the old and new + * security sides of a spin-off. + */ +public enum LedgerLegRole +{ + SOURCE_SECURITY_LEG, + TARGET_SECURITY_LEG, + RECEIVED_SECURITY_LEG, + DISTRIBUTED_RIGHT_LEG, + DISTRIBUTED_SECURITY_LEG, + SOURCE_BOND_LEG, + CASH_LEG, + CASH_COMPENSATION_LEG, + ACCRUED_INTEREST_LEG, + PRINCIPAL_REDEMPTION_LEG, + FEE_LEG, + TAX_LEG, + FOREX_CONTEXT_LEG +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/LedgerNativeEntryShape.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/LedgerNativeEntryShape.java new file mode 100644 index 0000000000..f289b6bcac --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/LedgerNativeEntryShape.java @@ -0,0 +1,21 @@ +package name.abuchen.portfolio.model.ledger.configuration; + +/** + * Describes Ledger configuration for entries, postings, parameters, or native shapes. + * This metadata is used by Ledger validation and assembly infrastructure. It is not a + * normal transaction-editing API. + * + *

+ * Native entry shapes are not persisted as Ledger facts. They describe assembly and + * validation policy for entry definitions; persisted files store the resulting entry type + * id, postings, parameters, and projections. + *

+ */ +public enum LedgerNativeEntryShape +{ + SINGLE_INSTRUMENT, + DUAL_INSTRUMENT, + INSTRUMENT_PLUS_ACCOUNT, + DUAL_INSTRUMENT_PLUS_ACCOUNT, + UNDEFINED +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/LedgerParameterCodeDomain.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/LedgerParameterCodeDomain.java new file mode 100644 index 0000000000..20e2924986 --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/LedgerParameterCodeDomain.java @@ -0,0 +1,58 @@ +package name.abuchen.portfolio.model.ledger.configuration; + +import java.util.Arrays; +import java.util.List; + +/** + * Defines the parameter code domain Ledger code domain used by configuration or validation. + * This is configuration metadata. Normal transaction-editing code should not treat these + * values as a direct mutation API. + * + *

+ * A domain is selected by {@link LedgerParameterType}; it is not stored as its own Ledger + * fact. The allowed codes are persisted only as string parameter values, so code strings + * must remain stable after they have been written to files. + *

+ */ +public enum LedgerParameterCodeDomain +{ + CORPORATE_ACTION_LEG, + CORPORATE_ACTION_KIND, + CORPORATE_ACTION_SUBTYPE, + EVENT_STAGE, + CASH_COMPENSATION_KIND, + FRACTION_TREATMENT, + ROUNDING_MODE, + COST_ALLOCATION_METHOD, + QUOTATION_STYLE, + FEE_REASON, + TAX_REASON; + + public List getAllowedCodes() + { + return switch (this) + { + case CORPORATE_ACTION_LEG -> codes(CorporateActionLeg.values()); + case CORPORATE_ACTION_KIND -> codes(CorporateActionKind.values()); + case CORPORATE_ACTION_SUBTYPE -> codes(CorporateActionSubtype.values()); + case EVENT_STAGE -> codes(EventStage.values()); + case CASH_COMPENSATION_KIND -> codes(CashCompensationKind.values()); + case FRACTION_TREATMENT -> codes(FractionTreatment.values()); + case ROUNDING_MODE -> codes(RoundingModeCode.values()); + case COST_ALLOCATION_METHOD -> codes(CostAllocationMethod.values()); + case QUOTATION_STYLE -> codes(QuotationStyle.values()); + case FEE_REASON -> codes(FeeReason.values()); + case TAX_REASON -> codes(TaxReason.values()); + }; + } + + public boolean allows(String code) + { + return getAllowedCodes().contains(code); + } + + private static List codes(LedgerCode... codes) + { + return Arrays.stream(codes).map(LedgerCode::getCode).toList(); + } +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/LedgerParameterType.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/LedgerParameterType.java new file mode 100644 index 0000000000..38d39390c1 --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/LedgerParameterType.java @@ -0,0 +1,213 @@ +package name.abuchen.portfolio.model.ledger.configuration; + +import java.util.Objects; + +import name.abuchen.portfolio.model.LedgerDiagnosticCode; +import name.abuchen.portfolio.model.ledger.LedgerParameter.ValueKind; + +/** + * Defines stable Ledger parameter codes used by persistence and validation. + * This is Ledger configuration metadata. Existing persistence codes must stay stable, and + * normal transaction-editing code should use higher-level write paths. + * + *

+ * Protobuf stores {@link #getCode()} in {@code PLedgerParameter.typeCode}. The + * parameter value kind is stored separately, so codes, value kinds, and controlled code + * domains must stay compatible with already persisted files. + *

+ */ +@SuppressWarnings("nls") +public enum LedgerParameterType +{ + EX_DATE("EX_DATE", Scope.GENERAL, ValueKind.LOCAL_DATE_TIME), + CORPORATE_ACTION_LEG("CORPORATE_ACTION_LEG", Scope.CORPORATE_ACTION, ValueKind.STRING, + LedgerParameterCodeDomain.CORPORATE_ACTION_LEG), + SOURCE_SECURITY("SOURCE_SECURITY", Scope.CORPORATE_ACTION, ValueKind.SECURITY), + TARGET_SECURITY("TARGET_SECURITY", Scope.CORPORATE_ACTION, ValueKind.SECURITY), + RATIO_NUMERATOR("RATIO_NUMERATOR", Scope.CORPORATE_ACTION, ValueKind.DECIMAL), + RATIO_DENOMINATOR("RATIO_DENOMINATOR", Scope.CORPORATE_ACTION, ValueKind.DECIMAL), + CASH_COMPENSATION_KIND("CASH_COMPENSATION_KIND", Scope.CORPORATE_ACTION, ValueKind.STRING, + LedgerParameterCodeDomain.CASH_COMPENSATION_KIND), + FEE_REASON("FEE_REASON", Scope.CORPORATE_ACTION, ValueKind.STRING, LedgerParameterCodeDomain.FEE_REASON), + TAX_REASON("TAX_REASON", Scope.CORPORATE_ACTION, ValueKind.STRING, LedgerParameterCodeDomain.TAX_REASON), + CORPORATE_ACTION_KIND("CORPORATE_ACTION_KIND", Scope.CORPORATE_ACTION, ValueKind.STRING, + LedgerParameterCodeDomain.CORPORATE_ACTION_KIND), + CORPORATE_ACTION_SUBTYPE("CORPORATE_ACTION_SUBTYPE", Scope.CORPORATE_ACTION, ValueKind.STRING, + LedgerParameterCodeDomain.CORPORATE_ACTION_SUBTYPE), + EVENT_REFERENCE("EVENT_REFERENCE", Scope.CORPORATE_ACTION, ValueKind.STRING), + EVENT_STAGE("EVENT_STAGE", Scope.CORPORATE_ACTION, ValueKind.STRING, LedgerParameterCodeDomain.EVENT_STAGE), + RECORD_DATE("RECORD_DATE", Scope.CORPORATE_ACTION, ValueKind.LOCAL_DATE), + PAYMENT_DATE("PAYMENT_DATE", Scope.CORPORATE_ACTION, ValueKind.LOCAL_DATE), + EFFECTIVE_DATE("EFFECTIVE_DATE", Scope.CORPORATE_ACTION, ValueKind.LOCAL_DATE), + SETTLEMENT_DATE("SETTLEMENT_DATE", Scope.CORPORATE_ACTION, ValueKind.LOCAL_DATE), + ELECTION_DEADLINE("ELECTION_DEADLINE", Scope.CORPORATE_ACTION, ValueKind.LOCAL_DATE), + INTEREST_PERIOD_START("INTEREST_PERIOD_START", Scope.CORPORATE_ACTION, ValueKind.LOCAL_DATE), + INTEREST_PERIOD_END("INTEREST_PERIOD_END", Scope.CORPORATE_ACTION, ValueKind.LOCAL_DATE), + RIGHT_SECURITY("RIGHT_SECURITY", Scope.CORPORATE_ACTION, ValueKind.SECURITY), + SOURCE_ACCOUNT("SOURCE_ACCOUNT", Scope.CORPORATE_ACTION, ValueKind.ACCOUNT), + TARGET_ACCOUNT("TARGET_ACCOUNT", Scope.CORPORATE_ACTION, ValueKind.ACCOUNT), + CASH_ACCOUNT("CASH_ACCOUNT", Scope.CORPORATE_ACTION, ValueKind.ACCOUNT), + SOURCE_PORTFOLIO("SOURCE_PORTFOLIO", Scope.CORPORATE_ACTION, ValueKind.PORTFOLIO), + TARGET_PORTFOLIO("TARGET_PORTFOLIO", Scope.CORPORATE_ACTION, ValueKind.PORTFOLIO), + FRACTION_QUANTITY("FRACTION_QUANTITY", Scope.CORPORATE_ACTION, ValueKind.DECIMAL), + CONVERSION_RATIO("CONVERSION_RATIO", Scope.CORPORATE_ACTION, ValueKind.DECIMAL), + PARTIAL_REDEMPTION_FACTOR("PARTIAL_REDEMPTION_FACTOR", Scope.CORPORATE_ACTION, ValueKind.DECIMAL), + COUPON_RATE("COUPON_RATE", Scope.CORPORATE_ACTION, ValueKind.DECIMAL), + REDEMPTION_PRICE_PERCENT("REDEMPTION_PRICE_PERCENT", Scope.CORPORATE_ACTION, ValueKind.DECIMAL), + SOURCE_COST_PERCENT("SOURCE_COST_PERCENT", Scope.CORPORATE_ACTION, ValueKind.DECIMAL), + TARGET_COST_PERCENT("TARGET_COST_PERCENT", Scope.CORPORATE_ACTION, ValueKind.DECIMAL), + AFFECTED_SOURCE_QUANTITY("AFFECTED_SOURCE_QUANTITY", Scope.CORPORATE_ACTION, ValueKind.DECIMAL), + NOMINAL_VALUE("NOMINAL_VALUE", Scope.CORPORATE_ACTION, ValueKind.MONEY), + SUBSCRIPTION_PRICE("SUBSCRIPTION_PRICE", Scope.CORPORATE_ACTION, ValueKind.MONEY), + REFERENCE_PRICE("REFERENCE_PRICE", Scope.CORPORATE_ACTION, ValueKind.MONEY), + CASH_IN_LIEU_AMOUNT("CASH_IN_LIEU_AMOUNT", Scope.CORPORATE_ACTION, ValueKind.MONEY), + VALUATION_PRICE("VALUATION_PRICE", Scope.CORPORATE_ACTION, ValueKind.MONEY), + FAIR_MARKET_VALUE("FAIR_MARKET_VALUE", Scope.CORPORATE_ACTION, ValueKind.MONEY), + ACCRUED_INTEREST_AMOUNT("ACCRUED_INTEREST_AMOUNT", Scope.CORPORATE_ACTION, ValueKind.MONEY), + FRACTION_TREATMENT("FRACTION_TREATMENT", Scope.CORPORATE_ACTION, ValueKind.STRING, + LedgerParameterCodeDomain.FRACTION_TREATMENT), + ROUNDING_MODE("ROUNDING_MODE", Scope.CORPORATE_ACTION, ValueKind.STRING, LedgerParameterCodeDomain.ROUNDING_MODE), + COST_ALLOCATION_METHOD("COST_ALLOCATION_METHOD", Scope.CORPORATE_ACTION, ValueKind.STRING, + LedgerParameterCodeDomain.COST_ALLOCATION_METHOD), + QUOTATION_STYLE("QUOTATION_STYLE", Scope.CORPORATE_ACTION, ValueKind.STRING, + LedgerParameterCodeDomain.QUOTATION_STYLE), + CASH_IN_LIEU_APPLIED("CASH_IN_LIEU_APPLIED", Scope.CORPORATE_ACTION, ValueKind.BOOLEAN), + TAXABLE_DISTRIBUTION("TAXABLE_DISTRIBUTION", Scope.CORPORATE_ACTION, ValueKind.BOOLEAN), + MANUAL_VALUATION_OVERRIDE("MANUAL_VALUATION_OVERRIDE", Scope.CORPORATE_ACTION, ValueKind.BOOLEAN), + SAME_SECURITY_AS_SOURCE("SAME_SECURITY_AS_SOURCE", Scope.CORPORATE_ACTION, ValueKind.BOOLEAN), + FRACTION_ROUNDED("FRACTION_ROUNDED", Scope.CORPORATE_ACTION, ValueKind.BOOLEAN), + RECLAIMABLE_TAX("RECLAIMABLE_TAX", Scope.CORPORATE_ACTION, ValueKind.BOOLEAN), + WITHHOLDING_TAX("WITHHOLDING_TAX", Scope.CORPORATE_ACTION, ValueKind.BOOLEAN), + TRANSACTION_TAX("TRANSACTION_TAX", Scope.CORPORATE_ACTION, ValueKind.BOOLEAN), + STAMP_DUTY("STAMP_DUTY", Scope.CORPORATE_ACTION, ValueKind.BOOLEAN); + + public enum Scope + { + GENERAL, + CORPORATE_ACTION + } + + private final String code; + private final Scope scope; + private final ValueKind expectedValueKind; + private final LedgerParameterCodeDomain codeDomain; + + private LedgerParameterType(String code, Scope scope, ValueKind expectedValueKind) + { + this(code, scope, expectedValueKind, null); + } + + private LedgerParameterType(String code, Scope scope, ValueKind expectedValueKind, + LedgerParameterCodeDomain codeDomain) + { + this.code = Objects.requireNonNull(code); + this.scope = Objects.requireNonNull(scope); + this.expectedValueKind = Objects.requireNonNull(expectedValueKind); + this.codeDomain = codeDomain; + + if (codeDomain != null && expectedValueKind != ValueKind.STRING) + throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_CORE_019 + .message(this + " must use STRING for controlled code domain " + codeDomain)); //$NON-NLS-1$ + } + + public String getCode() + { + return code; + } + + public static LedgerParameterType fromCode(String code) + { + for (LedgerParameterType type : values()) + if (type.code.equals(code)) + return type; + + throw new IllegalArgumentException( + LedgerDiagnosticCode.LEDGER_CORE_020.message("Unknown LedgerParameterType code: " + code)); //$NON-NLS-1$ + } + + public Scope getScope() + { + return scope; + } + + public ValueKind getExpectedValueKind() + { + return expectedValueKind; + } + + public Class getExpectedValueType() + { + return expectedValueKind.getValueType(); + } + + public Class getExpectedJavaType() + { + return getExpectedValueType(); + } + + public boolean isGeneral() + { + return scope == Scope.GENERAL; + } + + public boolean isCorporateAction() + { + return scope == Scope.CORPORATE_ACTION; + } + + public boolean supportsValueKind(ValueKind valueKind) + { + return expectedValueKind == valueKind; + } + + public void requireValueKind(ValueKind valueKind) + { + if (!supportsValueKind(valueKind)) + throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_CORE_021 + .message(this + " does not support " + valueKind + "; expected " + expectedValueKind)); //$NON-NLS-1$ //$NON-NLS-2$ + } + + public boolean supportsValue(Object value) + { + return expectedValueKind.supportsValue(value); + } + + public boolean hasCodeDomain() + { + return codeDomain != null; + } + + public boolean isControlledCode() + { + return hasCodeDomain(); + } + + public LedgerParameterCodeDomain getCodeDomain() + { + return codeDomain; + } + + public boolean supportsCode(String code) + { + return codeDomain == null || codeDomain.allows(code); + } + + public boolean isReferenceParameter() + { + return switch (expectedValueKind) + { + case ACCOUNT, PORTFOLIO, SECURITY -> true; + default -> false; + }; + } + + public boolean isDateParameter() + { + return expectedValueKind == ValueKind.LOCAL_DATE || expectedValueKind == ValueKind.LOCAL_DATE_TIME; + } + + public boolean isBooleanParameter() + { + return expectedValueKind == ValueKind.BOOLEAN; + } +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/LedgerPerformanceTreatment.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/LedgerPerformanceTreatment.java new file mode 100644 index 0000000000..94e5340e29 --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/LedgerPerformanceTreatment.java @@ -0,0 +1,25 @@ +package name.abuchen.portfolio.model.ledger.configuration; + +/** + * Defines the performance treatment Ledger code domain used by configuration or validation. + * This is configuration metadata. Normal transaction-editing code should not treat these + * values as a direct mutation API. + * + *

+ * These values are runtime configuration markers. They are not persisted in Ledger entries + * and do not implement performance calculation by themselves. + *

+ */ +public enum LedgerPerformanceTreatment +{ + EXTERNAL_CASH_FLOW, + INTERNAL_RECLASSIFICATION, + SECURITY_DISTRIBUTION, + INCOME_DISTRIBUTION, + PRINCIPAL_RETURN, + COST_BASIS_REALLOCATION, + PERFORMANCE_NEUTRAL, + REALIZED_GAIN_RELEVANT, + VALUATION_ONLY, + UNDEFINED +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/LedgerPostingType.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/LedgerPostingType.java new file mode 100644 index 0000000000..ad7faa6c18 --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/LedgerPostingType.java @@ -0,0 +1,150 @@ +package name.abuchen.portfolio.model.ledger.configuration; + +import java.util.EnumSet; +import java.util.Objects; + +import name.abuchen.portfolio.model.LedgerDiagnosticCode; + +/** + * Defines stable Ledger posting codes used by persistence and validation. + * This is Ledger configuration metadata. Existing persistence codes must stay stable, and + * normal transaction-editing code should use higher-level write paths. + * + *

+ * Protobuf stores {@link #getCode()} in {@code PLedgerPosting.typeCode}. Existing codes + * must never be changed or reused for a different posting meaning. + *

+ */ +@SuppressWarnings("nls") +public enum LedgerPostingType +{ + CASH("CASH", ComponentClass.CASH, Characteristic.MONEY_BEARING, Characteristic.CURRENCY_REQUIRED, + Characteristic.ACCOUNT_REFERENCE_MEANINGFUL, Characteristic.FOREX_MEANINGFUL), + SECURITY("SECURITY", ComponentClass.SECURITY, Characteristic.MONEY_BEARING, Characteristic.CURRENCY_REQUIRED, + Characteristic.SECURITY_BEARING, Characteristic.SECURITY_REQUIRED, Characteristic.SHARES_MEANINGFUL, + Characteristic.PORTFOLIO_REFERENCE_MEANINGFUL, Characteristic.FOREX_MEANINGFUL), + CASH_COMPENSATION("CASH_COMPENSATION", ComponentClass.COMPENSATION, Characteristic.MONEY_BEARING, + Characteristic.CURRENCY_REQUIRED, Characteristic.ACCOUNT_REFERENCE_MEANINGFUL, + Characteristic.FOREX_MEANINGFUL), + FEE("FEE", ComponentClass.FEE, Characteristic.MONEY_BEARING, Characteristic.CURRENCY_REQUIRED, + Characteristic.FOREX_MEANINGFUL), + TAX("TAX", ComponentClass.TAX, Characteristic.MONEY_BEARING, Characteristic.CURRENCY_REQUIRED, + Characteristic.FOREX_MEANINGFUL), + GROSS_VALUE("GROSS_VALUE", ComponentClass.GROSS_VALUE, Characteristic.MONEY_BEARING, Characteristic.CURRENCY_REQUIRED, + Characteristic.FOREX_MEANINGFUL), + FOREX("FOREX", ComponentClass.FOREX, Characteristic.FOREX_MEANINGFUL), + RIGHT("RIGHT", ComponentClass.RIGHT, Characteristic.SECURITY_BEARING, Characteristic.SHARES_MEANINGFUL, + Characteristic.PORTFOLIO_REFERENCE_MEANINGFUL), + BOND("BOND", ComponentClass.BOND, Characteristic.SECURITY_BEARING, Characteristic.SHARES_MEANINGFUL, + Characteristic.PORTFOLIO_REFERENCE_MEANINGFUL), + ACCRUED_INTEREST("ACCRUED_INTEREST", ComponentClass.ACCRUED_INTEREST, Characteristic.MONEY_BEARING, + Characteristic.CURRENCY_REQUIRED, Characteristic.FOREX_MEANINGFUL), + PRINCIPAL_REDEMPTION("PRINCIPAL_REDEMPTION", ComponentClass.PRINCIPAL_REDEMPTION, Characteristic.MONEY_BEARING, + Characteristic.CURRENCY_REQUIRED, Characteristic.FOREX_MEANINGFUL); + + public enum ComponentClass + { + CASH, + SECURITY, + COMPENSATION, + FEE, + TAX, + GROSS_VALUE, + FOREX, + RIGHT, + BOND, + ACCRUED_INTEREST, + PRINCIPAL_REDEMPTION + } + + public enum Characteristic + { + MONEY_BEARING, + CURRENCY_REQUIRED, + SECURITY_BEARING, + SECURITY_REQUIRED, + SHARES_MEANINGFUL, + ACCOUNT_REFERENCE_MEANINGFUL, + PORTFOLIO_REFERENCE_MEANINGFUL, + FOREX_MEANINGFUL + } + + private final String code; + private final ComponentClass componentClass; + private final EnumSet characteristics; + + private LedgerPostingType(String code, ComponentClass componentClass, Characteristic... characteristics) + { + this.code = Objects.requireNonNull(code); + this.componentClass = Objects.requireNonNull(componentClass); + this.characteristics = EnumSet.noneOf(Characteristic.class); + + for (var characteristic : characteristics) + this.characteristics.add(Objects.requireNonNull(characteristic)); + } + + public String getCode() + { + return code; + } + + public ComponentClass getComponentClass() + { + return componentClass; + } + + public boolean isMoneyBearing() + { + return hasCharacteristic(Characteristic.MONEY_BEARING); + } + + public boolean requiresCurrency() + { + return hasCharacteristic(Characteristic.CURRENCY_REQUIRED); + } + + public boolean isSecurityBearing() + { + return hasCharacteristic(Characteristic.SECURITY_BEARING); + } + + public boolean requiresSecurity() + { + return hasCharacteristic(Characteristic.SECURITY_REQUIRED); + } + + public boolean isSharesMeaningful() + { + return hasCharacteristic(Characteristic.SHARES_MEANINGFUL); + } + + public boolean isAccountReferenceMeaningful() + { + return hasCharacteristic(Characteristic.ACCOUNT_REFERENCE_MEANINGFUL); + } + + public boolean isPortfolioReferenceMeaningful() + { + return hasCharacteristic(Characteristic.PORTFOLIO_REFERENCE_MEANINGFUL); + } + + public boolean isForexMeaningful() + { + return hasCharacteristic(Characteristic.FOREX_MEANINGFUL); + } + + public boolean hasCharacteristic(Characteristic characteristic) + { + return characteristics.contains(characteristic); + } + + public static LedgerPostingType fromCode(String code) + { + for (LedgerPostingType type : values()) + if (type.code.equals(code)) + return type; + + throw new IllegalArgumentException( + LedgerDiagnosticCode.LEDGER_CORE_022.message("Unknown LedgerPostingType code: " + code)); //$NON-NLS-1$ + } +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/LedgerPostingTypeDefinition.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/LedgerPostingTypeDefinition.java new file mode 100644 index 0000000000..2ef9864810 --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/LedgerPostingTypeDefinition.java @@ -0,0 +1,61 @@ +package name.abuchen.portfolio.model.ledger.configuration; + +import java.util.Collections; +import java.util.EnumSet; +import java.util.Objects; +import java.util.Set; + +/** + * Describes Ledger configuration for entries, postings, parameters, or native shapes. + * This metadata is used by Ledger validation and assembly infrastructure. It is not a + * normal transaction-editing API. + * + *

+ * This definition is not persisted as a standalone object. It defines which parameter + * types are meaningful for postings that are persisted with a stable posting type code. + *

+ */ +public final class LedgerPostingTypeDefinition +{ + private final LedgerPostingType postingType; + private final Set componentParameterTypes; + + private LedgerPostingTypeDefinition(LedgerPostingType postingType, Set componentParameterTypes) + { + this.postingType = Objects.requireNonNull(postingType); + this.componentParameterTypes = copyOf(componentParameterTypes); + } + + static LedgerPostingTypeDefinition of(LedgerPostingType postingType, + Set componentParameterTypes) + { + return new LedgerPostingTypeDefinition(postingType, componentParameterTypes); + } + + public LedgerPostingType getPostingType() + { + return postingType; + } + + public Set getComponentParameterTypes() + { + return componentParameterTypes; + } + + public boolean supportsParameterType(LedgerParameterType parameterType) + { + return componentParameterTypes.contains(parameterType); + } + + private static Set copyOf(Set values) + { + Objects.requireNonNull(values); + + var copy = EnumSet.noneOf(LedgerParameterType.class); + + for (var value : values) + copy.add(Objects.requireNonNull(value)); + + return Collections.unmodifiableSet(copy); + } +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/LedgerPostingTypeDefinitionRegistry.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/LedgerPostingTypeDefinitionRegistry.java new file mode 100644 index 0000000000..7ad40be740 --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/LedgerPostingTypeDefinitionRegistry.java @@ -0,0 +1,199 @@ +package name.abuchen.portfolio.model.ledger.configuration; + +import java.util.Collection; +import java.util.Collections; +import java.util.EnumMap; +import java.util.EnumSet; +import java.util.Map; +import java.util.Optional; + +import name.abuchen.portfolio.model.LedgerDiagnosticCode; + +/** + * Registers Ledger configuration definitions used by validation and assembly. + * This is configuration infrastructure. Normal transaction-editing code should not update + * the registry directly. + * + *

+ * The registry is static Java configuration. It is not persisted in XML or Protobuf; files + * store postings with stable posting type codes and concrete parameter values. + *

+ */ +public final class LedgerPostingTypeDefinitionRegistry +{ + private static final SetBuilder SETS = new SetBuilder(); + + private static final Map DEFINITIONS = definitions(); + + private LedgerPostingTypeDefinitionRegistry() + { + } + + public static Optional lookup(LedgerPostingType postingType) + { + return Optional.ofNullable(DEFINITIONS.get(postingType)); + } + + public static Collection getDefinitions() + { + return DEFINITIONS.values(); + } + + public static boolean hasDefinition(LedgerPostingType postingType) + { + return DEFINITIONS.containsKey(postingType); + } + + private static Map definitions() + { + var definitions = new EnumMap(LedgerPostingType.class); + + register(definitions, cash()); + register(definitions, security()); + register(definitions, cashCompensation()); + register(definitions, fee()); + register(definitions, tax()); + register(definitions, grossValue()); + register(definitions, forex()); + register(definitions, right()); + register(definitions, bond()); + register(definitions, accruedInterest()); + register(definitions, principalRedemption()); + + return Collections.unmodifiableMap(definitions); + } + + private static void register(Map definitions, + LedgerPostingTypeDefinition definition) + { + if (definitions.put(definition.getPostingType(), definition) != null) + throw new IllegalStateException(LedgerDiagnosticCode.LEDGER_CORE_023 + .message("Duplicate Ledger posting type definition: " + definition.getPostingType())); //$NON-NLS-1$ + } + + private static LedgerPostingTypeDefinition cash() + { + return definition(LedgerPostingType.CASH, LedgerParameterType.SOURCE_ACCOUNT, + LedgerParameterType.TARGET_ACCOUNT, LedgerParameterType.CASH_ACCOUNT, + LedgerParameterType.EVENT_REFERENCE, LedgerParameterType.PAYMENT_DATE, + LedgerParameterType.SETTLEMENT_DATE); + } + + private static LedgerPostingTypeDefinition security() + { + return definition(LedgerPostingType.SECURITY, LedgerParameterType.SOURCE_SECURITY, + LedgerParameterType.TARGET_SECURITY, LedgerParameterType.RIGHT_SECURITY, + LedgerParameterType.SAME_SECURITY_AS_SOURCE, LedgerParameterType.RATIO_NUMERATOR, + LedgerParameterType.RATIO_DENOMINATOR, LedgerParameterType.FRACTION_QUANTITY, + LedgerParameterType.FRACTION_TREATMENT, LedgerParameterType.ROUNDING_MODE, + LedgerParameterType.COST_ALLOCATION_METHOD, LedgerParameterType.SOURCE_COST_PERCENT, + LedgerParameterType.TARGET_COST_PERCENT, LedgerParameterType.AFFECTED_SOURCE_QUANTITY, + LedgerParameterType.REFERENCE_PRICE, LedgerParameterType.FAIR_MARKET_VALUE, + LedgerParameterType.VALUATION_PRICE, LedgerParameterType.MANUAL_VALUATION_OVERRIDE, + LedgerParameterType.EX_DATE, LedgerParameterType.RECORD_DATE, + LedgerParameterType.EFFECTIVE_DATE, LedgerParameterType.SETTLEMENT_DATE, + LedgerParameterType.CORPORATE_ACTION_LEG); + } + + private static LedgerPostingTypeDefinition cashCompensation() + { + return definition(LedgerPostingType.CASH_COMPENSATION, LedgerParameterType.CASH_ACCOUNT, + LedgerParameterType.CASH_COMPENSATION_KIND, LedgerParameterType.CASH_IN_LIEU_AMOUNT, + LedgerParameterType.CASH_IN_LIEU_APPLIED, LedgerParameterType.FRACTION_QUANTITY, + LedgerParameterType.FRACTION_TREATMENT, LedgerParameterType.ROUNDING_MODE, + LedgerParameterType.PAYMENT_DATE, LedgerParameterType.SETTLEMENT_DATE, + LedgerParameterType.FEE_REASON, LedgerParameterType.TAX_REASON, + LedgerParameterType.CORPORATE_ACTION_LEG); + } + + private static LedgerPostingTypeDefinition fee() + { + return definition(LedgerPostingType.FEE, LedgerParameterType.FEE_REASON, LedgerParameterType.STAMP_DUTY, + LedgerParameterType.EVENT_REFERENCE, LedgerParameterType.PAYMENT_DATE, + LedgerParameterType.SETTLEMENT_DATE, LedgerParameterType.CORPORATE_ACTION_LEG); + } + + private static LedgerPostingTypeDefinition tax() + { + return definition(LedgerPostingType.TAX, LedgerParameterType.TAX_REASON, + LedgerParameterType.TAXABLE_DISTRIBUTION, LedgerParameterType.WITHHOLDING_TAX, + LedgerParameterType.TRANSACTION_TAX, LedgerParameterType.STAMP_DUTY, + LedgerParameterType.RECLAIMABLE_TAX, LedgerParameterType.EVENT_REFERENCE, + LedgerParameterType.PAYMENT_DATE, LedgerParameterType.SETTLEMENT_DATE, + LedgerParameterType.CORPORATE_ACTION_LEG); + } + + private static LedgerPostingTypeDefinition grossValue() + { + return definition(LedgerPostingType.GROSS_VALUE, LedgerParameterType.REFERENCE_PRICE, + LedgerParameterType.VALUATION_PRICE, LedgerParameterType.FAIR_MARKET_VALUE, + LedgerParameterType.MANUAL_VALUATION_OVERRIDE, LedgerParameterType.EVENT_REFERENCE); + } + + private static LedgerPostingTypeDefinition forex() + { + return definition(LedgerPostingType.FOREX, LedgerParameterType.REFERENCE_PRICE, + LedgerParameterType.VALUATION_PRICE, LedgerParameterType.EVENT_REFERENCE); + } + + private static LedgerPostingTypeDefinition right() + { + return definition(LedgerPostingType.RIGHT, LedgerParameterType.RIGHT_SECURITY, + LedgerParameterType.SOURCE_SECURITY, LedgerParameterType.RATIO_NUMERATOR, + LedgerParameterType.RATIO_DENOMINATOR, LedgerParameterType.SUBSCRIPTION_PRICE, + LedgerParameterType.REFERENCE_PRICE, LedgerParameterType.FRACTION_QUANTITY, + LedgerParameterType.FRACTION_TREATMENT, LedgerParameterType.ROUNDING_MODE, + LedgerParameterType.ELECTION_DEADLINE, LedgerParameterType.EX_DATE, + LedgerParameterType.RECORD_DATE, LedgerParameterType.EFFECTIVE_DATE, + LedgerParameterType.SETTLEMENT_DATE, LedgerParameterType.CORPORATE_ACTION_LEG); + } + + private static LedgerPostingTypeDefinition bond() + { + return definition(LedgerPostingType.BOND, LedgerParameterType.SOURCE_SECURITY, + LedgerParameterType.TARGET_SECURITY, LedgerParameterType.NOMINAL_VALUE, + LedgerParameterType.QUOTATION_STYLE, LedgerParameterType.CONVERSION_RATIO, + LedgerParameterType.RATIO_NUMERATOR, LedgerParameterType.RATIO_DENOMINATOR, + LedgerParameterType.REDEMPTION_PRICE_PERCENT, LedgerParameterType.PARTIAL_REDEMPTION_FACTOR, + LedgerParameterType.COUPON_RATE, LedgerParameterType.INTEREST_PERIOD_START, + LedgerParameterType.INTEREST_PERIOD_END, LedgerParameterType.REFERENCE_PRICE, + LedgerParameterType.VALUATION_PRICE, LedgerParameterType.FAIR_MARKET_VALUE, + LedgerParameterType.MANUAL_VALUATION_OVERRIDE, LedgerParameterType.EFFECTIVE_DATE, + LedgerParameterType.SETTLEMENT_DATE, LedgerParameterType.CORPORATE_ACTION_LEG); + } + + private static LedgerPostingTypeDefinition accruedInterest() + { + return definition(LedgerPostingType.ACCRUED_INTEREST, LedgerParameterType.ACCRUED_INTEREST_AMOUNT, + LedgerParameterType.COUPON_RATE, LedgerParameterType.INTEREST_PERIOD_START, + LedgerParameterType.INTEREST_PERIOD_END, LedgerParameterType.PAYMENT_DATE, + LedgerParameterType.SETTLEMENT_DATE, LedgerParameterType.WITHHOLDING_TAX, + LedgerParameterType.RECLAIMABLE_TAX, LedgerParameterType.TAX_REASON, + LedgerParameterType.CORPORATE_ACTION_LEG); + } + + private static LedgerPostingTypeDefinition principalRedemption() + { + return definition(LedgerPostingType.PRINCIPAL_REDEMPTION, LedgerParameterType.NOMINAL_VALUE, + LedgerParameterType.REDEMPTION_PRICE_PERCENT, LedgerParameterType.PARTIAL_REDEMPTION_FACTOR, + LedgerParameterType.SOURCE_SECURITY, LedgerParameterType.CASH_ACCOUNT, + LedgerParameterType.EFFECTIVE_DATE, LedgerParameterType.SETTLEMENT_DATE, + LedgerParameterType.REFERENCE_PRICE, LedgerParameterType.VALUATION_PRICE, + LedgerParameterType.FAIR_MARKET_VALUE, LedgerParameterType.MANUAL_VALUATION_OVERRIDE, + LedgerParameterType.CORPORATE_ACTION_LEG); + } + + private static LedgerPostingTypeDefinition definition(LedgerPostingType postingType, + LedgerParameterType firstParameterType, LedgerParameterType... rest) + { + return LedgerPostingTypeDefinition.of(postingType, SETS.parameterTypes(firstParameterType, rest)); + } + + private static final class SetBuilder + { + private EnumSet parameterTypes(LedgerParameterType first, LedgerParameterType... rest) + { + return EnumSet.of(first, rest); + } + } +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/LedgerReportingClass.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/LedgerReportingClass.java new file mode 100644 index 0000000000..9de68126da --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/LedgerReportingClass.java @@ -0,0 +1,27 @@ +package name.abuchen.portfolio.model.ledger.configuration; + +/** + * Defines the reporting class Ledger code domain used by configuration or validation. + * This is configuration metadata. Normal transaction-editing code should not treat these + * values as a direct mutation API. + * + *

+ * These values are runtime configuration markers. They are not persisted in Ledger entries + * and do not implement reporting behavior by themselves. + *

+ */ +public enum LedgerReportingClass +{ + CASH_DIVIDEND, + SECURITIES_DISTRIBUTION, + TOTAL_DISTRIBUTION_COMPONENT, + FIXED_INCOME_COUPON, + PRINCIPAL_REDEMPTION, + CASH_COMPENSATION, + RIGHTS_EVENT, + SECURITY_REORGANIZATION, + FEE, + TAX, + NONE, + UNDEFINED +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/QuotationStyle.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/QuotationStyle.java new file mode 100644 index 0000000000..43315fa64a --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/QuotationStyle.java @@ -0,0 +1,39 @@ +package name.abuchen.portfolio.model.ledger.configuration; + +/** + * Defines the quotation style Ledger code domain used by configuration or validation. + * This is configuration metadata. Normal transaction-editing code should not treat these + * values as a direct mutation API. + * + *

+ * The enum object is not persisted directly. Its {@link #getCode()} value can be stored as a + * string Ledger parameter value for parameters in the matching code domain. + *

+ */ +@SuppressWarnings("nls") +public enum QuotationStyle implements LedgerCode +{ + UNIT("UNIT"), + PERCENT("PERCENT"), + NOMINAL("NOMINAL"), + OTHER("OTHER"); + + private final String code; + + private QuotationStyle(String code) + { + this.code = code; + } + + @Override + public LedgerParameterCodeDomain getDomain() + { + return LedgerParameterCodeDomain.QUOTATION_STYLE; + } + + @Override + public String getCode() + { + return code; + } +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/RoundingModeCode.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/RoundingModeCode.java new file mode 100644 index 0000000000..cd8a896b9f --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/RoundingModeCode.java @@ -0,0 +1,41 @@ +package name.abuchen.portfolio.model.ledger.configuration; + +/** + * Defines the rounding mode code Ledger code domain used by configuration or validation. + * This is configuration metadata. Normal transaction-editing code should not treat these + * values as a direct mutation API. + * + *

+ * The enum object is not persisted directly. Its {@link #getCode()} value can be stored as a + * string Ledger parameter value for parameters in the matching code domain. + *

+ */ +@SuppressWarnings("nls") +public enum RoundingModeCode implements LedgerCode +{ + NONE("NONE"), + FLOOR("FLOOR"), + CEILING("CEILING"), + HALF_UP("HALF_UP"), + HALF_EVEN("HALF_EVEN"), + OTHER("OTHER"); + + private final String code; + + private RoundingModeCode(String code) + { + this.code = code; + } + + @Override + public LedgerParameterCodeDomain getDomain() + { + return LedgerParameterCodeDomain.ROUNDING_MODE; + } + + @Override + public String getCode() + { + return code; + } +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/TaxReason.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/TaxReason.java new file mode 100644 index 0000000000..f75b0db564 --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/TaxReason.java @@ -0,0 +1,41 @@ +package name.abuchen.portfolio.model.ledger.configuration; + +/** + * Defines the tax reason Ledger code domain used by configuration or validation. + * This is configuration metadata. Normal transaction-editing code should not treat these + * values as a direct mutation API. + * + *

+ * The enum object is not persisted directly. Its {@link #getCode()} value can be stored as a + * string Ledger parameter value for parameters in the matching code domain. + *

+ */ +@SuppressWarnings("nls") +public enum TaxReason implements LedgerCode +{ + WITHHOLDING_TAX("WITHHOLDING_TAX"), + CAPITAL_GAINS_TAX("CAPITAL_GAINS_TAX"), + TRANSACTION_TAX("TRANSACTION_TAX"), + STAMP_DUTY("STAMP_DUTY"), + RECLAIMABLE_TAX("RECLAIMABLE_TAX"), + OTHER("OTHER"); + + private final String code; + + private TaxReason(String code) + { + this.code = code; + } + + @Override + public LedgerParameterCodeDomain getDomain() + { + return LedgerParameterCodeDomain.TAX_REASON; + } + + @Override + public String getCode() + { + return code; + } +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/rule/LedgerParameterRule.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/rule/LedgerParameterRule.java new file mode 100644 index 0000000000..0480e5e37e --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/rule/LedgerParameterRule.java @@ -0,0 +1,75 @@ +package name.abuchen.portfolio.model.ledger.configuration.rule; + +import java.util.Objects; + +import name.abuchen.portfolio.model.ledger.configuration.LedgerParameterType; + +/** + * Describes a validation rule for Ledger entry configuration. + * This is configuration metadata used by Ledger infrastructure. Normal transaction-editing + * code should rely on creators, editors, converters, or validators that consume these rules. + * + *

+ * Rule objects are not persisted as standalone data. They describe which persisted + * parameter type codes are required, optional, or repeatable for a configured entry shape. + *

+ */ +public final class LedgerParameterRule +{ + private final LedgerParameterType parameterType; + private final LedgerRequirement requirement; + private final boolean repeatable; + + private LedgerParameterRule(LedgerParameterType parameterType, LedgerRequirement requirement, boolean repeatable) + { + this.parameterType = Objects.requireNonNull(parameterType); + this.requirement = Objects.requireNonNull(requirement); + this.repeatable = repeatable; + } + + public static LedgerParameterRule required(LedgerParameterType parameterType) + { + return of(parameterType, LedgerRequirement.REQUIRED, false); + } + + public static LedgerParameterRule optional(LedgerParameterType parameterType) + { + return of(parameterType, LedgerRequirement.OPTIONAL, false); + } + + public static LedgerParameterRule repeatable(LedgerParameterType parameterType, LedgerRequirement requirement) + { + return of(parameterType, requirement, true); + } + + private static LedgerParameterRule of(LedgerParameterType parameterType, LedgerRequirement requirement, + boolean repeatable) + { + return new LedgerParameterRule(parameterType, requirement, repeatable); + } + + public LedgerParameterType getParameterType() + { + return parameterType; + } + + public LedgerRequirement getRequirement() + { + return requirement; + } + + public boolean isRequired() + { + return requirement == LedgerRequirement.REQUIRED; + } + + public boolean isOptional() + { + return requirement == LedgerRequirement.OPTIONAL; + } + + public boolean isRepeatable() + { + return repeatable; + } +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/rule/LedgerPostingGroupRule.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/rule/LedgerPostingGroupRule.java new file mode 100644 index 0000000000..9afd2bb1bd --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/rule/LedgerPostingGroupRule.java @@ -0,0 +1,114 @@ +package name.abuchen.portfolio.model.ledger.configuration.rule; + +import java.util.Collections; +import java.util.EnumSet; +import java.util.Objects; +import java.util.Set; + +import name.abuchen.portfolio.model.LedgerDiagnosticCode; +import name.abuchen.portfolio.model.ledger.LedgerProjectionRole; +import name.abuchen.portfolio.model.ledger.configuration.LedgerPostingType; + +/** + * Describes a validation rule for Ledger entry configuration. + * This is configuration metadata used by Ledger infrastructure. Normal transaction-editing + * code should rely on creators, editors, converters, or validators that consume these rules. + * + *

+ * Rule objects are not persisted as standalone data. They describe how persisted postings + * and projections may be grouped for a configured entry shape. + *

+ */ +public final class LedgerPostingGroupRule +{ + private final String name; + private final LedgerRequirement requirement; + private final Set postingTypes; + private final Set projectionRoles; + private final boolean groupAnchorExpected; + + private LedgerPostingGroupRule(String name, LedgerRequirement requirement, Set postingTypes, + Set projectionRoles, boolean groupAnchorExpected) + { + this.name = requireName(name); + this.requirement = Objects.requireNonNull(requirement); + this.postingTypes = copyPostingTypes(postingTypes); + this.projectionRoles = copyProjectionRoles(projectionRoles); + this.groupAnchorExpected = groupAnchorExpected; + } + + public static LedgerPostingGroupRule of(String name, LedgerRequirement requirement, + Set postingTypes, Set projectionRoles, + boolean groupAnchorExpected) + { + return new LedgerPostingGroupRule(name, requirement, postingTypes, projectionRoles, groupAnchorExpected); + } + + public String getName() + { + return name; + } + + public LedgerRequirement getRequirement() + { + return requirement; + } + + public boolean isRequired() + { + return requirement == LedgerRequirement.REQUIRED; + } + + public boolean isOptional() + { + return requirement == LedgerRequirement.OPTIONAL; + } + + public Set getPostingTypes() + { + return postingTypes; + } + + public Set getProjectionRoles() + { + return projectionRoles; + } + + public boolean isGroupAnchorExpected() + { + return groupAnchorExpected; + } + + private static String requireName(String name) + { + if (name == null || name.isBlank()) + throw new IllegalArgumentException( + LedgerDiagnosticCode.LEDGER_CORE_024.message("Ledger posting group rule name is required")); //$NON-NLS-1$ + + return name; + } + + private static Set copyPostingTypes(Set values) + { + Objects.requireNonNull(values); + + var copy = EnumSet.noneOf(LedgerPostingType.class); + + for (var value : values) + copy.add(Objects.requireNonNull(value)); + + return Collections.unmodifiableSet(copy); + } + + private static Set copyProjectionRoles(Set values) + { + Objects.requireNonNull(values); + + var copy = EnumSet.noneOf(LedgerProjectionRole.class); + + for (var value : values) + copy.add(Objects.requireNonNull(value)); + + return Collections.unmodifiableSet(copy); + } +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/rule/LedgerPostingRule.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/rule/LedgerPostingRule.java new file mode 100644 index 0000000000..ed4b832bb9 --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/rule/LedgerPostingRule.java @@ -0,0 +1,108 @@ +package name.abuchen.portfolio.model.ledger.configuration.rule; + +import java.util.Collections; +import java.util.EnumSet; +import java.util.Objects; +import java.util.Set; + +import name.abuchen.portfolio.model.ledger.configuration.LedgerParameterType; +import name.abuchen.portfolio.model.ledger.configuration.LedgerPostingType; + +/** + * Describes a validation rule for Ledger entry configuration. + * This is configuration metadata used by Ledger infrastructure. Normal transaction-editing + * code should rely on creators, editors, converters, or validators that consume these rules. + * + *

+ * Rule objects are not persisted as standalone data. They describe which persisted posting + * type codes and parameter type codes are expected for a configured entry shape. + *

+ */ +public final class LedgerPostingRule +{ + private final LedgerPostingType postingType; + private final LedgerRequirement requirement; + private final Set requiredParameterTypes; + private final Set optionalParameterTypes; + private final Set repeatableParameterTypes; + + private LedgerPostingRule(LedgerPostingType postingType, LedgerRequirement requirement, + Set requiredParameterTypes, Set optionalParameterTypes, + Set repeatableParameterTypes) + { + this.postingType = Objects.requireNonNull(postingType); + this.requirement = Objects.requireNonNull(requirement); + this.requiredParameterTypes = copyOf(requiredParameterTypes); + this.optionalParameterTypes = copyOf(optionalParameterTypes); + this.repeatableParameterTypes = copyOf(repeatableParameterTypes); + } + + public static LedgerPostingRule required(LedgerPostingType postingType, + Set requiredParameterTypes, Set optionalParameterTypes) + { + return of(postingType, LedgerRequirement.REQUIRED, requiredParameterTypes, optionalParameterTypes, + EnumSet.noneOf(LedgerParameterType.class)); + } + + public static LedgerPostingRule optional(LedgerPostingType postingType, + Set requiredParameterTypes, Set optionalParameterTypes) + { + return of(postingType, LedgerRequirement.OPTIONAL, requiredParameterTypes, optionalParameterTypes, + EnumSet.noneOf(LedgerParameterType.class)); + } + + private static LedgerPostingRule of(LedgerPostingType postingType, LedgerRequirement requirement, + Set requiredParameterTypes, Set optionalParameterTypes, + Set repeatableParameterTypes) + { + return new LedgerPostingRule(postingType, requirement, requiredParameterTypes, optionalParameterTypes, + repeatableParameterTypes); + } + + public LedgerPostingType getPostingType() + { + return postingType; + } + + public LedgerRequirement getRequirement() + { + return requirement; + } + + public boolean isRequired() + { + return requirement == LedgerRequirement.REQUIRED; + } + + public boolean isOptional() + { + return requirement == LedgerRequirement.OPTIONAL; + } + + public Set getRequiredParameterTypes() + { + return requiredParameterTypes; + } + + public Set getOptionalParameterTypes() + { + return optionalParameterTypes; + } + + public Set getRepeatableParameterTypes() + { + return repeatableParameterTypes; + } + + private static Set copyOf(Set values) + { + Objects.requireNonNull(values); + + var copy = EnumSet.noneOf(LedgerParameterType.class); + + for (var value : values) + copy.add(Objects.requireNonNull(value)); + + return Collections.unmodifiableSet(copy); + } +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/rule/LedgerProjectionRule.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/rule/LedgerProjectionRule.java new file mode 100644 index 0000000000..bbfa4690e7 --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/rule/LedgerProjectionRule.java @@ -0,0 +1,80 @@ +package name.abuchen.portfolio.model.ledger.configuration.rule; + +import java.util.Objects; + +import name.abuchen.portfolio.model.ledger.LedgerProjectionRole; + +/** + * Describes a validation rule for Ledger entry configuration. + * This is configuration metadata used by Ledger infrastructure. Normal transaction-editing + * code should rely on creators, editors, converters, or validators that consume these rules. + * + *

+ * Rule objects are not persisted as standalone data. They describe which persisted + * projection roles are expected and whether a projection must point to a posting or group. + *

+ */ +public final class LedgerProjectionRule +{ + private final LedgerProjectionRole role; + private final LedgerRequirement requirement; + private final boolean primaryPostingExpected; + private final boolean postingGroupExpected; + + private LedgerProjectionRule(LedgerProjectionRole role, LedgerRequirement requirement, + boolean primaryPostingExpected, boolean postingGroupExpected) + { + this.role = Objects.requireNonNull(role); + this.requirement = Objects.requireNonNull(requirement); + this.primaryPostingExpected = primaryPostingExpected; + this.postingGroupExpected = postingGroupExpected; + } + + public static LedgerProjectionRule required(LedgerProjectionRole role, boolean primaryPostingExpected, + boolean postingGroupExpected) + { + return of(role, LedgerRequirement.REQUIRED, primaryPostingExpected, postingGroupExpected); + } + + public static LedgerProjectionRule optional(LedgerProjectionRole role, boolean primaryPostingExpected, + boolean postingGroupExpected) + { + return of(role, LedgerRequirement.OPTIONAL, primaryPostingExpected, postingGroupExpected); + } + + private static LedgerProjectionRule of(LedgerProjectionRole role, LedgerRequirement requirement, + boolean primaryPostingExpected, boolean postingGroupExpected) + { + return new LedgerProjectionRule(role, requirement, primaryPostingExpected, postingGroupExpected); + } + + public LedgerProjectionRole getRole() + { + return role; + } + + public LedgerRequirement getRequirement() + { + return requirement; + } + + public boolean isRequired() + { + return requirement == LedgerRequirement.REQUIRED; + } + + public boolean isOptional() + { + return requirement == LedgerRequirement.OPTIONAL; + } + + public boolean isPrimaryPostingExpected() + { + return primaryPostingExpected; + } + + public boolean isPostingGroupExpected() + { + return postingGroupExpected; + } +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/rule/LedgerRequirement.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/rule/LedgerRequirement.java new file mode 100644 index 0000000000..109cb3b1d9 --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/rule/LedgerRequirement.java @@ -0,0 +1,17 @@ +package name.abuchen.portfolio.model.ledger.configuration.rule; + +/** + * Describes a validation rule for Ledger entry configuration. + * This is configuration metadata used by Ledger infrastructure. Normal transaction-editing + * code should rely on creators, editors, converters, or validators that consume these rules. + * + *

+ * Requirement values are runtime configuration only. Persisted files store the actual + * Ledger facts; these values describe whether a configured fact is expected. + *

+ */ +public enum LedgerRequirement +{ + REQUIRED, + OPTIONAL +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/rule/LedgerRequirementGroup.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/rule/LedgerRequirementGroup.java new file mode 100644 index 0000000000..051533910b --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/rule/LedgerRequirementGroup.java @@ -0,0 +1,116 @@ +package name.abuchen.portfolio.model.ledger.configuration.rule; + +import java.util.Collections; +import java.util.EnumSet; +import java.util.Objects; +import java.util.Set; + +import name.abuchen.portfolio.model.LedgerDiagnosticCode; +import name.abuchen.portfolio.model.ledger.configuration.LedgerParameterType; +import name.abuchen.portfolio.model.ledger.configuration.LedgerPostingType; + +/** + * Describes a validation rule for Ledger entry configuration. + * This is configuration metadata used by Ledger infrastructure. Normal transaction-editing + * code should rely on creators, editors, converters, or validators that consume these rules. + * + *

+ * Requirement groups are runtime configuration only. They describe alternative sets of + * persisted facts that may satisfy a configured entry shape. + *

+ */ +public final class LedgerRequirementGroup +{ + private final String name; + private final LedgerRequirement requirement; + private final Set postingTypes; + private final Set parameterTypes; + + private LedgerRequirementGroup(String name, LedgerRequirement requirement, Set postingTypes, + Set parameterTypes) + { + this.name = requireName(name); + this.requirement = Objects.requireNonNull(requirement); + this.postingTypes = copyPostingTypes(postingTypes); + this.parameterTypes = copyParameterTypes(parameterTypes); + + if (this.postingTypes.isEmpty() && this.parameterTypes.isEmpty()) + throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_CORE_025 + .message("Ledger requirement group must contain postings or parameters")); //$NON-NLS-1$ + } + + public static LedgerRequirementGroup postingTypes(String name, LedgerRequirement requirement, + Set postingTypes) + { + return new LedgerRequirementGroup(name, requirement, postingTypes, EnumSet.noneOf(LedgerParameterType.class)); + } + + public static LedgerRequirementGroup parameterTypes(String name, LedgerRequirement requirement, + Set parameterTypes) + { + return new LedgerRequirementGroup(name, requirement, EnumSet.noneOf(LedgerPostingType.class), parameterTypes); + } + + public String getName() + { + return name; + } + + public LedgerRequirement getRequirement() + { + return requirement; + } + + public boolean isRequired() + { + return requirement == LedgerRequirement.REQUIRED; + } + + public boolean isOptional() + { + return requirement == LedgerRequirement.OPTIONAL; + } + + public Set getPostingTypes() + { + return postingTypes; + } + + public Set getParameterTypes() + { + return parameterTypes; + } + + private static String requireName(String name) + { + if (name == null || name.isBlank()) + throw new IllegalArgumentException( + LedgerDiagnosticCode.LEDGER_CORE_026.message("Ledger requirement group name is required")); //$NON-NLS-1$ + + return name; + } + + private static Set copyPostingTypes(Set values) + { + Objects.requireNonNull(values); + + var copy = EnumSet.noneOf(LedgerPostingType.class); + + for (var value : values) + copy.add(Objects.requireNonNull(value)); + + return Collections.unmodifiableSet(copy); + } + + private static Set copyParameterTypes(Set values) + { + Objects.requireNonNull(values); + + var copy = EnumSet.noneOf(LedgerParameterType.class); + + for (var value : values) + copy.add(Objects.requireNonNull(value)); + + return Collections.unmodifiableSet(copy); + } +}